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):

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.

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

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

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