Rust-函数式编程实用指南-全-
Rust 函数式编程实用指南(全)
原文:
annas-archive.org/md5/891b0cb98d085f3d6d1de623a6f9d7bd译者:飞龙
前言
感谢您对 Rust 中函数式编程的兴趣。Rust 是一种非常年轻的编程语言,对于函数式编程社区来说尤其新。尽管如此,该语言提供了丰富的工具,这些工具既实用又复杂。
在本书中,我们将介绍通用的函数式编程原则以及它们如何具体应用于 Rust。我们的目标是提供关于 Rust 的知识和视角,这些知识和视角将超越语言特性的微小变化。Rust 的发展速度非常快,在本书写作过程中,我们引入了新功能,因为它们变得可用且相关。我们希望让读者能够为这个快速变化的环境编写代码,以便他们能够最好地利用新功能。
本书面向的对象
本书面向熟悉基本 Rust 特性或愿意在阅读过程中参考其他材料的开发者。我们不会全面解释每个新的符号、库或语法形式,但我们会解释被认为是更高级的库或可能难以阅读的语法。同样,一些仅在介绍性材料中简要解释的概念将在详细解释。
本书涵盖的内容
第一章,功能编程比较,介绍了 Rust 中的函数式编程。比较了函数式风格与其他在 Rust 中普遍或具有影响力的范例。本章还简要概述了书中将出现的主题。
第二章,功能控制流,介绍了 Rust 的控制流结构,并解释了它们如何与函数式编程风格相关。通过示例展示了函数式编程和 Rust 以表达式为中心的特性。尽管有所限制,本章还开始了仅使用过程式表达式风格的编程的持续项目。
第三章,功能数据结构,向读者介绍了 Rust 中可用的各种高度表达的数据类型。值得注意的是,枚举类型被引入,这在函数式编程中具有特殊意义。项目继续扩展,以包含这些数据类型。
第四章,泛型和多态,解释了数据(泛型)和控制流(多态)参数化的概念。参数化及其与特质的自然交互减轻了程序员的负担,但语法可能会变得复杂。介绍了减少或缓解参数爆炸的一些方法。持续项目再次扩展,以包含这些特性。
第五章,代码组织和应用程序架构,讨论了一些架构关注点、建议和最佳实践。设计和管理软件项目的实现并非公式化。没有项目是完全相同的,而且很少有项目高度相似,因此没有工程流程能够捕捉软件开发的所有细微差别。在本章中,我们提供了最佳的工具,特别是功能编程所能提供的最佳工具。
第六章,可变性、所有权和纯函数,深入探讨了 Rust 中的一些独特特性。本章介绍了所有权和生命周期概念,这些是学习 Rust 时常见的绊脚石。还介绍了不可变性和纯函数的功能性概念,以帮助解开一个天真的 Rust 程序员在试图规避 Rust 的所有权规则时可能产生的混乱代码。
第七章,设计模式,列出了可以放入单个章节中的许多功能编程速查代码。通过示例和一些随意的定义解释了函子(functors)和单子(monads)的概念。本章还简要介绍了功能反应式编程的风格,并使用它构建了一个快速而简单的 Web 框架。
第八章,实现并发,解释了如何同时做很多事情。本章的大部分内容都用于阐明子进程、分叉进程和线程之间的差异以及相对的优缺点。然后假设了 Rust 的线程并发模型,并提供了更多信息以阐明 Rust 特有的线程逻辑。在章节的末尾,介绍了并发的行为模型,这是一个能够适应大多数情况和编程范式的强大并发模型。
第九章,性能、调试和元编程,以一些关于在 Rust 中进行编程的杂项技巧结束本书。性能技巧并不特别实用,而是主要关注语言特定的细节、一般性建议或相关的计算机科学知识。调试部分介绍了许多防止错误发生的技巧。同时,通过示例解释了如何使用交互式调试器。元编程部分精确地解释了 Rust 宏和过程宏的工作原理。这是 Rust 的一个伟大特性,但文档并不完善,因此可能会让人感到有些畏惧。
为了充分利用这本书
-
我们假设您熟悉 Rust 文档前 10 章的概念(
doc.rust-lang.org/book/)。这些章节中的一些内容相当高级,因此当相关时,我们也会在这里解释。然而,我们将期望您了解语法和非常基本的功能。 -
克隆 GitHub 代码仓库并跟随。调整示例并看看您能创造出什么效果。
-
保持好奇心。我们提到的某些关键词可能足以填满一本关于独特内容的书。其中一些主题非常普遍,以至于有相当不错的维基百科文章来解释和扩展这些概念。然而,了解关键词是了解搜索什么的基础。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
一旦文件下载完成,请确保使用最新版本的软件解压或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Functional-Programming-in-Rust。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。去看看吧!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“让我们首先定义physics模块的一些类型声明。”
代码块设置如下:
pub trait MotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState);
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput;
}
任何命令行输入或输出都写作如下:
closure may outlive the current function, but it borrows `a`, which is
owned by the current function
粗体:表示新术语、重要词汇或您在屏幕上看到的词汇。
警告或重要提示看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请将电子邮件发送至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过电子邮件联系我们的questions@packtpub.com。
勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们非常感谢您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果您有兴趣成为作者: 如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评价
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问packtpub.com。
第一章:函数式编程 – 一种比较
函数式编程(FP)是继面向对象编程(OOP)之后的第二大流行编程范式。多年来,这两个范式被分离到不同的语言中,以避免混合。多范式语言试图支持这两种方法。Rust 就是这样一种语言。
广义上,函数式编程强调使用可组合和最大可重用函数来定义程序行为。使用这些技术,我们将展示函数式编程如何巧妙地解决了许多常见但困难的问题。本章将概述本书中提出的多数概念。剩余的章节将致力于帮助您掌握每种技术。
我们希望提供的成果如下:
-
能够使用函数式风格来减少代码的重量和复杂性
-
能够通过利用安全抽象编写健壮且安全的代码
-
能够使用函数式原则来构建复杂的项目
技术要求
运行提供的示例需要一个较新的 Rust 版本,可以在以下链接找到:
www.rust-lang.org/en-US/install.html
本章的代码也可在 GitHub 上找到,链接如下:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每章的README.md文件中也包含了具体的安装和构建说明。
减少代码的重量和复杂性
函数式编程可以大大减少完成任务所需的代码量和复杂性。特别是在 Rust 中,正确应用函数式原则可能会简化通常复杂的设计要求,并使编程成为一种更加高效和有回报的体验。
使泛型更加通用
使泛型更加通用与起源于函数式语言的参数化数据结构和函数的实践相关。在 Rust 和其他语言中,这被称为泛型。类型和函数都可以进行参数化。可以在泛型类型上放置一个或多个约束,以指示特性和生命周期的要求。
没有泛型的情况下,结构定义可能会变得冗余。以下是对三个定义了“点”这一公共概念的结构的定义。然而,这些结构使用了不同的数值类型,因此单一的概念在intro_generics.rs中扩展成了三个独立的PointN类型定义:
struct PointU32
{
x: u32,
y: u32
}
struct PointF32
{
x: f32,
y: f32
}
struct PointI32
{
x: i32,
y: i32
}
相反,我们可以使用泛型来删除重复代码并使代码更加健壮。泛型代码更容易适应新的要求,因为许多行为(以及因此的需求)可以被参数化。如果需要更改,最好是只更改一行而不是一百行。
此代码片段定义了一个参数化的 Point 结构体。现在,单个定义可以捕获 Point 在 intro_generics.rs 中所有可能的数值类型:
struct Point<T>
{
x: T,
y: T
}
没有泛型,函数也存在问题。
这里是一个简单的平方数字的函数。然而,为了捕获可能的数值类型,我们在 intro_generics.rs 中定义了三个不同的函数:
fn foo_u32(x: u32) -> u32
{
x*x
}
fn foo_f32(x: f32) -> f32
{
x*x
}
fn foo_i32(x: i32) -> i32
{
x*x
}
函数参数,如这个例子所示,可能需要特质界限(指定一个或多个特质的约束)以允许在函数体中使用该类型上的任何行为。
这里是重新定义的 foo 函数,带有参数化类型。单个函数可以定义所有数值类型的操作。在 intro_generics.rs 中,甚至对于乘法或复制等基本操作,也必须显式设置界限:
fn foo<T>(x: T) -> T
where T: std::ops::Mul<Output=T> + Copy
{
x*x
}
函数本身也可以作为参数传递。我们称之为高阶函数。
这里是一个接受函数和参数的简单函数,然后使用参数调用该函数,并返回结果。注意特质界限 Fn,表示提供的函数是一个闭包。为了使对象可调用,它必须在 intro_generics.rs 中实现 fn、Fn、FnMut 或 FnOnce 中的一个特质:
fn bar<F,T>(f: F, x: T) -> T
where F: Fn(T) -> T
{
f(x)
}
函数作为值
函数是函数式编程的主要特性。具体来说,函数作为值是整个范式的基石。忽略许多细节,我们还将在此处引入术语 闭包 以供将来参考。闭包是一个充当函数的对象,实现了 fn、Fn、FnMut 或 FnOnce。
可以使用内置的闭包语法定义简单的闭包。这种语法的好处是,如果允许,fn、Fn、FnMut 和 FnOnce 特质将自动实现。这种语法非常适合简短的数据操作。
这里是一个从 0 到 10 的范围迭代器,映射到平方值。平方操作是通过将内联闭包定义发送到迭代器的 map 函数来应用的。此表达式的结果将是一个迭代器。以下是在 intro_functions.rs 中的一个表达式:
(0..10).map(|x| x*x);
如果使用块语法,闭包也可以有复杂的主体和语句。
这里是一个从 0 到 10 的迭代器,使用复杂方程映射。提供的映射闭包包括一个函数定义和一个变量绑定,在 intro_functions.rs 中:
(0..10).map(|x| {
fn f(y: u32) -> u32 {
y*y
}
let z = f(x+1) * f(x+2);
z*z
}
可以定义接受闭包作为参数的函数或方法。为了将闭包用作可调用的函数,必须指定 Fn、FnMut 或 FnOnce 的界限。
这里是一个接受函数 g 和参数 x 的 HoF 定义。该定义将 g 和 x 限制为处理 u32 类型,并定义了一些涉及调用 g 的数学运算。还提供了一个 f HoF 的调用示例,如下所示,使用 intro_functions.rs 中的简单内联闭包定义:
fn f<T>(g: T, x: u32) -> u32
where T: Fn(u32) -> u32
{
g(x+1) * g(x+2)
}
fn main()
{
f(|x|{ x*x }, 2);
}
标准库的许多部分,尤其是迭代器,鼓励大量使用函数作为参数。
这是一个从 0 到 10 的迭代器,后面跟着许多链式迭代器组合器。map 函数从原始值返回一个新值。inspect 查看一个值,不改变它,但允许副作用。filter 跳过所有不满足谓词的值。filter_map 使用单个函数进行过滤和映射。fold 从一个初始值开始,向左向右工作,将所有结果减少到一个单一值。以下是在 intro_functions.rs 中的表达式:
(0..10).map(|x| x*x)
.inspect(|x|{ println!("value {}", *x) })
.filter(|x| *x<3)
.filter_map(|x| Some(x))
.fold(0, |x,y| x+y);
迭代器
迭代器是面向对象语言的一个常见特性,Rust 良好地支持这个概念。Rust 迭代器也是以函数式编程为设计理念的,允许程序员编写更易读的代码。这里强调的具体概念是 可组合性。当迭代器可以被操作、转换和组合时,for 循环的混乱可以被单个函数调用所取代。这些例子可以在 intro_iterators.rs 文件中找到。这如下表所示:
| 带有描述的功能名称 | 示例 |
|---|---|
连接两个迭代器:first...second |
(0..10).chain(10..20); |
zip 函数将两个迭代器组合成元组对,迭代到最短迭代器的末尾:(a1,b1), (a2, b2), ... |
(0..10).zip(10..20); |
enumerate 函数是 zip 的一个特例,它创建带编号的元组 (0, a1),(1,a2), … |
(0..10).enumerate(); |
inspect 函数在迭代过程中将一个函数应用于迭代器中的所有值 |
`(0..10).inspect( |
map 函数将一个函数应用于每个元素,返回结果 |
`(0..10).map( |
filter 函数限制元素为满足谓词的元素 |
`(0..10).filter( |
fold 函数将所有值累积到一个单一的结果中 |
`(0..10).fold(0, |
| 当你想应用迭代器时,你可以使用 for 循环或调用 collect | for i in (0..10) {}
(0..10).collect::<Vec<u64>>(); |
紧凑易读的表达式
在函数式语言中,所有项都是表达式。函数体中没有语句,只有一个单独的表达式。所有控制流运算符随后都表示为具有返回值的表达式。在 Rust 中,这几乎就是情况;唯一不是表达式的是 let 语句和项目声明。
这两个语句都可以包裹在块中,以创建一个表达式以及任何其他项。以下是一个例子,在 intro_expressions.rs 中:
let x = {
fn f(x: u32) -> u32 {
x * x
}
let y = f(5);
y * 3
};
这种嵌套格式在野外不常见,但它说明了 Rust 语法宽容的本质。
回到函数式风格表达式的概念,重点始终应该放在编写易于阅读的代码上,无需太多麻烦或冗余。当其他人,或者你自己在以后的时间,来阅读你的代码时,它应该立即就能理解。理想情况下,代码应该能够自我说明。如果你发现自己不断地在代码和注释中重复写相同的代码,那么你应该重新考虑你的编程实践是否真正有效。
要开始一些函数式表达式的例子,让我们看看大多数语言中存在的一个表达式,三元条件运算符。在一个普通的if语句中,条件必须占据它自己的行,因此不能用作子表达式。
以下是一个传统的if语句,在intro_expressions.rs中初始化一个变量:
let x;
if true {
x = 1;
} else {
x = 2;
}
使用三元运算符,这个赋值可以移动到一行,如下所示在intro_expressions.rs中:
let x = if true { 1 } else { 2 };
几乎 Rust 中的每个从面向对象编程(OOP)来的语句也是一个表达式——if、for、while等等。在 Rust 中可以看到的一个更独特的表达式,在面向对象语言中不常见的是直接构造表达式。所有 Rust 类型都可以通过单个表达式实例化。构造器只在特定情况下是必要的,例如,当内部字段需要复杂的初始化时。以下是一个简单的struct和intro_expressions.rs中的等效元组:
struct MyStruct
{
a: u32,
b: f32,
c: String
}
fn main()
{
MyStruct {
a: 1,
b: 1.0,
c: "".to_string()
};
(1, 1.0, "".to_string());
}
函数式语言中的另一个独特表达式是模式匹配。模式匹配可以被认为是一个更强大的switch语句版本。任何表达式都可以发送到一个模式表达式中,并在执行分支表达式之前解构以将内部信息绑定到局部变量中。模式表达式非常适合与枚举一起工作。这两者是一对完美的搭档。
以下代码片段定义了一个Term,作为一个标记联合的表达式选项。在主函数中,构建了一个Term t,然后与一个模式表达式进行匹配。注意intro_expressions.rs中标记联合的定义与模式表达式内部的匹配的语法相似性:
enum Term
{
TermVal { value: String },
TermVar { symbol: String },
TermApp { f: Box<Term>, x: Box<Term> },
TermAbs { arg: String, body: Box<Term> }
}
fn main()
{
let mut t = Term::TermVar {
symbol: "".to_string()
};
match t {
Term::TermVal { value: v1 } => v1,
Term::TermVar { symbol: v1 } => v1,
Term::TermApp { f: ref v1, x: ref v2 } =>
"TermApp(?,?)".to_string(),
Term::TermAbs { arg: ref mut v1, body: ref mut v2 } =>
"TermAbs(?,?)".to_string()
};
}
严格的抽象意味着安全的抽象
拥有更严格的类型系统并不意味着代码会有更多的要求或更复杂。与其说是严格的类型,不如考虑使用“表达性类型”这个术语。表达性类型为编译器提供了更多信息。这些额外的信息允许编译器在编程时提供额外的帮助。这些额外的信息还允许一个非常丰富的元编程系统。所有这些都是在更安全、更健壮的代码的明显好处之上。
作用域数据绑定
Rust 中的变量比大多数其他语言处理得更为严格。全局变量几乎完全不允许。局部变量受到密切监控,以确保在超出作用域之前,分配的数据结构被正确地解构,但不是更早。这种跟踪变量适当作用域的概念被称为所有权和生命周期。
在一个简单的例子中,分配内存的数据结构在超出作用域时会自动解构。在intro_binding.rs文件中不需要手动内存管理:
fn scoped() {
vec![1, 2, 3];
}
在一个稍微复杂一点的例子中,分配的数据结构可以作为返回值传递,或者被引用,等等。这些简单的作用域的例外也必须在intro_binding.rs文件中考虑到:
fn scoped2() -> Vec<u32>
{
vec![1, 2, 3]
}
这种使用跟踪可能会变得复杂(且不可决),因此 Rust 有一些规则来限制变量何时可以逃离上下文。我们称之为复杂规则所有权。它可以以下面的代码来解释,在intro_binding.rs文件中:
fn scoped3()
{
let v1 = vec![1, 2, 3];
let v2 = v1;
//it is now illegal to reference v1
//ownership has been transferred to v2
}
当无法或不需要转移所有权时,clone特质鼓励在intro_binding.rs文件中创建被引用数据的副本:
fn scoped4()
{
vec![1, 2, 3].clone();
"".to_string().clone();
}
克隆或复制并不是一个完美的解决方案,并且会带来性能开销。为了使 Rust 更快,它已经相当快了,我们还有借用这个概念。借用是一种机制,它承诺在某个特定点将所有权返回,以直接引用某些数据。引用由一个和号表示。考虑以下示例,在intro_binding.rs文件中:
fn scoped5()
{
fn foo(v1: &Vec<u32>)
{
for v in v1
{
println!("{}", v);
}
}
let v1 = vec![1, 2, 3];
foo(&v1);
//v1 is still valid
//ownership has been returned
v1;
}
严格的拥有权的另一个好处是安全的并发。每个绑定都由一个特定的线程拥有,并且可以使用move关键字将这种所有权转移到新的线程。这已经在以下代码中解释过,在intro_binding.rs文件中:
use std::thread;
fn thread1()
{
let v = vec![1, 2, 3];
let handle = thread::spawn(move || {
println!("Here's a vector: {:?}", v);
});
handle.join().ok();
}
为了在线程之间共享信息,程序员有两个主要的选择。
首先,程序员可以使用传统的锁和原子引用的组合。这已经在以下代码中解释过,在intro_binding.rs文件中:
use std::sync::{Mutex, Arc};
use std::thread;
fn thread2()
{
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap());
}
其次,通道提供了一个很好的机制,用于线程之间的消息传递和作业排队。send特质也自动应用于大多数对象。考虑以下代码,在intro_binding.rs文件中:
use std::thread;
use std::sync::mpsc::channel;
fn thread3() {
let (sender, receiver) = channel();
let handle = thread::spawn(move ||{
//do work
let v = vec![1, 2, 3];
sender.send(v).unwrap();
});
handle.join().ok();
receiver.recv().unwrap();
}
所有这些并发都是类型安全的,并且由编译器强制执行。你可以尽可能多地使用线程,如果你不小心尝试创建竞态条件或简单的死锁,编译器会阻止你。我们称之为无畏并发。
代数数据类型
除了结构/对象和函数/方法之外,Rust 的函数式编程还包括对可定义类型和结构的丰富扩展。元组提供了定义简单匿名结构的简写。枚举提供了一种类型安全的联合复杂数据结构的方法,并增加了构造标签以帮助模式匹配的额外好处。标准库对泛型编程有广泛的支持,从基本类型到集合。甚至对象系统特性也是面向对象(OOP)概念中的类和函数式编程(FP)概念中的类型类的混合。函数式风格无处不在,即使你不在 Rust 中寻找,你也可能会不知不觉地使用这些功能。
type别名可以帮助创建复杂类型的简称。或者,可以使用newtype结构模式来创建具有不同非等效类型的别名。以下是一个例子,在intro_datatypes.rs文件中:
//alias
type Name = String;
//newtype
struct NewName(String);
一个struct,即使参数化,当仅用于将多个值存储到单个对象中时,也可能变得重复。这可以在intro_datatypes.rs文件中看到:
struct Data1
{
a: i32,
b: f64,
c: String
}
struct Data2
{
a: u32,
b: String,
c: f64
}
元组有助于消除冗余的结构定义。使用元组不需要先前的类型定义。以下是一个例子,在intro_datatypes.rs文件中:
//alias to tuples
type Tuple1 = (i32, f64, String);
type Tuple2 = (u32, String, f64);
//named tuples
struct New1(i32, f64, String);
struct New2(u32, String, f64);
可以通过实现正确的特性为任何类型实现标准运算符。以下是一个例子,在intro_datatypes.rs文件中:
use std::ops::Mul;
struct Point
{
x: i32,
y: i32
}
impl Mul for Point
{
type Output = Point;
fn mul(self, other: Point) -> Point
{
Point
{
x: self.x * other.x,
y: self.y * other.y
}
}
}
标准库集合和许多其他内置类型都是泛型的,例如intro_datatypes.rs中的HashMap:
use std::collections::HashMap;
type CustomHashMap = HashMap<i32,u32>;
枚举是多个类型的类型安全联合。请注意,递归的enum定义必须将内部值包裹在容器中,如Box,否则大小将是无限的。以下是如何表示的,在intro_datatypes.rs文件中:
enum BTree<T>
{
Branch { val:T, left:Box<BTree<T>>, right:Box<BTree<T>> },
Leaf { val: T }
}
标签联合也用于更复杂的数据结构。以下是一个例子,在intro_datatypes.rs文件中:
enum Term
{
TermVal { value: String },
TermVar { symbol: String },
TermApp { f: Box<Term>, x: Box<Term> },
TermAbs { arg: String, body: Box<Term> }
}
特性有点像面向对象中的类(OOP),以下是一个代码示例,在intro_datatypes.rs文件中:
trait Data1Trait
{
//constructors
fn new(a: i32, b: f64, c: String) -> Self;
//methods
fn get_a(&self) -> i32;
fn get_b(&self) -> f64;
fn get_c(&self) -> String;
}
特性也像类型类(FP),以下是一个代码片段,在intro_datatypes.rs文件中:
trait BehaviorOfShow
{
fn show(&self) -> String;
}
混合面向对象编程和函数式编程
如前所述,Rust 支持面向对象和函数式编程风格的许多方面。数据类型和函数对任何范式都是中立的。特性和特质专门支持这两种风格的混合。
首先,在面向对象风格中,使用struct、trait和impl定义一个简单的类和构造函数以及一些方法可以完成。这通过以下代码片段进行解释,在intro_mixoopfp.rs文件中:
struct MyObject
{
a: u32,
b: f32,
c: String
}
trait MyObjectTrait
{
fn new(a: u32, b: f32, c: String) -> Self;
fn get_a(&self) -> u32;
fn get_b(&self) -> f32;
fn get_c(&self) -> String;
}
impl MyObjectTrait for MyObject
{
fn new(a: u32, b: f32, c: String) -> Self
{
MyObject { a:a, b:b, c:c }
}
fn get_a(&self) -> u32
{
self.a
}
fn get_b(&self) -> f32
{
self.b
}
fn get_c(&self) -> String
{
self.c.clone()
}
}
在对象上添加对函数式编程的支持就像定义特性和使用函数式语言特性的方法一样简单。例如,接受一个闭包可以成为当适当使用时的一种很好的抽象。以下是一个例子,在intro_mixoopfp.rs文件中:
trait MyObjectApply
{
fn apply<F,R>(&self, f:F) -> R
where F: Fn(u32,f32,String) -> R;
}
impl MyObjectApply for MyObject
{
fn apply<F,R>(&self, f:F) -> R
where F: Fn(u32,f32,String) -> R
{
f(self.a, self.b, self.c.clone())
}
}
改进项目架构
函数式程序鼓励良好的项目架构和原则性设计模式。使用函数式编程的构建块通常可以减少需要做出的设计选择,使得好的选项变得明显。
“应该只有一个 - 最好是唯一一个 - 明显的方法来做这件事。”
– PEP 20
文件层次结构、模块和命名空间设计
Rust 程序主要以两种方式编译。第一种是使用 rustc 编译单个文件。第二种是使用 cargo 描述整个包以进行编译。我们假设这里的项目是使用 cargo 构建的,如下所示:
- 要开始一个包,你首先在一个目录中创建一个
Cargo.toml文件。从现在起,这个目录将是你的包目录。这是一个配置文件,它将告诉编译器应该将哪些代码、资源和额外信息包含到包中:
[package]
name = "fp_rust"
version = "0.0.1"
- 在完成基本配置之后,你现在可以使用
cargo build来编译整个项目。你决定将代码文件放在哪里,以及如何命名它们,取决于你如何在模块命名空间中引用它们。每个文件都会被赋予自己的模块mod。你还可以在文件内部嵌套模块:
mod inner_module
{
fn f1()
{
println!("inner module function");
}
}
- 在这些步骤之后,项目可以作为 cargo 依赖项添加,模块内部可以使用命名空间来公开符号。考虑以下代码片段:
extern crate package;
use package::inner_module::f1;
这些是 Rust 模块的基本构建块,但这与函数式编程有什么关系呢?
以函数式风格设计项目是一个过程,并且适合某些常规。通常,项目架构师会首先设计核心数据结构,在复杂情况下也会设计物理结构(代码/服务将在此处运行)。一旦数据布局被详细概述,就可以规划核心函数/常规(例如程序的行为)。到这一点,如果在架构阶段进行编码,可能会有一些代码尚未实现。最终阶段涉及用正确的行为替换这个模拟代码。
按照这个分阶段的发展过程,我们还可以看到典型的文件布局正在形成。在实际程序中,通常将这些阶段从上到下书写。尽管作者们不太可能在这些明确的阶段中进行规划,但由于简单起见,这仍然是一个常见的模式。考虑以下示例:
//trait definitions
//data structure and trait implementations
//functions
//main
将定义分组如下可能有助于标准化文件布局并提高可读性。在长文件中来回搜索符号定义是编程中常见但令人不快的一部分。这同样也是一个可以预防的问题。
函数式设计模式
除了文件布局之外,还有许多功能设计模式有助于减少代码重量和冗余。当正确使用时,这些原则可以帮助阐明设计决策,并使架构更加健壮。大多数设计模式都是单一责任原则的变体。这可以有多种形式,具体取决于上下文,但意图是相同的;编写做一件事做得好的代码,然后根据需要重用这段代码。我已如下解释:
- 纯函数:这些是没有副作用或逻辑依赖(除了函数参数之外)的函数。副作用是指影响函数之外任何内容的状态变化,除了返回值。纯函数很有用,因为它们可以被随意抛来抛去,组合,并且通常可以不加顾虑地使用,而不会产生意外的影响。
纯函数可能出现的最糟糕的事情是返回值不好,或者在极端情况下,栈溢出。
使用纯函数,即使使用不当,也难以引发错误。考虑以下纯函数的示例,在intro_patterns.rs中:
fn pure_function1(x: u32) -> u32
{
x * x
}
fn impure_function(x: u32) -> u32
{
println!("x = {}", x);
x * x
}
- 不可变性:不可变性是一种有助于鼓励纯函数的模式。Rust 变量绑定默认是不可变的。这是 Rust 鼓励你避免可变状态的一种不太微妙的方式。不要这样做。如果你绝对必须,可以使用
mut关键字标记变量以允许重新赋值。这可以通过以下示例在intro_patterns.rs中展示:
let immutable_v1 = 1;
//immutable_v1 = 2; //invalid
let mut mutable_v2 = 1;
mutable_v2 = 2;
- 函数组合:函数组合是一种模式,其中一个函数的输出连接到另一个函数的输入。以这种方式,函数可以串联起来,通过简单的步骤创建复杂的效果。这可以通过以下代码片段在
intro_patterns.rs中展示:
let fsin = |x: f64| x.sin();
let fabs = |x: f64| x.abs();
//feed output of one into the other
let transform = |x: f64| fabs(fsin(x));
- 高阶函数:这些之前已经提到过,但我们还没有使用这个术语。高阶函数是接受一个函数作为参数的函数。许多迭代器方法都是高阶函数。考虑以下示例,在
intro_patterns.rs中:
fn filter<P>(self, predicate: P) -> Filter<Self, P>
where P: FnMut(&Self::Item) -> bool
{ ... }
- 函子:如果你能越过这个名字,这些是一个简单而有效的设计模式。它们也非常灵活。这个概念在整体上可能难以捕捉,但你可能将函子视为函数的逆。一个函数定义了一个转换,接受数据,并返回转换的结果。函子定义数据,接受一个函数,并返回转换的结果。函子的一个常见例子是绑定在容器上的
map方法,例如在Vec上。以下是一个示例,在intro_patterns.rs中:
let mut c = 0;
for _ in vec!['a', 'b', 'c'].into_iter()
.map(|letter| {
c += 1; (letter, c)
}){};
“单子是端内函子的范畴中的幺半群,问题是什么?”
– 菲利普·瓦德勒
- Monads:Monads 是学习 FP 的人常见的绊脚石。Monads 和 functors 可能是你深入理论数学之旅中可能遇到的第一个词。我们不会深入那个领域。对我们来说,monads 只是一个有两个方法的
trait。以下代码展示了这一点,位于intro_patterns.rs文件中:
trait Monad<A> {
fn return_(t: A) -> Self;
//:: A -> Monad<A>
fn bind<MB,B>(m: Self, f: Fn(A) -> MB) -> MB
where MB: Monad<B>;
//:: Monad<A> -> (A -> Monad<B>) -> Monad<B>
}
如果这还不能帮助你澄清问题(很可能不能),monad 有两个方法。第一个方法是构造函数。第二个方法允许你绑定一个操作以创建另一个 monad。许多常见的特性都有隐藏的半 monad,但通过使概念明确化,这个概念就变成了一个强大的设计模式,而不是一个混乱的反模式。不要试图重新发明你不需要的东西。
- 函数 currying:对于来自面向对象或命令式语言背景的人来说,函数 currying 可能是一个陌生的技术。这种混淆的原因是,在许多函数式语言中,函数默认是 curry 的,而其他语言则不是这样。Rust 函数默认不是 curry 的。
curry 函数和非 curry 函数的区别在于 curry 函数是逐个传入参数,而非 curry 函数则是一次性传入所有参数。观察一个普通的 Rust 函数定义,我们可以看到它并不是 curry 函数。考虑以下代码,位于intro_patterns.rs文件中:
fn not_curried(p1: u32, p2: u32) -> u32
{
p1 + p2
}
fn main()
{
//and calling it
not_curried(1, 2);
}
一个curried函数逐个接受参数,如下所示,位于intro_patterns.rs文件中:
fn curried(p1: u32) -> Box<Fn(u32) -> u32>
{
Box::new(move |p2: u32| {
p1 + p2
})
}
fn main()
{
//and calling it
curried(1)(2);
}
Curry 函数可以用作函数工厂。前几个参数配置了最终函数应该如何表现。结果是允许简短配置复杂操作符的模式。Currying 通过将单个函数转换为多个组件来补充所有其他设计模式。
- 惰性评估:惰性评估在其他语言中在技术上也是可能的。然而,由于语言障碍,它很少在 FP 之外看到。正常表达式和惰性表达式的区别在于惰性表达式只有在被访问时才会被评估。以下是一个简单的惰性实现,位于
intro_patterns.rs文件中的函数调用之后:
let x = { println!("side effect"); 1 + 2 };
let y = ||{ println!("side effect"); 1 + 2 };
第二个表达式只有在函数被调用时才会被评估,此时代码才会解析。对于惰性表达式,副作用发生在解析时而不是初始化时。这是一个懒惰实现的糟糕例子,因此我们将在后面的章节中进一步详细说明。这种模式相当常见,一些操作符和数据结构需要惰性才能工作。一个必要的惰性例子是可能无法以其他方式创建的惰性列表。内置的 Rust 数值迭代器(惰性列表)很好地使用了这一点:(0..)。
缓存(Memoization)是我们在这里将要介绍的最后一个模式。它可能更多地被视为一种优化而不是设计模式,但由于其普遍性,我们在这里应该提到它。一个缓存的函数只计算一次唯一的结果。一个简单的实现是使用哈希表来保护函数。如果参数和结果已经在哈希表中,则跳过函数调用并直接从哈希表中返回结果。否则,计算结果,将其放入哈希表,并返回。这个过程可以在任何语言中手动实现,但 Rust 宏允许我们一次性编写缓存代码,并通过应用此宏来重用该代码。这可以通过以下代码片段展示,位于 intro_patterns.rs 文件中:
#[macro_use] extern crate cached;
#[macro_use] extern crate lazy_static;
cached! {
FIB;
fn fib(n: u64) -> u64 = {
if n==0 || n==1 { return n }
fib(n-1) + fib(n-2)
}
}
fn main()
{
fib(30);
}
此示例使用了两个 crate 和许多宏。我们不会在本书的最后一部分完全解释这里发生的一切。宏和元编程有很多可能性。缓存函数结果是起点。
元编程
在 Rust 中,元编程这个术语经常与宏(macros)这个术语重叠。Rust 中有两种主要的宏类型可用:
-
递归
-
过程式
这两种类型的宏都接受一个抽象语法树(AST)作为输入,并生成一个或多个 AST。
一个常用的宏是 println。通过宏将可变数量的参数和类型与格式字符串连接起来,以产生格式化的输出。要调用这样的递归宏,就像调用函数一样调用宏,只是在参数前加上一个 !。宏应用可以由 [] 或 {} 包围:
vec!["this is a macro", 1, 2];
递归宏通过 macro_rules! 语句定义。macro_rules 定义内部的格式与模式匹配表达式非常相似。唯一的区别是 macro_rules! 匹配语法而不是数据。我们可以使用此格式来定义 vec 宏的简化版本。以下是在 intro_metaprogramming.rs 文件中的代码片段展示:
macro_rules! my_vec_macro
{
( $( $x:expr ),* ) =>
{
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
}
}
此定义仅接受并匹配一个模式。它期望一个逗号分隔的表达式列表。语法模式 ( $( $x: expr ),* ) 匹配逗号分隔的表达式列表,并将结果存储在复数变量 $x 中。在表达式的主体中,有一个单独的块。该块定义了一个新的 vec,然后通过迭代 $x* 将每个 $x 推入 vec,最后,该块将其结果作为返回值。以下是在 intro_metaprogramming.rs 文件中的宏及其展开:
//this
my_vec_macro!(1, 2, 3);
//is the same as this
{
let mut temp_vec = Vec::new();
temp_vec.push(1);
temp_vec.push(2);
temp_vec.push(3);
temp_vec
}
重要的一点是,表达式作为代码移动,而不是作为值移动,因此副作用将移动到评估上下文,而不是定义上下文。
递归宏模式匹配标记字符串。根据匹配的标记执行不同的分支是可能的。一个简单的匹配案例如下,位于 intro_metaprogramming.rs 文件中:
macro_rules! my_macro_branch
{
(1 $e:expr) => (println!("mode 1: {}", $e));
(2 $e:expr) => (println!("mode 2: {}", $e));
}
fn main()
{
my_macro_branch!(1 "abc");
my_macro_branch!(2 "def");
}
递归宏的名字来源于宏中的递归,所以当然我们可以调用我们正在定义的宏。递归宏可以是一种快速定义领域特定语言的途径。考虑以下代码片段,在intro_metaprogramming.rs中:
enum DSLTerm {
TVar { symbol: String },
TAbs { param: String, body: Box<DSLTerm> },
TApp { f: Box<DSLTerm>, x: Box<DSLTerm> }
}
macro_rules! dsl
{
( ( $($e:tt)* ) ) => (dsl!( $($e)* ));
( $e:ident ) => (DSLTerm::TVar {
symbol: stringify!($e).to_string()
});
( fn $p:ident . $b:tt ) => (DSLTerm::TAbs {
param: stringify!($p).to_string(),
body: Box::new(dsl!($b))
});
( $f:tt $x:tt ) => (DSLTerm::TApp {
f: Box::new(dsl!($f)),
x: Box::new(dsl!($x))
});
}
宏定义的第二种形式是过程宏。递归宏可以被认为是一种好的语法,有助于定义过程宏。另一方面,过程宏是最通用的形式。你可以用过程宏做很多递归形式不可能做到的事情。
在这里,我们可以获取struct的TypeName,并使用它来自动生成特质实现。以下是宏定义,在intro_metaprogramming.rs中:
#![crate_type = "proc-macro"]
extern crate proc_macro;
extern crate syn;
#[macro_use]
extern crate quote;
use proc_macro::TokenStream;
#[proc_macro_derive(TypeName)]
pub fn type_name(input: TokenStream) -> TokenStream
{
// Parse token stream into input AST
let ast = syn::parse(input).unwrap();
// Generate output AST
impl_typename(&ast).into()
}
fn impl_typename(ast: &syn::DeriveInput) -> quote::Tokens
{
let name = &ast.ident;
quote!
{
impl TypeName for #name
{
fn typename() -> String
{
stringify!(#name).to_string()
}
}
}
}
相应的宏调用在intro_metaprogramming.rs中看起来如下:
#[macro_use]
extern crate metaderive;
pub trait TypeName
{
fn typename() -> String;
}
#[derive(TypeName)]
struct MyStructA
{
a: u32,
b: f32
}
如你所见,过程宏的设置稍微复杂一些。然而,好处是所有处理都是直接用正常的 Rust 代码完成的。这些宏允许在编译前以非结构化格式使用任何语法信息来生成更多的代码结构。
过程宏被处理为独立的模块,在正常的编译器执行期间预编译和执行。提供给每个宏的信息是局部化的,所以
整个程序的考虑是不可能的。然而,可用的本地信息足以实现一些相当复杂的效果。
摘要
在本章中,我们简要概述了本书中将要出现的主要概念。从代码示例中,你现在应该能够直观地识别函数式风格。我们还提到了一些为什么这些概念有用的原因。在接下来的章节中,我们将提供完整的环境,说明何时以及为什么每种技术是合适的。在那个环境中,我们还将提供掌握这些技术并开始使用函数式实践所需的知识。
从本章中,我们学会了尽可能地进行参数化,并且知道函数可以用作参数,通过组合简单行为来定义复杂行为,并且在 Rust 中只要编译通过,就可以随意使用线程。
本书的结构是先介绍简单的概念,然后随着书的继续,一些概念可能会变得更加抽象或技术化。此外,所有技术都将在一个持续的项目环境中介绍。该项目将控制电梯系统,随着书的进展,需求将逐渐变得更加严格。
问题
-
函数是什么?
-
函子是什么?
-
什么是元组?
-
为标记联合设计了哪种控制流表达式?
-
函数作为参数的函数叫什么名字?
-
在记忆化的
fib(20)中,fib会被调用多少次? -
可以通过通道发送哪些数据类型?
-
为什么函数在从函数返回时需要装箱?
-
move关键字的作用是什么? -
两个变量怎么可能共享一个单一变量的所有权?
进一步阅读
Packt 为学习 Rust 提供了许多其他优秀资源:
对于基本文档和教程,请参考此处:
第二章:函数式控制流
控制流是编程的最基本构建块。早期的语言没有数据结构或函数的概念,只有程序流程。这些控制流结构随着时间的推移而演变,从简单的分支和循环到 Rust 中可用的复杂值表达式。
在本章中,我们将开始开发构成本书所有代码示例基础的项目。第一个项目的需求将立即介绍。然后,我们将为您提供将项目需求转化为带有测试的代码概要的具体步骤。最后,我们将开发完整的可交付成果的代码。
学习成果:
-
收集项目需求
-
根据项目需求构建解决方案
-
使用和识别函数式风格的表达式
-
使用集成和单元测试测试解决方案
技术要求
运行提供的示例需要 Rust 的最近版本:
www.rust-lang.org/en-US/install.html
本章的代码也可在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每章的README.md文件中也包含了具体的安装和构建说明。
设计程序
为了设计程序,让我们看看项目所需的各种方面。
收集项目需求
考虑这种情况:您的工程公司正在考虑签订一份合同,为房地产开发商设计控制电梯的软件。合同列出了三个正在开发中的建筑,它们具有不同的高度和非均匀的电梯设计。电梯设计将由其他分包商最终确定,并在软件合同中标后不久可用。
为了提交您的提案,您的公司应展示您电梯控制软件的基本功能。一旦中标,您将需要将这些功能整合到最终软件中,以及必要的修改以适应物理电梯的规格和行为。
为了赢得提案,您的团队就几个关键点达成一致,以超越竞争对手。具体来说,您的电梯应做到以下几点:
-
在楼层之间移动的时间更短
-
在每个楼层位置停留得更精确
-
为乘客提供更平稳的乘坐体验
作为项目提案的配套程序交付成果,您应提供电梯行为的模拟。您负责进一步的细节和实现。
现在应该解决以下问题:
-
程序将访问和存储哪些数据?
-
程序将期望什么输入?
-
程序应该产生什么输出?
经过一番考虑,您的团队就一些行为达成一致:
-
程序应强调电梯的位置、速度和加速度。速度决定乘坐时间。加速度决定乘坐舒适度。静止时的位置决定停靠精度。这些都是您公司需要强调的关键卖点,因此演示软件应反映相同的信息。
-
程序应以文件作为输入,描述楼层数和楼层高度,以及电梯需要处理的楼层请求列表。
-
程序输出应该是关于电梯位置、速度和加速度的实时信息。在处理完所有楼层请求后,程序应打印位置、速度和加速度的平均值和标准差。
从需求构建代码图
为了概述我们的代码解决方案,我们将使用 stubs 方法。为了使用此过程,我们只需正常启动一个代码项目,并在想到时填写高级细节。细节将在最终确定大纲之前保持未实现状态。在我们对整体程序设计满意后,我们就可以开始实现程序逻辑了。我们现在开始这个项目。
创建 Rust 项目
要创建一个新的 Rust 项目,我们将执行以下步骤(或者,您也可以调用 cargo new):
-
为 Rust 项目创建一个新文件夹
-
创建一个
Cargo.toml文件,其内容如下:
[package]
name = "elevator"
version = "1.0.0"
[dependencies]
- 创建一个
src/main.rs文件,如下所示:
fn main()
{
println!("main")
}
现在,我们可以使用 cargo build 构建项目。
为每个程序需求编写存根
程序要求通常表述为结果。运行此程序时应该产生哪些效果?用代码回答这个问题通常很简单。以下是将项目需求系统地转化为代码的步骤列表:
-
列出所有程序需求
-
列出每个需求的相关依赖或先决条件
-
从需求列表和依赖列表创建依赖图
-
编写实现依赖图的存根
通过实践,这些步骤可以合并为编写存根代码的单一步骤。然而,如果在项目的架构阶段感到不知所措,那么明确执行这些步骤可能有所帮助。这是一个将复杂问题分解为更小问题的可靠方法:
-
首先,列出所有程序需求,从之前的考虑中我们知道我们需要存储位置、速度和加速度的实时数据。程序应接受一个描述楼层数、楼层高度和要处理的楼层请求列表的输入文件或标准输入。程序输出应该是实时电梯的位置、速度和加速度,并在完成时总结所有运输请求。总结应列出位置、速度和加速度的平均值和标准差。
-
第二,为每个需求列出依赖项或先决条件。数据似乎具有原子性,没有依赖项或先决条件。程序流程似乎自然地采用轮询循环的形式,从传感器更新实时状态信息,并在每次循环中发出运动命令。电梯状态和运动命令之间存在时间延迟的循环依赖:运动命令基于状态选择,下一个循环将实现这些命令的时间调整效果。
-
第三,使用以下内容从需求列表和依赖列表创建依赖图:
-
存储位置、速度和加速度状态
-
存储电机输入电压
-
存储输入建筑描述和楼层请求
-
解析输入并将其存储为建筑描述和楼层请求
-
当有剩余的楼层请求时循环:
-
更新位置、速度和加速度
-
如果队列中的下一个楼层请求得到满足,则从队列中移除它
-
调整电机控制以处理下一个楼层请求
-
打印实时统计信息
-
-
打印摘要
-
-
第四,编写实现依赖图的占位符。我们将更新
src/main.rs以实现此占位符逻辑。注意,由let绑定声明的变量存储在main函数内部。可变状态必须存储在函数或数据结构内部。这在下述代码块中显示:
pub fn run_simulation()
{
//1\. Store location, velocity, and acceleration state
let mut location: f64 = 0.0; // meters
let mut velocity: f64 = 0.0; // meters per second
let mut acceleration: f64 = 0.0; // meters per second squared
//2\. Store motor input voltage
let mut up_input_voltage: f64 = 0.0;
let mut down_input_voltage: f64 = 0.0;
//3\. Store input building description and floor requests
let mut floor_count: u64 = 0;
let mut floor_height: f64 = 0.0; // meters
let mut floor_requests: Vec<u64> = Vec::new();
//4\. Parse input and store as building description and floor requests
//5\. Loop while there are remaining floor requests
while floor_requests.len() > 0
{
//5.1\. Update location, velocity, and acceleration
//5.2\. If next floor request in queue is satisfied, then remove from queue
//5.3\. Adjust motor control to process next floor request
//5.4\. Print realtime statistics
}
//6\. Print summary
println!("summary");
}
fn main()
{
run_simulation()
}
或者,我们也可以将循环作为单独的函数编写。该函数将检查条件,并且该函数可能会再次调用自己。当一个函数调用自己时,这被称为递归。递归是函数式编程中极其常见且重要的模式。然而,这种特定的递归类型,称为尾递归,目前在 Rust 中不建议使用(参见 RFC #271 (github.com/rust-lang/rfcs/issues/271)——没有这个提议的优化,尾递归可能会不必要地使用额外的堆栈空间并耗尽内存)。
递归循环代码将如下所示:
fn process_floor_requests(...)
{
if floor_requests.len() == 0 { return; }
//5.1 Update location, velocity, and acceleration
//5.2 If next floor request in queue is satisfied, then remove from queue
//5.3 Adjust motor control to process next floor request
//5.4 Print realtime statistics
//tail recursion
process_floor_requests(...)
}
实现程序逻辑
一旦创建了占位程序,我们就可以继续用工作代码替换占位符。
填写空白
现在我们已经有了代码占位符和每个需要实现的功能的映射,我们可以开始编写代码逻辑。在此阶段,如果你在一个团队中工作,那么这是一个划分工作的好时机。架构阶段可能由一个人完成,或者作为一个团队,但不能并行进行。相比之下,实现阶段可以分解成单独工作的部分。
解析输入并将其存储为建筑描述和楼层请求
为了解析输入,我们首先需要决定是期望从stdin还是从文件接收输入。我们将采用以下约定:如果程序提供了文件名,则从该文件读取;如果文件名是-,则从stdin读取,否则从test1.txt读取。
使用 Rust 的 std::env 包和模式 match 语句,我们可以相当容易地完成这项任务。如下所示:
let buffer = match env::args().nth(1) {
Some(ref fp) if *fp == "-".to_string() => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
},
None => {
let fp = "test1.txt";
let mut buffer = String::new();
File::open(fp)
.expect("File::open failed")
.read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
},
Some(fp) => {
let mut buffer = String::new();
File::open(fp)
.expect("File::open failed")
.read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
}
};
现在,我们需要解析字符串的输入。对于输入中的每一行,我们将解析的值存储为楼层数、楼层高度或楼层请求,按此顺序。以下是实现此功能的代码:
for (li,l) in buffer.lines().enumerate() {
if li==0 {
floor_count = l.parse::<u64>().unwrap();
} else if li==1 {
floor_height = l.parse::<f64>().unwrap();
} else {
floor_requests.push(l.parse::<u64>().unwrap());
}
}
更新位置、速度和加速度
在这里,我们需要更新程序的状态,以反映自上次循环迭代以来状态变量的物理变化。所有这些变化都取决于对自上次迭代以来经过的时间的了解,但我们没有存储这些信息。所以,让我们对我们的代码做一些小的修改。
- 在循环外部存储上一次迭代的时间戳:
let mut prev_loop_time = Instant::now();
- 计算经过的时间,然后覆盖上一次的时间戳:
let now = Instant::now();
let dt = now.duration_since(prev_loop_time)
.as_fractional_secs();
prev_loop_time = now;
- 为了提高精度,在循环结束时暂停一段时间(记录亚毫秒级的测量值很困难):
thread::sleep(time::Duration::from_millis(10));
现在,我们可以开始计算新的位置、速度和加速度。位置是计算为前一个位置加上速度乘以时间。速度是计算为前一个速度加上加速度乘以时间。加速度是按照 F=ma 计算的,并将从电机力和载重计算得出。此时,我们意识到载重没有在输入文件中指定,但在经过一些讨论后,团队决定使用标准的载重而不是更改输入格式。
经过一些研究,你发现电梯载重大约为 1,200 公斤。同样,你估计一个简单的直流电机可以产生大约每伏特 8 牛顿的力。生成的代码如下所示:
location = location + velocity * dt;
velocity = velocity + acceleration * dt;
acceleration = {
let F = (up_input_voltage - down_input_voltage) * 8.0;
let m = 1200000.0;
//-9.8 is an approximation of acceleration due to gravity
-9.8 + F/m
};
如果队列中的下一个楼层请求得到满足,那么就将其从队列中移除
为了完成楼层请求,我们必须到达目的地楼层并停止。我们假设足够低的速度可以用某种形式的制动器停止。这将使我们稳定地保持在原地,直到乘客离开或进入电梯。代码如下:
let next_floor = floor_requests[0];
if (location - (next_floor as f64)*floor_height).abs() < 0.01
&& velocity.abs() < 0.01
{
velocity = 0.0;
floor_requests.remove(0);
}
调整电机控制以处理下一个楼层请求
为了调整电机控制,我们需要决定我们想要多少加速度,然后计算需要多少力来实现目标加速度。根据我们的目标,我们希望旅行时间更短,运动病更少,以及停止位置更准确。
为了实现这些目标,我们应该优化的指标是最大化平均速度,最小化加速度,以及最小化停止位置误差。所有这些目标相互竞争优先级,因此我们需要在它们之间做出妥协,以实现良好的整体性能。
经过一些研究,你发现舒适的加速度限制在每秒 1 到 1.5 米之间。你决定将目标定为最大 1 m/s²,在特殊情况下可以放宽到 1.5 m/s²。
对于速度,你决定超过 5 m/s 的载货速度是不安全的,所以你会实现一个最大速度,否则,速度应该总是最大化以达到下一个楼层。
对于位置精度,目标加速度与当前速度与目标目的地的计算是至关重要的。在这里,你将尝试将加速度保持在 1 m/s²附近,同时留有足够的空间进行额外的加速度。当足够接近目的地时,可能需要使用不同的加速度目标来进行更小的动作和速度调整。
要用代码实现这一点,我们首先计算减速范围。这定义为从该距离开始,在当前速度下,我们需要以大于 1 m/s²的加速度减速,以便在目的地停下。我们的加速度缓冲区提供了一些修正空间,这使得从下一个楼层开始减速之前,这是一个安全的目标。这在上面的代码中显示:
//it will take t seconds to decelerate from velocity v at -1 m/s²
let t = velocity.abs() / 1.0;
//during which time, the carriage will travel d=t * v/2 meters
//at an average velocity of v/2 before stopping
let d = t * (velocity/2.0);
//l = distance to next floor
let l = (location - (next_floor as f64)*floor_height).abs();
为了计算目标加速度,我们需要考虑三种情况:
-
如果我们在减速范围内,那么我们应该减速
-
如果我们不在减速范围内且不在最大速度,那么我们应该加速
-
如果我们不在减速范围内但已经达到最大速度,那么我们不应该改变速度:
let target_acceleration = {
//are we going up?
let going_up = location < (next_floor as f64)*floor_height;
//Do not exceed maximum velocity
if velocity.abs() >= 5.0 {
//if we are going up and actually going up
//or we are going down and actually going down
if (going_up && velocity>0.0)
|| (!going_up && velocity<0.0) {
0.0
//decelerate if going in wrong direction
} else if going_up {
1.0
} else {
-1.0
}
//if within comfortable deceleration range and moving in right direction, decelerate
} else if l < d && going_up==(velocity>0.0) {
if going_up {
-1.0
} else {
1.0
}
//else if not at peak velocity, accelerate
} else {
if going_up {
1.0
} else {
-1.0
}
}
};
最后,使用目标加速度,我们可以计算出应该施加到每个电机上的电压,以实现所需的加速度。通过倒推之前用于计算加速度的公式,我们现在可以从目标加速度计算出所需的电压,如下所示:
let gravity_adjusted_acceleration = target_acceleration + 9.8;
let target_force = gravity_adjusted_acceleration * 1200000.0;
let target_voltage = target_force / 8.0;
if target_voltage > 0.0 {
up_input_voltage = target_voltage;
down_input_voltage = 0.0;
} else {
up_input_voltage = 0.0;
down_input_voltage = target_voltage.abs();
};
打印实时统计数据
要打印实时统计数据,我们将使用一个控制台格式化库。这允许我们轻松地在屏幕上移动光标并写入清晰且易于格式化的文本。这在上面的代码中显示:
- 要开始,我们应该获取一些信息和
stdout的句柄,并将其存储在循环之外。这在上面的代码中显示:
let termsize = termion::terminal_size().ok();
let termwidth = termsize.map(|(w,_)| w-2).expect("termwidth");
let termheight = termsize.map(|(_,h)| h-2).expect("termheight");
let mut _stdout = io::stdout(); //lock once, instead of once per write
let mut stdout = _stdout.lock().into_raw_mode().unwrap();
- 在循环内部,让我们首先清除一个空间来渲染我们的输出:
print!("{}{}", clear::All, cursor::Goto(1, 1));
for tx in 0..(termwidth-1)
{
for ty in 0..(termheight-1)
{
write!(stdout, "{}", cursor::Goto(tx+1, ty+1));
write!(stdout, "{}", " ");
}
}
- 然后,我们可以渲染电梯井和载货平台。电梯井将是简单的括号,每个楼层左面和右面各一个。电梯载货平台将是一个
X标记,放置在离当前载货平台位置最近的楼层上。我们通过将floor_height乘以楼层相对于地面的偏移量来计算每个楼层的位置。然后,我们将每个楼层的位置与载货平台的位置进行比较,以找到最近的一个。代码如下:
print!("{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Hide);
let carriage_floor = (location / floor_height).floor() as u64;
let carriage_floor = cmp::max(carriage_floor, 0);
let carriage_floor = cmp::min(carriage_floor, floor_count-1);
for tx in 0..(termwidth-1)
{
for ty in 0..(termheight-1)
{
write!(stdout, "{}", cursor::Goto(tx+1, ty+1));
if tx==0 && (ty as u64)<floor_count {
write!(stdout, "{}", "[");
} else if tx==1 && (ty as u64)==((floor_count-1)-carriage_floor) {
write!(stdout, "{}", "X");
} else if tx==2 && (ty as u64)<floor_count {
write!(stdout, "{}", "]");
} else {
write!(stdout, "{}", " ");
}
}
}
stdout.flush().unwrap();
- 现在,我们需要打印实时统计数据。除了位置、速度和加速度之外,让我们还显示最近的楼层和电机输入电压,如下所示:
write!(stdout, "{}", cursor::Goto(6, 1));
write!(stdout, "Carriage at floor {}", carriage_floor+1);
write!(stdout, "{}", cursor::Goto(6, 2));
write!(stdout, "Location {}", location);
write!(stdout, "{}", cursor::Goto(6, 3));
write!(stdout, "Velocity {}", velocity);
write!(stdout, "{}", cursor::Goto(6, 4));
write!(stdout, "Acceleration {}", acceleration);
write!(stdout, "{}", cursor::Goto(6, 5));
write!(stdout, "Voltage [up-down] {}", up_input_voltage-down_input_voltage);
- 这里,我们发现终端屏幕正在撕裂,所以让我们调整输出以使用缓冲区:
let mut terminal_buffer = vec![' ' as u8; (termwidth*termheight) as usize];
for ty in 0..floor_count
{
terminal_buffer[ (ty*termwidth + 0) as usize ] = '[' as u8;
terminal_buffer[ (ty*termwidth + 1) as usize ] =
if (ty as u64)==((floor_count-1)-carriage_floor) { 'X' as u8 }
else { ' ' as u8 };
terminal_buffer[ (ty*termwidth + 2) as usize ] = ']' as u8;
terminal_buffer[ (ty*termwidth + termwidth-2) as usize ] = '\r' as u8;
terminal_buffer[ (ty*termwidth + termwidth-1) as usize ] = '\n' as u8;
}
let stats = vec![
format!("Carriage at floor {}", carriage_floor+1),
format!("Location {}", location),
format!("Velocity {}", velocity),
format!("Acceleration {}", acceleration),
format!("Voltage [up-down] {}", up_input_voltage-down_input_voltage)
];
for sy in 0..stats.len()
{
for (sx,sc) in stats[sy].chars().enumerate()
{
terminal_buffer[ sy*(termwidth as usize) + 6 + sx ] = sc as u8;
}
}
write!(stdout, "{}", String::from_utf8(terminal_buffer).unwrap());
现在,我们的屏幕将清楚地显示实时信息,直到循环结束。
打印总结
要打印我们的摘要,我们应该包括位置、速度和加速度的平均值和标准差。此外,查看电机控制的统计数据可能也很有趣,所以让我们也显示电压统计数据。此时,我们意识到数据存储的信息不足以计算平均值或标准差。
要计算变量的平均值,我们需要计算每个记录值的总和,并记录我们记录了多少个数据点。然后,我们将总值除以记录数,从而得到我们对时间平均值的估计。
要计算标准差,我们需要变量每个观测值的完整记录。此外,还需要平均值和记录数。然后,我们将使用以下公式来计算标准差:

在循环开始之前,我们需要声明新的变量来存储我们的数据:
- 要使用新变量存储数据,请使用以下代码:
let mut record_location = Vec::new();
let mut record_velocity = Vec::new();
let mut record_acceleration = Vec::new();
let mut record_voltage = Vec::new();
- 然后,在每次迭代之前,在计算新值之前,我们将存储每个数据点:
record_location.push(location);
record_velocity.push(velocity);
record_acceleration.push(acceleration);
record_voltage.push(up_input_voltage-down_input_voltage);
- 最后,我们计算统计信息:
let record_location_N = record_location.len();
let record_location_sum: f64 = record_location.iter().sum();
let record_location_avg = record_location_sum / (record_location_N as f64);
let record_location_dev = (
record_location.clone().into_iter()
.map(|v| (v - record_location_avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (record_location_N as f64)
).sqrt();
let record_velocity_N = record_velocity.len();
let record_velocity_sum: f64 = record_velocity.iter().sum();
let record_velocity_avg = record_velocity_sum / (record_velocity_N as f64);
let record_velocity_dev = (
record_velocity.clone().into_iter()
.map(|v| (v - record_velocity_avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (record_velocity_N as f64)
).sqrt();
let record_acceleration_N = record_acceleration.len();
let record_acceleration_sum: f64 = record_acceleration.iter().sum();
let record_acceleration_avg = record_acceleration_sum / (record_acceleration_N as f64);
let record_acceleration_dev = (
record_acceleration.clone().into_iter()
.map(|v| (v - record_acceleration_avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (record_acceleration_N as f64)
).sqrt();
let record_voltage_N = record_voltage.len();
let record_voltage_sum = record_voltage.iter().sum();
let record_voltage_avg = record_voltage_sum / (record_voltage_N as f64);
let record_voltage_dev = (
record_voltage.clone().into_iter()
.map(|v| (v - record_voltage_avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (record_voltage_N as f64)
).sqrt();
- 在退出程序之前,我们必须打印统计信息:
write!(stdout, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Show).unwrap();
write!(stdout, "Average of location {:.6}\r\n", record_location_avg);
write!(stdout, "Standard deviation of location {:.6}\r\n", record_location_dev);
write!(stdout, "\r\n");
write!(stdout, "Average of velocity {:.6}\r\n", record_velocity_avg);
write!(stdout, "Standard deviation of velocity {:.6}\r\n", record_velocity_dev);
write!(stdout, "\r\n");
write!(stdout, "Average of acceleration {:.6}\r\n", record_acceleration_avg);
write!(stdout, "Standard deviation of acceleration {:.6}\r\n", record_acceleration_dev);
write!(stdout, "\r\n");
write!(stdout, "Average of voltage {:.6}\r\n", record_voltage_avg);
write!(stdout, "Standard deviation of voltage {:.6}\r\n", record_voltage_dev);
write!(stdout, "\r\n");
stdout.flush().unwrap();
现在,已经组装好了所有部件,我们有一个完整的模拟。在测试输入上运行程序会产生一个漂亮的图形和结果摘要。这应该足以作为初始提案的补充。
将长段分解成组件
一旦项目功能正常,我们就可以开始寻找简化设计和消除冗余的机会。这里的第一个步骤应该是寻找类似代码的模式。我们的摘要统计是一个很好的例子,应该进行清理。我们有四个变量,我们跟踪并显示它们的统计数据。每个统计的计算都是相同的,但我们为每个变量显式地重复计算。输出格式化也有相似之处,所以我们也应该清理这一点。
为了消除冗余,首先要问的问题是代码是否可以重写为一个函数。在这里,我们确实有机会通过创建一个接受变量数据和打印摘要的函数来使用这个模式。这是按照以下方式完成的:
- 我们可以编写这个函数,如下所示:
fn variable_summary<W: Write>(stdout: &mut raw::RawTerminal<W>, vname: &str, data: Vec<f64>)
{
//calculate statistics
let N = data.len();
let sum: f64 = data.iter().sum();
let avg = sum / (N as f64);
let dev = (
data.clone().into_iter()
.map(|v| (v - avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (N as f64)
).sqrt();
//print formatted output
write!(stdout, "Average of {:25}{:.6}\r\n", vname, avg);
write!(stdout, "Standard deviation of {:14}{:.6}\r\n", vname, dev);
write!(stdout, "\r\n");
}
- 要调用函数,我们需要提供每个
name和data变量:
write!(stdout, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Show).unwrap();
variable_summary(&mut stdout, "location", record_location);
variable_summary(&mut stdout, "velocity", record_velocity);
variable_summary(&mut stdout, "acceleration", record_acceleration);
variable_summary(&mut stdout, "voltage", record_voltage);
stdout.flush().unwrap();
重新编写改进了程序的两个重要方面:
-
统计计算更容易阅读和调试
-
使用统计和摘要函数涉及很少的冗余,这减少了意外使用错误的变量名或其他常见错误的可能性
短小、易读的代码是健壮的,可以防止错误。长而冗余的代码是脆弱的,容易出错。
寻找抽象
在编写代码草稿后,再次阅读代码并寻找可能的改进是一个好习惯。在审查项目时,特别关注丑陋的代码、反模式和未经检查的假设。审查后,我们发现代码不需要修正。
然而,我们应该指出一个使用的函数式抽象,它显著减少了行数,那就是迭代器的使用。在计算我们的变量摘要时,我们总是使用迭代器来计算总和和统计。一些运算符尚未介绍,让我们更仔细地看看:
let N = data.len();
let sum: f64 = data.iter().sum();
let avg = sum / (N as f64);
let dev = (
data.clone().into_iter()
.map(|v| (v - avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (N as f64)
).sqrt();
在这里,使用了两个重要的迭代器方法——map 和 fold。map 接受一个映射函数并返回一个修改后的值的迭代器。fold 方法持有一个累加器值(参数 1),并且对于迭代器中的每个元素,应用累加器函数(参数 2),返回累加的值作为结果。调用 fold 函数时,会消耗迭代器。
迭代器由一个具有 next 方法的特质定义,它可能返回序列中的下一个项目。一个简单的无限列表可以这样定义:
struct Fibonacci
{
curr: u32,
next: u32,
}
impl Iterator for Fibonacci
{
type Item = u32;
fn next(&mut self) -> Option<u32>
{
let new_next = self.curr + self.next;
self.curr = self.next;
self.next = new_next;
Some(self.curr) //infinite list, never None
}
}
fn fibonacci() -> Fibonacci
{
Fibonacci { curr: 1, next: 1 }
}
这些对象定义了一个迭代器。map 函数和其他流修改器只是将输入流包装在另一个迭代器中,该迭代器应用修改器。
或者,统计计算可以用 for 循环来定义。结果看起来如下:
let N = data.len();
let mut sum = 0.0;
for di in 0..data.len()
{
sum += data[di];
}
let avg = sum / (N as f64);
let mut dev = 0.0;
for di in 0..data.len()
{
dev += (data[di] - avg).powi(2);
}
dev = (dev / (N as f64)).sqrt();
相比之下,我们可以看到函数式代码稍微短一点。更重要的是,函数式代码是声明性的。当代码只描述需求时,我们称这种代码为声明性的。当代码描述满足需求的机器指令时,我们称这种代码为命令式的。声明式风格相对于命令式风格的主要好处是声明式风格是自文档化的,并且通过使错误更明显来防止错误。
由于这些原因,在寻找抽象时,我们鼓励查看 for 循环。在大多数情况下,for 循环可能是杂乱的或不受欢迎的。迭代器和组合器可能是帮助提高代码质量的良好解决方案。
编写测试
要从命令行运行测试,请输入 cargo test。我们将经常这样做。
单元测试
单元测试专注于测试程序的内联接口和组件。它也被称为白盒测试。首先创建单元测试时,查看所有顶级类型、特性和函数是一个好主意。所有顶级标识符都适合作为测试用例。根据程序的结构,测试这些组件的组合以覆盖预期用例也可能是一个好主意。
我们有一个实用函数,即统计计算,这是一个很好的单元测试候选。然而,这个函数不返回任何结果。相反,它立即将输出打印到控制台。为了测试这一点,我们应该将函数分解为两个组件——一个用于计算统计,另一个用于打印统计。这看起来如下:
fn variable_summary<W: Write>(stdout: &mut raw::RawTerminal<W>, vname: &str, data: Vec<f64>)
{
let (avg, dev) = variable_summary_stats(data);
variable_summary_print(stdout, vname, avg, dev);
}
fn variable_summary_stats(data: Vec<f64>) -> (f64, f64)
{
//calculate statistics
let N = data.len();
let sum: f64 = data.iter().sum();
let avg = sum / (N as f64);
let dev = (
data.clone().into_iter()
.map(|v| (v - avg).powi(2))
.fold(0.0, |a, b| a+b)
/ (N as f64)
).sqrt();
(avg, dev)
}
fn variable_summary_print<W: Write>(stdout: &mut raw::RawTerminal<W>, vname: &str, avg: f64, dev: f64)
{
//print formatted output
write!(stdout, "Average of {:25}{:.6}\r\n", vname, avg);
write!(stdout, "Standard deviation of {:14}{:.6}\r\n", vname, dev);
write!(stdout, "\r\n");
}
现在我们已经将统计计算独立成一个函数,我们可以更容易地为其编写单元测试。首先,我们提供一些测试数据,然后验证每个结果。同时请注意,只要我们在测试声明中添加use super::*;,单元测试就可以访问私有函数。以下是我们统计计算的几个单元测试:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn variable_stats() {
let test_data = vec![
(vec![1.0, 2.0, 3.0, 4.0, 5.0], 3.0, 1.41),
(vec![1.0, 3.0, 5.0, 7.0, 9.0], 5.0, 2.83),
(vec![1.0, 9.0, 1.0, 9.0, 1.0], 4.2, 3.92),
(vec![1.0, 0.5, 0.7, 0.9, 0.6], 0.74, 0.19),
(vec![200.0, 3.0, 24.0, 92.0, 111.0], 86.0, 69.84),
];
for (data, avg, dev) in test_data
{
let (ravg, rdev) = variable_summary_stats(data);
//it is not safe to use direct == operator on floats
//floats can be *very* close and not equal
//so instead we check that they are very close in value
assert!( (avg-ravg).abs() < 0.1 );
assert!( (dev-rdev).abs() < 0.1 );
}
}
}
现在,如果我们运行cargo test,单元测试将会运行。结果应该显示一个测试通过。
集成测试
集成测试侧重于测试程序的外部接口。它也被称为黑盒测试。要创建集成测试,关注程序或模块的输入和输出应该是什么。考虑不同的选项、数据和可能的内部交互配置来创建测试。然后,这些测试应该提供对完成程序高级行为的良好覆盖。
要创建集成测试,我们首先需要将我们的项目重新配置为一个可以被导入的模块。集成测试无法访问除了它们可以从use语句中引用的符号之外的其他符号。为了实现这一点,我们可以将程序逻辑移动到src/lib.rs文件中,并为src/main.rs使用一个简单的包装器。在此更改之后,lib.rs文件应包含来自main.rs的所有代码,其中一项更改是将main函数重命名为run_simulation并使该函数公开。main.rs包装器应如下所示:
extern crate elevator;
fn main()
{
elevator::run_simulation();
}
现在,为了创建集成测试:
-
创建一个
tests/目录 -
在
tests/目录中创建一个integration_tests.rs文件 -
在
integration_tests.rs文件中,为每个测试用例创建函数
在这里,我们将创建一个单独的测试用例,以接受特定的电梯请求并检查请求是否在合理的时间内得到处理。测试框架如下:
extern crate elevator;
extern crate timebomb;
use timebomb::timeout_ms;
#[test]
fn test_main() {
timeout_ms(|| {
elevator::run_simulation();
}, 300000);
}
作为输入,我们将使用一个5层的建筑,每层5.67米,以及7个楼层请求。文件将存储为test1.txt,并应具有以下结构:
5
5.67
2
1
4
0
3
1
0
在这些测试到位后,我们现在可以确认基本逻辑是正常工作的,并且整个程序作为整体可以正常工作。要运行所有测试,请调用cargo test,或使用特定的测试用例cargo test casename。
一个示例测试运行如下:
[ ] Carriage at floor 1
[ ] Location 2.203829
[ ] Velocity -2.157214
[ ] Acceleration 1.000000
[X] Voltage [up-down] 1620000.000000
[ ] Carriage at floor 3
[ ] Location 11.344785
[X] Velocity 0.173572
[ ] Acceleration -1.000000
[ ] Voltage [up-down] 1320000.000000
[ ] Carriage at floor 4
[X] Location 19.235710
[ ] Velocity 2.669347
[ ] Acceleration -1.000000
[ ] Voltage [up-down] 1320000.000000
[ ] Carriage at floor 1
[ ] Location 0.133051
[ ] Velocity 0.160799
[ ] Acceleration -1.000000
[X] Voltage [up-down] 1320000.000000
一旦模拟完成,总结和测试结果如下:
Average of location 5.017036
Standard deviation of location 8.813507
Average of velocity -0.007597
Standard deviation of velocity 2.107692
Average of acceleration 0.000850
Standard deviation of acceleration 0.995623
Average of voltage 1470109.838195
Standard deviation of voltage 149352.287579
test test_main ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
running 1 test
test tests::variable_stats ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
总结
在本章中,我们概述了收集项目需求、设计解决方案以及实施完成交付物的步骤。我们关注了如何使用功能思维来明确这个过程。
在收集程序需求时,所需的数据、输入和输出应该被明确。当将需求转换为代码计划时,创建一个依赖图作为中间步骤可以帮助简化复杂的设计。在测试时,函数成为很好的单元来覆盖。相比之下,一行又一行的命令式代码几乎不可能进行测试。
我们将在整本书中持续开发这个软件项目。这个第一个模拟交付成果将伴随项目提案,并希望有助于我们的公司获得合同。在下一章中,你将收到开发者的反馈,并遇到你的竞争对手。
问题
-
三元运算符是什么?
-
单元测试的另一个名称是什么?
-
集成测试的另一个名称是什么?
-
声明式编程的另一个名称是什么?
-
命令式编程是什么?
-
迭代器特质中定义了什么?
-
fold 将会以哪个方向遍历迭代器序列?
-
依赖图是什么?
-
Option有哪两个构造函数?
第三章:函数式数据结构
数据结构是编程的第二个最基本构建块,仅次于控制流。在早期语言开发控制流结构之后,很快就很明显,简单的变量标签不足以开发复杂程序。数据结构已经从存储在地址上的固定大小数据的基本概念演变为字符串和数组,然后是混合结构,最后是集合。
在本章中,我们将回顾在第二章中介绍的项目,即函数式控制流。由于潜在客户反馈,项目需求已经扩展以适应。由于竞争对手的开发,还必须满足特定的性能目标。为了帮助我们的业务成功,我们现在必须改进之前的模拟,并确保它满足客户需求和性能目标。
在本章中,我们将涵盖以下内容:
-
适应项目范围的变更
-
重新格式化代码以支持多个用例
-
使用适当的数据结构来收集、存储和处理数据
-
将代码组织到特性和数据类中
技术需求
运行提供的示例需要使用 Rust 的最新版本:
www.rust-lang.org/en-US/install.html
本章的代码也可在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每章的 README.md 文件中也包含了具体的安装和构建说明。
适应项目范围的变更
你不可能为所有事情都做计划。你也可能不想尝试为所有事情都做计划。灵活的软件开发和强调健壮、逻辑上独立的组件,当需求或依赖不可避免地发生变化时,可以减少工作量。
收集新的项目需求
在初步演示之后,您的团队收到了潜在客户的评论和反馈。观看模拟时,电梯似乎经常在停止前经过并返回楼层。客户表示担忧,这不仅效率低下,而且对乘客来说可能是不舒适或烦躁的。为了赢得合同,客户希望看到改进和证据,以证明:
-
乘坐体验舒适且可靠直接
-
电梯从每个源头高效地移动到每个目标楼层
此外,您还了解到竞争对手提交了一份单独的提案。竞争对手具体声称其电梯控制系统保持加速度在舒适水平,速度在安全范围内,并在物理理论极限的 20%内准确到达目的地。没有提供具体数字,也没有演示模拟,但客户似乎非常确信,并保证项目成本将降低 10%。
从需求构建变更图
在收到反馈和新期望后,我们必须将这些需求转化为行动计划。需要更新模拟,并需要构建额外的工具。让我们回顾新信息,并设计一个解决方案以满足新的需求。
将期望转化为需求
审查反馈,很明显需要解决两个观点:
-
竞争对手提出了具体的主张,我们的公司需要超越
-
客户对第一次演示中的担忧有明确的期望
竞争对手的具体主张可以列出如下:
-
加速度在舒适范围内
-
速度在安全范围内
-
从任何楼层到任何其他楼层的行程时间在物理理论极限的 20%以内
-
该软件便宜 10%
我们将把价格谈判委托给我们的销售团队,但除此之外,我们需要调整我们的软件以超越其他三个主张。如果我们能够满足这些要求并提供充分的证据,那么这也应该解决客户的大部分明确担忧。
此外,客户特别关注电梯通过目标楼层并需要倒退的行为。我们应该解决这个问题,并确认在模拟中不会发生这种行为。
到目前为止,很明显之前的电机控制逻辑是不够的。在头脑风暴后,您的团队开发了两种可能的改进:
-
使用可变加速度/减速度计算,而不是开/关调整
-
减少更新间隔以允许更快且更精确的决策
将需求转化为变更图
考虑到各种新的要求,将之前的模拟代码拆分为不同的库和可执行文件似乎是合适的。我们将为以下每个创建一个单独的模块:
-
一个物理模拟器
-
电机控制
-
一个用于演示模拟的可执行文件
-
一个用于进一步分析模拟的可执行文件
物理模拟器应接受一个泛型电机控制器和一个测量累加器。提供的测量累加器将接受速度、加速度和模拟器可用的所有其他信息的读数。提供的电机控制器将接受类似的速度等读数,并产生对电机所需的电压输出。该函数将负责准确模拟任何指定电梯和建筑的物理操作。
电机控制将与模拟器或最终的实际电梯连接,以使用可用信息来决定如何操作电梯。
模拟可执行文件将包装物理模拟器和电机控制,以创建与第二章 功能控制流中的模拟等效的程序。此外,所有记录的模拟信息都应保存到文件中,以进行进一步详细分析。
分析可执行文件应接受模拟器跟踪文件并检查是否满足所有性能要求。此外,任何对开发目的有用的分析都将在此处添加。
将需求直接映射到代码
并非总是希望为每个项目或变更经历创建依赖图和伪代码的完整过程。在这里,我们将直接从先前的计划过渡到以下代码片段。
编写物理模拟器
src/physics.rs 中的物理模拟器负责模拟建筑和电梯的物理和布局。模拟器将提供一个对象来处理电机控制,另一个对象来处理数据收集。物理模拟器模块将为每个接口定义特质,电机控制和数据收集对象应分别实现每个 trait。
让我们从定义 physics 模块的一些类型声明开始。首先,让我们看看一个关键接口——直接电机输入。到目前为止,我们一直假设电机输入将具有简单的电压控制,我们可以将其表示为正或负浮点整数。这种定义是有问题的,主要是在于所有对此类型的引用都将引用 f64。此类型指定了一种非常具体的数据表示,没有调整的余地。如果我们用此类型的引用充斥我们的代码,那么任何更改都需要我们回过头来编辑每一个引用。
相反,对于电机输入类型,我们为该类型提供一个名称。这可以是对f64类型的别名,这将解决当前的问题。尽管这是可以接受的,但我们将选择更明确地定义类型并提供向上和向下的enum情况。enum类型,也称为标签联合体,用于定义可能具有多种结构或用例的数据。在这里,构造函数是相同的,但每个电压字段的意义是相反的。
此外,当与MotorInput类型交互时,我们应该避免假设任何内部结构。这最小化了我们未来接口变化的风险,这些变化可能是因为MotorInput定义了一个具有当前未知物理组件的接口。我们将负责与该接口的软件兼容性。因此,为了抽象与MotorInput的任何交互,我们将使用特性。那些不定义类型内在行为,而是定义相关行为的特性有时被称为数据类。
下面是enum和一个定义从输入推导出力的数据类的示例:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub enum MotorInput
{
Up { voltage: f64 },
Down { voltage: f64 }
}
pub trait MotorForce {
fn calculate_force(&self) -> f64;
}
impl MotorForce for MotorInput {
fn calculate_force(&self) -> f64
{
match *self {
MotorInput::Up { voltage: v } => { v * 8.0 }
MotorInput::Down { voltage: v } => { v * -8.0 }
}
}
}
pub trait MotorVoltage {
fn voltage(&self) -> f64;
}
impl MotorVoltage for MotorInput {
fn voltage(&self) -> f64
{
match *self {
MotorInput::Up { voltage: v } => { v }
MotorInput::Down { voltage: v } => { -v }
}
}
}
接下来,让我们定义电梯信息。我们将创建一个ElevatorSpecification,它描述了建筑和电梯的结构。我们还需要一个ElevatorState来保存有关当前电梯状态的信息。为了明确楼层请求的使用,我们还将为FloorRequests向量创建一个别名,使其意义明确。在这里,我们选择使用struct而不是元组来创建明确的字段名称。否则,struct和元组可以互换用于存储杂项数据。定义如下:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorSpecification
{
pub floor_count: u64,
pub floor_height: f64,
pub carriage_weight: f64
}
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorState
{
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: MotorInput
}
pub type FloorRequests = Vec<u64>;
MotorController和DataRecorder的特性几乎相同。唯一的区别是轮询MotorController期望返回一个MotorInput。在这里,我们选择使用init方法而不是构造函数来允许对每个资源的额外外部初始化。例如,DataRecorder可能需要在模拟期间打开文件或其他资源。以下是trait定义:
pub trait MotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState);
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput;
}
pub trait DataRecorder
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState);
fn poll(&mut self, est: ElevatorState, dst: u64);
fn summary(&mut self);
}
为了模拟电梯的物理特性,我们将从第二章的模拟中心循环中复制,功能控制流。一些状态已经被组织成结构而不是松散的变量。电机控制决策已经委托给MotorController对象。输出和数据记录已经委托给DataRecorder。还有一个新的参数字段来指定电梯的载重。有了所有这些概括,代码如下:
pub fn simulate_elevator<MC: MotorController, DR: DataRecorder>(esp: ElevatorSpecification, est: ElevatorState, req: FloorRequests,
mc: &mut MC, dr: &mut DR) {
//immutable input becomes mutable local state
let mut esp = esp.clone();
let mut est = est.clone();
let mut req = req.clone();
//initialize MotorController and DataController
mc.init(esp.clone(), est.clone());
dr.init(esp.clone(), est.clone());
//5\. Loop while there are remaining floor requests
let original_ts = Instant::now();
thread::sleep(time::Duration::from_millis(1));
while req.len() > 0
{
//5.1\. Update location, velocity, and acceleration
let now = Instant::now();
let ts = now.duration_since(original_ts)
.as_fractional_secs();
let dt = ts - est.timestamp;
est.timestamp = ts;
est.location = est.location + est.velocity * dt;
est.velocity = est.velocity + est.acceleration * dt;
est.acceleration = {
let F = est.motor_input.calculate_force();
let m = esp.carriage_weight;
-9.8 + F/m
};
在声明状态和计算时间相关变量之后,我们添加电梯控制逻辑:
//5.2\. If next floor request in queue is satisfied,
then remove from queue
let next_floor = req[0];
if (est.location - (next_floor as f64)*esp.floor_height).abs()
< 0.01 &&
est.velocity.abs() < 0.01
{
est.velocity = 0.0;
req.remove(0);
//remove is an O(n) operation
//Vec should not be used like this for large data
}
//5.4\. Print realtime statistics
dr.poll(est.clone(), next_floor);
//5.3\. Adjust motor control to process next floor request
est.motor_input = mc.poll(est.clone(), next_floor);
thread::sleep(time::Duration::from_millis(1));
}
}
编写电机控制器
src/motor.rs中的电机控制器将负责决定从电机产生多少力。物理驱动器将提供有关所有已知测量值(如位置、速度等)的当前状态信息。目前,电机控制器仅使用最新信息做出控制决策。然而,这可能在将来发生变化,在这种情况下,控制器可能会存储过去的测量值。
从上一章提取相同的控制算法,新的MotorController定义如下:
pub struct SimpleMotorController
{
pub esp: ElevatorSpecification
}
impl MotorController for SimpleMotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState)
{
self.esp = esp;
}
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput
{
//5.3\. Adjust motor control to process next floor request
//it will take t seconds to decelerate from velocity v
at -1 m/s²
let t = est.velocity.abs() / 1.0;
//during which time, the carriage will travel d=t * v/2 meters
//at an average velocity of v/2 before stopping
let d = t * (est.velocity/2.0);
//l = distance to next floor
let l = (est.location - (dst as
f64)*self.esp.floor_height).abs();
在确定基本常数和值之后,我们需要确定目标加速度:
let target_acceleration = {
//are we going up?
let going_up = est.location < (dst as
f64)*self.esp.floor_height;
//Do not exceed maximum velocity
if est.velocity.abs() >= 5.0 {
if going_up==(est.velocity>0.0) {
0.0
//decelerate if going in wrong direction
} else if going_up {
1.0
} else {
-1.0
}
//if within comfortable deceleration range and moving
in right direction, decelerate
} else if l < d && going_up==(est.velocity>0.0) {
if going_up {
-1.0
} else {
1.0
}
//else if not at peak velocity, accelerate
} else {
if going_up {
1.0
} else {
-1.0
}
}
};
确定目标加速度后,应将其转换为MotorInput值:
let gravity_adjusted_acceleration = target_acceleration + 9.8;
let target_force = gravity_adjusted_acceleration *
self.esp.carriage_weight;
let target_voltage = target_force / 8.0;
if target_voltage > 0.0 {
MotorInput::Up { voltage: target_voltage }
} else {
MotorInput::Down { voltage: target_voltage.abs() }
}
}
}
现在,让我们编写第二个控制器,实现所提出的改进。我们将在模拟的后期比较这两个控制器。第一个建议是减少轮询间隔。此更改必须在物理模拟器中完成,因此我们将测量其效果,但不会将其与电机控制器绑定。第二个建议是平滑加速度曲线。
经过考虑,我们意识到加速度(也称为jerk)的变化是让人感到不适的原因,比小的加速度力更甚。理解这一点后,只要加速度变化率保持小,我们将允许更快的加速度。我们将用以下约束和目标替换当前的目标加速度计算:
-
最大加速度变化率 =
0.2m/s³ -
最大加速度 =
2.0m/s² -
最大速度 =
5.0m/s -
目标加速度变化:
-
如果加速,则输出 0.2
-
如果减速,则输出-0.2
-
如果处于稳定速度,则输出 0.0
-
结果控制器如下所示:
const MAX_JERK: f64 = 0.2;
const MAX_ACCELERATION: f64 = 2.0;
const MAX_VELOCITY: f64 = 5.0;
pub struct SmoothMotorController
{
pub esp: ElevatorSpecification,
pub timestamp: f64
}
impl MotorController for SmoothMotorController
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState)
{
self.esp = esp;
self.timestamp = est.timestamp;
}
fn poll(&mut self, est: ElevatorState, dst: u64) -> MotorInput
{
//5.3\. Adjust motor control to process next floor request
//it will take t seconds to reach max from max
let t_accel = MAX_ACCELERATION / MAX_JERK;
let t_veloc = MAX_VELOCITY / MAX_ACCELERATION;
//it may take up to d meters to decelerate from current
let decel_t = if (est.velocity>0.0) == (est.acceleration>0.0) {
//this case deliberately overestimates d to prevent "back up"
(est.acceleration.abs() / MAX_JERK) +
(est.velocity.abs() / (MAX_ACCELERATION / 2.0)) +
2.0 * (MAX_ACCELERATION / MAX_JERK)
} else {
//without the MAX_JERK, this approaches infinity and
decelerates way too soon
//MAX_JERK * 1s = acceleration in m/s²
est.velocity.abs() / (MAX_JERK + est.acceleration.abs())
};
let d = est.velocity.abs() * decel_t;
//l = distance to next floor
let l = (est.location - (dst as
f64)*self.esp.floor_height).abs();
确定基本常数和值后,我们可以计算目标加速度:
let target_acceleration = {
//are we going up?
let going_up = est.location < (dst as
f64)*self.esp.floor_height;
//time elapsed since last poll
let dt = est.timestamp - self.timestamp;
self.timestamp = est.timestamp;
//Do not exceed maximum acceleration
if est.acceleration.abs() >= MAX_ACCELERATION {
if est.acceleration > 0.0 {
est.acceleration - (dt * MAX_JERK)
} else {
est.acceleration + (dt * MAX_JERK)
}
//Do not exceed maximum velocity
} else if est.velocity.abs() >= MAX_VELOCITY
|| (est.velocity + est.acceleration *
(est.acceleration.abs() / MAX_JERK)).abs() >=
MAX_VELOCITY {
if est.velocity > 0.0 {
est.acceleration - (dt * MAX_JERK)
} else {
est.acceleration + (dt * MAX_JERK)
}
//if within comfortable deceleration range and
moving in right direction, decelerate
} else if l < d && (est.velocity>0.0) == going_up {
if going_up {
est.acceleration - (dt * MAX_JERK)
} else {
est.acceleration + (dt * MAX_JERK)
}
//else if not at peak velocity, accelerate smoothly
} else {
if going_up {
est.acceleration + (dt * MAX_JERK)
} else {
est.acceleration - (dt * MAX_JERK)
}
}
};
确定目标加速度后,我们应该计算目标力:
let gravity_adjusted_acceleration = target_acceleration + 9.8;
let target_force = gravity_adjusted_acceleration
* self.esp.carriage_weight;
let target_voltage = target_force / 8.0;
if !target_voltage.is_finite() {
//divide by zero etc.
//may happen if time delta underflows
MotorInput::Up { voltage: 0.0 }
} else if target_voltage > 0.0 {
MotorInput::Up { voltage: target_voltage }
} else {
MotorInput::Down { voltage: target_voltage.abs() }
}
}
}
编写运行模拟的可执行文件
运行模拟的可执行文件,包含在src/lib.rs中,由上一章模拟的所有输入和配置组成。以下是配置和运行模拟所使用的工具:
pub fn run_simulation()
{
//1\. Store location, velocity, and acceleration state
//2\. Store motor input voltage
let mut est = ElevatorState {
timestamp: 0.0,
location: 0.0,
velocity: 0.0,
acceleration: 0.0,
motor_input: MotorInput::Up {
//a positive force is required to counter gravity and
voltage: 9.8 * (120000.0 / 8.0)
}
};
//3\. Store input building description and floor requests
let mut esp = ElevatorSpecification {
floor_count: 0,
floor_height: 0.0,
carriage_weight: 120000.0
};
let mut floor_requests = Vec::new();
//4\. Parse input and store as building description
and floor requests
let buffer = match env::args().nth(1) {
Some(ref fp) if *fp == "-".to_string() => {
let mut buffer = String::new();
io::stdin().read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
},
None => {
let fp = "test1.txt";
let mut buffer = String::new();
File::open(fp)
.expect("File::open failed")
.read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
},
Some(fp) => {
let mut buffer = String::new();
File::open(fp)
.expect("File::open failed")
.read_to_string(&mut buffer)
.expect("read_to_string failed");
buffer
}
};
for (li,l) in buffer.lines().enumerate() {
if li==0 {
esp.floor_count = l.parse::<u64>().unwrap();
} else if li==1 {
esp.floor_height = l.parse::<f64>().unwrap();
} else {
floor_requests.push(l.parse::<u64>().unwrap());
}
}
在建立模拟状态并读取输入配置后,我们运行模拟:
let termsize = termion::terminal_size().ok();
let mut dr = SimpleDataRecorder {
esp: esp.clone(),
termwidth: termsize.map(|(w,_)| w-2).expect("termwidth")
as u64,
termheight: termsize.map(|(_,h)| h-2).expect("termheight")
as u64,
stdout: &mut io::stdout().into_raw_mode().unwrap(),
log: File::create("simulation.log").expect("log file"),
record_location: Vec::new(),
record_velocity: Vec::new(),
record_acceleration: Vec::new(),
record_voltage: Vec::new()
};
/*
let mut mc = SimpleMotorController {
esp: esp.clone()
};
*/
let mut mc = SmoothMotorController {
timestamp: 0.0,
esp: esp.clone()
};
simulate_elevator(esp, est, floor_requests, &mut mc, &mut dr);
dr.summary();
}
DataRecorder实现,同样在src/lib.rs中,负责输出实时信息以及汇总信息。此外,我们还将序列化和存储模拟数据到日志文件中。注意使用lifetime参数以及参数化的trait:
struct SimpleDataRecorder<'a, W: 'a + Write>
{
esp: ElevatorSpecification,
termwidth: u64,
termheight: u64,
stdout: &'a mut raw::RawTerminal<W>,
log: File,
record_location: Vec<f64>,
record_velocity: Vec<f64>,
record_acceleration: Vec<f64>,
record_voltage: Vec<f64>,
}
impl<'a, W: Write> DataRecorder for SimpleDataRecorder<'a, W>
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState)
{
self.esp = esp.clone();
self.log.write_all(serde_json::to_string(&esp).unwrap().as_bytes()).expect("write spec to log");
self.log.write_all(b"\r\n").expect("write spec to log");
}
fn poll(&mut self, est: ElevatorState, dst: u64)
{
let datum = (est.clone(), dst);
self.log.write_all(serde_json::to_string(&datum).unwrap().as_bytes()).expect("write state to log");
self.log.write_all(b"\r\n").expect("write state to log");
self.record_location.push(est.location);
self.record_velocity.push(est.velocity);
self.record_acceleration.push(est.acceleration);
self.record_voltage.push(est.motor_input.voltage());
DataRecorder不仅负责将模拟数据记录到日志中,还负责将统计数据打印到终端:
//5.4\. Print realtime statistics
print!("{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Hide);
let carriage_floor = (est.location / self.esp.floor_height).floor();
let carriage_floor = if carriage_floor < 1.0 { 0 } else { carriage_floor as u64 };
let carriage_floor = cmp::min(carriage_floor, self.esp.floor_count-1);
let mut terminal_buffer = vec![' ' as u8; (self.termwidth*self.termheight) as usize];
for ty in 0..self.esp.floor_count
{
terminal_buffer[ (ty*self.termwidth + 0) as usize ] = '[' as u8;
terminal_buffer[ (ty*self.termwidth + 1) as usize ] =
if (ty as u64)==((self.esp.floor_count-1)-carriage_floor) { 'X' as u8 }
else { ' ' as u8 };
terminal_buffer[ (ty*self.termwidth + 2) as usize ] = ']' as u8;
terminal_buffer[ (ty*self.termwidth + self.termwidth-2) as usize ] = '\r' as u8;
terminal_buffer[ (ty*self.termwidth + self.termwidth-1) as usize ] = '\n' as u8;
}
let stats = vec![
format!("Carriage at floor {}", carriage_floor+1),
format!("Location {:.06}", est.location),
format!("Velocity {:.06}", est.velocity),
format!("Acceleration {:.06}", est.acceleration),
format!("Voltage [up-down] {:.06}", est.motor_input.voltage()),
];
for sy in 0..stats.len()
{
for (sx,sc) in stats[sy].chars().enumerate()
{
terminal_buffer[ sy*(self.termwidth as usize) + 6 + sx ] = sc as u8;
}
}
write!(self.stdout, "{}",
String::from_utf8(terminal_buffer).ok().unwrap());
self.stdout.flush().unwrap();
}
DataRecorder还负责在模拟结束时打印汇总信息:
fn summary(&mut self)
{
//6 Calculate and print summary statistics
write!(self.stdout, "{}{}{}", clear::All, cursor::Goto(1, 1), cursor::Show).unwrap();
variable_summary(&mut self.stdout, "location".to_string(), &self.record_location);
variable_summary(&mut self.stdout, "velocity".to_string(), &self.record_velocity);
variable_summary(&mut self.stdout, "acceleration".to_string(), &self.record_acceleration);
variable_summary(&mut self.stdout, "voltage".to_string(), &self.record_voltage);
self.stdout.flush().unwrap();
}
}
编写可执行文件以分析模拟
src/analyze.rs中的分析可执行文件应查看日志文件并确认所有要求都已满足——即以下内容:
-
加速度变化率(也称为jerk)小于
0.2m/s³ -
加速度低于
2.0m/s² -
速度低于
5.0m/s -
电梯在行程中不会倒退
-
所有行程都在物理理论极限的 20% 以内完成
程序设计将是遍历日志文件并检查所有值是否在指定的限制内。还需要一个方向标志来提醒我们备份事件。当行程完成后,我们将比较经过的时间与理论极限。如果任何要求未满足,我们将立即失败并打印一些基本信息。代码如下:
#[derive(Clone)]
struct Trip {
dst: u64,
up: f64,
down: f64
}
const MAX_JERK: f64 = 0.2;
const MAX_ACCELERATION: f64 = 2.0;
const MAX_VELOCITY: f64 = 5.0;
fn main()
{
let simlog = File::open("simulation.log").expect("read simulation log");
let mut simlog = BufReader::new(&simlog);
let mut jerk = 0.0;
let mut prev_est: Option<ElevatorState> = None;
let mut dst_timing: Vec<Trip> = Vec::new();
let mut start_location = 0.0;
初始化分析状态后,我们将遍历日志中的行以计算统计数据:
let mut first_line = String::new();
let len = simlog.read_line(&mut first_line).unwrap();
let esp: ElevatorSpecification = serde_json::from_str(&first_line).unwrap();
for line in simlog.lines() {
let l = line.unwrap();
let (est, dst): (ElevatorState,u64) = serde_json::from_str(&l).unwrap();
let dl = dst_timing.len();
if dst_timing.len()==0 || dst_timing[dl-1].dst != dst {
dst_timing.push(Trip { dst:dst, up:0.0, down:0.0 });
}
if let Some(prev_est) = prev_est {
let dt = est.timestamp - prev_est.timestamp;
if est.velocity > 0.0 {
dst_timing[dl-1].up += dt;
} else {
dst_timing[dl-1].down += dt;
}
let da = (est.acceleration - prev_est.acceleration).abs();
jerk = (jerk * (1.0 - dt)) + (da * dt);
if jerk.abs() > 0.22 {
panic!("jerk is outside of acceptable limits: {} {:?}", jerk, est)
}
} else {
start_location = est.location;
}
if est.acceleration.abs() > 2.2 {
panic!("acceleration is outside of acceptable limits: {:?}", est)
}
if est.velocity.abs() > 5.5 {
panic!("velocity is outside of acceptable limits: {:?}", est)
}
prev_est = Some(est);
}
分析在处理文件时验证一些要求;其他要求必须在处理完整个日志后才能验证:
//elevator should not backup
let mut total_time = 0.0;
let mut total_direct = 0.0;
for trip in dst_timing.clone()
{
total_time += (trip.up + trip.down);
if trip.up > trip.down {
total_direct += trip.up;
} else {
total_direct += trip.down;
}
}
if (total_direct / total_time) < 0.9 {
panic!("elevator back up is too common: {}", total_direct / total_time)
}
//trips should finish within 20% of theoretical limit
let mut trip_start_location = start_location;
let mut theoretical_time = 0.0;
let floor_height = esp.floor_height;
for trip in dst_timing.clone()
{
let next_floor = (trip.dst as f64) * floor_height;
let d = (trip_start_location - next_floor).abs();
theoretical_time += (
2.0*(MAX_ACCELERATION / MAX_JERK) +
2.0*(MAX_JERK / MAX_ACCELERATION) +
d / MAX_VELOCITY
);
trip_start_location = next_floor;
}
if total_time > (theoretical_time * 1.2) {
panic!("elevator moves to slow {} {}", total_time, theoretical_time * 1.2)
}
println!("All simulation checks passing.");
}
运行模拟和分析数据
运行 SimpleMotorController 模拟后,我们收集初始模拟日志。由于方便的 SerDe 库,模拟日志将以 JSON 格式保存。对于模拟器的每次迭代,都应该有一个初始电梯规范,然后是一个电梯状态。simulation.log 最终看起来可能如下所示:
{"floor_count":5,"floor_height":5.67,"carriage_weight":120000.0}[{"timestamp":0.001288587,"location":0.0,"velocity":0.0,"acceleration":0.0,"motor_input":{"Up":{"voltage":147000.0}}},2][{"timestamp":0.002877568,"location":0.0,"velocity":0.0,"acceleration":0.0002577174000002458,"motor_input":{"Up":{"voltage":147003.86576100003}}},2][{"timestamp":0.004389254,"location":0.0,"velocity":3.8958778553677168e-7,"acceleration":0.000575513599999411,"motor_input":{"Up":{"voltage":147008.632704}}},2][{"timestamp":0.005886777,"location":5.834166693603828e-10,"velocity":0.0000012514326383486894,"acceleration":0.0008778508000002461,"motor_input":{"Up":{"voltage":147013.16776200004}}},2][{"timestamp":0.007377939,"location":2.449505465225691e-9,"velocity":0.0000025604503929786564,"acceleration":0.0011773553999994136,"motor_input":{"Up":{"voltage":147017.660331}}},2][{"timestamp":0.008929299,"location":6.421685786877059e-9,"velocity":0.000004386952466321746,"acceleration":0.0014755878000016765,"motor_input":{"Up":{"voltage":147022.13381700003}}},2]
此序列化输出是由我们的 SerDe 序列化库创建的。使用 SerDe 实现序列化的步骤有几个,这对了解复杂库的工作方式非常有信息量。为了使用 SerDe 进行 JSON 序列化和反序列化,我们必须执行以下操作:
- 按照以下方式将 SerDe 添加到
Cargo.toml依赖项中:
[dependencies]
serde = "1.0"
serde_json = "1.0"
serde_derive = "1.0"
- 将
macro_use指令和extern crate导入添加到项目根目录:
#[macro_use] extern crate serde_derive;
extern crate serde;
extern crate serde_json;
- 为将要序列化的数据推导
Serialize和Deserialize特性。为了在声明上进行宏操作以推导特性,使用derive指令。对于指令中的每个宏,都期望有一个相应的过程宏。考虑以下代码:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub enum MotorInput
{
Up { voltage: f64 },
Down { voltage: f64 }
}
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorSpecification
{
pub floor_count: u64,
pub floor_height: f64,
pub carriage_weight: f64
}
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorState
{
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: MotorInput
}
- 根据需要序列化数据。在
lib.rs中,我们序列化ElevatorSpecification和ElevatorState结构体。类型提示通常是必要的,因为类型系统不喜欢猜测:
serde_json::to_string(&datum).unwrap().as_bytes()
- 根据需要反序列化数据。在
analyze.rs中,我们将行反序列化到ElevatorSpecification和ElevatorState结构体中。类型提示通常是必要的,因为类型系统不喜欢猜测:
serde_json::from_str(&l).unwrap()
SerDe 支持许多内置类型以进行序列化和反序列化。这些大致对应于 JSON 允许的所有类型,通过类型提示允许额外的结构体。
查看 simulation.log,我们可以找到大多数内置类型:
- 整数类型:整数类型变为直接的 JSON 整数:
5
- 浮点类型:浮点整数变为直接的 JSON 浮点数:
6.54321
- 字符串:Rust 字符串也被直接翻译成 JSON 等效项:
"timestamp"
- 向量和数组:Rust 集合有时以意想不到的方式序列化。大多数情况下,向量类型被直接翻译成 JSON 数组;包含向量包含的序列化版本:
[1,2,3,4,5,6,0]
- 元组:元组被序列化为 JSON 数组,然而,编译器通常需要一个类型提示来理解如何序列化和反序列化这些类型:
[{"timestamp":0.007377939,"location":2.449505465225691e-9,"velocity":0.0000025604503929786564,"acceleration":0.0011773553999994136,"motor_input":{"Up":{"voltage":147017.660331}}},2][{"timestamp":0.008929299,"location":6.421685786877059e-9,"velocity":0.000004386952466321746,"acceleration":0.0014755878000016765,"motor_input":{"Up":{"voltage":147022.13381700003}}},2]
- 结构体:Rust 结构体直接转换为 JSON 对象。这总是成功的,因为 Rust 字段名是有效的对象键,如下所示:
{"floor_count":5,"floor_height":5.67,"carriage_weight":120000.0}
- 标签联合:标签联合是一个稍微有些奇怪的情况。
union构造函数被转换成与任何其他结构体一样的 JSON 对象。然而,union标签也被赋予了自己的结构体,将union构造函数包裹在一个单独的对象中。类型提示在这里对于编译器正确地序列化和反序列化是非常必要的:
{"Up":{"voltage":147003.86576100003}}
- HashMap:Rust 的 HashMap 在序列化方面是一个特殊案例。库尝试将它们转换为 JSON 对象。然而,并非所有 HashMap 键都可以序列化。因此,某些序列化可能会失败,需要自定义序列化器:
{"a":5,"b":6,"c":7}
一些类型难以序列化,包括时间结构,如 Instant。尽管处理某些数据类型存在困难,但 SerDe 库在存储和加载数据时非常稳定、快速且不可或缺。
运行分析程序,我们可以确认这个电机控制器不足以满足当前项目要求:
jerk is outside of acceptable limits: ElevatorState {
timestamp: 0.023739637,
location: 0,
velocity: 0,
acceleration: 1,
motor_input: Up { voltage: 162000 }
}
切换到 SmoothMotorController,我们可以看到所有规格都符合要求:
All simulation checks passing.
摘要
在本章中,我们概述了处理项目范围变更和新规格的步骤。我们专注于如何编写健壮的代码,这将鼓励在未来的额外项目或改进中重用。
使用各种数据结构有助于组织我们的项目和数据。代码应尽可能实现自文档化。此外,类型安全的代码可以强制一些关于代码的假设,以阻止错误的输入和不适当的用法。通过使用数据类,我们还学会了如何扩展现有的数据结构以支持新的用途。我们还使用数据类作为接口来推迟对项目元素不确定性的假设。
在下一章中,我们将学习参数化和泛型。我们将进行深入的代码审查和案例分析。
问题
-
哪个库适合序列化和反序列化数据?
-
在
physics.rs中结构声明前的哈希标签派生行有什么作用? -
在参数化声明中,先声明生命周期还是特质?
-
在
trait实现中,impl、trait或类型上的参数有什么区别? -
trait和数据类之间有什么区别? -
你应该如何声明一个包有多个可执行文件?
-
你如何声明一个结构体字段为私有?
第四章:泛型和多态性
参数化,也称为泛型或多态性,是继控制流和数据结构之后的第三大重要语言特性。参数化解决了早期语言中的复制粘贴问题。此功能允许遵循“不要重复自己”的良好程序设计原则。
在本章中,我们将探讨参数化如何帮助我们设计能够随变化而演化的稳健程序,而不是与变化作斗争。不会引入新的项目需求。本章将完全反思,查看项目当前的结构,如何改进,以及参数化如何具体帮助。
本章的学习成果如下:
-
理解泛化代数数据类型
-
理解参数化多态性
-
理解参数化生命周期
-
理解参数化特性
-
理解模糊方法解析
技术要求
运行提供的示例需要 Rust 的最近版本:
本章的代码也可在 GitHub 上找到:
每章的README.md文件中也包含了具体的安装和构建说明。
在空闲时间保持生产力
在客户就谈判和可能接受你的项目提案做出最终决定之前,将有一段时间。在这段时间里,你的管理层鼓励你利用这段时间回顾你的工作,并为将电梯控制器集成到真实电梯中做好准备。
你对直接电梯控制接口了解不多,客户特别提到可能有多个分包商设计不同的电梯。在这个阶段做出假设可能会导致浪费精力,因此,你决定重新考虑你的代码,寻找消除任何假设的机会。
特性接口的参数化和使用有助于实现这一抽象目标。在这段时间内,你决定让团队学习参数化,并考虑如何将其应用于改进本项目或后续项目。
了解泛型
泛型是一种编写适用于不同类型上下文的代码的设施,参数化允许程序员编写对涉及代码定义的数据结构和代码段做出较少假设的代码。例如,一个非常模糊的概念将是加法概念。当程序员编写a + b时,这意味着什么?在 Rust 中,可以为几乎任何类型实现Add特质。只要存在与a和b的类型兼容的Add特质的实现,则此特质将定义操作。在这种模式中,我们可以编写以最抽象的术语定义概念的泛型代码,允许稍后定义的数据和方法与该代码接口而无需更改。
完全泛型代码的一个主要例子是内置的容器数据结构。向量和 HashMap 必须必然知道它们存储的对象的类型。然而,如果对底层数据结构或存储项的方法有任何假设,这将非常限制性。因此,容器的参数化允许容器及其方法显式声明期望从存储类型中获得的特征界限。存储项的所有其他特征都将被参数化。
调查泛型
泛型是指参数化面向对象编程语言中的类。Rust 没有类的确切等效物。然而,如果从那种意义上使用,数据类型与特质的组合概念与类非常相似。因此,在 Rust 中,泛型将指数据类型和特质的参数化。
以面向对象编程(OOP)的常见示例,让我们看看动物王国。在以下代码中,我们将定义一些动物和它们可以执行的动作。首先,让我们定义两种动物:
struct Cat
{
weight: f64,
speed: f64
}
struct Dog
{
weight: f64,
speed: f64
}
现在,让我们定义一个动物特征及其实现。所有动物都将具有max_speed方法。以下是代码:
trait Animal
{
fn max_speed(&self) -> f64;
}
impl Animal for Cat
{
fn max_speed(&self) -> f64
{
self.speed
}
}
impl Animal for Dog
{
fn max_speed(&self) -> f64
{
self.speed
}
}
在这里,我们定义了 Rust 中面向对象编程(OOP)接口的等效物。然而,我们没有对任何东西进行参数化,因此这里不应该有任何泛型内容。我们将添加以下代码,一个定义动物追逐玩具概念的特质。首先,我们将定义玩具的概念。这将遵循与前面代码相同的面向对象模式:
struct SqueakyToy
{
weight: f64
}
struct Stick
{
weight: f64
}
trait Toy
{
fn weight(&self) -> f64;
}
impl Toy for SqueakyToy
{
fn weight(&self) -> f64
{
self.weight
}
}
impl Toy for Stick
{
fn weight(&self) -> f64
{
self.weight
}
}
现在,我们有两个特质,每个特质都有两种可能的实现。让我们定义一个动物追逐玩具的动作。已经定义了多个可能的动物和多个可能的玩具,因此我们需要使用泛型定义。结构定义还通过特质界限约束每个参数,这为struct添加了额外的信息;现在,我们可以保证每个动物都将实现Animal特质,同样,每个玩具也将实现Toy。我们还将定义一些使用参数化特质方法的关联逻辑。以下是代码:
struct AnimalChasingToy<A: Animal, T: Toy>
{
animal: A,
toy: T
}
trait AnimalChasesToy<A: Animal, T: Toy>
{
fn chase(&self);
}
impl<A: Animal, T: Toy> AnimalChasesToy<A, T> for AnimalChasingToy<A, T>
{
fn chase(&self)
{
println!("chase")
}
}
在这一点上,我们已经定义了一个通用的 struct 和 trait,它接受类型,只知道一些关于每个对象特质的有限信息。可以指定多个特质或没有任何特质来声明所有预期的接口。可以使用 'l + Trait1 + Trait2 语法来声明多个特质或生命周期界限。
探究参数多态
另一个参数化的常见应用是函数。出于我们想要参数化数据结构或特质的原因,我们也应该考虑函数的参数化。函数的参数化被称为参数多态。多态在希腊语中意为多种形式,有时在现代用法中,它也可以意味着多个箭头。这个词表明一个函数有多个实现或多个基类型签名。
对于一个参数化函数的简单示例,我们可以想象一个泛型乘以三的函数。以下是实现:
fn raise_by_three<T: Mul + Copy>(x: T) -> T
where T: std::ops::Mul<Output=T>
{
x * x * x
}
在这里,raise_by_three 函数不知道 Mul 做什么。Mul 是一个特质和抽象行为,它还指定了一个关联类型,Output。在这里无法泛型地提升 x.pow(3),因为 x 可能不是数值类型。至少,我们不知道 x 是浮点类型还是整型。因此,我们使用可用的 Mul 特质将 x 乘以三次。这看起来可能有些奇怪,但在上下文中这个概念会变得清晰。
首先,考虑应用于浮点型和整型。这种用法很简单,但似乎还没有什么用处。我们已经有了一个工作的 raise by three 表达式,只要我们知道并拥有原始的浮点型或整型。那么,我们为什么不直接使用内置的表达式呢?首先,让我们比较两种选项的代码:
raise_by_three(10);
(10 as u64).pow(3);
raise_by_three(3.0);
(3.0 as f64).powi(3);
第二种选择似乎更可取,而且确实是。然而,第二种选择也假设我们知道每个参数的 u64 或 f64 的完整类型。让我们看看如果我们擦除一些类型信息会发生什么:
#[derive(Copy,Clone)]
struct Raiseable<T: Mul + Copy>
{
x: T
}
impl<T: Mul + Copy> std::ops::Mul for Raiseable<T>
where T: std::ops::Mul<Output=T>
{
type Output = Raiseable<T>;
fn mul(self, rhs: Self) -> Self::Output
{
Raiseable { x: self.x * rhs.x }
}
}
let x = Raiseable { x: 10 as u64 };
raise_by_three(x);
//no method named pow
//x.pow(3);
let x = Raiseable { x: 3.0 as f64 };
raise_by_three(x);
//no method named powi
//x.powi(3);
在我们失去对底层类型的访问后,我们很快就会在可以执行的操作方面受到限制。泛型编程在长期减少工作量方面很出色;然而,它也要求非常明确地声明和实现所有使用的接口。在这里,你可以看到我们必须将 Copy 声明为特质界限,这意味着能够将变量从一个内存位置复制到另一个位置。另一个低级特质是 Sized,它表示数据在编译时有一个已知的常量大小。
如果我们查看 HashMap 的声明,我们可以看到为什么这种抽象通常是必要的:
impl<K: Hash + Eq, V> HashMap<K, V, RandomState>
每个哈希键必须实现 Hash 和 Eq,这意味着它必须是可哈希的且可比较的。除此之外,没有其他特质被期望,因此整个数据结构保持非常通用。
正如函数可以被参数化一样,作为参数的函数也可以被参数化。函数作为参数有两种一般形式——闭包和函数指针。函数指针不允许携带状态。闭包可以携带状态,但大小是独立于其声明的类型的。函数指针可以自动提升为闭包:
fn foo<X>(x: X) -> X
{
x
}
fn bar<X>(f: fn(X) -> X, x: X) -> X
{
f(x)
}
foo(1);
bar(foo,1);
闭包也可以以类似的方式参数化。这种情况更为常见。如果你在考虑是否使用函数指针或闭包,请使用闭包。函数指针总是可以被提升为闭包。此外,这段代码引入了 where 语法;where 子句允许以更可读的形式声明特质界限。以下是代码:
fn baz<X,F>(f: F, x: X) -> X
where F: Fn(X) -> X
{
f(x)
}
baz(|x| x, 1);
baz(foo, 1);
在这里,我们可以看到将函数指针包装成闭包是多么容易。闭包是一个很好的抽象,并且当正确使用时非常强大。
研究泛化代数数据类型
有时,我们希望类型系统携带比正常更多的信息。如果我们看看编译过程,类型位于程序代码和程序可执行文件之间。代码在编译前可以以文本文件的形式存在,或者像 Rust 宏那样操作的抽象语法树。程序可执行文件由所有 Rust 原始元素(如表达式、函数、数据类型、特质等)的组合结果组成。
正在中间,可以引入一个新概念,称为代数数据类型(ADTs)。技术上,ADTs 是 Rust 原始元素的扩展,尽管重要的是要注意 ADTs 使用了多少额外的类型信息。这种技术涉及将额外的类型信息保留到可执行文件中。额外的运行时决策是向动态类型迈进的一步,并放弃了静态编译可用的优化。结果是编程原语稍微低效,但也是一个可以描述其他情况下难以接近的概念的原语。
让我们看看一个例子——延迟计算。当我们描述不同值和表达式的关联时,我们通常只是直接将这段代码写入程序中。然而,如果我们想将代码步骤与执行步骤分开,我们会怎么做?为了实现这一点,我们开始构建一个称为领域特定语言的东西。
以一个具体的例子来说,假设你正在构建一个用于 JavaScript 的 JIT(动态编译)解释器。Mozilla 项目有几个项目是专门针对用 Rust 构建的 JS 引擎的(blog.mozilla.org/javascript/2017/10/20/holyjit-a-new-hope/)。这是一个非常适合 Rust 的实际应用。要在 JIT 编译的解释器中使用 ADT,我们希望得到两件事:
-
在解释器中直接评估 ADT 表达式
-
如果选择了编译 ADT 表达式
因此,我们的 JavaScript 表达式中的任何部分都可以在任何时候被解释或编译。如果一个表达式被编译,那么我们希望所有后续的评估都使用编译版本。实现这一点的关键是给类型系统增加一些额外的权重。这些重型类型定义是 ADT 概念的精髓。以下是一个使用 ADT 定义 JavaScript 非常小子集的示例:
struct JSJIT(u64);
enum JSJITorExpr {
Jit { label: Box<JSJIT> },
Expr { expr: Box<JSExpr> }
}
enum JSExpr {
Integer { value: u64 },
String { value: String },
OperatorAdd { lexpr: Box<JSJITorExpr>, rexpr: Box<JSJITorExpr> },
OperatorMul { lexpr: Box<JSJITorExpr>, rexpr: Box<JSJITorExpr> }
}
在这里,我们可以看到每个中间表达式都有足够的信息来评估,同时也有足够的信息来编译。我们本可以将Add或Mul运算符包装在闭包中,但这将不允许 JIT 优化。我们需要在这里保持完整的表示,以便允许 JIT 编译。此外,请注意程序在决定评估表达式或调用编译代码之间的间接引用。
下一步是为每种表达式形式实施一个评估程序。我们可以将其分解为特性,或者定义一个更大的函数作为评估。为了保持函数式风格,我们将定义一个单一函数。为了评估一个表达式,我们将使用对JSJITorExpr表达式的模式匹配。这种即时编译表达式可以分解为通过调用jump函数运行的代码地址,或者必须动态评估的表达式。这种模式为我们提供了两者的最佳结合,将编译代码和解释代码混合在一起。代码如下:
fn jump(l: JSJIT) -> JSJITorExpr
{
//jump to compiled code
//this depends on implementation
//so we will just leave this as a stub
JSJITorExpr::Jit { label: JSJIT(0) }
}
fn eval(e: JSJITorExpr) -> JSJITorExpr
{
match e
{
JSJITorExpr::Jit { label: label } => jump(label),
JSJITorExpr::Expr { expr: expr } => {
let rawexpr = *expr;
match rawexpr
{
JSExpr::Integer {..} => JSJITorExpr::Expr { expr: Box::new(rawexpr) },
JSExpr::String {..} => JSJITorExpr::Expr { expr: Box::new(rawexpr) },
JSExpr::OperatorAdd { lexpr: l, rexpr: r } => {
let l = eval(*l);
let r = eval(*r);
//call add op codes for possible l,r representations
//should return wrapped value from above
JSJITorExpr::Jit { label: JSJIT(0) }
}
JSExpr::OperatorMul { lexpr: l, rexpr: r } => {
let l = eval(*l);
let r = eval(*r);
//call mul op codes for possible l,r representations
//should return wrapped value from above
JSJITorExpr::Jit { label: JSJIT(0) }
}
}
}
}
}
ADT 概念的另一个例子是异构列表。异构列表不像其他泛型容器,如向量。Rust 向量是同质的,意味着所有项目都必须具有相同的类型。相比之下,异构列表可以包含任何类型的元素混合。这听起来可能像元组,但元组具有固定的长度和平坦的类型签名。同样,异构列表必须具有在编译时已知长度和类型签名,但这种知识可以逐步实现。异构列表允许在部分了解列表类型的情况下工作,参数化它们不需要的知识。
这里是一个异构列表的示例实现:
pub trait HList: Sized {}
pub struct HNil;
impl HList for HNil {}
pub struct HCons<H, T> {
pub head: H,
pub tail: T,
}
impl<H, T: HList> HList for HCons<H, T> {}
impl<H, T> HCons<H, T> {
pub fn pop(self) -> (H, T) {
(self.head, self.tail)
}
}
注意这个定义故意使用特性来隐藏类型信息,没有这些信息,这样的定义将是不可能的。一个HList的声明看起来如下:
let hl = HCons {
head: 2,
tail: HCons {
head: "abcd".to_string(),
tail: HNil
}
};
let (h1,t1) = hl.pop();
let (h2,t2) = t1.pop();
//this would fail
//HNil has no .pop method
//t2.pop();
Rust 在类型检查方面有时可能有点僵化。然而,也有许多解决方案允许复杂的行为,这些行为一开始可能看起来是不可能的。
调查参数化生命周期
生命周期可能会很快变得复杂。例如,当生命周期用作参数时,它被称为参数化生命周期。为了涵盖最常见的问题,我们将生命周期概念分解为四个不同的概念:
-
在基础类型上的生命周期
-
泛型类型上的生命周期
-
特性上的生命周期
-
生命周期子类型
在基础类型上定义生命周期
基础类型是一个没有参数的类型。在基础类型上定义生命周期是最简单的情况。所有特质、字段、大小以及任何其他信息对于组类型都是直接可用的。
这里是一个在基础类型上声明生命周期的函数:
fn ground_lifetime<'a>(x: &'a u64) -> &'a u64
{
x
}
let x = 3;
ground_lifetime(&x);
声明生命周期通常是不必要的。有时,声明生命周期是必要的。推断规则很复杂,有时还会扩展,所以我们现在暂时忽略那部分。
在泛型类型上定义生命周期
在泛型类型上声明生命周期需要考虑一个额外的因素。所有具有指定生命周期的泛型类型都必须参数化为具有该生命周期。参数声明必须与参数的使用方式兼容。
这里有一个会失败的示例:
struct Ref<'a, T>(&'a T);
结构定义使用了具有生命周期'a的参数T;然而,参数T并不需要与'a具有兼容的生命周期。参数T必须由其自己的生命周期约束。这样做后,代码如下:
struct Ref<'a, T: 'a>(&'a T);
现在,参数T具有与'a兼容的显式约束,代码将能够编译。
在特质上定义生命周期
在定义、实现和实例化实现特质的对象时,对象和特质都可能需要生命周期。通常,可以从对象的生命周期推断出特质的生命周期。当这不可能时,程序员必须声明一个与所有其他约束兼容的特质生命周期。代码如下:
trait Red { }
struct Ball<'a> {
diameter: &'a i32,
}
impl<'a> Red for Ball<'a> { }
static num: i32 = 5;
let obj = Box::new(Ball { diameter: &num }) as Box<Red + 'static>;
定义生命周期子类型
有可能存在一个对象,它本身需要一个较长的生命周期,但同时也需要某些组件或方法具有较短的生命周期。这可以通过参数化多个生命周期来实现。这通常工作得很好,除非生命周期发生冲突。以下是一个多个生命周期的示例:
struct Context<'s>(&'s mut String);
impl<'s> Context<'s>
{
fn mutate<'c>(&mut self, cs: &'c mut String) -> &'c mut String
{
let swap_a = self.0.pop().unwrap();
let swap_b = cs.pop().unwrap();
self.0.push(swap_b);
cs.push(swap_a);
cs
}
}
fn main() {
let mut s = "outside string context abc".to_string();
{
//temporary context
let mut c = Context(&mut s);
{
//further temporary context
let mut s2 = "inside string context def".to_string();
c.mutate(&mut s2);
println!("s2 {}", s2);
}
}
println!("s {}", s);
}
调查参数化类型
在这一点上,了解到所有数据类型声明都可以参数化并不令人惊讶。需要注意的是,在声明参数化数据类型时,生命周期参数必须位于泛型参数之前。以下代码展示了这一点:
type TFoo<'a, A: 'a> = (&'a A, u64);
struct SFoo<'a, A: 'a>(&'a A);
struct SBar<'a, A: 'a>
{
x: &'a A
}
enum EFoo<'a, A: 'a>
{
X { x: &'a A },
Y { y: &'a A },
}
我们也看到了特质如何进行参数化。然而,当一个数据类型和特质都需要参数来实现时会发生什么?这有一个特殊的语法,涉及三个参数列表,看起来如下:
struct SBaz<'a, 'b, A: 'a, B: 'b>
{
a: &'a A,
b: &'b B,
}
trait TBaz<'a, 'b, A: 'a, B: 'b>
{
fn baz(&self);
}
impl<'a, 'b, A: 'a, B: 'b>
TBaz<'a, 'b, A, B>
for SBaz<'a, 'b, A, B>
{
fn baz(&self){}
}
我们还应提及一个特殊情况,那就是方法歧义的情况。当一个类型实现了多个特质时,可能存在多个同名的方法。为了访问不同的方法,在调用时必须指定要使用哪个trait。以下是一个示例:
trait Foo {
fn f(&self);
}
trait Bar {
fn f(&self);
}
struct Baz;
impl Foo for Baz {
fn f(&self) { println!("Baz’s impl of Foo"); }
}
impl Bar for Baz {
fn f(&self) { println!("Baz’s impl of Bar"); }
}
let b = Baz;
要调用方法,我们必须使用称为通用函数调用语法的东西。该语法有两种形式,一种较短,另一种较长。短形式通常足以解决所有但最复杂的情况。以下是一个与前面的类型定义相匹配的示例:
Foo::f(&b);
Bar::f(&b);
<Baz as Foo>::f(&b);
<Baz as Bar>::f(&b);
此外,还有一些不太文档化的语法形式(matematikaadit.github.io/posts/rust-turbofish.html)适用于需要显式提供参数的各种场景。目前 Rust 没有直接的类型注解,因此编译器需要时提供提示。
应用参数化概念
我们已经探讨了泛型和参数化的概念。让我们扫描一下项目,看看是否有任何概念适合使用。
参数化数据
参数化数据允许我们仅声明所需的最小语义信息量。我们不是指定一个类型,而是指定一个具有特质的泛型参数。让我们首先查看physics.rs中的类型声明:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub enum MotorInput
{
Up { voltage: f64 },
Down { voltage: f64 }
}
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorSpecification
{
pub floor_count: u64,
pub floor_height: f64,
pub carriage_weight: f64
}
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorState
{
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: MotorInput
}
pub type FloorRequests = Vec<u64>;
如果我们记得,当我们设计新的MotorInput实现时使用了physics.rs,我们应该注意到一个问题。我们希望将MotorInput的行为抽象在特质之后;然而,ElevatorState指定了一个特定的实现。让我们重新定义ElevatorState以使用motor_input的泛型类型。该参数应该实现MotorInput的所有特质,因此将如下所示:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub struct ElevatorState<MI: MotorForce + MotorVoltage + Clone, 'a serde::Serialize, 'a serde::Deserialize + Debug>
{
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: MI
}
这乍一看可能看起来可以接受,但现在MotorInput参数和所有特质都必须在提及任何包装MotorInput或ElevatorState类型的任何地方声明。我们得到了参数的爆炸。必须有一种更好的方法。
在这种情况下,参数爆炸看起来如下,在每个类型声明、特质声明、实现、函数或表达式中:
pub trait MotorController
<MI: MotorForce + MotorVoltage + Clone, 'a serde::Serialize, 'a serde::Deserialize + Debug>
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState<MI>);
fn poll(&mut self, est: ElevatorState<MI>, dst: u64) -> MI;
}
pub trait DataRecorder
<MI: MotorForce + MotorVoltage + Clone, 'a serde::Serialize, 'a serde::Deserialize + Debug>
{
fn init(&mut self, esp: ElevatorSpecification, est: ElevatorState<MI>);
fn poll(&mut self, est: ElevatorState<MI>, dst: u64);
}
impl MotorController
<MI: MotorForce + MotorVoltage + Clone, 'a serde::Serialize, 'a serde::Deserialize + Debug>
for SimpleMotorController
<MI: MotorForce + MotorVoltage + Clone, 'a serde::Serialize, 'a serde::Deserialize + Debug>
{
...
}
这只是针对一个参数!幸运的是,还有另一个解决这个问题的方法。该技术使用一种称为特质对象的东西。特质对象是一个实现了特质的对象,但在编译时没有已知类型。由于特质对象没有具体类型,因此不需要参数化。特质对象的缺点是它们不能被大小化,因此通常必须通过 Box 或其他大小容器间接处理。任何尝试大小化特质对象的行为都会导致编译器错误。同样,任何具有静态方法或不是对象安全的特质都不能与特质对象一起使用。
我们可以将MotorInput和ElevatorState对象重写为使用特质对象,如下所示:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub enum SimpleMotorInput
{
Up { voltage: f64 },
Down { voltage: f64 }
}
pub trait MotorInput: MotorForce + MotorVoltage
{
}
impl MotorInput for SimpleMotorInput {}
pub struct ElevatorState
{
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: Box<MotorInput>
}
在这里,我们声明一个MotorInput特质有两个子特质来指定行为。我们的ElevatorState声明不需要参数;然而,MotorInput特质对象必须被包裹在一个Box中。由于编译器无法为MotorInput特质对象的大小进行编译,这一层间接性是必需的。此外,因为MotorInput没有实现Sized,它不能使用Clone或serde宏。我们需要对一些代码进行修改以适应这一点,但这并不令人难以承受。
参数化函数和特质对象
在我们的电机控制器中,我们对电机做出了另一个无根据的假设。即,每个电压输入将产生一个平坦的力。电机控制器中的可疑代码如下所示:
let target_voltage = target_force / 8.0;
关于电机比预期更有效率或更无效率的假设可能是错误的。同样,生成的力与电压成线性关系的假设也不太可能。为了满足我们的电机控制器和物理模拟的要求,我们需要一个函数来考虑所使用的物理电机,并将电压转换为力。同样,我们还需要一个逆函数将目标力转换为目标电压。我们可以简单地按以下方式编写这些函数:
pub fn force_of_voltage(v: f64) -> f64
{
8.0 * v
}
pub fn voltage_of_force(v: f64) -> f64
{
v / 8.0
}
这看起来很棒,但它并不符合抽象物理电机概念的目标。我们应该将这些函数定义为接口上的方法。这样,我们就可以再次使用特质对象模式来抽象电机的类型,以及电机的类型参数。代码如下:
pub trait Motor
{
fn force_of_voltage(&self, v: f64) -> f64;
fn voltage_of_force(&self, v: f64) -> f64;
}
pub struct SimpleMotor;
impl Motor for SimpleMotor
{
fn force_of_voltage(&self, v: f64) -> f64
{
8.0 * v
}
fn voltage_of_force(&self, v: f64) -> f64
{
v / 8.0
}
}
在声明了Motor特质及其实现之后,我们可以将这个定义与ElevatorSpecification结构体集成。结果是如下所示:
pub struct ElevatorSpecification
{
pub floor_count: u64,
pub floor_height: f64,
pub carriage_weight: f64,
pub motor: Box<Motor>
}
再次,我们失去了使用某些derive宏的能力,但至少类型签名要干净得多。现在,电机控制器中的使用支持多个电机:
let target_voltage = self.esp.motor.voltage_of_force(target_force);
我们可以看到,在参数化或泛型行为的不同类型之间可能存在一些潜在的权衡。一方面,参数可能会很快变得难以跟踪。另一方面,特质对象破坏了许多具有诸如derive宏、非对象安全特性、需要具体类型等功能的语言。选择正确的工具是一个重要的决定,需要权衡每个选项的优点。
参数化特质和实现
现在,我们已经成功地将Motor和MotorInput实现为特质对象。然而,为了实现这一点,我们牺牲了诸如Clone、Serialize、Deserialize和Debug等美好的特性。我们能否恢复这些功能?
首先,让我们尝试复制这些功能。我们将把这些捆绑的特质称为ElevatorStateClone和ElevatorSpecificationClone。签名可能看起来如下(特质实现可以在src/physics.rs文件中找到):
pub trait ElevatorStateClone
{
fn clone(&self) -> ElevatorState;
fn dump(&self) -> (f64,f64,f64,f64,f64);
fn load((f64,f64,f64,f64,f64)) -> ElevatorState;
}
pub trait ElevatorSpecificationClone
{
fn clone(&self) -> ElevatorSpecification;
fn dump(&self) -> (u64,f64,f64,u64);
fn load((u64,f64,f64,u64)) -> ElevatorSpecification;
}
impl ElevatorStateClone for ElevatorState {
...
}
这些特质提供了最基本的功能,让我们能够回到之前使用序列化和复制语义的地方。主要的缺点是每个定义都很冗长。此外,序列化变成了一个元组,而不是直接在正确的类型之间来回转换。
那么,特质对象究竟有什么问题呢?我们知道它们必须被Box类型包裹以规避未知的大小。这是问题所在吗?这里有一个程序来测试这个理论:
#[derive(Serialize,Deserialize)]
struct Foo
{
bar: Box<u64>
}
所以,Box类型可以序列化。那么,问题肯定出在特质对象上。让我们用特质对象来做同样的尝试,看看会发生什么:
trait T {}
#[derive(Serialize,Deserialize)]
struct S1;
impl T for S1 {}
#[derive(Serialize,Deserialize)]
struct S2;
impl T for S2 {}
#[derive(Serialize,Deserialize)]
struct Container
{
field: Box<T>
}
当编译这个最后的片段时,我们得到错误:“为T没有实现serde::Deserialize<'_>特质”。因此,我们可以看到单独的结构体S1和S2都实现了Deserialize,但这个信息被隐藏了。特质对象T本身必须实现Deserialize。
在尝试序列化特质对象T的第一步中,我们可以遵循编写自定义序列化的说明。结果应该是以下类似的内容:
impl Serialize for Box<T>
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
serializer.serialize_unit_struct("S1")
}
}
struct S1Visitor;
impl<'de> Visitor<'de> for S1Visitor {
type Value = Box<T>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result
{
formatter.write_str("an S1 structure")
}
fn visit_unit<E>(self) -> Result<Self::Value, E>
where E: de::Error
{
Result::Ok(Box::new(S1))
}
}
impl<'de> Deserialize<'de> for Box<T> {
fn deserialize<D>(deserializer: D) -> Result<Box<T>, D::Error>
where D: Deserializer<'de>
{
deserializer.deserialize_unit_struct("S1", S1Visitor)
}
}
let bt: Box<T> = Box::new(S1);
let s = serde_json::to_string(&bt).unwrap();
let bt: Box<T> = serde_json::from_str(s.as_str()).unwrap();
这有点乱,但重要的是我们想要将S1或S2写入序列化器,并检查那些标签以反序列化。本质上,我们试图创建一个仅用于序列化的辅助枚举。某种方式下,序列化器需要知道T是S1还是S2通过接口,所以为什么不反过来提供一个返回枚举的方法呢?枚举也可以通过宏进行序列化,所以我们可以将自动序列化传递给T。让我们尝试一下,从类型和特质定义开始,如下所示:
#[derive(Clone,Serialize,Deserialize)]
enum T_Enum
{
S1(S1),
S2(S2),
}
trait T
{
fn as_enum(&self) -> T_Enum;
}
#[derive(Clone,Serialize,Deserialize)]
struct S1;
impl T for S1
{
fn as_enum(&self) -> T_Enum
{
T_Enum::S1(self.clone())
}
}
#[derive(Clone,Serialize,Deserialize)]
struct S2;
impl T for S2
{
fn as_enum(&self) -> T_Enum
{
T_Enum::S2(self.clone())
}
}
在这里,我们可以看到允许在特质对象上有一个将对象转换为枚举的方法没有问题。这种关系是自然的,并提供了一个逃生门,可以在特质对象及其内部表示之间来回转换。现在,为了实现序列化,我们只需要包装和展开枚举序列化器:
impl Serialize for Box<T>
{
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where S: Serializer
{
self.as_enum().serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Box<T>
{
fn deserialize<D>(deserializer: D) -> Result<Box<T>, D::Error>
where D: Deserializer<'de>
{
let result = T_Enum::deserialize(deserializer);
match result
{
Result::Ok(te) => {
match te {
T_Enum::S1(s1) => Result::Ok(Box::new(s1.clone())),
T_Enum::S2(s2) => Result::Ok(Box::new(s2.clone()))
}
}
Result::Err(err) => Result::Err(err)
}
}
}
那并没有那么糟糕,对吧?使用这项技术,我们可以在保持对数据直接访问和宏推导特质优势的同时,将参数隐藏在特质对象后面。这里有一点点样板代码。幸运的是,对于每个宏,无论你使用什么类型,代码几乎都是相同的。记住这一点;它可能很有用。
摘要
在本章中,我们探讨了泛型和参数化编程的基本和深入概念。我们学习了如何向类型、特质、函数和实现声明中添加生命周期、类型和特质参数。我们还考察了根据需要选择性地保留或隐藏类型信息的高级技术。
将这些概念应用于电梯模拟,我们观察到了参数化和泛型如何创建完全抽象的接口。通过使用特质对象,可以完全将特质接口与任何实现分离。我们还观察到了参数化和泛型的缺点或困难。过度使用参数化可能导致参数泄漏,可能需要与接口交互的所有代码本身也变得参数化。另一方面,我们观察到使用特质对象擦除类型信息时的困难。选择保留正确数量的信息很重要。
在下一章中,我们将学习具有复杂要求的实际项目结构。客户将对项目提案做出回应,而您的团队将对新的需求做出回应。
问题
-
什么是代数数据类型?
-
什么是多态性?
-
什么是参数多态性?
-
什么是基类型?
-
什么是通用函数调用语法?
-
特质对象的可能类型签名有哪些?
-
有哪两种方法可以隐藏类型信息?
-
如何声明子特质?
第五章:代码组织和应用架构
之前,我们概述了一些项目规划和代码架构的基本概念。我们推荐的策略特别强调在将需求适应为伪代码、存根代码和最终项目之前,先收集和列出需求。这个过程对大型项目仍然非常适用,但我们还没有涵盖文件和模块组织方面。代码应该如何分组到文件和模块中?
为了回答这个问题,我们推荐一种称为工作坊模式的方法。想象一个物理工作坊,里面有挂板、架子、罐子、工具箱和地板上的大型设备。当谈到代码架构时,专家们经常讨论不同的组织策略。代码可以按类型、目的、项目层或便利性进行分组。有无限的可能策略,这里只列举了四种常见的策略。虽然我们不建议选择任何一种特定的策略,但它们都没有错。螺母和螺栓可以按类型放入罐子中。手工具可以放在工具箱中(按目的)。大型工具可以放在地板上(按项目层)。常用工具可以挂在挂板上(按便利性)。这些策略都不是无效的,并且都可以在同一个工作坊(项目)中使用。
在本章中,我们将随着项目的增长而重新组织项目。我们将结合之前介绍的计划和架构原则,以及新的代码组织概念,来开发一个可导航且可维护的大型软件项目。
本章的学习成果如下:
-
通过类型组织识别和应用
-
通过目的组织识别和应用
-
通过分层组织识别和应用
-
通过便利性组织识别和应用
-
在项目重组过程中最小化代码浪费
技术要求
运行提供的示例需要 Rust 的最近版本:
本章的代码也可在 GitHub 上找到:
每章的README.md文件中也包含了具体的安装和构建说明。
在不牺牲质量的情况下交付产品
客户已经与你的销售团队完成了谈判——你赢得了合同。现在合同已经签署,你的团队的任务是将模拟提升到规格,以便运行所有电梯系统。客户为每个三个建筑、电梯、电机控制和制动系统提供了规格。你还了解到电梯电机具有智能电机控制软件,该软件可以动态调节内部电压和电流。为了控制电机,你只需提供所需的力输出。完整的规格如下:
-
对于建筑 1,有以下几点:
-
楼层高度: 8m, 4m, 4m, 4m, 4m
-
电梯重量: 1,200 kg
-
电梯电机: 最大 50,000 N
-
电梯驱动器: 提供软件接口
-
-
对于建筑 2,有以下几点:
-
楼层高度: 5m, 5m, 5m, 5m, 5m, 5m, 5m, 5m
-
电梯重量: 1,350 kg
-
电梯电机: 最大 1,00,000 N
-
电梯驱动器: 提供软件接口
-
-
对于建筑 3,有以下几点:
-
楼层高度: 6m, 4m, 4m, 4m
-
电梯重量: 1,400 kg
-
电梯电机: 最大 90,000 N
-
电梯驱动器: 提供软件接口
-
程序现在需要以操作模式运行,接受并添加新的楼层请求到队列中。模拟也应该继续运行,现在包含所有三个建筑规范。模拟应该验证承诺的性能和质量指标是否都满足。除此之外,你的团队可以自由地按照你的想法开发项目。
你决定现在是重新思考项目组织的好时机,需要重大的新变化。使用良好的架构和项目组织实践,你将相应地移动代码,以便有序且方便地分组组件。
重新组织项目
既然我们已经对良好的项目架构有了些想法,让我们规划项目的重组。让我们列出可能的研讨会组织方法:
-
按类型
-
按用途
-
按层
-
按便利性
应该使用按类型组织来处理车间螺母和螺栓类型的组件。螺母和螺栓是高度统一的组件,具有不同的直径、长度、等级等。我们这里也有一些很好的匹配,让我们列出可以按这种方式分组的对象和接口:
-
电机
-
建筑物
-
电梯控制器/驱动器
应该使用按用途组织来处理具有共同目的的杂项工具。我们也有一些适合这种组织风格的优秀候选者:
-
运输计划(静态/动态)
-
电梯的物理接口
应该使用按层组织来处理适合正常程序逻辑的独立建筑组件。一个例子是我们的物理层,它在逻辑上独立于其他模块。物理层仅用于存储常数、公式和建模过程。在这里,我们按层分组:
- 物理建模
应使用方便组织来组织常见或难以组织的组件。可执行文件非常适合这种类型的组织,因为它们始终是终点,而不是库,并且通常不适合其他任何组织:
-
模拟可执行文件
-
分析可执行文件
-
物理电梯驱动程序可执行文件
根据类型规划文件内容
这些文件将使用按类型方法进行组织。
组织 motor_controllers.rs 模块
所有电机将在 motor_controller.rs 模块中按类型分组。将有三种具有不同特性的电机。该模块应提供对所有电机以及每个实现的特质接口。特质应定义一个方法,从所需的力输出生成电机输入,以及一个方法来接受电机输入以生成力。该模块还必须链接到每个电机控制器的二进制驱动程序。旧的电机控制器逻辑将移动到一个名为 motion_controllers.rs 的新文件中,以动态控制电梯电机。以下内容应在该模块中定义:
-
电机输入特质
-
电机控制器特质
-
电机输入 1 实现
-
电机控制器 1 实现
-
电机输入 2 实现
-
电机控制器 2 实现
-
电机输入 3 实现
-
电机控制器 3 实现
组织 buildings.rs 模块
所有建筑规范将在 building.rs 模块中按类型分组。将有三种建筑规范。建筑物应封装电梯行为和控制的所有方面,以及建筑本身的规范。该模块应包含以下内容:
-
建筑特质
-
建筑物 1 实现
-
建筑物 2 实现
-
建筑物 3 实现
根据目的规划文件内容
这些文件将使用按目的方法进行组织。
组织 motion_controllers.rs 模块
运动控制器将根据目的进行组织。运动控制器将负责跟踪电梯状态以控制电机的动态。运动控制器模块应包含以下内容:
-
运动控制器特质
-
平滑运动控制器实现
组织 trip_planning.rs 模块
行程规划将根据目的进行组织。规划器应工作在两种模式下:静态和动态。对于静态模式,规划器应接受要处理的楼层请求列表。对于动态模式,规划器应动态接受楼层请求并将它们添加到队列中。规划器模块应包含以下内容:
-
规划器特质
-
静态规划器实现
-
动态规划器实现
组织 elevator_drivers.rs 模块
所有电梯驱动器将在elevator_driver.rs模块中按目的组织。有三个电梯驱动器提供二进制接口以进行链接。elevator driver模块应包含一个 trait,用于定义电梯驱动器的接口以及三个实现。planner模块应包含以下内容:
-
电梯驱动器 trait
-
电梯司机 1 实现
-
电梯司机 2 实现
-
电梯司机 3 实现
按层规划文件内容
这些文件将使用“分层”方法进行组织。
组织 physics.rs 模块
physics模块将按层组织所有与物理相关的代码。虽然这里会有一些杂项代码,但它们都应该以某种模拟或预测的形式存在。该模块应包含以下内容:
-
单位转换
-
公式实现
-
任何其他用于电梯模拟或操作所需的逻辑
-
物理模拟循环
组织 data_recorder.rs 模块
数据记录器模块将DataRecorder trait 和实现移动到其自己的模块。它应包含以下内容:
-
DataRecordertrait -
简单数据记录器实现
按便利性规划文件内容
这些文件将使用“便利性”方法进行组织。
组织 simulate_trip.rs 可执行文件
simulate_trip.rs可执行文件将根据便利性进行组织。行程模拟可执行文件的范围没有发生显著变化。此文件应包含以下内容:
-
参数和输入解析
-
数据记录器定义
-
模拟设置
-
运行模拟
组织 analyze_trip.rs 可执行文件
analyze_trip.rs可执行文件将根据便利性进行组织。分析行程可执行文件的范围没有发生显著变化。此文件应包含以下内容:
-
参数和输入解析
-
检查规格以确定接受或拒绝
组织 operate_elevator.rs 可执行文件
operate_elevator.rs可执行文件将根据便利性进行组织。操作电梯可执行文件应与模拟电梯可执行文件的逻辑非常相似。此文件应包含以下内容:
-
参数和输入解析
-
设置电梯驱动器以匹配指定的建筑规范
-
使用动态规划运行电梯
映射代码更改和添加
现在我们已经将概念、数据结构和逻辑组织到文件中,我们可以继续进行将需求转换为代码的正常流程。对于每个模块,我们将查看所需元素并生成代码以满足这些需求。
在这里,我们通过模块分解所有代码开发步骤。不同的模块有不同的组织结构,因此请注意有关组织和代码开发的模式。
按类型开发代码
这些文件将使用“按类型”方法进行组织。
编写 motor_controllers.rs 模块
新的 motor_controller 模块作为所有链接的电机驱动器和它们的接口的适配器,并提供一个单一的统一接口。让我们看看它是如何实现的:
- 首先,让我们将软件提供的所有驱动器链接到我们的程序中:
use libc::c_int;
#[link(name = "motor1")]
extern {
pub fn motor1_adjust_motor(target_force: c_int) -> c_int;
}
#[link(name = "motor2")]
extern {
pub fn motor2_adjust_motor(target_force: c_int) -> c_int;
}
#[link(name = "motor3")]
extern {
pub fn motor3_adjust_motor(target_force: c_int) -> c_int;
}
这一部分告诉我们的程序链接到名为 libmotor1.a、libmotor2.a 和 libmotor3.a 等的静态编译库。我们的示例章节还包含了这些库的源代码和构建脚本,因此您可以检查每个库。在一个完整的项目中,有许多方法可以链接到外部二进制库,这仅仅是许多选项之一。
- 接下来,我们应该为
MotorInput创建一个特质,并为每个电机创建一个泛型MotorDriver接口,包括每个电机的实现。代码如下:
#[derive(Clone,Serialize,Deserialize,Debug)]
pub enum MotorInput
{
Motor1 { target_force: f64 },
Motor2 { target_force: f64 },
Motor3 { target_force: f64 },
}
pub trait MotorDriver
{
fn adjust_motor(&self, input: MotorInput);
}
struct Motor1;
impl MotorDriver for Motor1 { ... }
//Motor 2
//Motor 3
- 接下来,我们应该实现电机控制器特质及其实现。电机控制器应该将电机信息和驱动器封装成一个统一的接口。这里的
MotorDriver和MotorController特质被强制转换为简单的上下力模型。因此,驱动器和控制器之间的关系是一对一,不能完全抽象成一个通用特质。相应的代码如下:
pub trait MotorController
{
fn adjust_motor(&self, f: f64);
fn max_force(&self) -> f64;
}
pub struct MotorController1
{
motor: Motor1
}
impl MotorController for MotorController1 { ... }
//Motor Controller 2 ...
//Motor Controller 3 ...
这些模块的完整代码可以在 GitHub 仓库中找到:github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST.
编写 buildings.rs 模块
构建模块再次按类型分组。应该有一个通用的特质接口,由三个建筑实现。建筑特质和结构应该额外封装并暴露给适当的电梯驱动器和电机控制器。代码如下:
- 首先,我们定义
Building特质:
pub trait Building
{
fn get_elevator_driver(&self) -> Box<ElevatorDriver>;
fn get_motor_controller(&self) -> Box<MotorController>;
fn get_floor_heights(&self) -> Vec<f64>;
fn get_carriage_weight(&self) -> f64;
fn clone(&self) -> Box<Building>;
fn serialize(&self) -> u64;
}
- 然后,我们定义一个
deserialize辅助函数:
pub fn deserialize(n: u64) -> Box<Building>
{
if n==1 {
Box::new(Building1)
} else if n==2 {
Box::new(Building2)
} else {
Box::new(Building3)
}
}
- 然后,我们定义一些杂项辅助函数:
pub fn getCarriageFloor(floorHeights: Vec<f64>, height: f64) -> u64
{
let mut c = 0.0;
for (fi, fht) in floorHeights.iter().enumerate() {
c += fht;
if height <= c {
return (fi as u64)
}
}
(floorHeights.len()-1) as u64
}
pub fn getCumulativeFloorHeight(heights: Vec<f64>, floor: u64) -> f64
{
heights.iter().take(floor as usize).sum()
}
- 最后,我们定义建筑及其特质实现:
pub struct Building1;
impl Building for Building1 { ... }
//Building 2
//Building 3
按目的开发代码
这些文件将使用按目的方法进行组织。
编写 motion_controllers.rs 模块
从 motor_controllers.rs 中来的旧逻辑,用于动态调整电机力,将被移动到这个模块。SmoothMotionController 没有太大变化,代码如下:
pub trait MotionController
{
fn init(&mut self, esp: Box<Building>, est: ElevatorState);
fn adjust(&mut self, est: &ElevatorState, dst: u64) -> f64;
}
pub struct SmoothMotionController
{
pub esp: Box<Building>,
pub timestamp: f64
}
impl MotionController for SmoothMotionController
{
...
}
编写 trip_planning.rs 模块
行程规划器应在静态和动态模式下工作。基本结构是一个 FIFO 队列,将请求推入队列,并弹出最旧的元素。我们可能能够将静态和动态模式统一到一个实现中,其外观如下。
行程规划将按目的进行组织。规划器应在两种模式下工作——静态和动态。对于静态模式,规划器应接受一个楼层请求列表进行处理。对于动态模式,规划器应动态接受楼层请求并将它们添加到队列中。规划器模块应包含以下内容:
use std::collections::VecDeque;
pub struct FloorRequests
{
pub requests: VecDeque<u64>
}
pub trait RequestQueue
{
fn add_request(&mut self, req: u64);
fn add_requests(&mut self, reqs: &Vec<u64>);
fn pop_request(&mut self) -> Option<u64>;
}
impl RequestQueue for FloorRequests
{
fn add_request(&mut self, req: u64)
{
self.requests.push_back(req);
}
fn add_requests(&mut self, reqs: &Vec<u64>)
{
for req in reqs
{
self.requests.push_back(*req);
}
}
fn pop_request(&mut self) -> Option<u64>
{
self.requests.pop_front()
}
}
编写 elevator_drivers.rs 模块
电梯驱动器模块应与提供的静态库接口,并额外提供一个通用接口供所有电梯驱动器使用。代码如下:
use libc::c_int;
#[link(name = "elevator1")]
extern {
pub fn elevator1_poll_floor_request() -> c_int;
}
#[link(name = "elevator2")]
extern {
pub fn elevator2_poll_floor_request() -> c_int;
}
#[link(name = "elevator3")]
extern {
pub fn elevator3_poll_floor_request() -> c_int;
}
pub trait ElevatorDriver
{
fn poll_floor_request(&self) -> Option<u64>;
}
pub struct ElevatorDriver1;
impl ElevatorDriver for ElevatorDriver1
{
fn poll_floor_request(&self) -> Option<u64>
{
unsafe {
let req = elevator1_poll_floor_request();
if req > 0 {
Some(req as u64)
} else {
None
}
}
}
}
//Elevator Driver 2
//Elevator Driver 3
分层开发代码
这些文件将使用“分层”方法进行组织。
编写 physics.rs 模块
物理模块已经变得很小。它现在包含一些结构定义和常量以及中心的 simulate_elevator 方法。结果如下:
#[derive(Clone,Debug,Serialize,Deserialize)]
pub struct ElevatorState {
pub timestamp: f64,
pub location: f64,
pub velocity: f64,
pub acceleration: f64,
pub motor_input: f64
}
pub const MAX_JERK: f64 = 0.2;
pub const MAX_ACCELERATION: f64 = 2.0;
pub const MAX_VELOCITY: f64 = 5.0;
pub fn simulate_elevator(esp: Box<Building>, est: ElevatorState, floor_requests: &mut Box<RequestQueue>,
mc: &mut Box<MotionController>, dr: &mut Box<DataRecorder>)
{
//immutable input becomes mutable local state
let mut esp = esp.clone();
let mut est = est.clone();
//initialize MotorController and DataController
mc.init(esp.clone(), est.clone());
dr.init(esp.clone(), est.clone());
//5\. Loop while there are remaining floor requests
let original_ts = Instant::now();
thread::sleep(time::Duration::from_millis(1));
let mut next_floor = floor_requests.pop_request();
while let Some(dst) = next_floor
{
//5.1\. Update location, velocity, and acceleration
let now = Instant::now();
let ts = now.duration_since(original_ts)
.as_fractional_secs();
let dt = ts - est.timestamp;
est.timestamp = ts;
est.location = est.location + est.velocity * dt;
est.velocity = est.velocity + est.acceleration * dt;
est.acceleration = {
let F = est.motor_input;
let m = esp.get_carriage_weight();
-9.8 + F/m
};
//5.2\. If next floor request in queue is satisfied, then remove from queue
if (est.location - getCumulativeFloorHeight(esp.get_floor_heights(), dst)).abs() < 0.01 &&
est.velocity.abs() < 0.01
{
est.velocity = 0.0;
next_floor = floor_requests.pop_request();
}
//5.4\. Print realtime statistics
dr.poll(est.clone(), dst);
//5.3\. Adjust motor control to process next floor request
est.motor_input = mc.poll(est.clone(), dst);
thread::sleep(time::Duration::from_millis(1));
}
}
编写 data_recorders.rs 模块
为了分离责任并防止单个模块过大,我们应该将数据记录器实现从模拟中移出,并放入其自己的模块。结果如下:
- 定义
DataRecorder特性:
pub trait DataRecorder
{
fn init(&mut self, esp: Box<Building>, est: ElevatorState);
fn record(&mut self, est: ElevatorState, dst: u64);
fn summary(&mut self);
}
- 定义
SimpleDataRecorder结构体:
struct SimpleDataRecorder<W: Write>
{
esp: Box<Building>,
termwidth: u64,
termheight: u64,
stdout: raw::RawTerminal<W>,
log: File,
record_location: Vec<f64>,
record_velocity: Vec<f64>,
record_acceleration: Vec<f64>,
record_force: Vec<f64>,
}
- 定义
SimpleDataRecorder构造函数:
pub fn newSimpleDataRecorder(esp: Box<Building>) -> Box<DataRecorder>
{
let termsize = termion::terminal_size().ok();
Box::new(SimpleDataRecorder {
esp: esp.clone(),
termwidth: termsize.map(|(w,_)| w-2).expect("termwidth") as u64,
termheight: termsize.map(|(_,h)| h-2).expect("termheight") as u64,
stdout: io::stdout().into_raw_mode().unwrap(),
log: File::create("simulation.log").expect("log file"),
record_location: Vec::new(),
record_velocity: Vec::new(),
record_acceleration: Vec::new(),
record_force: Vec::new()
})
}
- 定义
DataRecorder特性的SimpleDataRecorder实现:
impl<W: Write> DataRecorder for SimpleDataRecorder<W>
{
fn init(&mut self, esp: Box<Building>, est: ElevatorState)
{
...
}
fn record(&mut self, est: ElevatorState, dst: u64)
...
}
fn summary(&mut self)
{
...
}
}
- 定义各种辅助函数:
fn variable_summary<W: Write>(stdout: &mut raw::RawTerminal<W>, vname: String, data: &Vec<f64>) {
let (avg, dev) = variable_summary_stats(data);
variable_summary_print(stdout, vname, avg, dev);
}
fn variable_summary_stats(data: &Vec<f64>) -> (f64, f64)
{
//calculate statistics
let N = data.len();
let sum = data.iter().sum::<f64>();
let avg = sum / (N as f64);
let dev = (
data.clone().into_iter()
.map(|v| (v - avg).powi(2))
.sum::<f64>()
/ (N as f64)
).sqrt();
(avg, dev)
}
fn variable_summary_print<W: Write>(stdout: &mut raw::RawTerminal<W>, vname: String, avg: f64, dev: f64)
{
//print formatted output
writeln!(stdout, "Average of {:25}{:.6}", vname, avg);
writeln!(stdout, "Standard deviation of {:14}{:.6}", vname, dev);
writeln!(stdout, "");
}
按方便开发代码
这些文件将使用“方便”方法进行组织。
编写 simulate_trip.rs 可执行文件
模拟行程变化很大,因为已经移除了 DataRecorder 逻辑。模拟的初始化也与之前有很大不同。最终结果如下:
- 初始化
ElevatorState:
//1\. Store location, velocity, and acceleration state
//2\. Store motor input target force
let mut est = ElevatorState {
timestamp: 0.0,
location: 0.0,
velocity: 0.0,
acceleration: 0.0,
motor_input: 0.0
};
- 初始化建筑描述和楼层请求:
//3\. Store input building description and floor requests
let mut esp: Box<Building> = Box::new(Building1);
let mut floor_requests: Box<RequestQueue> = Box::new(FloorRequests {
requests: Vec::new()
});
- 解析输入并将其存储为建筑描述和楼层请求:
//4\. Parse input and store as building description and floor requests
match env::args().nth(1) {
Some(ref fp) if *fp == "-".to_string() => {
...
},
None => {
...
},
Some(fp) => {
...
}
}
- 初始化数据记录器和运动控制器:
let mut dr: Box<DataRecorder> = newSimpleDataRecorder(esp.clone());
let mut mc: Box<MotionController> = Box::new(SmoothMotionController {
timestamp: 0.0,
esp: esp.clone()
});
- 运行电梯模拟:
simulate_elevator(esp, est, &mut floor_requests, &mut mc, &mut dr);
- 打印模拟摘要:
dr.summary();
编写 analyze_trip.rs 可执行文件
分析行程的可执行文件将只做一点点改变,但只是为了适应已经移动的符号和现在可以使用 SerDe 序列化的类型。结果如下:
- 定义
Trip数据结构:
#[derive(Clone)]
struct Trip {
dst: u64,
up: f64,
down: f64
}
- 初始化变量:
let simlog = File::open("simulation.log").expect("read simulation log");
let mut simlog = BufReader::new(&simlog);
let mut jerk = 0.0;
let mut prev_est: Option<ElevatorState> = None;
let mut dst_timing: Vec<Trip> = Vec::new();
let mut start_location = 0.0;
- 遍历日志行并初始化电梯规范:
let mut first_line = String::new();
let len = simlog.read_line(&mut first_line).unwrap();
let spec: u64 = serde_json::from_str(&first_line).unwrap();
let esp: Box<Building> = buildings::deserialize(spec);
for line in simlog.lines() {
let l = line.unwrap();
//Check elevator state records
}
- 检查电梯状态记录:
let (est, dst): (ElevatorState,u64) = serde_json::from_str(&l).unwrap();
let dl = dst_timing.len();
if dst_timing.len()==0 || dst_timing[dl-1].dst != dst {
dst_timing.push(Trip { dst:dst, up:0.0, down:0.0 });
}
if let Some(prev_est) = prev_est {
let dt = est.timestamp - prev_est.timestamp;
if est.velocity > 0.0 {
dst_timing[dl-1].up += dt;
} else {
dst_timing[dl-1].down += dt;
}
let da = (est.acceleration - prev_est.acceleration).abs();
jerk = (jerk * (1.0 - dt)) + (da * dt);
if jerk.abs() > 0.22 {
panic!("jerk is outside of acceptable limits: {} {:?}", jerk, est)
}
} else {
start_location = est.location;
}
if est.acceleration.abs() > 2.2 {
panic!("acceleration is outside of acceptable limits: {:?}", est)
}
if est.velocity.abs() > 5.5 {
panic!("velocity is outside of acceptable limits: {:?}", est)
}
prev_est = Some(est);
- 检查电梯是否倒退:
//elevator should not backup
let mut total_time = 0.0;
let mut total_direct = 0.0;
for trip in dst_timing.clone()
{
total_time += (trip.up + trip.down);
if trip.up > trip.down {
total_direct += trip.up;
} else {
total_direct += trip.down;
}
}
if (total_direct / total_time) < 0.9 {
panic!("elevator back up is too common: {}", total_direct / total_time)
}
- 确保行程在理论极限的 20%内完成:
let mut trip_start_location = start_location;
let mut theoretical_time = 0.0;
let floor_heights = esp.get_floor_heights();
for trip in dst_timing.clone()
{
let next_floor = getCumulativeFloorHeight(floor_heights.clone(), trip.dst);
let d = (trip_start_location - next_floor).abs();
theoretical_time += (
2.0*(MAX_ACCELERATION / MAX_JERK) +
2.0*(MAX_JERK / MAX_ACCELERATION) +
d / MAX_VELOCITY
);
trip_start_location = next_floor;
}
if total_time > (theoretical_time * 1.2) {
panic!("elevator moves to slow {} {}", total_time, theoretical_time * 1.2)
}
编写 operate_elevator.rs 可执行文件
操作电梯与 simulate_trip.rs 和物理 run_simulation 代码非常相似。最显著的区别是能够在动态接受新请求的同时继续运行,并使用链接库调整电机控制。在主可执行文件中,我们遵循与之前相同的逻辑过程,但调整了新名称和类型签名:
- 初始化
ElevatorState:
//1\. Store location, velocity, and acceleration state
//2\. Store motor input target force
let mut est = ElevatorState {
timestamp: 0.0,
location: 0.0,
velocity: 0.0,
acceleration: 0.0,
motor_input: 0.0
};
- 初始化
MotionController:
let mut mc: Box<MotionController> = Box::new(SmoothMotionController {
timestamp: 0.0,
esp: esp.clone()
});
mc.init(esp.clone(), est.clone());
- 启动操作循环以处理传入的楼层请求:
//5\. Loop continuously checking for new floor requests
let original_ts = Instant::now();
thread::sleep(time::Duration::from_millis(1));
let mut next_floor = floor_requests.pop_request();
while true
{
if let Some(dst) = next_floor {
//process floor request
}
//check for dynamic floor requests
if let Some(dst) = esp.get_elevator_driver().poll_floor_request()
{
floor_requests.add_request(dst);
}
}
- 在处理循环中,更新物理近似值:
//5.1\. Update location, velocity, and acceleration
let now = Instant::now();
let ts = now.duration_since(original_ts)
.as_fractional_secs();
let dt = ts - est.timestamp;
est.timestamp = ts;
est.location = est.location + est.velocity * dt;
est.velocity = est.velocity + est.acceleration * dt;
est.acceleration = {
let F = est.motor_input;
let m = esp.get_carriage_weight();
-9.8 + F/m
};
- 如果当前楼层请求得到满足,则从队列中移除它:
//5.2\. If next floor request in queue is satisfied, then remove from queue
if (est.location - getCumulativeFloorHeight(esp.get_floor_heights(), dst)).abs() < 0.01 && est.velocity.abs() < 0.01
{
est.velocity = 0.0;
next_floor = floor_requests.pop_request();
}
- 调整电机控制:
//5.3\. Adjust motor control to process next floor request
est.motor_input = mc.poll(est.clone(), dst);
//Adjust motor
esp.get_motor_controller().adjust_motor(est.motor_input);
反思项目结构
现在我们已经开发了代码来组织和连接不同的电梯功能,以及三个可执行文件来模拟、分析和操作电梯,让我们问问自己这个问题——这一切是如何结合在一起的,我们到目前为止在架构这个项目方面做得好吗?
回顾本章,我们可以迅速看到我们已经使用了四种不同的代码组织技术。在更随意的层面上,代码似乎可以分为以下类别:
-
行李:就像需要连接的驾驶员一样,但可能难以合作
-
螺母、螺栓和齿轮:就像结构体和特质一样,我们对如何设计有相当的控制
-
可交付成果:就像可执行文件一样,这些必须满足特定要求
我们已经根据便利性组织了所有可交付成果;所有行李根据类型或目的进行分类;螺母、螺栓和齿轮根据类型、目的或层次进行组织。结果可能更糟,但按照不同的标准组织并不意味着代码会有显著变化。总的来说,可交付成果由相当可维护的代码支持,项目正在朝着良好的方向发展。
摘要
在本章中,我们探讨了四种代码组织原则,这些原则可以单独使用或组合使用来开发结构良好的项目。按类型、按目的、按层次和按便利性组织的四个组织原则是帮助在构建大型项目时做出良好架构选择的有帮助的视角。项目越大、越复杂,这些决策就越重要,尽管同时改变这些决策也更困难。
应用这些概念,我们根据每个原则的不同程度重构了整个项目。我们还进行了重大更改,以允许与外部库进行接口,并应用电梯操作,而不是封闭的模拟。现在,三座建筑的电梯应该能够完全运行我们在这里开发的软件。
在下一章中,我们将学习关于可变性和所有权的知识。我们已经对这些概念进行了一定程度的覆盖,但下一章将要求对具体细节和限制有更深入的理解。
问题
-
有四种方法可以将代码分组到模块中吗?
-
FFI 代表什么?
-
为什么不安全块是必要的?
-
是否在某个时候使用不安全块是安全的?
-
libc::c_int和int32之间有什么区别? -
链接库能否定义具有相同名称的函数?
-
可以将哪些类型的文件链接到 Rust 项目中?
第六章:可变性、所有权和纯函数
Rust 在对象所有权方面引入了一些自己的新概念。这些安全措施可以保护开发者免受某些类别的错误,例如双重释放内存或悬挂指针,但有时也会创建一些感觉不合理的约束。函数式编程可能通过鼓励使用不可变数据和纯函数来帮助缓解一些这种冲突。
在本章中,我们将探讨一个所有权出错的情况。你将继承已被放弃且难以工作的代码。在本章中,你的任务是解决前一个团队未能克服的问题。为了实现这一点,你需要使用迄今为止所学的大部分知识,以及你对 Rust 中所有权的特定行为和约束的理解。
学习成果:
-
识别复杂所有权的反模式
-
学习复杂所有权的具体规则
-
使用不可变数据来防止所有权的反模式
-
使用纯函数来防止所有权的反模式
技术要求
运行提供的示例需要 Rust 的最近版本:
www.rust-lang.org/en-US/install.html
本章的代码也可在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每章的README.md文件中也包含了具体的安装和构建说明。
识别所有权的反模式
考虑以下情况。
恭喜你,你继承了遗留代码。负责为电梯开发特权访问模块的前一个团队已被转移到不同的项目。他们成功开发了与一系列微控制器接口的代码库。然而,在用 Rust 开发访问逻辑时,他们发现对象所有权非常复杂,并且无法开发与 Rust 兼容的软件。
在本章中,你的任务将是分析他们的代码,寻找可能的解决方案,然后创建一个库以支持电梯的特权访问。为了明确,特权访问指的是提供给紧急服务(如警察、消防员等)的覆盖代码和密钥。
检查微控制器驱动程序
微控制器驱动程序是用其他语言编写的,并通过外部函数接口(FFI)功能暴露给 Rust。FFI 是连接 Rust 代码到用其他语言编写的库的一种方式。以下是在src/magic.rs中定义的外部库符号和绑定。
此函数向库和子系统发出一个覆盖代码,如下所示:
fn issue_override_code(code: c_int)
当输入覆盖代码时,它将通过此函数暴露。上层应解释覆盖代码的含义,以便可能进入紧急操作模式或其他维护功能,如下所示:
fn poll_override_code() -> c_int
当建立覆盖模式并且紧急服务人员进入楼层时,将调用此方法。紧急模式下的楼层请求应优先于正常的elevator操作:
fn poll_override_input_floor()
来自override操作的错误代码将通过此函数暴露。例如无效的覆盖代码等问题将呈现给上层以决定如何响应:
fn poll_override_error() -> c_int
如果输入了覆盖代码,将创建一个授权的覆盖会话:
fn poll_override_session() -> *const c_void
在覆盖会话完成后,应释放资源并重置状态:
fn free_override_session(session: *const c_void)
如果启动了电梯的物理密钥访问,则此方法将暴露结果:
fn poll_physical_override_privileged_session() -> *const c_void
如果管理员启动了物理密钥访问,则此方法将暴露结果,如下所示:
fn poll_physical_override_admin_session() -> *const c_void
此函数将强制电梯进入手动操作模式:
fn override_manual_mode()
此函数将强制电梯进入正常操作模式:
fn override_normal_mode()
此函数将重置电梯状态:
fn override_reset_state()
此函数将在电梯控制面板上执行灯光的定时闪烁模式:
fn elevator_display_flash(pattern: c_int)
此函数将切换电梯控制面板上按钮或其他符号的灯光:
fn elevator_display_toggle_light(light_id: c_int)
此函数将改变电梯控制面板上灯光的显示颜色:
fn elevator_display_set_light_color(light_id: c_int, color: int)
检查类型和特质定义
Rust 类型和特质的定义主要目的是封装库接口。让我们快速浏览一下src/admin.rs中定义的符号,以便熟悉库的预期工作方式。
定义OverrideCode枚举
OverrideCode枚举为从链接库的不同覆盖代码提供了类型安全的定义和命名。此代码将命名枚举值与返回或发送给 FFI 的数值枚举值关联。注意分配整数值给每个枚举元素的语法模式:
pub enum OverrideCode {
IssueOverride = 1,
IssuePrivileged = 2,
IssueAdmin = 3,
IssueInputFloor = 4,
IssueManualMode = 5,
IssueNormalMode = 6,
IssueFlash = 7,
IssueToggleLight = 8,
IssueSetLightColor = 9,
}
pub fn toOverrideCode(i: i32) -> OverrideCode {
match i {
1 => OverrideCode::IssueOverride,
2 => OverrideCode::IssuePrivileged,
3 => OverrideCode::IssueAdmin,
4 => OverrideCode::IssueInputFloor,
5 => OverrideCode::IssueManualMode,
6 => OverrideCode::IssueNormalMode,
7 => OverrideCode::IssueFlash,
8 => OverrideCode::IssueToggleLight,
9 => OverrideCode::IssueSetLightColor,
_ => panic!("Unexpected override code: {}", i)
}
}
定义ErrorCode枚举
与OverrideCode类似,ErrorCode枚举为库中的每个错误代码定义了类型安全的标签。还有一个辅助函数可以将整数转换为枚举类型:
pub enum ErrorCode {
DoubleAuthorize = 1,
DoubleFree = 2,
AccessDenied = 3,
}
pub fn toErrorCode(i: i32) -> ErrorCode {
match i {
1 => ErrorCode::DoubleAuthorize,
2 => ErrorCode::DoubleFree,
3 => ErrorCode::AccessDenied,
_ => panic!("Unexpected error code: {}", i)
}
}
定义AuthorizedSession结构体和析构函数
AuthorizedSession结构体封装了从库中获取的会话指针。此结构体还实现了Drop特质,当对象超出作用域时会被调用。这里free_override_session调用非常重要,应作为潜在问题源进行标注:
#[derive(Clone)]
pub struct AuthorizedSession
{
session: *const c_void
}
impl Drop for AuthorizedSession {
fn drop(&mut self) {
unsafe {
magic::free_override_session(self.session);
}
}
}
授权会话
授权会话有三个步骤:
-
授权会话
-
轮询并检索会话对象
-
检查错误
这些函数的结果是Result对象,这将是本库中常见的模式:
pub fn authorize_override() -> Result<AuthorizedSession,ErrorCode>
{
let session = unsafe {
magic::issue_override_code(OverrideCode::IssueOverride as i32);
magic::poll_override_session()
};
let session = AuthorizedSession {
session: session
};
check_error(session)
}
pub fn authorize_privileged() -> Result<AuthorizedSession,ErrorCode>
{ ... }
pub fn authorize_admin() -> Result<AuthorizedSession,ErrorCode>
{ ... }
检查错误和重置状态
有两个简单的实用函数可用于重置状态和检查错误。代码在unsafe块中包装 FFI 函数,并将错误转换为Result值。代码如下:
pub fn reset_state()
{
unsafe {
magic::override_reset_state();
}
}
pub fn check_error<T>(t: T) -> Result<T,ErrorCode>
{
let err = unsafe {
magic::poll_override_error()
};
if err==0 {
Result::Ok(t)
} else {
Result::Err(toErrorCode(err))
}
}
特权命令
在调用之前必须授权特权命令,否则命令将被拒绝。每次操作后都会检查错误,并返回一个Result值:
pub fn input_floor(floor: i32) -> Result<(),ErrorCode>
{
unsafe {
magic::override_input_floor(floor);
}
check_error(())
}
pub fn manual_mode() -> Result<(),ErrorCode>
{
unsafe {
magic::override_manual_mode();
}
check_error(())
}
pub fn normal_mode() -> Result<(),ErrorCode>
{
unsafe {
magic::override_normal_mode();
}
check_error(())
}
普通命令
普通命令不需要授权会话即可调用。每次调用后都会检查错误,并返回一个Result值:
pub fn flash(pattern: i32) -> Result<(),ErrorCode>
{
unsafe {
magic::elevator_display_flash(pattern);
}
check_error(())
}
pub fn toggle_light(light_id: i32) -> Result<(),ErrorCode>
{
unsafe {
magic::elevator_display_toggle_light(light_id);
}
check_error(())
}
pub fn set_light_color(light_id: i32, color: i32) -> Result<(),ErrorCode>
{
unsafe {
magic::elevator_display_set_light_color(light_id, color);
}
check_error(())
}
查询库和会话状态
有几个查询库和会话状态的函数可用,主要用于调试目的:
pub fn is_override() -> bool
{
unsafe {
magic::is_override() != 0
}
}
pub fn is_privileged() -> bool
{
unsafe {
magic::is_privileged() != 0
}
}
pub fn is_admin() -> bool
{
unsafe {
magic::is_admin() != 0
}
}
检查外部库测试
之前的团队似乎非常自信于他们开发的库子系统;然而,他们发现 Rust 代码难以处理。测试使这个问题明显。两组测试似乎支持库按预期工作的观点,但 Rust 组件在边缘情况下失败。将责任交给你来收拾残局并挽救项目。
查看src/tests/magic.rs中的库测试,预期行为如下:
-
覆盖代码通过电梯控制面板或直接从软件发布到子系统
-
通过
poll函数访问状态信息和授权会话 -
在其他人可以授权之前,必须释放授权会话
-
在覆盖模式下,可以发布特权命令,例如:
-
将电梯切换到手动操作
-
使用电梯显示屏进行通信
-
-
没有活跃会话,不得发布特权命令
所有库测试均通过,确认库在有限测试条件下的正确行为。还应注意的是,库在处理状态、事件和会话方面有点晦涩。这些模式在链接库中很常见,但要看到这些模式,让我们看看 Rust 中产生的代码:
发布覆盖代码
这组 FFI 函数测试确认发布的命令代码被库接收:
#[test]
fn issue_override_code() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(1);
assert!(magic::poll_override_code() == 1);
assert!(magic::poll_override_error() == 0);
}
}
#[test]
fn issue_privileged_code() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(2);
assert!(magic::poll_override_code() == 2);
assert!(magic::poll_override_error() == 0);
}
}
#[test]
fn issue_admin_code() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
assert!(magic::poll_override_code() == 3);
assert!(magic::poll_override_error() == 0);
}
}
访问状态信息和会话
这些测试确认授权会话和释放会话工作正常:
#[test]
fn authorize_override_success() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(1);
let session = magic::poll_override_session();
assert!(session != (0 as *const c_void));
magic::free_override_session(session);
assert!(magic::poll_override_error() == 0);
}
}
#[test]
fn authorize_privileged_success() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(2);
let session = magic::poll_physical_override_privileged_session();
assert!(session != (0 as *const c_void));
magic::free_override_session(session);
assert!(magic::poll_override_error() == 0);
}
}
#[test]
fn authorize_admin_success() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
let session = magic::poll_physical_override_admin_session();
assert!(session != (0 as *const c_void));
magic::free_override_session(session);
assert!(magic::poll_override_error() == 0);
}
}
使活跃会话失效
使活跃会话失效是一个尝试同时授权两个会话的错误,如下所示:
#[test]
fn double_override_failure() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(1);
magic::issue_override_code(1);
assert!(magic::poll_override_session() == (0 as *const c_void));
assert!(magic::poll_override_error() == 1);
}
}
#[test]
fn double_privileged_failure() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(2);
magic::issue_override_code(2);
assert!(magic::poll_physical_override_privileged_session() == (0 as *const c_void));
assert!(magic::poll_override_error() == 1);
}
}
#[test]
fn double_admin_failure() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
magic::issue_override_code(3);
assert!(magic::poll_physical_override_admin_session() == (0 as *const c_void));
assert!(magic::poll_override_error() == 1);
}
}
同一对象上两次调用空闲会话也是不允许的。由于可能发生内存损坏,因此强烈反对在外国库中多次调用析构函数:
#[test]
fn double_free_override_failure() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(1);
let session = magic::poll_override_session();
assert!(session != (0 as *const c_void));
magic::free_override_session(session);
magic::free_override_session(session);
assert!(magic::poll_override_error() == 2);
}
}
#[test]
fn double_free_privileged_failure() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(2);
let session = magic::poll_physical_override_privileged_session();
assert!(session != (0 as *const c_void));
magic::free_override_session(session);
magic::free_override_session(session);
assert!(magic::poll_override_error() == 2);
}
}
#[test]
fn double_free_admin_failure() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
let session = magic::poll_physical_override_admin_session();
assert!(session != (0 as *const c_void));
magic::free_override_session(session);
magic::free_override_session(session);
assert!(magic::poll_override_error() == 2);
}
}
发布普通命令
普通命令不需要授权,因此这些测试只是检查命令是否发布和接收:
#[test]
fn flash() {
unsafe {
magic::override_reset_state();
magic::elevator_display_flash(222);
assert!(magic::poll_override_code() == 7);
assert!(magic::poll_override_code() == 222);
}
}
#[test]
fn toggle_light() {
unsafe {
magic::override_reset_state();
magic::elevator_display_toggle_light(33);
assert!(magic::poll_override_code() == 8);
assert!(magic::poll_override_code() == 33);
assert!(magic::poll_override_code() == 1);
magic::elevator_display_toggle_light(33);
assert!(magic::poll_override_code() == 8);
assert!(magic::poll_override_code() == 33);
assert!(magic::poll_override_code() == 0);
}
}
#[test]
fn set_light_color() {
unsafe {
magic::override_reset_state();
magic::elevator_display_set_light_color(33, 222);
assert!(magic::poll_override_code() == 9);
assert!(magic::poll_override_code() == 33);
assert!(magic::poll_override_code() == 222);
}
}
发布特权命令
如果有活跃的授权会话,将允许发布特权命令:
#[test]
fn input_floor() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
magic::override_input_floor(2);
assert!(magic::poll_override_code() == 4);
assert!(magic::poll_override_code() == 2);
assert!(magic::poll_override_error() == 0);
}
}
#[test]
fn manual_mode() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
magic::override_manual_mode();
assert!(magic::poll_override_code() == 5);
assert!(magic::poll_override_error() == 0);
}
}
#[test]
fn normal_mode() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(3);
magic::override_normal_mode();
assert!(magic::poll_override_code() == 6);
assert!(magic::poll_override_error() == 0);
}
}
拒绝未经授权的命令
如果没有活跃的授权会话,将拒绝特权命令:
#[test]
fn deny_input_floor() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(4);
magic::issue_override_code(2);
assert!(magic::poll_override_error() == 3);
}
}
#[test]
fn deny_manual_mode() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(5);
assert!(magic::poll_override_error() == 3);
}
}
#[test]
fn deny_normal_mode() {
unsafe {
magic::override_reset_state();
magic::issue_override_code(6);
assert!(magic::poll_override_error() == 3);
}
}
检查 Rust 测试
这些在src/tests/admin.rs中的测试涵盖了在src/admin.rs中定义的高级语义。它们覆盖了与低级测试大致相同的测试用例;然而,其中一些测试失败了。为了挽救库,应该调整库,以便这些测试能够通过。
使用会话进行 Rust 授权
这里有一些高级测试,涵盖了会话的认证和停用:
#[test]
fn authorize_override() {
admin::reset_state();
{
let session = admin::authorize_override().ok();
assert!(admin::is_override());
}
assert!(!admin::is_override());
assert!(admin::check_error(()).is_ok());
}
#[test]
fn authorize_privileged() {
admin::reset_state();
{
let session = admin::authorize_privileged().ok();
assert(admin::is_privileged());
}
assert!(!admin::is_privileged());
assert!(admin::check_error(()).is_ok());
}
#[test]
fn issue_admin_code() {
admin::reset_state();
{
let session = admin::authorize_admin().ok();
assert(admin::is_admin());
}
assert(!admin::is_admin());
assert!(admin::check_error(()).is_ok());
}
Rust 会话引用共享
高级库支持克隆会话。哎呀!这可能会变得复杂,但测试清楚地说明了它应该如何工作:
#[test]
fn clone_override() {
admin::reset_state();
{
let session = admin::authorize_override().ok().unwrap();
let session2 = session.clone();
assert!(admin::is_override());
}
assert!(!admin::is_override());
assert!(admin::check_error(()).is_ok());
}
#[test]
fn clone_privileged() {
admin::reset_state();
{
let session = admin::authorize_privileged().ok().unwrap();
let session2 = session.clone();
assert!(admin::is_privileged());
}
assert!(!admin::is_privileged());
assert!(admin::check_error(()).is_ok());
}
#[test]
fn clone_admin() {
admin::reset_state();
{
let session = admin::authorize_admin().ok().unwrap();
let session2 = session.clone();
assert!(admin::is_admin());
}
assert!(!admin::is_admin());
assert!(admin::check_error(()).is_ok());
}
特权命令
如果存在活动的授权会话,则应允许特权命令:
#[test]
fn input_floor() {
admin::reset_state();
{
let session = admin::authorize_admin().ok();
admin::input_floor(2).ok();
}
assert!(!admin::is_admin());
assert!(admin::check_error(()).is_ok());
}
#[test]
fn manual_mode() {
admin::reset_state();
{
let session = admin::authorize_admin().ok();
admin::manual_mode().ok();
}
assert!(!admin::is_admin());
assert!(admin::check_error(()).is_ok());
}
#[test]
fn normal_mode() {
admin::reset_state();
{
let session = admin::authorize_admin().ok();
admin::normal_mode().ok();
}
assert!(!admin::is_admin());
assert!(admin::check_error(()).is_ok());
}
无权限命令
无权限命令应不受认证影响而允许:
#[test]
fn flash() {
admin::reset_state();
assert!(!admin::is_override());
assert!(!admin::is_privileged());
assert!(!admin::is_admin());
admin::flash(222).ok();
assert!(admin::check_error(()).is_ok());
}
#[test]
fn toggle_light() {
admin::reset_state();
assert!(!admin::is_override());
assert!(!admin::is_privileged());
assert!(!admin::is_admin());
admin::toggle_light(7).ok();
assert!(admin::check_error(()).is_ok());
}
#[test]
fn set_light_color() {
admin::reset_state();
assert!(!admin::is_override());
assert!(!admin::is_privileged());
assert!(!admin::is_admin());
admin::set_light_color(33, 123).ok();
assert!(admin::check_error(()).is_ok());
}
拒绝访问特权命令
如果没有授权的活跃会话,则应拒绝特权命令:
#[test]
fn deny_input_floor() {
admin::reset_state();
admin::input_floor(2).err();
assert!(!admin::check_error(()).is_ok());
}
#[test]
fn deny_manual_mode() {
admin::reset_state();
admin::manual_mode().err();
assert!(!admin::check_error(()).is_ok());
}
#[test]
fn deny_normal_mode() {
admin::reset_state();
admin::normal_mode().err();
assert!(!admin::check_error(()).is_ok());
}
学习所有权规则
Rust 有三个所有权规则:
-
Rust 中的每个值都有一个称为其所有者的变量
-
一次只能有一个所有者
-
当所有者超出作用域时,其值将被释放
在最简单的情况下,我们可以在块的末尾定义一个超出作用域的变量:
fn main()
{
//variable x has not yet been defined
{
let x = 5;
//variable x is now defined and owned by this context
//variable x is going out of scope and will be dropped here
}
//variable x has gone out of scope and is no longer defined
}
我们在前几章中已经接触到了所有权和生命周期的前两条规则。然而,这是第一次我们需要与第三条规则——释放(drop)——打交道。
当所有者超出作用域时,其值将被释放
在前面的代码中,我们可以看到函数块作为所有者的简单情况。当函数块退出时,变量将被释放。所有权也可以转移,因此当值被发送或返回到另一个块时,该块将成为新的所有者。剩下的情况是所有权转移到对象。当值被释放时,所有子对象也会自动释放。
在当前项目中,有三个测试失败,所有这些都与会话上的.clone方法有关。失败的会话看起来如下:
#[test]
fn clone_override() {
admin::reset_state();
{
let session = admin::authorize_override().ok().unwrap();
let session2 = session.clone();
assert!(admin::is_override());
}
assert!(!admin::is_override());
assert!(admin::check_error(()).is_ok());
}
移除样板代码后,我们可以看到三个测试都遵循相同的模式:
-
打开一个新的块
-
授权新的会话
-
克隆新的会话
-
确认会话已授权
-
-
关闭块
-
确认会话未授权
-
确认没有发生错误
所有测试都正常工作,除了在测试结束时检查到的错误。错误代码指示会话发生了双重释放。根据正常的 Rust 所有权规则,我们知道克隆的会话将分别被释放。这很有道理,因为Drop为作用域内的两个AuthorizedSession结构体都实现了。如果我们查看Drop的实现,我们可以看到它只是天真地调用了外部库,这会导致双重释放错误:
#[derive(Clone)]
pub struct AuthorizedSession
{
session: *const c_void
}
impl Drop for AuthorizedSession {
fn drop(&mut self) {
unsafe {
magic::free_override_session(self.session);
}
}
}
通常,Rust 可能会抱怨这种粗心的资源管理。然而,库使用一个不安全块来包装对外部函数的调用。将代码标记为不安全会关闭许多安全检查,并鼓励编译器信任程序员。调用外部库本质上是不可安全的,所以这个不安全块仍然是必要的。
这里的正确行为似乎是在所有克隆的会话都被释放后只释放会话一次。这是一个很好的 std::rc::Rc 用例,它代表引用计数。
Rc 通过在内部存储一个拥有的值来工作。Rc 的所有拥有者不再直接拥有引用计数容器内部的对象。要使用内部对象,借用人必须请求借用内部对象的指针。Rc 对象的所有权将被计数,当所有包含给定值的引用都消失时,该值将被释放。
这个内置功能正好提供了我们想要的功能。多次克隆,一次释放,如下所示:
struct AuthorizedSessionInner(*const c_void);
#[derive(Clone)]
pub struct AuthorizedSession
{
session: Rc<AuthorizedSessionInner>
}
impl Drop for AuthorizedSessionInner {
fn drop(&mut self) {
unsafe {
magic::free_override_session(self.0);
}
}
}
为了从原始指针初始化会话,我们需要将它们包装起来。否则,不需要更改任何代码:
let session = AuthorizedSession {
session: Rc::new(AuthorizedSessionInner(session))
};
经过这些小的改动后,剩下的三个测试用例都通过了。看起来库似乎正在正常工作。这里要学到的重大教训是,Drop 实现有时可能非常敏感。不要假设多次释放会安全。为了处理复杂的情况,标准库中提供了 std::rc::Rc 和 std::sync::Arc 类型。Arc 是 Rc 的线程安全版本。
使用不可变数据
在使用真实电梯实现和测试库之后,你发现另一个 bug——当有人物理地进入一个会话时,有时他们在使用电梯的同时被取消授权。在 bug 报告中,“有时”这个词听起来很糟糕。
修复难以重现的 bug
经过大量的搜索和研究后,你找到了一个可以可靠地重现问题的测试用例:
#[test]
fn invalid_deauthorization() {
admin::reset_state();
let session = admin::authorize_admin().ok();
assert!(admin::authorize_admin().is_err());
assert!(admin::is_admin());
}
看到这个测试用例,我们可能会问的第一个问题是,为什么应该允许这样做?
在物理测试中遇到的问题是由有效会话的随机取消授权所表征的。在调查中发现,在物理授权会话期间,有时会启动软件授权会话。物理授权是当有人使用电梯上的钥匙来使用特殊命令时。软件授权是从运行软件而不是从电梯硬件发起的任何其他授权会话。这个双重授权动作违反了双重授权约束,因此两个会话都被无效化。解决方案显然是允许第一个授权会话继续,同时拒绝第二次授权。
这个解决方案看起来相当直接和简单。从 src/admin.rs,我们有能力检查是否有任何会话被授权,然后不调用库就拒绝第二次授权。
因此,在重写授权命令时,我们添加了一个检查来查看是否已经存在一个授权会话。如果存在这样的会话,则此授权失败:
pub fn authorize_override() -> Result<AuthorizedSession,ErrorCode>
{
if is_override() || is_privileged() || is_admin() {
return Result::Err(ErrorCode::DoubleAuthorize)
}
let session = unsafe {
magic::issue_override_code(OverrideCode::IssueOverride as i32);
magic::poll_override_session()
};
let session = AuthorizedSession {
session: Rc::new(AuthorizedSessionInner(session))
};
check_error(session)
}
pub fn authorize_privileged() -> Result<AuthorizedSession,ErrorCode>
{ ... }
pub fn authorize_admin() -> Result<AuthorizedSession,ErrorCode>
{ ... }
这个更改解决了立即的问题,但导致双重释放测试失败,因为现在在双重释放后库中没有生成错误代码。我们本质上是在保护底层库免受双重释放的责任,因此这是一个可预见的后果。新的测试只是移除了之前检查错误代码的最后一行:
#[test]
fn double_override_failure() {
admin::reset_state();
let session = admin::authorize_override().ok();
assert!(admin::authorize_override().err().is_some());
}
#[test]
fn double_privileged_failure() {
admin::reset_state();
let session = admin::authorize_privileged().ok();
assert!(admin::authorize_privileged().err().is_some());
}
#[test]
fn double_admin_failure() {
admin::reset_state();
let session = admin::authorize_admin().ok();
assert!(admin::authorize_admin().err().is_some());
}
防止难以重现的 bug
Rust 被特别设计来避免这种难以重现的 bug。在 Rust 中,原始指针的处理是被阻止或强烈劝阻的。原始指针就像 Rust 一无所知的引用,因此无法就其使用提供任何安全保证。不幸的是,这个 bug 是外部库内部的,因此我们的 Rust 项目没有管辖权来抱怨这里的根本问题。尽管如此,我们仍然可以遵循一些良好的实践来防止或限制与变异和奇怪的副作用相关的 bug 的发生。
我们将推荐的第一个技术是不可变性。默认情况下,所有变量都被声明为不可变。这是 Rust 以一种不太微妙的方式告诉你,如果可能的话,要避免修改值,如下所示:
fn main() {
let a = 5;
let mut b = 5;
//a = 4; not valid
b = 4;
//*(&mut a) = 3; not valid
*(&mut b) = 3;
}
不可变值不能作为可变借用(按设计),因此要求函数参数的可变性将需要从发送给它的每个值中获取可变性:
fn f(x: &mut i32) {
*x = 2;
}
fn main() {
let a = 5;
let mut b = 5;
//f(&mut a); not valid
f(&mut b);
}
将不可变值转换为可变值可能就像克隆它以创建一个新相同值那样简单;然而,正如我们在本章中看到的,克隆并不总是简单操作,以下是一个示例:
use std::sync::{Mutex, Arc};
#[derive(Clone)]
struct TimeBomb {
countdown: Arc<Mutex<i32>>
}
impl Drop for TimeBomb
{
fn drop(&mut self) {
let mut c = self.countdown.lock().unwrap();
*c -= 1;
if *c <= 0 {
panic!("BOOM!!")
}
}
}
fn main()
{
let t3 = TimeBomb {
countdown: Arc::new(Mutex::new(3))
};
let t2 = t3.clone();
let t1 = t2.clone();
let t0 = t1.clone();
}
将变量声明为不可变并不能绝对防止所有变异,无论是内部还是外部。在 Rust 中,不可变变量允许持有可变数据类型的内部字段。例如,可以使用std::cell::RefCell在它持有的任何数据上实现内部可变性。
尽管有例外,但默认使用不可变变量可以帮助防止简单 bug 变成复杂 bug。不要让你的编程风格成为负担;练习防御性软件开发。
使用纯函数
纯函数是我们推荐的第二个技术,用于防止难以重现的 bug。纯函数可以被视为避免副作用原则的扩展。纯函数的定义是一个函数,其中以下条件是真实的:
-
函数外部没有引起任何变化(没有副作用)
-
返回值只依赖于函数参数
这里有一些纯函数的例子:
fn p0() {}
fn p1() -> u64 {
444
}
fn p2(x: u64) -> u64 {
x * 444
}
fn p3(x: u64, y: u64) -> u64 {
x * 444 + y
}
fn main()
{
p0();
p1();
p2(3);
p3(3,4);
}
这里有一些不纯函数的例子:
use std::cell::Cell;
static mut blah: u64 = 3;
fn ip0() {
unsafe {
blah = 444;
}
}
fn ip1(c: &Cell<u64>) {
c.set(333);
}
fn main()
{
ip0();
let r = Cell::new(3);
ip1(&r);
ip1(&r);
}
Rust 没有任何语言特性专门指定一个函数是更纯还是更不纯。然而,正如前面的例子所展示的,Rust 在一定程度上不鼓励不纯函数。函数的纯度应该被视为一种设计模式,并且与良好的函数式风格紧密相关。
与顶级函数一样,闭包也可以是纯的或不纯的。因此,当与高级函数一起工作时,函数的纯度成为一个关注点。某些函数式编程模式期望函数是纯的。一个很好的例子是我们简要提到的第一章中的记忆化模式——“函数式编程——比较”。让我们比较一下,如果记忆化的函数是不纯的,会发生什么。
首先,这是一个关于记忆化应该如何工作的提醒:
#[macro_use] extern crate cached;
cached!{
FIB;
fn fib(n: u64) -> u64 = {
if n == 0 || n == 1 { return n }
fib(n-1) + fib(n-2)
}
}
fn main() {
fib(30); //call 1, generates correct value and returns it
fib(30); //call 2, finds correct value and returns it
}
接下来,让我们看看一个记忆化的不纯函数:
#[macro_use] extern crate lazy_static;
#[macro_use] extern crate cached;
use std::collections::HashMap;
use std::sync::Mutex;
lazy_static! {
static ref BUCKET_COUNTER: Mutex<HashMap<u64, u64>> = {
Mutex::new(HashMap::new())
};
}
cached!{
BUCK;
fn bucket_count(n: u64) -> u64 = {
let mut counter = BUCKET_COUNTER.lock().unwrap();
let r = match counter.get(&n) {
Some(c) => { c+1 }
None => { 1 }
};
counter.insert(n, r);
r
}
}
fn main() {
bucket_count(30); //call 1, generates correct value and returns it
bucket_count(30); //call 2, finds stale value and returns it
}
这个第一个缓存示例应该每次都返回相同的值。第二个示例不应该每次都返回相同的值。从语义上讲,我们不希望第二个示例返回过时的值;然而,这也意味着我们无法安全地缓存结果。这里有一个必要的性能权衡。如果必要的话,这里两个示例的纯度或杂质都没有问题。这仅仅意味着第二个示例不应该被缓存。
然而,也存在不纯的反模式。让我们看看另一个表现不佳的不纯函数:
#[macro_use] extern crate cached;
use std::sync::{Arc,Mutex};
#[derive(Clone)]
pub struct TimeBomb {
countdown: Arc<Mutex<i32>>
}
impl Drop for TimeBomb
{
fn drop(&mut self) {
let mut c = self.countdown.lock().unwrap();
*c -= 1;
if *c <= 0 {
panic!("BOOM!!")
}
}
}
cached!{
TICKING_BOX;
fn tick_tock(v: i32) -> TimeBomb = {
TimeBomb {
countdown: Arc::new(Mutex::new(v))
}
}
}
fn main() {
tick_tock(3);
tick_tock(3);
tick_tock(3);
}
在这个例子中,数据本身是不纯的。每次tick_tock都会移动和丢弃一个TimeBomb。最终,它会爆炸,我们的缓存无法帮助我们保护。希望你在你的程序中不需要处理炸弹。
摘要
在本章中,我们使用了 Rust 的遗留代码和外部库。Rust 的安全保障可能难以学习,有时使用起来也很繁琐,但快速而松散的编码方式同样令人压力山大且问题重重。
Rust 内存安全规则的一个动机是双重释放内存的概念,我们在本章中提到了这一点。然而,展示的代码并没有涉及真正的双重释放内存。真正的双重释放会导致称为未定义行为的现象。未定义行为是语言标准中用来指代会导致程序行为异常的操作的术语。双重释放的内存通常是未定义行为中最糟糕的类型之一,会导致内存损坏和随后的崩溃或难以追踪到原始原因的无效状态。
在本章的后半部分,我们考察了特定的 Rust 设计决策、特性和模式,例如所有权、不可变性和纯函数。这些都是 Rust 对抗未定义行为和其他问题的防御机制。
正确使用 Rust 的安全措施而不是规避它们有许多好处。Rust 鼓励一种有利于大型项目设计的编程风格。通常,项目架构遵循一个超过线性的错误/复杂度曲线。随着项目规模的扩大,错误和困难情况将以更快的速度增长。通过锁定常见的错误来源或代码依赖,可以开发出问题更少的大型项目。
在下一章中,我们将正式解释许多功能设计模式。这将是一个学习函数式编程原则在 Rust 中应用程度和相关性很好的机会。如果下一章中没有什么看起来酷或有用,那么作者就失败了。
问题
-
Rc代表什么? -
Arc代表什么? -
什么是弱引用?
-
在不安全块中启用了哪些超级能力?
-
对象何时会被丢弃?
-
生命周期和所有权的区别是什么?
-
你如何确保一个函数是安全的?
-
内存损坏是什么,它会如何影响程序?
第七章:设计模式
函数式编程已经发展出了类似于面向对象或其他社区的设计模式。这些模式,不出所料,利用函数作为核心概念。它们还强调了一个称为单一职责原则的概念。单一职责原则指出,程序的逻辑组件应该只做一件事,并且要做好这件事。在本章中,我们将关注几个非常常见的模式。其中一些概念非常简单,以至于它们反直觉地变得难以解释。在这些情况下,我们将使用各种示例来展示一个简单的概念如何表现出复杂的行为。
在本章中,你将执行以下操作:
-
学习识别和使用函子
-
学习识别和使用单子
-
学习识别和使用组合子
-
学习识别和使用惰性求值
技术要求
运行提供的示例需要一个较新的 Rust 版本:
www.rust-lang.org/en-US/install.html
本章的代码也可在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每个章节的 README.md 文件中也包含了具体的安装和构建说明。
使用函子模式
函子大致是函数的逆:
-
函数定义了一个转换,接受数据,并返回转换的结果
-
函子定义数据,接受一个函数,并返回转换的结果
一个简单的函子例子是 Rust 向量和其伴随的 map 函数:
fn main() {
let m: Vec<u64> = vec![1, 2, 3];
let n: Vec<u64> = m.iter().map(|x| { x*x }).collect();
println!("{:?}", m);
println!("{:?}", n);
}
由于构成函子或非函子的规则,函子通常被认为只是 map 函数。前面提到的常见情况被称为结构保持映射。函子不需要是结构保持的。例如,考虑以下代码中为集合实现的类似映射情况:
use std::collections::{HashSet};
fn main() {
let mut a: HashSet<u64> = HashSet::new();
a.insert(1);
a.insert(2);
a.insert(3);
a.insert(4);
let b: HashSet<u64> = a.iter().cloned().map(|x| x/2).collect();
println!("{:?}", a);
println!("{:?}", b);
}
我们在这里看到,由于冲突,结果集比原始集小。这个映射仍然满足函子的性质。函子的定义性质如下:
-
一组对象,
C -
一个将
C中的对象映射到D中的对象的映射函数
前面的 Set 映射满足第一和第二个性质,因此是一个合适的函子。它还展示了如何通过函子将数据转换成不同形状的结构。发挥一点想象力,我们还可以考虑每个映射值可能产生多个输出的情况:
fn main() {
let sentences = vec!["this is a sentence","paragraphs have many sentences"];
let words:Vec<&str> = sentences.iter().flat_map(|&x| x.split(" ")).collect();
println!("{:?}", sentences);
println!("{:?}", words);
}
从技术角度讲,这个最后的例子不是一个正常的函子,而是一个逆变函子。所有函子都是协变的。协变与逆变之间的区别对我们来说并不重要,所以我们把这个问题留给最好奇的读者。
作为通过例子给出的最终定义,我们应该注意函子的输入和输出不需要是同一类型。例如,我们可以从向量映射到HashSet:
use std::collections::{HashSet};
fn main() {
let v: Vec<u64> = vec![1, 2, 3];
let s: HashSet<u64> = v.iter().cloned().map(|x| x/2).collect();
println!("{:?}", v);
println!("{:?}", s);
}
为了给出一个非平凡的例子来说明函子模式如何使用,让我们看看网络摄像头和 AI。现代 AI 面部识别软件能够在图片中识别人类面孔,甚至可见的情绪状态。让我们想象一个连接到网络摄像头并使用过滤器处理输入的应用程序。以下是程序的一些类型定义:
struct WebCamera;
#[derive(Debug)]
enum VisibleEmotion {
Anger,
Contempt,
Disgust,
Fear,
Happiness,
Neutral,
Sadness,
Surprise
}
#[derive(Debug,Clone)]
struct BoundingBox {
top: u64,
left: u64,
height: u64,
width: u64
}
#[derive(Debug)]
enum CameraFilters {
Sparkles,
Rain,
Fire,
Disco
}
在WebCamera类型上,我们将实现两个函子。一个函子,map_emotion,将情绪映射到其他情绪。这可能被用来向文本聊天添加表情符号。第二个协变函子,flatmap_emotion,将情绪映射到零个或多个过滤器。这些是可以应用到网络摄像头视野中的动画或效果:
impl WebCamera {
fn map_emotion<T,F>(&self, translate: F) -> Vec<(BoundingBox,T)>
where F: Fn(VisibleEmotion) -> T {
//Simulate emotion extracted from WebCamera
vec![
(BoundingBox { top: 1, left: 1, height: 1, width: 1 }, VisibleEmotion::Anger),
(BoundingBox { top: 1, left: 1, height: 1, width: 1 }, VisibleEmotion::Sadness),
(BoundingBox { top: 4, left: 4, height: 1, width: 1 }, VisibleEmotion::Surprise),
(BoundingBox { top: 8, left: 1, height: 1, width: 1 }, VisibleEmotion::Neutral)
].into_iter().map(|(bb,emt)| {
(bb, translate(emt))
}).collect::<Vec<(BoundingBox,T)>>()
}
fn flatmap_emotion<T,F,U:IntoIterator<Item=T>>(&self, mut translate: F) -> Vec<(BoundingBox,T)>
where F: FnMut(VisibleEmotion) -> U {
//Simulate emotion extracted from WebCamera
vec![
(BoundingBox { top: 1, left: 1, height: 1, width: 1 }, VisibleEmotion::Anger),
(BoundingBox { top: 1, left: 1, height: 1, width: 1 }, VisibleEmotion::Sadness),
(BoundingBox { top: 4, left: 4, height: 1, width: 1 }, VisibleEmotion::Surprise),
(BoundingBox { top: 8, left: 1, height: 1, width: 1 }, VisibleEmotion::Neutral)
].into_iter().flat_map(|(bb,emt)| {
translate(emt).into_iter().map(move |t| (bb.clone(), t))
}).collect::<Vec<(BoundingBox,T)>>()
}
}
要使用函子,程序员需要提供哪些情绪映射到哪些过滤器。由于函子模式提供的封装,复杂的 AI 和效果可以很容易地修改:
fn main() {
let camera = WebCamera;
let emotes: Vec<(BoundingBox,VisibleEmotion)> = camera.map_emotion(|emt| {
match emt {
VisibleEmotion::Anger |
VisibleEmotion::Contempt |
VisibleEmotion::Disgust |
VisibleEmotion::Fear |
VisibleEmotion::Sadness => VisibleEmotion::Happiness,
VisibleEmotion::Neutral |
VisibleEmotion::Happiness |
VisibleEmotion::Surprise => VisibleEmotion::Sadness
}
});
let filters: Vec<(BoundingBox,CameraFilters)> = camera.flatmap_emotion(|emt| {
match emt {
VisibleEmotion::Anger |
VisibleEmotion::Contempt |
VisibleEmotion::Disgust |
VisibleEmotion::Fear |
VisibleEmotion::Sadness => vec![CameraFilters::Sparkles, CameraFilters::Rain],
VisibleEmotion::Neutral |
VisibleEmotion::Happiness |
VisibleEmotion::Surprise => vec![CameraFilters::Disco]
}
});
println!("{:?}",emotes);
println!("{:?}",filters);
}
使用单子模式
单子为一种类型定义了return和bind操作。return操作就像一个构造函数来创建单子。bind操作结合新的信息并返回一个新的单子。单子还应遵守一些定律。我们不会引用这些定律,只是说单子应该像以下这样在链式操作中表现良好:
MyMonad::return(value) //We start with a new MyMonad<A>
.bind(|x| x+x) //We take a step into MyMonad<B>
.bind(|y| y*y); //Similarly we get to MyMonad<C>
在 Rust 中,标准库中有几个半单子:
fn main()
{
let v1 = Some(2).and_then(|x| Some(x+x)).and_then(|y| Some(y*y));
println!("{:?}", v1);
let v2 = None.or_else(|| None).or_else(|| Some(222));
println!("{:?}", v2);
}
在这个例子中,正常的Option构造函数,Some或None,取代了单子的命名约定,即return。这里实现了两个半单子,一个与and_then相关联,另一个与or_else相关联。这两个都对应于单子的bind命名约定,用于将新信息结合到新的单子返回值中。
单子的bind操作也是多态的,这意味着它们应该允许从当前单子返回不同类型的单子。根据这个规则,or_else在技术上不是一个单子;因此它是一个半单子:
fn main() {
let v3 = Some(2).and_then(|x| Some("abc"));
println!("{:?}", v3);
// or_else is not quite a monad
// does not permit polymorphic bind
//let v4 = Some(2).or_else(|| Some("abc"));
//println!("{:?}", v4);
}
单子最初是为了在纯函数式语言中表达副作用而开发的。这不是一个矛盾——纯函数式语言中的副作用?
如果效果作为输入和输出通过纯函数传递,答案是否。然而,为了使这可行,每个函数都需要声明每个状态变量并将其传递,这可能会变成一个非常长的参数列表。这就是单子的作用。单子可以隐藏自身内部的状态,这本质上比程序员交互的函数更大、更复杂。
一个具体的副作用隐藏的例子是通用日志器的概念。单子的return和bind可以用来封装状态和计算,在单子中记录所有中间结果。以下是日志单子的示例:
use std::fmt::{Debug};
struct LogMonad<T>(T);
impl<T> LogMonad<T> {
fn _return(t: T) -> LogMonad<T>
where T: Debug {
println!("{:?}", t);
LogMonad(t)
}
fn bind<R,F>(&self, f: F) -> LogMonad<R>
where F: FnOnce(&T) -> R,
R: Debug {
let r = f(&self.0);
println!("{:?}", r);
LogMonad(r)
}
}
fn main() {
LogMonad::_return(4)
.bind(|x| x+x)
.bind(|y| y*y)
.bind(|z| format!("{}{}{}", z, z, z));
}
只要每个结果实现了Debug特质,就可以使用这种模式自动记录。
单子模式对于将无法用正常代码块编写的代码连接起来也非常有用。例如,代码块总是被急切地评估。如果你想定义稍后或在片段中评估的代码,懒单子模式非常方便。懒评估是一个术语,用来描述只有在被引用时才进行评估的代码或数据。这与 Rust 代码的典型急切评估相反,Rust 代码将立即执行,无论上下文如何。以下是一个懒单子模式的例子:
struct LazyMonad<A,B>(Box<Fn(A) -> B>);
impl<A: 'static,B: 'static> LazyMonad<A,B> {
fn _return(u: A) -> LazyMonad<B,B> {
LazyMonad(Box::new(move |b: B| b))
}
fn bind<C,G: 'static>(self, g: G) -> LazyMonad<A,C>
where G: Fn(B) -> C {
LazyMonad(Box::new(move |a: A| g(self.0(a))))
}
fn apply(self, a: A) -> B {
self.0(a)
}
}
fn main() {
let notyet = LazyMonad::_return(()) //we create LazyMonad<()>
.bind(|x| x+2) //and now a LazyMonad<A>
.bind(|y| y*3) //and now a LazyMonad<B>
.bind(|z| format!("{}{}", z, z));
let nowdoit = notyet.apply(222); //The above code now run
println!("nowdoit {}", nowdoit);
}
这个块定义了在提供值之后、但在之前不会逐个评估的语句。这可能看起来有点微不足道,因为我们可以用简单的闭包和代码块做到同样的事情;然而,为了使这个模式更加牢固,让我们考虑一个更复杂的案例——异步 Web 服务器。
Web 服务器通常在处理之前会接收到一个完整的 HTTP 请求。决定如何处理请求有时被称为路由。然后请求被发送到请求处理器。在以下代码中,我们定义了一个服务器,它帮助我们将路由和处理器包装成一个单一的 Web 服务器对象。以下是类型和方法定义:
use std::io::prelude::*;
use std::net::TcpListener;
use std::net::TcpStream;
struct ServerMonad<St> {
state: St,
handlers: Vec<Box<Fn(&mut St,&String) -> Option<String>>>
}
impl<St: Clone> ServerMonad<St> {
fn _return(st: St) -> ServerMonad<St> {
ServerMonad {
state: st,
handlers: Vec::new()
}
}
fn listen(&mut self, address: &str) {
let listener = TcpListener::bind(address).unwrap();
for stream in listener.incoming() {
let mut st = self.state.clone();
let mut buffer = [0; 2048];
let mut tcp = stream.unwrap();
tcp.read(&mut buffer);
let buffer = String::from_utf8_lossy(&buffer).into_owned();
for h in self.handlers.iter() {
if let Some(response) = h(&mut st,&buffer) {
tcp.write(response.as_bytes());
break
}
}
}
}
fn bind_handler<F>(mut self, f: F) -> Self
where F: 'static + Fn(&mut St,&String) -> Option<String> {
self.handlers.push(Box::new(f));
self
}
}
这种类型定义了return和bind等操作。然而,bind函数不是多态的,操作也不是一个纯函数。如果没有这些妥协,我们就需要与 Rust 的类型和所有权系统作斗争;前面的例子不是以单子方式编写的,因为在尝试装箱和复制闭包时出现了复杂性。这是一个预期的权衡,当适当的时候,半单子模式不应该被劝阻。
为了定义我们的 Web 服务器响应,我们可以像以下代码那样附加处理器:
fn main() {
ServerMonad::_return(())
.bind_handler(|&mut st, ref msg| if msg.len()%2 == 0 { Some("divisible by 2".to_string()) } else { None })
.bind_handler(|&mut st, ref msg| if msg.len()%3 == 0 { Some("divisible by 3".to_string()) } else { None })
.bind_handler(|&mut st, ref msg| if msg.len()%5 == 0 { Some("divisible by 5".to_string()) } else { None })
.bind_handler(|&mut st, ref msg| if msg.len()%7 == 0 { Some("divisible by 7".to_string()) } else { None })
.listen("127.0.0.1:8888");
}
如果你运行这个程序并向本地主机8888发送消息,那么如果消息长度能被2、3、5或7整除,你可能会收到响应。
使用组合子模式
组合子是一个函数,它接受其他函数作为参数并返回一个新的函数。
组合子的一个简单例子是组合操作符,它将两个函数连接在一起:
fn compose<A,B,C,F,G>(f: F, g: G) -> impl Fn(A) -> C
where F: 'static + Fn(A) -> B,
G: 'static + Fn(B) -> C {
move |x| g(f(x))
}
fn main() {
let fa = |x| x+1;
let fb = |y| y*2;
let fc = |z| z/3;
let g = compose(compose(fa,fb),fc);
println!("g(1) = {}", g(1));
println!("g(12) = {}", g(12));
println!("g(123) = {}", g(123));
}
解析器组合子
组合子的另一个主要应用是解析器组合子。解析器组合子利用了单子和组合子模式。单子的bind函数用于从稍后返回的解析结果中绑定数据。组合子将解析器连接成序列、故障转移或其他模式。
chomp解析器组合子库是这个概念的很好实现。此外,该库提供了一个很好的parse!宏,使得组合子逻辑更容易阅读。以下是一个例子:
#[macro_use]
extern crate chomp;
use chomp::prelude::*;
#[derive(Debug, Eq, PartialEq)]
struct Name<B: Buffer> {
first: B,
last: B,
}
fn name<I: U8Input>(i: I) -> SimpleResult<I, Name<I::Buffer>> {
parse!{i;
let first = take_while1(|c| c != b' ');
token(b' '); // skipping this char
let last = take_while1(|c| c != b'\n');
ret Name{
first: first,
last: last,
}
}
}
fn main() {
let parse_result = parse_only(name, "Martin Wernstål\n".as_bytes()).unwrap();
println!("first:{} last:{}",
String::from_utf8_lossy(parse_result.first),
String::from_utf8_lossy(parse_result.last));
}
在这里,示例定义了一个用于姓氏和名字的语法。在名字函数中,解析器使用宏定义。宏的内部看起来几乎像正常代码,如let语句、函数调用和闭包定义。然而,生成的代码实际上是单子和组合器的混合。
每个let绑定对应一个组合器。每个分号对应一个组合器。函数take_while1和token都是引入解析器单子的组合器。然后,当宏结束时,我们留下一个处理输入以解析结果的表达式。
这个chomp解析器组合库功能齐全,如果你只是随意查看源代码,可能会难以理解。为了了解这里发生了什么,让我们创建自己的解析器组合器。首先,让我们定义解析器状态:
use std::rc::Rc;
#[derive(Clone)]
struct ParseState<A: Clone> {
buffer: Rc<Vec<char>>,
index: usize,
a: A
}
impl<A: Clone> ParseState<A> {
fn new(a: A, buffer: String) -> ParseState<A> {
let buffer: Vec<char> = buffer.chars().collect();
ParseState {
buffer: Rc::new(buffer),
index: 0,
a: a
}
}
fn next(&self) -> (ParseState<A>,Option<char>) {
if self.index < self.buffer.len() {
let new_char = self.buffer[self.index];
let new_index = self.index + 1;
(ParseState {
buffer: Arc::clone(&self.buffer),
index: new_index,
a: self.a.clone()
}, Some(new_char))
} else {
(ParseState {
buffer: Rc::clone(&self.buffer),
index: self.index,
a: self.a.clone()
},None)
}
}
}
#[derive(Debug)]
struct ParseRCon<A,B>(A,Result<Option<B>,String>);
#[derive(Debug)]
enum ParseOutput<A> {
Success(A),
Failure(String)
}
在这里,我们定义了ParseState、ParseRCon和ParseResult。解析器状态跟踪解析器所在的字符索引。解析器状态通常还记录信息,如行号和列号。
ParseRCon结构封装了状态以及一个可选值,该值被封装在结果中。如果在解析过程中发生不可恢复的错误,结果将变为Err。如果在解析过程中发生可恢复的错误,选项将为None。否则,解析器应该基本上像它们期望始终有可选值一样工作。
ParseResult类型在解析执行的最后返回,以提供成功的结果或错误信息。
解析器单子和组合器使用不同的函数定义。要创建一个解析器,最简单的选项可能是parse_mzero和parse_return:
fn parse<St: Clone,A,P>(p: &P, st: &ParseState<St>) -> ParseOutput<A>
where P: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A> {
match p(st.clone()) {
ParseRCon(_,Ok(Some(a))) => ParseOutput::Success(a),
ParseRCon(_,Ok(None)) => ParseOutput::Failure("expected input".to_string()),
ParseRCon(_,Err(err)) => ParseOutput::Failure(err)
}
}
fn parse_mzero<St: Clone,A>(st: ParseState<St>) -> ParseRCon<ParseState<St>,A> {
ParseRCon(st,Err("mzero failed".to_string()))
}
fn parse_return<St: Clone,A: Clone>(a: A) -> impl (Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A>) {
move |st| { ParseRCon(st,Ok(Some(a.clone()))) }
}
fn main() {
let input1 = ParseState::new((), "1 + 2 * 3".to_string());
let input2 = ParseState::new((), "3 / 2 - 1".to_string());
let p1 = parse_mzero::<(),()>;
println!("p1 input1: {:?}", parse(&p1,&input1));
println!("p1 input2: {:?}", parse(&p1,&input2));
let p2 = parse_return(123);
println!("p2 input1: {:?}", parse(&p2,&input1));
println!("p2 input2: {:?}", parse(&p2,&input2));
}
parse_mzero单子总是失败并返回一个简单的消息。parse_return总是成功并返回一个给定的值。
为了使事情更有趣,让我们实际看看一个消耗输入的解析器。我们创建了以下两个函数——parse_token和parse_satisfy。parse_token将始终消耗一个标记并返回其值,除非没有更多输入。parse_satisfy将消耗一个标记,如果标记满足某些条件。以下是定义:
fn parse_token<St: Clone,A,T>(t: T) -> impl (Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A>)
where T: 'static + Fn(char) -> Option<A> {
move |st: ParseState<St>| {
let (next_state,next_char) = st.clone().next();
match next_char {
Some(c) => ParseRCon(next_state,Ok(t(c))),
None => ParseRCon(st,Err("end of input".to_string()))
}
}
}
fn parse_satisfy<St: Clone,T>(t: T) -> impl (Fn(ParseState<St>) -> ParseRCon<ParseState<St>,char>)
where T: 'static + Fn(char) -> bool {
parse_token(move |c| if t(c) {Some(c)} else {None})
}
fn main() {
let input1 = ParseState::new((), "1 + 2 * 3".to_string());
let input2 = ParseState::new((), "3 / 2 - 1".to_string());
let p3 = parse_satisfy(|c| c=='1');
println!("p3 input1: {:?}", parse(&p3,&input1));
println!("p3 input2: {:?}", parse(&p3,&input2));
let digit = parse_satisfy(|c| c.is_digit(10));
println!("digit input1: {:?}", parse(&digit,&input1));
println!("digit input2: {:?}", parse(&digit,&input2));
let space = parse_satisfy(|c| c==' ');
println!("space input1: {:?}", parse(&space,&input1));
println!("space input2: {:?}", parse(&space,&input2));
let operator = parse_satisfy(|c| c=='+' || c=='-' || c=='*' || c=='/');
println!("operator input1: {:?}", parse(&operator,&input1));
println!("operator input2: {:?}", parse(&operator,&input2));
}
parse_token和parse_satisfy查看一个标记。如果标记满足提供的条件,它将返回输入标记。在这里,我们创建几个条件来对应单个字符匹配、数字、空格或算术运算符。
这些函数可以使用高级组合器组合起来创建复杂的语法:
fn parse_bind<St: Clone,A,B,P1,P2,B1>(p1: P1, b1: B1)
-> impl Fn(ParseState<St>) -> ParseRCon<ParseState<St>,B>
where P1: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A>,
P2: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,B>,
B1: Fn(A) -> P2 {
move |st| {
match p1(st) {
ParseRCon(nst,Ok(Some(a))) => b1(a)(nst),
ParseRCon(nst,Ok(None)) => ParseRCon(nst,Err("bind failed".to_string())),
ParseRCon(nst,Err(err)) => ParseRCon(nst,Err(err))
}
}
}
fn parse_sequence<St: Clone,A,B,P1,P2>(p1: P1, p2: P2)
-> impl Fn(ParseState<St>) -> ParseRCon<ParseState<St>,B>
where P1: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A>,
P2: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,B> {
move |st| {
match p1(st) {
ParseRCon(nst,Ok(_)) => p2(nst),
ParseRCon(nst,Err(err)) => ParseRCon(nst,Err(err))
}
}
}
fn parse_or<St: Clone,A,P1,P2>(p1: P1, p2: P2)
-> impl Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A>
where P1: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A>,
P2: Fn(ParseState<St>) -> ParseRCon<ParseState<St>,A> {
move |st| {
match p1(st.clone()) {
ParseRCon(nst,Ok(Some(a))) => ParseRCon(nst,Ok(Some(a))),
ParseRCon(_,Ok(None)) => p2(st),
ParseRCon(nst,Err(err)) => ParseRCon(nst,Err(err))
}
}
}
fn main() {
let input1 = ParseState::new((), "1 + 2 * 3".to_string());
let input2 = ParseState::new((), "3 / 2 - 1".to_string());
let digit = parse_satisfy(|c| c.is_digit(10));
let space = parse_satisfy(|c| c==' ');
let operator = parse_satisfy(|c| c=='+' || c=='-' || c=='*' || c=='/');
let ps1 = parse_sequence(digit,space);
let ps2 = parse_sequence(ps1,operator);
println!("digit,space,operator input1: {:?}", parse(&ps2,&input1));
println!("digit,space,operator input2: {:?}", parse(&ps2,&input2));
}
这里,我们看到如何使用单子的parse_bind或其衍生物parse_sequence来串联两个解析器。这里没有示例,但失败组合器也在parse_or中定义。
使用这些原始工具,我们可以创建一些很好的工具来帮助我们生成复杂的解析器,这些解析器期望、存储和操作来自标记流的 数据。解析组合器是单子和组合器更实用但更具挑战性的应用之一。这些概念在 Rust 中成为可能的事实展示了该语言在支持函数式概念方面的发展程度。
使用惰性评估模式
惰性评估是推迟,将工作推迟到以后而不是现在。为什么这很重要?好吧,结果证明,如果你推迟足够长的时间,有时最终发现这项工作根本不需要完成!
以一个简单的表达式评估为例:
fn main()
{
2 + 3;
|| 2 + 3;
}
在严格的解释下,第一个表达式将执行一个算术计算。第二个表达式将定义一个算术计算,但会等待然后再进行评估。
这种情况如此简单,以至于编译器会发出警告,并可能选择丢弃未使用的常量表达式。在更复杂的情况下,未评估的惰性评估情况将始终表现得更好。这应该是预期的,因为未使用的惰性表达式什么也不做,这是故意的。
迭代器是惰性的。它们在你收集或以其他方式迭代它们之前不会做任何事情:
fn main() {
let a = (0..10).map(|x| x * x);
//nothing yet
for x in a {
println!("{}", x);
}
//now it ran
}
另一个故意使用惰性评估的数据结构是惰性列表。惰性列表与迭代器非常相似,除了惰性列表可以独立地共享和以不同的速度消费。
在解析组合器示例中,我们在解析器状态结构中隐藏了一个惰性列表。让我们将其隔离出来,看看一个纯定义看起来像什么:
use std::rc::Rc;
#[derive(Clone)]
struct LazyList<A: Clone> {
buffer: Rc<Vec<A>>,
index: usize
}
impl<A: Clone> LazyList<A> {
fn new(buf: Vec<A>) -> LazyList<A> {
LazyList {
buffer: Rc::new(buf),
index: 0
}
}
fn next(&self) -> Option<(LazyList<A>,A)> {
if self.index < self.buffer.len() {
let new_item = self.buffer[self.index].clone();
let new_index = self.index + 1;
Some((LazyList {
buffer: Rc::clone(&self.buffer),
index: new_index
},new_item))
} else {
None
}
}
}
fn main()
{
let ll = LazyList::new(vec![1,2,3]);
let (ll1,a1) = ll.next().expect("expect 1 item");
println!("lazy item 1: {}", a1);
let (ll2,a2) = ll1.next().expect("expect 2 item");
println!("lazy item 2: {}", a2);
let (ll3,a3) = ll2.next().expect("expect 3 item");
println!("lazy item 3: {}", a3);
let (ll2,a2) = ll1.next().expect("expect 2 item");
println!("lazy item 2: {}", a2);
}
在这里,我们可以看到惰性列表与迭代器非常相似。事实上,惰性列表可以实现 Iterator 特性;那么它就真的是一个迭代器了。然而,迭代器不是惰性列表。惰性列表本质上具有无限的前瞻能力,可以查看任意数量的项目。另一方面,迭代器可选地可以实现 Peekable 特性,允许向前查看。
尽管惰性编程的核心存在一个基本问题。过多的推迟将永远不会完成任何任务。如果你编写一个发射导弹的程序,在程序的某个时刻,它需要实际发射导弹。这是程序运行的一个不可逆的副作用。我们不喜欢副作用,而惰性编程对副作用持极端的反对态度。同时,我们还需要完成给定的任务,这涉及到在某个时刻做出选择,按下发射按钮。
显然,我们永远无法完全包含具有副作用程序的行为。然而,我们可以使它们更容易处理。通过将副作用包装到惰性评估表达式中,然后将它们转换为单子,我们创建的是副作用单元。然后我们可以以更函数式的方式对这些单元进行操作和组合。
我们将要引入的最后一种懒惰模式是函数式响应式编程,简称FRP。有一些基于这个概念的整个编程语言,例如 Elm。流行的网络 UI 框架,如 React 或 Angular,也受到了 FRP 概念的影响。
FRP 概念是副作用/状态单例示例的扩展。事件处理、状态转换和副作用可以转换为响应式编程的单位。让我们定义一个单例来捕获这个响应式单元概念:
struct ReactiveUnit<St,A,B> {
state: Arc<Mutex<St>>,
event_handler: Arc<Fn(&mut St,A) -> B>
}
impl<St: 'static,A: 'static,B: 'static> ReactiveUnit<St,A,B> {
fn new<F>(st: St, f: F) -> ReactiveUnit<St,A,B>
where F: 'static + Fn(&mut St,A) -> B
{
ReactiveUnit {
state: Arc::new(Mutex::new(st)),
event_handler: Arc::new(f)
}
}
fn bind<G,C>(&self, g: G) -> ReactiveUnit<St,A,C>
where G: 'static + Fn(&mut St,B) -> C {
let ev = Arc::clone(&self.event_handler);
ReactiveUnit {
state: Arc::clone(&self.state),
event_handler: Arc::new(move |st: &mut St,a| {
let r = ev(st,a);
let r = g(st,r);
r
})
}
}
fn plus<St2: 'static,C: 'static>(&self, other: ReactiveUnit<St2,B,C>) -> ReactiveUnit<(Arc<Mutex<St>>,Arc<Mutex<St2>>),A,C> {
let ev1 = Arc::clone(&self.event_handler);
let st1 = Arc::clone(&self.state);
let ev2 = Arc::clone(&other.event_handler);
let st2 = Arc::clone(&other.state);
ReactiveUnit {
state: Arc::new(Mutex::new((st1,st2))),
event_handler: Arc::new(move |stst: &mut (Arc<Mutex<St>>,Arc<Mutex<St2>>),a| {
let mut st1 = stst.0.lock().unwrap();
let r = ev1(&mut st1, a);
let mut st2 = stst.1.lock().unwrap();
let r = ev2(&mut st2, r);
r
})
}
}
fn apply(&self, a: A) -> B {
let mut st = self.state.lock().unwrap();
(self.event_handler)(&mut st, a)
}
}
在这里,我们发现ReactiveUnit可以持有状态,可以响应输入,产生副作用,并返回一个值。可以通过bind扩展ReactiveUnit或通过plus连接它们。
现在,让我们创建一个响应式单元。我们将关注网络框架,因为它们似乎很受欢迎。首先,我们渲染一个简单的 HTML 页面,如下所示:
let render1 = ReactiveUnit::new((),|(),()| {
let html = r###"$('body').innerHTML = '
<header>
<h3 data-section="1" class="active">Section 1</h3>
<h3 data-section="2">Section 2</h3>
<h3 data-section="3">Section 3</h3>
</header>
<div>page content</div>
<footer>Copyright</footer>
';"###;
html.to_string()
});
println!("{}", render1.apply(()));
在这里,单元渲染了一个简单的页面,对应于网站上的第一部分。这个单元将始终渲染整个页面,不考虑任何状态或输入。让我们通过告诉单元根据哪个部分是活动的来给它更多的责任:
let render2 = ReactiveUnit::new((),|(),section: usize| {
let section_1 = r###"$('body').innerHTML = '
<header>
<h3 data-section="1" class="active">Section 1</h3>
<h3 data-section="2">Section 2</h3>
<h3 data-section="3">Section 3</h3>
</header>
<div>section 1 content</div>
<footer>Copyright</footer>
';"###;
let section_2 = r###"$('body').innerHTML = '
<header>
<h3 data-section="1">Section 1</h3>
<h3 data-section="2" class="active">Section 2</h3>
<h3 data-section="3">Section 3</h3>
</header>
<div>section 2 content</div>
<footer>Copyright</footer>
';"###;
let section_3 = r###"$('body').innerHTML = '
<header>
<h3 data-section="1">Section 1</h3>
<h3 data-section="2">Section 2</h3>
<h3 data-section="3" class="active">Section 3</h3>
</header>
<div>section 3 content</div>
<footer>Copyright</footer>
';"###;
if section==1 {
section_1.to_string()
} else if section==2 {
section_2.to_string()
} else if section==3 {
section_3.to_string()
} else {
panic!("unknown section")
}
});
println!("{}", render2.apply(1));
println!("{}", render2.apply(2));
println!("{}", render2.apply(3));
在这里,单元使用参数来决定应该渲染哪个部分。这开始感觉更像是一个 UI 框架,但我们还没有使用状态。让我们尝试使用它来解决一个常见的网络问题——页面撕裂。当网页上的大量 HTML 发生变化时,浏览器必须重新计算页面应该如何显示。大多数现代浏览器都是分阶段进行这一操作的,结果是组件在页面上被明显地扔来扔去,显得很丑陋。
为了减少或防止页面撕裂,我们应只更新已更改的页面部分。让我们使用状态变量和输入参数,仅在组件发生变化时发送更新:
let render3header = ReactiveUnit::new(None,|opsec: &mut Option<usize>,section: usize| {
let section_1 = r###"$('header').innerHTML = '
<h3 data-section="1" class="active">Section 1</h3>
<h3 data-section="2">Section 2</h3>
<h3 data-section="3">Section 3</h3>
';"###;
let section_2 = r###"$('header').innerHTML = '
<h3 data-section="1">Section 1</h3>
<h3 data-section="2" class="active">Section 2</h3>
<h3 data-section="3">Section 3</h3>
';"###;
let section_3 = r###"$('header').innerHTML = '
<h3 data-section="1">Section 1</h3>
<h3 data-section="2">Section 2</h3>
<h3 data-section="3" class="active">Section 3</h3>
';"###;
let changed = if section==1 {
section_1
} else if section==2 {
section_2
} else if section==3 {
section_3
} else {
panic!("invalid section")
};
if let Some(sec) = *opsec {
if sec==section { "" }
else {
*opsec = Some(section);
changed
}
} else {
*opsec = Some(section);
changed
}
});
在这里,我们发出命令以有条件地渲染标题的变化。如果标题已经处于正确的状态,则不执行任何操作。此代码仅负责标题组件。我们还需要渲染页面内容的变化:
let render3content = ReactiveUnit::new(None,|opsec: &mut Option<usize>,section: usize| {
let section_1 = r###"$('div#content').innerHTML = '
section 1 content
';"###;
let section_2 = r###"$('div#content').innerHTML = '
section 2 content
';"###;
let section_3 = r###"$('div#content').innerHTML = '
section 3 content
';"###;
let changed = if section==1 {
section_1
} else if section==2 {
section_2
} else if section==3 {
section_3
} else {
panic!("invalid section")
};
if let Some(sec) = *opsec {
if sec==section { "" }
else {
*opsec = Some(section);
changed
}
} else {
*opsec = Some(section);
changed
}
});
现在,我们有一个用于标题的组件和另一个用于内容的组件。我们应该将这两个组件合并成一个单元。FRP 库可能有一个很酷的整洁方法来做这件事,但我们没有;所以,我们只是编写了一个小单元来手动合并它们:
let render3 = ReactiveUnit::new((render3header,render3content), |(rheader,rcontent),section: usize| {
let header = rheader.apply(section);
let content = rcontent.apply(section);
format!("{}{}", header, content)
});
现在,让我们测试一下:
println!("section 1: {}", render3.apply(1));
println!("section 2: {}", render3.apply(2));
println!("section 2: {}", render3.apply(2));
println!("section 3: {}", render3.apply(3));
每个apply都会发出适当的新的更新命令。再次渲染第二部分的冗余apply不会返回任何命令,正如预期的那样。这实际上是一种懒惰的代码;是好的懒惰。
没有事件处理,响应式编程会是什么样子?让我们处理一些信号和事件。在页面状态之上,让我们引入一些数据库交互:
let database = ("hello world", 5, 2);
let react1 = ReactiveUnit::new((database,render3), |(database,render),evt:(&str,&str)| {
match evt {
("header button click",n) => render.apply(n.parse::<usize>().unwrap()),
("text submission",s) => { database.0 = s; format!("db.textfield1.set(\"{}\")",s) },
("number 1 submission",n) => { database.1 += n.parse::<i32>().unwrap(); format!("db.numfield1.set(\"{}\")",database.1) },
("number 2 submission",n) => { database.2 += n.parse::<i32>().unwrap(); format!("db.numfield2.set(\"{}\")",database.2) },
_ => "".to_string()
}
});
println!("react 1: {}", react1.apply(("header button click","2")));
println!("react 1: {}", react1.apply(("header button click","2")));
println!("react 1: {}", react1.apply(("text submission","abc def")));
println!("react 1: {}", react1.apply(("number 1 submission","123")));
println!("react 1: {}", react1.apply(("number 1 submission","234")));
println!("react 1: {}", react1.apply(("number 2 submission","333")));
println!("react 1: {}", react1.apply(("number 2 submission","222")));
我们定义了四种事件类型以进行响应。响应页面状态变化仍然像之前定义的那样工作。应该与数据库交互的事件会发出命令以在本地和远程更新数据库。输出 JavaScript 的视图如下所示:
event: ("header button click", "2")
$('header').innerHTML = '
<h3 data-section="1">Section 1</h3>
<h3 data-section="2" class="active">Section 2</h3>
<h3 data-section="3">Section 3</h3>
';$('div#content').innerHTML = '
section 2 content
';
event: ("header button click", "2")
event: ("text submission", "abc def")
db.textfield1.set("abc def")
event: ("number 1 submission", "123")
db.numfield1.set("128")
event: ("number 1 submission", "234")
db.numfield1.set("362")
event: ("number 2 submission", "333")
db.numfield2.set("335")
event: ("number 2 submission", "222")
db.numfield2.set("557")
这个对应关系展示了如何将简单的副作用单元组合起来以创建复杂的程序行为。这一切都是从一个少于 50 行代码的 FRP 库中构建的。想象一下增加几个辅助函数的潜在效用。
摘要
在本章中,我们介绍了许多常见的函数式设计模式。我们使用了大量令人畏惧的词汇,如函子、单子和组合子。你应该努力记住这些词汇及其含义。其他令人畏惧的词汇,如逆变,除非你想追求数学,否则你可能可以忘记。
在应用场景中,我们了解到函子可以隐藏信息以暴露对数据的简单转换。单子模式允许我们将顺序操作转换为计算单元。单子可以用来创建也表现得像列表的迭代器。惰性求值可以用来延迟计算。此外,这些模式通常可以以有用的方式组合,例如 FRP,它作为开发用户界面和其他复杂交互程序的工具而越来越受欢迎。
在下一章中,我们将探讨并发。我们将介绍 Rust 的线程/数据所有权、共享同步数据和消息传递的概念。线程级别的并发是 Rust 特别设计用于的功能。如果你在其他语言中处理过线程,那么下一章可能会给你带来鼓舞。
问题
-
什么是函子?
-
逆变函子是什么?
-
什么是单子?
-
单子法则是什么?
-
什么是组合子?
-
为什么闭包返回值需要使用
impl关键字? -
惰性求值是什么?
第八章:实现并发
并发是指同时做两件事的行为。在单核处理器上,这意味着 多任务处理。在多任务处理时,操作系统将在运行进程之间切换,以便每个进程都能获得处理器使用的时间份额。在多核处理器上,并发进程可以同时运行。
在本章中,我们将探讨不同的并发模型。其中一些工具与实际应用相关,而其他工具则更多用于教育目的。在这里,我们推荐并解释了并发中的线程模型。此外,我们还将解释如何使用函数式设计模式使开发有效使用并发的程序变得更加容易。
学习成果将包括以下内容:
-
适当地识别并应用子进程并发
-
理解 nix 分叉并发模型及其优势
-
适当地识别并应用线程并发
-
理解 Rust 原始
Send和Sync特性 -
识别并应用演员设计模式
技术要求
运行提供的示例需要 Rust 的最新版本:
www.rust-lang.org/en-US/install.html
本章的代码也可在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每个章节的 README.md 文件中也包含了具体的安装和构建说明。
使用子进程并发
子进程是从另一个进程中启动的命令。作为一个简单的例子,让我们创建一个有三个子进程的父进程。process_a 将是父进程。考虑以下代码片段:
use std::process::Command;
use std::env::current_exe;
fn main() {
let path = current_exe()
.expect("could not find current executable");
let path = path.with_file_name("process_b");
let mut children = Vec::new();
for _ in 0..3 {
children.push(
Command::new(path.as_os_str())
.spawn()
.expect("failed to execute process")
);
}
for mut c in children {
c.wait()
.expect("failed to wait on child");
}
}
子进程 process_b 运行一个循环并打印其自己的进程 ID。如下所示:
use std::{thread,time};
use std::process;
fn main() {
let t = time::Duration::from_millis(1000);
loop {
println!("process b #{}", process::id());
thread::sleep(t);
}
}
如果你运行 process_a,那么你将看到来自三个 process_b 进程的输出:
process b #54061
process b #54060
process b #54059
process b #54061
process b #54059
process b #54060
如果你从 process_a 开始检查进程树,那么你将发现三个 process_b 进程作为子进程附加,如下面的代码所示:
$ ps -a | grep process_a
54058 ttys001 0:00.00 process_a
55093 ttys004 0:00.00 grep process_a
$ pstree 54058
54058 process_a
> 54059 process_b
> 54060 process_b
> 54061 process_b
检查进程树的先前命令需要 Unix-like 命令提示符。然而,子进程模块本身基本上是平台无关的。
子进程并发对于想要运行和管理其他项目或实用程序非常有用。子进程并发做得很好的一个例子是 cron 实用程序。cron 接受一个配置文件,该文件指定了要运行的不同命令以及运行它们的时间表。cron 会持续在后台运行,并在适当的时间根据时间表启动每个配置的进程。
子进程并发通常不适合并行计算。使用 subprocess::Command 接口时,父进程和子进程之间不会共享任何资源。此外,这些进程之间难以轻松共享信息。
理解 nix 分叉并发
在 1995 年将线程作为 POSIX 操作系统的标准之前,可用的最佳并发选项是fork。在这些操作系统中,fork是一个相当原始的命令,允许程序作为子进程创建自己的副本。fork这个名字来源于将一个进程分成两个的想法。
fork不是平台无关的,具体来说,它在 Windows 上不可用,我们建议使用线程。然而,出于教育目的,介绍一些fork的概念是有帮助的,因为这些概念也与线程编程相关。
以下代码是将先前的process_a、process_b示例翻译成使用fork的代码:
extern crate nix;
use nix::unistd::{fork,ForkResult};
use std::{thread,time};
use std::process;
fn main() {
let mut children = Vec::new();
for _ in 0..3 {
match fork().expect("fork failed") {
ForkResult::Parent{ child: pid } => { children.push(pid); }
ForkResult::Child => {
let t = time::Duration::from_millis(1000);
loop {
println!("child process #{}", process::id());
thread::sleep(t);
}
}
}
}
let t = time::Duration::from_millis(1000);
loop {
println!("parent process #{}", process::id());
thread::sleep(t);
}
}
在这个例子中,父-子关系与我们的第一个例子非常相似。我们有三个子进程在运行,一个父进程在管理它们。
应该注意的是,初始时,被fork的进程共享内存。只有当任一进程修改其内存时,操作系统才会执行一个称为写时复制的操作,复制内存。这种行为是运行进程之间共享内存的第一步。
为了演示写时复制,让我们分配 200 MB 的内存并fork 500 个进程。如果没有写时复制,这将需要 100 GB 的内存,并且会崩溃大多数个人电脑。考虑以下代码:
extern crate nix;
use nix::unistd::{fork};
use std::{thread,time};
fn main() {
let mut big_data: Vec<u8> = Vec::with_capacity(200000000);
big_data.push(1);
big_data.push(2);
big_data.push(3);
//Both sides of the fork, will continue to fork
//This is called a fork bomb
for _ in 0..9 {
fork().expect("fork failed");
}
//2⁹ = 512
let t = time::Duration::from_millis(1000);
loop {
//copy on write, not on read
big_data[2];
thread::sleep(t);
}
}
父进程的许多资源也仍然可用,并且可以从子进程中安全地使用。这对于在父进程中监听套接字并在子进程中轮询传入连接的服务器应用程序非常有用。这个简单的技巧允许服务器应用程序在工作进程之间分配工作:
extern crate nix;
use nix::unistd::{fork,ForkResult};
use std::{thread,time};
use std::process;
use std::io::prelude::*;
use std::net::TcpListener;
fn serve(listener: TcpListener) -> ! {
for stream in listener.incoming() {
let mut buffer = [0; 2048];
let mut tcp = stream.unwrap();
tcp.read(&mut buffer).expect("tcp read failed");
let response = format!("respond from #{}\n", process::id());
tcp.write(response.as_bytes()).expect("tcp write failed");
}
panic!("unreachable");
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8888").unwrap();
let mut children = Vec::new();
for _ in 0..3 {
match fork().expect("fork failed") {
ForkResult::Parent{ child: pid } => { children.push(pid); }
ForkResult::Child => { serve(listener) }
}
}
let t = time::Duration::from_millis(1000);
loop {
thread::sleep(t);
}
}
在这个例子中,我们开始监听端口号8888的连接。然后,在三次fork之后,我们开始用我们的工作进程提供响应。向服务器发送请求,我们可以确认确实有多个进程在竞争提供服务。考虑以下代码:
$ curl 'http://localhost:8888/'
respond from #59485
$ curl 'http://localhost:8888/'
respond from #59486
$ curl 'http://localhost:8888/'
respond from #59487
$ curl 'http://localhost:8888/'
respond from #59485
$ curl 'http://localhost:8888/'
respond from #59486
所有三个工作进程至少提供了一次响应。结合内存共享的第一种策略和这种新的内置负载均衡概念,fork进程有效地解决了许多需要并发性的常见问题。
然而,fork并发模型非常僵化。这两个技巧都需要在分配资源后战略性地fork应用程序。一旦进程被分割,fork就完全无助于解决问题。在 POSIX 中,已经创建了额外的标准来解决这一问题。通过通道发送信息或共享内存是一种常见的模式,就像在 Rust 中一样。然而,这些解决方案没有一个像线程那样实用。
线程隐式地允许进程间消息传递和内存共享。线程的风险是,共享消息或内存可能不是线程安全的,可能会导致内存损坏。Rust 从头开始构建,以使线程编程更安全。
使用线程并发
Rust 线程具有以下特性:
-
共享内存
-
共享资源,例如文件或套接字
-
通常具有线程安全性
-
支持线程间消息传递
-
具有平台无关性
由于上述原因,我们建议 Rust 线程比子进程更适合大多数并发用例。如果您想分发计算、绕过阻塞操作或以其他方式利用并发来为您的应用程序提供服务——请使用线程。
为了展示线程模式,我们可以重新实现前面的示例。以下是三个子线程:
use std::{thread,time};
use std::process;
extern crate thread_id;
fn main() {
for _ in 0..3 {
thread::spawn(|| {
let t = time::Duration::from_millis(1000);
loop {
println!("child thread #{}:{}", process::id(),
thread_id::get());
thread::sleep(t);
}
});
}
let t = time::Duration::from_millis(1000);
loop {
println!("parent thread #{}:{}", process::id(),
thread_id::get());
thread::sleep(t);
}
}
在这里,我们启动了三个线程,并让它们运行。我们打印进程 ID,但我们必须也打印线程 ID,因为线程共享相同的进程 ID。以下是演示这一点的输出:
parent thread #59804:140735902303104
child thread #59804:123145412530176
child thread #59804:123145410420736
child thread #59804:123145408311296
parent thread #59804:140735902303104
child thread #59804:123145410420736
child thread #59804:123145408311296
下一个要移植的例子是 500 个进程和共享内存。在一个线程程序中,共享可能看起来像以下代码片段:
use std::{thread,time};
use std::sync::{Mutex, Arc};
fn main() {
let mut big_data: Vec<u8> = Vec::with_capacity(200000000);
big_data.push(1);
big_data.push(2);
big_data.push(3);
let big_data = Arc::new(Mutex::new(big_data));
for _ in 0..512 {
let big_data = Arc::clone(&big_data);
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let d = big_data.lock().unwrap();
(*d)[2];
thread::sleep(t);
}
});
}
let t = time::Duration::from_millis(1000);
loop {
thread::sleep(t);
}
}
进程启动了 500 个线程,它们共享相同的内存。此外,多亏了锁,如果我们想修改这个内存,我们可以安全地这样做。
让我们尝试以下代码所示的服务器示例:
use std::{thread,time};
use std::process;
extern crate thread_id;
use std::io::prelude::*;
use std::net::{TcpListener,TcpStream};
use std::sync::{Arc,Mutex};
fn serve(incoming: Arc<Mutex<Vec<TcpStream>>>) {
let t = time::Duration::from_millis(10);
loop {
{
let mut incoming = incoming.lock().unwrap();
for stream in incoming.iter() {
let mut buffer = [0; 2048];
let mut tcp = stream;
tcp.read(&mut buffer).expect("tcp read failed");
let response = format!("respond from #{}:{}\n",
process::id(), thread_id::get());
tcp.write(response.as_bytes()).expect("tcp write failed");
}
incoming.clear();
}
thread::sleep(t);
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8888").unwrap();
let incoming = Vec::new();
let incoming = Arc::new(Mutex::new(incoming));
for _ in 0..3 {
let incoming = Arc::clone(&incoming);
thread::spawn(move || {
serve(incoming);
});
}
for stream in listener.incoming() {
let mut incoming = incoming.lock().unwrap();
(*incoming).push(stream.unwrap());
}
}
在这里,三个工作进程从父进程那里抓取请求队列,这些请求由父进程提供服务。所有三个子进程和父进程都需要读取和修改请求队列。为了修改请求队列,每个线程都必须锁定数据。这里有一个子进程和父进程之间的舞蹈,以避免长时间持有锁。如果一个线程垄断了锁定的资源,那么所有其他想要使用数据的进程都必须等待。
锁定和等待的权衡被称为竞争。在最坏的情况下,两个线程可以各自持有锁,同时等待另一个线程释放它持有的锁。这被称为死锁。
竞争是与可变共享状态相关的一个难题。对于前面的服务器案例,向子线程发送消息会更好。消息传递不会创建锁。
下面是一个无锁服务器的示例:
use std::{thread,time};
use std::process;
use std::io::prelude::*;
extern crate thread_id;
use std::net::{TcpListener,TcpStream};
use std::sync::mpsc::{channel,Receiver};
use std::collections::VecDeque;
fn serve(receiver: Receiver<TcpStream>) {
let t = time::Duration::from_millis(10);
loop {
let mut tcp = receiver.recv().unwrap();
let mut buffer = [0; 2048];
tcp.read(&mut buffer).expect("tcp read failed");
let response = format!("respond from #{}:{}\n", process::id(),
thread_id::get());
tcp.write(response.as_bytes()).expect("tcp write failed");
thread::sleep(t);
}
}
fn main() {
let listener = TcpListener::bind("127.0.0.1:8888").unwrap();
let mut channels = VecDeque::new();
for _ in 0..3 {
let (sender, receiver) = channel();
channels.push_back(sender);
thread::spawn(move || {
serve(receiver);
});
}
for stream in listener.incoming() {
let round_robin = channels.pop_front().unwrap();
round_robin.send(stream.unwrap()).unwrap();
channels.push_back(round_robin);
}
}
在这种情况下,通道工作得更好。这个多线程服务器由父进程控制负载均衡,并且不受锁竞争的影响。
通道并不严格优于共享状态。例如,合法的竞争性资源用锁来处理是很好的。考虑以下代码片段:
use std::{thread,time};
extern crate rand;
use std::sync::{Arc,Mutex};
#[macro_use] extern crate lazy_static;
lazy_static! {
static ref NEURAL_NET_WEIGHTS: Vec<Arc<Mutex<Vec<f64>>>> = {
let mut nn = Vec::with_capacity(10000);
for _ in 0..10000 {
let mut mm = Vec::with_capacity(100);
for _ in 0..100 {
mm.push(rand::random::<f64>());
}
let mm = Arc::new(Mutex::new(mm));
nn.push(mm);
}
nn
};
}
fn train() {
let t = time::Duration::from_millis(100);
loop {
for _ in 0..100 {
let update_position = rand::random::<u64>() % 1000000;
let update_column = update_position / 10000;
let update_row = update_position % 100;
let update_value = rand::random::<f64>();
let mut update_column = NEURAL_NET_WEIGHTS[update_column as usize].lock().unwrap();
update_column[update_row as usize] = update_value;
}
thread::sleep(t);
}
}
fn main() {
let t = time::Duration::from_millis(1000);
for _ in 0..500 {
thread::spawn(train);
}
loop {
thread::sleep(t);
}
}
在这里,我们有一个大的可变数据结构(一个神经网络),它被分解成行和列。每一列都有一个线程安全的锁。行数据都与同一个锁相关联。这种模式对于数据密集型和计算密集型程序非常有用。神经网络训练是这种技术可能相关的良好例子。不幸的是,代码并没有实现一个实际的神经网络,但它确实展示了如何使用锁并发来实现这一点。
理解 Send 和 Sync 特性
在之前的神经网络示例中,我们使用了一个静态数据结构,它在没有包裹在计数器或锁中时在多个线程间共享。它包含锁,但为什么外部的数据结构被允许共享?
为了回答这个问题,让我们首先回顾一下所有权规则:
-
Rust 中的每个值都有一个称为其 所有者 的变量
-
每次只能有一个所有者
-
当所有者超出作用域时,其值将被丢弃
在这些规则的基础上,让我们尝试在多个线程间共享一个变量,如下所示:
use std::thread;
fn main() {
let a = vec![1, 2, 3];
thread::spawn(|| {
println!("a = {:?}", a);
});
}
如果我们尝试编译这个,那么我们会得到一个错误,抱怨以下内容:
closure may outlive the current function, but it borrows `a`, which is owned by the current function
这个错误指示以下内容:
-
在闭包内部引用变量
a是可以的 -
变量
a的生命周期比闭包长
发送到线程的闭包必须具有静态生命周期。变量 a 是局部变量,因此它将在静态闭包之前超出作用域。
为了修复这个错误,通常会将变量 a 移动到闭包中。因此,a 将继承闭包相同的生命周期:
use std::thread;
fn main() {
let a = vec![1, 2, 3];
thread::spawn(move || {
println!("a = {:?}", a);
});
}
这个程序可以编译并运行。变量 a 的所有权被转移到闭包中,因此避免了生命周期问题。需要注意的是,转移变量的所有权意味着原始变量不再有效。这是由所有权规则编号 2——一次只能有一个所有者所导致的。
如果我们再次尝试共享变量,我们会得到一个错误:
use std::thread;
fn main() {
let a = vec![1, 2, 3];
thread::spawn(move || {
println!("a = {:?}", a);
});
thread::spawn(move || {
println!("a = {:?}", a);
});
}
编译这个会给我们这个错误信息:
$ rustc t.rs
error[E0382]: capture of moved value: `a`
--> t.rs:11:28
|
6 | thread::spawn(move || {
| ------- value moved (into closure) here
...
11 | println!("a = {:?}", a);
| ^ value captured here after move
|
= note: move occurs because `a` has type `std::vec::Vec<i32>`, which does not implement the `Copy` trait
error: aborting due to previous error
For more information about this error, try `rustc --explain E0382`.
这个编译器错误有点复杂。它说以下内容:
-
移动值的捕获:
a -
这里移动的值(进入闭包)
-
在这里捕获的移动后的值
-
注意——移动发生是因为
a没有实现Copy特性
错误信息中的第四部分告诉我们,如果 a 实现了 Copy 特性,那么我们就不会有这个错误。然而,那将隐式地为我们复制变量,这意味着我们不会共享数据。所以,这个建议对我们来说没有用。
主要问题是第一部分——移动值的捕获 a:
-
首先,我们将变量
a移动到第一个闭包中。我们需要这样做以避免生命周期问题并使用该变量。在闭包中使用变量称为 捕获。 -
接下来我们在第二个闭包中使用变量
a。这是移动后的值捕获。
所以我们的问题是移动变量 a 使其对于进一步使用无效。这个问题的一个更简单的例子如下:
fn main() {
let a = vec![1, 2, 3];
let b = a;
}
通过将 a 中的值的所有权移动到 b,我们使原始变量无效。
那我们该怎么办?我们卡住了吗?
在神经网络示例中,我们使用了一个共享的数据结构,所以显然必须有一种方法。如果有方法,希望也有规则来解释这个问题。要完全理解 Rust 中的线程安全规则,你必须理解三个概念——作用域、Send 和 Sync。
首先,让我们解决作用域问题。线程的作用域意味着使用的变量必须允许捕获它们所使用的变量。变量可以通过值、引用或可变引用来捕获。
我们的第一个例子,没有使用 move,几乎成功了。唯一的问题是,我们使用的变量的生命周期过早地超出了作用域。所有线程闭包都必须具有静态生命周期,因此它们捕获的变量也必须具有静态生命周期。对此进行调整,我们可以创建一个简单的双线程程序,通过引用捕获我们的变量 A,因此不需要移动变量:
use std::thread;
fn main() {
static A: [u8; 100] = [22; 100];
thread::spawn(|| {
A[3];
});
thread::spawn(|| {
A[3]
});
}
从静态变量中读取是安全的。修改静态变量是不安全的。静态变量也不允许直接分配堆内存,因此它们可能难以处理。
使用 lazy_static 包是一个创建具有内存分配和初始化需求的静态变量的好方法:
use std::thread;
#[macro_use] extern crate lazy_static;
lazy_static! {
static ref A: Vec<u32> = {
vec![1, 2, 3]
};
}
fn main() {
thread::spawn(|| {
A[1];
});
thread::spawn(|| {
A[2];
});
}
解决作用域问题的第二种方法是使用引用计数器,例如 Arc。在这里,我们使用 Arc 而不是 Rc,因为 Arc 是线程安全的,而 Rc 不是。考虑以下代码:
use std::thread;
use std::sync::{Arc};
fn main() {
let a = Arc::new(vec![1, 2, 3]);
{
let a = Arc::clone(&a);
thread::spawn(move || {
a[1];
});
}
{
let a = Arc::clone(&a);
thread::spawn(move || {
a[1];
});
}
}
引用计数器将引用移动到闭包中。然而,内部数据是共享的,因此可以引用公共数据。
如果共享数据应该被修改,那么一个 Mutex 锁可以允许线程安全的锁定。另一个有用的锁是 std::sync::RwLock。如下所示:
use std::thread;
use std::sync::{Arc,Mutex};
fn main() {
let a = Arc::new(Mutex::new(vec![1, 2, 3]));
{
let a = Arc::clone(&a);
thread::spawn(move || {
let mut a = a.lock().unwrap();
(*a)[1] = 2;
});
}
{
let a = Arc::clone(&a);
thread::spawn(move || {
let mut a = a.lock().unwrap();
(*a)[1] = 3;
});
}
}
那么为什么在锁定之后允许修改,而在锁定之前不允许呢?答案是 Send 和 Sync。
Send 和 Sync 是标记特性。标记特性不实现任何功能;然而,它表明一个类型具有某些属性。这两个属性告诉编译器在数据线程间共享时应允许哪些行为。
这些是关于线程数据共享的规则:
-
如果将一个类型安全地发送到另一个线程,则该类型是
Send。 -
如果一个类型可以在多个线程间安全地共享,则该类型是
Sync。
要创建可以在多个线程间共享的可变数据,无论数据类型如何,你必须实现 Sync。标准 Rust 库有一些线程安全的并发原语,如 Mutex,用于此目的。如果你不喜欢可用的选项,那么你可以搜索另一个包或自己创建一个。
要为类型实现 Sync,只需实现没有主体的特性:
use std::thread;
struct MyBox(u8);
unsafe impl Send for MyBox {}
unsafe impl Sync for MyBox {}
static A: MyBox = MyBox(22);
fn main() {
thread::spawn(move || {
A.0
});
thread::spawn(move || {
A.0
});
}
警告——错误地实现 Send 或 Sync 可能会导致未定义的行为。这些特性总是不安全的实现。幸运的是,这两个标记特性通常由编译器自动推导,所以你很少需要手动推导它们。
在心中牢记这些各种规则,我们可以看到 Rust 如何防止许多常见的线程错误。首先,所有权系统防止了许多问题。然后,为了允许一些线程间的通信,我们发现通道和锁可以帮助安全地实现大多数并发模型。
这需要进行大量的试错,但总的来说,我们了解到thread、move、channel、Arc和Mutex将帮助我们解决大多数问题。
使用函数式设计进行并发
并发迫使程序员更加注意信息共享。这种困难偶然地鼓励了良好的函数式编程实践,如不可变数据和纯函数;当计算不是上下文相关时,它往往也是线程安全的。
函数式编程听起来非常适合并发,但有没有缺点?
在一个意图良好但效果不佳的例子中,在开发名为Haskell的函数式语言期间,开发团队(www.infoq.com/interviews/armstrong-peyton-jones-erlang-haskell)希望通过并发来使程序运行得更快。由于 Haskell 语言的独特特性,可以在新线程中运行所有表达式和子表达式。开发团队认为这听起来很棒,并进行了测试。
结果是,花费在创建新线程上的时间比进行任何计算的时间还要多。这个想法本身还是有价值的,但最终证明实现自动并发是困难的。并发编程中有许多权衡。让程序员就这些权衡做出决策是当前最先进的状态。
因此,从函数式编程来看,哪些模式被证明是有用的?
并发编程有许多模式,但在这里我们将介绍一些基本模式:
-
actor:线程和行为模式
-
监督者:监控和管理 actor
-
路由器:在 actor 之间发送消息
-
monad:可组合的行为单元
首先,让我们看看以下代码中的 actor:
use std::thread;
use std::sync::mpsc::{channel};
use std::time;
fn main() {
let (pinginsend,pinginrecv) = channel();
let (pingoutsend,pingoutrecv) = channel();
let mut ping = 1;
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let n = pinginrecv.recv().unwrap();
ping += n;
println!("ping {}", ping);
thread::sleep(t);
pingoutsend.send(ping).unwrap();
}
});
let (ponginsend,ponginrecv) = channel();
let (pongoutsend,pongoutrecv) = channel();
let mut pong = 2;
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let n = ponginrecv.recv().unwrap();
pong += n;
println!("pong {}", pong);
thread::sleep(t);
pongoutsend.send(pong).unwrap();
}
});
let mut d = 3;
loop {
pinginsend.send(d).unwrap();
d = pingoutrecv.recv().unwrap();
ponginsend.send(d).unwrap();
d = pongoutrecv.recv().unwrap();
}
}
这里我们有两个线程在相互发送消息。这真的和之前的任何例子有很大不同吗?
在函数式编程中有一个相当常见的说法:“闭包是穷人的对象,而对象是穷人的闭包”。
根据面向对象编程,对象有类型、字段和方法。我们定义的闭包持有它们自己的可变状态,就像对象上的字段一样。ping 和 pong 闭包有略微不同的类型。闭包内的行为可以被视为闭包对象上的一个无名称方法。在这里,对象和闭包之间有相似之处。
然而,使用普通对象会更好一些。尝试这样做的问题在于线程边界会阻碍操作。线程不暴露方法,只进行消息传递。作为一个折衷方案,我们可以将消息传递封装成方法的形式。这将隐藏所有的通道管理,使得使用并发对象进行编程更加方便。我们将这种模式称为 actor 模型。
演员与 OOP 对象非常相似,额外的一个属性是它生活在自己的线程中。消息被发送到演员,演员处理消息,并可能发送出自己的一些消息。演员模型就像一个繁忙的城市,人们生活在其中,从事不同的工作,但根据他们自己的时间表相互交流和交换。
有一些箱子试图提供优雅的并发演员行为,但我们不会特别推荐任何一种。目前,请只是眯起眼睛,继续假装闭包与对象相似。
在下一个例子中,让我们将这些演员包装成函数,以便更容易地创建它们:
use std::thread;
use std::sync::mpsc::{channel,Sender,Receiver};
use std::time;
extern crate rand;
fn new_ping() -> (Sender<u64>, Receiver<u64>) {
let (pinginsend,pinginrecv) = channel();
let (pingoutsend,pingoutrecv) = channel();
let mut ping = 1;
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let n = pinginrecv.recv().unwrap();
ping += n;
println!("ping {}", ping);
thread::sleep(t);
pingoutsend.send(ping).unwrap();
}
});
(pinginsend, pingoutrecv)
}
fn new_pong() -> (Sender<u64>, Receiver<u64>) {
let (ponginsend,ponginrecv) = channel();
let (pongoutsend,pongoutrecv) = channel();
let mut pong = 2;
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let n = ponginrecv.recv().unwrap();
pong += n;
println!("pong {}", pong);
thread::sleep(t);
pongoutsend.send(pong).unwrap();
}
});
(ponginsend, pongoutrecv)
}
要运行示例,我们将创建每种类型的三个演员,并将通道存储在一个向量中,如下面的代码所示:
fn main() {
let pings = vec![new_ping(), new_ping(), new_ping()];
let pongs = vec![new_pong(), new_pong(), new_pong()];
loop {
let mut d = 3;
let (ref pingin,ref pingout) = pings[(rand::random::<u64>() % 3) as usize];
pingin.send(d).unwrap();
d = pingout.recv().unwrap();
let (ref pongin,ref pongout) = pongs[(rand::random::<u64>() % 3) as usize];
pongin.send(d).unwrap();
pongout.recv().unwrap();
}
}
现在,我们为每个演员组有了演员和一个非常基本的监督者。这里的监督者只是一个向量,用于跟踪每个演员的通信通道。一个好的监督者应该定期检查每个演员的健康状况,杀死不良演员,并补充良好演员的库存。
我们将要提到的最后一个基于角色的原始方法是路由。路由是面向对象编程的方法等价物。OOP 方法调用最初被称为 消息传递。演员模型非常面向对象,因此我们仍然通过实际传递消息来调用方法。我们仍在使用穷人的对象(闭包),所以我们的路由可能看起来像是一个美化过的 if 语句。
要启动我们的演员路由器,我们将定义两种数据类型——地址和消息。地址应该定义消息的所有可能目的地和路由行为。消息应该对应于所有演员的所有可能方法调用。以下是我们的扩展乒乓应用:
use std::thread;
use std::sync::mpsc::{channel,Sender,Receiver};
use std::time;
extern crate rand;
enum Address {
Ping,
Pong
}
enum Message {
PingPlus(u64),
PongPlus(u64),
}
然后,我们定义我们的演员。现在,它们需要与新的 Message 类型匹配,并且发出的消息应该有一个 Address,除了 Message。尽管有所变化,代码仍然非常相似:
fn new_ping() -> (Sender<Message>, Receiver<(Address,Message)>) {
let (pinginsend,pinginrecv) = channel();
let (pingoutsend,pingoutrecv) = channel();
let mut ping = 1;
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let msg = pinginrecv.recv().unwrap();
match msg {
Message::PingPlus(n) => { ping += n; },
_ => panic!("Unexpected message")
}
println!("ping {}", ping);
thread::sleep(t);
pingoutsend.send((
Address::Pong,
Message::PongPlus(ping)
)).unwrap();
pingoutsend.send((
Address::Pong,
Message::PongPlus(ping)
)).unwrap();
}
});
(pinginsend, pingoutrecv)
}
fn new_pong() -> (Sender<Message>, Receiver<(Address,Message)>) {
let (ponginsend,ponginrecv) = channel();
let (pongoutsend,pongoutrecv) = channel();
let mut pong = 1;
thread::spawn(move || {
let t = time::Duration::from_millis(1000);
loop {
let msg = ponginrecv.recv().unwrap();
match msg {
Message::PongPlus(n) => { pong += n; },
_ => panic!("Unexpected message")
}
println!("pong {}", pong);
thread::sleep(t);
pongoutsend.send((
Address::Ping,
Message::PingPlus(pong)
)).unwrap();
pongoutsend.send((
Address::Ping,
Message::PingPlus(pong)
)).unwrap();
}
});
(ponginsend, pongoutrecv)
}
每个乒乓进程循环消费一条消息,并发送两条消息。程序的最后一个组件是初始化和路由:
fn main() {
let pings = vec![new_ping(), new_ping(), new_ping()];
let pongs = vec![new_pong(), new_pong(), new_pong()];
//Start the action
pings[0].0.send(Message::PingPlus(1)).unwrap();
//This thread will be the router
//This is a busy wait and otherwise bad code
//select! would be much better, but it is still experimental
//https://doc.rust-lang.org/std/macro.select.html
let t = time::Duration::from_millis(10);
loop {
let mut mail = Vec::new();
for (_,r) in pings.iter() {
for (addr,msg) in r.try_iter() {
mail.push((addr,msg));
}
}
for (_,r) in pongs.iter() {
for (addr,msg) in r.try_iter() {
mail.push((addr,msg));
}
}
for (addr,msg) in mail.into_iter() {
match addr {
Address::Ping => {
let (ref s,_) = pings[(rand::random::<u32>() as usize) % pings.len()];
s.send(msg).unwrap();
},
Address::Pong => {
let (ref s,_) = pongs[(rand::random::<u32>() as usize) % pongs.len()];
s.send(msg).unwrap();
}
}
}
thread::sleep(t);
}
}
在初始化了不同的演员之后,主线程开始充当路由器。路由器是一个单线程,唯一的责任是找到目的地,然后移动、复制、克隆以及其他方式将消息分发给接收线程。这不是一个复杂的解决方案,但它是有效的,并且只使用了我们迄今为止引入的类型安全、线程安全、平台无关的原始类型。
在一个更复杂的例子中,路由 Address 通常具有以下特点:
-
演员角色
-
方法名称
-
参数类型签名
那么消息将是根据前面的类型签名提供的参数。从演员发送消息就像发送你的(Address,Message)到路由器一样简单。此时,路由器应该定期检查每个通道是否有新的路由请求。当它看到新消息时,它将选择满足Address条件的演员,并将消息发送到该演员的收件箱。
观察输出,每次乒乓动作都会将接收到的消息数量翻倍。如果每个线程不做那么多睡眠,那么程序可能会迅速失控。消息噪声是过度使用演员模型的一个风险。
摘要
在本章中,我们介绍了并发计算的基本原理。子进程、分叉进程和线程是所有并发应用程序的基本构建块。在 Rust 的线程中,语言引入了额外的关注点,以鼓励类型和线程安全。
在几个示例中,我们使用分叉或线程构建了并发网络服务器。后来,在探索线程行为时,我们仔细观察了线程之间可以共享哪些数据以及如何安全地在线程之间发送信息。
在设计模式部分,我们介绍了演员设计模式。这种流行的技术结合了面向对象编程的一些元素和函数式编程的其他概念。结果是专为复杂健壮的并发设计的一种编程工具。
在下一章中,我们将探讨性能、调试和元编程。性能可能难以衡量或比较,但我们将尝试介绍对性能严格有益的习惯。为了帮助调试,我们将探讨主动和被动技术来解决这些问题。主动调试是一套技术,如适当的错误处理,它要么防止错误,要么使错误更容易记录和解决。被动技术对于没有明显原因的困难错误很有用。最后,元编程可以在幕后完成大量复杂的工作,使丑陋的代码看起来更美观。
问题
-
什么是子进程?
-
为什么分叉被称为分叉?
-
分叉(fork)是否仍然有用?
-
线程何时被标准化?
-
为什么有时需要
move来处理线程闭包? -
Send和Sync特质的区别是什么? -
为什么我们可以不对
Mutex加锁就进行修改,而不需要使用不安全的代码块?
第九章:性能、调试和元编程
编写快速高效的代码可以是一件值得骄傲的事情。这也可能浪费你雇主资源。在性能部分,我们将探讨如何区分这两者,并给出最佳实践、流程和指南,以保持你的应用程序精简。
在调试部分,我们提供了一些技巧,帮助您更快地找到和解决错误。我们还介绍了防御性编码的概念,它描述了防止或隔离潜在问题的技术和习惯。
在元编程部分,我们解释了宏和其他类似宏的功能。Rust 有一个相当复杂的元编程系统,允许用户或库通过自动代码生成或自定义语法形式扩展语言。
在本章中,我们将学习以下内容:
-
识别和应用良好的性能代码实践
-
诊断和改进性能瓶颈
-
识别和应用良好的防御性编码实践
-
诊断和解决软件错误
-
识别和应用元编程技术
技术要求
运行提供的示例需要 Rust 的最近版本:
www.rust-lang.org/en-US/install.html
本章的代码可在 GitHub 上找到:
github.com/PacktPublishing/Hands-On-Functional-Programming-in-RUST
每个章节的README.md文件中都包含了具体的安装和构建说明。
编写更快的代码
过早的优化是万恶之源
– 唐纳德·克努特
良好的软件设计往往能创建更快的程序,而糟糕的软件设计往往能创建更慢的程序。如果你发现自己正在问,“我的程序为什么这么慢?”,那么首先问问自己,“我的程序是否混乱?”
在本节中,我们描述了一些性能技巧。这些通常是 Rust 编程中的良好习惯,无意中会导致性能提升。如果你的程序运行缓慢,那么首先检查你是否违反了这些原则之一。
以发布模式编译
这是一个你应该知道的非常简单的建议,如果你对性能有任何关注的话。
- Rust 通常以调试模式编译,这比较慢:
cargo build
- Rust 可以选择以发布模式编译,这比较快:
cargo build --release
- 这里是一个使用调试模式为玩具程序进行比较的示例:
$ time performance_release_mode
real 0m13.424s
user 0m13.406s
sys 0m0.010s
- 以下为发布模式:
$ time ./performance_release_mode
real 0m0.316s
user 0m0.309s
sys 0m0.005s
发布模式在此示例中相对于 CPU 使用效率提高了 98%。
做更少的工作
更快的程序做更少的工作。所有优化都是一个寻找不需要完成的工作的过程,然后不去做它。
同样,最小的程序使用更少的资源。所有空间优化都是一个寻找不需要使用的资源的过程,然后不使用它们。
例如,当你不需要结果时,不要收集迭代器,考虑以下示例:
extern crate flame;
use std::fs::File;
fn main() {
let v: Vec<u64> = vec![2; 1000000];
flame::start("Iterator .collect");
let mut _z = vec![];
for _ in 0..1000 {
_z = v.iter().map(|x| x*x).collect::<Vec<u64>>();
}
flame::end("Iterator .collect");
flame::start("Iterator iterate");
for _ in 0..1000 {
v.iter().map(|x| x * x).for_each(drop);
}
flame::end("Iterator iterate");
flame::dump_html(&mut File::create("flame-graph.html").unwrap()).unwrap();
}
无需收集迭代器的结果会使代码比仅丢弃结果的代码慢 27%。
内存分配类似。设计良好的代码倾向于使用纯函数并避免副作用,从而最小化内存使用。相反,混乱的代码可能导致旧数据滞留。Rust 的内存安全性并不包括防止内存泄漏。泄漏被视为安全代码:
use std::mem::forget;
fn main() {
for _ in 0..10000 {
let mut a = vec![2; 10000000];
a[2] = 2;
forget(a);
}
}
forget 函数很少使用。同样,内存泄漏是被允许的,但被充分劝阻,以至于它们相对不常见。Rust 的内存管理往往是这样,当你造成内存泄漏时,你可能已经陷入了其他糟糕的设计决策中。
然而,未使用的内存并不少见。如果你不跟踪你正在积极使用的变量,那么旧变量很可能会保留在作用域内。这并不是内存泄漏的典型定义;然而,未使用的数据是类似资源的浪费。
优化需要优化的代码——分析
不要优化那些不需要优化的代码。这是浪费时间,也可能是糟糕的软件工程。省去麻烦,在尝试优化程序之前,准确识别性能问题。
对于很少执行的代码,性能不受影响
初始化一些资源并多次使用它是非常常见的。优化资源的 initialization 可能是错误的。你应该考虑专注于提高 work 的效率。这可以通过以下方式完成:
use std::{thread,time};
fn initialization() {
let t = time::Duration::from_millis(15000);
thread::sleep(t);
}
fn work() {
let t = time::Duration::from_millis(15000);
loop {
thread::sleep(t);
println!("Work.");
}
}
fn main() {
initialization();
println!("Done initializing, start work.");
work();
}
小数的倍数也是小数
反过来也可能成立。有时 work 的低频率会被频繁且昂贵的 initialization 所淹没。了解你遇到的问题将帮助你确定从哪里开始寻找以改进:
use std::{thread,time};
fn initialization() -> Vec<i32> {
let t = time::Duration::from_millis(15000);
thread::sleep(t);
println!("Initialize data.");
vec![1, 2, 3];
}
fn work(x: i32) -> i32 {
let t = time::Duration::from_millis(150);
thread::sleep(t);
println!("Work.");
x * x
}
fn main() {
for _ in 0..10 {
let data = initialization();
data.iter().map(|x| work(*x)).for_each(drop);
}
}
先测量,再优化
分析有很多选项。以下是我们推荐的一些。
flame crate 是手动分析应用程序的一个选项。在这里,我们创建了嵌套过程 a、b 和 c。每个函数创建一个与该方法对应的分析上下文。在运行分析器后,我们将看到每个函数的每次调用所花费的时间比例。
从函数 a 开始,此过程创建一个新的分析上下文,休眠一秒钟,然后调用 b 三次:
extern crate flame;
use std::fs::File;
use std::{thread,time};
fn a() {
flame::start("fn a");
let t = time::Duration::from_millis(1000);
thread::sleep(t);
b();
b();
b();
flame::end("fn a");
}
函数 b 几乎与 a 相同,并进一步调用函数 c:
fn b() {
flame::start("fn b");
let t = time::Duration::from_millis(1000);
thread::sleep(t);
c();
c();
c();
flame::end("fn b");
}
函数 c 会自我分析并休眠,但不会调用任何更深层的嵌套函数:
fn c() {
flame::start("fn c");
let t = time::Duration::from_millis(1000);
thread::sleep(t);
flame::end("fn c");
}
main 入口设置火焰图库并调用三次,然后保存火焰图到文件:
fn main() {
flame::start("fn main");
let t = time::Duration::from_millis(1000);
thread::sleep(t);
a();
a();
a();
flame::end("fn main");
flame::dump_html(&mut File::create("flame-graph.html").unwrap()).unwrap();
}
运行此程序后,flame-graph.html 文件将包含程序各部分占资源百分比的可视化。flame crate 容易安装,需要一些手动代码操作,但会产生一个看起来很酷的图表。
cargo profiler 是一个工具,它扩展了 cargo 以进行性能分析,而无需任何代码更改。以下是一个我们将要分析的随机程序:
fn a(n: u64) -> u64 {
if n>0 {
b(n);
b(n);
}
n * n
}
fn b(n: u64) -> u64 {
c(n);
c(n);
n + 2 / 3
}
fn c(n: u64) -> u64 {
a(n-1);
a(n-1);
vec![1, 2, 3].into_iter().map(|x| x+2).sum()
}
fn main() {
a(6);
}
要分析应用程序,我们运行以下命令:
$ cargo profiler callgrind --bin ./target/debug/performance_profiling4 -n 10
这将运行程序并收集有关哪些函数被最频繁使用的相关信息。这个分析器还有一个选项来分析内存使用情况。输出将如下所示:
Profiling performance_profiling4 with callgrind...
Total Instructions...344,529,557
27,262,872 (7.9%) ???:core::iter::iterator::Iterator
----------------------------------------------------------
22,319,604 (6.5%) ???:<alloc::vec
----------------------------------------------------------
16,627,356 (4.8%) ???:<core::iter
----------------------------------------------------------
13,182,048 (3.8%) ???:<alloc::vec
----------------------------------------------------------
10,785,312 (3.1%) ???:core::iter::iterator::Iterator::fold
----------------------------------------------------------
10,485,720 (3.0%) ???:core::mem
----------------------------------------------------------
8,088,984 (2.3%) ???:alloc::slice::hack
----------------------------------------------------------
7,639,596 (2.2%) ???:core::ptr
----------------------------------------------------------
7,190,208 (2.1%) ???:core::ptr
----------------------------------------------------------
7,190,016 (2.1%) ???:performance_profiling4
这清楚地表明,大部分时间都花在迭代器和向量的创建上。运行这个命令可能会使程序执行速度比正常情况下慢得多,但它也节省了在分析之前编写任何代码的时间。
将冰箱放在电脑旁边
如果你编程时想休息一下,那么在电脑旁边有一个冰箱和微波炉会非常方便。如果你去厨房吃零食,那么满足你的胃口需要更长的时间。如果你的厨房空了,你需要去购物,那么休息时间会更长。如果你的杂货店也空了,你需要开车去农场采摘蔬菜,那么你的工作环境显然不是为吃零食而设计的。
这个奇怪的类比说明了时间和空间之间必要的权衡。这种关系对于我们来说几乎不是一条物理定律,但几乎是。规则是,在更长的距离上旅行或通信,与花费的时间成正比。在一个方向上更多的距离(d)也意味着可用空间以二次(d²)或三次(d³)的比例增加。换句话说,将冰箱建得更远,可以为更大的冰箱提供更多的空间。
将这个故事带回到技术环境中,以下是一些程序员应该知道的延迟数字(~2012:gist.github.com/jboner/2841832):
| 请求 | 时间 |
|---|---|
| L1 缓存引用 | 0.5 ns |
| 分支预测错误 | 5 ns |
| L2 缓存引用 | 7 ns |
| 锁定/解锁互斥锁 | 25 ns |
| 主内存引用 | 100 ns |
| 使用 Zippy 压缩 1 Kb | 3000 ns |
| 在 1 Gbps 网络上发送 1 Kb | 10000 ns |
| 从 SSD 随机读取 4 Kb | 150000 ns |
| 从内存中顺序读取 1 Mb | 250000 ns |
| 同一数据中心内的往返 | 500000 ns |
| 发送数据包 CA | 荷兰 | CA | 150000000 ns |
在这里,我们可以看到具体的数字,如果你想吃甜甜圈和一些咖啡,那么在你从丹麦咬第一口之前,你可以在电脑旁边的冰箱里吃掉 3 亿个甜甜圈。
限制大 O
大 O 记号是计算机科学中的一个术语,用于根据输入值增大时函数增长的速度来分组函数。这个术语最常用于算法的运行时间或空间需求。
当在软件工程中使用这个术语时,我们通常关注以下四种情况之一:
-
常数
-
对数增长
-
多项式增长
-
指数增长
当我们关注应用程序性能时,考虑你使用的逻辑的大 O 效率是好的。根据你处理的前四个案例中的哪一个,优化策略的适当反应可能会改变。
持续无增长
常数时间操作是运行性能的不可分割的单位。在前一节中,我们提供了一个常见操作及其所需时间的表格。对我们程序员来说,这些都是基本物理常数。你不能优化光速使其更快。
然而,并非所有常数时间操作都是不可减少的。如果你有一个对固定大小数据进行固定数量操作的程序,那么它将是常数时间。这并不意味着该程序自动是高效的。在尝试优化常数时间程序时,问问自己这两个问题:
-
是否有任何工作可以避免?
-
冰箱离电脑太远了吗?
这里有一个强调常数时间操作的程序:
fn allocate() -> [u64; 1000] {
[22; 1000]
}
fn flop(x: f64, y: f64) -> f64 {
x * y
}
fn lookup(x: &[u64; 1000]) -> u64 {
x[234] * x[345]
}
fn main() {
let mut data = allocate();
for _ in 0..1000 {
//constant size memory allocation
data = allocate();
}
for _ in 0..1000000 {
//reference data
lookup(&data);
}
for _ in 0..1000000 {
//floating point operation
flop(2.0, 3.0);
}
}
然后,让我们分析这个程序:
Profiling performance_constant with callgrind...
Total Instructions...896,049,080
217,133,740 (24.2%) ???:_platform_memmove$VARIANT$Haswell
-----------------------------------------------------------
108,054,000 (12.1%) ???:core::ptr
-----------------------------------------------------------
102,051,069 (11.4%) ???:core::iter::range
-----------------------------------------------------------
76,038,000 (8.5%) ???:<i32
-----------------------------------------------------------
56,028,000 (6.3%) ???:core::ptr
-----------------------------------------------------------
46,023,000 (5.1%) ???:core::iter::range::ptr_try_from_impls
-----------------------------------------------------------
45,027,072 (5.0%) ???:performance_constant
-----------------------------------------------------------
44,022,000 (4.9%) ???:core::ptr
-----------------------------------------------------------
40,020,000 (4.5%) ???:core::mem
-----------------------------------------------------------
30,015,045 (3.3%) ???:core::cmp::impls
我们看到,大量的内存分配相当昂贵。至于内存访问和浮点运算,它们似乎被多次执行的循环的开销所压倒。除非在常数时间过程中有明显的性能不佳的原因,否则优化此代码可能并不简单。
对数增长
对数算法是计算机科学的骄傲。如果你的代码对于 n=5 的 O(n)复杂度可以用 O(log n)算法编写,那么至少会有一个人指出这一点。
二分搜索是 O(log n)。排序通常是 O(n log n)。任何包含对数的都是更好的。这种喜爱并非没有道理。对数增长有一个惊人的特性——随着输入值的增加,增长速度会减慢。
这里有一个强调对数增长的程序。我们初始化一个大小为 1000 或 10000 的随机数向量。然后我们使用内置库进行排序并执行 100 次二分搜索操作。首先让我们捕捉 1000 个案例的排序和搜索时间:
extern crate rand;
extern crate flame;
use std::fs::File;
fn main() {
let mut data = vec![0; 1000];
for di in 0..data.len() {
data[di] = rand::random::<u64>();
}
flame::start("sort n=1000");
data.sort();
flame::end("sort n=1000");
flame::start("binary search n=1000 100 times");
for _ in 0..100 {
let c = rand::random::<u64>();
data.binary_search(&c).ok();
}
flame::end("binary search n=1000 100 times");
现在我们分析 10000 个案例:
let mut data = vec![0; 10000];
for di in 0..data.len() {
data[di] = rand::random::<u64>();
}
flame::start("sort n=10000");
data.sort();
flame::end("sort n=10000");
flame::start("binary search n=10000 100 times");
for _ in 0..100 {
let c = rand::random::<u64>();
data.binary_search(&c).ok();
}
flame::end("binary search n=10000 100 times");
flame::dump_html(&mut File::create("flame-graph.html").unwrap()).unwrap();
}
运行此程序并检查火焰图后,我们可以看到,对于 10 倍更大的向量进行排序所需的时间几乎只增加了 10 倍——O(n log n)。搜索性能几乎不受影响——O(log n)。因此,对于实际应用,对数增长几乎可以忽略不计。
在尝试优化对数代码时,遵循与常数时间优化相同的方法。对数复杂度通常不是优化的好目标,尤其是考虑到对数复杂度是良好算法设计的强烈指标。
多项式增长
大多数算法都是多项式时间复杂度。
如果你有一个for循环,那么你的复杂度是O(n)。这在上面的代码中显示:
fn main() {
for _ in 0..1000 {
//O(n)
//n = 1000
}
}
如果你有两个for循环,那么你的复杂度是O(n²):
fn main() {
for _ in 0..1000 {
for _ in 0..1000 {
//O(n²)
//n = 1000
}
}
}
高阶多项式相对较少见。有时代码意外地变成了高阶多项式,你应该小心对待;否则,让我们只考虑前两种情况。
线性复杂度非常常见。每次你处理集合中的所有数据时,复杂度将是线性的。线性算法的运行时间将大约是处理的项目数量(n)乘以处理单个项目的时间(c)。如果你想使线性算法更快,你需要:
-
减少处理的项目数量(n)
-
减少处理一个项目(c)相关的常数时间
如果处理一个项目的时间不是常数或近似常数,那么你的整体时间复杂度现在将递归地依赖于那个处理时间。以下代码展示了这一点:
fn a(n: u64) {
//Is this O(n)?
for _ in 0..n {
b(n)
}
}
fn b(n: u64) {
//Is this O(n)?
for _ in 0..n {
c(n)
}
}
fn c(n: u64) {
//This is O(n)
for _ in 0..n {
let _ = 1 + 1;
}
}
fn main() {
//What time complexity is this?
a(1000)
}
高阶多项式复杂度也很常见,但可能表明你的算法设计得不好。在前面的描述中,我们提到线性处理时间可能依赖于处理单个项目的时间。如果你的程序设计得草率,那么很容易将三个或四个线性算法串联起来,无意中创建一个O(n⁴)的怪物。
高阶多项式按比例更慢。对于需要高阶多项式计算的算法,通常可以通过剪枝来移除冗余或完全不必要的数据计算。考虑以下代码:
extern crate rusty_machine;
use rusty_machine::linalg::{Matrix,Vector};
use rusty_machine::learning::gp::{GaussianProcess,ConstMean};
use rusty_machine::learning::toolkit::kernel;
use rusty_machine::learning::SupModel;
fn main() {
let inputs = Matrix::new(3,3,vec![1.1,1.2,1.3,2.1,2.2,2.3,3.1,3.2,3.3]);
let targets = Vector::new(vec![0.1,0.8,0.3]);
let test_inputs = Matrix::new(2,3, vec![1.2,1.3,1.4,2.2,2.3,2.4]);
let ker = kernel::SquaredExp::new(2., 1.);
let zero_mean = ConstMean::default();
let mut gp = GaussianProcess::new(ker, zero_mean, 0.5);
gp.train(&inputs, &targets).unwrap();
let _ = gp.predict(&test_inputs).unwrap();
}
当你需要使用高阶多项式算法时,使用库!这些内容很快就会变得复杂,改进这些算法是学术计算机科学家的主要工作。如果你正在对常见算法进行性能调优,并且不打算发表你的结果,那么你很可能会重复工作。
指数增长
在工程中,指数性能几乎总是错误或死胡同。这是我们将使用的算法与我们希望使用但无法因为性能原因使用的算法之间的墙。
程序中的指数增长经常伴随着术语“炸弹”:
fn bomb(n: u64) -> u64 {
if n > 0 {
bomb(n-1);
bomb(n-1);
}
n
}
fn main() {
bomb(1000);
}
这个程序只有O(2^n),因此几乎连指数增长都算不上!
引用数据更快
有一个经验法则,即引用数据比复制数据更快。同样,复制数据比克隆数据更快。这并不总是正确的,但当你试图提高程序性能时,这是一个值得考虑的好规则。
这里有一个函数,它交替使用通过引用、复制、内建克隆或自定义克隆的数据:
extern crate flame;
use std::fs::File;
fn byref(n: u64, data: &[u64; 1024]) {
if n>0 {
byref(n-1, data);
byref(n-1, data);
}
}
fn bycopy(n: u64, data: [u64; 1024]) {
if n>0 {
bycopy(n-1, data);
bycopy(n-1, data);
}
}
struct DataClonable([u64; 1024]);
impl Clone for DataClonable {
fn clone(&self) -> Self {
let mut newdata = [0; 1024];
for i in 0..1024 {
newdata[i] = self.0[i];
}
DataClonable(newdata)
}
}
fn byclone<T: Clone>(n: u64, data: T) {
if n>0 {
byclone(n-1, data.clone());
byclone(n-1, data.clone());
}
}
这里我们声明了一个包含1024个元素的数组。然后使用火焰图分析库应用上述函数来测量引用、复制和克隆性能之间的差异:
fn main() {
let data = [0; 1024];
flame::start("by reference");
byref(15, &data);
flame::end("by reference");
let data = [0; 1024];
flame::start("by copy");
bycopy(15, data);
flame::end("by copy");
let data = [0; 1024];
flame::start("by clone");
byclone(15, data);
flame::end("by clone");
let data = DataClonable([0; 1024]);
flame::start("by clone (with extras)");
//2⁴ instead of 2¹⁵!!!!
byclone(4, data);
flame::end("by clone (with extras)");
flame::dump_html(&mut File::create("flame-graph.html").unwrap()).unwrap();
}
观察这个应用程序的运行时间,我们看到与复制或克隆此数据相比,引用的数据只使用了很小的一部分资源。默认的克隆和复制特性不出所料给出了相似的性能。自定义克隆实际上非常慢。它在语义上与所有其他操作相同,但在底层优化方面并不一样。
通过防御性编程防止错误
你不需要修复永远不会发生的错误。预防性医学是好的软件工程,从长远来看会为你节省时间。
使用 Option 和 Result 而不是 panic!
在许多其他语言中,异常处理是通过 try…catch 块来执行的。Rust 并不自动提供这种功能,相反,它鼓励程序员显式地局部化所有的错误处理。
在许多 Rust 上下文中,如果你不想处理错误处理,你总是可以选择使用 panic!。这将立即结束程序并提供一个简短的错误消息。不要这样做。恐慌通常只是避免处理错误责任的一种方式。
相反,使用 Option 或 Result 类型来传达错误或异常情况。Option 表示没有值可用。Option 的 None 值应表示没有值,但一切正常且符合预期。
Result 类型用于传达处理过程中是否出现错误。Result 类型可以与 ? 语法结合使用,以传播错误同时避免引入过多的额外语法。? 操作将返回函数中的错误(如果有),因此该函数必须有一个 Result 返回类型。
在这里,我们创建了两个函数,它们返回 Option 或 Result 来处理异常情况。注意处理 Result 返回值时使用 try ? 语法。这种语法将传递 Ok 值或立即返回该函数中的任何 Err。因此,任何使用 ? 的函数也必须返回兼容的 Result 类型:
//This function returns an Option if the value is not expected
fn expect_1or2or_other(n: u64) -> Option<u64> {
match n {
1|2 => Some(n),
_ => None
}
}
//This function returns an Err if the value is not expected
fn expect_1or2or_error(n: u64) -> Result<u64,()> {
match n {
1|2 => Ok(n),
_ => Err(())
}
}
//This function uses functions that return Option and Return types
fn mixed_1or2() -> Result<(),()> {
expect_1or2or_other(1);
expect_1or2or_other(2);
expect_1or2or_other(3);
expect_1or2or_error(1)?;
expect_1or2or_error(2)?;
expect_1or2or_error(3).unwrap_or(222);
Ok(())
}
fn main() {
mixed_1or2().expect("mixed 1 or 2 is OK.");
}
Result 类型在与外部资源(如文件)交互时非常常见:
use std::fs::File;
use std::io::prelude::*;
use std::io;
fn lots_of_io() -> io::Result<()> {
{
let mut file = File::create("data.txt")?;
file.write_all(b"data\ndata\ndata")?;
}
{
let mut file = File::open("data.txt")?;
let mut data = String::new();
file.read_to_string(&mut data)?;
println!("{}", data);
}
Ok(())
}
fn main() {
lots_of_io().expect("lots of io is OK.");
}
使用类型安全的接口而不是字符串类型接口
Rust 中的枚举比使用数字或字符串更不容易出错。只要可能,编写以下代码:
const MyEnum_A: u32 = 1;
const MyEnum_B: u32 = 2;
const MyEnum_C: u32 = 3;
类似地,你可以编写一个字符串枚举:
"a"
"b"
"c"
最好使用以下枚举类型:
enum MyEnum {
A,
B,
C,
}
这样,接受枚举类型的函数将会是类型安全的:
fn foo(n: u64) {} //not all u64 are valid inputs
fn bar(n: &str) {} //not all &str are valid inputs
fn baz(n: MyEnum) {} //all MyEnum are valid
枚举也自然地与模式匹配相结合,原因相同。对枚举进行模式匹配不需要像整数或字符串类型那样有一个最终的错误情况:
match a {
1 => println!(“1 is ok”),
2 => println!(“2 is ok”),
3 => println!(“3 is ok”),
n => println!(“{} was unexpected”, n)
}
使用心跳模式处理长时间运行的过程
当你想创建一个长时间运行的过程时,能够从程序错误中恢复,这些错误会导致进程崩溃或终止,这会很好。也许进程耗尽了栈空间,或者遇到了某些代码路径的panic!。由于任何数量的原因,一个进程可能会被终止,并需要重新启动。
为了满足这种需求,有许多工具会为你监视程序,并在它死亡或停止对健康检查做出响应时重新启动它。在这里,我们推荐一个基于 Rust 并发的完全自包含的这种模式版本。
目标是创建一个充当监控器并监督一个或多个工作进程的父进程。进程树应该看起来像这样:
parent
—- child 1
—- child 2
—- child 3
当一个子进程死亡或停止对健康检查做出响应时,父进程应该终止或以其他方式清理进程资源,然后启动一个新的进程来替换它。以下是一个这种行为示例,从一个有时会死亡的子进程开始:
use std::{thread,time,process};
fn main() {
let life_expectancy = process::id() % 8;
let t = time::Duration::from_millis(1000);
for _ in 0..life_expectancy {
thread::sleep(t);
}
println!("process {} dies unexpectedly.", process::id());
}
这个工作进程非常不可靠,其寿命不超过八秒。然而,如果我们用心跳监控器将其包装起来,那么我们可以使其更加可靠:
use std::process::Command;
use std::env::current_exe;
use std::{thread,time};
fn main() {
//There is an executable called debugging_buggy_worker
//it crashes a lot but we still want to run it
let path = current_exe()
.expect("could not find current executable");
let path = path.with_file_name("debugging_buggy_worker");
let mut children = Vec::new();
//we start 3 workers
for _ in 0..3 {
children.push(
Command::new(path.as_os_str())
.spawn()
.expect("failed to spawn child")
);
}
//those workers will randomly die because they are buggy
//so after they die, we restart a new process to replace them
let t = time::Duration::from_millis(1000);
loop {
thread::sleep(t);
for ci in 0..children.len() {
let is_dead = children[ci].try_wait().expect("failed to try_wait");
if let Some(_exit_code) = is_dead {
children[ci] = Command::new(path.as_os_str())
.spawn()
.expect("failed to spawn child");
println!("starting a new process from parent.");
}
}
}
}
现在,如果运行中的进程意外终止,它们将会被重新启动。可选地,父进程可以检查每个子进程的健康状态,并重新启动无响应的工作进程。
验证输入和输出
预设条件和后置条件是锁定程序行为并找到在失控之前可能出现的错误或无效状态的好方法。
如果你使用宏来做这件事,那么预设条件和后置条件可以可选地仅在调试模式下运行,并从生产代码中删除。内置的debug_assert!宏就是这样做的。然而,使用断言作为返回值并不特别优雅,如果你忘记检查带有返回语句的分支,那么你的后置条件将不会被检查。
debug_assert!不是验证任何依赖于外部数据或非确定性行为的任何内容的良好选择。当你想在生产代码中检查预设条件或后置条件时,你应该使用Result或Option值来处理异常行为。
这里有一些 Rust 中预设条件和后置条件的示例:
use std::io;
//This function checks the precondition that [n < 100]
fn debug_precondition(n: u64) -> u64 {
debug_assert!(n < 100);
n * n
}
//This function checks the postcondition that [return > 10]
fn debug_postcondition(n: u64) -> u64 {
let r = n * n;
debug_assert!(r > 10);
r
}
//this function dynamically checks the precondition [n < 100]
fn runtime_precondition(n: u64) -> Result<u64,()> {
if !(n<100) { return Err(()) };
Ok(n * n)
}
//this function dynamically checks the postcondition [return > 10]
fn runtime_postcondition(n: u64) -> Result<u64,()> {
let r = n * n;
if !(r>10) { return Err(()) };
Ok(r)
}
//This main function uses all of the functions
//The dynamically validated functions are subjected to user input
fn main() {
//inward facing code should assert expectations
debug_precondition(5);
debug_postcondition(5);
//outward facing code should handle errors
let mut s = String::new();
println!("Please input a positive integer greater or equal to 4:");
io::stdin().read_line(&mut s).expect("error reading input");
let i = s.trim().parse::<u64>().expect("error parsing input as integer");
runtime_precondition(i).expect("runtime precondition violated");
runtime_postcondition(i).expect("runtime postcondition violated");
}
注意,用户输入超出了我们的控制。验证用户输入的最佳选项是在输入无效时返回一个Error条件。
寻找和修复错误
调试工具很大程度上依赖于平台。在这里,我们将解释lldb,它可用,并且适用于 macOS 和其他类 Unix 系统。
要开始调试,你需要编译程序并开启调试符号。正常的cargo debug build通常足够:
cargo build
程序编译完成后,启动调试器:
$ sudo rust-lldb target/debug/deps/performance_polynomial3-8048e39c94dd7157
在这里,我们引用了debugs/deps/program_name-GITHASH程序的副本。这现在是因为 lldb 的工作方式。
运行lldb后,你将在启动时看到一些信息滚动过去。然后,你应该进入 LLDB 命令提示符:
(lldb) command source -s 0 '/tmp/rust-lldb-commands.YnRBkV'
Executing commands in '/tmp/rust-lldb-commands.YnRBkV'.
(lldb) command script import "/Users/andrewjohnson/.rustup/toolchains/nightly-x86_64-apple-darwin/lib/rustlib/etc/lldb_rust_formatters.py"
(lldb) type summary add --no-value --python-function lldb_rust_formatters.print_val -x ".*" --category Rust
(lldb) type category enable Rust
(lldb) target create "target/debug/deps/performance_polynomial3-8048e39c94dd7157"
Current executable set to 'target/debug/deps/performance_polynomial3-8048e39c94dd7157' (x86_64).
(lldb)
现在,设置一个断点。我们将设置一个断点在函数 a 处停止:
(lldb) b a
Breakpoint 1: where = performance_polynomial3-8048e39c94dd7157`performance_polynomial3::a::h0b267f360bbf8caa + 12 at performance_polynomial3.rs:3, address = 0x000000010000191c
现在我们已经设置了断点,运行 r 命令:
(lldb) r
Process 99468 launched: '/Users/andrewjohnson/subarctic.org/subarctic.org/Hands-On-Functional-Programming-in-RUST/Chapter09/target/debug/deps/performance_polynomial3-8048e39c94dd7157' (x86_64)
Process 99468 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x000000010000191c performance_polynomial3-8048e39c94dd7157`performance_polynomial3::a::h0b267f360bbf8caa(n=1000) at performance_polynomial3.rs:3
1 fn a(n: u64) {
2 //Is this O(n);
-> 3 for _ in 0..n {
4 b(n);
5 }
6 }
7
Target 0: (performance_polynomial3-8048e39c94dd7157) stopped.
在代码停止点停止后,LLDB 将打印出代码停止处的上下文。现在我们可以检查程序了。让我们打印出在这个函数中定义了哪些变量:
(lldb) frame variable
(unsigned long) n = 1000
我们可以类似地打印作用域内的任何变量:
(lldb) p n
(unsigned long) $0 = 1000
当我们想要继续程序时,输入 c 以继续:
(lldb) c
Process 99468 resuming
Process 99468 exited with status = 0 (0x00000000)
程序在这里退出,因为我们没有设置更多的断点。这种调试方法很棒,因为它允许你在不不断添加 println! 语句和重新编译的情况下检查运行中的程序。如果其他方法都不奏效,这仍然是一个可行的选择。
元编程
Rust 中的元编程有两种形式——宏和过程宏。这两种实用工具都接受抽象语法树作为新的输入和输出符号进行编译。过程宏与正常宏非常相似,但它们在如何工作以及如何定义方面有更少的限制。
使用 macro_rules! 语法定义的宏通过匹配输入语法来递归地定义输出。理解这一点至关重要:宏匹配发生在解析之后。这意味着以下内容:
-
宏在创建新的语法形式时必须遵循某些规则
-
AST 被装饰了有关每个节点语法类别的信息
宏可以匹配单个标记,或者宏可以匹配(并捕获)整个语法类别。Rust 的语法类别如下:
-
tt:这是一个标记树(这是在解析之前从词法分析器输出的标记) -
ident:这是一个标识符 -
expr:这是一个表达式 -
ty:这是一个类型 -
stmt:这是一个语句 -
block:这些是包含语句块的括号 -
item:这是一个顶层定义,例如函数或结构体 -
pat:这是模式匹配表达式的匹配部分,也称为左侧 -
path:这是一个路径,例如std::fs::File -
meta:这是一个元项,可以放在#[...]或#![...]语法形式内部
使用这些模式,我们可以创建宏来匹配各种语法表达式组:
//This macro rule matches one token tree "tt"
macro_rules! match_tt {
($e: tt) => { println!("match_tt: {}", stringify!($e)) }
}
//This macro rule matches one identifier "ident"
macro_rules! match_ident {
($e: ident) => { println!("match_ident: {}", stringify!($e)) }
}
//This macro rule matches one expression "expr"
macro_rules! match_expr {
($e: expr) => { println!("match_expr: {}", stringify!($e)) }
}
//This macro rule matches one type "ty"
macro_rules! match_ty {
($e: ty) => { println!("match_ty: {}", stringify!($e)) }
}
//This macro rule matches one statement "stmt"
macro_rules! match_stmt {
($e: stmt) => { println!("match_stmt: {}", stringify!($e)) }
}
//This macro rule matches one block "block"
macro_rules! match_block {
($e: block) => { println!("match_block: {}", stringify!($e)) }
}
//This macro rule matches one item "item"
//items are things like function definitions, struct definitions, ...
macro_rules! match_item {
($e: item) => { println!("match_item: {}", stringify!($e)) }
}
//This macro rule matches one pattern "pat"
macro_rules! match_pat {
($e: pat) => { println!("match_pat: {}", stringify!($e)) }
}
//This macro rule matches one path "path"
//A path is a canonical named path like std::fs::File
macro_rules! match_path {
($e: path) => { println!("match_path: {}", stringify!($e)) }
}
//This macro rule matches one meta "meta"
//A meta is anything inside of the #[...] or #![...] syntax
macro_rules! match_meta {
($e: meta) => { println!("match_meta: {}", stringify!($e)) }
}
然后,让我们将这些宏应用到不同的输入上:
fn main() {
match_tt!(a);
match_tt!(let);
match_tt!(+);
match_ident!(a);
match_ident!(bcd);
match_ident!(_def);
match_expr!(1.2);
match_expr!(bcd);
match_expr!(1.2 + bcd / "b" - [1, 3, 4] .. vec![1, 2, 3]);
match_ty!(A);
match_ty!(B + 'static);
match_ty!(A<&(B + 'b),&mut (C + 'c)> + 'static);
match_stmt!(let x = y);
match_stmt!(());
match_stmt!(fn f(){});
match_block!({});
match_block!({1; 2});
match_block!({1; 2 + 3});
match_item!(struct A(u64););
match_item!(enum B { C, D });
match_item!(fn C(n: NotAType) -> F<F<F<F<F>>>> { a + b });
match_pat!(_);
match_pat!(1);
match_pat!(A {b, c:D( d@3 )} );
match_path!(A);
match_path!(::A);
match_path!(std::A);
match_path!(a::<A,_>);
match_meta!(A);
match_meta!(Property(B,C));
}
从示例中我们可以看出,标记树在大多数情况下并不受正常 Rust 语法限制,只受 Rust 词法分析器的限制。词法分析器知道开闭括号 () [] {} 的形式。这就是为什么标记是以标记树而不是标记列表的形式组织的。这也意味着宏调用内的所有标记都将作为标记树存储,并且不会进一步处理,直到宏被调用;只要我们创建与 Rust 标记树兼容的语法,那么通常应该允许其他语法创新。这条规则也适用于其他语法类别:语法类别只是匹配某些标记模式的一种简写,这些模式恰好对应 Rust 语法形式。
仅匹配单个标记或语法类别可能对宏来说不太有用。为了在实用场景中使用宏,我们需要利用宏语法序列和语法替代项。语法序列是在同一规则中请求匹配多个标记或语法类别。语法替代项是在同一宏中的单独规则,它匹配不同的语法。语法序列和替代项也可以在同一宏中组合。此外,还有一个特殊的语法形式来匹配许多标记或语法类别。
下面是一些相应的示例来展示这些模式:
//this is a grammar sequence
macro_rules! abc {
(a b c) => { println!("'a b c' is the only correct syntax.") };
}
//this is a grammar alternative
macro_rules! a_or_b {
(a) => { println!("'a' is one correct syntax.") };
(b) => { println!("'b' is also correct syntax.") };
}
//this is a grammar of alternative sequences
macro_rules! abc_or_aaa {
(a b c) => { println!("'a b c' is one correct syntax.") };
(a a a) => { println!("'a a a' is also correct syntax.") };
}
//this is a grammar sequence matching many of one token
macro_rules! many_a {
( $($a:ident)* ) => {{ $( print!("one {} ", stringify!($a)); )* println!(""); }};
( $($a:ident),* ) => {{ $( print!("one {} comma ", stringify!($a)); )* println!(""); }};
}
fn main() {
abc!(a b c);
a_or_b!(a);
a_or_b!(b);
abc_or_aaa!(a b c);
abc_or_aaa!(a a a);
many_a!(a a a);
many_a!(a, a, a);
}
如果你注意到了所有这些宏生成的代码,你可能会注意到所有生产规则都创建了表达式。宏输入可以是标记,但输出必须是上下文中良好形成的 Rust 语法。因此,你不能像下面这样编写 macro_rules!:
macro_rules! f {
() => { f!(1) f!(2) f!(3) };
(1) => { 1 };
(2) => { + };
(3) => { 2 };
}
fn main() {
f!()
}
编译器产生的具体错误如下:
error: macro expansion ignores token `f` and any following
--> t.rs:2:19
|
2 | () => { f!(1); f!(2); f!(3) };
| ^
|
note: caused by the macro expansion here; the usage of `f!` is likely invalid in expression context
--> t.rs:9:4
|
9 | f!()
| ^^^^
error: aborting due to previous error
这里的关键短语是 f!,在表达式上下文中可能是不合法的。macro_rules! 的每个输出模式都必须是一个良好形成的表达式。前面的例子最终将创建良好的 Rust 语法,但它的中间结果是碎片化的表达式。这种尴尬是使用过程宏的几个原因之一,过程宏与 macro_rules! 类似,但直接在 Rust 中编程,而不是通过特殊的 macro_rules! 语法。
过程宏是用 Rust 编程的,但也被用来编译 Rust 程序。这是怎么做到的?过程宏必须被隔离到它们自己的模块中并单独编译;它们基本上是一个编译器插件。
为了开始我们的过程宏,让我们创建一个新的子项目:
-
在项目根目录下创建一个
procmacro目录 -
在
procmacro目录中,创建一个包含以下内容的Cargo.toml文件:
[package]
name = "procmacro"
version = "1.0.0"
[dependencies]
syn = "0.12"
quote = "0.4"
[lib]
proc-macro = true
- 在
procmacro目录中,创建一个包含以下内容的src/lib.rs文件:
#![feature(proc_macro)]
#![crate_type = "proc-macro"]
extern crate proc_macro;
extern crate syn;
#[macro_use] extern crate quote;
use proc_macro::TokenStream;
#[proc_macro]
pub fn f(input: TokenStream) -> TokenStream {
assert!(input.is_empty());
(quote! {
1 + 2
}).into()
}
这个 f! 宏现在实现了前面的语义,没有任何抱怨。使用这个宏的示例如下:
#![feature(proc_macro_non_items)]
#![feature(use_extern_macros)]
extern crate procmacro;
fn main() {
let _ = procmacro::f!();
}
过程宏的接口非常简单。有一个 TokenStream 作为输入,还有一个 TokenStream 作为输出。proc_macro 和 syn 包还提供了解析标记或使用 quote! 宏轻松创建标记流的实用工具。要使用过程宏,有一些额外的设置和样板代码,但过了这些障碍后,接口现在相当直接了。
此外,通过 syn crate,过程宏还有许多更详细的语法类别可供使用。目前有 163 个类别(dtolnay.github.io/syn/syn/#macros)!这些类别包括递归宏中的相同模糊的语法树,但也包括非常具体的语法形式。这些类别对应于完整的 Rust 语法,因此允许在不创建自己的解析器的情况下,使用非常表达性的宏语法。
让我们创建一个使用这些语法类别的过程宏。首先,我们创建一个新的过程宏文件夹,就像之前的 procmacro 一样;这个我们将命名为 procmacro2。现在我们定义将持有所有程序信息的 AST(抽象语法树),如果用户输入有效的话:
#![feature(proc_macro)]
#![crate_type = "proc-macro"]
extern crate proc_macro;
#[macro_use] extern crate syn;
#[macro_use] extern crate quote;
use proc_macro::TokenStream;
use syn::{Ident, Type, Expr, WhereClause, TypeSlice, Path};
use syn::synom::Synom;
struct MiscSyntax {
id: Ident,
ty: Type,
expr: Expr,
where_clause: WhereClause,
type_slice: TypeSlice,
path: Path
}
MiscSyntax 结构将包含从我们的宏中收集的所有信息。我们现在应该定义这个宏及其语法:
impl Synom for MiscSyntax {
named!(parse -> Self, do_parse!(
keyword!(where) >>
keyword!(while) >>
id: syn!(Ident) >>
punct!(:) >>
ty: syn!(Type) >>
punct!(>>) >>
expr: syn!(Expr) >>
punct!(;) >>
where_clause: syn!(WhereClause) >>
punct!(;) >>
type_slice: syn!(TypeSlice) >>
punct!(;) >>
path: syn!(Path) >>
(MiscSyntax { id, ty, expr, where_clause, type_slice, path })
));
}
do_parse! 宏有助于简化 syn crate 中解析组合器的使用。id: expr >> 语法对应于单调绑定操作,而 expr >> 语法也是一种单调绑定。
现在我们利用这些定义来解析输入,生成输出,并暴露宏:
#[proc_macro]
pub fn misc_syntax(input: TokenStream) -> TokenStream {
let m: MiscSyntax = syn::parse(input).expect("expected Miscellaneous Syntax");
let MiscSyntax { id, ty, expr, where_clause, type_slice, path } = m;
(quote! {
let #id: #ty = #expr;
println!("variable = {}", #id);
}).into()
}
当使用这个宏时,它实际上是一堆随机的语法。这强调了宏并不局限于有效的 Rust 语法,它看起来如下:
#![feature(proc_macro_non_items)]
#![feature(use_extern_macros)]
extern crate procmacro2;
fn main() {
procmacro2::misc_syntax!(
where while abcd : u64 >> 1 + 2 * 3;
where T: 'x + A<B='y+C+D>;
[M];A::f
);
}
如果 Rust 语法对您来说很烦人,过程宏非常强大且有用。在特定上下文中,可以使用宏创建非常语义密集的代码,否则这将需要大量的样板代码和复制粘贴编程。
摘要
在这一章中,我们介绍了 Rust 编程的许多应用和实践考虑因素。性能和调试当然不是仅限于函数式编程的问题。在这里,我们试图介绍一些普遍适用但高度兼容于函数式编程的技巧。
在 Rust 中,元编程可能被视为一种功能特性本身。逻辑编程及其由此派生的功能与函数式编程原则密切相关。宏的递归、上下文无关特性也使其适合于函数式视角。
这也是本书的最后一章。我们希望您喜欢这本书,并欢迎任何反馈。如果您正在寻找进一步阅读,您可能想研究一下书中最后三章中提出的某些主题。关于这些主题有大量的材料可用,并且任何选择的路径都将无疑进一步加深您对 Rust 和函数式编程的理解。
问题
-
发布模式与调试模式有何不同?
-
一个空的循环将运行多长时间?
-
在 Big O 表示法中,线性时间是什么意思?
-
请举一个比指数增长更快的函数的例子。
-
磁盘读取和网络读取哪个更快?
-
你会如何返回一个包含多个错误条件的
Result? -
什么是标记树?
-
抽象语法树是什么?
-
为什么过程宏需要单独编译?
第十章:评估
函数式编程——比较
- 函数是什么?
函数定义了一个转换,接受数据,并返回转换的结果。
- 函子是什么?
函子定义数据,接受一个转换,并返回转换的结果。
- 元组是什么?
一个元组是一个固定数量不同值的容器。
- 为与枚举一起使用而设计的控制流表达式是什么?
模式匹配表达式是枚举的匹配,反之亦然。
- 函数作为参数的函数的名称是什么?
函数的函数被称为高阶函数。
- 在记忆化的
fib(20)中,fib将被调用多少次?
fib将被调用 39 次。fib将被调用 21 次。
- 哪些数据类型可以通过通道发送?
发送的数据必须实现Send,这通常由编译器自动派生。
- 为什么函数在从函数返回时需要被封装?
函数是特性,因此在编译时没有已知的大小。因此,它们必须被参数化或通过类似Box的方式转换为特性对象。
move关键字的作用是什么?
move关键字将变量的所有权转移到新的上下文中。
- 两个变量如何共享单个变量的所有权?
间接引用,如Rc,允许共享对同一数据的引用。
函数式控制流
- 三元运算符是什么?
如果条件是三元运算符,但具有 Rust 独特的语法if a { b } else { c }。
- 单元测试的另一个名字是什么?
单元测试也被称为白盒测试。
- 集成测试的另一个名字是什么?
集成测试也被称为黑盒测试。
- 什么是声明式编程?
声明式编程在描述程序时避免实现细节。
- 命令式编程是什么?
命令式编程在描述程序时关注实现细节。
- 迭代器特质中定义了什么?
迭代器特质由一个关联的Item类型和所需的next方法定义。
- 折叠操作将遍历迭代器序列的方向是什么?
fold将从左到右遍历迭代器,或者更具体地说,从第一个到最后一个。
- 依赖图是什么?
依赖图是一个有向图,描述了节点之间的依赖关系。在我们的情况下,我们使用它来描述形式为x必须在y之前发生的关联。
Option的两个构造函数是什么?
Option可以创建为Some(x)或None。
函数式数据结构
- 有什么好的库可以用来序列化和反序列化数据?
我们推荐serde。
physics.rs中结构声明前面的# derive行的作用是什么?
这些是自动为这些数据结构派生特质实现的宏。
- 参数化声明中哪个先来——生命周期还是特质?
生命周期参数必须在参数声明中的特性参数之前。
- 在特质实现中,impl、trait 或类型上的参数之间有什么区别?
impl<A,...> 语法定义了哪些符号将被参数化。Trait<A,...> 语法定义了正在实现的特质。Type<A,...> 语法定义了特质正在为哪种类型实现。
- 特质和数据类之间有什么区别?
术语 数据类 不是一个 Rust 术语。将数据类想象成一个特质,但比 Rust 可能施加的限制要少。
- 应该如何声明一个包有多个二进制文件?
在 Cargo.toml 中,列出所有二进制文件及其入口点:
[[bin]]
name = "binary1"
path = "binary1.rs"
[[bin]]
name = "binary2"
path = "binary2.rs"
- 如何声明结构字段为私有?
不要声明为 public。字段默认是 private。
泛型和多态
- 什么是代数数据类型?
代数数据类型是一种由其他类型组合而成的复合类型。
- 什么是多态?
多态是具有多种形式的质量。
- 什么是参数化多态?
参数化多态是根据参数具有多种形式的质量。
- 什么是基础类型?
基础类型是一种没有任何参数、修饰符或替换的类型。例如,i32 或 String。
- 什么是通用函数调用语法?
通用函数调用语法用于区分函数或方法。它看起来像 Foo::f(&b) 而不是 b.f()。
- 特质对象的可能类型签名有哪些?
特质对象是任何在编译时将具有已知大小的特质签名。常见的例子有 &Trait 或 Box<Trait>。
- 有哪两种方法可以隐藏类型信息?
特质对象和特质通常隐藏信息。关联类型也减少了与代码交互所需的信息量。
- 如何声明子特质?
trait SuperTrait: SubTrait1 + SubTrait2 {}
代码组织和应用程序架构
- 有哪四种方式将代码分组到模块中?
我们的工作坊模型有四种方式将代码分组:按类型、按目的、按层和按便利性。
- FFI 代表什么?
FFI 代表 Foreign Function Interface。
- 为什么需要 unsafe 块?
Rust 中的 unsafe 语法表示你想要使用超级能力,并且你接受相应的责任。
- 是否在某个时候可以使用 unsafe 块是安全的?
没有什么是安全的。核心 Rust 开发者正在进行一项持续的努力,将标准库代码重写为使用更少的 unsafe 功能。尽管如此,根据你观察的深度,任何上下文中都没有绝对的安全性。例如,核心编译器只是假设在安全性检查方面始终逻辑上一致(希望它是)。
libc::c_int和i32之间有什么区别?
c_int 是一个直接别名——type c_int = i32;。
- 链接库能否定义具有相同名称的函数?
C++ 使用名为名称混淆的技术来导出具有相同名称的符号。然而,Rust 目前不通过 extern 识别这种格式。
- 可以将哪种类型的文件链接到 Rust 项目中?
链接库可以是以下形式之一:name.a*、name.lib、name.so、name.dylib、name.dll或name.rlib,每种都有其自己的格式。
可变性、所有权和纯函数
Rc代表什么?
Rc代表引用计数。
Arc代表什么?
Arc代表原子引用计数。
- 什么是弱引用?
弱引用是一种不计入引用计数或以其他方式管理的引用。
- 在不安全块中启用了哪些超能力?
在不安全块中,你可以取消引用原始指针,调用不安全函数或方法,访问或修改可变静态变量,或者实现不安全特质。
- 对象何时会被丢弃?
当其所有者被丢弃或超出作用域时,对象将被丢弃。
- 生命周期和所有权的区别是什么?
生命周期是编译时检查。所有权是编译时以及运行时概念。这两个概念都描述了变量的跟踪、值以及它们的使用者。
- 如何确保一个函数是安全的?
在 Rust 中,无法在函数中声明不存在不安全行为。
- 什么是内存损坏以及它会如何影响程序?
存在两种类型的内存损坏——物理内存损坏和软件内存损坏。如果你的物理内存损坏了,那么你需要更换你的硬件。软件内存损坏是指程序对其自身程序语义结构的破坏。当内存损坏时,一切都会出错;这是最难诊断和处理的 bug 类别之一。
设计模式
- 什么是函子?
函子定义数据,接受一个函数,并返回数据的转换。
- 什么是反变函子?
反变函子是一种接受函数可能产生 0、1 或多个返回值的函子。相比之下,函子的接受函数必须返回恰好 1 个值。
- 什么是单子?
单子是一个参数化单一类型A的值,它有一个特质暴露两个操作,通常命名为return和bind。return是一个从提供的A值构建新的monad<A>的函数。bind应该结合新信息以产生一个相关但分离的monad<B>。
- 什么是单子法则?
这些等价性必须对严格单子成立。三个水平条表示等价性:
_return(v).bind(f) ≡ f(v)
m.bind(_return) ≡ m
m.bind(f).bind(g) ≡ (|x| f(x).bind(g))
- 什么是组合器?
函数组合器结合函数。一个更生成型的组合器结合事物。
- 为什么 impl 关键字对于闭包返回值是必要的?
闭包是特质,而不是类型。因此,它们在编译时没有已知的大小。impl用于返回类型告诉编译器参数化返回类型。
- 什么是惰性求值?
惰性求值是在未来某个时刻延迟计算。这与立即求值相对,立即求值是指计算立即发生。
实现并发
- 什么是子进程?
子进程是由父进程启动的子进程。子进程必须保持在父进程下,才能继续被称为子进程。
- 为什么 fork 被称为 fork?
fork 意味着一个分裂(进程),就像道路上的分叉,或者分叉的舌头。
- fork 仍然有用吗?
是的!如果你可以在你的系统上访问它。例如,心跳模式使用 fork 会更优雅。
- 线程是在何时标准化的?
线程从未被普遍标准化。Posix 标准在 1995 年引入了线程。值得注意的是,Windows 没有提供关于线程行为的标准或保证。有相似之处,但没有标准。
- 为什么有时需要移动线程闭包?
Move 告诉编译器,将捕获的变量的所有权转移到闭包中是可以的。
Send和Sync特质的区别是什么?
Sync是对线程安全的一个更强声明——如果一个类型是Send,那么它可以安全地发送到另一个线程。一个类型是Sync,如果它可以安全地在线程之间共享。
- 我们可以锁定什么,然后在不使用 unsafe 块的情况下突变互斥锁(Mutex)?
编译器已确定互斥锁(Mutex)已经足够安全以使用,并满足某些安全要求。但这并不意味着不会发生坏事——如果在恐慌期间丢失了其MutexGuards(当获取锁时返回的对象)之一,互斥锁会自我中毒。任何未来的尝试锁定互斥锁都将返回一个Err或panic!。
性能、调试和元编程
- 发布模式与调试模式有何不同?
这取决于你的 Cargo 配置。默认情况下,有几个编译器标志在发布和调试模式下有不同的默认值。其中一个标志是发送到 llvm 代码生成的 opt-level,默认的调试 opt-level 是 2,默认的发布 opt-level 是 3。这些默认值可以在Cargo.toml中更改。
- 一个空循环将运行多长时间?
尝试一下。否则,很难在每一个平台上确定。循环将始终是一个无限循环。while true可能也是一个无限循环,但会生成一个警告。for _ in 0..99999999 {}将在 opt-level 3 时被移除,但在 opt-level 2 时则不会。
- 大 O 记号中的线性时间是什么?
线性时间是O(n)时间。
- 命名一个比指数增长更快的函数。
阶乘O(n)!比指数增长增长得更快。
- 磁盘读取和网络读取哪个更快?
测量一下。这里有许多物理因素需要考虑。
- 你会如何返回包含多个错误条件的
Result?
Rust 建议使用枚举类型来描述多个错误条件。由于懒惰,你也可以使用std::any::Any类型。
- 令牌树是什么?
令牌树是一个包含令牌的树形数据结构。由于 Rust 的词法分析,(...), [...], 和 {...} 令牌组将变成它们自己的分支。
- 抽象语法树(AST)是什么?
抽象语法树就像一个标记树,但它有一个严格的结构,只有格式良好的(Rust)代码才能由它表示。
- 为什么过程宏需要单独编译?
过程宏是用常规 Rust 代码编写的。为了在编译中使用,过程宏需要已经被编译。


浙公网安备 33010602011771号