继续 trait 相关。
回顾:类型参数出现的地方。
不同位置的 T,基础意义都是类型参数,但其侧重的意义不同。
这节是另外一个东西,里面也带 T 参数。与之前几种形式有什么不同?
trait 上带类型参数:
trait TraitA {}
这个 trait 里面的函数或方法,会用到这个类型参数 T 。定义 trait 时,还没确定这个类型参数的具体类型。等到 impl 甚至使用类型方法时,才会具体化这个 T 的具体类型。
注意,这时 TraitA
实现时要在 impl 后面先定义类型参数:
impl TraitA for Atype {}
也可以在对类型实现时,将 T 参数具体化:
impl TraitA for Atype {}
如果被实现的类型上自身也带类型参数,那情况会更复杂。
trait TraitA {}
struct Atype {
a: U,
}
impl TraitA for Atype {}
这些类型参数都可以在 impl 时被约束:
use std::fmt::Debug;
trait TraitA {}
struct Atype {
a: U,
}
impl TraitA for Atype
where
T: Debug, // 在 impl 时添加了约束
U: PartialEq, // 在 impl 时添加了约束
{}
注:以上代码都在 playground 中编译通过。
具体的实例,体会带类型参数的 trait 的威力。
要实现一个模型:
本例借鉴了:https://github.com/pretzelhammer/rust-blog/blob/master/posts/tour-of-rusts-standard-library-traits.md#generic-types-vs-associated-types
代码:
// 定义一个带类型参数的trait
trait Add {
type Output;
fn add(self, rhs: T) -> Self::Output;
}
struct Point {
x: i32,
y: i32,
}
// 为 Point 实现 Add 这个 trait
impl Add for Point {
type Output = Self;
fn add(self, rhs: Point) -> Self::Output {
Point {
x: self.x + rhs.x,
y: self.y + rhs.y,
}
}
}
// 为 Point 实现 Add 这个 trait
impl Add for Point {
type Output = Self;
fn add(self, rhs: i32) -> Self::Output {
Point {
x: self.x + rhs,
y: self.y + rhs,
}
}
}
fn main() {
let p1 = Point { x: 1, y: 1 };
let p2 = Point { x: 2, y: 2 };
let p3 = p1.add(p2); // 两个Point实例相加
assert_eq!(p3.x, 3);
assert_eq!(p3.y, 3);
let p1 = Point { x: 1, y: 1 };
let delta = 2;
let p3 = p1.add(delta); // 一个Point实例加一个i32
assert_eq!(p3.x, 3);
assert_eq!(p3.y, 3);
}
详细解释:
Add
对 Point 类型,实现了两个 trait:Add
根据需求,运算后的类型也是 Point,故两个 trait 中的关联类型都是 Self。注意:两个 trait 中实现的不同算法。
通过这种形式,在同一个类型上实现了同名方法(add 方法)参数类型的多种形态。即:Point 实例的 add 方法既可以接收 Point 参数,又可以接收 i32 参数,Rustc 可以根据不同的参数类型自动找到对应的方法调用。
在 Java、C++ 中,有语言层面的函数重载特性来支持这种功能。Rust 并不直接支持函数重载特性,但用 trait 就轻松实现了同样的效果(全新的思路)。
定义带类型参数的 trait 的时候,可以为类型参数指定一个默认类型。
如 trait TraitA
例子。
// Self可以用在默认类型位置上
trait TraitA {
fn func(t: T) {}
}
// 这个默认类型为i32
trait TraitB {
fn func2(t: T) {}
}
struct SomeType;
// 这里省略了类型参数,所以这里的T为Self
// 进而T就是SomeType本身
impl TraitA for SomeType {
fn func(t: SomeType) {}
}
// 这里省略了类型参数,使用默认类型i32
impl TraitB for SomeType {
fn func2(t: i32) {}
}
// 这里不省略类型参数,明确指定类型参数为String
impl TraitA for SomeType {
fn func(t: String) {}
}
// 这里不省略类型参数,明确指定类型参数为String
impl TraitB for SomeType {
fn func2(t: String) {}
}
默认参数给表达上带来了一定程度的简洁,但增加了初学者识别和理解上的困难。
上一节课讲关联类型时我们提到过,在使用约束时可以具化关联类型。那里也是用的=号。如:
trait TraitA {
type Item;
}
// 这里,定义结构体类型时,用到了TraitA作为约束
struct Foo> {
x: T
}
初看这里容易混淆。区别:关联类型的具化是在应用约束时,类型参数的默认类型指定是在定义 trait 时,通过 trait 出现的场景可以区分。
trait 上的类型参数和关联类型都具有延迟具化的特点,区别是什么呢?为什么要设计两种不同的机制呢?
首先要明确的一点是,Rust 本身也在持续演化过程中。有些特性先出现,有些特性是后出现的。最后演化出功能相似但是不完全一样的特性是完全有可能的。
具体有两点不同:
第一点的示例:
use std::fmt::Debug;
trait TraitA
where
T: Debug, // 定义TraitA的时候,对T作了约束
{
fn play(&self, _t: T) {}
}
struct Atype;
impl TraitA for Atype
where
T: Debug + PartialEq, // 将TraitA实现到类型Atype上时,加强了约束
{}
fn main() {
let a = Atype;
a.play(10u32); // 在使用时,通过实例方法传入的参数类型具化T
}
几个要点。
当然,在 impl 的时候也可指定成 u32 类型:
use std::fmt::Debug;
trait TraitA
where
T: Debug,
{
fn play(&self, _t: T) {}
}
struct Atype;
impl TraitA for Atype {} // 这里具化成了 TraitA
fn main() {
let a = Atype;
a.play(10u32);
}
但这样就没前面那么灵活了,如 a.play(10u64) 就不行。
第二点:对关联类型,如在 impl 时不对其具化,就无法编译通过。
trait Add {
type ToAdd; // 多定义一个关联类型
type Output;
fn add(self, rhs: Self::ToAdd) -> Self::Output;
}
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type ToAdd = Point;
type Output = Point;
fn add(self, rhs: Point) -> Point {
Point {
x: self.x + rhs.x,
y: self.y + rhs.y,
}
}
}
impl Add for Point { // 这里重复impl了同一个trait,无法编译通过
type ToAdd = i32;
type Output = Point;
fn add(self, rhs: i32) -> Point {
Point {
x: self.x + rhs,
y: self.y + rhs,
}
}
}
fn main() {
let p1 = Point { x: 1, y: 1 };
let p2 = Point { x: 2, y: 2 };
let p3 = p1.add(p2);
assert_eq!(p3.x, 3);
assert_eq!(p3.y, 3);
let p1 = Point { x: 1, y: 1 };
let delta = 2;
let p3 = p1.add(delta); // 这句是错的
assert_eq!(p3.x, 3);
assert_eq!(p3.y, 3);
编译器出错:
error[E0119]: conflicting implementations of trait `Add` for type `Point`:
--> src/main.rs:23:1
|
12 | impl Add for Point {
| ------------------ first implementation here
...
23 | impl Add for Point {
| ^^^^^^^^^^^^^^^^^^ conflicting implementation for `Point`
对 Point 类型实现了多次 Add,冲突。编译不通过。所以这个模型仅用关联类型来实现,是写不出来的。
从一个函数要返回不同的类型说起。
如果写成返回固定类型的函数签名,那就只能返回那个类型:
struct Atype;
struct Btype;
struct Ctype;
fn doit() -> Atype {
let a = Atype;
a
}
第一个办法---用 enum。
struct Atype;
struct Btype;
struct Ctype;
enum TotalType {
A(Atype), // 用变体把目标类型包起来
B(Btype),
C(Ctype),
}
fn doit(i: u32) -> TotalType { // 返回枚举类型
if i == 0 {
let a = Atype;
TotalType::A(a) // 在这个分支中返回变体A
} else if i == 1 {
let b = Btype;
TotalType::B(b) // 在这个分支中返回变体B
} else {
let c = Ctype;
TotalType::C(c) // 在这个分支中返回变体C
}
}
enum 常用于聚合类型。无脑 + 强行揉在一起。enum 聚合类型是编码时已知的类型。在聚合前,需要知道待聚合类型的边界,一旦定义完成,之后运行时就不能改动了,是封闭类型集。
第二种办法---用类型参数:
struct Atype;
struct Btype;
struct Ctype;
fn doit() -> T {
let a = Atype;
a
}
无法通过编译:
error[E0308]: mismatched types
--> src/lib.rs:6:3
|
4 | fn doit() -> T {
| - - expected `T` because of return type
| |
| this type parameter
5 | let a = Atype;
6 | a
| ^ expected type parameter `T`, found `Atype`
|
= note: expected type parameter `T`
found struct `Atype`
这里这个类型参数 T 是在这个函数调用时指定,而不是在这个函数定义时指定的。没法在这里直接返回一个具体的类型代入 T。只能尝试用 T 来返回,改出第二个版本。
struct Atype;
struct Btype;
struct Ctype;
impl Atype {
fn new() -> Atype {
Atype
}
}
impl Btype {
fn new() -> Btype {
Btype
}
}
impl Ctype {
fn new() -> Ctype {
Ctype
}
}
fn doit() -> T {
T::new()
}
编译还是报错。
error[E0599]: no function or associated item named `new` found for type parameter `T` in the current scope
--> src/main.rs:24:6
|
23 | fn doit() -> T {
| - function or associated item `new` not found for this type parameter
24 | T::new()
| ^^^ function or associated item not found in `T`
Rustc并不知道我们定义这个类型参数 T 里面有 new 这个关联函数。
联想前面学过的,可以用 trait 来定义这个协议,让 Rust 认识它。第三个版本:
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {
fn new() -> Self; // TraitA中定义了new()函数
}
impl TraitA for Atype {
fn new() -> Atype {
Atype
}
}
impl TraitA for Btype {
fn new() -> Btype {
Btype
}
}
impl TraitA for Ctype {
fn new() -> Ctype {
Ctype
}
}
fn doit() -> T {
T::new()
}
fn main() {
let a: Atype = doit::();
let b: Btype = doit::();
let c: Ctype = doit::();
}
这个版本顺利通过编译。在这个示例中,我们认识到了引入 trait 的必要性,就是让 Rustc 小助手知道我们在协议层面有一个 new() 函数,一旦类型参数被 trait 约束后,它就可以去 trait 中寻找协议定义的函数和方法。
费了不少力气。Rust 提供了更优雅的方案来解决这个需求。用 trait 提供了一种特殊语法 impl trait:
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit() -> impl TraitA { // 注意这一行的函数返回类型
let a = Atype;
a
// 或
// let b = Btype;
// b
// 或
// let c = Ctype;
// c
}
表达非常简洁,同一个函数签名可以返回多种不同的类型,并且在函数定义时就可以返回具体的类型的实例。更重要的是消除了类型参数 T。
上述代码已经很有用了,但不够灵活,如用 if 逻辑选择不同的分支返回不同的类型,会遇到问题。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(i: u32) -> impl TraitA {
if i == 0 {
let a = Atype;
a // 在这个分支中返回类型a
} else if i == 1 {
let b = Btype;
b // 在这个分支中返回类型b
} else {
let c = Ctype;
c // 在这个分支中返回类型c
}
}
提示:
error[E0308]: `if` and `else` have incompatible types
--> src/lib.rs:22:5
|
17 | } else if i == 1 {
| __________-
18 | | let b = Btype;
19 | | b
| | - expected because of this
20 | | } else {
21 | | let c = Ctype;
22 | | c
| | ^ expected `Btype`, found `Ctype`
23 | | }
| |___- `if` and `else` have incompatible types
if else 要求返回同一种类型,Rust 检查确实严格。可以通过加 return 跳过 if else 的限制。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(i: u32) -> impl TraitA {
if i == 0 {
let a = Atype;
return a; // 这里用return语句直接从函数返回
} else if i == 1 {
let b = Btype;
return b;
} else {
let c = Ctype;
return c;
}
}
但还是报错。
error[E0308]: mismatched types
--> src/lib.rs:19:12
|
13 | fn doit(i: u32) -> impl TraitA { // 这一行
| ----------- expected `Atype` because of return type
...
19 | return b
| ^ expected `Atype`, found `Btype`
说期望 Atype,却得到了 Btype。这个报错其实有点奇怪,它们不是都满足 impl TraitA 吗?
原来问题在于,impl TraitA 作为函数返回值这种语法,其实也只是指代某一种类型而已,而这种类型是在函数体中由返回值的类型来自动推导出来的。例中,Rustc 遇到 Atype 分支时,已确定了函数返回类型为 Atype,当分析到后面的 Btype 分支,就发现类型不匹配了。可以将条件分支顺序换一下,看一下报错的提示,加深印象。
该怎么处理这种问题呢?用 trait object
trait object。形式上,是在 trait 名前加 dyn 关键字修饰,本例是 dyn TraitA。
dyn TraitName 本身就是一种类型,它和 TraitName 这个 trait 相关,但是它们不同,dyn TraitName 是一个独立的类型。
用 dyn TraitA 改写上面的代码。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(i: u32) -> dyn TraitA { // 注意这里的返回类型换成了 dyn TraitA
if i == 0 {
let a = Atype;
return a
} else if i == 1 {
let b = Btype;
return b
} else {
let c = Ctype;
return c
}
}
但是编译会报错。
error[E0746]: return type cannot have an unboxed trait object
--> src/lib.rs:13:20
|
13 | fn doit(i: u32) -> dyn TraitA {
| ^^^^^^^^^^ doesn't have a size known at compile-time
|
help: return an `impl Trait` instead of a `dyn Trait`, if all returned values are the same type
|
13 | fn doit(i: u32) -> impl TraitA {
| ~~~~
help: box the return type, and wrap all of the returned values in `Box::new`
|
13 ~ fn doit(i: u32) -> Box {
14 | if i == 0 {
15 | let a = Atype;
16 ~ return Box::new(a)
17 | } else if i == 1 {
18 | let b = Btype;
19 ~ return Box::new(b)
20 | } else {
21 | let c = Ctype;
22 ~ return Box::new(c)
提示很经典:
(:有没有 ChatGPT 的即时感,聪明得不太像一个编译器。)
第一个建议已经试过了,Pass。按第二种建议试试。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(i: u32) -> Box {
if i == 0 {
let a = Atype;
Box::new(a)
} else if i == 1 {
let b = Btype;
Box::new(b)
} else {
let c = Ctype;
Box::new(c)
}
}
编译通过,达成目标,成功地将不同类型的实例在同一个函数中返回了。
Box
具体到这个示例中,因为 a、b、c 都是函数中的局部变量,这里如果返回引用 &dyn TraitA 的话是万万不能的,因为违反了所有权规则。而 Box
整个推导过程,令人印象深刻的类型“体操”值得我们多品味几次。
impl trait 和 dyn trait 可用于函数传参。
impl trait 示例:
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(x: impl TraitA) {}
// 等价于
// fn doit(x: T) {}
fn main() {
let a = Atype;
doit(a);
let b = Btype;
doit(b);
let c = Ctype;
doit(c);
}
dyn trait 示例:
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit(x: &dyn TraitA) {} // 注意这里用了引用形式 &dyn TraitA
fn main() {
let a = Atype;
doit(&a);
let b = Btype;
doit(&b);
let c = Ctype;
doit(&c);
}
两种都可以。区别是什么?
impl trait 是编译器静态展开,编译时具化(单态化)。上面 impl trait 示例展开后类似于:
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn doit_a(x: Atype) {}
fn doit_b(x: Btype) {}
fn doit_c(x: Ctype) {}
fn main() {
let a = Atype;
doit_a(a);
let b = Btype;
doit_b(b);
let c = Ctype;
doit_c(c);
}
dyn trait 版本不会在编译期间做任何展开,dyn TraitA 自己就是一个类型,这个类型相当于一个代理类型,用于在运行时代理相关类型及调用对应方法。既然是代理,也就是调用方法的时候需要多跳转一次,从性能上来说,当然要比在编译期直接展开一步到位调用对应函数要慢一点。
静态展开,会使编译出来的内容体积增大,而 dyn trait 不会。各有利弊,可以根据需求视情况选择。另外,impl trait 和 dyn trait 都是消除类型参数的办法。
impl trait 和 dyn trait 和 enum 相比:
上述区别对于库的提供者非常重要。如果你提供了一个库,里面的多类型使用的 enum 包装,那么库的使用者没办法对你的 enum 进行扩展。因为一般来说,我们不鼓励去修改库里面的代码。而用 impl trait 或 dyn trait 就可以让接口具有可扩展性。用户只需要给他们的类型实现你的库提供的 trait,就可以代入库的接口使用了。
impl trait 目前只能用于:函数参数、函数返回值。其他的静态展开场景就得用类型参数形式。
dyn trait 本身是一种非固定尺寸类型,比 impl trait 应用于更多场合,如利用 trait obj 把不同的类型装进集合里。
示例,把三种类型装进一个 Vec 里。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn main() {
let a = Atype;
let b = Btype;
let c = Ctype;
let v = vec![a, b, c];
}
报错:
error[E0308]: mismatched types
--> src/main.rs:19:21
|
19 | let v = vec![a, b, c];
| ^ expected `Atype`, found `Btype`
Vec 要求元素是同一种类型(不同的类型实例,错误)。用 trait object “绕”过这个限制。
struct Atype;
struct Btype;
struct Ctype;
trait TraitA {}
impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}
fn main() {
let a = Atype;
let b = Btype;
let c = Ctype;
let v: Vec<&dyn TraitA> = vec![&a, &b, &c];
}
成功,不同类型的实例(实际是实例的引用)竟然被放进了同一个 Vec 中!也可以,将不同类型的实例放入 HashMap 中。
注意应用限制:性能损失,不是所有的 trait 都可以 dyn 化(不是所有的 trait 都能转成 trait object 使用)。
只有满足对象安全(object safety)的 trait 才能被用作 trait object。
Rust 参考手册上有关于 object safety 的详细规则,比较复杂。这里我们了解常用的模式就行。
安全的 trait object:
trait TraitA {
fn foo(&self) {}
fn foo_mut(&mut self) {}
fn foo_box(self: Box) {}
}
不安全的 trait object:
trait NotObjectSafe {
const CONST: i32 = 1; // 不能包含关联常量
fn foo() {} // 不能包含这样的关联函数
fn selfin(self); // 不能将Self所有权传入
fn returns(&self) -> Self; // 不能返回Self
fn typed(&self, x: T) {} // 方法中不能有类型参数
}
规则确实比较复杂,你可以简单记住几种场景。
并不是所有的 trait 都能以 trait object 形式(dyn trait)使用,实际上,以 dyn trait 使用的场景是少数。可在遇到编译器报错时,再回头来审视 trait 定义得是否合理。大部分情况下可以放心使用。
前半部分,讲解了 trait 中带类型参数的情况。各种符号组合起来,越来越复杂。模式就那几种,要花点时间熟悉理解。
用带类型参数的 trait 实现了其他语言中函数重载的功能。一条全新的思路:以往的语言必须给自身添加各种特性来满足用户的要求,在 Rust 中,用好 trait 就能搞定。对 Rust 的未来充满期待,不会像 C++、Java 那样永不停歇地添加可能会导致组合爆炸的新特性,而让自身越来越臃肿。
讨论了带类型参数的 trait 与关联类型的区别。它们之间并不存在绝对优势的一方,在合适的场景下选择合适的方案是最重要的。
如何让一个 Rust 函数返回可能的多种类型?推导出了引入 trait object 方案的必要性。整个推导过程比较曲折,同时也是对 Rust 类型系统的一次精彩探索。在这个探索过程中,我们和 Rustc 小助手成为了好朋友,在它的协助下,我们找到了最佳方案。
最后我们了解了 trait object 的一些用途,并讨论了 trait object、impl trait,还有使用枚举对类型进行聚合这三种方式之间的区别。类型系统(类型 + trait)是 Rust 的大脑,你可以多加练习,熟悉它的形式,掌握它的用法。
请谈谈在函数参数中传入 &dyn TraitA 与 Box 两种类型的区别。
答: