🎓 A1Learner
🔍

Rust Borrow Checker Blind Spots

A borrow checker blind spot is a safe code shape the checker still refuses. Each blind spot has a standard workaround.

Blind spot one: get it, or insert it

This get-or-default function looks safe — and is safe. The Some path returns a borrow. The None path holds no borrow at all. It is still refused, with E0499:

match map.get_mut(&key) {
    Some(value) => value,
    None => {
        map.insert(key, V::default()); // error E0499
        map.get_mut(&key).unwrap()
    }
}

Why the checker cannot allow it

The returned reference escapes into the caller, so the map stays borrowed for as long as the caller uses it. The checker reasons about the function as a whole. On the Some path a borrow leaves and never comes back. So it must assume the borrow might be live everywhere. That includes inside the None arm:

let v = get_default(&mut map, key); // the returned &mut keeps map borrowed out here
process(v);

The standard way around: the entry API

entry was designed for exactly this shape. One lookup finds-or-inserts, and the borrow question never arises:

map.entry(key).or_insert_with(|| V::default())

The honest workaround: check first, then borrow

Two short steps, each borrow finished before the next begins. It compiles today — it just pays for two lookups instead of one:

if !map.contains_key(&key) {
    map.insert(key, 0);
}
map.get_mut(&key).unwrap()

Blind spot two: the same shape inside a loop

Retry-until-found has the same problem, plus a path back to the top of the loop. The insert and the returned borrow meet, and E0499 appears again:

loop {
    match map.get_mut(&key) {
        Some(v) => return v,
        None => { map.insert(key, 0); } // error E0499
    }
}

Blind spot three: a borrow held across a branch

first is used after the if, so its borrow covers the whole if — including the branch that pushes. The push is refused, even on runs where it would be harmless:

let first = &v[0];
if v.len() == 3 {
    v.push(40); // error E0502: first is still used below
}
println!("first = {}", first);

Blind spot four: helper methods hide the pieces

Each getter borrows all of self. So two getters in one line always collide. The fields underneath never overlap. The fix is to borrow the fields in one pattern, which the Match Ergonomics lesson now shows:

self.get_a_mut().push(self.get_b().len() as i32); // error E0502

Example

use std::collections::HashMap;

fn get_or_default(map: &mut HashMap<i32, String>, key: i32) -> &mut String {
    map.entry(key).or_insert_with(|| String::from("default"))
}

fn main() {
    let mut scores: HashMap<i32, String> = HashMap::new();
    get_or_default(&mut scores, 1).push('!');
    println!("score 1 = {}", scores[&1]);

    let mut names = vec![String::from("ana"), String::from("bo"), String::from("cydney")];
    let mut longest = 0;
    for i in 1..names.len() {
        if names[i].len() > names[longest].len() {
            longest = i;
        }
    }
    names.push(String::from("dora"));
    println!("longest = {} (of {})", names[longest], names.len());
}

Key points

  • Problem Case #3: the name writers give the get-or-insert shape below.
  • It is the one case from the non-lexical lifetimes plan that the shipped checker still refuses.
  • A next-generation checker, called Polonius, accepts these shapes.
  • It is still being finished.
  • For now, the workarounds are the answer.
  • Use the entry API for maps: it exists because of this blind spot
  • Keep an index instead of a reference.
  • An index survives edits to the list.
  • You borrow v[i] fresh at each use.
  • Move the check to run time when you must.
  • RefCell<T> lends out borrows that are checked while the program runs.
  • The cost is a possible panic.
  • A blind spot is safe code that the checker refuses anyway.

In this example, two workarounds do the work the checker could not follow. get_or_default uses the entry API: entry(1) finds no score, or_insert_with stores "default", and the returned &mut String gets a ! pushed through it, printing score 1 = default!. Below, the longest-name search keeps an index, not a reference. longest is just the number 2. So pushing "dora" onto names afterwards breaks nothing. The last line still reads names[longest] fresh. It prints longest = cydney (of 4).

Note

A refusal is not a bug in your code.

It means the proof is one the checker cannot follow — restructure the code until the proof is one it can.

Try it Yourself

Edit the code, then press Run.

Running is simulated · Rust Playground

Exercise

Your get-or-insert function over a HashMap is refused with E0499, though each path is safe on its own. What is the standard fix?