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.
- Generic/Template classes are more complex and have their own page.
- Dynamic classes also create classes, and can be a lightweight implementation of traits; that page comes back to classes.
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:
- an optional super class (single inheritance only)
- optional trait(s)
- any number of private properties/fields that hold its state, though you might delegate that to a record
- any number of methods, each public, protected or private
- implementations of the standard operators
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:
- 'Account' has two properties declared as String? and as Integer?: the ? means they have no value until a constructor gives them one.
- Its constructor takes its parameters in one -> block and copies each into its property with this.owner: owner.
- The method describe has no return statement (EK9 has none); its result is the <- variable, see functions.
- 'Account' is declared as open so that 'SavingsAccount' can extend it. 'SavingsAccount' uses override to replace describe, and calls the original with super.describe().
- The properties have no initial value, so the no-argument constructor is declared default private: nothing outside the class can make an 'Account' with no owner.
- The default operator lines say what 'set' means (?), how two accounts compare (<=>, from which ==, < and the other comparisons are generated), the hash code (#?) and the String form ($).
#!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}`
Download classes_anatomy_no_isset.ek9
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 ?
Download classes_anatomy_public_default.ek9
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 ?
Download classes_anatomy_unset_property.ek9
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 #?
Download classes_anatomy_written_equality.ek9
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)
Download classes_anatomy_closed_base.ek9
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
- open: makes a class that is not abstract extendable. See 'SalaryPolicy' in composition, and structure.
- override: required whenever a sub class overrides a method of its base class, as each draw above does.
- public is the default for methods, so you don't declare it. doDraw is protected: only 'Ellipse' and 'Circle' can call it. A class extending 'Circle' (which would then have to be open) could call doDraw, and could also override it.
- private is the only visibility for properties: they are shared only through method accessors. The property shouldDraw on 'Rectangle' is visible only in that class.
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())
Download classes_constructors.ek9
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 ?
Download classes_constructors_private_partial.ek9
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 ?
Download classes_constructors_private_super.ek9
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:
- Why can't a property initialiser use 'this'?
- How does a child object hold a reference back to its parent?
- Two children and a callback with a back reference to the parent
- The mistakes EK9 rejects when a constructor hands out 'this'
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
Full source: classes_sealed.ek9 - Download classes_sealed.ek9
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 ?
Download classes_sealed_breach.ek9
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.
- Only state? Consider records.
- Many methods that don't relate to the class? Move them out to functions.
- Functionality that seems to belong in two different classes? That is a good candidate for a function delegate.
Next Steps
There is more on classes in methods, traits, extension by composition, inheritance, dynamic classes and advanced class methods.