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.
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.
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.