How can I make my proc macro compile-error if an `async` closure captures variables?

Why

I am writing a proc macro wrapping a user-provided closure. I want compilation to fail if the user-provided closure captures from its environment.

It's for reasons specific to my use-case: The closure is supposed to be a pure function of its input parameters, so capturing is almost always a serious logical error in context. I want a compile-time check.

In short: What code could I generate that does nothing if the user's closure is like the first line, but fails to compile if it's like the second?:

let user_closure = async |a, b| a + b;
let user_closure = async |a, b| a + b + captured_variable;

Solution for non-async closures

What I already do if the user passes a non-async closure, is to attempt to cast its clone to an fn-pointer. fns can't capture, so the cast succeeds if the closure captures nothing:

let user_closure = |a, b| a + b;

// Macro-generated check:
let _: fn(_, _) -> _ = user_closure.clone(); // This compiles.

And it fails if the closure captures anything:

let captured_variable = 1;
let user_closure = |a, b| a + b + captured_variable;

// Macro-generated check:
let _: fn(_, _) -> _ = user_closure.clone(); // Compile error!

This works great!

But async closures are stable on nightly now, and I want to support them.

What I've tried

Collapsed for compactness:

Idea 1: Assign to a `fn` pointer anyway? (Doesn't work.)

async closures can't be coerced to fn pointers, even though this looks reasonable:

let user_closure = async |a, b| a + b;

// Macro-generated check:
let _: fn(_, _) -> _ = user_closure.clone();
// Compile error!

It would be neat if the compiler allowed this, but alas.

Idea 2: Programmatically rearrange the closure into `|| async {}`? (Works, but technically incorrect.)
  1. Reorganise the syn::ExprClosure of the user's closure from

    async |$params| {$body}
    

    to

    |$params| async move {$body}
    
  2. Assign that to an fn-pointer.

let user_closure = async |a, b| a + b;

// Macro-generated check:
let user_closure = |a, b| async move { a + b + something().await };
let _: fn(_, _) -> _ = user_closure;
// This compiles.
let user_closure = async |a, b| a + b + something().await;

// Macro-generated check:
let user_closure = |a, b| async move { a + b + something().await };
let _: fn(_, _) -> _ = user_closure;
// Compile error, because `something` is captured. (Good!)

This works!

However, async || {...} is not equivalent to || async {...}, so we're not actually supporting async closures, just pretending to.


Can you think of a way to do this?

const fn assert_zero_sized<T: Helper>(_: &T) {
    <T as Helper>::UNIT
}

trait Helper {
    const UNIT: ();
}

impl<T> Helper for T {
    const UNIT: () = {
        let empty = [()];
        empty[size_of::<T>()]
    };
}

fn main() {
    let captured_variable = 10;

    let c1 = async |a: i32, b: i32| a + b;
    let c2 = async |a: i32, b: i32| a + b + captured_variable;
    
    assert_zero_sized(&c1); // ok
    assert_zero_sized(&c2); // error
}
3 Likes

Oh wow! That's very clever.

It seems the compiler doesn't technically guarantee that:

Closures have no layout guarantees.
ā€‚ā€” Type layout - The Rust Reference

But it does seem unlikely to change, other than maybe a future optimisation making a closure zero-sized if it only captures zero-sized types?

(With Rust 1.79 for const blocks[1])

const fn assert_zero_sized<T>(_: &T) {
    const {
        if size_of::<T>() != 0 {
            panic!("T must be zero-sized")
        }
    }
}

fn main() {
    assert_zero_sized(&());
    assert_zero_sized(&String::new()); // doesn't compile
}
My code's error message

I like this one because it has a clear customizable string for the error, while the other mentions empty[size_of::<T>()] and <T as Helper>::Unit.

error[E0080]: evaluation of `assert_zero_sized::<std::string::String>::{constant#0}` failed
 --> src/main.rs:4:13
  |
4 |             panic!("T must be zero-sized")
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the evaluated program panicked at 'T must be zero-sized', src/main.rs:4:13
  |
  = 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:2:5
  |
2 | /     const {
3 | |         if size_of::<T>() != 0 {
4 | |             panic!("T must be zero-sized")
5 | |         }
6 | |     }
  | |_____^

note: the above error was encountered while instantiating `fn assert_zero_sized::<std::string::String>`
  --> src/main.rs:11:5
   |
11 |     assert_zero_sized(&String::new());
   |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0080`.
error: could not compile `playground` (bin "playground") due to 1 previous error
alice's code's error message
error[E0080]: evaluation of `<{async closure@src/main.rs:33:14: 33:36} as Helper>::UNIT` failed
  --> src/main.rs:25:9
   |
25 |         empty[size_of::<T>()]
   |         ^^^^^^^^^^^^^^^^^^^^^ index out of bounds: the length is 1 but the index is 8

note: erroneous constant encountered
  --> src/main.rs:15:5
   |
15 |     <T as Helper>::UNIT
   |     ^^^^^^^^^^^^^^^^^^^

note: the above error was encountered while instantiating `fn assert_zero_sized::<{async closure@src/main.rs:33:14: 33:36}>`
  --> src/main.rs:36:5
   |
36 |     assert_zero_sized(&c2); // error
   |     ^^^^^^^^^^^^^^^^^^^^^^

For more information about this error, try `rustc --explain E0080`.
error: could not compile `playground` (bin "playground") due to 1 previous error

(You just beat me to mentioning that the layout isn't guaranteed)

But importantly, checking the closure's size to be zero doesn't make it necessarily pure, because it can still access and mutate global state, which is also possible with regular fn.


  1. No, replacing the const block with a const _: () doesn't work, because the const item can't use a generic parameter from the outer scope. ā†©ļøŽ

2 Likes

Thanks!

I keep forgetting that const fns can panic. I clearly need to reach for const more often.

Good point. I was loose with the term.

I'm OK with that sort of thing being possible. Even if it can't prove correctness, the check will reduce accidents. In the context of my use-case, somebody reaching for globals from the closure body is likely to realise that they're "holding it wrong" anyway.

It also still allows the closure to capture variables as long as the captured value is zero sized.

1 Like

I would have expected that too. But it seems not?

const fn assert_zero_sized<T>(_: &T) {
    const {
        if size_of::<T>() != 0 {
            panic!("T must be zero-sized")
        }
    }
}

fn main() {
    let captured_empty = ();
    let x = async |a: i32, b: i32| {
        println!("{:?}", captured_empty);
        a + b
    };
    
    assert_zero_sized(&x); // Compile error
}

(Playground)

I've tried variations of this, and I can't get it to pass with any captured ZST, even in release mode. :person_shrugging:

It works if you do async move. The reason you need move is because closures capture a reference by default, and a &() has a size.

That said, it doesn't really matter if you figure out how to capture a ZST because, by definition, a ZST can't have any state. It's just a token.

1 Like