数据结构与算法(Rust语言描述)

978-7-115-61168-0
作者: 谢波
译者:
编辑: 吴晋瑜

图书目录:

详情

这是一本基于 Rust 语言讲解数据结构及其实现方法的书。全书先介绍 Rust 语言的基 础知识以及计算机科学和算法分析的概念,然后介绍简单数据结构和算法的设计与实现,接着介绍较复杂的树和图数据结构,最后将这些知识应用于实战项目以解决实际问题。 本书适合程序设计爱好者、专业程序员以及对 Rust 语言感兴趣的读者阅读。

图书摘要

版权信息

书名:数据结构与算法(Rust语言描述)

ISBN:978-7-115-61168-0

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

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

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

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

著    谢 波

责任编辑 吴晋瑜

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

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

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

读者服务热线:(010)81055410

反盗版热线:(010)81055315

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e61168”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

内容提要

这是一本基于Rust语言讲解数据结构及其实现方法的书。全书先介绍Rust语言的基础知识以及计算机科学和算法分析的概念,然后介绍简单数据结构和算法的设计与实现,接着介绍较复杂的树和图数据结构,最后将这些知识应用于实战项目以解决实际问题。

本书适合程序设计爱好者、专业程序员以及对Rust语言感兴趣的读者阅读。

得益于高效、安全以及便捷的工程管理工具,Rust 逐渐发展成一门非常优秀的语言,甚至有人认为Rust很可能就是可以替代部分 C/C++ 工作的语言。

笔者接触Rust语言之初,市面上虽有一些 Rust 图书,但鲜见关于算法的Rust语言图书,为此在学习过程中屡屡碰壁。鉴于自己的亲身体会,笔者萌生了写一本用Rust语言讲解数据结构和算法的书的想法,以期能对刚接触这部分知识的读者有所帮助。

经过一段时间的查阅、整理资料和不断思考,结合自己的学习经历,笔者完成了这本书。笔者认为,虽然 Rust 学习曲线陡峭,但只要找准方向,辅以好的资源,学好Rust 并没有那么难。

笔者编写这本书还有一个初衷,那就是学习和推广Rust语言以及回馈Rust开源社区——开源社区的资源是笔者学习和成长的沃土。感谢PingCAP 开发的TiDB 及其运营的开源社区和线上课程。感谢 Rust语言中文社区的Mike Tang等成员对 Rust 会议的组织以及社区的建设和维护所做的贡献。感谢令狐壹冲在 Bilibili 弹幕网分享的 Rust 学习视频。感谢张汉东等前辈对 Rust 语言的推广,包括他所编写的优秀图书《Rust 编程之道》以及 《RustMagazine 中文月刊》。当然,还要感谢 Mozilla、AWS、Facebook、谷歌、微软、华为公司为 Rust 设立基金会——笔者正是由此认定 Rust 未来可期,其生态系统还处于蓬勃发展的阶段,才有了写这本书的动力。

最后,感谢母校电子科技大学(成都)给予的丰富学习资源和环境,感谢导师和 KC404 实验室众位师兄弟姐妹的关心和帮助。在那里,笔者学到了各种技术、知识,得到了成长,明确了人生前行的方向,感恩每一位给予我帮助和支持的老师、同学!

谢波

于成都

前  言

晶体管的出现引发了集成电路和芯片革命,人类有了中央处理器、大容量存储器和便捷的通信设施。Multics分时操作系统的失败催生了UNIX,而后出现的Linux内核及Linux发行版则将开源与网络技术紧密结合起来,使信息技术得到飞速发展。技术的进步为人类提供了实践想法的平台和工具,社会的进步则创造了新的需求和激情,继而推动技术的进步。尽管计算机世界的上层诞生了互联网、区块链、云计算、物联网,但在底层,其基本原理保持不变。变化的特性往往有不变的底层原理作为支撑,就像各种功能的蛋白质也只是几十种氨基酸组成的肽链的曲折变化一样。计算机世界的底层是基本硬件及其抽象数据类型(数据结构)和操作(算法)的组合,这是变化的上层技术取得进步的根本。

无论是普通计算机、超级计算机抑或量子计算机,其功能都建立在某种数据结构和算法的抽象之上。数据结构和算法作为抽象数据类型在计算机科学中具有重要地位,是完成各种计算任务的核心工具。本书重点关注抽象数据类型的设计、实现和使用。学习设计抽象数据类型有助于编程实现,而在编程过程中又可加深对抽象数据类型的理解。

本书的算法实现往往不是最优或最通用的工程实现,因为工程实现冗长,抓不住重点,这对于学习原理并无益处。本书提供的代码针对不同问题采取不同的实现措施,有的直接使用泛型,有的则使用具体的类型。之所以采取这些实现措施,一是为了简化代码,二是为了确保每段代码都可以单独编译通过并得到运行结果。

阅读前提

虽然理解抽象数据类型概念不涉及具体的形式,但是代码实现必须考虑具体的形式,这就要求你具有一定的 Rust 基础。

虽然每个人对 Rust 语言的熟悉程度和编码习惯有所不同,但基本要求是相同的。要阅读本书,你应具备以下能力。

能使用Rust语言实现完整的程序,如Cargo、rustc、test 等。

能使用基本的数据类型和结构,如结构体、枚举、循环、匹配等。

能使用 Rust 泛型,如生命周期、所有权系统、指针、非安全代码、宏等。

能使用内置库(crate)、外部库,会设置Cargo.toml。

如果你不了解上述内容,那么请先阅读相关的Rust学习资料。

内容概述

本书的章节安排并不是“强设定”的,你可以按照自己的需求选择先学某些章节,之后再学其他内容。但总的来说,我们建议你先阅读前3章内容,对于中间的章节及最后的实战章节,也建议你按顺序阅读。

第1章 Rust 基础。该章的内容包括 Rust 工具链安装、Rust 学习资料整理、Rust 基础知识回顾以及一个小项目。该章旨在帮助你复习Rust的重点基础知识,而非面面俱到。因此,如果你遇到不解之处,请参考其他资料。该章的最后一个项目是一个密码生成器,它也是笔者正在使用的一个小工具。之所以把它放到这里,是为了总结所学基础知识,同时便于你了解 Rust 的模块和代码是如何组织的。

第2章 计算机科学。该章介绍计算机科学的定义和概念,旨在让你了解如何分析实际问题。该章的内容包括如何为数据结构建立抽象数据类型模型,如何设计、实现算法以及对算法的运行结果进行检验。注意,抽象数据类型是对数据操作的逻辑描述,隐藏了实现细节,有助于更好地抽象出问题本质。

第3章 算法分析。该章介绍有关程序执行时间和空间性能的方法,以帮助你了解如何评估算法的执行效率。其中,大O分析法是算法分析的一种标准方法。

第4章 基础数据结构。计算机是线性系统,对于内存来说也不例外。基础数据结构因为保存在内存中,所以也是线性的。Rust 中基于这种线性内存模型建立的基础数据结构有数组、切片、Vec 以及衍生的栈、队列等。该章介绍如何用 Vec 这种基础数据结构来实现栈、队列、双端队列和链表。

第5章 递归。递归是一种算法技巧,它是迭代的另一种形式,必须满足递归三定律。尾递归是对递归的一种优化。动态规划是一类高效算法的代表,通常利用递归或迭代来实现。

第6章 查找。查找算法用于在某类数据集合中找到某个元素或判断某个元素是否存在,是使用最广泛的一类算法。根据数据集合是否有序,查找可以粗略地分为顺序查找和非顺序查找。非顺序查找包括二分查找和哈希查找等。

第7章 排序。顺序查找要求数据有序,但数据一般是无序的,所以需要排序。常见的排序算法有冒泡排序、快速排序、插入排序、希尔排序、归并排序、选择排序、堆排序、桶排序、计数排序和基数排序。蒂姆排序是结合了归并排序和插入排序的排序算法,效率非常高,它已经成为Java、Python、Rust 等语言的默认排序算法。

第8章 树。计算机是线性系统,但在线性系统中,通过适当的方法也能构造出非线性数据结构。树是一种非线性数据结构,它通过指针或引用来指向子树。树结构中最基础的是二叉树,由它可以衍生出二叉堆、二叉查找树、平衡二叉树、八叉树等。当然,树结构中还有 B 树、B+ 树、红黑树等更复杂的树。

第9章 图。树结构中的连接都是从根节点到子节点的,且数量不多。如果将这些限制取消,就得到了图结构。图是一种解决复杂问题的非线性数据结构,连接方向可有可无,没有父子节点的区别。虽然是非线性数据结构,但其存储形式也是线性的,包括邻接表和邻接矩阵。图用于处理含有大量节点和连接关系的问题,如网络流量、交通流量、路径搜索等问题。

第10章 实战。该章运用前9章的知识来解决实际问题,实现一些有用的数据结构和算法,包括距离算法、字典树、过滤器、缓存淘汰算法、一致性哈希算法以及区块链,旨在帮助你加深对数据结构的认识,提升你的Rust编程水平。

说明

本书和所有代码均在 Ubuntu 18.04 环境下编写完成,Rust 版本是 1.58。除了简单或说明性质的代码不给出运行结果之外,其他代码都给出了运行结果。比较短的结果就在当时的代码框中(用注释符号做了注释),较复杂的结果则单独放在了代码框中。

第1章 Rust基础

本章主要内容

安装 Rust 并了解其工具链

了解 Rust 各领域学习资料

回顾 Rust 编程语言基础知识

1.1 安装Rust及其工具链

Ubuntu 22.04及其以上版本自带Rust,可以直接使用,其他的类UNIX系统请使用如下命令安装Rust。Windows下的安装方式参见Rust官方网站。本书代码是在Linux下编写的,你最好也用Linux或macOS系统来学习,以避免由环境不一致造成的各种误解和错误。

$ curl --proto '=https' --tlsv1.2
    -sSf https://sh.rustup.rs | sh

在Linux下安装好Rust后还须设置环境变量,以便系统能够找到rustc编译器等工具的位置。首先,将如下3行代码添加到~/.bashrc的末尾:

# Rust 语言环境变量
export RUSTPATH=$HOME/.cargo/bin
export PATH=$PATH:$RUSTPATH

保存后,再执行source ~/.bashrc。如果嫌麻烦,你可以下载本书的配套源代码,执行install_rust.sh脚本,就可以完成Rust工具链的安装。第一段指令会安装rustup这个工具来管理并安装Rust工具链。Rust工具链包括编译器rustc、项目管理工具cargo、工具链管理工具rustup、文档工具rustdoc、格式化工具rustfmt和调试工具rust-gdb。

对于简单项目,你可以使用rustc来编译,但涉及大项目时就需要使用cargo工具来管理,其内部也是使用rustc来编译项目的。cargo是一个非常好的工具,它会将所有内容打包处理,集项目构建、测试、编译、发布于一体,十分高效。管理过C/C++工程的程序员,大多遭受过各种库和依赖的“折磨”,也正因如此,他们会喜欢上cargo这个工具。本书的大部分项目并不复杂,所以我们大多使用的是rustc编译器而不是cargo工具。rustup管理着Rust工具的安装、升级、卸载。注意,Rust语言包括stable版和nightly版,并且这两个版本可以共存。首次安装Rust时nightly版默认是没有的,可用如下命令安装。

$ rustup default nightly

安装好Rust后,可用rustup查看当前使用的版本,代码如下:

$ rustup toolchain list
    stable-x86_64-unknown-linux-gnu
    nightly-x86_64-unknown-linux-gnu (default)

要在stable版和nightly版之间切换,请执行如下命令:

$ rustup default stable # nightly

1.2 Rust基础知识

Rust是一门类似于C/C++的系统编程语言,这意味着你在那两门语言中学习的很多概念都能用于帮助你理解Rust。然而Rust也提出了自己独到的见解,特别是可变、所有权、借用、生命周期这几大概念,它们是Rust的优点,也是其学习难点。下面回顾Rust基础知识,熟悉Rust的读者可以跳过本节。

1.2.1 Rust语言历史

Rust是一种高效、可靠的通用高级编译型语言,后端基于LLVM(Low Level Virtual Machine)。Rust还是一种少有的兼顾开发效率和执行效率的语言。对于Rust语言,很多人可能听说过,但未必用过。Rust已经连续多年被Stack Overflow评为最受开发者喜爱的语言。2021年,谷歌、亚马逊、华为、微软、Mozilla还共同为Rust设立了基金会,谷歌甚至资助用Rust重写互联网基础设施Apache httpd、OpenSSL。此外,Rust也有自己的吉祥物,是一只红色的螃蟹,名叫Ferris。

Rust最早是Mozilla雇员Graydon Hoare的个人项目,它从2009年开始得到Mozilla研究院的支持,并于2010年对外公布。Rust得到Mozilla研究院的支持,是因为Mozilla开发和维护的Firefox Gecko引擎由于历史包袱及各种漏洞和性能瓶颈,已经落后于时代。Mozilla亟需一种能够安全编程的语言来保持Firefox的先进性。2010—2011年,Rust替换了用OCaml写的编译器,实现了自举,Rust开发团队在2015年发布了Rust的1.0版本。在整个研发过程中,Rust建立了一个强大而活跃的社区,形成了一整套完善且稳定的贡献机制。Rust固定每6周发布稳定版和测试版,每三年发布一个大的版次。目前,Rust已经发布过2015、2018、2021共三个版次,下一个版次是2024。

Rust采用了现代化的工程管理工具Cargo并配合随时随地可用的线上包(crate),开发效率非常高。Rust的包都会发布在crates.io上,目前可用的包就有82 498个,且还在不断增长。如果你实现了某个比较通用的包,你也可以将其推送到crates.io供其他人使用。

除了开发效率非常高之外,Rust的性能也很不错。2017年,由6名葡萄牙研究者组成的团队对各种编程语言的性能进行了一次调查[8]。他们用27种编程语言基于同样的算法写出了10个问题的解决方案,然后运行这些解决方案,记录每种编程语言消耗的电量、速度和内存使用情况,最终得到了各编程语言在各项指标上的表现,如图1.1所示。

图1.1 各编程语言性能对比

看了图1.1,相信你在心中对各种编程语言的资源使用情况也就有数了。Rust的能源消耗、时间消耗及内存消耗指标都非常不错。当然,这个对比不一定完全准确,但大的趋势是非常明显的,那就是Rust确实非常节能、高效。当前,人类社会正处于气候变化的关键节点,尤其是在碳达峰、碳中和的背景下,考虑采用Rust这类更节能、高效的语言来开发软件是符合历史潮流的。笔者觉得Rust可以作为企业转型的重要工具,期待整个行业和社会形成共识。当然,要是能形成国家指导政策,那就更好了。

Rust的使用领域非常广,包括但不限于命令行工具、DevOps工具、音/视频处理、游戏引擎、搜索引擎、区块链、物联网、浏览器、云原生、网络服务器、数据库、操作系统等。国内外已有众多高校、企业在大量使用Rust,比如清华大学新生学习用的操作系统rCore和zCore、字节跳动的飞书、远程桌面软件RustDesk、PingCAP的TiDB数据库、js/ts(JavaScript/TypeScript)运行时Deno、Google Fuchsia操作系统等。

1.2.2 关键字、注释、命名风格

下面列出了Rust目前在用的(未来可能增加)关键字,共39个。注意,Self和self是两个关键字。

Self      enum     match     super
as        extern   mod       trait
async     false    move      true
await     fn       mut       type
break     for      pub       union
const     if       ref       unsafe
continue  impl     return    use
crate     in       self      where
dyn       let      static    while
else      loop     struct

每种编程语言都有许多关键字,其中一些是通用的,剩下的就是各编程语言独有的。关键字实际上是语言设计者对程序设计思考的结果,不同的关键字体现了语言设计者对各种任务的权衡(trade-off)。比如,Rust 中的match功能非常强大,与此相对的是,C语言中类似match的switch功能就要少得多,可见这两种语言对该问题的考虑不同,从而导致不同的编码风格和思考方式。

为了方便阅读代码的人理解或为了说明复杂的逻辑,程序中还需要提供适当的解释。注释就是提供解释的好地方。Rust的注释有两大类:普通注释和文档注释。其中,普通注释有三种注释方式,如下所示:

// 第一种注释方式
 
/* 第二种注释方式 */
 
/*
 * 第三种注释方式
 * 多行注释
 * 多行注释
 */

Rust十分重视文档和测试,并为此专门提供了文档注释。有了文档注释,就可以在注释中写文档和测试代码,通过cargo test可以直接测试代码,通过cargo doc可以直接生成文档。

//! 生成库文档,用于说明整个模块的功能,置于模块文件的头部
/// 生成库文档,用于函数或结构体的说明,置于要说明对象的上方

Rust 的文档采用的是 Markdown 语法,所以符号“#”是必不可少的,如下所示:

//! Math 模块   <---- 文档注释,说明模块作用
//!
/// # add 函数  <---- 文档注释,说明函数作用、解释、测试用例
/// 该函数为求和函数
///
/// # Example   <---- 测试代码,使用用例
/// use math::add;
/// assert_eq!(3, add(1, 2));
fn add(x: i32, y: i32) -> i32 {
    // 求和     <---- 普通注释
    x + y
}

编码和命名风格一直是编程领域的热门话题,Rust也有推荐的做法。Rust推荐采用驼峰式命名(UpperCamelCase)来表示类级别的内容,而采用蛇形命名(snake_case)来描述值级别的内容。Rust中各种值推荐的命名风格如下所示:

项                 约定
包(Crate)           snake_case
类型                UpperCamelCase
特性(Trait)         UpperCamelCase
枚举                UpperCamelCase
函数                snake_case
方法                snake_case
构造函数            new 或 with_more_details
转换函数            from_other_type
宏                  snake_case!
局部变量            snake_case
静态变量            SCREAMING_SNAKE_CASE
常量                SCREAMING_SNAKE_CASE
类型参数            UpperCamelCase 的首字母,如 T、U、K、V
生命周期            lowercase,如 'a、'src、'dest

在UpperCamelCase模式下,复合词的首字母缩写词和缩略词算作一个词。例如,使用Usize而不使用USize。在snake_case或SCREAMING_SNAKE_CASE模式下,单词不应由单个字母组成,除非是最后一个单词。例如,使用btree_map而不使用b_tree_map,使用PI_2而不使用PI2。下面的示例代码展示了Rust中各种类型的命名风格(本书所有代码均参照此风格,建议你也遵循):

// 枚举
enum Result<T, E> {
    Ok(T),
    Err(E),
}
 
// 特性,trait
pub trait From<T> {
    fn from<T> -> Self;
}
 
// 结构体
struct Rectangle {
    height: i32,
    width: i32,
}
impl Rectangle {
    // 构造函数
    fn new(height: i32, width: i32) -> Self {
        Self { height, width }
    }
 
    // 函数
    fn calc_area(&self) -> i32 {
        self.height * self.width
    }
}
 
// 静态变量和常量
static NAME: &str = "kew";
const AGE: i32 = 25;
 
// 宏定义
macro_rules! add {
    ($a:expr, $b:expr) => {
        {
            $a + $b
        }
    }
}
 
// 变量及宏使用
let sum_of_nums = add!(1, 2);

随着Rust的普及和使用领域不断扩大,遵循统一的编码规范是十分有必要的。目前,有一份比较好的Rust编码规范是由张汉东老师主导的,建议多多借鉴。

1.2.3 常量、变量、数据类型

变量和常量是各种编程语言共同的概念,本来没什么好讲的,但Rust提出了可变与不可变的概念,这让即使非常简单的常量和变量概念,也能把人搞得晕头转向。新人,尤其是其他编程语言使用者,在转向Rust时往往需要和编译器斗智斗勇才能通过编译。Rust中存在常量、变量、静态变量三种类型的量。常量不能改变,不能被覆盖(重定义);变量可变可不变,还可被覆盖;静态变量可变可不变,不能被覆盖。

定义常量用const。常量是绑定到一个名称且不允许改变和覆盖的值,示例如下:

// 定义常量,类似于C/C++中的#define
const AGE: i32 = 1984;
// AGE = 1995; 报错,不允许改变
 
const NUM: f64 = 233.0;
// const NUM: f64 = 211.0; 报错,已经定义过,不能被覆盖

定义变量用let。变量是绑定到一个名称且允许改变的值,依据定义时是否使用mut,变量可以表现出可变或不可变特性。

let x: f64 = 3.14; //用let 定义变量x,x可被覆盖,但不允许改变
// x = 6.28; 报错,x 不可变
let x: f64 = 2.71 // 变量x被覆盖
 
let mut y = 985; // 用let mut定义变量y,y可被覆盖,并且允许改变
y = 996;         // 改变y
let y = 2019;    // 变量y被覆盖

定义静态变量用static。依据定义时是否使用mut,静态变量也可以表现出可变或不可变特性。

static NAME: &str = "shieber" // 静态变量可当作常量使用
// NAME = "kew"; 报错,NAME 不允许改变
 
static mut NUM: i32 = 100;  // 静态变量, NUM允许改变
unsafe {
    NUM += 1;               // 改变NUM
    println!("Num:{}",NUM);
}

在上述三个例子中,mut是约束条件,在变量前加mut后,变量才可以改变,否则不允许改变,这和其他编程语言有很大的不同。静态变量和常量有相似的地方,但其实它们大不同。常量在使用时采取内联替换,用多少次就替换多少次;而静态变量在使用时取一个引用,全局只有一份。用static mut定义的静态变量需要用unsafe进行包裹,说明这是不安全的,建议你只使用常量和变量,忘记静态变量,以免编码时出现错误。

数据类型是一门编程语言的基础,所有复杂的模块都是基于基础数据类型构建起来的。Rust的一些数据类型和C语言很像,但也有一些数据类型和Go语言相似。Rust的基础数据类型分标量类型和复合类型两大类。标量类型代表一个单独的值。Rust有4种基本的标量类型:整型、浮点型、布尔类型和字符类型。复合类型则是将多个值组合成一个值的类型,Rust有两种原生的复合类型:元组(tuple)和数组。

整数是没有小数部分的数字,根据是否有符号和长度,可分为12类。有符号类型以i开头,无符号类型以u开头。

长度    有符号     无符号
8       i8        u8
16      i16       u16
32      i32       u32
64      i64       u64
128     i128      u128
arch    isize     usize

这里有两点需要说明。第一,64位机器是如何处理128位的数字的?答案是采用分段存储,通过多个寄存器就能处理。第二,isize和usize是和机器架构相匹配的整数类型,所以在64位机器上,isize和usize分别表示i64和u64,在32位机器上则分别表示i32和u32。

浮点数是带有小数的数字,有f32和f64两种类型,默认为f64类型且都是有符号的。

长度    有符号
32       f32
64       f64   (默认)

布尔类型用bool表示,只有两个值——true和false,这和其他编程语言是一致的。

字符类型char和C语言中的char是一样的,都是最原始的类型。字符用单引号声明,字符串用双引号声明。下面的c和c_str是完全不同的类型,字符是一个4字节的Unicode标量值,而字符串对应的是数组。

// Unicode 标量值
let c = 's';
 
// 动态数组
let c_str = "s";

元组是一种能够将多个其他类型的值组合成复合值的类型,一旦声明,长度就不能增大或缩小。元组使用圆括号包裹值,并用逗号分隔值。为了从元组中获取值,你可以使用模式匹配和点符号,下标从0开始。

let tup1: (i8, f32, i64) = (-1, 2.33, 8000_0000);
// 使用模式匹配获取所有值
let (x, y, z) = tup1;
 
let tup2 = (0, 100, 2.4);
let zero = tup2.0; // 使用点符号获取值
let one_hundred = tup2.1;

x、y、z通过模式匹配分别获得了值-1、2.33、80 000 000。从这里也可以看出,let不只是定义变量,它还能解构出值来。其实,let就是模式匹配,定义变量时也是模式匹配。没有任何值的元组( )是一种特殊的类型,元组只有一个值时也写成( )。这种类型被称为单元类型,值则被称为单元值。在Rust中,如果表达式不返回任何值,则隐式返回单元值( )。

另一种包含多个值的类型是数组。但与元组不同的是,数组中每个元素的类型必须相同,并且长度也不能变。但如果需要可变数组,则可以使用Vec,Vec是一种允许增减长度的集合类型。大部分时候,你需要的数据类型很可能就是Vec。

// 定义数组
let genders = ["Female", "Male", "Bigender"];
let gender_f = genders[0];            // 访问数组元素
 
let digits[i32; 5] = [0, 1, 2, 3, 4]; //用[type; num]定义数组
let zeros = [0; 10];                  // 定义包含10个0的数组

Rust中的数据类型的大多数其实是可以相互转换的,比如将i8转成i32就是合理的转换。但Rust不支持原生类型之间的隐式转换,只能使用as关键字进行显式转换。整型之间的转换大体遵循C语言中的约定。在Rust中,所有整型转换都是定义良好的。

// type_transfer.rs
#![allow(overflowing_literals)]    // 忽略类型转换的溢出警告
fn main() {
    let decimal = 61.3214_f32;
    // let integer: u8 = decimal;  // 报错,不能将f32转成u8
    let integer = decimal as u8;   // 正确,用as进行显式转换
    let character = integer as char;
    println!("1000 as a u16: {}", 1000 as u16);
    println!("1000 as a u8: {}", 1000 as u8);
}

对于一些复杂的类型,Rust提供了From和Into两个trait来进行转换。

pub trait From<T> {
    fn from<T> -> Self;
}
pub trait Into<T> {
    fn into<T> -> T;
}

通过这两个trait,你可以按照自己的需求为各种类型提供转换功能。

// integer_to_complex.rs
#[derive(Debug)]
struct Complex {
    real: i32, // 实部
    imag: i32  // 虚部
}
// 为i32实现到复数的转换功能,将i32转换为实部,虚部置 0
impl From<i32> for Complex {
    fn from(real: i32) -> Self {
        Self { real, imag: 0 }
    }
}
fn main() {
    let c1: Complex = Complex::from(2_i32);
    let c2: Complex = 2_i32.into(); // 默认实现了Into
    println!("c1: {:?}, c2: {:?}", c1, c2);
}

1.2.4 语句、表达式、运算符、流程控制

Rust中最为基础的语法有两类:语句和表达式。语句指的是要执行的一些操作和产生副作用的表达式,而表达式则单纯用于求值。Rust中的语句包括声明语句和表达式语句两种。用于声明变量、静态变量、常量、结构体、函数、外部包和外部模块的语句是声明语句,而以分号结尾的语句则是表达式语句。

// 声明函数的语句
fn sum_of_nums(nums: &[i32]) -> i32{
    nums.iter().sum::<i32>()
}
 
let x = 5;     // 整句是语句,x = 5 是表达式,计算x的值
x + 1;         // 整句是表达式
let y = x + 1; // 整句是语句,y = x + 1 是表达式
println!("{y}");
 
let z = [1,2,3];
println!("sum is {:?}", sum_of_nums(&z)};

运算符是用于指定表达式计算类型的标志或符号,常见的有算术运算符、关系运算符、逻辑运算符、赋值运算符和引用运算符等。下面列出了部分Rust运算符并做了解释。

运算符     示例                              解释
+         expr + expr                      算术加法
+         trait + trait, 'a + trait        复合类型限制
-         expr - expr                      算术减法
-         - expr                           算术取负
*         expr * expr                      算术乘法
*         *expr                            解引用
*         *const type, *mut type           裸指针
/         expr / expr                      算术除法
%         expr % expr                      算术取余
=         var = expr, ident = type         赋值/等值
+=        var += expr                      算术加法与赋值
-=        var -= expr                      算术减法与赋值
*=        var *= expr                      算术乘法与赋值
/=        var /= expr                      算术除法与赋值
%=        var %= expr                      算术取余与赋值
 
==        expr == expr                     等于比较
!=        var != expr                      不等比较
>         expr > expr                      大于比较
<         expr < expr                      小于比较
>=        expr >= expr                     大于或等于比较
<=        expr <= expr                     小于或等于比较
 
&&        expr && expr                     逻辑与
||        expr || expr                     逻辑或
!         !expr                            按位非或逻辑非
 
&         expr & expr                      按位与
&         &expr, &mut expr                 借用
&         &type, &mut type,                借用指针类型
|         pat | pat                        模式选择
|         expr | expr                      按位或
^         expr ^ expr                      按位异或
<<        expr << expr                     左移
>>        expr >> expr                     右移
&=        var &= expr                      按位与和赋值
|=        var |= expr                      按位或与赋值
^=        var ^= expr                      按位异或与赋值
<<=       var <<= expr                     左移与赋值
>>=       var >>= expr                     右移与赋值
.         expr.ident                       成员访问
..        .., expr.., ..expr, expr..expr   右开区间范围
..        ..expr                           结构体更新语法
..=       ..=expr, expr..=expr             右闭区间范围模式
:         pat: type, ident: type           约束
:         ident: expr                      结构体字段初始化
:         'a: loop {...}                   循环标志
;         [type; len]                      固定大小的数组
=>        pat => expr                      匹配准备语法的部分
@         ident @ pat                      模式绑定
?         expr?                            错误传播
->        fn(...) -> type, |...| -> type   函数与闭包返回类型

大部分编程语言具有根据是否满足某个条件来决定是否执行某段代码或重复执行某段代码的能力,这种控制代码执行的方法被称为流程控制。Rust 中用来控制执行流的常见结构是if表达式和循环。if表达式及其关联的else表达式是最为常见的流程控制表达式。

let a = 3;
 
if a > 5 {
    println!("Greater than 5");
} else if a > 3 {
    println!("Greater than 3");
} else {
    println!("less or equal to 3");
}

if和let配合起来也可以控制代码的执行。一种是let if语句,旨在通过将满足条件的结果返回给let部分,如下所示。注意最后有分号,因为“let c = ..;”是一条语句。

let a = 3; let b = 2;
let a = 3; let b = 2;
let c = if a > b {
            true
        } else {
            false
        };

还有一种是if let语句,旨在通过模式匹配右端值来执行代码,如下所示。

let some_value = Some(100);
if let Some(value) = some_value {
    println!("value: {value}");
} else {
    println!("no value");
}

除了if let语句,也可以用match匹配来控制代码的执行,如下所示。

let a = 10;
match a {
    0 => println!("0 == a"),
    1..=9 => println!("1 <= a <= 9"),
    _ => println!("10 <= a"),
}

Rust提供了多种循环方式,包括loop、while、for in,再配合continue和break,便可以做到按需跳转及停止代码执行。loop关键字可以控制代码的重复执行——直到某个条件得到满足时才停止。

let mut val = 10;
let res = loop {
    // 停止条件,可以同时返回值
    if val < 0  {
        break val;
    }
 
    val -= 1;
    if 0 == val % 2 {
        continue;
    }
 
    println!("val = {val}");
}; // 注意此处有分号
// 死循环
loop {
    if res > 0 { break; }
 
    println!("{res}");
} // 注意此处没有分号

循环条件的计算是在内部进行的,如果使用while循环,则需要在外部计算循环条件。

let num = 10;
while num > 0 {
    println!("{}", num);
    num -= 1;
}
 
let nums = [1,2,3,4,5,6];
let mut index = 0;
while index < 6 {
    println!("val: {}", nums[index]);
    index += 1;
}

在遍历数组时,while循环使用了index和数组长度6两个辅助量。其实,Rust提供了更方便的遍历方式,那就是for in循环。

let nums = [1,2,3,4,5,6];
 
// 顺序遍历
for num in nums {
    println!("val: {num}");
}
 
// 逆序遍历
for num in nums.iter().rev() {
    println!("val: {num}");
}

for in循环并不需要index和数组长度6,这样不但简洁,而且避免了可能产生的错误。如果将6写成7,运行时就会报错。此外,通过iter().rev()还可实现逆序遍历。

while和let也可以组成模式匹配,这样就不用写停止条件了,因为let语法会自动判断,符合条件才继续执行while循环。

let mut v = vec![1,2,3,4,5,6];
while let Some(x) = v.pop() {
    println!("{x}");
}

通过上面的例子,你可以发现Rust的流程控制方式非常多,而且都很有用,尤其是match、if let、let if和while let,这类用法是完全符合Rust编码规范的,推荐你使用。

1.2.5 函数、程序结构

函数是编程语言的一个非常重要的构件。Rust中用fn来定义函数,fn的后面是蛇形命名风格的函数名,然后是一对圆括号,里面是函数的参数,格式为val: type(如x: i32);接着是用-> res表示的返回值,当然也可能没有返回值;最后是用大括号包裹起来的函数实现。和C语言一样,Rust也有主函数main。

// Rust函数定义
fn func_name(parameters) -> return_types {
    code_body;   // 函数体
 
    return_value // 返回值,注意没有分号
}

定义求和函数add,如下所示:

// 主函数
fn main() {
    let res = add(1, 2);
    println!("1 + 2 = {res}");
}
 
fn add(a: i32, b: i32) -> i32 {
    a + b
}

注意,函数定义写在main函数前或后都可以。add函数在返回值时没有使用return,当然也可以使用“return a + b;”,但是不推荐。对于返回值,直接写值就行了,不要加分号,加了反而是错的。这种语法是Rust特有的,也是推荐写法。Rust程序中的函数默认是私有的,要想导出供其他程序使用,则需要加上pub关键字才行。

现在让我们看看Rust程序的结构。Rust程序主要包括以下几个部分。

包(package)/库(lib)/箱(crate)/模块(mod)
变量
语句/表达式
函数
特性
标签
注释

下面是一个例子,其中包含了Rust程序中可能出现的各种元素。

// rust_example.rs
 
// 从标准库中导入max函数
use std::cmp::max;
 
// 公开模块
pub mod math {
    // 公开函数
    pub fn add(x: i32, y: i32) -> i32 { x + y }
 
    // 私有函数
    fn is_zero(num: i32) -> bool { 0 == num }
}
 
// 结构体
#[derive(Debug)]
struct Circle { radius: f32, // 半径 }
 
// 为f32实现到Circle的转换功能
impl From<f32> for Circle {
    fn from(radius: f32) -> Self {
        Self { radius }
    }
}
 
// 注释:自定义函数
fn calc_func(num1:i32, num2:i32) -> i32 {
    let x = 5;
    let y = {
        let x = 3;
        x + 1 // 表达式
    };        // 语句
 
    max(x, y)
}
 
// 使用模块函数
use math::add;
 
// 主函数
fn main() {
    let num1 = 1; let num2 = 2;
 
    // 函数调用
    println!("num1 + num2 = {}", add(num1, num2));
    println!("res = {}", calc_func(num1, num2));
 
    let f: f32 = 9.85;
    let c: Circle = Circle::from(f);
    println!("{:?}", c);
}

1.2.6 所有权、作用域规则、生命周期

Rust引入了所有权、借用、生命周期等概念,并通过这些概念限制了各种量的状态和作用范围。Rust的作用域规则在所有编程语言中是最严格的,变量只能按照生命周期和所有权机制在某个代码块中存在。Rust比较难学的部分原因就在于其作用域规则。Rust引入这些概念主要是为了应对复杂类型系统中的资源管理、悬荡引用等问题。

所有权系统是Rust中用来管理内存的手段,可以理解成其他语言中的垃圾回收机制或手动释放内存,但所有权系统与垃圾回收机制或手动释放内存有很大的不同。垃圾回收机制在程序运行时不断寻找不再使用的内存来释放,在有些编程语言中,程序员甚至需要亲自分配和释放内存。Rust则通过所有权系统来管理内存,编译器在编译时会根据一系列规则进行检查,倘若违反了规则,程序连编译都通不过。所有权规则很简单,我们可以将其归纳为如下三条。

每个值都有一个所有者(变量)。

值在任意时刻都只有一个所有者。

当所有者离开作用域时,其值将被丢弃(相当于执行垃圾回收)。

这说明Rust中的值被唯一的对象管理着,一旦不使用,内存就会立刻被释放。回想其他编程语言中的内存管理机制,多通过定时或手动触发垃圾回收。比如,Go语言使用三色法回收内存,这会导致stop the world问题,进而导致程序卡顿。反观Rust,则通过作用域判断,不用的就自动销毁,并不需要进行垃圾回收,释放内存是自然而然的事,这样Rust也就不需要停下来。实际上,Rust将垃圾回收机制的功能分散给了变量自身,使其成了变量的一种自带功能,这是Rust中的一大创新。

Rust的所有权规则还意味着更节省内存,因为Rust程序在运行过程中会不断释放不用的内存,这样后面的变量就可以复用这些释放出来的内存。自然地,Rust程序运行时的内存占用将会维持在一个合理的水平。相反,采用垃圾回收机制或手动释放内存的编程语言会因为变量增加而占用内存,忘记手动释放还会造成内存泄漏。下面结合代码来说明Rust的所有权机制。

fn main() {
    let long = 10;       <--- long出现在main作用域内
 
    { // 临时作用域
        let short = 5;   <--- short出现在临时作用域内
        println!("inner short: {}", short);
        let long = 3.14; <--- long出现在临时作用域内
        println!("inner long: {}", long);
    }                    <--- long和short离开临时作用域,清除
 
    let long = 'a';      <--- long被覆盖
    println!("outer long: {}", long);
}                        <--- long离开main作用域,清除

上面展示了各变量的作用域,所有权机制通过在变量离开作用域(比如这里的})时自动调用变量的drop方法来实现内存的释放。注意内部的long和外部的long是两个不同的变量,内部的long并不覆盖外部的long。

谈及所有权,我们不妨用一个比喻来阐释其含义。你购买了一本书,那么这本书的所有权就属于你。如果你的朋友从其他渠道得知这本书不错,说不定会向你借走看看,此时这本书的所有权还是你的,你的朋友只是暂时持有这本书。当然,如果你已经读完这本书,决定将其送给朋友,那么这本书的所有权就移动到了你的朋友手里。将这样的概念在Rust中推广,就得到了“借用”和“移动”。下面我们结合例子加以具体分析。

fn main() {
    let x = "Shieber".to_string(); // 在堆上创建字符串 "Shieber"
    let y = x;                     // x 把字符串移动给了 y
    // println!("{x}"); 报错,x 已经不持有字符串了
}   <--- y 调用 drop 方法以释放内存

“let y = x;” 被称为移动,它将所有权移交给了y。你可能会想,不是离开作用域才释放吗?当使用println输出时,x和y都还在main作用域内,怎么会报错呢?其实,所有权规则的第二条说得很清楚,一个值在任何时候都只能有一个所有者。所以变量x在移动后,立即就被释放了,后面不能再用。x的作用域只到“let y = x;”这一行,而没有到“}”这一行。此外,这种机制还保证了在“}”处只用释放y,不用释放x,从而避免了二次释放这种内存安全问题。为了同时使用x和y,你可以采用下面这种方式。

fn main() {
    let x = "Shieber".to_string(); // 在堆上创建字符串 "Shieber"
    let y = &x;                    // x把字符串借给了y
    println!("{x}");               // x持有字符串,y借用字符串
}

“let y = &x;”被称为借用,“&x”是对x的引用。引用就像指针(地址),可以通过引用来访问存储于该地址的属于其他变量的数据,创建引用就是为了方便别人借用。

借来的书,按理来说是不能做任何改动的。也就是说,借来的书是不可变的。不过,朋友在上面勾画一下也没什么不可。同样,Rust中借用的变量也分为可变变量和不可变变量两种,上面的“let y = &x;”表明y只是从x那里借用了一个不可变变量。如果想要变量可变,就需要为其添加mut修饰符,如下所示:

fn main() {
    let x = "Shieber".to_string(); // 在堆上创建字符串 "Shieber"
    let y = &mut x;                // x把字符串可变地借给了y
    y.push_str(", handsome!");
    // let z = &mut x; 报错,可变借用只能有一个
    println!("{x}");               // x持有字符串,y可变地借用字符串
}

“let z = &mut x;”报错,这说明可变引用只能有一个。这样做是为了避免数据竞争,比如同时写数据。不可变引用可以同时存在多个,因为多个不可变引用不会影响变量,只有多个可变引用才会造成错误。

现在我们来看另一种情况。假如你购买了一本电子书,你可能想着复制一份给你的朋友,这样你们两人就各自有了一本电子书,你们拥有各自电子书的所有权。以此类比,这就是Rust中的“拷贝”和“克隆”。

fn main() {
    let x = "Shieber".to_string(); // 在堆上创建字符串 "Shieber"
    let y = x.clone();             // 克隆字符串给 y
    println!("{x}、{y}");          // x 和 y 持有各自的字符串
}

clone函数通过深拷贝复制了一份数据给y。借用只是获取一个有效指针,速度快;而克隆需要复制数据,效率更低,而且内存消耗还会增加一倍。如果你尝试下面的写法,没有用clone函数,就会发现编译通过了,运行也没报错,那么是不是就不满足所有权规则了呢?

fn main() {
    let x = 10;          // 在栈上创建 x
    let y = x;
    println!("{x}、{y}"); // 按理来说,x 应该不可用了
}

其实仍满足所有权规则,此时的“let y= x;”并没有把10交给y,而是自动复制了一个新的10给y,这样x和y便各自持有一个10,这和所有权规则并不冲突。这里并没有调用clone函数,但Rust自动执行了clone函数。因为这些简单的变量都在栈上,Rust为这类数据统一实现了一个名为Copy的trait,通过这个trait可以实现快速拷贝,而旧变量x并没有被释放。在Rust中,数值、布尔值、字符等都实现了Copy这个trait,所以此类变量的移动等于复制。在这里,调用clone函数的结果是一样的,所以没必要。

前面提到,引用是有效指针,其实还可能有无效指针,其他编程语言中经常出现的悬荡指针就是无效指针,如下所示。

fn dangle() -> &String {
    let s = "Shieber".to_string();
    &s
}
 
fn main() {
    let ref_to_nothing = dangle();
}

上面的代码在编译时会出现如下类似的报错信息(此处有删减):

error[E0106]: missing lifetime specifier
 --> dangle.rs:1:16
1 | fn dangle() -> &String {
  |                ^ expected named lifetime parameter
  | help: function's return type contains a borrowed value,
  | but there is no value for it to be borrowed from
  | help: consider using the ''static' lifetime
1 | fn dangle() -> &'static String {
  |                ~~~~~~~~

其实,通过分析代码或报错信息就能发现问题:dangle函数返回了一个无效的引用。

fn dangle() -> &String { <--- 返回无效的引用
    let s = "Shieber".to_string();
    &s  <---- 返回s的引用
}       <---- s释放

按照所有权分析,s释放是满足要求的;“&s”是一个指针,返回了似乎也没问题,最多是指针位置无效。s和“&s”是两个不同的对象,所有权系统只能按照三条规则检查数据,但不可能知道“&s”指向的地址实际是无效的。那么编译为何会出错呢?错误信息显示是缺少lifetime specifier,也就是生命周期标记。可见悬荡引用和生命周期是冲突的,所以才报错。也就是说,即使所有权系统通过了,但生命周期不通过,也会报错。

其实,Rust中的每个引用都有生命周期,也就是引用保持有效的作用域。所有权系统并不能保证数据绝对有效,因此需要通过生命周期来确保数据有效。大部分时候,生命周期是隐含的并且可以推断,正如大部分时候类型也可以自动推断一样。当作用域内有引用时,就必须注明生命周期以表明相互间的关系,这样就能确保运行时实际使用的引用绝对是有效的。

fn main() {
    let a;                // ----------+'a, a 生命周期 'a
                          //           |
    {                     //           |
        let b = 10;       // --+'b     | b 生命周期 'b
                          //   |       |
        a = &b;           //  -+       | b' 结束
    }                     //           |
                          //           |
    println!("a: {}", a); // ----------+ a' 结束
}

a引用了b,a的生命周期比b的生命周期长,所以编译器报错。要让a正常地引用b,则b的生命周期至少要长到a的生命周期结束才行。通过比较生命周期,Rust能发现不合理的引用,从而避免悬荡引用问题。

为了合法地使用变量,Rust要求所有数据都带上生命周期标记。生命周期用单引号'加字母表示,置于&后,如&'a、&mut 't。函数中的引用也需要带上生命周期标记。

fn longest<'a>(x: &'a String, y: &'a String) -> &'a String {
    if x.len() < y.len() {
        y
    } else {
        x
    }
}

尖括号中放的是生命周期参数,作为一种泛型参数,它们需要声明在函数名和参数列表的尖括号中,用于表明两个参数和返回的引用存活得一样久。因为Rust会自动推断生命周期,所以很多时候可以省略生命周期标记。

fn main() {
    // static是静态生命周期,它能存活于整个程序期间
    let s: &' static str = "Shieber";
    let x = 10;
    let y = &x; // 也可以写成let y = &'a x;
    println!("y: {}", y);
}

在本节中,我们介绍了所有权系统、借用、克隆、作用域规则和生命周期等概念。总的来说,所有权系统是一种内存管理机制;借用、克隆是使用变量的方式,它们拓展了所有权系统;而生命周期是对所有权系统的补充,用于解决所有权系统无法处理的悬荡引用等问题。

1.2.7 泛型、trait

之前我们实现了一个i32类型的add函数,如果现在需要将两个i64类型的数据相加,则需要另外实现一个i64类型的add函数,因为前一个add函数只能处理i32类型的数据。

fn add_i64(x: i64, y: i64) -> i64 {
    x + y
}

如果要为所有数字类型实现add函数,那么代码将会非常冗长并且函数命名也十分麻烦,估计需要使用像add_i8、add_i16这样的函数名。其实,加法对任何类型的数字都应该是通用的,不需要重复地写多套代码。在Rust中,用于处理这种重复性问题的工具是泛型。泛型是具体类型或属性的抽象替代,实际使用时只需要表达泛型的属性,比如其行为或如何与其他泛型相关联,而不需要在编写代码时知道实际上是什么类型。

// 泛型实现add函数
fn add<T>(x: T, y: T) -> T {
    x + y
}

上面这个add函数就采用了泛型,在函数声明中,add后面紧跟的尖括号内放的就是泛型参数T,旨在向编译器说明这是一个泛型函数。泛型参数T代表任何数字类型,函数的返回值类型和参与运算的参数类型都是T,因此可以处理所有数字类型。

将泛型的概念拓展开来,泛型不仅能用于函数,也能用于其他程序组件,比如枚举。Rust中处理错误的Result就利用了泛型参数T和E,其中的T表示成功时的返回值类型,E表示错误时的返回值类型。

enum Result<T, E> {
    Ok(T),
    Err(E),
}

对于常用的结构体,也可以采用泛型。

struct Point<T> {
    x: T,
    y: T,
}
 
let point_i = Point { x: 3, y: 4 };
let point_f = Point { x: 2.1, y: 3.2 };
// let point_m = Point { x: 2_i32, y: 3.2_f32 }; 错误

泛型参数T会约束输入参数,两个参数的类型必须一样。对于Point函数来说,如果x用i32,y用f32,就会出错。

即便用泛型为add函数实现了一套通用的代码,也还是存在问题。上面的参数T并不能保证就是数字类型,如果一种类型不支持相加,却对其调用add函数,那么必然出错。要是能限制add函数的泛型参数的类型,只让数字调用就好了。trait就是一种定义和限制泛型行为的方法,trait里面封装了各类型共享的功能。利用trait就能很好地控制各类型的行为。一个trait只能由方法、类型、常量三部分组成,它描述了一种抽象接口,这种抽象接口既可以被类型实现,也可以直接继承其默认实现。比如,定义老师和学生两种类型,它们都有打招呼的行为,于是可以实现如下trait。

trait Greete {
    // 默认实现
    fn say_hello(&self) {
        println!("Hello!");
    }
}
 
// 各自封装自身独有的属性
struct Student {
    education: i32, // 受教育年限
}
struct Teacher {
    education: i32, // 受教育年限
    teaching: i32,  // 教书年限
}
 
impl Greete for Student {}
 
impl Greete for Teacher {
    // 重载实现
    fn say_hello(&self) {
        println!("Hello, I am teacher Zhang!");
    }
}
 
// 泛型约束
fn outer_say_hello<T: Greete>(t: &T) {
    t.say_hello();
}
 
fn main() {
    let s = Student{ education: 3 };
    s.say_hello();
 
    let t = Teacher{ education: 20, teaching: 2 };
    outer_say_hello(&t);
}

其中,outer_say_hello函数加上了泛型约束T:Greete,表明只有实现了Greete特性的类型T才能调用say_hello函数,这种泛型约束又称为trait bound。前面的add函数如果要用trait bound的话,则应该类似于下面这样。

fn add<T: Addable>(x: T, y: T) -> T {
    x + y
}

此处的Addable就是一种trait bound,表明只有实现了Addable特性的类型T才可以拿来相加。这样在编译时,不符合条件的类型都会报错,不用等到运行时才报错。

当然,trait约束还有另一种写法,那就是通过impl关键字来写,如下所示。这样写的意思是,t必须是实现了Greete特性的引用。

fn outer_say_hello(t: &impl Greete) {
    t.say_hello();
}

trait可能有多个,参数也可能有多种类型,只需要通过逗号和加号就可以将多个trait bound写到一起。

fn some_func<T: trait1 + trait2, U: trait1> (x: T, y: U) {
    do_some_work();
}

为了避免尖括号里写不下多个trait bound,Rust又引入了where语法,以便将trait bound从尖括号里拿出来。

// where 语法
fn some_func<T, U> (x: T, y: U)
    where T: trait1 + trait2,
          U: trait1,
{
    do_some_work();
}

前面为Student和Teacher类型分别实现了Greete中的say_hello函数,而Student 和Teacher类型自身还封装了它们各自独有的属性。Student类型更像是直接继承了Greete,而Teacher类型则重载了Greete。对于同样的say_hello函数,Student和Teacher类型表现的是不同的状态。这里出现了封装、继承、多态的概念,Rust似乎通过impl、trait实现了类这种概念,由此也就实现了面向对象编程。

1.2.8 枚举及模式匹配

假如要设计一份调查问卷,里面涉及性别、学历、婚姻状况等选择题,于是就得提供各种选项供人选择。编程语言通常用枚举来表示从多个选项中做出选择的情形,枚举允许你通过列举所有可能的成员来定义一种类型。枚举的定义和结构体类似,都是组合各个选项,比如表示性别的枚举。

enum Gender {
    Male,
    Female,
    TransGender,
}

要使用枚举,通过“枚举类型::枚举名”就可以了。

let male = Gender::Male;

Rust中常见的枚举类型是Option,其中的Some表示有,None表示无。

enum Option<T> {
    Some(T),
    None
}

枚举除了表示值,还可以结合 match 实现流程控制。match 允许将一个值与一系列模式做比较,并根据匹配的模式执行相应的代码。模式由字面值、变量、通配符和其他内容构成。你可以把 match 表达式想象成筛子,凡是比筛子眼小的都会掉下去,其他的则留在筛子上,从而起到分类的作用。match 就是 Rust 中的筛子,只是这个筛子的眼有很多种,所以分类更细。匹配的值会通过 match 的每一个模式,在遇到第一个符合的模式时进入关联的代码块,例如枚举人民币的币值。

enum Cash {
    One,
    Two,
    Five,
    Ten,
    Twenty,
    Fifty,
    Hundred,
}
fn cash_value(cash: Cash) -> u8 {
    match cash {
        Cash::One => 1,
        Cash::Two => 2,
        Cash::Five => 5,
        Cash::Ten => 10,
        Cash::Twenty => 20,
        Cash::Fifty => 50,
        Cash::Hundred => 100,
    }
}

除了上面这样的简单匹配,match 还支持采用通配符和 _ 占位符来进行匹配。

match cash {
    Cash::One => 1,
    Cash::Two => 2,
    Cash::Five => 5,
    Cash::Ten => 10,
    other => 0, // _ => 0,
}

此处用other代替所有大于10元的面额。如果不需要用other这个值,可以用_直接占位。

使用match匹配时必须穷尽所有可能,当然用_占位是能够穷尽的,但有时候我们只关心某个匹配模式,这时用 match 就会很麻烦。好在 Rust 对 match 的这种匹配模式做了推广,引入了 if let 匹配模式。下面的 if let 匹配统计了所有面额大于 1 元的纸币的数量。

let mut greater_than_one = 0;
if let Cash::One = cash { // 只关心One这种情况
    println!("cash is one");
} else {
    greater_than_one += 1;
}

1.2.9 函数式编程

Rust 不像面向对象编程语言那样喜欢通过类来解决问题,而是推崇函数式编程。函数式编程是指将函数作为参数值或其他函数的返回值,在将函数赋值给变量之后执行。函数式编程有两个极为重要的构件,分别是闭包和迭代器。

闭包是一种可以保存变量或作为参数传递给其他函数使用的匿名函数。闭包可以在一处创建,然后在不同的上下文中执行。不同于函数,闭包允许捕获调用者作用域中的值,闭包特别适合用来定义那些只使用一次的函数。

// 定义普通函数
fn function_name(parameters) -> return_types {
    code_body;
    return_value
}
 
// 定义闭包
|parameters| {
    cody_body;
    return_value
}

闭包也可能没有参数,同时返回值也可写可不写。实际上,Rust 会自动推断闭包的参数类型和返回值类型,所以参数和返回值的类型都可以不写。为了使用闭包,你只需要将其赋值给变量,然后像调用函数一样调用它即可。比如定义如下判断奇偶的闭包。

let is_even = |x| { 0 == x % 2 };
 
let num = 10;
println!("{num} is even: {}", is_even(num));

闭包可以使用外部变量。

let val = 2;
let add_val = |x| { x + val };
 
let num = 2;
let res = add_val(num);
println!("{num} + {val} = {res}")

此处,闭包add_val 捕获了外部变量 val。闭包捕获外部变量可能是为了获取所有权,也可能是为了获取普通引用或可变引用。针对这三种情况,Rust 专门定义了三个 trait:FnOnce、FnMut和Fn。

FnOnce 会消费从周围作用域捕获的变量,也就是说,闭包会获取外部变量的所有权并在定义闭包时将其移进闭包内。Once 代表这种闭包只能被调用一次。

FnMut 会获取可变的借用值,因此可以改变其外部变量。

Fn 则获取不可变的借用值。

这里的 FnOnce、FnMut和Fn 相当于实现了所有权系统的移动、可变引用和普通引用。由于所有闭包都可以被调用至少一次,因此所有闭包都实现了 FnOnce。那些没有移动变量所有权到闭包内而只使用可变引用的闭包,则实现了 FnMut。不需要对变量进行可变访问的闭包实现了 Fn。

如果希望强制将外部变量所有权移动到闭包内,那么可以使用 move 关键字。

let val = 2;
let add_val = move |x| { x + val };
// println!("{val}"); 报错,val 已被移动到闭包 add_val 内。

前面在介绍循环时,用 for in 遍历过数组。这里的数组其实是迭代器——默认实现了迭代功能的数组。迭代器会把集合中的所有元素按照顺序一个一个传递给处理逻辑。迭代器允许对一个序列进行某些处理,并且会遍历这个序列中的每一项以决定何时结束。迭代器默认都要实现 Iterator trait。Iterator trait 有两个方法—— iter()和next(),它们是迭代器的核心功能。

iter(),用于返回迭代器。
next(),用于返回迭代器中的下一项。

根据迭代时是否可以修改数据,iter()方法有三个版本。

方法               描述
iter()            返回只读可重入迭代器,元素类型为&T
iter_mut()        返回可修改可重入迭代器,元素类型为&mut T
into_iter()       返回只读不可重入迭代器,元素类型为T

可重入是指迭代后,原始数据还能使用,不可重入则表明迭代器消费了原始数据。如果只读取值,那就实现 iter();如果还需要改变原始数据,那就实现 iter_mut();如果要将原始数据直接转换为迭代器,那就实现 into_iter() ,以获取原始数据所有权并返回一个迭代器。下面展示了三种不同类型迭代器的用法。

let nums = vec![1,2,3,4,5,6];
 
// 不改变nums中的值
for num in nums.iter() { println!("num: {num}");
println!("{:?}", nums); // 还可再次使用nums
 
// 改变nums中的值
for num in nums.iter_mut() { *num += 1; }
println!("{:?}", nums); // 还可再次使用nums
 
// 将nums转换为迭代器
for num in nums.into_iter() { println!("num: {num}"); }
// println!("{:?}", nums); 报错,nums已被迭代器消费

当然,除了转移原始数据所有权,迭代器本身也可以被消费或再生成迭代器。消费是迭代器上的一种特殊操作,其主要作用就是将迭代器转换成其他类型的值而非另一个迭代器。sum、collect、nth、find、next和fold 都是消费者,它们会对迭代器执行操作,得到最终值。既然有消费者,就必然有生产者。Rust 中的生产者就是适配器,适配器的作用是对迭代器进行遍历并生成另一个迭代器。take、skip、rev、filter、map、zip和enumerate 都是适配器。按照此定义,迭代器本身就是适配器。

// adapter_consumer.rs
 
fn main() {
    let nums = vec![1,2,3,4,5,6];
    let nums_iter = nums.iter();
    let total = nums_iter.sum::<i32>();          // 消费者
 
    let new_nums: Vec<i32> = (0..100).filter(|&n| 0 == n % 2)
                                     .collect(); // 适配器
    println!("{:?}", new_nums);
 
    // 求小于1000的能被3或5整除的所有整数之和
    let sum = (1..1000).filter(|n| n % 3 == 0 || n % 5 == 0)
                       .sum::<u32>();           // 结合适配器和消费者
    println!("{sum}");
}

为了求小于 1000 的能被 3 或 5 整除的所有整数之和,这里利用了适配器、闭包和消费者。结合这些组件,我们可以简洁、高效地完成复杂问题的求解,这就是函数式编程。这个求和任务如果采用命令式编程,则可能需要编写如下代码。

fn main() {
    let mut nums: Vec<u32> = Vec::new();
    for i in 1..1000 {
        if i % 3 == 0 || i % 5 == 0 {
            nums.push(i);
        }
    }
 
    let sum = nums.iter().sum::<u32>();
    println!("{sum}");
}

如你所见,一行代码能搞定的事,结果命令式编程却产生这么冗长的代码,而且意思不甚明了。因此,建议你多利用闭包结合迭代器、适配器、消费者进行函数式编程。

其实,函数式编程只是一种编程范式,除了函数式编程,还有命令式编程、声明式编程等。

命令式编程是面向计算机硬件的抽象,有变量、赋值语句、表达式、控制语句等,命令式编程可以理解为冯·诺依曼的指令序列。我们平时用的结构化编程和面向对象编程都是命令式编程,其主要思想是关注计算机执行的步骤,即一步一步告诉计算机先做什么,再做什么。

声明式编程则以数据结构的形式表达程序执行的逻辑,其主要思想是告诉计算机应该做什么,但不指定具体要怎么做。SQL 编程就是一种声明式编程。函数式编程和声明式编程是有关联的,因为它们的思想是一致的:只关注做什么而不关注具体怎么做。但函数式编程并不局限于声明式编程,函数式编程是面向数学的抽象,旨在将计算描述为一种表达式求值。说白了,函数式程序就是数学表达式。函数式编程中的函数并非计算机中的函数,而是数学中的函数,就像 y = f (x) 这样的自变量映射关系。函数的输出值取决于函数的输入参数值,而不依赖于其他状态。比如,x.sin() 函数用于计算 x 的正弦值,只要 x 不变,无论何时调用,调用多少次,最终的结果都是一样的。

1.2.10 智能指针

作为一种系统编程语言,Rust 不可能完全放弃指针。指针是包含内存地址的变量,用于引用或指向其他的数据,这和 C 语言中指针的概念一样。Rust 中最常见的指针是引用,而引用只是一种普通指针,除了引用数据之外,没有其他功能。智能指针则是一种数据结构,其行为类似于指针,含有元数据,在大部分情况下拥有指向的数据,提供内存管理或绑定检查等附加功能,如管理文件句柄和网络连接。Rust 中的 Vec、String 都可以看作智能指针。

智能指针最初存在于 C++ 语言中,后被 Rust 借鉴。Rust 语言为智能指针封装了两大 trait——Deref和Drop,当变量实现了 Deref 和 Drop 后,就不再是普通变量了。实现 Deref 后,变量重载了解引用运算符“*”,可以当作普通引用来使用,必要时可以自动或手动实现解引用。实现 Drop 后,变量在超出作用域时会自动从堆中释放,当然还可自定义实现其他功能,如释放文件或网络连接,类似于C++语言中的析构函数。智能指针的特征如下。

智能指针在大部分情况下具有其所指向数据的所有权。

智能指针是一种数据结构,一般使用结构体来实现。

智能指针实现了 Deref 和 Drop 两大 trait。

常见的智能指针有很多,而且你也可以实现自己需要的智能指针。用得比较频繁的智能指针或数据结构如下所示。注意,Cell< T >和 RefCell< T > 按上面的特征来说不算智能指针,但它们的概念又和智能指针太相似了,所以放到一起讨论。

Box<T>是一种独占所有权的智能指针,指向存储在堆上且类型为 T 的数据。

Rc<T>是一种共享所有权的计数智能指针,用于记录存储在堆上的值的引用数。

Arc<T>是一种线程安全的共享所有权的计数智能指针,可用于多线程。

Cell<T>是一种提供内部可变性的容器,不是智能指针,允许借用可变数据,编译时检查,参数 T 要求实现 Copy trait。

RefCell<T>也是一种提供内部可变性的容器,不是智能指针,允许借用可变数据,运行时检查,参数 T 不要求实现 Copy trait。

Weak<T>是一种与 Rc<T> 对应的弱引用类型,用于解决 RefCell<T> 中出现的循环引用。

Cow<T>是一种写时复制的枚举体智能指针,我们使用Cow<T>主要是为了减少内存分配和复制,Cow<T>适用于读多写少的场景。

智能指针中的Deref 和 Drop 是最为重要的两个 trait。下面我们通过为自定义的数据类型(类似于Box<T>)实现 Deref 和 Drop 来体会引用和智能指针的区别。我们首先来看没有实现 Deref 的情况。

// 自定义元组结构体
struct SBox<T>(T);
impl<T> SBox<T> {
    fn new(x: T) -> Self {
        Self(x)
    }
}
 
fn main() {
    let x = 10;
    let y = SBox::new(x);
    println!("x = {x}");
    // println!("y = {}", *y); 报错,*y不能解引用
} <--- x和y自动调用drop方法以释放内存,只是无输出,看不出来

下面为 SBox 实现 Deref 和 Drop。

use std::ops::Deref;
 
// 为SBox实现Deref,自动解引用
impl<T> Deref for SBox<T> {
    type Target = T; // 定义关联类型,也就是解引用后的返回值类型
    fn deref(&sefl) -> &Self::Target {
        &self.0     // .0表示访问元组结构体SBox<T>(T)中的T
    }
}
 
// 为SBox实现Drop,添加额外信息
impl<T> Drop for SBox<T> {
    fn drop(&mut self) {
        println("SBox drop itself!"); // 只输出信息
    }
}

使用情况如下:

fn main() {
    let x = 10;
    let y = SBox::new(x);
    println!("x = {x}");
    println!("y = {}", *y); // *y相当于*(y.deref())
    // y.drop(); 主动调用会造成二次释放,所以报错
} <--- x和y自动调用了 drop方法,y在自动调用drop方法时会输出 SBox drop itself!

数据存储在堆上,实现 Deref 后,可以自动解引用。

fn main() {
    let num = 10;                  // num存储在堆上
    let n_box = Box::new(num);     // n_box存储在堆上
    println!("n_box = {}", n_box); // 自动解引用堆上的数据
    println!("{}", 10 == *n_box);  // 解引用堆上的数据
}

所有权系统的规则规定了一个值在任意时刻只能有一个所有者,但在有些场景下,我们又需要让值具有多个所有者。为了应对这种情况,Rust 提供了 Rc 智能指针。Rc 是一种可共享的引用计数智能指针,能产生多所有权值。引用计数意味着通过记录值的引用数来判断值是否仍在使用。如果引用数是0,就表示值可以被清理。

如图1.2所示,3 被变量a(1)和b(2)共享。共享就像教室里的灯,最后离开教室的人负责关灯。同理,在Rc 的各个使用者中,只有最后一个使用者会清理数据。克隆 Rc 会增加引用计数,就像教室里新来了一个人一样。

use std::rc::Rc;
fn main() {
    let one = Rc::new(1);
    let one_1 = one.clone();                    // 增加引用计数
    println!("sc:{}", Rc::strong_count(one_1)); // 查看计数
}

图1.2 3被变量ab共享

Rc 可以共享所有权,但只能用于单线程。如果要在多线程中使用,Rc 就不行了。为解决这一问题,Rust 提供了 Rc 的线程安全版本 Arc(原子引用计数)。Arc 在堆上分配了一个共享所有权的 T 类型值。在 Arc 上调用 clone 函数会产生一个新的 Arc,它指向与原 Arc 相同的堆,同时增加引用计数。Arc 默认是不可变的,要想在多个线程间修改 Arc,就需要配合锁机制,如 Mutex。Rc 和 Arc 默认不能改变内部值,但有时修改内部值又是必需的,所以 Rust 提供了 Cell 和 RefCell 两个具有内部可变性的容器。内部可变性是 Rust 中的一种设计模式,允许在拥有不可变引用时修改数据,但这通常是借用规则所不允许的。为此,该模式使用 unsafe 代码来绕过 Rust 可变性和借用规则。

Cell 提供了获取和改变内部值的方法。对于实现了 Copy 特性的内部值类型,get 方法可以查看内部值。对于实现了 Default 特性的数据类型,take 方法会用默认值替换内部值,并返回被替换的值。对于所有的数据类型,replace 方法会替换并返回被替换的值,into_inner 方法则消费 Cell 并返回内部值。

// use_cell.rs
 
use std::cell::Cell;
 
struct Fields {
    regular_field: u8,
    special_field: Cell<u8>,
}
 
fn main() {
    let fields = Fields {
        regular_field: 0,
        special_field: Cell::new(1),
    };
 
    let value = 10;
    // fields.regular_field = value;  错误:Fields 是不可变的
 
    fields.special_field.set(value);
    // 尽管Fields不可变,但special_field是一个 Cell
    // 而 Cell 的内部值可被修改
 
    println!("special: {}", fields.special_field.get());
}

Cell 相当于在不可变结构体 Fields 上开了一个后门,从而能够改变内部的某些字段。

RefCell 相比 Cell 多了前缀 Ref,所以 RefCell 本身具有 Cell 的特性,RefCell 与 Cell 的区别和 Ref 有关。RefCell 不使用 get 和 set方法,而是直接通过获取可变引用来修改内部数据。

// use_refcell.rs
 
use std::cell::{RefCell, RefMut};
use std::collections::HashMap;
use std::rc::Rc;
 
fn main() {
    let shared_map: Rc<RefCell<_>> =
        Rc::new(RefCell::new(HashMap::new()));
    {
        let mut map: RefMut<_> = shared_map.borrow_mut();
        map.insert("kew", 1);
        map.insert("shieber", 2);
        map.insert("mon", 3);
        map.insert("hon", 4);
    }
 
    let total: i32 = shared_map.borrow().values().sum();
    println!("{}", total);
}

在这里,shared_map 通过 borrow_mut( )直接得到了类型为 RefMut<_> 的 map,然后直接通过调用 insert 方法往 map 里添加元素,这就修改了 shared_map。RefMut<_> 是对 HashMap 的可变借用,通过 RefCell 可以直接修改其值。

通过以上两个例子可以看出,Cell 和 RefCell 都可以修改内部值,Cell直接通过替换值来进行修改,RefCell 则通过可变引用来进行修改。既然 Cell 需要替换并移动值,所以Cell适合实现 Copy的数据类型;而 RefCell 并不移动数据,所以 RefCell 适合未实现 Copy 的数据类型。另外,Cell 在编译时检查,RefCell 在运行时检查,使用不当会产生 Panic 异常。

Rust 本身提供了内存安全保证,这意味着很难发生内存泄漏,然而 RefCell 有可能造成循环引用,进而导致内存泄漏。为防止循环引用,Rust 提供了 Weak 智能指针。Rc 每次克隆时都会增加实例的强引用计数 strong_count 的值,只有 strong_count 为 0 时实例才会被清理。循环引用中的 strong_count 永远不会为 0。而 Weak 智能指针不增加 strong_count 的值,而是增加 weak_count 的值。weak_count 无须为 0 就能清理数据,这样就解决了循环引用问题。

正因为weak_count 无须为 0 就能清理数据,所以 Weak 引用的值可能会失效。为确保Weak引用的值仍然有效,你可以调用它的 upgrade 方法,这会返回 Option<Rc<T>>。如果值未被丢弃,结果将是 Some;如果值已被丢弃,结果将是 None。下面展示了一个使用 Weak 解决循环引用的例子,Car 和 Wheel 存在相互引用,如果都用 Rc,就会出现循环引用。

// use_weak.rs
 
use std::cell::RefCell;
use std::rc::{Rc, Weak};
 
struct Car {
    name: String,
    wheels: RefCell<Vec<Weak<Wheel>>>, // 引用 Wheel
}
 
struct Wheel {
    id: i32,
    car: Rc<Car>,                     // 引用Car
}
 
fn main() {
    let car: Rc<Car> = Rc::new(
        Car {
            name: "Tesla".to_string(),
            wheels: RefCell::new(vec![]),
        }
    );
    let wl1 = Rc::new(Wheel { id:1, car: Rc::clone(&car) });
    let wl2 = Rc::new(Wheel { id:2, car: Rc::clone(&car) });
 
    let mut wheels = car.wheels.borrow_mut();
 
    // downgrade,得到Weak
    wheels.push(Rc::downgrade(&wl1));
    wheels.push(Rc::downgrade(&wl2));
 
    for wheel_weak in car.wheels.borrow().iter() {
        let wl = wheel_weak.upgrade().unwrap();      // Option
        println!("wheel {} owned by {}", wl.id, wl.car.name);
    }
}

最后,我们来看看写时复制智能指针 Cow(Copy on write)。

pub enum Cow<'a, B>
    where B: 'a + ToOwned + 'a + ?Sized {
    Borrowed(&'a B),              // 包裹引用
    Owned(<B as ToOwned>::Owned), // 包裹所有者
}

Borrowed 的含义是以不可变的方式访问借用内容,Owned 的含义是在需要可变借用或所有权时克隆一份数据。假如要过滤字符串中的所有空格,则可以写出如下代码。

// use_cow.rs
fn delete_spaces(src: &str) -> String {
    let mut dest = String::with_capacity(src.len());
    for c in src.chars() {
        if ' ' != c {
            dest.push(c);
        }
    }
    dest
}

上述代码能正常运行,但效率不高。首先,参数到底该用 &str 还是 String?如果应该使用 String却输入&str,则必须先克隆才能调用;如果输入 String,则不需要克隆就可以调用,但调用后,字符串会被移动到函数内部,外部将无法使用。不管采用哪种方法,函数内部都做了一次字符串生成和拷贝。如果字符串中没有空白字符,则最好直接原样返回,不需要拷贝。这时 Cow 就可以派上用场了,Cow 的存在就是为了减少复制,提高效率。

// use_cow.rs
use std::borrow::Cow;
fn delete_spaces2<'a>(src: &'a str) -> Cow<'a, str> {
    if src.contains(' ') {
        let mut dest = String::with_capacity(src.len());
        for c in src.chars() {
            if ' ' != c { dest.push(c); }
        }
        return Cow::Owned(dest); // 获取所有权,dest被移出
    }
    return Cow::Borrowed(src);   // 直接获取src的引用
}

下面是 Cow 的使用示例。

// use_cow.rs
fn main() {
    let s = "i love you";
    let res1 = delete_spaces(s);
    let res2 = delete_spaces2(s);
    println!("{res1}, {res2}");
}

如果字符串中没有空白字符,就会直接返回,从而避免了复制,效率更高。下面是 Cow 的更多使用示例。

// more_cow_usage.rs
 
use std::borrow::Cow;
fn abs_all(input: &mut Cow<[i32]>) {
    for i in 0..input.len() {
        let v = input[i];
        if v < 0 { input.to_mut()[i] = -v; }
    }
}
 
fn main() {
    // 只读,不写,没有发生复制操作
    let a = [0, 1, 2];
    let mut input = Cow::from(&a[..]);
    abs_all(&mut input);
    assert_eq!(input, Cow::Borrowed(a.as_ref()));
    // 写时复制,读到-1时发生复制操作
    let b = [0, -1, -2];
    let mut input = Cow::from(&b[..]);
    abs_all(&mut input);
    assert_eq!(input, Cow::Owned(vec![0,1,2]) as Cow<[i32]>);
    // 没有写时复制,因为已经拥有所有权
    let mut c = Cow::from(vec![0, -1, -2]);
    abs_all(&mut c);
    assert_eq!(c, Cow::Owned(vec![0,1,2]) as Cow<[i32]>);
}

本节涵盖的内容较为繁杂,建议你结合其他资料进一步学习。

1.2.11 异常处理

异常是任何编程语言都会遇到的现象,Rust 并没有像其他编程语言那样提供 try catch 这样的异常处理方法,而是提供了一套独特的异常处理机制。在这里,笔者将 Rust 中的失败、错误、异常等统称为异常。Rust 中的异常有4种,分别是 Option、Result、Panic和Abort。

Option 用于应对可能的失败情况,Rust 用有(Some)和无(None)来表示是否失败。比如获取某个值,但如果没有获取到,得到的结果就是 None,这时不应该报错,而是应该依据情况进行处理。失败和错误不同,前者是符合设计逻辑的,也就是说,失败本来就是可能的,所以失败不会导致程序出问题。下面是 Option 的定义。

enum Option<T> {
    Some(T),
    None,
}

Result 用于应对可恢复错误,Rust 用成功和失败来表示是否有错。出错不一定导致程序崩溃,但需要进行专门处理,以使程序继续执行。下面是 Result 的定义。

enum Result<T,E> {
    Ok(T),
    Err(E),
}

打开不存在或没有权限的文件,或者将非数字字符串转换为数字,都会得到 Err(e)。

use std::fs::File;
use std::io::ErrorKind;
 
let f = File::open("kw.txt");
 
let f = match f {
    Ok(file) => file,
    Err(err) => match err.kind() {
    ErrorKind::NotFound => match File::create("kw.txt"){
        Ok(fc) => fc,
        Err(e) => panic("Error while creating file!"),
    }
    ErrorKind::PermissionDenied => panic("No permission!"),
    other => panic!("Error while openning file"),
}

这里对可能遇到的错误逐一进行了处理,实在无法处理时,才使用 Panic。Panic 是一种不可恢复错误,表示程序遇到无法绕过的问题,这时不再继续执行,而是直接停止,以便程序员排查问题。Panic 是 Rust 提供的一种机制,用于在遇到不可恢复错误时清理内存。当遇到此类错误时,如果不想用 Panic,而是想让操作系统来清理内存,则可以使用 Abort。

上面的错误处理代码看起来非常冗长。其实可以不用 match 进行匹配,而是用 unwrap 或 expect 来处理错误,这样代码就会简洁很多。

use std::fs::File;
use std::io;
use std::io::Read;
 
fn main() {
   let f = File::open("kew.txt").unwrap();
   let f = File::open("mon.txt").expect("Open file failed!");
   // expect相比unwrap提供了一些额外信息
}

如果遇到错误时只想上抛,不想处理,则可以使用“?”。此时返回类型是 Result,成功时返回 String,错误时返回 io::Error。

fn main() {
    let s = read_from_file();
    match s {
        Err(e) => println!("{}", e),
        Ok(s) => println!("{}", s),
    }
}
 
fn read_from_file() -> Result<String, io::Error> {
    let f = File::open("kew.txt")?; // 出错时直接抛出
    let mut s = String::new();
    f.read_to_string(&mut s);
 
    Ok(s)
}

使用 Option 需要自己处理可能的失败情况,使用 Result 需要调用方处理错误情况,使用 Panic 和 Abort 则直接结束程序。在学习或测试时,使用 Panic 没问题,但在工业产品中最好使用错误处理机制,以免程序崩溃。最后提一句,其实 Option 和 Result 非常相似,Option<T> 可以看成 Result<T, ()>。

1.2.12 宏系统

Rust 中并不存在内置库函数,一切都需要自己定义。但是 Rust 实现了一套高效的宏,包括声明宏、过程宏,利用宏能完成非常多的任务。C 语言中的宏是一种简单的替换机制,很难对数据做处理; Rust 中的宏则强大得多,得到了广泛应用。比如,使用 derive宏可以为结构体添加新的功能,常用的 println!、vec!、panic! 等也是宏。

Rust 中的宏有两大类:一类是使用 macro_rules! 声明的声明宏;另一类是过程宏,过程宏又分为三小类——derive 宏、类属性宏和类函数宏。因为前面使用过声明宏和 derive 宏,所以下面只打算介绍声明宏和 derive 宏。其他的宏,请通过查阅相关资料来学习。

声明宏的格式:macro_name!()、macro_name![]、macro_name!{}。首先是宏名,然后是感叹号,最后是()、[]或{}。这些括号都可用于声明宏,但不同用途的声明宏使用的括号是不同的,比如是 vec![] 而不是 vec!(),带“()”的更像是函数,这也是 println!() 使用“()”的原因。不同的括号只是为了满足意义和形式上的统一,实际使用时任何一种都可以。

macro_rules! macro_name {
    ($matcher) => {
        $code_body;
        return_value
    };
}

在上面的宏定义中,$matcher 用于标记一些语法元素,比如空格、标识符、字面值、关键字、符号、模式、正则表达式,注意前面的符号$用于捕获值。$code_body 将利用 $matcher 中的值来进行处理,最后返回 return_value,当然也可能不返回。比如,要计算二叉树中父节点 p 的左、右子节点,可以使用如下宏,节点 p 的左、右子节点的下标应该是 2p 和 2p + 1。

macro_rules! left_child {
    ($parent:ident) => {
        $parent << 1
    };
}
macro_rules! right_child {
    ($parent:ident) => {
        ($parent << 1) + 1
    };
}

当需要计算左、右子节点时,使用 leftchild!(p)、right_child!(p) 这样的表达式即可,不用再写2 * p和2 * p + 1这样的表达式,这不但极大简化了代码,而且意义也更明确。宏里的 indent 是标记,冒号是分隔符,我们将它们统称为元变量。此处的 indent 是 parent 的属性说明,表示 parent 是一个值。只有通过元变量标记,Rust 中的宏才能正常运转。

过程宏更像是函数或一种过程。过程宏接收代码作为输入,然后在这些代码上进行操作并产生另一些代码作为输出。derive 过程宏又称派生宏,它将直接作用于代码上,为其添加新功能,使用形式如下。

#[derive(Clone)]
struct Student;

derive 过程宏通过这种标记形式为 Student 实现了 Clone 里定义的方法,这样 Student 就可以直接通过调用 clone() 方法来实现复制。这种形式其实就是 impl Clone for Student 的简易写法。derive 过程宏里也可以同时放多个 trait,这样就可以实现各种功能了。

#[derive(Debug, Clone, Copy)]
struct Student;

宏属于非常复杂的内容,建议你仅在必要且能够简化代码时使用宏。

1.2.13 代码组织及包依赖关系

Rust里面存在包、库、模块、箱(crate)等说法,并且都有对应的实体。应该说,在Rust中,用cargo new 生成的就是包,一个包里有多个目录,一个目录可以看成一个 crate。一个 crate 在经过编译后,可能是一个二进制可执行文件,也可能是一个供其他函数调用的库。一个 crate 里往往有很多“.rs”文件,这些文件被称为模块,使用这些文件或模块时需要用到 use命令。下面展示了 Rust 中的代码组织方式以及对应的各种概念。

\begin{lstlisting}[style=styleRes]
package --> crates  (dirs)     一个包里有多个crate(dir)
crate   --> modules (lib/EFL)  一个crate包含多个模块
                               这个crate可编译成库或可执行文件
module  --> file.rs (file)     模块包含一个或多个“.rs”文件
package            <-- 包
├── Cargo.toml
├── src            <-- crate
│    ├── main.rs   <-- 模块,主模块
│    ├── lib.rs    <-- 模块,库模块(可编译成库或可执行文件)
│    └── math      <-- 模块,数学函数模块math
│        ├─ mod.rs <-- 模块,为math模块引入add和sub函数
│        ├─ add.rs <-- 模块,实现math模块的add函数
│        └─ sub.rs <-- 模块,实现math模块的sub函数
└── file           <-- crate
     ├── core      <-- 模块,文件操作模块
     └── clear     <-- 模块,清理模块

Rust 中的库都是这样组织的,建议你遵循同样的方式组织代码,尤其在你需要开启一个大项目时。一个比较好的例子就是 Tikv 的代码库,你可以参考一下。

Rust 有非常多的标准库,算是 Rust 官方对某些通用编程任务给出的解决方案。学习这些标准库既能加深你对 Rust 的了解,又能为你后面解决实际问题提供思路。Rust 标准库如下:

alloc        env       i64       pin         task
any          error     i128      prelude     thread
array        f32       io        primitive   time
ascii        f64       isize     process     u8
borrow       ffi       iter      raw         u16
boxed        fmt       marker    mem         u32
cell         fs        net       ptr         u64
char         future    num       rc          u128
clone        hash      ops       result      usize
cmp          hint      option    slice       vec
collections  i8        os        str         backtrace
convert      i16       panic     string      intrinsics
default      i32       path      sync        lazy

Rust 中各种库的依赖关系(笔者进行了大幅简化)如图1.3所示。之所以给出这幅图,主要是为了说明像 Rust 这样的编程语言大体上是怎么构建起来的。

这些库是 Rust 语言的事实标准,其中一些库很基础,其他库则依赖于它们。通过分析,你会了解到 Rust 标准库可以分为三层:core层、alloc层和std层。这三层,一层依赖于一层。std 层在最上面;alloc 层处于中间,负责处理内存;最核心的是 core 层,里面定义和实现了 Rust 基础语法里的各种核心概念,它们也是 Rust 初学者必须掌握的基础内容,比如变量、数字、字符、布尔类型等。

图1.3 Rust 中各种库的依赖关系

1.3 项目:Rust密码生成器

到目前为止,我们介绍了许多 Rust 基础知识。下面我们准备实现一个综合项目,旨在帮助你进一步了解Rust 项目代码的组织、模块的导入、各种注释的使用、测试的写法、命令行工具的制作等内容。

在互联网时代,我们都有许多账号和密码,也或多或少会遇到因账号太多而记不住密码的情况。于是,有人便将各种密码设置成类似的甚至一样的。但这样做的后果是,一旦某个密码遭到破译,接下来可能就是一堆账号被盗。要防止账号被盗,常见且有效的方法是为不同账号设置不同密码,并按需更新密码。然而,在动辄几十个甚至上百个账号的情况下,很少有人能做到为每个账号都设置一个不同的密码。首先,我们或多或少会遵循一些规律,要设置大量不同的密码,其实还是比较困难的;其次,要记住所有的密码,不仅很困难,而且容易混淆。这就不难理解为什么有人干脆为自己所有的账号都设置同一个密码了。

要解决这个问题,我们可以使用密码管理软件。然而,靠别人的软件来管理自己的密码还是令人难以放心。为此,我们不妨编写一个生成密码的命令行工具。这个命令行工具生成的密码要能保证长度在16个字符以上,同时要生成像 9KbAM4QWMCcyXAar 这样无规律的密码才行。此外,生成密码应该很好操作,比如输入一个只有自己才知道的数字、字符等,就能生成很复杂的密码。

我们把这个命令行工具命名为 PasswdGenerator,可通过控制参数 seed生成默认长度为 16个字符的复杂密码,还可通过 length 参数控制密码的长度。有了这个命令行工具,我们就能为所有账号快速生成不同的密码,而且只要记得seed(自己设定的种子),就不再有忘记密码的后顾之忧。

作为命令行工具,PasswdGenerator的使用方法需要和类UNIX系统中的命令行工具一样,所以PasswdGenerator的第一个用法是“PasswdGenerator -h”或“PasswdGenerator --help”。

shieber@kew $ PasswdGenerator -h
PasswdGenerator 0.1.0
A simple password generator for any account
 
USAGE:
    PasswdGenerator [OPTIONS] --seed <SEED>
 
OPTIONS:
    -h, --help               Prints help information
    -l, --length <LENGTH>    Length of password [default: 16]
    -s, --seed <SEED>        Seed to generate password
    -V, --version            Prints version information

USAGE 是用法,OPTIONS 中的选项是控制参数。

shieber@kew $ PasswdGenerator -s wechat
wechat: GQnaoXobRwrgW21A

这里的 wechat 是种子,可以将其和账号关联起来,当然也可以添加一些自己喜欢的前缀或后缀。wechat 是输出的第一部分,用于表示密码专为此账号生成。

shieber@kew $ PasswdGenerator -s wechat -l 20
wechat: GQnaoXobRwrgW21Ac2Pb

-l参数用于控制密码长度。没有 -l参数也行,密码默认长度为16个字符,这对所有账号基本够用了。

要处理命令行参数,可以用专门处理命令行参数的 clap 库,如下所示。

use clap::Parser;
 
/// 一个简单的适用于任何账号的密码生成器
#[derive(Parser, Debug)]
#[clap(version, about, long_about= None)]
struct Args {
    /// 用于生成密码的种子
    #[clap(short, long)]
    seed: String,
 
    /// 密码长度
    #[clap(short, long, default_value_t = 16)]
    length: usize,
}

字段 seed 和 length 就是命令行参数,它们有短写和全写两种形式,length 字段的默认值为16。在使用时,可通过 Args::parse() 获取命令行参数。

有了命令行参数,接下来生成密码。如何用一个简短的种子生成类似于GQnaoXobRwrgW21A 这样的密码呢?我们首先想到的是哈希算法或 base64 编码,这类算法生成的结果和所需密码非常相似。最终,我们决定结合哈希算法和 base64 编码来生成密码。

哈希算法有很多种,虽然也有各种库可用来生成哈希值,但动手写一个哈希算法更贴合自己的需求。这里我们选用梅森素数来生成哈希值。具体过程如下:将 seed 中每个字符的 ASCII 值和对应下标值加1相乘,用得到的值对梅森素数 127 取余,然后对余数求3次幂,将求得的值作为 seed 的哈希值。梅森素数是形如Mn = 2n − 1 的素数,其中的 n 本身也是素数。注意,即使 n 是素数,2n − 1也不一定是素数,比如 11、 23、29 等素数。这种素数其实挺特殊的,需要同时满足 nMn 都是素数,所以这里用来求哈希值。下面是一些梅森素数,127 是其中的第4个梅森素数。

序号      n       Mn
1        2       3
2        3       7
3        5       31
4        7       127
5        13      8191
6        17      131071
7        19      524287
8        31      2147483647
9        61      2305843009213693951

有了哈希值,我们就可以通过某种方法将其转换为字符串。一种可行的方法是进行字符替换:用哈希值对一个长度固定的字符串(密码子)的长度求余,将余数作为下标就可以获取字符串中的字符。对哈希值循环求余,可以得到一串字符,将这些字符拼接起来就可以作为密码。为增加复杂度,也可将 seed 拆分,然后和生成的密码拼接起来。

// 将seed 中的字符和passwd拼接起来
let interval = passwd.clone();
for c in seed.chars() {
    passwd.push(c);
    passwd += &interval;
}

密码子中如果存在不适合出现在密码中的字符,则需要进行转换。可以用 base64 将其编码成 64 个可见字符,并用 * 替换 base64 中的 + 和 / 。

passwd = encode(passwd); // 编码为 base64
passwd = passwd.replace("+", "*").replace("/", "*");

如果密码长度还不够,就循环写入自身,然后用format!封装种子和密码并返回。

let interval = passwd.clone();
while passwd.len() < length {
    passwd += &interval;
}
format!("{}: {}", seed, &passwd[..length])

让我们梳理一下整个命令行工具用到的功能。一是求梅森哈希值,这可以单独放到一个名为hash的crate里;二是生成密码,这可以单独放到一个名为encryptor的crate里;三是获取命令行参数,这可以和主模块main放在一起。主模块将调用encryptor中的generate_password函数以生成密码。generate_password函数则通过调用hash中的 mersenne_hash 函数来求哈希值。

现在我们组织一下代码。cargo 里有工作空间这样一种机制,通过指定 crate 的名称就可以生成对应的 crate。首先创建并进入目录:

shieber@kew $ mkdir PasswdGenerator && cd PasswdGenerator

然后写入如下 Cargo.toml 文件,其中定义了三个 crate。

[workspace]
members = [
    "hash",
    "encryptor",
    "main",
]

然后用 cargo 生成这三个 crate。注意,生成库和主模块的参数不同。

shieber@kew $ cargo new hash --lib
    Created library hash package
shieber@kew $ cargo new encryptor --lib
    Created library encryptor package
shieber@kew $ cargo new main
    Created binary (application) main package

得到的目录如下:

.
├── Cargo.toml
├── encryptor
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
├── hash
│   ├── Cargo.toml
│   └── src
│       └── lib.rs
└── main
    ├── Cargo.toml
    └── src
        └── main.rs
 
6 directories, 7 files

其中, hash 和 encryptor 是供外部调用的 crate, main 是主模块。各模块的 lib.rs 最好只用于导出函数,具体的功能则实现在一个单独的文件里。对于 mersenne_hash,可以封装在 merhash.rs 里,用于生成密码的函数则可封装在 password.rs 里。最终得到的目录如下:

├── Cargo.toml
├── encryptor
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── password.rs
├── hash
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── merhash.rs
└── main
    ├── Cargo.toml
    └── src
        └── main.rs
 
6 directories, 9 files

各 lib.rs 需要将各自的模块导出,供 main.rs 调用。hash 库不依赖于外部,可以先实现。

// hash/src/lib.rs
 
pub mod merhash; // 导出merhash模块
 
#[cft(test)]     // 测试模块
mod tests {
    use crate::merhash::mersenne_hash;
 
    #[test]
    fn mersenne_hash_works() {
        let seed = String::from("jdxjp");
        let hash = mersenne_hash(&seed);
        assert_eq!(2000375, hash);
    }
}
 
/// hash/src/merhash.rs
 
/// 梅森哈希
///
/// 用梅森素数127计算哈希值
///
/// # 示例
/// use hash::merhash::mersenne_hash;
///
/// let seed = "jdxjp";
/// let hash = mersenne_hash(&seed);
/// assert_eq!(2000375, hash);
pub fn mersenne_hash(seed: &str) -> usize {
    let mut hash: usize = 0;
 
    for (i, c) in seed.chars().enumerate() {
        hash += (i + 1) * (c as usize);
    }
 
    (hash % 127).pow(3) - 1
}

此处使用了文档注释和模块注释,且文档注释里还有测试代码,非常便于测试和后续文档维护。

encryptor 库的完成依赖于外部库 base64。另外,为了处理错误情况,这里还引入了 anyhow 库。打开 encryptor 目录下的 Cargo.toml,写入如下内容。

[package]
name = "encryptor"
authors = ["shieber"]
version = "0.1.0"
edition = "2021"
 
[dependencies]
anyhow = "1.0.56"
base64 = "0.13.0"
hash = { path = "../hash" }

[package] 下是库的基本信息,依赖的库在 [dependencies]下,包括库 anyhow 和 base64,还有我们自己写的 hash 库。下面是 password 模块的实现代码。

// encryptor/src/lib.rs
 
pub mod password; // 导出password模块
 
// encryptor/src/password.rs
use anyhow::{bail, Error, Result};
use base64::encode;
use hash::merhash::mersenne_hash;
 
/// 密码子(长度100),可随意交换次序和增减字符,以实现个性化定制
const CRYPTO: &str = "!pqHr$*+ST1Vst_uv:?wWS%X&Y-/Z01_2.34<ABl
9ECo|x#yDE^F{GHEI[]JK>LM#NOBWPQ:RaKU@}cde56R7=8f/9gIhi,jkzmn"
 
/// 哈希密码函数,旨在利用哈希值的高次幂来获取密码子中的字符
///
/// 示例
/// use encryptor::password::generate_password;
/// let seed = "jdwnp";
/// let length = 16;
/// let passwd = generate_password(seed, length);
/// match passwd {
///     Ok(val) => println!("{:#?}", val)
///     Err(err) => println!("{:#?}", err)
/// }
pub fn generate_password(seed: &str, length: usize)
    -> Result<String, Error>
{
    // 判断需要的密码长度,不能太短
    if length < 6 {
        bail!("length must >= 6"); // 返回错误
    }
 
    // 计算mer_hash
    let p = match length {
        6..=10 => 1,
        11..=15 => 2,
        16..=20 => 3,
        _ => 3,
    };
    let mut mer_hash = mersenne_hash(seed).pow(p);
 
    // 由mer_hash求passwd
    let mut passwd = String::new();
    let crypto_len = CRYPTO.len();
    while mer_hash > 9 {
        let loc = mer_hash % crypto_len;
        let nthc = CRYPTO.chars()
                         .nth(loc)
                         .expect("Error while getting char!");
        passwd.push(nthc);
        mer_hash /= crypto_len;
    }
 
    // 将seed中的字符和passwd拼接起来
    let interval = passwd.clone();
    for c in seed.chars() {
        passwd.push(c);
        passwd += &interval;
    }
 
    // 将passwd编码为base64
    passwd = encode(passwd);
    passwd = passwd.replace("+", "*").replace("/", "*");
 
    // 长度不够,interval来凑
    let interval = passwd.clone();
    while passwd.len() < length {
        passwd += &interval;
    }
 
    // 返回前length个字符作为密码
    Ok(format!("{}: {}", seed, &passwd[..length]))
}

在主模块 main 的 Cargo.toml 中引入以下库,注意 name 参数为 PasswdGenerator。

name = "PasswdGenerator"
authors = ["shieber"]
version = "0.1.0"
edition = "2021"
 
[dependencies]
anyhow = "1.0.56"
clap = { version= "3.1.6", features= ["derive"] }
encryptor = { path = "../encryptor"}
edition = "2021"
 
[dependencies]
anyhow = "1.0.56"
clap = { version= "3.1.6", features= ["derive"] }
encryptor = { path = "../encryptor"}

最后在主模块 main 中调用 encryptor 和命令行结构体以生成密码并返回。

// passwdgenerate/src/main.rs
 
use anyhow::{bail, Result};
use clap::Parser;
use encryptor::password::generate_password;
 
/// 一个简单的适用于任何账号的密码生成器
#[derive(Parser, Debug)]
#[clap(version, about, long_about= None)]
struct Args {
    /// 用于生成密码的种子
    #[clap(short, long)]
    seed: String,
 
    /// 密码长度
    #[clap(short, long, default_value_t = 16)]
    length: usize,
}
 
fn main() -> Result<()> {
    let args = Args::parse();
    // 种子不能太短
    if args.seed.len() < 4 {
        bail!("seed {} length must >= 4", &args.seed);
    }
 
    let (seed, length) = (args.seed, args.length);
    let passwd = generate_password(&seed[..], length);
    match passwd {
        Ok(val) => println!("{}", val),
        Err(err) => println!("{}", err),
    }
 
    Ok(())
}

至此,整个程序就完成了,代码量并不大。用 cargo build --release 编译后,就能在 target/release/ 目录下找到命令行工具 PasswdGenerator。将其放到 /usr/local/bin 目录下,这样就可以在系统中的任何位置使用它了。当然,利用 cargo doc 还能生成 doc 目录,其中包含项目的详细文档。相信你通过这个小项目已经熟悉 Rust 项目代码的组织、模块的导入、各种注释的使用、测试的写法及命令行工具的制作了。虽然这个密码生成器很简单,但对于个人来说已经完全够用了。

1.4 小结

在本章中,我们回顾了Rust的基础知识。我们首先介绍了如何安装 Rust 工具链并给出了Rust的一些学习资源,然后用较大的篇幅回顾了Rust的重点基础知识,包括变量、函数、所有权、生命周期、泛型、trait、智能指针、异常处理、宏系统等,最后通过一个综合项目演示了如何用 Rust实现一个命令行工具。

本书的重点是数据结构和算法,所以本章给出的内容比较基础。如果你有不明白的地方,请参考其他Rust 基础教程或技术手册。从第2章开始,我们将正式学习数据结构和算法。

读者服务:

微信扫码关注【异步社区】微信公众号,回复“e61168”获取本书配套资源以及异步社区15天VIP会员卡,近千本电子书免费畅读。

相关图书

算者生存:商业分析的方法与实践
算者生存:商业分析的方法与实践
R语言医学多元统计分析
R语言医学多元统计分析
Python数据分析(第3版)
Python数据分析(第3版)
Python数据分析入门与实战
Python数据分析入门与实战
Python贝叶斯分析(第2版)
Python贝叶斯分析(第2版)
Excel+Python轻松掌握数据分析
Excel+Python轻松掌握数据分析

相关文章

相关课程