编译时泛型类型大小检查

25
我正在尝试编写 Rust 绑定 C 集合库(Judy Arrays [1]),该库只提供了存储指针宽度值的空间。我的公司有相当多的现有代码,使用这个空间直接存储非指针值,例如指针宽度整数和小结构体。我希望我的 Rust 绑定能够使用泛型实现安全访问此类集合,但是在正确处理指针隐藏语义方面遇到了困难。
我已经使用 std::mem::transmute_copy() 实现了基本接口来存储该值,但是该函数明确没有做任何事情来确保源类型和目标类型具有相同的大小。我能够通过断言验证集合类型参数在运行时具有兼容的大小,但我真的希望检查能够在编译时进行。
示例代码:
pub struct Example<T> {
    v: usize,
    t: PhantomData<T>,
}

impl<T> Example<T> {
    pub fn new() -> Example<T> {
        assert!(mem::size_of::<usize>() == mem::size_of::<T>());
        Example { v: 0, t: PhantomData }
    }

    pub fn insert(&mut self, val: T) {
        unsafe {
            self.v = mem::transmute_copy(&val);
            mem::forget(val);
        }
    }
}

有没有更好的方法来处理这个问题?或者说,这种运行时检查是Rust 1.0支持的最佳方式吗?
(相关问题,请参见此处,解释为什么我不使用mem::transmute()。)
[1] 我知道已经存在rust-judy项目,但它不支持我想要的指针存储方式,而且我编写这些新绑定主要是作为学习练习。

1
这个不行。它只复制了 val 的第一个单词并将其存储在 v 中。哦,如果你想要存储一个指针,请存储指向实际存在的东西的指针,比如指向 Box<T> 中的 T 的指针。 - bluss
只要val的类型恰好是一个单词大小,我想要复制出val的第一个单词。这里的目标是使用以这种方式存储的数据与现有的C代码进行FFI交互。 - llasram
我认为目前 Rust 无法对 T 的大小进行一般性的限制。然而,断言当然是单态化并在编译时编译的,因此至少没有开销。 - bluss
关于assert!在编译时被解析为无操作或panic!的观点很好。如果这种运行时检查实际上是Rust 1.0能做到的最好的,我会接受它作为答案! - llasram
1
你也可以编写一些包含那些 assert!#[test] - porglezomp
3个回答

9

注意: 对于 Rust 1.57 及更高版本,请参见此答案


编译时检查?

有更好的方法来做这件事吗?或者说,这个运行时检查是 Rust 1.0 支持的最好的方式吗?

一般来说,有一些巧妙的解决方案可以对任意条件进行编译时测试。例如,static_assertions crate 提供了一些有用的宏(包括一个类似于 C++ 的 static_assert 宏)。然而,这是 hacky非常有限的。

在您的特定情况下,我没有找到一种方法来在编译时执行检查。根本问题在于您无法在通用类型上使用mem::size_ofmem::transmute。相关问题:#43408#47966。因此,static_assertions crate也不起作用。
如果您仔细思考一下,这也会导致Rust程序员非常陌生的一种错误:使用特定类型实例化通用函数时出错。这对于C++程序员来说是众所周知的——Rust的trait bounds用于修复这些经常非常糟糕和无用的错误消息。在Rust世界中,您需要将您的要求指定为trait bound:类似于where size_of::<T> == size_of::<usize>()
然而,目前这是不可能的。曾经有一个相当著名的“常量相关类型系统”"(const-dependent type system) RFC",它可以允许这些种类的限制,但目前已经被拒绝。对于这些功能的支持正在缓慢而稳步地推进。“Miri”在一段时间前被合并到编译器中,允许更强大的常量计算。这是许多事情的推动者,包括"(Const Generics RFC)",实际上已经被合并。它还没有被实现,但预计将在2018年或2019年实现。
不幸的是,它仍然不能实现你所需的那种限制。比较两个常量表达式是否相等故意被省略在主要RFC之外,将在未来的RFC中解决。
因此,可以预期类似于 where size_of::<T> == size_of::<usize>() 的约束最终将成为可能。但是这不应该在不久的将来出现!

解决方案

在您的情况下,我可能会引入一个不安全的特质AsBigAsUsize。为了实现它,您可以编写一个名为impl_as_big_as_usize的宏来执行大小检查并实现这个特质。也许可以像这样:

unsafe trait AsBigAsUsize: Sized {
    const _DUMMY: [(); 0];
}

macro_rules! impl_as_big_as_usize {
    ($type:ty) => {
        unsafe impl AsBigAsUsize for $type {
            const _DUMMY: [(); 0] = 
                [(); (mem::size_of::<$type>() == mem::size_of::<usize>()) as usize];
            // We should probably also check the alignment!
        }
    }
}

这个使用的基本上是与static_assertions相同的诡计。这是有效的,因为我们从未在通用类型上使用size_of,而只在宏调用的具体类型上使用。
所以...显然这还远非完美。您的库用户必须针对每种要在数据结构中使用的类型调用impl_as_big_as_usize一次。但至少它是安全的:只要程序员仅使用该宏来实现trait,则该trait实际上仅针对与usize大小相同的类型实现。此外,“trait bound AsBigAsUsize is not satisfied”这个错误非常易于理解。

运行时检查是什么?

正如bluss在评论中所说,在您的assert!代码中,由于优化器对检查进行了常量折叠,因此不存在运行时检查。让我们通过以下代码测试该语句:

#![feature(asm)]

fn main() {
    foo(3u64);
    foo(true);
}

#[inline(never)]
fn foo<T>(t: T) {
    use std::mem::size_of;

    unsafe { asm!("" : : "r"(&t)) }; // black box
    assert!(size_of::<usize>() == size_of::<T>());
    unsafe { asm!("" : : "r"(&t)) }; // black box
}

疯狂的asm!()表达式有两个目的:

  • t从LLVM“隐藏”,使LLVM无法执行我们不想要的优化(如删除整个函数)
  • 标记我们将查看的生成ASM代码中的特定位置

使用夜间编译器(在64位环境下!)进行编译:

rustc -O --emit=asm test.rs

通常情况下,生成的汇编代码很难阅读;以下是一些重要的地方(进行了一些清理):

_ZN4test4main17he67e990f1745b02cE:  # main()
    subq    $40, %rsp
    callq   _ZN4test3foo17hc593d7aa7187abe3E
    callq   _ZN4test3foo17h40b6a7d0419c9482E
    ud2

_ZN4test3foo17h40b6a7d0419c9482E: # foo<bool>()
    subq    $40, %rsp
    movb    $1, 39(%rsp)
    leaq    39(%rsp), %rax
    #APP
    #NO_APP
    callq   _ZN3std9panicking11begin_panic17h0914615a412ba184E
    ud2

_ZN4test3foo17hc593d7aa7187abe3E: # foo<u64>()
    pushq   %rax
    movq    $3, (%rsp)
    leaq    (%rsp), %rax
    #APP
    #NO_APP
    #APP
    #NO_APP
    popq    %rax
    retq

#APP-#NO_APP对是我们的asm!()表达式。

  • foo<bool>情况:您可以看到我们的第一个asm!()指令被编译,然后进行了一个无条件调用panic!(),之后什么也没有(ud2只是说“程序永远不会到达这个位置,panic!()分歧”)。
  • foo<u64>情况:您可以看到两个#APP-#NO_APP对(两个asm!()表达式),中间没有任何东西。

所以,编译器完全删除了检查
如果编译器拒绝编译代码会更好。但这样我们至少知道没有运行时开销。

如果你仔细想一下,这也会允许 Rust 程序员非常不熟悉的一种错误 - 显然这种事情现在几年后终于成为可能了(https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=505d2d7da0c5511c5012b9f7863ae8d1),因为计算常量可能会失败。 - user4815162342

4
自 Rust 1.57 版本开始,安全代码中已经可以进行编译时检查。截至本文撰写(Rust 1.67),可以通过在函数外部使用一个中间编译时常量来实现。以下是实现方式:
pub struct Example<T> {
    pub v: usize,
    pub t: PhantomData<T>,
}

impl<T> Example<T> {
    const SIZE_OK: () = assert!(size_of::<T>() == size_of::<usize>());

    pub fn new() -> Example<T> {
        let _ = Self::SIZE_OK;
        Example {
            v: 0,
            t: PhantomData,
        }
    }
}

pub struct Good(usize);
pub struct Bad(u8);

fn main() {
    let _e1 = Example::<Good>::new();  // compiles
    //let _e2 = Example::<Bad>::new(); // doesn't compile
}

游乐场


0

与被接受的答案相反,您可以在编译时进行检查!

诀窍是在使用优化编译时,在死代码路径中插入对未定义的 C 函数的调用。如果您的断言失败,您将获得链接器错误。


7
虽然这个答案可能正确也可能不正确,但它当前的表述过于笼统且缺乏例子,难以确定其准确性,这也意味着对于某些人来说,很难真正利用这个建议。 - Shepmaster

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接