Getting Started with EK9 · Chapter 9

Collections

So far each name has held a single value: one number, one price, one date. Real programs deal in many values at once: a shopping basket, a price list, the scores in a game, the words in a sentence. For that you need a collection, a value that holds other values. EK9 starts you with two that cover most needs: the List and the Dict.

A list of things

A List is an ordered sequence of values. You write one with square brackets:

basket <- ["Apples", "Bread", "Milk"]

That is a list of three Strings, in that order. You did not have to say "list of String": EK9 worked it out from the values, by type inference.

A list knows how many things it holds, whether it contains a particular value, and how to give you its items one at a time. You add to it with +=:

basket += "Cheese"

And you can go through it with a for loop:

for item in basket
  stdout.println(item)

A dictionary of pairs

Often you want to look something up: the price of an item, the phone number for a name. For that, EK9 has the Dict (short for dictionary): a set of key → value pairs. You write one with curly braces and colons:

prices <- {"Apples": 0.35#GBP, "Bread": 1.10#GBP, "Milk": 0.85#GBP}

Here each key is an item name (a String) and each value is its price (a Money). Given a key, the Dict gives back its value. The safe way to ask is getOrDefault, which takes the key and a fallback to use if the key is not there:

applePrice <- prices.getOrDefault("Apples", Money())

If "Apples" is in the dictionary you get its price; if not, you get the fallback. The fallback you choose matters. It is tempting to write 0#GBP, but that means "if we have no price for this, it costs nothing": a made-up answer. A missing price is not a free item, it is an unknown one. The truthful fallback is an unset Money(): "we do not have a price for that". getOrDefault is only as safe as the default you give it; a careless 0 turns a gap in your data into a wrong number.

Putting them together

Here is a shopping basket that uses both, and behaves like a real shop, where some things are not in stock. A List holds what you are trying to buy; a Dict holds the prices of what the shop sells. The program totals what is available and collects what is not:

#!ek9
defines module book.basket

  defines program
    Basket()
      stdout <- Stdout()
      enGB <- Locale("en_GB")

      prices <- {"Apples": 0.35#GBP, "Bread": 1.10#GBP, "Milk": 0.85#GBP}
      basket <- ["Apples", "Bread", "Kiwi", "Milk", "Mango"]

      labelWidth <- 8
      total <- 0#GBP
      unavailable <- List() of String
      for item in basket
        price <- prices.getOrDefault(item, Money())
        if price?
          total += price
          label <- item + ":"
          stdout.println(`${label.rightPadded(labelWidth)}${enGB.format(price)}`)
        else
          unavailable += item

      totalLabel <- "Total:"
      stdout.println(`${totalLabel.rightPadded(labelWidth)}${enGB.format(total)}`)
      stdout.println(`Not available: ${unavailable}`)

It prints:

Apples: £0.35
Bread:  £1.10
Milk:   £0.85
Total:  £2.30
Not available: ["Kiwi", "Mango"]

For each item the program looks up a price with the unset Money() default. If the price is set (the shop sells it), it is added to the total and printed. If it is not (Kiwi and Mango are not stocked), the item goes into an unavailable list, reported at the end. The total is the true total of what the customer can buy, and nothing is silently lost.

(label.rightPadded(labelWidth) pads a label with spaces to a set width, so every amount starts in the same column. There is a leftPadded too, for lining things up on the right.)

Because a missing price came back unset rather than 0#GBP, the program could tell it apart from a real price. A 0 default would have added nothing to the total for Kiwi and Mango, and hidden that they were missing.

An empty collection is set, not missing

A new, empty collection is set. It is a real, usable container that holds nothing, not "nothing". (There is a genuine "nothing here" case — asking a collection for an item it does not have — but that is a property of the lookup, not of the collection; Optional and Result returns to it.)

shopping <- List() of String     // a brand-new, empty list
stdout.println(`New list set? ${shopping?}  items: ${length shopping}`)

prints:

New list set? true  items: 0

An empty list is set, and length shopping — the length operator, written in front of the value, counts the items — gives 0. "An empty basket" and "no basket at all" are different situations, and EK9 keeps them different. You can pass an empty list around, add to it, and loop over it (zero times). If you have a List, you have a real list, even when it holds nothing yet.

How EK9 works out a collection's type

When you write a list or dictionary with values in it, EK9 has to decide what type it is.

The compiler looks at the values and tries to find one type that every element fits: a common type. When the elements agree, that is the answer:

words <- ["a", "b", "c"]        // List of String
counts <- [1, 2, 3]             // List of Integer
amounts <- [1.0, 2.0, 3.0]      // List of Float

The compiler is conservative. If the values do not share a clear common type, it does not guess; it stops with an error (E50030) that names the types it found:

mixed <- [1, "two", 3.0]     // no common type -> error E50030

The only type those three share is Any, which is so general that you can do almost nothing with the items. If a list of anything really is what you want, you must say so: mixed as List of Any: [1, "two", 3.0]. That is almost always a sign of a mistake: you meant the values to be one kind and they were not. So let a collection hold one kind of thing. If you want decimals, write decimals ([1.0, 2.0, 3.0]); if you want whole numbers, write whole numbers. A list's type is fixed once it is made — a List of Integer is not a List of Float — so when you need an empty list of a particular type, say so:

scores <- List() of Float      // an empty List of Float, ready to fill

A Dict works the same way, deciding a type for its keys and a type for its values separately, so {1: "quick", 2: "brown"} is a Dict of (Integer, String). The same advice — keep them consistent, or state the type — applies to each side.

Note. An empty literal cannot be inferred, because there are no values to look at, so x <- [] and x <- {} are syntax errors. Say what you want instead: List() of String, or Dict() of (String, Integer). This is the one time you must name the type.

The common type need not be a built-in like String or Integer. It can be a shared parent of your own making. If a Circle and a Rectangle are both kinds of Shape, then a list holding one of each is a List of Shape: one list, several kinds, all handled through the type they have in common. Working with many different types through one shared type is called polymorphism, and the chapter Abstract Types, Subtypes and Polymorphism covers it.

Where the promotion comes from

A whole number can sometimes stand in for a decimal. The reason is not a special rule buried in the compiler.

A whole number can become a decimal because the Integer type says so. It has a promotion operator, written #^, whose job is to answer "what wider type can I become?" For Integer, the answer is Float:

whole <- 5
asFloat <- #^ whole          // 5.0, an Integer promoted to a Float

Because that operator exists, EK9 promotes a whole number wherever a Float is wanted: in a calculation, or as an argument to something expecting a Float:

addHalf() as pure
  -> x as Float
  <- rtn as Float: x + 0.5

half <- addHalf(5)           // 5.5 - the 5 promotes to 5.0, then the work happens

Float has no #^ operator — there is no wider number for it to become — so promotion runs only one way: a whole number can rise to a decimal, but a decimal never silently drops to a whole number. Promotion is an operator a type either has or has not, and the compiler follows it, including when it works out the common type for a collection.

When you build your own types, you can give one its own #^ — "a Pennies can promote to Pounds", say — and it joins the same mechanism: promoted in calculations, accepted as arguments, consulted for a common type. EK9's conversions are operators you can read and, later, write yourself; Using Operators covers them.

Promotion is one of two ways a value can fit a wider type. Promotion converts: the 5 becomes a new 5.0 to fit where a Float is wanted, a different value of a different type. The other way needs no conversion. A Circle fits where a Shape is wanted because it already is one: the same object, unchanged. That is polymorphism. From the outside the two can look alike — a List of Float accepting a whole number, a List of Shape holding a Circle — but one makes a new value and the other uses the value it already has.

Straight to JSON

JSON is a common text format for exchanging data between programs. EK9 has a built-in JSON type, and any collection can turn itself into JSON with the $$ operator:

basket <- ["Apples", "Bread"]
stdout.println(`${$$ basket}`)

prints:

["Apples","Bread"]

Read $$ as "give me this as JSON", as $ means "give me this as text". A Dict becomes a JSON object, a List a JSON array, and the values inside come along:

prices <- {"apple": 35, "bread": 80}
asJson <- $$ prices              // {"apple":35,"bread":80}

You can read a field back out of a JSON value by name, with get:

apple <- asJson.get("apple")
stdout.println(`apple field: ${apple}`)
apple field: 35

So JSON is a type like any other: build it from a collection, read fields from it, pass it around. Text arriving from outside is parsed into a Result (introduced in Optional and Result), so a malformed document cannot slip past unnoticed; the second volume covers JSON in full.

Two very small collections

Two more container types belong to the same family; they are very small collections.

An Optional holds zero or one value: the smallest collection there is. It says "there might be a value here, and there might not", and it will not let you reach the value without first checking that it is there. A Result holds one of two things: the value if some work succeeded, or an error if it did not. It is how a function hands back "here is your answer" or "here is what went wrong" without a crash.

Both are about handling missing values and failures safely, so they have a chapter of their own, Optional and Result, after Guards and Safe Code. They share the family likeness of List and Dict: containers where "how many values are in here?" is a question the compiler helps you answer. (There is also a PriorityQueue, a collection that keeps its items in order of importance, useful in places such as scheduling.)

Try it yourself

  1. Build a Dict of three friends' names to their ages. Print each with a for loop. (Going through a Dict gives you each pair; read them with entry.key() and entry.value().)
  2. Look up a name that is not in your dictionary with getOrDefault. What default did you choose, and why?
  3. Make a new empty List of Integer, print its ? and length, then add three numbers and print them again.
  4. Write a mixed list like [1, "two"]. Read the error, then fix it by keeping the list to one type.

Recap

EK9's two core collections are the List, an ordered sequence of values ([...], +=, for item in list), and the Dict, a set of key → value pairs ({k: v}, getOrDefault). An empty collection is set, not missing, which keeps "empty" and "absent" apart, and a truthful default (an unset Money(), not a made-up 0) lets you see a gap instead of hiding it. EK9 infers a collection's type by finding one common type for the values, and refuses a mix that shares none unless you declare a List of Any. Number promotion comes from the #^ promotion operator, which your own types can define too. The JSON type comes from any collection with $$, and get reads a field back. Optional and Result are the smallest containers of all.

Next comes handling missing values safely: guards, and then the Optional and Result types.