C# 12 along with .NET 8 has been officially released in November 2023. Let’s explore C# 12 New Features in this post.
Primary Constructors syntax is now usable on class and struct in C# 12
Since C#9 we have the convenient primary constructor syntax for class record
(or just record
) and struct record
:
1 2 3 4 5 |
var p = new Person("Seth", "Gecko"); Assert.IsTrue($"{p.FirstName} {p.LastName}" == "Seth Gecko"); // p.FirstName = "Alan"; ERROR record are immutable by default public record Person(string FirstName, string LastName); |
C# 12 introduces primary constructors for non-record classes and structs. However, it’s important to understand that the reasons for implementing them vary:
- In records, the primary constructor offers a succinct method to generate public read-only properties. This is useful for creating simple immutable objects meant to store states.
- In C# 12 classes and structs, the primary constructor provides a concise way to generate private fields. Since classes and structs often have an internal logic that relies on internal states, the primary constructor aids in initializing these states during construction.
Primary constructors eliminate the need to declare private fields and manually link parameter values to those fields in constructor bodies. Say goodbye to that boilerplate code:
Notice that in non-record classes and structs, properties aren’t automatically generated for primary constructor parameters. You need to create them manually to indicate which data is accessible. This aligns with the typical usage of classes
More about primary constructor can be found in this post: C#12 class and struct Primary Constructors (naming conflict, interaction with other constructors, property, advanced usage of fields generated…).
C# 12 Collection Literals
C# 12 introduces a new terse syntax [a, b, c]
to create common collections. The goal is to provide a unified and user-friendly API for declaring collections. In the code sample below notice the usage of the spread operator ..
that let’s concatenate the collections:
1 2 3 4 |
List<int> start = [1, 2, 3]; Span<int> end = [5, 6, 7]; List<int> all = [.. start, 4, .. end]; Assert.SequenceEquals(all, [1, 2, 3, 4, 5, 6, 7]); |
A similar syntax using braces {a, b, c}
is available for a long time, but it is limited to working with arrays
This syntax also works to declare jagged arrays but not multi-dimensional arrays:
It is easy to make this new syntax usable with your collections classes thanks to the new attribute [CollectionBuilder]
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
Names namesA = ["Eric", "Stan"]; Names namesB = ["Kyle", "Kenny"]; Names namesC = [.. namesA, "Bart", .. namesB]; Assert.SequenceEquals(namesC, ["Eric", "Stan", "Bart", "Kyle", "Kenny"]); [CollectionBuilder(typeof(Names), nameof(Create))] public class Names : IEnumerable<string> { private readonly string[] m_Names; public IEnumerator<string> GetEnumerator() => m_Names.AsEnumerable().GetEnumerator(); IEnumerator IEnumerable.GetEnumerator() => m_Names.GetEnumerator(); public static Names Create(ReadOnlySpan<string> source) => new Names(source); private Names(ReadOnlySpan<string> source) { m_Names = new string[source.Length]; for (var i = 0; i < source.Length; i++) { m_Names[i] = source[i]; } } } |
Notice that new syntax for dictionary literals has been mentioned in the design discussions but this has been postponed for at best C# 13:
1 |
Dictionary<int, string> myMap = [1: "one", 2: "two", 3: "three"]; |
Also, a collection literal expression cannot be used directly as the first parameter for an extension method invocation. This is because no natural type gets inferred from a collection literal expression but this might be improved in future C# versions:
C# 12 Using directives to alias any types
C# has long-supported aliases for namespaces and named types (classes, delegates, interfaces, records, and structs). This feature effectively resolves naming conflicts when using directives may introduce ambiguity. Additionally, it simplifies the usage of complex generic types by providing concise alternatives. However, the language’s growing collection of complex type symbols has created a demand for aliases that are currently not permitted. Consider tuples, nullable and function-pointers, which often have lengthy and intricate textual representations that are cumbersome to write repeatedly and challenging to read. By allowing more aliases in C# 12, developers can assign shorter names to replace these extensive structural forms, thereby enhancing code readability and developer productivity.
1 2 3 4 5 6 7 8 |
using Person = (string FirstName, string LastName, int BorthYear); using ints = int[]; using DatabaseInt = int?; Method(("Bill", "Gates", 1955), new int[5], null); // Usage void Method(Person p, ints i, DatabaseInt db) { /*...*/} |
C# 12 Default values for lambda expressions
C# 12 boosts lambda expressions by enabling developers to set default parameter values. The syntax resembles that of other default parameters. For instance, (int val = 10) => val + 1;
. The default value 10
is utilized when no value is provided in the lambda call.
1 2 3 |
var lambdaWithDefaultParam = (int val = 10) => val + 1; Assert.IsTrue(lambdaWithDefaultParam() == 11); Assert.IsTrue(lambdaWithDefaultParam(4) == 5); |
C# 12 nameof operator improvement
The nameof
operator now works in C# 12 with member names, including static members, initializers, and in attributes:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using System.Diagnostics; var a = new Assert(); // At debug time we get {Assert "Length"} for this object string playerName = "John"; // These 3x usages of nameof already worked before C# 12 Assert.IsTrue(nameof(playerName) == "playerName"); Assert.IsTrue(nameof(Assert.IsTrue) == "IsTrue"); Assert.IsTrue(nameof(Assert.S) == "S"); // This usage of nameof in attribute is now possible in C# 12 [DebuggerDisplay("Assert {nameof(S.Length)}")] class Assert { public static void IsTrue(bool b) { Debug.Assert(b); } public string S { get; } = ""; public static int StaticField; // These 2 usages of nameof are now possible in C# 12 public const string SLength = nameof(S.Length); public string MaxValue = nameof(StaticField.MaxValue); } |
C# 12 Inline arrays
The InlineArrayAttribute
has been introduced with .NET 8 and can be used from C# 12 programs. The principal purpose of this attribute is to designate a type as eligible for representation as a contiguous sequence of primitives. The size of an inline array is fixed at compile time. This makes it faster because the exact layout is known by the compiler that generates optimized IL code to access elements of the inline array. Also, an inline array is a struct
which makes it allocated on the thread stack and not on the heap as with regular .NET arrays. Here is a short program based on .NET 8 inline arrays, notice how inline array element access is similar to regular array element access through inlineArray[i]
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
var inlineArray = new MyInlineArray<int>(); InlineArrayAccess(inlineArray); // Print 0 since the original array is passed by value // It is left untouched by the call to InlineArrayAccess() Console.WriteLine(inlineArray[4]); static void InlineArrayAccess(MyInlineArray<int> inlineArray) { for (int i = 0; i < 10; i++) { inlineArray[i] = i * i; } // Print 16 since a copy of the array with squares is passed PrintElem(inlineArray, 4); } static void PrintElem(MyInlineArray<int> inlineArray, int index) { Console.WriteLine(inlineArray[index]); } [System.Runtime.CompilerServices.InlineArray(10)] // Size 10 is fixed here! public struct MyInlineArray<T> { // The definition of an inline array requires such unused field! private T _elem; } |
Clearly, this C# 12 new feature is a niche one. It is chiefly aimed at utilization by the compiler, the .NET BCL, and select third-party libraries. But for those that need more performance like in the game industry, inline arrays are welcome.
C# 12 Interceptors
C# 12 interceptors is a powerful – yet strange – new feature that we are going to demonstrate first. Then we’ll go through potential use cases. To make work this short program you need a C# 12 compatible project with 2 files, Program.cs and MyInterceptors.cs. The .csproj project file content must look like this:
1 2 3 4 5 6 7 8 9 10 |
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <LangVersion>preview</LangVersion> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> <Features>InterceptorsPreview</Features> </PropertyGroup> </Project> |
Here is the content of Program.cs:
1 2 3 4 5 6 7 8 9 10 |
var obj = new Class(); obj.Method("1"); obj.Method("2"); obj.Method("3"); class Class { public void Method(string str) { Console.WriteLine($"Hi {str}"); } } |
Here is the content of MyInterceptors.cs:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
namespace System.Runtime.CompilerServices { // So far we must define ourselves this attribute! [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] sealed class InterceptsLocationAttribute(string filePath, int line, int column) : Attribute { } } static class GeneratedCode { [System.Runtime.CompilerServices.InterceptsLocationAttribute(@"C:\Users\pat\source\repos\ConsoleApp170\ConsoleApp170\Program.cs", 3,5)] public static void InterceptorMethod1(this Class obj, string str) { Console.WriteLine($"Hello {str}"); } [System.Runtime.CompilerServices.InterceptsLocationAttribute(@"C:\Users\pat\source\repos\ConsoleApp170\ConsoleApp170\Program.cs", 4, 5)] public static void InterceptorMethod2(this Class obj, string str) { Console.WriteLine($"Bonjour {str}"); } } |
This program outputs:
1 2 3 |
Hi 1 Hello 2 Bonjour 3 |
If we decompile the assembly generated, it with ILSpy we can see that calls to Method()
have been hijacked by the compiler:
1 2 3 4 5 6 7 |
// Program private static void <Main>$(string[] args) { Class obj = new Class(); obj.Method("1"); obj.InterceptorMethod1("2"); obj.InterceptorMethod2("3"); } |
Notice that if you change anything in:
System.Runtime.CompilerServices.InterceptsLocationAttribute(@"C:\Users\pat\source\repos\ConsoleApp170\ConsoleApp170\Program.cs", 3,5)]
the compiler emits an error because online a valid triple of (absolute file path + line + column) can work.
Why are they planning such a weird feature in C# 12? The primary rationale is Ahead-Of-Time compilation (AOT) which becomes first citizen with .NET 8. While interceptors aren’t exclusively tailored to AOT, their design unmistakably takes AOT into account. Through the employment of interceptors, it becomes feasible to transform code that previously posed challenges for AOT into versions generated directly from the source.
The GeneratedCode
class will be generated by a tool and developer won’t have to even change their code.
Conclusion
This article delves into new features introduced in C# 12 Preview. Microsoft is dedicated to enhancing the C# language, prioritizing efficiency and developer-friendliness. Expect further advancements in the near future.