4月3号,rust的1.86.0稳定版正式发布。官网博客上介绍引入的新特性时首先介绍的就是trait upcasting这一特性。
什么是up/down casting
先不说这个新特性有什么用,首先解释一下这个单词是什么意思。实际上熟悉现代一点的、基于类的面向对象编程语言(后面就省略这么多定语了,懂的自然懂)的朋友对这对概念应该不会陌生。简单来说它指的就是一种类型转换,特指从基类(抽象一点的类)到子类(更具体的类)的转换(downcast),反过来叫upcast。
例如在现代一点的面向对象编程语言中:
Animal a = new Animal();
a = new Dog();
Dog d = a as Dog;
在这个经典的类层次结构中,假设Dog继承了Animal。很多语言中,把子类实例放到一个需要基类实例的地方(upcast)是默认有效的,且不用进行任何显式的转换操作。因为这样的操作在这样的类型系统中总是安全的。
反过来将一个Animal给一个需要的Dog的地方(downcast)则不总是安全的。因为一个基类很可能有若干子类,并不能保证它就是你需要的那个子类。
trait和interface
初识rust时,你很有可能会觉得trait不就是那些面向对象语言里的接口吗(也可能是协议)?
当然,在很多地方,确实如此。但是在另一些地方你没法把它当成接口那样使用。比如在面向对象语言中,这样的操作是非常自然的:
object CloneAnything(ICloneable cloneable)
{
return cloneable.Clone();
}
class Animal(string Name) : ICloneable
{
public object Clone()
{
return new Animal(Name);
}
}
这里的ICloneable是.NET标准库中的一个接口,只需要实现一个返回object的Clone方法即可,用来表示此类型是可以克隆一个副本的。
使用接口作为变量(参数也一样)类型的好处就是可以表达我们只关心此接口提供的功能而不关心具体的实现。并且在未来可以接受任意实现此接口的类型的实例。
当然,如果你觉得在外面再转换一次类型有点麻烦,你也可以写个泛型版本:
T CloneAnythingGeneric<T>(T cloneable) where T: class, ICloneable
{
return cloneable.Clone() as T;
}
接口“继承”
不少语言的接口也允许“继承”。打引号的原因是因为这不算继承,只是用了和类继承差不多的语法来表示一种约束。比如interface IIterface: IRequire这样的定义只是说你如果要实现IInterface就必须同时实现IRequire。
这里之所以要提这一点就是因为rust虽然没有给面向对象编程提供那么多语法上的基础设施,但是它的trait也用了类似的语法来表达这种约束:
trait T{}
trait U: T{}
T是U的supertrait。这就意味着在实现U的时候必须同时实现T。
如果换成trait呢
然而,如果你想在rust中用trait来做类似的事情并没有那么简单直接。正巧rust标准库也有一个和ICloneable几乎完全一致的trait——Clone。
然而,你没法写一个那么直接的返回object的函数,毕竟rust中没有所谓的超级基类。实际上你可以做的其实和上面泛型的C#方法非常类似:
fn clone_anything<T>(t: &T) -> T where T: Clone {
t.clone()
}
这个函数是完全合法的。由于类型约束的存在,必然可以调用t上的clone方法得到一个副本。
在C#等语言中,下面的写法是完全没问题的:
ICloneable c = new Animal();
在rust中你却不能这样写:
let c: Clone = Animal::new();
这里实际上涉及几个问题。
trait对象
在语法上,rust 2021之后的版本不允许直接把trait直接当成类型使用,而是需要在trait名前加上dyn关键字。但是,dyn Clone也没法作为一个变量的类型,因为dyn T的大小是不确定的。这样的类型在rust中称为trait object type。和其他大小不确定的类型一样,要使用各种指针(和引用)把它包装起来才能正常使用:
let c: &dyn Clone = &Animal::new();
Dyn兼容性
实际上你还是没法去声明一个类型为&dyn Clone的变量。因为trait对象类型不允许dyn后面的类型要求实现Sized。
除了对trait本身的约束要求之外,还对trait中的方法的接收者类型有要求。
无论是用接口类型的参数还是trait对象类型的参数,我们实际上都是为了能够实现多态分派(dynamic dispatch)。也就是在运行时决定到底要调用哪个具体的方法。
完整的要求可以参见rust的语言参考。
另外dyn兼容性之前叫做“对象安全”(object safety)。相比之下这个新名字还是更清楚一些。
Rust的object类型
rust真的没有类似于object的类型可以容纳任意类型的对象吗?硬要说,那还是有。
标准库中有一个叫std::any::Any的trait。甚至于它的文档都说得非常直白:
A trait to emulate dynamic typing.
就是“模拟动态类型的trait”。
虽然Any是个trait,但是不需要手动实现。Any只要求你的struct中没有非'static的引用存在。一般的类型(的各种指针类型)可以直接转换成指向dyn Any的类型:
use std::any::Any;
let a: &dyn Any = &Animal{}; // 可以但不需要写成&Animal{} as &dyn Any
这里的a和&Animal{}的类型明显不一样,但是不需要显式转换,此时发生的是coercion。Coercion在rust的语境中指的就是针对一些类型的隐式转换。当然,所有的coercion也允许使用as显式表明。
检查dyn Any是否指向某个类型的对象
各种面向对象语言通常都能够检查某个变量是否指向某个具体的类型:
Animal a = new Animal();
if (a is Dog)
{
Console.WriteLine("It's a dog");
}
在rust中你使用Any的原因很有可能就是需要确定某个东西到底是不是某个具体的类型:
let a: &dyn Any = &Animal{};
assert!(a.is::<Animal>());
Any中的is方法可以检查trait对象背后的对象到底是不是某个类型。但是,请注意,这个方法不能检查它是不是实现了某个trait。你完全可以在is的类型参数里填上&dyn T,但是目前来说它不会报错也不会返回你期望的结果。
用Any进行downcast
从所谓的软件工程方面来说,理想状况下我们既然使用了接口或者trait就不应该考虑它背后到底是一个什么具体类型。但是实际上你很可能会遇到想要特殊处理的情况。
面向对象语言同样提供了从接口到具体类型的检查、转换设施。Any也提供了这样的接口。
let a: &dyn Any = &Animal{};
assert!(a.downcast_ref::<Animal>().is_some());
downcast_ref顾名思义将一个&dyn Any向下转换成对具体实现类型的对象的引用。由于转换可能失败,类似于其他有null的语言,这里返回的是Option<&T>。同样由于rust的设计,这个方法还有mut版。
更进一步的抽象
抽象基类在面向对象编程中并不少见。一些基类因为过于抽象而没有构造其实例的意义。比如刚才的Animal就完全可以定义为抽象基类,毕竟没有那么抽象的”动物“,或者说我们没必要构造一个”动物“实例。
Rust中完全可以用一个抽象程度较高的trait作为所谓”抽象基类“,然后其”子类“以其作为super trait。例如:
trait Animal {}
trait Mammal: Animal{}
struct Dog{}
impl Animal for Dog{}
impl Mammal for Dog{}
从任意trait对象downcast
downcast相关方法定义在Any上,但是我们会希望能从任意自定义的trait对象上进行类型检查和downcast该如何呢?毕竟在面向对象语言中,我们通常也可以尝试将一个接口类型的变量转换成具体的类型。
无论如何,这意味着我们首先要把这个trait对象转换到dyn Any才能进一步转换。在1.86之前,即使trait T要求实现Any,dyn T也无法直接转换成dyn Any。因此我们需要耍点小把戏来少些点代码。
trait AsAny: Any {
fn as_any(&self) -> &dyn Any;
// 如有需要你还可以加上其它mut、指针版本
}
impl<T: Any> AsAny for T {
fn as_any(&self) -> &dyn Any {
self
}
}
意思就是说虽然dyn T无法转换成dyn Any,但是我可以要求trait T: AsAny。如果实现trait T的struct S同时满足Any和AsAny,那么就可以通过as_any的实现把&S转换成&dyn Any。这本质上和&S{} as &dyn Any是一样的。
Rust的接口方法支持编写“默认实现”以减少样板代码(boilerplate)。当然实际上这里的写法叫做blanket implementation(通用实现),和直接在trait方法定义处直接编写实现稍微有点区别。这里定义了一个叫AsAny的trait,唯一的作用就是用默认实现让任何实现了Any的类型都可以转换成&dyn Any从而可以调用Any上的各种方法:
trait Animal: AsAny{}
struct Dog {}
impl Animal for Dog{}
let d: &dyn Animal = &Dog{};
assert!(d.as_any().is::<Dog>());
这里的Animal模拟一个抽象基类。我们给它加上了AsAny的约束。但是由于AsAny的blanket实现的存在,我们不需要手动为一个实现了Animal的类型手动实现AsAny。所以相对来说还是比较方便。
1.86.0之后
很有意思的小把戏,但是随着1.86.0的推出,我们不是那么需要这个AsAny了。
1.86.0之后的版本是允许将某个trait对象转换成supertrait对象:
use std::any::Any;
trait Animal: Any{}
trait Mammal: Animal{}
struct Dog {}
impl Animal for Dog{}
impl Mammal for Dog{}
fn main() {
let mammal: &dyn Mammal = &Dog{};
let animal: &dyn Animal = mammal;
let a: &dyn Any = animal;
assert!(a.is::<Dog>());
}
由于trait之间的约束关系,这里可以直接将&dyn Mammal隐式转换成了&dyn Animal,最后又成功转换成了&dyn Any。实际上也可以一步到位从&dyn Mammal转换成&dyn Any,这就是1.86引入的新特性带来的好处。