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 ?. Circle and Rectangle, which you will meet in a moment, have fields, so each declares default operator ?. Shape has 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. Shape never said as open, yet Circle could extend it. An abstract type is automatically open — its purpose is to be extended. A concrete class is closed unless you write as 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, or by delegation (Traits and Composition) — rather than a tall tower of extends. 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

  1. Add a Triangle extends Shape with its own area, put one in the list, and confirm the loop prints it with no change to the loop.
  2. Delete override area() from Circle and read the error.
  3. Try bad <- Shape() and read that error.
  4. Build a Circle(-1.0) and run it. The require r > 0 precondition 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.

  1. require insists its condition is definitely true. A value can be unset, and a comparison involving an unset value — r > 0 when r is unset — is itself an unset Boolean: neither true nor false. require treats that the same as false, and fails. So require r > 0 rejects a zero or negative radius and an unset one; it passes only when the answer is a set, true Boolean. A failed requirement stops the program and reports the exact condition that was not met, and where. ↩

  2. 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 S is a subtype of T, then objects of type T may be replaced with objects of type S without breaking the program. A subtype that cannot stand in for its parent is behind the surprised UnsupportedOperationException in code that assumed it could. ↩

  3. In Java, for example, a wrong cast raises a ClassCastException when the program runs. ↩