Type aliases change lifetime inference in a weird way

While working on an FFI wrapper for some Rust code, I encountered a case where code breaks unexpectedly by introducing a type alias. In said wrapper, I have a bunch of structures with several levels of indirection. To avoid potential mistakes with "borrowing" a temporary accidentally, I wanted to mark those structures with lifetimes of data they borrow to expose to FFI.

Here's a minimal snippet that illustrates the issue.

use std::ffi::c_int;
use std::marker::PhantomData;

#[repr(C)]
pub struct X {
    x: c_int,
}

#[repr(C)]
pub struct Y<'x> {
    x: *const X,
    _phantom: PhantomData<&'x X>,
}

fn do_bar(cb: impl FnOnce(&Y<'_>)) {
    let x = X { x: 42 };
    let y = Y { x: &x, _phantom: PhantomData };
    cb(&y);   
}

pub extern "C" fn bar(cb: unsafe extern "C" fn(y: *const Y<'_>)) {
    do_bar(|y| unsafe { cb(y); });
}

type Cb<T> = unsafe extern "C" fn(t: *const T);

fn do_baz(cb: impl FnOnce(&Y)) {
    let x = X { x: 42 };
    let y = Y { x: &x, _phantom: PhantomData };
    cb(&y);   
}

pub extern "C" fn baz(cb: Cb<Y<'_>>) {
    do_bar(|y| unsafe { cb(y); });
}

Playgroud

By introducing a type alias for this foreign callback, I get a "borrowed data escapes outside of closure" error in baz. But to me, bar and baz are equivalent! What am I missing here? What type of aliases change in lifetime inference (I assume the problem is connected to lifetimes)?

These two are the same (the first is sugar for the second):

        unsafe extern "C" fn(y: *const Y<'_>)
for<'x> unsafe extern "C" fn(y: *const Y<'x>)

It's a "higher-ranked type". It says the function take take a &const Y<'x> for any lifetime 'x, not just one specific lifetime.

Similarly, impl FnOnce(&Y<'_>) means impl for<'r, 'x> FnOnce(&'r Y<'x>).[1]

In contrast, these are the same:

pub extern "C" fn baz(cb: Cb<Y<'_>>) {
pub extern "C" fn baz<'y>(cb: Cb<Y<'y>>) {
pub extern "C" fn baz<'y>(cb: unsafe extern "C" fn(t: *const Y<'y>)) {

Here, cb can only take Y<'y> for one specific (caller-chosen) lifetime 'y. That makes it incompatible with the signature on do_bar that requires a higher-ranked implementation.

There's no way to have a higher-ranked binder (for<'x>) start outside of an alias and apply inside an alias:

// Doesn't work
pub extern "C" fn baz(cb: for<'x> Cb<Y<'x>>) {
    do_bar(|y| unsafe { cb(y); });
}

That is, your Cb<T> alias can only work when T represents a single type. And Rust doesn't have "higher-ranked generics" or the like:

// Not valid Rust
type Cb<T<'*>> = for<'x> unsafe extern "C" fn(t: *const T<'x>);

There's some workaround with traits and GATs or GAT-emulating patterns, but they are also unergonomic and somewhat restricting, so you're probably better off just using the type directly.


  1. In this case it's a higher-ranked trait bound (HRTB) not a higher-ranked type. ↩ī¸Ž

2 Likes

Or a macro I guess.

macro_rules! hrcb {
    ($t:ty) => {
        unsafe extern "C" fn(y: *const $t)
    };
}

pub extern "C" fn baz(cb: hrcb!(Y<'_>)) {
    do_bar(|y| unsafe { cb(y); });
}
Side note

I'm a little surprised that works; this was my first version:

macro_rules! hrcb {
    ($y:ident <'_>) => {
        for<'x> unsafe extern "C" fn(y: *const $y<'x>)
    };
    ($t:ty) => {
        unsafe extern "C" fn(y: *const $t)
    };
}

But I guess there's no hygiene or whatever preventing the higher-rankedness from "penetrating" the ty token, in contrast with the alias generic type parameter situation.

Thanks for the explanation! I did end up just using function pointer types directly. The behaviour just caught me by surprise. Your explanation (and some googling based on it) cleared things up. My understanding is that some types of Rust can be higher ranked. Among those, types bounded by Fn* traits (either trait objects or generic parameters bound by the traits) and function pointers are even more special. They are higher ranked types "by default".