Classes in EK9

A class holds private state and the methods and operators that work on it. Structure introduced it in its class section; this page covers classes and inheritance.

Uses for Classes

In an object-oriented language the class is the main construct. In EK9 classes still matter, but functions, records and components work beside them, and classes are not meant to be your main focus.

A class has:

Properties/fields are always private. Methods can be overloaded: one name, different combinations of parameters.

Note: a class field cannot be sanitized (E07920). The modifier is valid only on incoming String parameters of methods and functions (see Sanitized Parameters). To store cleaned data in a field, use a sanitized parameter on the method that sets it, or call the InputSanitizer class in your method.

An abstract class can only be extended, never created directly. Any other class is closed by default: to let it be extended, declare it open.

Why EK9 does it this way. Closed by default will polarise developers. The 'pain' is there on purpose, to steer you to traits and composition rather than always reaching for inheritance.

Quality Enforcement: the compiler applies quality limits to every class method: cyclomatic complexity at most 45, cognitive complexity 35, nesting depth 6, and 100 statements per method (50 for an operator). A class's aggregated complexity must not exceed 500. Names must be descriptive (no temp, flag, data, value, buffer, object), field and parameter names included, and references must be in alphabetical order. An unused method or constructor parameter is E08091 (override methods and operators are exempt). All of this is checked at compile time; see Code Quality.

Class Anatomy

The example below has every part of an ordinary class:

#!ek9
defines module site.classes.anatomy

  defines class

    //A class is closed unless it says 'as open' - SavingsAccount below extends it.
    Account as open
      //Properties are always private; 'as T?' means "no value until a constructor gives it one".
      owner as String?
      balance as Integer?

      //The properties have no initial value, so a no-argument constructor must be private:
      //nothing outside the class can make an Account with no owner.
      default private Account()

      //A constructor's parameters go in one '->' block; 'this.x: x' copies each into its property.
      Account()
        ->
          owner as String
          balance as Integer
        this.owner: owner
        this.balance: balance

      //A method that changes the object.
      deposit()
        -> amount as Integer
        balance += amount

      //A method that returns a value: there is no 'return', the result is the '<-' variable.
      describe() as pure
        <- rtn as String: `${owner} holds ${balance}`

      //'?' (set when every property is set), '<=>' (sameness - '==', '<' and the rest are generated
      //from it), '#?' (goes with sameness) and '$' (the String form).
      default operator ?
      default operator <=>
      default operator #?
      default operator $

    SavingsAccount extends Account
      rate as Integer?

      SavingsAccount()
        ->
          owner as String
          balance as Integer
          rate as Integer
        super(owner, balance)
        this.rate: rate

      //'override' is required to replace a method of the super class; 'super.' calls the original.
      override describe() as pure
        <- rtn as String: `${super.describe()} at ${rate}%`

      //A sub class with its own properties gives its own '?', '$' and sameness (with '#?'):
      //Account's cannot know 'rate', so each of these calls Account's first, then adds it.
      default operator ?
      default operator $
      default operator <=>

  defines program

    ClassAnatomy()
      stdout <- Stdout()

      ann <- Account("Ann", 120)
      bob <- SavingsAccount("Bob", 80, 3)

      stdout.println(ann.describe())
      stdout.println(bob.describe())

      bob.deposit(40)
      stdout.println(bob.describe())

      stdout.println($ann)

      twin <- Account("Ann", 120)
      carl <- Account("Carl", 50)
      stdout.println(`ann == twin: ${ann == twin}`)
      stdout.println(`ann < carl: ${ann < carl}`)
      stdout.println(`ann is set: ${ann?}`)

Running the program 'ClassAnatomy' prints:

Ann holds 120
Bob holds 80 at 3%
Bob holds 120 at 3%
Account(owner=Ann, balance=120)
ann == twin: true
ann < carl: true
ann is set: true
What the compiler rejects

Each of these is an easy mistake when you come from another language, and each is a compile error. Leaving out operator ? on a class with properties:

Fails with E07235: aggregate with properties/fields requires 'override operator ?' (custom) or 'default operator ?' (auto-generated) to define set/unset semantics

#!ek9
defines module bad.site.classes.anatomy.noisset

  defines class

    Account
      owner as String?

      default private Account()

      Account()
        -> owner as String
        this.owner: owner

      describe() as pure
        <- rtn as String: `Owned by ${owner}`

Leaving out the private default constructor while a property has no initial value (the compiler would otherwise provide a public one that builds an 'Account' with no owner):

Fails with E07175: default constructor must be 'private' when uninitialised properties exist

#!ek9
defines module bad.site.classes.anatomy.publicdefault

  defines class

    Account
      owner as String?

      Account()
        -> owner as String
        this.owner: owner

      describe() as pure
        <- rtn as String: `Owned by ${owner}`

      default operator ?

A constructor that does not give every property a value:

Fails with E08060: is/may not be initialised before use

#!ek9
defines module bad.site.classes.anatomy.unsetproperty

  defines class

    Account
      owner as String?
      balance as Integer?

      default private Account()

      Account()
        -> owner as String
        this.owner: owner

      describe() as pure
        <- rtn as String: `${owner} holds ${balance}`

      default operator ?

Asking for == as well as <=>. A type defines 'sameness' once, and every comparison with itself is generated from it:

Fails with E07181: comparisons with the type itself are generated from its sameness ('<=>' or '=='), never written or requested

#!ek9
defines module bad.site.classes.anatomy.writtenequality

  defines class

    Account
      owner as String?

      default private Account()

      Account()
        -> owner as String
        this.owner: owner

      describe() as pure
        <- rtn as String: `Owned by ${owner}`

      default operator ?
      default operator <=>
      default operator ==
      default operator #?

Extending a class that is not open:

Fails with E05030: not open to be extended

#!ek9
defines module bad.site.classes.anatomy.closedbase

  defines class

    Account
      owner as String?

      default private Account()

      Account()
        -> owner as String
        this.owner: owner

      describe() as pure
        <- rtn as String: `Owned by ${owner}`

      default operator ?

    SavingsAccount extends Account

      SavingsAccount()
        -> owner as String
        super(owner)

Shape Example

The example below is a traditional class hierarchy, a mix of abstract, open and closed classes. With a hierarchy like this you can put the abstract idea of a shape in a collection and process every sub class of shape the same way. The same reasoning applies to traits.

The program 'DrawShapes' draws each shape in two ways. First it builds a List of Shape: EK9 looks at each type in the list literal, finds the common type, and infers that 'shapes' is a List of Shape. It then uses the list in a for loop, and again in a processing pipeline.

'Shape' declares one method, draw, and each shape's version only prints what it would draw. To define widths, heights and centre points you would need a proper class structure. Mathematically a Square is a Rectangle, but you would not model it that way: Square would inherit setWidth and setHeight from Rectangle, which make no sense for a square, so those methods would break the Liskov substitution principle.

As the operators section explains, EK9 can provide an implementation of some operators that are not marked abstract and have no body or return value. The Coordinate class below shows this, along with a default constructor with no body. That constructor is public; to prevent default construction of a Coordinate, mark it private.

#!ek9
defines module site.classes.shapes

  defines function

    doDrawing() as abstract
      -> message as String

    doStandardDrawing() is doDrawing
      -> message as String
      Stdout().println(message)

    doSimpleDrawing() is doDrawing
      -> message as String
      Stdout().println(message.lowerCase())

    doFancyDrawing() extends doDrawing
      -> message as String
      Stdout().println(message.upperCase())

    drawShape()
      -> shape as Shape
      <- drawn as Bits: Bits(shape.draw())

  defines class

    Coordinate
      x as Float: 0.0
      y as Float: 0.0

      //Sort of lombok like, no implementation needed.
      default Coordinate() as pure

      //Constructor
      Coordinate() as pure
        ->
          initialX as Float
          initialY as Float

        x :=: initialX
        y :=: initialY

      x() as pure
        <- rtn as Float: x

      y() as pure
        <- rtn as Float: y

      operator + as pure
        -> coord as Coordinate
        <- rtn as Coordinate: Coordinate(this.x() + coord.x(), this.y() + coord.y())

      operator +=
        -> coord as Coordinate
        this.x += coord.x()
        this.y += coord.y()

      operator - as pure
        -> coord as Coordinate
        <- rtn as Coordinate: Coordinate(this.x() - coord.x(), this.y() - coord.y())

      operator -=
        -> coord as Coordinate
        this.x -= coord.x()
        this.y -= coord.y()

      //Instruction to add in additional operators where these can be auto generated.
      default operator

    Shape as abstract
      draw() as abstract
        <- drawn as Boolean?

    Ellipse extends Shape as open
      override draw()
        <- drawn as Boolean: true
        doDraw("Draw Ellipse")

      protected doDraw()
        -> message as String
        Stdout().println(message)

    Circle is Ellipse
      override draw()
        <- drawn as Boolean: true
        doDraw("Draw Circle")

    Rectangle is Shape
      shouldDraw as Boolean: true

      Rectangle()
        -> doDraw as Boolean
        shouldDraw: doDraw

      override draw()
        <- drawn as Boolean: shouldDraw
        if shouldDraw
          Stdout().println("Draw Rectangle")

      default operator ?

    Triangle is Shape
      override draw()
        <- drawn as Boolean: true
        Stdout().println("Draw Triangle")

    Square is Shape
      drawingFunction as doDrawing?
      message as String: "Draw Square"

      Square()
        this(doStandardDrawing)

      Square()
        -> functionToDraw as doDrawing
        this.drawingFunction = functionToDraw

      override draw()
        <- drawn as Boolean: true
        drawingFunction(message)

      default operator ?

  defines program
    DrawShapes()
      stdout <- Stdout()
      stdout.println("Will draw shapes")

      shapes <- [
        Circle(),
        Square(),
        Ellipse(),
        Rectangle(),
        Triangle(),
        Square(doSimpleDrawing),
        Square(doFancyDrawing),
        Rectangle(false)
        ]

      //Procedural
      for shape in shapes
        shape.draw()

      //Functional
      drawnShapes <- cat shapes | map with drawShape | collect as Bits
      stdout.println("Shapes drawn is: " + $drawnShapes)

Running the program 'DrawShapes' prints:

Will draw shapes
Draw Circle
Draw Square
Draw Ellipse
Draw Rectangle
Draw Triangle
draw square
DRAW SQUARE
Draw Circle
Draw Square
Draw Ellipse
Draw Rectangle
Draw Triangle
draw square
DRAW SQUARE
Shapes drawn is: 11111110

Constructors

A default constructor (one with no parameters) can be declared with no body, as Coordinate's is, and can be protected or private rather than public.

Why EK9 does it this way. It lets you define a class's default constructor quickly and control its visibility, and so control its use.

this(...), super(...) and the order things happen in

A constructor can hand its work to another constructor of the same class with this(...), or to a constructor of the super class with super(...); either must be the first thing it does. In the example below the no-argument 'Reading' constructor delegates with this(noReading), and 'Sample' calls super(amount) before setting its own property.

A property initialiser such as label as String: "reading" runs before any constructor body, which is why the body can extend 'label' with +=. A private constructor can only be called from inside the class; here 'relabelled' uses one. A constructor is judged through its this(...) chain, so the private constructor delegates first and every property is set before its own body runs.

#!ek9
defines module site.classes.constructors

  defines constant
    noReading <- 0

  defines class

    Reading as open
      //A property initialiser runs before ANY constructor body, so 'label' is "reading" there.
      label as String: "reading"
      amount as Integer?

      //A no-argument constructor that delegates with this(...) rather than repeating the work.
      Reading()
        this(noReading)

      Reading()
        -> amount as Integer
        this.amount: amount
        label += `: ${amount}`

      //A private constructor is for the class's own use; it delegates first, so every property is set.
      private Reading()
        ->
          label as String
          amount as Integer
        this(amount)
        this.label: `${label}: ${amount}`

      relabelled()
        -> newLabel as String
        <- rtn as Reading: Reading(newLabel, amount)

      describe() as pure
        <- rtn as String: label

      default operator ?
      default operator $

    Sample extends Reading
      units as String?

      //Its own property has no initial value, so its default constructor must be private.
      default private Sample()

      //super(...) runs the Reading constructor (and Reading's initialisers) before this body.
      Sample()
        ->
          amount as Integer
          units as String
        super(amount)
        this.units: units

      override describe() as pure
        <- rtn as String: `${super.describe()} ${units}`

      default operator ?
      default operator $

  defines program

    Constructors()
      stdout <- Stdout()

      stdout.println(Reading().describe())
      stdout.println(Reading(21).describe())
      stdout.println(Reading(21).relabelled("indoor").describe())
      stdout.println(Sample(7, "mm").describe())

Running the program 'Constructors' prints:

reading: 0
reading: 21
indoor: 21
reading: 7 mm

A private constructor that leaves a property unset can only be reached through this(...) from a constructor that sets it, never by a construction call:

Fails with E08264: this constructor leaves properties without a value - it can only be used through this() from a constructor that sets them; construct through a constructor that sets every property

#!ek9
defines module bad.site.classes.constructors.privatepartial

  defines class

    Reading
      label as String?
      amount as Integer?

      default private Reading()

      Reading()
        ->
          label as String
          amount as Integer
        this.label: label
        this.amount: amount

      private Reading()
        -> label as String
        this.label: label

      relabelled()
        -> newLabel as String
        <- rtn as Reading: Reading(newLabel)

      default operator ?

A constructor with no super(...) call relies on the super class's no-argument constructor; when that is private it must call an accessible one instead:

Fails with E07176: a constructor cannot rely on the implicit super() - the super has no accessible no-argument constructor (it is 'private', or there is none) - call or provide an accessible initialising super constructor

#!ek9
defines module bad.site.classes.constructors.privatesuper

  defines class

    Reading as open
      amount as Integer?

      default private Reading()

      Reading()
        -> amount as Integer
        this.amount: amount

      default operator ?

    Sample extends Reading
      units as String?

      Sample()
        -> units as String
        this.units: units

      default operator ?

An object cannot be used, nor this handed out, until every property is set, and a property initialiser cannot use this at all. These rules of two-phase construction, and the one way an object may hand itself to a child it creates (the parent link), are covered in the Q&A:

Methods

EK9 has no static methods or variables: every method is on an object, though it need not mutate the object or even use its state. SystemClock().time() is an example: in EK9 you create the object and call the method. Because 'Clock' is a trait, you can substitute another implementation for SystemClock where you need to, for example in testing.

Extending Functionality

Inheritance adds functionality, but 'Square' has its behaviour changed without it, through a function delegate. Traits show more ways to extend functionality without inheritance alone. Composition is built into EK9; it increases reuse and removes the need for multiple class inheritance.

'DrawShapes' creates three instances of 'Square': one with the standard drawing function and two with other delegates. The constructor with no parameters does not repeat the other's work; it delegates to it with this(doStandardDrawing). Calling draw on a 'Square' calls whichever function delegate it was given.

EK9 has no 'mixins'. It does support delegation to compatible classes, and function delegates. A delegate cannot reach private state, which enforces encapsulation.

Operators

'Coordinate' shows some of the operators you can add to a class; you can implement any of the operators on a class.

Like records, classes support the default operator syntax (see operators), which gives an operator a default implementation where one is logical and straightforward. You can still write your own implementation of any operator: put default operator after your own implementations, and EK9 adds the rest that it can.

A class that has properties must say what it means for one of its objects to be 'set'. 'Rectangle' and 'Square' do it with default operator ?; without it the compiler rejects the class (E07235). 'Coordinate' gets it from its bare default operator, which adds every operator it can.

Constraining Extension

Like traits, classes can use allow only to create sealed classes, restricting which classes can extend them. A sealed class must be declared as open, since EK9 classes are closed by default.

Shape allow only Circle, Square, Triangle as open
  area() as pure
    <- rtn as Float: 0.0

Only the named classes (Circle, Square, Triangle) can extend Shape. Any other class that tries is rejected at compile time with E05240:

Fails with E05240: type is not permitted to extend/implement this sealed type

#!ek9
defines module bad.site.classes.sealedbreach

  defines class

    Shape allow only Circle, Square as open
      area() as pure
        <- rtn as Float: 0.0

    Circle extends Shape
      default operator ?

    Square extends Shape
      default operator ?

    Pentagon extends Shape
      default operator ?

The compiler explains every error code it reports; ask it with ek9 -h:

$ ek9 -h E05240
EK9 Help for 'E05240'
========================================

--- ERROR EXPLANATION ---
[DIAGNOSIS]
A class extends or implements a sealed type (trait or class) that uses
'allow only', but the class is not in the permitted list. The 'allow only'
clause creates a sealed type -- only the explicitly named classes may
extend or implement it. This restriction is enforced after the full type
hierarchy has been established (PRE_IR_CHECKS phase).

This works identically for both sealed traits and sealed classes:
- Sealed trait: 'Shape allow only Circle, Square' on a trait
- Sealed class: 'Shape allow only Circle, Square as open' on a class

Enforcement is TRANSITIVE. Every concrete descendant must appear in the
'allow only' list of every sealed type above it -- at any depth, not just
the direct subclasses. A grandchild that extends a permitted subclass
(rather than the sealed type itself) must STILL be named in the sealed
type's list. For example, given 'Vehicle allow only Car, Sedan as open'
where 'Sedan extends Car' (not Vehicle directly), Sedan must still appear
in Vehicle's list because Vehicle is sealed. See 'ek9 -q 303'.

Abstract classes are exempt from this check because they cannot be
instantiated. The sealed set controls which concrete runtime types exist.
If an abstract helper class implements a sealed type, only its concrete
subclasses are checked -- and each of those must still appear in the sealed
type's 'allow only' list even though it inherits the type transitively
rather than declaring it directly.

Dynamic classes (anonymous classes created inline) are always rejected
because their generated names cannot appear in an 'allow only' list.

[EXAMPLES]
Compilable examples demonstrating this error and the correct fix:
  ek9 -q 101   (Why are types closed by default in EK9?)
  ek9 -q 303   (Can a permitted subclass also be sealed in EK9?)

[ACTION]
Either add the class to the sealed type's 'allow only' list, or remove
the extension/implementation from the class. If the class is an intermediate
abstract helper, mark it 'as abstract' to exempt it from the check.

[COMMON CAUSES]
- Refactoring: extracting a new class that extends a sealed type without
  updating the 'allow only' list
- Adding a new implementation of a sealed type without checking constraints
- Dynamic classes implementing sealed types (always rejected)
- Forgetting to update 'allow only' after splitting a class hierarchy

[SEE ALSO]
- E05100: incompatible genus (allow only requires classes)
- E05110: incompatible category (allow only rejects templates)
- E05270: sealed class must be declared 'as open'
- E05030: not open to be extended

[EXAMPLE]
This fails with E05240 (bad.ek9):

  #!ek9
  defines module error.e05240.bad

    defines trait

      SealedTrait allow only AllowedClass1, AllowedClass2
        process() as abstract

    defines class

      AllowedClass1 with trait of SealedTrait
        override process()
          content <- "Allowed 1"
          require content?

      AllowedClass2 with trait of SealedTrait
        override process()
          content <- "Allowed 2"
          require content?

      AddedLater with trait of SealedTrait
        override process()
          content <- "Added later"
          require content?

      AlsoAddedLater with trait of SealedTrait
        override process()
          content <- "Also added later"
          require content?

    defines function

      testDynamic()
        processor <- () trait of SealedTrait as class
          override process()
            content <- "Dynamic"
            require content?
        require processor?

Fixed (good_fix.ek9):

  #!ek9
  defines module error.e05240.good.fix

    defines trait

      SealedTrait allow only AllowedClass1, AllowedClass2, AddedLater, AlsoAddedLater
        process() as abstract

    defines class

      AllowedClass1 with trait of SealedTrait
        override process()
          content <- "Allowed 1"
          require content?

      AllowedClass2 with trait of SealedTrait
        override process()
          content <- "Allowed 2"
          require content?

      AddedLater with trait of SealedTrait
        override process()
          content <- "Added later"
          require content?

      AlsoAddedLater with trait of SealedTrait
        override process()
          content <- "Also added later"
          require content?

    defines function

      testDynamic()
        processor <- AllowedClass1()
        require processor?

Fixed (good.ek9):

  #!ek9
  defines module error.e05240.good

    defines trait

      SealedTrait allow only AllowedClass1, AllowedClass2
        process() as abstract

    defines class

      AllowedClass1 with trait of SealedTrait
        override process()
          content <- "Allowed 1"
          require content?

      AllowedClass2 with trait of SealedTrait
        override process()
          content <- "Allowed 2"
          require content?

      //Not in the list: it holds an allowed type rather than taking on the trait
      Composed
        delegate as AllowedClass1: AllowedClass1()

        process()
          delegate.process()

        default operator ?

Show me how to fix it (in the IDE): ek9 -ide -lesson fix-E05240

This is the same idea as Java 17's sealed classes with permits and Kotlin's sealed classes. With dispatchers, sealed classes give exhaustive dispatch: the compiler checks that there is a handler for every permitted type (E05260). allow only works the same way on traits and classes; see traits for more.

Summary

The example above shows classes and a function delegate. A class should have state and behaviour.

Next Steps

There is more on classes in methods, traits, extension by composition, inheritance, dynamic classes and advanced class methods.