rustdependencieslifetimeunsafe

Parent/Child relationships in Rust without lifetime pollution


I've got a reasonably complex app that is subdivided into a small number of reasonably complex, highly-interdependent structs. Right now we just have Arcs and Weaks spread around pretty haphazardly. I'd love to remove them, and assume there's a better way to accomplish what I'm trying to do, but haven't quite found a good solution yet.

All of the sub-parts in question have interior-mutability, so don't need to worry about mut here. However, each of the sub-parts interact with each other through a central App that is instantiated at startup, and destroyed at shutdown. The only exception to this is in integration tests, where multiple Apps might exist at the same time, and be destroyed before the runtime completes.

This was originally just a giant static/global singleton, which made things simple, but didn't work well for tests.

The current structure is (roughly) as follows:

struct App {
  things: ThingManager,
  stuff: StuffManager,
  actions: ActionManager,
}
impl App {
  fn new() -> Arc<App> {
    // App instantiates each of the Thing/Stuff/Actions, and gives them a weak reference to itself
    let app = Arc::new(App { ThingManager::new(), StuffManager::new, ActionManager::new() })
    app.things.set_app(&app)
    app.stuff.set_app(&app)
    app.actions.set_app(&app)
  }
}

// Each of the Manager's look like:
struct ThingManager {
  app: OnceCell<Weak<App>>,
  // Other fields
}
impl ThingManager {
  fn set_app(&self, app: &Arc<App>) {
    self.app.set(Arc::downgrade(app))
  }
  fn get_app(&self) -> Arc<App> {
    self.app.get().upgrade()
  }

  // And unfortunately touch other parts of the app
  fn complex_logic(&self) {
    let app = self.get_app();
    let some_stuff = app.stuff.find_specific_stuff();
    let action = app.action.do_something(some_stuff);
    // etc
  }
}

I've looked into using things like arena allocators (bumpalo, generational-arena), but every time I start implementing I end up polluting my structs with <'app> lifetimes, and they very quickly spread everywhere in the code (lots of functions take an App, and need the lifetimes specified, etc).

I know my ThingManager will live exactly as long as my App, and when App is dropped, I'd like to drop the managers as well, how do I represent this cleanly? I'm not super excited to use unsafe blocks, but if they were limited in scope (manual setup/teardown) and were "safe" assuming my constraints were maintained (Managers won't outlive an App, etc), I wouldn't be too opposed...


Solution

  • I don't know your use-case in detail but it looks like the following design may be a reasonable one:

    struct App {
      things: ThingManagerInner,
      stuff: StuffManagerInner,
      actions: ActionManagerInner,
    }
    impl App {
      fn new() -> App {
        App {
            things: ...,
            stuff: ...,
            actions: ...,
        }
      }
      fn things(&self) -> ThingManager<'_> {
        ThingManager { app: self }
      }
    }
    
    // Each of the Manager's look like:
    struct ThingManagerInner {
      // Other fields
      other_field: usize,
    }
    struct ThingManager<'a> {
        app: &'a App,
        // optionally add other references here as necessary if they are conditional based on app, deeply nested, inside a Vec...
    }
    impl Deref for ThingManager<'_> {
        type Target = ThingManagerInner;
        fn deref(&self) -> Self::Target {
            &self.app.things
        }
    }
    impl ThingManager<'_> {
      // And unfortunately touch other parts of the app
      fn complex_logic(&self) {
        let some_stuff = self.app.stuff().find_specific_stuff(self.other_field);
        let action = self.app.action().do_something(some_stuff);
        // etc
      }
    }
    

    In Rust, you usually want to orient your struct construction around data rather than behavior. One can then construct behavior layers on top.

    As a rule of thumb, you don't need unsafe for anything (besides FFI), and it's generally more a footgun than anything else, given the additional guarantees that one has to provide when writing unsafe in Rust compared to C (aliasing because of noalias optimizations, covariance/invariance,contravariance...) and the fact that there's always just a better design that preserves maximum performance while staying fully safe.