Getting Started with EK9 · Chapter 17
Abstract Types, Subtypes and Polymorphism
You can now build data (records), narrow it (constrained types) and guard it (classes). The remaining piece of type design is how types relate to one another. A circle and a rectangle are both shapes. A program often wants to ask them the same question — "what is your area?" — while each answers in its own way. One question with many answers, chosen by the actual type of the value, is called polymorphism. It rests on two ideas: abstract types and subtypes.
A type that cannot stand alone
Start with the shared idea, a shape, written as an abstract class:
Shape as abstract name() as pure abstract <- rtn as String? area() as pure abstract <- rtn as Float?
Shape as abstract says this type is deliberately incomplete. Its two methods are marked abstract
and have no body: <- rtn as Float? says what kind of answer area gives (a Float) but not how to
compute it, because a shape in general has no area. An abstract type is a contract, not a thing.
Note —
?and the shapes. Every aggregate that holds fields must answer "am I set?" with a?.CircleandRectangle, which you will meet in a moment, have fields, so each declaresdefault operator ?.Shapehas no fields, so it needs nothing: a class with no fields is always set, and the compiler gives it that?itself.
EK9 enforces the contract. Try to make a plain Shape and it will not build:
s <- Shape()
EK9 : Error : E50080: 'Shape' on line 15 position 11: which is a call to 'Shape' on line 5 in 'abstractShape.ek9': cannot make a call on an abstract function/type directly
See: ek9 -h E50080
(abstractShape.ek9, in the error-examples folder, is the whole program.)
You cannot create something incomplete. A Shape only becomes real when another type fulfils the
contract.
Fulfilling the contract: subtypes
A subtype takes the abstract type and completes it. Here is one of two, each extending Shape and
providing a real name and area:
Circle extends Shape radius as Float: Float() Circle() as pure -> r as Float require r > 0 radius :=? Float(r) override name() as pure <- rtn as String: "circle" override area() as pure <- rtn as Float: PI * radius * radius
extends (you may also write is) declares that a Circle is a kind of Shape. The override
keyword on each method is required: it tells the reader, and the compiler, that this method replaces
the abstract one the parent declared. A Rectangle does the same with width and height.
The constructor uses three earlier lessons. require r > 0 states a precondition: a circle with a
zero or negative radius is meaningless, so the constructor refuses to build one.1
radius :=? Float(r) stores a copy of the incoming value rather than sharing the caller's (the
reference-or-copy decision from Classes). And area multiplies by PI, a named constant
(PI <- 3.14159) declared once at the top of the module, rather than a number buried in the
arithmetic.
The compiler checks subtypes too. Leave out one of the required methods and the subtype is still
incomplete, so it must either supply the method or declare itself abstract. Here Circle leaves out
area (incompleteCircle.ek9, in the error-examples folder):
EK9 : Error : E07130: 'Circle' on line 12 position 4: 'public Float <- area() as abstract' on line 9 in 'incompleteCircle.ek9' not overridden: not declared abstract but still has abstract methods/operators
See: ek9 -h E07130
A concrete type — one complete enough to build, which is what "not abstract" means — cannot half-fulfil a contract. Either it supplies every method, or it is abstract too and cannot be built.
One question, many answers
Because a Circle and a Rectangle are both Shapes, you can put them together and treat them the
same way:
#!ek9 defines module book.shapes defines constant PI <- 3.14159 defines class Shape as abstract name() as pure abstract <- rtn as String? area() as pure abstract <- rtn as Float? Circle extends Shape radius as Float: Float() Circle() as pure -> r as Float require r > 0 radius :=? Float(r) override name() as pure <- rtn as String: "circle" override area() as pure <- rtn as Float: PI * radius * radius default operator ? Rectangle extends Shape width as Float: Float() height as Float: Float() Rectangle() as pure -> w as Float h as Float require w > 0 and h > 0 width :=? Float(w) height :=? Float(h) override name() as pure <- rtn as String: "rectangle" override area() as pure <- rtn as Float: width * height default operator ? defines program Show() stdout <- Stdout() shapes <- List() of Shape shapes += Circle(2.0) shapes += Rectangle(3.0, 4.0) for shape in shapes stdout.println(`a ${shape.name()} has area ${shape.area()}`)
a circle has area 12.56636
a rectangle has area 12.0
shapes is a List of Shape: it holds shapes, whatever their kind. The loop calls shape.area()
knowing only that each value is a Shape, yet a circle's area comes from the circle's formula and a
rectangle's from the rectangle's. There is no if to tell them apart: each value carries its own
area, and calling it runs the right one. That is polymorphism: one name, area, resolved to the
correct behaviour by the actual type of the value when it is called.
Notice what the loop does not say: it names no concrete type. Add a Triangle next month — a new
subtype with its own area — put one in the list, and this loop prints its area too, unchanged. Code
written against the abstract type keeps working for kinds of shape that did not exist when it was
written.
Why it is safe to substitute
All of this depends on one guarantee: a Circle must be usable anywhere a Shape is expected. It
has an area and a name, and it behaves as a Shape should, so the loop can rely on it without
checking. The principle — a subtype must be substitutable for its parent, with no surprises — is called
the Liskov Substitution Principle, and it is what makes polymorphism dependable.2
No casts — on purpose
Notice also what the loop does not need. There is no cast and no type test: no way to ask a value
"are you really a Circle?" and then reach inside it. EK9 has no type casts at all, by design,
in the same spirit in which it has no break. A cast is a claim the compiler cannot check — "trust me,
this Shape is a Circle" — and a wrong cast is a well-known cause of programs crashing while they
run.3 Remove the cast and that failure cannot happen.
Sometimes you do need behaviour that depends on the concrete type, more than a shared method like area
can express. For that 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 is a topic for the
second volume. Most type-specific behaviour is handled as area was, by letting each type carry its own
version; the dispatcher covers the rest.
Note.
Shapenever saidas open, yetCirclecould extend it. An abstract type is automatically open — its purpose is to be extended. A concrete class is closed unless you writeas open, and extending a closed class is an error (E05030, "not open to be extended"). So a type can be extended exactly when it is meant to be.
Not only classes
Polymorphism is not limited to classes. The same extends/is and override work for every
building block in the book.
Records can form families too. A record's behaviour lives in its operators rather than in
methods, so a record family is polymorphic through those. Here an abstract Contact has an abstract
$, and two kinds of contact each give their own text form:
#!ek9 defines module book.contacts defines record Contact as abstract operator $ as pure abstract <- rtn as String? default operator ? Email extends Contact address as String: String() Email() as pure -> a as String address :=? String(a) override operator $ as pure <- rtn as String: `email ${address}` default operator ? Phone extends Contact number as String: String() Phone() as pure -> n as String number :=? String(n) override operator $ as pure <- rtn as String: `phone ${number}` default operator ? defines program Show() stdout <- Stdout() contacts <- List() of Contact contacts += Email("sam@example.com") contacts += Phone("555-0100") for c in contacts stdout.println(`${c}`)
email sam@example.com
phone 555-0100
A List of Contact holds an Email and a Phone side by side, and ${c} prints each in its own
way: one name, many answers, this time through a record's $.
Note — how deep a hierarchy may go. EK9 limits inheritance depth: a class may sit at most four levels below its root, a record at most two; go further and the build stops (
E11019). For deeper reuse, use composition — a field, orbydelegation (Traits and Composition) — rather than a tall tower ofextends. Shallow hierarchies stay easy to follow; deep ones are where "which version runs here?" becomes hard to answer.
Functions have their own polymorphism, and you have already used it. The stream pipelines in
Streams and Pipelines used Predicate, Function and Consumer. Those are function types: a
Predicate of T is the shape "takes a T, gives back a Boolean", and any function of that shape
can stand in for it — isEven, isPositive, whichever you pass to filter. One function type,
answered by many concrete functions, each passed around like any other value. The full story is in
the second volume.
So "one contract, many implementations" runs through the whole language: classes, records, functions, and the component in Components. It is not a feature of objects alone.
Try it yourself
- Add a
Triangle extends Shapewith its ownarea, put one in the list, and confirm the loop prints it with no change to the loop. - Delete
override area()fromCircleand read the error. - Try
bad <- Shape()and read that error. - Build a
Circle(-1.0)and run it. Therequire r > 0precondition refuses the radius, so the circle is never created.
Recap
An abstract type (as abstract) is an incomplete contract that cannot be created directly
(E50080). A subtype completes it with extends or is and override, and the compiler
insists it supplies every required method or is abstract too (E07130). The result is
polymorphism: a List of Shape holding many kinds, and one call to area() running the right
behaviour for each — code written once against the abstract type that works for every subtype, now
and later. It is dependable because a subtype is always substitutable for its parent, so no value ever
needs to be tested or cast. Next, Traits and Composition: EK9's preferred way to share a contract,
without carrying state along, and the composition EK9 favours over deep inheritance.
-
requireinsists its condition is definitely true. A value can be unset, and a comparison involving an unset value —r > 0whenris unset — is itself an unsetBoolean: neither true nor false.requiretreats that the same asfalse, and fails. Sorequire r > 0rejects a zero or negative radius and an unset one; it passes only when the answer is a set, trueBoolean. A failed requirement stops the program and reports the exact condition that was not met, and where. ↩ -
Barbara Liskov set out the principle in her 1987 keynote "Data Abstraction and Hierarchy," later formalised with Jeannette Wing as "A Behavioral Notion of Subtyping" (ACM TOPLAS 16, no. 6, 1994). Informally: if
Sis a subtype ofT, then objects of typeTmay be replaced with objects of typeSwithout breaking the program. A subtype that cannot stand in for its parent is behind the surprisedUnsupportedOperationExceptionin code that assumed it could. ↩ -
In Java, for example, a wrong cast raises a
ClassCastExceptionwhen the program runs. ↩
In the EK9 reference: Design note: why EK9 types are closed by default, Inheritance in EK9