Rust 语言提供了前所未有的保护机制,其类型检查严格堪称举世无双,开发人员在使用 Rust 语言的时候能够得到优雅的可读性,但是也受到 Rust 高度抽象的烦恼。Rust 系统编程系列文章旨在深入探索 Rust 的特性,从 Rust 本身的设计哲学的视角中去理解 Rust 的细节。
本文重点探讨 trait 的机制相对比 OOP 语言的类与对象的定义之间的差异,以及深入理解 trait 的本质。
Rust 系统编程系列:
- Rust 系统编程(一):trait 的向下转型
- Rust 系统编程(二):动态分发 - 从 Box 说起
- Rust 系统编程(三):动态类型强制转换 - CoerceUnsized
- Rust 系统编程(四):self 语法糖 - Receiver
- Rust 系统编程(五):动态对象安全 - DispatchFromDyn
请注意,本文并不是trait教学,只是提供一些开发思路而已
1. rust downcast
请考虑这样的场景:我们可以很轻易的在面向对象编程语言中,例如C++、Java、Python等,对各种系统中会出现的类型进行抽象并形成复杂的类型关系,其中存在继承之后还可能继承的类型系统,这使得类型系统中各个类型间的关系呈现出树状。我们很容易地使用这种OOP思想来开展我们的工作,因为这种抽象是很自然的,十分贴近人的思维模式。这种继承关系很大程度上为我们节省了重复代码的编写,但可惜的是Rust并不是OOP的,而是更倾向于FP的。这使得我们在Rust进行继承类型抽象的时候遇到困难。所幸Rust提供了trait为我们提供多态机制。当然此多态非彼多态,Rust在trait(虚函数)的内存模型上优化了设计,使得它的虚函数访问得到加速,但是却带来了动态分发时,无法向下转型的问题。例如以下代码:
trait BaseTrait {}
struct DeriveStruct {}
impl BaseTrait for DeriveStruct {}
let d = DeriveStruct{};
let b = &d as &dyn BaseTrait;
我们可以轻易地将具体实现的d动态分发向上转型成为BaseTrait类型,但是在向下转型时,会发现困难重重,直接使用as转型会发现编译器报错了。难道rust不支持向下转型吗?
当然不,我们还是有曲线救国的办法的。例如,我们在BaseTrait中添加一个转换为子结构体的方法,强制要求子结构体实现这一方法来获取向下转型的接口。如下:
trait BaseTrait {
fn as_derive_struct(&self) -> &DeriveStruct;
}
struct DeriveStruct {}
impl BaseTrait for DeriveStruct {
fn as_derive_struct(&self) -> &DeriveStruct {
self
}
}
但是它总是看起来不是那么优雅,不是吗?从面向对象的角度而言,从基类到子类的转换似乎有点奇怪,并且每次有新的子类继承的时候,我们就必须要为基类新增接口,而原有的子类根本不可能合法地使用这个接口。
这时候,在core中定义的Any
trait就诞生了,这个trait定义在core::any::Any。这个trait旨在解决类似上述的动态分发问题。它使用起来会类似这样:
use core::any::Any;
trait AsAny {
fn as_any(&self) -> &dyn Any;
}
trait BaseTrait : AsAny {}
struct DeriveStruct;
impl AsAny for DeriveStruct {
fn as_any(&self) -> &dyn Any {
self
}
}
impl BaseTrait for DeriveStruct {}
let d = DerivedStruct;
let b = &d as &dyn BaseTrait;
let s = b
.as_any()
.downcast_ref::<DerivedStruct>()
.expect("unable to downcast");
这样看起来好多了,即便它看起来有点麻烦,仍然不如OOP式的编程语言那样能自然地进行转型,但是别忘了我们的Rust是FP式编程语言。
这个时候仍然不能皆大欢喜,我们会发现仍然有些问题,downcast方法只提供了一种向下转型为能够在编译器确定大小的类型,这意味着你无法转型为另一种trait。例如:
trait Base1 : AsAny { ... }
trait Base2 : Base1 { ... }
struct Derive;
impl AsAny for Derive ... ...
impl Base1 for Derive ... ...
impl Base2 for Derive ... ...
let d = Derive;
let b1 = &d as &dyn Base1;
let b2 = b1
.as_any()
.downcast_ref::<dyn Base2>()
.expect("unable to downcast");
这时,你发现编译器会报错,因为Base2不是可以静态编译器可确定大小的类型。
the size for values of type
dyn Base2
cannot be known at compilation time the traitSized
is not implemented fordyn Base2
如果我们试图用面向对象的概念去解释我们的代码时,我们很明确的是Derive的确继承自Base2,而Base2依赖于Base1,某种意义上也是一种继承,为何不能够转型呢?
实际上,这种想法是不对的,一个结构体所实现的各个trait之间是平行关系,并不像真正的OOP语言那样存在树状甚至网状的继承关系。我们可以看到我们是亲自为Derive实现了Base1和Base2,而非直接继承而来。这样的特性使得我们在考虑使用OOP的方法去使用Rust语言时,带来许多问题。
这里
2. rust 动态类型与一种不安全的 downcast 方法
前一节讲述了rust的向下转型的注意事项,本节试图深入探索rust的trait的内存模型,然后在最后能够自由地去使用trait,并提供一种不安全的downcast方法
之所以说提供一种
不安全
的downcast方法,是因为我们试图通过解析rust的内存模型来实现我们自己的downcast,这种行为某种程度上突破了编程界限,而深入到编译器内部实现当中,因而我在这里必须要强调,这种方法不一定在将来的rust版本中可用,也不一定在老版本中兼容。笔者在此使用的rust版本是:
> rustc 1.82.0-nightly (c6db1ca3c 2024-08-25)
2.1. DST
DST 即 Dynamically Sized Type。在讲述trait的内存模型之前,我希望说明一下什么是DST。
在rust中,我们看到了在C/C++中也存在的指针概念,这提示我们可以自己管理内存。但是rust的指针与我们所熟悉的指针是有区别的。在rust中usize
类型用以表示目标架构的指针长度类型,即64位平台上的usize
是8字节,而一个指针*mut ()
通常来说也是8字节,到此为止和我们在C/C++中所知的并无不同。但是,你会发现rust中存在一些特殊的类型,例如&str
、&[u8]
这样的类型似乎有点特别,它们作为一种引用,本身应当理解为编译后成为一个指针,但是你发现你能够使用它们调用类型len()的方法以获取长度,这和我们熟知的指针有些不同。在C中,我们知道一个字符指针char*
,它可以指向一个字符数组,但是我们不可能试图只通过char*
就知道它指向的数组的长度,但是为什么rust中的字符串字面量的借用&str
能够知道字符串的长度呢?这里归功于rust使用了胖指针(fat
pointer)的概念,胖指针是指一种大小为通常指针两倍的指针,即如果我们将通常的指针理解为一个一元元组结构体,那么胖指针就是二元元组结构体。
struct NormalPointer(usize);
struct FatPointer(usize, usize);
这样一来,一个&str
的借用如果看成一个指针,它将是胖指针,这个指针(二元组)包含的第一个数据是指针指向的对象的地址,第二个数据是它指向对象的大小。于是我们知道了,rust通过这种方式巧妙的隐藏了对象大小,并且我们可以在动态运行时获取它!
像上述这种胖指针指向的对象类型就是DST。指向DST的指针都是胖指针。对于&str
和&[T]
指针来说,第二个数据是对象的大小;而对于&dyn SomeTrait
来说,第二个数据是虚函数表的地址。在rust官方定义
指针元数据
。对于非胖指针,即普通指针来说,元数据也是存在的,只不过它是()
类型。
2.2. trait 指针
上节讲述了Rust中的指针,这里再做一下梳理。
总的来说,指针有两种,一是Thin Pointer
,这也是在C/C++中的传统意义的指针(这里为了方便我将它称为瘦指针);另一种是Fat Pointer
,这种指针的大小是的瘦指针的两倍,实际上就是有两个和瘦指针大小一样的数据,其中一个是对象地址,另一个是对象元数据。
而从指针元数据的角度来说,不管瘦指针还是胖指针都有元数据,只不过瘦指针的元数据类型是()
,不会实际占用空间,而胖指针的元数据有两种,一种是指针指向的切片对象[T]
的大小,另一种是trait对象的虚函数表地址。所以总结起来,目前指针元数据一共三类:()
、[T]对象大小
、trait object虚函数表地址
。
对于 trait 对象的指针来说,其指向的 trait 对象实际上不能算是一个结构体,也不存在内部数据,因而一个 trait 对象实际上只是一组虚函数表而已。那么对于一个指向 trait 对象的指针来说,其第一个数据是什么呢?其实联系起第一节我们使用Any trait进行downcast之后,就可以猜测的出,这第一个数据的指针指向的正是原对象,换句话说,我们在进行向上转型的时候,指针的第一个数据是不会变的,所以 trait 对象指针的数据排布应当如下:
第一个数据 | 第二个数据 |
---|---|
原对象地址 | trait 对象虚函数表地址 |
利用这一点,我们可以试图获取其第一个数据,将其强制转型成为“子类”类型,这样就完成了我们的强制downcast,实际上Any trait的转型也是如此实现的。
需要提一点特殊情况:如果原对象类型是单元结构体,那么此时由于没有内部数据,结构体指针本身就是随机的,或者说没有意义的,会导致upcast之后的指针第一个数据也是无意义的/随机的。这时强制转型需要你自己考虑清楚在做些什么。