未定大小值的类型转换

Lobsters Hottest 新闻

摘要

本文探讨了Rust中对未定大小值进行类型转换的挑战,与Go语言的接口动态类型进行比较,指出了Rust类型系统在处理非定大小类型方面的限制。

<p><a href="https://lobste.rs/s/5uovkh/unsizing_unsized_values">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/09/16 05:22

# 对未定义大小值的大小解除操作 - HackMD 来源: https://hackmd.io/@WorldSEnder/Hkyqni6Ofl \# "Go能做而Rust做不到的事"——对未定义大小值的大小解除操作 考虑 \[Go之旅\]\(https://go\.dev/tour/methods/16\) 中展示 \`interface{}\` 的代码:\[^2\] ```go package main import "fmt" func doit(i interface{}) { switch v := i.(type) { case int: fmt.Printf("Twice %v is %v\n", v, v*2) case string: fmt.Printf("%q is %v bytes long\n", v, len(v)) default: fmt.Printf("I don't know about type %T!\n", v) } } func main() { doit(21) doit("hello") doit(true) } ``` 我们可以询问一个 \`interface {}\` 值的类型,然后向下转型为具体类型并对其执行某些操作。天真地想,你可能认为我们可以在Rust中用\`Any\` trait做同样的事情,但其中有一些微妙的陷阱。 让我们开始思考\`doit\`应该是什么样子。我们想消费一个值,但为了生成单一函数,我们不应该使用泛型参数,而应使用某种\`dyn\`对象。这在某些领域很重要,在那里泛型参数是完全不可接受的;例如,为了让一个trait是\`dyn\`兼容的,或者如果\`doit\`本应是一个\`extern\`函数,或者为了将函数作为\`fn\`/\`Fn\`对象传递。 ```rust= use std::any::Any; fn doit(i: &dyn Any) { if let Some(v) = i.downcast_ref::<i32>() { println!("Twice {v} is {}", v*2); } else if let Some(s) = i.downcast_ref::<String>() { println!("String: {s} is {} bytes long", s.len()); } else if let Some(s) = i.downcast_ref::<&'static str>() { println!("&'static str: {s} is {} bytes long", s.len()); } else { println!("I don't know about type {:?}", i.type_id()); } } ``` 具体来说,上述第二次调用\`doit("hello")\`被证明是有问题的。 ```rust=+ pub fn main() { doit(&21); doit(&"hello".to_string()); doit(&true); } ``` 我们分配并复制了字符串,这真的很烦人!或者,我们可以向下转型为\`&'static str\`并添加一个分支。 ```rust if let Some(s) = i.downcast_ref::<&'static str>() { println!("{s} is {} bytes long", s.len()); } // ... doit(&"hello"); ``` 但请注意\`'static\`生命周期——我们实际上可以省略它,简单写成\`&str\`——这迫使我们要么泄漏字符串,要么让它成为程序持续期间的一个常量。但接受一个生命周期仅为某个持续时间的\`&str\`作为字符串是不可能的。 ```rust fn doit_for_str(s: &str) { // error[E0277]: the size for values of type `str` cannot be known at compilation time doit(s); // ^ doesn't have a size known at compile-time // help: consider borrowing the value, since `&&str` can be coerced into `&(dyn Any + 'static)` // // but this suggestion does not work! // error: lifetime may not live long enough doit(&s); // ^^ coercion requires that `'1` must outlive `'static` } ``` \# 反思 那么发生了什么?在实现强制转换时,编译器必须为\`dyn Any\`合成指针元数据,这恰好是一个\`&'static VTable\`,其中\`VTable\`值得稍后仔细看看。由于此虚函数表位于\`.rodata\`中,编译器必须在编译时从我们要强制转换的类型——这里是\`str\`——推导它,而无法使用该值。换句话说,在当前的Rust中,你只能将\`Sized\`值强制转换为\`dyn\`对象。 暂时深入编译器内部——我们稍后会依赖它——虚函数表包含四个项,其布局就像在一个\`\#[repr(C)]\`结构体中,顺序如下: - drop函数,大致签名为\`fn(\*const ())\`。由于丢弃\`str\`是简单的,这里可能是一个空操作, - 值的大小。哎呀,这个有问题。由于字符串在运行时可能具有任何大小(长度),这个无法在编译时推导, - 值的对齐。这再次没有问题,可以从类型推导——对齐是\`1\`, - \`type_id\`函数,大致签名再次为\`fn(\*const ()) -> TypeId\`。 亲爱的读者,我可能在drop函数中让你忽略了这一点,但这些函数指针并没有接收到原始的指针元数据!当编译器合成调用\`::type_id(i)\`时,它将使用指针元数据获取此函数指针,然后去掉元数据,仅传递瘦指针。由于我们不需要检查\`str\`来推导\`TypeId\`,我们再次避免了复杂性。但总的来说,原始指针元数据(即字符串长度)丢失了。\[^1\] \# 黑客攻击 我们将不得不"手动"构建虚函数表。由于我们\*必须\*在运行时构建它,因为我们需要它来存储字符串的实际大小,我们需要一个地方在栈上存储这个虚函数表。 ```rust=+ use std::any::TypeId; use std::mem::MaybeUninit; #[repr(C)] struct AnyVTable { drop: fn(*const()), size: usize, align: usize, type_id: fn(*const()) -> TypeId, } pub struct Host { vtable: MaybeUninit<AnyVTable>, } impl Host { pub fn new() -> Self { Host { vtable: MaybeUninit::uninit() } } pub fn borrow<'a>(&'a mut self, s: &'a str) -> &'a dyn Any { todo!() } } ``` 我们的目标将是实现\`borrow\`函数,它已经包含一个小谎言。返回的引用只能用于\`'a\`,这确保了我们存储在\`Host\`中的虚函数表也仍然被借用。但借助一些nightly魔法,我们可以提取指针元数据(使用\[\`std::ptr::metadata()\`\]\(https://doc\.rust\-lang\.org/std/ptr/fn\.metadata\.html\)),它忘记了生命周期,可以在借用之后使用。我建议在\`dyn\`关键字后为虚函数表添加一个生命周期来解决这个问题。 ```rust impl Host { pub fn borrow<'a, 's>(&'s mut self, s: &'a str) -> &'a dyn<'s> Any { } // ^^^^ // `dyn` could default like other lifetime to dyn<'static>, which would // have the same meaning as now, in some contexts and introduce a fresh // lifetime in other contexts (function signatures). I'm not certain this // default is necessarily what we want or need. // // In this case, the above would omit the 's lifetime by the way lifetimes // in return types are inferred to default to the lifetime of `&mut self`. // Perhaps for compat, the default should always be 'static and the explicit // form above is better. } ``` 但我跑题了,稍微暂停一下怀疑。让我们试试这个: ```rust=+ impl Host { pub fn borrow<'a>(&'a mut self, s: &'a str) -> &'a dyn Any { let vtable = self.vtable.write(AnyVTable { drop: |_| (), // str has trivial drop size: s.len(), align: 1, type_id: |_| TypeId::of::<str>(), }); #[repr(C)] struct FatPtr<'a> { thin: *const (), vtable: &'a AnyVTable, } let rep = FatPtr { thin: s.as_ptr() as *const (), vtable, }; unsafe { std::mem::transmute(rep) } } } fn doit_str(s: &str) { let mut host = Host::new(); doit(host.borrow(s)); } ``` 它运行并产生以下输出: ``` I don't know about type TypeId(0xb7381ee5f3fdfc9d7fa709f37e151622) ``` 啊,是的,因为类型id现在是\`str\`的,上述情况都不适用。我们需要一种再次向下转型的方法。如果我们尝试\`downcast\_ref\`方法,会遇到另一个障碍,主要是\`downcast\_ref\`要求\`T: Sized\`。这是因为我们之前发现的一个问题。由于通过虚函数表的调用丢弃了指针元数据,通常无法恢复它,向下转型只能对没有指针元数据的类型(即\`Sized\`类型)有效。但可惜,对于特定的\`str\`,我们实际上可以恢复我们所需的一切,特别是从给定的元数据中恢复字符串的长度。 ```rust=+ fn downcast_dyn_str(s: &dyn Any) -> Option<&str> { if s.type_id() != TypeId::of::<str>() { return None; } // On nightly we can use std::str::from_raw_parts Some(unsafe { std::str::from_utf8_unchecked( std::slice::from_raw_parts( std::ptr::from_ref(s).cast(), size_of_val(s), ) ) }) } // Add the case to doit: if let Some(s) = downcast_dyn_str(i) { println!("str: {s} is {} bytes long", s.len()); } ``` 有了这个,我们终于可以见证: ```rust doit_str("hello"); // str: hello is 5 bytes long ``` \# Miri 如果我们用\[miri\]\(https://play\.rust\-lang\.org/?version=stable&mode=debug&edition=2024&gist=0facbb927c95bebb75a16af9d8b5d200\)验证运行代码,它会声称: ``` error: Undefined Behavior: constructing invalid value of type &dyn std::any::Any: encountered 0x39858[alloc4896]<19017>, but expected a vtable pointer ``` 这是有道理的,因为我们绝对伪造了一个未被认可、不神圣的虚函数表指针。这是预料之中的。 但我想要声明的是,理论上编译器可以为我们进行这种转换,只有一个小小的注意事项。我们需要再次回顾指针元数据。如果虚函数表中的函数接收到完整的胖指针,我们就可以例如将"先前"的元数据塞进去。对于我们的用例,假设\`Host\`不仅包含一个\`AnyVTable\`,而是一个像这样的结构体: ```rust struct MetaChained { prev: usize, // str length in our case vtable: AnyVTable, } ``` 现在,胖指针仍然包含一个指向\`AnyVTable\`的指针,即结构体的第二个字段,但通过一些快速的指针运算,我们可以恢复先前的指针元数据。在编译时实例化的虚函数表可以像现在这样剥离元数据,但不是在调用虚函数表函数之前这样做,而是在内部这样做。这也会允许\`downcast\_ref::()\`。 \# 结论 这一切的意义由你来判断。我有一个\[包含进一步实验的仓库\]\(https://github\.com/WorldSEnder/unsize\-the\-unsize\),它将上述内容概括为一个\`Host\`,在与广义\`T: CoerceUnsized\`等价的假设下,将任何\`T\`强制转换为\`U\`。特别是你可以像强制转换引用一样,将\`Box<T>\`强制转换为\`Box<dyn Any>\`。除了允许用额外生命周期(如\`dyn<'a> Any\`)进行注释的语言更改外,它被声明为\`unsafe\`以防止意外误用。该实验无法支持其他强制转换,例如\`[T]\`到\`dyn Any\`,除非胖指针被传递给虚函数表函数。目前,它允许\`T: Copy\`,其中drop是简单的,我们不需要恢复切片元数据。丢失元数据的同样限制阻止了扩展到其他不太简单的trait,但它是唯一阻止我们恢复正确接收者值的东西。我不知道该找哪些合适的人来完成这件事。不过我确实希望在未来的某个时候,我至少能原生地将\`\&[T]\`强制转换为\`\&dyn Any\`,然后再\`downcast\_ref\`回来。 \[^1\]: 对于感兴趣的人,元数据剥离是编译器合成的\[\`DispatchFromDyn\`\]\(https://doc\.rust\-lang\.org/std/ops/trait\.DispatchFromDyn\.html\) trait存在的原因。\[^2\]: \`do\`已被重命名为\`doit\`,因为前者是保留关键字。

相似文章

关于整数的思考 (2023)

Lobsters Hottest

一篇博客文章,讨论了各种编程语言中整数类型的设计,认为 Rust 强制要求显式指定大小和符号的做法优于那些有默认 `int` 类型的语言。

Only Bounds

Lobsters Hottest

这篇博客文章介绍了'only bounds',这是对Rust泛型系统的一个提议改进,它用一组更丰富的大小特性代替了当前的Sized/?Sized层次结构,以适应动态大小类型和ARM's Scalable Vector Extension。

从Go迁移到Rust

Hacker News Top

一份为Go开发者迁移到Rust编写的全面指南,专注于后端服务,对比正确性、运行时和人体工程学方面的权衡,并提供关于渐进式迁移的实用建议。

擦除存在类型

Lobsters Hottest

深入探讨 Rust 类型系统中的存在量词,比较 `dyn Trait` 和 `impl Trait`,并探索超越 `Self` 的存在量化类型变量的高级模式。