Ben Bowen's Blog • Home / Blog • • About • • Subscribe •

Two Decades of C#: A Reference - C# 10 

Since C# was introduced in 2000 the language has grown immensely in size and I'm not sure it's possible for any one person to have intimate knowledge of every language feature in their head at all times. Therefore I wanted to write a series of quick reference posts summarizing all the major new language features ever since C# 2.0. I won't go in to detail with any of them, but instead I want this series to act as a reference for myself (and hopefully you too!) that I can go back to from time-to-time to remember what tools I have in the toolbox. :)

One small note before we start: I'm going to skip some of the more fundamental stuff (for example C# 2.0 introduced generics, but they're so widespread in usage that they're not really worth including); and I may also 'glue' a few features together for the sake of making this more terse. This series isn't meant to be a definitive or historical record of the language; instead it's meant as more of a "cheat-sheet" of important language features that might come in handy. You may find it useful to skim through the table-of-contents on the left to search for any features you don't recognise or need a quick reminder on.

C# 10.0 

File-Scoped Namespace Declaration 

I put this one first as it's been on my personal wishlist for years. It's probably the thing I've always missed the most from Java and it always frustrated me that no other C# engineer I ever spoke to seemed to care!

Anyway, the feature is as follows: With C# 10 we can now declare that every member in a file is scoped under a certain namespace; rather than using a specific scope (with { and }). For example:

// Before
namespace BenBowen.Blog.FileScopedNamespaceDemo {
	public class User {

	}
}



// After
namespace BenBowen.Blog.FileScopedNamespaceDemo;

public class User {

}
View Code Fullscreen • "File-Scoped Namespace Declarations Before and After"
The file-scoped namespace ends with a semicolon ; rather than starting a scope, and must come before any other member declaration in the file (before or after using declarations, but preferably after by convention).

This means all your code can have one less unnecessary indentation level! Finally 👨‍🍳👌!

Extended Property Patterns 

Property patterns have been improved to chain nested members more naturally using the dot . operator. Take this example from my previous post:

// Taken from https://benbowen.blog/post/two_decades_of_csharp_iv/property_patterns.html
var salary = user switch {
	Manager { ManagerialLevel: ManagerialLevel.CLevel } => 100_000, // C-Level managers get 100,000
	Manager { ManagerialLevel: ManagerialLevel.UpperManagement } => 70_000, // Upper managers get 70,000
	Manager _ => 50_000, // All other managers get 50,000
	{ LastAppraisal: { Rating: 10 } } => 40_000, // Users whose last appraisal gave them a 10/10 rating get 40,000
	_ => 30_000 // Everyone else gets 30,000
};
View Code Fullscreen • "Property Patterns"
We can now rewrite line 6 to this:

{ LastAppraisal.Rating: 10 } => 40_000, // Users whose last appraisal gave them a 10/10 rating get 40,000
View Code Fullscreen • "Extended Property Patterns"
Don't forget property patterns can be used in is expressions:

if (user is { LastAppraisal.Rating: 100 } or Manager { KeyMetrics.FinancialTargetStatus: TargetStatus.AboveTarget }) {
	GiveBonus();
}
View Code Fullscreen • "Extended Property Pattern in Is-Expression"

Global Using Directives 

Prepending the word global in front of a using directive (that's the directive that's used to import namespaces, typically at the top of the file) will mean that namespace is imported in to every file in your project.

Something I found quite fascinating is that using aliases also work, which could be very handy for name clashes between two dependency libraries. Here's an example:

global using System.Linq;
global using StringList = System.Collections.Generic.List<string>;
View Code Fullscreen • "GlobalImports.cs"
// No using statements here

var list = new StringList { "Hello", "I", "Am", "A", "List" }; // StringList instead of List<string>!
Console.WriteLine(list.Sum(str => str.Length)); // And we can use Linq!
View Code Fullscreen • "Main.cs"

Custom Parameterless Struct Constructors 

Until now it has not been possible to specify a custom parameterless constructor in any struct. However, with C# 10 this functionality has been added:

public readonly struct User {
	public readonly string Name;
	public readonly int Age;

	public User() { // Struct constructor defined with no parameters!
		Name = "<no name>";
		Age = -1;
	}
}

// ...

var u = new User();
Console.WriteLine(u.Name + " " + u.Age); // Prints "<no name> -1"
View Code Fullscreen • "Parameterless Struct Constructor"
As you can see in the example above, it's now possible to specify a parameterless constructor for a struct. If you explicitly want the default value of a struct, you can still use the default keyword (i.e. var user = default(User)), which will not use the user-defined constructor. Parameterless constructors must be public.

This feature was previously unsupported because it creates a potential pitfall for unsuspecting library authors. Although the addition of this functionality is a welcome improvement, I'll now demonstrate that there are numerous ways you can make mistakes using parameterless struct constructors (and associated features):

Struct Field Inline-Initializers 

Before C# 10, it was not permitted to inline-assign values to fields/properties in the struct definition. This made sense, as this syntax is essentially a shorthand for assigning the given values at the beginning of every constructor in the type, and this would include the default constructor. However, C# 10 now permits this:

public readonly struct User {
	public readonly string Name = "<no name>";
	public readonly int Age = -1;

	public User(string name, int age) {
		Name = name;
		Age = age;
	}
}

// ...

var u = new User();
Console.WriteLine(u.Name + " " + u.Age);
View Code Fullscreen • "Inline Struct Field Initialization"
However, what do you think will be printed on the console in this example? If you thought <no name> -1, I'm sorry to tell you that's not correct (but that was my first guess too)! In fact we'll see nothing and 0 which are the default values for Name and Age. But what happens if we take away the other constructor?

public readonly struct User {
	public readonly string Name = "<no name>";
	public readonly int Age = -1;
}

// ...

var u = new User();
Console.WriteLine(u.Name + " " + u.Age);
View Code Fullscreen • "Inline Struct Field Initialization With No Constructors"
Now we do see <no name> -1 on the console. I will admit at first this really surprised me, but after a little more thought it makes some sense:

In the example above with inline field initialization and a constructor also provided, it's worth thinking about what this code would look like if User was a class. In actuality, we wouldn't be able to invoke a parameterless constructor in this case, because providing any constructor at all in a class type removes the default one.
However, structs must still always be instantiable with a parameterless constructor, so the parameterless constructor in the struct version of User is not removed by the compiler but instead reverts to acting much like it always has done in the past, providing us a default value (and ignoring our field initializers).

So when would you want to use field initializers? Well, they're still useful for setting default values when some constructors will not provide those values:

public readonly struct User {
	public readonly string Name;
	public readonly int Age = -1;

	public User(string name) => Name = name;

	public User(string name, int age) {
		Name = name;
		Age = age;
	}
}

// ...

var u = new User("Ben");
Console.WriteLine(u.Name + " " + u.Age);
View Code Fullscreen • "Inline Struct Field Initialization With Multiple Constructors"
In the above example, we will see Ben -1 printed to the console.

The compiler will still show an error if you neglect to initialize a field in any constructor; either through direct assignment, field initializers, or explicitly chaining your constructor to call another that does assign it.

All-in-all, I'm not sure allowing us to shoot ourselves in the foot this way is wise. It feels like a bit of a footgun- adding a non-parameterless constructor to a struct that was previously using only field initialization will change the behaviour of every invocation of its parameterless constructor throughout the codebase from using the field initializers to instead acting like default(). I do like the addition of custom parameterless struct constructors, but I could have happily lived without field initializers. I don't think they add much but they have the potential to lead to constly and confusing mistakes, and in my codebases I will probably not use them as much as is possible. I also feel like programmers who aren't aware of these subtleties may get confused when using them or reading any code written that uses them.

Arrays and Uninitialized Fields 

So what happens when we have a struct type with a custom parameterless constructor that we want to use as a field in another class or as an array type?

public readonly struct User {
	public readonly string Name;
	public readonly int Age;

	public User() {
		Name = "<no name>";
		Age = -1;
	}
}

public sealed class UserWrapper {
	public User WrappedUser { get; }
}

// ...

var uw = new UserWrapper();
Console.WriteLine(uw.WrappedUser.Name + " " + uw.WrappedUser.Age); // What do you think this will print?
View Code Fullscreen • "Struct With Custom Parameterless Constructor as Field"
This prints nothing and a 0 again, i.e. the default value of the User type. This means the parameterless constructor is not being invoked.

This is what I expected, and you should too. As we already saw above, C# now differentiates between the idea of a struct's default value and its value as initialized by a parameterless constructor. Before C# 10 we could consider these two values one and the same, but now we need to be more careful in thinking about which we're going to get in any given situation. Because we never explicity invoked a constructor for WrappedUser, we get its default value.

Note that this differentiation also has implications for anywhere that treats new MyStructType() as a constant. For example, whereas public static void MyExampleMethod(MyStructType input = new MyStructType()) { ... } would always compile, it now may not if MyStructType specifies a custom parameterless constructor.

So what about with arrays?

var userArray = new User[3];
Console.WriteLine(userArray[0].Name + " " + userArray[0].Age);
View Code Fullscreen • "Struct With Custom Parameterless Constructor as Array Type"
Hopefully you will be expecting this to print nothing and a 0 again, because that's what we get.

Generics 

And what about as generic type parameters?

public readonly struct User {
	public readonly string Name;
	public readonly int Age;

	public User() {
		Name = "<no name>";
		Age = -1;
	}
}

// ...

static void PrintStructDetails<T>() where T : struct {
	void EnumerateFieldsOnToConsole(T instance, [CallerArgumentExpression("instance")] string? instanceName = null) { // If you're confused about this line, see the "Caller Argument Expressions" section below
		foreach (var field in typeof(T).GetFields()) {
			Console.WriteLine($"Field '{field.Name}' in {instanceName}: {field.GetValue(instance)}");
		}
	}

	var newedInstance = new T();
	var defaultInstance = default(T);

	EnumerateFieldsOnToConsole(newedInstance);
	EnumerateFieldsOnToConsole(defaultInstance);
}
View Code Fullscreen • "Generic Structs With Custom Parameterless Constructors"
Invoking PrintStructDetails<User>() results in the following being printed on the console:

Field 'Name' in newedInstance: <no name>
Field 'Age' in newedInstance: -1
Field 'Name' in defaultInstance:
Field 'Age' in defaultInstance: 0
View Code Fullscreen • "PrintStructDetails Output"

Struct Records 

I described records in my previous installment of this series: Record Types. This new feature, enabled by custom parameterless constructors for structs, simply allows you to declare records as structs (value-types) rather than the default (classes/reference-types). You can additionally declare struct records as readonly (just like regular structs):

public readonly record struct User(string Name, int Age);

// ...

var user = new User { Name = "Ben", Age = 32 };
user.Name = "Seb"; // Won't compile, 'Name' is init-only
View Code Fullscreen • "Simple Struct Record Type Definition"
Unlike with 'standard' (i.e. class/reference-type) records, by default a struct record's auto-generated properties are mutable (i.e. they have setters generated for them). You must specify the struct record as readonly to make its auto-generated properties init-only. Therefore, simply changing a standard record to a struct record actually makes your auto-generated properties more mutable than they were before, which is probably not what you want!

Caller Argument Expressions 

You may recall Caller Info Attributes from a previous version of C#. C# 10 now adds another, the CallerArgumentExpressionAttribute. This attribute will automatically fill the value in code that was passed to another parameter. It's probably easiest to understand with an example:

static void Evaluate(int value, [CallerArgumentExpression("value")] string? expression = null) {
	Console.WriteLine($"{expression} = {value}");
}

Evaluate(1512 - 19 * 7); // Prints "1512 - 19 * 7 = 1379" on the console
View Code Fullscreen • "CallerArgumentExpressionAttribute Example"

'With' Expressions on Structs 

'With' expressions were introduced in C# 9 as a way to create new record-type instances by slightly modifying a copy of an existing instance. In C# 10 they can now be used automatically with mutable structs:

public struct User {
	public string Name;
	public int Age;

	public User(string name, int age) {
		Name = name;
		Age = age;
	}
}

// ...

var user = new User("Ben", 31);
var birthdayBoy = user with { Age = user.Age + 1 };
Console.WriteLine(birthdayBoy.Name + " is now " + birthdayBoy.Age); // Prints "Ben is now 32"
View Code Fullscreen • "'With' Expression on Struct"
Mutable structs are generally ill-advised, but luckily this feature also works with init-only properties:

public readonly struct User {
	public string Name { get; init; }
	public int Age { get; init; }
}

// ...

var user = new User { Name = "Ben", Age = 31 };
var birthdayBoy = user with { Age = user.Age + 1 };
Console.WriteLine(birthdayBoy.Name + " is now " + birthdayBoy.Age); // Prints "Ben is now 32"
View Code Fullscreen • "'With' Expression Support for Immutable Structs"
Thanks to /u/meancoot on Reddit for pointing that out to me!

Const Interpolated Strings 

You can now use interpolated strings in constant declarations when every component of that string is itself a constant string:

const string AppVersion = "1.2.3";
const string WelcomeMessage = $"Thanks for installing SuperDuperApp. You are running version {AppVersion}.";
View Code Fullscreen • "Const Interpolated String"

Custom Interpolated String Handlers 

This feature allows you to manually handle the interpolation logic for interpolated strings in your APIs. The primary use-case for this feature is performance-oriented scenarios; for example allowing you to avoid building the resultant string when you know it won't be used anyway.

There are examples in MS's docs of this scenario already, so I'm going to mix it up a bit. Let's pretend we're dealing with sending data to an embedded hardware device, and we know our string can never be more than 100 UTF-8 bytes due to memory limitations on that device.

And then, let's say that in the case where we have an interpolated string that will end up larger than 100 bytes, we want to prioritize showing the dynamic data (i.e. the data within the {} braces) rather than the static/constant string literal parts. We have no choice but to truncate the string somehow (because the max is 100 bytes), so it's probably better to keep as much of the dynamic data as we can!

First of all, we must define our API that will send data to our embedded device:

public class EmbeddedClientStream {
	public const int MaxMessageLength = 100;

    public void SendMessage(string message) {
		// TODO truncate to 100 bytes max after encoding and send message

		Console.WriteLine($"Sent message: \"{message}\" ({Encoding.UTF8.GetBytes(message).Length} bytes UTF8)");
	}

    public void SendMessage(EmbeddedClientStreamInterpolatedStringHandler interpolatedStringHandler) {
        SendMessage(interpolatedStringHandler.GetFormattedText());
	}
}
View Code Fullscreen • "Embedded Client Stream API"
This is just our API, the actual implementation here doesn't matter. We're offering a method SendMessage that takes a string parameter to send to our embedded device; and an additional overload that takes an EmbeddedClientStreamInterpolatedStringHandler. This type, which we will define in a moment, is how we can inject special interpolation logic in to our API. We call GetFormattedText() on it to get our final resultant string post-interpolation.

It's always a good idea to offer an overload that works with plain string inputs when creating an API that uses a custom interpolated string handler. This means that your API can be used with regular non-interpolated strings as effortlessly as with interpolated ones.

Here's our implementation for EmbeddedClientStreamInterpolatedStringHandler:

[InterpolatedStringHandler]
public readonly ref struct EmbeddedClientStreamInterpolatedStringHandler {
	readonly List<(bool WasDynamicElement, Memory<byte> EncodedData)> _data;

	public EmbeddedClientStreamInterpolatedStringHandler(int literalLength, int formattedCount) {
		// literalLength is the sum total length of all the literal 'sections' of the interpolated string
	    // formattedCount is the number of non-literal components to the string (i.e. the number of elements demarcated with {} braces)
		// I'm not going to use either of them here

		_data = new();
	}

	public void AppendLiteral(string s) { // This method is called to append a section of the literal (non-interpolated) part of the string
		_data.Add((false, Encoding.UTF8.GetBytes(s)));
	}

	public void AppendFormatted<T>(T obj) { // This method is called to append a dynamic object (i.e. an element enclosed in {} braces)
		_data.Add((true, Encoding.UTF8.GetBytes(obj?.ToString() ?? "")));
	}

	public void AppendFormatted<T>(T obj, string format) where T : IFormattable { // This method is called to append a dynamic object with a format string
		_data.Add((true, Encoding.UTF8.GetBytes(obj?.ToString(format, null) ?? "")));
	}

	public void AppendFormatted(byte[] obj) { // You can even supply specific methods for handling specific types 
		AppendFormatted(String.Join(null, obj.Select(b => b.ToString("x2"))));
	}

	public string GetFormattedText() {
		var totalLength = _data.Sum(tuple => tuple.EncodedData.Length);

		// Note: There's a more efficient way to do this; we could pre-calculate this as the data comes in.
		// But it's a blog post about InterpolatedStringHandlers, not efficient algorithms, and I'm tired ;).
		// And this will be at the bottom of the post where no one gets to anyway. Prove me wrong by leaving a comment!
		while (totalLength > EmbeddedClientStream.MaxMessageLength && _data.Count > 0) {
			var lastStaticElementIndex = -1;
			totalLength = 0;

			for (var i = _data.Count - 1; i >= 0; --i) {
				if (lastStaticElementIndex > 0 || _data[i].WasDynamicElement) totalLength += _data[i].EncodedData.Length;
				else lastStaticElementIndex = i;
			}

			_data.RemoveAt(lastStaticElementIndex > -1 ? lastStaticElementIndex : _data.Count - 1);
		}

		return String.Join(null, _data.Select(tuple => Encoding.UTF8.GetString(tuple.EncodedData.Span)));
	}
}
View Code Fullscreen • "Embedded Client Stream Custom Interpolated String Handler"
Now, here's what happens when I send messages with this API:

var clientStream = new EmbeddedClientStream();
var messageAData = new byte[] { 0x3F, 0x7B, 0x14, 0x00 };
var messageBData = new byte[] { 0x47, 0x21, 0xAE, 0x10, 0x3F, 0x7B, 0x14, 0x00 };
var messageCData = new byte[] { 0x4B, 0x6A, 0x77, 0xFF, 0x47, 0x21, 0xAE, 0x10, 0x3F, 0x7B, 0x14, 0x00 };

clientStream.SendMessage($"Discovered missing packet (data {messageAData}). Please ensure shielding is applied (on {LineVoltage.FiveVolt:G} line)!");
clientStream.SendMessage($"Discovered missing packet (data {messageBData}). Please ensure shielding is applied (on {LineVoltage.FiveVolt:G} line)!");
clientStream.SendMessage($"Discovered missing packet (data {messageCData}). Please ensure shielding is applied (on {LineVoltage.FiveVolt:G} line)!");

// Result on console:
// Sent message: "Discovered missing packet (data 3f7b1400). Please ensure shielding is applied (on FiveVolt line)!" (97 bytes UTF8)
// Sent message: "Discovered missing packet (data 4721ae103f7b1400). Please ensure shielding is applied (on FiveVolt" (98 bytes UTF8)
// Sent message: "Discovered missing packet (data 4b6a77ff4721ae103f7b1400FiveVolt" (64 bytes UTF8)
View Code Fullscreen • "Embedded Client Stream API Usage"
Notice how our first message that contained only 4 bytes of data was short enough to include the entire string. However, message 'B' had 8 bytes of data to display, and that was enough to push our encoded data length past 100 bytes, so our interpolated string handler chose to lop off the end of the line (" line)!"). Finally, message 'C' had so much data that our interpolated string handler had to remove even more; but instead of just truncating off the end of the message it chose to remove the next string literal component and keep the important LineVoltage parameter in the message content.

In reality, you'd probably want to implement something a bit smarter in a scenario like this that made it clear where parts of the string were being smashed together, but this is just an example!

So, how does this work?

First of all, notice that this is a readonly ref struct. This example can actually work without the ref modifier just fine, but I wanted to demonstrate that these handlers can be ref structs which means you can store spans as fields in them. The readonly modifier is also optional.
Secondly, notice that we've annotated this type with the InterpolatedStringHandlerAttribute. Omitting this attribute means the compiler isn't aware that our struct is an interpolated string handler, and instead the compiler falls back to binding to the SendMessage(string message) overload in our API. The constructor must take two int parameters. If it doesn't, the compiler gives an error when trying to compile calls to SendMessage().
When we actually invoke SendMessage(), instead of interpolating the string using String.Format() and passing that to SendMessage(string) as usual, the compiler will notice that we have an overload of SendMessage() that takes a type marked as an InterpolatedStringHandler. It will construct an instance of our handler, and then begin using the AppendLiteral() and AppendFormatted() methods to construct the resultant string. I've attached the IL output of one call to clientStream.SendMessage() below:

    IL_003d: ldloc.0      // clientStream
    IL_003e: ldloca.s     V_4
    IL_0040: ldc.i4.s     81 // 0x51
    IL_0042: ldc.i4.2
    IL_0043: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::.ctor(int32, int32)
    IL_0048: ldloca.s     V_4
    IL_004a: ldstr        "Discovered missing packet (data "
    IL_004f: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendLiteral(string)
    IL_0054: nop
    IL_0055: ldloca.s     V_4
    IL_0057: ldloc.1      // messageAData
    IL_0058: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendFormatted(unsigned int8[])
    IL_005d: nop
    IL_005e: ldloca.s     V_4
    IL_0060: ldstr        "). Please ensure shielding is applied (on "
    IL_0065: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendLiteral(string)
    IL_006a: nop
    IL_006b: ldloca.s     V_4
    IL_006d: ldc.i4.1
    IL_006e: ldstr        "G"
    IL_0073: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendFormatted<valuetype TestingStuff.LineVoltage>(!!0/*valuetype TestingStuff.LineVoltage*/, string)
    IL_0078: nop
    IL_0079: ldloca.s     V_4
    IL_007b: ldstr        " line)!"
    IL_0080: call         instance void TestingStuff.EmbeddedClientStreamInterpolatedStringHandler::AppendLiteral(string)
    IL_0085: nop
    IL_0086: ldloc.s      V_4
    IL_0088: callvirt     instance void TestingStuff.EmbeddedClientStream::SendMessage(valuetype TestingStuff.EmbeddedClientStreamInterpolatedStringHandler)
    IL_008d: nop
View Code Fullscreen • "IL For One Invocation of ClientStream.SendMessage()"

Edit 22nd Jan '22: I had originally said that 'with' statements on structs were not possible with immutable/readonly structs. That is actually wrong, they work with init-only properties. Thank you to /u/meancoot on reddit for pointing that out.