Click here to Skip to main content
15,122,559 members
Articles / Programming Languages / C#
Tip/Trick
Posted 5 Aug 2021

Tagged as

Stats

4.9K views
11 bookmarked

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

Rate me:
Please Sign up or sign in to vote.
3.86/5 (4 votes)
10 Aug 2021CPOL8 min read
An overview of nulls and generics with IAsyncDisposable.
In this article, you will get an overview of nulls and generics with IAsyncDisposable, 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?), as well as the more challenging "index" or range checks (How to Implement a Robust C# Comparison in the New Nullable Context with Generics?). Today, we want to cover the dreaded IDisposable and IAsyncDisposable with a highly practical example, which paves the way to efficient I/O asynchrony, before we dive into the bits with a promised Id value type that addresses cloud objects. Code quality is of paramount importance and deserves your second look. This article aims to provide an overview of nulls and generics with IDisposable and IAsyncDisposable, just so you can love the new C# again. Little things matter.

Using the code

Before we talk about the how, let's focus on the why first, because the design intent of IDisposable is not as obvious as IComparable<T>. GC (the .Net garbage collector) doesn't run too often. When your objects lose all their references, GC may not kick in to do the cleanup within nanoseconds. Sometimes, it takes seconds or even minutes before your objects get cleaned up, especially with long-lived objects. What do you do? You don't want to force a GC cycle by calling GC.Collect. Instead, you want to dispose of your objects by releasing their expensive system resources only. An operating system is like a library. When you borrow very popular books, you want to return them as soon as you finish reading, hence the motivation for IDisposable.

Now, why IAsyncDisposable? Well, Microsoft introduced IAsyncEnumerable<T> and IAsyncEnumerator<T>, where IAsyncEnumerator<T> inherited IAsyncDisposable to mirror the fact that IEnumerator<T> inherits IDisposable, just so you could write await foreach. What's the difference? Well, with IAsyncDisposable, you now let the .Net background thread pool dispose of your objects for you, which may result in a delay, one not as serious as that caused by GC, but commonly from microseconds to seconds. Is this delay acceptable? Probably not. That's why we want to do something slightly different from this Microsoft article (Implement a DisposeAsync method). However, if you indeed are coding against unmanaged system resources directly, you want to wrap each of them by inheriting System.Runtime.InteropServices.SafeHandle or at least System.Runtime.ConstrainedExecution.CriticalFinalizerObject, following the advice from this Microsoft article (Implement a Dispose method), and then group them all at higher-level managed IDisposable or IAsyncDisposable objects. Always keep a safe distance with the unmanaged world! Never, ever, touch anything unmanaged outside of a CriticalFinalizerObject.

C#
using System;
namespace Pyramid.Kernel.Up
{
 /// <summary>A possessive <see cref="JIt"/>.</summary>
 public partial interface JIts : JIt
 {
  /// <summary>Its status <see cref="object"/>.</summary>
  object? XEx { get; init; }
  /// <summary>Set its <see cref="XEx"/>.</summary>
  /// <param name="ex">Its status <see cref="object"/>.</param>
  internal protected void JSetEx(object? ex) => throw new NotSupportedException();
 }
 /// <summary>An autonomous <see cref="JIts"/>.</summary>
 /// <typeparam name="J">Its self-referencing <see cref="JIts{J}"/>.</typeparam>
 public partial interface JIts<J> : JIt<J>, JIts where J : JIts<J>, new() { }
 /// <summary>An autonomous <see cref="JIts{J}"/>.</summary>
 /// <typeparam name="J">Its self-referencing <see cref="Its{J}"/>.</typeparam>
 [Serializable]
 public abstract partial class Its<J> : It<J>, JIts<J> where J : Its<J>, new()
 {
  object? _ex;
  /// <inheritdoc cref="JIts.XEx"/>
  public object? XEx { get => _ex; init => _ex = value; }
  void JIts.JSetEx(object? ex) => _ex = ex;
 }
}

If you wonder what JIt, JIt<J> or It<J> are, you are welcome to read my first article at Code Project (How to Start a Robust C# Project in the New Nullable Context with Generics?), where I started the global policy that everything be immutable. To do work with system resources, which are obviously mutable, we need to make mutable objects as sole exceptions to this policy, thus the motivation for JIts, something temporary and disposable belonging to another JIt. They possess system resources, only to be possessed by common immutable citizens. Mutability is dangerous, therefore always governed by a state machine, where XEx acts as a state or status object, ex for extra or exceptions. Here, we are introducing two additional prefixes, X- and Q-, on top of J- and Z-. To recap, we have X- for states, Q- for locals (quanta), J- for virtuals and Z- for finals. These letters are the rarest in English, thereby helping us save our keyboards by balancing keystrokes (English Letter Frequency). To actually implement IDisposable and IAsyncDisposable, we have our code as follows:

C#
using System.Threading.Tasks;
namespace Pyramid.Kernel.Up
{
 partial interface JIts : IAsyncDisposable, IDisposable
 {
  void IDisposable.Dispose()
  {
   JEnd();
   GC.SuppressFinalize(this);
  }
  async ValueTask IAsyncDisposable.DisposeAsync()
  {
   await JEndAsync().ConfigureAwait(false);
   Dispose();
  }
  /// <summary>End by disposing of its resources.</summary>
  protected void JEnd() { }
  /// <summary>End by disposing of its resources, asynchronously.</summary>
  protected ValueTask JEndAsync() => new(Task.CompletedTask);
 }
 partial class Its<J>
 {
  void JIts.JEnd() => JEnd();
  /// <inheritdoc cref="JIts.JEnd"/>
  protected abstract void JEnd();
 }
}

To simplify every future implementation of IDisposable and IAsyncDisposable, we deliberately separate the public call-only Dispose/DisposeAsync from the protected override-only JEnd/JEndAsync, where JEnd is made abstract in Its<J> as a reminder to list all managed IDisposable objects, while JEndAsync is always provided with an empty default implementation to discourage all further actions. This way, whether you call Dispose by using or DisposeAsync by await using, you'll always release all system resources immediately without any delay. How about the infamous protected method void Dispose(bool disposing)? Well, we don't really need it if we make sure to default every single IDisposable object right after disposal, with a convenience method below:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>End by disposing of its resources.</summary>
  /// <typeparam name="J">Its self-referencing <see cref="IDisposable"/>.</typeparam>
  /// <param name="it">A <typeparamref name="J"/>.</param>
  public static void End<J>(ref J it) where J : IDisposable?
  {
   it?.Dispose();
   it = default!;
  }
 }
}

Now, we are ready for a real-world example: the tuples. Though C++ tuples are implemented via compile-time recursion elegantly, C# doesn't have similar facilities. What we have is a series of cumbersome overloads from .Net BCL (Base Class Library), such as Tuple and ValueTuple. Why make our own tuples? Firstly, the Tuple overloads are reference types, not acceptable from the perspective of performance. Secondly, C# pushed for the introduction of the ValueTuple overloads to address performance concerns, but not enough. C# failed to make them immutable. We need immutable value type tuples, which can be copied by reference under many circumstances. With the Up/Ups overloads, which we're about to write, we can apply them to Func and Action, like Func<Ups<A, B, C>, Out> or Action<Ups<A, B, C>>, or even create our own method objects, code DOM, programming languages, development platforms, integrated development environments, code visualization facilities, massive online collaboration networks, AI and humanity as one, Internet 4.0, Earth 2.0, Civilization 2.0, Galaxy 1.0, Universe 1.0, just to name a few, the speed of light and imagination being our very limit today. Tuples form method signatures and table schemas, the essence of code contracts and the forefront application of set theories, the foundation of mathematics and the language of science and technology. Simplicity is beauty!

C#
namespace Pyramid.Kernel.Up
{
 /// <summary>A frame of 1-ary variant <see cref="JUps{JOut}"/>.</summary>
 /// <typeparam name="JA">Its 1-st output <see cref="object"/>.</typeparam>
 public partial interface JUp<
  out JA> :
  JUps<
   object?>
  where JA : class?
 {
  /// <summary>Its <typeparamref name="JA"/>.</summary>
  JA XA { get; }
 }
 /// <summary>A frame of in-memory <see cref="JData"/>.</summary>
 public partial interface JUps : JIts { }
 /// <summary>A frame of N-ary variant <see cref="JUps"/>.</summary>
 /// <typeparam name="JOut">Its output <see cref="object"/>.</typeparam>
 public partial interface JUps<out JOut> : JUps where JOut : class? { }
 /// <summary>A frame of 1-ary invariant <see cref="JUps{JOut}"/>.</summary>
 /// <typeparam name="JA">Its 1-st output <see cref="object"/>.</typeparam>
 /// <typeparam name="A">Its invariant <typeparamref name="JA"/>.</typeparam>
 [Serializable]
 public readonly partial struct Up<
  JA, A> :
  JUp<
   JA>
  where JA : class? where A : JA
 {
  JA JUp<
   JA>.XA => XA;
  /// <summary>Its <typeparamref name="A"/>.</summary>
  public A XA { get; init; }
  /// <inheritdoc cref="JIts.XEx"/>
  public object? XEx { get; init; }
 }
 /// <summary>A frame of 0-ary invariant <see cref="JUps{JOut}"/>.</summary>
 [Serializable]
 public readonly partial struct Ups
  :
  JUps<
   object?>
 {
  /// <inheritdoc cref="JIts.XEx"/>
  public object? XEx { get; init; }
 }
}

Immutability is covariant, but value types aren't due to their inconsistent sizes. For value types to participate in covariance, we must map them to interfaces, hence our above design. To cover 2-ary to 26-ary tuples, we can overload from A to Z with all English letters complete. What if we need more? Trust me. You won't. I/O is too slow, so all our computing facilities are designed with a cache hierarchy, organized in pages, where each page has a relatively small size to avoid a high ratio of unused data in cache. It's a good policy to keep the arity of every tuple within 26 anyway. The overload for a 2-ary Ups is given below. Now, imagine a 26-ary Ups. You wish you could program in C++!

C#
namespace Pyramid.Kernel.Up
{
 /// <summary>A frame of 2-ary variant <see cref="JUps{JOut}"/>.</summary>
 /// <typeparam name="JA">Its 1-st output <see cref="object"/>.</typeparam>
 /// <typeparam name="JB">Its 2-nd output <see cref="object"/>.</typeparam>
 public partial interface JUps<
  out JA, out JB> :
  JUp<
   JA>
  where JA : class? where JB : class?
 {
  /// <summary>Its <typeparamref name="JB"/>.</summary>
  JB XB { get; }
 }
 /// <summary>A frame of 2-ary invariant <see cref="JUps{JOut}"/>.</summary>
 /// <typeparam name="JA">Its 1-st output <see cref="object"/>.</typeparam>
 /// <typeparam name="A">Its invariant <typeparamref name="JA"/>.</typeparam>
 /// <typeparam name="JB">Its 2-nd output <see cref="object"/>.</typeparam>
 /// <typeparam name="B">Its invariant <typeparamref name="JB"/>.</typeparam>
 [Serializable]
 public readonly partial struct Ups<
  JA, A, JB, B> :
  JUps<
   JA, JB>
  where JA : class? where A : JA where JB : class? where B : JB
 {
  JA JUp<
   JA>.XA => XA;
  /// <summary>Its <typeparamref name="A"/>.</summary>
  public A XA { get; init; }
  JB JUps<
   JA, JB>.XB => XB;
  /// <summary>Its <typeparamref name="B"/>.</summary>
  public B XB { get; init; }
  /// <inheritdoc cref="JIts.XEx"/>
  public object? XEx { get; init; }
 }
}

Why enforce covariance? It looks like much ado about nothing! Covariance reduces memory consumption, so much so .Net breaks its type system by permitting it on mutable arrays, a horror story. Without covariance, you'd have to copy your entire arrays every time you want to cast to a different element type, a performance penalty unacceptable. Covariance is perfect, but type mapping comes with complexity. Doubling type parameters makes it inconvenient to declare our tuples. Fortunately, if we religiously program to interfaces, a group of utility methods can simplify instantiation for us.

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>As a frame of 1-ary invariant <see cref="JUps{JOut}"/> to
  /// <see langword="return"/>.</summary>
  /// <typeparam name="A">Its 1-st output <see cref="object"/>.</typeparam>
  /// <param name="it">An <typeparamref name="A"/>.</param>
  /// <param name="ex">A status <see cref="object"/>.</param>
  public static Up<
   A, A> As<
   A>(this A it,
   object? ex = null)
   where A : class? =>
   new()
   {
    XA = it,
    XEx = ex
   };
  /// <summary>As a frame of 2-ary invariant <see cref="JUps{JOut}"/> to
  /// <see langword="return"/>.</summary>
  /// <typeparam name="A">Its 1-st output <see cref="object"/>.</typeparam>
  /// <typeparam name="B">Its 2-nd output <see cref="object"/>.</typeparam>
  /// <param name="it">An <typeparamref name="A"/>.</param>
  /// <param name="b">A <typeparamref name="B"/>.</param>
  /// <param name="ex">A status <see cref="object"/>.</param>
  public static Ups<
   A, A, B, B> As<
   A, B>(this A it,
   B b, object? ex = null)
   where A : class? where B : class? =>
   new()
   {
    XA = it,
    XB = b, XEx = ex
   };
 }
}

Now that we can convert a list of objects into a tuple, why not vice versa? What if we have a tuple to convert back to a list of variables? Well, we can stick to the same method group As for all our conversion needs and rest on C# out parameters for call disambiguation.

C#
namespace Pyramid.Kernel.Up
{
 partial struct Up<
  JA, A>
 {
  /// <summary>As 1 element to <see langword="return"/>?</summary>
  /// <param name="a">An <typeparamref name="A"/>.</param>
  public bool As(
   out A a) => ((
   a) = (
   XA)).So(XEx.Isnt());
 }
 partial struct Ups<
  JA, A, JB, B>
 {
  /// <inheritdoc cref="Up{
  /// JA, A}.As(
  /// out A)"/>
  public bool As(
   out A a) => ((
   a) = (
   XA)).So(XEx.Isnt());
  /// <summary>As 2 elements to <see langword="return"/>?</summary>
  /// <param name="a">An <typeparamref name="A"/>.</param>
  /// <param name="b">A <typeparamref name="B"/>.</param>
  public bool As(
   out A a, out B b) => ((
   a, b) = (
   XA, XB)).So(XEx.Isnt());
 }
}

Unfortunately, C# requires a special method group Deconstruct for object deconstruction, whose overloads return a void to end any possible chain of calls. This necessitates two method groups for deconstruction, one to support C# deconstruction syntax and the other to permit method chaining, which we have done above.

C#
namespace Pyramid.Kernel.Up
{
 partial struct Up<
  JA, A>
 {
  /// <summary>Deconstruct to 1 element.</summary>
  /// <param name="a">An <typeparamref name="A"/>.</param>
  /// <param name="ex">A status <see cref="object"/>.</param>
  public void Deconstruct(
   out A a, out object? ex) => As(
    out a).So(ex = XEx);
 }
 partial struct Ups<
  JA, A, JB, B>
 {
  /// <inheritdoc cref="Up{
  /// JA, A}.Deconstruct(
  /// out A, out object?)"/>
  public void Deconstruct(
   out A a, out object? ex) => As(
    out a).So(ex = XEx);
  /// <summary>Deconstruct to 2 elements.</summary>
  /// <param name="a">An <typeparamref name="A"/>.</param>
  /// <param name="b">A <typeparamref name="B"/>.</param>
  /// <param name="ex">A status <see cref="object"/>.</param>
  public void Deconstruct(
   out A a, out B b, out object? ex) => As(
    out a, out b).So(ex = XEx);
 }
}

To enable programming against interfaces, we need the extension methods As and Deconstruct on the JUp/JUps interfaces. To save space, we show the 1-ary versions only.

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>As 1 element to <see langword="return"/>?</summary>
  /// <typeparam name="JUp">Its self-referencing 1-ary variant <see cref="JUps{JOut}"/>.
  /// </typeparam>
  /// <typeparam name="JA">Its 1-st output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JUp"/>.</param>
  /// <param name="a">A <typeparamref name="JA"/>.</param>
  public static bool As<JUp,
   JA>(
   this JUp it,
   out JA? a)
   where JUp : JUp<
    JA>?
   where JA : class? =>
   it.Is(out var up) ?
    ((a) = (
     up.XA)).So(up.XEx.Isnt()) :
    ((a) = (
     null)).So(true);
  /// <summary>Deconstruct to 1 element.</summary>
  /// <typeparam name="JUp">Its self-referencing 1-ary variant <see cref="JUps{JOut}"/>.
  /// </typeparam>
  /// <typeparam name="JA">Its 1-st output <see cref="object"/>.</typeparam>
  /// <param name="it">A <typeparamref name="JUp"/>.</param>
  /// <param name="a">A <typeparamref name="JA"/>.</param>
  /// <param name="ex">A status <see cref="object"/>.</param>
  public static void Deconstruct<JUp,
   JA>(
   this JUp it,
   out JA? a, out object? ex)
   where JUp : JUp<
    JA>?
   where JA : class? => it.As(
    out a).So(ex = it?.XEx);
 }
}

After a whole bunch of As overloads, we want to take one step further by supporting implicit and explicit casts, whenever applicable. Please note that we can always implicitly convert an Exception to a tuple of any arity in case of a runtime error, up to its caller to decide whether to throw it. Exceptions not thrown will by default be embedded in an IEnumerable, just like cell errors in an Excel spreadsheet, in-place and traceable.

C#
namespace Pyramid.Kernel.Up
{
 partial struct Up<
  JA, A>
 {
  /// <summary>As a frame of 1-ary invariant <see cref="JUps{JOut}"/> to
  /// <see langword="return"/>.</summary>
  /// <param name="it">An <typeparamref name="A"/>.</param>
  public static implicit operator Up<JA, A>(A it) => new() { XA = it };
  /// <summary>As a frame of 1-ary invariant <see cref="JUps{JOut}"/> to
  /// <see langword="return"/>.</summary>
  /// <param name="it">An <see cref="Exception"/>.</param>
  public static implicit operator Up<JA, A>(Exception? it) => new() { XEx = it };
  /// <summary>As an <typeparamref name="A"/> to <see langword="return"/>.</summary>
  /// <param name="it">An <see cref="Up{JA, A}"/>.</param>
  public static explicit operator A(Up<JA, A> it) => it.As(out var o) ? o : throw it.XEx.Ex();
 }
}

Is it worth the effort? Very hard to tell. I'm doing this mainly because I need an infrastructure for a higher-level visual programming language on top of C#, much like C# on top of C++, C++ on top of C, C on top of assembly language and assembly on top of machine code, where we can always interoperate with lower-level languages within the same process without any overhead. If you just need handy immutable value tuples in C#, you might want to make your own version without status, covariance and type mapping, thus a much simpler implementation. In case you wonder what Ex() is, here is what it is:

C#
namespace Pyramid.Kernel.Up
{
 partial class It
 {
  /// <summary>Exceptionalize to <see langword="return"/> an <see cref="Exception"/>.</summary>
  /// <param name="it">An <see cref="object"/>.</param>
  public static Exception Ex(this object? it) =>
   it.Is(out var o) ? o.Is(out Exception ex) ? ex : new InvalidOperationException() :
    new InvalidCastException();
 }
}

Points of Interest

  1. If you are encapsulating unmanaged resources, never, ever, implement IDisposable or IAsyncDisposable directly, unlike those Microsoft examples in their corresponding interface help pages. Instead, please by all means follow the advice from this Microsoft article (Implement a Dispose method) by inheriting System.Runtime.InteropServices.SafeHandle or at least System.Runtime.ConstrainedExecution.CriticalFinalizerObject with a one-to-one correspondence. Whenever you observe Microsoft being self-contradictory, the timestamps on those web pages are your best friends, usually the newer, the better.
  2. If you by any chance must implement IAsyncDisposable, please dispose of all your managed resources immediately and synchronously anyway. Why wait for a background thread to do it for you asynchronously while your operating system desperately needs the borrowed resources back?
  3. The original Tuple overloads in .Net are reference types, highly inefficient from the perspective of C#, thereby the introduction of the ValueTuple overloads, which are unfortunately mutable value types, highly inefficient to copy. To embrace the best of both worlds, you may want to consider your own immutable value types for tuples, similar to what we have done in this article.

History

  • 5th August, 2021: Initial version

License

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

Share

About the Author

Code Fan
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

 
-- There are no messages in this forum --