Tagged Unions Are Overrated
buttondown.email
buttondown.email
In programming language intermediate representations (the context of the article) exhaustiveness checking might not be as useful as in other contexts. You will have many variants, but for every concrete operation you want to do on the IR you will only consider a few of them, with a default catch-all case for all others.
For example, imagine you're writing a tool to represent and process source code for a Python-like language. Here are the statement types:
type Statement =
| Assign var value
| ExprStatement value
| If cond true_branch false_branch
| While cond body
| Return value
| Break
| Continue
| With context body
(I'm sure I forgot some interesting ones.) You want to do some analysis related to branching control flow statements. These are If and While, so you write: let analyze_control_flow stmt =
match stmt with
| If _ true_branch false_branch ->
do_something_with true_branch;
do_something_with false_branch
| While _ body ->
do_something_with body
| _ -> // not a branching control flow statement
()
This works nicely. Except that Python recently got pattern matching itself, so you add a new variant to your Statement type: ...
| Match pattern cases
...
and then you recompile and fix a few exhaustiveness errors and feel happy that you have handled everything, except that you haven't. You would also need to update your definition of analyze_control_flow, but due to the catch-all clause your compiler didn't alert you to this.(On the other hand, such fundamental language/IR changes are relatively rare.)
The former is available in Rust, just with a bit more boilerplate. The latter is probably achievable in some way if the language were to change and, even if not necessarily relevant to this problem, I could see being useful in some places...
;; can store at compile time the tags for an op
(define-op (if a b c)
(:tags my-tags:control my-tags:special)
...)
Elsewhere: ;; can check at compile-time if all ops
;; for that category are tried
(ematch-op (e :tags my-tags:control)
((if test then else) ...)
((while test body) ...))
Maybe this can be done with Rust macros tooHowever I've been writing a fair amount of ReScript (OCaml but in JavaScript: The Good Parts) for UI work, and we never use catch-all in pattern matching.
It is an all-or-nothing proposition: you want to have complete certainty all the time that the compiler will catch all possible cases when the type is modified.
Without that certainty, discriminated unions are simply a lot of work for nothing in return.
This can lead to verbose code, but sometimes it is solved by generic handlers.
let onControlFlow = (stmt, cb) => {
switch (stmt) {
| If(body)
| While(body) => cb(body)
| Module(_)
| Let(_)
| Statement(_) => ()
}
}
This is an unrealistic contortion of your example, but in user interfaces there are often cases where there is some sort of grouping within an ADT, and thus we can apply the same function to the tagged data common to it. let analyze_control_flow stmt =
match stmt with
| If _ true_branch false_branch -> ...
| While _ body -> ..
| Assign _ _ | ExprStatement _ | Return -> ...
So you still get to avoid repeating code, but all the variants are included visibly and the compiler can verify it.I think that's good practice!
but at a pretty large cost : now everything has to be boxed, will be dynamically allocated by default (yes, object pools and small-size optimization, but that does not produce very readable code and needs constant care), you risk slicing, every access has an indirection, etc.
Migrating some C++ code from OOP inheritance to variant-like stack-allocated value types has always resulted in n-fold improvements to runtime performance in my case.
The article makes clear that it is speaking of compiler intermediate representations. Those are pretty much by definition boxed, linked data structures that live on the heap.
> Migrating some C++ code from OOP inheritance to variant-like stack-allocated value types has always resulted in n-fold improvements to runtime performance in my case.
Was this in the context of processing some form of programming language IR?
The OO features were added because at the time it was the raging fad and people thought that every language simply must have "OOP".
But "OOP" was never a foundational principle for C++, more like a tacked-on sidecar.
a) 'Simula style' isn't at all like the Smalltalk-derived programming patterns we call 'OOP'. (Simula was about actor-oriented programming anyways, hence the name.)
b) The unique differentiator for C++ was the STL, and not the OOP features that were done better and cleaner in other languages.
c) The killer feature for early C++ adoption was operator overloading, not inheritance. (Pretty much nobody liked using inheritance in C++ even back in the day.)
Nothing about the name "Simula" suggests actors.
> The unique differentiator for C++ was the STL
https://en.wikipedia.org/wiki/C%2B%2B#History isn't very clear, but it seems to suggest that templates weren't added to C++ until 1990 or later. The STL came around 1993, eleven years after the development of C++ started, not even counting its previous life as C with Classes. Surely in those eleven years people found other differentiators that kept up interest?
This involves such a level of ignorance of the history of C++ that you should consider deleting your comment. The STL didn't exist in C++ for the first decade of its existence.
> Pretty much nobody liked using inheritance in C++ even back in the day.
What is your basis for this claim? Don't bother responding, the answer is that it's nothing more than your fantasizing.
> add classes to C
C is the foundation. Classes is the sidecar.
> Another way this manifests is that it’s often frustrating that patterns aren’t first-class; I can’t pass around a pattern, or build up a pattern on the fly.
To me this seems like limitations of pattern matching in Rust that perhaps is addressed by Haskell's ViewPatterns language extension. The idea is that the usual ML-style pattern matching exposes too much implementation detail of the ADT, for instance, consider instead the use of a "view function" viewer to convert the structure into a initial segment xs and a last element x.
pattern (:|>) :: Seq a -> a -> Seq a
pattern xs :|> x <- (viewr -> xs :> x)
where
xs :|> x = xs |> x
lastElem :: Seq a -> a
lastElem (_ :|> e) = e
:|> would let one treat the sequence data type as abstract (it could be a finger tree[0] underneath, for instance). In the author's use case of representing IRs, one could move the unboxing work into the view.And then goes to discuss Rust only. Nope. In many languages pattern matching is crippled because it is only allowed to be used in some contexts, but not in others. Or are crippled by things like "can't pattern match if something-something pointers".
For example, in Erlang you can use pattern matching and guards in function definitions, creating overloaded functions based on pattern-match alone. And you can match arbitrarily complex structures (because it doesn't have pointers etc.). This makes working with ASTs in such a language a breathe.
The main confusion I see is that in many languages it is unclear whether sum types are a runtime or compile time thing, which decides whether pattern matching is semantic or syntactic. Of course nobody wonders whether "if then else" is semantic, but for pattern matching I admit I am myself confused:
- in C all types are compile time (and loosely enforced) so if you want sum types, do them yourself. Implementing the matching is quite straightforward but unsafe and pedantic.
- in Java all type information is available at runtime: sum types can be implemented easily (casting to Object), matching is safe and can be compact if done with e.g. arrays.
- in C++ thinks start getting trickier: you can certainly do a lot mixing static and dynamic casts, but will this remain readable?
- in ML languages: AFAIK this is completely implementation defined. The compiler may choose to box some or all variables. Obviously in such a language you much less care about what is going on under the hood. In return you get a true pattern-matching construct.
- in Rust and others: frankly I don't know where they stand in regard of compile time vs runtime :)
Sadly, this reinforces the moat of legacy language. Why move to a new language with marginally better language features, when you can stick to languages that already have good JetBrains support?
enum Value {
Int32(i32),
Int64(i32),
Str(String),
}
fn only_int(of:Value[Int32, Int64]) -> Value.Str
And other times: enum ExtraValue:Value {
Bool(bool),
}
This is analog to structs, where eventually is "discovered" that is nice to: let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
active: user1.active,
sign_in_count: user1.sign_in_count,
};
let user2 = User {
email: String::from("another@example.com"),
username: String::from("anotherusername567"),
..user1
};
But somehow enums are still considered second-class in the algebraic type space.You can do
data Value a =
I32 Int32 a
B Bool
and then type Value1 = Value ()
type Value2 = Value Void
as a hackIn your example, Scala 2 would let Int32 and Int64 inherit a trait that Str doesn't. TypeScript would let you write a union type Int32 | Int64 that doesn't include Str (or add ExtraValue), and I think Scala 3 will have something very similar.
type Foo = { kind: 'A', aItem: number } | { kind: 'B', bItem: string } | { kind: 'C' };
type SubsetFoo = Foo & { kind: 'B' | 'C' }
type SupersetFoo = Foo | { kind: 'D', dItem: boolean };
I'm sure there are imperfections here. For a start, SubsetFoo's normalized form looks rather ugly when you mouse over it in VSCode. But it does get you the niceties of exhaustiveness checking and type-aware suggestions with control flow awareness, etc.. type SubsetFoo = Extract<Foo, { kind: 'B' | 'C' }>;Also the entire newsletter content is in a variable in a script tag? If javascript is disabled, you see the entire article. What is the point of all this javascript?
This is not just bad formatting, it’s a deeper mystery.
This isn't HackerParliament, I don't mind people venting about bad web design.
Moaning about clickbait, however, I immediately downvote because it actually does contribute nothing.
The right honourable website is rather slapdash with its scripts, without which it cannot render well upon Her Majesty the Chrome.
Hear hear!
It's also easy enough to emulate all control flow statements with goto. Tagged unions are valuable because they help communicate restraints. Not only to other developers, but also to the compiler.
Not having them in your language in 2021 should be considered the same as not having if/then/else in your language in 1980.