If you are going to use type-switch (for which there are many legitimate use cases & I do not necessarily agree with the author here) and want to enforce exhaustiveness dynamically, please use the %T formatting specifier like this:
switch x := x.(type) {
default:
panic(fmt.Errorf("unexpected %T", x))
}
Great post! “Use built in language facilities fully” is a timeless advice!
However, I’d say that the post leaves the false impression that “expression problem” is the lens to use when discussing sum-types vs interfaces. It seems there are another important aspects.
One is binary operations. If you need
func BinOp(something lhs, something rhs)
Than the difference between interfaces and some types becomes more pronounced. With sum types, one just writes match (lhs, rhs). With interfaces, one has to reach out for double dispatch, which is a rather involved pattern!
I’ve been toying around with the idea that the expression problem is basically a question of database normalization, and a language could approach this by storing stuff in a normalized database, e.g.
…then the developer tools could just show you a view of one function across many types, or one type across many functions (or whatever else you could think of).
I recently ran into this, including the counterintuitive refactoring pattern given for Go. I was working in RPython. I have three classes with eight methods each, for a total of 24 behaviors. The classes represent an AST, and the methods correspond to actions taken on the AST by different compiler stages. One can also see that my refactoring work isn’t done; there’s an enumeration corresponding to “hom”, “pair”, and “sum” but I haven’t isolated it yet.
I started out by arranging the code like the article does, with isinstance() for dispatch and an assertion for the default case. But, exactly like the article says, it is more ergonomic to migrate each of those dispatches to methods when working in languages with prototypes or classes.
Is this sum-type row-major or column-major? If it is row-major it is a trait or class, else if column-major it is an enum. Consider a table with rows for each type and columns for each point of dispatch. Row-major is generally superior, since you’re more likely to forget about a column/function (“all objects must be serialized”) than a type (“there are dogs”). Your code can mix row-major & column-major; the other day I dumped all the impl fmt::Debug for {various AST nodes} together in a single fold, as they are basically noise. Also you don’t have to use Self as the row index: you might focus impl Serialize<T> for File rather than impl SerializeTo<File> for T.
(If you are dealing with languages without sum types, you might like to put some comment #HASHTAG at the bottom of the list and in every switch.)
Wait wait… Are people considering Go to be an OO language now?
The creators of the language do
If you are going to use type-switch (for which there are many legitimate use cases & I do not necessarily agree with the author here) and want to enforce exhaustiveness dynamically, please use the
%T
formatting specifier like this:Great post! “Use built in language facilities fully” is a timeless advice!
However, I’d say that the post leaves the false impression that “expression problem” is the lens to use when discussing sum-types vs interfaces. It seems there are another important aspects.
One is binary operations. If you need
Than the difference between interfaces and some types becomes more pronounced. With sum types, one just writes
match (lhs, rhs)
. With interfaces, one has to reach out for double dispatch, which is a rather involved pattern!I’ve been toying around with the idea that the expression problem is basically a question of database normalization, and a language could approach this by storing stuff in a normalized database, e.g.
…then the developer tools could just show you a view of one function across many types, or one type across many functions (or whatever else you could think of).
Code analysis systems often use logic programming languages internally. I think Rust compiler used Datalog: https://github.com/frankmcsherry/blog/blob/master/posts/2018-05-19.md
I recently ran into this, including the counterintuitive refactoring pattern given for Go. I was working in RPython. I have three classes with eight methods each, for a total of 24 behaviors. The classes represent an AST, and the methods correspond to actions taken on the AST by different compiler stages. One can also see that my refactoring work isn’t done; there’s an enumeration corresponding to “hom”, “pair”, and “sum” but I haven’t isolated it yet.
I started out by arranging the code like the article does, with
isinstance()
for dispatch and an assertion for the default case. But, exactly like the article says, it is more ergonomic to migrate each of those dispatches to methods when working in languages with prototypes or classes.Thankfully, Rust solves this with
#[enum_dispatch]
to turn the closed-world guarantee of a sum type into the method-call sugar of a trait object.Is this sum-type row-major or column-major? If it is row-major it is a trait or class, else if column-major it is an enum. Consider a table with rows for each type and columns for each point of dispatch. Row-major is generally superior, since you’re more likely to forget about a column/function (“all objects must be serialized”) than a type (“there are dogs”). Your code can mix row-major & column-major; the other day I dumped all the
impl fmt::Debug for {various AST nodes}
together in a single fold, as they are basically noise. Also you don’t have to useSelf
as the row index: you might focusimpl Serialize<T> for File
rather thanimpl SerializeTo<File> for T
.(If you are dealing with languages without sum types, you might like to put some comment
#HASHTAG
at the bottom of the list and in everyswitch
.)