在改进rust代码的35种具体方法-类型(二)描述了使用特征来封装类型系统中的行为,作为相关方法的集合,并观察到有两种方法可以使用特征:作为通用的特质边界,或作为特质对象。本项目探讨了这两种可能性之间的权衡。
Rust的泛型大致等同于C++的模板:它们允许程序员编写适用于某些任意类型T
的代码,并在编译时生成通用代码的特定用途——这个过程在Rust中被称为单态化,在C++中被称为模板实例化。与C++不同,Rust以通用的特征边界的形式,在类型系统中显式编码对T
类型的期望。
相比之下,特征对象是脂肪指针(熟悉引用和指针类型),它将指向底层具体项的指针与指向vtable的指针相结合,而vtable又包含所有特征实现方法的函数指针。
这些基本事实已经允许对两种可能性进行一些直接比较:
- 泛型可能会导致更大的代码大小,因为编译器会为使用的每种类型T生成代码generic::<T>(T: &T)的新副本;一个traitobj(t: &dyn t)方法只需要一个实例。
- 从泛型调用特征方法通常比从使用特征对象的代码中调用略快,因为后者需要执行两次取消引用来找到代码的位置(特征对象到vtable,vtable到实现位置)。
- 泛型的编译时间可能会更长,因为编译器正在构建更多的代码,链接器有更多的工作要做,以折叠重复项。
在大多数情况下,这些都不是显著的差异;只有当您测量了影响并发现它具有真正的影响(速度瓶颈或有问题的占用增加)时,您才应该将与优化相关的问题作为主要决策驱动因素。
更显著的区别是,通用特征边界可用于有条件地提供方法,这取决于类型参数是否实现多个特征。
trait Drawable {
fn bounds(&self) -> Bounds;
}
struct Container<T>(T);
impl<T: Drawable> Container<T> {
// The `area` method is available for all `Drawable` containers.
fn area(&self) -> i64 {
let bounds = self.0.bounds();
(bounds.bottom_right.x - bounds.top_left.x)
* (bounds.bottom_right.y - bounds.top_left.y)
}
}
impl<T: Drawable + Debug> Container<T> {
// The `show` method is only available if `Debug` is also implemented.
fn show(&self) {
println!("{:?} has bounds {:?}", self.0, self.0.bounds());
}
}
let square = Container(Square::new(1, 2, 2)); // Square is not Debug
let circle = Container(Circle::new(3, 4, 1)); // Circle is Debug
println!("area(square) = {}", square.area());
println!("area(circle) = {}", circle.area());
circle.show();
// The following line would not compile.
// square.show();
特征对象只对单个特征的实现vtable进行编码,因此做等效的事情要笨拙得多。例如,可以为show()
案例定义组合DebugDrawable
特征,以及一些转换操作((六)-了解类型转换),以使生活更轻松。然而,如果存在不同特征的多个不同组合,很明显,这种方法的组合迅速变得笨重。
在改进rust代码的35种具体方法-类型(二)描述了使用特征边界来限制通用函数可接受的类型参数。性状边界也可以应用于性状定义本身:
trait Shape: Drawable {
fn render_in(&self, bounds: Bounds);
fn render(&self) {
self.render_in(overlap(SCREEN_BOUNDS, self.bounds()));
}
}
在本例中,render()
方法的默认实现使用特征绑定,依赖于Drawable
的bounds()
方法的可用性。
来自面向对象语言的程序员经常混淆trait边界和继承,错误地认为这样的trait边界意味着Shape是可绘制的。事实并非如此:这两种类型之间的关系最好表示为Shape also-implements Drawable。
在幕后,具有特征边界的特征的特征对象
let square = Square::new(1, 2, 2);
let draw: &dyn Drawable = □
let shape: &dyn Shape = □
有一个组合vtable 包含顶级特征的方法,以及所有特征边界的方法:
这意味着无法从Shape
to “向上转换” Drawable
,因为(纯)Drawable
vtable 无法在运行时恢复。相关特征对象之间无法进行转换,这又意味着没有利斯科夫换人。
Shape
用不同的词重复相同的点,接受特征对象的方法
- 可以使用来自的方法
Drawable
(因为Shape
also-implementsDrawable
,并且因为相关的函数指针存在于Shape
vtable中) - 无法将特征对象传递给另一个需要
Drawable
特征对象的方法(因为Shape
is-notDrawable
,并且Drawable
vtable 不可用)。
相反,接受实现项目的通用方法Shape
- 可以使用以下方法
Drawable
- 可以将项传递给另一个具有
Drawable
特征绑定的泛型方法,因为特征绑定在编译时被单态化以使用Drawable
具体类型的方法。
对特征对象的另一个限制是要求对象安全性:只有符合以下两条规则的特征才能用作特征对象。
第一个限制很容易理解:泛型方法f
实际上是无限的方法集,可能包含f::<i16>
, f::<i32>
, f::<i64>
, f::<u8>
, … 另一方面,特征对象的 vtable 很大程度上是函数指针的有限集合,因此不可能将无限夸脱装入有限品脱壶中。
第二个限制有点微妙,但往往是在实践中更经常遇到的限制——强加的特征Copy
或者Clone
特征界限立即属于此规则。要了解为什么它被禁止,请考虑手中有一个特征对象的代码;如果该代码调用(比如说)会发生什么let y = x.clone()
?调用代码需要y
在堆栈上保留足够的空间,但它不知道 的大小,y
因为Self
它是任意类型。结果,返回提到的类型1 Self
导致非对象安全的特征。
第二个限制有一个例外。如果返回某些相关类型的方法带有对其类型的显式限制,Self
则不会影响对象安全性Self
大小在编译时已知:Self: Sized
.此特征绑定意味着该方法无论如何都不能与特征对象一起使用,因为特征对象的大小显式未知(!Sized
),因此该方法与对象安全无关。
到目前为止,综合因素的影响,我们建议选择泛型而不是特征对象,但在某些情况下,特征对象是完成这项工作的正确工具。
第一个是实际的考虑:如果生成的代码大小或编译时间是一个问题,那么特征对象会执行得更好(如本项开头所述)。
导致特征对象的一个更理论的方面是它们从根本上涉及类型擦除:有关具体类型的信息在转换为特征对象时丢失。这可能是一个缺点,但它也可能很有用,因为它允许异构对象的集合——因为代码只 依赖于特征的方法,它可以调用和组合不同(具体)的方法键入的项目。
传统的OO 渲染形状列表的示例就是这样的一个例子:相同的 render()
方法可以用于同一循环中的正方形、圆形、椭圆形和星形。
let shapes: Vec<&dyn Shape> = vec![&square, &circle];
for shape in shapes {
shape.render()
}
特征对象的一个更隐蔽的潜在优势是当可用类型在编译时未知时;如果新代码在运行时动态加载(例如通过dlopen(3)),那么在新代码中实现特征的项目只能通过特征对象调用,因为没有源代码可以单态化。