Collections and Closures
Most programs spend their time on collections of things: lists of orders, maps of counts, buffers of bytes. This chapter covers Tessera's four collection types, List, Map, Bytes and Array, and the closures and pipelines that make working with them read like a description of what you want.
Lists
List[T] is a growable sequence of values of one type. You've used lists already; here are the most common operations in one place:
fn main(root: Root) {
let out = root.stdout
let mut temps = [18, 21, 17, 25]
temps.push(22)
out.print("{temps.len()} readings: {temps}")
out.print("first {temps[0]}, last {temps.last()}, tenth {temps.get(9)}")
out.print("sorted {temps.sort()}, reversed {temps.reverse()}")
out.print("lowest {temps.min()}, highest {temps.max()}")
out.print("first three {temps.take(3)}, the rest {temps.skip(3)}")
temps[1] = 20
out.print("after fixing one: {temps}")
for (i, t) in temps.with_index() {
if t > 20 {
out.print("reading {i} is warm: {t}")
}
}
}$ tessera run main.tsr
5 readings: [18, 21, 17, 25, 22]
first 18, last Some(22), tenth None
sorted [17, 18, 21, 22, 25], reversed [22, 25, 17, 21, 18]
lowest Some(17), highest Some(25)
first three [18, 21, 17], the rest [25, 22]
after fixing one: [18, 20, 17, 25, 22]
reading 3 is warm: 25
reading 4 is warm: 22
xs[i]reads an item and panics ifiis out of range;xs.get(i)returns anOptioninstead.first()andlast()areOptions too, since the list might be empty.pushappends in place, so it needs amutvariable. So does assigning to an item,xs[1] = 20.- Methods like
sort,reverse,takeandskipreturn a new list and leave the original alone.tempsis unchanged aftertemps.sort(). with_index()pairs each item with its position, for the times you need both.
Remember that lists are values: let b = a gives b its own list, and changing a afterwards doesn't touch it.
+ joins two lists into a new one, the items of the left list followed by those of the right:
fn main(root: Root) {
let defaults = ["--quiet", "--color"]
let given = ["--color", "--verbose"]
let flags = defaults + given
root.stdout.print("{flags}")
root.stdout.print("{defaults} and {given} are unchanged")
let mut queue = [1, 2]
queue = queue + [3, 4]
root.stdout.print("{queue}, distinct: {flags.distinct()}")
}+ examples/ch09-06-concat/main.tsr$ tessera run main.tsr
["--quiet", "--color", "--color", "--verbose"]
["--quiet", "--color"] and ["--color", "--verbose"] are unchanged
[1, 2, 3, 4], distinct: ["--quiet", "--color", "--verbose"]
Like + on strings, it leaves both lists as they were and makes a new one; queue = queue + [3, 4] keeps the result in the same variable. To add a single item to a mut list, push is the cheaper way, since it doesn't build a new list.
Maps
Map[K, V] maps keys to values. Here is the classic word count:
fn main(root: Root) {
let out = root.stdout
let text = "the cat saw the dog and the dog saw the cat"
let mut counts: Map[Str, Int] = map()
for w in text.split(" ") {
counts[w] = counts.get(w) ?? 0 + 1
}
out.print("{counts}")
out.print("\"the\" appears {counts["the"]} times")
out.print("\"bird\" appears {counts.get("bird") ?? 0} times")
counts.remove("and")
for (word, n) in counts.entries() {
out.print("{word}: {n}")
}
// the same count in one call
out.print("{text.split(" ").tally() == map([("the", 4), ("cat", 2), ("saw", 2), ("dog", 2), ("and", 1)])}")
}$ tessera run main.tsr
{"the": 4, "cat": 2, "saw": 2, "dog": 2, "and": 1}
"the" appears 4 times
"bird" appears 0 times
the: 4
cat: 2
saw: 2
dog: 2
true
map() makes an empty map, and map(pairs) one from a list of (key, value) tuples. m[k] = v inserts or replaces. m[k] reads, and panics if the key is missing, so m.get(k), which returns an Option, is the usual way to read a key that might not be there. counts.get(w) ?? 0 + 1 reads as "the count so far, or 0, plus one", because ?? binds tighter than +.
Maps keep their keys in insertion order. Iterating, printing and keys() all follow it, so a program's output never depends on how a hash table happened to be laid out, and is the same in both backends.
Keys can be Int, Str, Bytes, Bool, and tuples, structs and enums made of them, compared by value. Float can't be a key (NaN isn't even equal to itself), and neither can lists or maps.
Lists have helpers that build maps: tally() counts each item, count_by(key) counts by a key, and group_by(key) collects the items for each key.
Closures
A closure is a function written inline: |x| x * 2. You've been passing them to map and filter since chapter 3. Closures are values, like any other, and so are named functions:
fn apply_twice(f: fn(Int) -> Int, x: Int) -> Int {
f(f(x))
}
fn adder(n: Int) -> fn(Int) -> Int {
|x| x + n
}
fn main(root: Root) {
let out = root.stdout
let double = |x: Int| x * 2
out.print("{apply_twice(double, 5)}")
let add_ten = adder(10)
out.print("{apply_twice(add_ten, 5)}")
// a closure captures a copy of what it uses, when it is made
let mut base = 100
let from_base = |x: Int| base + x
base = 0
out.print("{from_base(1)} (base is now {base})")
// a block after the call's parentheses is a closure passed last
let big = [3, 14, 15, 92].filter { |x| x > 10 }
out.print("{big}")
}$ tessera run main.tsr
20
25
101 (base is now 0)
[14, 15, 92]
fn(Int) -> Int is the type of a function from Int to Int. apply_twice takes one, and adder returns one.
A function type can leave out -> R, just as a function declaration can: fn(Str) is a function that takes a Str and returns (). That's the type for a callback, called for what it does rather than for a value:
// Calls `visit` once for each word that is longer than `min`.
fn each_long_word(text: Str, min: Int, visit: fn(Str)) {
for word in text.split(" ") {
if word.len() > min {
visit(word)
}
}
}
fn main(root: Root) {
let out = root.stdout
let text = "a capability is a value that carries authority"
each_long_word(text, 5, |w| out.print("long: {w}"))
each_long_word(text, 8) { |w| out.print("{w} has {w.len()} letters") }
}fn(Str) examples/ch09-08-callbacks/main.tsr$ tessera run main.tsr
long: capability
long: carries
long: authority
capability has 10 letters
authority has 9 letters
each_long_word decides which words to visit, and the caller decides what visiting means. Here both closures print, using the out they captured, so the capability still comes from main, in plain sight. The second call passes its closure in the trailing form described below.
A closure captures the variables it uses, by value, when it is made. from_base copied base when it was created, so setting base = 0 later doesn't affect it. For the same reason, a closure can't assign to a variable from outside; if it could, which copy would change? To get a result out of a closure, return it.
When a closure is the last argument of a call, you can write it as a block after the parentheses: xs.filter { |x| x > 10 } is xs.filter(|x| x > 10). With no other arguments the parentheses can go. This trailing closure form is how catch { ... } from chapter 8 and g.go { ... } in chapter 13 are written.
Pipelines
With closures, a lot of loops become a chain of steps that each say what they do:
use text
struct Item {
name: Str,
price: Int,
qty: Int,
}
fn line_total(i: Item) -> Int {
i.price * i.qty
}
fn row(i: Item) -> Str {
text.pad_right(i.name, 10, ".") + text.pad_left("{line_total(i)}", 6, ".")
}
fn main(root: Root) {
let cart = [
Item { name: "apples", price: 3, qty: 4 },
Item { name: "bread", price: 2, qty: 1 },
Item { name: "cheese", price: 9, qty: 2 },
Item { name: "water", price: 1, qty: 0 },
]
let total = cart.map(line_total).fold(0, |a, b| a + b)
root.stdout.print("total: {total}")
let names = cart
.filter(|i| i.qty > 0)
.sort_by(|i| -line_total(i))
.map(|i| i.name)
root.stdout.print("{names}")
let report = cart
|> drop_empty
|> top(2)
|> render
root.stdout.print(report)
}
fn drop_empty(xs: List[Item]) -> List[Item] {
xs.filter(|i| i.qty > 0)
}
fn top(xs: List[Item], n: Int) -> List[Item] {
xs.sort_by(|i| -line_total(i)).take(n)
}
fn render(xs: List[Item]) -> Str {
xs.map(row).join("\n")
}$ tessera run main.tsr
total: 32
["cheese", "apples", "bread"]
cheese........18
apples........12
map transforms each item, filter keeps the items that pass a test, fold combines them into one value starting from an initial one, and sort_by sorts by a key. A chain of methods can continue on lines that start with ..
The pipe operator |> does the same for plain functions: x |> f(a) is f(x, a). The value on the left becomes the first argument of the call on the right. cart |> drop_empty |> top(2) |> render reads in the order things happen, where render(top(drop_empty(cart), 2)) reads inside out. A bare function name on the right, like drop_empty, is called with the value alone. Lines can start with |>, like method chains.
text.pad_right and text.pad_left come from the standard library's text module.
Pipelines aren't slower than loops. When the steps are closures that capture no capability, the native backend runs a chain like filter(...).map(...).fold(...) as one loop, without building the lists in between.
There are many more list methods: any, all, count, find, sum, min_by, max_by, flat_map, zip, chunks, windows, distinct, partition. The language specification, which tessera spec prints, lists them all.
Finding text in strings
A string isn't a collection type of its own, but you often search it like one. index_of finds where a piece of text first occurs, and last_index_of where it last occurs:
// Splits a path into its folder and its file name, at the last "/".
fn split_path(path: Str) -> (Str, Str) {
match path.last_index_of("/") {
Some(i) => (path.slice(0, i), path.slice(i + 1, path.len())),
None => ("", path),
}
}
fn main(root: Root) {
let path = "docs/guide/intro.md"
root.stdout.print("first /: {path.index_of("/")}")
root.stdout.print("last /: {path.last_index_of("/")}")
root.stdout.print("first ?: {path.index_of("?")}")
for p in [path, "README.md"] {
let (folder, file) = split_path(p)
root.stdout.print("folder \"{folder}\", file \"{file}\"")
}
}$ tessera run main.tsr
first /: Some(4)
last /: Some(10)
first ?: None
folder "docs/guide", file "intro.md"
folder "", file "README.md"
Both return the position as a byte offset in an Option, None when the text isn't there at all. Searching from the back is what you want for the last part of something: the file name after the last /, or the extension after the last .. split_path cuts the string with slice on either side of the slash, and handles the None case by treating the whole path as a file name.
Bytes and arrays
Text and binary data are different things, so Tessera keeps them apart. Bytes is immutable binary data:
fn checksum(data: Bytes) -> Int {
let mut sum = 0
for b in data.to_list() {
sum = (sum + b) % 256
}
sum
}
fn total[N](xs: Array[Int, N]) -> Int {
let mut t = 0
for x in xs {
t = t + x
}
t
}
fn main(root: Root) {
let out = root.stdout
let header = bytes([137, 80, 78, 71])
let data = header + "hello".to_bytes()
out.print("{data.len()} bytes: {data}")
out.print("byte 1 is {data[1]}, as text: {data.slice(1, 4).to_str()}")
out.print("checksum {checksum(data)}")
let rgb: Array[Int, 3] = [255, 128, 0]
let week: Array[Int, 7] = [1, 2, 3, 4, 5, 6, 7]
out.print("{rgb} sums to {total(rgb)}, {week} to {total(week)}")
}$ tessera run main.tsr
9 bytes: bytes([137, 80, 78, 71, 104, 101, 108, 108, 111])
byte 1 is 80, as text: PNG
checksum 130
[255, 128, 0] sums to 383, [1, 2, 3, 4, 5, 6, 7] to 28
bytes([137, 80, 78, 71]) builds Bytes from numbers 0 to 255, and s.to_bytes() and b.to_str() convert between text and bytes without copying. + concatenates, b[i] reads one byte, and slice cuts a piece. Keeping bytes out of Str means a buffer is never printed or concatenated as text by mistake.
Array[T, N] is a list of exactly N items, stored inline like a struct's fields: an RGB color is an Array[Int, 3]. A list literal of the right length becomes an array where one is expected. total[N] takes an array of any length; N is a type parameter that stands for a length, worked out at each call. Chapter 10 covers type parameters in general.
Summary
Lists and maps are values with a rich set of methods, + joins lists, maps keep insertion order, strings can be searched from either end, and Bytes and Array cover binary data and fixed sizes. Closures capture by value when they're created. Method chains and the pipe operator turn loops into readable pipelines, and the native backend fuses them into a single loop.
Next we'll write functions that work for many types: generics and interfaces.