Chapter 4 · Compound Types
The primitive types from the previous chapter are raw pieces.
To build real programs you glue them into structures:
aggregates with named fields, lists, tuples, and the two stdlib
types you’ll reach for more than any other, Option and
Result.
The sum types (type Tag = Foo | Bar(Int)) you saw in the
tour get a chapter of their own, chapter 5.
4.1 Records
A record is an aggregate with named fields. You declare it
with type and braces:
type Point = { x: Int, y: Int }
type Employee = {
name: String,
age: Int,
salary: Int,
}
The trailing comma is optional but idiomatic: adding a field doesn’t touch the previous line, so the diff stays clean.
To build a value, write the type name and the fields in braces:
let origin = Point { x: 0, y: 0 }
let p = Point { x: 3, y: 4 }
let ada = Employee { name: "Ada", age: 30, salary: 1500 }
To read a field, you use .:
println("p is at (#{p.x}, #{p.y})")
println("#{ada.name} is #{ada.age}")
That’s enough for the day-to-day. Three more details:
-
Records are immutable.
p.x = 7does not exist. If you need a point with one different field, you build a new one with the spread sugar:let p = Point { x: 3, y: 4 } let p2 = Point { ...p, x: 7 } # Point { x: 7, y: 4 }...pcopies all fields ofp; the named initializers after it (herex: 7) override what they repeat. It’s the list spread of §4.3, applied to records. -
Records are nominal.
Point { x: Int, y: Int }and anotherPosition { x: Int, y: Int }are distinct types even though they have the same shape. I wanted it that way: if you want a position, say position. -
Spread has rules. Only one spread per literal, and it must come first —
Point { x: 7, ...p }is a parse error. The initializers that follow must be named (x: expr), not punned or positional. These are deliberate: they make it obvious who wins on a duplicate.
Declaration, construction, access and functional update in one program:
# examples/ch04/01_records.kai
type Point = { x: Int, y: Int }
type Employee = {
name: String,
age: Int,
salary: Int
}
fn distance(a: Point, b: Point) : Int {
let dx = a.x - b.x
let dy = a.y - b.y
dx * dx + dy * dy
}
fn main() {
let origin = Point { x: 0, y: 0 }
let p = Point { x: 3, y: 4 }
println("origin.x = #{origin.x}, origin.y = #{origin.y}")
println("p at (#{p.x}, #{p.y})")
println("distance² = #{distance(origin, p)}")
# Functional update with `...`: copies all fields and
# overrides the named ones.
let p2 = Point { ...p, x: 7 }
println("p2 at (#{p2.x}, #{p2.y})")
let ada = Employee { name: "Ada", age: 30, salary: 1500 }
println("#{ada.name} is #{ada.age}, salary #{ada.salary}")
# Promotion: same employee, new salary.
let ada_post = Employee { ...ada, salary: 2200 }
println("#{ada_post.name} now earns #{ada_post.salary}")
}
$ kai run examples/ch04/01_records.kai
origin.x = 0, origin.y = 0
p at (3, 4)
distance² = 25
p2 at (7, 4)
Ada is 30, salary 1500
Ada now earns 2200
4.1.1 Private fields
Record fields are public by default: any module that imports
the type can read them and name them in a literal. priv in
front of a field name flips that default:
# module `safe`
pub type Account = {
name: String, # public by default
priv balance: Real, # private to module `safe`
}
pub fn open(name: String) : Account =
Account { name: name, balance: 0.0 }
pub fn deposit(a: Account, amount: Real) : Account =
Account { ...a, balance: a.balance + amount }
pub fn balance_of(a: Account) : Real = a.balance
From inside the safe module, the balance field reads and
writes like any other. From outside, it doesn’t:
import safe
fn main() {
let a = safe.open("savings")
println("#{a.name}") # OK, `name` is public
println("#{safe.balance_of(a)}") # OK, going through the getter
# The next two lines don't compile:
# println("#{a.balance}") # ← field `balance` of `Account` is private to module `safe`
# let d = safe.Account { # ← cannot construct `Account` from outside …
# name: "x",
# balance: 1000.0,
# }
}
The rule is strict: no external reads, no mention inside a
construction literal. If module safe wants others to create
accounts, it exposes constructors (open) and operations
(deposit) that preserve the invariants; the raw field stays
hidden.
The record becomes a lightweight abstract type: public shape, interior under the author’s control. You’ll use it that way in ch. 17 to hide the store actor’s state, and in ch. 18 so ledger balances can’t be constructed from outside the domain module.
The type with a private field and its operations, in a single
file. Inside the module that declares it, priv restricts
nothing: the rejected lines above fail only from another
module, and ch. 8 shows them with two files.
# examples/ch04/06_priv.kai
type Account = {
name: String,
priv balance: Real
}
fn open(name: String) : Account =
Account { name: name, balance: 0.0 }
fn deposit(a: Account, amount: Real) : Account =
Account { ...a, balance: a.balance + amount }
fn balance_of(a: Account) : Real = a.balance
fn main() {
let a0 = open("savings")
let a1 = deposit(a0, 100.0)
let a2 = deposit(a1, 50.0)
println("#{a2.name}: #{balance_of(a2)}")
}
$ kai run examples/ch04/06_priv.kai
savings: 150
4.2 Field access and destructuring
For one or two fields, . is fine. When you need several at
once, destructuring is cleaner:
fn distance_squared(a: Point, b: Point) : Int {
let Point { x: ax, y: ay } = a
let Point { x: bx, y: by } = b
let dx = ax - bx
let dy = ay - by
dx * dx + dy * dy
}
When the field names work as variables, drop the : and each
field binds to a variable of the same name:
fn describe(p: Point) : String {
let Point { x, y } = p
"(#{x}, #{y})"
}
Destructuring pays off most in match, where you decide what
to do based on the values of the fields:
fn classify(p: Point) : String =
match p {
Point { x: 0, y: 0 } -> "origin"
Point { x: 0, y: _ } -> "Y axis"
Point { x: _, y: 0 } -> "X axis"
Point { x, y } -> "point at (#{x}, #{y})"
}
The compiler checks exhaustiveness, just like with sum types.
Over Int fields it’s covered by the final Point { x, y }
arm, which matches any Point; remove that arm and it stops
compiling.
The three ways of opening a record, one after the other:
# examples/ch04/02_destructuring.kai
type Point = { x: Int, y: Int }
fn describe(p: Point) : String {
let Point { x, y } = p
"(#{x}, #{y})"
}
fn classify(p: Point) : String =
match p {
Point { x: 0, y: 0 } -> "origin"
Point { x: 0, y: _ } -> "Y axis"
Point { x: _, y: 0 } -> "X axis"
Point { x, y } -> "point at (#{x}, #{y})"
}
fn main() {
println(describe(Point { x: 3, y: 4 }))
println(classify(Point { x: 0, y: 0 }))
println(classify(Point { x: 0, y: 7 }))
println(classify(Point { x: 5, y: 0 }))
println(classify(Point { x: 2, y: 9 }))
}
$ kai run examples/ch04/02_destructuring.kai
(3, 4)
origin
Y axis
X axis
point at (2, 9)
4.3 Lists
A list is an immutable, linked sequence of values of the same
type. The type is written with brackets around the element
type: [Int], [String], [Point], [[String]] (list of
lists).
Building literals:
let primes = [2, 3, 5, 7, 11]
let empty: [Int] = []
kaikai also has range literals, which the type system treats as just another list:
let r1 = [1..10] # [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
let r2 = [1, 3..10] # [1, 3, 5, 7, 9]
let r3 = [10, 9..1] # [10, 9, 8, ..., 1]
The last two carry a step, and the step isn’t written: it’s read
off the first two elements. [1, 3..10] moves by two because
3 - 1 is two, and [10, 9..1] counts down because the
subtraction comes out negative. It’s the Haskell form, and it has
one advantage over annotating the step separately: the first two
elements you read are the ones you get.
One cost detail: the range is lazy. The
runtime stores it as three numbers (start, end, step), not ten
cells, and elements are generated only when something consumes
them. [1..1_000_000] |> list.sum never materializes a million
cells; to your code it is indistinguishable from the handwritten
list.
To extend an existing list, use ... (spread):
let xs = [1, 2, 3]
let ys = [0, ...xs, 99] # [0, 1, 2, 3, 99]
Unlike record spread, list spread takes any number of
spreads, anywhere in the literal: [...a, ...b] works, and so
does [...a, 9, ...b, 10].
Reach for it when you’re putting an element on the front. The
exact same list comes out of [h] ++ t:
fn prepend(h: Int, t: [Int]) : [Int] = [h, ...t]
fn prepend_concat(h: Int, t: [Int]) : [Int] = [h] ++ t
[h, ...t] says “a list starting with h and continuing with
t” — the same shape as the pattern you’ll take it apart with
later — and emits the cons directly. [h] ++ t says “glue these
two lists”, and to do that it builds a one-element list whose
only purpose is to be glued: one allocation per call.
Measuring it has a catch. Put both forms in a bench whose body
discards the result and each iteration costs less than the
clock’s resolution, so the report hands you noise (§7.4 shows
how to spot it). With the result consumed, built with
--release, three million iterations over an eight-element
tail, on my machine: 0.05 s for the spread against 0.11 s for
the concat.
kai lint flags the second form as
list_concat_literal_to_spread.
None of which is an argument against ++. Concatenating two
lists that both already exist is exactly what it’s for.
To take them apart, the patterns of match:
fn sum(xs: [Int]) : Int =
match xs {
[] -> 0
[h, ...t] -> h + sum(t)
}
[] matches the empty list. [h, ...t] matches any non-empty
list, binding h to the first element (“head”) and t to the
rest (“tail”). Between them they cover every case, so the
match is exhaustive.
More specific patterns are also legal:
match xs {
[] -> "empty"
[only] -> "one: #{only}"
[first, second, ...] -> "at least two"
}
[] and [h, ...t] already cover any list; to single out
“exactly one element”, write [only] before the catch-all.
A language convention: if the tail matters, give it a
name — [h, ...t] and then use t. If it doesn’t, write
... alone, no name — [h, ...]. It’s the difference
between “I take the head, the rest I keep for later” and “I
take the head, the rest I discard”. An invented name that
goes unused is noise; the bare form states the intent without
one.
For indexed access, the stdlib exposes list.nth:
let first = list.nth(xs, 0) # Option[Int]
let third = list.nth(xs, 2) # Option[Int]
Notice the return type: Option[a], not a. Ask for
element 99 of a list of three and there is no value to return;
the type forces you to consider it. It’s the same idea as
Option and Result, which you’ll put to work in §4.5:
kaikai prefers to enclose the possibility of failure in the
type rather than abort at runtime.
Two more things about indexed access. It is O(n) — lists
are linked, not indexed; walking to position i costs i
steps. For random access there are two other types: Vec[T],
coming up in a moment, and Array[T], which you’ll meet in
chapter 12.
And the syntax xs[i] that some languages use for lists is a
type error on a list — indexing is for Vec and Array.
xs[i] suggests cheap, guaranteed access, which would be
lying about a linked list.
For most code, you don’t want to index by hand anyway.
Recursion over [h, ...t] or the higher-order functions of
chapter 6 (map, filter, reduce) are the natural way to
process lists.
Lists are immutable. There is no xs[0] = 99. If you
need a modified list, you build a new one.
Construction, ranges, recursive traversal and patterns in a single program:
# examples/ch04/03_lists.kai
fn sum(xs: [Int]) : Int =
match xs {
[] -> 0
[h, ...t] -> h + sum(t)
}
fn length[a](xs: [a]) : Int =
match xs {
[] -> 0
[_, ...t] -> 1 + length(t)
}
fn main() {
let primes = [2, 3, 5, 7, 11]
let r = [1..10] # [1, ..., 10]
let evens = [2, 4..20] # [2, 4, ..., 20]
let combo = [0, ...primes, 13] # spread
println("primes: length=#{length(primes)}, sum=#{sum(primes)}")
println("r: length=#{length(r)}, sum=#{sum(r)}")
println("evens: length=#{length(evens)}, sum=#{sum(evens)}")
println("combo: length=#{length(combo)}, sum=#{sum(combo)}")
}
$ kai run examples/ch04/03_lists.kai
primes: length=5, sum=28
r: length=10, sum=55
evens: length=10, sum=110
combo: length=7, sum=41
Vec[T] — indexing without giving up values
When you genuinely need to index, the answer is Vec[T]: a
flat, contiguous buffer with O(1) access that behaves like
a value. Not one of its operations carries Mutable in its
row.
The operations live in collections.vec and you call them
qualified. They are the ones you’d expect: vec.get, vec.set,
vec.push, vec.length, vec.slice, vec.map,
vec.flat_map, vec.filter, vec.foldl, vec.from_list and
vec.to_list. The a[1] in the example is shorthand for
vec.get(a, 1). The pipes of chapter 6 run over a Vec just
as they do over a list: | dispatches to vec.map, || to
vec.flat_map, |? to vec.filter and |! to vec.each.
A list literal in a Vec position builds the vector directly,
with no linked list in between. And the interesting part is how
they write: set and push return the resulting vector
rather than modifying the one they get.
# examples/ch04/11_vec.kai
import collections.vec
fn main() : Unit / Stdout = {
let a: Vec[Int] = [10, 20, 30]
println("a[1] = #{a[1]}, same as vec.get(a, 1) = #{vec.get(a, 1)}")
# `set` and `push` return the resulting vector; `a` is untouched.
let b = vec.push(vec.set(a, 1, 99), 40)
println("a = #{vec.to_list(a)}")
println("b = #{vec.to_list(b)}")
}
$ kai run examples/ch04/11_vec.kai
a[1] = 20, same as vec.get(a, 1) = 20
a = [10, 20, 30]
b = [10, 99, 30, 40]
The annotation is what decides: that same bare [10, 20, 30],
without the : Vec[Int], is still a linked list.
Notice that a didn’t change. That’s the guarantee, and it has
no exceptions: no name pointing at a Vec ever sees what another
one writes.
The guarantee isn’t paid for with a copy per write. The runtime
checks whether the buffer is shared — the same reference
counting as chapter 13 — and writes in place when it has a
single owner. In the example a is used after the set (the
next line prints it), so at the moment of the write it is shared
and the write copies. Thread the vector linearly
instead, accumulator style, where each step is the sole owner of
what it receives, and nothing is copied: building it asks for
memory once.
A slice is a constant-cost view: a[1..2] copies no elements, it
shares the buffer. Both ends are included, as in a range literal.
And Array[T], which shows up in chapter 12, is the other
face: the mutable reference, where writes are observable
through every alias and each one demands / Mutable. The rule
is short: Vec when you want a collection that behaves like a
value, Array when you want genuine shared mutation.
4.4 Strings, not lists of chars
Many languages mix these two up. In kaikai, a String is
not a list of Char; they are distinct types:
let s: String = "hi"
let cs: [Char] = ['h', 'i']
s and cs are not interchangeable. You can’t write s[0]
expecting a Char, and you can’t pass a String where
[Char] is expected.
In Unicode there is no simple correspondence between “character” and “index”. An emoji can occupy several codepoints; an accented letter may have one or two representations; a grapheme can skip bytes and codepoints arbitrarily. Treating a string as a list of chars forces a decision about what counts as “a character” — and every decision is wrong for some case.
Internally, a String is a UTF-8 buffer. Its operations live
in the stdlib’s string module, which makes explicit a
distinction many languages paper over: bytes versus
Unicode codepoints. The moment you leave ASCII they differ,
and each function’s name tells you which unit it works in.
Measure "café" all four ways:
length(s)counts bytes, in O(1). For"café"it returns 5, not 4: theétakes two bytes in UTF-8.byte_length(s)is the same number with the unit in the name, for when the code reads better if nobody has to assume.char_count(s)counts Unicode codepoints. For"café"it returns 4 — the length a reader would say aloud.chars(s)decodes the buffer and hands back the codepoints as[Char];from_charsputs them back together.bytes(s)hands back the raw octets as[Byte], one per byte, splitting any multibyte codepoint into pieces;from_bytesis its inverse.
Notice that the two views have different types: [Char] is
characters, [Byte] is octets, so you cannot cross them by
accident — handing from_chars the result of
bytes(s) does not compile, because an octet is not a character
and the compiler knows it.
# examples/ch04/07_strings.kai
import core.string
import core.list
fn main() {
let s = "café"
println("bytes: #{string.length(s)}") # 5
println("codepoints: #{string.char_count(s)}") # 4
println("chars: #{list.length(string.chars(s))}") # 4
println("bytes list: #{list.length(string.bytes(s))}") # 5
println("round trip: #{string.from_bytes(string.bytes(s))}")
println("slice(2,2): #{string.slice(s, 2, 2)}")
println("byte_slice(2,3): #{string.byte_slice(s, 2, 3)}")
println("byte_slice(2,2): #{string.byte_slice(s, 2, 2)}")
}
$ kai run examples/ch04/07_strings.kai
bytes: 5
codepoints: 4
chars: 4
bytes list: 5
round trip: café
slice(2,2): fé
byte_slice(2,3): Some(fé)
byte_slice(2,2): None
The last three lines show the two ways to take a piece out of a string, which differ in the unit:
slice(s: String, from: Int, len: Int) : String
byte_slice(s: String, from: Int, len: Int) : Option[String]
slice counts codepoints. Taking "fé" out of "café" is
asking for two characters from index 2, counting from zero:
slice(s, 2, 2). Overrun the length and it clamps rather than
failing — slice(s, 1, 9) gives "afé" — and it can never hand
you a broken string, because it does not know how to cut a
character in half.
byte_slice counts bytes, which is what you need when the
offsets come from somewhere else: an index_of, a parser, a
protocol that measures in octets. Since any byte can land inside a
character, it returns Option: byte_slice(s, 2, 3) gives
Some("fé") because 2 and 5 are codepoint boundaries, and
byte_slice(s, 2, 2) gives None because 4 sits in the middle of
the é. If you would rather snap the offset than have it refused,
that is what is_char_boundary, floor_char_boundary and
ceil_char_boundary are for.
The mental rule is short: the name tells you the unit.
length, byte_length, bytes and byte_slice speak bytes;
char_count, chars and slice speak codepoints. Search comes in
the same pairs: index_of returns a byte offset, which feeds
byte_slice, and char_index_of returns a codepoint index, which
feeds slice.
That length counts bytes while slice counts codepoints is not a
lapse in coherence. length is O(1) and can’t break anything; a
cut by byte can. Each operation keeps the unit that won’t cost you.
(Graphemes like an “é” built from e plus a combining accent are
yet another layer; there even codepoints fall short, but you rarely
need them.)
The same care shows up in case folding. core.char ships
to_upper, to_lower, is_upper and is_lower for ASCII. Its
sibling core.char_unicode extends all four to the alphabets
whose mapping is a uniform codepoint offset: Latin-1, Latin
Extended-A, Greek and Cyrillic. Whatever falls outside that rule
passes through untouched, and kaikai would rather tell you so in
the module’s name than pretend to full Unicode coverage:
import core.char
import core.char_unicode
import core.list
import core.string
fn upper(s: String) : String =
string.from_chars(list.map(string.chars(s), char_unicode.to_upper))
fn main() : Unit / Stdout {
Stdout.print(upper("café"))
Stdout.print(upper("привет"))
Stdout.print(upper("omega"))
Stdout.print(upper("ωμεγα"))
Stdout.print(upper("ωμέγα"))
Stdout.print(upper("☃ 42"))
Stdout.print("#{char.to_upper('k')}")
}
upper is all it takes: decode the string to codepoints with
chars, apply to_upper to each one with map, and put the string
back together with from_chars. The last line reaches for
core.char rather than core.char_unicode, because ASCII needs no
more than that.
$ kai run examples/ch04/10_case_folding.kai
CAFÉ
ПРИВЕТ
OMEGA
ΩΜΕΓΑ
ΩΜέΓΑ
☃ 42
K
The ΩΜέΓΑ line marks the edge: ωμεγα folds whole, while in
ωμέγα the έ stays down, because its uppercase form does not sit
at a fixed offset. Full Unicode folding needs tables, and those do
not live in the stdlib yet.
To concatenate, use ++, as in chapter 3:
let greeting = "Hello, " ++ name
For interpolation, #{...} inside a literal "...".
++ is fine for joining two or three pieces. But beware of
building a large string by gluing fragments in a loop: because a
String is immutable, every ++ copies the whole accumulator,
which lands you in O(n²) — the classic quadratic of
concatenation.
If what you have is a list of strings and a separator, it is
already solved: string.join walks the list once and leaves no
separator dangling at the end.
string.join(["ana", "ben", "cleo"], ", ") # "ana, ben, cleo"
StringBuilder is for the other case: when the result is
assembled from pieces that are not a uniform list. It is a text
accumulator that holds the fragments as you add them and only
joins them at the end, in a single pass, with build. Appending
is amortized O(1), and the whole assembly is O(n).
import string_builder as sb
import core.list
import core.string
type Row = { name: String, points: Int }
fn line(r: Row) : String =
"\n#{string.pad_right(r.name, 8, ' ')}#{r.points}"
fn report(rows: [Row]) : String {
let total = list.foldl(rows, 0, (acc, r) => acc + r.points)
let header = sb.append(sb.new(),
"#{list.length(rows)} rows, #{total} points")
let body = list.foldl(rows, header, (b, r) => sb.append(b, line(r)))
sb.build(body)
}
$ kai run examples/ch04/09_string_builder.kai
3 rows, 40 points
ana 12
ben 7
cleo 21
The header comes out of a separate computation and each line out
of a row: different sources, and join does not cover them. The
builder gathers them without recopying what it already holds. The
newline goes at the front of each row so the result does not end
in one.
append rides the Mutable effect — internally it writes into
the builder’s fragment array — while build is pure: it only reads
and joins. Notice that report declares no / Mutable in its
signature even though it drives append: since the builder is born
and dies inside, never escaping, kaikai masks the effect at the
function boundary. The rest of the API (new, with_capacity,
append_char, length, is_empty) is in kai doc string_builder.
4.5 Option and Result: the daily tools
You met these two in chapter 2:
type Option[a] = None | Some(a)
type Result[a, e] = Ok(a) | Err(e)
Now you put them to work. They are generic sum types from the
stdlib that you will use constantly. Option says “there
might be no value”; Result, “there is either a value or an
error”.
A function that finds the first even element of a list:
fn first_even(xs: [Int]) : Option[Int] =
match xs {
[] -> None
[h, ...t] -> if h % 2 == 0 { Some(h) } else { first_even(t) }
}
And the caller has to consider both cases explicitly:
match first_even(xs) {
Some(n) -> println("found: #{n}")
None -> println("no evens")
}
A function that parses an age from a string can fail in two
distinct ways — and that’s exactly what Result is for:
type AgeError = NotNumeric | OutOfRange
fn parse_age(s: String) : Result[Int, AgeError] =
match string.to_int(s) {
None -> Err(NotNumeric)
Some(n) ->
if n < 0 or n > 130 { Err(OutOfRange) }
else { Ok(n) }
}
Result[Int, AgeError] reads as “an Int or an error of
type AgeError”. The successful value is in the first parameter
and the error in the second, as in Rust and most modern
languages.
Four patterns you’ll see often:
- Chain a failure with
!. If you have an expression that returnsResult[A, E]and you want “if it fails, propagate the error; otherwise continue with the value”, you writeexpr!, as in §2.3. - A default with
??.o ?? dis whateverocarries when it isSome, anddwhen it isNone. The right side runs only in theNonecase, sofirst_even(xs) ?? compute()computes nothing when it found something. It is forOption; aResultgoes in asok(r) ?? d. - Higher-order functions:
option.map,option.and_then,result.map_err. They arrive in chapter 6, and so do the pipes, which run overOptionandResultjust as over a list:Some(2) | doubleisSome(4). - Convert between the two:
option.ok_or(error)takes anOption[a]and an error of typeeand returns aResult[a, e]. Useful when the information lost byNoneisn’t enough.
Option and Result are sum types like any other. I pulled
them out of chapter 5 because their role in day-to-day design
is central — you’ll use them before you start declaring your
own sum types.
Both types at work: a search that may find nothing, and a validation that explains why it failed:
# examples/ch04/04_option_result.kai
type AgeError = NotNumeric | OutOfRange
fn first_even(xs: [Int]) : Option[Int] =
match xs {
[] -> None
[h, ...t] -> if h % 2 == 0 { Some(h) } else { first_even(t) }
}
fn parse_age(s: String) : Result[Int, AgeError] =
match string.to_int(s) {
None -> Err(NotNumeric)
Some(n) ->
if n < 0 or n > 130 {
Err(OutOfRange)
} else {
Ok(n)
}
}
fn describe_age(s: String) : String =
match parse_age(s) {
Ok(n) -> "valid age: #{n}"
Err(NotNumeric) -> "not a number"
Err(OutOfRange) -> "out of range"
}
fn main() {
let xs = [1, 3, 5, 6, 7]
match first_even(xs) {
Some(n) -> println("first even: #{n}")
None -> println("no evens")
}
println(describe_age("42"))
println(describe_age("hello"))
println(describe_age("999"))
}
$ kai run examples/ch04/04_option_result.kai
first even: 6
valid age: 42
not a number
out of range
4.6 Tuples
A tuple is a positional aggregate: like a record, but without field names. The syntax is parentheses with elements:
let point2d = (3, 4)
let trio = ("Ada", 30, true)
The type is written the same way: (Int, Int), (String, Int, Bool).
kaikai admits tuples of arity 2 to 4. There are no
1-element tuples — a single parenthesis, (e), is grouping,
not a tuple. And (a, b, c, d, e) is a parse error.
The bound is there because long tuples are unreadable. If you’re hitting 5 elements, almost always what you wanted was a record with named fields.
To take a tuple apart, destructuring:
fn divmod(a: Int, b: Int) : (Int, Int) = (a / b, a % b)
fn main() {
let (quotient, remainder) = divmod(17, 5)
println("17/5 = #{quotient}, remainder #{remainder}")
}
Internally, tuples are sugar over three records from the
stdlib: Pair[A, B] for arity 2, Triple[A, B, C] for 3,
Quad[A, B, C, D] for 4. The declaration
let p = (1, 2)
is exactly equivalent to
let p = Pair { fst: 1, snd: 2 }
So when you see Pair[Int, String] in a stdlib signature, you
know it’s (Int, String) and that let (a, b) = p
destructures it.
A function that returns two values, and a caller that splits
them in the let:
# examples/ch04/05_tuples.kai
fn divmod(a: Int, b: Int) : (Int, Int) = (a / b, a % b)
fn main() {
let (quotient, remainder) = divmod(17, 5)
println("17 / 5 = #{quotient}, remainder = #{remainder}")
# A 3-tuple
let point3d = (1, 2, 3)
let (x, y, z) = point3d
println("(#{x}, #{y}, #{z})")
}
$ kai run examples/ch04/05_tuples.kai
17 / 5 = 3, remainder = 2
(1, 2, 3)
Tuple or record?
When in doubt, use a record. Tuples are convenient for
cases where names don’t add — the result of divmod, where
“the first value is the quotient and the second the
remainder” is information the reader already gets from the
function name. Or when the aggregate lives one step:
xs |> map((e) => (e.name, e.age))
But as soon as the same aggregate appears more than once, or
crosses a module boundary, or its shape is something the
reader can’t deduce, prefer a record. An Employee is much
easier to read than a (String, Int, Bool, String, Int).
4.7 Hash maps and sets
Everything you’ve used so far is immutable: a new record
doesn’t mutate the old one, a list with one more element is a
new list. For most code that’s exactly what you want. But
sometimes you need a real associative table — insert and look
up by key in near-constant time — and building a fresh record
on every insertion won’t cut it. For that the stdlib ships two
mutable structures: HashMap[k, v], which maps keys to
values, and HashSet[a], a set with no duplicates.
That they’re mutable shows up in the effect row: the
operations that write (put, add, remove) yield
Mutable. If you come from Python or Java, a dictionary that
changes in place feels obvious; what’s new here is that the
language says so in the type whenever someone outside can
notice. The rule is the one from the StringBuilder in §4.4:
if the function creates the table and fills it itself, nobody
else sees the mutation, and kaikai masks the effect at the
function’s edge, even when the function returns the filled
table. A function that modifies a table it received as a
parameter does carry / Mutable in its signature, and the
compiler makes you declare it. Immutability is still the
default; visible mutation is marked where it happens.
Key access uses indexing, m[key], which returns an Option
— Some(v) if the key is present, None if it isn’t — so a
missing key never blows up in your face:
import collections.hashmap as hashmap
fn count(m: hashmap.HashMap[String, Int], w: String) : Unit / Mutable {
let current = match m[w] {
Some(n) -> n
None -> 0
}
hashmap.put(m, w, current + 1)
}
count writes to a table it doesn’t own, so it declares
Mutable.
The match on the Option has a shortcut: unwrap_or(m[w], 0)
does the same in one expression, and it is what the example uses.
A full example: count how often each word in a list appears,
first with a HashMap, and along the way use a HashSet to
count how many distinct words there are.
import collections.hashmap as hashmap
import collections.hashset as hashset
fn word_frequencies(words: [String]) : hashmap.HashMap[String, Int] {
let m = hashmap.empty()
words |! hashmap.put(m, ., unwrap_or(m[.], 0) + 1)
m
}
fn main() : Unit / Stdout {
let text = ["sun", "sea", "sun", "wind", "sea", "sun"]
let m = word_frequencies(text)
match m["sun"] {
Some(n) -> Stdout.print("sun appears #{n} times")
None -> Stdout.print("sun does not appear")
}
Stdout.print("distinct words: #{hashmap.size(m)}")
# A HashSet drops duplicates on insertion.
let seen = hashset.empty()
text |! hashset.add(seen, .)
Stdout.print("unique via set: #{hashset.size(seen)}")
}
$ kai run examples/ch04/08_maps.kai
sun appears 3 times
distinct words: 3
unique via set: 3
Neither word_frequencies nor main declares Mutable: each
structure is born inside the function that fills it, and the
caller only gets the result.
hashmap.put inserts or replaces in place; hashmap.get is
the named form of m[key]; size, keys, values, remove
and contains round out the day-to-day. The HashSet is the
valueless face of the same idea: add, contains, size,
plus the set operations union, intersection and
difference. The full list, with signatures, comes out of
kai doc collections/hashmap and kai doc collections/hashset
(ch. 16 covers kai doc).
One note on order: neither the HashMap nor the HashSet
promises a traversal order. keys, values and to_pairs
hand you the elements in the internal bucket order, which is
not insertion order. If you need order, sort at the end or
reach for the stdlib’s ordered structure (Map, built on an
AVL tree).
Map also compares by content: two maps holding the same
pairs are equal even when they were built by inserting in a
different order. That makes it the right structure when the
result leaves the function and something downstream will
compare it against an expectation. The bridge between the two
families lives in collections/convert: convert.to_map(h)
freezes a HashMap once the fast build is done, and
convert.to_hashmap(m) goes the other way.
Exercises
4.1. Define a record Book with fields title, author
and pages. Write a function fn short(b: Book) : Bool that
returns true if the book has fewer than 200 pages. Build
two books and try the function.
4.2. Write fn maximum(xs: [Int]) : Option[Int] returning
the largest element of the list, or None if the list is
empty. Use recursion and match.
4.3. Rewrite fn parse_age from §4.5 so it distinguishes
three errors: not numeric, negative age, age > 130. Use
a sum type with three constructors and a Result.
4.4. Define a function fn split(xs: [Int]) : ([Int], [Int]) returning a tuple with the evens and odds of xs,
each in original order. Why a tuple and not two return
values? And why a tuple and not a record?
4.5. Given a [Point] (with Point = { x: Int, y: Int }),
write fn center(ps: [Point]) : Option[Point] returning the
average of coordinates, or None if the list is empty. Hint:
you’ll need two passes or an accumulator, and int_to_real
to average — but note the final result has to be a Point
with Int fields, so you’ll also need real_to_int (or
settle for the integer part).
4.6. On paper, without writing code, draw what happens in memory when you run these three lines:
let xs = [1, 2, 3]
let ys = [0, ...xs]
let zs = [99, ...xs]
How many lists were created? How many elements were copied?
Are there cells that xs, ys, zs share? The answer
helps you understand why immutability isn’t expensive when
the structures are linked.