Is using Drop to perform some mendatory action a good pattern?

I want user to register their types in some registry by implementing a specific trait (here BuildType).
The registry has two methods to register the type : register::<T>() and register_with_name(name: &'static str).
I want to be sure that one of these methods is called and only once. For that I use Drop to performs this action.

Is it ok to do that ?
Is drop guaranteed to be called at the end of the build function?
Is there any other pattern that can be used here ?

trait BuildType {
     fn build(builder: TypeBuilder<Self>);
}

struct TypeBuilder<'a, T> {
    registry: &'a mut Registry,
    name: Option<&'static str>,
   _marker: PhantomData<T>,
}

impl<'a, T> TypeBuilder {
    fn with_name(&mut self, name: &'static str) -> &mut self {
        self.name = Some(name);
        self
    }

    /* Other methods omited */
}

impl<'a, T> Drop for TypeBuilder<'a, T> {
    fn drop(&mut self) {
        if let Some(name) = self.name {
            self.registry.register_with_name::<T>(name);
        } else {
            self.registry.register::<T>();
        }
    }
}

Drop is designed to perform resource cleanup and for restoring violated invariants. However, this often means that it's used for performing actions that must be executed once and only once. Thus, I would say this is fine.

7 Likes

Using Drop to do "mandatory" things can be a good pattern depending on what exactly the thing is, mostly because drop is not guaranteed to be called for a value in general even in safe code (see std::mem::forget and the decision that moved it from unsafe to safe). However, since in most Rust code you can expect drop to be called, anything that is sound when drop is omitted can rely on drop being called (at your discretion).

For example, Box uses Drop to ensure that the memory it dynamically allocates is freed and MutexGuard uses Drop to prevent a lot of cases where locks are acquired but never released (early returns, forgetting .unlock in other languages with mutexes). In the case of drop not being called this can cause memory leaks and programs to grind to a halt, but the key thing is that both of these are sound (i.e. they do not cause memory unsafety).

An example of something that had to be removed because of the lack of guarantee around drop in safe code is the old JoinGuard API. It prevented data races when sharing variables across threads in part by calling join in its drop implementation. Due to the discovery that drop could be "removed" in safe code, this API had to be scrapped completely.

The replacement API, std::thread::scope can show us a way to create a similar API under these constraints. What this implementation does differently is instead of using drop, it takes a closure which it calls and then performs the "mandatory" operations afterwards. This inverts the control flow and allows the function to guarantee that some piece of code is run after the code supplied by the user of the API.

10 Likes

I didn't know scoped threads are going to be released in stable very soon (in Rust version 1.63). Very nice :+1: