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

How to Implement a Robust C# Comparison in the New Nullable Context with Generics?

Rate me:
Please Sign up or sign in to vote.
1.00/5 (3 votes)
8 Aug 2021CPOL7 min read 11.7K   10   9
An overview of nulls and generics with IComparable.
In this article, you will get an overview of nulls and generics with IComparable, 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. Previously, we talked about the most fundamental null, type and reference checks (How to Start a Robust C# Project in the New Nullable Context with Generics?). Today, we want to cover something slightly more challenging, namely, range checks or "index" checks. Code quality is of paramount importance and deserves your second look. This article aims to provide an overview of nulls and generics with IComparable<T>, just so you can love the new C# again. Little things matter.

Using the Code

To start, let's begin with a simple method for illustration, which we call IsAbout to emphasize that an Equals comparison is intended to be inexact, such as +0.0 == -0.0, or its default implementation is usually more than good enough:

C#
using System;
using System.Diagnostics.CodeAnalysis;
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is about <paramref name="that"/>?</summary>
  /// <typeparam name="J">It's self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsAbout<J>(this J it, J that) where J : IComparable<J>? =>
   it.Is(out var o) ? that.Is(out var p) && o.CompareTo(p) == 0 : that.Isnt();
 }
}

Data often come from external sources, where nulls may be the norm, especially in relational databases, which in fact was the primary motivation for Nullable<T>. To declare the acceptance of nulls in a generic method, we can write either IsAbout<J>(this J it, J that) where J : IComparable<J>? or IsAbout<J>(this J? it, J? that) where J : IComparable<J>. For the sake of brevity and consistency, we want to embed all nullability information in types rather than in parameters, fields or properties. Write once and for all. If you write twice or many times, chances are you'll introduce inconsistencies everywhere in your code. Code consistency matters, therefore the first form is preferred. The implementation above looks cryptic, but it demonstrates how short code can be after a simple logical reduction, which will definitely get inlined by JIT. If you prefer human readability, the equivalent implementation is given below:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is about <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsAbout<J>(this J it, J that) where J : IComparable<J>?
  {
   if(it.Is(out var o))
   {
    if (that.Is(out var p)) return o.CompareTo(p) == 0;
    else return false;
   }
   else
   {
    if (that.Is()) return false;
    else return true;
   }
  }
 }
}

The core principle of robust code is to list all permutations, the same thing we do in multi-threading, just to ensure that we have not missed any single possible scenario. It's not difficult at all, but it's tremendously tedious, prone to error. That's why we bother to wrap even the simplest code into a C# extension method, which will end up inlined by JIT without any run-time overhead or performance penalty, much like a C++ macro. This way, if we by chance make a careless mistake, we can correct it once and for all as a global code policy. One place to fix them all, isn't that cool? Unfortunately, the above method doesn't work for Nullable<T>. We must overload it with a separate, dedicated version:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <inheritdoc cref="IsAbout{J}(J, J)"/>
  public static bool IsAbout<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.Is() ? that.Is() && it.Be().IsAbout(that.Be()) : that.Isnt();
 }
}

The new C# comes with a new documentation element called <inheritdoc>, which comes in handy if you're overloading or overriding lots of methods. It works even with classes and interfaces, where classes may choose to inherit interface documentation. The cref attribute appears in many documentation elements, such as <see> and <seealso>, but has been way underdocumented by Microsoft. It actually can refer to any code member, including operators and custom casts, implicit or explicit, all integrated into Visual Studio with every single mouse hover and tooltip. We will not cover everything here. Instead, they will come complete gradually in our future code samples. To support all scenarios, we have IsAbove and IsBelow defined as follows:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is above <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsAbove<J>(this J it, J that) where J : IComparable<J>? =>
   it.Is(out var o) && (!that.Is(out var p) || o.CompareTo(p) > 0);
  /// <inheritdoc cref="IsAbove{J}(J, J)"/>
  public static bool IsAbove<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.Is() && (!that.Is() || it.Be().IsAbove(that.Be()));
  /// <summary>Is below <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsBelow<J>(this J it, J that) where J : IComparable<J>? =>
   it.Is(out var o) ? that.Is(out var p) && o.CompareTo(p) < 0 : that.Is();
  /// <inheritdoc cref="IsBelow{J}(J, J)"/>
  public static bool IsBelow<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.Is() ? that.Is() && it.Be().IsBelow(that.Be()) : that.Is();
 }
}

How about IsAboutOrAbove, IsAboutOrBelow and IsNotAbout? Well, it works better with !IsBelow, !IsAbove and !IsAbout, because C# generics with value types results in code bloat as in C++. Whenever possible, we aim to keep the minimal primitives only, observing C++ minimalism religiously. So, why define Isnt in my previous article? Well, it's simple. Why define == null and != null as two separate primitives? They are used often enough to deserve their own CLR instructions. Besides, our Isnt overloads work with reference types mostly, which with C# generics reuse the same code unlike C++, no code bloat there. That being said, the newer CPU instruction sets come with dedicated operations to match ==, !=, <, <=, > and >=, all six of them, which we definitely want to leverage. Well, let's go with IsntBelow, IsntAbove and IsntAbout for symmetric completeness anyway, hardware first:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Isn't about <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsntAbout<J>(this J it, J that) where J : IComparable<J>? =>
   it.Is(out var o) ? that.Isnt(out var p) || o.CompareTo(p) != 0 : that.Is();
  /// <inheritdoc cref="IsntAbout{J}(J, J)"/>
  public static bool IsntAbout<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.Is() ? that.Isnt() || it.Be().IsntAbout(that.Be()) : that.Is();
  /// <summary>Isn't above <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsntAbove<J>(this J it, J that) where J : IComparable<J>? =>
   it.Isnt(out var o) || that.Is(out var p) && o.CompareTo(p) <= 0;
  /// <inheritdoc cref="IsntAbove{J}(J, J)"/>
  public static bool IsntAbove<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.Isnt() || that.Is() && it.Be().IsntAbove(that.Be());
  /// <summary>Isn't below <paramref name="that"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static bool IsntBelow<J>(this J it, J that) where J : IComparable<J>? =>
   it.Is(out var o) ? that.Isnt(out var p) || o.CompareTo(p) >= 0 : that.Isnt();
  /// <inheritdoc cref="IsntBelow{J}(J, J)"/>
  public static bool IsntBelow<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.Is() ? that.Isnt() || it.Be().IsntBelow(that.Be()) : that.Isnt();
 }
}

Given the Is method group, we want to define their Be equivalent:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Be about <paramref name="that"/> to <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static J BeAbout<J>(this J it, J that) where J : IComparable<J>? =>
   it.IsAbout(that) ? it : throw new OverflowException();
  /// <inheritdoc cref="BeAbout{J}(J, J)"/>
  public static J? BeAbout<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.IsAbout(that) ? it : throw new OverflowException();
  /// <summary>Be above <paramref name="that"/> to <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static J BeAbove<J>(this J it, J that) where J : IComparable<J>? =>
   it.IsAbove(that) ? it : throw new OverflowException();
  /// <inheritdoc cref="BeAbove{J}(J, J)"/>
  public static J? BeAbove<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.IsAbove(that) ? it : throw new OverflowException();
  /// <summary>Be below <paramref name="that"/> to <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="that">Another <typeparamref name="J"/>.</param>
  public static J BeBelow<J>(this J it, J that) where J : IComparable<J>? =>
   it.IsBelow(that) ? it : throw new OverflowException();
  /// <inheritdoc cref="BeBelow{J}(J, J)"/>
  public static J? BeBelow<J>(this J? it, J? that) where J : struct, IComparable<J> =>
   it.IsBelow(that) ? it : throw new OverflowException();
 }
}

There are two things to note here: the choice of OverflowException over IndexOutOfRangeException and the decision to skip a custom message. Firstly, range checks are often performed for index checks, which are built into C# and .NET, so we don't want to check them twice. If we do actually check ranges, chances are we are checking against an overflow, in either arithmetics or conversions, hence an OverflowException. Secondly, why skip a custom message? Well, it's my style, because I don't want to translate custom messages into hundreds of local languages, a requirement by the standard implementation of exceptions. Microsoft has supplied the translations for all default exception messages. Why not leverage them? Now, we want to check ranges in one call, where the absence of either boundary skips its corresponding check for semantic naturalness:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Be within the <paramref name="min"/> and the <paramref name="max"/> to
  /// <see langword="return"/>.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="min">A min <typeparamref name="J"/>.</param>
  /// <param name="max">A max <typeparamref name="J"/>.</param>
  public static J BeWithin<J>(this J it, J min, J max) where J : IComparable<J>? =>
   it.IsWithin(min, max) ? it : throw new OverflowException();
  /// <inheritdoc cref="BeWithin{J}(J, J, J)"/>
  public static J? BeWithin<J>(this J? it, J? min, J? max) where J : struct, IComparable<J> =>
   it.IsWithin(min, max) ? it : throw new OverflowException();
  /// <summary>Is within the <paramref name="min"/> and the <paramref name="max"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="min">A min <typeparamref name="J"/>.</param>
  /// <param name="max">A max <typeparamref name="J"/>.</param>
  public static bool IsWithin<J>(this J it, J min, J max) where J : IComparable<J>? =>
   it.Is(out var o) ?
    (min.Isnt(out var p) || o.CompareTo(p) >= 0) &&
    (max.Isnt(out var q) || o.CompareTo(q) <= 0) :
    min.Isnt();
  /// <inheritdoc cref="IsWithin{J}(J, J, J)"/>
  public static bool IsWithin<J>(this J? it, J? min, J? max)
   where J : struct, IComparable<J> =>
   it.Is() ?
    (min.Isnt() || it.Be().IsntBelow(min.Be())) &&
    (max.Isnt() || it.Be().IsntAbove(max.Be())) :
    min.Isnt();
  public static bool IsntWithin<J>(this J it, J min, J max) where J : IComparable<J>? =>
   it.Is(out var o) ?
    min.Is(out var p) && o.CompareTo(p) < 0 || max.Is(out var q) && o.CompareTo(q) > 0 :
    min.Is();
  /// <inheritdoc cref="IsntWithin{J}(J, J, J)"/>
  public static bool IsntWithin<J>(this J? it, J? min, J? max)
   where J : struct, IComparable<J> =>
   it.Is() ?
    min.Is() && it.Be().IsBelow(min.Be()) || max.Is() && it.Be().IsAbove(max.Be()) :
    min.Is();
 }
}

The code gets pretty big now. You might wonder why not ease the burden of null checks by delegating part of that responsibility to CompareTo, which accepts a null anyway. Well, that's only for the right-hand side, isn't it? How about the left-hand side? If you write it?.CompareTo(that), you end up with an int?, which actually complicates the story. Plus, the correct and fast implementation of CompareTo is usually fairly sophisticated beside those standard primitive types, which seldom gets inlined. By performing null checks outside CompareTo, we at least inline all our null checks, as we should. To ensure inlining, if that matters to you so much, you can use the new MethodImplOptions.AggressiveInlining option, available after .NET 4.5 or .NET Core 1.0. An even more aggressive option called MethodImplOptions.AggressiveOptimization was introduced after .NET 5 or .NET Core 3.0. You might want to have a look and take advantage. To verify that your method is indeed inlined, you can leverage a Reflection method called MethodBase.GetCurrentMethod. If it doesn't return your method, it means your method doesn't exist, therefore inlined. Congratulations!

C#
using System.Reflection;
using System.Runtime.CompilerServices;
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  static MethodBase? _method; // for test only
  /// <summary>Is within the <paramref name="min"/> and the <paramref name="max"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="min">A min <typeparamref name="J"/>.</param>
  /// <param name="max">A max <typeparamref name="J"/>.</param>
  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static bool IsWithin<J>(this J it, J min, J max) where J : IComparable<J>?
  {
   _method = MethodBase.GetCurrentMethod(); // for test only
   return
    it.Is(out var o) ?
     (min.Isnt(out var p) || o.CompareTo(p) >= 0) &&
     (max.Isnt(out var q) || o.CompareTo(q) <= 0) :
     min.Isnt();
  }
 }
}

If you are curious about how JIT decides what and what not to inline, the most complete explanation I can find online is an old, archived blog post by Vance Morrison (To Inline or not to Inline: That is the question), who worked on JIT optimization at Microsoft. If that doesn't satisfy your hunger for knowledge, the best article about .NET performance is the official publication by Microsoft, now archived as well (Writing Faster Managed Code: Know What Things Cost), written by Jan Gray. If you Google these two names, you'll find out a lot more .NET performance resources, mostly in the form of semi-official blogs at Microsoft. Furthermore, if you Google hard enough, you may come across this question at Stack Overflow (Is there an updated version of "Writing Faster Managed Code: Know What Things Cost"?), where Jan explained why they had very rarely blogged about .NET performance. In fact, they encourage you to do your own microbenchmarks, if you know where you want to deploy your code, such as these two posts (CLR Inside Out: Measure Early and Often for Performance, Part 1 and CLR Inside Out: Measure Early and Often for Performance, Part 2). There's no shortcut. You must learn on your own.

If you prefer human readability, an equivalent implementation is given below:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Is within the <paramref name="min"/> and the <paramref name="max"/>?</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IComparable{T}"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  /// <param name="min">A min <typeparamref name="J"/>.</param>
  /// <param name="max">A max <typeparamref name="J"/>.</param>
  public static bool IsWithin<J>(this J it, J min, J max) where J : IComparable<J>? =>
  {
   if (it.Is(out var o))
   {
    if (min.Is(out var p))
    {
     if (max.Is(out var q)) return o.CompareTo(p) >= 0 && o.CompareTo(q) <= 0;
     else return o.CompareTo(p) >= 0;
    }
    else
    {
     if (max.Is(out var q)) return o.CompareTo(q) <= 0;
     else return true;
    }
   }
   else
   {
    if (min.Is())
    {
     if (max.Is()) return false;
     else return false;
    }
    else
    {
     if (max.Is()) return true;
     else return true;
    }
   }
  }
 }
}

You see, robustness is tedious. That's why we don't want to write it from scratch every time. We want C# type-safe macros, such as extension methods and value types, to help reusing and managing robust code without any run-time overhead. Next time, we'll dive into the bits and make our own Id value type, a special Guid that addresses cloud objects, something a little more exciting.

Points of Interest

  1. The core principle of robust code is to list all permutations, the same thing we do in multi-threading, just to ensure that we have not missed any single possible scenario. Brute-force logic always works, however clumsy or boring it looks.
  2. The new C# comes with a new <inheritdoc> element to inherit documentation, where its cref attribute has been way underdocumented. In fact, it can refer to anything, including operator overloads and custom casts, implicit or explicit, all seamlessly integrated into Visual Studio with every mouse hover and tooltip.
  3. The new .NET introduces two options to MethodImplOptions, AggressiveInlining available after .NET 4.5 or .NET Core 1.0 and AggressiveOptimization, available after .NET 5 or .NET Core 3.0, both great additions to your arsenal in writing faster code.

History

  • 2nd July, 2021: Initial version

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

 
Questionanother brain dump Pin
BillWoodruff19-Jul-21 10:36
professionalBillWoodruff19-Jul-21 10:36 
QuestionLife got complicated... Pin
Tomaž Štih5-Jul-21 2:20
Tomaž Štih5-Jul-21 2:20 
AnswerRe: Life got complicated... Pin
Code Fan5-Jul-21 7:47
Code Fan5-Jul-21 7:47 
GeneralRe: Life got complicated... Pin
CHill605-Jul-21 21:46
mveCHill605-Jul-21 21:46 
GeneralRe: Life got complicated... Pin
Code Fan6-Jul-21 10:49
Code Fan6-Jul-21 10:49 
GeneralRe: Life got complicated... Pin
CHill606-Jul-21 21:17
mveCHill606-Jul-21 21:17 
GeneralRe: Life got complicated... Pin
User 140765527-Jul-21 6:10
User 140765527-Jul-21 6:10 
GeneralRe: Life got complicated... Pin
Code Fan7-Jul-21 9:06
Code Fan7-Jul-21 9:06 
GeneralRe: Life got complicated... Pin
Code Fan7-Jul-21 8:55
Code Fan7-Jul-21 8:55 

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.