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

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

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# 8.0 

Nullable Reference Types 

This feature is a large addition to C# that is aimed at helping prevent null reference exceptions at runtime by adding compile-time correctness checking.

Enabling the Feature 

To enable nullable checking in a project, add <Nullable>enable</Nullable> underneath the target framework declaration in the .csproj file:

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net5.0</TargetFramework>
	<Nullable>enable</Nullable>
  </PropertyGroup>
View Code Fullscreen • "Nullable Checking Enabled in Project File"
Alternatively, for existing codebases that you want to slowly convert to nullable-checked, compile-time directives can enable/disable this feature in source:

#nullable enable
// Nullable references enabled here

#nullable disable
// Nullable references disabled here

#nullable restore
// Resets status to project settings (i.e. disabled unless <Nullable>enable</Nullable> is specified in csproj, in which case enabled)

// See also https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/preprocessor-directives/preprocessor-nullable
View Code Fullscreen • "Nullable Directives"
If you have nullable references turned off project wide, it's recommended to use #nullable enable and #nullable restore (rather than #nullable disable) so that when you do enable it project-wide, you don't accidentally disable it in places.

Basic Usage 

It is up to the programmer to demarcate every field, parameter, or property (of a reference-type, i.e. class) as nullable or non-nullable. All fields/properties/parameters not declared with the question-mark syntax (i.e. string?) are considered non-nullable. These members should never have a null value set.
The compiler will emit warnings if it detects that a non-nullable field/property/parameter could have a null value assigned to it.
The compiler will emit warnings if it detects that you are attempting to dereference a nullable field/property/parameter without ensuring that its value is not null.

The following example shows a class that contains a nullable and non-nullable field and property, as well as a method that takes a nullable and non-nullable parameters:

#nullable enable
class TestClass {
	// If we don't assign a value to _nonNullableField here or in the constructor, the compiler will warn us
	string _nonNullableField;
	
	// We don't have to supply any initial value for a nullable field, the default value of null is acceptable
	string? _nullableField;

	// If we don't assign a value to NonNullableProperty here or in the constructor, the compiler will warn us
	public string NonNullableProperty { get; set; } = "Hello";
	
	// We don't have to supply any initial value for a nullable property, the default value of null is acceptable
	public string? NullableProperty { get; set; }

	public TestClass(string? initialFieldValue) {
		// If we just assign initialFieldValue to _nonNullableField without the null-coalesced fallback value, the compiler will warn us
		_nonNullableField = initialFieldValue ?? "Hi";
	}
	
	public void PrintStringLengths(string nonNullableParameter, string? nullableParameter) {
		Console.WriteLine($"Non-nullable parameter length is: {nonNullableParameter.Length}");
		
		// If we don't use the null-propagation operator (?.) or check nullableParameter for null first, the compiler will warn us
		Console.WriteLine($"Nullable parameter length is: {nullableParameter?.Length.ToString() ?? "<null>"}");
	}
}
View Code Fullscreen • "Nullable and Non-Nullable Fields, Properties, and Parameters"
In summary:
The compiler will warn us if we don't assign non-null values to _nonNullableField and NonNullableProperty before the constructor returns;
The compiler will warn us if we don't provide a non-null alternative value when assigning initialFieldValue to _nonNullableField in the constructor;
The compiler will warn us if we attempt to access the Length property of a string? parameter without checking for null.

The null-forgiving operator (!) requests the compiler to ignore a potential null value:

public void PrintFieldLengthsIfNonNull() {
    if (_nullableField != null) PrintFieldLengths();
}

void PrintFieldLengths() {
    Console.WriteLine($"Non-nullable field length is: {_nonNullableField.Length}");
    
	// Null-forgiving operator (!) disables compiler warning here caused by accessing .Length property of potentially-null _nullableField without null check
	Console.WriteLine($"Nullable field length is: {_nullableField!.Length}");
}
View Code Fullscreen • "Null-forgiving Operator"
In places where you want to leave null as the value for a non-nullable reference (say, for example, you know that the value will always be set through other means, or you're passing a null to unit test a class, etc), you can use the null-forgiving operator with a null or default literal (i.e. null! or default!):

// We know this property will have a non-null value set before it is used no matter what so
// we can tell the compiler to not worry about it being null here:
public string Name { get; } = null!; 
View Code Fullscreen • "Null-Forgiven Property"

Generics 

An unconstrained and un-annotated generic method simply uses the given type parameters as stated at the call site:

static T ReturnInput<T>(T t) => t;

var x = ReturnInput<string>(null); // Emits a warning: Passing a null reference to an input of type 'string'
var y = ReturnInput<int?>(null); // No warning, passing null to an input of type 'int?' is fine
var z = ReturnInput((object?) null); // No warning, passing null to an input of type 'object?' is fine
View Code Fullscreen • "Non-annotated Generic Method"
Note that the compiler would still emit a warning if we return default from ReturnInput, even though it has no null annotations. This is because if T is non-nullable but of a reference type (i.e. string), returning default will return an invalid null value.

To use nullables in a generic context, the generic type should be constrained as a struct or class:

static T? ReturnInput<T>(T? t) where T : struct => t;

var p = ReturnInput("test"); // Doesn't compile, 'string' isn't a struct
var q = ReturnInput(3); // Doesn't compile, 'int' is a struct but the compiler can't make the leap that you want to 
						// implictly convert an object of type 'int' to 'int?', and that therefore T should be 'int' 
						// (add explicit type parameter indication in angle brackets to fix)
var r = ReturnInput<int>(null); // Compiles, T is 'int' and the input parameter is default(Nullable<int>)
View Code Fullscreen • "Using 'struct' constraint with nullable references:"
static T? ReturnInput<T>(T? t) where T : class => t;

var x = ReturnInput<string>(null); // Compiles absolutely fine, T is 'string' and therefore the parameter type is 'string?'
var y = ReturnInput<int?>(null); // Does not compile: 'int?' is not a reference type (it's an alias for Nullable<T>)
var z = ReturnInput((object?) null); // Compiles absolutely fine, T is inferred to be 'object'
View Code Fullscreen • "Using 'class' constraint with nullable references:"
It is also possible to indicate that a type parameter may be of a nullable type when constraining to interfaces/subtypes:

static T? ReturnInput<T>(T? t) where T : IComparable? => t; // Notice nullable token (?) after IComparable constraint

var p = ReturnInput<string?>("test"); // No warning. Compiler would warn us if the constraint was 'IComparable' without the nullable token (?)
var q = ReturnInput(3); // No warning. Int32 implements IComparable, so this is fine. 
						// The nullable token (?) on the constraint indicates that the type MAY be nullable, not that it MUST be
var r = ReturnInput<int?>(3); // Doesn't compile. Nullable<T> has never been able to satisfy interface constraints
View Code Fullscreen • "Using nullable interface constraint"
Currently, there is no constraint or any way otherwise to write a generic method that accepts any nullable type (i.e. both nullable reference types and nullable structs). Conversely, the new notnull constraint can be used to disallow nullable types:

static T? ReturnInput<T>(T? t) where T : notnull => t; // Notice nullable token (?) after IComparable constraint

var x = ReturnInput<string?>("test"); // Emits a warning, 'string?' invalidates the notnull constraint for T
var y = ReturnInput((int?) 3); // Emits a warning, 'int?' invalidates the notnull constraint for T
var z = ReturnInput((object?) null); // No warning. T is implictly set to 'object' rather than 'object?'. 
									 // Notice that we can still accept maybe-null parameters and return maybe-null values, 
									 // the 'notnull' constraint applies to the type parameter itself, not method parameters
									 // or return values.
View Code Fullscreen • "Using notnull constraint"

Attributes 

The System.Diagnostics.CodeAnalysis namespace provides some new attributes that can be applied to various code elements in your own APIs/code to assist the compiler in determining null correctness:

// AllowNull indicates that a null value is permitted when setting/passing a value.

// Although the type of this property is string (and not string?) we will allow people to 'set' a null value,
// which will actually be replaced with a non-null fallback value. Hence we [AllowNull].

[AllowNull] 
public string NonNullableProperty {
	get => _nonNullableField;
	set => _nonNullableField = value ?? "fallback";
}
View Code Fullscreen • "Null-Correctness Assisting Attributes: AllowNull"
// DisallowNull indicates that a null value is not permitted when setting/passing a value.

// Although the type of this property is string? we don't wish to allow anyone to actually *set* a null value,
// it's just that the value may be still be null when retrieved. Hence we mark it with [DisallowNull].

[DisallowNull]
public string? NullableProperty {
	get => _nullableField;
	set => _nullableField = value ?? throw new ArgumentNullException(nameof(value));
}
View Code Fullscreen • "Null-Correctness Assisting Attributes: DisallowNull"
// MaybeNull applied to a return element indicates that the returned value may be null.
// This is useful when working with generics, as 'T?' isn't always valid.

[return: MaybeNull]
public T GetElementByName<T>(string name) { /* ... */ }
View Code Fullscreen • "Null-Correctness Assisting Attributes: MaybeNull"
// NotNull indicates that a ref or out parameter, that is marked as nullable, will never be set as null after the method returns.

public void AppendHello([NotNull] ref string? a) { 
	if (a == null) a = "Hello";
	else a = a + "Hello";
}
View Code Fullscreen • "Null-Correctness Assisting Attributes: NotNull"
// NotNullWhen indicates that a parameter is not null when the return value of a method is true or false.

// The annotation here indicates that when 'TryParseUser()' returns true, 'u' will always have a value assigned.

public bool TryParseUser(string userIdentifier, [NotNullWhen(true)] out User u) { /* ... */ }
View Code Fullscreen • "Null-Correctness Assisting Attributes: NotNullWhen"
There are some additional attributes suitable for rarer use-cases, all can be found here: Reserved attributes contribute to the compiler's null state static analysis.

Implicitly Typed Variables 

Any local variable declared with var will always be declared as nullable, even if the right-hand expression does not evaluate to a nullable type:


The reason is given in the notes for a language design meeting:

"At this point we've seen a large amount of code that requires people spell out the type instead of using var, because code may assign null later."

However, don't worry. Even though the type is marked as nullable, the compiler uses flow analysis to determine whether the value can actually be null. Assuming the value you assigned was non-nullable, this means you can still pass an implictly-typed variable to methods that expect non-nullable references and dereference the variable without a warning; until/unless you assign a new nullable value to that variable.

In some sense, local variables created with var in a nullable context can be thought of as being in a state of "can be assigned a nullable value, but actual null-state is being tracked by the compiler". Therefore, I personally like to think of var-declared locals as being of a hybrid 'tracked nullable' type.

Overriding/Implementing Methods 

The C# compiler is aware of nullability within the context of overriding/implementing methods. It takes in to account covariance and contravariance; meaning you can remove nullability on return types and add nullability on inputs, but not the inverse:

public interface ITest {
	public string? GetStr();
	public void SetStr(string? s);

	public string GetStrNonNull();
	public void SetStrNonNull(string s);
}

public class Test : ITest {
	public string GetStr() => "Hello"; // Fine!
	public void SetStr(string s) { } // Warning here because you can not remove nullability on an input (i.e. parameter)

	public string? GetStrNonNull() => null; // Warning here because you can not add nullability on an output (i.e. return value)
	public void SetStrNonNull(string? s) { } // Fine!
}
View Code Fullscreen • "Overriding / Implementing Nullability"

Fallibility 

Unfortunately it is still possible to create situations where a null reference exception is possible without any warnings:

var stringArray = new string[10];
Console.WriteLine(stringArray[0].Length); // NRE here, no warning!
View Code Fullscreen • "Simple Null Reference Exception in Nullable Context using Arrays"
Even though our array type is string (instead of string?), there is no way for the compiler to force us to initialize every element in the array with a non-null value. Consequently, a dereference on the second line passes the 'null test' (because the type returned by the expression stringArray[0] is of a non-nullable type), so there is no warning emitted, but we end up with a null reference exception at runtime anyway.

A similar effect can be seen with structs:

readonly struct TestStruct {
	public readonly string S;
	public TestStruct(string s) => S = s;
}

sealed class TestClass {
	TestStruct _ts;

	public void SetTS(TestStruct ts) => _ts = ts;

	public void PrintSLength() {
		Console.WriteLine(_ts.S.Length); // NRE here if _ts hasn't been assigned a value yet
	}
}
View Code Fullscreen • "Simple Null Reference Exception in Nullable Context using Structs"
Because the default value for any struct is simply zeroes for every field, any reference-type field will be set to null. Therefore, until someone invokes SetTS(), _ts will be equal to default(TestStruct), which means _ts.S will be null.

Like before, because _ts.S returns a string rather than a string?, the compiler does not emit a warning for the dereference, and we end up with a null reference exception at runtime.

Default Interface Implementations 

This feature allows specifying a default implementation for interface methods:

interface IExampleInterface {
    int GetAlpha();

    int GetBravo() => 456;
}

class ExampleClass : IExampleInterface {
    public int GetAlpha() => 123;
}

class Program {
    static void Main() {
        IExampleInterface e = new ExampleClass();

        Console.WriteLine(e.GetAlpha()); // Prints '123'
        Console.WriteLine(e.GetBravo()); // Prints '456'
    }
}
View Code Fullscreen • "Basic DIM Example"
Even though ExampleClass doesn't implement IExampleInterface.GetBravo(), because there is a default implementation specified we can still invoke e.GetBravo().

This feature is primarily designed to help library/API maintainers add new methods to existing interfaces without the risk of breaking existing classes that implement the interface downstream. If you have hundreds or thousands of classes that implement an interface it can become prohibitively expensive to alter that interface without using DIM.

Some people raised concerns that this feature 'breaks' the purposes of interfaces (i.e. "interfaces are meant to be a contract, and should not have implementations"). However, the 'meaning' of an interface doesn't change: It remains a mechanism for forward-declaration of a set of methods that a type must implement to support a facet of functionality. The only difference is that now in cases where a sensible default implementation can be provided, we can offer that default.

Note that default implementations are only imported as explicit implementations, and therefore can not be used as regular public methods on implementing classes:

ExampleClass e = new ExampleClass(); // Note 'e' is now of type ExampleClass rather than IExampleInterface

Console.WriteLine(e.GetAlpha());
Console.WriteLine(e.GetBravo()); // This line does not compile. We must cast 'e' to type IExampleInterface to use this method.
View Code Fullscreen • "DIM Explicit Implementation Example"
Unfortunately, as of time of writing, there is no officially supported way to invoke the default implementation for a method from an overriding implementation (much like base.Method() for class inheritance). It was planned but ultimately dropped before release.

However, if you wish to make a default implementation in an interface accessible to implementing classes, you can move it in to a protected static or public static method. Interfaces can now declare static members (methods/properties and fields). Just like static members on a class or struct, these members are accessible when invoked via the interface name itself rather than through an instance:

interface IExampleInterface {
    static readonly object _staticMutationLock = new object();
    static double _curValueMultiplier = 1d;
    
	public static double CurValueMultiplier {
        get {
            lock (_staticMutationLock) return _curValueMultiplier;
		}
        set {
            lock (_staticMutationLock) _curValueMultiplier = value;
        }
	}

    int CurValue { get; }

    void PrintCurValue() => PrintCurValueDefaultImpl(this);
	
	protected static void PrintCurValueDefaultImpl(IExampleInterface @this) => Console.WriteLine(@this.CurValue * CurValueMultiplier);
}

class ExampleClass : IExampleInterface {
    public int CurValue { get; } = 100;

    public void PrintCurValue() => IExampleInterface.PrintCurValueDefaultImpl(this); // Deliberately defer to default implementation
}

// ...

class Program {
    static void Main() {
        IExampleInterface e = new ExampleClass();

        IExampleInterface.CurValueMultiplier = 2d; // Static property access
        e.PrintCurValue(); // Prints 200
    }
}
View Code Fullscreen • "Static Interface Methods"

Interface Member Visibility and Polymorphic Behaviour 

By default, members declared on interfaces are always public. It's now possible however to declare interface members (static or instance) as being private, protected, internal, or public (also private protected and protected internal which I won't detail here).

private members are only visible to other members in the interface that declares them.
internal members are visible to any other source in the same assembly.
public members are visible to any other source.

Unfortunately, protected interface members are more complex:

protected instance members can only be accessed by child interfaces (not classes).
protected static members can be accessed by child interfaces and implementing classes.
External code can not access protected members at all, even if they're overridden in an implementing class (to do this, the class must implement the interface member explicitly).
When a class overrides or provides an implementation of a protected member, polymorphism/virtualisation is still applied. This means the class's implementation is still used when the protected member is invoked:

interface IExampleInterface {
	protected void Test() => Console.WriteLine("Interface");

    void InvokeTest() => Test();
}

class ExampleClass : IExampleInterface {
	void IExampleInterface.Test() => Console.WriteLine("Class"); // This MUST be implemented explicitly
}

class Program {
    static void Main() {
        IExampleInterface e = new ExampleClass();

        e.InvokeTest(); // Prints "Class"
    }
}
View Code Fullscreen • "Protected Interface Method Polymorphism"
When a class implements two interfaces that both provide a default implementation for the same parent-interface member, the implementing class must provide its own implementation:

interface IBase {
	char GetValue();
}

interface IDerivedAlpha : IBase {
	char IBase.GetValue() => 'A';
}

interface IDerivedBravo : IBase {
	char IBase.GetValue() => 'B';
}

class ExampleClass : IDerivedAlpha, IDerivedBravo {
	public char GetValue() => 'C'; // We must provide an implementation here or the compiler will emit an error
}

class Program {
    static void Main() {
        var e = new ExampleClass();

		Console.WriteLine(e.GetValue()); // Prints 'C'
		Console.WriteLine(((IDerivedAlpha) e).GetValue()); // Prints 'C'
		Console.WriteLine(((IDerivedBravo) e).GetValue()); // Prints 'C'
	}
}
View Code Fullscreen • "DIM Diamond Problem Resolution"
It is possible for child interfaces (i.e. interfaces that extend from other interfaces) to provide default implementations for their parents' members; as well as overriding existing default implementations, and even re-declaring members as abstract:

interface IExampleInterface {
	int GetValue();
}

interface IExampleInterfaceChild : IExampleInterface {
	int IExampleInterface.GetValue() => 123; // Provides a default implementation for GetValue() in parent interface 'IExampleInterface'
}

interface IExampleInterfaceChildChild : IExampleInterfaceChild {
	abstract int IExampleInterface.GetValue(); // Re-abstracts (i.e. removes the default implementation) for GetValue()
}
View Code Fullscreen • "Child Interfaces with DIM"
It is also possible to mark members as sealed.

Child interfaces can not provide new implementations for sealed members. The compiler emits an error if an attempt is made.
Implementing classes also can not provide new implementations for sealed members, but the compiler allows the declaration of members with the same name and does not warn that an interface method is being hidden:

interface IExampleInterface {
	sealed int GetValue() => 123;
}

class ExampleClass : IExampleInterface {
	public int GetValue() => 456; // No warning
}

class Program {
    static void Main() {
        var e = new ExampleClass();

		Console.WriteLine(e.GetValue()); // Prints 456
		Console.WriteLine(((IExampleInterface) e).GetValue()); // Prints 123
    }
}
View Code Fullscreen • "Sealed Interface Members"

Advanced Pattern Matching 

This version of C# added some more pattern matching features.

Switch expressions allow 'switching over' a variable to produce a new value:

// Assuming 'user' is a variable of type 'User':
var salary = user switch {
	Manager m when m.ManagerialLevel is ManagerialLevel.CLevel => 100_000, // C-Level managers get 100,000
	Manager m when m.ManagerialLevel is ManagerialLevel.UpperManagement => 70_000, // Upper managers get 70,000
	Manager _ => 50_000, // All other managers get 50,000
	_ => 30_000 // Everyone else gets 30,000
};
View Code Fullscreen • "Switch Expressions"
The new property pattern allows a more concise approach to matching properties on objects:

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
	{ LastAppriasal: { 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"
When types offer a Deconstruct method (including tuples), we can use the positional pattern instead:

// For sake of example, imagine User has a method declared with the signature:
// "public void Deconstruct(out int lastAppraisalRating, out bool isOverEighteen)"

var salary = user switch {
	Manager m when m.ManagerialLevel is ManagerialLevel.CLevel => 100_000,
	Manager m when m.ManagerialLevel is ManagerialLevel.UpperManagement => 70_000,
	Manager => 50_000,
	User (10, true) => 45_000, // Users with a 10/10 rating who are also over 18 get 45,000 
	User (9, true) => 40_000, // Users with a 9/10 rating who are also over 18 get 40,000 
	User (8, true) => 35_000, // Users with an 8/10 rating who are also over 18 get 35,000
	_ => 30_000
};
View Code Fullscreen • "Switch Expressions with Deconstruction"
The type specifier is optional if you just want to deconstruct the object in a positional pattern:

var salary = user switch {
	(10, true) => 45_000,
	(9, true) => 40_000,
	(8, true) => 35_000,
	_ => 30,000
};
View Code Fullscreen • "Switch Expressions with Tuples"
All of the patterns described above can be used in 'traditional' switch statements too.

IAsyncEnumerable 

Simply put, this feature allows iterating through an enumeration of awaitable items (i.e. Task<T>, ValueTask<T>, etc.), and creation of async iterators.

Assume DelayedSequence is a class that implements IAsyncEnumerable<int> which simply yields each integer in the sequence [1..n] with a delay between each iteration:

var delayedSequence = new DelayedSequence(5, TimeSpan.FromSeconds(1d)); // Sequence of 1 to 5 with one second delay between each iteration

await foreach (var i in delayedSequence) {
	Console.WriteLine(i);
}
View Code Fullscreen • "Simple Async Enumerable Example"
The await foreach tells the compiler that we want to await each iteration of delayedSequence before executing the loop body. An IAsyncEnumerable<T> returns a ValueTask<T> with each iteration, this is what we are awaiting on each loop.

It's possible to pass a CancellationToken when iterating using the WithCancellation() method:

await foreach (var i in someAsyncEnumerable.WithCancellation(someToken)) {
	/* ... */
}
View Code Fullscreen • "Passing a CancellationToken to async iteration"
Probably the biggest advantage of this feature is the ability to write async generators, which is the easiest way to create an IAsyncEnumerable<T>. Take this example that returns a paginated list of results from some resource:

public async IAsyncEnumerable<DataBatch> GetDataPaginated([EnumeratorCancellation] CancellationToken cancellationToken = default) {
	var paginationToken = new PaginationToken();
	
	try {		
		var results = await _database.GetNextResultBatch(paginationToken, cancellationToken);
		if (!results.ContainsValues) yield break;
		else yield return results.Batch;
	}
	catch (TaskCancelledException) {
		yield break;
	}
}
View Code Fullscreen • "Async Generators"
Inside this implementation, we construct an async generator by awaiting the read of a new batch of items from the database; and then either finishing the iteration (yield break) or passing the next DataBatch to be iterated over.

The compiler will turn this in to an IAsyncEnumerable<T> implementation for us automatically (you can still implement this interface manually and provide a manual implementation if required- it is not dissimilar to implementing IEnumerable<T>). The [EnumeratorCancellation] attribute on the cancellationToken parameter is required to tell the compiler that this parameter is the one we want to use when iterating over the returned IAsyncEnumerable<T> via a WithCancellation() method (remember, the caller invoking GetDataPaginated() may not be the one iterating over the returned IAsyncEnumerable<T>, so it's not like we can always pass in a CancellationToken at the creation point of the enumerable).

Indexes and Ranges 

This feature adds two new types to the framework that work together, Index and Range, and two new corresponding syntaxes.

Index 

An Index represents an element index in to a collection or enumerable of some sort. It does not have any link or reference to any particular enumerable/collection; instead it is just a standalone value.

Indexes can be specified either as being an offset from the start of a collection (as is traditional) or from the end:

var characterArray = new[] { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };

Index indexA = 0; // characterArray[indexA] is 'A'
Index indexB = 3; // characterArray[indexB] is 'D'
Index indexC = ^0; // characterArray[indexC] throws an IndexOutOfRangeException
Index indexD = ^3; // characterArray[indexD] is 'E'

Index indexA2 = Index.Start; // characterArray[indexA2] is 'A'
Index indexB2 = Index.FromStart(3); // characterArray[indexB2] is 'D'
Index indexC2 = Index.End; // characterArray[indexC2] throws an IndexOutOfRangeException
Index indexD2 = Index.FromEnd(3); // characterArray[indexD2] is 'E'
View Code Fullscreen • "Index Creation Examples"
The simplest way to specify an index is via implicit conversion from an integer; which creates an Index that specifies an offset from the start of an enumerable/collection. Both indexA and indexB demonstrate this. Index.Start is equivalent to (Index) 0 or Index.FromStart(0).
However, Indexes can also specify an offset from the end of an enumerable/collection. Both indexC and indexD demonstate this. The ^N syntax indicates that we're creating an index counting backwards from the end. ^0 points to one element 'past the end' of any given enumerable/collection; hence why characterArray[^0] throws an exception. ^1 will always get you the last element. Index.End is equivalent to ^0 or Index.FromEnd(0).
In the example above, indexA2 is identical to indexA, indexB2 is identical to indexB, etc.

Some people (including me) were initially surprised that ^0 indexes one element past the end of a collection; but it makes sense a lot when dealing with ranges. I actually wrote a little about this in 2018: C# 8 Concerns - A Followup.

Some additional members on the Index type:

// The Value and IsFromEnd properties can be used to deconstruct the index:

Console.WriteLine($"Index A: {indexA.Value}{(indexA.IsFromEnd ? " (from end)" : "")}"); // Index A: 0
Console.WriteLine($"Index B: {indexB.Value}{(indexB.IsFromEnd ? " (from end)" : "")}"); // Index B: 3
Console.WriteLine($"Index C: {indexC.Value}{(indexC.IsFromEnd ? " (from end)" : "")}"); // Index C: 0 (from end)
Console.WriteLine($"Index D: {indexD.Value}{(indexD.IsFromEnd ? " (from end)" : "")}"); // Index D: 3 (from end)

// GetOffset() will tell you what value the Index translates to for a collection of a given length:

Console.WriteLine($"Index A in characterArray: {indexA.GetOffset(characterArray.Length)}"); // Index A in characterArray: 0
Console.WriteLine($"Index B in characterArray: {indexB.GetOffset(characterArray.Length)}"); // Index B in characterArray: 3
Console.WriteLine($"Index C in characterArray: {indexC.GetOffset(characterArray.Length)}"); // Index C in characterArray: 7
Console.WriteLine($"Index D in characterArray: {indexD.GetOffset(characterArray.Length)}"); // Index D in characterArray: 4
View Code Fullscreen • "Index Additional Members"
Because indexes "from the end" are represented internally as a negative integer in the Index struct, the Value for an Index can never be negative.

Range 

A Range instance contains two Indexes; a Start and an End.

Note that the Range struct discussed here is in the System namespace. There is another unrelated Range type in System.Data.

var characterArray = new[] { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };

var rangeA = 0..3; // characterArray[rangeA] is ['A', 'B', 'C']
var rangeB = 3..^0; // characterArray[rangeB] is ['D', 'E', 'F']
var rangeC = 0..^0; // characterArray[rangeC] is ['A', 'B', 'C', 'D', 'E', 'F', 'G']
var rangeD = 4..^4; // characterArray[rangeD] throws ArgumentOutOfRangeException

var rangeA2 = Range.EndAt(3);
var rangeB2 = Range.StartAt(3);
var rangeC2 = Range.All;
var rangeD2 = new Range(4, ^4);

var rangeA3 = ..3;
var rangeB3 = 3..;
var rangeC3 = ..;
View Code Fullscreen • "Range Creation Examples"
The simplest way to create a Range is with the .. syntax (known as the Range operator); which takes an Index on each side. The Index on the left-hand-side of the operator is the inclusive start index, whereas the Index on the right-hand-side of the operator is the exclusive end index.
When you want to create a range whose Start is 0, you can omit the first parameter (see rangeA3).
When you want to create a range whose End is ^0, you can omit the second parameter (see rangeB3).
Both of these shortcuts can be combined to create a range that represents all elements (see rangeC3).
Like before, rangeA is identical to rangeA2 (as is rangeA3), etc.

Remember, ^N is a syntax that creates an Index that represents a value N items from the end of a given enumerable/collection. Therefore, a Range of 0..^0 represents every item in any enumerable/collection. This is the same as Range.All and ...

Some additional members on the Range type:

var characterArray = new[] { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };

var range = ^5..7;

Console.WriteLine(range.Start); // ^5
Console.WriteLine(range.End); // 7
Console.WriteLine(range.GetOffsetAndLength(characterArray.Length).Offset); // 2
Console.WriteLine(range.GetOffsetAndLength(characterArray.Length).Length); // 5
View Code Fullscreen • "Range Additional Members"
Note that GetOffsetAndLength() will throw an ArgumentOutOfRangeException if the given collection length is too small to accomodate the target range.

Types Supporting Index and Range 

Arrays have built-in support for Ranges, as demonstrated above. Using a range to create a subarray returns a new array whose values are copied from the original:

var characterArray = new[] { 'A', 'B', 'C', 'D', 'E', 'F', 'G' };

// This line is translated by the compiler to: var subArray = RuntimeHelpers.GetSubArray(characterArray, 1..^1);
var subArray = characterArray[1..^1];

characterArray[3] = 'X'; // Altering values in the original array does not affect the subArray

Console.WriteLine(subArray.Length); // 5
Console.WriteLine(String.Join(", ", subArray.Select(c => '\'' + c.ToString() + '\''))); // 'B', 'C', 'D', 'E', 'F'
View Code Fullscreen • "Array Range Support"
Additionally, any type that has a public Count or Length can automatically support Indexes if they provide an index operator and Rangees if they provide a method with the signature Slice(int, int):

// This class has everything required for automatic Index and Range support
class NumberLine {
	public int StartValue { get; }
	public int Length { get; }

	public NumberLine(int startValue, int length) {
		StartValue = startValue;
		Length = length;
	}

	public int this[int index] {
		get {
			if (index >= Length) throw new ArgumentOutOfRangeException(nameof(index));
			return StartValue + index;
		}
	}

	public IEnumerable<int> Slice(int offset, int length) {
		if (offset + length > Length) throw new ArgumentOutOfRangeException(nameof(length));
		for (var i = 0; i < length; ++i) yield return StartValue + offset + i;
	}
}

// Here we demonstrate the automatic support
var numberLine = new NumberLine(0, 10);

Console.WriteLine(numberLine[Index.Start]); // 0
Console.WriteLine(numberLine[Index.FromEnd(1)]); // 9

Console.WriteLine(String.Join(", ", numberLine[..])); // 0, 1, 2, 3, 4, 5, 6, 7, 8, 9
Console.WriteLine(String.Join(", ", numberLine[3..7])); // 3, 4, 5, 6
Console.WriteLine(String.Join(", ", numberLine[^7..^3])); // 3, 4, 5, 6
View Code Fullscreen • "Automatic Index and Range Support"
Some other types in the framework also offer automatic Index and/or Range support, including Span<T> and many collection types.

RAII-Style Using Statements 

This feature allows declaring a variable that should automatically be disposed when the enclosing scope ends:

void Test() {
	using var fileStream = File.OpenRead("somefile.txt");
	
	// ...
	
	// fileStream.Dispose() automatically invoked here at the end of this method
}
View Code Fullscreen • "RAII Using Statement"

Static Local Functions 

This performance-oriented feature allows you to ensure that local functions do not capture any variables.

Take this example of a non-static local function (CombineData() in CreateUserDetailsString()):

class User {
	public string Name { get; }

	public string PermanentData { get; }

	public string CreateUserDetailsString(string additionalData) {
		string CombineData() {
			return PermanentData + additionalData;
		}

		return $"{Name} ({CombineData()})";
	}
}
View Code Fullscreen • "Non-Static Local Function Example"
CombineData() captures two variables from outside its local scope, additionalData and this (which gives it access to this.PermanentData). In performance-sensitive scenarios variable capture can increase pressure on the garbage collector, which is detrimental.

Declaring a local function as static will cause the compiler to disallow the capture of any variables. In turn, this will cause a compiler error until the programmer manually passes in those variables to the local function.

Marking CombineData() as static in its current form will produce two compiler errors telling us we can not reference this and that we can not reference additionalData. To resolve this, we must pass in the parameters we want to use like a standard method call:

public string CreateUserDetailsString(string additionalData) {
	static string CombineData(string permanentData, string additionalData) {
		return permanentData + additionalData;
	}

	return $"{Name} ({CombineData(PermanentData, additionalData)})";
}
View Code Fullscreen • "Static Local Function Example"

Readonly Struct Members 

This performance-oriented addition allows marking specific members of a struct as non-modifying/non-modifyable.

As discussed previously with respect to in arguments, readonly structs are important to allow the compiler the flexibility not to create defensive copies of parameters. However, sometimes structs must be mutable and can not be marked as readonly. This feature allows making parts of a struct readonly, which therefore allows the compiler to still avoid defensive copies in certain circumstances.

In this context, readonly members can be likened somewhat to const members in C++.

struct MyStruct {
	public int Alpha { get; set; }

	// Readonly property
	public readonly int Bravo { get; }

	public void IncrementAlpha() {
		Alpha += 1;
	}

	// Readonly method: Does not alter any state in this struct
	public readonly void PrintBravo() {
		Console.WriteLine(Bravo);
	}
}
View Code Fullscreen • "Readonly Struct Members Example"
Attempting to mark IncrementAlpha() as readonly would cause a compiler error to be evoked, as the operation Alpha += 1 modifies Alpha.

Null-Coalescing Assignment 

This small feature lets you assign a value to a variable only if that variable is null with a terse syntax. Both lines in the following example have the same meaning:

// Classic example
if (myStr == null) myStr = "Hello";

// New way with null-coalescing assignment
myStr ??= "Hello";
View Code Fullscreen • "Null Coalescing Assignment"