Rust - Lifetimes

Coming to Rust from C++ and learning about lifetimes is very similar to coming to C++ from Java and learning about pointers. At first, it looks like an unnecessary concept, something a compiler should have taken care of. Later, as you realize that it gifts you with more power — in case of Rust it is more safety and better optimizations — you feel encouraged to master it but fail because it is not exactly intuitive and the formal rules are hard to find.

C++ pointers are perhaps easier to understand than Rust lifetimes because they are present throughout the code, whereas Rust lifetimes are typically masked by a tonne of syntactic sugar. As a result, you are exposed to situations—typically difficult ones—where syntactic sugar does not apply. When you are only exposed to complex situations, it is difficult to internalise the notion.

One must first understand that lifetimes are solely about references and have no other purpose. When we encounter a struct with a lifespan type-parameter, for instance, it only refers to the lifetimes of the references that belong to this struct. There are only lives of the references contained within a struct or closure; there is no such thing as a lifetime of a struct or closure. As a result, Rust references will undoubtedly come up in our discussion of the lives.

The motivation behind the lifetimes

We must first comprehend the motivations behind the borrowing laws in order to comprehend the lifetimes, which is a prerequisite to knowing the lives themselves. The lending regulations state:

There are no references to overlapping chunks of memory, often known as aliasing, where at least one of them is utilised to change the memory's contents.

Due to safety concerns and the fact that it prohibits the compiler from doing numerous optimizations, simultaneous mutation and aliasing are undesirable.

Example

Let's say we wanted to create a function that would move the specified coordinates twice along the x-axis in the specified direction.

struct Coords {
  pub x: i64,
  pub y: i64,
}
fn shift_x_twice(coords: &mut Coords, delta: &i64) {
  coords.x += *delta;
  coords.x += *delta;
}
fn main() {
  let mut a = Coords{x: 10, y: 10};
  let delta_a = 10;
  shift_x_twice(&mut a, &delta_a);  // All good.
  let mut b = Coords{x: 10, y: 10};
  let delta_b = &b.x;
  // shift_x_twice(&mut b, delta_b);  // Compilation failure. 
}

The previous sentence would have changed the coordinate three times rather than twice, which could have resulted in a variety of production system issues. The primary issue is that Rust's lifetimes and borrowing rules disallow the overlapping memory that delta b and &mut b point to. The Rust compiler specifically notes that while keeping an immutable reference to b until main() is required by delta b, we also attempt to create a mutable reference to b in that scope, which is forbidden.

The lives of all references must be known by the compiler in order to complete the borrowing rules check. The developer must intervene and manually annotate them when the compiler is unable to determine the lifetimes on its own, which happens occasionally. It also provides the developer with a design tool; for instance, one can mandate that all structs that implement a specific trait have all of their references live for at least the specified period.

Contrast C++ references with Rust references. Like Rust's &x and &mut x, C++ likewise supports const and non-const references. Lifetimes are absent in C++, nevertheless. Const references aid the C++ compiler's optimizations to some extent, but they do not provide full assurances of safety. Therefore, if the example above were written in C++, it would compile.

Desugaring

Because the term lifetime is used in multiple Rust documentation to refer to both scopes and type-parameters, we need to define it before we can fully comprehend lifetimes. Here, we use the terms lifetime to refer to a scope and lifetime-parameter to refer to a parameter that, like when it infers generic types, the compiler would replace with a genuine lifetime.

Example

We will desugar some Rust code to make the explanation transparent. Think about the following code snippet:

fn announce(value: &impl Display) {
  println!("Behold! {}!", value);
}
fn main() {
 let num = 42;
 let num_ref = #
 announce(num_ref);
}

Here the desugared version:

fn announce<'a, T>(value: &'a T) where T: Display {
    println!("Behold! {}!", value);
}
fn main() {
'x: {
    let num = 42;
    'y: {
        let num_ref = &'y num;
        'z: {
            announce(num_ref);
        }
    }
}
}

Following desugared code has lifetime-parameter 'a and lifetimes/scopes 'x, 'y, and 'x explicitly marked.

Additionally, we have compared lifespan parameters to general type parameters using impl Display. Take note of how sugar masked the generic type-parameter T as well as the lifetime-parameter 'a. The scopes are solely used for annotation purposes; they are not a part of the syntax of the Rust language. As a result, this code will not compile. In order to make the discussion simpler, we disregard non-lexical lives that were added in Rust 2018 in this example and others.

Subtyping

Lifetime is technically not a type since, unlike conventional types like u64 or Vec<T>, we cannot create an instance of it. In contrast, when we parametrize functions or structs, lifetime-parameters are utilised in the same way as type-parameters; for an illustration, see the announce example above. Additionally, we will continue to refer to variance rules as types in this piece because we shall see that they behave similarly to types when used with lives.

Comparing lifetimes to common types and lifetime-parameters to common type-parameters is useful:

  • If more than one type can fulfil the provided type-parameter, the compiler would complain while inferring a type for it. Multiple lifetimes may satisfy the specified lifetime-parameter in the case of lifetimes, and the compiler will select the smallest one.
  • A struct cannot be a subtype of another struct unless it has lifetime-parameters, which means that simple Rust types lack subtyping. Although lifetimes provide subtyping, if lifetime "longer" entirely encloses lifetime "shorter," then lifetime "longer" is a subtype of lifetime "shorter." Limited subtyping on types that include lifetime parameters is also possible thanks to lifetime subtyping. This suggests that &'longer int is a subtype of &'shorter int, as we shall show in a subsequent section. Because it is the longest of all lifetimes, the "static lifetime" is a subtype of them all. Static is somewhat the opposite of a Java object type, which is a supertype of all kinds.

The Rules

Coercions vs Subtyping

One type may be forced into another type in Rust thanks to a set of rules. Although coercions and subtyping have a similar appearance, it is crucial to tell them apart. The main distinction is that coercion alters the underlying value, whereas subtyping does not. In contrast to subtyping, which is simply a compiler check, the compiler adds extra code at the location of coercion to do some low-level conversion. Because the developer is unaware of this additional code, coercions and subtyping appear visually similar because both may resemble the following:

let b: B;
...
let a: A = b;

Side-by-side coercion and subtyping:

// This is coercion:
let values: [u32; 5] = [1, 2, 3, 4, 5];
let slice: &[u32] = &values;
// This is subtyping:
let val1 = 42;
let val2 = 24;
'x: {
    let ref1 = &'x val1;
    'y: {
        let mut ref2 = &'y val2;
        ref2 = ref1;
    }
}

Because 'x is a subtype of 'y and &'x is a subtype of &'y, this code functions as intended.

Learning some of the most prevalent forms of coercion makes it simple to distinguish between the two; the others are far less common;

  • Pointer weakening: &mut T to &T
  • Deref: &x of type &T to &*x of type &U, if T: Deref<Target=U>.
  • This allows using smart pointers like they were regular references.
  • [T; n] to [T].
  • T to dyn Trait, if T: Trait.

You might be wondering why it follows that &'x T is a subtype of &'y T because 'x is a subtype of 'y. We must talk about variance to provide a response.

Variance

Based on the previous section, it is simple to determine whether lifetime 'longer is a subtype of lifetime'shorter. Why &'longer T is a subtype of &'shorter T is even intuitively clear. Do you know if &'a mut &'longer T is a subtype of &'a mut &'shorter T, though? Actually, it isn't, and we need the rules of variance to comprehend why.

As we previously stated, types that are parameterized with lifetimes can have limited subtyping. Variance is a characteristic of type-constructors, which are types with parameters like Vec<T> or &mut T. The subtyping of the parameters and the subtyping of the final type are specifically affected by variance. If a type-constructor has more than one parameter, such as F<'a, T, U> or &'b mut V, the variance is calculated for each parameter separately.

There are three types of variances:

  • F<T> is covariant of T if F<Subtype> is a subtype of F<Supertype>
  • F<T> is contravariant over T if F<Subtype> is a supertype of F<Supertype> 
  • F<T>is invariant over T ifF<Subtype> is a neither a subtype nor a supertype of F<Supertype>, making them incompatible.

When a type function Object() { [native code] } has many parameters, the various variances can be discussed by stating, for instance, that F<'a, T> is covariant over 'a and invariant over T. Bivariance, a fourth sort of variance, is actually there, but it is a particular aspect of compiler implementation that we do not need to discuss at this time.

Here is the table of variances for most common type-constructors:

Essentially a pass-through rule, covariance. Contravariance is uncommon and only happens when a function pointer is passed to one that employs higher-rank trait constraints. The most significant variance is invariance, and when we begin combining variances, we will understand why it exists.

Variance arithmetic

Now that we are aware of the subtypes and supertypes of &'a mut T and Vec<T>, are we also aware of those for &'a mut Vec<T> and Vec<&'a mut T>? We must understand how to combine variations of the type-constructors in order to respond to this.

The transformation and the greatest lower bound, or GLB, are the two mathematical operations that can be used to combine variances. For type composition, Transform is used, whereas GLB is used for all aggregates, including struct, tuple, enum, and union. Let's use 0, +, and - to indicate in-, co-, and contravariance, respectively. The following two tables then describe Transform(x) and GLB():

Example

Imagine that we are interested in determining whether Box<&'longer> bool is a subtype of Box<&'shorter bool>. In other words, we are interested in Box's covariance with regard to 'a. Box<T> and &'a bool are covariant with respect to T and 'a, respectively. Transform (x) must be used because it is a composition: covariant (+) x covariant (+) Equals covariant (+). Which means we can assign Box<&'longer bool> to Box<&'shorter bool>.

Similarly, since covariant (+) x invariant (0) = invariant, Celllonger bool> cannot be assigned to Cellshorter bool> (0).

Example

The reason why we want invariance on specific type-constructors is illustrated by the following example. It makes an effort to write code that employs a released object.

fn evil_feeder<T>(input: &mut T, val: T) {
    *input = val;
}
fn main() {
    let mut mr_snuggles: &'static str = "meow! :3";  // mr. snuggles forever!!
    {
        let spike = String::from("bark! >:V");
        let spike_str: &str = &spike;                // Only lives for the block
        evil_feeder(&mut mr_snuggles, spike_str);    // EVIL!
    }
    println!("{}", mr_snuggles);                     // Use after free?
}

The Rust compiler forbade it. To comprehend why some of the code is desugared below:

fn evil_feeder<’a, T>(input: &’a mut T, val: T) {
    *input = val;
}
fn main() {
    let mut mr_snuggles: &'static str = "meow! :3";
    {
        let spike = String::from("bark! >:V");
        ‘x: {
let spike_str: &’x str = &’x spike;
‘y: {
        evil_feeder(&’y mut mr_snuggles, spike_str);
}
}
    }
    println!("{}", mr_snuggles);
}

The compiler will look for a parameter T during compilation that complies with the restrictions. Remember that the compiler uses the shortest lifespan possible, thus it will try to use &'x str for T. Now since evil feeder takes &'y mut &'x str as its first parameter, we are attempting to pass &'y &'static str in its place. Will it function?

Because 'static is a subtype of 'y, &'y mut &'z str must be covariant over 'z in order for it to function. Keep in mind that &'z T is covariant with regard to 'z and that &'y mut T is invariant with respect to T. Due to the fact that covariant(+) x invariant(0) = invariant, &'y mut &'z str is invariant with regard to 'z. (0). Therefore, it won't compile.

Interestingly, if this code had been written in C++, it would have compiled.

Example with structs

Since we would only encounter contravariance when using function pointers with structs, we would have to utilise GLB rather than Transform. Here is an example that won't build because the compiler has detected that struct Owner is invariant with regard to lifetime-parameter "c": It is necessary to substitute "spike" for "static" in type annotations. Since subtyping is effectively disabled by invariance, the lifetime of spike must precisely match that of mr_snuggles:

struct Owner<'a:'c, 'b:'c, 'c> {

        pub dog: &'a &'c str,
        pub cat: &'b mut &'c str,
}
fn main() {
    let mut mr_snuggles: &'static str = "meow! :3";
    let spike = String::from("bark! >:V");
    let spike_str: &str = &spike;
    let alice = Owner { dog: &spike_str, cat: &mut mr_snuggles };
}

Outro

All of these principles are difficult to remember, and we don't want to have to look them up every time we run into trouble in Rust. Understanding and keeping in mind the dangerous circumstances that these rules avoid is the best method to develop intuition.

  • We are able to remember the borrowing constraints that forbid simultaneous mutation and aliasing by using the first example with shifting coordinates.
  • Because it is always acceptable to pass a longer living lifetime where the shorter one is expected, with the exception of when they are wrapped in mutable reference and alike, &'a T and &'a mut T are covariant over 'a.
  • To prevent situations like the preceding evil feeder example and others like it, we want it to be invariant over T, which denotes an exact match of the lifetimes. &'a mut T, UnsafeCell<T>, Cell<T>, *mut T enable mutable access.

Rust improves in use and friendliness with each new version, but lifetimes is a fundamental idea that still has to be thoroughly explored.

I sincerely hope that the majority of you find the approach covered here to be helpful. Thank you for reading, and please feel free to leave any comments or questions in the comments section below.

Post a Comment

0 Comments