Rust-精要-全-
Rust 精要(全)
原文:
annas-archive.org/md5/0c8737059d70b7008acd011518872acf译者:飞龙
前言
Rust 是一种新的开源编译型编程语言,最终为软件开发者提供了前所未有的安全性——不仅类型安全,而且内存安全。编译器仔细检查所有变量的使用和指针,因此 C / C++和其他语言中常见的错误,如指向错误内存位置的指针或空引用,已成为过去式。潜在的问题在编译时就被检测到,因此 Rust 程序可以以与 C++版本相当的速度执行。
Rust 运行在一个非常轻量级的运行时环境中,不执行垃圾回收。同样,编译器负责生成在正确时间释放所有资源的代码。这意味着 Rust 可以在非常受限的环境中运行,例如嵌入式或实时系统。当并发执行代码时,不会发生数据竞争,因为编译器对内存安全性的限制与代码顺序执行时相同。
从前面的描述中可以看出,Rust 适用于所有以前 C 和 C++是首选语言的用例,并且它将做得更好。
Rust 是一种非常丰富的语言;它具有(如默认不可变等)概念和(如特质等)结构,使开发者能够以高度函数式和面向对象的方式编写代码。
Rust 的原始目标是作为一种语言来编写一个全新的安全浏览器引擎,这个引擎没有现有浏览器所困扰的许多安全漏洞。这是来自 Mozilla Research 的 Servo 项目。
本书的目标是为您打下坚实的基础,以便您可以从 Rust 开始开发。整本书中,我们强调 Rust 的三个支柱:安全性、性能和并发性。我们讨论了 Rust 与其他编程语言不同的领域和原因。代码示例不是随意选择的,而是作为构建游戏项目的一部分,以便示例中有一个连贯性和演变的感觉。
在整本书中,我将敦促您通过实践来学习;您可以通过输入代码、进行所需的修改、编译、测试和完成练习来跟随。
本书涵盖的内容
第一章, 从 Rust 开始 讨论了导致 Rust 发展的主要原因。我们比较了 Rust 与其他语言,并指出了它最合适的领域。然后,我们指导您安装 Rust 开发环境所需的所有必要组件。
第二章,使用变量和类型,探讨了 Rust 程序的基本结构。我们讨论了原始类型,如何声明变量以及它们是否需要类型化,以及变量的作用域。不可变性,这是 Rust 安全策略的关键基石之一,也得到了说明。然后,我们查看基本操作,如何进行格式化打印,以及表达式和语句之间的重要区别。
第三章,使用函数和控制结构,展示了如何在 Rust 中定义函数以及影响程序执行流程的不同方式。
第四章,结构化数据和匹配模式,讨论了编程的基本数据类型,如字符串、向量、切片、元组和枚举。然后,我们向您展示 Rust 中可能的强大模式匹配以及如何通过解构模式提取值。
第五章,使用高阶函数和参数化泛化代码,探讨了 Rust 的功能性和面向对象特性。您将看到数据结构和函数如何以泛化的方式定义,以及如何使用特质来定义行为。
第六章,指针和内存安全,介绍了借用检查器,这是 Rust 确保只能发生内存安全操作的机制。我们讨论了不同类型的指针以及如何处理运行时错误。
第七章,组织代码和宏,讨论了 Rust 中更大的代码组织结构。我们还将涉及如何构建宏以生成代码并节省时间和精力。
第八章,并发和并行,深入探讨了 Rust 的并发模型及其基本概念:线程和通道。我们还讨论了处理共享可变数据的安全策略。
第九章,边界编程,探讨了 Rust 如何接受命令行参数进行处理。然后,我们继续探讨我们必须离开安全边界的情况,例如当我们与 C 接口或使用原始指针时,以及 Rust 如何在这种情况下最小化潜在危险。
附录,进一步探索,讨论了 Rust 生态系统以及读者可以在哪里找到更多关于某些主题的信息,例如处理文件、数据库、游戏和 Web 开发。
您需要这本书的内容
要运行书中的代码示例,您需要在您的计算机上安装 Rust 系统,可以从www.rust-lang.org/install.html下载。这还包括 Cargo 项目和包管理器。为了更舒适地与 Rust 代码一起工作,Sublime Text 等开发环境也可能很有用。第一章,“从 Rust 开始”,包含了如何设置 Rust 环境的详细说明。
这本书面向的对象
本书旨在为在 C/C++、Java/C#、Python、Ruby、Dart 或类似语言中具有一些编程经验,并对通用编程概念有基本了解的开发者提供帮助。它将帮助您快速上手,为您提供开始构建自己的 Rust 项目所需的一切。
约定
在本书中,您将找到许多不同风格的文本,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们可以看到main()是一个函数声明,因为它前面有关键字fn,这像大多数 Rust 关键字一样简短而优雅。”
代码块设置如下:
let tricks = 10;
let reftricks = &mut tricks;
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
let n1 = {
let a = 2;
let b = 5;
a + b // <-- no semicolon!
};
任何命令行输入或输出都如下所示:
[root]
name = "welcomec"
version = "0.0.1"
新术语和重要词汇以粗体显示。屏幕上显示的词,例如在菜单或对话框中,在文本中如下所示:“当与 Rust 代码一起工作时,选择工具 | 构建系统 | Rust。”
注意
警告或重要注意事项以如下框的形式出现。
小贴士
小技巧和窍门如下所示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们开发您真正能从中获得最大价值的标题非常重要。
要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>,并在邮件的主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为本书做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者了,我们有一些可以帮助您从购买中获得最大价值的事情。
下载示例代码
您可以从www.packtpub.com下载示例代码文件,这是您购买的所有 Packt 出版物的账户。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
错误更正
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误更正,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误更正提交表单链接,并输入您的错误更正详情来报告。一旦您的错误更正得到验证,您的提交将被接受,错误更正将被上传到我们的网站或添加到该标题的错误更正部分下的现有错误更正列表中。
要查看之前提交的错误更正,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误更正部分下。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过链接mailto:copyright@packtpub.com与我们联系,提供疑似盗版材料的链接。
我们感谢您在保护我们的作者以及为我们提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过链接mailto:questions@packtpub.com与我们联系,我们将尽力解决。
第一章. 从 Rust 开始
Rust 是由 Mozilla 研究室开发并由一个庞大的开源社区支持的一种编程语言。它的开发始于 2006 年,由语言设计者 Graydon Hoare 开始。Mozilla 从 2009 年开始赞助它,并于 2010 年首次正式推出。该项目经过多次迭代,最终在 2015 年 5 月 15 日推出了第一个稳定的生产版本 1.0.0,由 Rust 项目开发者制作,包括 Mozilla 的 Rust 团队和超过 900 名贡献者的开源社区。Rust 基于清晰和稳固的原则。它是一种系统编程语言,在能力上与 C 和 C++ 相当。它在速度上与惯用的 C++ 相当,但它通过禁止使用可能导致程序因内存问题而崩溃的代码,让您以更安全的方式工作。此外,Rust 具有在多核机器上执行并发操作所需的内置功能;它通过垃圾回收使并发编程内存安全——这是唯一能做到这一点的语言。Rust 还消除了通过并发访问导致的共享数据损坏,也称为数据竞争。
本章将向您展示 Rust 流行度和采用率稳步增长的主要原因。然后,我们将设置一个可工作的 Rust 开发环境。
我们将涵盖以下主题:
-
Rust 的优势
-
Rust 的三合一优势:安全性、速度和并发性
-
使用 Rust
-
安装 Rust
-
Rust 编译器 –
rustc -
构建我们的第一个程序
-
使用 Cargo 进行工作
-
开发者工具
Rust 的优势
Mozilla 是一家以其使命为基础的公司,致力于开发基于开放标准的工具并推动 Web 的进化,最著名的是通过其旗舰浏览器 Firefox。如今,包括 Firefox 在内的每个浏览器都是用 C++ 编写的,Firefox 使用了约 12,900,992 行代码,Chrome 使用了约 4,490,488 行代码。这使得程序运行速度快,但本质上是不安全的,因为 C 和 C++ 允许的内存操作没有经过有效性检查。如果代码编写时开发者没有最严格的编程纪律,那么程序在执行时可能会出现崩溃、内存泄漏、段错误、缓冲区溢出和空指针等问题。其中一些可能导致严重的安全漏洞,这在现有的浏览器中是众所周知的。Rust 从一开始就被设计用来避免这些问题。
在编程语言谱系的另一端,我们有 Haskell,它广为人知是一个非常安全和可靠的语言,但几乎或完全没有对内存分配和其他硬件资源的控制。我们可以沿着这个控制-安全性轴绘制不同的语言,似乎当一种语言更安全时,它会失去底层控制;反之亦然:提供更多资源控制的语言提供的安全性更少,如下所示:

Rust (www.rust-lang.org/) 是为了克服这一困境而设计的,它提供了以下特性:
-
通过其强大的类型系统实现高安全性
-
对底层资源进行深入但安全的控制(与 C/C++ 相当),以便其运行接近硬件
Rust 允许你精确指定你的值在内存中的布局方式以及如何管理这些内存;这就是为什么它在控制和安全性的两端都表现得很好。这是 Rust 的独特卖点:它打破了在 Rust 之前存在于编程语言中的安全-控制二分法。使用 Rust,可以在不牺牲性能的情况下同时实现控制和安全性。
与大多数现代语言(如 Java、C#、Python、Ruby、Go)不同,Rust 可以在不使用垃圾回收器的情况下实现这两个目标;实际上,Rust 甚至还没有垃圾回收器(尽管计划中有一个)。Rust 是一种编译型语言:严格的 safety 规则由编译器强制执行,因此不会造成运行时开销。因此,Rust 可以使用最少的运行时,甚至完全没有运行时;因此,它可以用于实时或嵌入式项目,并且可以轻松地与其他语言或项目集成。
Rust 旨在为那些不仅重视性能和底层优化,而且还需要一个安全且稳定的执行环境的开发者和项目而设计。此外,Rust 在语言内部添加了许多高级函数式编程技术,因此它既感觉像是一种底层语言,又像是一种高级语言。
Rust 的三合一优势——安全性、速度和并发性
Rust 不是一个具有新尖端特性的革命性语言,但它从旧语言中吸收了许多经过验证的技术,同时在安全编程方面对 C++ 的设计进行了大量改进。
Rust 开发者设计 Rust 成为一个通用和多范式的语言。像 C++ 一样,它是一种命令式、结构化和面向对象的语言。除此之外,它还从函数式语言中继承了许多东西,并且还融合了并发编程的高级技术。
在 Rust 中,变量的类型是静态的(因为 Rust 是编译型语言)且强类型。然而,与 Java 或 C++ 不同,开发者不需要强制为所有内容指定类型,因为 Rust 编译器能够在许多情况下推断出类型。
C 和 C++ 被认为是受一系列问题的困扰,这些问题经常导致程序崩溃或内存泄漏,这些问题的调试和解决特别困难。想想悬垂指针、缓冲区溢出、空指针、段错误、数据竞争等等。Rust 编译器(称为 rustc)非常智能,可以在编译你的代码时检测到所有这些问题,从而在执行期间保证内存安全。这是通过编译器通过保留对内存布局的完全控制来实现的,无需运行时垃圾回收的负担(见 第六章,指针与内存安全)。此外,其安全性还意味着更少的潜在安全漏洞。
Rust 编译原生代码的方式类似于 Go 和 Julia。然而,与这两种语言相比,Rust 不需要带有垃圾回收的运行时。在这方面,它也不同于 Java JVM 和在 JVM 上运行的语言,如 Scala 和 Clojure。大多数其他流行的现代语言,如 .NET 中的 C# 和 F#、JavaScript、Python、Ruby、Dart 等等,都需要虚拟机和垃圾回收。
作为其并发机制之一,Rust 采用了来自 Erlang 的知名 actor 模型。轻量级进程称为线程,并行执行工作。它们不共享堆内存,而是通过通道进行数据通信,并通过类型系统消除数据竞争(见 第八章,并发与并行)。这些原语使得程序员能够利用当前和未来计算平台上可用的多个 CPU 核心的强大功能。
rustc 编译器是完全自托管的,这意味着它是用 Rust 编写的,并且可以使用之前的版本来编译自身。它使用 LLVM 编译器框架作为其后端(有关 LLVM 编译器框架的更多信息,请访问 en.wikipedia.org/wiki/LLVM)并生成原生可执行代码,运行速度极快,因为它编译成与 C++ 相同的低级代码(要查看其速度示例,请访问 benchmarksgame.alioth.debian.org/u64q/rust.php)。
Rust 被设计成与 C++ 一样可移植,并能在广泛使用的硬件和软件平台上运行;目前,它可以在 Linux、Mac OS X、Windows、FreeBSD、Android 和 iOS 上运行。它可以像 C 一样简单高效地调用 C 的代码,反之亦然,C 也可以调用 Rust 代码(见 第九章,边界编程)。以下是 Rust 的标志:

在后续章节中将更详细讨论的其他 Rust 特性如下:
-
它的变量默认是不可变的(参见第二章,使用变量和类型)
-
枚举(参见第四章,结构化数据和匹配模式)
-
模式匹配(参见第四章,结构化数据和匹配模式)
-
泛型(参见第五章,使用高阶函数和参数化泛化代码)
-
高阶函数和闭包(参见第五章,使用高阶函数和参数化泛化代码)
-
术语 traits 的接口系统(参见第五章,使用高阶函数和参数化泛化代码)
-
一种卫生宏系统(参见第七章,组织代码和宏)
-
零成本抽象,这意味着 Rust 具有高级语言结构,但这些结构不会对性能产生影响
总之,Rust 让你能够对内存分配拥有终极控制权,同时消除了与本地语言通常相关的大量安全和稳定性问题。
与其他语言的比较
动态语言,如 Ruby 或 Python,可以提供初始的编码速度,但当你需要编写更多测试、运行时崩溃或甚至生产中断电时,你将付出代价。Rust 编译器迫使你在编译时正确处理很多事情,这是识别和修复错误成本最低的地方。
Rust 的面向对象特性不如 Java、C# 和 Python 等常见面向对象语言那样明确或成熟,因为它没有类。与 Go 相比,Rust 给你更多的内存和资源控制,因此让你能够在更低的级别上进行编码。Go 也使用垃圾回收器,并且它没有泛型或防止其并发使用的 goroutines 之间数据竞争的机制。Julia 专注于数值计算性能;它与 JIT 编译器一起工作,并且不提供 Rust 提供的那种低级别控制。
使用 Rust
从前面的章节中可以清楚地看出,Rust 可以用于通常使用 C 或 C++ 的项目中。事实上,许多人认为 Rust 是 C 和 C++ 的继任者或替代品。尽管 Rust 被设计成一种系统语言,但由于其丰富的结构,它具有广泛的应用范围,使其成为以下类别之一或所有类别的理想候选:
-
客户端应用程序,如浏览器
-
低延迟、高性能的系统,例如设备驱动程序、游戏和信号处理
-
高度分布式和并发系统,例如服务器应用程序
-
实时和关键系统,例如操作系统或内核
-
嵌入式系统(需要非常小的运行时占用)或资源受限的环境,例如 Raspberry Pi、Arduino 或机器人
-
无法支持在 即时编译 (JIT) 系统中常见的长时间预热延迟的工具或服务,需要即时启动
-
网络框架
-
大规模、高性能、资源密集和复杂的软件系统
Rust 特别适合代码质量至关重要的场合,即:
-
规模适中或更大的开发者团队
-
长期运行的生产代码
-
需要定期维护和重构的具有较长生命周期的代码
-
对于你通常需要编写大量单元测试来保障其安全的代码
即使在 Rust 1.0 版本发布之前,已有两家公司已经开始在生产环境中使用它:
-
OpenDNS (
labs.opendns.com/2013/10/04/zeromq-helping-us-block-malicious-domains/) 是一个用于阻止恶意软件和恶意域的中间件工具 -
来自 Tilde (
www.tilde.io/) 公司的 Skylight (www.skylight.io/) 是一个用于监控 Rails 应用程序执行的工具。
Servo
Mozilla 使用 Rust 作为其新网络浏览器引擎 Servo 的编程语言,该引擎旨在实现并行性和安全性(github.com/servo/servo)。
由于 Rust 编译器的设计,许多种类的浏览器安全漏洞被自动预防。2013 年,三星公司介入,将 Servo 移植到 Android 和 ARM 处理器上。Servo 本身是一个拥有超过 200 位贡献者的开源项目。它正在积极开发中,并且已经实现了自己的 CSS3 和 HTML5 解析器,使用 Rust 编写。它在 2014 年 3 月通过了 ACID2 网络兼容性浏览器测试(en.wikipedia.org/wiki/Acid2/))。
安装 Rust
Rust 编译器和工具可以从 www.rust-lang.org/install.html 以二进制(即可执行)形式下载。该平台适用于三大主流操作系统(Linux 2.6.18 或更高版本、OS X 10.7 或更高版本以及 Windows 7、Windows 8 和 Windows Server 2008 R2),提供 32 位和 64 位格式,并以安装程序或存档格式提供。当你使用 Rust 进行专业工作时,应使用当前官方稳定版本 1.0。如果你想要调查或使用最新发展,请安装夜间构建版本。
对于 Windows,双击.exe安装程序来安装 Rust 的二进制文件和依赖项。将 Rust 的目录添加到可执行文件的搜索路径是安装过程中的一个可选部分,因此请确保已选择此选项。
对于 Linux 和 Mac OS X,最简单的方法是在您的 shell 中运行以下命令:
curl -sSL https://static.rust-lang.org/rustup.sh | sh
通过使用rustc –V或rustc - -version来显示 Rust 的版本,以验证安装的正确性,这将产生类似rustc 1.0.0-beta (9854143cb 2015-04-02) (built 2015-04-02)的输出。
在 Windows 上,可以通过运行C:\Rust\unins001.exe来卸载 Rust,或在 Linux 上运行/usr/local/lib/rustlib/uninstall.sh。
Rust 也已移植到基于 ARM 处理器的 Android 操作系统和 iOS。
一个名为 zinc 的裸机栈,用于在嵌入式环境中运行 Rust,可以在zinc.rs/找到。然而,目前它只支持 ARM 架构。
源代码位于 GitHub 上(github.com/rust-lang/rust/),如果您想从源代码构建 Rust,我们建议您参考github.com/rust-lang/rust#building-from-source。
Rust 编译器 – rustc
Rust 安装目录中包含rustc的文件夹可以在您的机器上的以下位置找到:
-
在 Windows 上,在
C:\Program Files\Rust 1.0\bin或您选择的文件夹中 -
在 Linux 或 Mac OS X 上,可以通过导航到
/usr/local/bin来找到。
如果将 Rust 的主文件夹添加到可执行文件的搜索路径中,则可以从任何命令行窗口运行rustc。Rust 库可以在 Windows 上bin目录的rustlib子目录中找到,或在 Linux 上的/usr/local/lib/rustlib中。其 HTML 文档可以在 Windows 上的C:\Rust\share\doc\rust\html或 Linux 上的/usr/local/share/doc/html找到。
rustc命令的格式如下:rustc [选项] 输入。
选项是编译器后面的单个字母指令,例如-g或-W,或者以双横线为前缀的单词,例如- -test或- -no-analysis。在调用rustc -h时,将显示所有带有解释的选项。在下一节中,我们将通过编译和运行我们的第一个 Rust 程序来验证我们的安装。
我们的第一款程序
让我们从向我们的游戏玩家显示欢迎信息开始:
-
打开您喜欢的文本编辑器(如记事本或 gedit)创建一个新文件,并输入以下代码:
// code in Chapter1\code\welcome.rs fn main() { println!("Welcome to the Game!"); } -
将文件保存为
welcome.rs。rs是 Rust 代码文件的标准扩展名。源文件名不得包含空格;如果包含多个单词,请使用下划线_作为分隔符;例如,start_game.rs。 -
然后,使用以下命令在命令行上将其编译为本地代码:
rustc welcome.rs这将在 Windows 上产生一个名为
welcome.exe的可执行程序,或在 Linux 上产生名为welcome的程序。 -
使用
welcome或./welcome运行此程序,以获取以下输出:Welcome to the Game!
输出可执行文件的名字来自源文件。如果你想给可执行文件另一个名字,比如 start,可以用 -o output_name 选项编译它:
rustc welcome.rs -o start
rustc –O 命令生成针对执行速度优化的本地代码(这相当于 rustc -C opt-level=2;最优化代码是在 rustc –C opt-level = 3 时生成的)。
编译和运行是分开的、连续的步骤,这与 Ruby 或 Python 等动态语言不同,在这些语言中这些步骤是在一个步骤中完成的。
让我们来解释一下这段代码。如果你已经在 C/Java/C# 等环境中工作过,这段代码看起来会很熟悉。就像大多数语言一样,代码的执行从 main() 函数开始,在可执行程序中这是强制性的。
在一个包含许多源文件的大型项目中,包含 main() 函数的文件按照惯例会被命名为 main.rs。
我们可以看到 main() 是一个函数声明,因为它前面有关键字 fn,这就像大多数 Rust 关键字一样简短而优雅。main() 后面的 () 表示参数列表,这里为空。函数的代码放置在代码块中,代码块由花括号 ({ }) 包围,其中开括号按照惯例放在与函数声明相同的行上,但与函数声明之间有一个空格。闭括号出现在这里的代码之后,紧接在 fn 下方。
我们程序只有一行,缩进四个空格以提高可读性(Rust 不对空白敏感)。这一行打印字符串 "欢迎来到游戏!"。Rust 将其识别为字符串,因为它被双引号 (" ")包围。这个字符串被作为 println! 宏的参数传递(! 表示这是一个宏而不是函数)。代码行以分号 (;)结尾,就像 Rust 中的大多数代码行一样(参见第二章,使用变量和类型)。
执行以下练习:
-
编写、编译并执行一个名为
name.rs的 Rust 程序,该程序打印出你的名字。 -
在 Rust 中,就代码大小而言,最小的可能程序是什么?
println! 宏有一些很好的格式化功能,同时它在编译时检查变量的类型是否适用于应用的格式化(参见第二章,使用变量和类型)。
使用 Cargo
Cargo 是 Rust 的包和依赖管理器,它类似于其他语言的 Bundler、npm、pub 或 pip。尽管你可以不使用 Cargo 编写 Rust 程序,但对于任何大型项目来说,Cargo 几乎是必不可少的;无论你在 Windows、Linux 还是 Mac OS X 系统上工作,Cargo 都能正常工作。上一节的安装过程包括了 Cargo 工具,因此 Rust 随工具一起提供。
Cargo 为你做了以下事情:
-
使用
cargo new命令为你的项目创建一个整洁的文件夹结构和一些模板: -
使用
cargo build命令编译(构建)你的代码: -
它通过使用
cargo run运行你的项目: -
如果你的项目包含单元测试,它可以使用
cargo test为你执行它们: -
如果你的项目依赖于包,它将下载它们,并使用
cargo update根据你的代码需求构建这些包:
我们现在将介绍如何使用 Cargo,稍后我们会回到这个话题,但你可以在这里找到更多信息:doc.crates.io/guide.html。
让我们通过以下步骤使用 Cargo 重新制作我们的第一个项目welcomec:
-
使用以下命令启动一个新的项目
welcomec:cargo new welcomec --bin--bin选项告诉 Cargo 我们想要制作一个可执行程序(二进制文件)。这创建了以下目录结构:![与 Cargo 一起工作]()
创建了一个与项目同名的文件夹;在这个文件夹中,你可以放置各种通用信息,例如
License文件、README文件等。此外,还创建了一个名为src的子文件夹,其中包含一个名为main.rs的模板源文件。(这包含与我们的welcome.rs文件相同的代码,但它会打印出字符串"Hello world!")文件
Cargo.toml(首字母大写 C)是项目的配置文件或清单;它包含 Cargo 编译项目所需的所有元数据。它遵循所谓的 TOML 格式(有关此格式的更多详细信息,请访问github.com/toml-lang/toml)并包含有关我们项目的以下文本:[package] name = "welcomec" version = "0.0.1" authors = ["Your name <you@example.com>"]"此文件是可编辑的,因此可以添加其他部分。例如,你可以添加一个部分来告诉 Cargo 我们想要一个名为 welcome 的二进制文件:
[[bin]] name = "welcome" -
我们可以使用以下命令构建我们的项目(无论它包含多少源文件):
cargo build这给我们以下输出(在 Linux 上):
Compiling welcomec v0.0.1 (file:///home/ivo/Rust_Book/welcomec)现在,产生了以下文件夹结构:
![与 Cargo 一起工作]()
目录
target包含可执行文件welcome。 -
要执行此程序,请运行以下命令:
cargo run这会产生以下输出:
Running `target/welcome` Hello, world!
第 2 步还产生了一个名为Cargo.lock的文件;这个文件被 Cargo 用来跟踪应用程序中的依赖关系。目前,应用程序只包含:
[root]
name = "welcomec"
version = "0.0.1"
使用相同的文件格式锁定你的项目所依赖的库或包的版本。如果你的项目在将来构建时可用更新版本的库,Cargo 将确保只使用记录在Cargo.lock中的版本,这样你的项目就不会使用与库不兼容的版本构建。这确保了可重复的构建过程。
执行以下练习:
- 使用 Cargo 制作、构建并运行一个名为
name的项目,该项目会打印出你的名字。
crates.io/网站是 Rust 包或 crate(它们被称为)的中心仓库,截至 2015 年 3 月底,包含 1700 个 crate。你可以使用特定术语搜索 crate,或者按字母顺序或下载量浏览它们:

开发者工具
由于 Rust 是一种系统编程语言,你唯一需要的是一款好的文本编辑器(但不是文字处理器!)来编写源代码,其余所有事情都可以通过终端会话中的命令来完成。然而,一些开发者欣赏那些专为编程或 IDE(集成开发环境)设计的更全面的文本编辑器提供的功能。尽管一些功能需要更新到最新的 Rust 版本,但 Rust 仍然很年轻,在这一领域已经出现了很多可能性。
Rust 插件适用于众多文本编辑器,例如 Atom、Brackets、BBEdit、Emacs、Geany、GEdit、Kate、TextMate、Textadept、Vim、NEdit、Notepad++和 SublimeText。大多数 Rust 开发者使用 Vim 或 Emacs。这些编辑器自带语法高亮和代码补全工具 racer;请访问github.com/phildawes/racer。
使用 Sublime Text
对于流行的 Sublime Text 编辑器(www.sublimetext.com/3)的插件特别易于使用,并且不会妨碍你。安装 Sublime Text(你可能想要获取一个注册版本)后,你还必须安装 Package Control 包。(有关如何操作的说明,请访问packagecontrol.io/installation)。
然后,要安装 Sublime Text 的 Rust 插件,在 Sublime Text 中打开调色板(Ctrl + Shift + P或在 Mac OS X 上为cmd + Shift + P)并选择Package Control | Install Package。然后,从列表中选择Rust,你将看到如下截图:

Sublime Text 是一个非常全面的文本编辑器,它包括配色方案。Rust 插件提供语法高亮和自动补全。输入一个或多个字母,使用箭头键从出现的列表中选择一个选项,然后按Tab键插入代码片段,或者通过鼠标点击简单地选择列表选项。要编译和执行 Rust 代码,请按照以下步骤操作:
-
在菜单中标记工具 | 构建系统 | Rust。
-
然后,你可以通过按Ctrl + B来编译源文件。警告或错误将出现在下方的面板中;如果一切正常,将出现类似于[完成耗时 0.6 秒]的消息。
-
然后,你可以通过按Ctrl + Shift + B来运行程序;输出将再次出现在代码下方。或者,你可以使用菜单项:工具 | 构建和工具 | 运行。
存在一个 SublimeLinter 插件,它提供了一个到 rustc 的接口,称为 SublimeLinter-contrib-rustc。它对您的代码进行额外的检查,以发现风格或编程错误。您可以通过 Package Control 如前所述安装它,然后从菜单 工具 | SublimeLinter 使用它。(有关更多详细信息,请访问 github.com/oschwald/SublimeLinter-contrib-rustc。)还有一个名为 racer 的代码补全工具;您可以在 packagecontrol.io/packages/RustAutoComplete 上找到如何安装它的信息。
其他工具
RustDT (rustdt.github.io/) 是一个基于 Eclipse 的新兴且有潜力的 Rust IDE。除了 Eclipse 提供的所有编辑功能外,它还基于 Cargo 进行项目开发。此外,它还具有代码补全和调试功能(使用 GDB 调试器)。
此外,还有以下这些插件,用于不同完成状态的 IDE:
-
RustyCage 插件 (
github.com/reidarsollid/RustyCage) 用于 Eclipse -
idea-rust 插件 (
plugins.jetbrains.com/plugin/7438) 用于 IntelliJ -
rust-netbeans 插件 (
github.com/azazar/rust-netbeans) 用于 NetBeans -
VisualRust 插件 (https://github.com/PistonDevelopers/VisualRust) 用于 Visual Studio
您甚至无需本地安装即可测试 Rust 代码,使用 Rust Play Pen:play.rust-lang.org/。在这里,您可以编辑或粘贴代码,并对其进行评估。
rusti 是一个交互式外壳或 读取-评估-打印-循环 (REPL),它正在为 Rust 开发;这对于动态语言来说是常见的,但对于静态编译语言来说则非常引人注目。您可以在 github.com/murarth/rusti 找到它。
摘要
在本章中,我们为您概述了 Rust 的特性,Rust 的应用场景,并将其与其他语言进行了比较。我们编写了第一个程序,演示了如何使用 Cargo 构建项目,并为您提供了构建更完整开发环境的选择。
在下一章中,我们将探讨变量和类型,并探讨可变性的重要概念。
第二章:使用变量和类型
在本章中,我们探讨 Rust 程序的基本构建块:变量及其类型。我们讨论原始类型的变量,是否需要声明其类型,以及变量的作用域。不可变性是 Rust 安全策略的基石之一,也将被讨论并举例说明。
我们将涵盖以下主题:
-
注释
-
全局常量
-
值和原始类型
-
将变量绑定到值
-
变量的作用域和阴影
-
类型检查和转换
-
表达式
-
栈和堆
我们的代码示例将集中在构建一个名为“Monster Attack”的基于文本的游戏。
注释
理想情况下,一个程序应该通过使用描述性的变量名和易于阅读的代码来自我说明,但总有一些情况下需要关于程序结构或算法的额外注释。Rust 遵循 C 语言约定,并具有以下注释标记约定:
-
行注释 (
//)://之后行上的所有内容都是注释,不会被编译 -
块或多行注释 (
/* */): 从开始/*到结束*/之间的所有内容都不会被编译
然而,Rust 推荐的风格是即使对于多行,也只使用行注释,如下面的代码所示:
// see Chapter 2/code/comments.rs
fn main() {
// Here starts the execution of the Game.
// We begin with printing a welcome message:
println!("Welcome to the Game!");
}
只使用块注释来注释掉代码。
Rust 还有一个有用的文档注释(///),适用于需要为客户和开发者提供官方文档的大型项目。这样的注释必须出现在单独一行上的项目(如函数)之前,以记录该项目。在这些注释中,你可以使用 Markdown 格式化语法;有关更多信息,请访问en.wikipedia.org/wiki/Markdown。
这里是一个doc注释:
/// Start of the Game
fn main() {
}
我们将在后面的代码片段中看到更多关于文档注释的相关用法。rustdoc工具可以将这些注释编译成项目的文档。
全局常量
通常,一个应用程序需要一些实际上是不变的值;它们在程序执行过程中不会改变。例如:我们游戏的名字“Monster Attack”是一个常量,同样,最大生命值,即数字 100,也是一个常量。我们必须能够在main()或程序中的任何其他函数中使用它们,因此它们被放置在代码文件的最顶部。它们存在于程序的全球范围内。这样的常量使用static关键字声明,如下所示:
// see Chapter 2/code/constants1.rs
static MAX_HEALTH: i32 = 100;
static GAME_NAME: &'static str = "Monster Attack";
fn main() {
}
常量的名称必须全部大写,可以使用下划线来分隔单词。它们的类型也必须指明;MAX_HEALTH是一个 32 位整数(i32),而GAME_NAME是一个字符串(str)。正如我们进一步讨论的那样,变量的类型声明方式与这完全相同,尽管当编译器可以从代码上下文中推断类型时,这通常是可选的。
目前不必太担心 '&static' 指示。记住,Rust 是一种底层语言,所以许多事情必须详细指定。& 注解是对某个东西的引用(它包含值的内存地址);在这里,它包含对字符串的引用。然而,如果我们只使用 &str 并编译,我们会得到该行的错误。看看下面的片段:
// warning: incorrect code!
static GAME_NAME: &str = "Monster Attack";
这将给出以下错误:
2:22 error: missing lifetime specifier [E0106]
这里,2:22 表示我们在第 2 行第 22 个位置有一个错误,因此我们必须在编辑器中设置行号。我们必须将 'static 生命周期指定符添加到类型注解中,以便得到 &'static str。在 Rust 中,对象的生存期非常重要,因为它说明了对象将在程序的内存中存活多久。Rust 编译器会在对象的生存期结束时添加代码来移除对象,释放它所占用的内存。'static 生命周期是最长可能的生存期;这样的对象在整个应用程序中保持存活,因此它对所有代码都是可用的。
即使添加了这个指定符,编译器也会给出“警告:静态项从未使用:MAX_HEALTH,默认开启 #[warn(dead_code)]”警告以及针对 GAME_NAME 的类似警告。
这些警告并不能阻止编译,因此在这个阶段,我们有一个可执行的程序。然而,编译器是对的。这些对象在程序的代码中从未被使用;所以,在一个完整的程序中,你应该要么使用它们,要么将它们丢弃。
小贴士
在一个有抱负的 Rust 开发者开始将 Rust 编译器视为他的或她的朋友,而不是一个不断吐出错误和警告的讨厌的机器之前,需要一段时间。只要你在编译器输出的末尾看到这条消息,“错误:由于之前的错误而中止”,就不会生成(新的)可执行文件。但请记住,纠正错误可以消除运行时问题,因此这可以节省你大量本可以浪费在追踪讨厌的虫子上的时间。通常,错误消息会附带有关如何消除错误的帮助性说明。甚至警告也可以指出你代码中的缺陷。Rust 还会在代码中声明但未使用某些内容时警告我们,例如未使用的变量、函数、导入的模块等。它甚至会在我们不应该改变变量值时警告我们,或者当代码没有执行时。编译器的工作如此出色,以至于当你达到所有错误和警告都已消除的阶段时,你的程序很可能运行正确!
除了静态值之外,我们还可以使用简单的常量值,其值永远不会改变。常量总是需要指定类型,例如,const PI: f32 = 3.14; 它们的范围比静态值更局部。
编译器会自动在代码的每个地方替换常量的值。
使用字符串插值打印
使用变量的一个明显方式是打印它们的值,就像我们在这里所做的那样:
// see Chapter 2/code/constants2.rs
static MAX_HEALTH: i32 = 100;
static GAME_NAME: &'static str = "Monster Attack";
fn main() {
const PI: f32 = 3.14;
println!("The Game you are playing is called {}.", GAME_NAME);
println!("You start with {} health points.", MAX_HEALTH);
}
这将产生以下输出:
The Game you are playing is called Monster Attack.
You start with 100 health points.
常量 PI 存在于标准库中,要使用此值,请将以下语句插入顶部:use std::f32::consts; 然后如下使用 PI 值:println!("{}", consts::PI);
println! 的第一个参数是一个包含 {} 占位符的文本格式字符串。逗号后面的常量或变量的值被转换为字符串并替换 {}。可以有多个占位符,并且它们可以按顺序编号,以便可以重复使用,如下面的代码所示:
println!("In the Game {0} you start with {1} % health, yes you read it correctly: {1} points!", GAME_NAME, MAX_HEALTH);
输出如下:
In the Game Monster Attack you start with 100 % health, yes you read it correctly: 100 points!
占位符也可以包含一个或多个命名参数,如下所示:
println!("You have {points} % health", points=70);
这将产生以下输出:
You have 70 % health
在冒号(:)之后的大括号({})内可以指示特殊的格式化方式,如下所示:
println!("MAX_HEALTH is {:x} in hexadecimal", MAX_HEALTH); // 64
println!("MAX_HEALTH is {:b} in binary", MAX_HEALTH); // 1100100
println!("pi is {:e} in floating point notation", PI); // 3.14e0
根据必须打印的类型,存在以下格式化可能性:
-
o表示八进制 -
x表示小写十六进制 -
X表示大写十六进制 -
p表示指针 -
b表示二进制 -
e表示小数指数表示法 -
E表示大写指数表示法 -
?用于调试目的
format! 宏具有相同的参数,并且与 println! 以相同的方式工作,但它返回一个字符串而不是打印出来。
前往 doc.rust-lang.org/std/fmt/ 了解所有可能性的概述。
值和原始类型
已经初始化的常量具有值。值存在于不同的类型中:70 是整数,3.14 是浮点数,而 Z 和 q 是 char 类型(它们是字符)。字符是占用每个 4 字节内存的 Unicode 值。Godzilla 是类型为 &str(默认为 Unicode UTF8)的字符串,true 和 false 是 bool 类型;它们是布尔值。整数可以以不同的格式书写:
-
以
0x开头的十六进制格式(例如,0x46表示70) -
以
0o开头的八进制格式(例如,0o106表示70) -
以
0b开头的二进制格式(例如,0b1000110)
下划线可用于提高可读性,例如 1_000_000。有时,编译器会敦促你通过后缀更明确地指示数字的类型。例如,u 或 i 后的数字是使用的内存位数,即 8、16、32 或 64:
-
10usize表示机器字大小的无符号整数usize,可以是u8、u16、u32或u64中的任何一种类型 -
10isize表示机器字大小的有符号整数isize,可以是i8、i16、i32和i64中的任何一种类型 -
在前面的情况下,对于 64 位操作系统,
usize实际上是u64,而isize等同于i64。 -
3.14f32表示 32 位浮点数 -
3.14f64表示 64 位浮点数
如果没有给出后缀,则数字类型 i32 和 f64 是默认值,但在此情况下,为了区分它们,必须在 f64 值的末尾加上 .0,如下所示:let e = 7.0;。
仅当编译器指示它无法推断变量的类型时,才需要指定特定类型。
Rust 在值上存在的不同运算符及其优先级方面,与其他 C 语言类似(有关更多信息,请参阅doc.rust-lang.org/reference.html#binary-operator-expressions)。然而,请注意,Rust 没有自增(++)或自减(--)运算符。要比较两个值是否相等,请使用==,要测试它们是否不同,请使用!=。
甚至还有空值(),它的大小为零,是所谓的单元类型()的唯一值。这用于表示表达式或函数返回无值(没有值)时的返回值,例如仅向控制台打印的函数。()在其他语言中不是 null 值的等价物;()表示没有值,而 null 是一个值。
查阅 Rust 文档
要找到有关 Rust 主题的更详细信息,最快的方法是浏览标准库的文档屏幕doc.rust-lang.org/std/。在其左侧,您可以找到所有可用的 crate 列表,您可以浏览以获取更多详细信息。然而,最有用的功能是顶部的搜索框;您可以输入几个字母或一个单词来获取多个有用的参考。请看以下截图:

以下是一个练习:
-
尝试更改常量的值。这当然是不允许的。你会得到什么错误?请看
Chapter2/exercises/change_constant.rs。 -
在文档中查找
println!宏。 -
阅读关于
fmt规范的说明,并编写一个程序,该程序将3.2f32值打印为+003.20。请参阅Chapter2/exercises/formatting.rs。
将变量绑定到值
将所有值存储在常量中不是一个选择。这样做不好,因为常量会随着程序的生命周期而存在,因此可以被更改,而我们通常希望更改值。在 Rust 中,我们可以通过使用let绑定来将值绑定到变量:
// see Chapter 2/code/bindings.rs
fn main() {
let energy = 5; // value 5 is bound to variable energy
}
与许多其他语言(如 Python 或 Go)不同,这里需要分号(;)来结束语句。否则,编译器会抛出错误:期望的是.、;或运算符,但找到的是}。
我们还希望在程序的其他部分使用绑定时才创建绑定,但您不必担心,因为 Rust 编译器会警告我们:
values.rs:2:6: 2:7 warning: unused variable: `energy`, #[warn(unused_variables)] on by default
小贴士
为了原型设计的目的,您可以通过在变量名前加一个下划线_来抑制警告,例如let _ energy = 5;。通常,_用于我们不需要的变量。
注意,在前面的声明中,我们不需要指示类型;Rust 通过let绑定推断出energy的类型是整数。如果类型不明显,编译器会在代码上下文中搜索,以检查变量从哪里获得值或如何使用。
然而,使用如let energy = 5u16;这样的类型提示也是可以的;这样你通过指示energy的类型(在这种情况下是一个 2 字节的无符号整数)来帮助编译器。
我们可以通过在表达式中使用它来使用energy变量;例如,通过将其赋值给另一个变量或打印它:
let copy_energy = energy;
println!("Your energy is {}", energy););
这里有一些其他的声明:
let level_title = "Level 1";
let dead = false;
let magic_number = 3.14f32;
let empty = (); // the value of the unit type ()
magic_number的值也可以写成3.14_f32;下划线_将数字与类型分开,以提高可读性。
声明可以替换相同变量的先前声明。例如,let energy = "Abundant";这样的语句现在会将energy绑定到字符串类型的值Abundant。旧的声明将不能再使用,并且其内存将被释放。
可变和不可变变量
假设我们通过吞下一个健康包获得提升,我们的能量值上升到 25。然而,如果我们写energy = 25;,我们会得到一个错误:error: re-assignment of immutable variable energy``。那么这里有什么问题?
好吧,Rust 在这里应用了程序员的智慧;很多错误都来自对变量进行的意外或错误更改,所以不要让代码更改值,除非你明确允许它!
注意
在 Rust 中,变量默认是不可变的,这与函数式语言非常相似。在纯函数式语言中,甚至不允许可变性。
如果你需要一个值在代码执行期间可以改变的变量,你必须通过mut显式地指出。看看下面的代码片段:
let mut fuel = 34;
fuel = 60;
仅通过声明变量为let n;也是不够的。如果我们这样做,我们会得到error: unable to infer enough type information about _; type annotations required。编译器需要一个值来推断其类型。
我们可以通过将值赋给n来给编译器提供这个信息,例如n = -2;,但正如消息所说,我们也可以如下指示其类型:
let n: i32;
或者,你甚至可以使用以下方法:
let n: i32 = -2; // n is a binding of type i32 and value -2
类型(在这里是i32)跟在变量名后面一个冒号(:)之后(正如我们之前为全局常量所展示的),可选地后面跟着一个初始化。一般来说,类型是这样表示的n: T,其中n是变量,T是类型,它被读作变量n是类型T。所以,这与 C/C++、Java 或 C#中的做法相反,在那里人们会写T n。
对于原始类型,这可以通过后缀简单地完成,如下所示:
let x = 42u8;
let magic_number = 3.14f64;
尝试使用未初始化的变量会导致error: use of possibly uninitialized variable错误(试试看)。局部变量在使用之前必须初始化,以防止未定义的行为。
你可以尝试一个可变的全局常量。你必须做什么才能允许它?为什么会这样?(有关示例代码,请参阅 mutable_constant.rs。)
当编译器无法识别你代码中的名称时,你会得到一个 unresolved name 错误。这可能是仅仅是一个拼写错误,但它会在编译时而不是运行时被捕获!
变量的作用域和阴影
在 bindings.rs 中定义的所有变量都具有局部作用域,由函数的 { } 分隔,这里恰好是 main() 函数,并且这适用于任何函数。在 } 结束后,它们的作用域结束,并且它们的内存分配被释放。
我们甚至可以在函数内部定义一个包含所有代码的代码块,以创建一个更有限的作用域,如下面的代码片段所示:
// see Chapter 2/code/scope.rs
fn main() {
let outer = 42;
{ // start of code block
let inner = 3.14;
println!("block variable: {}", inner);
let outer = 99; // shadows the first outer variable
println!("block variable outer: {}", outer);
} // end of code block
println!("outer variable: {}", outer);
}
这将产生以下输出:
block variable: 3.14
block variable outer: 99
outer variable: 42
在代码块(如 inner)中定义的变量仅在代码块内部已知。代码块中的变量也可以与封装作用域中的变量(如 outer)具有相同的名称,该名称在代码块结束时被替换(阴影)为代码块变量。当你尝试在代码块之后打印 inner 时会发生什么?试一试。
那么,为什么你想使用代码块呢?在 表达式 部分,我们将看到代码块可以返回一个值,该值可以用 let 绑定到一个变量上。代码块也可以为空({ })。
类型检查和转换
Rust 必须知道每个变量的类型,以便在编译时检查它们是否仅以允许的方式使用。这样程序就是类型安全的,并且可以避免一系列错误。
这也意味着由于静态类型,我们无法在变量的生命周期内更改其类型;例如,以下代码片段中的 score 变量不能从整数变为字符串:
// see Chapter 2/code/type_errors.rs
// warning: this code does not work!
fn main() {
let score: i32 = 100;
score = "YOU WON!"
}
我们得到编译器错误,error: mismatched types: expected int, found &'static str (expected int, found &-ptr)。
然而,我们可以编写以下代码:
let score = "YOU WON!";
Rust 允许我们重新定义变量;每个 let 绑定都会创建一个新的变量 score,它隐藏了之前的变量,之前的变量将从内存中释放。这实际上非常有用,因为变量默认是不可变的。
使用 + 运算符(如以下代码中的玩家)在 Rust 中未定义:
let player1 = "Rob";
let player2 = "Jane";
let player3 = player1 + player2;
然后,我们得到 error: binary operation +cannot be applied to type&str``。
在 Rust 中,你可以使用 to_string() 方法将值转换为 String 类型,如下所示:let player3 = player1.to_string() + player2;。
否则,你可以使用 format! 宏:
let player3 = format!("{}{}", player1, player2);
在这两种情况下,player3 的值都是 "RobJane"。
让我们找出当你将一个变量的值赋给另一个不同类型的变量时会发生什么:
// see Chapter 2/code/type_conversions.rs
fn main() {
let points = 10i32;
let mut saved_points: u32 = 0;
saved_points = points; // error !
}
这同样是不允许的;我们会得到相同的错误(error: mismatched types: expected u32, found i32 (expected u32, found i32))。为了启用最大化的类型检查,Rust 不允许像 C++ 那样自动(或隐式)地将一个类型转换为另一个类型;因此,它避免了大量难以发现的错误。例如,当 f32 值转换为 i32 值时,小数点后的数字会丢失;这可能导致自动转换时出现错误。
我们可以使用 as 关键字进行显式转换(类型转换):
saved_points = points as u32;
当点包含负值时,符号在转换后会丢失。同样,当从更宽的值(如浮点数)转换为整数时,小数部分会被截断:
let f2 = 3.14;
saved_points = f2 as u32; // truncation to value 3 occurs here
此外,值必须可以转换为新类型,因为字符串不能转换为整数,如下面的例子所示:
let mag = "Gandalf";
saved_points = mag as u32; // error: non-scalar cast:`&str`as`u32`
别名
有时给现有的类型起一个新名字,使其更具描述性或更短,是有用的。这可以通过 type 关键字来完成,如下面的例子中,我们需要一个特定的(但大小有限的)变量用于 MagicPower:
// see Chapter 2/code/alias.rs
type MagicPower = u16;
fn main() {
let run: MagicPower= 7800;
}
类型名称以大写字母开头,每个作为名称一部分的单词也是如此。当我们把值 7800 改为 78000 时会发生什么?编译器会通过以下警告来检测这一点,warning: literal out of range for its type。
表达式
Rust 是一种表达式导向的语言,这意味着大多数代码片段实际上都是表达式,也就是说,它们计算一个值并返回这个值(在这个意义上,值也是表达式)。然而,仅凭表达式本身并不能构成有意义的代码;它们必须被用在语句中。
以下 let 绑定是声明语句;它们不是表达式:
// see Chapter 2/code/expressions.rs
let a = 2; // a binds to 2
let b = 5; // b binds to 5
let n = a + b; // n binds to 7
然而,a + b 是一个表达式,如果我们省略末尾的分号,那么得到的值(这里 7)就会被返回。这通常在函数需要返回其值时使用(参见下一章的例子)。以分号结束表达式,如 a + b;,会抑制表达式的值,从而丢弃返回值,使其成为一个返回单位值 () 的表达式语句。代码通常是一系列语句的序列,每个代码行一个,Rust 必须知道何时一个语句结束;这就是为什么几乎每一行 Rust 代码都以分号结束。
你认为赋值 m = 42; 是什么?这不是一个绑定,因为没有 let。这应该在之前的代码行上发生。这是一个返回单位值 () 的表达式。在 Rust 中不允许复合绑定,如 let p = q = 3;,它会返回 error: unresolved name q 错误。然而,你可以像这样链式使用 let 绑定:
let mut n = 0;
let mut m = 1;
let t = m; m = n; n = t;
println!("{} {} {}", n, m, t); // which prints out 1 0 1
这里有一个练习给你。在以下代码片段之后打印出 a、b 和 n 的值,并解释 a 的值(例如代码,请参阅 compound_let.rs):
let mut a = 5;
let mut b = 6;
let n = 7;
let a = b = n;
代码块也是一个表达式,如果我们省略分号,它将返回其最后一个表达式的值。例如,在以下代码片段中,n1 获取值 7,但 n2 获取不到值(或者更确切地说,是单位值 ()),因为第二个代码块的返回值被抑制了:
let n1 = {
let a = 2;
let b = 5;
a + b // <-- no semicolon!
};
println!("n1 is: {}", n1); // prints: n1 is 7
let n2 = {
let a = 2;
let b = 5;
a + b;
};
println!("n2 is: {:?}", n2); // prints: n2 is ()
在这里,变量 a 和 b 在代码块中声明,并且它们只存在于代码块的生命周期内,因为它们是局部的。注意,在代码块结束括号后面的分号(;)是必需的。要打印单位值 (),我们需要 {:?} 作为格式说明符。
栈和堆
由于内存分配在 Rust 中非常重要,我们必须对正在发生的事情有一个清晰的了解。程序内存被分为栈和堆内存部分;要了解更多关于这些概念的信息,请阅读经典网页上的信息 stackoverflow.com/questions/79923/what-and-where-are-the-stack-and-heap。原始值,如数字(如图中的 32)、字符和真/假值存储在栈上,而更复杂且可能增长大小的对象的值存储在堆内存中。堆值通过栈上的变量引用,该变量包含堆上对象的内存地址:

虽然栈的大小有限,但堆的大小可以根据需要增长。
现在,我们将运行以下程序并尝试可视化程序的内存:
// see Chapter 2/code/references.rs
let health = 32;
let mut game = "Space Invaders";
值存储在内存中,因此它们有内存地址。health 变量包含一个整数值 32,它存储在栈上的位置 0x23fba4,而 game 变量包含一个字符串,它存储在从位置 0x23fb90 开始的堆中。(这些是在我执行程序时的地址,但当你运行程序时它们将是不同的。)
绑定值的变量是指针或值的引用。它们指向它们;game 是 Space Invaders 的引用。值的地址由 & 操作符给出。因此,&health 是存储值 32 的地址,而 &game 是存储 Space Invaders 值的地址。
我们可以使用格式字符串 {:p} 来打印这些地址,如下所示:
println!("address of health-value: {:p}", &health);
// prints 0x23fba4
println!("address of game-value: {:p}", &game); // prints 0x23fb90
println!("game-value: {}", game); // prints "Space Invaders"
现在,内存中的情况如下(每次执行时的内存地址将不同):

我们可以创建一个别名,它是另一个指向内存相同位置的引用,如下所示:
let game2 = &game;
println!("{:p}", game2); // prints 0x23fb90
要获取所引用的值而不是 game2 引用本身,可以使用星号 * 操作符进行解引用,如下所示:
println!("{}", *game2); // prints "Space Invaders"
(println! is clever, so println!("{}", game2); 这一行也会打印与 println!("game: {}", &game); 相同的值。
上述代码略有简化,因为 Rust 会分配值到栈上,其大小变化不会像可能的那样大,但这是为了更好地让你了解值引用的含义。
我们已经知道let绑定是不可变的,因此值不能被改变:
health = 33; // error: re-assignment of immutable variable `health`.
如果y被声明为let y = &health;,那么*y的值是32。引用变量也可以被赋予类型,如let x: &i64;,这样的引用可以在代码中传递。在此let绑定之后,x实际上还没有指向一个值,它不包含内存地址。在 Rust 中,没有创建空指针的方法,就像在其他语言中那样;如果你尝试将 nil、null 或单位值()分配给x,这将导致错误。仅此一项特性就使 Rust 程序员免于无数错误。此外,尝试在表达式中使用x,例如println!("{:?}", x);将导致“错误:使用可能未初始化的变量:x”错误。
不可变变量的可变引用是被禁止的;否则,不可变变量可以通过其可变引用被改变:
let tricks = 10;
let reftricks = &mut tricks;
这会抛出“错误:不能将不可变局部变量tricks作为可变引用借用”的错误。
可变分数变量的引用可以是不可变的或可变的,例如以下示例中的score2和score3:
let mut score = 0;
let score2 = &score;
// error: cannot assign to immutable borrowed content *score2
// *score2 = 5;
let mut score = 0;
let score3 = &mut score;
*score3 = 5;
score的值只能通过可变引用,如score3来改变。
由于我们将在后面看到的原因,你只能对一个可变变量创建一个可变引用:
let score4 = &mut score;
这会抛出“错误:一次不能多次借用score作为可变引用”的错误。
在这里,我们触及了 Rust 内存安全系统的核心,其中借用变量是其关键概念之一。我们将在第六章,指针和内存安全中更详细地探讨这一点。
堆比栈大得多,因此,一旦不再需要内存位置,就很重要。Rust 编译器会在变量结束其生命周期(换句话说,超出作用域)时看到,并在编译时插入代码,在代码执行时释放其内存。这种行为是 Rust 独有的,在其他常用语言中不存在。栈值可以被装箱,即通过围绕它们创建Box来在堆上分配,就像以下代码中的x值一样:
let x = Box::new(5i32);
Box是一个引用堆上值的对象。我们还会在第六章的盒子部分更详细地探讨这一点,指针和内存安全。
摘要
在本章中,你学习了如何在 Rust 中使用变量,并熟悉了许多常见的编译器错误信息。我们探讨了类型和变量的默认不可变性,这是 Rust 安全行为的基础。在下一章中,我们将通过使用程序逻辑和函数来编写一些有用的代码。
第三章。使用函数和控制结构
本章重点介绍我们如何通过函数控制代码的执行流程并模块化我们的代码。我们还将学习如何从控制台获取输入,以及如何记录和测试我们的代码。
我们将涵盖以下主题:
-
根据条件分支
-
循环
-
函数
-
属性
-
测试
根据条件分支
根据条件分支使用常见的if、if-else或if-else if-else结构,如本例所示:
// from Chapter 3/code/ifelse.rs
fn main() {
let dead = false;
let health = 48;
if dead {
println!("Game over!");
return;
}
if dead {
println!("Game over!");
return;
} else {
println!("You still have a chance to win!");
}
if health >= 50 {
println!("Continue to fight!");
} else if health >= 20 {
println!("Stop the battle and gain strength!");
} else {
println!("Hide and try to recover!");
}
}
这将给出以下输出:
Stop the battle and gain strength!
if后面的条件必须是布尔值。然而,与 C 不同,条件不能被括号包围。在if、else if或else之后需要用{ }(大括号)包围的代码块。第一个例子显示我们可以通过return从函数中退出。
if-else的另一个特性,作为表达式,是它返回一个值。这个值可以用作print!语句中的函数调用参数,或者可以像这样分配给let绑定:
let active =
if health >= 50 {
true
} else {
false
};
println!("Am I active? {}", active);
输出如下:
Am I active? false
代码块可以包含多行。然而,当你返回一个值时,你需要小心,确保在if或else块中的最后一个表达式之后省略;(分号)。(有关更多信息,请参阅第二章的表达式部分,使用变量和类型)。此外,所有分支必须始终返回相同类型的值。这减少了在 C++中需要的三元运算符(? :)的需求;你可以简单地像这样使用if:
let adult = true;
let age = if adult { "+18" } else { "-18" };
println!("Age is {}", age); // Age is +18
作为练习,尝试以下操作:
-
尝试在
+18和-18之后添加一个;(分号),如下所示{"+18";},并找出age将打印的值。如果你将age的类型注释为&str会发生什么? -
看看你是否可以省略块中的
{ }(大括号),如果块中只有一个语句的话。 -
此外,验证以下代码是否正确:
let result = if health <=0 { "Game over man!" };如果需要,你将如何纠正这个语句?(参考
第三章/练习/iftest.rs中的代码。) -
简化以下函数:
fn verbose(x: i32) -> &'static str { let mut result: &'static str; if x < 10 { result = "less than 10"; } else { result = "10 or more"; } return result; }(参见
第三章\练习\ifreturn.rs中的代码。)
模式匹配,我们将在下一章中探讨,它也会分支代码,但它基于变量的值来这样做。
循环
对于重复的代码片段,Rust 有常见的while循环,条件周围没有括号:
// from Chapter 3/code/loops.rs
fn main() {
let max_power = 10;
let mut power = 1;
while power < max_power {
print!("{} ", power); // prints without newline
power += 1; // increment counter
}
}
这将打印以下输出:
1 2 3 4 5 6 7 8 9
要启动无限循环,使用loop,它是while true的语法糖:
loop {
power += 1;
if power == 42 {
// Skip the rest of this iteration
continue;
}
print!("{} ", power);
if power == 50 {
print!("OK, that's enough for today");
break; // exit the loop
}
}
在这里,打印了包括50在内的所有功率值;然后循环通过break语句停止。然而,由于continue语句,功率值42没有被打印。因此,循环相当于一个无限循环,并且带有条件break的循环在其他语言中模拟了do while。
当循环嵌套在彼此内部时,break 和 continue 只应用于最近的包围循环。任何loop语句(也包括我们接下来将要看到的while和for)都可以有一个标签(表示为'label:')作为前缀,这样我们就可以跳转到下一个或外部的包围循环,如这个代码片段所示:
'outer: loop {
println!("Entered the outer dungeon - ");
'inner: loop {
println!("Entered the inner dungeon - ");
// break; // this would break out of the inner loop
break 'outer; // breaks to the outer loop
}
println!("This treasure can sadly never be reached - ");
}
println!("Exited the outer dungeon!");
代码打印以下输出:
Entered the outer dungeon –
Entered the inner dungeon -
Exited the outer dungeon!
C 语言中臭名昭著的goto在 Rust 中不存在!
通过for语句在范围表达式for var in a..b中对一个变量从起始值a到结束值b(不包括b)进行循环,可以完成从起始值a到结束值b的循环。以下是一个打印从 1 到 10 的数字平方的例子:
for n in 1..11 {
println!("The square of {} is {}", n, n * n);
}
通常,for in循环遍历一个迭代器,迭代器是一个返回一系列值的对象。范围a..b是迭代器的最简单形式。每个后续值都绑定到n并在下一个循环迭代中使用。当没有更多值时,for循环结束,n然后超出作用域。如果我们不需要在循环中使用n的值,我们可以用下划线_(一个下划线)来替换它:for _ in 1..11 { }。C 风格for循环中的许多错误,如计数器的偏移量错误,在这里不会发生,因为我们是在迭代器上循环。
变量也可以用在范围中,如下面的代码片段所示,它打印出九个点:
let mut x = 10;
for _ in 1 .. x { x -= 1; print!("."); }
我们将在第五章,使用高阶函数和参数化泛化代码中更详细地研究迭代器。
函数
每个 Rust 程序的起点是一个名为main()的fn函数,这个函数可以进一步细分为单独的函数以重用代码或更好地组织代码。Rust 不关心这些函数定义的顺序,但将main()放在代码的开头以获得更好的概览是个不错的选择。Rust 已经融合了许多传统函数式语言的特征;我们将在第五章,使用高阶函数和参数化泛化代码中看到这方面的例子。
让我们从基本函数的一个例子开始:
// from Chapter 3/code/functions.rs
fn main() {
let hero1 = "Pac Man";
let hero2 = "Riddick";
greet(hero2);
greet_both(hero1, hero2);
}
fn greet(name: &str) {
println!("Hi mighty {}, what brings you here?", name);
}
fn greet_both(name1: &str, name2: &str) {
greet(name1);
greet(name2);
}
输出如下:
Hi mighty Riddick, what brings you here?
Hi mighty Pac Man, what brings you here?
Hi mighty Riddick, what brings you here?
与变量一样,函数也有snake_case命名规则,这些名称必须是唯一的,它们的参数(必须指定类型)由逗号分隔。在这个代码片段中,示例是name1: &str和name2: &str(看起来像是一个绑定,但没有let)。
对参数强制类型是一个优秀的设计决策:这为调用者代码记录了函数的使用,并允许在函数内部进行类型推断。这里的类型是&str,因为字符串存储在堆上(参见第二章,使用变量和类型中的栈和堆部分)。
上述代码中的函数没有返回任何有用的东西(实际上,它们返回了单元值 ()),但如果我们想让函数真正返回一个值,其类型必须在箭头 -> 后指定,就像这个例子所示:
fn increment_power(power: i32) -> i32 {
println!("My power is going to increase:");
power + 1
}
fn main() {
let power = increment_power(1); // function is called
println!("My power level is now: {}", power);}
}
当执行时,会打印以下内容:
My power is going to increase:
I am now at power level: 2
函数的返回值是它的最后一个表达式的值。请注意,为了返回一个值,最后的表达式不能以分号结束。当你以分号结束时会发生什么?试一试。在这种情况下,将返回单元值 (),编译器会给出错误,not all control paths return a value error。
我们本可以写成 return power + 1; 作为最后一行,但这不是惯用的代码。如果我们想在最后一行代码之前从函数返回一个值,我们必须写 return value;,就像这里所示:
if power < 100 { return 999; }
如果这是函数中的最后一行,你会这样写:
if power < 100 { 999 }
一个函数只能返回一个值,但这并不是一个限制。如果我们有,例如,三个值 a、b 和 c 要返回,可以用它们创建一个元组 (a, b, c) 并返回这个元组。我们将在下一章更详细地研究元组。在 Rust 中,你还可以在另一个函数内部编写函数(称为嵌套函数),这与 C 或 Java 不同。然而,这应该只用于需要局部使用的辅助函数。
以下是一个练习题:
以下返回给定数字 x 的绝对值的函数有什么问题?
fn abs(x: i32) -> i32 {
if x > 0 {
x
} else {
-x
}
}
你需要纠正并测试它。(参见 Chapter 3/exercises/absolute.rs 中的代码。)
Documenting a function
让我们给你展示一个文档的例子。在 exdoc.rs 中,我们这样注释了一个 cube 函数:
fn main() {
println!("The cube of 4 is {}", cube(4));
}
/// Calculates the cube `val * val * val`.
///
/// # Examples
///
/// ```
/// let cube = cube(val);
/// ```rs
pub fn cube(val: u32) -> u32 {
val * val * val
}
如果现在我们在命令行上调用 rustdoc exdoc.rs,将会创建一个 doc 文件夹。这个文件夹包含一个 exdoc 子文件夹,其中包含 index.html 文件,它是提供每个函数文档页面的网站的起点。例如,fn.cube.html 显示以下内容:

通过点击 exdoc 链接,你可以返回到索引页。对于一个使用 cargo 包管理器创建的项目,运行 cargo doc 命令以获取其文档。
文档注释是用 Markdown 编写的。它们可以包含以下特殊部分,前面带有 #:示例、恐慌、失败和安全。代码位于 rs` ```rs. For a function to be documented, it must be prefixed with pub so that it belongs to the public interface (see Chapter 7, *Organizing Code and Macros*). For more information on this, go to [doc.rust-lang.org/book/documentation.html`](http://doc.rust-lang.org/book/documentation.html).
Attributes
You may have already seen examples of warnings within #[ … ] signs, such as #[warn(unused_variables)], in compiler output. These are attributes that represent metadata information about the code and are placed right before an item (such as a function) about which they have something to say. They can, for example, disable certain classes of warnings, turn on certain compiler features, or mark functions as being part of unit-tests or benchmark code.
Conditional compilation
If you want to make a function that only works on a specific operating system then annotate it with the #[cfg(target_os = "xyz")] attribute (where xyz can be either windows, macos, linux, android, freebsd, dragonfly, bitrig, or openbsd). For example, the following code works fine and runs on Windows:
// from Chapter 3/code/attributes_cfg.rs
fn main() {
on_windows();
}
#[cfg(target_os = "windows")]
fn on_windows() {
println!("This machine has Windows as its OS.")
}
```rs
This produces the output, **This machine has Windows as its OS**. If we try to build this code on a Linux machine, we get the **error: unresolved name `on_windows`** error, as the code does not build on Linux because the attribute prevents it from doing so!
Furthermore, you can even make your own custom conditions; go to [`rustbyexample.com/attribute/cfg/custom.html`](http://rustbyexample.com/attribute/cfg/custom.html) for more information on this.
Attributes are also used when testing and benchmarking code.
# Testing
We can prefix a function with the `#[test]` attribute to indicate that it is part of the unit tests for our application or library. We can then compile with `rustc --test program.rs`. This will replace the `main()` function with a test runner and show the result from the functions marked with `#[test]`. Have a look at the following code snippet:
// from Chapter 3/code/attributes_testing.rs
fn main() {
println!("No tests are compiled,compile with rustc --test! ");
}
[test]
fn arithmetic() {
if 2 + 3 == 5 {
println!("You can calculate!");
}
}
Test functions, such as `arithmetic()` in the example, are black boxes; they have no arguments or returns. When this program is run on the command line, it produces the following output:

However, even if we change the test to `if 2 + 3 == 6`, the test passes! Try it out. It turns out that test functions always pass when their execution does not cause a crash (called a panic in Rust terminology), and it fails when it does panic. This is why testing (or debugging) uses the `assert_eq!` macro (or other similar macros):
assert_eq!(2, power);
This statement tests whether `power` has the value 2\. If it does, nothing happens, but if `power` is different from 2, an exception occurs and the program panics with, **thread '<main>' panicked at 'assertion failed**.
In our first function, we will write the `assert_eq!(5, 2 + 3);` test that will pass. We can also write this as `assert!(2 + 3 == 5);` by using the `assert!` macro.
A test fails when the function panics, as is the case with the following example:
[test]
fn badtest() {
assert_eq!(6, 2 + 3);
}
This produces the following output:

Unit test your functions by comparing the actual function result to the expected result with an `assert_eq!(actual, expected)` macro call. In a real project, the tests will be collected in a separate tests module. (Have a look at Chapter 7, *Organizing Code and Macros*, for more information.)
## Testing with cargo
An executable project, or a crate as it is called in Rust, needs to have a `main()` startup function, but a library crate, to be used in other crates, does not need a `main()` function. Create a new `mylib` library crate with cargo as `cargo new mylib`.
This creates a `src` subfolder with a `lib.rs` source file that contains the following code:
[test]
fn it_works() {
}
因此,一个库 crate 被创建出来,它本身没有自己的代码,但它包含一个测试模板,可以用来增强你为库中的函数编写的单元测试。然后你可以使用`cargo test`来运行这些测试,它将产生类似于上一节中产生的输出。`cargo test`命令在可能的情况下会并行运行测试。
# 摘要
在本章中,你学习了如何通过使用`if`条件语句、`while`和`for`循环以及函数来结构化我们的代码,从而制作基本的程序。我们还能够接受程序输入。最后,我们看到了属性赋予 Rust 扩展其可能性的巨大力量,并在条件编译和测试中应用了这一点。
在下一章中,我们将开始使用复合值并探索模式匹配的力量。
# 第四章:结构化数据和模式匹配
迄今为止我们只使用了简单的数据,但要进行真正的编程,需要更多复合和结构化的数据值。其中包含灵活的数组、元组和枚举,以及表示更类似面向对象语言中对象行为的结构体。选项是另一种重要的类型,用于确保考虑了没有返回值的情况。然后,我们将探讨模式匹配,这是 Rust 中另一种典型的函数式结构。然而,我们将首先更仔细地查看字符串。我们将涵盖以下主题:
+ 字符串
+ 数组、向量和切片
+ 元组
+ 结构体
+ 枚举
+ 从控制台获取输入
+ 模式匹配
# 字符串
Rust 处理字符串的方式与其他语言中的字符串处理方式略有不同。所有字符串都是有效的 Unicode(UTF-8)字节序列。它们可以包含空字节,但它们不是以空字符终止,如 C 语言中的那样。Rust 区分两种类型的字符串:
+ 直至现在我们所使用的字面量字符串,是类型为`&str`的字符串切片。`&`字符表示字符串切片是字符串的引用。它们是不可变的,并且具有固定的大小。例如,以下绑定声明了字符串切片:
```rs
// from Chapter 4/code/strings.rs
let magician1 = "Merlin";
let greeting = "Hello, 世界!";
```
否则,我们关心的是明确注释字符串变量及其类型:
```rs
let magician2: &'static str = "Gandalf";
```
`&'static`命令表示字符串是静态分配的。我们之前在第二章中看到了这种表示法,即当我们声明全局字符串常量时。在那个情况下,指定类型是强制性的,但对于一个`let`绑定来说,它是多余的,因为编译器可以推断类型:
```rs
println!("Magician {} greets magician {} with {}",
magician1, magician2, greeting);
```
打印输出:`魔术师梅林向魔术师甘道夫问候,世界!`
这些字符串与程序的生命周期相同;它们具有程序的静态生命周期。它们在`std::str`模块中进行了描述。
+ 另一方面,`String`可以动态地增长大小(实际上是一个缓冲区),因此它必须在堆上分配。我们可以使用以下代码片段创建一个空字符串:
```rs
let mut str1 = String::new();
```
每次字符串增长时,它都必须在内存中重新分配。所以,例如,如果你知道它将开始为 25 个字节,你可以通过以下方式分配这么多的内存来创建字符串:
```rs
let mut str2 = String::with_capacity(25);
```
此类型在`std::string`模块中进行了描述。要将字符串切片转换为 String,请使用`to_string`方法:
```rs
let mut str3 = magician1.to_string();
```
`to_string()`方法可以用于将任何对象转换为`String`(更精确地说,任何实现了`ToString`特质的对象;我们将在下一章讨论特质)。此方法在堆上分配内存。
如果`str3`是一个 String,那么你可以使用`&str3`或`&str3[..]`从它创建一个字符串切片:
```rs
let sl1 = &str3;
```
以这种方式创建的字符串切片可以被视为对`String`的视图。它是 String 内部的引用,对其进行操作没有成本。
我更喜欢这种方式而不是`to_string()`来比较字符串,因为使用`&[..]`不会消耗资源,而`to_string()`会分配堆内存:
```rs
if &str3[] == magician1 {
println!("We got the same magician alright!")
}
要构建一个String,我们可以使用多种方法,如下所示:
-
push方法:将一个字符追加到String -
push_str方法:将另一个字符串追加到String
您可以在以下代码片段中看到它们的作用:
let c1 = 'q'; // character c1
str1.push(c1);
println!("{}", str1); // q
str1.push_str(" Level 1 is finished - ");
println!("{}", str1); // q Level 1 is finished -
str1.push_str("Rise up to Level 2");
println!("{}", str1); // q Level 1 is finished - Rise up to Level 2
如果您需要逐个按顺序获取String的字符,请使用chars()方法。此方法返回一个Iterator,因此我们可以使用 for in 循环(参见第二章的循环部分,使用变量和类型),如下所示:
for c in magician1.chars() {
print!("{} - ", c);
}
打印结果为:M - e - r - l - i - n -.
要遍历由空格分隔的String部分,我们可以使用split()方法,该方法还返回一个Iterator:
for word in str1.split(" ") {
print!("{} / ", word);
}
打印结果为:q / Level / 1 / is / finished / - / Rise / up / to / Level / 2 /.
要更改与另一个字符串匹配的String的第一部分,请使用replace方法:
let str5 = str1.replace("Level", "Floor");
此代码为修改后的str5字符串分配了新的内存。
当您编写一个接受字符串作为参数的函数时,始终将其声明为字符串切片,这是一个字符串的视图,如下面的代码片段所示:
fn how_long(s: &str) -> usize { s.len() }
原因是传递String str1作为参数会分配内存,所以我们最好将其作为切片传递。这样做最简单、最优雅的方式如下:
println!("Length of str1: {}", how_long(&str1));
或者:
println!("Length of str1: {}", how_long(&str1[..]));
请参阅doc.rust-lang.org/std/str/和doc.rust-lang.org/std/string/的文档以获取更多功能。以下是一个更清晰地显示两种字符串类型之间差异的方案:
| 字符串 | 字符串切片(&str) |
|---|---|
可变 – 堆内存分配模块:std::string |
固定大小 – String的视图 – 引用(&)模块:std::str |
数组、向量和切片
假设我们有一群外星生物来填充游戏关卡,那么我们可能希望将它们的名称存储在一个方便的列表中。Rust 的数组正是我们所需要的:
// from Chapter 4/code/arrays.rs
let aliens = ["Cherfer", "Fynock", "Shirack", "Zuxu"];
println!("{:?}", aliens);
要创建一个数组,请用逗号分隔不同的项目,并将整个内容括在[ ](方括号)内。所有项目都必须是同一类型。这样的数组必须是固定大小的(这必须在编译时已知)且不能更改;这是存储在一个连续的内存块中。
如果项目需要可修改性,请使用let mut声明您的数组;然而,即使在这种情况下,项目数量也不能改变。外星人数组可以是类型注释为[&str; 4]的类型,其中第一个参数是项目类型,第二个是它们的数量:
let aliens: [&str; 4] = ["Cherfer", "Fynock", "Shirack", "Zuxu"];
如果我们想用三个Zuxus初始化一个数组,这也很简单:
let zuxus = ["Zuxu"; 3];
那么您如何创建一个空数组?如下所示:
let mut empty: [i32; 0] = [];
println!("{:?}", empty); // []
我们还可以通过它们的索引访问单个项目,从 0 开始:
println!("The first item is: {}", aliens[0]); // Cherfer
println!("The third item is: {}", aliens[2]); // Shirack
数组中的元素数量由 aliens.len() 给出;那么,你是如何获取最后一个元素的?正是这样!通过使用 aliens[aliens.len() - 1]。或者,这也可以通过使用 aliens.iter().last().unwrap(); 来找到。
数组的指针使用自动解引用,因此你不需要显式地使用 *,如以下代码片段所示:
let pa = &aliens;
println!("Third item via pointer: {}", pa[2]);
它会打印出:通过指针访问的第三个元素:Shirack。你认为当我们尝试如下方式更改一个元素时会发生什么:
aliens[2] = "Facehugger";
希望你没有认为 Rust 会允许这样做,对吧?除非你明确地告诉它外星人可以通过 let mut aliens = [...]; 来改变,否则这是可以的!
索引在运行时也会检查是否在数组的界限内,即 0 和 aliens.len();如果不是,程序将因运行时错误或恐慌而崩溃:
println!("This item does not exist: {}", aliens[10]); // runtime error:
它给出了以下输出:
thread '<main>' panicked at 'index out of bounds: the len is 4 but the index is 10'
如果我们想逐个连续地遍历元素并打印它们或对它们进行一些有用的操作,我们可以这样做:
for ix in 0..aliens.len() {
println!("Alien no {} is {}", ix, aliens[ix]);
}
这可以工作,并且它为我们提供了每个元素的索引,这可能很有用。然而,当我们使用索引来获取每个连续的元素时,Rust 也必须每次检查我们是否仍然在内存数组的界限内。这就是为什么这并不非常高效,在第五章的迭代器部分,使用高阶函数和参数化泛化代码,我们将看到一种更高效的方法,如下迭代元素:
for a in aliens.iter() {
println!("The next alien is {}", a);
}
for 循环可以写成更短的形式如下:
for a in &aliens { … }
向量
通常,与可以增长(或缩小)大小的数组一起工作更为实用,因为它是分配在堆上的。Rust 通过 std::vec 模块中的 Vec 向量类型提供了这一点。这是一个泛型类型,这意味着元素可以具有任何 T 类型,其中 T 在代码中指定;例如,我们可以有 Vec<i32> 类型或 Vec<&str> 类型的向量。为了表示这是一个泛型类型,它被写成 Vec<T>。同样,所有元素都必须是相同的 T 类型。我们可以用两种方式创建一个向量,使用 new() 或使用 vec! 宏。这些在这里展示:
let mut numbers: Vec<i32> = Vec::new();
let mut magic_numbers = vec![7i32, 42, 47, 45, 54];
在第一种情况下,类型通过 Vec<i32> 明确指示;在第二种情况下,这是通过给第一个元素添加 i32 后缀来完成的,但这通常是可选的。
我们也可以创建一个新的向量并为其分配一个初始内存大小,如果你事先知道你至少需要那么多元素,这可能会很有用。以下初始化了一个用于有符号整数的向量,并为 25 个整数分配了内存:
let mut ids: Vec<i32> = Vec::with_capacity(25);
我们需要在这里提供类型,否则编译器无法计算所需的内存量。
向量也可以通过 collect() 方法和一个范围来从迭代器构建,例如在这个例子中:
let rgvec: Vec<u32> = (0..7).collect();
println!("Collected the range into: {:?}", rgvec);
它会打印出:将范围收集到:[0, 1, 2, 3, 4, 5, 6]。
索引、获取长度和遍历向量与数组的工作方式相同。例如,一个遍历向量的 for 循环可以简单地写成以下形式:
let values = vec![1, 2, 3];
for n in values {
println!("{}", n);
}
使用 push() 向向量的末尾添加新项,使用 pop() 移除最后一个项:
numbers.push(magic_numbers[1]);
numbers.push(magic_numbers[4]);
println!("{:?}", numbers); // [42, 54]
let fifty_four = numbers.pop();// fifty_four now contains 54
println!("{:?}", numbers); // [42]
如果一个函数需要返回许多相同类型的值,你可以用这些值创建一个数组或向量,并返回该对象。
切片
如果你想对数组或向量的某个部分进行操作,你可能会首先想到将这部分复制到另一个数组中,但 Rust 有一个更安全、更高效的解决方案;取数组的切片。不需要复制,而是你得到对现有数组的视图,类似于字符串切片是字符串的视图。
例如,假设我只需要从我们的 magic_numbers 向量中获取数字 42、47 和 45。那么,我可以采取以下切片:
let slc = &magic_numbers[1..4]; // only the items 42, 47 and 45
起始索引 1 是 42 的索引,最后一个索引 4 指向 54,但这个项不包括在内。& 表示我们正在引用现有的内存分配。切片与向量共享以下特点:
-
它们是泛型的,对于
T类型具有&[T]类型 -
它们的大小不必在编译时已知
字符串和数组
在本章的第一节中,我们看到了 String 中的字符序列是由 chars() 函数给出的。这看起来像数组吗?如果我们查看 String 字符的内存分配,它是由数组支持的;它存储为一个字节数组 Vec<u8> 的向量。
这意味着我们也可以从 String 中取出 &str 类型的切片:
let location = "Middle-Earth";
let part = &location[7..12];
println!("{}", part); // Earth
我们可以将切片的字符收集到一个向量中,并按如下方式排序:
let magician = "Merlin";
let mut chars: Vec<char> = magician.chars().collect();
chars.sort();
for c in chars.iter() {
print!("{} ", c);
}
这会打印出 M e i l n r(在排序顺序中,大写字母先于小写字母)。以下是一些使用 collect() 方法的其他示例:
let v: Vec<&str> = "The wizard of Oz".split(' ').collect();
let v: Vec<&str> = "abc1def2ghi".split(|c: char| c.is_numeric()).collect();
在这里,split() 接收一个闭包来决定在哪个字符上分割。切片类型 &str 和 &[T] 可以分别看作是 Strings 和向量的视图。以下方案比较了我们刚刚遇到的类型(T 表示一个泛型类型):
| 固定大小(栈分配) | 切片 | 动态大小(可增长)(堆分配) | |
|---|---|---|---|
&str``类型: &[u8] |
是视图到 | String |
|
数组类型: [T;size] |
切片类型: &[T] |
是视图到 | 向量类型: Vec<T> |
通过参考 Chapter 4/exercises/chars_string.rs 执行以下练习:
-
尝试使用
[0]或[4]来获取字符串的第一个或第五个字符 -
将
bytes()方法与chars()方法在let greeting = "Hello,世界``!";字符串上比较
元组
如果你想要组合一定数量的不同类型的值,那么你可以将它们收集在一个元组中,元组用括号 ( ) 括起来,并用逗号分隔,如下所示:
// from Chapter 4/code/tuples.rs
let thor = ("Thor", true, 3500u32);
println!("{:?}", thor); // ("Thor", true, 3500)
thor 的类型是 (&str, bool, u32),即:项类型的元组。要在一个索引上提取一个项,使用点语法:
println!("{} - {} - {}", thor.0, thor.1, thor.2);
另一种将项目提取到其他变量中的方法是通过对元组进行解构:
let (name, _, power) = thor;
println!("{} has {} points of power", name, power);
这将打印出:索尔有 3500 点力量值。
这里let语句将左侧的模式与右侧匹配。_表示我们对thor的第二个项目不感兴趣。
如果元组具有相同的类型,则它们只能相互赋值或比较。一个单元素元组需要这样写:let one = (1,);。
需要返回不同类型一些值的函数可以将它们收集在元组中,并如下返回该元组:
fn increase_power(name: &str, power: u32) -> (&str, u32) {
if power > 1000 {
return (name, power * 3);
} else {
return (name, power * 2);
}
}
如果我们用以下代码片段调用它:
let (god, strength) = increase_power(thor.0, thor.2);
println!("This god {} has now {} strength", god, strength);
输出是:这个神索尔现在有 10500 点力量值。
通过参考Chapter 4/exercises/tuples_ex.rs)中的代码进行以下练习:
-
尝试比较元组
(2, 'a')和(5, false),并解释错误信息。 -
创建一个空元组。我们之前没有遇到过吗?所以,单元值实际上是一个空元组!
结构体
通常,你可能需要在程序中将几个不同类型的值一起保存;例如,玩家的得分。让我们假设得分包含表示玩家健康和他们在哪个级别上玩游戏的数字。然后你可以做的第一件事是为这些元组赋予一个共同的名称,例如struct Score,或者更好的是,你可以指示值的类型:struct Score(i32, u8),然后我们可以创建一个得分如下:
let score1 = Score(73, 2);
这些被称为元组结构体,因为它们非常类似于元组。它们包含的值可以按如下方式提取:
// from Chapter 4/code/structs.rs
let Score(h, l) = score1; // destructure the tuple
println!("Health {} - Level {}", h, l);
这将打印出:健康 73 - 级别 2。
只有一个字段(称为新类型)的元组结构体使我们能够创建一个基于旧类型的新类型,这样它们就有相同的内存表示。以下是一个例子:
struct Kilograms(u32);
let weight = Kilograms(250);
let Kilograms(kgm) = weight; // extracting kgm
println!("weight is {} kilograms", kgm);
这将打印:重量是 250 公斤。
然而,我们仍然需要记住这些数字的含义以及它们属于哪个玩家。我们可以通过定义一个具有命名字段的struct来使编码更加简单:
struct Player {
nname: &'static str, // nickname
health: i32,
level: u8
}
这可以在main()内部或外部定义,尽管后者更受欢迎。现在,我们可以创建玩家实例或对象如下:
let mut pl1 = Player{ nname: "Dzenan", health: 73, level: 2 };
注意对象周围的括号({ })和key: value语法。nname字段是一个常量字符串,Rust 要求我们指出其生命周期,即这个字符串在程序中需要多长时间。我们在全局作用域中使用了&'static,来自第二章的全局常量部分,使用变量和类型。
我们可以使用点符号来访问实例的字段:
println!("Player {} is at level {}", pl1.nname, pl1.level);
如果字段值可以改变,结构变量必须声明为可变的;例如,当玩家进入新关卡时:
pl1.level = 3;
按照惯例,结构体的名称始终以大写字母开头,并遵循驼峰命名法。它还定义了一个由其项目类型组成的自己的类型。
与元组一样,结构体也可以在 let 绑定中进行解构,例如:
let Player{ health: ht, nname: nn, .. } = pl1;
println!("Player {} has health {}", nn, ht);
它将打印出:Player Dzenan has health 73。这表明你可以重命名字段,如果你想的话可以重新排序它们,或者省略字段。
指针在访问数据结构元素时执行自动解引用,如下所示:
let ps = &Player{ nname: "John", health: 95, level: 1 };
println!("{} == {}", ps.nname, (*ps).nname);
结构体(Structs)与 C 语言中的记录或结构体非常相似,甚至与其他语言中的类相似。在 第五章,使用高阶函数和参数化泛化代码 中,我们将看到如何定义结构体上的方法。
参考第四章/exercises/monster.rs 中的代码执行以下练习:
- 定义一个具有健康和伤害字段的
Monster结构体。然后,创建一个Monster并显示其状态。
枚举(Enums)
如果某个值只能是有限数量的命名值之一,则将其定义为枚举。例如,如果我们的游戏需要指南针方向,我们可以定义如下:
// from Chapter 4/code/enums.rs
enum Compass {
North, South, East, West
}
然后按照 main() 或另一个函数中的示例使用它:
let direction = Compass::West;
枚举的值也可以是其他类型或结构体,如下例所示:
type species = &'static str;
enum PlanetaryMonster {
VenusMonster(species, i32),
MarsMonster(species, i32)
}
let martian = PlanetaryMonster::MarsMonster("Chela", 42);
在其他语言中,枚举有时被称为联合类型或代数数据类型。如果我们在一开始就在代码文件中创建一个 use 函数:
use PlanetaryMonster::MarsMonster;
然后,类型可以缩短,如下所示:
let martian = MarsMonster("Chela", 42);
枚举在使代码清晰方面非常出色,并且在 Rust 中使用得很多。要有效地在代码中使用它们,请参阅本章的 匹配模式 部分。
Result 和 Option
在这里,我们查看 Rust 代码中普遍存在的两种枚举。Result 是在标准库中定义的一种特殊枚举。当执行某些操作,可能以以下两种方式结束时使用:
-
成功的话,则返回一个
Ok值(某种类型T) -
出错时,则返回一个
Err值(类型E)
由于这种情况很常见,因此提供了这样的规定,即值 T 和错误 E 类型可以尽可能通用或泛型。Result 枚举定义如下:
enum Result<T, E> {
Ok(T),
Err(E)
}
Option 是在标准库中定义的另一个枚举。当存在值时使用,但也可能不存在值。例如,假设我们的程序期望从控制台读取一个值。然而,当它意外地作为后台程序运行时,它将永远不会得到输入值。Rust 希望在可能的情况下始终采取安全措施,因此在这种情况下,最好将值读取为具有两种可能性的 Option 枚举:
-
Some,如果有值 -
None,如果没有值
这个值可以是任何类型 T,因此选项再次被定义为泛型类型:
enum Option<T> {
Some(T),
None
}
从控制台获取输入
假设我们想在游戏开始之前捕获玩家的昵称;我们该如何做?输入/输出功能由std crate 中的io模块处理。它有一个stdin()函数用于从控制台读取输入。这个函数返回一个Stdin类型的对象,它是输入流的引用。Stdin有一个read_line(buf)方法用于读取以换行符结束的完整输入行(当用户按下Enter键时)。这个输入被读取到一个 String 缓冲区buf中。方法是为定义在特定类型上的函数命名,它使用点符号调用,例如object.method(参见第五章,使用高阶函数和参数化泛化代码)。
因此,我们的代码将如下所示:
let mut buf = String::new();
io::stdin().read_line(&mut buf);
然而,这对 Rust 来说还不够好;它给出了警告,未使用的 result 必须使用。Rust 首先是一个安全的语言,我们必须准备好应对可能发生的一切。读取一行可能成功并提供输入值,但它也可能失败;例如,如果这段代码在后台运行在机器上,那么没有控制台可以获取输入。
你将如何应对这种情况?嗯,read_line()返回一个 Result 值,它可以是正常值(一个Ok),当一切正常时,或者是一个错误值(一个Err),当出现问题时。为了处理可能出现的错误,我们需要一个ok()函数和一个expect()函数;ok()将 Result 转换为 Option 值(它包含读取的字节数)和expect()在发生错误时给出这个值或显示其消息。在 Rust 中,当发生无法恢复的错误时,程序会崩溃,expect()中的字符串参数会显示出来,告诉我们错误发生的位置。
这段代码是用 Rust 编写的,采用链式形式(第一次看到可能会觉得有点不寻常),如下所示:
io::stdin().read_line(&mut buf).ok().expect("Error!");
Rust 允许我们将这些连续的调用写在单独的行上,这对大多数人来说可以大大澄清代码:
// from Chapter 4/code/input.rs
use std::io;
fn main() {
println!("What's your name, noble warrior?");
let mut buf = String::new();
io::stdin().read_line(&mut buf)
.ok()
.expect("Failed to read line");
println!("{}, that's a mighty name indeed!", buf);
}
当我们从命令行运行这段代码时,我们得到以下对话:
What's your name, noble warrior?
Riddick
Riddick
, that's a mighty name indeed!
你能猜到为什么that's a mighty name indeed!出现在新的一行上吗?这是因为输入buf仍然包含一个换行符,\n!幸运的是,我们有一个trim()方法来从字符串中删除前后空白。如果我们插入以下代码片段中的行:
let name = buf.trim();
println!("{}, that's a mighty name indeed!", name);
现在我们得到了正确的输出:Riddick,这确实是一个响亮的名字!
如果输入不成功,我们的程序将崩溃,并显示以下输出:
What's your name, noble warrior?
thread '<main>' panicked at 'Failed to read line
我们如何从控制台读取一个正整数?
// from Chapter 4/code/pattern_match.rs
let mut buf = String::new();
io::stdin().read_line(&mut buf)
.ok()
.expect("Failed to read number");
let input_num: Result<u32, _> = buf.trim().parse();
我们从控制台读取数字到buf String 缓冲区,并使用trim()处理值;如果出现问题,expect()将显示消息。然而,我们读取的内容仍然是一个String,因此我们必须将String转换为数字。
在这个情况下,parse()方法尝试将输入转换为无符号 32 位整数。它返回的实际上又是一个 Result 值;这可以是整数(Ok<u32>)或错误(Err),当转换失败时。
我们将在第五章的泛型部分遇到更多 Option 和 Result 的例子,使用高阶函数和参数化泛化代码。
匹配模式
但我们如何测试上一节中的input_num,它是一个 Result 类型,是否包含值呢?当值是Ok(T)函数时,unwrap()函数可以像这样提取T:
println!("Unwrap found {}", input_num.unwrap());
打印结果为:Unwrap found 42。然而,当结果是Err值时,这会导致程序因恐慌而崩溃,具体表现为thread '<main>' panicked at 'called Result::unwrap()on anErr value'。这是不好的!
为了解决这个问题,仅使用复杂的 if-else 结构是不够的;我们需要在这里使用 Rust 的神奇 match,它比其他语言的 switch 有更多的可能性,并且在处理错误时经常使用:
match input_num {
Ok(num) => println!("{}", num),
Err(ex) => println!("Please input an integer number! {}", ex)
};
match函数测试一个表达式的值与所有可能值。只有第一个匹配分支后面的=>之后的代码(可以是一个代码块)被执行。所有分支都由逗号分隔。在这种情况下,打印出与输入相同的数字。没有从一分支到下一分支的跳转,因此不需要 break 语句;这使我们能够避免 C++中常见的错误。
为了继续使用match的返回值,我们必须将那个值绑定到一个变量上,这是可能的,因为 match 本身是一个表达式:
let num = match input_num {
Ok(num) => num,
Err(_) => 0
};
这个match从input_num中提取数字,以便我们可以将其与其他数字进行比较或进行计算。两个分支都必须返回相同类型的值;这就是为什么我们在Err情况下返回0(假设我们期望一个大于 0 的数字)。
获取 Result 或 Option 值的另一种方法是使用if let构造,如下所示:
if let Ok(val) = input_num {
println!("Matched {:?}!", val);
} else {
println!("No match!");
}
input_num函数被解构,如果它包含一个值val,则提取该值。在某些情况下,这可以简化代码,但你会失去穷尽匹配检查。相同的原理也可以在while循环内部应用,如下所示:
while let Ok(val) = input_num {
println!("Matched {:?}!", val);
if val == 42 { break }
}
使用match,必须覆盖所有可能值,这在我们使用 Result、Option(Some或None相当穷尽)或其他枚举值匹配时是成立的。
然而,看看当我们测试一个字符串切片时会发生什么:
// from Chapter 4/code/pattern_match2.rs
let magician = "Gandalf";
match magician {
"Gandalf" => println!("A good magician!"),
"Sauron" => println!("A magician turned bad!")
}
这个match在magician上给出了一个错误:非穷尽模式:_未覆盖。毕竟,除了"Gandalf"和"Sauron"之外,还有其他魔术师!编译器甚至给出了解决方案:使用下划线(_)表示所有其他可能性;因此,这是一个完整的匹配:
match magician {
"Gandalf" => println!("A good magician!"),
"Sauron" => println!("A magician turned bad!"),
_ => println!("No magician turned up!")
}
小贴士
为了始终确保安全,在测试变量的可能值或表达式时使用 match!
分支的左侧可以包含多个值,如果它们由|符号分隔或以起始值 … 结束值的包含值范围形式书写。以下代码片段展示了这一功能的应用:
let magical_number: i32 = 42;
match magical_number {
// Match a single value
1 => println!("Unity!"),
// Match several values
2 | 3 | 5 | 7 | 11 => println!("Ok, these are primes"),
// Match an inclusive range
40...42 => println!("It is contained in this range"),
// Handle the rest of cases
_ => println!("No magic at all!"),
}
这会打印出:“它包含在这个范围内”。匹配的值可以使用@符号捕获到变量中(这里为num),如下所示:
num @ 40...42 => println!("{} is contained in this range", num)
这会打印出:“42 包含在这个范围内”。
匹配甚至比这更强大;正在匹配的表达式可以在左侧进行解构,并且这甚至可以与称为守卫的if条件结合:
let loki = ("Loki", true, 800u32);
match loki {
(name, demi, _) if demi => {
print!("This is a demigod ");
println!("called {}", name);
},
(name, _, _) if name == "Thor" =>
println!("This is Thor!"),
(_, _, pow) if pow <= 1000 =>
println!("This is a powerless god"),
_ => println!("This is something else")
}
这会打印出:“这是一个半神洛基”。
注意,由于demi是布尔值,我们不必写if demi == true。如果你想在分支中不执行任何操作,则写=> {}。解构不仅适用于元组,如这个示例所示,还可以应用于结构体。
执行以下练习:
如果你将_分支从最后一个位置向上移动会发生什么?请参见Chapter 4/exercises/pattern_match.rs中的示例。
使用..和...记号可能会令人困惑,所以这里是对 Rust 1.0 中情况的总结:
| What works | Does not work | |
|---|---|---|
for in |
.. exclusive |
... |
Match |
... inclusive |
.. |
摘要
在本章中,我们增强了在 Rust 中处理复合数据的能力,从字符串、数组、向量以及它们的切片,到元组、结构体和枚举。我们还发现,模式匹配结合解构和守卫是一个编写清晰、优雅代码的非常强大的工具。
在下一章中,我们将看到函数比我们预期的要强大得多。此外,我们将发现结构体可以通过实现特质来拥有方法,这几乎就像在其他语言中的类和接口一样。
第五章. 使用高阶函数和参数化泛化代码
现在我们已经建立了数据结构和控制结构,我们可以开始探索 Rust 的函数式和面向对象特性,这使得它成为一种真正表达性的语言。在本章中,我们将涵盖以下主题:
-
高阶函数和闭包
-
迭代器
-
消费者和适配器
-
泛型数据结构和函数
-
错误处理
-
结构体上的方法
-
特质
-
使用特质约束
-
内置特质和运算符重载
高阶函数和闭包
到目前为止,我们知道如何使用函数,如下面的示例所示,其中 triples 函数改变了我们的 strength,但只有当 triples 的返回值赋给 strength 时:
// see code in Chapter 5/code/higher_functions.rs
let mut strength = 26;
println!("My tripled strength equals {}",triples(strength)); // 78
println!("My strength is still {}", strength); // 26
strength = triples(strength);
println!("My strength is now {}", strength); // 78
将 triples 定义为 fn triples(s: i32) -> i32 { 3 * s },s 代表力量。
假设我们的玩家砸碎了一块惊人的力量宝石,使得他的力量翻倍,并且结果的力量再次翻倍,因此我们可以写 triples(triples(s))。我们也可以写一个函数来做这件事,但有一个更通用的函数,让我们称它为 again,它可以在其结果上应用某种函数 f,F 类型,使我们能够创建各种新的游戏技巧,如下所示:
fn again (f: F, s: i32) -> i32 { f(f(s)) }
然而,这还不够信息给 Rust;编译器会要求我们解释 F 类型是什么。我们可以在参数列表之前添加 <F: Fn(i32) -> i32> 来使这一点明确:
fn again<F: Fn(i32) -> i32>(f: F , s: i32) -> i32 {
f(f(s))
}
< >(尖括号)之间的表达式告诉我们 F 是一个函数,Fn,它接受 i32 作为参数并返回一个 i32 函数。
现在看看 triples 的定义。这正是这个函数所做的(triples 有 F 类型的签名),因此我们可以再次调用,将 triples 作为第一个参数:
strength = again(triples, strength);
println!("I got so lucky to turn my strength into {}", strength); // 702 (= 3 * 3 * 78)
again 函数是一个 高阶函数,这意味着它是一个接受另一个函数(或多个函数)作为参数的函数。
通常,像 triples 这样的简单函数甚至没有定义为命名函数:
strength = 78;
let triples = |n| { 3 * n };
strength = again(triples, strength);
println!("My strength is now {}", strength); // 702
这里,我们有一个 匿名函数 或 闭包,|n| { 3 * n },它接受一个 n 参数并返回其三倍值。||(竖线)标记闭包的开始,它们包含传递给它的参数(当没有参数时,它写作 ||)。没有必要指出参数的类型或返回值,因为闭包可以从其被调用的上下文中推断它们的类型。
triples 函数仅是一个对名称的绑定,这样我们就可以在另一段代码中引用闭包。我们甚至可以省略那个名称,将闭包内联,如下所示:
strength = 78;
strength = again(|n| { 3 * n }, strength);
println!("My strength is now {}", strength); // 702
闭包使用 n 参数调用,该参数的值为 s,它是 strength 的一个副本。大括号也可以省略,以简化闭包如下:
strength = again(|n| 3 * n , strength);
那么,为什么它被称为闭包?这在以下示例中变得更加明显:
let x: i32 = 42;
let print_add = |s| {
println!("x is {}", x);
x + s
};
let res = print_add(strength);
// here the closure is called and "x is 42" is printed
assert_eq!(res, 744); // 42 + 702
print_add() 闭包有一个参数并返回一个 32 位整数。print_add 闭包知道 x 的值以及在其周围作用域中可用的所有其他变量——它将它们“封闭”起来。没有参数的闭包有一个空参数列表,||。
此外,还有一种特殊的闭包称为移动闭包,它由 move 关键字指示。一个普通的闭包只需要对其封装的变量的引用,但移动闭包会获取所有封装变量的所有权。
之前的例子将使用移动闭包如下编写:
let m: i32 = 42;
let print_add_move = move |s| {
println!("m is {}", m);
m + s
};
let res = print_add_move(strength); // strength == 702
assert_eq!(res, 744); // 42 + 702
移动闭包主要用于程序与不同的并发线程一起工作时(你可以在 第八章 的 并发和并行 中看到这一点)。
正如你将在以下章节中看到的那样,Rust 中广泛使用高阶函数和闭包,因为它们可以使代码更加简洁和易读,并且它们对于泛化计算非常有用。
迭代器
Iterator 是一个对象,它按顺序返回集合中的项目,从第一个项目到最后一个项目。为了返回下一个项目,它使用 next() 方法。在这里,我们有使用 Option 的机会:因为迭代器在某个 next() 调用可能没有更多值,所以 next() 返回 Option:当有值时返回 Some(value),当没有更多值时返回 None。
具有这种行为的简单对象是一个数字范围,0...n。每次我们使用 for 循环,例如 for i in 0...n,底层的迭代器机制就会被激活。让我们看一个例子:
// see code in Chapter 5/code/iterators.rs
let mut rng = 0..7;
println!("> {:?}", rng.next()); // prints Some(0)
println!("> {:?}", rng.next()); // prints Some(1)
for n in rng {
print!("{} - ", n);
} // prints 2 - 3 - 4 - 5 - 6 -
在这里,我们看到 next() 在工作,它产生 0、1 等等;for 循环继续到结束。
进行以下练习:
在前面的例子中,我们看到 next() 返回一个 Some 对象,这是 Option 类型的变体(参见 第四章 的 结果和 Option 部分,结构化数据和匹配模式)。使用 next() 在 rng 上编写一个无限循环,看看会发生什么。你将如何打破无限循环?使用 Option 值上的 match。 (例如,请参阅 Chapter 5/exercises/range_next.rs)。实际上,我们在这个练习之前看到的 for 循环是这个 loop – match 构造的语法糖。
迭代器也是遍历数组或切片的首选方式。让我们回顾一下来自 第四章 的外星人数组,let aliens = ["Cherfer", "Fynock", "Shirack", "Zuxu"];,结构化数据和匹配模式。而不是使用索引逐个显示所有项目,让我们使用 iter() 函数以迭代器的方式来做:
for alien in aliens.iter() {
print!("{} / ", alien)
// process alien
}
它打印出:Cherfer / Fynock / Shirack / Zuxu /。外星变量是&str类型,它依次引用每个项目。(技术上,这里它是&&str类型,因为项目本身是&str类型,但这与这里要说明的点无关。)这要高效得多,也更安全,因为 Rust 不需要进行索引边界检查,我们总是确定在数组的内存中移动。
更短的方法是写:
for alien in &aliens {
print!("{} / ", alien)
}
外星数组也是&str类型,但print!宏会自动解引用。如果你想按相反的顺序打印它们,请使用aliens.iter().rev()。我们在上一章中遇到的其它迭代器是Strings上的chars()和split()方法。
迭代器天生是惰性的;除非被询问,否则不会生成值,我们通过调用next()方法或在循环中应用for来询问它们。这很有意义,因为我们不希望在以下绑定中分配一百万个整数:
let rng = 0..1000_000; // _ makes the number 1000000 more readable
我们只想在我们需要的时候分配内存。
消费者和适配器
现在,我们将看到一些示例,说明为什么迭代器如此有用。迭代器是惰性的,必须通过调用一个消费者来激活以开始使用值。让我们从 0 到 999 的数字范围开始。为了将其转换为向量,我们应用collect()消费者:
// see code in Chapter 5/code/adapters_consumers.rs
let rng = 0..1000;
let rngvec = rng.collect::<Vec<i32>>();
println!("{:?}", rngvec);
它打印出范围(我们用...缩短了输出):[0, 1, 2, 3, 4, ... , 999]
collect()遍历整个迭代器,并将所有元素收集到一个容器中,这里在Vec<i32>类型中。这个容器不必是迭代器。注意,我们用Vec<i32>表示向量的项目类型,但我们也可以写成Vec<_>。collect::<Vec<i32>>()的表示法是新的;它表示collect是一个参数化方法,可以与泛型类型一起工作,正如你将在下一节中看到的。这一行也可以写成:
let rngvec: Vec<i32> = rng.collect();
find()消费者获取迭代器中第一个满足其条件(这里,>= 42)的值,并将其作为Option函数返回,例如:
let forty_two = rng.find(|n| *n >= 42);
println!("{:?}", forty_two); // prints out Some(42)
find的值是一个Option函数,因为条件可能对所有项目都为假,然后它将返回一个None值。条件被包裹在一个|n| *n >= 42闭包中,该闭包通过一个n引用应用于迭代器的每个项目;这就是为什么我们必须解引用*n来获取值。
假设我们只想在我们的范围内有偶数,通过在每个项目上测试闭包条件来生成一个新的范围。这可以通过filter()函数来完成,它是一个适配器,因为它从旧迭代器生成一个新的迭代器。它的结果可以像任何迭代器一样收集:
let rng_even = rng.filter(|n| is_even(*n))
.collect::<Vec<i32>>();
println!("{:?}", rng_even);
在这里,is_even是以下函数:
fn is_even(n: i32) -> bool {
n % 2 == 0
}
这打印出:[0, 2, 4, ..., 996, 998],这表明奇数被过滤掉了。
注意我们如何只需在filter()的结果上应用.collect()就能链式调用我们的消费者/适配器。
现在,如果我们想对结果迭代器中的每个项进行立方运算(n * n * n),我们会怎么做?我们可以通过使用map()函数将闭包应用于每个项来生成一个新的范围:
let rng_even_pow3 = rng.filter(|n| is_even(*n))
.map(|n| n * n * n)
.collect::<Vec<i32>>();
println!("{:?}", rng_even_pow3);
现在打印出:[0, 8, 64, ..., 988047936, 994011992]。
如果你只想获取前五个结果,请在collect函数之前插入一个take(5)适配器。结果向量将包含[0, 8, 64, 216, 512]。
因此,如果你在编译时看到警告未使用的必须使用的结果:迭代适配器是惰性的,除非被消费不会做任何事情,你就知道该怎么做——调用一个消费者!
要查看所有消费者和适配器,请查阅std::iter模块的文档。
执行以下练习:
另一个非常强大的消费者是fold()函数。以下示例计算前一百个整数的和。它从一个基值 0 开始,这也是求和累加器的初始值,然后迭代并添加每个n项到和中:
let sum = (0..101).fold(0, |sum, n| sum + n);
println!("{}", sum); // prints out 5050
现在,计算从 1 到 6 的整数的所有立方的乘积。结果应该是 1,728,000,但要注意基值!作为第二个练习,从[1, 9, 2, 3, 14, 12]数组中减去所有项,从 0 开始(即 0, 1, 9, 2,以此类推)。这将得到41。(作为一个提示,记住迭代器项是一个引用;对于一些示例代码,请参阅第五章/练习/fold.rs)。
泛型数据结构和函数
泛型是编写一次代码的能力,无需或部分指定类型,以便代码可以用于许多不同的类型。Rust 具有丰富的这种能力,并将其应用于数据结构和函数。
如果一个复合数据结构的项的类型可以是通用的<T>类型,则该数据结构是泛型的。T可以是i32、f64、String或我们自定义的Person等结构体类型。因此,我们不仅可以有Vec<f64>,还可以有Vec<Person>。如果你将T指定为具体类型,那么你必须将数据结构定义中所有出现的T替换为该类型。
我们的数据结构可以用泛型<T>类型参数化,因此它有多个具体定义——它是多态的。Rust 广泛使用这一概念,我们在第四章中已经遇到过,当我们讨论数组、向量、切片以及Result和Option类型时,我们称之为“结构化数据和模式匹配”。
假设你想定义一个具有两个字段的结构体,第一个和第二个,但你希望保持这些字段的类型泛型。我们可以这样定义:
// see code in Chapter 5/code/generics.rs
struct Pair<T> {
first: T,
second: T,
}
我们现在可以定义一对魔法数字,或一对魔术师,或我们想要的任何东西,如下所示:
let magic_pair: Pair<u32> = Pair { first: 7, second: 42 };
let pair_of_magicians: Pair<&str> = Pair { first: "Gandalf", second: "Sauron" };
如果我们想要编写与泛型数据结构一起工作的函数,它们也必须是泛型的,对吧?作为一个简单的例子,我们如何编写一个返回一对中第二个元素的函数?我们可以这样做:
fn second<T>(pair: Pair<T>) {
pair.second;
}
我们可以将其调用为let a = second(magic_pair);,产生42。
注意函数名后面的<T>字符;这就是泛型函数的声明方式。
现在,让我们来探讨为什么Option和Result如此强大。再次给出Option类型的定义:
enum Option<T> {
Some(T),
None
}
从这个例子中,我们可以定义多个具体类型如下:
let x: Option<i8> = Some(5);
let pi: Option<f64> = Some(3.14159265359);
let none: Option<f64> = None;
let none2 = None::<f64>;
let name: Option<&str> = Some("Joyce");
当类型与值不匹配时,会发生类型不匹配错误,类似于在let magic: Option<f32> = Some(42)中使用的Option。
我们可以定义一个Person结构体如下:
struct Person {
name: &'static str,
id: i32
}
我们也可以定义几个Person对象如下:
let p1 = Person{ name: "James Bond", id: 7 };
let p2 = Person{ name: "Vin Diesel", id: 12 };
let p3 = Person{ name: "Robin Hood", id: 42 };
然后,使用这些对象,我们可以为Person创建Option或向量:
let op1: Option<Person> = Some(p1);
let pvec: Vec<Person> = vec![p2, p3];
在你期望得到一个值,但有可能不会得到值的情况下,你应该使用Option类型。一个典型的场景是用户输入。
与此相关的是我们在第四章的“结果和选项”部分首次遇到的Result类型,即第四章中的“结构化数据与模式匹配”。当计算应该返回一个结果时,可以使用它,但如果出现错误,它也可以返回一个错误。Result是通过以下两个泛型类型——T和E——定义的:
enum Result<T, E> {
Ok(T),
Err(E)
}
这再次显示了 Rust 对安全性的承诺;如果它是Ok,它将返回一个T类型的值,如果存在问题,则返回一个E类型的错误(这通常是一个错误消息字符串)。因此,我们也可以将它们读作Ok(what)和Err(why),其中what具有T类型,而why具有E类型。
那么,为什么Option和Result是 Rust 的杀手级特性呢?记得从第四章中的“结果和选项”部分,在结构化数据与模式匹配节中,我们是如何在获取数字输入时使用Option的?这里再次给出:
let input_num: Result<u32, _> = buf.trim().parse();
在其他语言,如 Java 或 C#中,将输入解析为数字可能会导致异常(当输入包含非数字字符或为空或 null 时),你将不得不使用资源密集型的try/catch来处理它。
在 Rust 中,parse()的结果是一个Result,我们只需使用match来测试Result的返回值,这是一个更简单的机制:
match input_num {
Ok(num) => println!("{}", num),
Err(ex) => println!("Please input an integer number! {}", ex)
};
这里是另一个如何使用Result返回错误条件的例子。我们使用std::num::Float::sqrt()函数计算浮点数的平方根:
fn sqroot(r: f32) -> Result<f32, String> {
if r < 0.0 {
return Err("Number cannot be negative!".to_string());
}
Ok(Float::sqrt(r))
}
我们通过返回一个Err值来防止对负数取平方根(这将给出 NaN,即“不是一个数字”)。
let m = sqroot(42.0);
这将打印出:“42 的平方根是 6.480741”。
在调用代码中,我们使用我们信任的模式匹配机制来区分这两种情况:
match m {
Ok(sq) => println!("The square root of 42 is {}", sq),
Err(str) => println!("{}", str)
}
使用let m = sqroot(-5.0);时,错误消息会打印为“数字不能为负!”。
注意
对于Option和Result值,使用match可以确保没有空值或错误可以在你的代码中传播,这避免了空指针运行时错误或其他异常导致程序崩溃。
错误处理
一个 Rust 程序必须尽可能准备好处理未预见的错误,但意外的事情总是可能发生的,比如整数除以零:
// see code in Chapter 5/code/errors.rs
let x = 3;
let y = 0;
x / y;
当这种情况发生时,程序会停止并显示以下消息:“<main>线程恐慌于'尝试除以零'”。
潘克
可能会出现一种非常糟糕的情况(比如除以零),以至于继续运行程序已经没有意义,也就是说,我们无法从错误中恢复。在这种情况下,我们可以调用panic!("message")宏,这将释放线程拥有的所有资源,报告消息,然后使程序退出。我们可以改进之前的代码如下:
if (y == 0) { panic!("Division by 0 occurred, exiting"); }
println!("{}", div(x, y));
这里,div是以下函数:
fn div(x: i32, y: i32) -> f32 {
(x / y) as f32
}
还可以使用许多其他宏,如assert!系列,来指示这种不受欢迎的条件:
assert!(x == 5); //thread <main> panicked at assertion failed: x == 5
assert!( x == 5, "x is not equal to 5!");
// thread <main> panicked at "x is not equal to 5!"
assert_eq!(x, 5); // thread '<main>' panicked at 'assertion failed: (left: `3`, right: `5`)',
当条件不成立时,它们会导致恐慌情况并退出。如果assert!的第二个参数提供了错误消息,则会打印出来,否则会给出通用消息,“断言失败”。assert!函数主要用于测试前置和后置条件。
代码中那些通常不会被执行的部分可以包含unreachable!宏,当它被执行时会引发恐慌:
unreachable!();
// thread '<main>' panicked at 'internal error: entered unreachable code'
失败
在大多数情况下,我们希望尝试从错误中恢复并让程序继续运行。幸运的是,我们已经在第四章的“结果和选项”部分以及本章的“通用数据结构和函数”部分看到了处理这种错误的基本技术。
当我们期望一个值时,可以使用Option<T>枚举;此时,如果存在值,则给出Some(T)枚举,如果没有值或发生失败,则返回None值。这样,Rust 强制将“无”以清晰和语法上可识别的形式出现,避免了空指针运行时错误。
Result<T, E>枚举可以在正常(成功)情况下返回Ok(T)值,在失败情况下返回Err(E)值,包含有关错误的信息。在前一节的示例中,我们使用了 Result 来安全地从键盘读取值并创建一个计算数字平方根的安全函数。
结构体上的方法
现在,我们将看看 Rust 如何满足那些习惯于 object.method() 语法而不是 function(object) 的面向对象开发者。在 Rust 中,我们可以在结构体上定义 方法,这基本上与传统概念中的 class 相当。
假设我们正在开发一个游戏,游戏动作发生在遥远太阳系中的一个星球上,那里居住着敌对外星人。为了这个游戏,让我们定义一个 Alien 结构体如下:
// see code in Chapter 5/code/methods.rs
struct Alien {
health: u32,
damage: u32
}
在这里,health 是外星人的状态,而 damage 是当它攻击时你的健康减少的量。我们可以创建一个外星人如下:
let mut bork = Alien{ health: 100, damage: 5 };
health 参数不能超过 100,但当我们创建结构体实例时,我们无法强制这个约束。解决方案是为外星人定义一个 new 方法,这样我们就可以测试这个值:
impl Alien {
fn new(mut h: u32, d: u32) -> Alien {
// constraints:
if h > 100 { h = 100; }
Alien { health: h, damage: d }
}
}
我们可以按照以下方式构建一个新的 Alien 数组:
let mut berserk = Alien::new(150, 15);
我们在 impl Alien 块内部定义了 new 方法(以及所有其他方法),这个块与 Alien 结构体的定义是分开的。在应用了所有约束之后,它返回一个 Alien 对象。我们通过 Alien::new() 在 Alien 结构体本身上调用它。由于它是一个 静态方法,所以我们不会在 Alien 实例上调用它。这样一个新的方法与面向对象语言中的构造函数非常相似。之所以叫它 new,仅仅是因为惯例,因为我们本可以叫它 create() 或 give_birth()。另一个静态方法可以是所有外星人给出的警告:
fn warn() -> &'static str {
"Leave this planet immediately or perish!"
}
这可以这样调用:
println!("{}", Alien::warn());
当一个特定的外星人攻击时,我们可以为该外星人定义一个方法如下:
fn attack(&self) {
println!("I attack! Your health lowers with {} damage points.", self.damage);
}
然后按照以下方式在 alien berserk 上调用它:berserk.attack();。将 berserk(调用方法时所在的 Alien 对象)的引用作为 &self 传递给方法。实际上,self 与 Python 中的 self 或 Java 或 C# 中的 this 类似。实例方法总是有 &self 作为参数,与静态方法相对。
在这里,对象以不可变的方式传递,但如果攻击你也会降低外星人的健康值怎么办?让我们添加第二个攻击方法:
fn attack(&self) {
self.health -= 10;
}
然而,Rust 报错,指出有两个编译错误。首先,它说,cannot assign to immutable field self.health。我们可以通过像这样传递一个可变引用来解决这个问题:fn attack(&mut self)。但现在 Rust 抱怨,duplicate definition of value 'attack'。这意味着 Rust 不允许有两个同名的方法;Rust 中没有方法重载。这是由于类型推断的工作方式造成的。
通过将名称改为 attack_and_suffer,我们得到以下内容:
fn attack_and_suffer(&mut self, damage_from_other: u32) {
self.health -= damage_from_other;
}
在调用 berserk.attack_and_suffer(31); 之后,berserk 的健康值现在是 69(其中 31 是另一个攻击外星人对 berserk 造成的伤害点数)。
没有方法重载意味着我们只能定义一个新函数(这本身也是可选的)。我们可以为我们的构造函数发明不同的名字,这在代码文档方面是好的。否则,你可以选择所谓的Builder模式,你可以在doc.rust-lang.org/book/method-syntax.html#builder-pattern找到更多信息。
注意
注意,在 Rust 中,方法也可以定义在元组和枚举上。
执行以下练习:
复数,如 2 + 5i(i 是-1 的平方根),有一个实部(这里为 2)和一个虚部(5);两者都是浮点数。定义一个Complex结构体和一些方法:
-
一个
new方法来构造一个复数。 -
一个
to_string方法,用于打印复数,如 2 + 5i 或 2 – 5i(作为一个提示,使用与println!相同方式的format!宏,但它返回一个String。) -
一个
add方法来添加两个复数;这是一个新的复数,其实部是操作数的实部之和,同样适用于虚部。 -
一个
times_ten方法,通过将两部分都乘以 10 来改变对象本身(作为一个提示,仔细思考这个方法参数。) -
作为额外奖励,创建一个
abs方法,用于计算复数的绝对值。(请参阅en.wikipedia.org/wiki/Absolute_value。)
现在,测试你的方法!(有关示例代码,请参阅第五章/练习/complex.rs。)Rust 在 crate num中定义了一个Complex类型。
特质
如果我们的游戏真的非常多样化呢?也就是说,除了外星人,我们还有僵尸和捕食者,不用说,他们都想攻击。我们能否将它们的共同行为抽象成它们都拥有的东西?当然,在 Rust 中,我们说它们有一个共同的特质,这类似于其他语言中的接口或超类。让我们称这个特质为Monster,因为它们都想攻击,第一个版本可以是这样的:
// see code in Chapter 5/code/traits.rs
trait Monster {
fn attack(&self);
}
特质只包含方法的描述,即它们的类型声明或签名,但它没有真正的实现。这是逻辑的,因为僵尸、捕食者和外星人可能各自有自己的攻击方法。所以,在函数签名之后的{}之间没有代码体,但不要忘记用;关闭它。
当我们想要为Alien结构体实现Monster特质时,我们编写以下代码:
impl Monster for Alien {
}
当我们编译这个程序时,Rust 抛出not all trait items implemented, missing: 'attack'错误。这很好,因为 Rust 提醒我们哪些特质的函数我们忘记了实现。以下代码可以使其通过:
impl Monster for Alien {
fn attack(&self) {
println!("I attack! Your health lowers with {} damage points.", self.damage);
}
}
因此,为类型实现的特性必须提供实际代码,该代码将在调用Alien对象上的该方法时执行。如果僵尸攻击是两倍糟糕,其Monster实现可能如下所示:
impl Monster for Zombies {
fn attack(&self) {
println!("I bite you! Your health lowers with {} damage points.", 2 * self.damage);
}
}
我们可以向特性添加其他方法,例如new方法、noise方法和attack_with_sound方法:
trait Monster {
fn new(hlt: u32, dam: u32) -> Self;
fn attack(&self);
fn noise(&self) -> &'static str;
fn attacks_with_sound(&self) {
println!("The Monster attacks by making an awkward sound {}", self.noise());
}
}
注意,在new方法中,生成的对象是Self类型,在特性的实际实现中,它成为Alien或Zombie实现类型。
出现在特性中的函数被称为方法。方法与函数的不同之处在于它们有&self作为参数;这意味着它们有被调用的对象作为参数,例如,fn noise(&self) -> &'static str。当我们用zmb1.noise()调用它时,zmb1对象成为 self。
特性可以为方法提供默认代码(类似于这里的attack_with_sound方法)。实现类型可以选择采用此默认代码或用自己的版本覆盖它。特性方法中的代码也可以使用self.method()调用特性中的其他方法,类似于attack_with_sound,其中调用了self.noise()。
对于Zombie类型,Monster特性的完整实现可能如下所示:
impl Monster for Zombie {
fn new(mut h: u32, d: u32) -> Zombie {
// constraints:
if h > 100 { h = 100; }
Zombie { health: h, damage: d }
}
fn attack(&self) {
println!("The Zombie bites! Your health lowers with {} damage points.", 2 * self.damage);
}
fn noise(&self) -> &'static str {
"Aaargh!"
}
}
这里是我们游戏场景的一个简短片段:
let zmb1 = Zombie { health: 75, damage: 15 };
println!("Oh no, I hear: {}", zmb1.noise());
zmb1.attack();
它打印出:Oh no, I hear: Aaargh!
The Zombie bites! Your health lowers with 30 damage points.。
特性不仅限于结构体;它们可以应用于任何类型。一个类型也可以实现多个不同的特性。所有实现的不同方法都被编译成针对它们类型的特定版本,因此编译后,例如,存在针对Alien、Zombie和Predator的新方法。
实现特性中的所有方法可能是繁琐的工作。例如,我们可能希望以这种方式显示我们的生物:
println!("{:?}", zmb1);
不幸的是,这给我们带来了the trait 'core::fmt::Debug' is not implemented for the type 'Zombie' compiler错误。所以,从消息中,我们可以推断出这个{:?}使用了一个Debug特性。如果我们查看文档,我们会发现我们必须实现一个fmt方法(指定格式化对象的方式)。然而,编译器在这里又一次帮助我们;如果我们用属性#[derive(Debug)]前缀我们的Zombie结构定义,那么将自动生成默认代码版本:
#[derive(Debug)]
struct Zombie { health: u32, damage: u32 }
println!("{:?}", zmb1);片段现在显示为:Zombie { health: 75, damage: 15 }。
这也适用于一系列其他特性。(参见本章的内置特性和运算符重载部分和rustbyexample.com/trait/derive.html。)
使用特性约束
在泛型数据结构和函数部分,我们创建了一个sqroot函数来计算 32 位浮点数的平方根:
fn sqroot(r: f32) -> Result<f32, String> {
if r < 0.0 {
return Err("Number cannot be negative!".to_string());
}
Ok(f32::sqrt(r))
}
如果我们想计算一个 f64 数字的平方根呢?为每种类型制作不同的版本将非常不实用。第一次尝试可能是将 f32 替换为泛型类型 <T>:
// see code in Chapter 5/code/trait_constraints.rs
extern crate num;
use num::traits::Float;
fn sqroot<T>(r: T) -> Result<T, String> {
if r < 0.0 {
return Err("Number cannot be negative!".to_string());
}
Ok(num::traits::Float::sqrt(r))
}
然而,Rust 不会同意,因为它对 T 一无所知,并且会给出多个错误(num 是一个外部库,它通过 extern crate num 导入,请参阅第七章 Chapter 7,组织代码和宏):
binary operation `<` cannot be applied to type `T`
the trait `core::marker::Copy` is not implemented for the type `T`
the trait `core::num::NumCast` is not implemented for the type `T`
…
所缺少的所有特性都由 Float 特性实现。我们可以断言 T 必须实现此特性,作为 fn sqroot<T: num::traits::Float>。这被称为在 T 类型上放置特性约束或特性界限,这确保了函数可以使用指定特性的所有方法。
为了尽可能通用,我们还使用了 num::traits::Float 特性中存在的特殊指示符 0,它被命名为 num::zero(); 因此,我们的函数现在如下所示:
fn sqroot<T: num::traits::Float>(r: T) -> Result<T, String> {
if r < num::zero() {
return Err("Number cannot be negative!".to_string());
}
Ok(num::traits::Float::sqrt(r))
}
这对以下两个调用都适用:
println!("The square root of {} is {:?}", 42.0f32, sqroot(42.0f32) );
println!("The square root of {} is {:?}", 42.0f64, sqroot(42.0f64) );
这将输出如下:
The square root of 42 is Ok(6.480741)
The square root of 42 is Ok(6.480741)
然而,如果我们尝试如下调用 sqroot 在一个整数上,我们会得到一个错误:
println!("The square root of {} is {:?}", 42, sqroot(42) );
我们得到一个错误,“std::num::Float 特性未在类型 _ 上实现” [E0277],因为整数不是 Float 类型。
我们的 sqroot 函数是泛型的,适用于任何 Float 类型。编译器为它应该与之一起工作的任何类型创建不同的可执行 sqroot 方法——在这种情况下,f32 和 f64。当函数调用是多态的,即函数可以接受不同类型的参数时,Rust 应用此机制。这被称为 静态 分发,并且没有运行时开销。这应该与 Java 接口的工作方式形成对比,其中分发是在运行时由 Java 虚拟机动态执行的。然而,Rust 也有动态分发的形式;有关更多详细信息,请参阅 doc.rust-lang.org/1.0.0-beta/book/static-and-dynamic-dispatch.html。
以另一种方式编写相同的特性约束是使用 where 子句,如下所示:
fn sqroot<T>(r: T) -> Result<T, String> where T: num::traits::Float { … }
为什么存在这种其他形式?嗯,可能存在多个泛型 T 和 U 类型。此外,每种类型都可以被约束到多个特性(特性之间用 + 连接),例如 Trait1、Trait2 等等,就像在这个虚构的例子中:
fn multc<T: Trait1, U: Trait1 + Trait2>(x: T, y: U) {}
使用 where 语法,这可以更易于阅读,如下所示:
fn multc<T, U>(x: T, y: U) where T: Trait1, U: Trait1 + Trait2 {}
执行以下练习:
定义一个具有 draw 方法的 Draw 特性。定义具有整数字段的 S1 结构体类型和具有浮点字段 S2 结构体类型。
为 S1 和 S2 实现特性 Draw(绘制打印值,并被 *** 所包围)。
创建一个泛型 draw_object 函数,它接受任何实现了 Draw 的对象。
测试这些!(请参阅第五章的示例代码 exercises/draw_trait.rs)
内置特性和运算符重载
Rust 标准库中充满了特性,它们被广泛应用于各个地方。例如,有用于:
-
比较对象(
Eq和PartialEq特性)。 -
排序对象(
Ord和PartialOrd特性)。 -
创建一个空对象(
Default特性)。 -
使用
{:?}格式化值(Debug特性,它定义了一个fmt方法)。 -
复制一个对象(
Clone特性)。 -
添加对象(
Add特性,它定义了一个add方法)注意
+运算符只是使用的一个好方法;add: n + m与n.add(m)相同。所以,如果我们实现了Add特性,我们就可以使用+运算符;这被称为运算符重载。许多其他特性也可以用来重载运算符,例如Sub(-)、Mul(*)、Deref (*v)、Index([])等等。 -
当对象超出作用域时释放对象资源(换句话说,
Drop特性,即对象有一个析构函数)
在 迭代器 部分中,我们描述了迭代器的工作原理,并在范围和数组上使用了它。实际上,迭代器在 Rust 的 std::iter::Iterator 中也被定义为一个特性。从迭代器的文档(参考 doc.rust-lang.org/core/iter/trait.Iterator.html)中,我们看到我们只需要定义 next() 方法,该方法将迭代器向前推进以返回下一个值作为选项。当 next() 为你的对象类型实现时,我们就可以使用 for in 循环来遍历对象。
摘要
在本章中,我们学习了各种技术,通过使用高阶函数、闭包、迭代器和泛型类型和函数来使我们的代码更加灵活。然后我们回顾了利用泛型类型的基本错误处理机制。
我们还发现了 Rust 的面向对象特性,通过在结构体上定义方法和实现特性。最后,我们看到了特性是 Rust 的结构化概念。
在下一章中,我们将揭示 Rust 语言的瑰宝,这些瑰宝构成了其内存安全行为的基础。
第六章。指针和内存安全
这可能是本书最重要的章节。在这里,我们详细描述了 Rust 借用检查器机制如何以独特的方式在编译时检测问题,以防止内存安全错误。这对于 Rust 中的其他一切是基本的,因为该语言专注于这些所有权和借用概念。一些材料已经在前面讨论过,但在这里,我们将加强这个基础。我们将涵盖以下主题:
-
指针和引用
-
所有权和借用
-
框架
-
引用计数
尝试和实验示例是关键,因为可能有许多你还不熟悉的概念。
指针和引用
第二章的堆栈和堆部分,使用变量和类型为我们提供了理解 Rust 内存布局所需的基本信息。让我们回顾一下这些信息,并填补一些空白。
堆栈和堆
当程序启动时,默认情况下会授予它一个 2MB 的内存块,称为堆栈。程序将使用其堆栈来存储所有其局部变量和函数参数;例如,一个i32变量占用堆栈的 4 个字节。当我们的程序调用一个函数时,会为其分配一个新的堆栈帧。通过这种机制,堆栈知道函数调用的顺序,以便函数能够正确地返回调用代码,并可能返回值。
动态大小的类型,如字符串或数组,不能存储在堆栈上。对于这些值,程序可以在其堆上请求内存空间,因此这比堆栈大得多。
小贴士
当可能时,堆栈分配比堆分配更受欢迎,因为访问堆栈要高效得多。
生命周期
Rust 代码中的所有变量都有生命周期。假设我们使用let n = 42u32;绑定声明一个n变量。这样的值从声明的地方开始有效,直到它不再被引用,这被称为变量的生命周期。以下代码片段说明了这一点:
// see code in Chapter 6/code/lifetimes.rs
fn main() {
let n = 42u32;
let n2 = n; // a copy of the value from n to n2
life(n);
println!("{}", m); // error: unresolved name `m`.
println!("{}", o); // error: unresolved name `o`.
}
fn life(m: u32) -> u32 {
let o = m;
o
}
n的生命周期在main()结束时结束;一般来说,生命周期的开始和结束发生在相同的范围内。生命周期和范围是同义词,但我们通常使用“生命周期”一词来指代引用的范围。与其他语言一样,在函数中声明的局部变量或参数在函数执行完毕后不再存在;在 Rust 中,我们说它们的生命周期已经结束。这是前面代码片段中m和o变量的情况,它们只在life函数中是已知的。
同样,嵌套块中声明的变量的生命周期被限制在该块内,就像以下示例中的phi一样:
{
let phi = 1.618;
}
println!("The value of phi is {}", phi); // is error
当phi的生命周期结束时尝试使用它会导致错误:未解析的名称 'phi'。
代码中可以通过注解来指示值的生命周期,例如'a,它读作生命周期,其中a只是一个指示符;它也可以写成'b、'n或'life。用单个字母表示生命周期是很常见的。在先前的示例中,由于没有涉及引用,因此不需要显式地指示生命周期。所有标记有相同生命周期的值都具有相同的最大生命周期。我们已经从'static这个符号中了解到这一点,正如我们在第二章的全局常量部分所看到的,使用变量和类型,它是整个程序持续存在的对象的生存周期,因此只有在你需要这么长时间值的时候才使用'static。
在以下示例中,我们有一个显式声明其s参数生命周期为'a的transform函数:
fn transform<'a>(s: &'a str) { /* ... */ }
注意函数名称后面的<'a>指示。在几乎所有情况下,这种显式指示都是不必要的,因为编译器足够智能,可以推断出生命周期,因此我们可以简单地写成这样:
fn transform_without_lifetime(s: &str) { /* ... */ }
这里有一个例子,即使我们指示了生命周期指定符'a,编译器也不允许我们的代码。让我们假设我们定义了一个Magician结构体如下:
struct Magician {
name: &'static str,
power: u32
}
如果我们尝试构造以下函数,我们将得到一个错误信息:
fn return_magician<'a>() -> &'a Magician {
let mag = Magician { name: "Gandalf", power: 4625};
&mag
}
错误信息是错误:'mag'的生命周期不足以长。为什么会这样?mag值的生命周期在return_magician函数结束时结束,但这个函数仍然试图返回一个指向Magician值的引用,而这个值已经不存在了。这种无效的引用被称为悬垂指针。这是一个明显会导致错误的情况,不允许发生。
指针的生存期必须始终短于或等于它所指向的值的生存期,从而避免悬垂(或空)引用。
在某些情况下,确定一个对象的生命周期何时结束的决策可能很复杂,但在几乎所有情况下,借用检查器都会通过在中间代码中插入生命周期注解自动为我们完成这项工作;因此,我们不必这样做。这被称为生命周期省略。
例如,当与结构体一起工作时,我们可以安全地假设结构体实例及其字段具有相同的生命周期。只有在借用检查器不确定的情况下,我们才需要显式地指示生命周期;然而,这种情况很少发生,通常发生在返回引用时。
一个例子是我们有一个具有引用字段的struct。以下代码片段解释了这一点:
struct MagicNumbers {
magn1: &u32,
magn2: &u32
}
这将无法编译,并给出以下错误:缺少生命周期指定符 [E0106]。
因此,我们必须按照以下方式更改代码:
struct MagicNumbers<'a> {
magn1: &'a u32,
magn2: &'a u32
}
这指定了结构和字段都具有'a的生命周期。
执行以下练习:
解释为什么以下代码无法编译:
// see code in Chapter 6/exercises/dangling_pointer.rs:
fn main() {
let m: &u32 = {
let n = &5u32;
&*n
};
let o = *m;
}
对于这个代码片段也要回答相同的问题:
let mut x = &3;
{
let mut y = 4;
x = &y;
}
复制值和 Copy 特性
在我们之前讨论的代码中(见 Chapter 6/code/lifetimes.rs),每次通过新的 let 绑定或作为函数参数赋值给 n 时,n 的值都会被复制到一个新的位置:
let n = 42u32;
// no move, only a copy of the value:
let n2 = n;
life(n);
fn life(m: u32) -> u32 {
let o = m;
o
}
在程序执行过程中的某个时刻,我们会拥有四个包含复制值 42 的内存位置,我们可以这样可视化:

每个值在它对应的变量的生命周期结束时消失(并且其内存位置被释放),这发生在定义它的函数或代码块的末尾。这种 Copy 行为(其中值(其位)被简单地复制到栈上的另一个位置)不会出现太多错误。许多内置类型,如 u32 和 i64,与此类似,这种复制值的行为在 Rust 中定义为 Copy 特性,而 u32 和 i64 实现了该特性。
如果你的类型的所有字段或项目都实现了 Copy,你也可以为你的类型实现 Copy 特性。例如,包含 u64 类型字段的 MagicNumber 结构体可以具有相同的行为。有两种方式来表示这一点:
-
一种方法是将
Copy实现显式命名为以下内容:struct MagicNumber { value: u64 } impl Copy for MagicNumber {} -
否则,我们可以用
Copy属性来注释它:#[derive(Copy)] struct MagicNumber { value: u64 }
这意味着我们可以通过赋值创建两个不同的 MagicNumber 的副本,mag 和 mag2:
let mag = MagicNumber {value: 42};
let mag2 = mag;
它们是复制的,因为它们有不同的内存地址(显示的值将在每次执行时不同):
println!("{:?}", &mag as *const MagicNumber); // address is 0x23fa88
println!("{:?}", &mag2 as *const MagicNumber); // address is 0x23fa80
(*const 函数是一个所谓的原始指针;有关更多详细信息,请参阅 第九章,在边界编程。没有实现 Copy 特性的类型被称为不可复制的。
实现这一点的另一种方法是让 MagicNumber 实现了 Clone 特性:
#[derive(Clone)]
struct MagicNumber {
value: u64
}
然后,我们可以使用 clone() 将 mag 复制到另一个名为 mag3 的不同对象中,从而有效地创建一个副本如下:
let mag3 = mag.clone();
println!("{:?}", &mag3 as *const MagicNumber); // address is 0x23fa78
mag3 是一个新指针,它引用了 mag 值的新副本。
指针
在 let n = 42i32; 绑定中的 n 变量存储在栈上。栈或堆上的值可以通过指针访问。指针是一个包含某个值内存地址的变量。要访问它指向的值,需要使用 * 解引用指针。这在简单的案例中是自动发生的,例如在 println! 或将指针作为方法参数时。例如,在以下代码中,m 是一个包含 n 地址的指针:
// see code in Chapter 6/code/references.rs:
let m = &n;
println!("The address of n is {:p}", m);
println!("The value of n is {}", *m);
println!("The value of n is {}", m);
这会打印出以下输出,每次程序运行的结果都不同:
The address of n is 0x23fb34
The value of n is 42
The value of n is 42
那么,为什么我们需要指针呢?当我们处理可以改变大小的动态分配值,例如String时,该值的内存地址在编译时是未知的。因此,内存地址需要在运行时计算。因此,为了能够跟踪它,我们需要一个指针,其值将在String在内存中的位置改变时发生变化。
编译器会自动处理指针的内存分配和在其生命周期结束时释放内存。你不需要像在 C/C++中那样自己来做这件事,在那里你可能会在错误的时间或多次释放内存。
在 C++等语言中,指针的不正确使用会导致各种问题。
然而,Rust 在编译时强制实施一套称为借用检查器的严格规则,因此我们得到了保护。我们已经在实际操作中看到了它们,但从现在开始,我们将解释其规则背后的逻辑。
指针也可以作为函数的参数传递,并且可以从函数中返回,但编译器对其使用有严格的限制。
当将指针值传递给函数时,始终最好使用引用-解引用&*机制,如下例所示:
let q = &42;
println!("{}", square(q)); // 1764
fn square(k: &i32) -> i32 {
*k * *k
}
Rust 有许多种指针,我们将在本章中探讨。所有指针(除了在第九章(part0069.xhtml#aid-21PMQ1 "第九章。边界编程")中讨论的原始指针外,边界编程)都保证为非空(即它们指向内存中的有效位置)并且会自动清理。
引用
在我们之前的例子中,具有&n值的m是最简单的指针形式,它被称为引用(或借用指针);m是栈分配的n变量的引用,并且具有&i32类型,因为它指向i32类型的值。
注意
通常,当n是T类型的值时,则&n引用是&T类型。
在这里,n是不可变的,所以m也是不可变的;例如,如果你尝试通过m使用*m = 7;来改变n的值,你会得到一个cannot assign to immutable borrowed content '*m'错误。与 C 不同,Rust 不允许你通过指针改变不可变变量。
由于通过引用改变n的值没有危险,因此允许对不可变值有多个引用;它们只能用来读取值,例如:
let o = &n;
println!("The address of n is {:p}", o);
println!("The value of n is {}", *o);
它会按照前面描述的方式打印出来:
The address of n is 0x23fb34
The value of n is 42
我们可以将这种情况在内存中表示如下:

很明显,与这种或更复杂的情况一起工作需要比 Copy 行为更严格的规则。例如,只有当没有变量或指针与它关联时,才能释放内存。当值是可变的,是否可以通过其任何指针来更改它?这些更严格的规则,由下一节讨论的所有权和借用系统描述,由编译器强制执行。
可变引用确实存在,并且它们声明为 let m = &mut n。然而,n 也必须是可变值。当 n 是不可变的,编译器会因错误 cannot borrow immutable local variable 'n' as mutable 而拒绝 m 可变引用绑定。这很有道理,因为即使你知道它们的内存位置,不可变变量也不能更改。
再次强调,为了通过引用更改值,变量及其引用都必须是可变的,如下面的代码片段所示:
let mut u = 3.14f64;
let v = &mut u;
*v = 3.15;
println!("The value of u is now {}", *v);
这将打印:u 的值现在是 3.15。
现在,变量 u 的内存位置中的值已更改为 3.15。
然而,请注意,我们现在不能再通过使用 u: u = u * 2.0; 变量来更改(甚至打印)该值,因为编译器会报错:cannot assign to 'u' because it is borrowed(我们将在本章的 所有权和借用 部分解释原因)。我们说通过引用变量(通过对其创建引用)会冻结该变量;原始的 u 变量被冻结(并且不再可用),直到引用超出作用域。
此外,我们只能有一个可变引用:let w = &mut u; 这会导致错误:cannot borrow 'u' as mutable more than once at a time。编译器甚至会在之前的代码行上添加以下注释:let v = &mut u; 注释:previous borrow of 'u' occurs here; the mutable borrow prevents subsequent moves, borrows, or modification of 'u' until the borrow ends。这是合乎逻辑的;编译器(正确地)担心通过一个引用对 u 值的更改可能会改变它的内存位置,因为 u 可能会改变大小,所以它不再适合其之前的位置,并必须重新定位到另一个地址。这将使所有其他对 u 的引用都无效,甚至危险,因为通过它们我们可能会无意中更改另一个变量,该变量已经占据了 u 之前的位置!
可变值也可以通过将它的地址作为可变引用传递给函数来更改,如下面的示例所示:
let mut m = 7;
add_three_to_magic(&mut m);
println!("{}", m); // prints out 10
函数 add_three_to_magic 声明如下:
fn add_three_to_magic(num: &mut i32) {
*num += 3; // value is changed in place through +=
}
注意
总结来说,当 n 是 T 类型的可变值时,任何时候只能存在一个对该值的可变引用(&mut T 类型)。通过这个引用,可以更改值。
在匹配中使用 ref
如果你想在 match 函数内部获取匹配变量的引用,请使用 ref 关键字,如下面的示例所示:
// see code in Chapter 6/code/ref.rs
fn main() {
let n = 42;
match n {
ref r => println!("Got a reference to {}", r),
}
let mut m = 42;
match m {
ref mut mr => {
println!("Got a mutable reference to {}", mr);
*mr = 43;
},
}
println!("m has changed to {}!", m);
}
它将打印出:
Got a reference to 42
Got a mutable reference to 42
m has changed to 43!
match内部的r变量具有&i32类型。换句话说,ref关键字创建了一个用于模式的引用。如果你需要一个可变引用,请使用ref mut。
我们还可以使用ref通过let绑定在解构中获取结构体或元组的字段的引用。例如,在重用Magician结构体时,我们可以通过使用ref提取mag的名字,然后从match中返回它:
let mag = Magician { name: "Gandalf", power: 4625};
let name = {
let Magician { name: ref ref_to_name, power: _ } = mag;
*ref_to_name
};
println!("The magician's name is {}", name);
打印结果为:The magician's name is Gandalf.
引用是最常见的指针类型,并且具有最多的可能性;其他指针类型应该只应用于非常特定的用例。
所有权和借用
在前面的章节中,大多数错误信息中提到了“借用”。这究竟是怎么回事?这个借用检查机制背后的逻辑是什么?
每个程序,无论它做什么,无论是从数据库读取数据还是进行计算,都涉及到处理资源。程序中最常见的资源是分配给其变量的内存空间。其他资源可能是文件、网络连接、数据库连接等等。当我们用let绑定一个资源时,每个资源都会被赋予一个名称;在 Rust 语言中,我们说资源获得了一个所有者,例如,在以下代码片段中,klaatu拥有由Alien结构体实例占据的一块内存:
// see code in Chapter 6/code/ownership1.rs
struct Alien {
planet: String,
n_tentacles: u32
}
fn main() {
let mut klaatu = Alien{ planet: "Venus".to_string(),
n_tentacles: 15 };
}
只有所有者才能更改它所指向的对象,并且一次只能有一个所有者,因为所有者负责释放对象的资源。当一个引用超出作用域时,它不会释放底层内存,因为引用不是值的所有者。这很有道理;如果一个对象可以有多个所有者,它的资源可能会被多次释放,这会导致问题。当所有者的生命周期结束时,编译器会自动释放内存。
所有者可以将对象的拥有权移动到另一个变量,如下所示:
let kl2 = klaatu;
在这里,所有权已从klaatu移动到kl2,但实际上没有数据被复制。原始所有者klaatu不能再使用了:
println!("{}", klaatu.planet);
它会给出编译器错误:use of moved value 'klaatu.planet'。
另一方面,我们可以通过创建一个(在这个例子中是可变的)引用kl2到klaatu来借用资源,使用let kl2 = &mut klaatu;。借用是一个通过&传递数据结构地址的临时引用。
现在,kl2可以更改对象,例如,当我们的外星人在战斗中失去一个触手时:
kl2.n_tentacles = 14;
println!("{} - {}", kl2.planet, kl2.n_tentacles);
这会打印出:Venus – 14。
然而,如果我们尝试通过以下代码更改外星人的星球,将会得到一个错误信息:
klaatu.planet = "Pluto".to_string();
错误信息是error: cannot assign to `klaatu.planet` because it is borrowed;它确实被kl2借用了。类似于日常生活,当一个对象被借用时,所有者无法访问它,因为它不再在他们手中。为了更改资源,klaatu需要拥有它,同时资源没有被同时借用。
Rust 甚至通过添加注释来向我们解释这一点:borrow of 'klaatu.planet' occurs here ownership.rs:8 let kl2 = &mut klaatu;。
由于kl2借用了资源,Rust 甚至禁止我们使用其旧名称klaatu来访问实例:
println!("{} - {}", klaatu.planet, klaatu.n_tentacles);
然后,编译器会抛出这个错误信息:error: cannot borrow 'klaatu.planet' as immutable because 'klaatu' is also borrowed as mutable。
当一个资源被移动或借用时,原始所有者将无法再使用它。这防止了被称为悬垂指针的内存问题,即使用指向无效内存位置的指针。
但这里有一个启示:如果我们通过kl2将其借用隔离在其自己的代码块中,如下所示:
// see code in Chapter 6/code/ownership2.rs
fn main() {
let mut klaatu = Alien{ planet: "Venus".to_string(), n_tentacles: 15 };
{
let kl2 = &mut klaatu;
kl2).n_tentacles = 14;
println!("{} - {}", kl2.planet, kl2.n_tentacles);
// prints: Venus - 14
}
}
前面的问题已经消失了!在代码块之后,我们现在可以例如这样做:
println!("{} - {}", klaatu.planet, klaatu.n_tentacles); klaatu.planet = "Pluto".to_string();
println!("{} - {}", klaatu.planet, klaatu.n_tentacles);
这会打印:
Venus – 10
Pluto – 10.
为什么会发生这种情况?因为当kl2被绑定在代码块中,并且该代码块的}闭合后,它的生命周期就结束了。借用结束(借用必须在某时结束)并且klaatu重新获得了全部所有权,因此拥有了更改的权利。当编译器检测到原始所有者klaatu的生命周期最终结束时,结构体实例所占用的内存会自动释放。
事实上,这是 Rust 中的一个通用规则;每当一个对象超出作用域并且不再有所有者时,它的析构函数会自动被调用,它所拥有的资源会被释放,这样就不会有任何内存(或其他资源)泄漏。换句话说,Rust 遵循资源获取即初始化(RAII)规则。更多信息,请访问en.wikipedia.org/wiki/Resource_Acquisition_Is_Initialization。
正如我们在引用部分所实验的,一个资源可以被不可变地借用多次,但在它被不可变地借用期间,原始数据不能被可变地借用。
移动资源(并转移所有权)的另一种方式是将它作为参数传递给一个函数;在下面的练习中尝试一下:
-
检查
kl2不是可变引用的情况下的情况(let kl2 = &klaatu;)。你能通过kl2更改实例吗?你能通过klaatu更改实例吗?用你所知道的关于所有权和借用的知识解释错误(参考Chapter 6/exercises/ownership3.rs)。 -
如果我们在定义
let kl2 = &klaatu;绑定之前做let klaatuc = klaatu;会发生什么? -
检查你是否可以通过从一个不可变所有者移动到一个可变所有者来更改资源的可变性。
-
对于我们的
Alien结构体,编写一个grow_a_tentacle方法,该方法通过一个增加触手的数量(参看Chapter 6/exercises/grow_a_tentacle.rs)。
盒子
Rust 中的另一种指针类型称为盒子指针,Box<T>,它可以定义一个泛型 T 类型的值。盒子是一个不可复制的值。这种指针类型用于在堆上分配对象。例如,在这里我们通过以下代码在堆上分配一个 Alien 值:
// see code in Chapter 6/code/boxes1.rs
let mut a1 = Box::new(Alien{ planet: "Mars".to_string(), n_tentacles: 4 });
println!("{}", a1.n_tentacles); // 4
a1 变量是这个可能被读取和写入的内存资源的唯一拥有者。
我们可以引用由盒子指针指向的值,如果原始盒子和这个新的引用都是可变的,我们可以通过这个引用来更改对象:
let a2 = &mut a1;
println!("{}", a2.planet ); // Mars
a2.n_tentacles = 5;
在进行这样的借用之后,通常的拥有权规则如前所述仍然适用,因为 a1 已经无法访问,甚至无法读取:
// error: cannot borrow `a1.n_tentacles` as immutable because `a1` is also borrowed as mutable
// println!("{}", a1.n_tentacles); // is error!
// error: cannot assign to `a1.planet` because it is borrowed
a1.planet = "Pluto".to_string(); // is error!
我们也可以使用这种机制将简单值放在堆上,如下所示:
let n = Box::new(42);
总是如此,n 默认指向一个不可变值,任何尝试通过以下方式更改此值的尝试:
*n = 67;
会导致错误:无法分配给不可变的 'Box' 内容 '*n'。
另一个引用也可以指向解引用的 Box 值:
let q = &*n;
println!("{}", q); // 42
在以下示例中,我们再次看到由 n 指向的盒子值,但现在值的拥有权已经给了可变指针 m:
// see code in Chapter 6/code/boxes2.rs
let n = Box::new(42);
let mut m = n;
*m = 67;
// println!("{}", n); // error: use of moved value: `n`
println!("{}", m); // 67
通过解引用 m 并将新值赋给 m,这个值被输入到原本由 n 指向的内存位置。当然,n 不能再使用了;我们得到错误信息:use of moved value: 'n',因为 n 已不再是该值的拥有者。
在以下示例中,拥有权已经从 a1 移动到 a2:
let mut a1 = Box::new(Alien{ planet: "Mars".to_string(), n_tentacles: 4 });
let a2 = a1;
println!("{}", a2.n_tentacles); // 4
这里没有复制任何数据,只是复制了结构值地址。移动后,a1 无法再用来访问数据,a2 负责释放内存。
如果将 a2 作为参数传递给函数,如以下代码片段中的 use_alien 函数,a2 也会放弃拥有权,然后该拥有权被传递给函数:
use_alien(a2);
// Following line gives the error: use of moved value: `a2.n_tentacles`
// println!("{}", a2.n_tentacles);
} // end of main() function
fn use_alien(a: Box<Alien>) {
println!("An alien from planet {} is freed after the closing brace", a.planet);
}
这会打印出:来自火星的外星人被释放了。
事实上,当 use_alien() 执行完毕后,该值的内存分配被释放。然而,通常,你必须始终让你的函数接受一个简单的引用作为参数(类似于前面解释的 square 函数),而不是接受 Box 类型的参数。我们可以通过以下方式调用 use_alien2 函数来改进我们的示例:
fn use_alien2(a: &Alien) {
println!("An alien from planet {} is freed", a.planet);
}
并且通过以下方式调用它:use_alien2(&*a2);。
有时,你的程序可能需要操作一个递归数据结构,该结构指向自身,如下面的 struct 所示:
struct Recurs {
list: Vec<u8>,
rec_list: Option<Box<Recurs>>
}
这代表了一个字节列表的列表。rec_list函数要么是一个包含指向另一个列表的Box指针的Some<Box<Recurs>>函数,要么是一个None值,这意味着列表的列表在这里结束。由于这个列表(及其大小)的数量只在运行时才知道,因此这些结构必须始终作为Box类型构造。对于其他用例,你必须优先选择引用而不是盒子。
引用计数
有时候,你需要同时引用一个不可变值;这也被称为共享所有权。Box<T>在这里帮不上忙,因为这个类型按照定义只有一个所有者。为此,Rust 提供了通用的引用计数盒,Rc<T>,其中多个引用可以共享相同的资源。std::rc模块提供了一种在不同Rc指针之间共享相同值所有权的方法;只要至少有一个指针引用它,该值就会保持活跃。
在以下示例中,我们有拥有多个触手的异形。每个Tentacle必须表明它属于哪个Alien;除此之外,它还有其他属性(例如毒性的程度),因此我们也将其定义为结构体。这个尝试的第一步可能是以下代码,然而它无法编译(来自Chapter 6/code/refcount_not_good.rs):
struct Alien {
name: String,
n_tentacles: u8
}
struct Tentacle {
poison: u8,
owner: Alien
}
fn main() {
let dhark = Alien { name: "Dharkalen".to_string(), n_tentacles: 7 };
// defining dhark's tentacles:
for i in 1u8..dhark.n_tentacles {
Tentacle { poison: i * 3, owner: dhark }; // <- error!
}
}
编译器在 for 循环的行给出了以下错误:error: use of moved value 'dhark' - note: 'dhark' moved here because it has type 'Alien', which is non-copyable。
当它被定义时,每个Alien Tentacle似乎都试图复制一个Alien实例作为其所有者,这是没有意义且不被允许的。
正确的版本在Tentacle结构体中定义所有者为Rc<Alien>类型:
// see code in Chapter 6/code/refcount.rs
use std::rc::Rc;
#[derive(Debug)]
struct Alien {
name: String,
n_tentacles: u8
}
#[derive(Debug)]
struct Tentacle {
poison: u8,
owner: Rc<Alien>
}
fn main() {
let dhark = Alien { name: "Dharkalen".to_string(), no_tentacles: 7 };
let dhark_master = Rc::new(dhark);
for i in 1u8..dhark_master.n_tentacles {
let t = Tentacle { poison: i * 3, owner: dhark_master.clone() };
println!("{:?}", t);
}
}
这会打印出以下内容:
Tentacle { poison: 3, owner: Alien { name: "Dharkalen", n_tentacles: 7 } }
Tentacle { poison: 6, owner: Alien { name: "Dharkalen", n_tentacles: 7 } }
…
Tentacle { poison: 18, owner: Alien { name: "Dharkalen", n_tentacles: 7 } }
我们使用Rc::new(dhark)将Alien实例包裹在Rc<T>类型中。对这个Rc对象应用clone()方法为每个Tentacle提供对Alien对象的独立引用。注意,这里的clone()复制的是Rc指针,而不是Alien结构体。我们还使用#[derive(Debug)]注解结构体,这样我们就可以通过println!("{:?}", t);打印出它们的实例。
如果我们想在Rc类型内部实现可变性,我们必须要么使用实现了*Copy*特质的值的*Cell*指针,要么使用*RefCell*指针。这两个智能指针都位于std:cell模块中。
然而,Rc指针类型只能用于单个执行线程中。如果你需要在多个线程之间共享所有权,你需要使用Arc<T>指针(简称原子引用计数盒),它是Rc的线程安全版本(参考第八章的原子引用计数部分,并发与并行)。
指针概述
在下表中,我们总结了 Rust 中使用的不同指针。T 代表一个泛型类型。我们尚未遇到 Arc、*const 和 *mut 指针,但为了完整性,它们也被包括在内。
| 指针 | 指针名称 | 描述 |
|---|---|---|
&T |
引用 | 这允许一个或多个引用读取 T。 |
&mut T |
可变引用 | 这允许对 T 进行单个引用的读取和写入。 |
Box<T> |
Box | 这是一个具有单个所有者且可以读取和写入 T 的堆分配 T。 |
Rc<T> |
Rc 指针 | 这是一个具有多个读者的堆分配 T。 |
Arc<T> |
Arc 指针 | 这类似于 Rc<T>,但允许线程之间安全地可变共享(参考第八章,并发与并行)。 |
*const T |
原始指针 | 这允许对 T 进行不安全地读取访问(参考第九章,边界编程)。 |
*mut T |
可变原始指针 | 这允许对 T 进行不安全地读取和写入访问(参考第九章,边界编程)。 |
摘要
在本章中,我们学习了 Rust 编译器的智慧,这体现在所有权、移动值和借用原则中。我们看到了 Rust 所倡导的不同指针:引用、boxed 和引用计数器。现在我们已经掌握了这一切是如何协同工作的,我们将以更好的方式理解编译器可能会抛出的错误、警告和信息。
在下一章中,我们将展示代码中更大的代码组织单元,如模块和 crate,以及我们如何编写宏来减少代码的重复性。
第七章。组织和宏
我们从讨论 Rust 中的大规模代码组织结构开始这一章,即模块和 crate。我们将探讨以下主题:
-
构建 crate
-
定义模块
-
项目的可见性
-
导入模块和文件层次结构
-
导入外部 crate
-
导出公共接口
-
将外部 crate 添加到项目中
-
测试模块
我们还将讨论如何构建宏以生成代码并节省时间和精力,特别是在以下主题中:
-
使用宏的原因
-
开发宏
-
使用 crate 中的宏
模块和 crate
到目前为止,我们只看了我们的代码适合一个文件的情况。然而,当项目发展时,我们希望将代码拆分到几个文件中,例如,如果我们把描述特定功能的全部数据结构和方法放在同一个文件中,主代码文件将如何调用其他文件中的这些函数?
此外,当我们开始在多个文件中使用不同的函数时,有时我们会希望为两个不同的函数使用相同的名称。我们如何正确区分这些函数?我们如何使某些函数可以在任何地方调用,而其他函数则不行?为此,我们需要其他语言中称为命名空间和访问修饰符的东西;在 Rust 中,这是通过模块系统实现的。
构建 crate
在构建 crate 的最高级别,有 crate。Rust 分发版包含许多 crate,例如我们经常使用的标准库中的 std crate。其他内置 crate 包括具有处理字符串、向量、列表和键值映射功能的 collections crate,以及具有单元测试和微基准测试功能的 test crate。
在其他语言中,crate 等同于包或库。它也是编译的单位;rustc 一次只编译一个 crate。这意味着什么?当我们的项目包含一个包含 main() 函数的代码文件时,那么我们的项目就是一个可执行程序(也称为二进制文件),它从 main() 开始执行。例如,如果我们编译 structs.rs 为 rustc structs.rs,在 Windows 上(以及其他操作系统的等效格式)将生成一个可以独立执行的 .exe 文件 structs.exe。当你调用 rustc 时,这是标准的行为。当使用 Cargo(参考第一章,从 Rust 开始),在创建项目时,我们必须使用 --bin 标志来指定我们想要一个二进制项目:cargo new projname --bin。
然而,通常您的意图是编写一个可以从其他项目调用的项目代码,即所谓的共享库(在 Windows 中是.dll文件,在 Linux 中是.so文件,在 Mac OS X 中是.dylib文件。)在这种情况下,您的代码将只包含处理这些数据结构及其函数。然后,您必须使用带有lib选项的--crate-type标志显式地通知编译器:rustc --crate-type=lib structs.rs。
生成的文件大小远小于原来的文件,称为libstructs.rlib;后缀现在是.rlib(对于 Rust 库)并在文件名前加上lib。如果您希望集合具有另一个名称,例如mycrate,则可以使用以下--crate-name标志:
rustc --crate-type=lib --crate-name=mycrate structs.rs
这将创建一个libmycrate.rlib作为输出文件。使用rustc标志的替代方法是,将此信息作为属性放在代码文件顶部,如下所示:
// from Chapter 7/code/structs.rs
#![crate_type = "lib"]
#![crate_name = "mycrate"]
crate_type属性可以取bin、lib、rlib、dylib或staticlib值,具体取决于您是否想要一个可执行的二进制文件或某种类型的库,该库是动态链接或静态链接的。(一般来说,当attr属性应用于整个集合时,代码中使用的语法是#![crate_attr]。)
在任何应用程序中使用的每个库都是一个独立的集合。在任何情况下,你需要一个可执行(二进制)集合,它使用库集合。
Cargo 的职责是处理集合(有关 Cargo 的更多信息,请参阅第一章的使用 Cargo部分,从 Rust 开始);它默认创建一个库项目。您可以从crates.io的集合存储库中将其他集合安装到您的项目中;在本章的将外部集合添加到项目部分,我们将看到如何做到这一点。
定义一个模块
集合是编译后的实体,它们被分发到机器上执行。一个集合的所有代码都包含在一个隐式的根模块中。然后,开发者可以将这些代码分割成称为模块的代码单元,实际上,这些模块在根模块下形成了一个子模块的层次结构。这样,我们代码的组织结构可以得到极大的改善。一个明显的模块候选者是测试代码——我们将在测试模块部分使用它。
模块也可以定义在其他模块内部,称为嵌套模块。模块不会单独编译;只有集合会被编译。在编译开始之前,所有模块的代码都会插入到集合的源文件中。
在前面的章节中,我们使用了内置的模块,例如来自std集合的io、str和vec。std集合包含了许多在真实项目中使用的模块和函数;最常见的类型、特性、函数和宏(如println!)都在导入模块中声明。
一个模块通常包含一系列代码项,如特性、结构体、方法、其他函数,甚至嵌套模块。模块的名称定义了它包含的所有对象的命名空间。我们使用mod关键字和一个小写名称(例如game1)来定义一个模块,如下所示:
mod game1 {
// all of the module's code items go in here
}
与 Java 类似,每个文件都是一个模块,对于每个代码文件,编译器都会定义一个隐式模块,即使它不包含mod关键字。正如我们将在导入模块和文件层次结构部分中看到的那样,这样的代码文件可以使用mod文件名导入到当前代码文件中。假设game1是一个包含func2函数的模块的名称。如果你想在模块外部的代码中使用这个函数,你应该将其地址指定为game1::func2。然而,这是否可行将取决于func2的可见性。
项目的可见性
模块中的项目默认情况下只在模块内部可见;它们对模块是私有的。如果你想让一个项目可以从模块外部的代码中调用,你必须通过在项目前加上pub(代表公共)来显式地表示这一点。在以下代码中,尝试调用func1()是不允许的,编译器会报错:error: 函数func1是私有的:
// from Chapter 7/code/modules.rs
mod game1 {
// all of the module's code items go in here
fn func1() {
println!("Am I visible?");
}
pub fn func2() {
println!("You called func2 in game1!");
}
}
fn main() {
// game1::func1(); // <- error!
game1::func2();
}
然而,如果你调用func2(),它将没有任何问题,因为它被声明为公共的,并且会打印出:你在 game1 中调用了 func2!
在嵌套模块中的函数只有在嵌套模块本身被声明为公共的情况下才能被调用,如下代码片段所示:
mod game1 {
// other code
pub mod subgame1 {
pub fn subfunc1() {
println!("You called subfunc1 in subgame1!");
}
}
}
fn main() {
// other code
game1::subgame1::subfunc1();
}
它会打印出:你在 subgame1 中调用了 subfunc1!
在模块中调用的函数必须用其模块名称作为前缀。这可以将其与具有相同名称的另一个函数区分开来,以避免名称冲突。
当从定义它的模块外部访问结构体时,它只有在声明为pub时才可见。此外,它的字段默认是私有的,所以你必须显式声明为pub的字段,以便它们可以在模块外部可见。这是传统面向对象语言中的封装属性(也称为信息隐藏)。在以下示例中,Magician结构体的name和age字段属于公共接口,但power字段不属于:
pub struct Magician {
pub name: String,
pub age: i32,
power: i32
}
因此,这个语句:
let mag1 = game1::Magician { name: "Gandalf".to_string(), age: 725, power: 98};
这会导致编译器错误:field 'power' of struct 'game1::Magician' is private
执行以下练习:
这是否意味着我们不能从具有私有字段的struct中创建实例?试着想一个绕过这个问题的方法。(作为一个提示,想想类似于构造函数的new函数;参考Chapter 7/code/priv_struct.rs。)
导入模块和文件层次结构
在use game1::func2;中使用use关键字从game1模块导入一个func2函数,以便可以简单地使用其名称func2()来调用它。你甚至可以用use game1::func2给它一个更短的名字,比如gf2;这样就可以调用为gf2()。
当game1模块包含两个(或更多)我们想要导入的函数,如func2和func3时,可以使用use game1::{func2, func3};来完成。
如果你想要导入game1模块的所有(公共)函数,你可以使用*:use game1::*;。
然而,在测试模块之外,使用这样的全局导入并不是最佳实践。主要原因是一个全局导入使得难以看到名称的绑定位置。此外,它们与未来的版本不兼容,因为新的上游导出可能与现有名称冲突。
在模块内部,self::和super::可以添加到类似于game1::func2的路径前面,以区分当前模块本身中的函数和模块外部父作用域中的函数。use语句最好写在代码文件的最顶部,这样它们就可以作用于整个代码。
在前面的例子中,模块是在主源文件本身中定义的;在大多数情况下,模块将在另一个源文件中定义。那么,我们如何导入这样的模块呢?在 Rust 中,我们可以通过在代码顶部(但在任何use语句之后)声明模块来将整个模块的源文件内容插入到当前文件中,如下所示:mod modul1;,这个声明可以可选地前面加上pub。这个语句将在当前源文件相同的文件夹中查找modul1.rs文件,并在当前代码中的modul1模块内导入其代码。如果没有找到modul1.rs文件,它将在modul1子文件夹中查找mod.rs文件,并插入其代码。
这是一个简单的import_modules.rs示例,其中包含以下代码:
// from Chapter 7/code/import_modules.rs
mod modul1;
mod modul2;
fn main() {
modul1::func1();
modul2::func1();
}
在modul1子文件夹中,我们有一个包含以下代码片段的mod.rs文件:
pub fn func1() {
println!("called func1 from modul1");
}
与import_modules.rs文件相同的文件夹中的modul2.rs文件包含以下代码:
pub fn func1() {
println!("called func1 from modul2");
}
注意
注意,这些模块的源文件不再包含mod声明,因为它们已经在import_modules.rs中声明过了。
执行import_modules会打印出以下输出:called func1 from modul1 and called func1 from modul2。
如果你简单地在main()中调用func1()会发生什么?现在,编译器不知道应该调用哪个func1,是从modul1还是从modul2,结果出现错误信息:unresolved name 'func1'。然而,如果我们添加use modul1::func1然后调用func1(),它将正常工作,因为歧义得到了解决。
导入外部 crate
在第五章的特质部分中,使用高阶函数和参数化泛化代码,我们为Alien、Zombie和Predator角色实现了Monster特质的traits.rs结构体。代码文件包含一个main()函数以使其可执行。我们现在将这个代码(不包括main()部分)整合到一个名为 monsters 的库项目中,看看我们如何调用这段代码。
使用 cargo new monsters 创建项目,并在 monsters/src/lib.rs 文件中创建一个带有 template 代码的文件夹结构:
#[test]
fn it_works() {
}
删除此代码,并用来自 traits.rs 的代码替换它,但省略 main() 函数。此外,添加一个简单的 print_from_monsters() 函数来测试是否可以从库中调用它:
// from Chapter 7/code/monsters/src/lib.rs:
fn print_from_monsters() {
println!("Printing from crate monsters!");
}
然后,使用 cargo build 编译库,在 target/debug 文件夹中生成一个 libmonsters-hash.rlib 库文件(其中 hash 是类似于 547968b7c0a4d435 的随机字符串)。
现在,我们在 src 文件夹中创建一个 main.rs 文件,以创建一个可执行文件,该文件可以调用我们的 monsters 库,并将来自 traits.rs 的原始 main() 代码复制到其中,并添加对 print_from_monsters() 的调用:
// from Chapter 7/code/monsters/src/main.rs:
fn main() {
print_from_monsters();
let zmb1 = Zombie {health: 75, damage: 15};
println!("Oh no, I hear: {}", zmb1.noise());
zmb1.attack();
println!("{:?}", zmb1);
}
注意
这是一个常见的设计模式——一个包含可执行程序的库项目,可以用来演示或测试库。
cargo build 函数现在在没有问题的情况下会编译两个项目。然而,代码将无法编译,编译器会给出错误信息:未解析的名称 'print_from_monsters',显然函数的代码没有找到。
我们必须做的第一件事是让库代码对我们程序可用,这可以通过在开头放置以下语句来实现:
extern crate monsters;
这个语句将导入包含在 crate monsters 中的所有(公共)项目,并在具有相同名称的模块下导入。然而,这还不够;我们必须还表明 print_from_monsters 函数可以在 monsters 模块中找到。实际上,monsters crate 创建了一个具有相同名称的隐式模块。因此,我们必须按照以下方式调用我们的函数:
monsters::print_from_monsters();
现在,我们得到错误:函数 'print_from_monsters' 是私有的 信息,这告诉我们函数已被找到,但无法访问。这很容易修复。在 项目的可见性 部分,我们看到了如何解决这个问题;我们必须在函数头前加上 pub,如下所示:
pub fn print_from_monsters() { … }
现在,我们代码的这一部分可以正常工作!打开终端,进入 (cd) 到 target/debug 文件夹,并启动 monsters 可执行文件。这将输出 从 crate monsters 打印!。
你会看到 extern crate abc(其中 abc 是一个 crate 名称)在代码中经常被使用,但你永远不会看到 extern crate std; 为什么会这样?原因是 std 在每个其他 crate 中默认导入。出于同样的原因,预置模块的内容在默认情况下导入到每个模块中。
导出公共接口
编译器向我们抛出了以下错误:错误:Zombie 没有命名结构。显然,Zombie 结构体的代码没有找到。由于这个结构体也位于 monsters 模块中,所以修复这个问题的解决方案很简单;在 Zombie 前加上 monsters::,如下所示:
let zmb1 = monsters::Zombie {health: 75, damage: 15};
另一个错误:struct 'Zombie' 是私有的,清楚地表明我们必须用 pub 标记 Zombie 结构体,即 pub struct Zombie { … }。
现在,我们将在包含 zmb1.noise() 的行上得到一个错误:error: type 'monsters::Zombie' 不实现任何作用域内名为 'noise' 的方法
伴随的帮助说明向我们解释了我们应该做什么以及为什么应该这样做:``帮助:如果特质在作用域内,则可以调用特质的方法;以下特质已实现但不在作用域内,可能需要添加一个 use:```
帮助:候选 #1:使用 'monsters::Monster'。因此,让我们将其添加到以下代码中:
extern crate monsters;
use monsters::Monster;
我们必须解决的最后一个错误——error: trait 'Monster' is private - source trait is private——发生在 use 行。再次非常合乎逻辑;如果我们想使用一个特质,它必须对公众可见:p ub trait Monster { … }。
现在,如果执行 cargo build,如果成功,输出将如下所示:
Printing from crate monsters!
Oh no, I hear: Aaargh!
The Zombie bites! Your health lowers with 30 damage points.
Zombie { health: 75, damage: 15 }
这使得我们想要在模块中使其可见的(或者说,我们想要导出的)内容必须用 pub 进行标注;它们构成了我们的模块对外界暴露的接口。
将外部 crate 添加到项目中
如何在我们的项目中使用他人编写的库(即,从crates.io提供的众多库中选择)?Cargo 使得这一过程变得非常简单。
假设我们想在怪物项目中同时使用 log 和 mac 库。log 函数是由 Rust 项目开发者提供的简单日志框架,它为我们提供了 info!、warn! 和 trace! 等宏来记录信息消息。mac 函数是一个包含有用宏的惊人集合,由 Jonathan Reem 维护。
要获取这些库,我们需要编辑我们的 Cargo.toml 配置文件,并在尚未存在的情况下添加一个 [dependencies] 部分。在其下方,我们指定我们想要使用的库的版本:
[dependencies]
log = "0.2.5"
mac = "*"
* 字符表示任何版本都可以,并将安装最新版本。
保存文件,在 monsters 文件夹中,执行 cargo build 命令。Cargo 将负责本地安装和编译库:

它还将自动更新 Cargo.lock 文件以注册已安装的库版本,以便后续的项目构建始终使用相同的版本(这里,log v0.3.1 和 mac v0.0.1)。如果您稍后想更新到某个库的最新版本,例如 log 库,请执行 cargo update –p log 或 cargo update 以更新所有库。这将下载带有 * 版本的最新 crate 版本。如果您想要一个更高版本的 crate,请更改其在 Cargo.toml 中的版本号。
通过在代码中导入它们的 crate 来开始使用这些库:
#[macro_use]
extern crate log;
extern crate mac;
#[macro_use] 属性允许使用外部 crate 中定义的宏。(有关更多信息,请参阅下一节)。然后,例如,我们可以像下面这样使用 crate mac 中的 info! 宏:
info!("Gathering information from monster {:?}", zmb1);
测试模块
让我们将这种代码组织应用到包含我们的测试的模块中。在一个更大的项目中,测试代码与应用程序代码分离如下:
-
单元测试被收集在
test模块中 -
集成测试被收集在
tests目录下的lib.rs文件中
让我们通过使用来自第三章的cube函数来做一个具体的例子,使用函数和控制结构,并使用cargo new cube开始其项目。我们必须用以下代码替换src\lib.rs中的代码:
// from Chapter 7/code/cube/src/lib.rs:
#[cfg(test)]
mod test;
pub fn cube(val: u32) -> u32 {
// implementation goes here
val * val * val
}
#[cfg(test)]确保只有当进行测试时才会编译测试模块。在第二行,我们声明我们的test模块,它前面有test属性。这个模块的代码放入同一文件夹中的test.rs文件中:
// from Chapter 7/code/cube/src/test.rs:
use super::*;
#[test]
fn cube_of_2_is_8() {
assert_eq!(cube(2), 8);
}
// other test functions:
// ...
我们需要使用super::*来导入所有需要测试的函数;这里,这是 cube。
集成测试会放入一个位于测试文件夹中的lib.rs文件中:
// from Chapter 7/code/cube/tests/lib.rs:
extern crate cube;
#[test]
fn cube_of_4_is_64() {
assert_eq!(cube::cube(4), 64);
}
// other test functions:
// ...
在这里,我们需要使用extern命令导入cube包,并用其模块名cube(或者也可以使用use cube::cube;)来限定cube函数名。
当我们输入cargo test命令时,测试代码才会被编译和运行,这将给出以下结果:

我们可以看到,我们的两个测试都通过了。输出结果的末尾还显示,如果存在,文档中的测试也会被执行。
宏
宏对你来说并不陌生,因为我们已经使用过它们了。每次我们调用以感叹号(!)结尾的表达式时,我们都是在调用一个内置宏;感叹号符号将其与函数区分开来。到目前为止,在我们的代码中,我们已经使用了println!、assert_eq!、panic!和vec!宏。
我们为什么要使用宏?
宏使得语言或语法扩展变得强大;因此,它们使得元编程成为可能。例如,Rust 有一个regex!宏,允许你在程序中定义正则表达式,这些正则表达式在代码编译时被编译。这样,正则表达式得到验证,它们可以在编译时优化,并且没有运行时开销。
宏可以捕获重复或相似的代码模式,并用其他源代码来替换它们:宏将原始代码扩展成新代码。这种扩展发生在编译的早期阶段,在执行任何静态检查之前,因此生成的代码与原始代码一起编译。从这个意义上说,它们与 Lisp 宏比 C 宏更相似。Rust 宏允许你通过提取函数的公共部分来编写不要重复自己(DRY)的代码。然而,宏比函数处于更高的层次,因为宏允许你在编译时为许多函数生成代码。
Rust 开发者也可以编写自己的宏,用更简单的代码替换重复的代码,从而自动化任务。在光谱的另一端,它甚至可能使编写特定领域的语言成为可能。宏编码遵循一组特定的声明性基于模式的规则。Rust 的宏系统也是卫生的,这意味着宏中使用的变量和宏外部的变量之间不可能发生冲突。每个宏展开都在一个独特的语法上下文中发生,每个变量都带有它在其中引入的语法上下文标签。
宏代码本身比正常的 Rust 代码更难理解,因此编写起来并不容易。然而,你不会每天编写宏;如果宏经过测试,就使用它。宏编写的完整故事深入 Rust 的高级领域,但在接下来的章节中,我们将讨论开发宏的基本技术。
开发宏
命名为 mac1 的宏的基本结构如下所示:
macro_rules! mac1 {
(pattern) => (expansion);
(pattern) => (expansion);
...
}
宏的定义也是通过宏来完成的,即 macro_rules 宏!正如你所见,宏类似于一个 match 块,因为它定义了一个或多个用于模式匹配的规则,并且每个规则都以分号结束。每个规则由 => 符号之前的模式组成(也称为匹配器),在编译期间替换为展开部分,而不是在执行代码时替换。
以下 welcome! 宏不期望任何模式,通过使用 println! 宏扩展为一个打印语句;这很简单,但它展示了宏是如何工作的:
// from Chapter 7/code/macros.rs
macro_rules! welcome {
() => (
println!(""Welcome to the Game!");
)
}
它通过在其名称后添加一个感叹号(!)来调用:
fn main() {
welcome!()
}
这将打印出:欢迎来到游戏!
匹配器可以包含 $arg:frag 形式的表达式:
-
当宏被调用时,
$arg函数将一个arg元变量绑定到一个值。在宏内部使用的变量,如$arg,前面带有$符号,以区分代码中的普通变量。 -
frag函数是一个 片段指定器,可以是expr、item、block、stmt、pat、ty(类型)、ident、path或tt。
(你可以在官方文档中找到有关这些片段含义的更多信息,请参阅doc.rust-lang.org/1.0.0/book。)
在匹配器中出现的任何其他 Rust 文字(标记)都必须完全匹配。例如,以下 mac1 宏:
macro_rules! mac1 {
($arg:expr) => (println!("arg is {}", $arg));
}
当你调用 mac1!(42); 时,它将打印出 arg is 42。mac1 函数将其参数 42 视为一个表达式(expr)并将 arg 绑定到该值。
执行以下练习:
-
编写一个
mac2宏,使其参数乘以三。测试以下参数:5 和 2 + 3。 -
编写一个
mac3宏,它接受一个标识符名称,并用该名称的绑定替换它,绑定值为 42。(作为一个提示,使用$arg:ident而不是$arg:expr;ident用于变量和函数名称。) -
编写一个
mac4宏,当像mac4!("Where am I?");这样调用时,打印出start - Where am I? - end。(请参阅第七章/exercises/macro_ex.rs中的示例代码。)
重复
如果有多个参数怎么办?我们将模式用 $(...)* 包围起来,其中 * 表示零个或多个(而不是 *,你可以使用 +,表示一个或多个)。例如,以下 printall 宏对其每个参数调用 println!,这些参数可以是任意类型,并且由 a 分隔:
macro_rules! printall {
( $( $arg:expr ), * ) => ( {$( print!("{} / ", $arg) ); *} );
}
当用 printall!("hello", 42, 3.14); 调用时,它将打印出:hello / 42 / 3.14 /。
在示例中,每个参数(由逗号分隔)被替换为相应的 print! 调用,这些调用由 / 分隔。注意,在右侧,我们必须通过将它们括在 {} 中来创建结果打印语句的代码块。
创建一个新函数
这里是一个在编译时创建新函数的 create_fn 宏:
macro_rules! create_fn {
($fname:ident) => (
fn $fname() {
println!("Called the function {:?}()", stringify!($fname))
}
)
}
stringify! 宏只是将其参数转换成字符串。现在,我们可以用 create_fn!(fn1); 调用这个宏。这个语句不在 main() 或另一个函数内部;它在编译期间被转换成函数定义。然后,对 fn1() 函数的正常调用将调用它,这里打印 Called the function "fn1"()。
在下面的 massert 宏中,我们模仿了 assert! 宏的行为,当其表达式参数为真时什么也不做,但当其为假时引发恐慌:
macro_rules! massert {
($arg:expr) => (
if $arg {}
else { panic!("Assertion failed!"); }
);
}
例如,massert!(1 == 42); 将打印出 thread '<main>' panicked at 'Assertion failed!'。
在以下语句中,我们测试 v 向量是否包含某些元素:
let v = [10, 40, 30];
massert!(v.contains(&30));
massert!(!v.contains(&50));
unless 宏模仿了一个 unless 语句,其中如果 arg 条件不为真,则执行分支。例如:
unless!(v.contains(&25), println!("v does not contain 25"));
这应该会打印出 v does not contain 25,因为条件不成立。
这也是一个单行宏:
macro_rules! unless {
($arg:expr, $branch:expr) => ( if !$arg { $branch }; );
}
最后一个例子结合了我们迄今为止看到的技术。在 第三章 的 属性 - 测试 部分,使用函数和控制结构,我们看到了如何使用 #[test] 属性创建测试函数。让我们创建一个 test_eq 宏,当它被调用时,会生成一个测试函数:
test_eq!(seven_times_six_is_forty_two, 7 * 6, 42);
测试函数如下:
#[test]
fn seven_times_six_is_forty_two() {
assert_eq!(7 * 6, 42);
}
我们还想要一个失败的测试:
test_eq!(seven_times_six_is_not_forty_three, 7 * 6, 43);
test_eq 的第一个参数是测试的名称,第二个和第三个参数是要比较相等性的值,所以通常的格式是:test_eq!(name, left, right);。
在这里,name 是一个标识符;left 和 right 是表达式。就像 create_fn 调用一样,test_eq! 调用是在函数外部编写的。
现在,我们可以这样组合我们的宏:
macro_rules! test_eq {
($name:ident, $left:expr, $right:expr) => {
#[test]
fn $name() {
assert_eq!($left, $right);
}
}
}
你可以通过调用 rustc --test macros.rs 来创建测试运行器。
当宏可执行文件运行时,它将打印出:
running 2 tests
test seven_times_six_is_forty_two ... ok
test seven_times_six_is_not_forty_three ... FAILED
宏也可以是递归的,并在展开分支中调用自身。这对于处理树状结构输入很有用,例如在解析 HTML 代码时。
使用 crates 中的宏
正如我们在“将外部 crates 添加到项目”部分的结尾所展示的,应该通过在extern crate abc前加上#[macro_use]属性来加载外部 crate 中的所有宏。如果你只需要mac1和mac2宏,你可以这样写:
#[macro_use(mac1, mac2)]
extern crate abc;
如果没有该属性,则不会从abc加载任何宏。此外,在abc模块内部,只有使用#[macro_export]属性定义的宏可以在另一个模块中加载。为了区分不同模块中具有相同名称的宏,请在宏中使用$crate变量。在从abccrate 导入的宏的代码中,特殊的$crate宏变量将展开为::abc。
摘要
在本章中,我们学习了如何将模块结构化到 crates 中,使我们的代码更加灵活和模块化。现在,你也知道了编写宏的基本规则,以使代码更加紧凑且重复性更低。
在下一章中,我们将探讨 Rust 在代码并发和并行执行方面的强大功能,以及 Rust 如何在这一领域保持内存安全。
第八章。并发与并行
作为一种现代的系统级编程语言,Rust 必须有一个很好的故事来描述如何在多个处理器上同时并发和并行执行代码。确实如此;Rust 提供了一系列的并发和并行工具。它的类型系统足够强大,可以编写具有不同于以往任何东西的性质的并发原语。特别是,它可以编码一系列内存安全的并行抽象,同时保证数据竞争自由,而不使用垃圾回收器。这令人震惊,因为没有任何其他语言能够做到这一点。所有这些功能都不是内置于语言本身中的,而是由库提供的,因此可以始终构建改进或新版本。开发者应该选择适合当前任务的工具,或者他们可以改进或开发新的工具。
在本章中,我们将讨论以下主题:
-
并发和线程
-
共享可变状态
-
通过通道进行通信
-
同步和异步通信
并发和线程
当多个计算同时执行并且可能相互交互时,系统是并发的。只有当这些计算在不同的核心或处理器上执行时,它们才能并行(即同时)运行。
一个正在执行的 Rust 程序由一组原生操作系统(OS)线程组成;操作系统也负责它们的调度。Rust 中的计算单元称为thread,这是一个在std::thread模块中定义的类型。每个线程都有自己的栈和局部状态。
到目前为止,我们的 Rust 程序只有一个线程,即main线程,对应于main()函数的执行。然而,当需要时,Rust 程序可以创建很多线程来同时工作。每个线程(不仅仅是main())都可以作为父线程并生成任意数量的子线程。
可以在数据上执行以下操作:
-
它可以在线程之间共享(参考通过原子类型进行共享可变状态部分)
-
它可以在线程之间传递(参考通过通道进行通信部分)
创建线程
可以通过创建一个thread来创建线程;这会创建一个独立的、分离的子线程,它通常可以比其父线程存活得更久。这在下述代码片段中得到了演示:
// code from Chapter 8/code/thread_spawn.rs:
use std::thread;
fn main() {
thread::spawn(move || {
println!("Hello from the goblin in the spawned thread!");
});
}
spawn参数是一个闭包(这里没有参数,所以是||),它被安排独立于父线程(这里,这是main())执行。请注意,这是一个移动闭包,它获取上下文中的变量的所有权。我们这里的闭包是一个简单的打印语句,但在实际示例中,这可以被替换为一个重或耗时的操作。
然而,当我们执行这段代码时,通常不会看到任何输出;这是为什么?结果是main()(从线程的角度来看)是一个糟糕的父线程,它不会等待其子线程正确结束;当main()的结束关闭程序时,它会终止其他仍在运行的线程。如果我们让main()在终止前暂停一下,那么派生线程的输出就会变得可见。这可以通过thread::sleep_ms方法实现,该方法接受一个无符号 32 位整数(以毫秒为单位):
fn main() {
thread::spawn(move || { … });
thread::sleep_ms(50);
}
这现在会打印出:Hello from the goblin in the spawned thread!。
通常情况下,这个暂停期是不需要的;子线程可以比父线程存活得更久,即使父线程已经停止,子线程也可以继续执行。
然而,在这种情况下,更好的做法是将spawn返回的 join handle 捕获到变量中。在handle上调用join()方法将阻塞父线程,并使其等待子线程完成执行。它返回一个Result实例;unwrap()将从Ok中获取值并返回子线程的结果(在这种情况下因为是一个打印语句,所以是())或者在Err情况下 panic。
fn main() {
let handle = thread::spawn(move || {
println!("Hello from the goblin in the spawned thread!");
});
// do other work in the meantime
let output = handle.join().unwrap();
println!("{:?}", output); // ()
}
如果在子线程执行期间没有其他工作要做,我们也可以这样写:
thread::spawn(move || {
// work done in child thread
}).join();
在这种情况下,我们正在同步等待子线程完成,所以没有很好的理由去启动一个新的线程。
启动一定数量的线程
每个线程都有自己的堆栈和局部状态,默认情况下,除非是不可变数据,否则线程之间不共享任何数据。生成线程是一个非常轻量级的过程,因为启动成千上万的线程只需要几秒钟。以下程序就是这样做的,并打印出从 0 到 9,999 的数字:
// code from Chapter 8/code/many_threads.rs:
use std::thread;
static NTHREADS: i32 = 10000;
fn main() {
for i in 0..NTHREADS {
let _ = thread::spawn(move || {
println!("this is thread number {}", i)
});
}
}
由于数字是在独立的线程中打印的,所以输出中的顺序是不保留的;例如,它可能以以下内容开始:
this is thread number 1
this is thread number 3
this is thread number 4
this is thread number 2
this is thread number 6
this is thread number 5
this is thread number 0
…
常常出现的一个问题是:我需要派生多少个线程?基本规则是,CPU 密集型任务的数量与 CPU 核心的数量相同。这个数字可以通过 Rust 中的num_cpus crate 来检索。让我们用cargo new many_threads --bin创建一个新的项目:
-
将 crate 依赖添加到
Cargo.toml中:[dependencies] num_cpus = "*" -
然后,将
main.rs更改为以下代码:extern crate num_cpus; fn main() { let ncpus = num_cpus::get(); println!("The number of cpus in this machine is: {}", ncpus); } -
在
many_threads文件夹内,执行 cargo build 来安装 crate 并编译代码。使用 cargo run 执行程序会得到以下输出(取决于计算机):The number of cpus in this machine is: 8。
现在,你可以在一个池中启动这个(或任何其他)数量的线程。这个功能是由threadpool crate 提供的,我们可以通过在Cargo.toml依赖中添加threadpool = "*"并执行 cargo build 来获取它。将以下代码添加到文件的开头:
extern crate threadpool;
use std::thread;
use threadpool::ThreadPool;
然后,将以下代码添加到main()函数中:
let pool = ThreadPool::new(ncpus);
for i in 0..ncpus {
pool.execute(move || {
println!("this is thread number {}", i)
});
}
thread::sleep_ms(50);
当执行上述代码时,会得到以下输出:
this is thread number 0
this is thread number 5
this is thread number 7
this is thread number 3
this is thread number 4
this is thread number 1
this is thread number 6
this is thread number 2
线程池用于在固定的一组并行工作线程上运行多个作业;它创建指定数量的工作线程,并在任何线程崩溃时补充线程池。
崩溃的线程
当其中一个生成的线程进入崩溃状态时会发生什么?由于线程彼此隔离,这不会造成任何问题;崩溃的线程在释放其资源后会崩溃,但父线程不受影响。事实上,父线程可以像以下这样测试 spawn 的is_err返回值:
// code from Chapter 8/code/panic_thread.rs:
use std::thread;
fn main() {
let result = thread::spawn(move || {
panic!("I have fallen into an unrecoverable trap!");
}).join();
if result.is_err() {
println!("This child has panicked");
}
}
前面的代码输出如下:
thread '<unnamed>' panicked at 'I' have fallen into an unrecoverable trap!'
This child has panicked
否则,换句话说,线程是失败隔离的单位。
线程安全性
如果允许不同的线程在相同的可变数据上工作,即所谓的共享内存,那么使用线程的传统编程非常难以正确实现。当两个或更多线程同时更改数据时,由于线程调度的不可预测性,可能会发生数据损坏(也称为数据竞态)。一般来说,当其内容不会被不同线程的执行所损坏时,数据(或类型)被认为是线程安全的。其他语言没有提供这样的帮助,但 Rust 编译器简单地禁止出现非线程安全的情况。贯穿 Rust 以防止内存安全错误的相同所有权策略也使你能够编写安全的并发程序。考虑以下程序:
// code from Chapter 8/code/not_shared.rs:
use std::thread;
fn main() {
let mut health = 12;
for i in 2..5 {
thread::spawn(move || {
health *= i;
});
}
thread::sleep_ms(2000);
println!("{}", health); // 12
}
我们初始的健康值是 12,但有 3 个仙女可以将我们的健康值翻倍、三倍和四倍。我们让她们各自在不同的线程中这样做,线程完成后,我们期望健康值达到 288(相当于 12 * 2 * 3 * 4)。然而,在她们神奇的行动之后,我们的健康值仍然保持在 12,即使我们等待足够长的时间以确保线程完成。显然,三个线程是在我们的变量副本上工作,而不是在变量本身上。Rust 不允许健康值变量在线程间共享,以防止数据损坏。在下一节中,我们将探讨如何使用线程间共享的可变变量。
共享可变状态
那么,我们如何让not_shared.rs程序给出正确的结果?Rust 提供了工具,即所谓的原子类型,来自std::sync::atomic子模块,以安全地处理共享可变状态。为了共享数据,你需要将数据包裹在一些同步原语中,例如Arc、Mutex、RwLock、AtomicUSize等等。
基本上,这里使用的是锁定原理,这与操作系统和数据库系统中使用的原理类似——将资源的独占访问权赋予已获得锁的线程(该锁也称为mutex,来源于互斥)。一次只能由一个线程获得锁。这样,两个线程就不能同时更改这个资源,因此不会发生数据竞争;当需要时,强制执行锁定原子性。当获得锁的线程完成其工作后,锁被移除,然后另一个线程可以与数据一起工作。在 Rust 中,这是通过std::sync模块中的泛型Mutex<T>类型来实现的;sync来源于 synchronize,这正是我们想要对线程所做的事情。Mutex确保一次只有一个线程可以更改我们数据的内部内容。我们必须通过以下方式创建此类型的实例,将我们的数据包装起来:
// code from Chapter 8/code/thread_safe.rs:
let data = Mutex::new(health);
现在,在for循环中,在我们创建新线程后立即,我们对health对象施加锁:
for i in 2..5 {
thread::spawn(move || {
let mut health = data.lock().unwrap();
// do other things
}
}
对lock()的调用将返回Mutex内部值的引用,并阻止对lock()的任何其他调用,直到该引用超出作用域,这将在线程关闭结束时发生。然后,线程完成其工作,锁自动移除。然而,我们仍然得到一个错误:捕获已移动值:'data'消息。这意味着数据不能多次移动到另一个线程。
此问题可以通过使用第六章中引用计数部分的Rc指针来解决,指针和内存安全。实际上,这里的情况非常相似;所有线程都需要对同一数据的引用,即我们的健康变量。因此,我们在第六章中应用了相同的技巧,指针和内存安全——我们为我们的数据创建一个Rc指针,然后为每个需要的引用创建指针的clone()。然而,简单的Rc指针不是线程安全的;因此,我们需要一个特殊的版本,即线程安全的版本,所谓的原子引用计数指针或Arc<T>。原子意味着它在线程之间是安全的,它也是泛型的。因此,我们将健康变量包裹在一个Arc指针中,如下所示:
let data = Arc::new(Mutex::new(health));
此外,在for循环中,我们使用clone创建一个新的指向Mutex的指针:
for i in 2..5 {
let mutex = data.clone();
thread::spawn(move || {
let mut health = mutex.lock().unwrap();
*health *= i;
});
}
因此,每个线程现在都使用通过clone()获得的指针副本工作。Arc实例将跟踪对health的引用数量。对clone()的调用将增加对健康引用的计数。mutex引用在线程关闭结束时超出作用域,这将减少引用计数。当引用计数变为零时,Arc将释放相关的健康资源。
调用 lock() 给活动线程提供对数据的独占访问。原则上,获取锁可能会失败,因此它返回一个 Result<T, E> 对象。在上面的代码中,我们假设一切正常。unwrap() 函数是一种快速返回数据引用的方法,但在失败的情况下,它会引发恐慌。
这里涉及了相当多的步骤。因此,我们将再次完整地重复代码,但这次,我们将通过替换 unwrap() 提供健壮的错误处理。用前面解释的解释消化每一行:
// code from Chapter 8/code/thread_safe.rs:
use std::thread;
use std::sync::{Arc, Mutex};
fn main() {
let mut health = 12;
println!("health before: {:?}", health);
let data = Arc::new(Mutex::new(health));
for i in 2..5 {
let mutex = data.clone();
thread::spawn(move || {
let health = mutex.lock();
match health {
// health is multiplied by i:
Ok(mut health) => *health *= i,
Err(str) => println!("{}", str)
}
}).join().unwrap();
};
health = *data.lock().unwrap();
println!("health after: {:?}", health);
}
这会打印出:
health before: 12
health after: 288
( 288 确实等于 12 * 2 * 3 * 4 ). 我们将线程连接起来,以便它们有时间完成工作;数据是一个引用,因此我们需要取消引用它以获取 health 值:
health = *data.lock().unwrap();
在前一个部分中概述的机制,使用组合的 Mutex 和 Arc,当共享数据占用大量内存时是可取的;这是因为有了 Arc,数据将不再为每个线程复制。Arc 作为共享数据的引用,并且只有这个引用被共享和克隆。
同步特性
Arc<T> 对象实现了 Sync 特性(而 Rc 则没有),这向编译器表明它可以安全地在多个线程中并发使用。任何必须同时在多个线程之间共享的数据都必须实现 Sync 特性。如果 &T 引用在线程之间传递时没有数据竞争的可能性,则 T 类型是 Sync 的;简而言之,&T 是线程安全的。所有简单类型,如整数和浮点数类型,以及所有由简单类型构建的复合类型(如结构体、枚举和元组)都是 Sync,任何只包含实现 Sync 的东西的类型都是自动 Sync。
通过通道进行通信
数据也可以通过在它们之间传递消息在线程之间交换。在 Rust 中,这是通过通道实现的,通道就像连接两个线程的单向管道——数据是先入先出处理的。数据在这两个端点之间通过这个通道流动,从 Sender<T> 到 Receiver<T>;两者都是泛型,并接受要传输的消息的 T 类型(显然,对于 Sender 和 Receiver 通道必须是相同的)。在这个机制中,为接收线程创建要共享的数据的副本,因此你不应该用它来传输非常大的数据:

要创建一个通道,我们需要从 std::sync 模块导入 mpsc 子模块(mpsc 代表 多生产者,单消费者通信 原语)然后使用 channel() 方法:
// code from Chapter 8/code/channels.rs:
use std::thread;
use std::sync::mpsc::channel;
use std::sync::mpsc::{Sender, Receiver};
fn main() {
let (tx, rx): (Sender<i32>, Receiver<i32>) = channel();
}
这创建了一个端点元组;tx(来自传输的 t)是 Sender,rx(来自接收器的 r)是 Receiver。我们已经指出,我们将通过通道发送 i32 整数,但如果编译器可以从代码的其余部分推断出通道的数据类型,则不需要类型注解。
发送和接收数据
那么,哪些数据类型可以通过通道发送?Rust 强制要求要发送到通道的数据必须实现 Send 特性,这保证了线程之间安全地转移所有权。没有实现 Send 的数据不能离开当前线程。i32 是 Send 的,因为我们可以复制它,所以让我们在下面的代码片段中这样做:
fn main() {
let (tx, rx) = channel();
thread::spawn(move|| {
tx.send(10).unwrap();
});
let res = rx.recv().unwrap();
println!("{:?}", res);
}
当然,这会打印 10。
在这里,tx 被移动到了闭包内部。更好的写法是 tx.send(10).unwrap(),如下所示:
tx.send(10).ok().expect("Unable to send message");
这将确保在出现问题时,会发送一条消息。
send() 是由子线程执行的;它在通道中排队一个消息(一个数据值;这里,它是 10),并且不会阻塞。recv() 是由父线程执行的;它从通道中选取一个消息,如果没有消息可用,它会阻塞当前线程。(如果你需要以非阻塞的方式执行此操作,请使用 try_recv()。)如果你不处理接收到的值,这种阻塞可以写成如下:
let _ = rx.recv();
send() 和 recv() 操作返回一个 Result,可以是 Ok(value) 类型或 Err 错误。这里省略了完整的错误处理,因为在 Err 的情况下,通道不再工作,并且线程失败(panic)并停止更好。
在一般场景中,我们可以让子线程执行长时间的计算,然后在父线程中接收结果,如下所示:
// code from Chapter 8/code/channels2.rs:
use std::thread;
use std::sync::mpsc::channel;
fn main() {
let (tx, rx) = channel();
thread::spawn(move|| {
let result = some_expensive_computation();
tx.send(result).ok().expect("Unable to send message");
});
some_other_expensive_computation();
let result = rx.recv();
println!("{:?}", result);
}
fn some_expensive_computation() -> i32 { 1 }
fn some_other_expensive_computation() { }
这里的 result 函数具有 Ok(1) 的值。
在下面的代码片段中,展示了优雅的代码模式,其中通道是在 make_chan() 函数中创建的,该函数返回调用代码的接收端点:
// code from Chapter 8/code/make_channel.rs:
use std::sync::mpsc::channel;
use std::sync::mpsc::Receiver;
fn make_chan() -> Receiver<i32> {
let (tx, rx) = channel();
tx.send(7).unwrap();
rx
}
fn main() {
let rx = make_chan();
if let Some(msg) = rx.recv().ok() {
println!("received message {}", msg);
};
}
这会打印出:received message 7。
执行以下练习:
构建一个 shared_channel.rs 程序,允许任意数量的线程共享一个通道来发送值,并有一个接收器收集所有值。作为一个提示,使用 clone() 给每个线程提供对发送 tx 端点的访问。(参考第八章的示例代码 shared_channel.rs。)
同步和异步通信
我们之前使用的发送通道是异步的;这意味着它不会阻塞执行代码。Rust 还有一个名为 sync_channel 的同步通道类型,其中如果其内部缓冲区已满,send() 会阻塞——它等待父线程开始接收数据。在下面的代码中,这种类型的通道被用来通过通道发送 Msg 结构体的值:
// code from Chapter 8/code/sync_channel.rs:
use std::sync::mpsc::sync_channel;
use std::thread;
type TokenType = i32;
struct Msg {
typ: TokenType,
val: String,
}
fn main() {
let (tx, rx) = sync_channel(1); // buffer size 1
tx.send(Msg {typ: 42, val: "Rust is cool".to_string()}).unwrap();
println!("message 1 is sent");
thread::spawn(move|| {
tx.send(Msg {typ: 43, val: "Rust is still cool".to_string()}).unwrap();
println!("message 2 is sent");
});
println!("Waiting for 3 seconds ...");
thread::sleep_ms(3000);
if let Some(msg) = rx.recv().ok() {
println!("received message of type {} and val {}", msg.typ, msg.val);
};
if let Some(msg) = rx.recv().ok() {
println!("received second message of type {} and val {}", msg.typ, msg.val);
};
}
这会打印:
message 1 is sent
Waiting for 3 seconds
然后,3 秒后,打印:
received message of type 42 and val Rust is cool
message 2 is sent
received second message of type 43 and val Rust is still cool
这清楚地表明,只有在缓冲区被接收第一条消息清空后,才能发送第二条消息。
执行以下练习:
解释当第二个消息也从主线程内部发送而不是在单独的线程中发送时会发生什么。
摘要
在本章中,我们探讨了 Rust 的轻量级线程进程——如何创建它们,如何让它们共享数据,以及如何让它们通过通道传递数据。
在接下来的章节中,我们将探讨边界问题——我们将了解 Rust 程序如何接受参数并与之交互。我们还将探讨在 Rust 中,当我们达到如此低级以至于编译器无法保证安全性的情况下,我们需要做什么,以及我们如何与其他语言如 C 进行接口交互。
第九章。边界编程
在本章中,我们将探讨如何使用命令行参数启动 Rust 程序。然后,我们将探讨我们必须离开安全边界的情况,例如与 C 程序接口,以及 Rust 在这样做时如何最小化潜在的危险。
我们将讨论以下主题:
-
程序参数
-
不安全代码
-
原始指针
-
与 C 接口
-
内联汇编代码
-
从其他语言调用 Rust
程序参数
在 Rust 中,从启动时读取命令行参数很容易;只需使用 std::env::args() 方法。我们可以将这些参数收集到一个 String 向量中,如下所示:
// code from Chapter 9/code/arguments.rs:
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
println!("The program's name is: {}", args[0]);
for arg in args.iter() {
println!("Next argument is: {}", arg)
}
println!("I got {:?} arguments: {:?}.", args.len() - 1);
for n in 1..args.len() {
println!("The {}th argument is {}", n, args[n]);
}
}
按以下格式调用程序:
-
arguments arg1 arg2在 Windows 上 -
./arguments arg1 arg2在 Linux 和 Mac OS X 上
以下是从实际调用中得到的输出:

程序名称是 args[0];下一个参数是命令行参数。我们可以遍历这些参数或通过索引访问它们。参数的数量由 args.len() – 1 给出。
对于更复杂的带有选项和标志的解析,请使用 getopts 或 docopt crate。要开始,请参阅 rustbyexample.com 上的示例。
现在,env::vars() 返回操作系统的环境变量:
let osvars = env::vars();
for (key, value) in osvars {
println!("{}: {}", key, value);
}
在 Windows 上,首先会打印出以下内容:
HOMEDRIVE: C:
USERNAME: CVO
LOGONSERVER: \\MicrosoftAccount
…
不安全代码
有一些情况,即使是 Rust 编译器也无法保证我们的代码以安全的方式运行。这可能在以下场景中发生:
-
当我们必须针对“金属”编程,接近操作系统、处理器和硬件时
-
当我们想要与 C 中可能实现的控制量相同
-
当我们将程序执行的一部分委托给不安全语言,如 C 时
-
当我们想要内联汇编语言时
Rust 允许我们为这些场景编写代码,但我们必须将这些可能危险的代码包裹在一个 unsafe 块中:
unsafe {
// possibly dangerous code
}
现在,程序员承担全部责任。unsafe 块是对编译器的一个承诺,即不安全性不会从块中泄漏出来。编译器将更宽松地检查标记为 unsafe 的代码区域,并允许其他禁止的操作,但所有权系统(有关更多信息,请参阅第六章,指针和内存安全)的一些规则仍然有效。
明显的优势是,问题区域现在将非常清晰地隔离;如果出现问题,我们将知道它只能在这些标记的代码区域中发生。拥有 99% 的代码是安全的、1% 是不安全的代码库比拥有 100% 不安全代码的代码库更容易维护,就像在 C 中那样!
在 unsafe 块中我们可以做以下事情:
-
与原始指针一起工作,特别是通过解引用它们。有关更多信息,请参阅本章的 原始指针 部分。
-
通过 Foreign Function Interface(FFI)调用另一种语言中的函数。有关更多信息,请参阅本章的 与 C 接口 部分。
-
内联汇编代码
-
使用
std::mem::transmute来进行简单的类型位操作;以下是一个使用示例,其中字符串被转换为一个字节数组切片:// code from Chapter 9/code/unsafe.rs: use std::mem; fn main() { let v: &[u8] = unsafe { mem::transmute("Gandalf") }; println!("{:?}", v); }
这将打印以下输出:
[71, 97, 110, 100, 97, 108, 102]
unsafe 块也可以调用执行这些危险操作并标记为 unsafe fn dangerous() { } 的 unsafe 函数。
在 unsafe 代码中,使用 std::mem 模块(其中包含用于在低级别处理内存的函数)和 std::ptr 模块(其中包含用于处理原始指针的函数)很常见。
小贴士
我们建议你在 unsafe 代码中大量使用 assert! 语句来检查运行时是否做了你期望的事情。例如,在解引用未知来源的原始 ptr 指针之前,始终调用 assert!(!ptr.is_null()); 以确保指针指向有效的内存位置。
原始指针
在 unsafe 代码块中,Rust 允许使用一种称为 原始指针 的新类型指针。对于这些指针,没有内置的安全机制,你可以像 C 指针一样自由地使用它们。它们被写成如下形式:
-
*const T:这用于不可变值或T类型的指针 -
*mut T:这用作可变指针
它们可以指向无效的内存,内存资源需要手动释放。这意味着原始指针可能在释放它所指向的内存之后意外地被使用。此外,多个并发线程对可变原始指针有非独占访问权。由于我们不确定其内容(至少我们没有编译器保证有效内容),解引用原始指针也可能导致程序失败。
正因如此,解引用原始指针只能在 unsafe 块内部进行,如下面的代码片段所示:
// code from Chapter 9/code/raw_pointers.rs:
let p_raw: *const u32 = &10;
// let n = *p_raw; // compiler error!
unsafe {
let n = *p_raw;
println!("{}", n); // prints 10
}
如果你尝试在普通代码中这样做,你将得到以下输出:
error: dereference of unsafe pointer requires unsafe function or block [E0133]
我们可以使用 & 作为 *const,隐式或显式地从引用安全地创建原始指针,如下面的代码片段所示:
let gr: f32 = 1.618;
let p_imm: *const f32 = &gr as *const f32; // explicit cast
let mut m: f32 = 3.14;
let p_mut: *mut f32 = &mut m; // implicit cast
然而,将原始指针转换为引用,应该通过 &*(解引用的地址)操作完成,必须在 unsafe 块内部进行:
unsafe {
let ref_imm: &f32 = &*p_imm;
let ref_mut: &mut f32 = &mut *p_mut;
}
在定义其他更智能的指针时,原始指针也可能很有用;例如,它们用于实现 Rc 和 Arc 指针类型。
与 C 接口
由于 C 代码中存在大量的功能,有时将处理委托给 C 例程可能很有用,而不是在 Rust 中编写一切。
你可以通过使用 libc crate 来调用 C 标准库中的所有函数,这必须通过 Cargo 获取。为此,只需将以下内容添加到你的 Rust 代码中:
#![feature(libc)]
extern crate libc;
要导入 C 函数和类型,你可以这样总结:
use libc::{c_void, size_t, malloc, free};
或者,您可以使用 * 通配符,例如 use libc::*;,使它们全部可用。
要从 Rust(或另一种语言)中与 C(或另一种语言)一起工作,您将不得不使用 FFI,它在其 std::ffi 模块中有其工具。
下面是一个简单的示例,使用 C 的 puts 函数打印 Rust 字符串:
// code from Chapter 9/code/calling_libc.rs:
#![feature(libc)]
extern crate libc;
use libc::puts;
use std::ffi::CString;
fn main() {
let sentence = "Merlin is the greatest magician!";
let to_print = CString::new(sentence).unwrap();
unsafe {
puts(to_print.as_ptr());
}
}
这将打印出以下句子:
Merlin is the greatest magician!
CString 的 new() 方法将从 Rust 字符串生成一个与 C 兼容的字符串(以一个 0 字节结尾)。as_ptr() 方法返回指向这个 C 字符串的指针。
#![feature(libc)] 属性(一个所谓的功能门)是(暂时)必要的,以启用 libc 的使用。它不与 beta 通道的 Rust 一起工作,您需要从 nightly 通道获取 Rust 编译器。
注意
功能门在 Rust 中很常见,用于启用某些功能的使用,但在稳定 Rust 中不可用;它们仅在当前开发分支(nightly 发布)中可用。
使用 C 库
假设我们想要计算复数的正切值。num 包提供了复数的基本操作,但在这个时候,tangents 函数尚未包含,因此我们将调用 C 库 libm 中的 ctanf 函数,这是一个用 C 实现的数学函数集合。
以下代码正是如此,并定义了一个复数作为一个简单的 struct:
// code from Chapter 9/code/calling_clibrary.rs:
#[repr(C)]
#[derive(Copy, Clone)]
#[derive(Debug)]
struct Complex {
re: f32,
im: f32,
}
#[link(name = "m")]
extern {
fn ctanf(z: Complex) -> Complex;
}
fn tan(z: Complex) -> Complex {
unsafe { ctanf(z) }
}
fn main() {
let z = Complex { re: -1., im: 1\. }; // z is -1 + i
let z_tan = tan(z);
println!("the tangens of {:?} is {:?}", z, z_tan);
}
这个程序将打印以下输出:
the tangens of Complex { re: -1, im: 1 } is Complex { re: -0.271753, im: 1.083923 }
#[derive(Debug)] 属性是必要的,因为我们想在 {:?} 格式字符串中显示数字。#[derive(Copy, Clone)] 属性是必要的,因为我们想在调用 ctanf(z) 将 z 移动后使用 z 在 println! 语句中。#[repr(C)] 的功能是让编译器确信我们传递给 C 的类型是外国函数安全的,并且它告诉 rustc 创建与 C 相同布局的 struct。
我们想要使用的 C 函数的签名必须列在一个 extern {} 块中。编译器无法检查这些签名,因此准确指定它们对于在运行时正确绑定非常重要。此块还可以声明 C 导出的全局变量,以便在 Rust 中使用。它们必须标记为 static 或 static mut,例如,static mut version: libc::c_int。
extern 块之前必须有一个 #[link(name = "m")] 属性,以链接 libm 库。这指示 rustc 链接到该本地库,以便解析该库的符号。
C 调用本身显然必须在 unsafe {} 块内完成。这个块被一个 tan(z) 包装函数包围,该函数只使用 Rust 类型。这样这个包装函数就可以作为一个安全的接口暴露出来,通过隐藏 Rust 和 C 类型之间的不安全调用和类型转换,特别是 C 指针。当 C 代码返回资源时,Rust 代码必须包含这些值的析构函数,以确保它们的内存释放。
内联汇编代码
在 Rust 中,我们可以嵌入汇编代码。这应该非常罕见,但我们可以想到一些可能有用的情况,例如,当你必须获得极致的性能或非常低级别的控制时。然而,当你这样做时,你的代码的可移植性和可能的不稳定性都会降低。Rust 编译器可能会生成比你自己编写的更好的汇编代码,所以大多数时候这并不值得。
小贴士
此功能在 Rust 1.0 的稳定发布渠道中尚未启用。在此期间,要使用此机制(或其他不稳定功能),您必须使用来自 master 分支的 Rust(这是夜间发布)。
该机制通过使用 asm! 宏来实现,例如以下示例中,我们通过调用汇编代码来计算减法函数中的 b:
// code from Chapter 9/code/asm.rs:
#![feature(asm)]
fn subtract(a: i32, b: i32) -> i32 {
let sub: i32;
unsafe {
asm!("sub $2, $1; mov $1, $0"
: "=r"(sub)
: "r"(a), "r"(b)
);
}
sub
}
fn main() {
println!("{}", subtract(42, 7)) }
}
这将结果打印为 35。
我们只能使用具有所谓功能门的功能 asm!,在这里是 #![feature(asm)]。
asm! 宏有多个参数,由 : 分隔。第一个是汇编模板,包含作为字符串的汇编代码,然后是输出和输入操作数。
您可以使用 cfg 属性及其 target_arch 值来指示您的汇编代码打算在哪种处理器上执行,例如:
#[cfg(any(target_arch = "x86", target_arch = "x86_64"))]
编译器将检查您是否为该处理器指定了有效的汇编代码。
关于 asm! 的更多详细信息,请参阅本章的 内联汇编 部分,链接为 doc.rust-lang.org/book/unsafe.html。
从其他语言调用 Rust
可以从任何可以调用 C 的语言中调用 Rust 代码。然而,Rust 库应该具有 dylib 包类型值。当 rustfn1 是要调用的 Rust 函数时,必须如下声明:
#[no_mangle]
pub extern "C" fn rustfn1() { }
在这里,#[no_mangle] 用于保持函数名称简单明了,以便更容易链接。C 使用 C 调用约定将函数导出至外部世界。
在文章 siciarz.net/24-days-of-rust-calling-rust-from-other-languages/ 中可以找到从 C、Python、Haskell 和 Node.js 调用 Rust 的示例。从 Perl 和 Julia 调用 Rust 的示例可以在 paul.woolcock.us/posts/rust-perl-julia-ffi.html 找到。
摘要
在本章中,我们向您展示了如何处理在启动时从命令行读取的程序参数。然后,我们进入了不安全的地带,原始指针指引了方向。我们介绍了如何使用汇编代码,如何从 Rust 调用 C 函数,以及如何从其他语言调用 Rust 函数。
本章结束了我们对 Rust 的基本巡游。在随后的 附录,探索更多 中,我们为您提供了一些线索(无意中!)来继续您的 Rust 之旅。
附录 A.进一步探索
Rust 是一种非常丰富的语言。在这本书中,我们没有讨论 Rust 的每一个概念,也没有在每一个细节上讨论。在这里,我们将讨论我们省略了什么,以及读者可以在哪里找到更多关于这些主题的信息或细节。
Rust 和标准库的稳定性
Rust 1.0 生产版本承诺提供稳定性;如果您的代码在 Rust 稳定版 1.0 上编译,它将在 Rust 稳定版 1.x 上编译,无需或仅需最小更改。
Rust 的开发遵循一个具有三个发布渠道(夜间、beta 和稳定)的列车模型,并且每六周将发布一个新的稳定版本。生产用户将更倾向于坚持使用稳定分支。每六周,将发布一个新的 beta 版本;这排除了所有不稳定代码,因此您知道,如果您正在使用 beta 或稳定版本,您的代码将继续编译。同时,现有的 beta 分支将被提升为稳定发布。如果您想要最新的更改和新增功能,则使用夜间渠道;它包括可能以向后不兼容的方式更改的不稳定特性和库。
标准库中的绝大多数功能现在都已稳定。如需深入了解,请参阅doc.rust-lang.org/std/上的文档。
Crates 的生态系统
通常有一个趋势是将较少使用或更实验性的应用程序编程接口(APIs)从语言和标准库中移出,放入它们自己的 crates 中。Rust 的 crates 生态系统不断增长,您可以在crates.io/找到,截至撰写本文时(2015 年 5 月)已有超过 2,000 个 crates。
在Awesome Rust (github.com/kud1ing/awesome-rust),您可以找到经过精选的 Rust 项目列表。该网站仅包含有用且稳定的项目,并指出它们是否在最新的 Rust 版本中编译。此外,值得搜索Rust Kit (rustkit.io/),以及位于www.rust-ci.org/projects/的 Rust-CI 仓库。
通常,当您开始一个需要特定功能的项目时,建议您搜索已经可用的 crates。很可能已经存在一个符合您需求的 crates,或者您可能可以找到一些可用的起始代码,您可以在其基础上构建您确切需要的东西。
其他学习 Rust 的资源
本书几乎涵盖了所谓的“书” (doc.rust-lang.org/book/) 中的所有主题,有时甚至超越了它。尽管如此,Rust 网站上的“书”仍然是一个寻找最新信息的良好资源,同时还有 Rust 代码示例的优秀集合,可以在 Rust 主页上的“更多示例”链接找到,也可以通过 rustbyexample.com/ 访问。对于最完整、最深入的信息,请参考 doc.rust-lang.org/reference.html 上的参考。
在 Reddit (www.reddit.com/r/rust) 和 Stack Overflow (stackoverflow.com/questions/tagged/rust) 上提问或关注并评论讨论也可以帮助你。最后但同样重要的是,当你有紧急的 Rust 问题需要解决时,你可以在 client01.chat.mibbit.com/?server=irc.mozilla.org&channel=%23rust 的 IRC 频道与友好的专家聊天。
你可以在 doc.rust-lang.org/nightly/style/ 找到关于 Rust 的编码指南资源。
24 天 Rust 学习 是 Zbigniew Siciarz 推荐的一系列关于 Rust 高级主题的精彩文章;你可以在 siciarz.net/24-days-of-rust-conclusion/ 查看索引。
文件和数据库
标准库提供了 std::io::fs 模块用于文件系统操作:
-
如果你必须处理 逗号分隔值(CSV)文件,可以使用可用的 crate,例如
simple_csv、csv或xsv。在siciarz.net/24-days-of-rust-csv/的文章可以帮你入门。 -
对于处理 JSON 文件,可以使用
rustc-serialize或json_macros这样的 crate;开始时可以阅读siciarz.net/24-days-of-rust-working-json/上的信息。 -
对于 XML 格式,有许多可能性,例如
rust-xml和xml-rscrate。
对于数据库,有可用于以下技术的 crate:
-
SQLite3(
rust-sqlitecrate) -
PostgreSQL(
postgres和r2d2_postgrescrate);你可以通过siciarz.net/24-days-of-rust-postgres/开始使用它。 -
MySQL(
mysqlcrate) -
对于 MongoDB,有由 MongoDB 开发者构建的
mongocrate;更多关于这个的信息,请访问blog.mongodb.org/post/56426792420/introducing-the-mongodb-driver-for-the-rust。 -
对于Redis,有
redis、redis-rs或rust-redis的 crate;查看siciarz.net/24-days-of-rust-redis/以获取快速入门信息。 -
如果你感兴趣的是对象关系映射器(ORM)框架,请查看 deuterium crate。
图形和游戏
它的高性能和底层能力使 Rust 成为图形和游戏领域的理想选择。搜索图形会揭示 OpenGL(带有gl-rs、glfw-sys包)、Core Graphics(带有gfx、gdk包)和其他的绑定。
在游戏方面,有 Piston 和 chipmunk 2D 的游戏引擎以及 SDL1、SDL2 和 Allegro5 的绑定。一个简单 3D 游戏引擎的 crate 是kiss3d。存在许多物理(ncollide)和数学(nalgebra和cgmath-rs)crate,这些 crate 在这里可能很有用。
Web 开发
可以在arewewebyet.com/找到该领域的总体状况概述。目前,用于开发 HTTP 应用程序的最先进和稳定的 crate 是 hyper。它速度快,包含 HTTP 客户端和服务器,可以构建复杂的 Web 应用程序。要开始使用它,请阅读入门文章siciarz.net/24-days-of-rust-hyper/。
基于 hyper 构建的 HTTP 客户端库有rust-request和rest_client。一个新的名为 teepee 的 Rust HTTP Toolkit 项目正在兴起([http://teepee.rs/](http://teepee.rs/))。它看起来很有前途,但在撰写本书时还处于起步阶段。
对于 Web 框架,最佳可用的项目是 iron。如果你只需要一个轻量级的微 Web 框架,rustful 可能是你的选择。如果你需要一个表示状态转移(REST)框架,选择 rustless。另一个有用的 Web 框架,目前仍在积极开发中,是 nickel([http://nickel.rs/](http://nickel.rs/))。
当然,你绝对不能忽视新兴的新 servo 浏览器!
此外,还存在许多其他类别的 crate,如函数式编程和嵌入式编程([http://spin.atomicobject.com/2015/02/20/rust-language-c-embedded/](http://spin.atomicobject.com/2015/02/20/rust-language-c-embedded/))、数据结构、图像处理(image crate)、音频、压缩、编码和加密(rust-crypto和crypto)、正则表达式、解析、哈希、工具、测试、模板引擎等等。你可以查看 Rust-CI 仓库或 Awesome Rust 汇编;你可以参考crate 生态系统部分中的链接,以了解可用的内容。Zinc([http://zinc.rs/](http://zinc.rs/))是一个使用 Rust 编写处理器代码栈的项目示例(目前为 ARM)。
这本书中关于 Rust 的旅程就此结束。我们希望您享受阅读它如同我们享受撰写它一样。您现在有了坚实的基础,可以开始使用 Rust 进行开发了。我们也希望这个简要概述向您展示了为什么 Rust 是软件开发界的一颗新星,并且您会在您的项目中使用它。加入 Rust 社区,开始发挥您的编程才能。也许我们会在 Rust(非)宇宙中再次相遇。




浙公网安备 33010602011771号