The Dispatcher
Abstract Types, Subtypes and Polymorphism in Volume 1 said that when behaviour has to depend on the concrete type, EK9 has the dispatcher: a method that selects its behaviour from the actual type of its arguments, with the compiler checking that every case is covered. It called that a topic for the second volume. The sentence needs one correction before it is useful: the compiler checks every case only where it is told the whole set of cases, and says so when it cannot. This chapter is about what a dispatcher checks, what it does not, and why.
If you know the visitor pattern from Java or C++, the dispatcher is its replacement with the boilerplate removed: no
accept method on every type, no interface with one visit per class. If you know Rust or Scala's match, or Java's sealed
switch, it is the same idea with the cases written as overloads. Clojure's multimethods route on run-time values too, and find a missing or ambiguous case when the call is made. EK9's
checks before the program runs, and the check is the point.
Routing on what the value is
A dispatcher is a method in a class, marked dispatcher, with one or two parameters. Beside it are ordinary methods of the
same name, the handlers, each taking a narrower type. A call is routed to the handler for the argument's run-time
type; if no handler fits, the dispatcher's own body runs, as the entry point of last resort.
#!ek9 defines module book.vol2.dispatcher.shapes defines class Shape as abstract name() as pure abstract <- rtn as String? default operator ? Ellipse is Shape as open override name() as pure <- rtn as String: "ellipse" Circle is Ellipse override name() as pure <- rtn as String: "circle" Rectangle is Shape override name() as pure <- rtn as String: "rectangle" Triangle is Shape override name() as pure <- rtn as String: "triangle" Renderer render() as pure dispatcher -> shape as Shape <- rtn as String: `outline of a ${shape.name()}` render() as pure -> ellipse as Ellipse <- rtn as String: `smooth curve: ${ellipse.name()}` render() as pure -> rectangle as Rectangle <- rtn as String: `four edges of a ${rectangle.name()}` Intersector intersect() as pure dispatcher -> first as Shape second as Shape <- rtn as String: `${first.name()} and ${second.name()}: general case` intersect() as pure -> first as Circle second as Circle <- rtn as String: "two circles: compare centres and radii" intersect() as pure -> first as Circle second as Rectangle <- rtn as String: "circle then rectangle: nearest edge to centre" intersect() as pure -> first as Rectangle second as Rectangle <- rtn as String: "two rectangles: compare edges" defines program Dispatching() stdout <- Stdout() renderer <- Renderer() shapes as List of Shape: [Circle(), Ellipse(), Rectangle(), Triangle()] for shape in shapes stdout.println(renderer.render(shape)) intersector <- Intersector() left as Shape: Circle() right as Shape: Rectangle() stdout.println(intersector.intersect(left, left)) stdout.println(intersector.intersect(left, right)) stdout.println(intersector.intersect(right, left)) stdout.println(intersector.intersect(right, right)) stdout.println(intersector.intersect(Triangle(), left))
smooth curve: circle
smooth curve: ellipse
four edges of a rectangle
outline of a triangle
two circles: compare centres and radii
circle then rectangle: nearest edge to centre
rectangle and circle: general case
two rectangles: compare edges
triangle and circle: general case
Every variable the program dispatches on has the static type Shape; the for loop's shape is one, and left and right
are declared as one. Nothing in the call says which handler runs. The first four lines are the single-argument case, and
they show the fallback in both directions. A Circle has no handler of its own, so it goes to the nearest handler up the
hierarchy, the one for Ellipse. A Triangle has none anywhere up its hierarchy but the entry point, so the entry point
runs. The last five are the two-argument case. intersect(left, right) is a Circle then a Rectangle and has a handler;
intersect(right, left) is the same two shapes the other way round and has none, so it falls to the general case. Argument
order is part of the route.
There is no cast anywhere in that program. The generated routing compares each argument's run-time class against the
handler types and calls the handler with the argument as that type, a conversion the comparison has just justified. That is
the correction to the Java habit: where you would write if (shape instanceof Circle c), you declare a handler instead,
and the type test and the conversion are the compiler's to generate and to get right.
When two handlers fit equally
The nearest-type rule has one hole, and the compiler refuses to leave it open. A class that takes two traits is at the same distance from a handler for each of them:
Duck is Animal with trait of Flyable, Swimmable override name() as pure <- rtn as String: "duck" default operator ?
With handlers for Flyable and Swimmable and none for Duck, a call with a Duck would have two equally good targets.
The program does not compile:
EK9 : Error : E05230: 'describe' on line 42 position 6: type 'Duck' matches 2 handlers at equal cost in dispatcher 'public String <- describe(animal as Animal)': 'public String <- describe(flyer as Flyable)' (cost 0.10) on line 46 in 'ambiguousDispatch.ek9', 'public String <- describe(swimmer as Swimmable)' (cost 0.10) on line 50 in 'ambiguousDispatch.ek9' dispatcher has ambiguous handler resolution for reachable type
See: ek9 -h E05230
The message names the dispatcher, the type, both handlers and their costs. This is checked at declaration, not at the call:
the ambiguity exists in the hierarchy whether or not anyone ever passes a Duck. A handler for Duck itself costs nothing
and wins; ek9 -h E05230 lists that and a bridge trait that combines the two as the fixes. The cost of the rule is that you
cannot leave the hierarchy ambiguous and hope: if you add a multi-trait class, every dispatcher over its traits stops
compiling until you say which handler wins. What it buys is that a call never lands on a handler you did not choose.
Exhaustiveness belongs to allow only
Now the correction to Volume 1. In the shapes program the Triangle fell to the entry point silently. That is legitimate
there, because Shape is an open hierarchy and a handler for every future shape cannot be demanded. Where the set of cases
is closed, EK9 lets you say so, with allow only on the base type, and the dispatcher over it becomes exhaustive:
Payment allow only Card, Cash amount() as pure abstract <- rtn as Money?
charge() as pure dispatcher -> payment as Payment <- rtn as String: `no specific handler: ${payment.amount()}` charge() as pure -> card as Card <- rtn as String: `card payment of ${card.amount()}` charge() as pure -> cash as Cash <- rtn as String: `cash payment of ${cash.amount()}`
Run as a whole, the program prints what you would expect:
card payment of 10.00#GBP
cash payment of 5.00#GBP
Take the Cash handler away and the compiler names the omission:
EK9 : Error : E05260: 'charge' on line 38 position 6: on sealed 'Payment' is missing handlers for: Cash: dispatcher on sealed type must have handlers for all permitted types
See: ek9 -h E05260
The compiler's own help for that code makes the comparison this chapter started with: it is exhaustiveness checking "similar
to Java's sealed switch or Kotlin's sealed when". It cuts the other way too. Add a third type to the allow only list and
every dispatcher over Payment fails to compile until it has a handler for it. A type outside the list cannot join the family:
EK9 : Error : E05240: 'Voucher' on line 24 position 4: 'Voucher' is not in 'Payment' allow only list: type is not permitted to extend/implement this sealed type
See: ek9 -h E05240
So exhaustiveness here is exactly as strong as the seal and no stronger. On an unsealed base there is no exhaustiveness check: a missing handler falls back to the entry point without a word. And even with every handler present, the entry point needs a body. Declare it with no body and it is refused:
EK9 : Error : E07120: 'charge' on line 38 position 6: base level implementation must be provided for dispatcher method
See: ek9 -h E07120
forAI: dispatcherPattern
That second rule means a call always has somewhere to land, whatever the run-time type turns out to be: a handler if one fits, the base implementation if none does. There is no cast for you to get wrong and no call that reaches nothing.
The parent's version, on purpose
The other mechanic Volume 1 pointed here is calling the parent. An override that replaces its parent's behaviour is common;
one that extends it needs to reach the parent's version, and super.method() does:
override describe() as pure <- rtn as String: `${super.describe()} at ${rate}%` override monthlyCharge() as pure <- rtn as Money: super.monthlyCharge() * 0.0
Run as a whole, the program builds an Account and a SavingsAccount that extends it and calls both, once through
the subtype and once through a variable typed as the parent:
Ada holds 120.00#GBP
Bob holds 800.00#GBP at 3.5%
Bob holds 800.00#GBP at 3.5%
charges: 2.50#GBP and 0.00#GBP
The third line is the point: seen through an Account variable, the override still runs, and it still reaches the parent's
describe() through super. The constructor does the same with super(holder, balance). It is ordinary overriding. It
belongs in this chapter because it is the other way of letting the concrete type decide, and the one to try first: if the
behaviour belongs on the type, give each type its own version and call super where it extends the parent's.
The cost
You organise behaviour by dispatch instead of by a chain of type tests, and that is a shift in where you look. The logic
for one operation is spread across a handler per type rather than gathered in one if ladder; the compiler checks the
spread, but you read it in pieces. The rules are strict where a visitor would be loose. A dispatcher takes one or two
parameters, not three (E06320). It lives in a class, not a function or a trait (E07810). Its handlers must match its purity,
both ways: a pure entry point needs every handler pure, and a non-pure entry point needs none of them pure (E05170). And
there is a run-time price. The compiler's bytecode tests describe the generated routing as a table of getClass() comparisons
for each reachable type, and for two parameters as the Cartesian product of both parameters' types, so the table grows with
the product of the hierarchies. This book gives no timings for it; if a dispatcher is on a hot path, measure it.
What those rules buy is that the dispatcher is the one place type-specific behaviour is allowed to live, and the compiler checks the whole of it: no ambiguity the compiler can find (E05230), no gap on a sealed family, no cast anywhere. The Payoffs: Liskov and Hoare returns to the substitution rule that makes the fallback to the nearest supertype safe.