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.)
However 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.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 tooI think that's good practice!