How to get around lifetime issues with checking for entry in HashMap then inserting if not present

TLDR; I need to check for an entry in a map, return the result if it is present and if not present do some processing, insert, then return a reference to the new entry. (entry().or_insert_with() does not fit my use case). The borrowchecker is fighting me and I need an idiomatic work around.

I have struct that caches some data from the file system in a HashMap. It exposes an API that attempts to retrieve from the cache and if it is not present, it reaches out to the file system does some processing, stores result in the map and returns a reference to the new entry. Below(linked) is a simplified example:

use std::collections::HashMap;

struct Cache {
    cache: HashMap<String, String>,
}

impl Cache {
    fn find(&mut self) -> Result<&str, String> {
        if let Some(cached) = self.cache.get("JumboTrout") {
            return Ok(cached);
        }
        // Reach out to file system...
            
        let returned = self.cache.entry("JumboTrout".to_string()).or_insert("Value".to_string());
        
        Ok(returned)
    }
}


error[E0502]: cannot borrow `self.cache` as mutable because it is also borrowed as immutable
  --> src/lib.rs:14:24
   |
8  |     fn find(&mut self) -> Result<&str, String> {
   |             - let's call the lifetime of this reference `'1`
9  |         if let Some(cached) = self.cache.get("JumboTrout") {
   |                               ---------- immutable borrow occurs here
10 |             return Ok(cached);
   |                    ---------- returning this value requires that `self.cache` is borrowed for `'1`
...
14 |         let returned = self.cache.entry("JumboTrout".to_string()).or_insert("Value".to_string());
   |                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ mutable borrow occurs here

As you can see in the compiler error, the borrowchecker rejects the program. But this just seems wrong to me?
My understanding of the issue is that self.cache.get("JumboTrout") is a borrow that is considered to live to the end of the function and it clashes with the "reborrow" at

let returned = self.cache.entry("JumboTrout".to_string()).or_insert("Value".to_string());

It is clear that if the let pattern matches on Some(cached), the function returns early. If the pattern does not match self.cache is no longer borrowed due to the early return and later borrows should be valid.
A notable work around is you can check if the entry exists by using is_some() then immediately accessing again (and return the result) and the compiler allows it:

impl Cache {
    fn find(&mut self) -> Result<&str, String> {
        if self.cache.get("JumboTrout").is_some() {
            return Ok(self.cache.get("JumboTrout").unwrap());
        }
        // Reach out to file system...
            
        let returned = self.cache.entry("JumboTrout".to_string()).or_insert("Value".to_string());
        
        Ok(returned)
    }
}

I think this is because is_some returns a bool, not a reference, so the borrow only last as long as is_some() is executing.
My issue with this solution is that it is clunky and seems unidiomatic. Is there a better way?

While looking into how to get around this I did find or_insert_with(FnOnce) which looked promising, but the closure passed in cannot return a Result; it must return an entry into the map. This is a problem for me because accessing the file system is an error prone process and I need to return early if something goes wrong.

My question is if there is a more idiomatic (and less clunky) way to do this? This seems like a pretty common pattern and so I imagine there is a way to do this that I am not considering.

Also is my understanding that the borrowchecker is being "unduly conservative" here by not acknowledging the mutually exclusive branching or do I not understand lifetimes well enough yet?

It might be better to use or_insert_with() here.

self.cache.entry(..).or_insert_with(|| {
    // Reach out to filesystem
    ...
})

You seem to be wanting to check if the value exists first, so that you don't do unneeded work, but when using or_insert_with(), you pass in a function to call, that only gets evaluated if the entry is unoccupied.

Restructuring this way might cause the borrowing error to go away.

Classic https://docs.rs/polonius-the-crab , or compile your code with RUSTFLAGS=-Zpolonius cargo run on nightly.

2 Likes

Holy Hell!

Thank you for showing me this. It seems this is a well documented issue.