精通Rust 第2版

978-7-115-55254-9
作者: [印]拉胡尔·沙玛(Rahul Sharma)[芬]韦萨·凯拉维塔(Vesa Kaihlavirta)
译者: 邓世超
编辑: 胡俊英

图书目录:

详情

Rust是一门系统编程语言,是支持函数式、命令式以及泛型等编程范式的多范式语言。Rust在语法上和C++类似。Rust快速、可靠、安全,它提供了甚至超过C/C++的性能和安全保证,同时它也是一种学习曲线比较平滑的热门编程语言。 本书内容共17章,由浅入深地讲解Rust相关的知识,涉及基础语法、软件包管理器、测试工具、类型系统、内存管理、异常处理、高级类型、并发模型、宏、外部函数接口、网络编程、HTTP、数据库、WebAssembly、GTK+框架和GDB调试等重要知识点。 本书适合想学习Rust编程的读者阅读,希望读者能够对C、C++或者Python有一些了解。书中丰富的代码示例和详细的讲解能够帮助读者快速上手,高效率掌握Rust编程。

图书摘要

版权信息

书名:精通Rust(第2版)

ISBN:978-7-115-55254-9

本书由人民邮电出版社发行数字版。版权所有,侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。

如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。


著    [印]拉胡尔•沙玛(Rahul Sharma)

     [芬]韦萨•凯拉维塔(Vesa Kaihlavirta)

译    邓世超

责任编辑 胡俊英

人民邮电出版社出版发行  北京市丰台区成寿寺路11号

邮编 100164  电子邮件 315@ptpress.com.cn

网址 http://www.ptpress.com.cn

读者服务热线:(010)81055410

反盗版热线:(010)81055315


Copyright ©2019 Packt Publishing. First published in the English language under the title Mastering Rust, Second Edition.

All rights reserved.

本书由英国Packt Publishing公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。

版权所有,侵权必究。


Rust是一门系统编程语言,是支持函数式、命令式以及泛型等编程范式的多范式语言。Rust在语法上和C++类似。Rust快速、可靠、安全,它提供了甚至超过C/C++的性能和安全保证,同时它也是一种学习曲线比较平滑的热门编程语言。

本书内容共17章,由浅入深地讲解Rust相关的知识,涉及基础语法、软件包管理器、测试工具、类型系统、内存管理、异常处理、高级类型、并发模型、宏、外部函数接口、网络编程、HTTP、数据库、WebAssembly、GTK+框架和GDB调试等重要知识点。

本书适合想学习Rust编程的读者阅读,希望读者能够对C、C++或者Python有一些了解。书中丰富的代码示例和详细的讲解能够帮助读者快速上手,高效率掌握Rust编程。


拉胡尔•沙玛(Rahul Sharma)对编程教学一直充满热情,过去两年他一直在从事软件开发相关的工作。他在开发Servo时开始接触Rust,Servo是由Mozilla Research公司开发的浏览器引擎,是他的GSoC项目的一部分。目前他供职于AtherEnergy公司,正在为智能滑板车构建弹性的云基础架构。他感兴趣的领域包括系统编程、分布式系统、编译器及类型理论等。他也是Rust社区的特约撰稿人,并在Mozilla的Servo项目中指导实习生。

韦萨•凯拉维塔(Vesa Kaihlavirta)从5岁起就开始接触编程,并且是从C64 BASIC开始入门的。他的职业目标是提高软件应用领域的开发者对编程语言和软件质量的认识。他是一名资深的Linux开发人员,并且已经在电信和金融行业工作了10年。Vesa目前住在芬兰中部的Jyvaskyla。


高拉夫•阿罗拉(Gaurav Aroraa)拥有计算机科学硕士学位。他是微软认证的MVP,印度计算机学会终身会员,IndiaMentor顾问成员,并获得Scrum培训师/教练员、ITIL-F的XEN、PRINCE-F和PRINCE-P的APMG等认证。他是一名开源软件开发者、TechNet Wiki撰稿人、Ovatic系统有限公司创始人。在20多年的职业生涯中,他指导了数千名学生和行业内专业人士。你可以通过Twitter上的@g_arora与他互动。

感谢我的妻子Shuby Arora,感谢我的女儿Aarchi Arora——她是我的天使。感谢她们允许我用本应该和她们一起共度的时光去审阅这本书。感谢整个Packt团队,特别是Ulhas和Anugraha Arunagiri,感谢他们在此期间的协调和沟通。感谢Denim Pinto,是他向我介绍了这本书。


本书是关于Rust编程语言的,它能够让你构建各种软件系统——从底层的嵌入式软件到动态的Web应用程序。Rust快速、可靠、安全,它提供了甚至超过C/C++的性能和安全保证,同时还是一种学习曲线比较平滑的热门编程语言。通过逐步完善,与积极友好的社区文化相结合,该语言的前景会非常美好。

从设计层面来讲,Rust并不是一门新的编程语言,它不会尝试重复构造“轮子”。相反,它借鉴了一些独特的思路,这些思路隐藏在学术型原型语言中,从未被大规模采用。而它将这些思路巧妙地组合起来,并提供一种实用的编程语言,使你能够在构建安全的软件系统的同时仍然保持高效。

本书的目标读者,是编程新手和熟悉其他命令式编程语言,但对Rust一无所知的中级程序员。假定读者至少熟悉一种命令式编程语言,例如C、C++或Python。了解函数式编程的要求并不是必需的,但对它有一个大致的了解将会大有裨益。不过,我们会确保对从这些语言中引入的任何概念或思想进行解释。

第1章简要介绍Rust及其背后的设计理念,并介绍该语言的基本语法。本章最后通过练习巩固了所学的语言特征。

第2章探讨在Rust中如何使用专用的软件包管理器管理大型软件项目,这是后续章节的基础。本章还介绍Rust与Visual Studio Code编辑器的集成。

第3章介绍Rust内置的测试工具,编写单元测试、集成测试以及如何在Rust中编写文档,还介绍Rust代码的基准测试工具,最后完成一个包含文档和测试的完整软件包示例。

第4章探讨Rust的类型系统,然后通过构造复杂的程序库来解释类型系统的各种用法。

第5章首先介绍内存管理的动机以及与内存相关的底层编程语言中的各种陷阱,然后解释Rust独特的编译期内存管理机制,还介绍Rust中的各种智能指针类型。

第6章从错误处理的动机开始,并探讨其他语言中错误处理的不同模型,然后在探讨不可恢复的错误处理机制之前,还介绍Rust的错误处理策略和类型。本章以实现自定义错误类型的程序库作为结束。

第7章更详细地探讨前面章节中已经介绍的一些概念,介绍Rust提供的一些类型系统抽象的底层模型的详细信息。

第8章探讨Rust标准库中的并发模型和API,并介绍如何构建没有数据竞争的高并发程序。

第9章介绍如何在Rust中编写强大的高级宏来生成代码,并通过构建两种宏来阐述声明性宏和过程宏的使用。

第10章探讨Rust的不安全模式以及与其他语言进行互操作的API。这些示例包括从其他语言调用Rust,例如Python、Node.js和C,以及如何从其他语言调用Rust。

第11章强调日志记录在软件开发实践中的重要性,说明我们为何需要日志框架,以及探索Rust生态系统中提供可用于帮助将日志记录功能集成到应用程序中的程序库。

第12章简要介绍网络编程之后,还介绍如何构建可以与Redis官方客户端通信的Redis服务器。最后,本章介绍如何使用标准库中的网络原语,以及tokio和futures程序库。

第13章首先介绍HTTP,然后介绍使用hyper程序库构建一个简单的URL短网址服务器,同时介绍使用reqwest程序库构建一个URL短网址客户端。最后探讨actix-web,它是一个高性能的异步Web应用程序框架,用于构建书签API服务器。

第14章首先解释将数据库作为应用程序后端的动机,并探讨Rust生态系统中可用的软件包,以便与各种数据库后端(如SQLite和PostgreSQL)进行交互。本章还介绍被称为diesel的类型安全的ORM库,然后介绍如何将它与第13章介绍的书签API服务器集成,以便使用diesel提供数据库支持。

第15章解释WebAssembly是什么,以及开发人员该如何使用它。然后继续探索Rust生态系统中可用的软件包,并使用Rust和WebAssembly构建实时markdown编辑器Web应用。

第16章解释在Rust中如何使用GTK+框架构建桌面应用程序,然后构建一个简单的黑客新闻桌面应用程序。

第17章探讨使用GDB调试Rust程序,并演示如何将GDB与Visual Studio Code编辑器集成。

要真正掌握本书的内容,建议你亲自编写书中的示例代码并尝试改进代码,以熟悉Rust的错误处理机制,从而让它们指导你编写出正确的Rust程序。

本书没有任何特定的硬件要求,任何内存大于1GB,并且采用的是较新版本的Linux操作系统的硬件都可以。本书中的所有示例代码和项目都是在运行Ubuntu 16.04的Linux操作系统上开发的。Rust还为其他操作系统提供了一流的支持,其中包括macOS、BSD和最新版的Windows,因此所有示例代码都应该能够在这些操作系统上编译并运行。

本书使用了一系列的排版约定。

CodeInText:表示文本、数据库表名、文件夹名、文件名、文件扩展名、路径名、URL、用户输入和Twitter引用等。下面是一个示例:“项目位于Chapter08/目录下名为threads_demo的文件夹中。”

代码块的格式设置如下所示:

fn main() {
    println!("Hello Rust!");
}

当希望读者对特定代码块特别留意时,相关的代码行或元素将会用粗体显示:

[dependencies]
serde = "1.0.8"
crossbeam = "0.6.0"
typenum = "1.10.0"

任何命令行输入或输出都用如下格式表示:

$ rustc main.rs
$ cargo build

粗体:表示你在屏幕上看到的新术语,关键字、词等。例如,菜单或对话框中的单词出现在文本中。例如这样一个示例:“从‘Administration’面板中选择‘System info’”。

 

 

警告或重要注意事项

 

 

 

提示或技巧

 


本书由异步社区出品,社区(https://www.epubit.com/)为您提供相关资源和后续服务。

本书提供配套资源,请在异步社区本书页面中单击,跳转到下载界面,按提示进行操作即可。注意:为保证购书读者的权益,该操作会给出相关提示,要求输入提取码进行验证。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。

当您发现错误时,请登录异步社区,按书名搜索,进入本书页面,单击“提交勘误”,输入勘误信息,单击“提交”按钮即可。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是contact@epubit.com.cn。

如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。

如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们;有意出版图书的作者也可以到异步社区在线提交投稿(直接访问www.epubit.com/selfpublish/submission即可)。

如果您是学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。

如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。

“异步社区”是人民邮电出版社旗下IT专业图书社区,致力于出版精品IT技术图书和相关学习产品,为作译者提供优质出版服务。异步社区创办于2015年8月,提供大量精品IT技术图书和电子书,以及高品质技术文章和视频课程。更多详情请访问异步社区官网https://www.epubit.com。

“异步图书”是由异步社区编辑团队策划出版的精品IT专业图书的品牌,依托于人民邮电出版社近30年的计算机图书出版积累和专业编辑团队,相关图书在封面上印有异步图书的LOGO。异步图书的出版领域包括软件开发、大数据、AI、测试、前端、网络技术等。

异步社区

微信服务号

学习一门新语言就像盖房子一样——需要将基础打牢。对于一种可能会改变你思考和推理代码的方式的语言,学习之初就需付出更多努力,并且认识到这一点非常重要。不过最重要的是,你可以使用新发现的概念和工具转变自己的思维。

本章将带你了解Rust的设计理念,简要介绍其语法和类型系统。我们假定你已掌握主流语言(例如C、C++或Python)的基本知识,以及了解面向对象编程的思想。本章包含代码示例及其说明,将提供足够的代码示例和编译器的输出结果,从而帮助你熟悉该语言。我们还将重点介绍该语言的发展史,以及发展前景。

掌握一门新语言需要坚持不懈的探索和实践。强烈建议所有读者动手编写本书中提供的代码示例,而不是进行简单的复制/粘贴。编写和修改Rust代码的关键在于利用从编译器获得的精确且有用的错误提示信息。Rust社区通常称之为异常-驱动开发(Exception-driven development)。我们将在本书中经常看到这样的错误提示信息,以了解编译器如何解析代码。

在本章中,我们将介绍以下主题。

“Rust是一种采用过去的知识解决将来的问题的技术。”

——Graydon Hoare

Rust是一种快速、高并发、安全且具有授权性的编程语言,最初由Graydon Hoare于2006年创造和发布。现在它是一种开源语言,主要由Mozilla团队和许多开源社区成员共同维护和开发。它的第一个稳定版本于2015年5月发布,该项目开发的初衷是希望解决使用C++编写的Gecko中出现的内存安全问题。Gecko是Mozilla Firefox浏览器采用的浏览器引擎。C++不是一种容易驾驭的语言,并且存在并发抽象容易被误用的问题。针对C++的Gecko,开发人员在2009年和2011年进行了几次尝试来并行化它的层叠样式表(Cascading Style Sheets,CSS)解析代码,以便充分利用当前流行的并行CPU架构,但他们失败了,因为C++的并发代码难以理解和维护。由于大量开发人员在拥有庞大代码库的Gecko上进行协作,因此使用C++在其中编写并发代码的体验非常糟糕。随着希望消除C++“不良”部分的呼声日渐高涨,Rust诞生了,随之而来的是Servo—— 一个从头开始创建浏览器引擎的新研究项目。Servo项目利用前沿编程语言的特性向语言开发团队提供反馈,这反过来又影响了语言的演变。

2017年11月左右,部分Servo项目,特别是stylo(Rust中的并行CSS解析器)项目,开始发布最新的Firefox版本(Quantum项目),在如此短的时间内完成新版本的发布是一项伟大的成就。Servo的最终目标是用其组件逐步取代Gecko中的组件。

Rust的灵感来自多种语言的知识,其中值得一提的是Cyclone(一种安全的C语言变体)的基于区域的内存管理技术、C++的RAII原则、Haskell的类型系统、异常处理类型和类型类。

 

 

注意

资源获取时初始化(Resource Acquisition Is Initialization,RAII)是一种范式,表明必须在对象初始化期间获取资源,并且必须在调用其析构函数或解除分配时释放资源。

 

该语言的运行时非常小,不需要垃圾收集,并且对于程序中声明的任何值,默认情况下更倾向于堆栈(stack)分配,而不是堆(heap)分配(开销),我们将在第5章中详细解释这些内容。Rust编译器rustc最初是用Ocaml(一种函数式编程语言)编写的,并且于2011年由自身重新编译后成为自托管版本。

 

 

注意

自托管是指通过编译自己的源代码构建编译器,该过程被称为编译器自举。编译器自己的源代码可以作为编译器的一个非常好的测试用例。

 

Rust在GitHub上有开源开发的网址,它的发展势头非常迅猛。通过社区驱动的请求注解过程(Request For Comments,RFC)将新功能添加到语言中,并且任何人都可以在其中提交新的功能特性,然后在RFC文档中详细描述它们。之后就RFC寻求共识,如果达成共识,则该功能特性进入实施阶段。然后,社区会对已实现的功能进行审核,经过用户在每晚发布的版本中进行的几次测试后,这些功能最终被整合到主分支中。从社区获得反馈对语言的发展至关重要。每隔6周,社区就会发布一个新的稳定版本的编译器。除了快速变化的增量更新之外,Rust还具有版本的概念,这个概念被标记为该语言提供统一的更新。这包括工具、文档、相关的生态系统,以及逐步实现的任何重大改进。到目前为止,Rust包括两个版本,其中Rust 2015专注于稳定性,Rust 2018专注于提高生产力(这是本书在编写时的版本情况)。

虽然Rust是一种通用的多范式语言,但它的目标是C和C++占主导地位的系统编程领域。这意味着你可以使用Rust编写操作系统、游戏引擎和许多性能关键型应用程序。同时,它还具有足够的表现力,你可以使用它构建高性能的Web应用程序、网络服务,类型安全的数据库对象关系映射(Object Relational Mapping,ORM)库,还可以将程序编译成WebAssembly在Web浏览器上运行。Rust还在为嵌入式平台构建安全性优先的实时应用程序方面获得了相当大的关注,例如Arm基于Cortex-M的微控制器,目前该领域主要由C语言主导。Rust因其广泛的适用性在多个领域都表现良好,这在单一编程语言中是非常罕见的。

此外,Cloudflare、Dropbox、Chuckfish、npm等公司和机构都已经将它应用到多个高风险项目的产品中。

Rust作为一门静态和强类型语言而存在。静态属性意味着编译器在编译时具有所有相关变量和类型的信息,并且在编译时会进行大量检查,在运行时只保留少量的类型检查。它的强类型属性意味着不允许发生诸如类型之间自动转换的事情,并且指向整数的变量不能在代码中更改为指向字符串。例如在JavaScript等弱类型语言中,你可以轻松地执行类似“two = "2"; two = 2 + two;”这样的操作。JavaScript在运行时将2的类型弱化为字符串,因此会将22作为字符串存储到变量two中,这与你的意图完全相反并且毫无意义。在Rust中,与上述代码意义相同的代码是“let mut two = "2"; two = 2 + two;”,该代码将会在编译时捕获异常,并提示信息:“cannot add '&str' to '{integer}'”。因此,强类型属性使Rust可以安全地重构代码,并在编译时捕获大多数错误,而不是在运行时出错。

用Rust编写的程序表现力和性能都非常好,因为使用它你可以拥有高级函数式语言的大部分特性,例如高阶函数和惰性迭代器,这些特性使你可以编译像C/C++程序这样高效的程序。它的很多设计决策中强调的首要理念是编译期内存安全、零成本抽象和支持高并发。让我们来详细说明这些理念。

编译期内存安全:Rust编译期可以在编译时跟踪程序中资源的变量,并在没有垃圾收集器(Garbage Collectors,GC)的情况下完成所有这些操作。

 

 

注意

资源可以是内存地址,包含某个值的变量、共享内存引用、文件句柄、网络套接字或数据库连接句柄等。

 

这意味你不会遇到在free、double free命令之后调用指针,或者运行时挂起指针等“臭名昭著”的问题。Rust中的引用类型(类型名称前面带有&标记的类型)与生命周期标记隐式关联('foo),有时由程序员显式声明。在生命周期中,编译器可以跟踪代码中可以安全使用的位置,如果它是非法的,那么会在编译期报告异常。为了实现这一点,Rust通过这些引用上的生命周期标签来运行借用/引用检查算法,以确保你永远不能访问已释放的内存地址。这样做也可以防止你释放被其他某些变量调用的任何指针。我们将在第5章详细介绍这一主题。

零成本抽象:编程的目的就是管理复杂性,这是通过良好的抽象来实现的。接下来让我们来看一个Rust和Kotlin的良好抽象示例。抽象让我们能够编写高级并且易于阅读和推断的代码。我们将比较Kotlin的流和Rust的迭代器在处理数字列表时的性能,并参照Rust提供的零成本抽象原则。这里的抽象是指能够使用以其他方法作为参数的方法,根据条件过滤数字而不使用手动循环。在这里引入Kotlin是因为它看上去和Rust存在相似性。代码很容易理解,我们的目标是给出更高层面的解释,并对代码中的细节进行详细阐述,因为这个示例的重点是理解零成本特性。

首先,我们来看Kotlin中的代码:

1. import java.util.stream.Collectors
2. 
3. fun main(args: Array<String>) 
4. {
5.     //创建数字流
6.     val numbers = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10).stream()
7.     val evens = numbers.filter { it -> it % 2 == 0 }
8.     val evenSquares = evens.map { it -> it * it }
9.     val result = evenSquares.collect(Collectors.toList())
10.    println(result)       // prints [4,16,36,64,100]
11.
12.    println(evens)
13.    println(evenSquares)
14. }

我们创建了一个数字流(第6行)并调用了一系列方法(filter和map)来转换元素,以收集仅包含偶数的序列。这些方法可以采用闭包或函数(第8行中的“ it -> it * it”)来转换集合中的元素。在函数式编程语言中,当我们在流/迭代器上调用这些方法时,对于每个这样的调用,该语言会创建一个中间对象来保存与正在执行的操作有关的任何状态或元数据。因此,evens和evenSquares将在JVM堆上分配两个不同的中间对象。在堆上分配资源将会产生内存开销,这是我们在Kotlin中为抽象必须额外付出的代价。

当我们输出evens和evenSquares的值时,确实得到了两个不同的对象,如下所示:

java.util.stream.ReferencePipeline$Head@51521cc1
java.util.stream.ReferencePipeline$3@1b4fb997

@之后的十六进制值是JVM对象的哈希值。由于哈希值不同,所以它们是不同的对象。

在Rust中,我们会做相同的事情:

1. fn main() {
2.     let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10].into_iter();
3.     let evens = numbers.filter(|x| *x % 2 == 0);
4.     let even_squares = evens.clone().map(|x| x * x);
5.     let result = even_squares.clone().collect::<Vec<_>>();
6.     println!("{:?}", result);      // 输出 [4,16,36,64,100]
7.     println!("{:?}\n{:?}", evens, even_squares);
8. }

接下来将解释上述代码的细节。在第2行中,我们调用vec![]创建一个数字列表,然后调用into_iter()方法使其成为一个数字的迭代器/流。使用into_iter()方法从集合中创建了一个包装器的迭代器类型(这里Vec<i32>是一个有符号的32位整数列表),即IntoIter([1,2,3,4,5,6, 7,8,9,10]),此迭代器类型引用原始的数字列表。然后我们执行filter和map转换(第3行和第4行),就像我们在Kotlin中所做的那样。第7行输出evens和even_squares的类型,如下所示(为了简洁,省略了一些细节):

evens:        Filter { iter: IntoIter( <numbers> ) }
even_squares: Map { iter: Filter { iter: IntoIter( <numbers> ) }}

中间对象Filter和Map是基础迭代器结构上的包装器类型(未在堆上分配),它本身是一个包装器,包含对第2行的原始数字列表的引用。第4行和第5行的包装器结构在分别调用filter和map时创建,它们之间没有任何指针解引用,并且不会像Kotlin那样产生堆分配的开销。所有这些可归结为高效的汇编代码,这相当于使用循环(语句)的手动编写版本。

支持高并发:当我们说Rust是并发安全的时,其含义是该语言具有应用程序接口(Application Programming Interface,API)和抽象能力,使得编写正确和安全的并发代码变得非常容易。而在C++中,并发代码出错的可能性非常大。在C++中同步访问多个线程的数据时,需要在每次进入临界区时调用mutex.lock(),并在退出它时调用mutex.unlock():

// C++

mutex.lock();                    // 互斥锁锁定
 // 执行某些关键操作
mutex.unlock();                  // 执行完毕

 

 

注意

临界区:这是一组需要以原子方式执行的指令/语句。这里的原子意味着没有其他线程可以中断临界区中正在执行的线程,并且在临界区执行代码期间,任何线程都无法感知其中的中间值。

 

在大量开发人员共同协作的大型代码库中,你可能会忘记在多线程访问共享对象之前调用mutex.lock(),这可能导致数据访问冲突。在其他情况下,你可能忘记解开互斥锁(Mutex),并使其他想要访问数据的线程一直处于等待状态。

Rust对此有不同的处理方式。在这里,你将数据包装成Mutex类型,以确保来自多个线程的数据进行同步可变访问:

// Rust

use std::sync::Mutex;

fn main() {
    let value = Mutex::new(23);
    *value.lock().unwrap() += 1;      // 执行一些修改
}                                     // 这里自动解锁

在上述代码中,我们能够在变量value调用lock()方法之后修改数据。Rust采用了保护共享数据自身,而不是代码的概念。Rust与Mutex和受保护的数据的交互并不是独立的,这和C++中的情况一样。你无法在Mutex类型不调用lock()方法的情况下访问内部数据。那么lock()方法的作用是什么?调用lock()方法之后会返回一个名为MutexGuard的东西,它会在变量超出作用域范围之后自动解除锁定,它是Rust提供的众多安全并发抽象之一。另一个新颖的想法是标记特征的概念,它在编译期验证,并确保在并发代码中同步和安全地访问数据,第4章详细介绍了该特征。类型会被称为Send和Sync的标记特征进行注释标记,以指示它们是否可以安全地发送到线程或者在线程之间共享。当程序向线程发送值时,编译器会检查该值是否实现了所需的标记特征,如果没有,则禁止使用该值。通过这种方式,Rust允许你毫无顾虑地编写并发代码,编译器在编译时会捕获多线程代码中的异常。编写并发代码已经很难了,使用C/C++会让它变得更加困难和神秘。当前CPU没有获得更多的时钟频率;相反,我们添加了更多内核。因此,并发编程是正确的发展方向。Rust使得编写并发代码变得轻而易举,并且降低了编写安全的并发代码的门槛。

Rust还借鉴了C++的RAII原则用于资源初始化,这种技术的本质是将资源的生命周期和对象的生命周期绑定,而堆分配类型的解除分配是通过执行drop特征上的drop()方法实现的。当变量超出作用域时,程序会自动调用此方法。它还用Result和Option类型替代了空指针的概念,我们将在第6章对此进行详细介绍。这意味着Rust不允许代码中出现null/undefined的值,除非通过外部函数接口与其他语言交互,以及使用不安全代码时。该语言还强调组合,而不是继承,并且有一套特征系统,它由数据类型实现,类似于Haskell的类型类,也被称为加强型的Java接口。Rust中的特征属性是很多其他特性的基础,我们将在后续的章节逐一介绍。

但同样重要的是,Rust社区非常活跃和友好。该语言包含非常全面的文档,可以在Rust官网中找到。Rust在Stack Overflow的开发者调查上连续3年(2016年、2017年和2018年)被评为最受欢迎的编程语言,因此编程社区对它非常青睐。总而言之,如果你希望编写具有较少错误的高性能软件,又希望感受当前流行语言的特性和极佳的社区文化,那么Rust应该是一个不错的选择。

1.2 安装Rust工具链

Rust工具链包含两个主要组件:编译器rustc和软件包管理器Cargo,后者有助于管理Rust项目。工具链的发布有3个渠道。

建议开发人员使用稳定版的工具链。但是,夜间版可以实现某些前沿特性,某些库和程序需要用到它。你可以通过rustup轻松地修改夜间版的工具链。

rustup.rs

rustup是一款在所兼容的平台上安装Rust编译器的工具。为了让不同平台上的开发者能够轻松地下载和使用该语言,Rust团队开发了rustup。它是一个用Rust编写的命令行工具,提供了一种简单的方法来安装编译器的预构建二进制文件,以及构建用于交叉编译的二进制标准库。它还可以安装其他组件,例如Rust源代码、文档、Rust格式化工具(rustfmt)、Rust语言服务器(Rust Language Server,用于IDE的RLS),并且它支持所有操作系统,包括Windows。

根据官方网站的提示,安装工具链的推荐做法是运行以下命令:

curl https://sh.rustup.rs -sSf | sh

默认情况下,安装程序会安装稳定版的Rust编译器、软件包管理器Cargo,以及语言的标准库文档,以便脱机查看。它们会默认安装到~/.cargo目录下。rustup还会更新环境变量PATH以指向此目录。

以下是在Ubuntu 16.04上运行上述命令的输出结果:

如果你需要对安装进行任何更改,那么可以选择2)。不过默认配置对我们来说没有任何问题,因此我们将继续选择1),以下是安装成功后的输出结果:

rustup还包含其他功能,例如将工具链更新到最新版本,这可以通过运行rustup update命令来完成,还可以通过运行rustup self update 命令来更新自身的版本。它还提供了针对特定目录的工具链配置。默认情况下工具链会设置成全局安装,在这种情况下安装的是稳定版的工具链。你可以通过运行rustup show命令查看默认设置。如果你想为某个项目使用最新的夜间版工具链,可以通过运行rustup override set nightly命令告知rustup针对特定目录切换到夜间版的工具链。如果由于某种原因想使用较旧版本的工具链或者对工具链进行降级(例如 2016-06-03的夜间版),那么可以通过运行rustup install nightly-2016-06-03命令,然后使用override子命令来达到目的。

 

 

注意

本书中的所有代码示例和项目都基于编译器rustc 1.32.0(9fda7c223 2019-01-16)。

 

现在,你应该拥有编译和运行Rust程序所需的一切。让我们开始探索Rust之旅吧!

1.3 Rust简介

对于基本的语言功能,Rust不会偏离你在其他语言中习惯的内容;在较高层面,Rust程序会被组织成模块的形式,根模块会包含一个main()函数。对于二进制可执行项目,根模块通常是一个main.rs文件,而对于程序库,根模块通常是一个lib.rs文件。在模块中,你可以定义函数、导入程序库、定义类型、创建常量、编写测试和宏,甚至创建嵌套模块。我们将进行上述所有操作,但是让我们先从基础开始。接下来将介绍一个简单的Rust程序:

// greet.rs

1. use std::env;
2.
3. fn main() {
4.    let name = env::args().skip(1).next();
5.    match name {
6.       Some(n) => println!("Hi there ! {}", n),
7.       None => panic!("Didn't receive any name ?")
8.    }
9. }

让我们编译并运行该程序。将上述代码存储成名为greet.rs的文件,并使用该文件名运行rustc,然后将你的名字作为参数传递给它。这里传递的名称是Rust的非官方吉祥物Ferris,并在计算机上得到以下输出结果:

很明显,它的输出结果与预期的一致。让我们逐行解释一下该程序。

在第1行中,我们从std库导入一个名为env的模块,std是Rust的标准库。在第3行代码中,我们可以看到常见的main函数。然后在第4行中,我们调用env模块中的函数args(),它会返回传递给程序的参数的迭代器(序列)。因为第一个参数包含程序名,我们希望跳过它,所以我们调用skip并传入一个数字,该数字表示我们希望跳过的元素数目(1)。因为Rust中的迭代器是惰性的,并且不会进行预先计算,我们必须显式要求它给出下一个元素,所以接下来会调用next(),它会返回一个名为Option的枚举类型。Option既可以是Some(value),也可以是None变量,因为用户可能忘记提供参数。

在第5行中,我们在变量名上使用Rust提供的math表达式特性,并检查它是Some(n)值还是None值。match和if else语句的构造类似,但功能更强大。在第6行中,当它是Some(n)时,我们调用println!(),并传入内部字符串变量n(这在使用match表达式时会自动声明),之后向用户展示输出结果。println!调用并非一个函数,而是一个宏(它们都是以!结尾)。最后,在第7行中,如果它是一个枚举类型的None变量,那么将会调用panic!()(另外一个宏),这将中止程序运行,并向用户输出一条错误提示信息。

println!宏会接收一个字符串,该字符串包含一个用"{}"表示的元素占位符。这些字符串被称为格式化字符串,而字符串中的"{}"被称为格式化声明符。要输出简单的类型(例如基元类型),可以使用"{}"格式化声明符,而对于其他类型,可以使用"{:?}"格式化声明符。当然,与之有关的细节还有很多。当println!遇到一个格式化声明符,即"{}",以及相应的替换值时,它会在该值上调用一个方法,并返回该值的字符串形式。这种方法是特征的一部分。对于"{}"格式化声明符,它会调用一个来自Display特征的方法,对于"{:?}",它会调用一个来自Debug特征的方法。后者主要用于调试,而前者用于显示数据类型的可读形式的输出。它有点类似Java中的toString()方法。在开发过程中,通常需要输出数据类型以进行调试。使用"{:?}"格式化声明符时,上述方法在类型上是不可用的,我们需要在类型上添加#[derive(Debug)]属性来获取这些方法。后续的章节将会详细介绍这些属性,不过在接下来的代码示例中就会看到它。我们将在第9章中重温println!宏的应用。

在本章中,手动运行rustc并不意味着你在实际开发工作中也必须这么做。在后文中,我们将使用Rust的软件包管理器来构建和运行程序。除了在本地运行编译器之外,另一个可用于运行代码示例的工具是名为Rust Playground的官方在线编译器,以下是计算机上的截图:

Rust Playground还支持导入外部库,并可在运行示例程序时使用。

在前面的示例中,我们对基本的Rust程序进行了概述,但没有深入了解所有细节和语法。在接下来的章节中,我们将分别解释该语言的特性和语法。下面的解释可为你提供足够的知识储备,以便你可以快速地启动并运行编写的Rust程序,而无须详尽地浏览所有用例。简单起见,每个部分还包含对相关内容的引用,以更详细地解释这些概念。此外,Rust文档页面和内置的搜索功能将帮助你了解详细信息。建议你主动搜索后文中介绍的任意概念,这将有助于你获得正在学习的相关概念的更多背景信息。

本章中的所有代码示例都可以在本书的GitHub版本库(PacktPublishing/Mastering- RUST-Second-Edition)中找到。对于本章,它们在“Chapter 1, Getting Started with Rust Directory”目录下——本书后文的代码示例将遵循相同的约定。

 

 

注意

某些代码文件是刻意提供的,它们无法编译,因此你可以在编译器的帮助下自行修复。

 

接下来,让我们从Rust的基元类型开始。

1.3.1 基元类型

Rust中内置的基元类型有以下几种。

有符号

无符号

i8

u8

i16

u16

i32

u32

i64

u64

i128

u128

1.3.2 变量声明和不可变性

变量允许我们存储一个值,以便可以在后续的代码中轻松地引用它。在Rust中,我们使用关键字let来声明变量。这在本节开头的greet.rs示例中已经展示了这一点。在诸如C或Python等主流的命令式语言中,不能阻止你为初始化后的变量重新分配其他值。Rust通过在默认情况下让变量不可变而另辟蹊径,也就是说,在初始化变量后,你无法为变量分配其他值。如果稍后需要将变量指向其他变量(同一类型),则需要在其前面加上关键字mut。Rust要求你明确地表达自己的意图。

考虑如下代码:

// variables.rs

fn main() {
    let target = "world";
    let mut greeting = "Hello";
    println!("{}, {}", greeting, target);
    greeting = "How are you doing";
    target = "mate";
    println!("{}, {}", greeting, target);
}

我们声明了两个变量,即target和greeting。target是一个不可变的绑定变量,而greeting前面有一个关键字mut,这使它成为一个可变的绑定变量。但是,如果我们运行此程序,则会出现以下错误提示信息:

从上述错误提示信息可以看出,Rust不允许你再次为target分配值。为了让该程序通过编译,我们需要在let语句中的target之前加上关键字mut,然后再次编译和运行它。以下是程序运行后的输出结果:

$ rustc variables.rs
$ ./variables
Hello, world
How are you doing, mate

let语句不仅是为变量分配值,也是Rust中的模式匹配语句。在第7章中,我们将详细介绍它。接下来我们将讨论函数。

1.3.3 函数

函数将一堆指令抽象为具名实体,稍后可以通过其他代码调用这些指令,并帮助用户管理复杂性。我们已经在greet.rs程序中使用了一个函数,即main函数。让我们看看如何定义另一个函数:

// functions.rs

fn add(a: u64, b: u64) -> u64 {
    a + b
}

fn main() {
    let a: u64 = 17;
    let b = 3;
    let result = add(a, b);
    println!("Result {}", result);
}

在上述代码中,我们创建了一个名为add的新函数。关键字fn用于创建函数,随后跟着的是函数名add,圆括号中的a和b是参数,花括号中的是函数体。冒号的右边是参数的类型。函数的返回类型使用->指定,其后跟着的是类型,即u64。如果函数无返回值,那么可以省略该类型声明。函数也有类型,我们的函数add的类型表示为fn (u64,u64) -> u64。类型声明也可以存储在变量中传递给其他函数。

如果你仔细查看add的函数体,会发现我们不需要像其他语言那样使用关键字return来返回a+b,因为最后一个表达式会自动返回。不过Rust中仍有关键字return,但它用于提前退出。函数基本上是返回值的表达式,默认情况下是()(Unit)类型的值,这与C/C++中的void返回类型相似。也可以在其他函数中声明返回值,这用于你很难将某个函数(例如foo)中的某个功能作为语句序列进行推断时。在这种情况下,你可以在本地函数bar中提取这些行,然后在父函数foo中定义它们。

在main函数中,我们用let语句声明两个变量a和b。与b的情况类似,我们甚至可以省略指定类型,因为Rust可以通过检查代码来推断大多数情况下变量的类型。这种情况也适用于result,它是一个类型为u64的值。该特性有助于防止类型签名混乱,并提高代码可读性,特别是当你的类型嵌套在多个具有长名称的其他类型中时。

 

 

注意

Rust的类型推断基于Hindly-Milner类型系统。该系统包含一组规则和算法,可以通过编程语言进行类型推断。其采用了一种有效的类型推断方法,在线性时间内执行,使它对大型程序的类型检查具有实际意义。

 

我们还可以使用能够修改其参数的函数。考虑以下代码:

// function_mut.rs

fn increase_by(mut val: u32, how_much: u32) {
    val += how_much;
    println!("You made {} points", val);
}

fn main() {
    let score = 2048;
    increase_by(score, 30);
}

我们声明了一个变量score,并且为其赋值为2048,然后调用函数increase_by,将score作为第1个参数,30作为第2个参数传递给它。在increase_by中,我们将第1个参数指定为mut val,这表示该参数应该被视为可变的,这允许变量在函数内部被修改。我们的函数increase_by修改了val绑定变量并输出了该值。以下是程序运行后的输出结果:

$ rustc function_mut.rs
$ ./function_mut
You made 2078 points

接下来让我们探索一下闭包。

1.3.4 闭包

Rust也支持闭包。闭包与函数类似,但具有声明它们的环境或作用域的更多信息。虽然函数具有与之关联的名称,闭包的定义没有,但可以将它们分配给变量。Rust类型推断的另一个优点是,在大多数情况下,你可以为没有类型的闭包指定参数。这是一个最简单的闭包“let my_closure = || ();”。我们刚刚定义了一个什么都不做的无参数闭包。然后我们可以通过my_closure()来调用它,这和函数类似。两个竖条“||”用于存放闭包的参数(如果有的话),例如|a,b|。当Rust无法找出正确的类型时,有时需要指定参数类型(|a:u32|)。和函数类似,闭包也可以存储在变量中,稍后调用或传递给其他函数。但是,闭包的主体可以是单一表达式,也可以是由花括号标识的多个表达式组成。更复杂的闭包示例如下所示:

// closures.rs

fn main() {
    let doubler = |x| x * 2;
    let value = 5;
    let twice = doubler(value);
    println!("{} doubled is {}", value, twice);

    let big_closure = |b, c| {
        let z = b + c;
        z * twice
    };

    let some_number = big_closure(1, 2);
    println!("Result from closure: {}", some_number);
}

在上述代码中,我们定义了两个闭包:doubler和big_closure。doubler将给定的值加倍。在这种情况下,它从父作用域或上下文环境传递value,即main函数。同样,在big_closure中,我们从其环境中使用变量twice。这个闭包在花括号内有多行表达式,需要以分号结尾,以便我们将它分配给变量big_closure。之后,我们调用big_closure,传入1、2作为参数,并输出some_number。

闭包主要用作高阶函数的参数。高阶函数是一个以另一个函数或闭包作为参数的函数。例如,标准库中的thread::spawn函数接收一个闭包作为参数,你可以在其中编写要在另一个线程中运行的代码。闭包提供简便、抽象的另一个场景是,当你有一个对Vec等集合进行操作的函数时,希望根据某些条件过滤元素。Rust的迭代器特征(iterator trait)有一个名为filter的方法,可以接收一个闭包作为参数。此闭包由用户定义,并返回true或false,具体取决于用户希望过滤集合中元素的方式。我们将在第7章深入了解闭包。

1.3.5 字符串

字符串是在任何编程语言中最常用的数据类型之一。在Rust中,它们通常以两种形式出现:&str类型和String类型。Rust字符串保证是有效的UTF-8编码字节序列。它们不像C字符串那样以空值(NULL)终止,并且可以在字符串之间包含空的字节。以下程序展示了这两种类型:

// strings.rs

fn main() {
    let question = "How are you ?";          // &str类型
    let person: String = "Bob".to_string();
    let namaste = String::from(![26.tif{15%}](/api/storage/getbykey/original?key=21016942a5efbb225ec6));     // unicodes yay!

    println!("{}! {} {}", namaste, question, person);
}

在上述代码中,person和namaste的类型为String,而question的类型为&str。创建String类型数据的方法有多种。String类型数据是在堆上分配的,&str类型数据通常是指向现有字符串的指针,这些字符串可以在堆栈和堆上,也可以是已编译对象代码的数据段中的字符串。&是一个运算符,用于创建指向任何类型的指针。在初始化前面代码中的字符串后,我们使用println!宏通过格式化字符串将它们一起输出。这些是最基本的字符串知识,我们将在第7章对字符串进行详细介绍。

1.3.6 条件和判断

Rust中的条件判断和其他语言中的类似,它们也遵循类C语言风格的if else结构:

// if_else.rs

fn main() {
    let rust_is_awesome = true;
    if rust_is_awesome {
        println!("Indeed");
    } else {
        println!("Well, you should try Rust !");
    }
}

在Rust中,if构造不是语句,而是一个表达式。在一般的编程用语中,语句不返回任何值,但表达式会返回值。这种区别意味着Rust中的if else条件总是会返回一个值。该值可以是empty类型的(),也可能是实际的值。无论花括号中的最后一行是什么,都会成为if else表达式的返回值。重点是要注意if和else分支应该具有相同的返回类型。如前所示,我们不需要在if条件表达式的两边添加括号,我们甚至可以将if else代码块的值分配给变量:

// if_assign.rs

fn main() {
    let result = if 1 == 2 {
        "Wait, what ?"
    } else {
        "Rust makes sense"
    };

    println!("You know what ? {}.", result);
}

当将要分配的值从if else表达式返回时,我们需要用分号作为结束标志。例如,if是一个表达式,那么let是一个声明,期望我们在结尾处有分号。在赋值的情况下,如果需要从前面的代码中删除else代码块,编译器会抛出一个错误提示信息,如下所示:

如果没有else代码块,当if条件的判断结果为false时,那么结果将是(),变量result的值将可能是两个,即()和&str。Rust不允许将多种类型的数据存储在一个变量中。因此,在这种情况下,我们需要if和else代码块返回相同的类型。此外,在条件分支中添加分号会更改代码的含义。通过在一些代码中的if代码块中的字符串之后添加分号,编译器会认为用户希望抛弃该值:

// if_else_no_value.rs

fn main() {
    let result = if 1 == 2 {
        "Nothing makes sense";
    } else {
        "Sanity reigns";
    };

    println!("Result of computation: {:?}", result);
}

在这种情况下,结果将是一个empty类型的(),这就是我们必须更改 println!({:?})表达式的原因;此类型无法以常规方式输出。对于更复杂的多值条件判断,Rust提供了被称为match表达式的强大构造来处理。接下来我们将会对它进行介绍。

1.3.7 match表达式

Rust的match(匹配)表达式非常简单、易用。它基本上类似于C语言的switch语句简化版,允许用户根据变量的值,以及是否具有高级过滤功能做出判断。以下是一个使用match表达式的程序:

// match_expression.rs

fn req_status() -> u32 {
    200
}

fn main() {
    let status = req_status();
    match status {
        200 => println!("Success"),
        404 => println!("Not Found"),
        other => {
            println!("Request failed with code: {}", other);
            //从缓存中获取响应
        }
    }
}

在上述代码中有一个req_status函数,它返回一个伪超文本传输协议(HyperText Transfer Protocol,HTTP)请求状态代码200,然后在main函数中调用,并将它分配给变量status。之后使用关键字match匹配此值,关键字后面跟着的是要检查的变量(status),后面跟一对花括号。在花括号内,我们编写表达式——它们被称为匹配臂。这些匹配臂表示匹配的变量可以采用的候选值。每个匹配臂是通过可能写入变量的值来构造的,随后跟着的是一个“=>”,然后右边是表达式。在右侧,你可以在花括号中使用单行表达式或多行表达式。当编写的是单行表达式时,需要用逗号进行分隔。此外,每个匹配臂必须返回相同的类型。在这种情况下,每个匹配臂返回一个Unit类型()。

另一个很好的特性,或者可以称之为match表达式的保证,是我们必须对所有可能匹配的值进行彻底匹配。在本示例中,这将列出所有数字直到i32类型允许的最大值。实际上这是不可能的。如果我们想忽略相关的值,Rust允许我们通过使用catch all变量(这里是other)或者_(下画线)来忽略其余的可能性。当你有多个可能的值,并且需要简洁地进行构造时,match表达式是围绕这些值做出决策的主要方式。与if else 表达式一样,match表达式的返回值也可以在用分号分隔的let语句中为变量赋值,其中所有匹配臂的返回值类型相同。

1.3.8 循环

在Rust中重复做某些事情可以使用3种构造来完成,即loop、while和for。在所有这些构造中,通常都包含关键字continue和break,分别允许你跳过和跳出循环。以下是一个使用循环的示例,相当于C语言中的while(true):

// loops.rs

fn main() {
    let mut x = 1024;
    loop {
        if x < 0 {
            break;
        }
        println!("{} more runs to go", x);
        x -= 1;
    }
}

loop表示无限循环。在上述代码中,我们简单地递减x的值,当它达到if条件x<0时,中断循环。在Rust中执行循环的一个额外特性是,能够使用名称标记循环代码块。这可以在你有两个或多个嵌套循环,并想要从它们中的任何一个中断的情况下使用,而不仅针对直接包含break语句的循环。以下是使用循环标签中断loop的示例:

// loop_labels.rs

fn silly_sub(a: i32, b: i32) -> i32 {
    let mut result = 0;
    'increment: loop {
        if result == a {
            let mut dec = b;
            'decrement: loop {
                if dec == 0 {
                    //直接从 'increment循环中断
                    break 'increment;
                } else {
                    result -= 1;
                    dec -= 1;
                }
            }
        } else {
            result += 1;
        }
    }
    result
}

fn main() {
    let a = 10;
    let b = 4;
    let result = silly_sub(a, b);
    println!("{} minus {} is {}", a, b, result);
}

在上述代码中,我们正在执行一种非常低效的减法操作,只是为了演示标签在嵌套循环中的使用方法。在内部'decrement标签中,当dec等于0时,可以传递一个标签来中断循环(这里是'increment),并且中断外部的'increment循环。

现在,让我们看看while循环。这个示例非常简单:

// while.rs

fn main() {
    let mut x = 1000;
    while x > 0 {
        println!("{} more runs to go", x);
        x -= 1;
    }
}

Rust中也有关键字for,它类似于其他语言中使用的for循环,但它们的实现完全不同。Rust的for循环基本上是一种更强大的重复构造(迭代器)的语法糖。我们将在第7章详细地讨论它。简单地说,Rust中的for循环只适用于可以转换为迭代器的类型。一种这样的类型是Range类型。Range类型可以指代一系列数字,例如(0..10)。它们可以用于for循环,如下所示:

// for_loops.rs

fn main() {
    //不包括10
    print!("Normal ranges: ");
    for i in 0..10 {
        print!("{},", i);
    }

    println!();       //另起一行
    print!("Inclusive ranges: ");
    //开始计数直到10
    for i in 0..=10 {
        print!("{},", i);
    }
}

一般的区间语法0..10,是不包括10的,Rust还具有包含区间的语法,例如0..=10,它会一直迭代到10才停止,如第2个for循环所示。现在,我们将开始讨论自定义数据类型。

1.3.9 自定义数据类型

自定义类型,顾名思义,是由用户定义的类型。自定义类型可以由几种类型组成。它们可以是基元类型的包装器,也可以是多个自定义类型的组合。它们有3种形式:结构体、枚举及联合,或者被称为struct、enum及union。它们允许你更轻松地表示自己的数据。自定义类型的命名规则遵循驼峰命名法(CamelCase)。Rust的结构体和枚举功能比C语言的结构体和枚举功能更强大,而Rust的联合非常类似于C语言的联合,主要用于与C语言代码库交互。我们将在本节中介绍结构体和枚举,将在第7章中详细介绍联合。

结构体

在Rust中,结构体的声明形式有3种。其中最简单的是单元结构体(unit struct),它使用关键字struct进行声明,随后是其名称,并用分号作为结尾。以下代码示例定义了一个单元结构体:

// unit_struct.rs

struct Dummy;

fn main() {
    let value = Dummy;
}

我们在上述代码中定义了一个名为Dummy的单元结构体。在main函数中,我们可以仅使用其名称初始化此类型。value现在包含一个Dummy实例,并且值为0。单元结构体在运行时不占用任何空间,因为没有与之关联的数据。用到单元结构体的情况非常少。它们可用于对没有与之关联的数据或状态进行实体建模;也可用于表示错误类型,结构体本身足以表述错误,而不需要对其进行描述;还可用于表示状态机实现过程中的状态。接下来,让我们看看结构体的第2种形式。

结构体的第2种形式是元组结构体(tuple struct),它具有关联数据。其中的每个字段都没有命名,而是根据它们在定义中的位置进行引用。假定你正在编写用于图形应用程序的颜色转换/计算库,并希望在代码中表示RGB颜色值。可以用以下代码表示Color类型和相关元素:

// tuple_struct.rs

struct Color(u8, u8, u8);

fn main() {
    let white = Color(255, 255, 255);
    //可以通过索引访问它们
    let red = white.0;
    let green = white.1;
    let blue = white.2;

    println!("Red value: {}", red);
    println!("Green value: {}", green);
    println!("Blue value: {}\n", blue);

    let orange = Color(255, 165, 0);
    //你也可以直接解构字段
    let Color(r, g, b) = orange;
    println!("R: {}, G: {}, B: {} (orange)", r, g, b);

    //也可以在解构时忽略字段
    let Color(r, _, b) = orange;
}

在上述代码中,Color(u8, u8, u8)是创建和存储到变量white的元组结构体。然后,我们使用white.0语法访问white中的单个颜色组件。元组结构体中的字段可以通过variable. <index>这样的语法访问,其中索引会引用结构体中字段的位置,并且是以0开头的。访问结构体中字段的另一种方法是使用let语句对结构体进行解构。后面,我们创建了一个颜色orange(橙色)。随后我们编写了一条let语句,并让Color(r, g, b)位于等号左边,orange位于等号右边。这使得orange中的3个字段分别存储到了变量r、g和b中。系统会自动为我们判定r、g和b的类型。

对于5个以下的属性进行数据建模时,元组结构体是理想的选择。除此之外的任何选择都会妨碍代码的可读性和我们的推理。对于具有3个以上字段的数据类型,建议使用类C语言的结构体,这是第3种形式,也是最常用的形式。请参考如下代码:

// structs.rs

struct Player {
    name: String,
    iq: u8,
    friends: u8,
    score: u16
}

fn bump_player_score(mut player: Player, score: u16) {
    player.score += 120;
    println!("Updated player stats:");
    println!("Name: {}", player.name);
    println!("IQ: {}", player.iq);
    println!("Friends: {}", player.friends);
    println!("Score: {}", player.score);
}

fn main() {
    let name = "Alice".to_string();
    let player = Player { name,
                          iq: 171,
                          friends: 134,
                          score: 1129 };

    bump_player_score(player, 120);
}

在上述代码中,结构体的创建方式与元组结构体的相同,即通过指定关键字struct,随后定义结构体的名称。但是,结构体以花括号开头,并且声明了字段名称。在花括号内,我们可以将字段写成以逗号分隔的“field:type”对。创建结构体的实例也很简单;我们只需编写Player,随后跟一对花括号,花括号中包含以逗号分隔的字段。使用与字段具有相同名称的变量初始化字段时,我们可以使用字段初始化简化(field init shortland)特性,即前面代码中的name字段。然后,我们可以使用struct.field_name语法轻松地访问此前创建的实例中的字段。

在上述代码中,我们还有一个名为bump_player_score的函数,它将结构体Player作为参数。默认情况下,函数参数是不可变的,所以当我们需要修改播放器中的分数(score)时,需要将函数中的参数修改为mut player,以允许我们修改它的任何字段。在结构体上使用关键字mut意味着它的所有字段都是可修改的。

使用结构体而不是元组结构体的优点在于,我们可以按任意顺序初始化字段,还可以为字段提供有意义的名称。此外,结构体的大小只是其每个字段成员大小的总和,如有必要,还包括任意数据对齐填充所需的空间大小。它没有任何额外的元数据尺寸的开销。接下来,让我们来看看枚举。

枚举

当你需要为不同类型的东西建模时,枚举可能是一种好办法。它是使用关键字enum创建的,之后跟着的是枚举名称和一对花括号。在花括号内部,我们可以编写所有可能的类型,即变体。这些变体可以在包含或不包含数据的情况下定义,并且包含的数据可以是任何基元类型、结构体、元组结构体,甚至是枚举类型。

不过,在递归的情况下,例如你有一个枚举Foo和一个引用枚举的变体,则该变体需要在指针类型(Box、Rc等)的后面,以避免类型无限递归定义。因为枚举也可以在堆栈上创建,所以它们需要预先指定大小,而无限的类型定义使它无法在编译时确定大小。现在,我们来看看如何创建一个枚举:

// enums.rs

enum Direction {
    N,
    E,
    S,
    W
}

enum PlayerAction {
    Move {
        direction: Direction,
        speed: u8
    },
    Wait,
    Attack(Direction)
}

fn main() {
    let simulated_player_action = PlayerAction::Move {
        direction: Direction::N,
        speed: 2,
    };
    match simulated_player_action {
        PlayerAction::Wait => println!("Player wants to wait"),
        PlayerAction::Move { direction, speed } => {
          println!("Player wants to move in direction {:?} with speed {}",
                direction, speed)
        }
        PlayerAction::Attack(direction) => {
            println!("Player wants to attack direction {:?}", direction)
        }
    };
}

上述代码定义了两个变体:Direction和PlayerAction。然后我们通过选择任意变体来创建它们的实例,其中变体和枚举名用双冒号分隔,例如Direction::N和PlayerAction::Wait。注意,我们不能使用未初始化的枚举,它必须是变体之一。给定枚举值,要查看枚举实例包含哪些变体,可以使用match表达式进行模式匹配。当我们在枚举上匹配时,我们可以将变量放在PlayerAction::Attack(direction)中的direction等字段中,从而直接解构变体中的内容,反过来,这意味着我们可以在匹配臂中使用它们。

正如你在前面的Direction变体中看到的,我们有一个#[derive(Debug)]注释。这是一个属性,它允许用户在println!()中以{:?}格式输出Direction实例。这是通过名为Debug的特征生成方法来完成的。编译器告诉我们是否缺少Debug,并提供有关修复它的建议,因此我们需要从那里获得该属性:

从函数式程序员的角度看,结构体和枚举也称为代数数据类型(Algebraic Data Type,ADT),因为可以使用代数规则来表示它们能够表达的值的取值区间。例如,枚举被称为求和类型,是因为它可以容纳的值的范围基本上是其变体的取值范围的总和;而结构体被称为乘积类型,是因为它的取值区间是其每个字段取值区间的笛卡儿积。在谈到它们时,我们有时会将它们称为ADT。

1.3.10 类型上的函数和方法

没有行为的类型功能有限,并且通常情况下我们希望类型具有函数或方法,以便我们可以返回它们的实例而不是手动构造它们,或者使我们能够操作自定义类型中的字段。这可以通过impl块来实现,它被视作某个类型提供实现。我们可以为所有自定义类型或包装器类型提供实现。首先,我们来看看如何编写结构体的实现。

结构体上的impl块

我们可以使用两种机制向之前定义的结构体Player中添加行为:一种是类似构造函数的函数,它接收一个名称并为Person中的其余字段设置默认值,另一种是设置Person的friends字段的getter和setter方法。

// struct_methods.rs

struct Player {
    name: String,
    iq: u8,
    friends: u8
}

impl Player {
    fn with_name(name: &str) -> Player {
        Player {
            name: name.to_string(),
            iq: 100,
            friends: 100
        }
    }

    fn get_friends(&self) -> u8 {
        self.friends
    }

    fn set_friends(&mut self, count: u8) {
        self.friends = count;
    }
}

fn main() {
    let mut player = Player::with_name("Dave");
    player.set_friends(23);
    println!("{}'s friends count: {}", player.name, player.get_friends());
    //另一种调用实例方法的方式
    let _ = Player::get_friends(&player);
}

我们指定关键字impl,然后指定我们希望实现方法的类型,后跟一对花括号。在花括号中,我们可以编写两种方法。

   Player::with_name("Dave");
   let player = Player::with_name("Dave");
   player.get_friends();

如果我们使用关联方法的语法调用get_friends,即Player::get_friends(),编译器会给出如下错误提示信息:

这里的错误提示信息具有误导性,但它表明实例方法基本上就是关联方法,self是第1个参数,而instance.foo()是一种语法糖。这意味着我们可以这样调用它:Player::get_friends (&player);。在此调用中,我们给方法传递了一个Player的实例,&self就是&player。

我们可以在类型上实现3种实例方法的变体。

我们的set_friends方法是一个&mut self方法,它允许我们修改player中的字段。我们需要在self之前添加运算符&,这表示self在方法存续期间被借用,这正是我们想要的。如果没有&符号,调用者会将所有权移动到方法,这意味着在get_friends方法返回后将取消分配值,我们将不能再使用Player实例。不必担心,我们没有在第5章详细解释所有这些之前,移动和借用这些术语并没有什么特别的含义。

接下来,我们将讨论枚举的实现。

impl块和枚举

我们还可以为枚举提供实现。例如,考虑使用Rust构建的支付程序库,它公开了一个名为pay的API:

// enum_methods.rs

enum PaymentMode {
    Debit,
    Credit,
    Paypal
}

//一些网络支付处理程序

fn pay_by_credit(amt: u64) {
    println!("Processing credit payment of {}", amt);
}
fn pay_by_debit(amt: u64) {
    println!("Processing debit payment of {}", amt);
}
fn paypal_redirect(amt: u64) {
    println!("Redirecting to paypal for amount: {}", amt);
}

impl PaymentMode {
    fn pay(&self, amount: u64) {
        match self {
            PaymentMode::Debit => pay_by_debit(amount),
            PaymentMode::Credit => pay_by_credit(amount),
            PaymentMode::Paypal => paypal_redirect(amount)
        }
    }
}

fn get_saved_payment_mode() -> PaymentMode {
    PaymentMode::Debit
}

fn main() {
    let payment_mode = get_saved_payment_mode();
    payment_mode.pay(512);
}

上述代码中有一个名为get_saved_payment_mode的方法,它返回用户保存的付款方式。这些方式可以是信用卡、借记卡或Paypal。最好将其建模为枚举,其中可以添加不同的付款方式作为其变体。然后程序库为我们提供单一的pay()方法,以便用户可以方便地提供支付金额。此方法可以确定枚举中的某个变体,并相应地将方法指派给正确的支付服务供应商,而不会让程序库的用户担心要检查使用哪种付款方式。

枚举也广泛用于状态机,当其与match表达式搭配使用时,它们可使状态转换代码非常简洁。它们还可用于自定义错误类型的建模。当枚举变体没有任何与之关联的数据时,它们可以像C语言的枚举那样使用,其中的变体默认具有以0开头的整数值(isize),但也可以手动标记整数值。这在与外部C程序库交互时很有用。

1.3.11 module、import和use语句

编程语言通常会提供一种将大型代码块拆分为多个文件以管理复杂性的方法。Java遵循每个.java文件就是公共类的约定,而C++为我们提供了头文件和include语句。Rust也不例外,它为我们提供了模块机制。模块是Rust程序中命名和组织代码的一种方式。为了灵活地组织代码,Rust提供了多种创建模块的方法。

模块是一个复杂的主题,本章只对它进行简要介绍,我们将重点介绍它的应用。第2章将会对它进行深入讨论。以下是Rust模块的主要内容。

上述内容是模块的简要介绍。第7章将会讨论模块的高级应用。接下来,让我们看一下标准库中常用的集合类型。

1.3.12 集合

通常情况下,你的程序必须处理多个数据实例,因此,可使用集合类型。根据你的需要以及数据驻留在内存中的位置,Rust提供了多种内置类型来存储数据集合。首先,我们有数组和元组。然后,我们的标准库中有动态集合类型,将介绍其中最常用的类型,即项目列表(vector)和键/值对(map)。最后,我们还引用了被称为切片的集合类型,它们基本上是对某些其他变量所拥有的连续数据的视图。让我们先从数组开始介绍。

数组

数组具有固定长度,可以存储相同类型的元素。它们用[T,N]表示,其中T表示任意类型,N表示数组元素的数量。数组的大小不能用变量表示,并且必须是usize的字面值:

// arrays.rs

fn main() {
    let numbers: [u8; 10] = [1, 2, 3, 4, 5, 7, 8, 9, 10, 11];
    let floats = [0.1f64, 0.2, 0.3];

    println!("Number: {}", numbers[5]);
    println!("Float: {}", floats[2]);
}

在上述代码中,我们声明了一个整型数组,其中包含10个元素,并在左侧指定了元素的类型。在第二个浮点型数组中,我们将类型指定为数组中第一个元素的后缀,即0.1f64。这是指定类型的另一种方法。接下来,让我们来介绍元组。

元组

元组与数组的不同之处在于,数组的元素必须具有相同的类型,而元组中的元素可以具有不同的类型。元组是异构集合,可用于将不同类型的元素存储在一起,从函数返回多个值时可以使用它。考虑下列应用元组的代码:

// tuples.rs

fn main() {
    let num_and_str: (u8, &str) = (40, "Have a good day!");
    println!("{:?}", num_and_str);
    let (num, string) = num_and_str;
    println!("From tuple: Number: {}, String: {}", num, string);
}

在上述代码中,num_and_str是一个包含两个元素的元组,即(u8, &str)。我们还将已经声明的元组中的值提取到单个变量中。输出元组后,我们将已经声明的元组解构为num和string变量,并自动推断它们的类型。该代码非常简洁。

项目列表

项目列表和数组类似,不过它们的内容和长度不需要事先指定,并且可以按需增长。它们是在堆上分配的。我们既可以使用构造函数Vec::new,也可以使用宏 vec![]创建它们:

// vec.rs

fn main() {
    let mut numbers_vec: Vec<u8> = Vec::new();
    numbers_vec.push(1);
    numbers_vec.push(2);

    let mut vec_with_macro = vec![1];
    vec_with_macro.push(2);
    let _ = vec_with_macro.pop(); //忽略空格

    let message = if numbers_vec == vec_with_macro {
        "They are equal"
    } else {
        "Nah! They look different to me"
    };

    println!("{} {:?} {:?}", message, numbers_vec, vec_with_macro);
}

在上述代码中,我们以不同方式创建了两个项目列表,即numbers_vec和vec_with_macro。我们可以使用push()方法将元素推送到vector中,并可以使用pop()方法删除元素。如果你希望了解更多相关的方法,可以参考官方帮助文档,还可以使用for循环语句迭代访问vector,因为它们也实现了Iterator特征。

键/值对

Rust还为我们提供了键/值对,它可以用于存储键/值对。它们来自std::collections模块,名为HashMap。它们是使用构造函数HashMap::new创建的:

// hashmaps.rs

use std::collections::HashMap;

fn main() {
    let mut fruits = HashMap::new();
    fruits.insert("apple", 3);
    fruits.insert("mango", 6);
    fruits.insert("orange", 2);
    fruits.insert("avocado", 7);
    for (k, v) in &fruits {
        println!("I got {} {}", v, k);
    }

    fruits.remove("orange");
    let old_avocado = fruits["avocado"];
    fruits.insert("avocado", old_avocado + 5);
    println!("\nI now have {} avocados", fruits["avocado"]);
}

在上述代码中,我们新建了一个名为fruits的HashMap。然后使用insert方法向其中插入了一些水果元素以及相关的计数。接下来,我们使用for循环遍历键/值对,其中通过&fruits引用我们的水果映射结构,因为我们只希望读取其中的键和值。默认情况下,for循环将使用该值。在上述情况下,for循环返回一个包含两个字段的元组((k,v))。还有单独的方法keys()和values()分别用于迭代访问键和值。用于哈希化HashMap类型键的哈希算法基于Robin hood开放寻址方案,但我们可以根据用例和性能替换成自定义哈希方案。接下来,让我们看看切片。

切片

切片是获取集合类型视图的常用做法。大多数用例是对集合类型中特定区间的元素进行只读访问。切片基本上是指针或引用,指向现有集合类型中某个其他变量所拥有的连续区间。实际上,切片是指向堆栈或堆中某处现有数据的胖指针,这意味着它还包含关于指向元素多少的信息,以及指向数据的指针。

切片用&[T]表示,其中T表示任意类型。它们的使用方式与数组非常类似:

// slices.rs

fn main() {
    let mut numbers: [u8; 4] = [1, 2, 3, 4];
    {
        let all: &[u8] = &numbers[..];
        println!("All of them: {:?}", all);
    }

    {
        let first_two: &mut [u8] = &mut numbers[0..2];
        first_two[0] = 100;
        first_two[1] = 99;
    }

    println!("Look ma! I can modify through slices: {:?}", numbers);
}

在上述代码中有一个numbers数组,这是一个堆栈分配值。然后我们使用&numbers[..]语法对数组中的数字进行切片并存储到变量all中,其类型为&[u8]。末尾的[..]表示我们要获取整个集合。这里我们需要用到&,是因为切片是不定长类型(unsized types),不能将切片存储为裸值——即仅在指针后面。与之有关的细节将会在第7章详细介绍。我们还可以提供范围([0..2])以获得任意区间的切片。切片也可以可变地获得。first_two是一个可变切片,我们可以通过它修改原始的numbers数组。

对细心的读者来说,你会发现在上述代码中,我们在进行切片时额外使用了一对花括号。它们用于隔离从不可变引用中获取切片的可变引用的代码。没有它们,代码将无法进行编译。第5章将会对它们进行详细介绍。

 

 

注意

&str类型也属于切片类型([u8]),与其他字节切片的唯一区别在于,它们保证为UTF-8。也可以在Vec或String上执行切片。

 

接下来,让我们来讨论迭代器。

1.3.13 迭代器

迭代器是一种构造,它提供了一种高效访问集合类型元素的方法,不过它并不是一个新的概念。在许多命令式语言中,它们为从集合类型(例如list或map)构造的对象。例如,Python的iter(some_list)或者C++的vector.begin()是从现有集合构造迭代器的方法。迭代器的一个优点是它们提供了对集合中元素的更高级别抽象,而不是使用手动循环,因为后者很容易因为某个错误而终止执行。

迭代器的另一个优点它是不会在内存中读取整个集合,并且是惰性的。惰性表示迭代器仅在需要时对集合中的元素进行求值或访问。迭代器还可以与多个转换操作链接,例如根据相关条件过滤元素,并且在你需要之前不进行求值转换。当你需要访问这些元素时,迭代器会提供next()方法,该方法尝试从集合中读取下一个元素,这一操作会在迭代器进行链式计算求值时发生。

 

 

注意

只有在类型具有集合(语义)时,才有必要实现Iterator特征。例如,对于()单位类型实现Iterator特征是无意义的。

 

在Rust中,迭代器是实现了Iterator特征的任意类型。可以在for循环中使用迭代器来遍历其元素。它们是为大多数标准库集合类型实现的,例如vector、HashMap、BTreeMap等,并且还可以为自定义类型实现。

我们在Rust中处理集合类型时,经常会用到迭代器。事实上,Rust的for循环可以转换成一个普通的match表达式,其中包含对迭代器对象next()方法的调用。此外,我们可以通过调用其中的iter()或者into_iter()方法将大多数集合类型转换为迭代器。上述内容已经提供了与迭代器相关的足够多的信息,以便我们进行接下来的练习。我们将会在第7章深入介绍迭代器,并实现一个自定义迭代器。

1.4 改进字符计数器

掌握了前面的基础知识后,是时候学以致用了!在这里,我们有一个程序来统计文本文件中的单词实例,并将文件作为参数传递给它。这个程序已经快要完成了,但是有一些编译器捕获的错误和瑕疵,以下是该程序代码:

// word_counter.rs

use std::env;
use std::fs::File;
use std::io::prelude::BufRead;
use std::io::BufReader;

#[derive(Debug)]
struct WordCounter(HashMap<String, u64>);

impl WordCounter {
    fn new() -> WordCounter {
        WordCounter(HashMap::new());
    }

    fn increment(word: &str) {
        let key = word.to_string();
        let count = self.0.entry(key).or_insert(0);
        *count += 1;
    }

    fn display(self) {
        for (key, value) in self.0.iter() {
            println!("{}: {}", key, value);
        }
    }
}

fn main() {
    let arguments: Vec<String> = env::args().collect();
    let filename = arguments[1];
    println!("Processing file: {}", filename);
    let file = File::open(filename).expect("Could not open file");
    let reader = BufReader::new(file);
    let mut word_counter = WordCounter::new();

    for line in reader.lines() {
        let line = line.expect("Could not read line");
        let words = line.split(" ");
        for word in words {
            if word == "" {
                continue
            } else {
                word_counter.increment(word);
            }
        }
    }
    word_counter.display();
}

根据上述代码继续完善该程序,并将它另存为一个文件;尝试在编译器的帮助下修复所有错误。每次尝试修复一个错误,并根据编译器重新编译代码来获得反馈。除了本章介绍的主题之外,本练习的目的是让你学会利用编译器的错误提示信息,这是了解编译器及其如何分析代码的更多信息的重要练习。你可能会惊讶地发现编译器在帮助你从代码中剔除错误方面非常有用。

完善上述代码之后,你可以尝试通过以下练习来进一步提高自己的水平。

1.5 小结

本章介绍了很多主题。我们了解了Rust的历史以及该语言诞生的原因;同时简要介绍了其设计原则和基本功能;还可以看到Rust如何通过其类型系统为用户提供丰富的抽象。我们学习了如何安装Rust工具链,以及如何使用rustc来构建和运行简单的示例程序。

第2章中,我们将介绍使用专用的软件包管理器构建Rust应用程序和程序库的标准方法,并使用代码编辑器设置Rust开发环境,这将为本书后续所有的练习和项目打下坚实的基础。


相关图书

Rust游戏开发实战
Rust游戏开发实战
JUnit实战(第3版)
JUnit实战(第3版)
Kafka实战
Kafka实战
Rust实战
Rust实战
PyQt编程快速上手
PyQt编程快速上手
Elasticsearch数据搜索与分析实战
Elasticsearch数据搜索与分析实战

相关文章

相关课程