Quantcast
Channel: Andrew Lock | .NET Escapades
Viewing all articles
Browse latest Browse all 743

Recent updates for NetEscapades.EnumGenerators: interceptors!

$
0
0

In this post I describe a recent update to the NetEscapades.EnumGenerators package. The big new feature introduced in 1.0.0-beta11 is experimental support for interceptors. Interceptors allow you to replace one method call with a completely different method.

This post doesn't discuss how I added support for interceptors; I will describe that in a future post. This post discuss how the feature works in the NuGet package, and how it works in general.

I first describe why I created this package, show how to add it to your own project, how to use the basic functionality, and provide some basic benchmarks. Finally I show off the new interceptor feature and discuss what you need to do to enable it.

Enums are often surprisingly slow

NetEscapades.EnumGenerators was one of the first source generators I created using the incremental generator support introduced in .NET 6. I chose to create this package to work around an annoying characteristic of working with enums: some operations are surprisingly slow.

Note that while this has historically been true, this fact won't necessarily remain true forever. In fact, .NET 8 provided a bunch of improvements to enum handling in the runtime.

As an example, let's say you have the following enum:

public enum Colour
{
    Red = 0,
    Blue = 1,
}

At some point, you want to print the name of a Color variable, so you create this helper method:

public void PrintColour(Colour colour)
{
    Console.WriteLine("You chose "+ colour.ToString()); // You chose Red
}

While this looks like it should be fast, it's really not. NetEscapades.EnumGenerators works by automatically generating an implementation that is fast. It generates a ToStringFast() method that looks something like this:

public static class ColourExtensions
{
    public string ToStringFast(this Colour colour)
        => colour switch
        {
            Colour.Red => nameof(Colour.Red),
            Colour.Blue => nameof(Colour.Blue),
            _ => colour.ToString(),
        }
    }
}

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 it falls back to the built-in ToString() implementation to ensure correct handling of unknown values (for example this is valid C#: PrintColour((Colour)123)).

If we compare these two implementations using BenchmarkDotNet for a known colour, you can see how much faster ToStringFast() implementation is:

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
MethodFXMeanErrorStdDevRatioGen 0Allocated
ToStringnet48578.276 ns3.3109 ns3.0970 ns1.0000.045896 B
ToStringFastnet483.091 ns0.0567 ns0.0443 ns0.005--
ToStringnet6.017.985 ns0.1230 ns0.1151 ns1.0000.011524 B
ToStringFastnet6.00.121 ns0.0225 ns0.0199 ns0.007--

These numbers are a little old now, but the overall pattern hasn't changed: .NET is way faster than .NET Framework, and the ToStringFast() implementation is way faster than the built-in ToString(). Obviously your mileage may vary and the results will depend on the specific enum you're using, but in general, using the source generator should give you a free performance boost.

Adding NetEscapades.EnumGenerators to your project

You can install NetEscapades.EnumGenerators into your own projects using

dotnet add package NetEscapades.EnumGenerators --prerelease

Note that this NuGet package uses incremental generator APIs introduced in the .NET 7 SDK incremental generator APIs, so you must be using at least the .NET 7 SDK, although you can still target earlier frameworks. If you wish to use the interceptors feature described later in the post, you must use the .NET 8.0.4xx SDK at a minimum.

This adds a <PackageReference> to your project. You can additionally mark the package as PrivateAssets="all" and ExcludeAssets="runtime".

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <!-- 👇 Add the package reference-->
    <PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta11" 
    PrivateAssets="all" ExcludeAssets="runtime" />
    <!-- -->
  </ItemGroup>

</Project>

Setting PrivateAssets="all" means any projects referencing this one won't get a reference to the NetEscapades.EnumGenerators package. Setting ExcludeAssets="runtime" ensures the NetEscapades.EnumGenerators.Attributes.dll file is not copied to your build output (it is not required at runtime).

Adding the package to your project automatically adds a marker attribute, [EnumExtensions], to your project. To use the generator, add the [EnumExtensions] attribute to an enum. For example:

using NetEscapades.EnumGenerators;

[EnumExtensions]
public enum Colour
{
    Red = 0,
    Blue = 1,
}

This generates various extension methods for your enum, including ToStringFast(). You can use this method anywhere you would ordinarily call ToString() on the enum, and benefit from the performance improvement for known values:

public void PrintColour(Colour colour)
{
    Console.WriteLine("You chose "+ colour.ToStringFast()); // You chose Red
}

You can view the definition of ToStringFast() by navigating to it's definition:

The ToStringFast definition for Colour

The source generator generates many different extension methods, as described in the project's README. It also adds support for the [DisplayName] and other attributes that enable fast-tracking other common behaviours.

What's more, you can even generate these performance-related extensions for external enums, such as those defined in the runtime or in other NuGet packages. For this you need the generic [EnumExtensions<T>] assembly attribute:

using NetEscapades.EnumGenerators;

[assembly:EnumExtensions<DateTimeKind>]

After adding the above attribute to a project, the generator produces helper methods similar to the following

public static partial class DateTimeKindExtensions
{
    public static string ToStringFast(this DateTimeKind value)
        => value switch
        {
            DateTimeKind.Local => nameof(DateTimeKind.Local),
            DateTimeKind.Unspecified => nameof(DateTimeKind.Unspecified),
            DateTimeKind.Utc => nameof(DateTimeKind.Utc),
            _ => value.ToString(),
        };
}

All of this functionality has been available since 1.0.0-beta09, but there's a big new feature that was added to the latest version 1.0.0-beta11: interceptors.

Adding support for interceptors

Version 1.0.0-beta11 of NetEscapades.EnumGenerators adds support for interceptors. In this section I'll give a brief overview of interceptors, why you might want them, and how to enable them in your own project.

What are interceptors?

Interceptors were an experimental feature originally released in C#12 (with .NET 8) that allow you to replace (or "intercept") a method call in your application with a different method. When your app is compiled, the compiler automatically "swaps out" the call to the original method with your substitute.

If you want to learn more about interceptors I wrote about them in more detail over a year ago, when I explored how the ASP.NET Core framework is using them for minimal APIs.

An obvious question here is why would you want to do that. Why not just call the substitute method directly?

The main motivation is ahead-of-time compilation (AOT). Interceptors aren't specifically for AOT, but they are clearly designed with AOT in mind. By using interceptors you could take code which previously wasn't AOT friendly, and replace it with a source-generated version.

Customer's don't need to change their code, the source generator just automatically "upgrades" the method calls to use the source generated versions. The minimal API and configuration binder AOT support built-in to ASP.NET Core for .NET 8+ uses interceptors to replace the reflection-dependent AOT-unfriendly code with source-generated AOT-friendly equivalents.

Another great example from the community is the DapperAOT project, which uses a source generator to make the Dapper micro-ORM AOT-compatible.

The interceptor API has evolved a little in recent versions of the Roslyn API, but it's still a preview feature as best I can tell. Nevertheless, it presents an interesting possibility for NetEscapades.EnumGenerators!

Why would interceptors be useful for NetEscapades.EnumGenerators?

One of the "problems" of NetEscapades.EnumGenerators is that ToStringFast() is a bit of an ugly name 😅 And you always have to remember to explicitly call the extension instead of the slow built-in ToString() method. Every now and again I get an issue raised asking why I can't "just" replace ToString() directly. Unfortunately, there's not been a way to do that in C#…until now!

I originally explored the possibility of using interceptors in NetEscapades.EnumGenerators about a year ago, when .NET 8 was first released. You can read all about it in detail here, including why it didn't work, but the short answer is that there was a bug in the compiler which was fixed in version 8.0.300 of the .NET SDK (which shipped with Visual Studio 17.10).

With the fix in place, this opened up the possibility of adding an interceptor to NetEscapades.EnumGenerators. The benefit is that if you enable the interceptors for your project, you don't need to call ToStringFast() in your application; the interceptor will kick in and replace all the ToString() calls on your [EnumExtensions] with calls to ToStringFast()!

So once you enable interceptors, this code suddenly gets faster!

public void PrintColour(Colour colour)
{
    // The interceptor replaces this       👇 with ToStringFast()
    Console.WriteLine("You chose "+ colour.ToString()); // You chose Red
}

The end result is the same, but your code suddenly gets faster without you having to do anything!

Currently only ToString() and HasFlag(flag) (for [Flags] enums) are supported by the interceptor, and there are also some other caveats as described later, but for the most part, if you're using a supported version of the .NET SDK, it Just Works™.

Enabling interceptor support for NetEscapades.EnumGenerators

Interceptor support was introduced as part of the NetEscapades.EnumGenerators 1.0.0-beta11 package. In order to enable intercepting usages of ToString(), you must do two things:

  • Update to version 8.0.400 or greater of the .NET SDK. This ships with Visual Studio version 17.11, so you will need at least that version or higher. Just to clarify, the .NET 9 SDK works too.
  • Enable the interceptor in your .csproj by setting EnableEnumGeneratorInterceptor to true, as shown below.

As shown previously, you can add NetEscapades.EnumGenerators to your project using

dotnet add package NetEscapades.EnumGenerators --prerelease

You should then update your project file with the EnableEnumGeneratorInterceptor property:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <!-- 👇 Add this property to enable the interceptor in the project -->
    <EnableEnumGeneratorInterceptor>true</EnableEnumGeneratorInterceptor>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="NetEscapades.EnumGenerators" Version="1.0.0-beta11" 
    PrivateAssets="all" ExcludeAssets="runtime" />
  </ItemGroup>

</Project>

After making that change you'll "magically" have interception! Any enums you decorate with the [EnumExtensions] attribute (and similarly any external enums you generate extensions for using [EnumExtensions<T>]) are automatically opted-in to interception.

Unfortunately, by it's nature, it's somewhat difficult to tell that interception is working—none of your code visibly changes after all! Unfortunately, I've found disappointingly few ways to confirm it's working as expected, even in your IDE.

The main way I found to check if the interceptors are working, is checking the generated output. The interceptor generator results in an additional source-generated file per enum, adding a file with the _Interceptors.g.cs suffix. If you have set EmitGeneratedFiles to true in your project, then these files are visible in the obj/generated folder, but you can also see them directly in Visual Studio or Rider:

The source-generated interceptors are shown with a file suffix of _Interceptors.g.cs

In the above image you can see that there are interceptor files for both DateTimeKind and EnumInFoo. You can dig into these files to check that a particular usage of ToString() was intercepted, but it's not very friendly or convenient.

Note that the current version of Rider 2024.2.7 doesn't handle the new layout of the NuGet package, which causes IntelliSense to fail for the source generated code completely currently. I've filed a bug, and hopefully it'll be fixed in a future version of 2024.3.

Given that interception should "just work", the somewhat-opaque nature of interceptors shouldn't be a big problem, but given there also some caveats around interception I wonder if IDEs will make interceptions sites more visible in the future. Even just having an icon next to interception sites seems like it would be a nice addition.

Disabling interception for specific enums

By default, all calls to ToString() and HasFlag() for enums defined in your project are intercepted when you enable interception, but you can also opt enums out of interception, while still generating the extension methods. To disable interception per-enum, set IsInterceptable = false in the [EnumExtensions] attribute:

[EnumExtensions(IsInterceptable = false)]
public enum Colour
{
    Red = 0,
    Blue = 1,
}

Similarly you can disable interception for any external enums you choose to generate extensions for in a project:

 // 👇 This _will_ have interceptors
[assembly:EnumExtensions<DateTimeKind>]

// 👇 This _won't_ have interceptors
[assembly:EnumExtensions<StringComparison>(IsInterceptable = false)]

An important point is that interception here only works inside the same project as the original [EnumExtensions] enum was defined (or where the [EnumExtensions<T>] external attribute is declared). If you want interception to occur in projects that reference the enum, you'll need to opt in to that, as described below.

Intercepting enums defined in other projects

The interceptor feature currently only runs in the same project as the enum extensions are defined. If you want an enum to also be intercepted in a different project you should add the [Interceptable<T>] assembly attribute to the project where you want interception.

For example, if you have the following enum extensions generated in project A:

[assembly:EnumExtensions<DateTimeKind>]

[EnumExtensions]
public enum Colour
{
    Red = 0,
    Blue = 1,
}

and you want to enable interception in project B (which has a reference to project A) you should add the following in project B:

[assembly:Interceptable<DateTimeKind>]
[assembly:Interceptable<Colour>]

With this change, both project A and B will have interceptions enabled 🎉

Caveats: this won't work everywhere!

The interceptors kick in any time you explicitly call ToString() or HasFlag() on an interceptable enum (assuming you have enabled interception and have a high enough .NET SDK). Unfortunately, there are some cases where interception won't work:

  • When ToString() is called in other source generated code.
    • Source generators can't "see" other source generated code, so there's no way to intercept these usages.
  • When ToString() is called in already-compiled code.
    • Interception works at compile-time, so if a ToString() method has already been "baked in", then the call can't be intercepted.
  • If the ToString() call is implicit.
    • Only explicit calls to ToString() are intercepted.
    • For example, if you use an enum in an interpolated string, "The value is {Color.Red}", there's no explicit call to ToString(), so it won't be intercepted.
  • If the ToString() call is made on a base type, such as System.Enum or object
    • The compile-time type you're calling ToString() on has to be your interceptable enum.
  • If the ToString() call is made on a generic type.
    • This doesn't work, as you would need to call a different interceptor for each T

To try and reinforce these rules, the following shows cases that can be intercepted, followed by cases that won't be intercepted:

[EnumExtensions]
public enum Colour
{
    Red = 0,
    Blue = 1,
}

// All the examples in this method CAN be intercepted
public void CanIntercept()
{
    var ok1 = Color.Red.ToString(); // ✅
    var red = Color.Red;
    var ok2 = red.ToString(); // ✅
    var ok3 = "The colour is " + red.ToString(); // ✅
    var ok4 = $"The colour is {red.ToString()}"; // ✅
}

public void CantIntercept()
{
    var bad1 = ((System.Enum)Color.Red).ToString(); // ❌ Base type
    var bad2 = ((object)Color.Red).ToString(); // ❌ Base type
    
    var bad3 = "The colour is " + red; // ❌ implicit
    var bad4 = $"The colour is {red}"; // ❌ implicit

    string Write<T>(T val)
        where T : Enum
    {
        return val.ToString(); // ❌ generic
    }
}

Another important aspect to be aware of is that the ToStringFast() implementation used in the interceptor is aware of the [Description] and [Display] attributes, and will preferentially use the values they provide over the simple name of the enum member. That means ToString and ToStringFast() may give different values in some cases:

[EnumExtensions]
public enum Values
{
    First,
    [Display(Name = "2nd")]
    Second,
    Third,
}

var without = Values.Second.ToString(); // Returns "Second" when interception is disabled
var with = Values.Second.ToString(); // Returns "2nd" when interception is enabled

So as you can see, on the one hand, interceptors can give you a "free" speed boost, without having to add ToStringFast() calls everywhere, but it won't work for all cases by a long shot.

Future ideas

I'd like to create a stable version of the package soon, but there are a couple of things I'd like to address first.

First of all, I think it was an error adding the extra [Description]/[Display] behaviour to the default ToStringFast() method; that should have been added to overloads only. Depending on feedback one way or another, I may well make a breaking change and change the default behaviour.

This would have the benefit of being a "safer" drop in replacement for ToString() in the interceptor scenario, and generally just maps better I think.

In terms of interceptors, I don't like the fact that there are lots of ways to "accidentally" not use the interceptor. I was considering adding an analyzer suggesting that you call ToString() in certain situations. For example

  • When you use an enum in an interpolated string then the ToString() call won't be intercepted.
  • When you pass an enum to any method that takes an object or System.Enum then if the method serializes the enum, the call won't be intercepted.

There's going to be trade-offs if I do add an analyzer for these though, so I'm not entirely sure they're worth doing. And I don't enjoy writing analyzers 😅

Nevertheless, I'd be interested to see what people thing about the experimental interceptor support, whether there's any obvious concerns with the current implementation (particularly the ToString()/ToStringFast() differences) or if anyone has further ideas. Please raise an issue on GitHub if you have any thoughts!

Summary

In this post, I described the new experimental interceptor support added to NetEscapades.EnumGenerators in 1.0.0-beta11. You can enable interception for supported enums by setting EnableEnumGeneratorInterceptor=true in your .csproj file.

Interception is only enabled in the project where the enum is defined; if you want interception in other projects you need to add the [assembly:Interceptable<T>] attribute in those projects. You can disable interception for a specific enum by setting the IsInterceptable property on the [EnumExtensions] attribute.

Please give the new version a try, see what you think, if you have any feedback, raise an issue on GitHub, thanks!


Viewing all articles
Browse latest Browse all 743

Trending Articles