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 reportsE07235. 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:
- Text:
$(and${mon}inside a string) gives a value's name. - Comparing:
==for equality, and<and<=>for order. Values compare in the order you listed them, soMonday < Wednesday. - Ends:
#<gives the first value of the type and#>the last —MondayandSunday. - From text:
Day("Friday")builds aDayfrom a string. A name that is not in the list does not raise an error; it gives an unsetDay, and?reportsfalse.
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
- Add a
name as Stringfield toCoordand print it. Nothing else has to change. - Add a
TrafficLightenumeration (Red,Amber,Green) and a function that returns what a driver should do for each. (NotColour: that name belongs to the built-in type, and the compiler refuses it withE01090.) Name every value, and add adefaultas well: EK9 requires one even when every value is listed (E07320), because the value could be unset. - Add a fourth value,
Flashing, toTrafficLightand 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.
-
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 explicitas 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. ↩ -
Why copy, rather than store
initialX? AnIntegerin EK9 is an object held by reference. Keeping the caller's object would let the caller change thisCoord's field later, through their own variable. Taking a freshInteger(initialX)means theCoordowns 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. ↩ -
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. ↩
In the EK9 reference: Design note: records are anemic by design, Enumerations in EK9, Records in EK9