Generics/Templates in EK9

Many examples have used the built-in collections, such as List and Dict. These are Generic/Template classes, parameterised with another type: parametric polymorphism. Other examples have defined new generic types, both generic classes and generic functions.

This section first explains how EK9 handles generic types. It then shows, through an example, how generics can be used in different ways, and how you control (constrain) the types used to parameterise a generic type, and what that gains you.

Type Creation

Parameterising a generic class with one or more types creates, in effect, a new type, and that has implications that may not be obvious. Declaring List of Person (where Person is an existing type) creates a new type: 'List of Person'.

Take an abstract base class Shape, and two classes that extend it, Circle and Square. Use a 'List of Shape', a 'List of Circle' and a 'List of Square', and you have created three new types. As you would expect:

You can also pass a 'List of Shape' as a parameter. But can a 'List of Circle' be used as a 'List of Shape'? The answer may surprise you.

A 'List of Circle' is not a 'List of Shape'!

A 'Circle' extends 'Shape', so a 'Circle' is a 'Shape'. But if EK9 let you pass a 'List of Circle' where a 'List of Shape' was needed, the receiver could add 'Squares' to it. So EK9 does not allow it:

Fails with E06270: parameter mismatch

#!ek9
defines module bad.site.generics.listOfCircle

  defines class

    Shape as abstract
      shapeName() as pure abstract
        <- rtn as String?

    Circle extends Shape
      override shapeName() as pure
        <- rtn as String: "circle"

  defines function

    countShapes() as pure
      -> shapes as List of Shape
      <- rtn as Integer: length shapes

  defines program

    PassingCircles()
      circles <- [Circle(), Circle()]
      Stdout().println(countShapes(circles))

That the parameterising types are compatible (polymorphic) does not make the types created from them compatible.

Separate Types

Each parameterisation of a generic type is a new type with no relationship to any other: 'List of Circle' does not extend 'List of Shape', or any other parameterisation of List. Like every EK9 type, it is still an Any.

So a class can have two methods with the same name that differ only in their generic parameters. Java cannot do this; EK9 has no type erasure:

SimpleExample as abstract
  assimilate() as pure abstract
    -> items as List of Shape
    <- rtn as Shape?

  assimilate() as pure abstract
    -> items as List of Circle
    <- rtn as Shape?

  assimilate() as pure abstract
    -> items as List of Square
    <- rtn as Shape?

Why EK9 does it this way. The method assimilate can be 'overloaded' because each parameter is a truly different type. That may not seem surprising, but it is one of the limitations of Java's 'type erasure', and one of the reasons EK9 creates a new type for each parameterisation.

'Shape', 'Circle' and 'Square' are polymorphically compatible, but an 'Optional', 'List', 'Dict' or any other generic type parameterised with them is not.

Constraining/Widening

Constraining the type that can parameterise a generic type sounds limiting, and in some ways it is, but it enables more functionality in others. Think of an 'area' made from a 'width' and a 'height': to keep the same 'area' with a smaller 'width', you must increase the 'height'. Constraining the parameterising type reduces the 'width'; the 'height' that grows is the methods you can now call on the type.

Without a constraint, a generic function or class can assume only a little about type 'T': in EK9, that it provides the standard operators. This is another reason EK9 defines so many specific operators.

Operators

When you write a generic function or class, you can assume that the type it is parameterised with provides every operator your generic code uses. If it does not, the compiler fails where the parameterisation takes place (E07620), and the type must be given those operators before it can be used. This was one of the reasons for including operators in EK9.

defines function
  eventHandler of type T
    -> shape as T
    require shape?
    //any other 'operator' on T can be used

So unconstrained generic code can use only the standard operators. To do anything beyond 'collections' you need to call methods, and that is the main reason EK9 lets you constrain parameterising types.

Methods

Generic code can call methods on the conceptual type 'T', at a price: it must be constrained to work only with types that have those methods. It is then less generic (it cannot be parameterised with 'any' type), but it can call more methods and deliver more.

Why EK9 does it this way. All this effort and compromise is to keep 'type safety' at compile time.

defines function
  eventHandler of type T constrain by Shape
    -> shape as T
    require shape?
    require shape.description()?
    //any other 'method' on Shape can be used

This second snippet limits eventHandler to 'Shape' and its subclasses. It is slightly less generic, but it can now call the methods of 'Shape'.

The Example

All the examples use this setup code, shown once:

defines function

  //Generic function - now open so can be extended once parameterized.
  eventHandler of type T constrain by Shape as open
    -> shape as T
    require shape?
    //Now it is possible access the 'description' method if needs be.
    require shape.description()?

defines class

  Text
    mainValue as String?

    default private Text() as pure

    Text() as pure
      -> content as String
      mainValue:=? String(content)

    mainValue() as pure
      <- rtn as String: String(mainValue)

    default operator ?

  Shape as abstract
    shapeInfo as String?

    default private Shape() as pure

    Shape() as pure
      -> info as String
      shapeInfo:=? String(info)

    description() as pure
      <- rtn as String: String(shapeInfo)

    override operator ? as pure
      <- rtn as Boolean: shapeInfo?

  Circle is Shape

    private Circle() as pure
      -> info as String
      //Must chain to an accessible initialising super - Shape's no-arg default is private (E07176).
      super(info)

    Circle() as pure
      super("Circle")

    roundness() as pure
      <- rtn as Float: 0.0

  Square is Shape

    private Square() as pure
      -> info as String
      //Must chain to an accessible initialising super - Shape's no-arg default is private (E07176).
      super(info)

    Square() as pure
      super("Square")

It defines some simple classes around 'Shape', and one called 'Text' that is not part of that hierarchy. 'Text' is there only to show the constraint on eventHandler.

eventHandler is a generic function, constrained to 'Shape'. With traits you can constrain generic functions and classes to work with a wide range of types, as long as they conform to a specific trait.

Example 1

This example uses the functions and classes above to refine dynamic functions from the generic function eventHandler, by extending it.

If the generic function's own body is all you need, you don't have to provide a new one: shapeHandler <- () is eventHandler of Shape as function is enough. Capture nothing you do not use, since an unused captured variable is an error (E11018).

defines program

  ExampleDriver1()
    stdout <- Stdout()
    stdout.println("Driver for generic event handler")
    eventProcessor <- ShapeEventProcessor()

    //This is not allowed because eventHandler is constrained to Shape
    //textHandler <- (eventProcessor) is eventHandler of Text

    //Setup some event handlers and an example
    //Process through 'Shape'
    shapeHandler <- (eventProcessor) is eventHandler of Shape as function
      eventProcessor.processShapeEvent(shape)

    //Now type safe via generics and direct call to specific method
    circleHandler <- (eventProcessor) is eventHandler of Circle as function
      //Note strict typing means method 'roundness' is accessible.
      Stdout().println("Roundness " + $shape.roundness())
      eventProcessor.processCircleEvent(shape)

    //While handler is specific to Square can still call via processShapeEvent
    squareHandler <- (eventProcessor) is eventHandler of Square as function
      eventProcessor.processShapeEvent(shape)

    //make some calls on the event handlers
    shapeHandler(Circle())
    shapeHandler(Square())

    circleHandler(Circle())
    squareHandler(Square())

    //Will not compile - which is correct!
    //circleHandler(Square())
    //squareHandler(Circle())

circleHandler and squareHandler again show strong typing. shapeHandler accepts anything compatible with 'Shape'; pass an incompatible type to the other handlers and the compiler reports an error.

Here is ShapeEventProcessor:

defines class
  //non-generic processor
  ShapeEventProcessor
    stdout as Stdout: Stdout()

    processShapeEvent()
      -> shape as Shape
      require shape?
      stdout.println(`Processed shape [${shape.description()}]`)

    processCircleEvent()
      -> circle as Circle
      require circle?
      stdout.println(`Processed circle [${circle.description()} roundness ${$circle.roundness()}]`)

    default operator ?

shapeHandler can call only 'processShapeEvent', because it sees only the 'Shape' type. circleHandler sees the 'Circle' type, so it can use the methods of 'Circle' and call 'processCircleEvent'.

The ExampleDriver1 program prints:

Driver for generic event handler
Processed shape [Circle]
Processed shape [Square]
Roundness 0.0
Processed circle [Circle roundness 0.0]
Processed shape [Square]

The different event handlers show both type safety and flexibility.

Example 2

This simpler example redefines the processor as a generic class.

defines program

  ExampleDriver2()
    stdout <- Stdout()
    stdout.println("Driver for generic event handler and generic processor")

    eventProcessor as GenericShapeEventProcessor of Shape: GenericShapeEventProcessor()

    shapeHandler <- (eventProcessor) is eventHandler of Shape as function
      eventProcessor.processShapeEvent(shape)

    //make some calls on the event handler
    shapeHandler(Circle())
    shapeHandler(Square())

defines class

  GenericShapeEventProcessor of type T constrain by Shape
    stdout as Stdout: Stdout()

    default GenericShapeEventProcessor()

    GenericShapeEventProcessor()
      -> shape as T
      require shape?

    processShapeEvent()
      -> shape as T
      require shape?
      stdout.println(`Processed shape [${shape.description()}]`)

    default operator ?

ExampleDriver2 prints:

Driver for generic event handler and generic processor
Processed shape [Circle]
Processed shape [Square]

The most significant part is the definition and use of the generic class GenericShapeEventProcessor. This line:

eventProcessor as GenericShapeEventProcessor of Shape: GenericShapeEventProcessor()

parameterises GenericShapeEventProcessor with the type 'Shape', declares a variable of the resulting type and initialises it with a new instance. As before, the dynamic function shapeHandler captures this eventProcessor and uses it in its body.

Conclusion

Generics/Templates (parametric polymorphism) generate the same code on a per type basis. They are ideal for 'collections', and for processing that does not depend on type; where type does matter, you can make them 'less generic'. They are 'type safe': a 'List of Circle' can only hold objects compatible with 'Circle'.

Writing generic code is more complex than standard code, and takes time and focus.

Next Steps

The next section, Text Properties, is lighter. It encapsulates 'text' and String interpolation in a form similar to 'property files', with compiler support.