Understanding Lifetime in Rust – Part II

In Part I we discussed the motivation behind lifetime management in Rust and how it works from a function. In this installment we will explore how lifetime helps us model containment relationship (that is, when an object contains a reference to another object).

The Business Requirement

We will design a Person type. A person may own a Car. A person should be able to buy and sell cars. Two persons should be able to exchange (or trade) their cars.

Design the Types

The Car type is easy. We will keep it simple.

struct Car {
    model : String
}

How should the Person type contain a Car? A full containment will look like this:

struct Person {
    car : Option<Car>
}

This is easy from a memory management point of view. But it has several problems. A car is not an integral part of a person. Buying and trading in cars will create copies of cars. For performance reasons we will like to avoid that. Instead, we will like Person to store an optional reference to a Car. But that’s when things begin get complicated.

Containing a Reference Pointer

In Rust a reference must point to a valid memory area. Which means that the container object must not live longer than the object that it holds a reference to. Rust requires us to model this rule using lifetime parameters. Our Person type will look like this.

struct Person<'a> {
    car:Option<&'a Car>
}

In Person<'a> we are saying that there is a lifitime named a.

In Option<&'a Car> we are saying that the Car object has a lifetime of a.

After this, the compiler will ensure that the Person object has a lifetime of a or less. This will be done to avoid a Person object from containing an invalid Car reference pointer.

What we did here is basic common sense. Unfortunately, the Rust compiler does not let us elide the lifetime parameters in this situation. At least not right now.

Write the Implementation

Now that our type is designed, we can go ahead an implement the methods.

impl <'a> Person<'a> {
    fn new() -> Person<'a> {
        Person{
            car: None
        }
    }

    fn buy_car(&mut self, c : &'a Car) {
        self.car = Some(c);
    }

    fn sell_car(&mut self) {
        self.car = None;
    }    
}

This has everything we need except for trading cars between persons. We will develop that later.

As you have caught on by now, lifetime parameters are built into the data type using the same syntax as generics (the full type name is: Person<'a>). That is why we start the implementation using impl <'a> Person<'a> just like we would if the Person type used generics. Technically this lets us write different implementations for different types of lifetimes. But this concept is beyond my understanding for now.

Using the Contained Reference

Alright. Let’s take our code for a spin. This will compile just fine.

fn main() {
  let car = Car{model: "Honda Civic".to_string()};
  let mut bob = Person::new();

  bob.buy_car(&car);

  println!("{}", bob.car.unwrap().model);
}

The order in which you create bob and car does not matter. This will also work.

fn main() {
  let mut bob = Person::new();
  let car = Car{model: "Honda Civic".to_string()};

  bob.buy_car(&car); //OK!

  println!("{}", bob.car.unwrap().model);
}

In the old days this would have caused compile error because compiler thought the car got destroyed before bob. We can try to enforce that order using a scope. Now the code will run afoul.

fn main() {
    let mut bob = Person::new();

    {
        let car = Car{model: "Honda Civic".to_string()};

        bob.buy_car(&car); //Error!
    }

    println!("{}", bob.car.unwrap().model);
}

The Car object does not live as long as the Person. This violates our lifetime rule.

The compiler is good but there are cases where it can not deterministically asses lifetime and errs on the side of safety. Consider the example below.

fn main() {
  let ghibli = Car{model: "Maserati Ghibli".to_string()};
  let mut bob = Person::new();

  { 
    let civic = Car{model: "Honda Civic".to_string()};

    bob.buy_car(&civic); //Error!
    bob.buy_car(&ghibli);
  }

  println!("{}", bob.car.unwrap().model);    
}

The code does not compile because the civic variable doesn’t live as long as bob. This is unfortunate because clearly the code is perfectly safe from a human developer’s point of view. At the end of the inner scope bob no longer has any reference to civic. The system is strictly going by the way we declared the buy_car(&mut self, c : &'a Car) method. All it knows is that the supplied Car has a lifetime of a and the container object must not outlive it.

Recall, we can have multiple immutable borrowers as long as there are no other mutable borrowers. This makes it possible for two people to own the same car at the same time.

fn main() {
    let mut bob = Person::new();
    let mut alice = Person::new();
    let civic = Car{model: "Honda Civic".to_string()};

    bob.buy_car(&civic);
    alice.buy_car(&civic);

    println!("Bob has: {}", bob.car.unwrap().model);
    println!("Alice has: {}", alice.car.unwrap().model);
}

Having two owners of a car is a possible violation of our nation’s legal construct. Compiler should not enforce such rules. If you must lean on the compiler to deny sharing of a car then read on. We discuss that case later in this article.

Implement Trading

Trading will involve swapping cars between two Person instances. We add this method to the implementation:

impl <'a> Person<'a> {
  //Other methods omitted...

  fn trade_with(&mut self, other : &mut Person<'a>) {
    let tmp = other.car;

    other.car = self.car;
    self.car = tmp;
  }
} 

Nothing special here. Except to note that the lifetime is a part of the data type. The other variable has a full data type of &mut Person<'a>.

We can now use the trade_with() method like this.

fn main() {
    let civic = Car{model: "Honda Civic".to_string()};
    let ghibli = Car{model: "Maserati Ghibli".to_string()};

    let mut bob = Person::new();
    let mut alice = Person::new();

    bob.buy_car(&civic);
    alice.buy_car(&ghibli);

    bob.trade_with(&mut alice);

    println!("Bob has: {}", bob.car.unwrap().model);
    println!("Alice has: {}", alice.car.unwrap().model);
}

These days the order in which Car and Person objects are created makes no difference. The compiler is smart enough to deduce their lifetimes.

You can throw a really devious case at the compiler. It will catch the problem and won’t compile.

fn main() {
    let mut bob = Person::new();
    let civic = Car{model: "Honda Civic".to_string()};
    
    {
        let ghibli = Car{model: "Maserati Ghibli".to_string()};
        let mut alice = Person::new();

        bob.buy_car(&civic);
        alice.buy_car(&ghibli); //Error!

        bob.trade_with(&mut alice);
  
        println!("Alice has: {}", alice.car.unwrap().model);
    }

    println!("Bob has: {}", bob.car.unwrap().model);
}

The compiler correctly deduces that bob becomes the new owner of the Maserati Ghibli and Ghibli doesn’t live as long as bob does.

Borrow Rules Apply

When an object holds a reference to another it borrows the reference and all standard borrowing rules apply. This is expected. I mention them here just to reinforce the concepts.

You can have as many immutable borrows as you want as long as there are no mutable borrows.

let ghibli = Car{model: "Maserati Ghibli".to_string()};
let mut bob = Person::new();

bob.buy_car(&ghibli); //bob borrows ghibli immutably

let p1 = &ghibli; //More immutable borrows are OK
let p2 = &ghibli;

A mutable borrow is exclusive and is only permitted if there is no other borrow of any kind.

let mut ghibli = Car{model: "Maserati Ghibli".to_string()};
let mut bob = Person::new();

bob.buy_car(&ghibli); //bob borrows ghibli immutably

let p1 = &mut ghibli; //Can't do this.

You can not move an object while someone has borrowed it’s reference. Because doing so will make the borrowed reference point to an invalid memory area.

let mut ghibli = Car{model: "Maserati Ghibli".to_string()};
let mut bob = Person::new();

bob.buy_car(&ghibli); //bob borrows ghibli

let g = ghibli; //Can't move

This is, however, somewhat unexpected.

let civic = Car{model: "Honda Civic".to_string()};
let mut ghibli = Car{model: "Maserati Ghibli".to_string()};
let mut bob = Person::new();

bob.buy_car(&ghibli);
bob.buy_car(&civic);

let p1 = &mut ghibli; //Still Can't do this

From a human developer’s point of view the code above is safe. bob no longer borrows any reference to ghibli at a time when we are trying to borrow ghibli mutably. But the compiler is unable to deduce that.

Prevent Sharing of a Reference

Earlier we have shown that two Person objects can share the same Car reference. In some situations this may not be desired. For example this prevents a Person from tinkering with (mutating) the car in any way. We can solve this issue by giving a Person a mutable reference to a Car. Remember a mutable reference creates an exclusive access. Nobody else can have any kind of reference to the same object. Here is the full code.

struct Car {
    model : String
}

struct Person<'a> {
    //Hold a mutable reference
    car:Option<&'a mut Car>
}

impl <'a> Person<'a> {
    fn new() -> Person<'a> {
        Person{
            car: None
        }
    }

    fn buy_car(&mut self, c : &'a mut Car) {
        self.car = Some(c);
    }

    fn sell_car(&mut self) {
        self.car = None;
    }    

    fn trade_with<'b>(&mut self, other : &'b mut Person<'a>) {

        let tmp = other.car.take();
    
        other.car = self.car.take();
        self.car = tmp;
    } 
}

fn main() {
    let mut civic = Car{model: "Honda Civic".to_string()};
    let mut ghibli = Car{model: "Maserati Ghibli".to_string()};

    let mut bob = Person::new();
    let mut alice = Person::new();

    bob.buy_car(&mut civic);
    alice.buy_car(&mut ghibli);

    bob.trade_with(&mut alice);

    println!("Bob has: {}", bob.car.unwrap().model);
    println!("Alice has: {}", alice.car.unwrap().model);
}

That will work fine. Notice in the trade_with() method we had to introduce a new lifetime for the other variable. The reasoning gets complicated. Because the cars are switching hands they may not outlive either one of the persons. Compiler needs a little bit of help here. We are now saying that other may have a different lifetime than &self.

Sharing is now disallowed.

fn main() {
    let mut civic = Car{model: "Honda Civic".to_string()};

    let mut bob = Person::new();
    let mut alice = Person::new();

    bob.buy_car(&mut civic);
    alice.buy_car(&mut civic); //Error!

    println!("Bob has: {}", bob.car.unwrap().model);
    println!("Alice has: {}", alice.car.unwrap().model);
}

Not All is Well

Well, as we have seen above it’s not that hard to model containment of reference pointers. The bad news is that this model does not always work. Consider this function. It will not compile.

fn shop_for_car(p : &mut Person) {
    let car = Car{model: "Mercedes GLK350".to_string()};

    p.buy_car(&car); //Error! car doesn't live long enough
}

That is because the car object simply doesn’t live as long as the Person buying it. So how can we keep reference to an object that is created in an inner scope like a function? The answer lies in heap allocation which in Rust is achieved via Box::new. We will explore that in Part III.

10 thoughts on “Understanding Lifetime in Rust – Part II

  1. The semantics implies that a person can only own a car, so buy_car should return an Option in case the owner has already owned a car. He just throw away the car right now.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.