Subtyping on assignment?

Hi,

I am designing a system with some config and a state that can be initialized from config, borrowing from it. The state also has an update function that allows swapping out the config for a new version. This will retain parts of the state that are still valid under the new configuration, while re-initializing other parts. The example code below uses a simplified version of both structures.

struct Config {
    name: String,
}

struct State<'a> {
    name: &'a str,
    value: String,
}

impl<'a> State<'a> {
    fn new(config: &'a Config, value: String) -> Self {
        Self {
            name: &config.name,
            value,
        }
    }

    fn update<'b>(self, config: &'b Config) -> State<'b> {
        State {
            name: &config.name,
            value: self.value,
        }
    }

    fn identify(&self) {
        eprintln!("state has {}", self.name);
    }
}

When actually trying to use these structures, however, we run into some difficulties. Ideally, we would want a current config variable and a state variable borrowing from it. When an updated config arrives, we can construct a new state, consuming the old one, allowing the original config to be dropped. To do so, we need to borrow from the new config, making it impossible to move it to the original config variable afterwards.

It is easy enough to update the config once:

fn main() {
    let a: Config = Config {
        name: String::from("my name"),
    };

    let mut state = State::new(&a, String::from("state"));
    state.identify();

    let b: Config = Config {
        name: String::from("another name"),
    };
    state = state.update(&b);
    std::mem::drop(a);
    state.identify();
}

I was actually a little surprised to see this compile. I'd expect the compiler to deduce a lifetime for state based on the reference to config a and then complain when we overwrite its value with a state with another lifetime bound to config b. I'd expect to need let state = state.update(&b) instead; but the compiler accepts the program and seems to do the right thing. If i try to drop a before the update, it complains. It seems as if the type of state changed through the assignment, even though it is still the same variable. Can anyone explain how this actually works?

To update the config in a loop, i came up with the following solution:

fn main() {
    let mut configs = [
        Config {
            name: String::from("my name"),
        },
        Config {
            name: String::from("another name"),
        },
        Config {
            name: String::from("third name"),
        },
        Config {
            name: String::from("fourth name"),
        },
    ]
    .into_iter();

    let mut a: Option<Config> = (&mut configs).next();
    let mut b: Option<Config> = None;

    let mut state: State<'_> = State::new(a.as_ref().unwrap(), String::from("state"));
    state.identify();

    while let Some(next) = configs.next() {
        b = Some(next);
        state = state.update(b.as_ref().unwrap());
        state.identify();

        if let Some(next) = configs.next() {
            a = Some(next);
            state = state.update(a.as_ref().unwrap());
            state.identify();
        } else {
            break;
        }
    }
}

This alternates between two variables to hold the current config. It works, though it produces a warning about the value of b never being read (which i believe is not correct). Any suggestions for a more elegant solution?

I believe the analysis is that it sees the lifetime in the return types of State::new(..) and state.update(..) are independent. Let's call them 'a and 'b respectively, and let's call the lifetime of the state variable's type 's. The assignments to state require 'a: 's and 'b: 's. They don't require 'a: 'b.

If you make your struct invariant, you do get a borrow check error.

You overwrite the None when you enter the loop before using b, and if you never enter the loop, you never use b. You could get rid of the error like so:

-    let mut b: Option<Config> = None;
+    let mut b: Option<Config>;

But since you only assign Some(_), you might as well ditch the Option wrapper:

    let mut b: Config;
    // ...
    while let Some(next) = configs.next() {
        b = next;
        state = state.update(&b);

Actually...[1] this works too;[2] b needs not be declared before the loop:

-    let mut b: Config;
     // ...
-    while let Some(next) = configs.next() {
+    while let Some(b) = configs.next() {
-        b = next;
         state = state.update(&b);

Also come to think of it, you only assign Some(_) to a too, except when you also unwrap() before the loop. So:

    let mut a: Config = configs.next().unwrap();
    let mut state: State<'_> = State::new(&a, String::from("state"));
    // ...
    while let Some(next) = configs.next() {
        // ...
        let Some(tmp) = configs.next() else { break };
        a = tmp;
        state = state.update(&a);

And that's as far as I got.


  1. /me tries ↩︎

  2. for reasons similar to above presumably ↩︎

3 Likes

If you make your struct invariant, you do get a borrow check error.

Wouldn't the PhantomData<fn(&'a ())> make the struct contravariant over 'a? Making the struct invariant over 'a (using PhantomData<&'a mut ()>) doesn't seem to make much of a difference. Interestingly, the compile error in the contravariant example can be solved by introducing a new binding for state.

And that's as far as I got.

Nice! Quite a bit tidier.

Still needs the manual alternation between two configs, but after some more thought, that seems unavoidable. I think moving the borrowed config would actually be fine in the example (if the compiler could be convinced) because the state holds a &str reference (to the memory allocated by the String), not a &String reference (to the field in the struct) -- but the compiler doesn't know that. To be able to express this (probably placing the config in something like a Box), there would need to be a distinction between the need to keep the allocation alive and the need to keep it in the same place.

Now i wonder if the alternation pattern could somehow be encapsulated...

Yes, that alone is contravariant, however there's also the &'a str field which is covariant, and those two together make the whole type invariant in 'a.

3 Likes

Right, so my PhantomData<&'a mut ()> field didn't change anything. To make the struct invariant over 'a it would need to be PhantomData<&'a mut &'a ()> and then (in the effect for the whole struct) it is equivalent to PhantomData<fn(&'a ())>.