Cursed Linear Types In Rust

Banner Image
Posted 2024-11-27
Last Updated 2024-11-29

Inspired by Jack Wrenn’s post on Undroppable Types in Rust, I set out to see if it’s possible to create types that must be used exactly once. From my understanding, those things are called linear types, but don’t quote me on that1.

Let’s see if we can create a struct UseOnce<T> which enforces that an instance is used (or consumed) exactly once. It should be impossible to consume it more than once, and it should produce a compile error if it’s not consumed at all. The first part is trivial with destructive move semantics, the second part is where we steal adapt Jack’s original idea.

Implementation

use core::mem::ManuallyDrop;
use core::mem::MaybeUninit;

pub struct UseOnce<T>(MaybeUninit<T>);

impl<T> UseOnce<T> {
    pub fn new(val: T) -> Self {
        Self(MaybeUninit::new(val))
    }

    pub fn consume<F, R>(self, f: F) -> R
    where
        F: FnOnce(T) -> R,
    {
        // (1)
        let mut this = ManuallyDrop::new(self);
        // (2)
        let mut val = MaybeUninit::uninit();
        std::mem::swap(&mut this.0, &mut val);
        unsafe {
            let val = val.assume_init();
            f(val)
        }
    }
}

impl<T> Drop for UseOnce<T> {
    fn drop(&mut self) {
        const {
        panic!("UseOnce instance must be consumed!")
        }
    }
}

fn main() {
    let instance = UseOnce::new(41);
    // (3)
    // comment out this line to get a compile error
    let _result = instance.consume(|v| v + 1);
}

Playground Link. Again, the clever part is Jack Wrenn’s original idea. I was also surprised this works. To my understanding, it relies on the fact that the compiler can reason that the drop implementation does not have to be generated when consume is called due to ①. There’s some additional unsafe trickery in ②, which is not terribly important but it’s actually safe. It allows me to use MaybeUninit<T> instead of Option<T> as the inner type so that there’s no space penalty as there could be if I had used an Option.

As is, the code compiles just fine, but if we comment out the consume below ③, it will fail with a compile error like so:

error[E0080]: evaluation of `<UseOnce<i32> as std::ops::Drop>::drop::{constant#0}` failed
  --> src/main.rs:27:9
   |
27 |         panic!("UseOnce instance must be consumed!")
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the evaluated program panicked at 'UseOnce instance must be consumed!', src/main.rs:27:9
   |
   = note: this error originates in the macro `$crate::panic::panic_2021` which comes from the expansion of the macro `panic` (in Nightly builds, run with -Z macro-backtrace for more info)

note: erroneous constant encountered
  --> src/main.rs:26:9
   |
26 | /         const {
27 | |         panic!("UseOnce instance must be consumed!")
28 | |         }
   | |_________^

note: the above error was encountered while instantiating `fn <UseOnce<i32> as std::ops::Drop>::drop`
   --> /playground/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/ptr/mod.rs:574:1
    |
574 | pub unsafe fn drop_in_place<T: ?Sized>(to_drop: *mut T) {
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0080`.

Not exactly pretty but it does the trick. Note, that the compile error is not triggered by simply running cargo check, but we need to run cargo build.

Why It’s Cursed

Unfortunately, the UseOnce<T> is not as useful or powerful as it might seem at first sight. Firstly, since the compiler error is enforced by the Drop implementation, we can just mem::forget the instance and not actually consume it. I don’t feel this is a giant problem because it’s still very explicit and arguably counts as a sort of consumption. However, there’s a more fundamental problem with linear types in Rust as pointed out by u/Shnatsel in the reddit thread for this post. Note also that I am using the term linear types somewhat loosely and incorrectly, please see this comment thread.

Secondly, the presented API allows us to “exfiltrate” the inner value of the UseOnce<T> instance by just calling consume with the identity function. To address this, we can replace the implementation of consume by two functions like so:

pub fn consume<F, R>(self, f: F) -> R
where
    F: FnOnce(&T) -> R,
{
    let mut this = ManuallyDrop::new(self);
    let mut val = MaybeUninit::uninit();
    std::mem::swap(&mut this.0, &mut val);
    unsafe {
        let val = val.assume_init();
        f(&val)
    }
}

pub fn consume_mut<F, R>(self, f: F) -> R
where
    F: FnOnce(Pin<&mut T>) -> R,
{
    let mut this = ManuallyDrop::new(self);
    let mut val = MaybeUninit::uninit();
    std::mem::swap(&mut this.0, &mut val);
    unsafe {
        let mut val = val.assume_init();
        let pinned = Pin::new_unchecked(&mut val);
        f(pinned)
    }
}

Playground Link. Calling any of these functions will still consume the instance of UseOnce<T>, but the functions only expose access to the inner value by shared or mutable reference, respectively. The borrow checker prohibits simply passing the reference to the outside. Note, that we have used the infamous Pin in the consume_mut function to express that the inner value must not be moved out of this reference2.

Thirdly, as was pointed out by u/SkiFire13 in the original reddit thread, this trick relies on the compiler’s ability to reason without optimizations that the type will not be dropped3. Thus, simply sticking a function call between the creation and consumption of the instance will make this code fail4:

fn foo() {}

fn main() {
    let instance = UseOnce::new(41);
    foo();
    let _result = instance.consume(|v| v + 1);
}

This code does not compile despite the value being consumed. You can see how this severely limits the applicability of UseOnce. There is an even more cursed remedy for that, which is using the idea of the prevent_drop crate. In that crate, a non-existing external function is linked in the Drop implementation, which moves the error to link time. That will make it work for this case but it also makes the error even uglier5.

Endnotes

  1. Unless you are quoting the title of this article which explicitly says linear types… I feel stupid now. 

  2. It’s still possible to use unsafe code that violates the semantic restrictions of Pin to do that, though. 

  3. To add insult to injury, this implementation relies on unspecified behavior of the compiler. This won’t cause runtime UB though, to my understanding. So the worst thing that can happen is that this neat trick stops compiling alltogether. Thanks to u/dydhaw for mentioning this

  4. If you want to find out why, it’s explained in the comment thread. 

  5. Plus it introduces the can of worms of how to know that a symbol name is never going to be actually linked. There are ways around that, but I don’t feel they’ll be pretty. 

Banner Image

Comments

    You can comment on this post using your GitHub account.

    Join the discussion for this article on this ticket. Comments appear on this page instantly.