Values
This chapter is about something many languages leave implicit: what happens to a value when you assign it, pass it to a function or put it in a list. Tessera's answer is simple, and it shapes the rest of the language. Then we'll look at the other half of the story: how Tessera represents a value that might not be there, without null.
Every value is a value
In Tessera, assigning a value, passing it to a function, or capturing it in a closure behaves as if the value were copied. No two variables ever see each other's changes.
fn main(root: Root) {
let out = root.stdout
let mut a = [1, 2]
let b = a
a.push(3)
out.print("a = {a}, b = {b}")
let scores = [70, 85, 90]
let curved = add_bonus(scores, 5)
out.print("scores = {scores}, curved = {curved}")
}
// `xs` is this function's own copy: changing it changes nothing outside.
fn add_bonus(xs: List[Int], bonus: Int) -> List[Int] {
let mut xs = xs
for i in 0..<xs.len() {
xs[i] = xs[i] + bonus
}
xs
}$ tessera run main.tsr
a = [1, 2, 3], b = [1, 2]
scores = [70, 85, 90], curved = [75, 90, 95]
After let b = a, a and b are two separate lists. Pushing onto a leaves b alone. The same goes for function calls: add_bonus gets its own copy of the scores, changes it, and returns it; the caller's scores are untouched.
This is true for every type: numbers, strings, lists, maps, structs and enums. There are no references, no pointers and no aliasing. If you're coming from a language where objects are shared by default, this takes a moment to get used to, and then it takes away a whole family of bugs: a function can't quietly modify the list you passed it, and two parts of a program can't tangle through a shared object.
A function that wants to "change" a value does what add_bonus does: it takes the value, makes its changes to its own copy, and returns the new one. The caller decides whether to keep it.
Parameters are immutable, like let bindings. let mut xs = xs at the top of add_bonus makes a mutable variable with the same name, which is the usual way to start changing a parameter.
Isn't copying slow?
It would be, if everything were really copied every time. It isn't. The native backend shares a value between variables and copies it only at the moment one of them changes it while it is still shared, a technique called copy-on-write. On top of that, a value's last use moves it instead of sharing it, so in the common case where you take a value, change it and pass it on, nothing is copied at all. In listing 4-1, main prints scores after calling add_bonus, so the list is still shared when add_bonus changes it, and it is copied once. If main didn't use scores again, the list would move into the call and be changed in place, with no copy at all.
The result is that you write code as if values were copied, and pay for a copy only where the program could tell the difference.
Copies with changes
Since values are never shared, "change one field" really means "make a new value like this one, but different here". Structs (chapter 5) have a short way to say that:
struct Settings {
theme: Str,
font_size: Int,
line_numbers: Bool,
}
fn main(root: Root) {
let defaults = Settings { theme: "light", font_size: 14, line_numbers: false }
let mine = { defaults with theme: "dark", font_size: 16 }
root.stdout.print("{defaults}")
root.stdout.print("{mine}")
root.stdout.print("same? {defaults == mine}")
}$ tessera run main.tsr
Settings { theme: "light", font_size: 14, line_numbers: false }
Settings { theme: "dark", font_size: 16, line_numbers: false }
same? false
{ defaults with theme: "dark", font_size: 16 } is a new Settings equal to defaults except for the fields named. defaults itself doesn't change. When the old value isn't used afterwards, as in s = { s with count: s.count + 1 }, the native code updates it in place.
== compares values field by field, for every type except functions and capabilities.
Changing several variables at once
A function that returns a new value often has something else to say as well: a parser returns what it read and where it stopped, a random number generator returns a number and its next state. It returns both as a tuple, a few values in parentheses, (n, next). Chapter 6 covers tuples in full; for now, they're a way to return two things.
On the calling side, the values usually go back into variables that already exist. Put the variables in parentheses on the left of =, and each one gets its item of the tuple:
// A small random number generator. The state goes in, and a die roll and the
// next state come out.
fn roll(seed: Int) -> (Int, Int) {
let next = (seed * 1103515245 + 12345) % 2147483648
(next / 65536 % 6 + 1, next)
}
fn main(root: Root) {
let mut a = 0
let mut b = 1
let mut fib: List[Int] = []
for _ in 0..<10 {
fib.push(a)
(a, b) = (b, a + b)
}
root.stdout.print("fibonacci: {fib}")
let mut seed = 42
let mut die = 0
let mut rolls: List[Int] = []
for _ in 0..<8 {
(die, seed) = roll(seed)
rolls.push(die)
}
root.stdout.print("rolls: {rolls}")
}$ tessera run main.tsr
fibonacci: [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
rolls: [2, 6, 6, 4, 3, 2, 4, 5]
(a, b) = (b, a + b) steps through the Fibonacci numbers. The whole right side is worked out first, using the old a and b, and only then are the variables assigned, from left to right. So there's no need for a temporary variable, and (a, b) = (b, a) swaps two values.
(die, seed) = roll(seed) is the pattern for threading state through a loop. roll takes the current seed and returns a die roll together with the next seed; the loop keeps both, and the next round passes the new seed back in. Nothing is hidden: the state is an ordinary value that goes into a function and comes back out.
As with a plain assignment, every variable on the left must be mut, and each must have the type of its item. The left side and the tuple must also have the same number of items:
fn roll(seed: Int) -> (Int, Int) {
let next = (seed * 1103515245 + 12345) % 2147483648
(next / 65536 % 6 + 1, next)
}
fn main(root: Root) {
let mut seed = 42
let mut die = 0
let mut count = 0
(die, seed, count) = roll(seed)
root.stdout.print("{die}")
}$ tessera check main.tsr
error[E0211]: the left side assigns 3 values, but the right side is `(Int, Int)`
--> main.tsr:10:26
|
10 | (die, seed, count) = roll(seed)
| ^^^^^^^^^^
= note: the right side of `(a, b, c) = value` must be a tuple of 3 items
1 error
The left side can hold more than plain variables: a field, a list element, _ for an item you don't want, or another pair of parentheses for a tuple inside the tuple.
No null
Many languages let any reference be null, and a lot of crashes come from code that forgot to check. Tessera has no null. A value of type Str is always a string; a value of type User is always a user.
When a value might be missing, its type says so: Option[T] is either Some(value) or None. You have already used it: read_line returns Option[Str], parse_int returns Option[Int], and list.get(i) returns Option[T] because the index might be out of range.
The point is that an Option[Int] is not an Int, and the compiler won't let you use it as one:
fn main(root: Root) {
let ages = [31, 25]
let first = ages.get(0)
root.stdout.print("next year: {first + 1}")
}$ tessera check main.tsr
error[E0201]: operator `+` cannot be applied to `Option[Int]`
--> main.tsr:4:36
|
4 | root.stdout.print("next year: {first + 1}")
| ^^^^^^^^^
error[E0201]: expected `Option[Int]`, found `Int`
--> main.tsr:4:44
|
4 | root.stdout.print("next year: {first + 1}")
| ^
2 errors
So the question "what if it isn't there?" has to be answered where the value is used, which is exactly where you know the answer.
Working with Option
There are several ways to answer that question, and you'll use all of them.
struct Address {
city: Str,
}
struct User {
name: Str,
address: Option[Address],
}
fn main(root: Root) {
let out = root.stdout
let names = ["ana", "bo", "cy"]
let third = names.get(2)
let tenth = names.get(9)
out.print("{third} {tenth}")
match tenth {
Some(name) => out.print("the tenth is {name}"),
None => out.print("there is no tenth"),
}
out.print("tenth or nobody: {tenth ?? "nobody"}")
out.print("length of the third: {third.map(|n| n.len())}")
let ana = User { name: "ana", address: Some(Address { city: "Lyon" }) }
let bo = User { name: "bo", address: None }
for u in [ana, bo] {
out.print("{u.name} lives in {u.address?.city ?? "an unknown place"}")
}
}$ tessera run main.tsr
Some("cy") None
there is no tenth
tenth or nobody: nobody
length of the third: Some(2)
ana lives in Lyon
bo lives in an unknown place
matchhandles both cases explicitly. Use it when they do different things.??gives the value, or a default:tenth ?? "nobody". The default is only evaluated when it's needed. Note that??binds tighter than arithmetic, socount ?? 0 + 1means(count ?? 0) + 1, which is usually what you want.maptransforms the value if there is one, and leavesNonealone:third.map(|n| n.len())isSome(2).?., safe navigation, reaches into an optional value:u.address?.cityis the city if there is an address, andNoneif there isn't. A whole chain short-circuits at the firstNone, and the result is a singleOption, never anOptioninside anOption.
Printed, an Option shows as Some(...) or None, which makes debugging easy.
Passing None up with ?
Often, a missing value in the middle of a function means the whole function has nothing to return. Writing a match for every step gets tedious, so there's an operator for it:
// Reads "WIDTHxHEIGHT", such as "1920x1080". Any piece missing or not a
// number gives None.
fn parse_size(s: Str) -> Option[(Int, Int)] {
let parts = s.split("x")
let w = parts.get(0)?.parse_int()?
let h = parts.get(1)?.parse_int()?
Some((w, h))
}
fn main(root: Root) {
for s in ["1920x1080", "800", "axb"] {
root.stdout.print("{s}: {parse_size(s)}")
}
}? returns None early examples/ch04-05-question-on-option/main.tsr$ tessera run main.tsr
1920x1080: Some((1920, 1080))
800: None
axb: None
In a function that returns an Option, expr? takes the value out of Some, or returns None from the function right away. parse_size has four places where things can go missing, and handles all of them in four characters. The same ? works on Result, for errors with explanations attached; that's chapter 8.
Summary
Values in Tessera behave like copies, so nothing you pass to a function can be changed behind your back, and copy-on-write keeps that cheap. A struct can be copied with a few fields changed using with. A function that returns a new value and something more returns a tuple, and (x, state) = step(state) puts both back into your variables. There is no null: a value that might be missing is an Option, and you handle the missing case with match, ??, map, ?. or ?.
Next we'll build our own types, with structs and methods.