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

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

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

Init-Only Setters 

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"

Top-Level Statements 

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.

Native-Sized Integers 

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"

Record Types 

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.

Enhanced Pattern Matching 

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 Expressions 

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.

Covariant Return Types 

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"

GetEnumerator Extensions 

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"

Module Initializers 

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.

SkipLocalsInit 

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, SuppressGCTransition, and UnmanagedCallersOnly 

Managed Function Pointers 

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 Function Pointers 

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"

SuppressGCTransition 

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"

UnmanagedCallersOnly 

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.

Source Generators 

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.

Setup 

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.

The source generator project must target .NET Standard 2.0 exactly (not even 2.1 worked in my testing; nor did .NET 5). This seems unlikely to change in the near future.

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.

Implementing ISourceGenerator 

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.

Removed Restrictions to Partial Methods 

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.