Context Managers: Undroppable Types for Free
— 2024-06-05

In program language design I'm a big fan of features which fall out of other, more general features. People occasionally talk about both "unleakable" and "undroppable" types. I see a lot of value in "unleakable" types because if a type is "unleakable" we can guarantee the type will always have its destructor called. This would allow us to use Drop to be used to uphold safety invariants, which will make it possible to write things like "task scopes". For type system nerds: yes, this means Rust would be able to uphold linear invariants via its type system, AKA equip Rust with linear types.

"Undroppable types" have a more limited application. There is some element of undroppability needed to ensure async destructors can only be called in async contexts, but that's not the same things as a generalized system for "undroppable types". The main use for undroppable types is to be able to guarantee that some type, when destructed, always either takes or returns some kind of value.

1

I believe it should be possible to express "undroppable types" using Tmandry's design for context/capabilities. This design introduces a system similar to Python's "context manager" API. That systems enables additional arguments to be passed to specific trait impls (like we do with thread-locals today, but via function signatures), which must be in-scope when the trait is invoked. Assuming the implementation is general enough, that would enable us to require Drop impls to take extra arguments like so:

struct Tuna {}
struct Cat { name: &'static str }
impl Drop for Cat {
    fn drop(&mut self) with Tuna { // ← Requires `Tuna` to be in scope when invoked
        println!("{} is snacking on tuna", self.name);
    }
}

fn main() {
    let cat = Cat { name: "Chashu" };
    with Tuna {} {  // ← Bring `Tuna` into the `with`-scope here
        drop(cat);  // ← Prints: "Chashu is snacking on tuna"
    }
}

That should work pretty well for types which want to require specific arguments when dropped. Because if Tuna would not be in scope, the type Cat could not be dropped. Now where this falls short is if we want to require destructors to return values too. This is not a MAY return values type of deal, but a MUST return values. If returning a value was not a hard requirement, then we could just add some method and use Drop for all other case. But for true "undroppable types", it must be impossible for a type to be dropped. That means: the only way to destruct the value should be by calling the method on it. It should be possible to express this by leveraging private constructors and contexts like so:

struct Priv { _priv: () } // ← Type can only be constructed in this module
struct Loaf { _priv: () } // ← Type can only be constructed in this module

struct Cat { name: &'static str }
impl Cat {
    // Consume `Cat` and returns a sleepy little loaf
    fn nap(self) -> Loaf {
        with Priv { _priv: () } { // ← Create a context to drop `Cat`
            drop(self);           // ← Drop the `Cat` instance
            Loaf { _priv: () }    // ← Return some type
        }
    }
}

/// The type `Priv` cannot be externally constructed, meaning this
/// `Drop` impl prevents the type from being dropped outside of the
/// `Cat::nap` method.
impl Drop for Cat {
    fn drop(&mut self) with Priv {}
}

fn main() {
    let cat = Cat { name: "Chashu" };
    cat.nap(); // ✅ Compiles as expected.

    let cat = Cat { name: "Nori" };
    drop(cat); // 💥 Compiler: Cannot invoke `impl Drop for Cat`, `Priv` is not in scope.
}

I think this is pretty fun: a more general feature with wide applicability could be leveraged to solve a more niche feature with more limited applications. I bet if custom diagnostics are generalized enough, we could even improve the error message here, pointing people to call Cat::nap when they can't just drop the type.

Of course there are a number of other practical applications that come with undroppable types. The main one being the fact that both any function may panic, and panic currently equates recoverable control flow. For anyone to even attempt using undroppable types outside of a demo scenario, they'd have to grapple with that. But luckily: that again is something that can be resolved independently from this. I'm happy to be able to show that "undroppable types" does not need to be its own, standalone feature, but instead can be expressed using a more general system 2.

2

For anyone wondering to themselves: "If undroppable types are more restrictive than unleakable types, can't we just use undroppable types everywhere instead?" - The answer is, "Not practically, no". Unleakable types add minor restrictions to the language (e.g. no mem::forget, no Arc, etc.). Undroppable types add additional major restrictions (e.g. no closure captures, no holding values across ?). Some limitations are acceptable, too many limitations make the experience painful. For more on this, read my 2023 post: Linearity and Control.