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.
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:
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!