.NET Core 3.0 and the accompanying latest version for Visual Studio 2019 arrived in September 2019, and with-it, C# 8.0 was released. Before I look at the details of the C# 8.0 improvements, consider what features you'd add if you were king or queen for a day. For many coders, the list is quite short. This provides an indication that, in general, most of us are quite satisfied with C# the way it is.

Never the less, C# 8.0 comes with a host of features (significantly more than C# 7.0) that once you start using them, you'll appreciate. In fact, I expect that in the future, features like nullable reference types will become so integrated that you'll likely forget programming in a version of C# without them. (Do you remember that C# didn't have generics in C# 1.0 for example?)

Nullable reference types is perhaps the most significant feature in C# 8.0. It introduces a configurable change whereby reference types are non-nullable by default but can explicitly allow null with a nullable modifier. Of course, the new feature list doesn't stop there. Other larger features include support for data and pattern matching improvements. The latter of these provides a rather terse syntax for switch expressions (yes, expressions) that enable conditional checks for evaluating the shape and data within an object in order to determine a result. In addition, there are several miscellaneous enhancements such as default interface implementations, using declarations, static local functions, indices/ranges, and a null-coalescing assignment operator. I'll begin with a look at nullable reference types.

Nullable Reference Types

If you're puzzled by the feature name “nullable reference types,” you're paying attention. Since C# 1.0, all reference types have been nullable out of the box, so no changes are necessary to support such a feature. However, the very fact that previously, reference types were nullable by default is the reason for a change. As you know, when you dereference a null value, the runtime throws a System.NullReferenceException and this is always a bug. It indicates that the developer failed to check for a null value before dereferencing. Unfortunately, the bug occurs relatively frequently and, in fact, writing the bug is easier than coding defensively. In other words, as developers, we fall into doing the wrong thing (invoking a reference type without checking for null) because the right thing isn't intuitive and requires extra code.

To fix this, C# 8.0 allows you to configure the compiler so that, by default, all reference types are non-nullable unless they're declared with a nullable modifier (e.g., ?), the same modifier used to declare a value type as nullable. For example, consider the code in Listing 1.

Listing 1: Nullable reference types example

#nullable enable
static string GetTempPath(string? directory = null)
{
    if (directory is null)  // Could use Null-coalescing assignment
    {
        directory = Path.GetTempPath();
    }

    string fullName;
    do
    {
        fullName = Path.Combine(directory, Path.GetRandomFileName());
    }
    while (!Directory.Exists(fullName) && !File.Exists(fullName));

    return fullName;
}

First, the null reference type feature is activated in this example with the #nullable directive. The directive supports values of enable, disable, and restore - the latter of which restores the nullable context to the project-wide setting. By default, a project file's project-wide setting is disabled. To enable it, add a Nullable project property whose value is “enable.”

...
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>netcoreapp3.0</TargetFramework>
  <Nullable>enable</Nullable>
</PropertyGroup>
...

Once nullability is enabled, the complier issues a warning when a non-nullable reference type is assigned null. For example, assigning null to directory with no nullable modifier in Listing 1 results in a warning: CS8625 - Cannot convert null literal to non-nullable reference type. To avoid the warning, enabling the nullability setting adds support for declaring a nullable reference type explicitly, as demonstrated in the string? directory parameter shown in the GetTempPath() method header.

Once a data type is identified as nullable, the compiler begins using static analysis to determine whether perhaps the code is dereferencing a potential null value. For example, try removing the block of code that checks whether directory is null. Without that code, the invocation of Path.Combine() causes the compiler to issue a new warning: CS8604 - Possible null reference argument for parameter ‘path1’ in 'string Path.Combine(string path1, string path2)'. Notice that the Path.Combine() takes two non-nullable strings. Therefore, passing the nullable directory parameter causes the complier to issue a warning, implying that a nullable reference type can't implicitly be assigned to a non-nullable reference type, roughly mimicking the behavior of assigning a nullable value type to a non-nullable value type). Unlike with value types, not only is the assignment only a warning rather than an error, static analysis is able to determine if, in fact, the code has checked for null and, therefore, no warning is necessary. In other words, with the “directory is null” check and the assignment of Path.GetTempPath() (which returns a non-nullable string) to directory, by the time the code invokes Path.Combine(), directory won't be null so there's no need to issue a warning.

Unfortunately, at this time, it isn't reasonable for static analysis to make a deep determination of whether null is sufficiently checked for or not. For example, consider the following function:

bool IsNotNull<T>(T thing) => !(thing is null); 

which determines whether a value is not null. If you replace the if block in Listing 1 with a call to IsNotNull(directory) instead, the compiler still issues a CS8604 warning in the first call to Path.Combine().

string fullName;
do
{
    if (IsNotNull(directory))
    {
        // Warning: CS8604 - Possible null reference argument for parameter       ...
        fullName = Path.Combine(directory, Path.GetRandomFileName());
    }
    else
        fullName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
}
while (!Directory.Exists(fullName) 
    && !File.Exists(fullName));
return fullName;

In other words, although static analysis can identify a null check that uses <value> is null, <value> == null, or even ReferenceEquals() with a null value, invoking a custom function to check for null or using non-compile time determinant conditions won't out-smart it.

Another place that nullability checks occur is in class declarations. Imagine, for example, an address class defined as follows:

class Address
{
    public string Street1 { get; set; }
    public string? Street2 { get; set; }
    public string City { get; set; }
    public string Zip { get; set; }
    public string State { get; set; }
    public string Country { get; set; }
}

Because Street2 is nullable, no initialization is required. All other properties are non-nullable and a warning will be issued for each: CS8618 - Non-nullable property ‘Street1’ is uninitialized. Consider declaring the property as nullable.

It's important to understand that there's no semantic change in the runtime execution of code when nullability is enabled. The compiler doesn't embed a null check into your code when using a non-nullable type. For example, if you ignore a non-nullable warning, disable nullability, or assign null to a non-nullable type from C# 7.0 (or earlier) code, the runtime won't report any error and executes successfully at runtime (albeit likely issuing a System.NullReferenceException). The burden on programmers to check for null on public APIs (all APIs if you ignore the nullability warnings) isn't diminished. Similarly, (and unlike nullable value types which compile to Nullable<T>) a nullable reference type compiles down to standard nullable reference type in intermediate language (IL). The Street2 property compiles to a simple string in IL, as does the directory parameter of GetTempPath():

.method private hidebysig static string
    GetTempPath(string directory) cil managed

Having some way to generate runtime null checks for APIs would be a great add to C# 9.0 IMO.

Another drawback to nullable reference types arises from the fact that support wasn't available in C# 1.0. Enabling nullability for code written prior to nullability is likely going to introduce a significant number of warnings. For example, all uninitialized reference properties (such as those in the Address type) will cause warnings when nullability is enabled. And, in fact, errors from earlier versions of the compiler could potentially lead you to write code that will generate a warning when nullability is enabled. For example, if you dereference a value in C# 7.0 before assigning it, the compiler issues an error**: CS0165 ? Use of unassigned local variable**, to which you'd likely respond with assigning default (or null if it is a reference type). C# 7.3 allows you to use default rather than default(T) if the data type can be inferred from the data type of the variable that's assigned.

Assigning null to a reference type in C# 7.0 results in assigning null to a non-nullable type in C# 8.0 when nullability is enabled. Ughh!! For this reason, the C# team has worked hard to provide a mechanism for fine-grained control on enabling nullable. In addition to the #nullable directive mentioned earlier and the Nullable project property values of “enable” and “disable”, there are two additional values. “warnings” cause the compiler to issue warnings whenever a nullable modifier is used on a reference type while still enabling static analysis warnings. Additionally, a value of “annotations” allows the use of the nullable modifier on reference types but disables static analysis warnings.

Remember that because nullability at the project level can be configured with the Nullable project property, you can also specify the Nullable project property value with an environment variable or on the command line when building. For example, setting a Nullable environment variable to “disable” disables the nullability support and adding a /p:Nullable=enable argument to the dotnet build command reenables it. (The order of priority from lowest to highest is CSPROJ assigned value, environment variable, and command line. In other words, a value specified on the command line overrides an environment variable and a CSPROJ file specified value.)

Asynchronous Streams

Although nullable reference types is certainly the most extensive and the most impactful (if you enable it) of the C# 8.0 features, there are several more features that stand out if you have the circumstances to need them. The next one to consider is asynchronous (async) streams. Collections in C# are all built on the IEnumerable<T> and IEnumerator<T>, the latter with a single GetEnumerator<T>() function that returns an IEnumerable<T> over which you can iterate with a foreach loop. Unfortunately, these interfaces don't provide a reasonable means of iterating over the collection asynchronously. Furthermore, you can't use yield return syntax from within an async method. (In C# 7.0, the async method must return a Task/ValueTask in order to support async and an IEnumerator in order to be compatible with yield return iterator syntax.)

To address these problems, the C# team added asynchronous streams (async stream) support in C# 8.0, which is specifically designed to enable asynchronous iteration and the building of asynchronous collections and enumerable type methods using yield return.

For example, imagine a method that encrypts all the files in the current directory. Given that IO and encryption operations are relatively time consuming, this is a great method to implement using async. To accomplish this, the following snippet leverages yield return syntax with an async method that has a couple of awaits within. Listing 2 provides an example of both producing and consuming an async stream.

Listing 2: Producing and consuming an async stream

static public async IAsyncEnumerable<string> EncryptFilesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    IEnumerable<string> files = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.*");

    foreach (string fileName in files)
    {
        string encryptedFileName = $"{fileName}.encrypt";

        using (FileStream outputFileStream = new FileStream(encryptedFileName, FileMode.Create))
        {
            string data = await File.ReadAllTextAsync(fileName);

            await Cryptographer.EncryptAsync(data, outputFileStream);
                    
            yield return encryptedFileName;

            cancellationToken.ThrowIfCancellationRequested();
        }
    }
}

static public async void Main()
{
    // Create a cancellation token source to cancel if the operation takes more than a minute.
    CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(1000*60);

        await foreach (string fileName in EncryptFilesAsync().WithCancellation(cancellationTokenSource.Token))
        {
            Console.WriteLine(fileName);
        }
}

Ignoring the cancellation token parameter for the moment, notice that the EncryptFilesAsync() method is async and returns an IAsyncEnumerable<T>. C# 8.0 adds two additional types to the allowable async method return types: IAsyncEnumerable<T> and IAsyncEnumerator<T>. In so doing, C# enables await calls within async methods. In Listing 2, you invoke both File.ReadAllTextAsync() and Cryptographer.EncryptAsync() with the await keyword. The result is that the EncryptFilesAsync() method iterates over all the files in the current directory, reads in the contents, and writes and encrypted version to a new file with the “.encrypt” extension before returning (yield return) the encrypted file name.

And, given a method that returns IAsyncEnumerable<T>, you can iterate over the result using an await foreach statement, as demonstrated in the Main() method. In this case, the Main() method writes out the encrypted file names to the console.

Figure 1 shows the asynchronous versions of the IEnumerable base interfaces. Note that both the IAsyncDisposable<T>.DisposeAsync() and IAsyncEnumerator<T>.MoveNextAsync() methods are asynchronous versions IEnumerators<T> equivalent methods. The Current property isn't asynchronous. Also, there's no Reset() method in the asynchronous implementations.

Figure 1: The Class Diagram for Async Stream Interfaces.
Figure 1: The Class Diagram for Async Stream Interfaces.

There isn't enough space to sufficiently cover IAsyncDisposable, except to point out that you can using it with either C# 8.0's new await using statement or their new await using declaration (see IntelliTect.com/AsyncUsing).

Notice that in Listing 2, the signature for GetAsyncEnumerator() includes a CancellationToken parameter. Because await foreach generates the code that calls GetAsyncEnumerator(), the way to inject a cancellation token and provide cancellation is via the WithCancellation() extension method (as Figure 1 shows, there's no WithCancellation() method on IAsyncEnumerable<T> directly). To support cancellation in an async stream method, add an optional CancellationToken with an EnumeratorCancellationAttribute as demonstrated by the EncryptFilesAsunc method declaration:

static public async IAsyncEnumerable<string> EncryptFilesAsync(string directoryPath = null, string searchPattern = "*", [EnumeratorCancellation] CancellationToken cancellationToken = default)
{ ... }

In Listing 2, you provide an async stream method that returns the IAsyncEnumerable<T> interface. Like the non-async iterators, however, you can also implement the IAsyncEnumerable<T> interface with its GetAsyncEnuerator() method, as shown in Listing 3. Of course, any class implementing the interface can then be iterated over with an await foreach statement, as shown in Listing 3.

Listing 3: Implementing IAsyncEnumerable

class AsyncEncryptionCollection : IAsyncEnumerable<string>
{

    public async IAsyncEnumerator<T> GetAsyncEnumerator(CancellationToken cancellationToken = default) 
    {
        ...
    }
}

static public async void Main()
{
    AsyncEncryptionCollection collection = new AsyncEncryptionCollection();
    ...


    await foreach (string fileName in collection)
    {
        Console.WriteLine(fileName);
    }
}

One point of caution: Remember that declaring an async method doesn't automatically cause the execution to run in parallel. Just because the EncryptFilesAsync() method is asynchronous doesn't mean that iterating over each file and invoking File.ReadAllTextAsync() and Cryptographer.EncryptAsync() will happen in parallel. For that, you need to leverage a Task invocation or something like a System.Threading.Tasks.Parallel.ForEach().

Declaring an async method doesn't automatically cause the execution to run in parallel.

Enhanced Pattern Matching

Another C# 8.0 feature that's quite powerful if you have the scenario to warrant is the enhanced pattern matching. Included in this feature is a new switch expression syntax demonstrated in Listing 4.

Listing 4: Switch expression

static public bool TryFormatDate(object input, string? format, out string? result)
{
    result = input switch
    {
        DateTime date => date.ToString(format),
        DateTimeOffset date => date.ToString(format), string date => DateTime.TryParse(date, out DateTime dateTime) ? dateTime.ToString(format) : null,
        _ => null
    };
    return !(result is null);
}

The expression syntax has several significant changes. First, unlike with the switch statement, the match expression appears before the switch keyword. Second, although there are still curly brackets following the keyword, the case label is removed and the colon is replaced with a lambda operator, following which you can provide the expression. Note that this switch expression relies on type pattern matching (introduced in C# 7.0) so other than the expression syntax, there are no other new features introduced in this code snippet. That said, there are four additional pattern matching type features that have been added: is not null, property patterns, tuple patterns, and positional patterns.

Pattern Matching Is Not Null

In C# 7.0 pattern matching with the is operator was introduced. With it, you could type check a value and declare a new variable to hold the value if the type checking returned true. For example, you could check whether the input argument from the TryFormatDate() function of Listing 4 is of type DateTime and assign it to a new DateTime variable called date if it is:

if (input is DateTime date) 
    result = date.ToString(format);

The return from the TryFormatDate() method is another example of C# 7.0 pattern matching with the is operator:

return !(result is null);

In C# 8.0, there's the semantic equivalent of the not-null syntax using two curly braces ({ }):

return result is { };

Leveraging this syntax, you can convert a nullable value into a non-nullable value by declaring the variable after the pattern-matching expression (see the conditional expression in the if statement):

string? text;
// ...
if(text is { } result)
{
    // Do something with result
    // which is not null
}

As you move forward with the C# 8.0 pattern-matching features using switch expressions, remember that all pattern-matching approaches can also be used with the is operator.

Property Pattern Matching

With property patterns, you can switch match expressions based on property values, as shown in the following code snippet:

static public bool IsWeekend(DateTime dateTime) => dateTime switch
{
    { DayOfWeek: DayOfWeek.Saturday } => true,
    { DayOfWeek: DayOfWeek.Sunday } => true,
    // Assume the weekend starts at 5 PM on Friday
    { DayOfWeek: DayOfWeek.Friday, Hour: int hour } when hour >= 5 => true,
    // Same as default for value types
    // { } => false,       
    // Not null
    _ => false
};

In this example, you determine whether the DateTime value is a weekend where weekend is Saturday, Sunday, or Friday after 5 p.m. (Note that the when clause syntax was introduced in C# 7.0.) Because DateTime is not nullable, you don't (can't) include a null case.

In place of the default keyword, the switch expression syntax uses an underscore (_). In general, it's possible to specify a not-null case using empty curly braces ({ }), however, because dateTime is a value type and can't be null, the not-null case and the default case are the same so you aren't allowed to have them both.

The property pattern syntax is especially apt for mapping multiple properties to a single value, such as whether the date is a weekend or weekday in this case.

Recursive Pattern Matching

The result of a pattern matching expression is an expression and can be used as the input on another expression (see Listing 5).

Listing 5: Recursive pattern matching

public static bool TryCompositeFormatDate(object input, string compositFormatString, out string? result)
{
    result = null;
    if(input switch
    {
        DateTime {Year: int Year, Month: int Month, Day: int Day } => (Year, Month, Day),
        DateTimeOffset  { Year: int Year, Month: int Month, Day: int Day }  => (Year, Month, Day),
        string dateText => DateTime.TryParse(dateText, out DateTime dateTime) ? (dateTime.Year, dateTime.Month, dateTime.Day) : default((int Year, int Month, int Day)?),
        _ => default((int Year, int Month, int Day)?)
    } is { } date)
    {
        result = string.Format(compositFormatString, date.Year, date.Month, date.Day);
        return true;
    }

    return false;
}

Admittedly, this example is non-trivial (don't do this at home - or in production code) so let's break it apart. The first case of the switch statement uses type pattern matching (C# 7.0) to check whether input is of type DateTime. If the result it true, it passes the result to the property pattern matching in order declare and assign the values year, month, and day and then uses those variables in a tuple expression that returns the tuple (year, month, day). The DateTimeOffset case works the same way. The case string isn't using recursive pattern matching and neither is the default (_). Note that in both these cases the return is of the following type:

default((int year, int month, int day)?)

This evaluates to null. Specifying the type is critical so that the expression following the case statement doesn't evaluate to true when the result is null.

input switch {
 ... 
} is { } date

To clarify, the default of ((int year, int month, int day) evaluates to (0, 0, 0); not null in other words, so the conditional incorrectly evaluates to true. To fix this, the code explicitly uses default((int year, int month, int day)?).

Tuple Pattern Matching

Another pattern matching approach is tuple pattern matching. In this case, specific tuple values are mapped to an expression. This type of pattern matching works great for state transition tables. Listing 6 provides an example.

Listing 6: Tupple pattern matching

public static string GetNextSate(int door, int motionSensor, string currentState) => (door, motionSensor, currentState) switch
{
    (0, 0, "armed") => "disarm",
    (0, 0, "disarmed") => "arm",
    (0, 1, "armed") => "disarm",
    (0, 1, "disarmed") => "disarm",
    (1, 0, "armed") => "arm",
    (1, 0, "disarmed") => "arm",
    (1, 1, "armed") => "arm",
    (1, 1, "disarmed") => "disarm",
    _ => throw new InvalidOperationException()
};

Note that for type pattern matching, the values in the tuple case are constants.

Positional Pattern Matching

C# 7.0 included a feature called the deconstructor. Given an instance method called Deconstruct() with only out parameters, it's possible to deconstruct an object into a tuple representing its constituent parts, seemingly casting the type into a tuple, as demonstrated by the second statement of the test in Listing 7.

Listing 7: Positional pattern matching using deconstructors

[TestClass]
public partial class AngleTests
{
    [TestMethod]
    public void Deconstruct_UsingAssignmentSyntax_Success()
    {
        Angle angle = new Angle(15, 15, 15);
        (int degrees, int minutes, int seconds) = angle;
        Assert.AreEqual<(int, int, int)>((15, 15, 15), (degrees, minutes, seconds));
    }
}

public struct Angle
{
    public Angle(int degrees, int minutes, int seconds)=> (Degrees, Minutes, Seconds) = (degrees, minutes, seconds);

    // Using C# 6.0 read-only, automatically implemented properties
    public int Degrees { get; }
    public int Minutes { get; }
    public int Seconds { get; }

    public void Deconstruct(out int degrees, out int minutes, out int seconds) => (degrees, minutes, seconds) = (Degrees, Minutes, Seconds);

    public static double ConvertToDecimalDegrees(Angle angle) => angle is (int degrees, int minutes, int seconds) ? degrees * 60 + minutes / 60 + seconds / 60 * 60 : throw new InvalidOperationException();
} 

Leveraging the deconstructor, you can use pattern matching to convert a match expression (angle) in the ConvertToDecimalDegrees() function, to a tuple, (degrees, minutes, seconds), which you can then use in an expression. The ConvertToDecimalDegrees provides a full positional pattern-matching example with an is operator rather than a switch expression or statement. The reason it's called a positional pattern match is because the variable declared in the tuple match the position and types of the parameters identified in the Deconstruct() method. The tuple can also be declared using the var syntax, in which case, positional matching is based solely on order and arity of the tuple. For example:

angle is var (degrees, minutes, seconds)

Avoid Pattern Matching Given Polymorphism

Before closing off the topic of pattern matching, here's one important guideline: Avoid pattern matching when polymorphism is possible instead. In other words, if you can get custom behavior based on methods in a shared base class or interface implementation, that approach is preferable. Pattern matching should be reserved for situations where polymorphism isn't possible. In the switch expression in Listing 4, for example, even though DateTime and DateTimOffset both have Date properties, there's no base class or interface with the Date member. The same is true for string. For this reason, polymorphism isn't possible, so pattern matching is applicable.

Summary

Wow. That's a lot of stuff in a single release. And, although the syntax and usage may seem obvious when you read about it, there are a surprising number of subtleties that you'll encounter. My experience is that this was especially true for pattern matching. Fortunately, the compiler points out most of them.

With pattern matching, I still think scenarios are less common than you might expect, but in my experience, they're occurring more and more. If nothing else, pattern matching can provide a great way to run some initial validation on method parameters (checking for null, for example) and other important scenarios when you remember that nullable reference types don't make semantic code changes, so you still need to be programming defensively and checking for null.

Async stream is important for server-side programming, where previously you couldn't easily leverage iterators (yield return) on async methods and relying on the synchronous method versions was fatal when it became time to scale. In addition, it's incredibly helpful for UI programming in order to avoid locking up the UI thread and causing the application to be unresponsive.

For any new projects, there's no doubt, you should enable nullable reference types. And, as quickly as possible, you should migrate existing code to enable nullability as well. Follow the principle of checking your code in “better than you found it” and then enable nullability on each file you modify until, over time, you suddenly discover that you're almost done. This allows you to take lots of smaller bites rather than becoming overwhelmed at what might be an elephant-sized number of warnings.

Unfortunately, there were several features I wasn't able to cover in detail. For a full list of C# 8.0 features, I've posted a C# 8.0 summary table at IntelliTect.com/CSharp8Summary. In addition, you can download the source code from intellitect.com/EC#8.0-2019.11.

On a final note, much of this material comes from my blog and is also included in the next edition of my book, Essential C# 8.0 (intellitect.com/EssentialCSharp), which should be out before the end of 2019.