OneOf (with benchmarks)
There’s a library called OneOf that gives something close to discriminated unions in C# (like in for example F# and TypeScript). With it you can return a OneOf
that wraps several return-types from your methods and handle them with some in-line lambdas.
For example, instead of using exception-based flow (which you really should never use) like this:
public bool ExceptionBased()
{
try
{
DoSomethingExceptional();
}
catch (Exception)
{
return false;
}
return true;
}
int DoSomethingExceptional()
{
var v = _random.Next(1);
if (v == 0)
{
throw new Exception();
}
return v;
}
You would write something like this in our contrived example:
public bool OneOfBased()
{
var result = DoSomethingOneOf();
return result.Match(
success => true,
failure => false
);
}
OneOf<int, Failure> DoSomethingOneOf()
{
var v = _random.Next(1);
if (v == 0)
{
return new Failure();
}
return v;
}
public record Failure();
To me this reads a lot better, and in larger code-bases it represents all the possible states a lot better than exceptions or (the more common case) returning an object and inspecting random things inside it to decide which code-flow to use.
Caveat emptor
The order of the types in the OneOf
(int
and Failure
, in this case) decide the order of the lambdas, and you just have to get it right. If you return something that you use in your lambda the compiler will probably catch it, but if it’s just marker-types like Failure
you may run into problems.
I’ve gotten into the convention of returning the happy-case as the first type, and then progressing on to more and more off-the-path.
Also, if you have something you need to return many types from to catch many possible results your OneOf<T, U, V, W, X, Y, Z...>
will get big and ugly. If you’re using that OneOf
in several places it’ll really screw up your code.
For those kind of problems you can create your own return-type that inherits a OneOfBase
and then use that. It will be a class
instead of a struct
, and therefore be slightly slower and more memory-intensive – but it’s nice to work with. To get this working for free you just use the OneOf.SourceGenerator
-package and declare your return-type class like this (not the use of partial
here to allow the generator to expand the ReturnType
):
[GenerateOneOf]
public partial class ReturnType : OneOfBase<int, Failure> {}
Performance
How does this perform, in comparison to other ways of handling control flow?
I’ve made some benchmarks with BenchmarkDotNet to see how OneOf
stacks up in comparison to a few different ways of handling flow. Like all micro-benchmarks this is likely not directly applicable to your code, but it can tell us something about how OneOf
behaves.
You can download and run the benchmarks yourself from the GitHub repo.
I used the code above, with the exception-based flow as the worst performer (by far). I also made some variations that returned a nullable int and a tuple with a boolean and an int and an object with a boolean and an int.
One of my immediate concerns with using OneOf
is that the failure-case constructs a new Failure
on failure – so I also made benchmarks using a static
instance of the Failure
, and ReturnType
returns with new
and static
Failure
s.
I ran the benchmarks in a devcontainer with 12 processors and 32GB of memory, but please download the code and run it for yourself to see! Here are the results, ranked from fastest to slowest:
Method | Time spent (ns) | Memory (B) | Slower by |
---|---|---|---|
Nullable | 2.629 | – | – |
Tuple | 9.177 | – | 3.5x |
Object | 10.523 | 24 | 3.9x |
Record | 10.709 | 24 | 4.1x |
OneOf-static | 11.769 | – | 4.5x |
OneOf | 17.571 | 24 | 6.7x |
ReturnType-static | 22.030 | 32 | 8.4x |
ReturnType | 29.526 | 56 | 11.2x |
Exception | 15 148.419 | 344 | 5 762.0x |
Benchmark results
Conclusions
The fastest is to just use a nullable int
. This does no allocation and works well if you only have two cases and can represent it well with existence or absence. A tuple is a little more expressive, and works very well for this kind of thing – they do get unwieldy (like OneOf
) when you want to represent several things.
The OneOf
-styles seem to impose an overhead over the built-in features (nullables, tuples, records and objects). They are nowhere near the exception-based -flow (which you really should never use), though. Using static
instances for the returned types that do not contain data seems to be a worthwhile optimization for very little work.
Is the change in your code worth this overhead? For me I think the OneOf
with a static
instance is a really nice place to be – you get to use the nice .Match
and .Switch
-methods at a very low cost. For states that are representable by nullables I’d still use that.
If you don’t need many complex states the gain in readability may still be worth it. I think it’s more relevant whether you like the kind of code this makes you write than the performance-cost of it.
As always: instrument your code and find your actual hot-spots before you rely on micro-benchmarks like this to decide what to use. And, remember, your highest cost may be CPU, Memory, Network or developer-time – act accordingly.
Happy coding!