UnionGen 1.2.0-preview2

This is a prerelease version of UnionGen.
There is a newer version of this package available.
See the version list below for details.
dotnet add package UnionGen --version 1.2.0-preview2
NuGet\Install-Package UnionGen -Version 1.2.0-preview2
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="UnionGen" Version="1.2.0-preview2" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add UnionGen --version 1.2.0-preview2
#r "nuget: UnionGen, 1.2.0-preview2"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install UnionGen as a Cake Addin
#addin nuget:?package=UnionGen&version=1.2.0-preview2&prerelease

// Install UnionGen as a Cake Tool
#tool nuget:?package=UnionGen&version=1.2.0-preview2&prerelease

Union Source Generator

Union Source Generator is a C# source generator that generates a union type for a set of types. The generated union type can hold any one of the specified types. Consuming the type can be done by exhaustive pattern matching.

The main component is one generic attribute, UnionAttribute, which is used to specify the types that the union can hold, on a struct:

[Union<Result<int>, NotFound>]
public readonly partial struct SimpleObj;

This will result in a generated SimpleObj type that can hold any of the specified types, but only one at a time. It also provides compile time checked exhaustive Switch and Match methods to handle the different types. Implicit conversions operators are generated as well as equality members.

SimpleObj simple = CreateSimple();
simple.Switch(
              r =>  Console.WriteLine($"Found: {r}"),
              _ => Console.WriteLine("not found"));
int result = simple.Match(r => r.Value * 2,
                          _ => -1);

SimpleObj CreateSimple() => new NotFound();

While the generator itself has to be a netstandard2.0 project, the generated code assumes C#12 / .NET 8 at this point.

This project is heavily influenced by the great OneOf library. All credit for the original concept to its authors!

Opinionated Naming Scheme

This library is opinionated as it will try to assign 'readable' names to the properties based on the specified types:

SimpleObj simple = new SimpleObj(new Result<int>(12));
bool found = simple.IsNotFound;
Result<int> result = simple.AsResultOfInt32();

It even will try to detect collections and assign names like ListOfFoo or DictionaryOfStringAndInt64.

The same is true for the lambda parameter names in the Match & Switch methods. For Switch they will get names like forString (or forNone) and for Match ones like withString (or withNone).

That can work great in many scenarios but will probably lead to bad naming in some cases - that's the trade-off I'm willing to accept.

Union Object Size

We try to be smart and use as little memory as possible for the union object.

  • They are readonly structs
  • Only those fields actually needed are generated
    • e.g. only a single reference field which is used for all reference types
    • if there are no reference types, no reference field is generated
    • if there are no value types, no value field is generated
  • The fields of the value types are stored at the same offset
    • That is safe, because only one of those will ever be set and the values are readonly
  • A single byte is used for storing the state so that they union object knows what it is

So the minimal size is 3 bytes (1 for state, 1 for each of the two min. required types) and the maximal size is 9 bytes (1 for state and 8 for the reference type) + the size of the largest value type. Plus padding for alignment (see below).

We assume 8 bytes for reference types, so 32bit targets waste some space and, for larger pointer sizes explicit alignment configuration is required!

Alignment

There is always a little trade-off between memory and performance. I expected that it would be better to sacrifice a few bytes to get better alignment of the fields and improve perf. As so often, running a few benchmarks proved me wrong 🤔

Unaligned was usually on par and faster much more often than it was slower. In any case, the difference were a couple of ms for summing up a million values (with random picking). Thus, I decided to keep it unaligned by default, but the option to change it, based on your knowledge about the concrete types used, exists as detailed below.

As far as I know it is not possible to get the size of a type at compile time, so we cannot automatically make the optimal decision here.

Alignment can be configured for each union type individually by passing one of the UnionAlignment enum values to the attributes constructor like so:

[Union<Result<int>, NotFound>(UnionAlignment.Aligned8)]
public readonly partial struct Foo;

At this point there are four options:

  • Unaligned: No padding is added to the state field
    • This is the default
    • A reference type still gets 8 bytes at the beginning of the struct
    • Value type fields are placed directly after the state field - this will result in those being misaligned in most cases, but no space is wasted
  • Aligned4: The state field is followed by 3 bytes of padding
    • A reference type still gets 8 bytes at the beginning of the struct
    • Should work well for 32bit targets or field with a natural alignment of 4 bytes
  • Aligned8: The state field is followed by 7 bytes of padding
    • A reference type still gets 8 bytes at the beginning of the struct
    • This setting should work well for 64bit targets, or fields with a natural alignment of 8 bytes, but wastes quite a bit of space
  • Aligned16: The state field is followed by 15 bytes of padding
    • A reference type still also gets 16 bytes at the beginning of the struct
      • In case you have a special system with 16 byte pointers, this can be used to reserve enough space
    • This setting might be useful for SIMD scenarios or other special cases

And, of course, at the end of the struct the runtime will probably pad to the next 8 byte boundary as well - as usual.

Motivation

My main motivation was to finally learn more about writing source generators by creating one myself. I haven't found a lot of resources regarding generic marker attributes in combination with source generators, so I'm not sure my approach is optimal, but maybe it can serve as a starting point for others.

As a first project I wanted something with a small scope and I was always a little annoyed by the property names (T0, T1, ...) in the OneOf library (which they have to use due to the types being generic - even when using their source generator). So this is what I decided to tackle.

Quality

This is a two-day toy project without much testing (and no serious automated tests). I will probably use it in my own projects in the future to see how far I'll get and fix issues as they arise.

Feedback (and PRs 😉) to make the implementation more robust, efficient and generally better are welcome, of course!

Don't expect production grade reliability here!

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net8.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
2.1.1 27 5/17/2024
2.1.0 37 5/16/2024
2.0.0 229 5/5/2024
1.5.0 140 4/10/2024
1.4.1 76 4/10/2024
1.4.0 130 3/28/2024
1.3.0 99 3/27/2024
1.2.2 93 3/27/2024
1.2.2-preview1 76 3/27/2024
1.2.1 91 3/26/2024
1.2.0 100 3/26/2024
1.2.0-preview2 63 3/26/2024
1.2.0-preview1 59 3/26/2024
1.1.4 124 3/24/2024
1.1.2 140 3/24/2024
1.1.1 137 3/24/2024
1.1.0 148 3/24/2024
1.0.1 150 3/23/2024
1.0.0 121 3/23/2024