Click here to Skip to main content
15,867,308 members
Articles / Programming Languages / C#
Tip/Trick

How to Start a Robust C# Project in the New Nullable Context with Generics?

Rate me:
Please Sign up or sign in to vote.
1.17/5 (12 votes)
31 Aug 2021CPOL6 min read 13.6K   15   25
An overview of nulls and generics with "is" and "as" keyword concepts.
In this article, you will get an overview of nulls and generics with "is" and "as" keyword concepts, just so you can love the new C# again.

Introduction

The new nullable context can be enabled via the <Project><PropertyGroup><Nullable>enable</Nullable> element in your C# project (.csproj) file. It gives you full null-state static analysis at compile-time and promises to eliminate every single NullReferenceException once and for all. Have you tried it? Do you like it? Why not? Code quality is of paramount importance and deserves your second look. This article aims to provide an overview of nulls and generics with is and as keyword concepts, just so you can love the new C# again. Little things matter.

Using the Code

C# provides two kinds of "type-safe macros", much like delegates as "type-safe function pointers", the first being extension methods and the second value types, both tiny enough that they often end up inlined by JIT, hence "macros". With these little C# macros, you can create nearly anything you want, and make C# your style of language. To start a new robust C# project, you begin with a root interface:

C#
using System;
using System.Collections.Generic;
[assembly: CLSCompliant(true)]
namespace Pyramid.Kernel.Up
{
 /// <summary>The extension method <see langword="class"/>.</summary>
 public static partial class It { }
 /// <summary>An <see cref="object"/>.</summary>
 public partial interface JIt
 {
  /// <summary>An <see langword="interface"/> for external 0's.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  partial interface J0<out JOut>
  {
   /// <summary>The 0 <typeparamref name="JOut"/> <see cref="IEnumerator{T}"/>.</summary>
   static readonly IEnumerator<JOut> ZTo = Z.GetEnumerator();
   /// <summary>The 0 <typeparamref name="JOut"/> <see cref="Array"/>.</summary>
   static JOut[] ZArray => Array.Empty<JOut>();
   /// <summary>The 0 <typeparamref name="JOut"/> <see cref="IEnumerable{T}"/>.</summary>
   static IEnumerable<JOut> Z => ZArray;
   /// <summary>The 0 <typeparamref name="JOut"/> <see cref="IReadOnlyCollection{T}"/>.
   /// </summary>
   static IReadOnlyCollection<JOut> ZReadOnlyCollection => ZArray;
   /// <summary>The 0 <typeparamref name="JOut"/> <see cref="IReadOnlyList{T}"/>.</summary>
   static IReadOnlyList<JOut> ZReadOnlyList => ZArray;
  }
 }
 /// <summary>An autonomous <see cref="JIt"/>.</summary>
 /// <typeparam name="J">Its self-referencing <see cref="JIt{J}"/>.</typeparam>
 public partial interface JIt<J> : JIt where J : JIt<J>, new()
 {
  /// <summary>The 0 <typeparamref name="J"/>.</summary>
  static readonly J Z0 = new();
 }
 /// <summary>An autonomous <see cref="JIt{J}"/>.</summary>
 /// <typeparam name="J">Its self-referencing <see cref="It{J}"/>.</typeparam>
 [Serializable]
 public abstract partial class It<J> : object, JIt<J> where J : It<J>, new() { }
}

Given that nulls are evil, we want a zero object for every type we create, and yet nulls must be there to represent data yet to arrive, especially useful in asynchronous programming. We use the J- prefix for C# interfaces to remind ourselves that all interface instance members are virtual like Java, as well as the Z- prefix for C# constants and read-only fields to note that they are final, also like Java. Instead of asynchronous tasks and delegates, enumerators will be used as very light-weight step-wise threads, which are even better than fibers because a yield in enumerators is much cheaper than Thread.Yield(), about 10,000+ times faster, not to mention easy progress bar support as natural loops. This is why nulls are important, since you can always yield a null or a special object like DBNull.Value to signal an I/O wait. This is for later. In this article, we want to focus on in-cache operations, which are generally hundreds of times faster than in-memory operations, which in turn are thousands or millions of times faster than disk or network I/O operations, an I/O hierarchy just like a pyramid, thus our new namespace Pyramid.

Firstly, we introduce null checks:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is (non-<see langword="null"/>)?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  public static bool Is<J>(this J it) => it != null;
  /// <summary>Is (non-<see langword="null"/>) for the <paramref name="alias"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="alias">An alias <typeparamref name="J"/>.</param>
  public static bool Is<J>(this J it, [MaybeNullWhen(false), NotNullWhen(true)] out J alias) =>
   (alias = it).Is();
 }
}

It looks perfect, but the second method overload doesn't work for Nullable<T>, for the alias coming out of the call will carry the same type, therefore still nullable. We can, of course, overload with a third method Is<J>(this J? it, out J alias) where J : struct, but that makes the call Is(out var o) ambiguous, which now requires an explicit type specifier instead of var. This is where C# needs serious improvements, hopefully to be addressed in C# 10 or 11 or something. Ambiguity is the root of all evil, enough said. However, given that you must write o != null? (O)o: new() anyway, why not embrace our new style, namely, o.Is()? o.Be(): new()? Well, it's time to add a few neat methods:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="JOut"/>
  /// <see cref="IEnumerable{T}"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JOut"/> <see cref="IEnumerable{T}"/>.</param>
  public static IEnumerable<JOut> Be<JOut>(this IEnumerable<JOut>? it) => it ?? JIt.J0<JOut>.Z;
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="JOut"/>
  /// <see cref="IEnumerator{T}"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JOut"/> <see cref="IEnumerator{T}"/>.</param>
  public static IEnumerator<JOut> Be<JOut>(this IEnumerator<JOut>? it) =>
   it ?? JIt.J0<JOut>.ZTo;
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="JOut"/>
  /// <see cref="IReadOnlyCollection{T}"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JOut"/> <see cref="IReadOnlyCollection{T}"/>.
  /// </param>
  public static IReadOnlyCollection<JOut> Be<JOut>(this IReadOnlyCollection<JOut>? it) =>
   it ?? JIt.J0<JOut>.ZReadOnlyCollection;
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="JOut"/>
  /// <see cref="IReadOnlyList{T}"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JOut"/> <see cref="IReadOnlyList{T}"/>.</param>
  public static IReadOnlyList<JOut> Be<JOut>(this IReadOnlyList<JOut>? it) =>
   it ?? JIt.J0<JOut>.ZReadOnlyList;
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="J"/> to
  /// <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="JIt{J}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  public static J Be<J>(this J? it) where J : JIt<J>, new() => it ?? JIt<J>.Z0;
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="J"/> to
  /// <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="ValueType"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  public static J Be<J>(this J? it) where J : struct => it ?? new();
  /// <summary>Be (non-<see langword="null"/>) a <typeparamref name="JOut"/>
  /// <see cref="Array"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JOut"/> <see cref="Array"/>.</param>
  public static JOut[] Be<JOut>(this JOut[]? it) => it ?? JIt.J0<JOut>.ZArray;
  /// <summary>Be (non-<see langword="null"/>) a <see cref="string"/> to
  /// <see langword="return"/>.</summary>
  /// <param name="it">A <see cref="string"/>.</param>
  public static string Be(this string? it) => it ?? "";
 }
}

This is it! Whenever we call o.Be(), we'll end up with a shared default empty typed object if the type is immutable and thread-safe at zero. If we are willing to follow the discipline that all our types will be immutable, we can even leverage JIt<J>.Z0. It is fascinating to note that Array.Empty<JOut>().GetEnumerator() gives you an immutable and thread-safe IEnumerator, yet another great method overload to our family. Null checks are easy. We now proceed to type checks:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is (<see langword="is"/>) <typeparamref name="JOut"/>?</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">An <see cref="object"/>.</param>
  public static bool Is<JOut>(this object? it) => it is JOut;
  /// <summary>Is (<see langword="is"/>) for the <paramref name="alias"/>?</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">An <see cref="object"/>.</param>
  /// <param name="alias">An alias <typeparamref name="JOut"/>.</param>
  public static bool Is<JOut>(this object? it, [NotNullWhen(true)] out JOut alias)
   where JOut : notnull => it is JOut o ? (alias = o).So(true) : (alias = default!).So(false);
 }
}

We are using this object? to cleverly circumvent call ambiguity with null checks, knowing that type checks always box value types to get their type pointers anyway. There's nothing fancy about type checks, which can be seen as more general forms of null checks. Type coercion is a bit more interesting, as the as keyword works on reference types only. We want to generalize it:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Be a <typeparamref name="JOut"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">An <see cref="object"/>.</param>
  [return: NotNullIfNotNull("it")] public static JOut Be<JOut>(this object? it) => (JOut)it!;
  /// <summary>Be the <paramref name="alias"/> to <see langword="return"/>.</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">An <see cref="object"/>.</param>
  /// <param name="alias">An alias <typeparamref name="JOut"/>.</param>
  [return: NotNullIfNotNull("it")]
  public static JOut Be<JOut>(this object? it, [NotNullIfNotNull("it")] out JOut alias) =>
   alias = it.Be<JOut>();
 }
}

We've chosen Be as their method name to set the method group apart from the as keyword, which "swallows" an InvalidCastException. We don't want that. We always want exceptions when run-time errors occur. Most importantly, we want to include value types together with reference types, while permitting nulls to pass type checks, as they should. Why not? Why else can you write return null instead of return (O)null? Nulls must pass all type checks for type consistency! Well, it turns out that the new C# cast does exactly what we want if we let JOut be nullable, no need for the as keyword anymore. To parallel type checks with null checks, we would very much like to have yet another overload, too:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Be the <paramref name="alias"/> to <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="alias">An alias <typeparamref name="J"/>.</param>
  public static J Be<J>(this J it, out J alias) => alias = it;
 }
}

This method does nothing more than to declare a new variable with the same value, which turns out to be highly convenient in one-liner lambda expressions, in fact enabling all C# expressions to declare in-line variables. Lastly, to prevent unintentional boxing on reference checks, we want to add another shortcut method:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <typeparam name="JThat">Its other <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">A <typeparamref name="JThat"/>.</param>
  public static bool Is<J, JThat>(this J it, JThat that)
   where J : class? where JThat : class? => ReferenceEquals(it, that);
 }
}

By ensuring reference types only in reference checks, we catch all occurrences of unintentional boxing at compile-time. Unintentional boxing is not only slow and heavy on garbage collection, but also dangerous in creating little subtle bugs that even the most experienced developers and experts can miss. Catch all bugs at compile-time if you can! Finally, we include the Isnt methods to complement our Is methods:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Isn't (<see langword="null"/>)?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  public static bool Isnt<J>(this J it) => it == null;
  /// <summary>Isn't (<see langword="null"/>) for the <paramref name="alias"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="alias">An alias <typeparamref name="J"/>.</param>
  public static bool Isnt<J>(this J it,
   [MaybeNullWhen(true), NotNullWhen(false)] out J alias) => (alias = it).Isnt();
  /// <summary>Isn't <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <typeparam name="JThat">Its other <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">A <typeparamref name="JThat"/>.</param>
  public static bool Isnt<J, JThat>(this J it, JThat that)
   where J : class? where JThat : class? => !it.Is(that);
  /// <summary>Is (<see langword="is"/>) not <typeparamref name="JOut"/>?</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">An <see cref="object"/>.</param>
  public static bool Isnt<JOut>(this object? it) => !it.Is<JOut>();
  /// <summary>Is (<see langword="is"/>) not for the <paramref name="alias"/>?</summary>
  /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
  /// <param name="it">An <see cref="object"/>.</param>
  /// <param name="alias">An alias <typeparamref name="JOut"/>.</param>
  public static bool Isnt<JOut>(this object? it, [NotNullWhen(false)] out JOut alias)
   where JOut : notnull => !it.Is(out alias);
 }
}

That's good enough for now, a tiny little code space we'll revisit from time to time to add quick-&-big wins globally. All these extension methods are very small, automatically candidates for JIT inlining, costing zero overhead while providing code safety and quality. Oh yes, what's that little So method in our lambda expressions? Well, as lambda lovers, we absolutely want this shortcut in our arsenal, which does nothing more than chaining expressions together into one line:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>So the <paramref name="next"/> to <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="object"/>.</typeparam>
  /// <typeparam name="JNext">Its next <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="next">A <typeparamref name="JNext"/>.</param>
  [SuppressMessage("", "IDE0060")]
  public static JNext So<J, JNext>(this J it, JNext next) => next;
 }
}

That concludes our short article. Next time, we'll talk about "index" checks, on top of null checks and type checks, yet another tip & trick all about run-time safety. After that, we can then get into something more interesting.

Points of Interest

  1. Empty arrays are immutable and thread-safe, so are their enumerators. Take advantage of that whenever you can.
  2. Ambiguity is the root of all evil in code. Our tiny reference check method overload cleverly avoids just that by using two generic types, complying even with CLS signature requirements where in and out modifiers cannot be the sole differentiator for method overloads. Plus, it prevents unintentional boxing.
  3. The [AllowNull] attribute tag is largely unnecessary by now, thanks to Microsoft's latest improvements on the integration of generics and nullability, because an unconstrained generic type can by default accept a nullable type, no more need to make it explicit. If your Visual Studio doesn't do this yet, please make sure that you upgrade it to the latest version.

Happy coding!

History

  • 19th June, 2021: Initial version
  • 26th June 2021: Complete code rewrite based on the 3rd Point of Interest

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer
Canada Canada
Montreal is the second largest French city in the world, next to Paris. I like the fact that real estate is dirt cheap here, so cheap that software development alone enables a financial capacity to afford a nearly 2,000-square-foot luxurious condo right in the middle of Downtown Montreal, a 5-minute walk from my office, beside the largest and oldest art museum in Canada with visitors and tourists from all over the planet, including Hollywood stars. I've chosen C# as my first language at Code Project, because it is the only garbage-collected language and platform meeting the performance requirements for real-time game programming, proven by Unity. Code must be perfect, providing safety, security, performance, scalability, availability, reliability, maintainability, extensibility, portability, compatibility, interoperability, readability, productivity, just to name a few. C# is the only language that comes close, with Rust second to it. That being said, even C# is far from being perfect. I dream my own programming language, while on my journey to it. We will see how it goes!

Comments and Discussions

 
GeneralRe: please explain the context of your code examples Pin
BillWoodruff21-Jun-21 21:28
professionalBillWoodruff21-Jun-21 21:28 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.