This new syntax allows creating properties that can be set using
object initialization syntax, but never again:
public class User {
public string Name { get; init; }
public int Age { get; init; }
}
// ...
var user = new User { Name = "Ben", Age = 30 };
user.Name = "Seb"; // Won't compile, 'Name' is init-only
View Code Fullscreen • "Init-Only Setters"
Init-only properties can also have access modifiers applied, just like regular setters:
public class User {
public string Name { get; init; }
public int Age { get; internal init; } // Age can only be set from within this assembly
}
View Code Fullscreen • "Internal Init-Only Setter"
This small feature lets you omit the 'boilerplate' in your program's entry-point (i.e.
Main() function). Here's a before and after to demonstrate:
// Before
using System;
using System.Threading.Tasks;
namespace TestNamespace {
class Program {
static async Task<int> Main(string[] args) {
if (args.Length > 0 && args[0] == "Do It") {
var success = await Database.DownloadData();
if (success) return 0;
else return 1;
}
Console.WriteLine("What should I do? Exiting...");
return 100;
}
}
}
View Code Fullscreen • "Top-Level Statements; Before"
// After
using System;
using System.Threading.Tasks;
using TestNamespace;
if (args.Length > 0 && args[0] == "Do It") {
var success = await Database.DownloadData();
if (success) return 0;
else return 1;
}
Console.WriteLine("What should I do? Exiting...");
return 100;
View Code Fullscreen • "Top-Level Statements; After"
Notice that there is no
Main() declaration or namespace declaration any more; the compiler synthesizes that for us (we can even still use the
args array). The only caveat is that because we are no longer in the
TestNamespace namespace, we must import it via
using TestNamespace; if we want to use the
TestNamespace.Database class.
This performance-oriented feature adds two new keywords/aliases;
nint and
nuint. These are meant to represent signed/unsigned integers of the native platform's word-size (i.e. 32 bits on a 32-bit platform, 64 bits on a 64-bit platform, etc).
Technically
nint is an alias for
IntPtr, and
nuint is an alias for
UIntPtr. However, the compiler offers some additional arithmetic operations when using a variable typed as a native-sized integer:
nint nativeIntegerOne = 100;
nint nativeIntegerTwo = 200;
IntPtr intPtrOne = new IntPtr(100);
IntPtr intPtrTwo = new IntPtr(200);
Console.WriteLine(nativeIntegerOne + nativeIntegerTwo); // 300
Console.WriteLine(intPtrOne + intPtrTwo); // Doesn't compile
View Code Fullscreen • "Native Integers vs IntPtrs"
This feature makes it easier to define types that are primarily meant to encapsulate data (as opposed to types that abstract/encapsulate behaviours). Record types are regular classes, but the compiler will automatically generate some members on the type that make it easier to work with them as data containers.
public record User(string Name, int Age) { }
View Code Fullscreen • "Simple Record Type Definition"
The line
public record User(string Name, int Age) { } declares a new
User class type that:
Has two properties: public string Name { get; init; } and public int Age { get; init; }.
Has a constructor public User(string Name, int Age) that assigns Name to this.Name and Age to this.Age (yes, the ctor parameters are in PascalCase).
Implements IEquatable<User>; the implementation for Equals(User other) returns true if other is specifically a User (rather than a derived type), and if both Names and Ages are equal in value. In other words, record types implement value-equality.
Overrides ToString() to provide an implementation that reports the value of all members.
Provides a Deconstruct() implementation that has positional arguments in the same order as defined in the record definition (i.e. string Name, int Age).
void Test() {
// Constructor
var user = new User("Ben", 30);
// Properties
Console.WriteLine(user.Name); // Ben
Console.WriteLine(user.Age); // 30
// Equality
var user2 = new User("Ben", 30);
var user3 = new User("Seb", 27);
Console.WriteLine(user == user2); // True
Console.WriteLine(user == user3); // False
// ToString
Console.WriteLine(user); // User { Name = Ben, Age = 30 }
Console.WriteLine(user3); // User { Name = Seb, Age = 27 }
// Deconstructor
var (userName, userAge) = user;
Console.WriteLine(userName); // Ben
Console.WriteLine(userAge); // 30
}
View Code Fullscreen • "Generated Members Example"
By default, record types define immutable (init-only) properties. You can create a copy of a record instance with modified values using a
with statement. The
with statement returns a
new instance of the same record type but with the specified modified properties. All unspecified properties remain the same:
var user = new User("Ben", 30);
user = user with { Age = 31 };
Console.WriteLine(user); // User { Name = Ben, Age = 31 }
View Code Fullscreen • "Record With Statement Example"
Modern software engineering generally recognises having your data types be immutable as conveying multiple benefits. Copying data in to new object instances with desired modifications (rather than directly modifying existing instances) conveys many benefits, including making concurrency easier to understand and less error-prone, as well as making it easier to write the classes themselves (if nothing can change, you only need to validate inputs once in the constructor; and you don't need to worry about mutability for things like implementing
GetHashCode()). More information can be found here:
NDepend Blog: C# Immutable Types: Understanding the Attraction.
The positional arguments that go next to the record type name are optional. We can create a similar record type by declaring the properties in a more traditional way:
public record User {
public string Name { get; init; }
public int Age { get; init; }
}
View Code Fullscreen • "Record Without Positional Properties"
This declares a
User record with the same properties as before. As this is a
record declaration rather than a
class, the compiler will still generate a
ToString() method and an
IEquatable<T> implementation for us; and
with statements are still supported. However, without the positional properties the compiler will
not create a constructor or deconstructor for us.
We can also combine both approaches to override the default implementation of properties. Here's an example where we make the auto-generated
Name property mutable:
public record User(string Name, int Age) {
public string Name { get; set; } = Name;
}
View Code Fullscreen • "Overriding Auto Generated Properties"
The syntax '
Name { get; set; } = Name;' here may look a little surprising. In fact, this is a special syntax supported only for record types that tells the compiler we want to assign the
Name constructor parameter to the
Name property.
This can be used with any property:
public record User(string Name, int Age) {
public string Note { get; set; } = $"{Name}, aged {Age}";
}
void Test() {
var user = new User("Ben", 30);
Console.WriteLine(user.Name); // Ben
Console.WriteLine(user.Age); // 30
Console.WriteLine(user.Note); // Ben, aged 30
}
View Code Fullscreen • "Assigning Constructor Parameters"
When creating your own constructor for a record type you
must invoke the compiler-generated one (via
this() invocation):
public record User(string Name, int Age) {
public string Note { get; set; } = $"{Name}, aged {Age}";
public User(string name, int age, string note) : this(name, age) { // Without the 'this(name, age)', this ctor will not compile
Note = note;
}
}
void Test() {
Console.WriteLine(new User("Ben", 30).Note); // Ben, aged 30
Console.WriteLine(new User("Ben", 30, "Custom user note").Note); // Custom user note
}
View Code Fullscreen • "Additional Record Constructors"
The reason why the compiler-generated constructor must be called should now be apparent. On the line
public string Note { get; set; } = $"{Name}, aged {Age}"; we assign a default value to
Note using the constructor parameters
Name and
Age. If the compiler-generated constructor is never invoked, these parameters would not be available, and it would be unclear what the default value of
Note should be.
Relational matching allows matching against ranges of values using
>,
>=,
< and
<=.
Type matching allows omitting the discard when matching purely on an object's type. The example below uses the
property pattern in a
switch expression but
relational matching can also be used with most other patterns:
var salary = user switch {
Manager { YearsAtCompany: >= 5, DirectReports: { Count: >= 10 } } => 120_000, // Managers who have worked at the company for at least 5 years and have at least 10 direct reports get 120,000
Manager { YearsAtCompany: >= 5 } => 100_000, // Managers who have worked at the company for at least 5 years get 100,000
Manager => 70_000, // All other managers get 70,000 (notice no discard '_' variable required any more)
{ YearsAtCompany: >= 3, Age: >= 18 } => 50_000, // Anyone else who's at least 18 and has worked
_ => 30_000 // Everyone else gets 30,000
};
View Code Fullscreen • "Relational and Type Pattern Matching"
Conjunctive,
disjunctive, and
negative patterns allow you to combine patterns in a familiar way:
/*
* The following code determines whether a player is eligible for an award.
* If the player has a score >= 100, is not dead, and is NOT a MonsterPlayer, return true.
* If the player is a hero who has slain >= 3 monsters, or is a monster who has chomped >= 5 or has >= 200 score, return true.
* Else return false.
*/
var playerIsEligibleForMvpAward = player switch {
Player { Score: >= 100, IsDead: false } and not MonsterPlayer => true,
HeroPlayer { MonstersSlain: >= 3 } or MonsterPlayer { HeroesChomped: >= 5 } or MonsterPlayer { Score: >= 200 } => true,
_ => false
};
View Code Fullscreen • "Conjunctive/Disjunctive/Negative Patterns"
The
negative pattern is especially useful when checking if a variable is
not of a given type:
// Revive the player if they're not a monster
if (player is not MonsterPlayer) player.Revive();
// Send the player to hell if they're not a hero, otherwise send them to heaven
if (player is not HeroPlayer hero) player.SendToHell();
else hero.SendToHeaven();
View Code Fullscreen • "Negative Type Check"
Target-typed new expressions allow you to omit the type name when invoking a constructor if the type can be inferred:
// Field
Dictionary<string, List<(int Age, string Name)>> _userLookupDict = new(); // No need to re-iterate the type "Dictionary<string, List<(int Age, string Name)>>"!
void Test() {
// Locals
List<string> names = new(_userLookupDict.Keys); // Can still pass constructor parameters as usual
User u = new() { Name = "Ben", Age = 31 }; // Can use object initialization syntax as usual
}
View Code Fullscreen • "Target-Typed New Expressions"
For obvious reasons, target-typed new expressions are not compatible with implicitly-typed locals (i.e. var).
Target-typed conditionals allow the compiler to better find a common type between two operands of a conditional expression. Here's an example with the ternary conditional operator:
// Assume 'selectManager' is a bool, 'manager' is a Manager (where Manager : User) and 'developer' is a Developer (where Developer : User)
User u = selectManager ? manager : developer;
View Code Fullscreen • "Target-Typed Ternary Conditional"
Prior to C# 9 this line could not compile because
manager and
developer are variables of different types. However, now we can declare
u as a variable of type
User (which is a shared parent/base class of
Manager and
User), and compile the line.
Unfortunately this feature is also not compatible with implicitly-typed locals.
This feature lets you specify a more-dervied return type when overriding a base-class method:
abstract class Player {
public abstract IWeapon GetEquippedWeapon();
}
class MonsterPlayer : Player {
// Here we can specify that the weapon will always be a ClawsWeapon for a MonsterPlayer:
public override ClawsWeapon GetEquippedWeapon() {
// ...
}
}
View Code Fullscreen • "Covariant Return Type Override"
Thie feature lets you add
foreach support to any type via extension method:
// Add a GetEnumerator to UInt16 that iterates through every bit (from MSB to LSB)
public static class UInt16Extensions {
public static IEnumerator<bool> GetEnumerator(this UInt16 @this) {
for (var i = (sizeof(UInt16) * 8) - 1; i >= 0; --i) {
yield return (@this & (1 << i)) != 0;
}
}
}
// Usage example:
// This program writes "1100001100001111" to the console
ushort u = (ushort) 0b1100_0011_0000_1111U;
foreach (var bit in u) {
Console.Write(bit ? 1 : 0);
}
View Code Fullscreen • "Extension GetEnumerator() Example"
This feature lets us execute code before any other code in the module (the DLL/EXE in most circumstances).
When writing a program with an entry point this feature may not seem that useful; however when writing a library for others to include as a dependency in their own applications this feature may come in very handy.
Let's say we need to resolve some bespoke dependency graph before any type in our library is accessed. A module initializer will let us ensure that the graph is resolved before our library is used:
static class TypeDependencyResolver {
[ModuleInitializer]
public static void ResolveGraph() {
// Do stuff here
}
}
View Code Fullscreen • "Module Initializer Example"
In this example,
ResolveGraph() will be invoked automatically by the runtime any time before the first usage of any type/method in the module. There is no restriction on what can be done in the initializer function (including invoking other functions, creating objects, working with I/O, etc); but as a rule of thumb initializers should not take too long to execute or have the potential to throw exceptions.
Module initializer functions must be
public and
static, and return
void. They
can be
async however (i.e.
async void). It is possible to have multiple
ModuleInitializers in the same module. The order of execution of the multiple methods is arbitrary and up to the runtime.
This performance-oriented attribute can be applied to methods, types, and modules. It instructs the compiler
not to emit a runtime flag that normally tells the runtime to zero all locally-declared variables before a function begins.
Usually the compiler emits this flag on all methods because it protects us against certain types of error. However,
zeroing memory can have a significant performance penalty in certain circumstances. When we apply
[SkipLocalsInit] we can ask the compiler to skip this step:
// Following code will only write "Found a non-zero byte" when [SkipLocalsInit] is applied
[SkipLocalsInit]
public static void Main() {
Span<byte> stackData = stackalloc byte[4000];
for (var i = 0; i < stackData.Length; ++i) {
if (stackData[i] != 0) {
Console.WriteLine("Found a non-zero byte!");
break;
}
}
}
View Code Fullscreen • "SkipLocalsInit Example"
Function pointers and the
[SuppressGCTransition] attribute are performance-oriented features that allow streamlining of indirect method invocations; either managed or unmanaged/native.
Function pointers are declared using the
delegate* syntax (and must be used in an unsafe context). This first example shows how to use pointers to
managed functions:
public class User {
public string Name { get; set; }
public int Age { get; set; }
public void ClearUserDetails() {
Name = "<cleared>";
Age = 0;
}
}
public static class Database {
public static int ClearAllRecords(string idPrefix) => idPrefix.Length;
public static void ClearUserDetails(User u) => u.ClearUserDetails();
}
// ...
unsafe {
delegate* managed<string, int> databaseClearFuncPtr = &Database.ClearAllRecords;
Console.WriteLine(databaseClearFuncPtr("Testing")); // Prints '7' on console
var user = new User { Name = "Ben", Age = 31 };
delegate* managed<User, void> userClearFuncPtr = &Database.ClearUserDetails;
userClearFuncPtr(user);
Console.WriteLine($"User: {user.Name} / {user.Age}"); // Prints 'User: <cleared> / 0' on console
}
View Code Fullscreen • "Managed Function Pointers"
The first pointer (
databaseClearFuncPtr) points to
Database.ClearAllRecords. It's declared as a
managed function pointer that takes a
string input and returns an
int. Invoking it on the next line is similar to invoking a
Func<string, int>.
The second pointer (
userClearFuncPtr) shows how it is possible to invoke a non-static function by working around
single dispatch for object instances. We can not create a pointer to an instance method (i.e.
User.ClearUserDetails()), but we
can create a static method that takes the instance and invokes the relevant method for us. Therefore,
userClearFuncPtr points to
Database.ClearUserDetails(). It's declared as a
managed function pointer that takes a
User input and returns nothing (
void). Invoking it on the next line is similar to invoking an
Action<User>.
Unmanaged pointers allow you to directly store a pointer to an unmanaged function. You might receive this pointer via P/Invoke call or some other means.
Imagine we had a C++ library with the following implementation:
static const wchar_t* GetHelloString() {
return L"Hello";
}
typedef const wchar_t* (*helloStrPtr)(void);
extern "C" __declspec(dllexport) void GetFuncPtr(helloStrPtr* outFuncPtr) {
*outFuncPtr = &GetHelloString;
}
View Code Fullscreen • "Example Native Method Declaration"
The implementation of
GetFuncPtr() requires a pointer-to-a-pointer so that it can set our function pointer to point to
GetHelloString().
GetFuncPtr() could in theory just return a function pointer, but I created this example to show the arguably more difficult use case of marshalling a pointer-to-a-pointer.
From the C# side we would represent the exported
GetFuncPtr() like this:
public static class NativeMethods {
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static unsafe extern void GetFuncPtr(delegate* unmanaged<char*>* outFuncPtr);
}
View Code Fullscreen • "Representing GetFuncPtr() in C#"
We can then invoke
GetFuncPtr() and use the function pointer like so:
unsafe {
delegate* unmanaged<char*> getHelloStrFuncPtr;
NativeMethods.GetFuncPtr(&getHelloStrFuncPtr);
Console.WriteLine(new String(getHelloStrFuncPtr())); // Writes "Hello" to the console
}
View Code Fullscreen • "Usage of GetFuncPtr()"
Finally, when declaring an
unmanaged pointer it's possible to specify the calling convention:
delegate* unmanaged<int, int> automaticConventionFuncPtr;
delegate* unmanaged[Cdecl]<int, int> cdeclConventionFuncPtr;
delegate* unmanaged[Fastcall]<int, int> fastcallConventionFuncPtr;
delegate* unmanaged[Stdcall]<int, int> stdcallConventionFuncPtr;
delegate* unmanaged[Thiscall]<int, int> thiscallConventionFuncPtr;
View Code Fullscreen • "Declaring Unmanaged Function Pointers"
Note that the C++ implementation of
GetFuncPtr() is extremely trivial. Usually when invoking a native method via P/Invoke the runtime will first set up the GC to handle the transition in to non-managed code. However, in certain circumstances this transition can add unnecessary overhead. This is true when the method being invoked:
Is trivial
Completes very quickly
Does not do any I/O
Does not use any synchronization/threading
Does not throw exceptions
The
[SuppressGCTransition] attribute can be applied to
extern methods to tell the runtime not to bother with this transition when we
know the method meets all criteria in the list above:
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl), SuppressGCTransition]
public static unsafe extern void GetFuncPtr(delegate* unmanaged<char*>* outFuncPtr);
View Code Fullscreen • "GetFuncPtr() with SuppressGCTransition Applied"
We can now write methods that are
only invokable via a function pointer from native code. Similar to
SuppressGCTransition, applying an
[UnmanagedCallersOnly] attribute to a method helps the runtime/compiler reduce overhead for native-to-managed calls.
Assuming we have a C++ implementation as follows:
typedef int (*getIntPtr)(void);
extern "C" __declspec(dllexport) void InvokeFuncPtr(getIntPtr funcPtr) {
std::wcout << funcPtr();
}
View Code Fullscreen • "UnmanagedCallersOnly Example, C++ Side"
We can represent this method with the following C# signature methods:
[DllImport("NativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static unsafe extern void InvokeFuncPtr(delegate* unmanaged[Cdecl]<int> funcPtr);
[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
public static int ReturnInt() => 123;
View Code Fullscreen • "UnmanagedCallersOnly Example, C# Side"
Attempting to invoke
ReturnInt() directly from C# will emit a compiler error. Instead, we can pass a pointer to it to our C++ method:
unsafe {
NativeMethods.InvokeFuncPtr(&NativeMethods.ReturnInt); // Prints 123 to std::wcout (i.e. console)
}
View Code Fullscreen • "Using UnmanagedCallersOnly Pointer"
Note: Adding SuppressGCTransition to the InvokeFuncPtr() declaration causes this program to crash at runtime with the message "Fatal error. Invalid Program: attempted to call a UnmanagedCallersOnly method from managed code.". This is because that GC transition is actually what allows the runtime to detect if a method has been invoked from a native caller.
This feature allows you to write code that will generate more code at compile-time. This feature can only add/overwrite code, not modify existing code.
To get started, you must create a new .NET Standard class library project and add
Microsoft.CodeAnalysis.Analyzers and
Microsoft.CodeAnalysis.CSharp to your project via NuGet. This will be the
source generator project, and it will generate code in the
target project.
This new project will contain code that generates code in the target project. To do this, we must add a special reference to the generator project from the target project. Open the
.csproj file of the target project and add a generator reference:
<!-- This ItemGroup should be added inside the Project node -->
<ItemGroup>
<ProjectReference Include="..\SourceGen\SourceGen.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
View Code Fullscreen • "Target Project CSPROJ File"
Now when we build our target project, the
SourceGen project will be compiled and executed during compilation. The
SourceGen project will have the opportunity then to insert code in our target project before it is compiled.
Add a class to the generator project that implements
ISourceGenerator. You'll need to import the
Mircosoft.CodeAnalysis namespace. Annotate this class with
[Generator]:
[Generator]
public class MySourceGenerator : ISourceGenerator {
public void Execute(GeneratorExecutionContext context) {
// TODO
}
public void Initialize(GeneratorInitializationContext context) {
// TODO
}
}
View Code Fullscreen • "Generator Class Stub (In Generator Project)"
The
Initialize function can be used to register a function that will create
ISyntaxReceivers; which in turn will have its
OnVisitSyntaxNode function invoked for every syntax node in the source project as the compiler moves through it.
You can also access the instantiated
ISyntaxReceiver from the
Execute method via
context.SyntaxReceiver. The following example shows how to hook up a simple
ISyntaxReceiver that prints all the nodes to a text file:
class SyntaxPrinter : ISyntaxReceiver {
readonly FileStream _fs;
readonly TextWriter _tw;
public SyntaxPrinter() {
_fs = File.OpenWrite(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "test.txt"));
_tw = new StreamWriter(_fs);
}
public void OnVisitSyntaxNode(SyntaxNode syntaxNode) {
_tw.WriteLine($"Node received: {syntaxNode.Kind()} {syntaxNode}");
_fs.Flush();
}
}
[Generator]
public class MySourceGenerator : ISourceGenerator {
public void Execute(GeneratorExecutionContext context) {
// TODO
}
public void Initialize(GeneratorInitializationContext context) {
context.RegisterForSyntaxNotifications(() => new SyntaxPrinter());
}
}
View Code Fullscreen • "Generator Class Syntax Receiver Example"
To add source code implement the
Execute function. The depth of potential for source generation could fill an entire new blog post, so in this case I will just show an example of adding a new file to the compilation:
[Generator]
public class MySourceGenerator : ISourceGenerator {
const string ExampleSource = @"
namespace GeneratedNamespace {
public static class GeneratedClass {
public static void SayHello() => System.Console.WriteLine(""Hello"");
}
}";
public void Execute(GeneratorExecutionContext context) {
context.AddSource("Generated.cs", ExampleSource);
}
public void Initialize(GeneratorInitializationContext context) {
/* do nothing */
}
}
View Code Fullscreen • "Generator Source Addition Example"
Note that this file is added 'virtually' during the compilation; no actual file named Generated.cs is added to the target project.
This additional file declares a static method
SayHello() in a static class
GeneratedClass in the namespace
GeneratedNamespace. In our target project we can invoke this method directly:
using System;
GeneratedNamespace.GeneratedClass.SayHello();
View Code Fullscreen • "Generator Target Source"
Intellisense will complain that
GeneratedNamespace.GeneratedClass.SayHello() does not exist, but we can go ahead and compile this anyway, because we know something intellisense does not in this instance. Running the target project will print "Hello" on the console.
This new feature also includes some changes to partial methods. It removes the necessity for partial methods to be
private and
void-returning; as long as a definition is provided by compile-time. This allows us to declare methods that are invoked by our target application ahead of time (thus eliminating intellisense and discoverability issues), but where the definitions are supplied by generator at compilation-time.
We can declare a method that we want to implement via generator:
using System;
Console.WriteLine(GeneratorTarget.GeneratedClass.GetInt());
namespace GeneratorTarget {
public static partial class GeneratedClass {
public static partial int GetInt();
}
}
View Code Fullscreen • "Generator Target Source With Partial Method"
Unfortunately, we still get an intellisense error telling us that we haven't provided an implementation for
GetInt() anywhere; but at least that error is located only on
GetInt(), and still allows us to discover methods that
will be implemented.
The implementation for this method is as you would expect:
[Generator]
public class MySourceGenerator : ISourceGenerator {
const string ExampleSource = @"
namespace GeneratorTarget {
public static partial class GeneratedClass {
public static partial int GetInt() => 123;
}
}";
public void Execute(GeneratorExecutionContext context) {
context.AddSource("Generated.cs", ExampleSource);
}
public void Initialize(GeneratorInitializationContext context) {
/* do nothing */
}
}
View Code Fullscreen • "Generator Source Project With Partial Method"
Running our target project prints
123 on the screen.