Making a value of a type `Undroppable` at compile time

Disclaimer: in the remainder of my post, I'll be using the currently nightly-only / unstable feature(generic_const_exprs), to keep things simple and intuitive. On stable Rust, there are two ways to polyfill them:

  • the preferred method, here, imho, would be to be using ::typenum generic types as const GENERIC: usize replacements, since by virtue of being types within which basic arithmetic operations such as + 1 have been encoded in it, it lets us write generic_const_exprs-looking statements in stable Rust. Really good fit here.

  • in other scenarios, using const within which assert!()ions are made is also possible (called a post monomorphization error). This is, however, to be used as a last resource, since:

    • the errors when these assertions fail are not always displayed to the user. Most infamously, a simple cargo check is unable to trigger post monomorphization errors (since such a command never makes it to codegen let alone monomorphization).

    • there are certain cases where Rust will allow a failing/panicking constant without causing a compilation error. Beyond the aforementioned cargo check case, consts in generic context will not cause this either, and up until the most recent versions of Rust, a failing const in dead_code/unreachable_code, such as if false { function_with_failing_const() }, would not trigger this either.

      All this, in turn, means that using these failing constants as a safeguard mechanism can be rather error-prone or flaky-ish.


You seem to have, currently, an API along the lines of:

fn demo<T>(s: Secret<T, 3, 0>)
{
    let exposed = s.expose_secret();
    let s = exposed.into_secret();
    let exposed = s.expose_secret();
    let s = exposed.into_secret();
    let exposed = s.expose_secret();
    let s = exposed.into_secret();
    
    // let exposed = s.expose_secret(); // <- Error!
}

Thanks to:

pub
fn expose_secret(self: Secret<T, MEC, EC>)
  -> ExposedSecret<T, MEC, EC>
where
    generic_const_predicate!(MEC > EC) :,
{
    ExposedSecret(self.0)
}

and:

pub
fn into_secret(self: ExposedSecret<T, MEC, EC>)
  -> Secret<T, MEC, { EC + 1 }>
{
    Secret(self.0)
}

Imho, to achieve the MEC > EC enforcement, you do not need to require .into_secret() to be called back: the user could technically get rid of the exposed secret, and nobody else gets access to it. Their loss.


But, if you really want to do it, or are just curious about how would one do it should they want to, the only solution we have at the moment, in Rust, to "guarantee" these things is by a combination of two things:

  • a scoped / callback-based API, so as to put a very specific constraint on the return type of such a closure;
  • a builder pattern / typestate pattern wherein this specific return type can only be producing by having consumed one of the closure inputs in a very specific manner.

Note that this does not guarantee lack of panics, infinite loops, or aborts etc.. What it guarantees is that if the callback returns without unwinding, then the expected function calls have taken place.

source.with(|transformed| {
    /* stuff... */
    
    // eventually, required by the callback return type
    transformed.undo() // <- required unless we `!`-diverge (e.g., panic).
});

Given the above remark, it would technically be possible to use an internal panic to avoid the requirement, coupled with a catch_unwind() to recover from it:

let mut smuggling_channel = None;
catch_unwind(|| {
  source.with(|transformed| {
    smuggling_channel = Some(transformed);

    panic!();
  })
}).ok();
let transformed = smuggling_channel.unwrap();
// It escaped!

Which is why, optionally, but customarily, the tranformed callback input / value yielded by the API is infected with a scope-emprisoned / anonymous / higher-order lifetime parameter, much like that of ::std::thread::scope().

That way, it would be illegal to smuggle out the transformed value through some out variable such as my smuggling_channel example.

Complementarily, one can also set up a abort-on-unwind guard around that callback, which is yet another advantage of these scoped APIs.

The API in question would boil down to:

  1. Add a lifetime-infecting, and invariant (effectively lifetime branding) parameter to our type:

    pub
    struct ExposedSecret<'brand, T, const MEC: usize, const EC: usize>(
        T,
        ::core::marker::PhantomData<fn(&'brand ()) -> &'brand ()>,
    );
    
  2. write our scoped API:

    pub
    fn expose_secret(
        self: Secret<T, MEC, EC>,
        scope: impl FnOnce(ExposedSecret<'_, T, MEC, EC>)
                        -> ExposedSecret<'_, T, MEC, EC>
        ,
    ) -> Secret<T, MEC, { EC + 1 }>
    where
        generic_const_predicate!(MEC > EC) :,
        Secret<T, MEC, { EC + 1 }> :,
    {
        let yield_ = scope;
        let witness = yield_(
            ExposedSecret(self.0, <_>::default())
        );
        Secret::new(witness.0)
    }
    
  3. Usage:

    fn demo<T>(s: Secret<T, 2, 0>)
    {
        let s = s.expose_secret(|exposed| {
            // ...
            exposed // <- Give it back
        });
        let s = s.expose_secret(|exposed| {
            // ...
            exposed // <- Necessary!
        });
        // let s = s.expose_secret(|exposed| { // <- Error!
        //     // ...
        //     exposed.into_secret()
        // });
    }
    

It effectively boils down to that } callback-scope-closing brace acting as an "implicit" .expose_secret(), which cannot be skipped.


Note that depending on your use case, you could then simply expose a &T in that closure, and it would be just as simple:

pub
fn expose_secret<Ret>(
    self: Secret<T, MEC, EC>,
    scope: impl FnOnce(&T) -> Ret,
) -> (Secret<T, MEC, { EC + 1 }>, Ret)
where
    generic_const_predicate!(MEC > EC) :,
{
    let yield_ = scope;
    let ret = yield_(
        &self.0
    );
    (Secret::new(self.0), ret)
}

in this design, there would simply be no ExposedSecret at all, since the scoped API already conveys all the necessary info, alone.

7 Likes