References and Slices
So far, references have appeared in passing: push returns one, .= binds
one, and a parameter of type i64& is one. This chapter looks at them
properly, together with their many-element cousin, the slice. It also
explains the single rule the compiler uses to make sure neither can ever
dangle.
#References8.1
A reference T& is the address of a T. It is eight bytes, it can never
be null, and there is no arithmetic on it and no way to cast it to
anything else.
References are transparent. Wherever a reference is used as a value, it
behaves exactly like the thing it refers to: you read it, do arithmetic with
it, compare it, print it and pass it to functions as if it were the target.
Assigning to it writes the target. There is no * and no ->.
That raises an obvious question. If r = 5 writes the target, how do you
change which thing r refers to? The answer is a separate operator, .=,
which works on the reference itself:
fn main() {
var pens: i64[>..] = [10, 20, 30, 40, 50];
var r .= pens[1]; // r refers to pens[1]
var c = pens[1]; // c is a copy of pens[1]
r += 1; // writes pens[1]
c += 100; // writes the copy
print(pens, " ", r, " ", c);
r .= pens[4]; // r now refers to pens[4]
r = 0;
print(pens);
}
[10, 21, 30, 40, 50] 21 120 [10, 21, 30, 40, 0]
The dot is the visual cue: x .= e and x .== e are about the reference
x, while x = e and x == e are about whatever it refers to.
let r .= e;orvar r .= e;declaresras a reference toe, which can be a variable, a field, an element, or a call that returns a reference.r .= e;rebinds an existing reference variable, field or element.r1 .== r2asks whether two references refer to the same thing;r1 == r2compares the two things' values.
fn main() {
var pens: i64[>..] = [40, 40];
let a .= pens[0];
let b .= pens[1];
let c .= pens[0];
print(a == b, " ", a .== b, " ", a .== c);
}
true false true
#Where references come from8.1.1
You will get most of your references from push, from parameters of
reference type, and from .=. The explicit way to make one is &x, which
takes a reference to a variable, a field or an element:
fn main() {
var honks = 1;
let a = &honks; // a reference, because of the explicit &
let b .= honks; // also a reference
var r .= honks;
let copy_of = r; // a copy: a reference to a fixed-size value decays
a += 1;
b += 1;
print(honks, " ", copy_of);
}
3 1
The line let copy_of = r; is worth a second look. When a variable's type
is inferred from a reference to a fixed-size value, it gets a copy of the
value, not a second reference. You have to say .= or & to get a
reference. Non-fixed values never copy implicitly, as you saw in Chapter 6,
so for those a plain let gives a reference.
You can only take a reference to something that has a location. &5, or
& of a function's temporary result, is an error.
#Slices8.2
A slice T[:] is a view of a run of elements in some array: a reference to
the first one and a count. It is the universal "here are some elements"
type. Every kind of array converts to a slice of itself, without copying,
wherever a slice is expected:
fn total(xs: i64[:]) -> i64 {
var t = 0;
for x in xs { t += x; }
return t;
}
fn main() {
let fixed: i64[3] = [1, 2, 3];
var grown: i64[>..] = [10, 20, 30, 40, 50];
print(total(fixed), " ", total(grown), " ", total([4, 5]));
}
6 150 9
You can also take a slice of part of an array with a half-open range.
Either bound can be left out, and ^k means "k from the end":
fn main() {
var pens: i64[>..] = [10, 21, 60, 40, 0];
let mid = pens[1..4];
print(mid, " ", pens[..2], " ", pens[^2..], " ", mid.len);
}
[21, 60, 40] [10, 21] [40, 0] 3
A slice is bounds-checked against its own length, and a slice of a slice can never be larger than the original. A slice taken from a grow-only array stays valid when the array grows, because nothing moves. It keeps viewing the elements it was made from, and does not include the new ones:
fn main() {
var pens: i64[>..] = [10, 21, 60, 40, 0];
let mid = pens[1..4];
pens.push(60);
print(mid, " of ", pens.len);
}
[21, 60, 40] of 6
#The one rule8.3
Here is the whole of Goose's borrow checker:
A reference or slice must not outlive the variable that owns what it points at.
That is all. There are no lifetime parameters, no rules about how many references may exist at once, and no distinction between shared and exclusive borrows. Two references to the same element are fine, and you can write through both:
fn main() {
var nest: i64[>..] = [3];
let a .= nest[0];
let b .= nest[0];
a += 1;
b += 1;
print(nest);
}
[5]
Aliasing like this is harmless in Goose, because the things that make it dangerous elsewhere are absent. Nothing can be freed while a reference exists, a grow-only array cannot move, and there is no memory shared between threads.
#Roots8.3.1
To enforce the rule, the compiler gives every reference and slice a
root: the variable whose lifetime bounds what the reference points at.
&x is rooted at x. A slice of pens is rooted at pens. A reference
returned by a function is rooted at whichever arguments it might have come
from.
You never write roots down. The compiler works them out, and when a function is called with references that have different roots, it type-checks the function separately for each combination. That gives the precision of Rust's lifetime parameters without any of the syntax.
Most of the time you will only notice roots when you break the rule. There are three ways to do that.
Returning a reference to a local. A function's locals die when it returns, so nothing rooted in them can be returned:
fn first_three(s: u8[:]) -> u8[:] {
var buf: u8[>..] = [];
buf.append(s);
return buf[..3];
}
fn main() { print(first_three("goose")); }
error: returning a reference rooted in buf, which dies with this function (§9.2)
The fix is to return the array itself, which is built in the caller's
destination, or a slice of the parameter s, which belongs to the caller.
Storing a reference somewhere that lives longer. Here view outlives
the block that owns inner:
fn main() {
var outer: u8[>..] = [];
outer.append("outside");
var view = outer[..];
{
var inner: u8[>..] = [];
inner.append("inside");
view = inner[..];
}
print(view);
}
error: storing a reference rooted at inner, which does not outlive the destination (§9.2)
Keeping a view of a temporary. A function's result, a literal or a
copy is a temporary that lasts until the end of its statement. You can
pass a view of it to a function, but you cannot keep one:
fn make() -> u8[>..] { str("honk honk") }
fn main() {
let v = make()[..4];
print(v);
}
error: binding v to a slice rooted at a temporary, which does not outlive it (§9.2): a temporary lasts until the end of its statement, so bind it to a variable of its own first
The message says what to do: let t = make(); let v = t[..4];.
#A function that returns one of its arguments8.3.2
A function may return a reference that came from one of its parameters, because the arguments belong to the caller. When it could return any of several, the result is rooted at all of them:
fn heavier(a: i64[:], b: i64[:]) -> i64[:] {
if total(a) >= total(b) { a } else { b }
}
fn total(xs: i64[:]) -> i64 { var t = 0; for x in xs { t += x; } t }
fn main() {
var morning: i64[>..] = [4200, 3900];
var evening: i64[>..] = [4600, 4100];
let w = heavier(morning, evening);
print(w);
}
[4600, 4100]
Here w is rooted at both morning and evening, and it must satisfy the
rule for both: it may not outlive either one. In Rust you would have written
fn heavier<'a>(a: &'a [i64], b: &'a [i64]) -> &'a [i64]. Goose works the
same thing out from the body.
#Liveness, not scope8.4
The compiler cares about when a reference is last used, not about where its scope ends. A slice that is never used again is dead, even if its variable is still in scope, and a dead slice does not stop its array from shrinking:
fn main() {
var line: u8[>..] = [];
line.append("honk honk");
let first = line[..4];
print(first); // first's last use
line.clear(); // fine
line.append("hiss");
print(line);
}
honk hiss
Swap the last two uses around, so that first is printed after the
clear, and the compiler objects, naming both the array and the slice that
is still in use:
fn main() {
var line: u8[>..] = [];
line.append("honk honk");
let first = line[..4];
line.clear();
print(first);
}
error: cannot clear line while first is still used: it may hold a reference or slice into it (§5.1)
This is why a scratch buffer can be refilled in a loop and still hand out
slices of itself on every iteration. Each iteration's slices are dead by the
time the next clear comes around.
#Writability8.5
Everything is writable unless its type says otherwise. const T& is a
reference through which the target cannot be written, and const T[:] is a
read-only slice. Some things are read-only by nature: a string literal is
static data, so it is a const u8[:].
Parameters are generic over constness. A parameter declared u8[:]
accepts both a writable slice and a read-only one, and the function is
checked separately for each. Writing through the parameter is fine for a
writable argument, and an error, reported at the call, for a read-only one:
fn shout(s: u8[:]) {
for &c in s { if c >= 'a' && c <= 'z' { c -= 32; } }
}
fn main() {
var buf: u8[>..] = [];
buf.append("honk");
shout(buf); // fine: buf is writable
shout("hiss"); // a string literal is not
}
error: cannot assign through this path (const, or a read-only instantiation, §9.5) in fn shout(u8[:]) instantiated from main.goose:9
The second line of the message points at the call that caused the problem.
Remove that call and the program runs, turning honk into HONK.
So you rarely write const on a parameter. You write it when you want to
promise that a function only reads, which also lets the compiler catch an
accidental write inside the function itself. The places where const is
required are slots that store references: a struct field, an array
element or a global that is meant to hold read-only data, such as a string
literal, must say so in its type. Chapter 9 has examples.
#Rebinding8.6
A reference variable that you rebind with .= stays tied to the root it
started with, or to another root at the same scope depth. In practice that
means you can walk around inside a structure, or move between sibling
variables, which is what rebinding is for:
fn main() {
var a: i64[>..] = [1, 2];
var b: i64[>..] = [3, 4];
var cursor .= a[0];
cursor .= a[1];
cursor .= b[0]; // another local at the same depth: fine
print(cursor);
}
3
Anything more adventurous, such as pointing an outer variable at something from an inner scope, is the "storing a reference" error from before.
#Exercises8.7
-
Write a function
largest(xs: i64[:]) -> i64&that returns a reference to the largest element, and use it to double the largest element of[3, 9, 4]in place. -
Write
fn middle(s: u8[:]) -> u8[:]that returns its argument without its first and last bytes (or an empty slice if it is shorter than 2). Call it on a string literal and on a builder. Why is it fine to return a slice here, whenfirst_threeabove was rejected? -
Change the
lineexample above so that it printsfirstafter theclear, but compiles. (There is more than one way.)