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:
using System;
using System.Collections.Generic;
[assembly: CLSCompliant(true)]
namespace Pyramid.Kernel.Up
{
public static partial class It { }
public partial interface JIt
{
partial interface J0<out JOut>
{
static readonly IEnumerator<JOut> ZTo = Z.GetEnumerator();
static JOut[] ZArray => Array.Empty<JOut>();
static IEnumerable<JOut> Z => ZArray;
static IReadOnlyCollection<JOut> ZReadOnlyCollection => ZArray;
static IReadOnlyList<JOut> ZReadOnlyList => ZArray;
}
}
public partial interface JIt<J> : JIt where J : JIt<J>, new()
{
static readonly J Z0 = new();
}
[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:
namespace Pyramid.Kernel.Up
{
partial class It
{
public static bool Is<J>(this J it) => it != null;
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:
namespace Pyramid.Kernel.Up
{
partial class It
{
public static IEnumerable<JOut> Be<JOut>(this IEnumerable<JOut>? it) => it ?? JIt.J0<JOut>.Z;
public static IEnumerator<JOut> Be<JOut>(this IEnumerator<JOut>? it) =>
it ?? JIt.J0<JOut>.ZTo;
public static IReadOnlyCollection<JOut> Be<JOut>(this IReadOnlyCollection<JOut>? it) =>
it ?? JIt.J0<JOut>.ZReadOnlyCollection;
public static IReadOnlyList<JOut> Be<JOut>(this IReadOnlyList<JOut>? it) =>
it ?? JIt.J0<JOut>.ZReadOnlyList;
public static J Be<J>(this J? it) where J : JIt<J>, new() => it ?? JIt<J>.Z0;
public static J Be<J>(this J? it) where J : struct => it ?? new();
public static JOut[] Be<JOut>(this JOut[]? it) => it ?? JIt.J0<JOut>.ZArray;
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:
namespace Pyramid.Kernel.Up
{
partial class It
{
public static bool Is<JOut>(this object? it) => it is JOut;
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:
namespace Pyramid.Kernel.Up
{
partial class It
{
[return: NotNullIfNotNull("it")] public static JOut Be<JOut>(this object? it) => (JOut)it!;
[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:
namespace Pyramid.Kernel.Up
{
partial class It
{
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:
namespace Pyramid.Kernel.Up
{
partial class It
{
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:
namespace Pyramid.Kernel.Up
{
partial class It
{
public static bool Isnt<J>(this J it) => it == null;
public static bool Isnt<J>(this J it,
[MaybeNullWhen(true), NotNullWhen(false)] out J alias) => (alias = it).Isnt();
public static bool Isnt<J, JThat>(this J it, JThat that)
where J : class? where JThat : class? => !it.Is(that);
public static bool Isnt<JOut>(this object? it) => !it.Is<JOut>();
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:
namespace Pyramid.Kernel.Up
{
partial class It
{
[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
- Empty arrays are immutable and thread-safe, so are their enumerators. Take advantage of that whenever you can.
- 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. - 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