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 add a Circle to a 'List of Circle' but not to a 'List of Square'.
- You can add a Square to a 'List of Square' but not to a 'List of Circle'.
- You can add both Circles and Squares to a 'List of Shape'; they both extend Shape.
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))
Download generics_list_of_circle.ek9
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?
Full source: generics_separate_types.ek9 - Download generics_separate_types.ek9
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
Full source: generics_unconstrained.ek9 - Download generics_unconstrained.ek9
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
Full source: generics_constrained.ek9 - Download generics_constrained.ek9
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")
Full source: generics_event_handlers.ek9 - Download generics_event_handlers.ek9
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.
- textHandler is commented out: the compiler enforces the constraint, and 'Text' is not a 'Shape' (E50030). shapeHandler, circleHandler and squareHandler compile.
- Each handler captures a ShapeEventProcessor (defined a little later) as a variable. This shows type safety and strong typing with generics, and how the code moves from the generic type 'T' to the actual type used in the parameterisation.
- shapeHandler is a function delegate: the parameterisation of eventHandler with 'Shape'. Its new body passes the shape to the captured eventProcessor's method 'processShapeEvent'.
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())
Full source: generics_event_handlers.ek9 - Download generics_event_handlers.ek9
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 ?
Full source: generics_event_handlers.ek9 - Download generics_event_handlers.ek9
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 ?
Full source: generics_event_handlers.ek9 - Download generics_event_handlers.ek9
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()
Full source: generics_event_handlers.ek9 - Download generics_event_handlers.ek9
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.