Rust 语言从入门到实战 唐刚--读书笔记07
基础篇 (11讲)
07|类型与类型参数:给Rust小助手提供更多信息
类型相关的知识,对 Rust ,非常重要而有趣。
对一个二进制值来说,正是 类型 赋予了它意义。
如 01100001 这个二进制数字,是整数表示 97,是字符表示 'a' 这个 char。没有类型去赋予它额外的信息,仅看到这串二进制编码,不知道它代表什么。
类型
《Programming.with.Types》2019 对类型的定义:类型是对数据的分类,定义了这些数据的意义、被允许的值的集合,能在这些数据上执行哪些操作。编译器或运行时会检查类型化过程,以确保数据的完整性,对数据施加访问限制,以及把数据按程序员的意图进行解释。
为简化,把操作部分忽略,简单地把类型看作集合,这个集合表达了这个类型的实例能取到的所有可能的值。
类型系统
书里还定义了类型系统。
类型系统是一套规则集——把类型赋予和施加到编程语言的元素上。这些元素可以是变量、函数和其他高阶结构。类型系统通过在代码中提供的标注来给元素赋予类型,或根据它的上下文隐式地推导某个元素的类型。类型系统允许在类型间做各种转换,同时禁止其他的一些转换。
类型的标注: let a: u32 = 10;
用 : u32 语法对变量 a 进行标注,表明变量 a 的类型是 u32 类型。
u32 可以转换成 u64。
let b = a as u64;
但 u32 不能直接转换到 String 。
fn main() {
let a: u32 = 10;
let b = a as String; // 错误的
println!("{b}");
}
类型化的好处
类型化 5 大好处:正确性、不可变性、封装性、组合性、可读性。都是软件工程理论推崇的
Rust 语言非常强调类型化,它的类型系统非常严格,隐式转换非常少:
fn main() {
let a = 1.0f32;
let b = 10;
let c = a * b;
}
错误,提示不能将一个浮点数和一个整数相乘。
error[E0277]: cannot multiply `f32` by `{integer}`
--> src/main.rs:5:15
|
5 | let c = a * b;
| ^ no implementation for `f32 * {integer}`
|
= help: the trait `Mul<{integer}>` is not implemented for `f32`
= help: the following other types implement trait `Mul<Rhs>`:
<&'a f32 as Mul<f32>>
<&f32 as Mul<&f32>>
<f32 as Mul<&f32>>
<f32 as Mul>
初学者往往会觉得 Rust 过于严苛。
当基础类型转换错误时,用 as 操作符显式地将类型转成一致。
fn main() {
let a = 1.0f32;
let b = 10 as f32; // 添加了 as f32
let c = a * b;
}
编译通过。
Rust 明显的特点:尽可能地显式化。
显式化两层意思。
- 不做自动隐式转换。
- 没有内置转换策略。
不做自动隐式转换:多了 as f32 这几个字符,由程序员为编译器明确地提供了一些额外的信息。
没有内置转换策略:拿 JavaScript 社区中流传的一张梗图来对比说明。
在 JavaScript 里,9 + "1" 的结果是 "91"。在两个不同类型之间相加如何处理上,JavaScript 自己内置了策略,硬生生出了一个结果。当遇到 91- "1" 时,策略又变了,算出了数字 90。这些就是内置类型转换策略的体现。
Rust 中,9+"1" 不可能通过编译,更不会计算出一个神奇的结果。
如果要写类似的代码,Rust 中可以这样做。
fn main() {
let a = 9 + '1' as u8;
let b = 9.to_string() + "1";
}
特别清晰!一眼就能推断出 a 和 b 的类型,a 为 u8 类型,b 为 String 类型。
Rust 有着严密的类型体系,在类型化上绝不含糊。项目越大,用 Rust 就越舒服,原因之一就是严谨的类型系统在保驾护航。
类型作为一种约束
类型是变量所有可能取得的值的集合。类型实际上限制或定义了变量的取值空间。类型对于变量来说,也是一种约束。
实际上,Rust 中的 : (冒号)在语法层面上就是约束。
let a: u8 = 10;
let b: String = "123".to_string();
变量 a 被限制为只能在 u8 这个类型的值空间取值(0 到 255 里的一个),而 10 属于这个空间。变量 b 被限制为只能在字符串值空间内取值。不管字符串的值空间多大(其实是无限),这些值与 u8、u32 这些类型的值也是不同的。
多种类型如何表示?
用一种类型来对变量的取值空间进行约束。利于健壮性。
Rust 中,把整数分成 u8、u16、u32、u64。想写一个函数,它的参数支持整数,要同时能接受 u8、u16、u32、u64 这几种类型的值,怎么办?
如何让这一个日志函数同时支持数字和字符串作为参数输入呢?
问题:Rust 中,怎么用某种方式来表示多种类型?
类型参数
Rust 中定义类型的时候,可以用类型参数。
如标准库里的 Vec<T>,带一个类型参数 T,它可以支持不同类型的列表,如 Vec<u8>、Vec<u32>、Vec<String> 等。这个 T 表示一个类型参数,在定义时还不知道它具体是什么类型。只有在使用的时候,才会对这个 T 赋予一个具体的类型。
这个 Vec<T>,是一个类型整体,单独拆开来讲 Vec 类型是没有意义的。T 是 Vec<T> 中的类型参数,它其实也是信息,提供给 Rust 编译器使用。带类型参数的类型整体(如 Vec<T>)叫做泛型(generic type)。
结构体中的类型参数
定义一个结构体 Point<T>。
struct Point<T> {
x: T,
y: T,
}
二维平面上的点,由 x、y 坐标定义。 x、y 的取值可能是整数、浮点数、无穷精度的类型。定义为 struct Point<T>, Point<T> 整体就成为了泛型。标注 x 和 y 分量的类型都是 T。T 占据了冒号后面定义类型的位置,是占位类型。
隐藏的细节:x 和 y 字段的类型都是 T, x 和 y 两个分量的类型是一样的。
看这个 Point 结构体类型如何实例化。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 }; // 一个整数point
let float = Point { x: 1.0, y: 4.0 }; // 一个浮点数point
}
编译通过。
如果实例化的时候,给 x 和 y 赋予不同的类型值会怎样呢?
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
期望整数却收到了浮点数,报错。
细节:编译器对 Point<T> 中的 T 参数进行了推导,先遇到 x 的值 5,整数类型,因此编译器就把 T 具体化成了整数类型(具体哪一种在这里不重要),再遇到 y 分量的值,是浮点数类型,和刚才的整数类型不一致了。Point 定义时,要求 x 和 y 的类型是相同的,产生了冲突,报错。
如何解决这个问题?把 x 分量和 y 分量定义成不同的参数化类型。
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
通过编译。
强调,Point<T> 和 Point<T, U> 都是一个类型整体。把 Point 这个符号本身单独拿出来没有意义。
可用 turbofish 语法 ::<> 明确地给泛型(或 Rust 编译器)提供类型参数信息。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point::<u32> { x: 5, y: 10 };
let float = Point::<f32> { x: 1.0, y: 4.0 };
}
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point::<u32, u32> { x: 5, y: 10 };
let both_float = Point::<f32, f32> { x: 1.0, y: 4.0 };
let integer_and_float = Point::<u32, f32> { x: 5, y: 4.0 };
}
使用时提供类型参数信息用的是 ::<>,而定义类型参数时只用 <>。通过语法明确地区分开了。
类型参数存在两个过程,定义时,使用时。这两个过程的区分很重要。这里所谓的“使用时”,仍然是在编译期进行分析的,就是分析在代码的某个地方用到了这个带类型参数的类型,然后把这个参数具体化,从而形成一个最终的类型版本。
如 Point<T> 类型的具化类型可能是 Point<u32>、Point<f32> 等等;Point<T, U> 类型的具化类型可能是 Point<u32, u32>、Point<u32, f32>、Point<f32, u32>、Point<f32, f32> 等等。由编译器自动计算展开。
这种在编译期间完成的类型展开成具体版本的过程,被叫做编译期单态化。单态化的意思就是把处于混沌未知的状态具体化到一个单一的状态。
在泛型上做 impl
用 Point<T> 举例。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> { // 注意这一行
fn play(n: T) {} // 注意这一行
}
对 Point<T> 做 impl 时,要在 impl 后面加一个 <T>,表示在 impl 的 block 中定义类型参数 T,供 impl block 中的元素使用,这些元素包括: impl<T> Point<T> 里 Point<T> 中的 T 和整个 impl 的花括号 body 中的代码,如 play() 函数的参数就用到了这个 T。
细节要注意:struct Point<T> 里 Point<T> 中的 T 是定义类型参数 T,impl<T> Point<T> 中的 Point<T> 中的 T 是使用类型参数 T,这个 T 是在 impl 后面那个尖括号中定义的。
对泛型做了 impl 后,对其某一个具化类型继续做 impl 也是可以的:
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn play(n: T) {}
}
impl Point<u32> { // 这里,对具化类型 Point<u32> 继续做 impl
fn doit() {}
}
具体场景中的类型参数的使用。
枚举中的类型参数
枚举的变体可以挂载任意其他类型作为负载。每个负载的位置,都可以出现类型参数。
最常见的两个枚举,Option<T> 与 Result<T, E>,就是泛型。
Option<T> 表示 有或无。
enum Option<T> {
Some(T),
None,
}
Result<T, E> 表示结果,正确或错误。Ok 变体带类型参数 T,Err 变体带类型参数 E。
enum Result<T, E> {
Ok(T),
Err(E),
}
更复杂的枚举中带类型参数。
struct Point<T> {
x: T,
y: T,
}
enum Aaa<T, U> {
V1(Point<T>),
V2(Vec<U>),
}
枚举 Aaa<T, U> 的变体 V1 带了一个 Point<T> 的负载,变体 V2 带了一个 Vec<U> 的负载。由于出现了两个类型参数 T 和 U,要在 Aaa 后面的尖括号里定义这两个类型参数。
类型参数也是一种复用代码的方式,代码更紧凑。
看具体的应用场景。
函数中的类型参数
对每个具体的类型实现一次同样的逻辑,很臃肿。不好维护,容易出错。
struct PointU32 {
x: u32,
y: u32,
}
struct PointF32 {
x: f32,
y: f32,
}
fn print_u32(p: PointU32) {
println!("Point {}, {}", p.x, p.y);
}
fn print_f32(p: PointF32) {
println!("Point {}, {}", p.x, p.y);
}
fn main() {
let p = PointU32 {x: 10, y: 20};
print_u32(p);
let p = PointF32 {x: 10.2, y: 20.4};
print_f32(p);
}
针对不同的字段类型(u32,f32)分别定义结构体(PointU32,PointF32)和对应的打印函数(print_u32,print_f32),并分别调用。
优化成这样:
struct Point<T> {
x: T,
y: T,
}
fn print<T: std::fmt::Display>(p: Point<T>) {
println!("Point {}, {}", p.x, p.y);
}
fn main() {
let p = Point {x: 10, y: 20};
print(p);
let p = Point {x: 10.2, y: 20.4};
print(p);
}
清爽多了!
实际上,清爽只是我们看到的样子。在编译的时候,Rust 编译器会帮助我们把这种泛型代码展开成前面那个示例的样子。
print 函数的类型参数在定义的时候,多了一个东西。
fn print<T: std::fmt::Display>(p: Point<T>) {
T: std::fmt::Display 是要求 T 满足某些条件 / 约束。这里具体来说就是 T 要满足可以被打印的条件。函数的目的是把 x 和 y 分量打印出来,那么它确实要能被打印才行,比如得能转换成人类可见的某种格式。
方法中的类型参数
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> { // 在impl后定义impl block中要用到的类型参数
fn x(&self) -> &T { // 这里,在方法的返回值上使用了这个类型参数
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
// 输出
p.x = 5
示例中,Point<T> 的方法 x() 的返回值类型是 &T,使用到了 impl<T> 这里定义的类型参数 T。
更复杂的内容,方法中的类型参数和结构体中的类型参数可以不同。
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
// 这里定义了impl block中可以使用的类型参数X3, Y3,
impl<X3, Y3> Point<X3, Y3> {
// 这里单独为mixup方法定义了两个新的类型参数 X2, Y2
// 于是在mixup方法中,可以使用4个类型参数:X3, Y3, X2, Y2
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X3, Y2> {
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);
}
// 输出
p3.x = 5, p3.y = c
在 Point<X3, Y3> 的方法 mixup() 上新定义了两个类型参数 X2、Y2,于是在 mixup() 方法中,可以同时使用 4 个类型参数:X2、Y2、X3、Y3。品味一下这个示例。
类型体系构建方法
类型体系构建:如何从最底层的小砖块开始,通过封装、组合、集成,最后修建成一座类型上的摩天大楼。在 Rust 里,主要有四大基础设施参与这个搭建的过程。
- struct 结构体
- enum 枚举
- 洋葱结构
- type 关键字
struct 和 enum
struct 是 Rust 里把简单类型组合起来的主要结构。struct 里的字段可以是基础类型,也可以是其他结构体或枚举等复合类型,这样一层一层往上套。
结构体 struct 表达的是一种元素间同时起作用的结构。
枚举 enum 表达的是一种元素间在一个时刻只有一种元素起作用的结构。特别适合做配置和类型聚合之类的工作。
综合示例,对某个学校的数学课程建模的层级模型。
struct Point(u32, u32); // 定义点
struct Rectangle { // 长方形由两个点决定
p1: Point,
p2: Point,
}
struct Triangle(Point, Point, Point); // 三角形由三个点组成
struct Circle(Point, u32); // 圆由点和半径组成
enum Shape { // 由枚举把长方形,三角形和圆形聚合在一起
Rectangle(Rectangle),
Triangle(Triangle),
Circle(Circle),
}
struct Axes; // 定义坐标
struct Geometry { // 几何学由形状和坐标组成
shape: Shape,
axes: Axes,
}
struct Algebra; // 定义代数
enum Level { // 定义学校的级别
Elementary, // 小学
Secondary, // 初中
High, // 高中
}
enum Course { // 数学要学习几何和代数,由枚举来聚合
Geometry(Geometry),
Algebra(Algebra),
}
struct MathLesson { // 定义数学课程,包括数学的科目和级别
math: Course,
level: Level,
}
请根据注释认真体会其中类型的层级结构。你甚至可以试着去编译一下上述代码,看看是不是可以编译通过。
偷偷告诉你答案:可以通过!
newtype
结构体还有一种常见的封装方法,用单元素的元组结构体。
如定义一个列表类型 struct List(Vec<u8>);。实际就是 Vec<u8> 类型的一个新封装,给里面原来那种类型取了一个新名字,也把原类型的属性和方法等屏蔽起来了。
没有具化类型参数的情形。
struct List<T>(Vec<T>);
这种模式非常常见,叫 newtype 模式,用新的类型名字替换里面原来那个类型名字。
洋葱结构
Rust 中的类型还有另外一种构建方法——洋葱结构。
示例,type 关键字在这里的作用是把一个类型重命名,取了更短的名字。
// 你可以试着编译这段代码
use std::collections::HashMap;
type AAA = HashMap<String, Vec<u8>>;
type BBB = Vec<AAA>;
type CCC = HashMap<String, BBB>;
type DDD = Vec<CCC>;
type EEE = HashMap<String, DDD>;
EEE 展开是这样的:
HashMap<String, Vec<HashMap<String, Vec<HashMap<String, Vec<u8>>>>>>;
尖括号的层数很多,像洋葱一样一层一层的,叫洋葱类型结构。可以把这个层次无限扩展下去。
结合 newtype 和 struct 的更复杂的示例。
use std::collections::HashMap;
struct AAA(Vec<u8>);
struct BBB {
hashmap: HashMap<String, AAA>
}
struct CCC(BBB);
type DDD = Vec<CCC>;
type EEE = HashMap<String, Vec<DDD>>;
最后,EEE展开就类似下面这样(仅示意,无法编译通过)
HashMap<String, Vec<Vec<CCC(BBB {hashmap: HashMap<String, AAA<Vec<u8>>>})>>>
洋葱结构在嵌套层级多了之后,展开是相当复杂的。
type 关键字
type 关键字,作用是在洋葱结构表示太长了之后,把一大串类型的表达简化成一个简短的名字。Rust 中用 type 关键字,使类型大厦的构建过程变得清晰可控。
type 关键字还可以处理泛型的情况:
type MyType<T> = HashMap<String, Vec<HashMap<String, Vec<HashMap<String, Vec<T>>>>>>;
最里面那个是类型 Vec<T>,T 类型参数还在。给洋葱类型重命名的时候,要把这个 T 参数带上,变成了 MyType<T>。
这种写法在标准库里很常见,最佳示例就是关于各种 Result* 的定义。
在 std::io 模块里,取了一个与 std::result::Result<T, E> 同名的 Result 类型,把 std::result::Result<T, E> 定义简化了,具化其 Error 类型为 std::io::Error,同时仍保留了第一个类型参数 T。于是得到了 Result<T>。
pub type Result<T> = Result<T, Error>;
刚开始接触 Rust 的时候,你可能会对这种表达方式产生疑惑,其实道理就在这里。在阅读 Rust 生态中各种库的源码时,会经常遇到这种封装方式,要习惯它。
关于这种定义更多的资料:
https://doc.rust-lang.org/std/result/enum.Result.html
https://doc.rust-lang.org/std/io/type.Result.html
https://doc.rust-lang.org/std/io/struct.Error.html
小结
对 Rust 中类型相关的知识做了一个专门的讲解。
现代编程语言的趋势是越来越强调类型化,如 TypeScript、Rust。一个成熟的类型系统对于编写健壮的程序来说至关重要。类型可以看作是对变量取值空间的一种约束。
Rust 中,有很多对多种类型做统一处理的需求 --> 类型参数和泛型。单一的类型确实不方便,或者不能满足我们的需求。
4 种类型体系建模方法,在实践过程中慢慢加深理解。
思考题
如果你给某个泛型实现了一个方法,那么,还能为它的一个具化类型再实现同样的方法吗?
答:
思考题:"为泛型实现了一个方法,能否再为具化类型实现一个同名方法",取决于这个泛型能否表示相应的具化类型。比如为泛型 T 和 String 实现了相同的方法,由于 T 没有施加任何约束,它可以代表 String。那么当调用方法时,对于具化类型 String 来说,要调用哪一个呢?因此会出现歧义,编译器会报错:方法被重复定义了。 但如果给泛型 T 施加了一个 Copy 约束,要求 T 必须实现了 Copy trait,那么就不会报错了,因为此时 T 代表不了 String,所以调用方法不会出现歧义。但如果再为 i32 实现一个同名方法就会报错了,因为 i32 实现了 Copy,它可以被 T 表示。
答:
不能,编译器会提示duplicate definitions for XXXXX。 如果想为具化类型再实现同样的方法,则可以定义一个trait,用具化类型实现这个trait,来达到"为具化类型再实现同样的方法“的目的。
思考题:通常是不能, 但可以通过 trait 进行特化
思考题:核心点就在于不能为同一个类型实现 2 个相同函数签名的方法,因为这会引起方法冲突。 编译报错如下:
error[E0592]: duplicate definitions with name `print`
--> examples/generic.rs:7:5
|
7 | fn print(&self) {
| ^^^^^^^^^^^^^^^ duplicate definitions for `print`
...
13 | fn print(&self) {
| --------------- other definition for `print`
所以一般情况下,如果 impl<T> 后面的 T 没有任何的约束,那么就表示为所有类型的 T 都实现了方法,比如说 print(),这个时候是不能为具化类型再次实现 print() 的,因为这个时候就产生了方法冲突。
但是,如果 impl<T: std::fmt:Display> 后面的 T 是有约束的,那么其实只为符合这个约束的类型实现了 print(),其余类型是没有实现的,所以是可以为其余具化类型实现相同的方法的。 如:
struct Point<T> {
x: T,
y: T,
}
struct NotDisplay {
a: u32,
}
impl<T: std::fmt::Display> Point<T> {
fn print(&self) {
println!("Point: {}, {}", self.x, self.y);
}
}
impl Point<NotDisplay> {
fn print(&self) {
println!("not display");
}
}