Getting Started with EK9 · Chapter 16
Classes
Records hold plain data, and constrained types narrow it. The other half of the choice in Two Ways to Build: Data, Functions and Objects is objects. In EK9 an object is made from a class, and the shortest useful definition is this: a class is a record whose fields are hidden, with functions attached. Those attached functions are called methods. A method is a function that belongs to the aggregate and can see its hidden fields, which ordinary functions cannot.
The smallest class
Here is a counter, about the smallest class worth writing:
#!ek9 defines module book.counter defines class Counter count as Integer: 0 tick() count += 1 total() as pure <- rtn <- Integer(count) default operator ? defines program Show() stdout <- Stdout() clicks <- Counter() clicks.tick() clicks.tick() clicks.tick() stdout.println(`clicks so far: ${clicks.total()}`)
clicks so far: 3
defines class opens it; Counter names it. So far it looks like a record: one field, count,
starting at 0. The difference is what other code cannot do. A record's fields are open to any code
with a dot. A class's fields are always private: there is no way to make one reachable from
outside. Outside the class, clicks.count is not allowed; the only way to change the count is
tick(), and the only way to read it is total(). The methods are the object's public surface, and
the field behind them is out of reach.1
total() is written in the shorter inferred-return form, <- rtn <- Integer(count),2 and it
returns Integer(count) — a copy of the field, not the field itself. Why that matters is the
subject of "The leak nobody mentions", below.
Because no outside code can touch count, it can never be set to -5 or 1000, or reset. It changes
only through tick(), by exactly one. The object looks after its own state.
Why hide anything?
Records and Enumerations argued for keeping data open. Open and hidden suit different jobs.
A record is public data, right when the data is just values to carry around, with no rules about what is allowed. A class is right when the data has rules to enforce: a count that may only go up, a balance that may not go negative, a list that must stay sorted. Hide the data, and those rules live in one place — the methods — with no way around them. The point is not secrecy; it is that every change goes through code you control.
So the choice becomes a simple test: is this just data, or data with rules? Just data: use a record. Data with rules to protect: use a class.
Guarding real state
Here is a class doing a real job: a scoreboard that keeps a list of scores and always knows the best one.
#!ek9 defines module book.scoreboard defines record Summary count as Integer: Integer() best as Integer: Integer() Summary() as pure -> c as Integer b as Integer count :=? Integer(c) best :=? Integer(b) operator $ as pure <- rtn as String: `${count} scores, best ${best}` default operator ? defines class ScoreBoard scores as List of Integer: List() of Integer topScore as Integer: Integer() add() -> points as Integer scores += Integer(points) if not topScore? or points > topScore topScore := Integer(points) summary() as pure <- rtn <- Summary(length scores, topScore) default operator ? defines program Show() stdout <- Stdout() board <- ScoreBoard() board.add(30) board.add(70) snap <- board.summary() stdout.println(`snapshot after two: ${snap}`) board.add(90) stdout.println(`snapshot is unchanged: ${snap}`) stdout.println(`live summary now: ${board.summary()}`)
snapshot after two: 2 scores, best 70
snapshot is unchanged: 2 scores, best 70
live summary now: 3 scores, best 90
ScoreBoard hides two fields: the scores list and the running topScore. The only way to add a
score is add(), which does two things together: it appends the score and keeps topScore
correct. The two can never drift apart, because they are updated together, every time, in the one
method allowed to change them. Keeping two facts in step is exactly the kind of rule a class exists to
protect. (The list field starts as List() of Integer, an empty list — which, as Collections
noted, is set, with nothing in it yet.)
Sealing the empty back door
Both classes so far gave every field a starting value. Some fields have no sensible starting value.
An account without an owner is not an account with a blank owner; it is meaningless. So you declare
the field in the absent form, owner as String? — a name with no object yet — and give the class a
constructor that requires an owner:
#!ek9 defines module book.account defines class Account //No sensible starting value exists: an account without an owner is nonsense. owner as String? balance as Integer: 0 //Seals off the no-argument constructor, so no ownerless Account can be built. default private Account() as pure Account() as pure -> who as String owner :=? String(who) deposit() -> amount as Integer require amount > 0 balance += amount statement() as pure <- rtn as String: `${owner} has ${balance}` default operator ? defines program Show() stdout <- Stdout() acc <- Account("Sam") acc.deposit(30) acc.deposit(70) stdout.println(acc.statement()) stdout.println(`account set? ${acc?}`)
Sam has 100
account set? true
The line to look at is this one:
default private Account() as pure
Every class has a no-argument constructor, Account(). Usually that is harmless, because the fields
have starting values. Here it would be a back door: an Account() built with no arguments would have
no owner, and nothing would stop that half-built object being passed around as if it were real. So
EK9 refuses. Delete that line and the compiler stops you (openConstructor.ek9, in the
error-examples folder, is Account.ek9 without it):
EK9 : Error : E07175: 'Account' on line 5 position 4: default constructor must be 'private' when uninitialised properties exist
See: ek9 -h E07175
default private Account() as pure is the fix: default means the standard no-argument constructor,
and private means no outside code can call it. The only way left to make an Account is
Account("Sam"), which demands an owner. owner starts absent, and every constructor must give it
an object (here String(who)) before the object can exist. A half-built Account cannot reach the
rest of your program, because it cannot be created.
Note — the object itself,
this. The constructor calls its parameterwho, notowner, so there is no doubt which is which. If you want a parameter to share a field's name,thissettles it:this.owneris the field and plainownerthe parameter, sothis.owner :=? owneris unambiguous.thisis the object a method is working on. Use it to name a field explicitly, or to pass the whole object to something else.
Many languages leave this problem — objects that exist but are not fully formed — to your discipline: remember to make the constructor private, remember not to call it. EK9 removes the remembering. If a field has no honest starting value, the empty constructor is sealed, or the code does not build.
The anaemic class: a record in disguise
A common wrong turn is a class with a private field, a getScore() and a setScore() for it, then
another field and another pair, until the class is nothing but fields and the methods that expose
them.
That class hides nothing. A setScore() that writes any value straight to the field is an open field
with extra typing: the rules you hid the data to protect are never applied. If you want open data, use
a record. Use a class when it does something a record cannot — guard its state, enforce a
rule, keep two fields in step. A class of plain getters and setters is a record in disguise, and the
disguise costs clarity for nothing.
The leak nobody mentions
Now the reason for Integer(count). This trap catches experienced programmers too.
In EK9 there are no primitives. An Integer is not a bare machine number; it is a full object,
and — like every value in EK9 — you hold it by reference. When you pass or return an object, you
hand over the very same object, not a duplicate. That is fast, but it means sharing is the default.
EK9's rule is the same for every type: everything is a reference, and nothing is copied unless you
ask. It applies to Integer, List, your own classes — every type. The one exception you have met is
a constant, which is handed over as a copy so that nothing can change it (Text, Numbers, Time and
Money).3
Suppose Counter returned its field directly:
total() as pure <- rtn as Integer: count // WRONG: hands back the field itself
Watch it leak:
clicks.tick() // three ticks -> count is 3 stdout.println(`total via method: ${clicks.total()}`) n <- clicks.total() n += 100 stdout.println(`after n += 100: n=${n}, counter says ${clicks.total()}`)
total via method: 3
after n += 100: n=103, counter says 103
We added 100 to our own variable n, and the counter jumped to 103. <- rtn as Integer: count
returned a reference to the very object the field holds, so n and the field are the same object:
change one and you change both. Even an integer getter leaks.
If you want a separate copy, ask for one. That is what Integer(count) does. It is the copy
constructor: it makes a new Integer and copies the value in, so the caller gets an object of their
own and the field is untouched. With it, n += 100 moves n to 103 and leaves the counter at 3.
A copy costs work — a new object, and the value copied into it — so EK9 does not make one behind your
back on every read. You pay for a copy when you decide you want one, and it shows in the code. (There
is also a copy operator, :=:, for copying into an existing object; for returning a fresh value, the
copy constructor is simpler.)
With a collection the same leak is worse, because a whole list is exposed. Suppose a board
(LeakyBoard.ek9) had a method that returned its list, beside a count() of its scores:
rawScores() as pure <- rtn as List of Integer: scores // WRONG: the actual list count() as pure <- rtn as Integer: length scores
board.add(30) board.add(70) stdout.println(`count via class: ${board.count()}`) leaked <- board.rawScores() leaked += 999999 stdout.println(`the list we got back has ${length leaked} items`) stdout.println(`but the board's own count is now: ${board.count()}`)
count via class: 2
the list we got back has 3 items
but the board's own count is now: 3
We never called add. We took the list, added a number to it, and the board's count changed —
because rawScores() returned the board's own list, and now the caller and the board hold the same
one. The field was private and the method looked read-only, yet the protection was lost anyway. This
is called representation exposure, and it is a common way for a class to lose control of its
data.4
The fix is the one summary() already uses. To show internal state, do not hand out the real thing;
build a new record filled with copies of the information. That is why the Summary constructor
copies each value in (count :=? Integer(c), best :=? Integer(b)): the snapshot must share nothing
with the board. In the output above, the snap taken after two scores still reads "2 scores, best 70"
after a third went in. It cannot change, because every value inside it is its own copy. The caller
gets the information and no way into your internals.
This also buys freedom. Callers only ever see a Summary, never the list, so you can change how a
ScoreBoard stores its scores — a different collection, a running tally, a database — and no caller
breaks, as long as you can still produce a Summary. That is the idea underneath encapsulation: hide
the decisions most likely to change, so the rest of the program does not depend on them.5
Aside — other languages. Every language has this problem; they differ in how consistent their rule is. In C# and Java, numbers are copied but lists are shared, and a "read-only" list is often a read-only view of the same list.6 In Go, structs are copied but slices and maps still share their contents.7 Rust forbids sharing and changing at the same time, and makes you write
.clone()for a copy.8 EK9 is closest to Rust in that a copy is always something you ask for by name, and it has no special cases: you make the same decision — share a reference, or hand over a copy — every time, for every type.
Note. When a class holds a cluster of plain data, you do not have to choose "hidden" or "open" for all of it. Put the plain data in its own record, hold that record as a field of the class, and keep the class's behaviour around it.
Try it yourself
- Give
Counterareset()method — but first decide whether it should have one. Who is allowed to reset a scoreboard mid-game? Hiding the data makes you answer questions like this on purpose. - Add a
lowest()toScoreBoard, kept correct inadd()the same waytopScoreis. Check the two stay in step whatever order scores arrive in. - Add the leaky
rawScores()toScoreBoard, reproduce the count changing underneath you, then replace it with a method that returns aSummaryand see the leak close.
Recap
A class is a record with its fields hidden and methods attached; a method is a function
that belongs to the aggregate and can see its fields. Use a class rather than a record when the data
has rules to protect: the fields are private, so every change goes through a method you control.
If a field has no sensible starting value, the no-argument constructor must be private. A class of
nothing but getters and setters is an anaemic class — a record in disguise. Because EK9 has no
primitives, returning a field hands back the real object, so a caller can change your internals:
representation exposure. Ask for a copy explicitly with the copy constructor
(Integer(count)), and to show internal state, return a new record filled with copies. That
protects your state and leaves you free to change the implementation behind a stable interface. Next,
Abstract Types, Subtypes and Polymorphism: one name standing for several kinds of thing, each
behaving in its own way.
-
Many languages offer a
protectedvisibility that lets subclasses reach a parent's fields. EK9 has nothing like it for state: class fields are always private. (Methods may be public — the default — orprotectedorprivate; only fields are private-only.) This is deliberate. Shared, subclass-visible state is how inheritance comes to break encapsulation and produce the "fragile base class": a subtype comes to depend on a parent's internals, and a later change to the parent breaks it. In EK9 a subtype can use a parent's methods, never its fields. Traits and Composition returns to this when it weighs inheritance against composition. ↩ -
The
<- rtn <- valueform lets the compiler infer the return type from the value; it is a shorter way to write<- rtn as Integer: value. It works when the type is obvious from a simple value: a construction likeInteger(count)orSummary(...), or a literal. A more involved expression, or a bare reference to a variable (<- rtn <- count), does not qualify (E04050); write the explicit<- rtn as Type:form there instead. ↩ -
Java, by contrast, has three cases to keep in mind: primitives such as
intare copied, so returning one is safe;Stringis an object but cannot be changed, so sharing it is also safe; but anArrayListor any ordinary changeable object is shared, so returning one leaks. Whether a getter is safe depends on which case the type falls into. TheCounterleak above could not happen with a Javaint; it can with any changeable Java object. ↩ -
The term representation exposure — and the remedy of never returning a reference to a changeable internal part — is from Barbara Liskov and John Guttag, Program Development in Java: Abstraction, Specification, and Object-Oriented Design (Addison-Wesley, 2000). Joshua Bloch puts the working attitude bluntly in Effective Java: "You must program defensively, with the assumption that clients of your class will do their best to destroy its invariants." ↩
-
David L. Parnas, "On the Criteria To Be Used in Decomposing Systems into Modules," Communications of the ACM 15, no. 12 (1972): 1053–1058. Parnas's principle of information hiding is that each module should hide a design decision likely to change, so that the decision can be revised without disturbing the rest of the system. ↩
-
In C#,
List<T>.AsReadOnly()and theIReadOnlyList<T>interface return a read-only view over the same underlying list, not an independent copy; the backing collection remains changeable. See Microsoft Learn, "IReadOnlyList<T> Interface" and "List<T>.AsReadOnly Method." ↩ -
In Go, slices and maps are reference-like: assigning or returning one copies a header that still points at the shared backing array or store, so changes through the copy are visible to the original. Defending requires an explicit
copy()(slices) or a manual clone (maps). See "Arrays, Slices and Maps" in Go 101. ↩ -
Rust enforces this at compile time through its borrow checker: data may be shared or changed but not both at once, so an outstanding reference to a field prevents that field being changed. An owned, independent value is obtained explicitly with
.clone(). See The Rust Programming Language, "References and Borrowing." ↩
In the EK9 reference: Classes in EK9, Dynamic Classes in EK9, Methods in EK9