未定大小值的类型转换
摘要
本文探讨了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)
一篇博客文章,讨论了各种编程语言中整数类型的设计,认为 Rust 强制要求显式指定大小和符号的做法优于那些有默认 `int` 类型的语言。
Only Bounds
这篇博客文章介绍了'only bounds',这是对Rust泛型系统的一个提议改进,它用一组更丰富的大小特性代替了当前的Sized/?Sized层次结构,以适应动态大小类型和ARM's Scalable Vector Extension。
从Go迁移到Rust
一份为Go开发者迁移到Rust编写的全面指南,专注于后端服务,对比正确性、运行时和人体工程学方面的权衡,并提供关于渐进式迁移的实用建议。
Rust 项目目标:不可移动类型与保证析构函数
Rust 项目目标概述了不可移动类型和保证析构函数的计划,以提升语言安全性和资源管理。
擦除存在类型
深入探讨 Rust 类型系统中的存在量词,比较 `dyn Trait` 和 `impl Trait`,并探索超越 `Self` 的存在量化类型变量的高级模式。