To clarify, union here means discriminated union, not a C-style union. It's what Rust calls an enum when the variants of the enum have payloads and not just a tag. I think zig calls these tagged unions, and if you're enjoying a nice cup of coffee at the top of the ivory tower you're probably making fun of all the silly ways the commoners refer to what you know as sum types.
Anyway, discriminated unions are cool, because they let you model a closed list of different things. Emphasis on the closed aspect. It's the fact that they're closed which turns these unions into a superpower. Consider the alternative: You want to model a bunch of cases, say you have input bindings from your game, which could be one of[1]:
- A mouse button
- A mouse axis (movement)
- A gamepad axis
- A gamepad button
- A keyboard key
There's two ways you could model this in C# before:
The bad one:
Code: Select all
enum InputEventKind {
MouseButtonCase,
MouseAxisCase,
GamepadAxisCase,
GamepadButtonCase,
KeyboardKeyCase
}
struct InputEvent {
InputEventKind kind;
MouseButton? mouseButton;
MouseAxis? mouseAxis;
GamepadAxis? gamepadAxis;
GamepadButton? gamepadButton;
KeyboardKey? keyboardKey;
}
Code: Select all
interface InputEvent;
struct MouseButtonCase : InputEvent {
MouseButton mouseButton;
}
struct MouseAxisCase : InputEvent {
MouseAxis mouseAxis;
}
// .. and so on
Anyway, I think that's more than enough context. The key concept here is exhaustivity. Unions allow us to do this:
Code: Select all
union InputEvent(GamepadButton, GamepadAxis, MouseButton, ...and the others);
Code: Select all
void Foo(InputEvent ev) =>
ev switch {
GamepadButton gb => $"Gamepad button {gb}",
GamepadAxis ga => $"Gamepad axis {ga}"
MouseButton mb => ..., // and so on...
MouseAxis ma => ...,
KeyboardKey kb => ...,
};
Those errors are the useful feature, the TODO list generator. When you add a new case, say, idk VrControllerGesture? The compiler will remind you of all the new locations where you need to handle this new variant. All the glue code and plumbing that's just passing InputEvents without taking a look inside remains untouched!
But here comes the catch. C# screwed up big time in their implementation, because they limited exhaustiveness to the switch expression construct. This would be a non-issue in an expression-based language. Like any Lisp, Haskell, or even Rust. In those languages, statements are either not a thing at all or we have some kind of hybrid where all statements (including blocks containing multiple statements) are, themselves, valid expressions. C# however cannot do that. What this means is pattern matching on enum variants with exhaustiveness checking is limited to.... a single expression
Yes you heard it right. Say you want to have *two lines of code* to handle one of the cases? Can't do that. Do you want to return from some branches but not others? No can do either. Break or continue? Same shit. All these things require statements. Forget about having if statements inside one of these branches, let alone loops.
Some people recommend the goofy pattern of declareing a lambda that's immediately called, the old JS trick (they called it IIFE, for immediately-invoked function expression). Lambdas are an expression, so this works. However, at this point your solution is just as bad as the existing source-generator-based implementations for discriminated unions which already rely on the same trick. You can't return / break / continue from inside a lambda, you can't access struct ref variables either, and setting locals inside the lambda will cause them to be moved to the heap.
Note that you can do pattern matching for union variants using a chain of if statements or the legally distinct switch statement (a completely different thing from the switch expression!). The problem with those two is that the compiler only enforces exhaustiveness for switch expressions and nothing else, and discriminated unions are pretty useless without the exhaustiveness.
I understand why they did what they did. The switch expression is the only language construct that checked exhaustiveness for other datatypes. Still, I would've welcomed some sort of ugly [EnforceExhaustive] annotation to put on top of my switch statements so that the feature would stay useful in non-toy examples.
As things stand right now, they did all this work and to me it's as if they'd done nothing. In fact it's less than nothing, because all those oop zealots who don't know better might be satisfied by this feature and it will be left there as yet another little situational language quirk. If we reach that point, the feature is as good as dead. Let's hope it doesn't get to that.
I will keep my current state of not holding my breath for any new upcoming C# (or any language, for that matter) features and appreciate technology for what it is today, and not what it could be tomorrow. I don't think I'll be adopting this new feature unless there's a significant change of direction. Especially when there's already good alternatives like DUSharp that offer no significant downsides compared to the new "official" version.
[1]: I think the people in the original blog are making a huge disservice to the feature by using an example of a Pet union with Cat and Dog variants. It's not a good example for a closed thing since modeling animal taxonomies is one of the canonical examples for class inheritance. People should stop treating their readers as stupid.