I implemented open types in Effes the other day, so I’m gearing up for the next big push: generics! I was thinking of doing tuples first, but they have all of the same complexities as full-blown generics. (You can think of tuples as just sugar around predefined generic classes like
Tuple3[A,B,C] — in fact, a bunch of languages do exactly that.)
Generics interact with type disjunction in interesting ways. For instance, what happens when you disjoin
Box[B]? Is it a vanilla disjunction, or are disjunctions distributive, so that
Box[A] | Box[B] becomes
Box[A | B]? Both approaches have their pros and cons.
I’ll call the first one the “standard” option, and the second one the “distributive” one. I’ll illustrate with
type Maybe[A] = One[A] | Nothing, which uses
type One[A](elem: A). When you disjoin
Maybe[A] | Maybe[B], Effes will expand both
Maybes, leading to
Maybe[A] | Maybe[B] | Nothing | Nothing, which simplifies to just
Maybe[A] | Maybe[B] | Nothing. And then what?
The standard option is straightforward. When you pattern match, you have to specify which of the alternatives you want, filled out completely (with the generic parameter and all). This has the chief benefit of being simple, though the syntax it suggests is a bit clunky:
case mysteryBox of One[A](elem): handleA elem One[B](elem): handleB elem Nothing: handleNothing
The disjunctive interpretation, on the other hand, feels really dynamic, which I like. I think one of the strengths of Effes is that it gives you the feel of dynamic typing with the protections of static typing. In this view of things,
mysteryBox isn’t one of three concrete options as above; it’s one of two options, the first of which is itself fuzzy.
For instance, let’s say we’re painting a layer with transparency. A given pixel could have a color or not, and the color could be specified by RGB value or by name:
Maybe[Rgb] | Maybe[ColorName]. If there’s already a method
paintPixel(color: Rgb | ColorName), the distributive option works perfectly. You don’t need to specify the generic parameter in the pattern match, because it’s unamibiguous to the compiler:
case maybeColor of One(c): paint c -- c:(Rgb | ColorName) Nothing: paintTransparency
This is nice, but I think there are times when the user won’t want that flexibility; they’ll want to treat each option separately. In a differently-factored version of the above, we may want the non-distributive option, so that we can feed the color to
paintNamed, as appropriate.
One argument in favor of the distributive option is that it can simulate the standard option pretty easily:
case maybeColor of One(c): case c of Rgb: paintRgb c ColorName: paintNamed c Nothing: paintTransparency
That looks promising, but it’s actually very limited: it breaks down when the container can hold multiple items, instead of just one. For instance, what if we want to paint a row of columns, typed as
List[Rgb] | List[NamedColor]? The nested case doesn’t work naturally. At best, we can wait for lambdas, then perform an inline map on the list, but that’s more complicated than it should be.
And lastly, the distributive approach takes a huge liberty with the programmer’s semantics. A
List[A] is a homogeneous list of
List[A] | List[B] represents either a
List[A] or a
List[B]. To change that to a heterogeneous list of
(A | B) is a big departure from the explicitly-written code.
All of that is to say that the standard system, despite its increased verbosity and stodgy syntax, is almost definitely the right approach. But wait! We can throw a big of sugar at the problem to make the standard approach feel like the hip, distributive one!
The first problem with the syntax was that awkward combo of square brackets and parenthesis:
One[A](elem). We can solve this by borrowing from our method declaration syntax, and putting the type inside the parens:
One(elem: A). Feels better already.
Next, we can take that one step further. If no type is specified, then the compiler will try to rewrite the
case with each of the possible patterns, using the one in the code as a template. So, this:
case mysteryBox of One(elem): handle elem Nothing: handleNothing
… is just sugar for:
case mysteryBox of One(elem: A): handle elem One(elem: B): handle elem Nothing: handleNothing
One of the things I like about this is that it adds to the sugar of the language without adding to the amount of sugar the programmer needs to think about, because it complements the invoke-on-disjunction sugar so nicely.
One area that’s important to keep in mind is how types with multiple generic parameters will interact with error messages. Consider this snippet:
case foo of Pair(o1, o2): doSomethingWith o1 [o2] ...
(The syntax is a bit funky, and I may change it; but that just calls
doSomethingWith with two arguments,
o2. You can essentially ignore the square brackets.)
o1 may be of type
o2 may be
D. But we don’t get all four combinations: if
o2 must be
C, and if
o2 must be
D. That’s simple enough if you write the expansion out, but if you make a mistake in your head, the error message could confuse you more than it helps. For instance, imagine if
doSomethingWith takes an
A and a
D and you get an error message saying something like “
doSomethingWith expected types
[A| B, C | D] but saw
[A, D].” Doesn’t that look like it’s complaining that it got good inputs? A better message would be
doSomethingWith expected types
[A, C] or
[B, D] but saw
[A, D].” Even then, I’m not sure this would be clear to someone who’s new to the language.