Compared to some previous releases, C# 12 is quite a small, incremental update to the language. Here's the feature summary:
You can now define a "primary" constructor for a non-record
class or
struct alongside the name of the type (similar to the
primary constructor syntax for records):
sealed class User(string name, int age) { // Notice the new syntax here, defining a primary ctor with a string "name" and int "age"
// We define a single void method that uses the name and age parameters from the primary constructor
public void PrintNameAndAge() {
Console.WriteLine($"User name: {name}, Age: {age}");
}
}
// Usage:
static void Test() {
var user = new User("Ben", 33);
user.PrintNameAndAge(); // Prints "User name: Ben, Age: 33" to console
}
View Code Fullscreen • "Example Primary Constructor on a Non-Record Class"
Unlike with record types, a primary constructor on a regular class/struct type does not automatically induce the generation of matching properties:
sealed class User(string name, int age) { }
static void Test() {
var user = new User("Ben", 33);
Console.WriteLine(user.Name); // Doesn't compile unless User is a record type (and 'name' is renamed to 'Name')
}
View Code Fullscreen • "Demonstration of Difference Between Record and Non-Record Primary Constructor Property Generation"
So what's the advantage here? The biggest reasoning for this feature is that it cuts down on some unnecessary ceremony for types that tend to just assign their constructor parameters through to fields/properties anyway. Here's a "before and after" using
an example from the official docs:
// Before:
public struct Distance {
double _dx;
double _dy;
public readonly double Magnitude => Math.Sqrt(_dx * _dx + _dy * _dy);
public readonly double Direction => Math.Atan2(_dy, _dx);
public void Translate(double deltaX, double deltaY) {
_dx += deltaX;
_dy += deltaY;
}
public Distance() : this(0, 0) { }
public Distance(double dx, double dy) {
_dx = dx;
_dy = dy;
}
}
// After:
public struct Distance(double dx, double dy) {
public readonly double Magnitude => Math.Sqrt(dx * dx + dy * dy);
public readonly double Direction => Math.Atan2(dy, dx);
public void Translate(double deltaX, double deltaY) {
dx += deltaX;
dy += deltaY;
}
public Distance() : this(0, 0) { }
}
View Code Fullscreen • "Traditional Constructor vs Primary Constructor"
So it does cut down on quite a bit of "noise", but I personally have some reservations about this feature:
You can't validate parameters (e.g. check for null or out-of-range values). Most of the real-world types I write usually require constructor parameter validation. There are the occasional "plain old data" types that don't, but...
...I don't often see types that have constructor parameters that don't need additional validation/sanity checking but also wouldn't benefit from being a record type in the first place, including the value-semantics (and we already had this syntax for record types).
The naming convention for the parameters clashes with those for primary constructors in record types. For records I've always used PascalCase in order to have the auto-generated properties correctly be in PascalCase (e.g. sealed record class User(string Name, int Age)), but with non-record types the examples in the official documentation all use camelCase. You could declare non-record primary ctor parameters as PascalCase also (going against the conventions set by the examples for the feature), but PascalCase in C# has always traditionally implied a non-private member, and the generated fields are private. Also, personally, I prefer the _underscorePrefix convention for private fields anyway.
Familiar operations such as calling base-class constructors, constructor chaining, and even just finding where fields/values come from in a class now have a "second" way to do them that you need to learn and look out for. That is true of primary constructors in record types too, but the auto-generation of value-semantic-supporting methods and properties make the tradeoff more appealing in my opinion.
Perhaps worst of all, I don't like that this leaves a mutable field 'hiding' in your class/struct (conversely, record types generate init-only properties by default). I'm personally a huge proponent of the idea that all types should be immutable-by-default, and saving myself a bit of syntax at the cost of making my type privately-mutable doesn't really appeal. Even if I could validate the constructor parameters, the fact that the values might be altered anywhere elsewhere in the type post-construction makes it much harder to reason about the overall safety of the class/struct. Yes, they're only private fields, but that still bothers me.
Having said that, you can mark a struct with a primary constructor as readonly, which means the compiler will generate readonly fields, and you can not accidentally modify them anywhere. But I'd argue even more strongly that you almost always want a record type for a struct anyway.
It should be noted that the compiler only creates a field to 'back' your primary constructor parameters if you attempt to use them in any method, set them from a property, or do anything other than simply assign them to a property (e.g. public string Name => name + " test"; still generates a field, but public string Name => name; does not, it just assigns the property at construction time). Somewhat of a moot point, though.
Here's an example of how the constructor parameter becomes a mutable field and how that can be undesirable:
public sealed class User(string name, int age) {
public void PrintNameAndAge() {
Console.WriteLine($"User name: {name}, Age: {age}");
}
public void PrintNameAllCapsIfOld(int oldThreshold) {
if (age >= oldThreshold) {
name = name.ToUpperInvariant(); // Oops- did we really mean to change this permanently for this User instance?
}
Console.WriteLine(name);
}
}
static void Test() {
var user = new User("Ben", 33);
user.PrintNameAllCapsIfOld(30); // "BEN"
user.PrintNameAndAge(); // "User name: BEN, Age: 33" -- we've accidentally altered the name field in the previous method invocation
}
View Code Fullscreen • "Primary Constructor Parameters are Mutable Fields"
Mutable fields open you up to an entire class of programmer-error/bug. The mistake shown above is simply impossible to commit when using immutable fields. You can technically 'hide' the parameters by redeclaring the fields as readonly:
public sealed class User(string name, int age) {
// Notice we've added two readonly fields back in:
readonly string name = name;
readonly int age = age;
public void PrintNameAndAge() {
Console.WriteLine($"User name: {name}, Age: {age}");
}
public void PrintNameAllCapsIfOld(int oldThreshold) {
if (age >= oldThreshold) {
name = name.ToUpperInvariant(); // This line no longer compiles
}
Console.WriteLine(name);
}
}
View Code Fullscreen • "Hiding Mutable Primary Constructor Fields"
...But now we're almost back to the same amount of 'boilerplate' we had before the primary constructor was added, and introduced
another ostensibly-odd syntax construction (e.g. the
name = name in the field initializer).
So honestly, I probably won't be using the feature in my codebases in its current form. If you want to eschew parameter validation and constructor boilerplate, I'd consider
required members as an alternative route (because the properties can still be declared as
init-only), or just use
records. I suppose this addition to the language does close a gap in syntax between record and non-record types, but even then the convention regarding camelCase vs PascalCase in the parameter declarations is still off-putting. I have read around and it's being considered to add a
readonly modifier (or similar) for the parameters in a future version-- I may revisit the idea then.
This is a syntactical addition that makes it easier to initialize various collection types, and probably the most useful addition in C# 12 for me (especially when writing unit tests). The actual feature is quite small though, and I think it's easiest to just explain using various examples:
// You can initialize member fields/properties using a collection expression.
// This expression creates an IEnumerable<string> containing three elements ("Ben", "Jos", "Seb").
// The actual type of _names is '\u003C\u003Ez__ReadOnlyArray<T>' which is an internal type used for collection expressions that implements IEnumerable<T>.
static readonly IEnumerable<string> _names = ["Ben", "Jos", "Seb"];
// For interfaces that allow collection mutation, the collection expression usually creates a List<T> under-the-hood instead.
// (e.g. _mutableNames is of type List<string> here)
static readonly ICollection<string> _mutableNames = ["Ben", "Jos", "Seb"];
// We can return collection expressions directly from methods where the return type is compatible.
// Notice the expression evaluates to a type inferred by the return type of the method (List<int> in this case):
static List<int> ReturnList() => [1, 2, 3];
// And another example with int[]:
static int[] ReturnArray() => [1, 2, 3];
static void Test() {
// We can create various collection types easily:
List<int> oneTwoThreeList = [1, 2, 3];
IEnumerable<int> oneTwoThreeEnumerable = [1, 2, 3];
// Proving they all initialize as expected:
PrintEnumerableContents(oneTwoThreeList); // 1, 2, 3
PrintEnumerableContents(oneTwoThreeEnumerable); // 1, 2, 3
PrintEnumerableContents(ReturnList()); // 1, 2, 3
PrintEnumerableContents(ReturnArray()); // 1, 2, 3
Console.WriteLine(_names.First()); // Ben
_mutableNames.Add("Curie");
Console.WriteLine(_mutableNames.Last()); // Curie
// You can pass collection expressions directly to methods that expect a compatible type:
PrintEnumerableContents([1, 2, 3]); // 1, 2, 3
PrintEnumerableContents([]); // Nothing (empty collection)
// We can also use collection expressions with spans:
Span<int> oneTwoThreeSpan = [1, 2, 3];
ReadOnlySpan<int> oneTwoThreeReadOnlySpan = [1, 2, 3];
// Finally, there's also a new 'spread' operator that allows you to easily concatenate multiple collections together:
IEnumerable<int> triple = [..oneTwoThreeList, ..oneTwoThreeEnumerable, ..oneTwoThreeSpan];
PrintEnumerableContents(triple); // 1, 2, 3, 1, 2, 3, 1, 2, 3
}
static void PrintEnumerableContents(IEnumerable<int> enumerable) {
Console.WriteLine(String.Join(", ", enumerable.Select(i => i.ToString(CultureInfo.InvariantCulture))));
}
View Code Fullscreen • "Examples of Collection Expressions"
The full rules governing what type a collection expression will actually return are viewable
in the language proposal.
You can now
using alias any type; including pointers, value tuples with named parameters, and
function pointers:
using NameAge = (string Name, int Age); // We can give the tuple properties names here and turn it in to a pseudo-class!
using unsafe FloatPtr = float*; // Notice the 'unsafe' modifier. New to C# 12 and required anywhere you want to alias a pointer-type.
using unsafe UserToIntFunc = delegate* managed<(string Name, int Age), int>; // I tried to use NameAge here instead of the value tuple explicitly, but no luck.
static int GetDoubleAge(NameAge user) => user.Age * 2;
static unsafe void Test() {
NameAge user = ("Ben", 33);
Console.WriteLine($"User's name is {user.Name}, and they are {user.Age} years old."); // "User's name is Ben, and they are 33 years old."
var f = 3f;
FloatPtr fPtr = &f;
*fPtr = 4f;
Console.WriteLine(f); // "4"
UserToIntFunc ageDoubler = &GetDoubleAge;
var doubleAge = ageDoubler(user);
Console.WriteLine($"Double my age is {doubleAge}."); // "Double my age is 66."
}
View Code Fullscreen • "Examples of New Using Aliases"
This small change makes it possible to declare a lambda (e.g. an anonymous function implementation) that has optional parameters, and also one that takes a
params argument:
var multiplierFunc = (string input, int numMultiples = 2) => String.Join(null, Enumerable.Repeat(input, numMultiples));
Console.WriteLine(multiplierFunc("hello")); // "hellohello"
var joinFunc = (params string[] input) => String.Join(null, input);
Console.WriteLine(joinFunc("hi", " ", "there")); // "hi there"
View Code Fullscreen • "Example of Optional and Params Lambda Arguments"
In the first example, multiplierFunc is declared with an optional parameter 'numMultiples'. When we invoke it on the next line we allow the default value of 2 to be used (notice how we only pass one argument to the delegate).
In the second example, joinFunc is declared with a single params parameter. On the next line, we pass three strings to the delegate as our params arguments.
You might think that the type of
multiplierFunc is
Func<string, int, string>, and the type of
joinFunc is
Func<string[], string>, but the compiler makes this feature work by creating hidden types for each delegate:
The type of multiplierFunc is actually f__AnonymousDelegate0<string, int, string>
This means that this syntax does
not work for non-implicitly-typed members or variables (including class members):
sealed class TestClass {
static readonly Func<string, int, string> _multiplierFunc = (string input, int numMultiples = 2) => String.Join(null, Enumerable.Repeat(input, numMultiples));
static readonly Func<string[], string> _joinFunc = (params string[] input) => String.Join(null, input);
static void Test() {
_multiplierFunc("hello"); // doesn't compile
_joinFunc("hi", " ", "there"); // doesn't compile
}
}
View Code Fullscreen • "Demonstration that Optional and Params Args Require Var"
There are probably use-cases for this I'm not thinking of, but when we already have
full local functions I'm not sure how often I'll use this feature.
This attribute can be applied to anything in a codebase (including the entire assembly) to indicate that the type/member/assembly is experimental. When any user consumes that annoted target their compiler will emit an error letting them know that they're using a construct that may change or be removed in the future:
[Experimental("FungusConsumer")]
static void ExperimentalMethod() {
Console.WriteLine("Eating a random mushroom I found in a field...");
}
static void Test() {
ExperimentalMethod(); // Compiler error here
}
View Code Fullscreen • "Example of Experimental Attribute"
The error can be bypassed either by marking your own code as
Experimental also, or by explicitly permitting the experimental code through with a compiler pragma:
#pragma warning disable FungusConsumer
static void Test() {
ExperimentalMethod(); // This now compiles, thanks to the pragma warning disable directives.
}
#pragma warning restore FungusConsumer
View Code Fullscreen • "Allowing Experimental Code"
This performance- and interop-oriented feature allows creating structs that have a fixed-length 'array' of contiguous elements contained within them. The following shows an example of a struct that encapsulates three strings:
[InlineArray(3)] // Notice this attribute (in System.Runtime.CompilerServices); this means this struct will act as an array of three elements.
struct ThreeStringBuffer {
string _; // The element type is defined by the single field declared in the struct. The name is unimportant and won't be seen anywhere, so I use '_'.
}
// .. Usage:
static void Test() {
var buffer = new ThreeStringBuffer();
buffer[0] = "Hello";
buffer[1] = "I'm";
buffer[2] = "Ben";
Console.WriteLine(buffer[0] + " " + buffer[1] + " " + buffer[2]); // Prints "Hello I'm Ben" on console
}
View Code Fullscreen • "Example of Inline Array"
In terms of memory usage/layout, this is equivalent to creating a struct that declares three separate consecutive string fields. And accordingly, unlike with
fixed-size buffers, you can use this feature with any non-pointer type (managed references will be tracked by the GC as normal).
You can do anything else you'd normally do with a struct type, including implementing interfaces:
[InlineArray(3)]
struct ThreeStringBuffer : IEnumerable<string> {
string _;
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
public IEnumerator<string> GetEnumerator() {
for (var i = 0; i < 3; ++i) yield return this[i];
}
}
static void Test() {
var buffer = new ThreeStringBuffer();
buffer[0] = "Hello";
buffer[1] = "I'm";
buffer[2] = "Ben";
Console.WriteLine(String.Join(" ", buffer)); // Prints "Hello I'm Ben" on console, but only if we implement IEnumerable<T> manually
}
View Code Fullscreen • "Inline Array IEnumerable<T> Implementation"
The interface implementation is required if you want to pass an inline array to a method that expects an
IEnumerable. However, the compiler can rewrite a foreach loop to a for loop for you around the buffer automatically, even without the implementation:
[InlineArray(3)]
struct ThreeStringBuffer {
string _;
}
static void Test() {
var buffer = new ThreeStringBuffer();
buffer[0] = "Hello";
buffer[1] = "I'm";
buffer[2] = "Ben";
foreach (var str in buffer) {
Console.WriteLine(str); // Writes Hello / I'm / Ben across three lines on console
}
// We can also use a standard for loop if we prefer:
for (var i = 0; i < 3; ++i) {
Console.WriteLine(buffer[i]); // Identical output as above
}
}
View Code Fullscreen • "Inline Array Foreach Iteration"
The
foreach and
for loop result in almost identical MSIL:
Top implementation is the foreach loop output, bottom is the for loop
Finally, inline arrays are implicitly convertible to
Span<T> and
ReadOnlySpan<T>, which means you can pass them as-is to methods that consume spans:
static void Test() {
var buffer = new ThreeStringBuffer();
buffer[0] = "Hello";
buffer[1] = "I'm";
buffer[2] = "Ben";
ConsumeSpan(buffer);
}
// Notice that this function takes any ReadOnlySpan<string>, but we passed it a ThreeStringBuffer instance just fine.
static void ConsumeSpan(ReadOnlySpan<string> span) {
foreach (var str in span) Console.WriteLine(str); // Writes Hello / I'm / Ben across three lines on console
}
View Code Fullscreen • "Inline Array as Span"
This new parameter modifier indicates that an argument passed in to a function should be passed by ref, but that the value the reference refers to can not be modified:
static void Test() {
var i = 4;
TryAdjustRefReadonly(ref i);
}
static void TryAdjustRefReadonly(ref readonly int i) {
i = 3; // Compiler error here: "Cannot assign to variable 'i' or use it as the right hand side of a ref assignment because it is a readonly variable"
}
View Code Fullscreen • "Example of Ref Readonly Parameters"
Without the
readonly demarcation on
ref readonly int i we could happily reassign
i to be 3 inside
TryAdjustRefReadonly. You may be wondering what the difference is between this new modifier and the pre-existing
'in' modifier. In a nutshell, we can explain the difference with the following bullets:
in as a modifier is somewhat "hidden" to the consumer of the function at the call-site. It converts the argument from being pass-by-value to pass-by-reference but in a mostly invisible way. There is no need to annotate the in argument with ref at the call-site (in fact, until C# 12 this invoked a compiler error), because the value can not be modified anyway; in parameters are purely a performance optimisation chosen by the function author (you can annotate with in if desired or necessary for overload resolution).
ref readonly as a modifier, broadly speaking, has the opposite philosophy; it wants to make it explicit that you're passing in a reference. Whereas in is arguably only ever used as a performance optimisation, ref and ref readonly can be used to deliberately and obviously indicate that a reference to a value is being passed for reasons relevant to the treatment of the given argument.
The implication of this clear separation is more than just cosmetic. The compiler will emit warnings when you use
in or
ref readonly with different types of values (e.g.
rvalues vs
lvalues) incorrectly or suspiciously:
static void ConsumeRefReadonly(ref readonly int i) { /* do something */ }
static void ConsumeIn(in int i) { /* do something */ }
static void Test() {
// Passing an lvalue (a value that has a memory location) requires the 'ref' keyword for the ref-readonly method only:
var i = 3;
ConsumeRefReadonly(ref i);
ConsumeIn(i);
// The 'ref' modifier is required on arguments to ref-readonly parameters, and is not recommended on arguments to in parameters.
// Swapping the 'ref' keyword presence compared to the last example emits warnings in both places:
ConsumeRefReadonly(i); // Compiles with warning: "Argument 1 should be passed with 'ref' or 'in' keyword"
ConsumeIn(ref i); // Compiles with warning: "The 'ref' modifier for argument 1 corresponding to 'in' parameter is equivalent to 'in'."
// Passing rvalues (a value that may or may not have a memory location) is permitted in both cases, but emits a warning for ref-readonly arguments:
ConsumeRefReadonly(123); // Compiles with warning: "Argument 1 should be a variable because it is passed to a 'ref readonly' parameter"
ConsumeIn(123);
}
View Code Fullscreen • "Ref Readonly Arguments vs In Arguments"
In a nutshell, use
ref readonly when you specifically want a reference to a value that has a memory location (e.g. the constructor of
ReadOnlySpan<T> has been updated to use
ref readonly rather than
in), and use
in when an rvalue (such as a constant, e.g.
123) is perfectly acceptable.