Lately I've played around a bit with source generators.
I wanted to see if I could improve the experience of using my JOS.Enumeration project.
"Old" implementation
When creating your own Enumeration implementation before, you needed to implement the abstract Enumeration<T> class, something like this:
public record Hamburger : Enumeration<Hamburger>
{
public static readonly Hamburger Cheeseburger = new (1, "Cheeseburger");
public static readonly Hamburger BigMac = new(2, "Big Mac");
public static readonly Hamburger BigTasty = new(3, "Big Tasty");
private Hamburger(int value, string displayName) : base(value, displayName)
{
}
}
The Enumeration<T> base class looked like this:
public abstract record Enumeration<T> : IComparable<T> where T : Enumeration<T>
{
private static readonly Lazy<Dictionary<int, T>> AllItems;
private static readonly Lazy<Dictionary<string, T>> AllItemsByName;
static Enumeration()
{
AllItems = new Lazy<Dictionary<int, T>>(() =>
{
return typeof(T)
.GetFields(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly)
.Where(x => x.FieldType == typeof(T))
.Select(x => x.GetValue(null))
.Cast<T>()
.ToDictionary(x => x.Value, x => x);
});
AllItemsByName = new Lazy<Dictionary<string, T>>(() =>
{
var items = new Dictionary<string, T>(AllItems.Value.Count);
foreach (var item in AllItems.Value)
{
if (!items.TryAdd(item.Value.DisplayName, item.Value))
{
throw new Exception(
$"DisplayName needs to be unique. '{item.Value.DisplayName}' already exists");
}
}
return items;
});
}
protected Enumeration(int value, string displayName)
{
Value = value;
DisplayName = displayName;
}
public int Value { get; }
public string DisplayName { get; }
public override sealed string ToString() => DisplayName;
public static IReadOnlyCollection<T> GetAll()
{
return AllItems.Value.Values;
}
public static IEnumerable<T> GetEnumerable()
{
return AllItems.Value.Values;
}
public static T FromValue(int value)
{
if (AllItems.Value.TryGetValue(value, out var matchingItem))
{
return matchingItem;
}
throw new InvalidOperationException($"'{value}' is not a valid value in {typeof(T)}");
}
public static T FromDisplayName(string displayName)
{
if (AllItemsByName.Value.TryGetValue(displayName, out var matchingItem))
{
return matchingItem;
}
throw new InvalidOperationException($"'{displayName}' is not a valid display name in {typeof(T)}");
}
public int CompareTo(T? other) => Value.CompareTo(other!.Value);
}
As you can see, the above implementation relied heavily on reflection to obtain all enumeration fields at runtime. They were then cached in a Dictionary to allow faster lookups.
Source generated implementation
By using source generators, we can generate highly optimized code, thus increasing the performance since we don't need to do any reflection at runtime.
Let's start with creating our Hamburger implementation once more.
public partial record Hamburger : IEnumeration<Hamburger>
{
public static readonly Hamburger Cheeseburger = new (1, "Cheeseburger");
public static readonly Hamburger BigMac = new(2, "Big Mac");
public static readonly Hamburger BigTasty = new(3, "Big Tasty");
}
A couple of things to note:
- We are now implemeting the IEnumeration<T> interface instead of the Enumeration<T> class.
- The record is now marked as partial to allow us to extend the class with our source generators.
- We don't need to create the private constructor -> it will be generated automatically by our source generator.
IEnumeration<T> looks like this:
public interface IEnumeration<out T>
{
int Value { get; }
string DisplayName { get; }
static abstract IReadOnlyCollection<T> GetAll();
static abstract IEnumerable<T> GetEnumerable();
static abstract T FromValue(int value);
static abstract T FromDisplayName(string displayName);
}
The generated record will look like this:
public partial record Hamburger : IComparable<Hamburger>
{
private static readonly IReadOnlyCollection<Hamburger> AllItems;
static Hamburger()
{
AllItems = new HashSet<Hamburger>(3)
{
Cheeseburger,
BigMac,
BigTasty
};
}
private Hamburger(int value, string displayName)
{
Value = value;
DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName));
}
public int Value { get; }
public string DisplayName { get; }
public static IReadOnlyCollection<Hamburger> GetAll()
{
return AllItems;
}
public static IEnumerable<Hamburger> GetEnumerable()
{
yield return Cheeseburger;
yield return BigMac;
yield return BigTasty;
}
public static Hamburger FromValue(int value)
{
return value switch
{
1 => Cheeseburger,
2 => BigMac,
3 => BigTasty,
_ => throw new InvalidOperationException($"'{value}' is not a valid value in 'JOS.Enumerations.Hamburger'")};
}
public static Hamburger FromDisplayName(string displayName)
{
return displayName switch
{
"Cheeseburger" => Cheeseburger,
"Big Mac" => BigMac,
"Big Tasty" => BigTasty,
_ => throw new InvalidOperationException($"'{displayName}' is not a valid display name in 'JOS.Enumerations.Hamburger'")};
}
public int CompareTo(Hamburger? other) => Value.CompareTo(other!.Value);
public static implicit operator int (Hamburger item) => item.Value;
public static implicit operator Hamburger(int value) => FromValue(value);
}
Some features:
- Generated IComparable<T> method.
- Generated implicit operators (convert to/from int).
- Generated optimized GetAll, FromValue and FromDisplayName methods.
Let's compare the generated methods vs the ones from the Enumeration<T> class one by one.
FromValue
Enumeration<T>
public static T FromValue(int value)
{
if (AllItems.Value.TryGetValue(value, out var matchingItem))
{
return matchingItem;
}
throw new InvalidOperationException($"'{value}' is not a valid value in {typeof(T)}");
}
Generated
public static Hamburger FromValue(int value)
{
return value switch
{
1 => Cheeseburger,
2 => BigMac,
3 => BigTasty,
_ => throw new InvalidOperationException($"'{value}' is not a valid value in 'JOS.Enumerations.Hamburger'")};
}
The generated version is using a switch statement, is that faster than the Dictionary approach in the old version?
Performance
Apple M2, 1 CPU, 8 logical and 8 physical cores
.NET SDK=8.0.100-preview.4.23260.5
[Host] : .NET 7.0.4 (7.0.423.11508), Arm64 RyuJIT AdvSIMD
.NET 7.0 : .NET 7.0.4 (7.0.423.11508), Arm64 RyuJIT AdvSIMD
Job=.NET 7.0 Runtime=.NET 7.0
| Method | Categories | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|-------------------------- |---------------- |-----------:|----------:|----------:|-----------:|------:|--------:|-------:|----------:|------------:|
| Generic_FromValue | FromValue | 8.5714 ns | 0.1219 ns | 0.1140 ns | 8.5324 ns | 1.00 | 0.00 | - | - | NA |
| Generated_FromValue | FromValue | 1.1819 ns | 0.0076 ns | 0.0071 ns | 1.1828 ns | 0.14 | 0.00 | - | - | NA |
The answer is yes. No sane person would write the generated version by hand. It's boring and easy to forget to populate the switch statment when adding a new enumeration item. In other words; a perfect fit for source generation.
FromDisplayName
Enumeration<T>
public static T FromDisplayName(string displayName)
{
if (AllItemsByName.Value.TryGetValue(displayName, out var matchingItem))
{
return matchingItem;
}
throw new InvalidOperationException($"'{displayName}' is not a valid display name in {typeof(T)}");
}
Generated
public static Hamburger FromDisplayName(string displayName)
{
return displayName switch
{
"Cheeseburger" => Cheeseburger,
"Big Mac" => BigMac,
"Big Tasty" => BigTasty,
_ => throw new InvalidOperationException($"'{displayName}' is not a valid display name in 'JOS.Enumerations.Hamburger'")};
}
Same approach here, will it generate the same result?
Performance
Apple M2, 1 CPU, 8 logical and 8 physical cores
.NET SDK=8.0.100-preview.4.23260.5
[Host] : .NET 7.0.4 (7.0.423.11508), Arm64 RyuJIT AdvSIMD
.NET 7.0 : .NET 7.0.4 (7.0.423.11508), Arm64 RyuJIT AdvSIMD
Job=.NET 7.0 Runtime=.NET 7.0
| Method | Categories | Mean | Error | StdDev | Median | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|-------------------------- |---------------- |-----------:|----------:|----------:|-----------:|------:|--------:|-------:|----------:|------------:|
| Generic_FromDisplayName | FromDisplayName | 13.7528 ns | 0.0280 ns | 0.0248 ns | 13.7465 ns | 1.00 | 0.00 | - | - | NA |
| Generated_FromDisplayName | FromDisplayName | 1.1866 ns | 0.0020 ns | 0.0018 ns | 1.1870 ns | 0.09 | 0.00 | - | - | NA |
Yeah, no surprises here either. The generated version is much faster.
GetAll
Enumeration<T>
public static IReadOnlyCollection<T> GetAll()
{
return AllItems.Value.Values;
}
Generated
public static IReadOnlyCollection<JOS.Enumerations.Hamburger> GetAll()
{
return AllItems;
}
No difference here really, both methods just return a collection that's been initialized once in a static ctor, so they perform more or less the same. The old version will be a bit slower the first time it's called if it needs to initialize the collection (via reflection).
Enumerations class
As a "bonus", I've also generated an Enumerations class that looks like this:
public static class Enumerations
{
public static class Hamburger
{
public static class Cheeseburger
{
public const int Value = 1;
public const string DisplayName = "Cheeseburger";
}
public static class BigMac
{
public const int Value = 2;
public const string DisplayName = "Big Mac";
}
public static class BigTasty
{
public const int Value = 3;
public const string DisplayName = "Big Tasty";
}
}
public static class Sausage
{
public static class HotDog
{
public const int Value = 1;
public const string DisplayName = "Hot Dog";
}
public static class Pølse
{
public const int Value = 2;
public const string DisplayName = "Pølse";
}
}
}
This makes it possible to refer to the enumerations where you'll need constant values. Since we also have implicit operators to convert to/from an int, the following code is possible to write, it just works.
const int cheeseburger = Enumerations.Hamburger.Cheeseburger.Value;
Hamburger hamburger = cheeseburger;
hamburger.ShouldBeSameAs(Hamburger.Cheeseburger);
The opposite works as well:
var cheeseburger = Hamburger.Cheeseburger;
int hamburger = cheeseburger;
hamburger.ShouldBeSameAs(Enumerations.Hamburger.Cheeseburger.Value);
The actual source generator can be found here.
In my next post, I will go through it step by step and explain it more in depth.