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 parameter who, not owner, so there is no doubt which is which. If you want a parameter to share a field's name, this settles it: this.owner is the field and plain owner the parameter, so this.owner :=? owner is unambiguous. this is 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

  1. Give Counter a reset() 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.
  2. Add a lowest() to ScoreBoard, kept correct in add() the same way topScore is. Check the two stay in step whatever order scores arrive in.
  3. Add the leaky rawScores() to ScoreBoard, reproduce the count changing underneath you, then replace it with a method that returns a Summary and 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.

  1. Many languages offer a protected visibility 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 — or protected or private; 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. ↩

  2. The <- rtn <- value form 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 like Integer(count) or Summary(...), 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. ↩

  3. Java, by contrast, has three cases to keep in mind: primitives such as int are copied, so returning one is safe; String is an object but cannot be changed, so sharing it is also safe; but an ArrayList or any ordinary changeable object is shared, so returning one leaks. Whether a getter is safe depends on which case the type falls into. The Counter leak above could not happen with a Java int; it can with any changeable Java object. ↩

  4. 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." ↩

  5. 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. ↩

  6. In C#, List<T>.AsReadOnly() and the IReadOnlyList<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." ↩

  7. 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. ↩

  8. 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." ↩