Once in a Lifetime

October 12, 2018

After spending a bit of time getting to know Rust, I think I’ve finally come to an understanding with the borrow checker, at least in terms of ownership and borrowing. The one thing I still have trouble getting my head around is lifetimes. I’ve written this post as my attempt to internalise a proper understanding of lifetimes.

According to the Rust Book, a lifetime is the scope for which a reference is valid. To explore lifetimes I will implement a simple event handler project which will call a specific handler function dependent on the name of an event. The project is made up of the following main parts:

  • a Handler is a function that takes a string slice as a parameter.
  • a Dispatcher maintains a map of event names to Handlers.
  • the Dispatcher calls the relevant Handler, passing in the parameter, when the call method is invoked with an event name and parameter.

The first version of the code looks like this:

use std::collections::HashMap;

type Handler = Fn(&str);

struct Dispatcher {
    handlers: HashMap<&str, &Handler>,
}

impl Dispatcher {
    // create a new dispatcher
    fn new() -> Dispatcher {
        Dispatcher {
            handlers: HashMap::new(),
        }
    }

    // add a new handler
    fn add(&mut self, event_name: &str, handler_function: &Handler) {
        self.handlers.insert(event_name, handler_function);
    }

    // call a handler, passing parm as a parameter
    fn call(&self, event_name: &str, parm: &str) {
        if let Some(h) = self.handlers.get(event_name) {
            h(parm);
        }
    }
}

// a test handler
fn handler_1(value: &str) {
    println!("handler_1 called with value '{}'", value);
}

fn main() {
    // create the dispatcher
    let disp = Dispatcher::new(); 
    // add a handler to the dispatcher
    disp.add("handler_1", &handler_1);
    // call the dispatcher, passing "parm 1" as a parameter 
    disp.call("handler_1", "parm 1"); 
}

When run the code it should return:

$ cargo run
handler_1 called with value 'parm 1'

but instead, the result is:

$ cargo run
   Compiling dispatcher v0.1.0 (C:\rust\dispatcher)
error[E0106]: missing lifetime specifier
 --> src\main.rs:7:23
  |
7 |     handlers: HashMap<&str, &Handler>,
  |                       ^ expected lifetime parameter

error[E0106]: missing lifetime specifier
 --> src\main.rs:7:29
  |
7 |     handlers: HashMap<&str, &Handler>,
  |                             ^ expected lifetime parameter

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0106`.
error: Could not compile `dispatcher`.

To learn more, run the command again with --verbose.

Because the Dispatcher struct contains references in the HashMap, the rust compiler does not know how the lifetimes of the references relate to the lifetime of the struct itself. In order for it to be safe to use the Dispatcher stuct, the struct cannot out live it’s references, otherwise it could be left with dangling references that point to invalid memory.

To tell the compiler how the lifetimes relate to each other I need to add lifetime annotations to the declarations. It is important to note that these annotations don’t actually affect the lifetimes of the things they annotate, they just tell the compiler how long references need to remain valid. If the compiler decides that the code cannot meet the specified lifetime requirements, the compilation will fail.

Here I’ve added a lifetime annotation of 'a to the struct and the references in the HashMap. This tells the compiler that the references in the HashMap must remain valid for as long as the struct remains valid.

struct Dispatcher<'a> {
    // maps an event name to a function
    handlers: HashMap<&'a str, &'a Handler>,
}

Ok, with those changes in place, lets try and build it again:

$ cargo build
   Compiling dispatcher v0.1.0 (C:\rust\dispatcher)
error[E0106]: missing lifetime specifier
  --> src\main.rs:10:6
   |
10 | impl Dispatcher {
   |      ^^^^^^^^^^ expected lifetime parameter

error[E0106]: missing lifetime specifier
  --> src\main.rs:12:17
   |
12 |     fn new() -> Dispatcher {
   |                 ^^^^^^^^^^ expected lifetime parameter
   |
   = help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
   = help: consider giving it a 'static lifetime

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0106`.
error: Could not compile `dispatcher`.

To learn more, run the command again with --verbose.

The compilation got a bit further this time, but now I have a problem in the implementation block for the Dispatcher. The first error

10 | impl Dispatcher {
   |      ^^^^^^^^^^ expected lifetime parameter

tells me that I need to add a lifetime annotation to the Dispatcher part of the implementation declaration. This is because the lifetime annotation that I added to the Dispatcher struct is now considered a part of the struct’s signature.

The second error

12 |     fn new() -> Dispatcher {
   |                 ^^^^^^^^^^ expected lifetime parameter

tells me that I need to add a lifetime annotation to the return value of the new method. This is because the new method is returning a borrowed value and the compiler doesn’t know how long the borrowed value needs to live.

I fixed those 2 errors and tried again

impl Dispatcher<'a> {
    // create a new dispatcher
    fn new() -> Dispatcher<'a> {
        Dispatcher {
            handlers: HashMap::new(),
        }
    }

which results in

$ cargo build
   Compiling dispatcher v0.1.0 (C:\rust\dispatcher)
error[E0261]: use of undeclared lifetime name `'a`
  --> src\main.rs:11:17
   |
11 | impl Dispatcher<'a> {
   |                 ^^ undeclared lifetime

error[E0261]: use of undeclared lifetime name `'a`
  --> src\main.rs:13:28
   |
13 |     fn new() -> Dispatcher<'a> {
   |                            ^^ undeclared lifetime

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0261`.
error: Could not compile `dispatcher`.

To learn more, run the command again with --verbose.

These latest two errors have the same cause. If an implementation block contains any lifetime annotations, they need to be specified immediately after the impl keyword in order to be valid in the implentation block, like the following.

impl<'a> Dispatcher<'a> {
    // create a new dispatcher
    fn new() -> Dispatcher<'a> {
        Dispatcher {
            handlers: HashMap::new(),
        }
    }

With that change made, it is time to try compiling again

$ cargo build
   Compiling dispatcher v0.1.0 (C:\rust\dispatcher)
error[E0621]: explicit lifetime required in the type of `event_name`
  --> src\main.rs:21:30
   |
20 |     fn add(&mut self, event_name: &str, handler_function: &Handler) {
   |                                   ---- help: add explicit lifetime `'a` to the type of `event_name`: `&'a str`
21 |         self.handlers.insert(event_name, handler_function);
   |                              ^^^^^^^^^^ lifetime `'a` required

error[E0621]: explicit lifetime required in the type of `handler_function`
  --> src\main.rs:21:42
   |
20 |     fn add(&mut self, event_name: &str, handler_function: &Handler) {
   |                                                           -------- help: add explicit lifetime `'a` to the type of `handler_function`: `&'a (dyn for<'r> std::ops::Fn(&'r str) + 'static)`
21 |         self.handlers.insert(event_name, handler_function);
   |                                          ^^^^^^^^^^^^^^^^ lifetime `'a` required

error: aborting due to 2 previous errors

For more information about this error, try `rustc --explain E0621`.
error: Could not compile `dispatcher`.

To learn more, run the command again with --verbose.

This time, with help from the compilers extremely useful messages, I can see that I need to add lifetime annotations to the parameters on the add method. This is because I am inserting the event_name and the handler_function into the handlers vector, which already has a lifetime defined. Adding the lifetime annotations to the parameters indicates that the parameters need to live as long as the vector.

// add a new handler
fn add(&mut self, event_name: &'a str, handler_function: &'a Handler) {
    self.handlers.insert(event_name, handler_function);
}

Giving it another compile, finally gives us a good build

$ cargo build
   Compiling dispatcher v0.1.0 (C:\rust\dispatcher)
    Finished dev [unoptimized + debuginfo] target(s) in 2.39s

and I can run the program and finally get the expected output.

$ cargo run
handler_1 called with value 'parm 1'

As I said at the top of this post, my purpose in writing this was to internalise the way lifetimes work in Rust, so that I don’t keep bumping up against the kind of compilation failiures seen here. I think that the understanding of lifetimes that I’ve worked through here is correct, but if I’m making any mistakes, please add a comment below.

Copyright (c) 2018, all rights reserved.