C# 9 - Making your code simpler

Posted by: Damir Arh , on 4/10/2021, in Category C#
Views: 575089
Abstract: The tutorial introduces a subset of C# 9 features which can make your code shorter and simpler. It concludes with a brief look at what future versions of the language might bring.

Many new features in the last few versions of C# had made it possible to write shorter and simpler code. C# 9 is no exception to that. Records, new types of patterns, and some other features can make your code much more expressive. In certain cases, you can avoid writing a lot of boilerplate code that was previously needed.

C# 9.0

Init-only properties

The best way to create immutable types before C# 9 was to create a class with read-only properties that were initialized in the constructor:

public class Person
{
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    public string FirstName { get; }
    public string LastName { get; }
}

Such a class would not allow changing its properties after it was created:

var person = new Person("John", "Doe");
person.FirstName = "Jane"; // does not compile

In a class with many properties, it can be inconvenient to initialize them all with a constructor because that constructor will have just as many parameters – one for each property. Unless you explicitly name the parameters when calling the constructor, it can become difficult to know which parameter initializes which property. For properties with reasonable default values, the object initializer syntax is commonly used instead of optional parameters to reduce the total number of parameters in the constructor:

var person = new Person("John", "Doe")
{
    MiddleName = "Patrick"
};

Unfortunately, this does not work with a read-only property. To assign a value to a property in the object initializer, that property must have a setter, or the code above will not compile:

public string? MiddleName { get; set; }

However, such a property can be modified after the object has been created. Hence, the only way to create an immutable type before C# 9 was to make all its properties read-only (i.e., without a setter) and initialize them in the constructor.

C# 9 changes that by introducing init-only properties. Their setter is declared with the init keyword instead of the set keyword:

public string? MiddleName { get; init; }

Init-only properties can be initialized in the object initializer but cannot be modified after that:

var person = new Person("John", "Doe")
{
    MiddleName = "Patrick"
};

person.MiddleName = "William"; // does not compile

This allows you to create immutable types with properties that do not necessarily need to be initialized in the constructor because they have valid default values.

Records

C# 9 adds a new keyword for declaring types: record. These types are still classes (i.e., reference types) but they have some additional features and differences in functionality.

Optional simplified syntax for declaring a constructor is one such feature:

public record Person(string FirstName, string LastName);

Records still support regular property and constructor syntax just like classes. The following more verbose code can be used create an equivalent record to the one above:

public record Person
{
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }

    public string FirstName { get; init; }
    public string LastName { get; init; }

    public void Deconstruct(out string firstName, out string lastName)
    {
        firstName = FirstName;
        lastName = LastName;
    }
}

This means that when the shorter syntax is used, the compiler automatically creates a constructor with parameters listed in the parenthesis of the record declaration, as well as matching init-only properties. That is the reason why with this shorter syntax, instead of camel case, Pascal case is typically used for parameter names.

Additionally, the compiler also creates a Deconstruct method which can be used to deconstruct the record into a matching tuple:

var person = new Person("John", "Doe");
(var firstName, var lastName) = person;

Both the constructor and the Deconstruct method have well-defined order of their parameters. That is why the records using the shorter syntax that automatically generates the two, are also called positional records.

Positional records can of course still have additional properties that are not initialized with the constructor:

public record Person(string FirstName, string LastName)
{
    public string? MiddleName { get; init; }
}

These additional properties will not be included in the generated Deconstruct method.

To create a similar immutable type before C# 9, you would have to write a lot more code yourself:

  • 3 read-only properties,
  • a constructor for initializing them,
  • a Deconstruct method.

With expressions

Since immutable types do not allow any modifications, creating new instances of them becomes a much more common operation, especially new instances initialized with data from an existing instance and with only some of the properties modified:

var person = new Person("John", "Doe")
{
    MiddleName = "Patrick"
};

var modifiedPerson = new Person(person.FirstName, person.LastName)
{
    MiddleName = "William"
};

The syntax above is quite verbose and error prone, but it can be somewhat simplified if we add the following helper method to our immutable class:

public Person With(
    string? firstName = null,
    string? lastName = null,
    string? middleName = null)
{
    return new Person(
        firstName != null ? firstName : this.FirstName,
        lastName != null ? lastName : this.LastName)
    {
        MiddleName = middleName != null ? middleName : this.MiddleName
    };
}

When invoking it, you could only specify the properties that you want to change:

var modifiedPerson = person.With(middleName: "Patrick");

With records in C# 9, you can now achieve that without writing a helper method for each immutable class and with an even nicer syntax by taking advantage of the new with expression:

var modifiedPerson = person with
{
    MiddleName = "Patrick"
};

The with expression is automatically available for any record and can be used to create a copy of it with any number of its init-only properties modified. In C# 9, with expressions only support record types.

Value equality

There is one final difference between classes and records in C# 9.

Classes implement reference equality which distinguishes between two different instances even if all the properties have the same values:

var person1 = new Person("John", "Doe");
var person2 = new Person("John", "Doe");
var areEqual = person1.Equals(person2); // = false

In contrast to that, records implement value equality which means that two instances are treated as equal if all of their properties are equal (that’s how structs behave):

var person1 = new Person("John", "Doe");
var person2 = new Person("John", "Doe");
var areEqual = person1.Equals(person2); // = true

To make a class behave like that, you would have to override its Equals method:

public override bool Equals(object? obj)
{
    if (!(obj is Person other))
    {
        return false;
    }

    return this.FirstName == other.FirstName
        && this.LastName == other.LastName
        && this.MiddleName == other.MiddleName;
}

When you override the Equals method, you must also override the GetHashCode method or the collection classes that depend on them will start to behave incorrectly:

public override int GetHashCode()
{
    return this.FirstName.GetHashCode()
        ^ this.LastName.GetHashCode()
        ^ (this.MiddleName?.GetHashCode() ?? 0);
}

Again, that is a lot of code that you do not have to write and maintain for records if you need value equality for your reference types.

There is a caveat, though. Value equality only works well for reference types if they are immutable. The problem is that changing a value of a property will change the result of the GetHashCode method for that instance. This will cause problems if that instance is in a collection similar to what would happen if the GetHashCode method isn’t overridden to match the Equals method implementation:

var person = new Person("John", "Doe");

var set = new HashSet<Person>();
set.Add(person);
var setContainsBefore = set.Contains(person); // = true

person.FirstName = "Patrick";
var setContainsAfter = set.Contains(person); // = false

Although the modified instance is obviously still in the set, the set’s Contains method cannot find it because the GetHashCode method of the instance returns a different value than it did when the instance was put into the set before the change.

To avoid problems like this, you should use positional records and only add read-only or init-only properties to them so that they are immutable.

Pattern matching

Pattern matching was first added to C# in version 7. Since then, the feature has been improved with every version to make the code more expressive. In C# 9, two new patterns have been introduced.

The relational pattern allows you to use relational operators as part of the pattern. In C# 8 you could only do that by adding a when clause to a case statement:

var unit = duration.TotalMinutes switch
{
    double d when d < 1 => DurationUnit.Seconds,
    double d when d < 60 => DurationUnit.Minutes,
    double d when d < 24 * 60 => DurationUnit.Hours,
    double d when d >= 24 * 60 => DurationUnit.Days,
    _ => DurationUnit.Unknown
};

In C# 9, this code can be further simplified by omitting the when clause and putting the comparison in the pattern itself:

var unit = duration.TotalMinutes switch
{
    < 1 => DurationUnit.Seconds,
    < 60 => DurationUnit.Minutes,
    < 24 * 60 => DurationUnit.Hours,
    >= 24 * 60 => DurationUnit.Days,
    _ => DurationUnit.Unknown
};

Logical patterns add support for using logical operators in the pattern. This allows you to combine multiple conditions in a single pattern. The feature is particularly useful in a switch expression. In switch statements, case statement fall-through is an alternative to the or operator:

var weaponType = WeaponType.Unknown;
switch (weapon)
{
    case Bow _:
    case Crossbow _:
        weaponType = WeaponType.Ranged;
        break;
    case Sword _:
        weaponType = WeaponType.Melee;
        break;
}

Since switch expressions do not have an equivalent for the statement fall-through syntax, it was necessary to repeat the expression body in such a scenario:

var weaponType = weapon switch
{
    Bow _ => WeaponType.Ranged,
    Crossbow _ => WeaponType.Ranged,
    Sword _ => WeaponType.Melee,
    _ => WeaponType.Unknown
};

With the introduction of logical patterns in C# 9, the first two cases in the code block above can be combined into one:

var weaponType = weapon switch
{
    Bow or Crossbow => WeaponType.Ranged,
    Sword => WeaponType.Melee,
    _ => WeaponType.Unknown
};

You can also notice that there is no discard (_) in the patterns anymore. That is another improvement to pattern matching in C# 9. In type patterns, the discard can be omitted when the case body does not reference the typed value.

 

Target-typed expressions

Two types of target-typed expressions were added to C# 9.

Target-typed new expressions are applicable to more use cases. They allow you to omit the type specification from the constructor call when the type being constructed can be implied from the context:

Person person = new("John", "Doe");

In the case above, this new feature does not bring much benefit since there was no need to repeat the type definition even before if you used the var keyword to implicitly type the variable instead:

var person = new Person("John", "Doe");

The two variants are of very similar length and it is only a matter of taste which one you prefer. We are certainly more used to the second one because it was already available before C# 9.

There are other contexts in which the target-typed new expression makes much more sense. In my opinion, the new syntax brings the most benefits in collection initializers:

var persons = new List<Person>() { new("John", "Doe"), new("Jane", "Doe") };

Before C# 9, you had to repeat the type definition for every item in the list. Now, it only needs to be specified once to declare the collection type. It can be omitted for all items if their type matches the collection’s element type.

The second type of target-typed expressions is the target-typed conditional expression. Its main benefit is that certain conditional expressions which required a cast before C# 9, now simply work without it:

// compiles in C# 9 only, doesn’t work in earlier versions
int? length = string.IsNullOrEmpty(input) ? null : input.Length;

Before C# 9, it was necessary to cast the null value to make such an expression valid:

// already works before C# 9
int? length = string.IsNullOrEmpty(input) ? (int?)null : input.Length;

Of course, even in C# 9, the cast is still necessary if you implicitly type the local variable using the var keyword because the type cannot be determined from the target variable:

// already works before C# 9
var length = string.IsNullOrEmpty(input) ? (int?)null : input.Length;

It is a minor change, but it can still make the code slightly more readable in certain cases.

Top-level programs

The final C# 9 feature that I am going to cover in this article is support for top-level programs.

Before C# 9, any C# program required a static Main method as its entry point:

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Hello World!");
    }
}

With C# 9, that is not required anymore. Any C# program can have a single file with code placed outside any class method:

System.Console.WriteLine("Hello World!");

That file will serve as the program entry point. If there is more than one such file in the program, the build will fail because the compiler cannot determine which file should act as the entry point.

This feature is particularly beneficial to beginners who do not need to learn about the Main method to write their first C# program.

However, the top-level program syntax can also be used in more complex applications. The code in the file can be asynchronous, i.e., it can use the await keyword. Also, the args variable containing the command line arguments is implicitly available in the code. Hence the following lines of code are a working program that writes the contents of a file to standard output:

using static System.IO.File;
using static System.Console;

var text = await ReadAllTextAsync(args[0]);
WriteLine(text);

I added the using static directives at the top of the file so that I do not have to specify the class name when calling the static methods.

A look into the future

Although it has not been long since the release of C# 9, the language design team is already thinking about features for future versions of C#: 10 and beyond. Below is a selection of what has already been mentioned. None of these features have yet been confirmed for C# 10 or any other future version of the language. It is just a look into what the team is thinking about. To learn more, you can check the C# Language Design GitHub repository. There is also a video available in which, towards the end, Mads Torgersen talks about these features and more.

Records-related features

Several other features related to newly introduced records might be added in the future:

  • new features being added to records,
  • and features currently limited to records being supported by other types.
Shorthand for init-only properties

As a shorthand for init-only properties a new data keyword might be introduced:

public data string? MiddleName;

This would be equivalent to the currently supported longer syntax:

public string? MiddleName { get; init; }
Short constructor syntax not limited to record types

The short constructor syntax without the body that is currently only supported for positional records might become more generally available for other types such as classes and structs, for example:

public class Person(string firstName, string lastName)
{
    public string FirstName { get; } = firstName;
    public string LastName { get; } = lastName;
}

For even more flexibility, this syntax might not automatically generate and initialize init-only properties for you. Instead, the constructor parameters could be used for initializing properties you declare yourself.

Factory methods

The with expression is currently only supported for records. You cannot add any code to classes yourself that would allow you to use the same syntax. The introduction of special factory methods would change that:

public class Person
{
    // ...

    [Factory]
    public Person Copy()
    {
        return new Person(FirstName, LastName)
        {
            MiddleName = MiddleName
        };
    }
}

A factory method would be an instance method that returns a new instance of the same type. In the code snippet above, it is annotated with a Factory attribute. A new language keyword might be used instead.

You can already write such a method. But with the compiler recognizing it as such, it would allow you to use the initializer syntax to modify the created instance, including any init-only properties:

var person = new Person("John", "Doe");
var modifiedPerson = person.Copy()
{
    FirstName = "Patrick"
};

While the syntax is different from with expressions, it provides you the same feature set.

Final initializers

With the introduction of init-only properties which can only be initialized during object construction, there is still one feature missing. If you fully initialize the object in the constructor, you can add validation code at the end of it, to make sure that the values of all properties are valid once the initialization is complete.

The final initializers would allow you to do that even when using object initializers. The code inside them would run after the object initializer has already been run in full:

public record Person(string FirstName, string LastName)
{
    public string? MiddleName { get; init; }

    init
    {
        if (MiddleName != null && MiddleName.Length < 2)
        {
            throw new ArgumentException("Middle name not long enough.");
        }
    }
}
Record structs

Currently, records are always classes, i.e., reference types. This might also change in the future with the introduction of record structs which would be value types like regular structs.

Static members in interfaces

Interfaces might be extended with support for static members, including operators. One of the use cases that this would enable are generic numeric algorithms.

Implementation details would be based on algebraic structures. To give you an example how that would work, here is an interface describing one of the simplest algebraic structures, a monoid:

interface IMonoid<T>
{
    static T Zero { get; }
    static T operator +(T x, T y)
}

The structure consists of an associative operation (meaning that the order of performing the operation when there is more than two operands does not affect the result) and an identity element for this operation (meaning that it does not change the second operand when used with the said operation). As an example, addition is an associative operation for integer numbers and 0 is an identity element for addition because adding 0 to any other element does not change its value.

This means that the built-in int datatype could implement this interface:

struct Int32 : IMonoid<Int32> // ...
{
    // ...
}

All of this would allow implementation of generic methods for numeric operations:

public static T AddAll<T>(T[] operands) where T: IMonoid<T>
{
    T result = T.Zero;
    foreach (T operand in operands)
    {
        result += operand;
    }
    return result;
}

But there is no need to worry if algebraic structures are not your strong point. The base class library would be extended with all the common structures and operations, implemented by built-in data types and ready for use.

With built-in numeric datatypes implementing the IMonoid<T> interface, you could write a method like AddAll to be used with all of them, including decimal and even Complex. Today, you need to write overloads of such methods for every data type you want to support.

As a bonus, you could create a custom data type that implements the same interface, e.g. the IMonoid<T> algebraic structure, and any methods written for IMonoid<T> would automatically work with your data type as well.

Discriminated unions

Pattern matching and the switch expression in combination with the ever-increasing set of supported pattern types allow writing code that is in many ways similar to what is possible in functional languages such as F#. However, there is still an important feature missing which in many cases prevents you from writing an exhaustive set of cases without including a “catch-all” option.

Imagine having multiple types implementing an IShape interface:

public class Circle : IShape
{
    public double Radius { get; set; }
}

public class Rectangle : IShape
{
    public double Width { get; set; }
    public double Height { get; set; }
}

public class Triangle : IShape
{
    public double A { get; set; }
    public double B { get; set; }
    public double C { get; set; }
}

You can now write a single switch expression to calculate the perimeter of these shapes:

var perimeter = shape switch
{
    Circle circle => 2 * Math.PI * circle.Radius,
    Rectangle rectangle => 2 * (rectangle.Width + rectangle.Height),
    Triangle triangle => triangle.A + triangle.B + triangle.C,
    _ => throw new NotImplementedException(),
};

Although, you know that there are only three different types of shapes in your code, the compiler does not have this information and gives you a warning that your switch expression is not exhaustive unless you add the final case that catches any types you have not explicitly handled before.

F# solves this problem with discriminated unions which allow you to define a finite set of shapes unlike the inheritance approach in C#:

type Shape =
    | Circle of radius : double
    | Rectangle of width : double * height : double
    | Triangle of a : double * b : double * c : double

Based on this information, the compiler can reliably determine whether a match expression (an F# equivalent to the C# switch expression) is exhaustive.

In a future version of C#, there might be an equivalent to a discriminated union from F#. The language design team would like to add this feature in a way that is idiomatic to C# and closer to the already existing concept of inheritance.

Conclusion

The recently released C# 9 brought several new features which can make your code shorter and simpler in certain scenarios. The most prominent new feature is the new record type, along with its supporting features: init-only properties and with expressions. Other features that can contribute to simpler code are new pattern types, target-typed expressions, and top-level programs.

As always, the language is continuously evolving, and the language design team is already thinking about future features. In this article, I covered potential improvements to records, support for static members in interfaces, and discriminated unions.

This article was technically reviewed by Yacoub Massad.

This article has been editorially reviewed by Suprotim Agarwal.

Absolutely Awesome Book on C# and .NET

C# and .NET have been around for a very long time, but their constant growth means there’s always more to learn.

We at DotNetCurry are very excited to announce The Absolutely Awesome Book on C# and .NET. This is a 500 pages concise technical eBook available in PDF, ePub (iPad), and Mobi (Kindle).

Organized around concepts, this Book aims to provide a concise, yet solid foundation in C# and .NET, covering C# 6.0, C# 7.0 and .NET Core, with chapters on the latest .NET Core 3.0, .NET Standard and C# 8.0 (final release) too. Use these concepts to deepen your existing knowledge of C# and .NET, to have a solid grasp of the latest in C# and .NET OR to crack your next .NET Interview.

Click here to Explore the Table of Contents or Download Sample Chapters!

What Others Are Reading!
Was this article worth reading? Share it with fellow developers too. Thanks!
Share on LinkedIn
Share on Google+

Author
Damir Arh has many years of experience with software development and maintenance; from complex enterprise software projects to modern consumer-oriented mobile applications. Although he has worked with a wide spectrum of different languages, his favorite language remains C#. In his drive towards better development processes, he is a proponent of Test-driven development, Continuous Integration, and Continuous Deployment. He shares his knowledge by speaking at local user groups and conferences, blogging, and writing articles. He is an awarded Microsoft MVP for .NET since 2012.


Page copy protected against web site content infringement 	by Copyscape




Feedback - Leave us some adulation, criticism and everything in between!