Custom vtables with integers

If I want to build a custom vtable containing function pointers, I can do this:

use std::fmt::Display;

struct Vtable {
    to_string: unsafe fn(*const ()) -> String,
}

fn vtable_for_type<T: Display>() -> &'static Vtable {
    &Vtable {
        to_string: to_string::<T>,
    }
}

unsafe fn to_string<T: Display>(the_value: *const ()) -> String {
    let my_ref = &*(the_value as *const T);
    my_ref.to_string()
}

And the above works great. Static promotion ensures that the vtable lives for the 'static lifetime.

I might also want to store some sort of computed value in the vtable, e.g. I could add a field with the struct size:

struct Vtable {
    to_string: unsafe fn(*const ()) -> String,
    value_size: usize,
}

fn vtable_for_type<T: Display>() -> &'static Vtable {
    &Vtable {
        to_string: to_string::<T>,
        value_size: compute_value_size::<T>(),
    }
}

const fn compute_value_size<T>() -> usize {
    std::mem::size_of::<T>()
}

playground

However, now static promotion now longer works!

error[E0515]: cannot return reference to temporary value
  --> src/lib.rs:9:5
   |
9  |        &Vtable {
   |  ______^-
   | | _____|
   | ||
10 | ||         to_string: to_string::<T>,
11 | ||         value_size: compute_value_size::<T>(),
12 | ||     }
   | ||     ^
   | ||_____|
   | |______returns a reference to data owned by the current function
   |        temporary value created here

Is there any way to get around static promotion no longer working in this case? Curiously, if you call std::mem::size_of directly rather than call my custom function, then it does work, but I don't understand why.

The actual value I want to put in my vtable is the offset of a field in the struct.

Interesting. The 1414-rvalue_static_promotion - The Rust RFC Book RFC claims

These rules above should be consistent with the existing rvalue promotions in const initializer expressions:

// If this compiles:
const X: &'static T = &<constexpr foo>;

// Then this should compile as well:
let x: &'static T = &<constexpr foo>;

yet

fn foo() {
    const X: &'static u32 = &f(); // <- works
    let x: &'static u32 = &f(); // <- doesn't work
}

const fn f() -> u32 {
    42
}

The typical workaround for generic consts is to use traits, and that works here as well

use std::fmt::Display;

struct Vtable {
    to_string: unsafe fn(*const ()) -> String,
    value_size: usize,
}

const fn vtable_for_type<T: Display>() -> &'static Vtable {
    trait HasVTable {
        const VTABLE: &'static Vtable;
    }
    impl<T: Display> HasVTable for T {
        const VTABLE: &'static Vtable = &Vtable {
            to_string: to_string::<T>,
            value_size: compute_value_size::<T>(),
        };
    }
    T::VTABLE
}

const fn compute_value_size<T>() -> usize {
    std::mem::size_of::<T>()
}

unsafe fn to_string<T: Display>(the_value: *const ()) -> String {
    let my_ref = &*(the_value as *const T);
    my_ref.to_string()
}

Ah, interesting. I didn't think of using traits.

If anyone knows why this happens, I would be interested in hearing the answer.

On the other hand it makes sense that there are limitations beyond what that RFC wrote. You probably wouldn't want

let x: &u32 = &(panic!() as _);

to panic at compile time, even though

static X: &'static u32 = &(panic!() as _);

compiles somewhat successfully (at least until it reports that compile-time panic).

And even more non-trivial than simple panics, non-terminating (or slow) computations would be hard to handle in non-confusing ways. (E. g.: Should static promotion be tried and then disregarding it becomes a silent fallback in case of panic or if evaluation is taking to long? But if hitting some evaluation limit turns into lifetime errors that would be weird and confusing.)

Yes, this last point from @steffahn hits the nail on the head: traditionally code used to be more aggressive with static promotion, but this incurred in problems and limitations, related to UB-in-function-or-computation being lifted to compile-time (:scream:), and the semver hazards Steffahn mentioned about introducing panics in the function-or-computation causing compile-errors.

To illustrate:

use ::core::num::NonZeroU8;

if N != 0 {
    let x: &u8 = 42 / N; // compile-error if lifted to compile-time!
    unsafe {
        let y: &NonZeroU8 = &NonZeroU8::new_unchecked(N); // UB if lifted to compile-time
    }
}

Using explicit consts thus makes it clear that the author intended for the calculation / function-call to occur at compile-time, which thus makes these branched / guarded scenarios impossible. Hence why a more ad-hoc solution to the OP would be to involve a helper trait just for the problematic value:

- value_size: compute_value_size::<T>(),
+ value_size: {
+     // Generic `const { … }` pattern:
+     struct GenericConst<T>(*mut Self);
+     impl<T> GenericConst<T> {
+         const VALUE: usize = compute_value_size::<T>();
+     }
+     GenericConst::<T>::SIZE
+ },

that being said, for the specific case of a vtable, using a "generic const" for the whole vtable value will scale better (e.g., imagine there being two value_size kind of vtable entries).


Aside: FWIW, I had always been using the generic const approach for vtables, since I had a fuzzy memory of inline versions of it not being static-promoted for one reason or another. It's only somewhat recently that somebody "proved me wrong" with vtables of only function pointers, which puzzled me quite a bit since I did remember some kind of failure about it. So this thread finally clarifies things: not all of the const-compatible values are eligible for static promotion after all! :upside_down_face:

Oh, you can use a struct, too!? That's nice, I was only aware of traits for some reason ^^

const-block also seems to make it compile! (playground)

        value_size: const { compute_value_size::<T>() },

For assoc types you do need a trait, so I imagine that was the reason :upside_down_face:

This is definitely the long-term answer.

Basically, the rules got too complicated. See the top of 2203-const-repeat-expr - The Rust RFC Book noting that we actually unaccepted it in favour of const{}.

With the explicit form, it fixes the problems mentioned above in Custom vtables with integers - #4 by steffahn -- if you want something more complex than something "obviously" fine like &10, you have to say that you need it done at compile-time, rather than leaving it up to the optimizer.

Makes me wonder…

…as far as I understand, const (as in, “can be used as the value of a const”) is about two properties of a value:

  • can be evaluated at compile-time
  • can be duplicated using a shallow copy (the concrete value can be duplicated, not any value of the same type)

With this in mind, for the use-case of array expressions, I wonder if one couldn’t rely solely on this second property while leaving the evaluation to run-time. I.e. the same checks as for const expressions is used to determine whether its okay to duplicate the value, however the problems of non-termination or panics can be left to run-time. (const { … } would still be useful to force compiler-time evaluation.)


(Of course for rvalue promotion, compile-time evaluation is essential, so this wouldn’t matter for that use-case.)

I think it's ok to stick with from_fn in std::array - Rust for the cases where re-calculating the value is ok but it doesn't need to be compile-time.