Unmasking Latest .NET C# Features

Matteo Tosato
16 min readJan 17, 2024

--

In the ever-evolving landscape of programming languages, C# stands as a beacon of innovation and adaptability. With each new version, the language undergoes a metamorphosis, introducing features that not only streamline development but also empower developers to craft elegant and efficient solutions.

For nearly two decades now, C# has been introducing new functional programming concepts to its arsenal with each release.

We’ll cover some of these relevant features of the latest years:

  • Immutable and Frozen Collections
  • Properties Initializer
  • Record Type
  • Primary Constructors
  • Pattern Matching

Immutable Collections

Let’s start talk from Immutable Collections. These classes are available as part of the core .NET libraries, however they’re not part of the core class library distributed with .NET Framework. For .NET Framework 4.6.2 and later apps, the classes are available through NuGet packages.

Probably, if you have tested them superficially, you have noticed that the following operation is still possible, for example, on a ‘List’:

var list = ImmutableList<int>.Empty;

var newList = list.Add(1);

Then, what exactly means “Immutable” here ?
If you looking carefully the Add method signature you should notes his return type: is an immutable list itself:

public ImmutableList<T> Add(T value)

Immutable objects are designed to returning a new version of itself, a completely new instance. That’s because they’re called “immutable”. The original list will not be touched. This behaviour follows functional programming practices and they come to solve quite crucial scenarios.

Immutable collections are also designed to:

  • Reuse as much memory as possible, to avoid copying.
  • Reduce pressure on the garbage collector.

This appears clearer when looking at their internal implementation.

Most are imple­mented using AVL trees (balanced binary tree).

Balanced binary tree is the perfect choice due to his properties, when a node is added or removed, a new version of the tree is created with minimal copying, as most of the existing structure is reused, reducing the memory overhead associated with creating new versions.

When you are working with data snapshots in a multithreaded application or in a producer-consumer scenario, immutable collections come into play because every work chunk is a virtually isolated copy of a tree that shares its internal structure:

ImmutableStack<Int32> s1 = ImmutableStack<Int32>.Empty;
ImmutableStack<Int32> s2 = s1.Push(1);
ImmutableStack<Int32> s3 = s2.Push(2);
ImmutableStack<Int32> s4 = s3.Push(3);
ImmutableStack<Int32> s5 = s4.Push(4);
ImmutableStack<Int32> s6 = s4.Pop();
ImmutableStack<Int32> s7 = s6.Pop();
Immutable collection pointers

What scenarios can they help me resolve? One among them: Multithreading.

In a multi-threaded access to a shared resource, immutable objects are suitable to build the model state:

Shared state for two threads

Usually, with mutable classes, various mechanism can help when one ore more threads compete for writing. Lock-like approaches are a common choice.

Instead, Immutability allows us to build a lock-free state model. This does not always happen, there are many cases where each thread needs to know as soon as possible if a resource is modified. In these cases, a concurrent mutable collection should be the correct solution. Supposing you are in the first case, how to implement a lock-free model using immutable objects ? Immagine a thread-safe stack using no lock on write:

class LockFreeStack<T>
{
ImmutableStack<T> immutable = ImmutableStack<T>.Empty;

void Push(T item)
{
do
{
var old = immutable;
var n = old.Push(item); //new stack created!
} while (Interlocked.CompareExchange(ref s, n, old) != old);
}
}

Please note that this example is simplified for educational purposes and may not cover all edge cases or guarantee complete thread safety.

In this case the method class uses the Compare-And-Swap (CAS) operation provided by Interlocked.CompareExchange to make the push atomic and thread-safe. The Push method attempts to update the head of the stack by creating a new node and using CAS to replace the current head if nobody else has updated it.

If you look at the ImmutableInterlocked.Push method of .NET, you can see the same optimistic strategy working with the immutable stack collection:

public static void Push<T>(ref ImmutableStack<T> location, T value)
{
ImmutableStack<T> comparand = Volatile.Read<ImmutableStack<T>>(ref location);
bool flag;
do
{
Requires.NotNull<ImmutableStack<T>>(comparand, nameof (location));
ImmutableStack<T> immutableStack1 = comparand.Push(value);
ImmutableStack<T> immutableStack2 = Interlocked.CompareExchange<ImmutableStack<T>>(ref location, immutableStack1, comparand);
flag = comparand == immutableStack2;
comparand = immutableStack2;
}
while (!flag);
}

Similarly, the same applies for transactional memory, when memory collections represent database objects like rows in a table:

Memory Transaction

Transaction 1 read object A and write B. Transaction 2 read B and write C. When transaction mechanism check the B object, consistency checks fails and transaction 2 will abort. Immutable objects, by their nature, contribute to making these transactions more straightforward and less error-prone (if a transaction has to be rolled back, state of collection can be retained more easily).

The fundamental benefit of immutability is that it makes it significantly easier to reason about how the code works, enabling you to quickly write correct code.

What’s the downside of the Immutable Collections discussed above? Performance in some use cases.

I don’t think the performance can be matched with concurrent collection, where the goals are totally different. Navigating and manipulating the tree of immutable objects is always costly.

Frozen Collections

Performance on Read introduces us to the second collection type: the Frozen Collection. These structures are completely different from the previous ones, but by the end of this section, it will be interesting to compare them. They are relatively new, were introduced in .NET 7.

The word “frozen” here takes on completely different connotations than the “immutable” seen previously. Frozen collection are designed to never actually be changed:

var frozenSet = names.ToFrozenSet();

frozenSet.Add("new string") // Compiler error

FrozenSet<> don’t provide any methods to change the set content.

These collections use an optimized hash table to store data for fast read, to the detriment of construction phase. The idea is to use a frozen collection when you don’t need to change its content any more in his lifetime. Of course, you have to keep in mind this performance degradation in the construction phase.

Benchmarks are very useful now to better understand the performance point of view of the standard, immutable and frozen collections in different operations and use cases. These results help us choose the right collection for the right task. Take a look for the set structure.

[Benchmark]
public HashSet<string> HashSetConstruction() => new HashSet<string>(_names);

[Benchmark]
public ImmutableHashSet<string> ImmutableSetConstruction() => _names.ToImmutableHashSet();

[Benchmark]
public FrozenSet<string> FrozenSetConstruction() => _names.ToFrozenSet();
| Method                   | Mean      | Error    | StdDev   | Allocated |
|------------------------- |----------:|---------:|---------:|----------:|
| HashSetConstruction | 16.91 us | 0.082 us | 0.068 us | 21.7 KB |
| ImmutableSetConstruction | 150.88 us | 0.476 us | 0.372 us | 56.09 KB |
| FrozenSetConstruction | 140.50 us | 0.379 us | 0.354 us | 102.8 KB |

Also note the higher memory consumption by the latter two. In particular, the structures necessary for fast readings are those that take up the most space.

[Benchmark(Baseline = true)]
public void HashSetRead()
{
foreach (var name in _names)
_ = _stringHashSet.Contains(name);
}

[Benchmark]
public void ImmutableHashSetRead()
{
foreach (var name in _names)
_ = _stringImmutableHashSet.Contains(name);
}

[Benchmark]
public void FrozenSetRead()
{
foreach (var name in _names)
_ = _stringFrozenSet.Contains(name);
}
| Method               | Mean      | Error     | StdDev    |
|--------------------- |----------:|----------:|----------:|
| HashSetRead | 11.766 us | 0.0883 us | 0.0826 us |
| ImmutableHashSetRead | 35.478 us | 0.7047 us | 1.9173 us |
| FrozenSetRead | 4.628 us | 0.0121 us | 0.0113 us |

Frozen collections are the fastest on read, while regular hash sets are useful when I need to be able to write and read; immutable ones address particular scenarios like concurrent access or when I need to adopt a more functional programming style.

[Benchmark(Baseline = true)]
public void HashSetWrite()
{
foreach (var name in _names)
_ = _stringHashSet.Add(name);
}

[Benchmark]
public void ImmutableHashSetWrite()
{
foreach (var name in _names)
_ = _stringImmutableHashSet.Add(name);
}
| Method                | Mean     | Error    | StdDev   |
|---------------------- |---------:|---------:|---------:|
| HashSetWrite | 10.89 us | 0.095 us | 0.089 us |
| ImmutableHashSetWrite | 26.88 us | 0.117 us | 0.103 us |

Properties Initializer

The ‘required’ keyword, added in C# 11, is completing the cycle started in C# 9, when init-only setters were introduced. Before these, programmer use to decorate their class members with get only keyword to force field initialization in the constructor. You could have an immutable class with is mutable during construction:

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

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

In C# 9, init keyword was introduce to initialize the properties.

public string FirstName { get; init; }

The object would be mutable during the instantiation phase. Anyway, init only is dangerous when the object is created with the initializer. I could remove the constructor that was requesting the fields. No warnings are present from the caller, but he can forget to initialize the object completely despite the init is present:

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

// Missing inizialization !!
var person = new Person()
{
FirstName = "Matteo";
};

To have a complete feature, we had to wait C# 11 with the new required keyword. Only with required and init, a compile-time error warns us in the previous code.

Problem seems solved, but if I put back the constructor, an error appears again about missing required property initialization.

class Person
{
public required string FirstName { get; init; }
public required string LastName { get; init; }

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

var person = new Person("Matteo", "Tosato"); // Compiler error:
// Required member 'Person.FirstName' must be set in the object initializer
// Required member 'Person.LastName' must be set in the object initialize

It is because the compiler is not checking whether the constructor is setting the required properties or not. It expect you to add the SetsRequiredMembers attribute to every constructor you add:

class Person
{
public required string FirstName { get; init; }
public required string LastName { get; init; }

public Person() { }

[SetsRequiredMembers]
public Person(string firstName, string lastName) =>
(FirstName, LastName) = (firstName, lastName);
}

This should be the correct version.

C# seems to emphasize the usage of immutable objects. This is a drift towards functional programming that embraces this and other aspects of the new versions of the framework.

Record Type

record modifier has been introduced in C# 10 to provide new functionality for encapsulating data. A record or a record class declares a reference type (The class keyword is optional). A record struct declares a value type.

Records are quite big change in my opinion. They are mutable, but they’re primarily intended for supporting immutable data models; essentially, they add a built-in behaviour useful for designing data-centric objects.

Using the decompiler is an excellent way to realize how many features are added under the hood by the compiler (actually, decompiling in low-level C# is enough). With the following single line of code defining Account record:

public record Account(string Name, DateTime Birth);

The compiler creates:

  • Backing fields for each positional parameter provided in the declaration with some minor differences between record class and record struct.
  • A primary constructor whose parameters match the positional parameters on the record declaration.
  • For record struct types, a parameterless constructor that sets each field to its default value.
  • A deconstruct method with an out parameter for each positional parameter provided in the record declaration.
[NullableContext(1)]
[Nullable(0)]
public class Account :
/*[Nullable(0)]*/
IEquatable<Account>
{
[CompilerGenerated]
private readonly string <Name>k__BackingField;
[CompilerGenerated]
private readonly DateTime <Birth>k__BackingField;

public Account(string Name, DateTime Birth)
{
this.<Name>k__BackingField = Name;
this.<Birth>k__BackingField = Birth;
base..ctor();
}

[CompilerGenerated]
protected virtual Type EqualityContract
{
[CompilerGenerated] get
{
return typeof (Account);
}
}

public string Name
{
[CompilerGenerated] get
{
return this.<Name>k__BackingField;
}
[CompilerGenerated] init
{
this.<Name>k__BackingField = value;
}
}

public DateTime Birth
{
[CompilerGenerated] get
{
return this.<Birth>k__BackingField;
}
[CompilerGenerated] init
{
this.<Birth>k__BackingField = value;
}
}

[CompilerGenerated]
public override string ToString()
...

[NullableContext(2)]
[CompilerGenerated]
[SpecialName]
public static bool op_Inequality(Account left, Account right)
...

[NullableContext(2)]
[CompilerGenerated]
[SpecialName]
public static bool op_Equality(Account left, Account right)
...

[CompilerGenerated]
public override int GetHashCode()
{
return (EqualityComparer<Type>.Default.GetHashCode(this.EqualityContract) * -1521134295 + EqualityComparer<string>.Default.GetHashCode(this.<Name>k__BackingField)) * -1521134295 + EqualityComparer<DateTime>.Default.GetHashCode(this.<Birth>k__BackingField);
}

[NullableContext(2)]
[CompilerGenerated]
public override bool Equals(object obj)
{
return this.Equals(obj as Account);
}

[NullableContext(2)]
[CompilerGenerated]
public virtual bool Equals(Account other)
{
if ((object) this == (object) other)
return true;
return (object) other != null && Type.op_Equality(this.EqualityContract, other.EqualityContract) && EqualityComparer<string>.Default.Equals(this.<Name>k__BackingField, other.<Name>k__BackingField) && EqualityComparer<DateTime>.Default.Equals(this.<Birth>k__BackingField, other.<Birth>k__BackingField);
}

[CompilerGenerated]
public virtual Account <Clone>$()
{
return new Account(this);
}

[CompilerGenerated]
protected Account(Account original)
{
base..ctor();
this.<Name>k__BackingField = original.<Name>k__BackingField;
this.<Birth>k__BackingField = original.<Birth>k__BackingField;
}

[CompilerGenerated]
public void Deconstruct(out string Name, out DateTime Birth)
{
Name = this.Name;
Birth = this.Birth;
}
}

In the equality operator, unlike normal class default behavior, the reference equality is not checked. These details are relevant to understanding record types as value objects to represent external data; they are discoverable as multiple instances during distinct instances, for example:

var accounts = new List<Account>
{
new Account("Matteo", new DateTime(1986, 12, 24)),
new Account("Paolo", new DateTime(2013, 12, 11)),
new Account("Alfred", new DateTime(2017, 04, 01)),
new Account("Erik", new DateTime(1981, 06, 13)),
new Account("Alfred", new DateTime(2017, 04, 01))
};

var distinct = accounts.Distinct();

foreach (var acc in distinct) Console.WriteLine($"Account: {acc}");
Account: Account { Name = Matteo, Birth = 24/12/1986 00:00:00 }
Account: Account { Name = Paolo, Birth = 11/12/2013 00:00:00 }
Account: Account { Name = Alfred, Birth = 01/04/2017 00:00:00 }
Account: Account { Name = Erik, Birth = 13/06/1981 00:00:00 }

“Alfred” is twice. This would not be possible with a class by default. It would in fact have printed all five (Note that also a valid print method in record reference type has been automatically generated). You don’t need to write those lines anymore.

Another interesting feature of record type is the non-destructive mutation. You can use the with expression to only change one property, while the original instance will remain the same:

var account = new Account("Joe", new DateTime(1893, 03, 21));
var anotherAccount = account with { Name = "Paul" };

All properties have been copied, and only one (the name) has been changed, and this is very useful to enforce the vertical compatibility of the account object. If I add a new parameter for the account type, I’ll not need to track down this change in all the places where the accounts are copied using with. Because it just copies all the properties by itself. That’s another advantage of features added by the record modifier at compile time.

Records in C# are designed to streamline the creation and usage of immutable data types, promoting concise syntax, immutability, and enhanced support for common operations such as equality comparisons and deconstruction. They are particularly well-suited for representing data-centric structures where mutability is not required, offering a modern and expressive alternative to traditional class definitions. However, if objects have dependencies, services, etc., these are not suitable for record types.

Primary Constructors

Primary constructor feature is introduced in C# 12 and is still related to functional programming and objects initialization. Primary constructor limit redundant initialization code in the constructor class simply using the following new syntax:

public class Account(string name, string surname)
{
public string Name { get; } = name;
public string Surname { get; } = surname;
}

The compiler auto generates backing fields, in the IL code you’ll see: '<Surname>k__BackingField' and ‘<Name>k__BackingField’.

That’s all about primary constructor. Our class is now much more similar to a record type.

However, many programmers warning about primary constructor abuse. In the account class is possible to reuse the constructor parameters, the following code is valid:

public class Account(string name, string surname)
{
public string Name { get; } = name;
public string Surname { get; } = surname;

public override string ToString() =>
$"Account has a name: {name} and surname: {Surname}";
}

The compiler create a private field to capture any parameter you use in any method inside the class (Look at the ‘<name>P’ in IL code as below):

.method public hidebysig specialname rtspecialname instance void
.ctor(
string name,
string surname
) cil managed
{
.maxstack 8

IL_0000: ldarg.0 // this
IL_0001: ldarg.1 // name
IL_0002: stfld string PrimaryConstructor.Account::'<name>P'

// [5 35 - 5 39]
IL_0007: ldarg.0 // this
IL_0008: ldarg.0 // this
IL_0009: ldfld string PrimaryConstructor.Account::'<name>P'
IL_000e: stfld string PrimaryConstructor.Account::'<Name>k__BackingField'

// [6 38 - 6 45]
IL_0013: ldarg.0 // this
IL_0014: ldarg.2 // surname
IL_0015: stfld string PrimaryConstructor.Account::'<Surname>k__BackingField'

// [3 14 - 3 50]
IL_001a: ldarg.0 // this
IL_001b: call instance void [System.Runtime]System.Object::.ctor()
IL_0020: ret

} // end of method Account::.ctor

This duplication point the problem with primary constructor: The primary’s constructor parameter’s scope is the whole type. You could override the parameter inside one of the class methods, creating a bug. It’s plausible to think that this is a feature that still needs to be completed or modified to eliminate this uncertainty.

...
public Account GetBestContact(IEnumerable<Account> accounts)
{
var best = accounts.First();
foreach (var account in accounts)
{
if (accountComparer.Compare(best, account) > 0)
best = account;

// Bug: accountComparer should be readonly!
accountComparer = Comparer<Account>.Default;
}

return best;
}
...

Pattern Matching

Pattern matching capabilities received a constant thrust over the latest C# versions. It doesn’t merely solve problems; it introduces a paradigm shift, allowing you to navigate the intricacies of your code with finesse, bringing forth a new level of clarity and efficiency to your programming realm.

Pattern matching enhances code maintainability, improve readability by giving more expression to your code, reducing its volumes (lines of code).

There are several kinds of pattern matching. Let’s start with the switch. It seems similar to the classical switch but has a much higher expressive potential.

The following basic example is taken from Microsoft Learn:

public State PerformOperation(string command) =>
command switch
{
"SystemTest" => RunDiagnostics(),
"Start" => StartSystem(),
"Stop" => StopSystem(),
"Reset" => ResetToReady(),
_ => throw new ArgumentException("Invalid string value for command", nameof(command)),
};

Relational patterns are useful to compare a value to constants:

public static string DescribeStage(int v) 
=> v switch
{
< 2 => "Early",
< 4 => "Normal",
< 6 => "Later",
_ => "TooLate"
};

Discard Pattern (_ always match his type):

bool.TryParse("True", out _);

Comparing discrete values, with a type cast:

if (parameter is int value && (value > 0 && value <= 1024)) 
{
//do something
}

We do not want to exhaustively cover all the capabilities, just a taste. Instead, putting these concepts in practice, they can improve syntax to express algorithms reducing complexity of nested if conditionals.

Let’s see a concrete example.

Imagine to model a railway crossroad as the following:

A Double Slip Showing Route Options
A Double Slip Showing Route Options

The double slip switch (double slip) node has two sets of moveable switch rails, one set at each end. Each set has its own throwbar (or tiebar). Double-slips as above have 4 switch rails at each end. Is a narrow-angled diagonal flat crossing of two lines combined with four pairs of points in such a way as to allow vehicles to change from one straight track to the other, alternatively to going straight across.

The arrangement gives the possibility of setting four routes, but because only one route can be traversed at a time, the four blades at each end of the crossing are connected to move in unison, so the crossing can be worked by two levers or point motors.

Every switch state can be represented by a boolean value hold in a RailRoad switch class. This state can be True or False (Open or Closed). We also need a Train class to identify train entities passing on the railways:

internal class RailRoadSwitch
{
private readonly bool[] _endSwitchesA = new bool[2];
private readonly bool[] _endSwitchesB = new bool[2];

private readonly Train?[] _trains;
...

Is important to ensure the correctness of the switch positions when the train passes on (a switch could be broken or tampered with).

You might be tempted to use a series of “if” “else” to implement a logic of a CanPassThrough method:

public bool CanPassThrough(int trailIndex)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(trailIndex, 3);

var train = _trains[trailIndex];

if (train is null)
return true;

var switchA1 = _endSwitchesA[0];
...
var switchB2 = _endSwitchesB[1];

if (train.TrainMovement == TrainMovement.Forward)
{
switch (trailIndex)
{
case 0:
{
if (switchA1 && switchA2)
{
if (switchB1 && switchB2)
{
...
}
else
{
...
}
}
else
{
...
}

break;
}
...

This is a dangerous dance of if-else statements, It makes the code unreadable and susceptible to the introduction of logical errors.

A better way is to use patterns to determine if the state is a valid one, and if a train can pass through.

First, start by a table states (switches pair):

Possible switches states

The combinations above are the legal ones that allow a train to pass through the railway crossing. Let’s combine these with the implementation details using pattern matching and rewrite a better (and more complete) version of the CanPassThrough method:

internal class RailRoadSwitch
{
private readonly bool[] _endSwitchesA = new bool[2];
private readonly bool[] _endSwitchesB = new bool[2];

private const bool Open = true;
private const bool Closed = false;

private readonly Train?[] _trains;

public RailRoadSwitch(Train?[] trains)
{
...
}

public bool CanPassThrough(int trailIndex)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(trailIndex, 3);

var train = _trains[trailIndex];

var switchA1 = _endSwitchesA[0];
...
var switchB2 = _endSwitchesB[1];

var matched = (switchA1, switchA2, switchB1, switchB2, trailIndex, _trains, train) switch
{
(Open, Closed, Open, Closed, 0, [_, null, null, null], { TrainMovement: TrainMovement.Forward }) => true,
(Closed, Open, Open, Closed, 0,[_, null, null, null], { TrainMovement: TrainMovement.Forward }) => true,
(Closed, Open, Closed, Open, 1, [null, _, null, null], { TrainMovement: TrainMovement.Forward }) => true,
(Open, Closed, Closed, Open, 1,[null, _, null, null], { TrainMovement: TrainMovement.Forward }) => true,
(_, _, _, _, _, _, { TrainMovement: TrainMovement.Backward }) => false,
(_, _, _, _, _, _, null) => throw new ArgumentNullException($"No train on trail {trailIndex}"),
_ => false
};

return matched;
}
}

( It reminds me of Prolog language sometimes, doesn’t it? )

The new implementation are capable of:

  • Match all the valid switch states
  • Detect concurrent trains coming at the crossing ( via _trains array, 6th argument in the pattern)
  • Train position (from which trail) and his direction

The code has more expressive capacity and greater ability to detect edge cases.

Pattern matching is straightforward, but with a cost. If you analyze the JIT asm code generated pattern matching can add a burden on the CPU. Giving priority to the most frequently used cases at the beginning can streamline the process and enhance efficiency.

While pattern matching is generally designed for efficiency, it’s advisable to profile and benchmark your specific use cases to ensure that it meets your performance requirements. For most scenarios, the benefits of improved code readability and maintainability often outweigh any minor performance considerations.

Conclusion

These are some of the latest relevant features added, but there are more. I talked about those that are most relevant to me. I hope this article has helped you better understand how the C# language is evolving towards a mixture of object-oriented and functional programming to meet new technological and business challenges. C# is placing more emphasis on data structures and built-in tools to work with them. Software is becoming more and more data-centric, and the need to manipulate data from different sources is almost always present.

It is up to you to decide whether to stick with the old-fashioned object-oriented pure design or to embrace the functional mindset of the latest language versions.

--

--