Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to extract Rust code with nested borrows / NLLs into a function

I have a large enum where I'm mutating data. The match cases are getting unwieldy, so I'd like to extract those into a separate function. However, it seems that it is not possible to do that due to lifetime issues.

The example

Here is a simplified example (Rust Playground link) of the issue at hand:

#[derive(Debug)]
enum Variants {
    WithString(String),
    WithNum(usize),
}

fn manipulate(v: &mut Variants) {
    match v {
        Variants::WithString(s) => {
            if s.is_empty() {
                s.push_str("abc");
            } else {
                *v = Variants::WithNum(123);
            }
        }
        Variants::WithNum(n) => *v = Variants::WithString(n.to_string()),
    }
}

I have an enum that is not Clone or Default. Let's look at the WithString() case in detail:

  • I have a v: &mut Variants variable.
  • I have a s: &mut String variable that points into v. While the s borrow is active, I cannot use the outer object v.
  • The Rust borrow checker is smart enough to recognize that if I stop using s, then I can access v again. I do this in the else branch.

I think that's an example of non-lexical lifetimes (NLLs)?

My actual usecase is worse in that the data structure is recursive, but I do not think that's relevant for an MCVE.

Extracting the function doesn't work

When I try to extract the code in the WithString(..) case into a separate function, this will invariably fail, because function lifetimes do not seem to express that "can't use v while s is active" relationship:

match v {
    // rejected by borrow checker:
    // cannot borrow `*v` as mutable more than once at a time
    Variants::WithString(s) => manipulate_with_string(v, s),
    ...
}

// this function doesn't know that "s" points into "v"
fn manipulate_with_string(v: &mut Variants, s: &mut String) {
    if s.is_empty() {
        s.push_str("abc");
    } else {
        *v = Variants::WithNum(123);
    }
}

Potential workarounds

Is there a way to solve this without drastically changing the data model?

I know of a couple solutions, but all are unsatisfactory:

  • Just live with a very large match block.

  • Pass the entire data structure into the extracted function and panic if it was the wrong variant – sounds like the most appropriate solution since this is easy to test. So roughly:

    fn manipulate_with_string(v: &mut Variants) {
      let Variants::WithString(s) = v else { unreachable!() };
      ...
    }
    
  • Give up trying to solve this on a type system level, and check this dynamically via RefCell instead. However, that requires significant changes to the data model, and introduces the possibility of panics.

  • Use owned data instead, i.e. using a signature fn manipulate(v: Variants) -> Variants. However, that requires re-architecting a lot of the data flow.

  • Make the data Default so that I can std::mem::take() it at the beginning of the function and place it back just before I return. For example, this can be achieved by passing a v: &mut Option<Variants> instead. However, this gives up some type safety.

  • Cook up some smart pointer or guard object using unsafe that can safely express this nested-borrow relationship. However, I am not confident that I can do this correctly.

So, is there a way to extract this manipulate_with_string() function, or am I stuck with one of the workarounds? Does Rust have any features/RFCs that might help with this in the future?

like image 803
amon Avatar asked Aug 20 '26 23:08

amon


1 Answers

Copy

First of all, I want to note that the problem is moot if the type is Copy. This may seem like a non-sequitur, but my personal experience is that many of my types end up being Copy.

In such a case, extracting is as simple as:

fn manipulate(v: &mut Variants) {
    *v = match v {
        Variants::WithString(s) => manipulate_with_string(*s),
        Variants::WithNum(n) => manipulate_with_num(*n),
    }
}

fn manipulate_with_string(mut s: String) -> Variants {
    if s.is_empty() {
        s.push_str("abc");
        Variants::WithString(s)
    } else {
        Variants::WithNum(123)
    }
}

fn manipulate_with_num(n: usize) -> Variants {
    Variants::WithString(n.to_string())
}

Do note that the v variable is NOT passed into the helper, only the components of each enum case are passed.

Note: The above code may not compile depending on which String is used, the standard String, notably, not being Copy. One step at a time, please!

Steal

Now, Copy, is not always possible... but the above structure is still usable regardless!

fn manipulate(v: &mut Variants) {
    *v = match v {
        Variants::WithString(s) => manipulate_with_string(mem::take(s)),
        //                                                ^~~~~~~~~~~~
        Variants::WithNum(n) => manipulate_with_num(*n),
    }
}

fn manipulate_with_string(mut s: String) -> Variants {
    if s.is_empty() {
        s.push_str("abc");
        Variants::WithString(s)
    } else {
        Variants::WithNum(123)
    }
}

fn manipulate_with_num(n: usize) -> Variants {
    Variants::WithString(n.to_string())
}

That is, you don't need an Option<Variants> or a Default implementation for Variants to be able to steal the components, you just need the components themselves to have a cheap default state.

There is a concern that should the helper function panic, the variable v will be left into an "in-between" state; this may happen as the result of any mutation on a case component, though -- such as panicking after push_str has been called.

Note: The standard collections' philosophy of cheap Default construction (no allocation) make them particularly suitable for such an endeavor.

Branch In, Branch Out

Finally, the least desirable solution (in my opinion) is "duplicating" the control-flow:

  • Encapsulate the (complex) branch and conditional transformation with the helper.
  • Branch outside the helper on whether the transformation occurred, or not.

I do not particularly like it because I find it more difficult to understand what is going on, but should stealing not be an option...

fn manipulate(v: &mut Variants) {
    match v {
        Variants::WithString(s) =>
            if let Some(new) = manipulate_with_string(s) {
                *v = new;
            },
        Variants::WithNum(n) => *v = manipulate_with_num(*n),
    }
}

fn manipulate_with_string(s: &mut String) -> Option<Variants> {
    if s.is_empty() {
        s.push_str("abc");
        None
    } else {
        Some(Variants::WithNum(123))
    }
}

fn manipulate_with_num(n: usize) -> Variants {
    Variants::WithString(n.to_string())
}

Thankfully, the simple case is still simple (direct replacement), so only the more complex cases need adopt this less intuitive strategy.

like image 109
Matthieu M. Avatar answered Aug 23 '26 06:08

Matthieu M.



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!