In this article, we will explore new features in C# 12. We will describe primary constructors, collection expressions, inline arrays, and other newly introduced features. 

We’ll need the latest Visual Studio 2022 or .NET 8 SDK to try each of these new C# 12 features, so download and install as needed. 

Let’s dive in.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

Primary Constructors

A constructor is a special class method we use to initialize a class. The compiler calls the constructors when we create an instance of a type. 

Until C# 12, primary constructors were available only for the record types. From C# 12, the class and struct types can also have a primary constructor that adds parameters to the class or struct declaration. Besides that, these parameters are in scope throughout the entire class definition. It is important to emphasize that the parameters, except for the record types, are not properties or class members. 

Let’s look at this in action: 

public class Person(string firstName, string lastName, string address = default)
{
    public string Name => string.Join(" ", firstName, lastName);

    public Person() : this(string.Empty, string.Empty, string.Empty)
    {        
    }

    public void Reset()
    { 
        firstName = string.Empty; 
        lastName = string.Empty;
    }
}

Here, we define the Person class with a primary constructor with three parameters. The parameter address is unused and won’t be stored in the class. Moreover, the compiler will warn us about that. 

The parameters of the primary constructor can be accessed and used, as we can see in the implementation of the public Name property. They also can be assigned to. As an example, in the Reset() method, we assign string.Empty to primary constructor parameters. 

When we declare a class with a primary constructor, the compiler won’t assign the default, parameterless constructor. But we can explicitly create a class constructor with or without parameters. In a class with a primary constructor, such constructors must invoke the primary constructor using this(...) syntax to ensure that all constructors assign the primary constructor parameters. 

Derived Classes

A derived class can invoke the base class primary constructor:

public class Employee(string firstName, string lastName, string address, int employeeId) 
    : Person(firstName, lastName, address)
{
    public string EmployeeIdentifier => string.Join(" ", firstName, lastName, employeeId);
}

Here, we define the derived class that introduces additional parameters in its constructor. If the derived class uses the parameters from the primary constructor, it will create a copy of it. In other words, if we change the parameter’s value in the base class, it won’t be changed in the derived class. This will be the case with the firstName and lastName parameters. A solution would be to create a property in the base class storing these parameters and use it in the derived class.   

A derived class doesn’t have to implement a primary constructor. We can also declare a constructor that invokes the base class primary constructor. 

Common Use Cases

Let’s explore a few common use cases for which we can implement a primary constructor:

  • an argument to a base() constructor invocation
  • initialization of a member field or property
  • referencing them in an instance
  • dependency injection.

Next up, let’s look at collection expressions. 

Collection Expressions

C# 12 introduces a new syntax to initialize a collection. A collection expression contains a sequence of elements between [ and ] brackets. Additionally, we can pass a collection expression as an argument to a method that accepts collection types.

With a collection expression, we can initialize array types (for example, int[]), Span<T>, and ReadOnlySpan<T> types, all types that support collection initializers (for example, List<T>):

int[] fibonacci = [0, 1, 1, 2, 3, 5, 8, 13];

var red = "red";
var green = "green";
var blue = "blue";

Span<string> rgbColors = [red, green, blue];

int Sum(IEnumerable<int> values) => values.Sum();

Sum([0, 1, 1, 2, 3, 5, 8, 13]);

We initialize the fibonacci variable, an array of integers, with the first eight numbers of the sequence. 

In the example with Span<string> collection initialization, we can see that we can use variables to compose the collection expression. 

Afterwards, we define the Sum() method accepting the IEnumerable<int> as an argument, and we call it with the same collection expression we used to initialize the fibonacci array. 

We can use the spread operator .. to inline collection values in a collection expression:

int[] numbersToThree = [1, 2, 3];
int[] fourAndFive = [4, 5];

int[] numbersToFiveIncludingZero = [0, .. numbersToThree, .. fourAndFive];

Here, we initialize the numbersToFiveIncludingZero array with previously initialized arrays, the numbersToThree and fourAndFive. Note that we can combine a spread operator with ordinary elements.

The result sequence in the numbersToFiveIncludingZero variable will be [0, 1, 2, 3, 4, 5].

A .. operator variable must be enumerated using foreach statement

To explore the collection expressions further, read Simple Initialization With Collection Expression in .NET.

Inline Arrays

Another one of the new C# 12 features, an inline array is a structure that contains a block of N elements of the same type.

As such, it represents a safe equivalent of the fixed buffer and brings performance improvements when working with the Array type.  

An inline array allows us to create a fixed-size array in a struct type: 

[System.Runtime.CompilerServices.InlineArray(10)]
public struct IntBuffer
{
    private int _element;
}

We use the InlineArray attribute with the length of the array. An inline array does not have a Length property, and it is impossible to use LINQ with it. 

The major difference from an ordinary array is that an inline array is the struct type while an ordinary array is the reference type. As such, the inline array is allocated on the stack, bringing performance improvements. 

Let’s see how we can use the inline array:

var inlineArray = new IntBuffer();

for (int i = 0; i < 10; i++)
{
    inlineArray[i] = i;
}

foreach (var item in inlineArray)
{
    Console.Write($"{item} ");
}

Here, we initialize the array with numbers up to ten and then print them to the console.

The .NET runtime team and library authors primarily utilize inline arrays.

Default Lambda Parameters

We enclose the parameters of the lambda expression in parentheses. Starting from C# 12 those parameters can have the default values

var IncrementBy = (int start, int increment = 1) => start + increment;

The default value of the increment parameter in the above-declared lambda expression is 1. If the caller does not provide the argument for this parameter, the call to the function will be with the default parameter value.

We can call the expression in two ways: 

IncrementBy(5);
IncrementBy(5, 2);

The result of our first method call is 6, increasing the first argument by default value. However, if we provide the increment value in the second call, we get 7. 

Ref Readonly Parameters

C# 12 introduces ref readonly parameter modifier as an addition to the existing in and ref parameter modifiers. The aim is to enable more clarity for APIs that use those already existing parameter modifiers. To learn more about parameter modifiers, take a look at the Microsoft documentation.

The ref readonly modifier prevents the called method from changing the value passed to it:

void RefReadOnlyModifier(ref readonly int number)
{
    Console.WriteLine($"Passed number: {number}");
    number++; // Cannot assign to variable 'number' or use it as the right hand side 
                // of a ref assignment because it is a readonly variable
}

If we try to modify the parameter’s value with the ref readonly modifier, the compiler will issue an error. But the same would happen if we use a in modifier. However, introducing the ref readonly modifier enables more clarity on the caller site by issuing a warning if the right-hand side value is passed to a method. 

Let’s call the previously defined method in different ways:

var argument = 5;
RefReadOnlyModifier(ref argument);
RefReadOnlyModifier(in argument);
RefReadOnlyModifier(argument);
RefReadOnlyModifier(5);

The RefReadOnlyModifier(argument) method call will issue the compiler warning: “Argument 1 should be passed with ‘ref’ or ‘in’ keyword”, and the call to RefReadOnlyModifier(5): “Argument 1 should be a variable because it is passed to a ‘ref readonly’ parameter”. Using ref or in modifier are both recommended ways to pass an argument to a calling method. 

Alias for Any Type

In C#, it was always possible to introduce aliases for namespaces and named types (such as classes, delegates, interfaces, records, and structs): 

using MyClassAlias = MyNameSpace.MyClass;

In the code after that, we can refer to the MyClass class as MyClassAlias without referencing it by the namespace. Similarly, from C# 12, aliases are possible for any type, not just named types. That includes tuples, arrays, pointers, as well as other unsafe types:

using MyArray = int[];
using MyTuple = System.Tuple<int, int>;

var myArray = new MyArray[3];
var myTuple = new MyTuple(1, 1);

Here, we alias the Tuple type as MyTuple and an integer array as MyArray. To learn more about tuple aliases, read Tuple Aliases in C#.

Experimental Attribute

From version 12 of C#, types, methods, and assemblies can be marked with the System.Diagnostics.CodeAnalysis.ExperimentalAttribute attribute. The attribute will indicate an experimental feature and the compiler will issue a warning when we access such a feature in our code. 

Let’s define an experimental class:

[Experimental(diagnosticId: "Exp_001")]
public class MyExperimentalClass
{
    public void DoExperiment()
    {
        Console.WriteLine("Experimenting...");
    }
}

We decorate the MyExperimentalClass with the Experimental attribute, which requires the value for the diagnosticId parameter. The compiler uses this value in an error when we use the class. We also use this value to disable the compiler’s error. 

Let’s look at the class usage: 

#pragma warning disable Exp_001
var myExperimentalClass = new MyExperimentalClass();
myExperimentalClass.DoExperiment();

If we omit the first line, where we disable the warning, we could see the error: 

Experimental Feature Error

Interceptors

The interceptors are an experimental feature available online in preview mode. This feature may change or disappear in the future. Therefore, we should not use it for production.

The interceptors are the methods that can substitute a call to any interceptable method with a call to itself. The substitution happens at compile time. The interceptor declares the source location of the call it intercepts. 

Nevertheless, interceptors are helpful and powerful features, and we cover them in complete detail in our article How to Use Interceptors in C# 12

Conclusion

Version 12 of C# brings interesting new features that increase the simplicity and performance of the language.

Most notably, the primary constructor simplifies the classes and structs construction. As most classes implement only one constructor, it makes sense to have a more straightforward implementation. Additionally, collection expressions simplify collection initialization, while other newly added features increase code readability or performance.

We encourage you to experiment with and integrate these features into your projects. We would like to hear your opinions and experiences in the comments below!

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!