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

Traits Example

The example covers several ideas; the sections after it discuss each one.

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

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

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:

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.

Patterns that make the most of EK9's delegation will take time to emerge.

Next Steps

Composition covers this subject further.