Getting Started with EK9 · Chapter 14

Records and Enumerations

Of the three ways to build, plain data needs the fewest new ideas, and the others build on it. This chapter makes your own records (bundles of data) and your own enumerations (a fixed set of named values).

A record is a bundle of data

A record groups a few named values into one thing. Here is a point on a grid — an x and a y, kept together:

#!ek9
defines module book.coord

  defines record
    Coord
      x as Integer: Integer()
      y as Integer: Integer()

      default Coord() as pure

      Coord() as pure
        ->
          initialX as Integer
          initialY as Integer

        x :=? Integer(initialX)
        y :=? Integer(initialY)

      operator $ as pure
        <- rtn as String: `(${x}, ${y})`

      default operator <=>
      default operator ?

  defines function
    moveTo()
      ->
        point as Coord
        toX as Integer
        toY as Integer

      point.x := toX
      point.y := toY

  defines program
    Show()
      stdout <- Stdout()

      origin <- Coord(2, 3)
      twin <- Coord(2, 3)
      faraway <- Coord(9, 9)
      stdout.println(`origin = ${origin}, its x is ${origin.x}`)
      stdout.println(`origin == twin?    ${origin == twin}`)
      stdout.println(`origin == faraway? ${origin == faraway}`)

      blank <- Coord()
      stdout.println(`a blank Coord is made   (set? ${blank?})`)
      moveTo(blank, 5, 6)
      stdout.println(`after moveTo: ${blank}     (set? ${blank?})`)

It prints:

origin = (2, 3), its x is 2
origin == twin?    true
origin == faraway? false
a blank Coord is made   (set? false)
after moveTo: (5, 6)     (set? true)

defines record opens the definition; Coord names it. Inside are its fields, x and y. Each is declared as Integer with a starting value of Integer(), an unset Integer: the field exists but holds no number yet — the middle of the three states.1 A record's fields are open: any code holding a Coord can read them with a dot, so origin.x gives 2. That openness is what a record is for: plain, visible data.

Building one in a single step

The program makes its points with Coord(2, 3), not by creating an empty record and filling in its fields one at a time. That is a constructor: setup code that takes the values a Coord needs and returns a finished one.

      Coord() as pure
        ->
          initialX as Integer
          initialY as Integer

        x :=? Integer(initialX)
        y :=? Integer(initialY)

It reads like a function: two Integers come in, and each is copied into a field. It stores Integer(initialX), a fresh copy, rather than initialX itself.2 The :=? is the assign-if-unset operator — "set this only if it is not already set". Each field starts unset, so :=? gives it its first value.

The constructor is marked as pure. Pure code may not reassign a value, so inside pure code you cannot make an empty Coord and then set .x and .y: the second step is a reassignment, and the compiler rejects it. A constructor avoids that by building the value complete, in one step. Use one whenever a value should arrive fully formed; in pure code it is the only way to build one.

Or build it blank, and fill it in later

Above that constructor is a one-line second one:

      default Coord() as pure

This is the default constructor: it takes no values. default asks for the standard version, which leaves every field as it starts — unset. So blank <- Coord() is a real Coord, with an x and a y, but neither holds a number. Ask whether it is set, and it answers false:

a blank Coord is made   (set? false)

That is the three-state model applied to your own type: a Coord can be set (built with values) or present but unset (built blank), and ? tells them apart.

The moveTo function fills in a blank one:

    moveTo()
      ->
        point as Coord
        toX as Integer
        toY as Integer

      point.x := toX
      point.y := toY

It writes the two numbers into the fields with :=. It does not build a new Coord; it changes the one it was given. That works because a Coord is passed by reference: moveTo and the caller hold the same record, so once moveTo sets the fields, the caller's Coord is set too:

after moveTo: (5, 6)     (set? true)

So there are two ways to build: a constructor that delivers a complete value, or a blank one you fill in afterwards. moveTo is not marked pure: it changes the record it is given, and only non-pure code may do that. Use build-complete in pure code; use build-blank-then-fill when a value really does arrive in pieces.

Giving data a little behaviour

The three declarations after the constructors are operators, the one kind of behaviour a record may have. A record cannot hold general methods (that is what classes are for), but it can define operators from a small, fixed set that almost every value needs. Here they do three jobs: printing, comparing and checking.

operator $ handles printing: how a Coord becomes text, (2, 3). It is the same $ that gives every built-in type its printed form; writing one for Coord is what lets ${origin} inside a string show the point.

default operator <=> handles comparing. default asks the compiler to generate the obvious version: compare the fields in order, which is what lets Coords be sorted. From that one comparison the compiler also generates ==, <>, <, <=, > and >=, so two Coords are equal exactly when <=> says they are the same. That is why origin == twin is true (both are (2, 3)) and origin == faraway is false. You did not write == or ask for it: a type says once what "the same" means, and every other comparison follows, so they cannot disagree. (Asking for default operator == beside <=> is an error, E07181.)

default operator ? handles checking: whether the record is set.

The Using Operators chapter covers the full set. The idea to keep: a record is data first, with just enough operators to print, compare and check itself like any other value.

One line for all the obvious ones

Naming every operator you want becomes tedious. Write default operator with no symbol after it, and the compiler generates every operator it can work out for itself, leaving alone any you wrote. Coord could end like this, with the same result:

      operator $ as pure
        <- rtn as String: `(${x}, ${y})`

      default operator

Read it as a statement of intent: how a Coord prints is my decision, so I write $; the rest is mechanical, so the compiler can have it. Write the one or two operators that carry your own judgement, and default the rest. This chapter's example spells each operator out so you can see them by name; later examples use the one-line form.

Note. Every aggregate that holds fields must define ?, so it can answer "am I set?" like every other value. default operator ? gives the standard one; leave it out and the compiler reports E07235. A definition also has a fixed order: fields first, then constructors and methods, then operators last.

Plain data is a virtue

Even with its operators, a Coord is a small, open bundle of values with no agenda of its own. Programmers call this a DTO, a data transfer object, and use it to carry related values from one place to another.

It is tempting to think that locking data inside an object is always the better design. Often it is not. A great deal of good software keeps its data plain and open, in records like this, and puts the work in the functions that use it — the style of the stream pipelines in Streams and Pipelines. Alan Perlis put it this way: it is better to have a hundred functions operate on one data structure than ten functions on ten of them.3 EK9 supports that style fully. A record and a set of functions is not a beginner's stage you grow out of; it is often the clearest design.

The Classes chapter shows the other path: a class, for data that must be protected rather than passed around freely. Neither is the "right" one, and you can combine them: a class can hold a record for its plain data and add behaviour around it.

Enumerations: a fixed set of names

In Making Decisions and Repeating Work we wrote a switch on a day of the week using the numbers 1 to 7. An enumeration does this properly: it is a type whose values are a small, fixed set of names — the days of the week, the suits in a pack of cards, the states of a traffic light:

#!ek9
defines module book.days

  defines type
    Day
      Monday
      Tuesday
      Wednesday
      Thursday
      Friday
      Saturday
      Sunday

  defines function
    kindOf() as pure
      -> day as Day
      <- rtn as String: "weekday"
      switch day
        case Day.Monday, Day.Tuesday, Day.Wednesday, Day.Thursday, Day.Friday
          rtn: "weekday"
        case Day.Saturday, Day.Sunday
          rtn: "weekend"
        default
          rtn: "no such day"

  defines program
    Week()
      stdout <- Stdout()
      for day in Day
        stdout.println(`${day} is a ${kindOf(day)}`)

which prints:

Monday is a weekday
Tuesday is a weekday
Wednesday is a weekday
Thursday is a weekday
Friday is a weekday
Saturday is a weekend
Sunday is a weekend

defines type with a name and an indented list of values declares the enumeration. You refer to its values by name — Day.Monday, Day.Saturday — never by number, so a Day can only ever be a real day. for day in Day loops over the whole set, and ${day} prints the name.

Day.Saturday says what it means; 6 did not. And a Day cannot hold 9, or a value from some other list.

An enumeration comes with operators

A record's operators had to be asked for. An enumeration gets a set of them the moment you declare it. You wrote only a list of names, yet all of this works:

      mon <- Day.Monday
      wed <- Day.Wednesday

      stdout.println(`as text: ${mon} and ${wed}`)
      stdout.println(`mon == wed?  ${mon == wed}`)
      stdout.println(`mon < wed?   ${mon < wed}`)
      stdout.println(`first day:   ${#< mon}`)
      stdout.println(`last day:    ${#> mon}`)

      fromText <- Day("Friday")
      stdout.println(`from "Friday": ${fromText}   (set? ${fromText?})`)

      nonsense <- Day("Blursday")
      stdout.println(`from "Blursday" set? ${nonsense?}`)

which prints:

as text: Monday and Wednesday
mon == wed?  false
mon < wed?   true
first day:   Monday
last day:    Sunday
from "Friday": Friday   (set? true)
from "Blursday" set? false

That is four kinds of behaviour:

Turning outside text — a line from a file, a word from a form — into a typed value is where programs meet bad input. An enumeration turns that into a set-or-unset question: build it, ask ?, carry on.

The switch that cannot forget

When you switch on an enumeration, the compiler insists you handle every value. Suppose kindOf listed only Day.Saturday in its weekend case and left out Day.Sunday (missingCase.ek9, in the error-examples folder). It would not build:

EK9     : Error   : E07310: 'switch' on line 18 position 6: should cover values: (Monday as Day, Tuesday as Day, Wednesday as Day, Thursday as Day, Friday as Day, Saturday as Day, Sunday as Day) : 'cases' should cover all enumerated values in 'switch'
             See: ek9 -h E07310

Our kindOf covers all seven days — the five weekdays in one case, the two weekend days in another — and adds a default for the remaining possibility: a Day can be unset, like any other value. The default is required even when the cases name every value; EK9 asks for one on every switch (E07320).

This matters most when the enumeration changes. Suppose next month you add Day.Holiday. In many languages every switch with a catch-all would carry on, treating a holiday as an ordinary weekday — a bug found only when someone notices. In EK9, every switch that does not handle the new value stops compiling, and each error points at a place that now needs a decision. "Remember to update every place" becomes a list of errors from the compiler, so a forgotten case cannot survive.

Try it yourself

  1. Add a name as String field to Coord and print it. Nothing else has to change.
  2. Add a TrafficLight enumeration (Red, Amber, Green) and a function that returns what a driver should do for each. (Not Colour: that name belongs to the built-in type, and the compiler refuses it with E01090.) Name every value, and add a default as well: EK9 requires one even when every value is listed (E07320), because the value could be unset.
  3. Add a fourth value, Flashing, to TrafficLight and build. Read the error, and follow it to the switch that no longer handles every case.

Recap

A record is an open bundle of named fields (defines record), read and set with a dot. It gets a little behaviour from operators: $ for its text form, and a default <=> from which == and the other comparisons are generated. A constructor builds a complete value in one step; the default constructor builds a blank one to fill in later. An enumeration (defines type) is a fixed set of named values that can never hold a wrong one, and it comes with $, ==, < and <=>, #< and #>, and safe build-from-text, with no default operator to write. A switch over an enumeration must handle every value, so adding a value turns "did I update every place?" into compile errors. Next, Constrained Types applies the same "a value cannot be wrong" idea to familiar types: a driving age that is an Integer, but never -5.

  1. A field can also be declared with <-, as an ordinary variable is — x <- Integer() — and the compiler infers the type. It is shorter, especially for collections, where the explicit form names the type twice (scores as List of Integer: List() of Integer) and the <- form names it once (scores <- List() of Integer). This book uses the explicit as Type: form for fields, so every field's type is written out while you learn the shapes; the second volume moves to the shorter form. ↩

  2. Why copy, rather than store initialX? An Integer in EK9 is an object held by reference. Keeping the caller's object would let the caller change this Coord's field later, through their own variable. Taking a fresh Integer(initialX) means the Coord owns its value. For the literals in this example — Coord(2, 3) — it makes no difference; the habit matters when a value comes from code that might change it. The Classes chapter gives the full reasoning, under the name representation exposure. ↩

  3. Alan Perlis, Epigrams on Programming (1982), epigram number 9: "It is better to have 100 functions operate on one data structure than 10 functions operate on 10 data structures." Perlis was the first recipient of the Turing Award, in 1966. ↩