泛型和特征
泛型和特征是 Rust 中最最重要的抽象类型,也是你在学习 Rust 路上的拦路虎,但是挑战往往与乐趣并存,一旦学会,在后面学习 Rust 的路上,你将一往无前。
泛型 Generics
Go 语言在 2022 年,就要正式引入泛型,被视为在 1.0 版本后,语言特性发展迈出的一大步,为什么泛型这么重要?到底什么是泛型?Rust 的泛型有几种? 本章将一一为你讲解。
我们在编程中,经常有这样的需求:用同一功能的函数处理不同类型的数据,例如两个数的加法,无论是整数还是浮点数,甚至是自定义类型,都能进行支持。在不支持泛型的编程语言中,通常需要为每一种类型编写一个函数:
fn add_i8(a:i8, b:i8) -> i8 {
a + b
}
fn add_i32(a:i32, b:i32) -> i32 {
a + b
}
fn add_f64(a:f64, b:f64) -> f64 {
a + b
}
fn main() {
println!("add i8: {}", add_i8(2i8, 3i8));
println!("add i32: {}", add_i32(20, 30));
println!("add f64: {}", add_f64(1.23, 1.23));
}
上述代码可以正常运行,但是很啰嗦,如果你要支持更多的类型,那么会更繁琐。程序员或多或少都有强迫症,一个好程序员的公认特征就是 —— 懒,这么勤快的写一大堆代码,显然不是咱们的优良传统,是不?
在开始讲解 Rust 的泛型之前,先来看看什么是多态。
在编程的时候,我们经常利用多态。通俗的讲,多态就是好比坦克的炮管,既可以发射普通弹药,也可以发射制导炮弹(导弹),也可以发射贫铀穿甲弹,甚至发射子母弹,没有必要为每一种炮弹都在坦克上分别安装一个专用炮管,即使生产商愿意,炮手也不愿意,累死人啊。所以在编程开发中,我们也需要这样“通用的炮管”,这个“通用的炮管”就是多态。
实际上,泛型就是一种多态。泛型主要目的是为程序员提供编程的便利,减少代码的臃肿,同时可以极大地丰富语言本身的表达能力,为程序员提供了一个合适的炮管。想想,一个函数,可以代替几十个,甚至数百个函数,是一件多么让人兴奋的事情:
fn add<T>(a:T, b:T) -> T {
a + b
}
fn main() {
println!("add i8: {}", add(2i8, 3i8));
println!("add i32: {}", add(20, 30));
println!("add f64: {}", add(1.23, 1.23));
}
将之前的代码改成上面这样,就是 Rust 泛型的初印象,这段代码虽然很简洁,但是并不能编译通过,我们会在后面进行详细讲解,现在只要对泛型有个大概的印象即可。
泛型详解
上面代码的 T
就是泛型参数,实际上在 Rust 中,泛型参数的名称你可以任意起,但是出于惯例,我们都用 T
(T
是 type
的首字母)来作为首选,这个名称越短越好,除非需要表达含义,否则一个字母是最完美的。
使用泛型参数,有一个先决条件,必需在使用前对其进行声明:
fn largest<T>(list: &[T]) -> T {
该泛型函数的作用是从列表中找出最大的值,其中列表中的元素类型为 T。首先 largest<T>
对泛型参数 T
进行了声明,然后才在函数参数中进行使用该泛型参数 list: &[T]
(还记得 &[T]
类型吧?这是数组切片)。
总之,我们可以这样理解这个函数定义:函数 largest
有泛型类型 T
,它有个参数 list
,其类型是元素为 T
的数组切片,最后,该函数返回值的类型也是 T
。
下面是一个错误的泛型函数的实现:
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {}", result);
}
运行后报错:
error[E0369]: binary operation `>` cannot be applied to type `T` // `>`操作符不能用于类型`T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- T
| |
| T
|
help: consider restricting type parameter `T` // 考虑对T进行类型上的限制 :
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
| ++++++++++++++++++++++
因为 T
可以是任何类型,但不是所有的类型都能进行比较,因此上面的错误中,编译器建议我们给 T
添加一个类型限制:使用 std::cmp::PartialOrd
特征(Trait)对 T
进行限制,特征在下一节会详细介绍,现在你只要理解,该特征的目的就是让类型实现可比较的功能。
还记得我们一开始的 add
泛型函数吗?如果你运行它,会得到以下的报错:
error[E0369]: cannot add `T` to `T` // 无法将 `T` 类型跟 `T` 类型进行相加
--> src/main.rs:2:7
|
2 | a + b
| - ^ - T
| |
| T
|
help: consider restricting type parameter `T`
|
1 | fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
| +++++++++++++++++++++++++++
同样的,不是所有 T
类型都能进行相加操作,因此我们需要用 std::ops::Add<Output = T>
对 T
进行限制:
fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
a + b
}
进行如上修改后,就可以正常运行。
显式地指定泛型的类型参数
有时候,编译器无法推断你想要的泛型参数:
use std::fmt::Display;
fn create_and_print<T>() where T: From<i32> + Display {
let a: T = 100.into(); // 创建了类型为 T 的变量 a,它的初始值由 100 转换而来
println!("a is: {}", a);
}
fn main() {
create_and_print();
}
如果运行以上代码,会得到报错:
error[E0283]: type annotations needed // 需要标明类型
--> src/main.rs:9:5
|
9 | create_and_print();
| ^^^^^^^^^^^^^^^^ cannot infer type of the type parameter `T` declared on the function `create_and_print` // 无法推断函数 `create_and_print` 的类型参数 `T` 的类型
|
= note: multiple `impl`s satisfying `_: From<i32>` found in the `core` crate:
- impl From<i32> for AtomicI32;
- impl From<i32> for f64;
- impl From<i32> for i128;
- impl From<i32> for i64;
note: required by a bound in `create_and_print`
--> src/main.rs:3:35
|
3 | fn create_and_print<T>() where T: From<i32> + Display {
| ^^^^^^^^^ required by this bound in `create_and_print`
help: consider specifying the generic argument // 尝试指定泛型参数
|
9 | create_and_print::<T>();
| +++++
报错里说得很清楚,编译器不知道 T
到底应该是什么类型。不过好心的编译器已经帮我们列出了满足条件的类型,然后告诉我们解决方法:显式指定类型:create_and_print::<T>()
。
于是,我们修改代码:
use std::fmt::Display;
fn create_and_print<T>() where T: From<i32> + Display {
let a: T = 100.into(); // 创建了类型为 T 的变量 a,它的初始值由 100 转换而来
println!("a is: {}", a);
}
fn main() {
create_and_print::<i64>();
}
即可成功运行。
结构体中使用泛型
结构体中的字段类型也可以用泛型来定义,下面代码定义了一个坐标点 Point
,它可以存放任何类型的坐标值:
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
这里有两点需要特别的注意:
提前声明,跟泛型函数定义类似,首先我们在使用泛型参数之前必需要进行声明
Point<T>
,接着就可以在结构体的字段类型中使用T
来替代具体的类型x 和 y 是相同的类型
第二点非常重要,如果使用不同的类型,那么它会导致下面代码的报错:
struct Point<T> {
x: T,
y: T,
}
fn main() {
let p = Point{x: 1, y :1.1};
}
错误如下:
error[E0308]: mismatched types //类型不匹配
--> src/main.rs:7:28
|
7 | let p = Point{x: 1, y :1.1};
| ^^^ expected integer, found floating-point number //期望y是整数,但是却是浮点数
当把 1
赋值给 x
时,变量 p
的 T
类型就被确定为整数类型,因此 y
也必须是整数类型,但是我们却给它赋予了浮点数,因此导致报错。
如果想让 x
和 y
既能类型相同,又能类型不同,就需要使用不同的泛型参数:
struct Point<T,U> {
x: T,
y: U,
}
fn main() {
let p = Point{x: 1, y :1.1};
}
切记,所有的泛型参数都要提前声明:Point<T,U>
! 但是如果你的结构体变成这鬼样:struct Woo<T,U,V,W,X>
,那么你需要考虑拆分这个结构体,减少泛型参数的个数和代码复杂度。
枚举中使用泛型
提到枚举类型,Option
永远是第一个应该被想起来的,在之前的章节中,它也多次出现:
enum Option<T> {
Some(T),
None,
}
Option<T>
是一个拥有泛型 T
的枚举类型,它第一个成员是 Some(T)
,存放了一个类型为 T
的值。得益于泛型的引入,我们可以在任何一个需要返回值的函数中,去使用 Option<T>
枚举类型来做为返回值,用于返回一个任意类型的值 Some(T)
,或者没有值 None
。
对于枚举而言,卧龙凤雏永远是绕不过去的存在:如果是 Option
是卧龙,那么 Result
就一定是凤雏,得两者可得天下:
enum Result<T, E> {
Ok(T),
Err(E),
}
这个枚举和 Option
一样,主要用于函数返回值,与 Option
用于值的存在与否不同,Result
关注的主要是值的正确性。
如果函数正常运行,则最后返回一个 Ok(T)
,T
是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E)
,E
是错误类型。例如打开一个文件:如果成功打开文件,则返回 Ok(std::fs::File)
,因此 T
对应的是 std::fs::File
类型;而当打开文件时出现问题时,返回 Err(std::io::Error)
,E
对应的就是 std::io::Error
类型。
方法中使用泛型
上一章中,我们讲到什么是方法以及如何在结构体和枚举上定义方法。方法上也可以使用泛型:
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
使用泛型参数前,依然需要提前声明:impl<T>
,只有提前声明了,我们才能在Point<T>
中使用它,这样 Rust 就知道 Point
的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 Point<T>
不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T>
而不再是 Point
。
除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:
struct Point<T, U> {
x: T,
y: U,
}
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c'};
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
这个例子中,T,U
是定义在结构体 Point
上的泛型参数,V,W
是单独定义在方法 mixup
上的泛型参数,它们并不冲突,说白了,你可以理解为,一个是结构体泛型,一个是函数泛型。
为具体的泛型类型实现方法
对于 Point<T>
类型,你不仅能定义基于 T
的方法,还能针对特定的具体类型,进行方法定义:
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
这段代码意味着 Point<f32>
类型会有一个方法 distance_from_origin
,而其他 T
不是 f32
类型的 Point<T>
实例则没有定义此方法。这个方法计算点实例与坐标(0.0, 0.0)
之间的距离,并使用了只能用于浮点型的数学运算符。
这样我们就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法。
const 泛型(Rust 1.51 版本引入的重要特性)
在之前的泛型中,可以抽象为一句话:针对类型实现的泛型,所有的泛型都是为了抽象不同的类型,那有没有针对值的泛型?可能很多同学感觉很难理解,值怎么使用泛型?不急,我们先从数组讲起。
在数组那节,有提到过很重要的一点:[i32; 2]
和 [i32; 3]
是不同的数组类型,比如下面的代码:
fn display_array(arr: [i32; 3]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(arr);
let arr: [i32; 2] = [1, 2];
display_array(arr);
}
运行后报错:
error[E0308]: mismatched types // 类型不匹配
--> src/main.rs:10:19
|
10 | display_array(arr);
| ^^^ expected an array with a fixed size of 3 elements, found one with 2 elements
// 期望一个长度为3的数组,却发现一个长度为2的
结合代码和报错,可以很清楚的看出,[i32; 3]
和 [i32; 2]
确实是两个完全不同的类型,因此无法用同一个函数调用。
首先,让我们修改代码,让 display_array
能打印任意长度的 i32
数组:
fn display_array(arr: &[i32]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(&arr);
let arr: [i32; 2] = [1, 2];
display_array(&arr);
}
很简单,只要使用数组切片,然后传入 arr
的不可变引用即可。
接着,将 i32
改成所有类型的数组:
fn display_array<T: std::fmt::Debug>(arr: &[T]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(&arr);
let arr: [i32; 2] = [1, 2];
display_array(&arr);
}
也不难,唯一要注意的是需要对 T
加一个限制 std::fmt::Debug
,该限制表明 T
可以用在 println!("{:?}", arr)
中,因为 {:?}
形式的格式化输出需要 arr
实现该特征。
通过引用,我们可以很轻松的解决处理任何类型数组的问题,但是如果在某些场景下引用不适宜用或者干脆不能用呢?你们知道为什么以前 Rust 的一些数组库,在使用的时候都限定长度不超过 32 吗?因为它们会为每个长度都单独实现一个函数,简直。。。毫无人性。难道没有什么办法可以解决这个问题吗?
好在,现在咱们有了 const 泛型,也就是针对值的泛型,正好可以用于处理数组长度的问题:
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(arr);
let arr: [i32; 2] = [1, 2];
display_array(arr);
}
如上所示,我们定义了一个类型为 [T; N]
的数组,其中 T
是一个基于类型的泛型参数,这个和之前讲的泛型没有区别,而重点在于 N
这个泛型参数,它是一个基于值的泛型参数!因为它用来替代的是数组的长度。
N
就是 const 泛型,定义的语法是 const N: usize
,表示 const 泛型 N
,它基于的值类型是 usize
。
在泛型参数之前,Rust 完全不适合复杂矩阵的运算,自从有了 const 泛型,一切即将改变。
const 泛型表达式
假设我们某段代码需要在内存很小的平台上工作,因此需要限制函数参数占用的内存大小,此时就可以使用 const 泛型表达式来实现:
// 目前只能在nightly版本下使用
#![allow(incomplete_features)]
#![feature(generic_const_exprs)]
fn something<T>(val: T)
where
Assert<{ core::mem::size_of::<T>() < 768 }>: IsTrue,
// ^-----------------------------^ 这里是一个 const 表达式,换成其它的 const 表达式也可以
{
//
}
fn main() {
something([0u8; 0]); // ok
something([0u8; 512]); // ok
something([0u8; 1024]); // 编译错误,数组长度是1024字节,超过了768字节的参数长度限制
}
// ---
pub enum Assert<const CHECK: bool> {
//
}
pub trait IsTrue {
//
}
impl IsTrue for Assert<true> {
//
}
const fn
在讨论完 const
泛型后,不得不提及另一个与之密切相关且强大的特性:const fn
,即常量函数。const fn
允许我们在编译期对函数进行求值,从而实现更高效、更灵活的代码设计。
为什么需要 const fn
通常情况下,函数是在运行时被调用和执行的。然而,在某些场景下,我们希望在编译期就计算出一些值,以提高运行时的性能或满足某些编译期的约束条件。例如,定义数组的长度、计算常量值等。
有了 const fn
,我们可以在编译期执行这些函数,从而将计算结果直接嵌入到生成的代码中。这不仅提高了运行时的性能,还使代码更加简洁和安全。
const fn 的基本用法
要定义一个常量函数,只需要在函数声明前加上 const
关键字。例如:
const fn add(a: usize, b: usize) -> usize {
a + b
}
const RESULT: usize = add(5, 10);
fn main() {
println!("The result is: {}", RESULT);
}
const fn 的限制
虽然 const fn
提供了很多便利,但是由于其在编译期执行,以确保函数能在编译期被安全地求值,因此有一些限制,例如,不可将随机数生成器写成 const fn
。
无论在编译时还是运行时调用 const fn
,它们的结果总是相同,即使多次调用也是如此。唯一的例外是,如果你在极端情况下进行复杂的浮点操作,你可能会得到(非常轻微的)不同结果。因此,不建议使 数组长度 (arr.len())
和 Enum判别式
依赖于浮点计算。
结合 const fn 与 const 泛型
将 const fn
与 const 泛型
结合,可以实现更加灵活和高效的代码设计。例如,创建一个固定大小的缓冲区结构,其中缓冲区大小由编译期计算确定:
struct Buffer<const N: usize> {
data: [u8; N],
}
const fn compute_buffer_size(factor: usize) -> usize {
factor * 1024
}
fn main() {
const SIZE: usize = compute_buffer_size(4);
let buffer = Buffer::<SIZE> {
data: [0; SIZE],
};
println!("Buffer size: {} bytes", buffer.data.len());
}
在这个例子中,compute_buffer_size
是一个常量函数,它根据传入的 factor
计算缓冲区的大小。在 main
函数中,我们使用 compute_buffer_size(4)
来计算缓冲区大小为 4096 字节,并将其作为泛型参数传递给 Buffer
结构体。这样,缓冲区的大小在编译期就被确定下来,避免了运行时的计算开销。
泛型的性能
在 Rust 中泛型是零成本的抽象,意味着你在使用泛型时,完全不用担心性能上的问题。
但是任何选择都是权衡得失的,既然我们获得了性能上的巨大优势,那么又失去了什么呢?Rust 是在编译期为泛型对应的多个类型,生成各自的代码,因此损失了编译速度和增大了最终生成文件的大小。
具体来说:
Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
编译器所做的工作正好与我们创建泛型函数的步骤相反,编译器寻找所有泛型代码被调用的位置并针对具体类型生成代码。
让我们看看一个使用标准库中 Option
枚举的例子:
let integer = Some(5);
let float = Some(5.0);
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T>
的值并发现有两种 Option<T>
:一种对应 i32
另一种对应 f64
。为此,它会将泛型定义 Option<T>
展开为 Option_i32
和 Option_f64
,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样:
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
我们可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。
特征 Trait
如果我们想定义一个文件系统,那么把该系统跟底层存储解耦是很重要的。文件操作主要包含四个:open
、write
、read
、close
,这些操作可以发生在硬盘,可以发生在内存,还可以发生在网络 IO 甚至(...我实在编不下去了,大家来帮帮我)。总之如果你要为每一种情况都单独实现一套代码,那这种实现将过于繁杂,而且也没那个必要。
要解决上述问题,需要把这些行为抽象出来,就要使用 Rust 中的特征 trait
概念。可能你是第一次听说这个名词,但是不要怕,如果学过其他语言,那么大概率你听说过接口,没错,特征跟接口很类似。
在之前的代码中,我们也多次见过特征的使用,例如 #[derive(Debug)]
,它在我们定义的类型(struct
)上自动派生 Debug
特征,接着可以使用 println!("{:?}", x)
打印这个类型;再例如:
fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
a + b
}
通过 std::ops::Add
特征来限制 T
,只有 T
实现了 std::ops::Add
才能进行合法的加法操作,毕竟不是所有的类型都能进行相加。
这些都说明一个道理,特征定义了一组可以被共享的行为,只要实现了特征,你就能使用这组行为。
定义特征
如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
例如,我们现在有文章 Post
和微博 Weibo
两种内容载体,而我们想对相应的内容进行总结,也就是无论是文章内容,还是微博内容,都可以在某个时间点进行总结,那么总结这个行为就是共享的,因此可以用特征来定义:
pub trait Summary {
fn summarize(&self) -> String;
}
这里使用 trait
关键字来声明一个特征,Summary
是特征名。在大括号中定义了该特征的所有方法,在这个例子中是: fn summarize(&self) -> String
。
特征只定义行为看起来是什么样的,而不定义行为具体是怎么样的。因此,我们只定义特征方法的签名,而不进行实现,此时方法签名结尾是 ;
,而不是一个 {}
。
接下来,每一个实现这个特征的类型都需要具体实现该特征的相应方法,编译器也会确保任何实现 Summary
特征的类型都拥有与这个签名的定义完全一致的 summarize
方法。
为类型实现特征
因为特征只定义行为看起来是什么样的,因此我们需要为类型实现具体的特征,定义行为具体是怎么样的。
首先来为 Post
和 Weibo
实现 Summary
特征:
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Post {
pub title: String, // 标题
pub author: String, // 作者
pub content: String, // 内容
}
impl Summary for Post {
fn summarize(&self) -> String {
format!("文章{}, 作者是{}", self.title, self.author)
}
}
pub struct Weibo {
pub username: String,
pub content: String
}
impl Summary for Weibo {
fn summarize(&self) -> String {
format!("{}发表了微博{}", self.username, self.content)
}
}
实现特征的语法与为结构体、枚举实现方法很像:impl Summary for Post
,读作“为 Post
类型实现 Summary
特征”,然后在 impl
的花括号中实现该特征的具体方法。
接下来就可以在这个类型上调用特征的方法:
fn main() {
let post = Post{title: "Rust语言简介".to_string(),author: "Sunface".to_string(), content: "Rust棒极了!".to_string()};
let weibo = Weibo{username: "sunface".to_string(),content: "好像微博没Tweet好用".to_string()};
println!("{}",post.summarize());
println!("{}",weibo.summarize());
}
运行输出:
文章 Rust 语言简介, 作者是Sunface
sunface发表了微博好像微博没Tweet好用
说实话,如果特征仅仅如此,你可能会觉得花里胡哨没啥用,接下来就让你见识下 trait
真正的威力。
特征定义与实现的位置(孤儿规则)
上面我们将 Summary
定义成了 pub
公开的。这样,如果他人想要使用我们的 Summary
特征,则可以引入到他们的包中,然后再进行实现。
关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A
实现特征 T
,那么 A
或者 T
至少有一个是在当前作用域中定义的! 例如我们可以为上面的 Post
类型实现标准库中的 Display
特征,这是因为 Post
类型定义在当前的作用域中。同时,我们也可以在当前包中为 String
类型实现 Summary
特征,因为 Summary
定义在当前作用域中。
但是你无法在当前作用域中,为 String
类型实现 Display
特征,因为它们俩都定义在标准库中,其定义所在的位置都不在当前作用域,跟你半毛钱关系都没有,看看就行了。
该规则被称为孤儿规则,可以确保其它人编写的代码不会破坏你的代码,也确保了你不会莫名其妙就破坏了风马牛不相及的代码。
默认实现
你可以在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重写该方法:
pub trait Summary {
fn summarize(&self) -> String {
String::from("(Read more...)")
}
}
上面为 Summary
定义了一个默认实现,下面我们编写段代码来测试下:
impl Summary for Post {}
impl Summary for Weibo {
fn summarize(&self) -> String {
format!("{}发表了微博{}", self.username, self.content)
}
}
可以看到,Post
选择了默认实现,而 Weibo
重写了该方法,调用和输出如下:
println!("{}",post.summarize());
println!("{}",weibo.summarize());
(Read more...)
sunface发表了微博好像微博没Tweet好用
默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现。如此,特征可以提供很多有用的功能而只需要实现指定的一小部分内容。例如,我们可以定义 Summary
特征,使其具有一个需要实现的 summarize_author
方法,然后定义一个 summarize
方法,此方法的默认实现调用 summarize_author
方法:
pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}
为了使用 Summary
,只需要实现 summarize_author
方法即可:
impl Summary for Weibo {
fn summarize_author(&self) -> String {
format!("@{}", self.username)
}
}
println!("1 new weibo: {}", weibo.summarize());
weibo.summarize()
会先调用 Summary
特征默认实现的 summarize
方法,通过该方法进而调用 Weibo
为 Summary
实现的 summarize_author
方法,最终输出:1 new weibo: (Read more from @horse_ebooks...)
。
使用特征作为函数参数
之前提到过,特征如果仅仅是用来实现方法,那真的有些大材小用,现在我们来讲下,真正可以让特征大放光彩的地方。
现在,先定义一个函数,使用特征作为函数参数:
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}
impl Summary
,只能说想出这个类型的人真的是起名鬼才,简直太贴切了,顾名思义,它的意思是 实现了Summary
特征 的 item
参数。
你可以使用任何实现了 Summary
特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法,例如 summarize
方法。具体的说,可以传递 Post
或 Weibo
的实例来作为参数,而其它类如 String
或者 i32
的类型则不能用做该函数的参数,因为它们没有实现 Summary
特征。
特征约束(trait bound)
虽然 impl Trait
这种语法非常好理解,但是实际上它只是一个语法糖:
pub fn notify<T: Summary>(item: &T) {
println!("Breaking news! {}", item.summarize());
}
真正的完整书写形式如上所述,形如 T: Summary
被称为特征约束。
在简单的场景下 impl Trait
这种语法糖就足够使用,但是对于复杂的场景,特征约束可以让我们拥有更大的灵活性和语法表现能力,例如一个函数接受两个 impl Summary
的参数:
pub fn notify(item1: &impl Summary, item2: &impl Summary) {}
如果函数两个参数是不同的类型,那么上面的方法很好,只要这两个类型都实现了 Summary
特征即可。但是如果我们想要强制函数的两个参数是同一类型呢?上面的语法就无法做到这种限制,此时我们只能使特征约束来实现:
pub fn notify<T: Summary>(item1: &T, item2: &T) {}
泛型类型 T
说明了 item1
和 item2
必须拥有同样的类型,同时 T: Summary
说明了 T
必须实现 Summary
特征。
多重约束
除了单个约束条件,我们还可以指定多个约束条件,例如除了让参数实现 Summary
特征外,还可以让参数实现 Display
特征以控制它的格式化输出:
pub fn notify(item: &(impl Summary + Display)) {}
除了上述的语法糖形式,还能使用特征约束的形式:
pub fn notify<T: Summary + Display>(item: &T) {}
通过这两个特征,就可以使用 item.summarize
方法,以及通过 println!("{}", item)
来格式化输出 item
。
Where 约束
当特征约束变得很多时,函数的签名将变得很复杂:
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {}
严格来说,上面的例子还是不够复杂,但是我们还是能对其做一些形式上的改进,通过 where
:
fn some_function<T, U>(t: &T, u: &U) -> i32
where T: Display + Clone,
U: Clone + Debug
{}
使用特征约束有条件地实现方法或特征
特征约束,可以让我们在指定类型 + 指定特征的条件下去实现方法,例如:
use std::fmt::Display;
struct Pair<T> {
x: T,
y: T,
}
impl<T> Pair<T> {
fn new(x: T, y: T) -> Self {
Self {
x,
y,
}
}
}
impl<T: Display + PartialOrd> Pair<T> {
fn cmp_display(&self) {
if self.x >= self.y {
println!("The largest member is x = {}", self.x);
} else {
println!("The largest member is y = {}", self.y);
}
}
}
cmp_display
方法,并不是所有的 Pair<T>
结构体对象都可以拥有,只有 T
同时实现了 Display + PartialOrd
的 Pair<T>
才可以拥有此方法。 该函数可读性会更好,因为泛型参数、参数、返回值都在一起,可以快速的阅读,同时每个泛型参数的特征也在新的代码行中通过特征约束进行了约束。
也可以有条件地实现特征,例如,标准库为任何实现了 Display
特征的类型实现了 ToString
特征:
impl<T: Display> ToString for T {
// --snip--
}
我们可以对任何实现了 Display
特征的类型调用由 ToString
定义的 to_string
方法。例如,可以将整型转换为对应的 String
值,因为整型实现了 Display
:
let s = 3.to_string();
函数返回中的 impl Trait
可以通过 impl Trait
来说明一个函数返回了一个类型,该类型实现了某个特征:
fn returns_summarizable() -> impl Summary {
Weibo {
username: String::from("sunface"),
content: String::from(
"m1 max太厉害了,电脑再也不会卡",
)
}
}
因为 Weibo
实现了 Summary
,因此这里可以用它来作为返回值。要注意的是,虽然我们知道这里是一个 Weibo
类型,但是对于 returns_summarizable
的调用者而言,他只知道返回了一个实现了 Summary
特征的对象,但是并不知道返回了一个 Weibo
类型。
这种 impl Trait
形式的返回值,在一种场景下非常非常有用,那就是返回的真实类型非常复杂,你不知道该怎么声明时(毕竟 Rust 要求你必须标出所有的类型),此时就可以用 impl Trait
的方式简单返回。例如,闭包和迭代器就是很复杂,只有编译器才知道那玩意的真实类型,如果让你写出来它们的具体类型,估计内心有一万只草泥马奔腾,好在你可以用 impl Iterator
来告诉调用者,返回了一个迭代器,因为所有迭代器都会实现 Iterator
特征。
但是这种返回值方式有一个很大的限制:只能有一个具体的类型,例如:
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
Post {
title: String::from(
"Penguins win the Stanley Cup Championship!",
),
author: String::from("Iceburgh"),
content: String::from(
"The Pittsburgh Penguins once again are the best \
hockey team in the NHL.",
),
}
} else {
Weibo {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
}
}
}
以上的代码就无法通过编译,因为它返回了两个不同的类型 Post
和 Weibo
。
`if` and `else` have incompatible types
expected struct `Post`, found struct `Weibo`
报错提示我们 if
和 else
返回了不同的类型。如果想要实现返回不同的类型,需要使用下一章节中的特征对象。
修复上一节中的 largest
函数
还记得上一节中的例子吧,当时留下一个疑问,该如何解决编译报错:
error[E0369]: binary operation `>` cannot be applied to type `T` // 无法在 `T` 类型上应用`>`运算符
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- T
| |
| T
|
help: consider restricting type parameter `T` // 考虑使用以下的特征来约束 `T`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
| ^^^^^^^^^^^^^^^^^^^^^^
在 largest
函数体中我们想要使用大于运算符(>
)比较两个 T
类型的值。这个运算符是标准库中特征 std::cmp::PartialOrd
的一个默认方法。所以需要在 T
的特征约束中指定 PartialOrd
,这样 largest
函数可以用于内部元素类型可比较大小的数组切片。
由于 PartialOrd
位于 prelude
中所以并不需要通过 std::cmp
手动将其引入作用域。所以可以将 largest
的签名修改为如下:
fn largest<T: PartialOrd>(list: &[T]) -> T {}
但是此时编译,又会出现新的错误:
error[E0508]: cannot move out of type `[T]`, a non-copy slice
--> src/main.rs:2:23
|
2 | let mut largest = list[0];
| ^^^^^^^
| |
| cannot move out of here
| help: consider using a reference instead: `&list[0]`
error[E0507]: cannot move out of borrowed content
--> src/main.rs:4:9
|
4 | for &item in list.iter() {
| ^----
| ||
| |hint: to prevent move, use `ref item` or `ref mut item`
| cannot move out of borrowed content
错误的核心是 cannot move out of type [T], a non-copy slice
,原因是 T
没有实现 Copy
特性,因此我们只能把所有权进行转移,毕竟只有 i32
等基础类型才实现了 Copy
特性,可以存储在栈上,而 T
可以指代任何类型(严格来说是实现了 PartialOrd
特征的所有类型)。
因此,为了让 T
拥有 Copy
特性,我们可以增加特征约束:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {}", result);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {}", result);
}
如果并不希望限制 largest
函数只能用于实现了 Copy
特征的类型,我们可以在 T
的特征约束中指定 Clone
特征 而不是 Copy
特征。并克隆 list
中的每一个值使得 largest
函数拥有其所有权。使用 clone
函数意味着对于类似 String
这样拥有堆上数据的类型,会潜在地分配更多堆上空间,而堆分配在涉及大量数据时可能会相当缓慢。
另一种 largest
的实现方式是返回在 list
中 T
值的引用。如果我们将函数返回值从 T
改为 &T
并改变函数体使其能够返回一个引用,我们将不需要任何 Clone
或 Copy
的特征约束而且也不会有任何的堆分配。尝试自己实现这种替代解决方式吧!
通过 derive
派生特征
在本书中,形如 #[derive(Debug)]
的代码已经出现了很多次,这种是一种特征派生语法,被 derive
标记的对象会自动实现对应的默认特征代码,继承相应的功能。
例如 Debug
特征,它有一套自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s)
的形式打印该结构体的对象。
再如 Copy
特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy
特征,进而可以调用 copy
方法,进行自我复制。
总之,derive
派生出来的是 Rust 默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重写该实现。
详细的 derive
列表参见附录-派生特征。
调用方法需要引入特征
在一些场景中,使用 as
关键字做类型转换会有比较大的限制,因为你想要在类型转换上拥有完全的控制,例如处理转换错误,那么你将需要 TryInto
:
use std::convert::TryInto;
fn main() {
let a: i32 = 10;
let b: u16 = 100;
let b_ = b.try_into()
.unwrap();
if a < b_ {
println!("Ten is less than one hundred.");
}
}
上面代码中引入了 std::convert::TryInto
特征,但是却没有使用它,可能有些同学会为此困惑,主要原因在于如果你要使用一个特征的方法,那么你需要将该特征引入当前的作用域中,我们在上面用到了 try_into
方法,因此需要引入对应的特征。
但是 Rust 又提供了一个非常便利的办法,即把最常用的标准库中的特征通过 std::prelude
模块提前引入到当前作用域中,其中包括了 std::convert::TryInto
,你可以尝试删除第一行的代码 use ...
,看看是否会报错。
几个综合例子
为自定义类型实现 +
操作
在 Rust 中除了数值类型的加法,String
也可以做加法,因为 Rust 为该类型实现了 std::ops::Add
特征,同理,如果我们为自定义类型实现了该特征,那就可以自己实现 Point1 + Point2
的操作:
use std::ops::Add;
// 为Point结构体派生Debug特征,用于格式化输出
#[derive(Debug)]
struct Point<T: Add<T, Output = T>> { //限制类型T必须实现了Add特征,否则无法进行+操作。
x: T,
y: T,
}
impl<T: Add<T, Output = T>> Add for Point<T> {
type Output = Point<T>;
fn add(self, p: Point<T>) -> Point<T> {
Point{
x: self.x + p.x,
y: self.y + p.y,
}
}
}
fn add<T: Add<T, Output=T>>(a:T, b:T) -> T {
a + b
}
fn main() {
let p1 = Point{x: 1.1f32, y: 1.1f32};
let p2 = Point{x: 2.1f32, y: 2.1f32};
println!("{:?}", add(p1, p2));
let p3 = Point{x: 1i32, y: 1i32};
let p4 = Point{x: 2i32, y: 2i32};
println!("{:?}", add(p3, p4));
}
自定义类型的打印输出
在开发过程中,往往只要使用 #[derive(Debug)]
对我们的自定义类型进行标注,即可实现打印输出的功能:
#[derive(Debug)]
struct Point{
x: i32,
y: i32
}
fn main() {
let p = Point{x:3,y:3};
println!("{:?}",p);
}
但是在实际项目中,往往需要对我们的自定义类型进行自定义的格式化输出,以让用户更好的阅读理解我们的类型,此时就要为自定义类型实现 std::fmt::Display
特征:
#![allow(dead_code)]
use std::fmt;
use std::fmt::{Display};
#[derive(Debug,PartialEq)]
enum FileState {
Open,
Closed,
}
#[derive(Debug)]
struct File {
name: String,
data: Vec<u8>,
state: FileState,
}
impl Display for FileState {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
FileState::Open => write!(f, "OPEN"),
FileState::Closed => write!(f, "CLOSED"),
}
}
}
impl Display for File {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "<{} ({})>",
self.name, self.state)
}
}
impl File {
fn new(name: &str) -> File {
File {
name: String::from(name),
data: Vec::new(),
state: FileState::Closed,
}
}
}
fn main() {
let f6 = File::new("f6.txt");
//...
println!("{:?}", f6);
println!("{}", f6);
}
以上两个例子较为复杂,目的是为读者展示下真实的使用场景长什么样,因此需要读者细细阅读,最终消化这些知识对于你的 Rust 之路会有莫大的帮助。
最后,特征和特征约束,是 Rust 中极其重要的概念,如果你还是没搞懂,强烈建议回头再看一遍,或者寻找相关的资料进行补充学习。如果已经觉得掌握了,那么就可以进入下一节的学习。
特征对象
在上一节中有一段代码无法通过编译:
fn returns_summarizable(switch: bool) -> impl Summary {
if switch {
Post {
// ...
}
} else {
Weibo {
// ...
}
}
}
其中 Post
和 Weibo
都实现了 Summary
特征,因此上面的函数试图通过返回 impl Summary
来返回这两个类型,但是编译器却无情地报错了,原因是 impl Trait
的返回值类型并不支持多种不同的类型返回,那如果我们想返回多种类型,该怎么办?
再来考虑一个问题:现在在做一款游戏,需要将多个对象渲染在屏幕上,这些对象属于不同的类型,存储在列表中,渲染的时候,需要循环该列表并顺序渲染每个对象,在 Rust 中该怎么实现?
聪明的同学可能已经能想到一个办法,利用枚举:
#[derive(Debug)]
enum UiObject {
Button,
SelectBox,
}
fn main() {
let objects = [
UiObject::Button,
UiObject::SelectBox
];
for o in objects {
draw(o)
}
}
fn draw(o: UiObject) {
println!("{:?}",o);
}
Bingo,这个确实是一个办法,但是问题来了,如果你的对象集合并不能事先明确地知道呢?或者别人想要实现一个 UI 组件呢?此时枚举中的类型是有些缺少的,是不是还要修改你的代码增加一个枚举成员?
总之,在编写这个 UI 库时,我们无法知道所有的 UI 对象类型,只知道的是:
UI 对象的类型不同
需要一个统一的类型来处理这些对象,无论是作为函数参数还是作为列表中的一员
需要对每一个对象调用
draw
方法
在拥有继承的语言中,可以定义一个名为 Component
的类,该类上有一个 draw
方法。其他的类比如 Button
、Image
和 SelectBox
会从 Component
派生并因此继承 draw
方法。它们各自都可以覆盖 draw
方法来定义自己的行为,但是框架会把所有这些类型当作是 Component
的实例,并在其上调用 draw
。不过 Rust 并没有继承,我们得另寻出路。
特征对象定义
为了解决上面的所有问题,Rust 引入了一个概念 —— 特征对象。
在介绍特征对象之前,先来为之前的 UI 组件定义一个特征:
pub trait Draw {
fn draw(&self);
}
只要组件实现了 Draw
特征,就可以调用 draw
方法来进行渲染。假设有一个 Button
和 SelectBox
组件实现了 Draw
特征:
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}
impl Draw for Button {
fn draw(&self) {
// 绘制按钮的代码
}
}
struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}
impl Draw for SelectBox {
fn draw(&self) {
// 绘制SelectBox的代码
}
}
此时,还需要一个动态数组来存储这些 UI 对象:
pub struct Screen {
pub components: Vec<?>,
}
注意到上面代码中的 ?
吗?它的意思是:我们应该填入什么类型,可以说就之前学过的内容里,你找不到哪个类型可以填入这里,但是因为 Button
和 SelectBox
都实现了 Draw
特征,那我们是不是可以把 Draw
特征的对象作为类型,填入到数组中呢?答案是肯定的。
特征对象指向实现了 Draw
特征的类型的实例,也就是指向了 Button
或者 SelectBox
的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体调用的类型方法。
可以通过 &
引用或者 Box<T>
智能指针的方式来创建特征对象。
Box<T>
在后面章节会详细讲解,大家现在把它当成一个引用即可,只不过它包裹的值会被强制分配在堆上。
trait Draw {
fn draw(&self) -> String;
}
impl Draw for u8 {
fn draw(&self) -> String {
format!("u8: {}", *self)
}
}
impl Draw for f64 {
fn draw(&self) -> String {
format!("f64: {}", *self)
}
}
// 若 T 实现了 Draw 特征, 则调用该函数时传入的 Box<T> 可以被隐式转换成函数参数签名中的 Box<dyn Draw>
fn draw1(x: Box<dyn Draw>) {
// 由于实现了 Deref 特征,Box 智能指针会自动解引用为它所包裹的值,然后调用该值对应的类型上定义的 `draw` 方法
x.draw();
}
fn draw2(x: &dyn Draw) {
x.draw();
}
fn main() {
let x = 1.1f64;
// do_something(&x);
let y = 8u8;
// x 和 y 的类型 T 都实现了 `Draw` 特征,因为 Box<T> 可以在函数调用时隐式地被转换为特征对象 Box<dyn Draw>
// 基于 x 的值创建一个 Box<f64> 类型的智能指针,指针指向的数据被放置在了堆上
draw1(Box::new(x));
// 基于 y 的值创建一个 Box<u8> 类型的智能指针
draw1(Box::new(y));
draw2(&x);
draw2(&y);
}
上面代码,有几个非常重要的点:
draw1
函数的参数是Box<dyn Draw>
形式的特征对象,该特征对象是通过Box::new(x)
的方式创建的draw2
函数的参数是&dyn Draw
形式的特征对象,该特征对象是通过&x
的方式创建的dyn
关键字只用在特征对象的类型声明上,在创建时无需使用dyn
因此,可以使用特征对象来代表泛型或具体的类型。
继续来完善之前的 UI 组件代码,首先来实现 Screen
:
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}
其中存储了一个动态数组,里面元素的类型是 Draw
特征对象:Box<dyn Draw>
,任何实现了 Draw
特征的类型,都可以存放其中。
再来为 Screen
定义 run
方法,用于将列表中的 UI 组件渲染在屏幕上:
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
至此,我们就完成了之前的目标:在列表中存储多种不同类型的实例,然后将它们使用同一个方法逐一渲染在屏幕上!
再来看看,如果通过泛型实现,会如何:
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
impl<T> Screen<T>
where T: Draw {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}
上面的 Screen
的列表中,存储了类型为 T
的元素,然后在 Screen
中使用特征约束让 T
实现了 Draw
特征,进而可以调用 draw
方法。
但是这种写法限制了 Screen
实例的 Vec<T>
中的每个元素必须是 Button
类型或者全是 SelectBox
类型。如果只需要同质(相同类型)集合,更倾向于采用泛型+特征约束这种写法,因其实现更清晰,且性能更好(特征对象,需要在运行时从 vtable
动态查找需要调用的方法)。
现在来运行渲染下咱们精心设计的 UI 组件列表:
fn main() {
let screen = Screen {
components: vec![
Box::new(SelectBox {
width: 75,
height: 10,
options: vec![
String::from("Yes"),
String::from("Maybe"),
String::from("No")
],
}),
Box::new(Button {
width: 50,
height: 10,
label: String::from("OK"),
}),
],
};
screen.run();
}
上面使用 Box::new(T)
的方式来创建了两个 Box<dyn Draw>
特征对象,如果以后还需要增加一个 UI 组件,那么让该组件实现 Draw
特征,则可以很轻松的将其渲染在屏幕上,甚至用户可以引入我们的库作为三方库,然后在自己的库中为自己的类型实现 Draw
特征,然后进行渲染。
在动态类型语言中,有一个很重要的概念:鸭子类型(duck typing),简单来说,就是只关心值长啥样,而不关心它实际是什么。当一个东西走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子,就算它实际上是一个奥特曼,也不重要,我们就当它是鸭子。
在上例中,Screen
在 run
的时候,我们并不需要知道各个组件的具体类型是什么。它也不检查组件到底是 Button
还是 SelectBox
的实例,只要它实现了 Draw
特征,就能通过 Box::new
包装成 Box<dyn Draw>
特征对象,然后被渲染在屏幕上。
使用特征对象和 Rust 类型系统来进行类似鸭子类型操作的优势是,无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征, 那么 Rust 根本就不会编译这些代码:
fn main() {
let screen = Screen {
components: vec![
Box::new(String::from("Hi")),
],
};
screen.run();
}
因为 String
类型没有实现 Draw
特征,编译器直接就会报错,不会让上述代码运行。如果想要 String
类型被渲染在屏幕上,那么只需要为其实现 Draw
特征即可,非常容易。
注意 dyn
不能单独作为特征对象的定义,例如下面的代码编译器会报错,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的。
而 &dyn
和 Box<dyn>
在编译期都是已知大小,所以可以用作特征对象的定义。
fn draw2(x: dyn Draw) {
x.draw();
}
10 | fn draw2(x: dyn Draw) {
| ^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `(dyn Draw + 'static)`
help: function arguments must have a statically known size, borrowed types always have a known size
特征对象的动态分发
回忆一下泛型章节我们提到过的,泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。
与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字 dyn
正是在强调这一“动态”的特点。
当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。
下面这张图很好的解释了静态分发 Box<T>
和动态分发 Box<dyn Trait>
的区别:
结合上文的内容和这张图可以了解:
特征对象大小不固定:这是因为,对于特征
Draw
,类型Button
可以实现特征Draw
,类型SelectBox
也可以实现特征Draw
,因此特征没有固定大小几乎总是使用特征对象的引用方式,如
&dyn Draw
、Box<dyn Draw>
虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
ptr
和vptr
),因此占用两个指针大小一个指针
ptr
指向实现了特征Draw
的具体类型的实例,也就是当作特征Draw
来用的类型的实例,比如类型Button
的实例、类型SelectBox
的实例另一个指针
vptr
指向一个虚表vtable
,vtable
中保存了类型Button
或类型SelectBox
的实例对于可以调用的实现于特征Draw
的方法。当调用方法时,直接从vtable
中找到方法并调用。之所以要使用一个vtable
来保存各实例的方法,是因为实现了特征Draw
的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作特征Draw
来使用时(此时,它们全都看作是特征Draw
类型的实例),有必要区分这些实例各自有哪些方法可调用
简而言之,当类型 Button
实现了特征 Draw
时,类型 Button
的实例对象 btn
可以当作特征 Draw
的特征对象类型来使用,btn
中保存了作为特征对象的数据指针(指向类型 Button
的实例数据)和行为指针(指向 vtable
)。
一定要注意,此时的 btn
是 Draw
的特征对象的实例,而不再是具体类型 Button
的实例,而且 btn
的 vtable
只包含了实现自特征 Draw
的那些方法(比如 draw
),因此 btn
只能调用实现于特征 Draw
的 draw
方法,而不能调用类型 Button
本身实现的方法和类型 Button
实现于其他特征的方法。也就是说,btn
是哪个特征对象的实例,它的 vtable
中就包含了该特征的方法。
Self 与 self
在 Rust 中,有两个self
,一个指代当前的实例对象,一个指代特征或者方法类型的别名:
trait Draw {
fn draw(&self) -> Self;
}
#[derive(Clone)]
struct Button;
impl Draw for Button {
fn draw(&self) -> Self {
return self.clone()
}
}
fn main() {
let button = Button;
let newb = button.draw();
}
上述代码中,self
指代的就是当前的实例对象,也就是 button.draw()
中的 button
实例,Self
则指代的是 Button
类型。
当理解了 self
与 Self
的区别后,我们再来看看何为对象安全。
特征对象的限制
不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,它的对象才是安全的:
方法的返回类型不能是
Self
方法没有任何泛型参数
对象安全对于特征对象是必须的,因为一旦有了特征对象,就不再需要知道实现该特征的具体类型是什么了。如果特征方法返回了具体的 Self
类型,但是特征对象忘记了其真正的类型,那这个 Self
就非常尴尬,因为没人知道它是谁了。但是对于泛型类型参数来说,当使用特征时其会放入具体的类型参数:此具体类型变成了实现该特征的类型的一部分。而当使用特征对象时其具体类型被抹去了,故而无从得知放入泛型参数类型到底是什么。
标准库中的 Clone
特征就不符合对象安全的要求:
pub trait Clone {
fn clone(&self) -> Self;
}
因为它的其中一个方法,返回了 Self
类型,因此它是对象不安全的。
String
类型实现了 Clone
特征, String
实例上调用 clone
方法时会得到一个 String
实例。类似的,当调用 Vec<T>
实例的 clone
方法会得到一个 Vec<T>
实例。clone
的签名需要知道什么类型会代替 Self
,因为这是它的返回值。
如果违反了对象安全的规则,编译器会提示你。例如,如果尝试使用之前的 Screen
结构体来存放实现了 Clone
特征的类型:
pub struct Screen {
pub components: Vec<Box<dyn Clone>>,
}
将会得到如下错误:
error[E0038]: the trait `std::clone::Clone` cannot be made into an object
--> src/lib.rs:2:5
|
2 | pub components: Vec<Box<dyn Clone>>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone`
cannot be made into an object
|
= note: the trait cannot require that `Self : Sized`
这意味着不能以这种方式使用此特征作为特征对象。
深入了解特征
特征之于 Rust 更甚于接口之于其他语言,因此特征在 Rust 中很重要也相对较为复杂,我们决定把特征分为两篇进行介绍,第一篇在之前已经讲过,现在就是第二篇:关于特征的进阶篇,会讲述一些不常用到但是你该了解的特性。
关联类型
在方法一章中,我们讲到了关联函数,但是实际上关联类型和关联函数并没有任何交集,虽然它们的名字有一半的交集。
关联类型是在特征定义的语句块中,声明一个自定义类型,这样就可以在特征的方法签名中使用该类型:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
以上是标准库中的迭代器特征 Iterator
,它有一个 Item
关联类型,用于替代遍历的值的类型。
同时,next
方法也返回了一个 Item
类型,不过使用 Option
枚举进行了包裹,假如迭代器中的值是 i32
类型,那么调用 next
方法就将获取一个 Option<i32>
的值。
还记得 Self
吧?在之前的章节提到过, Self
用来指代当前调用者的具体类型,那么 Self::Item
就用来指代该类型实现中定义的 Item
类型:
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// --snip--
}
}
fn main() {
let c = Counter{..}
c.next()
}
在上述代码中,我们为 Counter
类型实现了 Iterator
特征,变量 c
是特征 Iterator
的实例,也是 next
方法的调用者。 结合之前的黑体内容可以得出:对于 next
方法而言,Self
是调用者 c
的具体类型: Counter
,而 Self::Item
是 Counter
中定义的 Item
类型: u32
。
聪明的读者之所以聪明,是因为你们喜欢联想和举一反三,同时你们也喜欢提问:为何不用泛型,例如如下代码:
pub trait Iterator<Item> {
fn next(&mut self) -> Option<Item>;
}
答案其实很简单,为了代码的可读性,当你使用了泛型后,你需要在所有地方都写 Iterator<Item>
,而使用了关联类型,你只需要写 Iterator
,当类型定义复杂时,这种写法可以极大的增加可读性:
pub trait CacheableItem: Clone + Default + fmt::Debug + Decodable + Encodable {
type Address: AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash;
fn is_null(&self) -> bool;
}
例如上面的代码,Address
的写法自然远比 AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash
要简单的多,而且含义清晰。
再例如,如果使用泛型,你将得到以下的代码:
trait Container<A,B> {
fn contains(&self,a: A,b: B) -> bool;
}
fn difference<A,B,C>(container: &C) -> i32
where
C : Container<A,B> {...}
可以看到,由于使用了泛型,导致函数头部也必须增加泛型的声明,而使用关联类型,将得到可读性好得多的代码:
trait Container{
type A;
type B;
fn contains(&self, a: &Self::A, b: &Self::B) -> bool;
}
fn difference<C: Container>(container: &C) {}
关联类型还可以被其它特征进行约束,例如:
trait Container{
type A:Display;
type B;
fn contains(&self, a: &Self::A, b: &Self::B) -> bool;
}
默认泛型类型参数
当使用泛型类型参数时,可以为其指定一个默认的具体类型,例如标准库中的 std::ops::Add
特征:
trait Add<RHS=Self> {
type Output;
fn add(self, rhs: RHS) -> Self::Output;
}
它有一个泛型参数 RHS
,但是与我们以往的用法不同,这里它给 RHS
一个默认值,也就是当用户不指定 RHS
时,默认使用两个同样类型的值进行相加,然后返回一个关联类型 Output
。
可能上面那段不太好理解,下面我们用代码来举例:
use std::ops::Add;
#[derive(Debug, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 });
}
上面的代码主要干了一件事,就是为 Point
结构体提供 +
的能力,这就是运算符重载,不过 Rust 并不支持创建自定义运算符,你也无法为所有运算符进行重载,目前来说,只有定义在 std::ops
中的运算符才能进行重载。
跟 +
对应的特征是 std::ops::Add
,我们在之前也看过它的定义 trait Add<RHS=Self>
,但是上面的例子中并没有为 Point
实现 Add<RHS>
特征,而是实现了 Add
特征(没有默认泛型类型参数),这意味着我们使用了 RHS
的默认类型,也就是 Self
。换句话说,我们这里定义的是两个相同的 Point
类型相加,因此无需指定 RHS
。
与上面的例子相反,下面的例子,我们来创建两个不同类型的相加:
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
这里,是进行 Millimeters + Meters
两种数据类型的 +
操作,因此此时不能再使用默认的 RHS
,否则就会变成 Millimeters + Millimeters
的形式。使用 Add<Meters>
可以将 RHS
指定为 Meters
,那么 fn add(self, rhs: RHS)
自然而言的变成了 Millimeters
和 Meters
的相加。
默认类型参数主要用于两个方面:
减少实现的样板代码
扩展类型但是无需大幅修改现有的代码
之前的例子就是第一点,虽然效果也就那样。在 +
左右两边都是同样类型时,只需要 impl Add
即可,否则你需要 impl Add<SOME_TYPE>
,嗯,会多写几个字:)
对于第二点,也很好理解,如果你在一个复杂类型的基础上,新引入一个泛型参数,可能需要修改很多地方,但是如果新引入的泛型参数有了默认类型,情况就会好很多,添加泛型参数后,使用这个类型的代码需要逐个在类型提示部分添加泛型参数,就很麻烦;但是有了默认参数(且默认参数取之前的实现里假设的值的情况下)之后,原有的使用这个类型的代码就不需要做改动了。
归根到底,默认泛型参数,是有用的,但是大多数情况下,咱们确实用不到,当需要用到时,大家再回头来查阅本章即可,手上有剑,心中不慌。
调用同名的方法
不同特征拥有同名的方法是很正常的事情,你没有任何办法阻止这一点;甚至除了特征上的同名方法外,在你的类型上,也有同名方法:
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
这里,不仅仅两个特征 Pilot
和 Wizard
有 fly
方法,就连实现那两个特征的 Human
单元结构体,也拥有一个同名方法 fly
(这世界怎么了,非要这么卷吗?程序员何苦难为程序员,哎)。
既然代码已经不可更改,那下面我们来讲讲该如何调用这些 fly
方法。
优先调用类型上的方法
当调用 Human
实例的 fly
时,编译器默认调用该类型中定义的方法:
fn main() {
let person = Human;
person.fly();
}
这段代码会打印 *waving arms furiously*
,说明直接调用了类型上定义的方法。
调用特征上的方法
为了能够调用两个特征的方法,需要使用显式调用的语法:
fn main() {
let person = Human;
Pilot::fly(&person); // 调用Pilot特征上的方法
Wizard::fly(&person); // 调用Wizard特征上的方法
person.fly(); // 调用Human类型自身的方法
}
运行后依次输出:
This is your captain speaking.
Up!
*waving arms furiously*
因为 fly
方法的参数是 self
,当显式调用时,编译器就可以根据调用的类型( self
的类型)决定具体调用哪个方法。
这个时候问题又来了,如果方法没有 self
参数呢?稍等,估计有读者会问:还有方法没有 self
参数?看到这个疑问,作者的眼泪不禁流了下来,大明湖畔的关联函数,你还记得嘛?
但是成年人的世界,就算再伤心,事还得做,咱们继续:
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
就像人类妈妈会给自己的宝宝起爱称一样,狗妈妈也会。狗妈妈称呼自己的宝宝为Spot,其它动物称呼狗宝宝为puppy,这个时候假如有动物不知道该如何称呼狗宝宝,它需要查询一下。
Dog::baby_name()
的调用方式显然不行,因为这只是狗妈妈对宝宝的爱称,可能你会想到通过下面的方式查询其他动物对狗狗的称呼:
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
铛铛,无情报错了:
error[E0283]: type annotations needed // 需要类型注释
--> src/main.rs:20:43
|
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^ cannot infer type // 无法推断类型
|
= note: cannot satisfy `_: Animal`
因为单纯从 Animal::baby_name()
上,编译器无法得到任何有效的信息:实现 Animal
特征的类型可能有很多,你究竟是想获取哪个动物宝宝的名称?狗宝宝?猪宝宝?还是熊宝宝?
此时,就需要使用完全限定语法。
完全限定语法
完全限定语法是调用函数最为明确的方式:
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
在尖括号中,通过 as
关键字,我们向 Rust 编译器提供了类型注解,也就是 Animal
就是 Dog
,而不是其他动物,因此最终会调用 impl Animal for Dog
中的方法,获取到其它动物对狗宝宝的称呼:puppy。
言归正题,完全限定语法定义为:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
上面定义中,第一个参数是方法接收器 receiver
(三种 self
),只有方法才拥有,例如关联函数就没有 receiver
。
完全限定语法可以用于任何函数或方法调用,那么我们为何很少用到这个语法?原因是 Rust 编译器能根据上下文自动推导出调用的路径,因此大多数时候,我们都无需使用完全限定语法。只有当存在多个同名函数或方法,且 Rust 无法区分出你想调用的目标函数时,该用法才能真正有用武之地。
特征定义中的特征约束
有时,我们会需要让某个特征 A 能使用另一个特征 B 的功能(另一种形式的特征约束),这种情况下,不仅仅要为类型实现特征 A,还要为类型实现特征 B 才行,这就是基特征( super trait )。
例如有一个特征 OutlinePrint
,它有一个方法,能够对当前的实现类型进行格式化输出:
use std::fmt::Display;
trait OutlinePrint: Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
等等,这里有一个眼熟的语法: OutlinePrint: Display
,感觉很像之前讲过的特征约束,只不过用在了特征定义中而不是函数的参数中,是的,在某种意义上来说,这和特征约束非常类似,都用来说明一个特征需要实现另一个特征,这里就是:如果你想要实现 OutlinePrint
特征,首先你需要实现 Display
特征。
想象一下,假如没有这个特征约束,那么 self.to_string
还能够调用吗( to_string
方法会为实现 Display
特征的类型自动实现)?编译器肯定是不愿意的,会报错说当前作用域中找不到用于 &Self
类型的方法 to_string
:
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
因为 Point
没有实现 Display
特征,会得到下面的报错:
error[E0277]: the trait bound `Point: std::fmt::Display` is not satisfied
--> src/main.rs:20:6
|
20 | impl OutlinePrint for Point {}
| ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter;
try using `:?` instead if you are using a format string
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
既然我们有求于编译器,那只能选择满足它咯:
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
上面代码为 Point
实现了 Display
特征,那么 to_string
方法也将自动实现:最终获得字符串是通过这里的 fmt
方法获得的。
在外部类型上实现外部特征(newtype)
在特征章节中,有提到孤儿规则,简单来说,就是特征或者类型必需至少有一个是本地的,才能在此类型上定义特征。
这里提供一个办法来绕过孤儿规则,那就是使用newtype 模式,简而言之:就是为一个元组结构体创建新类型。该元组结构体封装有一个字段,该字段就是希望实现特征的具体类型。
该封装类型是本地的,因此我们可以为此类型实现外部的特征。
newtype
不仅仅能实现以上的功能,而且它在运行时没有任何性能损耗,因为在编译期,该类型会被自动忽略。
下面来看一个例子,我们有一个动态数组类型: Vec<T>
,它定义在标准库中,还有一个特征 Display
,它也定义在标准库中,如果没有 newtype
,我们是无法为 Vec<T>
实现 Display
的:
error[E0117]: only traits defined in the current crate can be implemented for arbitrary types
--> src/main.rs:5:1
|
5 | impl<T> std::fmt::Display for Vec<T> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^------
| | |
| | Vec is not defined in the current crate
| impl doesn't use only types from inside the current crate
|
= note: define and implement a trait or new type instead
编译器给了我们提示: define and implement a trait or new type instead
,重新定义一个特征,或者使用 new type
,前者当然不可行,那么来试试后者:
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {}", w);
}
其中,struct Wrapper(Vec<String>)
就是一个元组结构体,它定义了一个新类型 Wrapper
,代码很简单,相信大家也很容易看懂。
既然 new type
有这么多好处,它有没有不好的地方呢?答案是肯定的。注意到我们怎么访问里面的数组吗?self.0.join(", ")
,是的,很啰嗦,因为需要先从 Wrapper
中取出数组: self.0
,然后才能执行 join
方法。
类似的,任何数组上的方法,你都无法直接调用,需要先用 self.0
取出数组,然后再进行调用。
当然,解决办法还是有的,要不怎么说 Rust 是极其强大灵活的编程语言!Rust 提供了一个特征叫 Deref
,实现该特征后,可以自动做一层类似类型转换的操作,可以将 Wrapper
变成 Vec<String>
来使用。这样就会像直接使用数组那样去使用 Wrapper
,而无需为每一个操作都添加上 self.0
。
同时,如果不想 Wrapper
暴露底层数组的所有方法,我们还可以为 Wrapper
去重载这些方法,实现隐藏的目的。