本书的第一部分涵盖了围绕Rust类型系统的建议。这种类型系统比其他主流语言更具表现力;它与OCaml或Haskell等“学术”语言有更多的共同点。
其中一个核心部分是Rust的enum
类型,它比其他语言中的枚举类型更具表现力,并且允许代数数据类型。
Rust类型系统的另一个核心支柱是trait
类型。特征大致等同于其他语言中的接口类型,但它们也与Rust的泛型(项目12)相关联,以允许在没有运行时开销的情况下重新使用接口。
3.1 使用类型系统来表达您的数据结构
“谁称他们为程序员,而不是打字员”– @thingskatedid
任何来自另一种静态类型编程语言(如C++、Go或Java)的人都非常熟悉Rust类型系统的基础知识。有一组具有特定大小的整数类型,既有符号(i8,i16,i32,i64,i128),也有无符号(u8,u16,u32,u64,u128)。
还有有符号(isize)和无符号(usize)整数,其大小与目标系统上的指针大小相匹配。Rust不是一种在指针和整数之间转换时要做很多事情的语言,因此表征并不真正相关。然而,标准集合将其大小返回为usize
(来自.len()
),因此集合索引意味着usize
值非常常见——从容量的角度来看,这显然是好的,因为内存集合中的项目不能超过系统上的内存地址。
积分类型确实给了我们第一个提示,即Rust是一个比C++更严格的世界——试图将一夸脱(i32
)放入品脱锅(i16
)会产生编译时错误。
let x: i32 = 42;
let y: i16 = x;
结果:
error[E0308]: mismatched types
--> use-types/src/main.rs:14:22
|
14 | let y: i16 = x;
| --- ^ expected `i16`, found `i32`
| |
| expected due to this
|
help: you can convert an `i32` to an `i16` and panic if the converted value doesn't fit
|
14 | let y: i16 = x.try_into().unwrap();
| ++++++++++++++++++++
在这里,建议的解决方案不会引起错误处理的幽灵,但转换仍然需要明确。我们稍后将更详细地讨论类型转换。
继续不足为奇的原始类型,Rust有一个bool类型,浮点类型(f32,f64)和一个单元类型()如C的void
)。
更有趣的是char类型,它具有Unicode值(类似于Go的rune类型)。虽然这在内部存储为4字节,但同样没有对32位整数的静默转换。
类型系统中的这种精度迫使你明确你试图表达什么——auu32
值与char
不同,这又与UTF-8字节序列不同,而UTF-8字节序列又与任意字节序列不同,由你来准确指定你的意思1。Joel Spolsky的著名博客可以帮助你了解你需要什么。
当然,有一些辅助方法允许您在这些不同类型之间进行转换,但它们的签名迫使您处理(或明确忽略)失败的可能性。例如,aUnicode代码点2总是可以用32位表示,因此允许'a' as u32
,但另一个方向更棘手(因为有u32
值不是有效的Unicode代码点):
- char::from_u32返回一个
Option<char>
迫使调用者处理故障情况 - char::from_u32_unchecked假设有效性,但结果被标记为
unsafe
,迫使调用者也使用unsafe
。
聚合类型
转到聚合类型,Rust有:
- 数组,容纳单个类型的多个实例,其中实例数量在编译时是已知的。例如,
[u32; 4]
是连续四个4字节的整数。 - 元组,包含多个异构类型的实例,其中元素的数量及其类型在编译时是已知的,例如
(WidgetOffset, WidgetSize, WidgetColour)
如果元组中的类型没有区别——例如(i32, i32, &'static str, bool)
——最好给每个元素一个名称并使用... - 结构,它也包含编译时已知的异构类型的实例,但允许以名称引用整体类型和单个字段。
元组结构是struct
与元组的杂交:整体类型有名称,但单个字段没有名称——它们用数字来引用:s.0
,s.1
等。
struct TextMatch(usize, String);
let m = TextMatch(12, "needle".to_owned());
assert_eq!(m.0, 12);
这把我们带到了Rust字体系统皇冠上的宝石,enum
。
以其基本形式,很难看出有什么值得兴奋的。与其他语言一样,theenum允许您指定一组相互排斥的值,可能附加数字或字符串值。
enum HttpResultCode {
Ok = 200,
NotFound = 404,
Teapot = 418,
}
let code = HttpResultCode::NotFound;
assert_eq!(code as i32, 404);
由于每个enum
定义都创建了不同的类型,因此可用于提高接受bool
参数的函数的可读性和可维护性。而不是:
print_page(
/* both_sides= */
true,
/* colour= */
false);
使用一对enum
的版本:
pub enum Sides {
Both,
Single,
}
pub enum Output {
BlackAndWhite,
Colour,
}
pub fn print_page(sides: Sides, colour: Output) {
// ... todo
}
在调用时更易于类型安全,更容易阅读:
print_page(Sides::Both, Output::BlackAndWhite);
与bool
版本不同,如果库用户不小心翻转参数的顺序,编译器会立即抱怨:
error[E0308]: mismatched types
--> use-types/src/main.rs:89:20
|
89 | print_page(Output::BlackAndWhite, Sides::Single);
| ^^^^^^^^^^^^^^^^^^^^^ expected enum `enums::Sides`, found enum `enums::Output`
error[E0308]: mismatched types
--> use-types/src/main.rs:89:43
|
89 | print_page(Output::BlackAndWhite, Sides::Single);
| ^^^^^^^^^^^^^ expected enum `enums::Output`, found enum `enums::Sides`
(使用新类型模式包装bool
也实现了类型安全性和可维护性;如果语义始终是布尔语,则通常最好使用该模式,如果有可能出现新的替代方案,则使用enum
(例如Sides::BothAlternateOrientation
)将来可能会出现。)
let msg = match code {
HttpResultCode::Ok => "Ok",
HttpResultCode::NotFound => "Not found",
// forgot to deal with the all-important "I'm a teapot" code
};
error[E0004]: non-exhaustive patterns: `Teapot` not covered
--> use-types/src/main.rs:65:25
|
51 | / enum HttpResultCode {
52 | | Ok = 200,
53 | | NotFound = 404,
54 | | Teapot = 418,
| | ------ not covered
55 | | }
| |_____- `HttpResultCode` defined here
...
65 | let msg = match code {
| ^^^^ pattern `Teapot` not covered
|
= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
= note: the matched value is of type `HttpResultCode`
编译器迫使程序员考虑由theenum表示的所有可能性3,即使结果只是添加一个默认的手臂_ => {}
。(请注意,现代C++编译器也可以并确实警告enum
缺少switch
臂。)
enums 带字段
Rustenum
特征的真正力量来自于这样一个事实,即每个变体都可以拥有随之而来的数据,使其成为代数数据类型(ADT)。主流语言的程序员不太熟悉这一点;用C/C++术语来说,它就像枚举与union
的组合——只有类型安全。
这意味着程序数据结构的不变量可以编码到Rust的类型系统中;不符合这些不变量的状态甚至不会编译。精心设计的enum
使人类和编译器都清楚地表明了创作者的意图:
pub enum SchedulerState {
Inert,
Pending(HashSet<Job>),
Running(HashMap<CpuId, Vec<Job>>),
}
仅从类型定义中,可以合理地猜测,在调度器完全激活之前,Jobs会以待Pending
状态排队,此时它们被分配到一些每个CPU池。
这突出了此项目的中心主题,即使用Rust的类型系统来表达与软件设计相关的概念。
当这种情况没有发生时,一个死的赠品是一个注释,解释某个字段或参数何时有效:
struct DisplayProps {
x: u32,
y: u32,
monochrome: bool,
// `fg_colour` must be (0, 0, 0) if `monochrome` is true.
fg_colour: RgbColour,
}
这个小例子说明了一个关键的建议:使无效状态在您的类型中无法表达。仅支持有效值组合的类型意味着整类错误被编译器拒绝,导致代码更小、更安全。
选项和错误
回到enum
的力量,有两个概念非常常见,以至于Rust包括内置的enum
类型来表达它们。
第一个是Option的概念:要么有特定类型的值(Some(T)
),要么没有(None
)。始终对可能缺失的值使用Option
;永远不要回退使用哨点值(-1,nullptr
,...)来尝试在带内表达相同的概念。
不过,有一个微妙之处需要考虑。如果您正在处理一系列事物,您需要决定在集合中拥有零事物是否等同于没有集合。在大多数情况下,这种区别不会出现,你可以继续使用Vec<Thing>
:数零意味着没有东西。
然而,肯定还有其他罕见的情况,需要用Option<Vec<Thing>>
来区分这两种情况——例如,加密系统可能需要区分“单独传输的有效载荷”和“提供的空有效载荷”。(这与围绕SQL中NULL标记列的辩论有关。)
一种常见的边缘情况是在中间的字符串可能不存在——“”或“none”是否更有意义来表示值的不存在?两种方法都可以,但是Option<String>清楚地传达了该值可能不存在的可能性。
第二个常见概念源于错误处理:如果函数失败,该故障应如何报告?历史上,使用特殊的哨点值(例如来自Linux系统调用的-errno
返回值)或全局变量(POSIX系统的errno
)。最近,支持函数的多个或元组返回值(如Go)的语言可能有一个返回(result, error)
对的惯例,假设当error
非“零”时,result
存在一些合适的“零”值。
在Rust中,始终将可能失败的操作结果编码为Result<T, E>。T
类型持有成功结果(在Ok
变体中),E
类型在失败时持有错误详细信息(在Err
变体中)。使用标准类型可以明确设计意图,并允许使用标准转换和错误处理;它还使简化错误处理与?
操作员。
1:如果涉及文件系统,情况会变得更混乱,因为流行平台上的文件名介于任意字节和UTF-8序列之间:请参阅std::ffi::OsString。
3: 这也意味着在库中现有的枚举中添加一个新的变体是一个破坏性的更改(第21条):库的客户端将需要更改他们的代码来处理新的变体。如果枚举实际上只是一个旧式的值列表,则可以通过将其标记为anon_详尽枚举来避免这种行为。
3.2 使用类型系统来表达常见行为
方法
在Rust的类型系统中,行为可见的第一个地方是在数据结构中添加方法:作用于该类型项目的函数,由self
识别。这以面向对象的方式将相关数据和代码封装在一起,类似于其他语言;然而,在Rust中,方法可以添加到enum
类型和struct
类型中,以符合Rustenum
的普遍性。
enum Shape {
Rectangle { width: f64, height: f64 },
Circle { radius: f64 },
}
impl Shape {
pub fn area(&self) -> f64 {
match self {
Shape::Rectangle { width, height } => width * height,
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
}
}
}
方法的名称为其编码的行为提供标签,方法签名为其输入和输出提供类型信息。方法的第一个输入将是一些self
变体,表明该方法可能对数据结构有什么作用:
&self
参数表示数据结构的内容可以从中读取,但不会被修改。&mut self
参数表示该方法可能会修改数据结构的内容。self
参数表示该方法消耗数据结构。
抽象方法
调用方法总是导致执行相同的代码;从调用到调用的所有变化都是该方法操作的数据。这涵盖了许多可能的场景,但如果代码需要在运行时发生变化怎么办?
Rust在其类型系统中包括几个功能来适应这一点,本节将探讨这些功能。
功能指针
最简单的行为抽象是函数指针:指向(只是)一些代码的指针,其类型反映了函数的签名。类型在编译时被检查,因此当程序运行时,该值只是指针的大小。
fn sum(x: i32, y: i32) -> i32 {
x + y
}
// Explicit coercion to `fn` type is required...
let op: fn(i32, i32) -> i32 = sum;
函数指针没有与之相关的其他数据,因此它们可以以各种方式被视为值:
// `fn` types implement `Copy`
let op1 = op;
let op2 = op;
// `fn` types implement `Eq`
assert!(op1 == op2);
// `fn` implements `std::fmt::Pointer`, used by the {:p} format specifier.
println!("op = {:p}", op);
// Example output: "op = 0x101e9aeb0"
需要注意的一个技术细节:需要对fn
类型进行明确的胁迫,因为仅仅使用函数的名称并不能给你一些fn
类型的东西;
注意:
需要注意的一个技术细节:需要对fn
类型进行明确的胁迫,因为仅仅使用函数的名称并不能给你一些fn
类型的东西;
let op1 = sum;
let op2 = sum;
// Both op1 and op2 are of a type that cannot be named in user code,
// and this internal type does not implement `Eq`.
assert!(op1 == op2);
error[E0369]: binary operation `==` cannot be applied to type `fn(i32, i32) -> i32 {main::sum}`
--> use-types-behaviour/src/main.rs:117:21
|
117 | assert!(op1 == op2);
| --- ^^ --- fn(i32, i32) -> i32 {main::sum}
| |
| fn(i32, i32) -> i32 {main::sum}
|
help: you might have forgotten to call this function
|
117 | assert!(op1( /* arguments */ ) == op2);
| +++++++++++++++++++
help: you might have forgotten to call this function
|
117 | assert!(op1 == op2( /* arguments */ ));
| +++++++++++++++++++
相反,编译器错误表明类型类似fn(i32, i32) -> i32 {main::sum}
,该类型完全是编译器内部的类型(即无法在用户代码中写入),并标识特定函数及其签名。换句话说,sum
的类型对函数的签名及其位置(出于优化原因)进行编码;这种类型可以自动强制为fn
类型。
关闭
裸函数指针是限制的,因为调用函数唯一可用的输入是那些作为参数值显式传递的输入。
例如,考虑一些使用函数指针修改切片每个元素的代码。
// In real code, an `Iterator` method would be more appropriate.
pub fn modify_all(data: &mut [u32], mutator: fn(u32) -> u32) {
for value in data {
*value = mutator(*value);
}
}
这适用于切片的简单突变:
fn add2(v: u32) -> u32 {
v + 2
}
let mut data = vec![1, 2, 3];
modify_all(&mut data, add2);
assert_eq!(data, vec![3, 4, 5,]);
然而,如果修改依赖于任何额外的状态,则不可能隐式地将其传递到函数指针中。
let amount_to_add = 3;
fn add_n(v: u32) -> u32 {
v + amount_to_add
}
let mut data = vec![1, 2, 3];
modify_all(&mut data, add_n);
assert_eq!(data, vec![3, 4, 5,]);
error[E0434]: can't capture dynamic environment in a fn item
--> use-types-behaviour/src/main.rs:142:17
|
142 | v + amount_to_add
| ^^^^^^^^^^^^^
|
= help: use the `|| { ... }` closure form instead
为了(大致)了解捕获的工作原理,想象一下编译器创建一个一次性的内部类型,其中包含lambda表达式中提及的所有环境部分。创建闭包时,将创建此临时类型的实例以保存相关值,当调用闭包时,该实例将用作附加上下文。
let amount_to_add = 3;
// *Rough* equivalent to a capturing closure.
struct InternalContext<'a> {
// references to captured variables
amount_to_add: &'a u32,
}
impl<'a> InternalContext<'a> {
fn internal_op(&self, y: u32) -> u32 {
// body of the lambda expression
y + *self.amount_to_add
}
}
let add_n = InternalContext {
amount_to_add: &amount_to_add,
};
let z = add_n.internal_op(5);
assert_eq!(z, 8);
在这个名义上下文中保存的值通常是引用,但它们也可以是对环境中事物的可变引用,或者完全移出环境的值(在输入参数之前使用move
关键字)。
回到modify_all
示例,不能在需要函数指针的地方使用闭包。
error[E0308]: mismatched types
--> use-types-behaviour/src/main.rs:165:31
|
165 | modify_all(&mut data, |y| y + amount_to_add);
| ^^^^^^^^^^^^^^^^^^^^^ expected fn pointer, found closure
|
= note: expected fn pointer `fn(u32) -> u32`
found closure `[closure@use-types-behaviour/src/main.rs:165:31: 165:52]`
note: closures can only be coerced to `fn` types if they do not capture any variables
--> use-types-behaviour/src/main.rs:165:39
|
165 | modify_all(&mut data, |y| y + amount_to_add);
| ^^^^^^^^^^^^^ `amount_to_add` captured here
相反,接收闭包的代码必须接受Fn*
特征之一的实例。
pub fn modify_all<F>(data: &mut [u32], mut mutator: F)
where
F: FnMut(u32) -> u32,
{
for value in data {
*value = mutator(*value);
}
}
Rust有三种不同的Fn*
特征,它们之间表达了围绕这种环境捕获行为的一些区别。
- FnOnce描述一个只能调用一次的闭包。如果其环境的某些部分被
move
关闭状态,那么该move
只能发生一次——没有其他源项目的副本可以move
——因此关闭只能调用一次。 - FnMut描述了一个可以重复调用的闭包,它可以改变其环境,因为它可以相互借用环境。
- Fn描述了一个可以重复调用的闭包,它只能不可改变地从环境中借用值。
编译器自动为代码中的任何lambda表达式实现这些Fn*
特征的适当子集;无法手动实现任何这些特征1(与C++的operator()
过载不同)。
回到上述闭包的粗略心理模型,编译器自动实现的哪些特征大致对应于捕获的环境环境是否具有:
FnOnce
:任何移动值FnMut
:对值的任何可变引用(&mut T
)Fn
:仅对值(&T
)的正常引用。
上面列表中的后两个特征都具有前一个特征的特质约束,当你考虑使用闭包的东西时,这是有意义的。
- 如果某物只希望调用一次闭包(通过接收
FnOnce
表示),则可以传递一个能够重复调用的闭包(FnMut
)。 - 如果某物希望重复调用可能使其环境突变的闭包(通过接收
FnMut
表示),可以向其传递一个不需要突变其环境(Fn
)的闭包。
裸函数指针类型fn
也名义上属于此列表的末尾;任何(unsafe
)fn
类型都会自动实现所有Fn*
特征,因为它不从环境中借用任何东西。
因此,在编写接受闭包的代码时,请使用最通用的Fn*
特征,为调用者提供最大的灵活性——例如,对于只使用一次的闭包,接受FnOnce
。同样的推理也导致建议更喜欢Fn*
特征边界而不是裸函数指针(fn)。
特征
Fn*
特征比裸函数指针更灵活,但它们仍然只能描述单个函数的行为,甚至只能用函数的签名来描述。
然而,它们本身就是描述Rust类型系统中行为的另一种机制的例子,即特征。特征定义了一组相关方法,一些底层项目公开提供这些方法。特征中的每个方法也有一个名称,提供了一个标签,允许编译器消除具有相同签名的方法的歧义,更重要的是,它允许程序员推断方法的意图。
Rust特征大致类似于Go和Java中的“接口”,或C++中的“抽象类”(所有虚拟方法,没有数据成员)。特征的实现必须提供所有方法(但请注意,特征定义可以包括默认实现,项目13),也可以有这些实现使用的相关数据。这意味着代码和数据以某种面向对象的方式以通用的抽象方式封装在一起。
接受struct
并调用方法的代码仅限于使用该特定类型。如果有多种类型实现共同行为,那么定义封装该共同行为的特征,并让代码使用该特征的方法而不是特定struct
上的方法会更灵活。
这导致了与其他受OO影响的语言2相同的建议:如果预计未来有灵活性,他们更喜欢接受特征类型而不是具体类型。
有时,您想在类型系统中区分一些行为,但不能表示为特征定义中的某些特定方法签名。例如,考虑对集合进行排序的特征;实现可能是稳定的(比较相同的元素将在排序前后以相同的顺序出现),但无法在sort
方法参数中表达这一点。
在这种情况下,仍然值得使用类型系统来跟踪此要求,使用标记特征。
pub trait Sort {
/// Re-arrange contents into sorted order.
fn sort(&mut self);
}
/// Marker trait to indicate that a [`Sortable`] sorts stably.
pub trait StableSort: Sort {}
标记特征没有方法,但实现仍然必须声明它正在实现该特征——这是实现者的承诺:“我庄严宣誓,我的实现是稳定的”。然后,依赖稳定排序的代码可以指定StableSort
特征绑定,依靠荣誉系统来保留其不变量。使用标记特征来区分无法在特征方法签名中表达的行为。
一旦行为作为特征被封装到Rust的类型系统中,有两种方法可以使用:
- 作为一种特征绑定,它限制了在编译时可以接受的通用数据类型或方法的类型,或
- 作为特征对象。它限制了哪些类型可以在运行时存储或传递给方法。
更详细地讨论了这些之间的权衡。
特征绑定表示由某种类型T
参数化的通用代码只能在该类型T
实现某些特定特征时使用。特征绑定的存在意味着泛型的实现可以使用该特征的方法,因为知道编译器将确保任何编译的T
确实有这些方法。此检查发生在编译时,当泛型被单态化时(Rust的术语C++将称为“模板实例化”)。
对目标类型T
的这种限制是显式的,在性状边界中编码:该性状只能由满足性状边界的类型来实现。这与C++中的等效情况形成鲜明对比,在C++中,template<typename T>
中使用的类型T
的约束是隐式的3:C++模板代码仍然只有在所有引用方法在编译时都可用时才能编译,但检查纯粹基于方法和签名。(这种“鸭子类型”会导致混淆的可能性;使用t.pop()
的C++模板可能会为Stack
或Balloon
的T
类型参数编译——这不太可能是理想的行为。)
对显式性状边界的需求也意味着很大一部分泛型使用性状边界。要了解为什么会这样,请将观察转过来,并考虑使用T
上没有特征边界的struct Thing<T>
可以做什么。如果没有特质边界,Thing只能执行适用于任何类型T
的操作;这允许容器、集合和智能指针,但不允许其他操作。任何使用T
型的东西都需要一个特征绑定。
pub fn dump_sorted<T>(mut collection: T)
where
T: Sort + IntoIterator,
T::Item: Debug,
{
// Next line requires `T: Sort` trait bound.
collection.sort();
// Next line requires `T: IntoIterator` trait bound.
for item in collection {
// Next line requires `T::Item : Debug` trait bound
println!("{:?}", item);
}
}
因此,这里的建议是使用特征边界来表达对泛型中使用的类型的要求,但建议很容易遵循——编译器将迫使您无论如何遵守它。
特征对象是利用特征定义的封装的另一种方式,但在这里,在运行时而不是编译时选择不同的可能的特征实现。这种动态调度类似于在C++中使用虚拟函数,在封面下,Rust有“vtable”对象,这些对象大致类似于C++中的对象。
特征对象的这种动态方面也意味着它们总是必须通过引用(&dyn Trait
)或指针(Box<dyn Trait>
)间接处理。这是因为实现该特征的对象的大小在编译时是未知的——它可能是一个巨大的struct
或一个微小enum
——因此没有办法为裸性状对象分配适量的空间。
类似的担忧意味着,用作特征对象的特征不能有返回Self
类型的方法,因为使用特征对象的编译的已编代码将不知道Self
可能有多大。
具有通用方法fn method<T>(t:T)
的特征允许对可能存在的所有不同类型T
无限数量的实现方法。这对于用作特征绑定的特征来说是可以的,因为无限集可能调用的泛型方法在编译时成为实际调用的泛型方法的有限集。特征对象并非如此:编译时可用的代码必须处理可能在运行时到达的所有可能的T
。
这两个限制——没有返回Self
和没有通用方法——被合并到对象安全的概念中。只有对象安全特征才能用作特征对象。
由于篇幅,下一篇