Traits in EK9
The structure section introduced the trait. This page shows what a trait does, including how it controls which classes may extend it.
Main uses for traits
A trait looks like a Java Interface, but it does more. It gives you more control than a class does, offers less implementation, and holds no state: a trait cannot have properties (E07075). Don't treat it as a cut-down abstract class.
Quality Enforcement: EK9 enforces quality limits on trait methods: cyclomatic complexity 45, cognitive complexity 35, nesting depth 6, and 100 statements per method (50 for an operator). Variable and parameter names must be descriptive: not temp, flag, data, value, buffer or object (E11031). Traits are exempt from the cohesion check (E11014) because they define interfaces; their coupling limit (Ce) is 8. See Code Quality.
The main features/characteristics
- Constraining Class Extension
- Providing a signature interface
- Trait inheritance
- Facilitates Composition
Traits Example
The example covers several ideas; the sections after it discuss each one.
- The program 'ProcessorTest' creates several 'Processors', calls process on each to get a 'ProcessingResponse', and passes the response to 'ResultBuilder.build' to make an output String.
- Several 'Processor' classes have the trait 'Processor': different implementations, one signature.
- 'Processor' inherits from the traits 'CostAssessment' and 'Moniterable', which both declare lowCost (a deliberate name clash).
- 'DelegatingProcessor' shows composition.
- 'StringResponse' and 'StructuredResponse' both have the trait 'ProcessingResponse', which provides the method 'result()'.
The Example
#!ek9 defines module site.traits.processors defines trait CostAssessment lowCost() as pure <- rtn <- true Moniterable available() as pure <- rtn <- true lowCost() as pure <- rtn <- false Processor with trait of Moniterable, CostAssessment process() <- response as ProcessingResponse? override lowCost() as pure <- rtn as Boolean: CostAssessment.lowCost() ProcessingResponse allow only StringResponse, StructuredResponse result() <- rtn as String? defines class StringResponse with trait of ProcessingResponse as open theResponse as String: String() StringResponse() as pure -> response as String theResponse :=: response StringResponse() as pure this(String()) override result() <- rtn as String: theResponse default operator ? StructuredResponse is StringResponse contentType as String: String() StructuredResponse() as pure -> response as String contentType as String super(response) this.contentType :=: contentType StructuredResponse() as pure -> response as String this(response, "text/plain") contentType() as pure <- rtn as String: contentType default operator ? SimpleProcessor with trait of Processor default SimpleProcessor() as pure override process() <- response as ProcessingResponse: StringResponse("Simple Message") DelegatingProcessor with trait of Processor by proc proc as Processor? DelegatingProcessor() as pure -> processorToUse as Processor proc :=? processorToUse DelegatingProcessor() as pure this(SimpleProcessor()) override lowCost() as pure <- rtn as Boolean: false default operator ? XMLProcessor with trait of Processor override process() <- response as ProcessingResponse: StructuredResponse("<tag>Simple Message</tag>", "text/xml") override lowCost() as pure <- rtn as Boolean: false //I've used a slightly different layout here and also used named parameters as an example. JSONProcessor with trait of Processor override process() <- response as ProcessingResponse: StructuredResponse( response: `{"name": "John", "age": 31, "city": "New York"}`, contentType: "application/json" ) ResultBuilder build() as dispatcher -> response as ProcessingResponse <- rtn as String: response.result() build() -> response as StringResponse <- rtn as String: `"${response.result()}"` build() -> response as StructuredResponse <- rtn as String: `${response.contentType()} (( ${response.result()} ))` defines program ProcessorTest stdout <- Stdout() processors <- [ SimpleProcessor(), XMLProcessor(), DelegatingProcessor(JSONProcessor()), JSONProcessor()] for processor in processors response <- processor.process() stdout.println(ResultBuilder().build(response))
Download traits_processors.ek9
The program prints:
"Simple Message"
text/xml (( <tag>Simple Message</tag> ))
application/json (( {"name": "John", "age": 31, "city": "New York"} ))
application/json (( {"name": "John", "age": 31, "city": "New York"} ))
'ResultBuilder' acts as a decorator (the design pattern). It can call the extra method contentType() because the dispatcher calls the method that matches the actual type of the parameter.
What has been shown in the Example
Each aspect of traits in the example is discussed below.
Constraining Extension
A trait can limit which classes may extend it, with allow only. This is useful for
APIs, libraries and subsystems: users of your API can use your traits and classes but not extend them. EK9
classes are also closed to extension unless declared open.
In some languages final is the only way to limit extension. Others have sealed types, similar to
this: Java's sealed interfaces with permits, Kotlin's sealed
classes/interfaces, Rust's enum variants.
ProcessingResponse allow only StringResponse, StructuredResponse
Full source: traits_processors.ek9 - Download traits_processors.ek9
A class that is not on the list and has the trait fails with E05240.
Transitive Enforcement
The allow only restriction is enforced transitively. A concrete class that
acquires a sealed trait through an intermediate trait or abstract class must still be on the
allow only list, so there is no indirect way around it.
Abstract Class Exemption
An abstract class cannot be instantiated, so it is exempt from the allow only check: the sealed set
controls which concrete types exist at run time. Abstract classes can implement a sealed trait to give
shared functionality to the permitted concrete classes. They cannot appear on the
allow only list itself (E05250); it holds concrete classes only.
Dispatcher Exhaustiveness
When a dispatcher method's entry point takes a sealed trait,
the compiler requires a handler for every permitted type
(E05260), as Java's exhaustive switch on a sealed interface or Kotlin's
when on a sealed class does. Add a type to the allow only list and every dispatcher on
that trait fails to compile until it has a handler, so no dispatch point is missed.
Classes Too
allow only works the same way on classes:
allow only Circle, Square as open limits which classes can extend it. Traits and classes share one
sealed type system.
Providing a signature interface
This is the traditional use of interfaces/traits: a set of method signatures, used abstractly. 'Processor' and 'ProcessingResponse' are signatures; 'ProcessingResponse' also limits which classes may use it.
A trait can also implement concrete (non abstract) methods, a little like default methods on Java interfaces. 'Processor' implements lowCost, and 'XMLProcessor' overrides it to show that a class can change the implementation.
Trait inheritance
A trait can inherit from one or more traits: 'Processor' inherits from 'CostAssessment' and 'Moniterable'. Where two inherited methods conflict, as lowCost does, the trait or class that brings them together must override it (E06150).
'Processor' overrides lowCost and delegates to CostAssessment.lowCost(). It could have called Moniterable.lowCost() instead, or returned true/false directly.
Composition
A major benefit of traits in EK9 is automatic composition. DelegatingProcessor with trait of Processor by proc shows it.
by proc delegates the trait's methods to the private property 'proc', which must be of type 'Processor'. Create 'proc' and set it, and every call on 'DelegatingProcessor' is routed to that delegate. The class can still override any of the methods and implement them itself.
Why EK9 does it this way. Think about what by means: delegation is now as easy as inheritance. It is not always the right choice, but you now have a short syntax for delegation whenever it is.
With composition you don't have to implement every method 'Processor' defines. The other 'Processors' must implement its abstract methods:
- SimpleProcessor with trait of Processor
- XMLProcessor with trait of Processor
- JSONProcessor with trait of Processor
The program constructs DelegatingProcessor(JSONProcessor()); pass an 'XMLProcessor' instead and the behaviour changes. That is composition: more flexible than inheritance in many ways, and it reinforces encapsulation and direct re-use.
You can delegate any number of traits. Because a class can have several traits, its behaviour can be composed from several other classes, much as multiple class inheritance would allow, but simpler and easier to maintain.
You can still override part of the 'Processor' signature in the class: in the example lowCost is re-implemented, while available is delegated.
A delegate is another provider of the trait's members, and the usual rules apply to it. It provides all the trait's members, including those inherited from the traits it extends (operators too), and all of them are routed to the delegate. If something else also provides one of those members (the super class, another delegated trait, or a trait with an implementation), the class must declare that member itself, exactly as it would without by; otherwise it is error E06150. The class's own method can then call super.method() or proc.method(), or combine them.
Thoughts
Why EK9 does it this way. A trait is more than a set of method signatures, although it should be used for that too. It makes delegation easy and limits which classes can use it. It is not an abstract class, and it is quite different from what most other languages offer.
Dispatcher
This is not about traits as such. 'ResultBuilder' has three methods called build, with different parameter types; the one taking 'ProcessingResponse' (the base trait) is marked as a dispatcher. EK9 looks at the actual type of the object passed in and calls the matching build method, so you never test types yourself. Advanced class methods covers this in more detail.
EK9 has no cast and no instanceof: it cannot test or convert types. Think of 'ResultBuilder' as a 'switch statement for types': it has a default behaviour, and looks for a method that accepts the parameter's actual type rather than the declared one.
Why EK9 does it this way. The dispatcher keeps an object-oriented design and avoids 'class cast exceptions'. Casting and 'instanceof' make brittle code that does not refactor easily.
Summary
Traits provide control and abstraction. They let you view objects in different but complementary ways, and design through composition rather than only inheritance.
- When you need functionality from one class in another, don't reach for inheritance first; it can be very limiting. Refactor into smaller classes, extract method signatures into traits, and compose.
- Another approach: hold what would have been 'class properties' in a record, and use traits for control and signatures across a range of classes and dynamic classes. Add 'function delegates' and you have quite a different way to implement functionality.
Patterns that make the most of EK9's delegation will take time to emerge.
Next Steps
Composition covers this subject further.