To ensure borrowed references are always safe to use, they carry a lifetime constraint that restricts them from living beyond the scope they were created in. This constraint prevents a borrowed reference from pointing to an object that no longer exists. Borrowed references may move freely around the program, so long as they never try to travel beyond this lifetime barrier. Attempts to violate this lifetime constraint result in a compiler error.
For a borrowed reference used within the same function where it was borrowed, the compiler knows exactly which scope created the borrowed reference, and thus knows exactly what scope represents its lifetime barrier. However, a function that receives a borrowed reference from another function has no visibility into which scope created the borrowed reference. There is effectively a thick brick wall separating a called function from the function that calls it. Each cannot see anything about what the other is doing with borrowed references.
Lifetime Annotations on Function Signatures
To bridge this knowledge gap, Cone looks for lifetime annotations on borrowed references in a function's signature.
fn dostuff(a: &'a i32, b: &'b i32) &'a i32, &'b i32
'a and 'b are lifetime annotations representing two possibly-different lifetime scopes. Lifetime annotations are used to establish lifetime relationships between borrowed references. In this example, this means the first return value has the same lifetime as a, and the second has the same lifetime as b.
Although the called function treats them as having the "same" lifetime, they might in fact have different lifetimes in the calling function. When references with two different lifetimes are treated as having the same lifetime, their understood lifetime would be the shortest one, as that is the only lifetime these references have in common.
Lifetime annotations may be omitted on some or all borrowed references on a function signature. All borrowed references without annotations are understood to have the same lifetime, as if they had all been marked by the same lifetime annotation. The above example is thus identical to:
fn dostuff(a: &i32, b: &'b i32) &i32, &'b i32
We can rule out the need for any lifetime annotations if the function does not receive at least two borrowed references in its parameters. We can also rule out them out if the borrowed references in parameters are all read-only (const) and no borrowed reference is returned. Beyond this simple test, there are two specific situations where lifetime annotations might be required.
Returned borrowed references
When a function returns a borrowed reference and accepts at least two borrowed references as parameters, lifetime annotations might be needed. Let's illustrate with a simple example:
fn caller(nbr i32) i32 mut ref &i32 imm outer = 5 do imm inner = 10 ref = refswitch(nbr, &outer, &inner) *ref // Is this safe???
That final de-reference will not be safe if the called function looks like this:
fn refswitch(nbr i32, ref1 &i32, ref2 &i32) &i32 if nbr < 0 ref1 else ref2
The problem is that caller has no idea whether refswitch will return a reference to inner or outer. Since it could possibly be a reference to inner, that final de-reference could be unsafe, as inner is gone by the time the de-reference happens.
However, if we call a function that clearly indicates the returned reference only originates from ref1 (by marking ref2 as having a different lifetime 'a), then caller's logic is safe:
fn refswitch(nbr i32, ref1 &i32, ref2 &'a i32) &i32 ref1
If the signature shows the returned reference originates from ref2, caller once again triggers a compiler error:
fn refswitch(nbr i32, ref1 &'a i32, ref2 &i32) &i32 ref2
The lesson here is pretty simple: If a function returns a borrowed reference, make sure its lifetime annotation matches the borrowed reference(s) it could be sourced from. Then make sure that any borrowed references that are not the source for it are marked with a different lifetime. When correctly marked in this way, lifetime constraints will be properly enforced on both the caller and called functions.
Mutable borrowed reference parameters
When a function passes at least two borrowed references as parameters, at least one being mutable, lifetime annotations might be needed. Consider:
fn tryit() imm i = 5 mut a = &i do imm b = 10 refstore(&mut a, &b) // is this ok?
That's not going to work if it calls this function:
fn refstore(refmut &mut &i32, refval &i32) *refmut = refval // Oh my!
What's the problem? refstore assumes that both borrowed references point to objects of the same (smallest) lifetime. tryit knows that smallest lifetime is the scope of b. However, it realizes that there is a risk that refstore might store refval in retmut (as it does!), which would effectively lengthen the reference's lifetime to the scope of a, which is longer than the known lifetime of the object (b) it points to. Because of this risk, tryit will trigger a compile error on that call.
There are two ways to make this error go away. The first involves move b to the same scope as a, making the lifetimes truly equivalent. The other approach is to make the refstore function promise it won't store one reference inside the other, by marking their lifetimes as independent:
fn refstore(refmut &mut &i32, refval &'b i32) **refmut = *refval
The lesson here is once again simple: If you pass a mutable borrowed reference, mark every other borrowed reference with a different lifetime annotation if you don't intend for it to be stored in the mutable borrowed reference.
Lifetime Annotations on Fields
It is not enough to just constrain the lifetimes of borrowed references, we must also constrain the lifetimes of any data structures that contain borrowed references as fields. Consider:
struct Wrapper refa &'a u32 refb &'b u32
Every Wrapper value contains two fields, both borrowed references, each with a different lifetime constraint. The lifetime of this value will be constrained by whichever ends up as the shorter of the two lifetimes:
fn process() imm val = 5 makewrap(&val) fn makewrap(valref &i32) Wrapper imm val = 10 Wrapper[valref, &val] // oops
The Wrapper value is created in the makewrap function using a borrowed reference created in that function. Therefore, this value is lifetime-constrained to this function and may not be returned to process.
Lifetime Annotations on Types
The same lifetime annotation principles we discussed earlier apply to functions that receive or return values which have borrowed references within them as fields.
fn process() imm val = 5 makewrap(&val) fn makewrap(valref &i32) &i32 imm val = 10 getref(Wrapper[valref, &val]) fn getref(wrap: Wrapper<'a, 'b>) &'a i32 wrap.refa
getref shows how lifetime annotations are specified differently on aggregate types: inside angled brackets are two lifetimes, one for each borrowed reference in the struct. Notice that the function returns the borrowed reference held by refa (the longer one), also signalled by the lifetime annotation on the return type.
Were we to change getref to return refb instead, the makewrap function will be smacked with an error for trying to return a borrowed reference it created and which must not outlive its scope.