Getting Started with EK9 · Chapter 21
Using Operators
You have used operators since the first chapter. + added numbers, == compared them, $ turned a
value into text, ? asked whether it was set, <=> sorted. Since Records and Enumerations you have
given a few to your own types, such as default operator ?. This chapter looks at the whole set at
once, because the way EK9 handles operators says a good deal about the language.
A fixed, named set
EK9 has a fixed catalogue of operators, each with a name and a settled meaning. You may give most of them to your own types (the comparisons are the exception: the compiler generates them, as you will see), but you cannot invent a new symbol.
The closed set is deliberate. Because + always means add and < always means "comes before" —
everywhere, in everyone's code — any operator you meet is one you already understand. There is no
private set of invented symbols to learn for each library; a newcomer can read every operator aloud and
reason about it. That keeps code readable, including the code you write today and read again next
year.1 Here is the catalogue, grouped by job (The Operator Reference appendix lists every
one):
- Comparing: the comparator
<=>(which returns "less, equal or greater" as one number), and<~>(a fuzzy, how-similar comparison). From a type's<=>the compiler generates<<=>>===<>; a type that can say "the same" but not "comes before" gives==instead, and gets<>from it. You never write the generated ones yourself. - Arithmetic:
+-*/^(power)modrem++--andabs,sqrt, and a-in front of a value to negate it (-n). - Turning into something:
$(to text),$$(to JSON),#^(promote, from Collections),#?(a hash code: a whole number worked out from the value, which collections such asDictuse to find values quickly; equal values must give equal hash codes). - Asking about state:
?(is it set?),empty,length,contains,matches. - Copying and merging:
:=:(copy),:~:(merge),:^:(replace). - Logic and the rest:
and,or,xor,not(which can also be written~), the stream pipe|, and a few more.
You have met most of these already. Gathered together, they make the point: a small vocabulary, each symbol chosen because it already means something to most programmers, and closed so that it stays that way.
Giving your own type operators
You add an operator to your type with the word operator, as you have seen for ?. Some you ask the
compiler to generate — default operator <=> — and some you write yourself. Here a small
two-dimensional vector gets its own + (add two vectors) and $ (how it prints), and default
operators for comparison and the set check:
#!ek9 defines module book.vectors defines record Vec2 x as Integer: Integer() y as Integer: Integer() Vec2() as pure -> initialX as Integer initialY as Integer x :=? Integer(initialX) y :=? Integer(initialY) operator + as pure -> other as Vec2 <- rtn as Vec2? require other? rtn: Vec2(x + other.x, y + other.y) operator $ as pure <- rtn as String: `(${x}, ${y})` default operator <=> default operator ? defines program Show() stdout <- Stdout() a <- Vec2(1, 2) b <- Vec2(3, 4) twin <- Vec2(1, 2) stdout.println(`${a} + ${b} = ${a + b}`) stdout.println(`a == twin? ${a == twin}`) stdout.println(`a == b? ${a == b}`)
(1, 2) + (3, 4) = (4, 6)
a == twin? true
a == b? false
operator + reads like a small method: it takes another Vec2 and returns a new one with the
components added, so a + b works on your type as + works on an Integer. The default operators
are the compiler writing the obvious version: <=> compares the fields, ? reports whether the record
is set. a == twin works although you never mentioned ==: <=> is how Vec2 says what "the same"
means, and the compiler generates ==, <>, <, <=, > and >= from it. You never write or
request those yourself (E07181 if you try), so they cannot drift out of step with <=>.
The require other? line states a precondition inside the operator: adding a vector only makes sense
if there is a vector to add, so other must be set. A record built from unset parts is itself unset
— Vec2(Integer(), Integer()), with no real numbers in it, answers ? with false — so
require other? catches it. Pass a set vector and the operator proceeds; pass an unset one and the
program stops at once, naming the failed requirement (it prints Requirement failed: `other?` ,
with the file, line and column) rather than computing a meaningless result. The precondition comes
first in the body, so it runs before any adding.
Write what is special, default the rest
Naming each operator — default operator <=>, default operator ?, and so on — works, but listing half
a dozen is tedious, and it is not what EK9 programmers write. Write default operator with no
symbol after it, and the compiler generates every operator it can work out for itself, and never
replaces an operator you wrote by hand. So a well-made type follows one rule: write the one or two
operators that carry your own judgement, and let default operator fill in the rest.
Here a Release record does exactly that. Only $ is written by hand, because "1.5" is our own choice
of how a version should read:
#!ek9 defines module book.versions defines record Release major as Integer: Integer() minor as Integer: Integer() Release() as pure -> initialMajor as Integer initialMinor as Integer major :=? Integer(initialMajor) minor :=? Integer(initialMinor) //Written by hand, because "1.5" is our own idea of how a Release reads operator $ as pure <- rtn as String: `${major}.${minor}` //And the compiler fills in every other operator it can work out for itself default operator defines program Show() stdout <- Stdout() versions <- List() of Release versions += Release(2, 0) versions += Release(1, 5) versions += Release(2, 3) sorted <- cat versions | sort | collect as List of Release for v in sorted stdout.println(`sorted: ${v}`) a <- Release(1, 5) b <- Release(2, 0) stdout.println(`a < b? ${a < b}`) stdout.println(`a > b? ${a > b}`) stdout.println(`as text: ${a}`) stdout.println(`as JSON: ${$$a}`)
sorted: 1.5
sorted: 2.0
sorted: 2.3
a < b? true
a > b? false
as text: 1.5
as JSON: {"major":1,"minor":5}
default operator gave Release the comparator <=>, which answers how two values relate as one
number (negative, zero or positive), and the comparisons generated from it, <, >, == and the
rest. <=> is what lets sort put a List of Release in order — 1.5, 2.0, 2.3 — with no
sorting code from you. It also gave the set check ?, a hash, and the JSON form $$. The one
operator the compiler did not touch is the $ you wrote, which is why the text form is 1.5.
That is the division of work default is for. The compiler is good at the mechanical operators —
compare the fields, hash the fields, turn the fields into JSON — and does them for you. It cannot
guess the ones that carry a judgement, such as how a version number should look. You write those.
Turning a value into text, and into JSON
Two conversions come up constantly, so each has its own operator. $ produces a text form — what
you want when you print a value or put it in a `...` string. $$ produces a JSON form — what
you want when a value leaves the program, for a file or a network. On Release, $ was written by
hand (1.5, the "major.minor" a person expects), while $$ came from default operator, built from
the fields:
{"major":1,"minor":5}
A value's human form is a presentation choice — 1.5, or £1.50, or a date in the local style — so
$ is usually yours to write. Its machine form follows from its structure, so the default $$ is
normally right. Two conversions, two operators, so there is never any doubt about which form you get.
Promotion: one more look at #^
In Collections an Integer became a Float wherever a Float was wanted — 5 became 5.0 —
through an operator, #^ (promote). Your own types can define it too. When EK9 finds a value of one type
where another is wanted, it looks for a single #^ that converts it, and calls it. Here a Celsius
promotes itself to a Fahrenheit Float:
#!ek9 defines module book.temperature defines record Celsius degrees as Float: Float() Celsius() as pure -> initialDegrees as Float degrees :=? Float(initialDegrees) operator $ as pure <- rtn as String: `${degrees}C` operator #^ as pure <- rtn as Float: (degrees * 9.0 / 5.0) + 32.0 default operator ? defines program Show() stdout <- Stdout() boiling <- Celsius(100.0) fahrenheit as Float: boiling stdout.println(`${boiling} is ${fahrenheit}F`) count <- 7 asFloat as Float: count stdout.println(`${count} promoted to ${asFloat}`)
100.0C is 212.0F
7 promoted to 7.0
fahrenheit as Float: boiling needs a Float, but boiling is a Celsius. EK9 finds Celsius's
#^, calls it, and gets 212.0 — the same mechanism that turns 7 into 7.0 a few lines later.
Promotion goes one step only: EK9 uses a single #^ to convert between two types, and never chains
promotions together looking for a route, so conversions stay short and predictable.2 Like the
fixed operator set, it is open to your own types but bounded.
Keep the meaning
The fixed set solves half the readability problem. The other half is yours: an operator you define
must keep the meaning of its symbol. + should combine, not send an email; < should order; $
should produce a faithful text form. It is tempting to give an operator a clever, surprising meaning,
but once + means something a reader would not guess, your code has become a puzzle.3
Keeping the meaning pays off: because Vec2 adds with + and compares with <, it works with
everything built on those operators — sorting, arithmetic, the collection type inference from
Collections — with no special cases.
The compiler helps. a == a — comparing a value with itself — does not compile (E08081): the answer
is known in advance, so it is almost certainly a slip. That is one case of a wider check: EK9 detects
tautologies — tests that are always true — and contradictions — tests that are always
false — from the obvious, like x or not x, to ones it works out by following how your data reached
the test. No compiler can catch every always-true expression; that is impossible in general. EK9
reports the ones it can prove, turning a family of quiet logic bugs ("this if can never run",
"this check always passes") into compile errors while they are cheap to fix.
One more check belongs here. A computational operator — +, <, $, contains — exists to
produce a value, so throwing the value away is dead code: call one as a statement and ignore what it
returns (a.+(b) on its own line, say) and EK9 stops you (E11050). It is the same "nothing unchecked,
nothing discarded" rule you met for Result and Optional. Mutating operators are the exception:
+=, ++ and :~: exist for the change they make, return nothing, and stand on their own.
The deeper half is in the second volume
There is more to operators than using them. An operator can be mutating (like ++, which changes its
value in place) or non-mutating (like +, which computes a new value and leaves its inputs alone),
and EK9 has an exact, enforced model of which is which and how that interacts with pure. That is the
subject of the operators chapter of the second volume. For everyday types, what you have here is enough:
use default where the obvious version will do, write your own where it will not, and keep every
symbol's meaning.
Recap
EK9 has a fixed, named catalogue of operators and does not let you invent new ones, so the
vocabulary stays small, shared and readable. You give your own type an operator with operator:
operator + with a body to write your own, or default operator <=> to have the compiler generate the
obvious version. The bare default operator generates every operator it can work out and leaves
your hand-written ones alone — write what carries your judgement, and default the rest. That gives
comparison and sorting (through <=>), a text form ($) and a JSON form ($$), the set check ? and
a hash; and #^ lets your type promote itself as Integer promotes to Float, one step and no casts.
Keep each symbol's established meaning, so a type with + and < works with everything built on
them. The compiler enforces the clear-cut cases: ==, < and the rest are always generated from the
type's one sameness operator, <=> or ==, and never written; a value cannot be compared with itself;
a computed result cannot be thrown away. The mutating and non-mutating model, and how operators work
with purity, wait for the second volume. Next, Programs, Input and Output: where a program meets the
outside world.
-
Languages differ in how far they open this door. C++ lets you overload its own fixed set of operators but not invent new ones; others — Haskell and Scala among them — let you define entirely new operator symbols (
>>=,|@|, and the like). That is a powerful facility, and in careful hands it reads well; its cost is that the same symbol can mean different things in different libraries, which asks more of a reader. Some later languages — Java and Go — left operator overloading out altogether. EK9 takes a middle path: the operators are yours to define on your own types, but the set is fixed and each symbol's meaning is settled. ↩ -
The one-step rule is a deliberate contrast with languages where several unwritten conversions can combine in one place. In C++, for instance, an implicit conversion sequence may combine a standard conversion with a user-defined one — which is powerful but can make it hard to see why a given expression type-checks. EK9 allows exactly one
#^step and no more, so a promotion is always a single, findable step. It is also why there is no cast syntax at all: every conversion is an operator, either written out or a single automatic#^, and so stays visible in the code. ↩ -
The EK9 language documentation puts the guidance directly: "It is very easy to become seduced by operators, to override and twist them into contrived meanings. When overriding an operator it is really worth taking the time to ensure it retains its semantics and consistency." ↩
In the EK9 reference: Advanced Class Methods in EK9, Design note: an operator keeps its meaning, EK9 Operators