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 <- []andx <- {}are syntax errors. Say what you want instead:List() of String, orDict() 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
- Build a
Dictof three friends' names to their ages. Print each with aforloop. (Going through aDictgives you each pair; read them withentry.key()andentry.value().) - Look up a name that is not in your dictionary with
getOrDefault. What default did you choose, and why? - Make a new empty
List of Integer, print its?andlength, then add three numbers and print them again. - 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.
In the EK9 reference: Collection Types