Generics/Templates in EK9

Many of the examples have used the built-in collections; like List or Dict; these are Generic/Template classes that can be parameterised with another type.
This is parametric polymorphism.

There have also been examples of the definition of new generic types. These show how both generic classes and generic functions can be developed and used.

There is inline example at the end of this section, it shows how generics can be used in different ways and how the types used to parameterize a generic type can be controlled. The benefits of this control (constraint) are then discussed.

Before that however; it is necessary to discuss the way EK9 deals with generics/templated types.

Type Creation

When instantiating a new Object of a generic class and parameterizing it with one or more types; a new type is created (in effect). There are some implications around this, that might not be obvious.

i.e. Just declaring: List of Person (where Person is an existing type), has actually created a new type; the 'List of Person' type!

As an example consider an abstract base class of Shape and two classes that extend it, Circle and Square.

Now declare to use of 'List of Shape', a 'List of Circle' and a 'List of Square'. Three new types have been created, because you have declared their use somewhere.

It should come as no surprise that you can:

OK, all obvious and understandable. But what about passing the 'List of Shape' as a parameter?
More interestingly: "can a 'List of Circle' be considered to be 'List of Shape'"?

Well an Object that has the type 'List of Shape' can be passed about as a parameter. So that answers the first question, but the second question has an answer you may not be expecting!

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

But wait a 'Circle' extends 'Shape' so a 'Circle' is a 'Shape'. So surely this can't be right!

If EK9 did allow this, then a 'List of Circle' could be passed as a parameter where a 'List of Shape' was needed. Then it would be possible to add 'Squares' to it!

This is not what we want! So just because the generic type is parameterised with types that are compatible (in a polymorphic manner) it does not follow that the newly created types from parameterizing a generic type are compatible (polymorphic).

Separate Types

When generic types are parameterised with types; they define a new type and that new type does not have any sort of type hierarchy; i.e. it does not extend any other type.

This point is important as it means that a class with two methods with the same name but different generic parameters is possible (this is unlike Java! There is no type erasure in EK9). i.e.

...
  defines class
    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
...

In the short snip above, the method assimilate is 'overloaded' and this is possible because each of the parameters is truly a different type. While this might not be that surprising it is one of the limitations of Java with 'type erasure'. This is one of the reasons EK9 creates new types when generic types are parameterized.

There is polymorphic compatibility between the type 'Shape', 'Circle' and 'Square'; but this should not lead you to believe that when an 'Optional', 'List', 'Dict' or any other generic type is parameterised with any of the above types, the resulting types will be compatible! They won't.

Constraining/Widening

The example below aims to show the benefits of constraining the parameterizing type that can be used with a generic type. While the word 'constrain' appears to be a limiting feature; which it is some ways. It actually enables more functionality in other ways.
A reasonable analogy would be to consider an 'area' made from a 'width' and a 'height'. To keep the same 'area' but increase the 'width' you must reduce the 'height'. By constraining the parameterizing type the 'width' is reduced, but to keep the same 'area', the 'length' is increased (this means that now it is possible to use methods on the type).

Without constraining the parameterizing type 'T'; the generic function or class can only make a certain number of assumptions about type 'T'. In EK9 those assumptions are the provision of all the standard operators. This is another one of the reasons EK9 defines so many specific operators.

Operators

The developer creating a generic function or class can assume that when their generic code is used and parameterised with a specific type; that the type will provide all the operators they have used in their generic code. If this is not the case; then the compiler will fail to compile the code where the parameterization took place. The developer must now augment the type they intend to parameterize the generic code with, to include those operators. This was one of the reasons operators were included in EK9.

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

So any generic code is limited to only use the standard set of operators! That might be a little too limiting; if you want to do anything beyond just 'collections' you need to call methods. This is the main reason EK9 allows the constraining of parameterized types.

Methods

It may be that the generic code needs to call some methods on the conceptual type 'T'! This can be done, but at a price! The price is that the generic code has to be constrained in some way to only work with types that have those methods! So in some ways the code in now less generic (i.e. it cannot be parameterised by 'any' type). But the upside is that additional methods can be called and functionality can be delivered.

All this is effort/compromise is to retain compile time 'type safety'.

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

The second snip above limits the eventHandler; so that it can only be used with 'Shape' and its subclasses. So it is slightly less generic, but now specific methods on 'Shape' can be called!

The Example

This same setup EK9 source code will be used for all the examples, it is shown here once for brevity.

#!ek9
defines module introduction

  defines function

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

  defines class

    Text
      value as String?

      default private Text()

      Text() as pure
        -> content as String
        value: String(content)

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

    Shape as abstract
      shapeInfo as String?

      private Shape()
        assert false

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

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

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

    Circle is Shape
      Circle() as pure
        super("Circle")

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

    Square is Shape
      Square() as pure
        super("Square")
... 

The code above defines a number of simple classes around 'Shape' and also one called 'Text'; it is not part of that class hierarchy. The 'Text' class is only used in the following part of the example to highlight the constraint applied to eventHandler.

The eventHandler is a generic function; but in this case it has been constrained to work with 'Shape'. Through the use of traits it is possible to constrain generic functions and classes to work with a wide range of types as long as they conform to a specific trait.

Example 1

The example code below uses the functions and classes defined above to show how a dynamic function can be refined from generic functions. In this case the generic function eventHandler is extended.

The textHandler below is commented out as an example of how the 'constrain' mechanism is enforced by the compiler. The other 'handlers' shapeHandler, circleHandler and squareHandler can all be defined without error (unlike the textHandler).

A ShapeEventProcessor class (which is defined a little later) is 'captured' as a variable in the dynamic functions. This is to highlight the type safety and strong typing with generics. It also highlights how it is possible to move from a generic type 'T' to the actual type that has been used in parameterization.

The shapeHandler dynamic function is a function delegate; it results from the parameterization of the generic function eventHandler and the type 'Shape'. It is possible to redefine the function body as shown below; where the shape is passed through to the eventProcessor (the captured variable), by using method 'processShapeEvent'.

If the functionality in the generic function is all that is needed; then it is not necessary to provide a new function body. i.e. shapeHandler <- (eventProcessor) is eventHandler of Shape would have sufficed by itself.

...
  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())
...

The circleHandler and squareHandler have been created to again demonstrate strong typing. As shown above shapeHandler can be used with anything that is compatible with 'Shape'. When incompatible types are used with the other handlers, the compiler will issue errors.

The ShapeEventProcessor is now shown below.

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

      processShapeEvent()
        -> shape as Shape
        assert shape?
        stdout.println("Processed shape [" + shape.description() + "]")

      processCircleEvent()
        -> circle as Circle
        assert circle?
        stdout.println("Processed circle [" + circle.description() + " roundness " + $circle.roundness() + "]")
... 

The shapeHandler can only call the method 'processShapeEvent' as it only has access to the 'Shape' type. Whereas the circleHandler has access to the 'Circle' type and so can access methods on the 'Circle' and call method 'processCircleEvent'.

The output of the ExampleDriver1 program is shown below:

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 have been defined to demonstrate type safety and flexibility.

Example 2

This second example is simpler in many ways and redefines ShapeEventProcessor to be just generic.

...
  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()

      processShapeEvent()
        -> shape as T
        assert shape?
        stdout.println("Processed shape [" + shape.description() + "]")
... 

The output of ExampleDriver2 is shown below:

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

Probably the most significant part of this example is the definition and use of the generic class GenericShapeEventProcessor.

The line:

  • eventProcessor as GenericShapeEventProcessor of Shape : GenericShapeEventProcessor()

GenericShapeEventProcessor parameterizes GenericShapeEventProcessor with type 'Shape' and declares a variable of that resulting type and initialises it with a new instance.

As in the previous example this eventProcessor is captured by the dynamic function shapeHandler and used in the function body.

Conclusion

Generics/Templates (Parametric polymorphism) provides a sophisticated mechanism to create the same set of code instructions on a per type basis. They are ideal for 'collections' but also for generic processing irrespective of type. Where type does become important; they can be made 'less generic'.

Importantly they are 'type safe'; meaning that a 'List of Circle' can only contain Objects that are compatible with the 'Circle' type.

Writing Generic/Templated code a quite a bit more complex than standard code. It takes quite a bt of time and focus.

Next Steps

The next section on Text Properties is a little lighter. It just enables the encapsulation of 'text' and String interpolation in a form similar to 'property files', but with compiler support.