In this post I describe how I tried (and failed) to add an interceptor to my NetEscapades.EnumExtensions NuGet package. The bad news is I didn't get it to work. The good news is that the experiment unearthed a bug in the Roslyn compiler. I start by providing a little background to the NuGet package, then talk about interceptors, and describe what happened when I tried to create one!
Working with enum
can be slow
The humble enum
is a mainstay of a lot of .NET code. In many ways it's a glorified integer (and pretty much behaves as such at runtime), but it allows you to give understandable names to specific integer values and provides a certain level of type-safety.
However, one of the unfortunate realities of working with enum
types is that some apparently simple operations, such as calling ToString()
on an enum
value, are surprisingly slow.
For example, let's say you have this simple enum:
public enum Colour
{
Red = 0,
Blue = 1,
}
At some point, you want to print out the name of the enum using ToString()
. No problem, right?
public void PrintColour(Colour colour)
{
Console.WriteLine("You chose "+ colour.ToString()); // You chose Red
}
This works, but it's surprisingly slow. To see how slow, let's compare it to a simple alternative:
public static class ColourExtensions
{
public static string ToStringFast(this Colour colour)
=> colour switch
{
Colour.Red => nameof(Colour.Red),
Colour.Blue => nameof(Colour.Blue),
_ => colour.ToString(), // fallback to the built-in method for unknown values
}
}
This simple switch statement checks for each of the known values of Colour
and uses nameof
to return the textual representation of the enum
. If it's an unknown value, then the underlying value is returned using the built-in ToString()
implementation for simplicity. This is necessary because enum
s really are just wrappers for int
(or another integer backing type), so doing (Color)123
is value C#!
If we compare this simple switch statement to the default ToString()
implementation using BenchmarkDotNet for a known colour, you can see how much faster the second implementation is, even on my old laptop:
BenchmarkDotNet=v0.13.1, OS=Windows 10.0.19042.1348 (20H2/October2020Update)
Intel Core i7-7500U CPU 2.70GHz (Kaby Lake), 1 CPU, 4 logical and 2 physical cores
DefaultJob : .NET Framework 4.8 (4.8.4420.0), X64 RyuJIT
.NET SDK=6.0.100
DefaultJob : .NET 6.0.0 (6.0.21.52210), X64 RyuJIT
Method | FX | Mean | Error | StdDev | Ratio | Gen 0 | Allocated |
---|---|---|---|---|---|---|---|
ToString | net48 | 578.276ns | 3.3109ns | 3.0970ns | 1.000 | 0.0458 | 96 B |
ToStringFast | net48 | 3.091ns | 0.0567ns | 0.0443ns | 0.005 | - | - |
ToString | net6.0 | 17.9850ns | 0.1230ns | 0.1151ns | 1.000 | 0.0115 | 24 B |
ToStringFast | net6.0 | 0.1212ns | 0.0225ns | 0.0199ns | 0.007 | - | - |
First off, it's worth pointing out that ToString()
in .NET 6 is over 30× faster and allocates only a quarter of the bytes than the method in .NET Framework! Compare that to the "fast" version though, and it's still super slow!
These all got even faster in .NET 8, both the built-in and source-generated versions, but I don't have the exact numbers to hand.
This pattern isn't just the case for ToString()
. Common methods like Enum.TryParse<T>()
, Enum.GetValues<T>()
and Enum.GetNames<T>
are all slow compared to the "naïve" approach. The trouble is that maintaining methods like ToStringFast()
manually are doomed to failure. It's just a matter of time until you change the definition of enum
, and forget to update your helper methods.
That's where the NetEscapades.EnumGenerators package comes in.
Generating fast methods with NetEscapades.EnumGenerators
The NetEscapades.EnumGenerators NuGet package contains a source generator that writes the "by-hand" version of ToStringFast()
shown in the previous section for you. The key advantage of the source-generator over the by-hand version is that if you change the enum
definition, the generated ToStringFast()
method is also regenerated. This alleviates the main pain point with using by-hand versions.
To use the package, add it to your application using
dotnet add package NetEscapades.EnumGenerators --prerelease
This makes the [EnumExtensions]
attribute available. If you place this on your enum
, the source generator will generate an extension class containing ToStringFast()
. For example, add the attribute like this:
using NetEscapades.EnumGenerators;
[EnumExtensions]
public enum Colour
{
Red = 0,
Blue = 1,
}
And the source generator generates a ColourExtensions
class containing ToStringFast()
(and other helper methods):
You can read more about the additional helper methods on Github or in the announcement blog post.
The one problem that the NetEscapades.EnumGenerators source generator can't solve is that you have to remember to use the extension methods in your code. That means you have to remember to call myValue.ToStringFast()
instead of myValue.ToString()
, or ColourExtensions.IsDefined(value)
instead of Enum.IsDefined<Colour(value)
. That's where interceptors come in.
Intercepting and replacing call site invocations
Interceptors are a preview feature in .NET 8. I wrote about them previously in more detail, but essentially they allow you to provide an alternative implementation for a method. This is particularly useful for AOT, as it means library authors can provide alternative AOT-friendly implementations for methods, without the user (you) having to change your code at all.
Interceptors are designed to be used with source generators, in that they require you to specify the exact file, line, and character number of the method you're going to replace. On top of that, the method you substitute in has to have the exact same signature as the method you're intercepting.
In it's simplest form, this means a program something like this:
Console.WriteLine("Hello, world!");
static class Interception
{
[InterceptsLocation("/some/path/Program.cs", line: 1, column: 9)]
public static void CustomWriteLine(string line)
=> Console.WriteLine("Intercepted: " + line);
}
Will print "Intercepted: Hello, world!"
, becase the CustomWriteLine()
method is called.
So where do interceptors come in for NetEscapades.EnumGenerators? Well, they have the potential to remove the biggest hurdle to using the library: remembering to use the library.
My thought was that I could create an interceptor that looked for anywhere some writes Colour.ToString()
and replace it with a call to Colour.ToStringFast()
instead. That way you wouldn't need to do anything to make use of the library. You could literally install the library, add [EnumExtensions]
to your enum
, and 💥boom💥, free performance.
I spent a while going down this rabbit hole to get a PoC concept that was producing the required [IntercepsLocation]
attributes and implementations and… it didn't work. So I backtracked, and tried to produce a "manual" example that did what I wanted, and it still didn't work. Hmmm…
A minimum viable interceptor for Enum.ToString()
If you read the intro to this post, you already know where this is going, but I'm documenting exactly what I did here for proseprity, and hopefully noone else goes down the same dead end!
I started with a very simple program in which I define an enum Color
and call ToString()
on and instance of it:
using System.Runtime.CompilerServices;
var value = Colour.Blue;
Console.WriteLine(value.ToString());
public enum Colour
{
Red,
Blue,
}
Nothing exciting yet, this prints "Blue"
to the console when it's run as you would expect. Now lets add the interceptor.
First, we need to make sure the [InterceptsLocation]
is available. The easiest way to do that is to define it somewhere in your project like this:
namespace System.Runtime.CompilerServices
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
sealed class InterceptsLocationAttribute : Attribute
{
public InterceptsLocationAttribute(string filePath, int line, int column)
{
_ = filePath; // These stop the compiler complaining about unused fields
_ = line;
_ = column;
}
}
}
Next, we create the interceptor itself, using the [InterceptsLocation]
we defined, and specifying the correct path, line, and column that corresponds to the ToString()
call in our program.
namespace MyInterceptors;
public static class Interceptors
{
[InterceptsLocation(@"C:\testapp\Program.cs", line: 5, column: 25)]
public static string OtherToString(this Colour value)
=> "Something else"; // 👆 Extension method on Colour
}
Notes that OtherToString()
has the signature of an extension method on Colour
. This is the general design you will take to create interceptors. If you attempt to build your app now, you'll get an error like this:
error CS9137: The 'interceptors' experimental feature is not enabled in this namespace.
Add '<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);MyInterceptors
</InterceptorsPreviewNamespaces>' to your project.
As you can see from the error, interceptors aren't automatically enabled currently. You have to explicitly opt-in to an interceptors namespace (so that you can conditionally opt in to interceptors) using the InterceptorsPreviewNamespaces
property in your csproj file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<!-- 👇 Add this -->
<InterceptorsPreviewNamespaces>$(InterceptorsPreviewNamespaces);MyInterceptors</InterceptorsPreviewNamespaces>
</PropertyGroup>
</Project>
Note that the MyInterceptors
value I used is the namespace in which I defined the Interceptors.OtherToString()
value.
OK, now we should have everything we need for the interceptor. But that's where things go off the rails.
ToString()
weirdness and bugs
After adding the InterceptorsPreviewNamespaces
property to the csproj file, I tried to build again. This time I got another build-time error, though the problem here was less obvious:
error CS9148: Interceptor must have a 'this' parameter
matching parameter 'Enum this' on 'Enum.ToString()'.
What this error seemed to be saying was that instead of an interceptor with a method signature that looks like this:
// 👇 Extension method on Colour
public static string OtherToString(this Colour value)
I needed one that looks like this:
// 👇 Extension method on System.Enum
public static string OtherToString(this System.Enum value)
That seemed…odd. But I know that enum
are a bit weird, because they have special-case compiler support, so it seemed plausible. And sure enough, opening Visual Studio's Syntax Visualizer showed that the "method symbol" being invoked in the expression value.ToString()
was indeed Enum.ToString()
not Colour.ToString()
On that basis, I updated the method argument for OtherToString()
to use System.Enum
and left everything else the same:
namespace MyInterceptors;
public static class Interceptors
{
[InterceptsLocation(@"C:\testapp\Program.cs", line: 5, column: 25)]
public static string OtherToString(this System.Enum value)
=> "Something else"; // 👆 Extension method on Enum
}
And it compiles!
But if you try to run this application, you get:
> dotnet run
Unhandled exception. System.InvalidProgramException:
Common Language Runtime detected an invalid program.
at Program.<Main>$(String[] args)
Oh dear, InvalidProgramException
's are generally Bad™ I took to GitHub to describe the issue, and sure enough, it's a bug in Roslyn 😬
No interceptors for me
The problem appears to be a bug in the Roslyn compiler producing invalid IL. If we look at the IL of the compiled application's entrypoint (for example by using ildasm.exe), it looks like this:
.method private hidebysig static void '<Main>$'(string[] args) cil managed
{
.entrypoint
// Code size 15 (0xf)
.maxstack 1
.locals init (valuetype Colour V_0)
IL_0000: ldc.i4.1
IL_0001: stloc.0
IL_0002: ldloc.0
IL_0003: call string MyInterceptors.Interceptors::OtherToString(class [System.Runtime]System.Enum)
IL_0008: call void [System.Console]System.Console::WriteLine(string)
IL_000d: nop
IL_000e: ret
}
I've added a basic explanation of the IL op-codes below (it assumes you have a basic understanding of IL as a stack-based virtual machine):
.locals init (valuetype Colour V_0) // Declare a single variable of type Colour
IL_0000: ldc.i4.1 // Pushes the value "1" onto the stack
IL_0001: stloc.0 // Stores the value into the variable at location 0
IL_0002: ldloc.0 // Loads the variable at location 0 into the stack
IL_0003: call string MyInterceptors.Interceptors::OtherToString(class [System.Runtime]System.Enum)
// 👆 Attempts to call OtherToString, passing in the variable 💥
The problem in the IL is that we have a variable of type Colour
which we assign the value 1
(Blue
) in the first 2 op codes. We then load the Colour
variable and try to directly invoke OtherToString
which requires System.Enum
. Colour
is not assignable to System.Enum
in IL, and so we get an InvalidProgramException
!
So where does that leave us? Well, until this bug is fixed, there's basically no hope for NetEscapdes.EnumGenerators
interceptors. But once it is, I think they should definitely be feasible, which I think would be a really nice bonus feature!
Summary
In this post I described some of the performance concerns related to enum
and how NetEscapades.EnumGenerators addresses them by avoiding the need to keep your app constantly updated. I then attempted to address the downside of the NetEscapades.EnumGenerators package—remembering to use it—by creating an interceptor. Unfortunately, my attempt to create an interceptor for Enum.ToString()
revealed a bug in the compiler, which means that's off the table for now. I'm hoping to be able to make it work soon, assuming the bug is fixed soon!