Blog
Enums are great, but enums with performance is better
Enums are great I use them all the time and I think all the .NET developers are using it but there have a tradeoff: enums are not that performant because all the utility methods to work with them are relatively expansive regarding to performance.
Let's do a little benchmark to measure how these utility methods aren't optimized (we will compare these results later with other options, this benchmark was done on a console application with the .NET Framework 4.8):
| Method | Mean | Error | StdDev | Allocated |
|-------------- |----------:|---------:|---------:|----------:|
| EnumToString | 324.46 ns | 4.764 ns | 4.456 ns | 36 B |
| EnumIsDefined | 66.95 ns | 0.877 ns | 0.820 ns | - |
| EnumTryParse | 211.29 ns | 1.256 ns | 1.175 ns | 68 B |
Now that we have put the finger on this issue, what can we do to fix it? The obvious solution would be to implement a caching system. We could store the label of each value of our enum in a collection (dictionary? array?) and get it from that collection rather than getting it from the enum itself. Our caching class could be something like it:
public static class ColorCachingExtensions
{
private static IDictionary<Color, string> colorNames = new Dictionary<Color, string>();
public static string ToStringWithCache(this Color color)
{
if (colorNames.ContainsKey(color))
{
return colorNames[color];
}
string colorName = color.ToString();
colorNames.Add(color, colorName);
return colorName;
}
}
Let's see if we improved the performance and the memory allocation or not:
Benchmark with .NET 4.8:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |-----------:|----------:|-----------:|----------:|
| EnumToString | 346.352 ns | 5.1282 ns | 4.2823 ns | 36 B |
| EnumToStringWithCache | 23.668 ns | 0.5214 ns | 0.7137 ns | - |
Benchmark with .NET 6:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |----------:|----------:|----------:|----------:|
| EnumToString | 25.039 ns | 0.5674 ns | 0.6306 ns | 24 B |
| EnumToStringWithCache | 16.416 ns | 0.2045 ns | 0.1913 ns | - |
As you can see it works pretty well (15x faster with no memory allocation) but there are two tradeoffs doing something like that:
- Even if the code is very basic it would be a lot of code to write and maintain if we have several enums and some big ones, what is somehing that often happen in projects
- A caching system is sensitive and can be a source of difficult bugs to investigate on
Another solution would be to create a custom extension methods for our enumeration to implement ourselves the equivalent of the .ToString() method, it could be something like this:
public static class ColorExtensions
{
public static string ToStringFast(this Color color)
=> color switch
{
Color.Green => nameof(Color.Green),
Color.Blue => nameof(Color.Blue),
Color.Red => nameof(Color.Red),
Color.Yellow => nameof(Color.Yellow),
Color.Cyan => nameof(Color.Cyan),
Color.Pink => nameof(Color.Pink),
_ => color.ToString(),
};
}
As you see I just implement a switch that returns the name of the value passed by argument. Let's benchmark it to see how the performance is compared to the previous ones:
Benchmark with .NET 4.8:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |-----------:|----------:|-----------:|----------:|
| EnumToString | 346.352 ns | 5.1282 ns | 4.2823 ns | 36 B |
| EnumToStringWithCache | 23.668 ns | 0.5214 ns | 0.7137 ns | - |
| EnumToStringFast | 3.045 ns | 0.0508 ns | 0.0451 ns | - |
Benchmark with .NET 6:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |----------:|----------:|----------:|----------:|
| EnumToString | 25.039 ns | 0.5674 ns | 0.6306 ns | 24 B |
| EnumToStringWithCache | 16.416 ns | 0.2045 ns | 0.1913 ns | - |
| EnumToStringFast | 1.679 ns | 0.0782 ns | 0.0693 ns | - |
As you can see our extension method is 115x faster that the classic .ToString() method and has no memory allocation, way more performant! And we can also implement the others methods as well.
The .IsDefined method could be something like this:
public static bool IsDefinedFast(this string color)
=> color switch
{
"Green" => true,
"Blue" => true,
"Red" => true,
"Yellow" => true,
"Cyan" => true,
"Pink" => true,
_ => false
};
And the TryParse method could look like this:
public static bool TryParseFast(string color, out Color? parsedColor)
{
parsedColor = null;
switch (color)
{
case "Green":
parsedColor = Color.Green;
return true;
case "Blue":
parsedColor = Color.Blue;
return true;
case "Cyan":
parsedColor = Color.Cyan;
return true;
case "Pink":
parsedColor = Color.Pink;
return true;
case "Red":
parsedColor = Color.Red;
return true;
case "Yellow":
parsedColor = Color.Yellow;
return true;
}
return false;
}
Benchmark with .NET 4.8:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |-----------:|----------:|-----------:|----------:|
| EnumToString | 346.352 ns | 5.1282 ns | 4.2823 ns | 36 B |
| EnumToStringWithCache | 23.668 ns | 0.5214 ns | 0.7137 ns | - |
| EnumToStringFast | 3.045 ns | 0.0508 ns | 0.0451 ns | - |
| EnumIsDefined | 69.344 ns | 1.1880 ns | 1.4142 ns | - |
| EnumIsDefinedFast | 15.167 ns | 0.2528 ns | 0.3543 ns | - |
| EnumTryParse | 242.248 ns | 4.8671 ns | 12.8219 ns | 68 B |
| EnumTryParseFast | 30.590 ns | 0.5771 ns | 0.5926 ns | - |
Benchmark with .NET 6:
| Method | Mean | Error | StdDev | Allocated |
|---------------------- |----------:|----------:|----------:|----------:|
| EnumToString | 25.039 ns | 0.5674 ns | 0.6306 ns | 24 B |
| EnumToStringWithCache | 16.416 ns | 0.2045 ns | 0.1913 ns | - |
| EnumToStringFast | 1.679 ns | 0.0782 ns | 0.0693 ns | - |
| EnumIsDefined | 84.807 ns | 1.4815 ns | 1.3858 ns | - |
| EnumIsDefinedFast | 2.753 ns | 0.0872 ns | 0.0773 ns | - |
| EnumTryParse | 76.595 ns | 1.3933 ns | 1.2351 ns | - |
| EnumTryParseFast | 11.209 ns | 0.1397 ns | 0.1307 ns | - |
As you can see, performance gain are massive. We already saw that the faster equivalent of the .ToString() method is more than 100 times faster that the classic method but you can see that the IsDefined method is 5x faster than is equivalent and the TryParse method is 8 times faster than is equivalent with no memory allocation against 68 bytes. It may seem small because we speack about nanoseconds and bytes here but multiply that by the number of times you work with enums and it can make a difference, especially if you have to work with limited environments like arduinos or Raspberry Pis.
Our custom methods are way more efficients than the .NET ones but obviously our solution isn't perfect: it requires to write a specific extension class with several methods implementations for each enumeration you are using. It's a lot of code, it must be maintened to not create bugs and it can get quite laborious if you have to deal with big enumerations (my colors enumeration in the example has 6 values but the .NET one has 147 values for examples, imagine our class now with these enum).
This is where comes Andrew Lock and Source Generators!
A Source Generator is a new kind of component that you can write to do several things like generating C# source files that can be added to a compilation object during compilation. It means that you can provide to the compilation some additional source files while the source code is being compiled. It's exactly what we need: Source Generators can be used to create extensions classes dynamically at the compilation to write our methods for us. It's brilliant, and it's exactly what Andrew Lock did! Let me introduce you the NetEscapades.EnumGenerators NuGet package.
NetEscapades.EnumGenerators
This little but absolutely brilliant NuGet package is a Source Generator that creates one extension class per enum that adds 7 useful methods to it that are much faster than their built-in equivalents:
- ToStringFast() (replaces ToString())
- IsDefined(T value) (replaces Enum.IsDefined
(T value)) - IsDefined(string name) (new, is the provided string a known name of an enum)
- TryParse(string? name, bool ignoreCase, out T value) (replaces Enum.TryParse())
- TryParse(string? name, out T value) (replaces Enum.TryParse())
- GetValues() (replaces Enum.GetValues())
- GetNames() (replaces Enum.GetNames())
To use it, you have to do two things:
- Add the package to your project
- Identity with an attribute the enums for which you want an extension class
Note that this NuGet package uses the .NET 6 incremental generator APIs, so you must have the .NET 6 SDK installed, though you can target earlier frameworks like .NET Core 3.1 or .NET Framework 4.8.
You can run the following command to install this package:
dotnet add package NetEscapades.EnumGenerators --prerelease
This adds a Package Reference to your project. You can additionally mark the package as PrivateAssets="all" and ExcludeAssets="runtime". Setting PrivateAssets="all" means any projects referencing this one won't get a reference to the NetEscapades.EnumGenerators package and setting ExcludeAssets="runtime" ensures the NetEscapades.EnumGenerators.Attributes.dll file is not copied to your build output because it is not required at runtime, the Source Generator operates during the compilation.
In a .NET Framework 4.8 project you will have a line like this in your packages.config file:
<package id="NetEscapades.EnumGenerators" version="1.0.0-beta04" targetFramework="net48" PrivateAssets="all" ExcludeAssets="runtime" />
To use the Source Generator to an enum, you have to add the attribute EnumExtensions to it:
[EnumExtensions]
public enum Color
{
Green,
Red,
Blue,
Yellow,
Cyan,
Pink
}
Now if you compile your project, NetEscapades.EnumGenerators will generate an extension class for each enum you marked with the EnumExtensions attribute, here is my ColorExtensions class:
Let's do a little benchmark to see what are the performances now with the NetEscapades.EnumGenerators methods (benchmark on .NET 6):
| Method | Mean | Error | StdDev | Allocated |
|------------------ |---------:|----------:|----------:|----------:|
| EnumToStringFast | 2.055 ns | 0.1037 ns | 0.0919 ns | - |
| EnumIsDefinedFast | 3.765 ns | 0.0880 ns | 0.0735 ns | - |
| EnumTryParseFast | 9.916 ns | 0.1995 ns | 0.2375 ns | - |
As you can see, NetEscapades.EnumGenerators is a really great and elegant solution to make enumerations performant which is a major thing because of the utility of enums.
Also, this package has a lot of features. For example, he takes enums attributes into account when generating the extension class so you can still use enums values display name for example (not in the 1.0.0-beta04 which was release in November 2021 but the development of this feature is ready to ship):
[EnumExtensions]
public enum Color
{
Green,
Red,
Blue,
[Display(Name = "It's yellow")]
Yellow,
[Display(Name = "Clear blue")]
Cyan,
Pink
}
You also can configure the extension class for an enum with a custom name and a custom namespace:
[EnumExtensions(ExtensionClassName ="MyCustomColorExtensionClass", ExtensionClassNamespace = "MyCustomColorExtensionClassNamespace")]
And now my class has another name and namespace:
You can check the GitHub of the project to see all the available options and features, and check the links below to deep more about NetEscapades.EnumGenerators (I recommand the post from Andrew Lock itself on his blog which are really well written and detailled and the video by Nick Chaspas).
Keep coding folks! :)