多伦多大学-ECE1724-Rust-高性能编程笔记-全-

多伦多大学 ECE1724 Rust 高性能编程笔记(全)

001:导论 🚀

在本节课中,我们将要学习这门课程的整体介绍,包括课程目标、为什么选择Rust语言,以及课程的基本安排。


欢迎来到EC 1724课程,这是一门关于软件工程专题的课程。在整个学期中,我们将围绕Rust语言展开讨论。这是本课程的第二次开设,去年是首次开设,实际上这也是加拿大和美国首批与Rust相关的课程之一。今天我们将首先讨论与课程相关的一些行政和后勤事宜,然后在课程的后半部分,我们将直接进入编程语言的学习,并争取在今天完成一些实际内容。

在开始课程之前,我想先简单介绍一下自己。大家能认出我在这里使用的编程语言吗?没错,这是JavaScript。我们稍后还会再提到这门语言。这里我没有写“Hello World”,而是写了我自己的名字“Baochun Li”。三十年前,我获得了清华大学的学士学位。二十五年前,我在伊利诺伊大学厄巴纳-香槟分校获得了博士学位。从那时起,我就在这里工作。我是IEEE Fellow和EIC Fellow。我的研究兴趣广泛,涉及云计算、分布式系统(特别是近年来的分布式机器学习系统)、计算机网络的基础问题、计算机安全的许多主题,以及最近的隐私保护(特别是与机器学习相关的隐私保护协议)。过去25年的大部分工作都是由一个非常小的研究团队完成的,我大约有22名学生(包括博士后)在世界各地(主要在加拿大、美国和中国香港)成为了教授。我的研究愿景是弥合理论进展与现实世界实践系统之间的差距。从今天开始,在这门关于Rust语言的课程中,我们将对现实世界的实践系统产生兴趣。这是我的网站,如果你对我的论文感兴趣,可以随时从网站下载。

现在,让我们进入课程本身。

这门课程有一个标题,一个更花哨的标题是“使用Rust构建高性能软件系统”。其基本思想是,我们希望构建一些现代软件系统。所谓“现代”,是指我们真正希望充分利用现代计算机硬件的极致性能优势,从嵌入式系统、物联网系统,一直到数据中心中由GPU驱动的服务器。为了实现这一点,我个人认为我们必须使用Rust,这也是我们在这门课程中要讨论的语言。我们不仅要理解Rust,还要深入其中,培养对这种特定编程语言的实践性、动手性和深入的理解,并了解如何实际使用这门语言来开发现实世界的现代软件系统。这就是我们在这门课程中追求的目标。

为了做到这一点,我们首先需要谈谈为什么选择Rust。我们有三页幻灯片来讨论这个问题,因为我们对这个问题有很多答案。

幻灯片上的第一个要点是:Rust能够生成可靠的软件系统。它保证的第一件事叫做内存安全。那么,如果我们谈论的是内存安全系统,我们可以防止哪些错误呢?是的,如果我们试图在释放某块内存后继续使用它,这很可能不能称为内存安全,这不安全。但如果你使用的语言不保证内存安全,这种错误会经常发生,例如C或C++等语言。所以,内存安全是我们将要保证的,我们不允许任何释放后使用内存的行为,不允许对同一块内存有双重指针,我们希望没有与内存使用相关的错误。换句话说,如果你习惯了C和C++中的核心转储、段错误等问题,在Rust中,这些都不会发生。

可靠性的另一个要求是线程安全。线程安全是更高层次的要求,内存安全是基础,而线程安全是锦上添花。但在足够现代的软件系统中,它几乎和内存安全一样是必须的。线程安全关乎并发编程。如果你有多个线程,或者在Rust中,我们将讨论比线程更高级的概念——协程。我们希望确保并发可以在没有运行时错误的情况下进行。换句话说,你在多个线程中的代码可以并发执行,而无需担心通常在C++等不保证线程安全的编程语言中会担心的运行时问题。在Rust中,我们有时称之为“无畏并发”,意思是我们可以编写并发程序而无需太多恐惧。我们可能仍然会不时犯错,但与C或C++相比,语言保证的错误要少得多。这也是我们必须支持的。这类运行时线程安全问题的一个例子是竞态条件,如果你上过操作系统课程,通常会看到,如果我们不仔细锁定代码的关键部分,就会出现竞态条件。这与线程安全有关,而在Rust中,我们没有竞态条件。

可靠性的第三种要求是静态类型的使用。之前我们展示了一个“Hello Bran”程序,那是用JavaScript写的,JavaScript没有静态类型。C编程语言有静态类型或准静态类型,但C编译器并没有非常严格地强制执行。在Rust中,我们不仅有静态类型(意味着每个变量都必须有类型),而且还有非常严格、极其严格的编译器强制执行。我们稍后会看到这一点,这是我们必须注意的,以确保可靠性。

我们还试图通过一个非常严格的编译器来实现所有这三个要点。一个严格的编译器会将我们所有关于内存安全、线程安全以及任何相关的运行时错误,尽可能多地转换为编译时错误。这就是我们想要的,也是我们从Rust编译器中得到的。

Rust是一门相对年轻的编程语言,始于2015年(大约10年前),大约从五年前(2020年和2021年)才开始成熟。我们在Rust中实际上有一个非常好的编译器,今天也会看到一些例子。

那么,为什么是Rust?我们如此关心这门语言以至于需要为它开设一门新课程的其他原因是:它性能极高,速度极快。它快到你无需担心与为性能设计的传统语言(如C)相比有任何性能损失。最好的例子就是C,C速度极快,它是每门语言设计时进行基准测试的标杆。Rust在编译后的速度与C一样快。

它还能像C一样完全控制底层细节,在这方面没有问题。所以,就超能力而言,它拥有与C编程语言相同的超能力。

没有垃圾收集器。垃圾收集器是现代发明,大约20年前随着Java的出现而出现,后来被JavaScript继承,最近还有Go编程语言。这些垃圾收集器通常没问题,直到你尝试做一些需要高速的事情。所以,直到你尝试设计一个高性能的软件系统时,它才会介入并试图拖慢你。最好的例子是,如果你使用Go作为开发Web服务器的语言,它通常运行良好,但如果你希望它拥有最佳性能,垃圾收集器每几百毫秒就会启动一次,这会在运行时略微拖慢速度。这是Rust所没有的。

它还具有非常小的内存占用,就像C一样。并且,如前所述,它保证了完整的内存安全。

最后但同样重要的是,我们有一些非常现代的东西:协程。这是为了在我们实际进行并发编程时比线程更快而设计的。线程显然受Rust支持,而协程大约从五年前(2020-2021年时间段)开始得到支持。不仅支持协程(可以将其视为轻量级线程,是线程的更快版本),而且还是协程实现中更快的替代方案。有两种不同类型的协程:基于栈的和无栈的。基于栈的协程通常由传统语言(如Go)实现,Go设计用于支持这些轻量级线程的协程。但更好的版本是无栈协程,它甚至不需要栈,实现起来更快一点,所需内存占用也更小。Rust实际上支持这种无栈协程。Rust支持这些协程的方式非常独特和先进,先进到我们可能没有时间在这门课程中涵盖。我们将看看有多少讲座可以实际涵盖这些高级主题。但这是我们将Rust用于高性能软件系统(如需要极高可扩展性的Web服务器)的主要原因之一。我们需要这些协程非常非常快。协程将是一个非常好的理由,也是Rust的一个优点。据我所知,过去五年中唯一支持无栈协程的其他编程语言是C++。但就支持而言,我不是那门语言的粉丝。Rust在这方面要好得多。

关于Rust实际优势的最后一页幻灯片。然而,我们为什么要花这么多时间学习Rust呢?因为它拥有世界上最好的生态系统和工具链。不是最好的之一,就是世界上最好的。在生态系统和工具链方面,没有什么能打败它。所谓生态系统,我指的是由其他开发者设计的第三方框架或库,这些开发者有使用这门语言开发现实世界系统的相似兴趣。他们开发了大量极其高质量的库,并得到了一些世界最大公司的支持,如亚马逊、字节跳动、谷歌、微软等。这些大公司实际上支持团队用Rust编写这些第三方库。工具链也非常好。工具链指的是,例如,我们如何在生态系统内管理我们的包,如何确保我们的代码正确使用这些框架。这听起来很简单,你只需导入库,但这并不容易,因为代码可能依赖于其他代码。这个库可能依赖于其他地方的其他第三方库,每个库都可能依赖于数百个其他库,版本之间可能存在冲突。你如何管理包依赖、版本、硬件要求等等?所有这些问题在其他语言中都是主要问题。例如Python,它仍在改进,但在过去十年中,Python在使用工具管理包依赖方面一直表现不佳。而Rust在这方面是世界上最好的。

除此之外,它还有极其有用的编译时错误信息。这是我们今天将要讨论的一个话题。它还有Cargo包管理器crates.io。crates.io有点像Python中的PyPI,管理所有公开可用的包,这是一个非常成熟的系统。虽然仍有一些小问题,但它是世界上最好的。你不可能拥有一个完美的包管理系统。

此外,它还有非常好的文档。文档通常在docs.rs上,每个包都有非常好的文档。它有一个非常好的系统,可以从代码注释中自动生成文档。所有这些都是这门语言的优点,也是我们学习Rust的原因。希望到现在为止,你已经信服了。实际上,我希望你在选择这门课程时就已经信服了,并且我希望你有足够的兴趣进入这门课程。顺便说一句,这门课程是一个相当大的挑战,Rust是一门很难学的语言,我们稍后会讨论这一点。

以下是我们打算涵盖的内容:我们将从最基础开始,不假设你有任何Rust背景或任何其他面向系统的编程语言(如C和C++)的背景。唯一的要求可能是你应该对Python或Java有一点了解。本系本科课程中的E 244(C++编程基础)并不是必需的,如果你对C++一无所知,那也没关系,我们仍然可以从基本的编程概念中学到很多东西。但我们会相当快地从基本编程概念进入Rust的一些主要主题。

首先是所有权。所有权是Rust独有的编程模型,实际上是一种思维方式。唯一另一种由编译器强制执行所有权思想的近期语言是Mojo(专为机器学习设计,也是一门编译语言)。除此之外,Rust是少数强制执行所有权思维方式的语言之一。在编程时,你必须具备这种思维方式。

我们将涵盖结构体枚举。它们在Rust中无处不在。Rust中的结构体和枚举不仅仅是C语言中的结构体,也不仅仅是简单的枚举值。它们在Rust中极其强大,尤其是枚举,其威力不容小觑,它改变了编程的思维方式。其他语言不常使用枚举,事实上,Go编程语言甚至没有枚举,你必须自己模拟枚举。但在Rust中,这是编译器支持的基本编程概念之一。

使用Result类型进行选项和错误处理。这也是我们必须涵盖的内容,因为错误处理在所有编程语言中都是一个痛点,包括C++、Python等。每当出现错误时,你如何处理?是try、catch还是finally?在Rust中,我们有自己的方式使用Result类型来处理错误,我们将讨论这一点。

我们还将讨论一些我们经常使用的基本数据结构:向量哈希映射切片字符串。这些由标准库提供。当然,还有大量额外的第三方库提供的额外数据结构,你可以使用Cargo包管理器轻松导入。

然后,我们将继续讨论一些更高级的主题:泛型。其他语言也提供泛型,但不常用,而在Rust中更常用。我们将讨论特质,它改变了一切,改变了编程的思维方式。这类似于Swift中的协议或Java中的接口,但要强大得多。它取代了C++中的对象。事实上,在Rust中,我们根本没有面向对象。Rust开发者不喜欢面向对象,Rust设计者认为面向对象不是一个好主意,事实上,他们认为这是一个坏主意。所以他们做的是,从中提取好的想法放入特质中,并抛弃坏的想法。所以我们没有类、对象等,这些都是坏主意,我们将它们抛弃,从头开始。

除此之外,我们还有一个额外的概念,需要花一整节课来讨论:生命周期。生命周期是Rust中一个非常独特的概念,然而,它对内存安全至关重要。生命周期很难理解,但你必须理解它,只需欣赏Rust如何管理内存安全的美妙之处。通常我们不会遇到它,但一旦我们足够深入,就需要面对它。即使是典型的人工智能模型也无法处理这些东西。

此外,Rust也支持函数式编程,不仅仅是基本的程序式编程。我们使用闭包迭代器来支持函数式编程。这是一种完全不同的思维方式,Rust显然是一门多范式编程语言,支持程序式和函数式编程。它不支持面向对象,但支持函数式,这是一个好主意。所以我们确实希望在课程中涵盖这一点。

此外,我们必须理解,Rust通常不手动使用堆。通常你不像在C++中那样直接使用堆(malloc、free等)。我们确实使用堆,但通常是通过使用第三方库来使用堆,它们为我们管理实际内存。但如果我们是第三方库的开发者,或者我们在做一些非常高级的事情,我们确实希望在堆上管理数据,这必须通过使用智能指针等高级方式来完成,以确保同时实现内存安全和性能。所以我们想要内存安全、性能,也想要灵活性,我们想要超能力。所有这些都将通过智能指针来完成,这是Rust中一些我们将要提到的高级主题。

如果我们有时间,还将讨论与Future无栈协程相关的无畏并发。如果没有时间,我们将不得不跳过这些。

最后但同样重要的是,我们将讨论编写Rust程序的最佳实践,即所谓的地道Rust。我们必须变得“Rusty”(熟练)。如果你“Rusty”了,就意味着你在Rust编程中很地道。还有设计模式,即编写Rust程序的好方法。这些都是我们在这门课程中必须真正深入的内容。有很多东西需要学习,非常有挑战性。希望你为此做好了准备。

这里再补充一点个人观点:我个人相信Rust是未来40年的编程语言,我今天会告诉你为什么。这就是为什么我们需要在这个学期花一些宝贵的时间学习这门语言。

首先,它既是一门高级语言,也是一门低级语言。C更像低级语言,Python更像高级语言。我们可以在Rust中同时进行高级和低级编程。在非常低的层面,如果我们需要性能,我们实际上可以为WebAssembly等平台编写Rust代码(这基本上就像Web浏览器的低级代码),我们可以为云中的容器编写代码,我们也可以为非常接近硬件的裸机芯片编写代码。它还支持我们想要的三个要素:我们想要快速(高性能),我们想要可靠性(我们提到的内存安全、线程安全),我们还想要生产力,即我们想要快速完成编程。Rust给了我们生产力。这主要是因为可靠性,因为我们没有很多运行时错误。如果它编译通过,它运行时就没有错误。这非常美妙,因为通常你至少有几千行代码,可能是几万、几十万行代码。如果你有运行时错误,即使有AI模型,调试也会花费很长时间,因为AI模型在调试运行时错误方面可能帮不上太多忙。

我们有无畏并发,希望在这门课程中有时间讨论。我们提到这将给我们带来性能,因为有了高性能,有了当今的多核CPU和GPU,我们需要并发,我们需要进行并发编程。并发编程基本上意味着我们想要真正启动所有的协程,成千上万个,充分利用我们的CPU和GPU核心。这是Rust实际上可以支持的,尤其是在CPU方面。Rust在GPU方面也在努力取得进展,但尤其是在CPU方面,我们实际上支持并发。

这里有一个最近的例子:我最喜欢的与AI模型相关的CI/CD工具之一是OpenAI的Codex CI。你们中有多少人用过Codex CI?很好,我相信你们很多人都听说过它。但它在第一次迭代中并不容易使用,因为第一次迭代是用TypeScript实现的,效果不是很好,并且在与Claude Cloud Code CI的竞争中失利。所以Codex CI之前是用TypeScript实现的。大约两个月前,团队意识到不应该用TypeScript来做。于是它用Rust重新实现,并于上个月(8月8日)与GPT-5一起发布了第一个Rust驱动的版本。根据用户在线报告,性能要好得多。这是我最喜欢的例子之一,表明Rust在性能方面真正推动了极限,并且是OpenAI和XAI for G等公司选择的编程语言。有很多例子表明,任何与机器学习相关的东西都需要性能,而Rust是我们追求性能的选择,即使只是实现一个CLI(命令行界面)。

这里有一个关于幻灯片的有趣旁注:这些幻灯片设计成HTML格式,你只需访问网站打开即可。没有PDF下载,这里有一点导航侧边栏。此外,这些浅蓝色文本部分,你可以点击。一旦点击,你将能够打开一个网站。这很好。这里有一个“了解更多”和脚注。很多时候,那里会有一个很好的链接,例如,你可以点击这个,它会给你一些关于OpenAI的Codex CI用Rust重写的进一步阅读的网站。通常,“了解更多”链接与我们正在讨论的幻灯片内容相关。所以你只需访问那个网站了解更多信息。这是这套幻灯片的一个不错的功能。我使用的幻灯片正是你将在课程网站上看到的幻灯片。事实上,这是直接从课程网站加载的。

关于人工智能的一点说明:今天我们处在这个AI大时代,显然每个人都会谈论AI并使用AI。AI被广泛认为是一种超级有效的编码助手。很多人说它实际上取代了初级或中级程序员等。这里有两个故事:一个是Anthropic(一家AI公司)的一位联合创始人在最近的一次采访中提到,Anthropic公司80%的代码是由他们自己的AI编写的。另一家初创公司叫Amcodo,我不确定你们是否听说过,这是一家有趣的公司。这家公司试图提供一个CLI(命令行界面),但市面上有几十种不同的CLI。这个特定CLI支持的功能是,它不会给你选择模型的灵活性,它会自动为你挑选模型。换句话说,你不能随意使用这个或那个模型,它会根据公司自己的判断,为你的任务挑选最佳模型。所以它基本上试图打包世界上所有的模型,给你最好的,你支付的将是使用这些模型的API密钥费用,不会比模型提供商向你收取的费用多一分钱。所以,如果你感兴趣,可以试试。最近与Amcodo联合创始人的一次采访中说,Amcodo公司95%的代码实际上也是由AI编写的。你可能会说,如果80%和95%的代码都是由AI编写的,为什么我还需要学习编程?因为80%和95%不是100%。那剩下的5%到20%的代码,最顶层的、最困难的部分,仍然留给了人类。所以我们仍然需要完成那至关重要的5%到20%的编码,至少今天如此。我的预测是,在可预见的未来也是如此。原因在于,无论AI在规划、架构设计、根据架构计划编写代码方面多么强大,它在创新方面仍然无法击败人类程序员。人类最擅长的是原创想法和创新。AI拥有大量经验,能够基于这些经验解决问题,但它无法创新,无法创造新想法。而那5%的代码需要新想法和创新,这将是我们需要完成的部分。换句话说,使用AI将非常有益,但我们仍然需要人类来完成。这对于Rust来说尤其如此。也许对于Python,你只需要1%的人类参与;对于Rust,你可能需要20%。如果你曾经尝试使用AI来编写Rust代码,你会发现,与Python相比,它失败的概率更高。对于Python,你几乎可以用AI来写代码,99%的情况下它都是正确的,你甚至不用担心。但对于Rust,很多情况下代码甚至无法编译。它无法通过编译时检查,因为我们有一个严格的编译器。如果你将结果反馈给AI,或者如果你使用生成式编码,AI会自己尝试编译,然后可能会说“抱歉,我们得重来”。在很多情况下,它会修复编译错误,但在少数情况下,它无法修复这些问题,因为设计是错误的。因此,使用AI进行Rust编程的问题被放大了,因为Rust对人类来说是一门非常难的语言,对AI来说也是一门非常难的语言。

有时。第一次说。所以,可能有一些原因导致仍有20%或5%的代码留给人类。但最主要的原因之一可能是你提到的,缺乏编写最新、最伟大代码方式的数据,但这在今天不是主要问题。AI实际上可以进行网络搜索等来弥补这种情况。但这里的问题,尤其是在Rust方面,有时我们需要一个设计。而这个设计不仅仅是任何设计,可能有几种不同的设计。我需要最好的、可能的最佳设计。这种设计需要创新,需要原创性。大型语言模型的运作方式是,它们基于最佳经验预测下一个标记。那将是一种设计,一种潜在的设计。它可能在80%或95%的情况下有效,确实有效,但在很多情况下,当它无效时,它会卡住。即使它有效,也可能不是性能方面的最佳设计。所以我们仍然需要关心很多事情。

坏消息是,为了让人来编写那5%的代码,你必须比AI更好。这很难。你必须了解这门语言最精细的细节才能创新。你真的必须理解这门语言、特性、最精细的细节,才能创新。这有点像使用AI,你必须拥有数据集才能编写好代码。同样,为了创新并拥有设计方面的原创想法,你必须学习很多。你必须学习最精细的细节,而不仅仅是高层次的内容。这有点像第22条军规,因为如果你使用AI,它会为你提供所有最精细的细节,或者你不需要学习它们。但如果你不学习最精细的细节,你就无法完成最后20%或5%的代码。换句话说,项目没有完成。但为了学习最精细的细节,你必须放弃使用AI来学习最精细的细节。这有点像第22条军规。在Rust中尤其如此,这可能是个问题。

现在,在这门课程中,我们想讨论的不仅仅是制作任何软件。我们想讨论制作完美的软件。我们想讨论使用Rust制作快速、高性能的软件。在这种情况下,AI尚无法为我们提供这块特定的软件。如果它能提供,我不知道是五年后还是十年后,那么我会取消这门课程,我们就直接用AI来做。但就目前而言,至少根据我的个人经验,我使用了所有最好的前沿模型,我使用了Claude Code、Codex AI(抱歉是Codex CI)、GPT-5 Pro等一切,当我需要时,它仍然无法帮助我完成代码,因为我项目中遇到的挑战。为了完成这些项目,我们需要Rust,我们需要学习这门语言,来完成那顶部的20%。

现在进入与这门课程相关的行政事宜。我们有一本教科书,你可能需要几乎从头到尾阅读。这是近10年来关于编程语言最好的教科书之一,它就是《Rust编程语言》,有时我们称之为“the book”。这是你必须阅读的。阅读这本书是一种享受、一种乐趣,我希望你会喜欢阅读。你不能不读这本书就了解这门语言。

为了激励你阅读这本书,我们在这门课程的讲座中,将非常紧密地遵循这本书。我会确切地告诉你我们涵盖了哪些章节和哪些部分,如果你愿意,你可以只阅读那些章节和部分。但我强烈建议你阅读整本书。它有点长,但它是唯一的书。所以我们不需要读两本或三本书,这仍然是可行的,而且是一件非常愉快的事情。

哦,它要多少钱?它不花钱,因为你只需要点击这个,它就在那里。是的,它是在线的,也可以离线使用。我们会讨论这个。

好了,这是关于教科书的内容。此外,我们有一个课程网站。课程网站现在就是这个链接。你可以点击这个链接,我希望它能用。是的,它确实能用。在课程网站上,有课程大纲。如果你看一下这个课程网站,我们确实有课程大纲。就在这里。课程大纲包含了关于这门课程的所有信息,其中很多我们今天不想花时间讨论,但显然你可以阅读。我们还有课程概述,包括截止日期和评分政策等,这些都将在课程网站内。

所有讲座我都尽量录制。有时我可能在设置硬件时出错,但我尽量不出错,尽量录制所有视频,并在讲座后立即将视频上传到课程网站。讲座幻灯片通常在讲座前提供,但它们是暂定的,可能在讲座前被修改。它们通常只是HTML格式,所以它们是实时的,在课程网站上。所以在课程网站上,进入讲座部分,选择“课程介绍”,就会看到我们正在展示的内容。例如,下一个将是“动手学习Rust”。你可以在课程网站内找到第二讲。

公告和成绩将发布在Caucus上。公告也会通过电子邮件发送给你。成绩将发布。我们有一位助教,将协助我们发布这些和成绩。

现在让我们更详细地了解一下课程大纲。我们每周有一次办公时间,在我的办公室,周一一个小时,或者通过预约。

我们有四次作业。第一次作业是反转井字棋游戏。第二次作业是搜索实用程序,是一个命令行实用程序。第三次作业是一个命令行Web客户端,就像curl一样。第四次作业是一个高性能Web服务器。顺便说一下,这些作业的设计是为了让你从零开始。换句话说,你不需要有任何Rust背景。你只需从第一行代码开始。你不需要知道任何东西,它们是为让你从零开始而设计的。这就是为什么我强调你不应该使用AI。因为如果你使用AI,那就违背了目的,对吧?如果你说“哦,我要为了学分上这门课,我用AI为作业生成代码”,当然你会得到分数。但这有什么意义呢?没有意义,因为你放弃了精心设计的作业来帮助你促进学习体验。这些作业的设计是从简单的想法开始,逐渐变得有点难,再难一点,再难一点。如果你使用AI,你可以在一天内解决这些问题,没问题,可能几个小时,生成式编码可能就能完成,很可能在几个小时内。但这有什么意义呢?这样做毫无意义。原因在于,你基本上浪费了通过动手经验学习的机会,如果你没有这些从简单想法开始的动手经验,你将永远学不会。这些都是为你构建简单事物而精心设计的。当然,你可以用AI来做,但不要这样做。

分数方面,作业占45%。前三次作业各占10%,第四次占15%。对于第四次作业,我们有一点竞赛之类的。但就性能而言,这就是为什么它在分数中所占比例稍高一些。但所有这些都相当容易,即使你完全不用AI自己编码。

你们中的一些人可能离不开AI,我理解。我可以为你破例,那就是代码补全。如果你真的想使用代码补全,可以。但我强烈建议你不要这样做。因为它会给你带来很多额外的体验。如果你不使用代码补全,这些天代码补全有点太强大了。它们基本上会给你五行甚至十行代码,一旦你开始写五个字符。这不是最好的主意。第一,你变成了代码审查者而不是编码者,对吧?你不是在编码,你是在审查别人的代码。当然,你可以敲击、敲击、敲击,然后完成。当然,写下一行,它会敲击、敲击、敲击,然后完成。当然,你可以这样做。我建议关掉它。现在,在你最喜欢的代码编辑器(如VS Code等)中,如果你关掉代码补全,你仍然会得到一些代码补全。我想你从未尝试过,但这有点像愉快的惊喜。当你输入一个函数名时,如果你完成一个函数名,它会为你完成;你输入一个关键字,它会为你做点什么。这叫做语言服务器,是LSP(语言服务器协议)。这是免费提供的,即使你不做代码补全。所以,如果你关掉代码补全,我强烈推荐,这是个好主意。这有点像当你在飞机上编程时,这些天即使在飞机上也有网络,所以这不是最好的例子。但你知道,在飞机上,我有点舍不得买网络。所以我基本上在飞机上没有任何网络的情况下编程。我实际上可以在几个小时的编码中非常高效。所以没有代码补全对你来说不会成为问题,尤其是在你习惯之后。

问题:作业是个人完成还是允许合作?不,它们是个人完成的。我们不允许合作。它们是个人完成的。它们有点太简单了,根本不应该使用合作来完成。

问题:我们可以使用外部库吗?我们将在每次编程作业中讨论这个问题。作业说明会告诉你,哪些库是你不允许使用的,如果那是你不能使用的东西。否则,你可以使用任何第三方库。但我们实际上也会在那些作业说明中建议使用一些有用的第三方库。

就这些编程作业的评分而言,由于班级规模庞大(大约120名学生,几乎像一个本科班),我们没有足够的资源进行任何手动评分。所以我们将不得不依赖于自动评分,即我们将不得不比较你的输出与标准输出。因此,我们将有一些测试用例,其中一些测试用例是公开宣布给你的,另一些测试用例是隐藏的,也将用于评分。所以你会有公开测试用例,在作业发布时收到,我们将使用这些测试用例,并且我们也会使用自己的私有隐藏测试用例。基本上,你的输出将与标准输出进行比较,并放宽一些要求(例如空格等)。这就是我们评分的方式。

关于作业还有其他问题吗?哦,问题是推荐我们使用什么IDE?很多人,大多数人使用Visual Studio Code。我觉得它太慢了。所以我使用Zed,这有点像Mac的……你知道,Zed是……那是Mac的版本。所以我使用那个。这是我自己用的。顺便说一下,Zed是一个用Rust 100%编写的编辑器。所以,你知道,超级快,比Visual Studio Code快得多。使用起来非常愉快。显然。所以我用它。我不使用VS Code就是这个原因。但大多数人使用VS Code,VS Code足够好,非常好。

关于作业还有其他问题吗?好的,如果没有更多问题,让我们休息大约7分钟。我们将在2点10分恢复。

002:创建、构建并运行第一个Rust项目 🚀

在本节课中,我们将学习如何开始使用Rust。我们将了解课程项目的要求,并亲身体验Rust编译器的强大与友好。通过一个从零开始的例子,我们将看到Rust如何引导我们编写正确的代码。最后,我们将学习使用Cargo工具链来创建、构建和管理Rust项目。


课程项目概述

课程项目是我们学习Rust的另一个重要机会。与作业相比,课程项目将有大量实质性的重叠内容,并且可能会占用我们课程中大约三分之二的时间,因此需要尽早开始。

关于课程项目提案,我们有一个较早的截止日期。你需要尽早思考要做什么。

课程项目的目标是构建比作业要求更好的东西。例如,构建一个性能优异、速度快的软件框架。项目通常由两到三名学生组成的团队完成。我强烈建议你不要单独行动,除非你非常享受独自工作的时间。但团队人数不能超过三人。

你可以使用来自Crates.io(Cargo包管理器)的第三方框架。我们将很快宣布一些启发性的项目创意供团队选择。现在,你可以开始尝试组建团队。

我们将启动一个在线讨论论坛,并会发布相关公告。我对于使用的软件非常挑剔。我唯一喜欢使用的在线讨论平台是GitHub Discussions。我将创建一个私有仓库来使用GitHub Discussions。你需要通过作业0向课程提交你的GitHub用户名,我将通过脚本将你的用户名导入GitHub,以便你获得访问该私有仓库的权限。在那里,你可以参与讨论、提问任何问题,也可以使用该论坛寻找团队成员。

团队当然可以使用自己的项目创意。事实上,我们非常鼓励你从自己的想法开始。我们对这些项目有一些最低要求,例如代码量。每个团队成员至少需要贡献500行代码。因此,一个三人团队至少需要1500行代码。这不包括注释和空行。我们将提供一个命令行工具来帮助你统计代码行数,并会在项目公告中包含这些信息。

我强烈建议你不要使用AI来编写新代码。原因在于,项目的目的不是开发用于生产环境的东西,而是作为训练工具,让你以最快、最有效的方式学习Rust。如果你使用AI编写代码,就失去了这个意义。然而,我知道你们可能对AI有依赖,因此我会稍微放宽限制。你可以使用AI来定位错误,但即便如此,我仍建议你尽量不要这样做,因为定位错误是理解语言细节的良好实践。Rust编程语言本身在定位错误方面提供了很多帮助。如果你遇到难以定位的错误,可以使用AI来节省时间。但如果你使用AI来定位错误,你需要在提交的文件中包含你使用的提示词和对话历史记录。对于课程项目,你的提交将是一个GitHub仓库(公开或私有均可),其中必须包含这些对话历史和提示词。这很容易做到,只需从对话中复制粘贴即可。

关于课程项目的更多细节,将在课程网站的项目部分公布。目前还没有,但预计在下周下一次讲座之前会发布。

最后提醒,请遵守学术诚信。不要抄袭其他学生的作业,也不要向他人提供你的代码。在当今的AI时代,检测抄袭非常容易。我只需要一个提示词就能检测出学术诚信问题,甚至不需要编写任何代码。


开始学习Rust 🦀

现在,让我们进入第二讲,以实践的方式学习Rust。

Rust是近年来最令人兴奋的语言之一。但坏消息是,它很难。它是一门复杂的语言,学习曲线陡峭。你需要忘记之前从其他编程语言学到的一些记忆,例如面向对象、类、继承等概念。Rust改变了编程的思维方式,所有权、生命周期等概念都改变了编程方式。因此,它不仅难学,而且可能不是一门适合自学的语言。不过,本课程旨在为你提供一些帮助,让你能以更好的方式学习。

我也有一些好消息要告诉你。如果你以实践的方式学习,Rust可以变得简单。我将通过今天的现场演示向你展示这一点。

Rust拥有一个我称之为“超级先进”的编译器。它先进的一个表现是它极其友好。编译器对你如此友好,感觉就像在与AI互动一样。你无需访问互联网或任何模型,仅仅是编译器在与你对话。这就是编译器的超能力,也是你可以通过实践学习的原因。我们将向你展示如何做到这一点。

对于其他语言,编译器只负责给出编译时错误。这些错误信息通常晦涩难懂,你的第一反应可能是复制粘贴这些错误信息到搜索引擎中寻求帮助。但在Rust中,你没有这个问题。你需要仔细阅读错误信息,因为编译器会成为你的老师,指导你学习Rust。它会告诉你错误是什么,在很多情况下,它甚至会帮助你修复这些错误。换句话说,它会教你如何在Rust中编程。下面我们来看一个例子。


与编译器互动示例

我们将编写一些代码。假设你了解JavaScript,它是一种非常简单的语言。我们可以用它定义一个函数。我们有一个hello函数,传入参数name,然后返回Hello 加上name。这是一行有效的JavaScript代码,非常简单。这是JavaScript的优点,它简单易懂,易于编写代码,但显然存在许多其他问题,而Rust可以解决这些问题。

让我们复制这段代码,然后打开我的编辑器。我将代码保存为main.rs。然后打开终端,尝试编译这段Rust代码。我们使用rustc main.rs命令来编译。

编译结果会显示编译时错误。显然,这是JavaScript代码,Rust不理解。但编译器会告诉你一些信息,例如“help”。整个输出是语法高亮的,非常美观,并且使用了漂亮的ASCII字符图形来提供帮助。它会说,用fn而不是function来声明函数。这非常友好,它知道你错了,并告诉你应该怎么写。

如果我们尝试编写Python代码,也会得到类似的帮助。它会说用fn而不是def来定义函数。所以,在Rust中运行Python代码也会得到同样的帮助。

让我们修复它。将function改为fn,然后再次运行编译。

现在,我们有很多错误。一开始可能不太可读,但你需要继续阅读。它会说“help: if this is a parameter name, give it a type”。这听起来很有用,因为name确实是一个参数名,我们需要给它一个类型。编译器会提示你添加类型名,并用漂亮的ASCII图形展示在哪里添加。

它还会在底部说明“some errors have detailed explanations”。每个Rust错误都有一个特定的代码,例如E0308E0601等。你可以运行rustc --explain命令来获取详细解释。但现在,我们先遵循建议:如果这是一个参数名,就给它一个类型。显然,这是一个字符串类型,所以我们给它加上string

再次编译后,我们需要向上滚动查看输出。它会说“help: a struct with a similar name exists”。哦,这太棒了!编译器告诉你有一个类似名称的结构体,但需要大写S。所以我们将string改为String

再次编译,现在它理解了String。但接下来,它说“cannot add String to &str”。第一行没问题,但第二行return "Hello " + name;是JavaScript的字符串连接方式。在Rust中,这不是正确的做法。编译器会告诉你,不能将String(大写S)与&str(字符串切片)相加。&str通常被称为“字符串切片”,它们是不同的类型。在Rust中,作为强静态类型语言,你不能将两种不同的类型相加。

但编译器会再次提供帮助:“help: create an owned String on the left and add a borrow on the right”。它甚至给出了代码建议:return "Hello ".to_owned() + &name;&符号表示从name借用,创建一个引用,这样就可以进行相加了。我们现在可能不理解为什么这样可行,但它确实给出了能工作的代码。

让我们复制并粘贴这个建议到代码中。然后再次尝试编译。现在它说“expected () (unit), found String”。这意味着我们的hello函数没有显式声明返回类型。在Rust中,所有函数都必须显式声明返回类型,否则默认是单元类型()。编译器检测到实际返回的类型不是单元类型,因此出现了类型不匹配错误。

要了解更多关于这个错误(E0308)的信息,我们可以运行rustc --explain E0308。它会给出详细的解释和示例。例如,它可能展示一个期望i32类型但传入&str类型的例子,这就是类型不匹配。错误说明指出,当表达式被用在编译器期望不同类型表达式的地方时,就会发生这种错误,最常见的情况是调用函数时传递的参数类型与函数声明中的类型不匹配。

Ctrl+C退出解释。这就是rustc --explain的功能。

现在我们来修复这个错误。根据帮助信息,我们需要添加返回类型-> String。添加后,我们可以尝试使用cargo build编译,但因为我们还没有Cargo项目,所以暂时无法使用。不过,我们可以继续使用rustc。编译器现在抱怨需要main函数。如果我们通过cargo build创建项目(接下来会讨论),就能成功构建。换句话说,函数本身没有问题。

这就是我们与编译器互动的例子。编译器是一个友好的朋友,它教我们如何编写代码。在当今的AI时代,这是一种传统但令人愉悦的方式。我非常享受这种无需AI的编程体验。当然,你可以将整个错误信息复制粘贴给AI,但为什么要这样做呢?你会失去阅读编译时错误、享受修复代码过程的机会。这就像手动驾驶汽车一样,你应该选择手动驾驶,让编译器教你。


安装与工具链 🛠️

现在,我们有一个简单的一行命令来安装Rust。你可以浏览官方网站复制粘贴这行命令来安装。

它在macOS和Linux上运行,但不支持Windows。如果你使用Windows,可以尝试使用图形界面安装程序,或者在Windows中运行Linux(例如WSL)。我已经25年没有使用Windows了,所以这取决于你。你可以选择使用Mac或通过其他方式安装。

更新Rust非常简单,使用rustup update命令即可。rustup是一个自动安装的命令行工具。我每天都会运行它,尽管Rust版本大约每五周才更新一次,但我就是喜欢运行它,享受更新到最新版本的过程。

rustup还有其他命令选项。例如,rustup doc会打开默认浏览器,显示Rust标准库的文档。rustup doc --book会直接打开《Rust编程语言》这本书。你可以在没有网络的情况下阅读,非常适合在飞机上编程或阅读。这本书写得非常优美,唯一的缺点是它不够深入。在本课程的后半部分,我们会超越这本书的范围,但它仍然是入门的好书。

让我们编译一个“Hello, world!”程序。我们可以直接使用rustc main.rs编译。遵循Unix哲学,“没有消息就是好消息”。如果一切顺利,它不会输出成功信息;只有当出现问题时,它才会告诉你。这不是典型的Rust开发方式,典型的方式是使用Cargo创建项目。


使用Cargo管理项目 📦

使用Cargo创建项目非常简单,它会生成一个包含Git仓库的完整项目结构。只需运行cargo new hello,它就会创建hello目录,其中包含Cargo.toml配置文件、src目录和.gitignore文件。

Cargo.toml是一个TOML格式的配置文件,用于指定项目的元数据,如项目名称、版本和Rust版本(edition)。Rust版本(edition)每三年发布一次(如2015、2018、2021、2024),用于保持向后兼容性。如果你指定项目使用2021版本,它将与所有其他使用2021版本的Rust代码兼容。依赖项可以手动添加,但通常我们使用Cargo命令来管理。

运行cargo build会编译你的代码及其所有依赖项。cargo clean会删除所有构建产物,回到干净的状态。cargo build --release会以发布模式构建,生成优化的二进制文件,适用于需要高性能的场景(如作业四中的高性能Web服务器)。我经常使用这三个命令,非常方便。

运行项目使用cargo run。检查项目(仅检查编译错误而不构建)使用cargo check,这比构建代码稍快一些。

Cargo的功能远不止这些,它是一个瑞士军刀般的工具。例如:

  • cargo doc: 生成本地包的文档。
  • cargo bench: 运行基准测试。
  • cargo test: 运行单元测试。
  • cargo add: 添加包依赖。例如,cargo add rand会添加最新版本的rand随机数生成器库到Cargo.toml中。你可以指定版本号,如0.9表示使用0.9.x系列的最新版本,0表示使用0.x.x系列的最新版本。
  • cargo update: 更新依赖到符合版本约束的最新版本。

Cargo是可扩展的,第三方包可以通过插件扩展Cargo的功能。Cargo是一个非常强大的系统,可以说是世界上最好的构建工具之一。

此外,我们还有cargo fmt用于格式化代码,cargo clippy用于检查代码是否符合最佳实践。我非常喜欢运行cargo clippy,因为它能利用Rust社区的丰富知识来检查我的代码。每当我的代码中有不符合最佳实践的地方,它都会告诉我,有时还会告诉我如何修复。我喜欢让代码完全没有clippy警告。

rustup管理Rust工具链本身。还有一个叫rustlings的工具,它包含许多小而快的练习,你可以在几分钟内完成,非常适合巩固Rust编程技能。它与官方的《Rust编程语言》书并行,读完书后可以做些练习。


额外资源与下一步 📚

我们还有一些额外的资源。其中一个我最喜欢的网站是“fasterthanli.me”,它提供了很多关于Rust的详细信息。“Rust by Example”也是一个官方资源,它提供了大量易于理解的示例,与《Rust编程语言》书章节顺序对应,能帮助你建立正确的思维方式。

今天我们没有时间了。从下次开始,我们将着手我们的第一个Rust项目:猜数字游戏。这是书中的第二章。在下一次讲座之前,我强烈建议你至少阅读这一章(以及我们已经涵盖的第一章),以便通过猜数字游戏的例子,对Rust编程语言的基本元素有一个良好的开端。

这就是我今天要讲的全部内容。如果有问题,欢迎课后提问。谢谢。

003:猜谜游戏 🎮

在本节课中,我们将通过构建一个简单的“猜数字”游戏来巩固之前学到的Rust基础知识。我们将从零开始,逐步实现一个程序,它会生成一个随机数,然后让用户猜测这个数字,并给出提示。通过这个项目,我们将学习如何导入库、处理用户输入、使用变量、处理错误以及进行基本的逻辑判断。


项目概述与设置

上一节我们介绍了如何使用Rust编译器和创建新项目。本节中,我们来看看如何开始我们的第一个Rust项目——“猜数字”游戏。我们将从查看初始代码和演示开始。

首先,我们需要创建一个新的Rust项目。打开终端,使用Cargo命令创建一个名为guess的项目:

cargo new guess

这个命令会创建一个新的目录,其中包含一个基础的Rust项目结构,包括Cargo.toml配置文件和src/main.rs源代码文件。


导入库与主函数

在Rust中,我们需要使用use关键字来导入标准库或其他第三方库(在Rust中称为crate)中的功能。这与Python中的import或C/C++中的include类似。

以下是项目初始代码,它导入了处理输入/输出的模块并定义了程序的主入口点:

use std::io;

fn main() {
    println!("猜数字游戏!");
    println!("请输入你的猜测:");
}
  • use std::io;:这行代码导入了标准库中的io(输入/输出)模块。io模块不属于“预导入模块”(prelude),所以我们需要显式地导入它才能使用其功能,比如读取用户输入。
  • fn main() { ... }:这是每个可执行Rust程序的入口点。fn是定义函数的关键字。
  • println!:这是一个宏(由!标识),用于向标准输出打印一行文本。宏是Rust的高级特性,这里我们只是使用标准库提供的这个宏。

变量声明与可变性

接下来,我们需要一个变量来存储用户的猜测。在Rust中,我们使用let关键字来声明变量。

以下是声明变量的代码:

let mut guess = String::new();
  • let mut guess:这里声明了一个名为guess的变量。mut关键字表示这个变量是可变的。在Rust中,变量默认是不可变的。这意味着一旦给变量赋值,就不能再改变它的值。如果你希望变量可变,必须显式地使用mut。这种设计有助于避免无意中修改了不该修改的值,是更安全的编程实践。
  • String::new():这部分调用了String类型的关联函数 new。关联函数是针对特定类型实现的函数,这里它创建并返回一个新的、空的String实例。我们通过::语法来调用关联函数。
  • 类型推断:虽然我们没有显式写出guess的类型(如let mut guess: String),但Rust编译器能够根据等号右侧的值(String::new()的返回值)推断出guess的类型是String。这是现代语言中一个方便的特性。

读取用户输入

现在,我们需要从用户那里获取输入。我们将使用之前导入的std::io模块。

以下是读取用户输入的代码:

io::stdin()
    .read_line(&mut guess)
    .expect("读取行失败");

以下是该过程的详细步骤:

  1. io::stdin():这个函数返回一个指向标准输入流(stdin)的句柄。
  2. .read_line(&mut guess):我们在输入句柄上调用read_line方法。这个方法的作用是读取一行用户输入。
    • &mut guess:这里我们将变量guess可变引用传递给read_line。引用(&)允许函数访问我们的变量而不获取其所有权。mut表示这个引用是可变的,这样read_line方法才能将读取到的字符串存入guess变量中。我们将在后续课程中深入讨论所有权和引用。
  3. .expect("读取行失败")read_line方法返回一个Result类型。Result是Rust中用于错误处理的枚举类型。它有两个变体:Ok(表示操作成功并包含结果值)和Err(表示操作失败并包含错误信息)。
    • .expect方法处理这个Result。如果ResultErrexpect会打印我们提供的错误信息(“读取行失败”)并使程序崩溃退出。如果ResultOkexpect会提取出Ok内部的值并返回它(在这个例子中,返回值是输入的字节数,但我们暂时不关心它)。这是一种简单的错误处理方式,后续我们会学习更优雅的方法。

使用第三方库生成随机数

我们的游戏需要一个秘密数字。我们将使用一个名为rand的第三方crate来生成随机数。

首先,我们需要在项目的Cargo.toml文件中添加rand作为依赖。你可以手动编辑文件,或者使用Cargo命令:

cargo add rand

这个命令会自动将最新版本的rand crate添加到Cargo.toml文件的[dependencies]部分。

关于依赖和版本管理

  • Cargo有一个强大的依赖管理系统。Cargo.toml中指定的版本号(如rand = "0.9")是语义化版本要求。
  • Cargo.lock文件会锁定所有直接和间接依赖的确切版本,确保每次构建环境一致。通常建议将Cargo.lock提交到版本控制系统。
  • 运行cargo update可以按照Cargo.toml中的版本规则,更新依赖到可用的最新版本。

添加依赖后,我们可以在代码中使用rand来生成随机数:

use rand::Rng; // 需要导入Rng trait

let secret_number = rand::thread_rng().gen_range(1..=100);
  • use rand::Rng;:这里我们导入了Rng trait。Trait定义了类型必须实现的功能集合。gen_range方法是在Rng trait中定义的,所以我们需要导入这个trait才能使用它。Trait是Rust的核心概念之一,我们将在课程后半部分深入讲解。
  • rand::thread_rng():这个函数返回一个特定于当前线程的随机数生成器。
  • .gen_range(1..=100):在随机数生成器上调用gen_range方法。参数1..=100是一个范围表达式,表示从1到100(包含两端)的闭区间。这个方法会生成并返回该范围内的一个随机整数。返回类型通常是i32(32位有符号整数)。

查阅文档:如果你不确定某个crate的函数如何使用,可以运行cargo doc --open命令。这会在浏览器中打开本地生成的文档,包含你的项目以及所有依赖crate的文档,非常方便。


比较猜测与秘密数字

现在我们已经有了用户的猜测(字符串)和秘密数字(整数)。我们需要比较它们。但首先,必须将用户输入的字符串转换为整数。

以下是转换和比较的代码:

let guess: u32 = guess.trim().parse().expect("请输入一个数字!");

match guess.cmp(&secret_number) {
    std::cmp::Ordering::Less => println!("太小了!"),
    std::cmp::Ordering::Greater => println!("太大了!"),
    std::cmp::Ordering::Equal => println!("你赢了!"),
}

以下是该过程的详细步骤:

  1. 字符串转换与阴影

    let guess: u32 = guess.trim().parse().expect("请输入一个数字!");
    
    • 我们创建了一个新的变量,也命名为guess。这叫做变量遮蔽。新的guess遮蔽了之前存储字符串的旧guess。旧的guess变量在此之后不再可访问,新的guess是一个u32类型(32位无符号整数)的变量。
    • guess.trim():去掉用户输入字符串首尾的空白字符(包括按回车产生的换行符)。
    • .parse():尝试将修剪后的字符串解析成一个数字。因为我们在变量声明中使用了类型注解: u32,所以parse知道应该解析成u32类型。
    • .expect("请输入一个数字!"):和之前一样,parse返回一个Result类型。如果解析失败(例如用户输入了字母),程序会崩溃并显示我们指定的错误信息。
  2. 使用match进行比较

    • guess.cmp(&secret_number):调用guesscmp方法,与secret_number进行比较。cmp方法返回一个Ordering类型的枚举值。Ordering也是一个枚举,有三个变体:LessGreaterEqual
    • match表达式是Rust中强大的控制流运算符。它根据cmp返回的Ordering值,选择执行对应的代码分支(在Rust中称为“臂”)。
    • match必须是穷尽的,即必须处理枚举所有可能的值。如果漏掉了Ordering::Equal这个臂,编译器会报错。这保证了代码的健壮性,避免了未处理情况的发生。

添加循环实现多次猜测

目前的程序只能猜一次。为了让游戏可以持续进行直到猜中,我们需要引入循环。

以下是添加循环后的核心代码结构:

use std::io;
use rand::Rng;
use std::cmp::Ordering;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/c81e2d16bcae0e91aac47afd71d2f6e3_21.png)

fn main() {
    println!("猜数字游戏!");

    let secret_number = rand::thread_rng().gen_range(1..=100);

    loop {
        println!("请输入你的猜测(1-100):");

        let mut guess = String::new();

        io::stdin()
            .read_line(&mut guess)
            .expect("读取行失败");

        let guess: u32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => {
                println!("请输入一个有效的数字!");
                continue;
            }
        };

        match guess.cmp(&secret_number) {
            Ordering::Less => println!("太小了!"),
            Ordering::Greater => println!("太大了!"),
            Ordering::Equal => {
                println!("恭喜你,猜对了!");
                break;
            }
        }
    }
}

以下是循环实现的关键点:

  1. loop:这是一个无限循环关键字。它会一直重复执行其代码块内的内容。
  2. 改进的错误处理:在将字符串转换为数字的部分,我们不再使用.expect在出错时直接崩溃,而是使用了更完整的match来处理Result
    let guess: u32 = match guess.trim().parse() {
        Ok(num) => num, // 解析成功,返回数字
        Err(_) => { // 解析失败,下划线 `_` 是一个通配符,匹配任何错误值
            println!("请输入一个有效的数字!");
            continue; // 跳过本次循环的剩余部分,开始下一次猜测
        }
    };
    
    这样,当用户输入非数字时,程序会提示错误并允许用户重新输入,而不是直接退出。
  3. 退出循环:当用户猜对数字时(Ordering::Equal臂),我们打印胜利信息,然后使用break关键字跳出loop循环,游戏结束。

总结

本节课中我们一起学习了如何构建一个完整的“猜数字”游戏。通过这个项目,我们实践了以下核心Rust概念:

  1. 项目创建与依赖管理:使用cargo new创建项目,在Cargo.toml中添加依赖,并通过Cargo.lock锁定版本。
  2. 导入与模块:使用use关键字导入标准库模块(如std::io)和第三方crate中的功能(如rand::Rng)。
  3. 变量与可变性:使用let声明变量,理解默认的不可变性,并使用mut关键字使变量可变。
  4. 用户输入:使用stdin().read_line()读取用户输入,并理解其返回的Result类型用于错误处理。
  5. 错误处理:初步接触Result枚举,使用.expect进行简单处理,以及使用match进行更优雅的模式匹配和错误恢复。
  6. 类型转换:使用.trim().parse()将字符串转换为数字,并处理可能发生的转换错误。
  7. 控制流:使用match表达式进行模式匹配和值比较,利用其穷尽性检查确保代码安全。使用loop创建无限循环,并用break控制循环退出。
  8. 变量遮蔽:在同一作用域内重用变量名,用于类型转换等场景。

这个简单的游戏涵盖了Rust编程的许多基础但重要的方面。在接下来的课程中,我们将以更快的节奏深入探讨字符串、所有权、结构体等更复杂的主题。

004:基础编程概念 🚀

在本节课中,我们将学习Rust语言的基础编程概念,包括变量、常量、数据类型、控制流(如循环和条件语句)以及函数。我们将通过一个简单的“猜数字”游戏示例来理解这些概念的实际应用。


变量与常量

上一节我们介绍了“猜数字”游戏的基本结构,本节中我们来看看Rust中变量和常量的定义与区别。

在Rust中,变量默认是不可变的。这意味着一旦给变量绑定了一个值,就不能再改变它。如果需要可变变量,必须使用 mut 关键字显式声明。

代码示例:

let x = 5; // 不可变变量
let mut y = 10; // 可变变量
y = y + 1; // 允许修改

常量使用 const 关键字声明,并且必须在编译时就知道其值。常量的命名约定是全部大写,用下划线分隔单词。

代码示例:

const MAX_POINTS: u32 = 100_000;

静态变量(全局变量)使用 static 关键字声明,也存在于全局作用域和数据段中,但生命周期更长。

代码示例:

static LANGUAGE: &str = "Rust";

数据类型

Rust是一种严格的静态类型语言,这意味着所有变量的类型在编译时都必须已知。这有助于将许多运行时错误转换为编译时错误,提高代码的可靠性。

以下是Rust中的主要标量数据类型:

  • 整数类型:例如 i32(有符号32位)、u32(无符号32位)、isize(架构相关有符号整数)。
  • 浮点类型:例如 f32f64
  • 布尔类型bool,值为 truefalse
  • 字符类型char,表示单个Unicode标量值,占用4个字节。

Rust还有两种复合数据类型:

  • 元组(Tuple):将多个不同类型的值组合成一个复合类型。元组长度固定。
    代码示例:
    let tup: (i32, f64, u8) = (500, 6.4, 1);
    let (x, y, z) = tup; // 解构
    let first = tup.0; // 通过索引访问
    
  • 数组(Array):固定长度且元素类型相同的集合。数组在栈上分配内存。
    代码示例:
    let a = [1, 2, 3, 4, 5];
    let first = a[0]; // 访问元素
    

对于需要动态大小的集合,应使用 Vec(向量)而非数组。


控制流

控制流结构允许根据条件执行不同的代码块,或重复执行某段代码。

条件语句:if

if 表达式允许根据条件执行代码。与许多语言不同,Rust的 if 条件不需要括号,并且 if 本身可以作为表达式返回值。

代码示例:

let number = 6;
if number % 2 == 0 {
    println!("数字是偶数");
} else {
    println!("数字是奇数");
}

// if 作为表达式
let result = if condition { 5 } else { 6 };

循环

Rust提供了几种循环方式:loopwhilefor

  • loop:无限循环,直到遇到 breakloop 也可以返回值。
    代码示例:
    let mut counter = 0;
    let result = loop {
        counter += 1;
        if counter == 10 {
            break counter * 2; // 跳出循环并返回值
        }
    };
    
  • while:条件循环,当条件为真时继续执行。
    代码示例:
    let mut number = 3;
    while number != 0 {
        println!("{}!", number);
        number -= 1;
    }
    
  • for:最常用且安全的循环,用于遍历集合。
    代码示例:
    let a = [10, 20, 30, 40, 50];
    for element in a.iter() {
        println!("值是: {}", element);
    }
    // 使用范围
    for number in (1..4).rev() {
        println!("{}!", number);
    }
    

函数

函数使用 fn 关键字定义。Rust不关心函数定义的位置。函数的返回值类型在箭头 -> 后指定。如果未指定返回值,则默认为单元类型 ()。函数体中的最后一个表达式将作为返回值,无需 return 关键字(除非提前返回)。

代码示例:

fn add(x: i32, y: i32) -> i32 {
    x + y // 最后一个表达式,作为返回值
}

fn is_divisible_by(lhs: u32, rhs: u32) -> bool {
    if rhs == 0 {
        return false; // 提前返回需要使用 return
    }
    lhs % rhs == 0 // 正常情况下的返回值
}

错误处理:Result 类型与 match

在“猜数字”游戏中,我们使用了 Result 类型和 match 表达式来优雅地处理用户输入错误,而不是让程序崩溃。

Result 是一个枚举类型,有两种变体:Ok(T) 表示操作成功并包含结果值,Err(E) 表示操作失败并包含错误信息。

match 表达式允许根据 Result 的值执行不同的代码分支。

代码示例(来自猜数字游戏):

let guess: u32 = match guess.trim().parse() {
    Ok(num) => num, // 解析成功,返回数字
    Err(_) => continue, // 解析失败,跳过此次循环迭代
};

这种方法将潜在的程序崩溃(运行时错误)转换为可控制的流程,是Rust安全哲学的核心体现。


本节课中我们一起学习了Rust的基础编程概念。我们了解了变量与常量的区别、Rust严格的静态类型系统带来的好处、如何使用元组和数组组织数据、以及通过 ifloopwhilefor 控制程序流程。最后,我们通过 Resultmatch 看到了Rust如何鼓励开发者编写更健壮、错误处理更完善的代码。这些概念是构建更复杂Rust程序的基石。

005:所有权与字符串类型

在本节课中,我们将要学习Rust编程语言中一个非常重要且独特的概念:所有权。我们将深入探讨内存管理,特别是堆内存的管理,并理解Rust如何通过所有权规则在编译时确保内存安全,从而避免常见的内存错误。

内存布局回顾

上一节我们介绍了本课程的主题。在深入Rust的所有权之前,让我们先简要回顾一下程序如何使用内存。

一个运行中的程序(进程)看到的是虚拟内存。操作系统内核确保每个进程看到的是虚拟内存。程序内存的一部分称为

栈包含了所有的局部变量和函数调用参数。这些变量是局部的,因为它们声明在函数内部。函数调用以“后进先出”的顺序组织在栈上。当我们调用一个函数时,我们将一个栈帧压入栈顶。当函数返回时,我们将其栈帧弹出栈。

栈的速度非常快,因为从栈中弹出数据只需要将栈指针移动到下一个栈帧。栈从高地址向低地址增长。

与栈相对的是。堆用于动态内存分配。程序在运行时动态地分配和释放内存。

在C语言中,我们使用 malloc 来分配内存;在C++中,我们使用 new。这些函数或关键字会返回一个指针(地址)。这个指针可以存储在栈上的一个普通变量中。当我们需要在运行时释放内存时,在C中调用 free,在C++中调用 delete

堆的分配比栈慢,因为需要搜索堆中合适的位置来存放请求大小的内存。堆从低地址向高地址增长。

栈和堆之间是未使用的虚拟内存。我们还有代码段数据段。代码段从可执行文件加载,包含可执行代码。数据段包含全局变量和常量。在Rust术语中,这些被称为 static,意味着它们是全局的。代码段和数据段的大小是固定的。

Rust的所有权规则

现在,让我们进入Rust的世界。Rust处理动态内存(堆内存)的方式与其他语言完全不同。

在深入Rust与其他编程语言的差异之前,让我先陈述游戏规则。这就是我们所说的所有权规则。我们将在本讲中通过更多演示和例子反复强调这些规则。

以下是所有权规则:

  1. Rust中的每个值都有一个变量作为其所有者
  2. 一个值在任意时刻只能有一个所有者。
  3. 当所有者离开作用域时,这个值将被丢弃

我们讨论的是堆上的值。在Rust术语中,“丢弃”意味着释放内存,相当于C++中的 delete 或C中的 free。关键区别在于,在Rust中,丢弃是自动完成的,我们不需要显式地释放内存。

变量作用域

让我们先看一个关于变量作用域的简单例子,重新理解这个概念。

fn main() {
    {                      // s 在这里无效,它尚未声明
        let s = "hello";   // 从此处起,s 是有效的
        println!("{}", s); // 使用 s
    }                      // 此作用域已结束,s 不再有效
}

变量 s 在声明和初始化之前是无效的。一旦声明,它就进入作用域并从此点开始有效。然后我们可以使用它。它的作用域在当前代码块(由花括号定义)结束时终止,此时它不再有效。

运行此代码将输出 hello

所有权与字符串类型

所有权规则只适用于堆上的值。因此,我们需要一种在堆上动态分配内存的类型。

之前我们见过很多具体类型,如整数、浮点数、元组等。它们有固定大小,可以轻松存储在栈上作为局部变量。然而,有一个例外,那就是字符串类型

字符串类型是个例外,因为在编译时,我们通常不知道该类型值的大小。该类型可能在运行时增长。之前我们见过最简单的字符串类型,即字符串字面量常量。那些字符串的大小是已知的,因为它们是常量,属于数据段,并且是不可变的。但这并不典型。

通常,我们想存储一个在编译时未知、仅在运行时才知道的字符串。例如,我们可能想从用户输入获取一个字符串。用户输入的内容只在运行时才知道。

我们将使用 String 类型。String 类型是我们之前见过的代码。

fn main() {
    let s = String::from("hello");
    println!("{}", s);
}

String::from 是一个关联函数,它创建一个新的 String。这个字符串分配在堆上。当我们讨论所有权规则时,我们将使用字符串作为例子,因为它们是分配在堆上的类型。当然,后面我们会讨论更多分配在堆上的类型。

我们还想让这个字符串可变,以展示字符串可以增长,这利用了堆上内存可以增长的特性。

fn main() {
    let mut s = String::from("hello");
    s.push_str(", world!");
    println!("{}", s);
}

push_strString 类型众多可用函数之一。它是一个方法,意味着我们使用 s.push_str(...) 的语法。它将参数(此处是 ", world!")追加到字符串 s 的末尾。打印输出将是 hello, world!

分配与释放

现在我们来讨论在堆上分配和释放字符串。

我们之前有一个使用 String::from 手动为字符串 s 分配内存的例子。我们想再次强调,当变量离开作用域时,内存将被自动释放。

当变量离开作用域时,因为在这个特定情况下,变量 s(字符串 s)在堆上,我们想要释放分配给该字符串的内存。

这与C/C++完全不同。在Rust中,不需要手动调用C中的 free 或C++中的 delete。事实上,Rust甚至没有对应的操作。Rust有一个非常高级的特性可以告诉编译器“不要释放内存”,但我们没有任何手动释放内存的操作。

我们不希望允许手动释放内存,因为这是一个非常糟糕的主意。手动释放堆内存是编程史上最糟糕的想法之一。很多事情都可能出错。

例如,如果我们释放得太晚,或者完全忘记了,就会导致内存泄漏。内存泄漏很难发现和修复。另一方面,如果我们释放得太早,然后继续使用它,就会导致无效内存访问,引发段错误(核心转储)。第三个问题是双重释放。如果我们释放了内存,又试图再次释放,可能会破坏内存分配器,导致系统崩溃。

所有这些原因都说明手动释放内存是个坏主意。因此我们不这样做。这也是为什么我们转向使用垃圾回收器(如Go、Java等语言中的)的原因——我们不想手动释放内存。垃圾回收器是一个低优先级线程,会不时运行来检查并释放需要释放的内存。

但垃圾回收也不是一个好主意。原因在于性能。每次垃圾回收器启动,都会占用一些CPU周期,减慢主线程的速度。如果运行的是Web服务器等,即使速度只慢一点,影响也很大。此外,如果垃圾回收器启动得太晚,内存可能在被回收之前就迅速分配完毕,导致内存不足。

那么,我们能否在变量离开作用域时自动分配和释放内存呢?人们对此思考了许多年,提出了很多想法,如引用计数、自动引用计数等。Rust的做法正是沿着自动化的路线,但比以往的想法(如Swift编程语言中的自动引用计数ARC)要好得多。

在Rust的方式中,如果我们看之前的例子,s 进入作用域,我们修改并增长字符串 s。只要它在作用域内,它就保持有效。当它离开作用域时,它会被自动释放。

你可能会问,为什么以前没人想到这个主意?这看起来很简单。问题是,什么是“自动释放”?是由编译器完成的,还是由某种运行时(如垃圾回收器)完成的?不,不是运行时,是编译器。编译器自动生成代码,在正确的位置(即变量离开作用域的地方)调用 drop 函数来丢弃该变量。这是由编译器在编译代码时生成的,因此没有运行时性能损失。

在C++中,实际上有非常相似的东西。如果你是一个真正高级的C++程序员,你可以实现类似Rust的功能(不完全相同,但类似)。在C++中,人们给它起了一个很难理解的名字:资源获取即初始化,简称RAII。

RAII的思想是,资源(如文件句柄、分配的内存等)在构造函数中获取,在析构函数中释放。因为C++是面向对象的编程语言,所以有构造函数和析构函数的概念。但C++编译器并不强制要求这样做,你必须是一个优秀的程序员才能做对。这就是问题所在。

在Rust中,编译器强制你做正确的事。在C++中,你必须懂行才能做对。你必须在析构函数中释放内存、关闭文件句柄等。C++标准库提供了一些辅助工具,称为智能指针,例如 unique_ptrshared_ptr。你应该使用它们,因为它们遵循RAII原则。

Rust与C++的主要区别在于,Rust甚至不允许你手动删除。在C++中,这是允许的。这种权力是个坏主意。你根本不想让任何人拥有这种权力。你希望100%的内存由编译器释放。Rust通过在编译时强制执行所有权规则来实现这一点。

这些所有权规则由编译器100%严格执行。因为它们如此严格,有些在C/C++中能做的事情在Rust中不能做。但这些事情看似提供了很大的灵活性和强大功能,实际上却不应该被允许。原因很简单:你不应该信任任何人使用那种权力。

在Rust中,这种权力被从程序员手中剥夺。在Rust中,如果代码不能编译,你就不能运行它。编译器强制执行这些非常严格的所有权规则。如果你不能运行它,就无法使用C/C++所拥有的那种权力。

栈与堆上的类型

现在让我们回到之前关于局部变量和堆的讨论,思考一下可以存储在栈上的具体类型与作为我们今天例子的 String 类型之间的区别。

对于局部变量,最简单的具体类型是整数。

fn main() {
    let x = 5;
    let y = x;
    println!("x = {}, y = {}", x, y);
}

x 是一个局部变量,它是一个具体类型,有固定大小(例如32位)。如果我们想复制这个值,我们可以简单地复制。y 将得到 x 值的副本。打印结果将是 5, 5。这是因为 xy 都是局部变量,有固定大小,所以我们可以快速复制值。这一切都在本地栈上,速度很快。我们允许这样做,没问题。

这与字符串非常不同。字符串不在栈上,这意味着字符串分配在堆上。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("{}, {}", s1, s2);
}

如果我们这样做会发生什么?这是一个编译时错误。让我们尝试一下。我们将代码复制到一个新的Rust项目中并构建它。构建会失败,错误是 E0382对已移动值的借用。这正是我们在本讲开头提到的可怕错误。

错误信息显示:移动发生是因为 s1 的类型是 String,它没有实现 Copy trait。Copy trait是什么?我们稍后会讨论。但这里的要点是,s1 的值被移动到了 s2。因为字符串没有实现 Copy trait,所以它不能被复制。既然不能被复制,就必须移动。在Rust中,你要么复制,要么移动,不能做其他事情。如果你不能复制,就必须移动。在这种情况下,s1 的值被移动到了 s2。然后你试图在移动后打印 s1。这就是错误 E0382:对已移动值的借用。移动后你不能借用该值。编译器建议使用 s1.clone(),我们稍后会讲到。

让我们深入探讨一下。当我们说 s2 = s1 时会发生什么?在说 s2 = s1 之前,让我们看看 s1 有什么。

你可以把 s1 想象成一个结构体(尽管它是在类型内部实现的,我们稍后会讨论结构体)。在这个数据结构中,我们大致有三个字段:一个指针、一个长度和一个容量

指针就像C语言中的指针,但在Rust内部,它包含一个地址。长度包含该字符串的实际字节数(对于 "hello",假设每个字符占一个字节,长度就是5)。容量是我们为此字符串分配的内存量(这里只分配了5字节)。这个结构体在哪里?在栈上还是堆上?它在栈上。那堆上有什么?堆上是字符串本身的实际存储内容,即 "hello"

栈上有一个指向堆上存储的指针。现在,如果我们声明另一个 String 类型的变量 s2s2 也有指针、长度和容量,它们也都在栈上。

如果我们想复制 String 类型,因为它包含一个指向堆存储的指针,并且没有实现 Copy trait,所以不能简单地复制。对于字符串,因为你不能复制,在Rust中,你必须移动。所以当你说 s2 = s1 时,意味着你要确保 s1 的值进入 s2。因为我们不能复制(这涉及到堆上的值),我们必须移动。这意味着 s1 将变得无效。无效化意味着如果你试图用它做任何事情,都会导致编译时错误。你甚至无法运行它。

在我们的例子中,s1 被设置为 "hello"s2 = s1。如前所述,这将导致编译时错误。一个哲学上的原因是,如果你不把它设为编译时错误,s1s2 都将在代码块结束时的 println! 之后离开作用域。我们提到过,当堆上的值离开作用域时,我们必须丢弃它。但在这个例子中,如果你不移动,那么应该丢弃哪一个?你甚至不知道。所以你必须移动。否则,就是双重释放错误。你基本上会丢弃两者。这是双重释放错误,对内存分配器非常不利。因此,s1 甚至不能有效,这是由编译器强制执行的。

我们在这里所做的是所谓的浅拷贝。这个术语在Rust中并不存在,它是从C/C++领域借用的,意思是我们复制指针,而不是指针指向的实际内容。这不是深拷贝。深拷贝会复制指针指向的内容。我们做的是浅拷贝,只复制指针,然后使原始指针无效。在Rust术语中,我们称之为移动。我们没有浅拷贝或深拷贝这些术语,我们称之为移动。

因此,当我们尝试这样做时,s1 被移动到 s2。每当你尝试再次使用 println! 时,正如我们演示的,都会出现错误:对已移动值的借用。编译器不允许你继续。

重新赋值与作用域

那么这段代码会发生什么?

fn main() {
    let mut s = String::from("hello");
    s = String::from("aloha");
    println!("{}", s);
}

我们有一个字符串 s,然后尝试给它赋一个不同的值,因为它是可变变量。这样对吗?这会编译吗?它应该不是编译时错误,会运行。当然,我们在这里所做的是将新值赋给 s。但是 "hello" 怎么了?编译器足够聪明,会说:被替换的原始字符串离开了作用域,因此被丢弃。没问题。所以这里会打印 aloha。它会编译,没有问题。这是作用域的一个新规则。当我们用新值覆盖时,原始字符串("hello")的作用域就在那里结束,并在那里被丢弃。这是一个微妙但很有道理的规则。

克隆:进行深拷贝

现在让我们看看编译器的建议:进行克隆。如果你真的想打印两个字符串,你需要进行深拷贝。但在Rust中,我们不称之为深拷贝,我们称之为克隆

克隆意味着 s1.clone()。这意味着我们不会复制指针,而是会克隆 "hello",让 s2 指向克隆后的字符串。这是一个深拷贝。这是一个非常好的主意。在其他语言中,你没有这么好的主意。在其他语言中,它们不提供这种 .clone 操作。.clone 操作是显式的,它明确告诉你这里正在进行深拷贝。如果字符串非常大,你需要意识到这种复制的性能成本。如果你在一个大的繁忙循环中进行克隆,必须非常小心。所有这些都是你的选择。

在其他语言中,赋值可能是自动的(比如C++中的拷贝构造函数),这是完全错误的方式。你希望是显式的。每次进行深拷贝时,你都调用 clone。这样其他开发者、程序员很容易理解这段代码。你知道这里有性能成本。实际上,如果你回到我们的例子,错误信息会建议:如果性能成本可以接受,考虑克隆这个值。你必须小心性能成本。这也是编译器建议的。

因为有克隆,我们没有移动。所以这里不再有错误。s1s2 都将离开作用域并被自动丢弃。

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {}, s2 = {}", s1, s2);
}

一个很好的问题是:克隆只适用于字符串吗?还是也适用于其他类型?显然,它也适用于许多其他类型。很多类型都需要克隆。然而,对于一些类型(例如你自己的类型),如果没有定义克隆,你可以通过一些简写形式自动为你的类型定义 clone。这意味着以后即使对于你自己的类型,你也可以调用 .clone 来进行深拷贝。

Copy Trait 与 Drop Trait

回到我们的整数例子,这是一个标量值,在本地栈上。我们只是进行复制,打印 5, 5。我们这里没有调用 clone。为什么我们自动复制 x,而不自动复制字符串呢?

我们之前已经讨论过,字符串和这些有固定大小的标量类型之间有区别。整数只在栈上。当然,不仅仅是整数,这些仅栈上的数据复制起来不昂贵。因此,没有理由使其无效并将其移动到其他地方,因为复制非常廉价。

在Rust中,我们可以随时创建自己的类型。当我们创建自己的类型时,也可以将类型标记为适合复制。这意味着它们将在栈上,而不是堆上。因为它们适合复制,基本上有固定大小,你可以复制它们。通过将你的类型标记为实现一个叫做 Copy trait 的东西(我们稍后会讨论trait),你的类型就可以像整数一样在栈上复制。整数实际上就实现了 Copy trait。

如果一个类型实现了 Copy trait,那么该类型的变量(例如整数)将不会被移动,而是被简单地复制。另一方面,如果你实现了 Drop trait(这是 Copy trait 的替代方案),那么你肯定不能实现 Copy trait。如果你实现了 Drop trait,意味着该类型在堆上有信息/数据,需要释放。这将在值离开作用域时由编译器自动完成。

当然,一个类型不能同时拥有 Copy trait 和 Drop trait。如果你有 Drop trait,你可以克隆,但不能简单地复制。这是一个非常简单易懂的概念。

本节课总结

在本节课中,我们一起学习了Rust所有权系统的核心概念。我们回顾了程序内存布局,理解了栈和堆的区别。重点掌握了Rust的三条所有权规则,并通过 String 类型深入探讨了值的移动、克隆与复制。我们明白了Rust如何在编译时通过严格的规则避免内存错误,如双重释放、内存泄漏和悬垂指针。我们还初步了解了 CopyDrop trait 如何影响类型的行为。这些知识是理解Rust内存安全和编写高效、安全Rust代码的基础。

006:字符串切片与引用

在本节课中,我们将学习 Rust 中关于函数调用、所有权转移以及引用的核心概念。我们将探讨如何通过引用来借用值,以避免所有权的转移,并理解 Rust 为确保内存安全和避免数据竞争而制定的严格规则。

函数调用与所有权转移

上一节我们介绍了所有权的基本概念,本节中我们来看看函数调用时所有权是如何转移的。

在 Rust 中,将一个值作为参数传递给函数,其行为与赋值操作完全相同。事实上,这不仅限于 Rust,许多其他语言(如 C)也是如此。当你将一个值作为参数传递给函数时,它本质上就是一次赋值。

让我们看一个例子。我们有一个名为 takes_ownership 的函数。我们有一个字符串 s,其值为 “hello”。我们将这个字符串作为参数传递给函数。

因为函数调用传递参数等同于赋值,而赋值对于 String 类型意味着移动(move),所以 s 的所有权会移动到函数内部。这就是为什么这个函数被命名为 takes_ownership——它取得了该字符串的所有权。

因此,在 takes_ownership 函数调用之后,变量 s 将不再有效,因为它已经被移动了。如果尝试使用它,将会导致编译时错误。

然而,对于实现了 Copy trait 的标量类型或任何具体类型,情况则不同。当你调用一个函数时,这同样等同于赋值,但值会被复制到函数的参数中。就像在 C 或 C++ 中一样,该值会被复制到栈顶属于新函数的栈帧中。这种复制之所以会发生,是因为这是对实现了 Copy trait 的类型的赋值。

在这个主程序结束时,sx 都会离开作用域。由于 s 的值已被移动,不会发生任何特殊的事情,因为它已经无效。对于 x,也不会发生什么,因为它不是堆上的类型。

让我们看看 takes_ownership 函数做了什么。它有一个 String 类型的参数,而 String 类型没有实现 Copy trait,所以 s 会移动到这个函数中。我们可以打印 s 的值。当函数结束时,s 会离开作用域,并因此被自动丢弃(drop)。对于 makes_copy 函数中的整数类型,当它离开作用域时,不会发生任何特殊操作,因为它不在堆上。

返回值与所有权转移

我们刚刚提到,通过参数调用函数等同于赋值,返回值也是如此。

假设我们有一个名为 gives_ownership 的函数,它会返回一个 String。这个字符串会从函数内部移动到外部的变量 s1 中。原因是,作为 String 类型返回的值,在赋值时必须是移动而非复制。

接着,我们有一个变量 s2,它创建了一个新的字符串 “hello”。然后我们调用一个名为 takes_and_gives_back 的函数,将 s2 传递给它。该函数会返回一个字符串,我们将返回值赋给 s3。因此,s2 被移动到函数中,而它的返回值又被移动到 s3。这里发生了大量的移动操作——无论是调用还是返回,都是移动。

在这个函数结束时,s1s3 会被丢弃。s2 已经被移动,这意味着它已经失效,不会发生任何特殊的事情。

gives_ownership 的实现很简单:我们创建一个新的字符串,然后返回它。因为我们返回它,所以将它移出了函数。takes_and_gives_back 函数接收一个 String 并返回一个 String。当它接收字符串时,所有权转移到函数内部;当它返回字符串时,所有权转移出函数。如果我们简单地返回 s,那么我们是取得所有权,然后将其归还。当 s 离开函数作用域时,不会发生任何特殊的事情,因为所有权已经转移到了函数外部。

引用的引入:借用值

以上概念听起来容易理解,但操作起来可能有些笨拙和不便。我们希望允许函数使用一个值,但不取得其所有权。

以下是一个例子,用以说明这种需求。

我们有一个字符串 s1,值为 “hello”。我们想调用一个名为 calculate_length 的函数来计算其长度,并同时返回 s1 和长度值,然后打印出字符串 s1 的长度。

calculate_length 函数的实现是:接收一个 String 类型的参数 s,返回一个元组,其中第一个元素是 String,第二个元素是表示长度的 usize 整数。

这段代码能编译吗?不能。为什么?因为当我们从函数返回某个值时,它是一次赋值。对于 String 类型的赋值,它被视为移动。所以,s 在返回时被移动了,然后我们又尝试在移动后借用它(打印),这是不允许的。

如何修复这个问题?我们可以在返回之前计算长度。修改代码,先使用 let length = s.len(); 获取长度,然后返回 (s, length)。这样修改后,代码可以正常工作。

但这仍然不够方便。如果我们能只计算并返回长度,而不需要取得和归还所有权,那就更好了。这时,引用的概念就登场了。

引用详解

引用在其他语言(如 C++)中也被称为引用,在 C 中被称为指针。但在 Rust 中,尽管名称相同,引用与 C/C++ 中的有所不同。在 Rust 中,引用永远有效,不允许空引用或悬垂引用。在 Rust 的术语中,我们称之为“借用”(borrowing)一个值。

我们通过使用 & 符号来创建引用(借用),这类似于 C++。我们也可以通过 * 来解引用,这也类似于 C++。但与 C/C++ 中的指针不同,Rust 的引用受到严格限制,永远不会是空或悬垂的。编译器会确保这一点,因此你无需担心引用出错。

现在,让我们用引用来重构之前的例子。我们有一个字符串 s1,现在我们传递 s1 的引用给 calculate_length 函数,并且只返回长度。因为函数没有取得所有权,所以当函数结束时,s(引用)离开作用域,不会发生任何丢弃操作,因为引用不拥有数据。

可变引用

现在,让我们尝试在函数内部修改我们借用的值。我们创建一个 change 函数,尝试使用 push_str 方法在字符串末尾添加内容。我们能这样做吗?

不能。你会得到一个编译时错误:“cannot borrow s as mutable, as it is behind a & reference”。这意味着,默认情况下,引用类型(就像其他具体类型一样)是不可变的。当你有一个不可变引用类型时,它所引用的数据是不可变的。因此,我们不能修改字符串。

如何修复?我们需要一个可变引用。在 Rust 中,要修改借用的值,需要在三个地方明确声明可变性:

  1. 变量本身必须是可变的:let mut s = String::from("hello");
  2. 函数参数必须接收可变引用:fn change(some_string: &mut String)
  3. 传递参数时必须使用可变引用:change(&mut s);

只有在这三处都声明了可变性后,你才能修改字符串。这明确表达了你的修改意图。

引用规则

现在有一个问题:我们能创建对同一个值的两个可变引用吗?例如:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);

这段代码能编译吗?不能。编译器会报错:“cannot borrow s as mutable more than once at a time”。这是 Rust 的借用规则之一:你不能多次可变借用同一个值。

但是,如果我们不使用第一个可变引用 r1 呢?例如,只声明 r1 但不使用它,然后声明 r2 并使用它。这时,由于 r1 在其最后一次使用(这里根本没有使用)后就结束了作用域,所以在 r2 创建时,r1 已经不在作用域内,因此代码可以编译(可能会有未使用变量的警告)。Rust 编译器具有“非词法作用域生命周期”的特性,它会智能地判断引用的实际使用范围。

另一个问题:我们能同时拥有一个可变引用和一个不可变引用吗?例如:

let mut s = String::from("hello");
let r1 = &s; // 不可变引用
let r2 = &s; // 不可变引用
let r3 = &mut s; // 可变引用
println!("{}, {}, and {}", r1, r2, r3);

不能。Rust 编译器不允许可变引用与不可变引用共存,因为可能存在数据竞争的风险。即使你的代码当前不是并发的,Rust 编译器也保守地强制执行此规则,以确保代码在任何潜在并发环境下都是安全的。

以下是引用规则的小结:

  • 在任何给定时间,对于同一数据,要么只能有一个可变引用,要么只能有多个不可变引用,不能两者兼有。
  • 引用必须始终有效(无空引用、无悬垂引用)。

这些规则在编译时强制执行,从根本上防止了数据竞争,这是 Rust 内存安全的核心保障之一。

悬垂引用

悬垂引用指的是引用所指向的数据在其生命周期结束前就被释放了。Rust 编译器会阻止这种情况发生。

例如,考虑一个返回字符串引用的函数 dangle

fn dangle() -> &String {
    let s = String::from("hello");
    &s
} // s 在这里离开作用域并被丢弃。返回的 &s 将成为悬垂引用。

这段代码无法编译。编译器会报错:“missing lifetime specifier”(缺少生命周期说明符)。这是一种告诉你不能返回悬垂引用的方式。要修复它,应该直接返回 String(转移所有权),而不是返回它的引用。

字符串切片

最后,我们讨论一个实际问题:编写一个函数,接收一个字符串,并返回它在字符串中找到的第一个单词。

一个初步的想法是返回该单词的结束索引(例如,空格的位置)。但这种方法存在问题:返回的索引在未来可能失效。例如,在获取索引后,如果原始字符串被清空(s.clear()),那么该索引就不再指向有效数据。

这引出了字符串切片的概念。字符串切片是对字符串一部分的不可变引用,其类型写作 &str。你可以使用范围语法来创建切片,例如 &s[0..5] 表示字符串 s 中从索引 0 到 4(不包括 5)的切片。

字符串切片在内部存储了一个指向起始位置的指针和切片的长度。使用切片来返回第一个单词是更好的方法,因为它直接引用了原始字符串中的数据,无需复制,并且与原始字符串的生命周期相关联,安全性由编译器保证。

我们将在后续课程中更深入地探讨字符串类型和切片。

总结

本节课中我们一起学习了:

  1. 函数调用和返回值如何导致所有权的移动。
  2. 如何使用引用(&)来借用值,以避免所有权的转移。
  3. 可变引用(&mut)的用法和限制,以及为何需要多处声明可变性。
  4. Rust 严格的引用规则:一次只能有一个可变引用,或多个不可变引用;引用必须始终有效。
  5. 这些规则如何帮助在编译时防止数据竞争和悬垂引用。
  6. 字符串切片(&str)作为解决部分字符串引用问题的优雅方案。

理解这些所有权和借用的概念是掌握 Rust 编程、编写安全高效代码的关键。

007:结构与枚举 🏗️

在本节课中,我们将继续学习所有权和字符串的相关知识,然后重点介绍Rust中的结构体(struct)和枚举(enum)类型。我们将通过实例和代码来理解这些核心概念,并学习如何在实际编程中使用它们。

所有权与字符串回顾

上一节我们介绍了所有权和引用的一些重要规则。本节中,我们先来简要回顾一下这些内容。

所有权规则主要包括以下几点:

  • 引用必须有效。
  • 你只能拥有一个可变引用,或者任意数量的不可变引用,但不能同时拥有可变和不可变引用。
  • 数据不能在引用之前离开作用域。

这些规则都由编译器强制执行,这与许多其他编程语言不同,后者通常将这些错误留到运行时。

字符串切片的应用

我们曾讨论过一个简单问题:编写一个函数,接收一个字符串并返回它找到的第一个单词。我们最初尝试返回一个索引,但这存在设计缺陷,因为无法保证该索引在未来始终有效。

这引出了字符串切片的概念。字符串切片是对字符串某部分的不可变引用。我们可以通过指定起始和结束索引(一个范围)来创建它们。

以下是创建字符串切片的几种方式:

let s = String::from("hello world");
let slice1 = &s[0..2]; // 从索引0到2(不包括2)
let slice2 = &s[..2];  // 从开头到索引2
let slice3 = &s[3..];  // 从索引3到结尾
let slice4 = &s[..];   // 整个字符串

字符串切片内部存储着起始引用和长度。我们之前提到的字符串字面量,其类型就是字符串切片 &str

使用字符串切片,我们可以重写 first_word 函数,使其返回一个切片而不是索引。这样,如果在获取切片后尝试修改原字符串,编译器就会报错,因为它违反了引用规则(不能同时拥有可变和不可变引用)。这正是Rust安全性的体现。

通过将函数参数定义为字符串切片 &str 而非字符串引用 &String,我们获得了更大的灵活性。编译器会进行隐式转换,使得字符串引用和字符串字面量都能作为参数传入。

字符串切片的概念也适用于其他集合类型,例如数组。

深入理解字符串类型

现在,让我们更深入地探讨字符串类型。String 类型在内部被存储为堆上的字节向量(Vec<u8>)。因为它是可增长的,所以数据必须放在堆上。

String 使用UTF-8编码。与C等语言不同,Rust的字符串可以轻松容纳任何Unicode字符,包括表情符号。

字符串的创建与操作

以下是创建和使用字符串的一些方法:

创建字符串:

let s1 = String::new();
let s2 = String::from("hello");
let s3 = "world".to_string();

增长字符串:
可以使用 push_str 追加一个字符串切片,或者使用 push 追加单个字符。

let mut s = String::from("foo");
s.push_str("bar");
s.push('!');

连接字符串:
可以使用 + 运算符或 format! 宏。

let s1 = String::from("Hello, ");
let s2 = String::from("world!");
let s3 = s1 + &s2; // 注意:s1 的所有权被移动了

let s1 = String::from("tic");
let s2 = String::from("tac");
let s3 = String::from("toe");
let s = format!("{}-{}-{}", s1, s2, s3); // 不会获取任何参数的所有权

使用 + 运算符时,其实现类似于 fn add(self, s: &str) -> String 方法,所以第一个操作数(self)的所有权会被移动。

字符串索引

在Rust中,不能使用索引直接访问 String 中的字符(例如 s[0])。这会导致编译错误。

这主要有两个原因:

  1. 操作习惯:在Rust中,我们更倾向于使用迭代器(如 for (i, &item) in bytes.iter().enumerate())来遍历,而不是直接索引。
  2. 编码复杂性:由于UTF-8编码,一个字符可能占用1到4个字节不等。索引 0 指的是第一个字节,而不一定是第一个字符,这会造成歧义。

如果需要按字符处理,应该使用 .chars() 方法。如果需要原始的字节,则使用 .bytes() 方法。

let hello = "नमस्ते";
for c in hello.chars() {
    println!("{}", c); // 打印每个字符
}
for b in hello.bytes() {
    println!("{}", b); // 打印每个字节
}

字符串切片 &str 允许你按字节范围获取子串,但你必须确保范围落在字符边界上,否则可能导致运行时错误。

结构体

现在,让我们开始今天的主要内容:结构体。结构体允许你将多个相关的值组合在一起,形成一个有意义的组。与元组类似,结构体的各部分可以是不同的类型;但与元组不同的是,结构体的每个部分都有一个名字,我们称之为字段

定义与实例化结构体

以下是一个定义和创建结构体实例的例子:

struct User {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}

let user1 = User {
    active: true,
    username: String::from("someusername123"),
    email: String::from("someone@example.com"),
    sign_in_count: 1,
};

要修改结构体实例的字段,整个实例必须是可变的。不能只将单个字段标记为可变。

let mut user1 = User { ... }; // 实例必须是 mut
user1.email = String::from("anotheremail@example.com");

字段初始化简写与结构体更新语法

当函数参数名与结构体字段名相同时,可以使用字段初始化简写语法:

fn build_user(email: String, username: String) -> User {
    User {
        active: true,
        username, // 字段初始化简写,等同于 username: username
        email,    // 字段初始化简写,等同于 email: email
        sign_in_count: 1,
    }
}

可以使用结构体更新语法,基于一个实例创建新实例:

let user2 = User {
    email: String::from("another@example.com"),
    ..user1 // 其余字段从 user1 复制或移动
};

使用更新语法时,所有权规则依然适用。例如,如果 user1.usernameString 类型(未实现 Copy),那么在 user2 创建后,user1.username 将失效(被移动)。如果字段是实现了 Copy 的类型(如布尔值、整数),则会被复制。

在结构体中使用引用

在结构体字段中使用引用(例如 &str 而不是 String)是可能的,但这会引入生命周期的复杂性,需要显式标注生命周期参数。对于初学者,通常建议在结构体中直接使用自有类型(如 String),以简化代码,避免过早涉及生命周期的概念。

struct User {
    username: &str, // 需要生命周期标注,例如 `username: &'a str`
    // ...
}

枚举

枚举是Rust中另一个强大的复合类型。它允许你定义一个类型,其值可以是若干可能变体中的一个。

定义枚举

一个经典的例子是IP地址,它可以是V4或V6版本:

enum IpAddrKind {
    V4,
    V6,
}

枚举的变体可以关联数据,甚至是不同类型和结构的数据:

enum IpAddr {
    V4(u8, u8, u8, u8), // 关联一个元组
    V6(String),         // 关联一个 String
}

let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

枚举的功能非常强大,Option 枚举是标准库中一个极其重要的例子,它用于处理可能存在或不存在的值,取代了其他语言中的 null

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

总结

本节课中我们一起学习了Rust中两个核心的复合数据类型:结构体和枚举。

  • 我们回顾了所有权和字符串切片,理解了如何安全地引用字符串的一部分。
  • 我们深入探讨了 String 类型的内部机制和操作方法,并明白了为何不能直接索引字符串。
  • 我们学习了如何定义和使用结构体来创建自定义类型,组织相关联的数据,包括实例化、修改、使用简写语法和更新语法。
  • 我们初步认识了枚举类型,它让我们能够定义一组可能的值,并且每个变体可以关联不同的数据。

结构体和枚举是构建Rust程序复杂数据模型的基石,在后续课程中,我们还将看到如何为它们定义方法。掌握这些概念对于编写清晰、安全的Rust代码至关重要。

008:面向对象语言的特征

在本节课中,我们将学习Rust中面向对象编程的核心特征,包括结构体(structs)的特殊形式、方法定义、枚举(enums)的强大功能,以及如何使用Option枚举来安全地处理可能不存在的值。


结构体的特殊形式

上一节我们介绍了常规结构体的使用,本节中我们来看看结构体的几种特殊形式。

元组结构体

元组结构体类似于元组,但拥有一个类型名称。与普通元组不同,元组结构体提供了编译时的类型检查,使得代码更严格且更具可读性。

struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

在上面的例子中,ColorPoint虽然都由三个i32元素组成,但它们是不同的类型,编译器会进行类型检查。

类单元结构体

类单元结构体不包含任何数据,类似于单元类型()。它们通常用于实现特定的特质(trait)而不关联数据,这在后续讨论特质时会非常有用。

struct AlwaysEqual;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/63d77797e97179e63cf8099e840ab5b5_5.png)

let subject = AlwaysEqual;

打印结构体内容

默认情况下,Rust不知道如何打印自定义结构体。尝试直接打印会导致编译错误,提示该结构体未实现std::fmt::Display特质。

为了调试,我们可以为结构体派生Debug特质。这通过添加#[derive(Debug)]属性实现,它允许我们使用{:?}{:#?}格式化符来打印结构体。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    println!("rect1 is {:?}", rect1); // 简单调试打印
    println!("rect1 is {:#?}", rect1); // 美化调试打印
}

为结构体定义方法

我们可以将函数与结构体关联起来,使其成为方法。方法与普通函数的区别在于,它们的第一个参数总是self(或其变体),代表调用该方法的实例。

以下是定义和使用方法的步骤:

  1. 使用impl块为结构体定义方法。
  2. 方法的第一个参数是self&self&mut self,分别表示获取所有权、不可变借用或可变借用。
  3. 使用点号(.)语法调用实例上的方法。
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    // 一个借用实例的方法
    fn area(&self) -> u32 {
        self.width * self.height
    }

    // 一个可以修改实例的方法
    fn set_width(&mut self, width: u32) {
        self.width = width;
    }

    // 一个获取器方法,方法名可以与字段名相同
    fn width(&self) -> u32 {
        self.width
    }
}

fn main() {
    let mut rect = Rectangle { width: 30, height: 50 };
    println!("Area: {}", rect.area());
    rect.set_width(10);
    println!("Width via getter: {}", rect.width());
}

Rust编译器会自动处理引用和解引用,因此无论是直接实例、引用还是可变引用,都可以使用.method()语法调用方法,无需像C++那样使用->操作符。

一个结构体可以有多个impl块。


关联函数

关联函数是定义在impl块中但不以self为第一个参数的函数。它们通常用作构造函数,通过::语法调用。

impl Rectangle {
    fn square(size: u32) -> Rectangle {
        Rectangle {
            width: size,
            height: size,
        }
    }
}

fn main() {
    let sq = Rectangle::square(10);
}

枚举

枚举允许你通过列举可能的变体来定义一种类型。一个枚举的值只能是其定义的变体之一。

基础枚举

最简单的枚举变体不包含任何数据。

enum IpAddrKind {
    V4,
    V6,
}

let four = IpAddrKind::V4;
let six = IpAddrKind::V6;

包含数据的枚举

Rust枚举的强大之处在于其变体可以直接包含数据,可以是任意类型,如字符串、元组、结构体,甚至是另一个枚举。

enum IpAddr {
    V4(u8, u8, u8, u8), // 包含一个元组
    V6(String),         // 包含一个字符串
}

let home = IpAddr::V4(127, 0, 0, 1);
let loopback = IpAddr::V6(String::from("::1"));

这种方式比使用一个包含kind字段和address字段的结构体更加优雅和类型安全,因为它将数据和变体类型紧密绑定在一起。

为枚举定义方法

和结构体一样,我们也可以使用impl块为枚举定义方法。

enum Message {
    Quit,
    Write(String),
}

impl Message {
    fn call(&self) {
        // 方法体
    }
}

let m = Message::Write(String::from("hello"));
m.call();

Option 枚举

Option枚举是Rust标准库中用于处理值可能存在也可能不存在的情况的核心类型。它消除了null引用的风险,被视作修复了“价值十亿美元的错误”。

Option<T>的定义如下:

enum Option<T> {
    Some(T),
    None,
}
  • Some(T):表示存在一个类型为T的值。
  • None:表示不存在任何值。

为什么 Option 比 Null 更好?

  1. 编译时检查Option<T>T是两种完全不同的类型。编译器不允许你将一个Option<T>直接当作T来使用,从而避免了在期望有值的地方意外使用了None
    let x: i8 = 5;
    let y: Option<i8> = Some(5);
    let sum = x + y; // 编译错误!无法将 `Option<i8>` 与 `i8` 相加
    
  2. 显式处理:任何可能为“空”的值都必须显式地包装在Option中。要使用其中的值,你必须显式地处理None的情况,这迫使开发者考虑所有可能的状态。

从 Option 中获取值

有多种方式可以安全地从Option中提取值,最常用的是match表达式。

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

let five = Some(5);
let six = plus_one(five); // Some(6)
let none = plus_one(None); // None

match表达式强制你为Option的每一种变体(SomeNone)提供处理分支,确保了代码的完备性。


本节课中我们一起学习了Rust中结构体的特殊形式、如何定义和使用方法、枚举的强大功能及其数据承载能力,以及如何使用Option枚举来安全、显式地处理可能缺失的值,从而在编译阶段避免许多常见错误。

009:错误处理

在本节课中,我们将要学习Rust中强大的错误处理机制。我们将从回顾枚举(enum)的强大功能开始,特别是Option枚举如何优雅地处理空值问题。接着,我们将深入探讨Rust如何区分可恢复错误与不可恢复错误,并介绍处理这两种错误的核心方法:Result枚举和panic!宏。通过本节课的学习,你将理解Rust如何通过其类型系统,从根本上避免其他语言中常见的错误处理陷阱。

回顾枚举与Option类型

上一节我们介绍了枚举的基本概念,本节中我们来看看它在处理“空值”问题上的具体应用。

在Rust中,我们使用Option<T>枚举来替代其他语言中的空指针(null)。这是对托尼·霍尔(Tony Hoare)在2009年提出的“十亿美元错误”的直接回应。空指针的引入导致了无数的错误、安全漏洞和系统崩溃。Rust的解决方案是彻底摒弃空指针,用Option枚举来明确表达“有值”或“无值”的状态。

Option<T>枚举的定义非常简单,它只有两个变体:

enum Option<T> {
    Some(T),
    None,
}
  • Some(T):表示存在一个类型为T的值。
  • None:表示没有值,对应于其他语言中的null

这里使用了泛型T,这意味着Option可以包装任何类型的值。例如,Option<i32>表示一个可能存在的i32整数。

需要记住的一个核心原则是:Option<T>T是两种完全不同的类型。由于Rust强大的类型系统,你不能直接对Option<T>类型和T类型进行运算。例如,你不能将一个Option<i32>和一个i32直接相加。这强制你在代码中明确处理值可能缺失的情况,从而避免了因意外空值导致的运行时错误。

那么,如何从Option中取出值呢?最符合Rust风格(“Rusty” way)的方法是使用match表达式。

使用Match表达式处理枚举

match表达式是Rust中处理枚举的强大工具,它比传统的if语句更强大、更安全。

if语句只能判断布尔值(true或false),而match允许你根据枚举的具体变体来执行不同的代码分支。更重要的是,match必须是穷尽的(exhaustive),即你必须处理枚举的所有可能变体,否则编译器会报错。这确保了代码的健壮性,不会因为遗漏某个情况而产生bug。

以下是如何使用match处理一个自定义的Coin枚举:

enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter(UsState), // Quarter变体包含一个UsState类型的数据
}

fn value_in_cents(coin: Coin) -> u8 {
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter(state) => { // 通过模式匹配提取state
            println!("State quarter from {:?}!", state);
            25
        },
    }
}

match的分支中,箭头=>左侧是模式匹配,右侧是匹配成功后要执行的代码块或表达式。对于包含数据的变体(如Quarter),我们可以直接在模式中绑定其内部数据(如state)以供使用。

处理Option枚举的原理完全相同:

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}

let five = Some(5);
let six = plus_one(five); // Some(6)
let none = plus_one(None); // None

这个plus_one函数接收一个Option<i32>,如果值是Some(i),则返回Some(i+1);如果值是None,则直接返回None

有时,我们可能只关心枚举的某一个特定变体,而对其他所有变体执行相同的操作。这时可以使用“通配符”模式_

let some_u8_value = 0u8;
match some_u8_value {
    1 => println!("one"),
    3 => println!("three"),
    5 => println!("five"),
    7 => println!("seven"),
    _ => (), // 匹配所有其他情况,并返回空元组`()`,即什么都不做
}

If Let简洁控制流

match虽然强大,但在只关心一个变体时,写法略显繁琐。Rust提供了语法糖if let来实现更简洁的控制流。

if letmatch的一种简写形式,它只处理你关心的那个模式匹配成功的情况。

以下是使用matchif let的对比:

// 使用match,需要处理所有变体
let config_max = Some(3u8);
match config_max {
    Some(max) => println!("The maximum is configured to be {}", max),
    _ => (), // 必须包含这个分支来处理None情况
}

// 使用if let,只处理Some变体,更简洁
let config_max = Some(3u8);
if let Some(max) = config_max {
    println!("The maximum is configured to be {}", max);
}
// 如果是None,则跳过代码块,什么也不做

if let后面跟着一个模式(Some(max))和一个表达式(config_max)。如果config_max的值与模式Some(max)匹配,那么变量max将被绑定到Some内部的值,并执行随后的代码块。如果不匹配,则跳过代码块。

if let牺牲了match的穷尽性检查,换来了代码的简洁性。它非常适合处理Option类型,因为我们常常只关心Some的情况。

枚举的强大之处:和结构体的对比

为了更好地理解枚举的设计哲学,我们将其与结构体(struct)进行对比。

结构体可以看作是乘积类型(Product Type)。例如,一个包含xy坐标的Point结构体,其可能的值是x所有可能值和y所有可能值的笛卡尔积,构成了一个二维空间。

而枚举是和类型(Sum Type)。对于一个枚举变量,在任意时刻,有且只有一个变体是有效的。这使其能够精确地建模“多种可能性中的一种”这种场景。

考虑一个设计“圣诞树”类型的例子。我们关心两个属性:树是否存活(alive),以及是否在生长(growing)。一个关键约束是:如果树死了,它就不可能生长

如果用结构体来设计,这个约束无法在类型层面得到保证:

struct ChristmasTree {
    alive: bool,
    growing: bool,
}
// 可以创建一个“死了但还在生长”的树,这不符合逻辑
let dead_but_growing = ChristmasTree { alive: false, growing: true };

而使用枚举,我们可以将逻辑约束编码到类型中:

enum ChristmasTree {
    Alive(bool), // bool表示是否在生长
    Dead,
}
// 现在,Dead变体根本没有“是否生长”这个字段,从根本上杜绝了矛盾状态。
let tree = ChristmasTree::Alive(true); // 一棵活着且在生长的树

通过枚举,编译器可以在编译期就帮助我们检查代码逻辑,避免无效状态的出现。这是Rust类型系统帮助编写健壮代码的绝佳例子。

Rust与面向对象编程

既然谈到了类型设计,我们简要探讨一下Rust与面向对象编程(OOP)的关系。

传统的OOP有三大支柱:封装、继承和多态。

  • 封装:Rust通过pub关键字控制模块和结构体字段的可见性,默认私有,完美支持封装。
  • 继承:Rust刻意不支持传统的类继承(尤其是数据继承)。Rust设计者认为,继承是一种有问题的代码复用机制,它常常导致紧耦合和复杂的层次结构(“香蕉-大猩猩-丛林”问题)。在Rust中,代码复用主要通过特质(Trait) 来实现,特质类似于其他语言中的接口(Interface)或协议(Protocol),但不能包含数据字段,这避免了数据继承带来的问题。
  • 多态:Rust通过泛型特质对象来实现多态。泛型提供编译时多态,而特质对象(&dyn TraitBox<dyn Trait>)提供运行时多态。这是一种更显式、更受约束的多态,被称为“有界参数多态”,它比基于继承的动态多态更安全、更清晰。

因此,Rust吸收了OOP中优秀的部分(如封装、将数据与行为结合),而摒弃了被认为有缺陷的部分(继承),并提供了现代化的替代方案(特质和泛型)。

错误处理概览

现在,让我们进入本节课的核心主题:错误处理。错误处理是编程中至关重要的一环,而许多语言(如使用异常机制的C++、Java、Python)的处理方式存在缺陷,导致开发者常常忽略错误处理。

Rust将错误分为两大类:

  1. 可恢复错误:例如“文件未找到”。我们通常希望向用户报告错误并重试操作,而不是终止程序。
  2. 不可恢复错误:通常是程序中的bug,例如访问数组越界。遇到这种错误,程序无法继续安全运行,应该立即终止。

许多语言使用异常来处理所有错误。但Rust认为,将可恢复错误和不可恢复错误混为一谈是不理想的。对于可恢复错误,应该有更优雅、更显式的处理方式;对于不可恢复错误,则应该快速失败。

在Rust中:

  • 对于不可恢复错误,我们使用panic!宏,它会导致程序立即终止并打印错误信息。
  • 对于可恢复错误,我们使用Result<T, E>枚举类型来显式地传递和处理错误。

不可恢复错误与Panic

当程序遇到无法处理的严重错误时,应该触发panic。这可以由Rust自身在检测到非法操作时触发(如数组越界),也可以由开发者主动调用panic!宏触发。

调用panic!宏时,程序会:

  1. 打印错误信息和panic位置。
  2. 展开(unwind)调用栈,清理每个函数中的数据。
  3. 最后退出程序。

为了诊断panic发生的原因,我们可以获取回溯(backtrace)信息,它显示了panic发生时函数的调用链。通过设置环境变量RUST_BACKTRACE=1,可以在程序panic时打印回溯信息。

让我们通过一个简单的例子来演示panic和回溯:

fn main() {
    let v = vec![1, 2, 3];
    // 尝试访问不存在的索引,这将导致panic
    v[99];
}

运行上述程序(设置RUST_BACKTRACE=1)会触发panic,并打印出详细的调用栈信息,帮助你定位问题根源。

可恢复错误与Result类型

对于大多数预期内可能发生的错误(如网络中断、用户输入错误),我们不应该让程序崩溃,而是应该优雅地处理。Rust使用Result枚举来封装这类操作的结果。

Result<T, E>枚举的定义如下:

enum Result<T, E> {
    Ok(T),  // 操作成功,包含结果值
    Err(E), // 操作失败,包含错误信息
}

这里T代表成功时返回的数据类型,E代表错误时返回的错误类型。

一个典型的例子是打开文件:

use std::fs::File;

fn main() {
    let f = File::open("hello.txt");
    // f的类型是 Result<File, std::io::Error>
}

File::open的返回值是一个Result。如果文件hello.txt存在且成功打开,则fOk(File);如果文件不存在或没有权限,则fErr(io::Error)

我们必须显式地处理这个Result值,不能假装它一定会成功。最基础的处理方式仍然是使用match表达式:

let f = File::open("hello.txt");

let f = match f {
    Ok(file) => file, // 成功,返回文件句柄
    Err(error) => {
        panic!("Problem opening the file: {:?}", error); // 失败,触发panic
    },
};

上面的代码在文件打开失败时会直接panic,这相当于将可恢复错误转换成了不可恢复错误。有时这是合理的(例如,配置文件缺失导致程序无法启动),但很多时候我们需要更细致的处理,比如尝试不同的文件路径、创建新文件或向用户报告错误。

Result枚举和match表达式相结合,强制开发者面对所有可能的错误路径,这是编写健壮、可靠系统软件的关键。

传播错误

在实际开发中,一个函数中发生的错误,常常需要传递给它的调用者来处理,这被称为错误传播。在Rust中,错误传播可以手动实现,但比较繁琐。幸运的是,Rust提供了?运算符来极大地简化这个过程。

假设我们有一个从文件中读取用户名的函数:

use std::fs::File;
use std::io;
use std::io::Read;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/e95802b4e94c378792c74e96d48941f5_5.png)

fn read_username_from_file() -> Result<String, io::Error> {
    let mut f = File::open("hello.txt")?; // 如果打开失败,`?`会立即将错误返回给调用者
    let mut s = String::new();
    f.read_to_string(&mut s)?; // 如果读取失败,同样立即返回错误
    Ok(s) // 一切顺利,返回包含用户名的Ok
}

?运算符的作用是:如果Result的值是Ok,则提取出其中的值并继续执行;如果是Err,则立即从当前函数返回这个Err。这使得错误传播的代码变得非常清晰简洁。

?运算符只能用于返回Result(或Option,或其他实现了特定特质的类型)的函数中。它是Rust错误处理工具箱中极其重要的工具。

何时使用Panic,何时使用Result

选择panic!还是返回Result,是一个设计决策。以下是一些指导原则:

使用panic!(不可恢复错误)的场景:

  • 示例、原型代码或测试中,需要快速失败并显示错误位置。
  • 程序遇到了表明其自身存在bug的情况(如数组越界、逻辑错误)。
  • 在某些情况下,你比编译器拥有更多信息,可以确定Result一定是Ok,但编译器无法证明。这时你可以调用.unwrap().expect(“error message”)方法,它们在值是Ok时返回值,在值是Err时触发panic。请谨慎使用。

使用Result(可恢复错误)的场景:

  • 错误是预期可能发生的,并且调用者应该有机会以某种方式处理它(例如,重试、使用默认值、报告给用户)。
  • 你正在编写库代码,应该将错误的处理权交给调用者。
  • 操作失败并不代表程序整体失效(例如,一次非关键的HTTP请求失败)。

总结

本节课中我们一起学习了Rust强大而独特的错误处理哲学与实践。

我们首先回顾了Option枚举如何优雅地解决空值问题,并通过matchif let表达式来安全地处理枚举值。我们深入探讨了枚举作为和类型的强大表达能力,它能将程序逻辑约束编码到类型系统中,由编译器来保障正确性。

接着,我们分析了Rust与面向对象编程的关系,理解了Rust如何取其精华(封装、组合),去其糟粕(继承),并通过特质和泛型提供现代化的抽象机制。

最后,我们系统地学习了Rust错误处理的核心:区分可恢复错误与不可恢复错误。对于不可恢复错误,我们使用panic!宏快速失败;对于可恢复错误,我们使用Result<T, E>枚举类型来显式传递错误,并通过match表达式或?运算符进行优雅的处理和传播。

Rust的错误处理机制强制开发者面对错误,做出明确的选择,从而在编译期就消除了大量潜在的错误处理漏洞,这是构建高性能、高可靠软件系统的基石。

010:用于验证的自定义类型 🛡️

在本节课中,我们将要学习 Rust 中如何处理可恢复的错误。我们将重点探讨 Result 类型的使用,如何传播错误,以及如何通过创建自定义类型来保证数据的有效性。

概述

上一节我们介绍了不可恢复的错误(panic)。本节中我们来看看可恢复的错误。Rust 没有像其他语言那样的异常处理机制,而是使用 Result 枚举类型来优雅地处理可能失败的操作。

可恢复错误与 Result 类型

Result 类型是一个枚举,用于表示操作可能成功或失败。其定义如下:

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

T 代表操作成功时返回的数据类型,E 代表操作失败时返回的错误类型。

处理 Resultmatch 表达式

处理 Result 最直接的方式是使用 match 表达式。以下是一个打开文件的例子:

use std::fs::File;

fn main() {
    let file_result = File::open("llms.txt");

    let file = match file_result {
        Ok(file) => file,
        Err(error) => panic!("Problem opening the file: {:?}", error),
    };
}

如果文件打开成功,我们获得文件句柄;如果失败,程序会 panic 并打印错误信息。

更优雅的错误处理:创建新文件

然而,当文件不存在时直接 panic 通常不是最佳选择。更好的做法是尝试创建文件。以下是实现方法:

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    let file_result = File::open("llms.txt");

    let file = match file_result {
        Ok(file) => file,
        Err(error) => match error.kind() {
            ErrorKind::NotFound => match File::create("llms.txt") {
                Ok(fc) => fc,
                Err(e) => panic!("Problem creating the file: {:?}", e),
            },
            other_error => {
                panic!("Problem opening the file: {:?}", other_error);
            }
        },
    };
}

这段代码首先尝试打开文件。如果错误是“未找到”,则尝试创建文件;对于其他错误,则直接 panic。

简化错误处理:unwrapexpect

对于某些情况,如果错误发生我们确实希望程序终止,可以使用 unwrapexpect 方法。expect 更好,因为它允许我们提供自定义的错误信息。

let file = File::open("llms.txt").unwrap(); // 出错时 panic,信息简单
let file = File::open("llms.txt").expect("Failed to open llms.txt"); // 出错时 panic,附带自定义信息

错误传播:? 运算符

很多时候,函数内部遇到错误时,不应该自己处理,而应该将错误“传播”给调用者,让调用者决定如何处理。Rust 提供了 ? 运算符来简化这个过程。

传播错误示例

假设我们有一个从文件读取用户名的函数:

use std::fs::File;
use std::io::{self, Read};

fn read_username_from_file() -> Result<String, io::Error> {
    let mut username = String::new();
    let mut file = File::open("llms.txt")?;
    file.read_to_string(&mut username)?;
    Ok(username)
}

? 运算符的作用是:如果 Result 的值是 Ok,则提取其中的值并继续执行;如果是 Err,则立即从当前函数返回这个错误。

? 运算符也可用于 Option

? 运算符同样可以用于 Option 类型,在遇到 None 时提前返回。

fn last_char_of_first_line(text: &str) -> Option<char> {
    text.lines().next()?.chars().last()
}

main 函数也可以返回 Result

main 函数通常返回 (),但它也可以返回 Result 类型,这样程序就可以向操作系统返回错误码。

use std::error::Error;
use std::fs::File;

fn main() -> Result<(), Box<dyn Error>> {
    let _greeting_file = File::open("llms.txt")?;
    Ok(())
}

这里 Box<dyn Error> 表示任何实现了 Error trait 的类型,它允许 main 函数返回多种不同的错误。

何时应该使用 panic!

使用 panic!(或 unwrapexpect)是一种设计决策,主要适用于以下情况:

  1. 你有绝对自信不会出错时:例如,解析一个硬编码的、已知有效的字符串。
    let home: IpAddr = "127.0.0.1".parse().expect("Hardcoded IP address should be valid");
    
  2. 当你正在编写库,并且传入的无效数据会破坏后续逻辑时:此时应在数据验证处 panic,以保护后续代码的假设。例如,String 的切片索引方法会在索引无效时 panic。

创建用于验证的自定义类型

一个更健壮的设计模式是创建自定义类型来封装数据和其有效性规则。以猜数字游戏为例,我们可以创建一个 Guess 类型来确保值始终在 1 到 100 之间。

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn new(value: i32) -> Guess {
        if value < 1 || value > 100 {
            panic!("Guess value must be between 1 and 100, got {}.", value);
        }
        Guess { value }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}

通过将字段 value 设为私有,并只提供构造函数 new 和获取方法 value,我们保证了:一旦 Guess 实例被创建,其包含的值一定是有效的。这比在业务代码中到处检查范围要清晰和安全得多。

总结

本节课中我们一起学习了 Rust 处理错误的精髓。我们了解了如何使用 ResultOption 类型来表示可能失败的操作,如何使用 match? 运算符来处理和传播错误。我们还探讨了何时应该让程序 panic,并学习了通过创建自定义类型来在编译期和运行时强制数据有效性,这是构建可靠 Rust 程序的关键模式。下一讲,我们将开始学习集合类型。

011:向量与哈希表 🚀

在本节课中,我们将学习Rust标准库中两个非常重要的数据结构:向量(Vector)和哈希表(HashMap)。向量是一种可增长的数组,而哈希表是一种键值对存储结构。我们将探讨它们的基本用法、所有权规则以及一些实用的技巧。


向量(Vectors)

向量是一种可增长的数组,存储在堆上,可以容纳多个相同类型的值。例如,字符串本质上就是字符的向量。

定义向量

我们可以使用 vec! 宏来定义一个带有初始值的向量:

let v = vec![1, 2, 3];

如果需要创建一个空的向量,必须显式指定类型:

let v: Vec<i32> = Vec::new();

向量使用泛型类型 Vec<T> 定义,其中 T 是向量中元素的类型。

向向量中添加元素

使用 push 方法可以向向量末尾添加元素:

let mut v = Vec::new();
v.push(5);
v.push(6);

读取向量中的元素

有两种主要方式读取向量中的元素:通过索引直接访问和使用 get 方法。

通过索引直接访问:

let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("第三个元素是 {}", third);

这种方式在索引越界时会导致程序 panic(运行时崩溃)。

更安全的方式是使用 get 方法,它返回一个 Option<&T> 类型:

let v = vec![1, 2, 3, 4, 5];
match v.get(2) {
    Some(third) => println!("第三个元素是 {}", third),
    None => println!("没有第三个元素。"),
}

当索引越界时,get 方法会返回 None,而不会导致程序崩溃。

所有权与借用规则

在向量中,所有权和借用规则同样适用。例如,在获取了向量中某个元素的不可变引用后,不能同时修改向量:

let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0]; // 不可变借用
v.push(6); // 错误!尝试可变借用

这是因为 push 方法可能需要重新分配内存,使得之前的引用失效。Rust在编译时就会阻止这种潜在的错误。

遍历向量

我们可以使用 for 循环来遍历向量中的元素:

let v = vec![100, 32, 57];
for i in &v {
    println!("{}", i);
}

如果需要修改元素,可以使用可变引用:

let mut v = vec![100, 32, 57];
for i in &mut v {
    *i += 50; // 解引用并修改值
}

在向量中存储多种类型

向量只能存储单一类型,但我们可以使用枚举来存储多种类型:

enum SpreadsheetCell {
    Int(i32),
    Float(f64),
    Text(String),
}

let row = vec![
    SpreadsheetCell::Int(3),
    SpreadsheetCell::Text(String::from("蓝色")),
    SpreadsheetCell::Float(10.12),
];

这种方法在预先知道可能存储的类型时非常有用。


上一节我们介绍了向量的基本用法和所有权规则,本节中我们来看看另一种重要的数据结构:哈希表。

哈希表(Hash Maps)

哈希表是一种键值对存储结构,类似于其他语言中的字典(如Python)或映射。在Rust中,哈希表通过 HashMap<K, V> 实现。

使用哈希表

首先,需要将 HashMap 引入作用域,因为它不在预导入模块中:

use std::collections::HashMap;

创建一个新的哈希表并插入键值对:

let mut scores = HashMap::new();
scores.insert(String::from("蓝色"), 10);
scores.insert(String::from("黄色"), 50);

遍历哈希表

可以使用 for 循环遍历哈希表中的所有键值对:

for (key, value) in &scores {
    println!("{}: {}", key, value);
}

更新哈希表

更新哈希表有多种方式。直接使用 insert 方法会覆盖已存在的键:

let mut scores = HashMap::new();
scores.insert(String::from("蓝色"), 10);
scores.insert(String::from("蓝色"), 25); // 更新值为25

如果只想在键不存在时插入,可以使用 entryor_insert 方法:

let mut scores = HashMap::new();
scores.insert(String::from("蓝色"), 10);
scores.entry(String::from("黄色")).or_insert(50);

基于旧值更新

一个常见的用例是基于旧值更新哈希表中的值,例如统计单词出现次数:

let text = "hello world wonderful world";
let mut map = HashMap::new();

for word in text.split_whitespace() {
    let count = map.entry(word).or_insert(0);
    *count += 1;
}

println!("{:?}", map);

这段代码会输出每个单词的出现次数,例如 {"world": 2, "hello": 1, "wonderful": 1}


泛型(Generics)

泛型用于消除代码重复,允许我们使用类型占位符代替具体类型。我们之前已经见过泛型在 Option<T>Result<T, E>Vec<T> 中的应用。

泛型函数

假设我们需要一个函数来查找向量中的最大元素。最初,我们可能为每种类型编写单独的函数:

fn largest_i32(list: &[i32]) -> &i32 {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/da8faab7b76be12ee92cd785ea9660a3_9.png)

fn largest_char(list: &[char]) -> &char {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

为了避免重复,我们可以使用泛型:

fn largest<T: PartialOrd>(list: &[T]) -> &T {
    let mut largest = &list[0];
    for item in list {
        if item > largest {
            largest = item;
        }
    }
    largest
}

这里 T: PartialOrd 是一个特质约束,表示类型 T 必须实现 PartialOrd 特质,以便进行比较。

泛型结构体

我们也可以在结构体中使用泛型:

struct Point<T> {
    x: T,
    y: T,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/da8faab7b76be12ee92cd785ea9660a3_13.png)

let integer_point = Point { x: 5, y: 10 };
let float_point = Point { x: 1.0, y: 4.0 };

如果需要结构体的字段具有不同的类型,可以使用多个泛型参数:

struct Point<T, U> {
    x: T,
    y: U,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/da8faab7b76be12ee92cd785ea9660a3_15.png)

let both_integer = Point { x: 5, y: 10 };
let integer_and_float = Point { x: 5, y: 4.0 };

泛型枚举

枚举也可以使用泛型,例如标准库中的 Option<T>Result<T, E>

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

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/da8faab7b76be12ee92cd785ea9660a3_17.png)

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

泛型方法

我们可以在结构体上定义泛型方法:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());

还可以为特定具体类型定义方法:

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

这样,只有 Point<f32> 类型的实例才能调用 distance_from_origin 方法。


总结 🎉

在本节课中,我们一起学习了Rust中的向量和哈希表这两种重要的数据结构。我们探讨了它们的基本用法、所有权规则以及一些实用技巧。此外,我们还深入了解了泛型的概念,包括泛型函数、泛型结构体、泛型枚举和泛型方法。泛型帮助我们消除代码重复,并使代码更加灵活和可重用。掌握这些概念是编写高效、安全Rust代码的关键。

012:通用数据类型 🧩

在本节课中,我们将要学习Rust中的通用数据类型,特别是泛型类型参数和特质(Traits)的深入应用。我们将探讨如何灵活地组合它们,理解其零成本抽象的特性,并学习如何通过特质来定义和共享类型的行为。


泛型类型参数的混合使用

上一节我们介绍了泛型的基本概念,本节中我们来看看如何在结构体、实现块和方法中混合使用不同的泛型类型参数,以获得极大的灵活性。

我们有一个结构体 Point,它使用泛型类型 X1, Y1。在为其定义的方法中,我们不仅可以使用结构体自身的泛型参数,还可以引入方法独有的泛型参数。

struct Point<X1, Y1> {
    x: X1,
    y: Y1,
}

impl<X1, Y1> Point<X1, Y1> {
    fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
        Point {
            x: self.x,
            y: other.y,
        }
    }
}

在这个例子中:

  • X1, Y1 是结构体 Point 和其实现块的泛型参数。
  • X2, Y2 是方法 mixup 独有的泛型参数。
  • 方法 mixup 返回一个新的 Point,其 x 来自 self(类型为 X1),y 来自 other(类型为 Y2)。

使用方式如下:

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c' };
    let p3 = p1.mixup(p2);
    println!("p3.x = {}, p3.y = {}", p3.x, p3.y); // 输出:p3.x = 5, p3.y = c
}

通过混合三种不同的泛型参数来源(结构体、实现块、方法),我们可以实现高度灵活的代码。


零成本抽象与单态化

你可能会担心,如此复杂的泛型是否会带来运行时性能开销?答案是:不会。这是Rust设计的一项核心承诺——零成本抽象。

所有泛型相关的类型确定工作都在编译时完成,不会增加任何运行时开销。编译器速度可能较慢,但这远比在运行时处理类型信息要好。

Rust编译器通过一个称为 单态化 的过程来实现这一点。

以下是单态化的工作原理:

  1. 编译器分析代码中所有使用泛型的地方。
  2. 它确定在程序中实际用到的具体类型有哪些。
  3. 编译器会为每一个被使用的具体类型组合生成一份特化后的代码副本。

例如,对于 Option<T>,如果你在代码中只使用了 Option<i32>Option<f64>,那么编译器最终生成的二进制文件中,只会包含针对 i32f64Option 代码,就像你一开始就手写了这两份代码一样。因此,使用泛型的代码在运行时与使用具体类型的代码效率完全相同。


特质详解

之前我们多次提到特质,现在我们来深入了解它。特质是Rust中定义共享行为的方式,它是语言的一个突出亮点。

一个特质定义了一组方法签名,它是一份契约,规定了实现该特质的类型必须提供哪些功能。不同的类型可以通过实现同一个特质来共享相同的行为。

定义与实现特质

假设我们有两种类型:NewsArticleTweet。虽然它们内容不同,但都支持“总结”这一行为。

首先,我们定义一个 Summary 特质:

pub trait Summary {
    fn summarize(&self) -> String;
}

summarize 方法签名是契约的一部分,实现该特质的类型必须提供完全匹配此签名的方法。

接着,我们为 NewsArticle 类型实现这个特质:

pub struct NewsArticle {
    pub headline: String,
    pub author: String,
    pub location: String,
}

impl Summary for NewsArticle {
    fn summarize(&self) -> String {
        format!("{}, by {} ({})", self.headline, self.author, self.location)
    }
}

同样地,为 Tweet 类型实现:

pub struct Tweet {
    pub username: String,
    pub content: String,
    pub reply: bool,
    pub retweet: bool,
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("{}: {}", self.username, self.content)
    }
}

实现特质时,必须严格遵循特质中定义的方法签名。

调用特质方法

要调用特质方法,需要将特质本身引入作用域。

use crate::Summary; // 假设特质定义在当前crate中
use crate::Tweet;

fn main() {
    let tweet = Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("of course, as you probably already know, people"),
        reply: false,
        retweet: false,
    };
    println!("1 new tweet: {}", tweet.summarize());
}

实现特质的灵活性

Rust在特质实现上提供了很大的灵活性,但遵循“孤儿规则”以保证代码安全:

  • 可以 为当前crate中的类型实现当前crate中定义的特质。(标准做法)
  • 可以 为外部库中的类型实现当前crate中定义的特质。(扩展外部类型)
  • 可以 为当前crate中的类型实现外部库中定义的特质。(使自己的类型具备标准行为)
  • 不可以 为外部库中的类型实现外部库中定义的特质。(防止破坏性修改)

这条规则确保了你的代码不会被意外的外部实现所破坏。


默认实现与特质继承

特质中的方法可以有默认实现。这为特质实现者提供了便利,他们可以选择使用默认实现,也可以选择覆盖它。

pub trait Summary {
    fn summarize(&self) -> String {
        String::from("(Read more...)")
    }
}

impl Summary for NewsArticle {}
// NewsArticle 现在可以使用 summarize 的默认实现

更强大的是,默认实现可以调用特质中的其他方法,即使那些方法没有默认实现。这允许我们在特质中定义基于其他方法的行为模板。

pub trait Summary {
    fn summarize_author(&self) -> String;
    fn summarize(&self) -> String {
        format!("(Read more from {}...)", self.summarize_author())
    }
}

impl Summary for Tweet {
    fn summarize_author(&self) -> String {
        format!("@{}", self.username)
    }
    // summarize 方法使用了默认实现,它会调用我们提供的 summarize_author
}

这种方式避免了传统面向对象继承中数据继承带来的复杂性(“菱形继承”问题)。特质只定义行为,不包含数据字段,从而更清晰、更安全。


特质作为参数与特质约束

特质的一个关键用途是定义函数参数,使函数可以接受任何实现了特定特质的类型。这有两种主要语法:特质约束impl Trait 语法

impl Trait 语法(简便语法)

这种语法更简洁,适用于直接参数。

pub fn notify(item: &impl Summary) {
    println!("Breaking news! {}", item.summarize());
}

函数 notify 接受一个实现了 Summary 特质的类型的引用。

特质约束语法

这种语法更强大、更显式,尤其适用于复杂的泛型情况。

pub fn notify<T: Summary>(item: &T) {
    println!("Breaking news! {}", item.summarize());
}

这与上面的 impl Summary 是等价的。T: Summary 被称为 特质约束,它限定了泛型类型 T 必须实现 Summary 特质。

特质约束允许编译器在编译时检查所有具体类型是否提供了正确的行为,从而将潜在的错误从运行时转移到编译时。

两种语法的区别与选择

impl Trait 语法是特质约束的语法糖,但在某些情况下,特质约束更具表达力。

考虑以下两个函数:

// 版本 1: 使用 impl Trait
pub fn notify(item1: &impl Summary, item2: &impl Summary) {...}
// item1 和 item2 可以是实现了 Summary 的 *不同* 类型。

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/edb8e9b2463f8e58904a22c6551dab8b_30.png)

// 版本 2: 使用特质约束
pub fn notify<T: Summary>(item1: &T, item2: &T) {...}
// item1 和 item2 必须是 *相同* 的具体类型 T,且 T 需实现 Summary。
  • 如果你希望两个参数可以是不同的类型(只要都实现 Summary),使用 impl Trait 更合适。
  • 如果你强制要求两个参数必须是完全相同的具体类型,使用特质约束更清晰。

多重特质约束与 where 从句

可以使用 + 语法指定多个特质约束。

// 要求 T 同时实现 Summary 和 Display
pub fn notify<T: Summary + Display>(item: &T) {...}
// 等价的使用 impl Trait 的语法
pub fn notify(item: &(impl Summary + Display)) {...}

当约束很多时,可以使用 where 从句来提升可读性。

fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {...}
// 使用 where 从句改写
fn some_function<T, U>(t: &T, u: &U) -> i32
where
    T: Display + Clone,
    U: Clone + Debug,
{...}

返回实现了特质的类型

可以使用 impl Trait 语法来指定函数返回某个实现了特定特质的类型。

fn returns_summarizable() -> impl Summary {
    Tweet {
        username: String::from("horse_ebooks"),
        content: String::from("..."),
        reply: false,
        retweet: false,
    }
}

这对于返回闭包或迭代器等复杂类型非常有用,我们将在后续章节看到。

重要限制:虽然返回类型是 impl Summary,但一次函数调用只能返回一种具体类型。你不能在函数内部通过条件判断返回 NewsArticleTweet,因为编译器需要在编译时确定具体的返回类型。


使用特质约束进行条件性实现

特质约束的另一个强大功能是,我们可以有条件地为泛型类型实现方法。一个方法的存在与否,取决于该类型是否实现了某些特质。

use std::fmt::Display;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/edb8e9b2463f8e58904a22c6551dab8b_39.png)

struct Pair<T> {
    x: T,
    y: T,
}

impl<T> Pair<T> {
    fn new(x: T, y: T) -> Self {
        Self { x, y }
    }
}

// 只有那些实现了 Display 和 PartialOrd 特质的 T,才会拥有 cmp_display 方法
impl<T: Display + PartialOrd> Pair<T> {
    fn cmp_display(&self) {
        if self.x >= self.y {
            println!("The largest member is x = {}", self.x);
        } else {
            println!("The largest member is y = {}", self.y);
        }
    }
}

在这个例子中:

  • new 方法对所有类型 T 都可用。
  • cmp_display 方法只对同时实现了 Display(可打印)和 PartialOrd(可比较)的类型 T 可用。对于不满足此条件的类型,这个方法甚至不存在。

这保证了代码的安全性:你只能在支持比较和打印的类型上调用比较并打印的方法。


覆盖实现

覆盖实现是Rust中一个高级但强大的特性。它可以有条件地为任何实现了另一个特质的类型,自动实现某个特质

impl<T: Display> ToString for T {
    // ... 为所有实现了 Display 的类型 T 实现 to_string 方法
}

标准库中就有这样的例子:它为所有实现了 Display 特质的类型自动实现了 ToString 特质。这意味着任何可以显示的类型,都自动拥有了一个 to_string() 方法将其转换为字符串。

这种机制极大地增强了代码的表达力和复用性。


总结

本节课中我们一起深入学习了Rust的通用数据类型。

  1. 我们探讨了泛型类型参数在结构体、实现块和方法中的混合使用,展示了其灵活性。
  2. 我们理解了Rust 零成本抽象 的承诺,以及编译器通过 单态化 消除泛型运行时开销的原理。
  3. 我们系统学习了 特质
    • 如何定义特质作为行为契约。
    • 如何为类型实现特质,并了解了“孤儿规则”。
    • 如何使用默认实现和特质继承来构建灵活的行为模板。
  4. 我们掌握了使用特质的两种主要方式:
    • 作为函数参数(impl Trait 和特质约束),以编写接受多种类型的通用函数。
    • 作为返回类型,尽管有单次调用返回类型必须一致的限制。
  5. 我们学习了高级特性:多重特质约束where从句、使用特质约束进行条件性方法实现,以及强大的覆盖实现

这些特性共同构成了Rust类型系统的核心,使其在保持高性能的同时,具备极强的表达能力和安全性。在下一讲中,我们将探讨Rust另一个独特且重要的概念:生命周期。

013:函数式Rust part1

在本节课中,我们将要学习Rust中的函数式编程概念,特别是闭包。我们将从回顾上一讲的生命周期和别名概念开始,然后深入探讨闭包的定义、类型推断以及它们如何捕获环境中的值。

生命周期与别名回顾

上一节我们介绍了生命周期。本节中我们来看看别名的概念以及它如何影响编译器优化。

引用有两种类型:可变引用和共享引用。Rust编译器强制执行两条核心规则。

第一条规则是,引用不能比其引用的值存活得更久。编译器通过生命周期分析来强制执行此规则。

第二条规则是关于别名的。一个可变引用不能被别名化。这意味着,当一个可变引用存活时,内存中不应有其他存活的引用指向重叠的内存区域。

别名的定义是:如果两个变量指向内存中重叠的区域,则它们互为别名。Rust保证可变引用没有别名,这不仅关乎内存安全,也为编译器优化创造了条件。

以下是一个展示别名如何影响优化的例子。

fn compute(input: &i32, output: &mut i32) {
    if *input > 10 {
        *output = 1;
    }
    if *input > 5 {
        *output *= 2;
    }
}

编译器能否将上述两次比较优化为一次?这取决于 inputoutput 是否可能互为别名。在C/C++中,由于无法保证这一点,编译器难以进行此类优化。但在Rust中,由于可变引用不能被别名化的保证,编译器可以安全地将 input 的值缓存到寄存器中,然后进行优化,因为 output 不可能指向与 input 重叠的内存。

这个特性简化了编译器的别名分析,使其能够进行更多积极的优化。相比之下,面向对象编程中的动态多态(运行时分发)会阻碍编译器优化,因为它在编译时无法确定调用哪个函数。

因此,Rust的别名规则不仅确保了内存安全,还显著提升了代码的运行性能。

闭包简介

上一节我们回顾了生命周期的核心概念。本节中,我们将正式进入函数式Rust的世界,首先了解什么是闭包。

函数式编程将函数视为“一等公民”。这意味着函数可以像普通变量一样被存储、传递和返回。闭包是Rust中实现函数式编程的关键特性,它们是匿名函数,可以捕获其定义环境中的值。

闭包之所以强大,是因为它们能够自动捕获环境中的值。如果无法捕获环境,闭包的功能将大打折扣。

让我们通过一个例子来理解闭包的用法。

示例:T恤赠送程序

假设一家T恤公司进行促销,赠送限量版T恤(红色或蓝色)。用户可以指定喜欢的颜色(可选),如果未指定,则获得库存最多的颜色。

首先,我们定义数据结构和一些方法。

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    // 返回库存最多的颜色
    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }

    // 根据用户偏好赠送T恤
    fn giveaway(&self, user_preference: Option<ShirtColor>) -> ShirtColor {
        // 使用闭包作为参数调用 `unwrap_or_else`
        user_preference.unwrap_or_else(|| self.most_stocked())
    }
}

giveaway 方法的核心是调用了 Option 类型的 unwrap_or_else 方法。该方法接受一个闭包作为参数:如果 OptionSome(value),则返回其中的值;如果是 None,则调用传入的闭包并返回其结果。

我们传入的闭包是 || self.most_stocked()。虽然它没有显式的参数(管道 || 内为空),但它通过捕获环境中的 self 来调用 most_stocked 方法。这正是闭包捕获环境值能力的体现。

这种写法非常符合Rust的“生态”风格,利用库函数和闭包简洁地表达逻辑。

闭包的类型推断与注解

与普通函数不同,闭包通常不需要显式注解参数和返回值的类型。因为闭包通常只在有限的上下文中使用,编译器能够根据其用法推断出类型。

当然,你也可以为了清晰性而添加类型注解。

以下是函数与闭包语法的一些对比,展示了闭包如何通过类型推断简化代码。

// 1. 普通函数
fn add_one_v1 (x: u32) -> u32 { x + 1 }

// 2. 带完整类型注解的闭包
let add_one_v2 = |x: u32| -> u32 { x + 1 };

// 3. 省略返回类型注解的闭包
let add_one_v3 = |x| { x + 1 };

// 4. 省略函数体大括号的闭包(单表达式)
let add_one_v4 = |x| x + 1;

上述四个定义在功能上是等价的,但闭包版本(v2-v4)因类型推断而更加简洁。

需要注意的是,闭包的类型在第一次调用时就被推断并固定下来。

let example_closure = |x| x;

let s = example_closure(String::from("hello")); // 推断 x 为 String 类型
let n = example_closure(5); // 错误!期望String类型,找到整数类型

第二次调用会导致编译错误,因为闭包 example_closure 的类型在第一次调用时已被推断为接受 String 参数,不能再接受整数。

本节课中我们一起学习了Rust中函数式编程的基础——闭包。我们了解了闭包作为一等公民的概念,如何定义和使用闭包,以及其强大的类型推断和环境捕获能力。在接下来的课程中,我们将深入探讨闭包捕获环境的三种方式,这是闭包最核心也最具挑战性的部分。

014:函数式Rust(第二部分)

在本节课中,我们将深入学习Rust闭包捕获环境值的三种方式,并探讨迭代器(Iterator)的核心概念与使用方法。我们将通过具体的代码示例,理解闭包的FnFnMutFnOnce特征,以及迭代器的惰性求值和适配器模式。


闭包捕获环境值的三种方式

上一节我们介绍了闭包的基本概念。本节中,我们来看看闭包如何从其所在的环境中捕获值。这类似于向常规函数传递参数的三种方式。

闭包可以从其环境中捕获值,这赋予了它强大的能力。捕获值的方式恰好对应了调用函数的三种方式:不可变借用、可变借用和获取所有权。

以下是三种捕获方式的详细说明和示例。

1. 不可变借用

这种方式下,闭包以不可变引用的形式捕获环境中的值。这意味着闭包可以读取这些值,但不能修改它们。

fn main() {
    let list = vec![1, 2, 3];
    println!("调用闭包前: {:?}", list);

    // 定义一个闭包,它不可变地借用 `list`
    let only_borrows = || println!("来自闭包: {:?}", list);

    only_borrows(); // 调用闭包
    println!("调用闭包后: {:?}", list);
}

运行此代码会按顺序打印列表三次,证明闭包只是读取了数据。

2. 可变借用

这种方式下,闭包以可变引用的形式捕获环境中的值,允许闭包修改这些值。

fn main() {
    let mut list = vec![1, 2, 3];
    println!("调用闭包前: {:?}", list);

    // 定义一个闭包,它可变地借用 `list` 并修改它
    let mut borrows_mutably = || {
        list.push(4);
        println!("来自闭包: {:?}", list);
    };

    borrows_mutably(); // 调用闭包
    // println!("调用闭包后: {:?}", list); // 如果取消注释,会导致编译错误
}

在这个例子中,闭包内部修改了list。需要注意的是,在闭包被定义后、调用前,如果尝试使用list,可能会因为所有权规则(特别是可变引用的独占性)而导致编译错误。但在闭包调用完成后,其生命周期结束,可以再次使用list

3. 获取所有权

这种方式下,闭包获取环境中值的所有权。这通常用于将数据移动到新线程等场景,以确保父线程不会同时修改数据,避免并发问题。使用move关键字可以强制闭包获取其捕获变量的所有权。

use std::thread;

fn main() {
    let list = vec![1, 2, 3];

    // 使用 `move` 关键字将 `list` 的所有权移动到新线程的闭包中
    let handle = thread::spawn(move || {
        println!("在新线程中: {:?}", list);
    });

    // 主线程中不能再使用 `list`
    // println!("在主线程中: {:?}", list); // 这会导致编译错误

    handle.join().unwrap();
}

闭包特征:FnOnceFnMutFn

Rust 使用特征来对闭包进行分类,这影响了闭包如何被调用和使用。理解这些特征对于使用接受闭包作为参数的库函数至关重要。

FnOnce 特征

  • 含义:只能被调用一次的闭包。
  • 行为:这类闭包会将其捕获的值移出闭包体。所有闭包都至少实现 FnOnce
  • 示例:标准库中 Option::unwrap_or_else 方法的参数就要求是 FnOnce 闭包,因为它最多只需要执行一次。

FnMut 特征

  • 含义:可以被多次调用,并且可能修改其捕获值的闭包。
  • 行为:这类闭包通过可变引用来捕获环境,允许在多次调用中改变捕获的变量。
  • 示例Vec::sort_by_key 方法接受一个 FnMut 闭包,因为它需要对每个元素调用该闭包以获取排序键,并且闭包可能需要更新内部状态(例如一个计数器)。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/168c04572cb3d6fc65f9da9b403c43bc_16.png)

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut count = 0;
    list.sort_by_key(|r| {
        count += 1; // 修改捕获的变量
        r.width // 返回排序依据的键
    });

    println!("{:#?}, 排序操作调用闭包次数: {}", list, count);
}

Fn 特征

  • 含义:可以被多次调用,并且既不移动也不修改其捕获值的闭包。
  • 行为:这类闭包通过不可变引用来捕获环境,或者不捕获任何环境。
  • 示例:适用于纯计算的场景。

关键点:这三个特征之间存在继承关系:Fn 继承自 FnMutFnMut 继承自 FnOnce。这意味着一个实现了 Fn 的闭包也自动实现了 FnMutFnOnce


迭代器深入解析

迭代器是Rust中处理序列数据的强大工具。它们遵循零成本抽象原则,意味着使用迭代器通常不会带来运行时性能开销。

迭代器是惰性的

创建迭代器本身不会立即进行任何计算或遍历。只有在“消费”迭代器时,操作才会执行。

fn main() {
    let v1 = vec![1, 2, 3];
    let v1_iter = v1.iter(); // 此时没有任何遍历发生

    for val in v1_iter { // `for` 循环开始消费迭代器
        println!("得到: {}", val);
    }
}

迭代器特征

迭代器的核心是 Iterator trait,它要求实现一个 next 方法。

pub trait Iterator {
    type Item; // 关联类型,代表迭代器产生的值的类型
    fn next(&mut self) -> Option<Self::Item>;
    // ... 其他有默认实现的方法
}

next 方法返回 Option<Self::Item>。当有下一个元素时返回 Some(item),遍历完成时返回 None

消费迭代器的方法

这些方法会消耗迭代器,通常获取最终结果。

  • sum:计算迭代器中所有元素的和。
    let v1 = vec![1, 2, 3];
    let total: i32 = v1.iter().sum(); // 消费迭代器,得到总和6
    

迭代器适配器

这些方法接收一个迭代器,返回一个新的、经过某种转换的迭代器。它们本身是惰性的。

  • map:对每个元素应用一个闭包,产生新元素。
    let v1 = vec![1, 2, 3];
    let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); // 需要 collect 来消费
    // v2 为 [2, 3, 4]
    
  • filter:根据闭包条件过滤元素。
  • zip:将两个迭代器“压缩”成一个。

重要:迭代器适配器本身不产生最终结果,必须通过消费适配器(如 collect, sum)来触发计算。

三种创建迭代器的方式

根据不同的使用场景,可以通过以下三种方法从集合创建迭代器:

  1. iter():生成一个产生 不可变引用 (&T) 的迭代器。用于只读遍历。
  2. iter_mut():生成一个产生 可变引用 (&mut T) 的迭代器。用于需要修改集合元素的遍历。
  3. into_iter():生成一个 获取所有权 的迭代器。它会消耗原集合,遍历后原集合将不可再用。for 循环默认使用 into_iter
let mut v = vec![1, 2, 3];

// 只读遍历
for i in v.iter() {
    println!("{}", i); // i 是 &i32
}

// 可修改遍历
for i in v.iter_mut() {
    *i += 1; // 可以修改元素
}

// 获取所有权遍历(消耗 v)
for i in v.into_iter() {
    println!("{}", i); // i 是 i32
}
// 此处 v 已不再有效

总结

本节课中我们一起学习了:

  1. 闭包捕获环境值的三种方式:不可变借用、可变借用和获取所有权(使用 move 关键字),它们分别对应了函数调用的参数传递模式。
  2. 闭包的三个特征FnOnce(调用一次)、FnMut(可多次调用并可修改环境)、Fn(可多次调用且不修改环境)。这些特征被库函数用作约束,以确保闭包行为符合预期。
  3. 迭代器的核心机制:迭代器是惰性的,其基础是 Iterator trait 和 next 方法。
  4. 迭代器的使用模式:区分了消费适配器(如 sum, collect)和迭代器适配器(如 map, filter)。
  5. 创建迭代器的三种方法iter()iter_mut()into_iter(),用于满足只读、修改和消耗所有权等不同场景的需求。

掌握这些概念是编写地道、高效且安全的Rust代码的关键,尤其是在函数式编程风格和数据处理领域。

015:智能指针 part1

在本节课中,我们将要学习Rust中的迭代器剩余内容,并深入探讨智能指针的核心概念。智能指针是Rust中管理内存和数据所有权的强大工具,理解它们对于编写高效、安全的Rust程序至关重要。

迭代器回顾与深入

上一节我们介绍了迭代器的基本概念,本节中我们来看看迭代器的更多高级用法和特性。

迭代器允许我们遍历数据序列,例如向量中的元素。需要再次强调的是,迭代器是惰性的。如果不调用消耗迭代器的方法,迭代器本身不会执行任何操作。

以下是创建迭代器的三种主要方法:

  • iter(): 生成一个包含不可变引用的迭代器。
  • into_iter(): 获取数据的所有权,生成一个拥有数据项的迭代器。
  • iter_mut(): 生成一个包含可变引用的迭代器。

next() 方法是一个消耗型适配器,它会消耗迭代器并返回下一个元素。当所有元素被消耗后,它会返回 None

除了消耗型适配器,还有迭代器适配器。它们不消耗原始迭代器,而是通过某种方式转换它,生成一个新的迭代器。

map 是一个迭代器适配器。它接收一个闭包,并将该闭包应用于迭代器的每个元素,从而生成一个新的迭代器。由于迭代器是惰性的,仅调用 map 不会产生任何实际效果。

let v1 = vec![1, 2, 3];
let v1_iter = v1.iter().map(|x| x + 1); // 此时没有计算发生

为了消耗这个新迭代器并获取结果,我们需要使用 collect() 方法。collect() 会消耗迭代器并将其结果收集到一个集合(如 Vec)中。这是一种典型且高效的模式。

let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); // v2 为 [2, 3, 4]

filter 是另一个常用的迭代器适配器。它接收一个返回布尔值的闭包,并创建一个只包含闭包返回 true 的元素的新迭代器。由于 filter 可能需要丢弃元素,它通常需要获取数据的所有权,因此常与 into_iter() 配合使用。

struct Shoe { size: u32 }
fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> {
    shoes.into_iter().filter(|s| s.size == shoe_size).collect()
}

使用 mapfiltercollect 的组合是比传统 for 循环更符合Rust习惯的写法。编译器能对其进行更好的优化,代码也更简洁清晰。

智能指针简介

现在,让我们进入本节课的核心主题:智能指针。

在讨论智能指针之前,我们需要重温指针的概念。在Rust中,指针通常被称为引用(使用 &&mut 表示)。引用是一个地址,它借用(borrow)了它所指向的值,并且所有权的所有规则都适用于引用。

那么,什么是智能指针?智能指针是一种数据结构,它的行为类似于指针,但拥有额外的功能和元数据。智能指针通常拥有它所指向的数据,并可能实现 DerefDrop 这两个特殊的 trait 来定制解引用行为和清理逻辑。

我们其实已经使用过一些智能指针,例如 StringVec<T>。它们除了存储数据外,还管理着长度、容量等元数据。

Box<T>:最简单的智能指针

Box<T> 是最简单的智能指针。它的主要作用是在上分配一个值。没有 Box 时,像 i32 这样的简单类型通常存储在栈上。使用 Box 可以确保数据存储在堆上,而 Box 本身(一个指向堆数据的指针)则存储在栈上。

使用 Box<T> 几乎没有任何性能开销。那么,我们为什么需要将数据从栈移到堆呢?主要有三种情况:

  1. 转移大量数据的所有权:当你有一个包含大量数据的结构体(例如超过1000字节),并且需要传递其所有权时,在栈上复制整个结构体代价高昂。使用 Box 后,你只需要复制指针(Box本身),这要高效得多。工具 cargo clippy 会检测大结构体并建议你使用 Box
  2. 处理 trait 对象:这是 Box 的一个关键用途,我们稍后会详细讨论。
  3. 处理编译时大小未知的类型:当你有一个在编译时大小未知的类型(例如 trait 对象),但需要在要求确切大小的上下文中(如将其放入 Vec)使用它时,可以使用 Box,因为 Box 本身的大小是已知的(一个指针的大小)。

Box 的使用很简单。当 Box 离开作用域时,不仅 Box 指针本身会被释放,它所指向的堆内存也会被自动释放,这是通过实现 Drop trait 来完成的。

let b = Box::new(5); // 值 5 被分配在堆上
println!("b = {}", b); // 自动解引用,打印 5

使用 Trait 对象实现多态

之前我们讨论过,可以使用枚举(enum)来让一个向量存储多种不同类型的值。但这种方法有一个明显的缺点:类型集合在编译时必须确定。如果你在编写一个库,希望用户能够定义自己的类型并放入你的向量中,枚举方法就无法满足需求。

在其他语言(如C++)中,通常使用继承来解决这个问题。在Rust中,我们使用 trait 对象 来实现运行时多态,这是一种更灵活的方式。

假设我们想定义一个 Animal trait,并让用户实现它。我们希望有一个向量可以存储任何实现了 Animal trait 的类型。

trait Animal {
    fn make_sound(&self);
}

struct Dog { name: String }
struct Cat { lives: u8 }

impl Animal for Dog { fn make_sound(&self) { println!("Woof!"); } }
impl Animal for Cat { fn make_sound(&self) { println!("Meow!"); } }

我们可能尝试这样写:

let animals: Vec<dyn Animal> = vec![Dog{name: "Buddy".into()}, Cat{lives: 9}]; // 错误!

但这会导致编译错误。错误信息指出:dyn Animal 是一个 DST(动态大小类型),它在编译时没有已知的大小。而Rust的 Vec<T> 要求其元素类型 T 必须实现 Sized trait(即大小在编译时已知)。

解决方案就是使用 Box<T>Box<dyn Animal> 的大小是已知的(一个指针的大小),因此可以放入向量中。

let animals: Vec<Box<dyn Animal>> = vec![
    Box::new(Dog{name: "Buddy".into()}),
    Box::new(Cat{lives: 9}),
];
for animal in animals {
    animal.make_sound(); // 动态调用正确的方法
}

通过将 trait 对象放入 Box,我们成功地将不同类型的值(DogCat)存储在了同一个向量中,并在运行时调用了各自正确的 make_sound 方法。这为库的设计提供了极大的灵活性。


本节课中我们一起学习了迭代器适配器(如 mapfilter)的完整使用模式,并深入探讨了智能指针的核心概念。我们重点介绍了 Box<T> 的三种主要用途,特别是如何利用 Box<dyn Trait> 来实现 trait 对象和运行时多态,从而编写出更灵活、可扩展的代码。理解这些概念是掌握Rust内存管理和抽象能力的关键一步。

016:智能指针 part2

在本节课中,我们将要学习智能指针的更多高级概念,包括宽指针、DerefDrop特质的实现,以及RcRefCell这两种特殊的智能指针。这些知识将帮助你理解Rust如何管理内存和实现多态。

宽指针与动态大小类型

上一节我们介绍了动态大小类型。本节中我们来看看宽指针。

宽指针,有时也称为胖指针,是一种带有元数据的智能指针。它们是将动态大小类型转换为已知大小类型的常用方法。例如,&str是一个字符串切片,属于动态大小类型,因为我们不知道其具体长度。为了使其具有已知大小,我们使用&str,这实际上是一个宽指针。

一个指向切片的宽指针包含两个部分:数据的地址和切片的长度。这使得编译器能够将其视为实现了Sized特质的类型。

以下是宽指针的典型结构:

// 宽指针结构示意(非实际代码)
struct WidePointer {
    data_address: *const u8,
    length: usize,
}

特质对象的宽指针

现在,让我们讨论指向特质对象的宽指针。

一个指向特质对象(如dyn Animal)的宽指针由两部分组成:一个数据指针和一个虚函数表指针。

  • 数据指针:指向存储特质对象具体数据的内存地址。
  • 虚函数表指针:指向一个虚函数表,该表是一个包含多个函数指针的结构体,每个指针指向特质中每个方法的具体实现代码。

由于特质对象的动态特性,我们无法在编译时知道应该调用哪个具体的方法实现。因此,需要在运行时通过查询虚函数表来决定。这种机制被称为动态分发

这与C++中实现多态( polymorphism )的机制类似。Rust通过特质对象和虚函数表同样可以实现多态,但方式更为受限:Rust的特质只能包含方法,不能包含数据字段。

使用这种动态分发会带来额外的开销,因为每次方法调用都需要一次虚函数表查找。这是为实现运行时多态性所付出的代价。

实现 Deref 特质

接下来,我们看看如何为自定义智能指针实现 DerefDrop 特质。首先从 Deref 特质开始。

Deref 特质允许将智能指针当作普通引用来使用。当我们使用解引用运算符 * 时,实际上调用的是 deref 方法。

Rust标准库中的 Box<T> 已经实现了 Deref。例如:

let x = 5;
let y = Box::new(x); // 将x装箱
assert_eq!(5, *y); // 解引用y,调用 y.deref()

现在,假设我们想为自己的类型实现 Deref。我们定义一个简单的元组结构体 MyBox

struct MyBox<T>(T); // 一个元组结构体,包装类型T

impl<T> MyBox<T> {
    fn new(x: T) -> MyBox<T> {
        MyBox(x)
    }
}

此时,我们还不能对 MyBox 使用 * 运算符进行解引用,因为它尚未实现 Deref 特质。

以下是实现 Deref 特质的方法:

use std::ops::Deref;

impl<T> Deref for MyBox<T> {
    type Target = T; // 定义关联类型为T

    fn deref(&self) -> &Self::Target {
        &self.0 // 返回内部数据的引用
    }
}

实现后,当我们编写 *y (其中 yMyBox<T>)时,Rust实际上会将其转换为 *(y.deref())。首先调用 deref 方法获取内部数据的引用,然后再对这个引用进行解引用。

解引用强制多态

Rust提供了称为“解引用强制多态”的语法糖,让代码更简洁。

考虑以下例子:

fn hello(name: &str) {
    println!("Hello, {}!", name);
}

let m = MyBox::new(String::from("Rust"));
hello(&m);

这里,hello 函数接收一个 &str 参数。我们传入的是 &MyBox<String>。Rust编译器会进行以下自动转换:

  1. &m 触发 MyBox<String>deref 方法,得到 &String
  2. Rust标准库为 String 实现了自动解引用到 &str,因此 &String 被强制转换为 &str

这一切都在编译时完成,无需我们手动转换,使得API调用更加友好。

除了 Deref,还有 DerefMut 特质用于获取可变引用。其规则与 Deref 类似,但需要注意所有权规则:可以将可变引用转换为不可变引用(通过 Deref),但不能将不可变引用转换为可变引用,因为这可能违反引用安全规则。

实现 Drop 特质

现在,让我们讨论 Drop 特质。

通过实现 Drop 特质中的 drop 方法,可以自定义当值离开作用域时发生的清理逻辑。这对于释放资源(如内存、文件句柄、网络连接)非常有用。

为我们的 MyBox 实现一个简单的 Drop

impl<T> Drop for MyBox<T> {
    fn drop(&mut self) {
        println!("Dropping MyBox!");
        // 这里可以添加释放资源的代码
    }
}

MyBox 的实例离开作用域时,会自动调用此 drop 方法并打印信息。

通常,我们无法手动调用 drop 方法(例如 y.drop()),因为这会导致在作用域结束时再次调用,引发“双重释放”错误。如果确实需要提前释放资源,应使用 std::mem::drop 函数:

let y = MyBox::new(5);
drop(y); // 显式提前丢弃y
// 此时y已失效,不能再使用

Rc<T> 引用计数智能指针

接下来,我们介绍两种特殊的智能指针。首先是 Rc<T>,即引用计数智能指针。

Rust的所有权规则通常要求一个数据只有一个所有者。Rc<T> 允许数据有多个所有者,它通过引用计数来追踪所有者的数量。只有当引用计数变为零时,数据才会被清理。

Rc<T> 只适用于单线程场景,并且它提供的是不可变引用,意味着数据是只读的。这在需要共享数据但不需要修改的场景中非常有用,例如图结构中的多个边指向同一个节点。

以下是 Rc<T> 的基本用法:

use std::rc::Rc;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_62.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_63.png)

let first = Rc::new(String::from("hello"));
println!("count after creating first = {}", Rc::strong_count(&first)); // 计数为1

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_65.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_67.png)

{
    let second = Rc::clone(&first); // 克隆Rc,增加计数,不克隆底层数据
    println!("count after creating second = {}", Rc::strong_count(&first)); // 计数为2
    println!("first: {}, second: {}", *first, *second); // 解引用获取数据
} // second离开作用域,计数减1

println!("count after second goes out of scope = {}", Rc::strong_count(&first)); // 计数恢复为1

在多线程环境下,需要使用 Arc<T>(原子引用计数)来替代 Rc<T>

RefCell<T> 与内部可变性模式

最后,我们看看 RefCell<T>。它实现了“内部可变性”设计模式。

RefCell<T> 在运行时而非编译时执行借用检查规则。它允许你在拥有不可变引用的同时,修改其内部的数据。这在一定程度上绕过了编译时严格的借用检查,但将错误检查推迟到了运行时。如果运行时违反了借用规则(例如,同时存在多个可变借用),程序会 panic

RefCell<T> 通常与 Rc<T> 结合使用,以在单线程场景下实现共享的可变状态。

以下是 RefCell<T>Rc<T> 结合使用的示例:

use std::rc::Rc;
use std::cell::RefCell;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_73.png)

let counter = Rc::new(RefCell::new(0)); // 用Rc包装RefCell

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_75.png)

let c1 = Rc::clone(&counter);
let c2 = Rc::clone(&counter);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_76.png)

// 通过第一个引用修改值
{
    let mut num = c1.borrow_mut(); // 获取可变借用
    *num += 1;
} // num离开作用域,可变借用释放

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_78.png)

// 通过第二个引用修改值
{
    let mut num = c2.borrow_mut();
    *num += 2;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_80.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_82.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_84.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/f0ff31812e93ed4785df1d52bdc08a0f_86.png)

// 读取最终值
println!("Final counter value: {}", *counter.borrow()); // 输出: 3

borrow_mut 方法返回一个 RefMut<T> 智能指针,borrow 方法返回一个 Ref<T> 智能指针。它们分别代表可变借用和不可变借用,并在运行时管理借用规则。

在多线程环境下,需要使用 Mutex<T>(互斥锁)来替代 RefCell<T>,通常与 Arc<T> 结合为 Arc<Mutex<T>>,以实现线程间安全的共享可变状态。

本节课中我们一起学习了智能指针的核心概念。我们探讨了宽指针如何包装动态类型,如何通过实现 DerefDrop 特质来定制智能指针的行为,并深入了解了 Rc<T>RefCell<T> 这两种允许共享所有权和内部可变性的智能指针。理解这些工具是编写安全、高效Rust程序的关键。下一讲,我们将进入多线程和异步编程的世界。

017:无畏并发 - 线程 part1

在本节课中,我们将要学习Rust中并发编程的基础知识,特别是线程的使用。我们将探讨Rust如何通过其独特的所有权模型和类型系统,将许多传统的运行时并发错误转化为编译时错误,从而实现所谓的“无畏并发”。

概述:并发与Rust的解决方案

上一节我们介绍了智能指针。本节中,我们来看看并发编程。并发对于Rust至关重要。今天我们将开始讨论并发,但无法在一次课程中完成所有内容。我们将分为两部分,第一部分是关于线程。

在Rust中,你可能听说过“无畏并发”这个术语。这有点夸张,因为我们仍然对并发心存敬畏。换句话说,Rust虽然解决了很多过去四十年来编程语言设计中的问题,是我们目前拥有的最佳方案,但我们仍有许多需要考虑和小心处理的事情。因此,它并非完全无畏,但我们仍要开始讨论。

在Rust 1.0之前,设计者们曾认为可以分别解决内存安全和并发这两大难题。然而,他们最终发现,之前讨论过的所有权模型(这在Rust中相当独特)加上极其严格的类型检查,结合一些额外的思想,或许也能解决许多与并发相关的问题。这仍然不是完全无畏的,我们仍需小心处理某些问题,但它比其他语言要好得多。而且,我们不需要在所有权和严格类型检查之外引入大量额外的并发系统。今天我们将看到这方面的例子。

许多运行时并发问题,如竞态条件等,过去令人头疼,因为它们发生在运行时且难以复现。现在,得益于Rust的所有权模型和类型检查,它们变成了编译时错误。这是前所未有的最佳方案。每当开始涉及并发时,例如Python 3.14最近引入了真正的多线程编程,一旦开始共享内存,就会遇到并发问题,而这些问题是过去四十年的运行时难题。Rust提供了你能拥有的最佳方案。

Rust并非第一个思考此问题的语言。例如,Erlang、Pony和Go语言都考虑过。Erlang是最古老的语言之一,由一位博士生在1998年创建,其博士论文旨在创建这种新语言,其基本思想是施加编程层面的约束,以减少运行时的并发错误,使并发更安全。Pony也有类似理念。

然而,尽管使用这些语言(包括Go)能更好地解决并发问题,但它们通常伴随着性能上的权衡,有时速度不够快。我们通常希望鱼与熊掌兼得,既要有并发安全性,又不想有或只有极小的性能损失。我们想要一种在开始时不给强加限制的语言,希望在设计时拥有很大的灵活性和自由,而Rust能够做到这一点。接下来我们将讨论Actor模型。Erlang和Pony都强制使用Actor模型,Go也很大程度上强制了Actor模型,即只允许在Actor之间传递消息。

但在Rust中,我们不做这样的强制。我们允许实现Actor模型,但如果性能损失更小、速度更快,我们也允许线程之间直接共享状态。这就是与Erlang、Pony和Go相比,Rust的优势所在:它不仅支持Actor模型。

并发与并行编程

在深入讨论线程细节之前,我们先谈谈并发和并行编程。我们稍后会再次讨论这个问题,以便更好地理解多线程编程。

并发编程意味着程序的不同部分独立执行,但可能不是同时进行。它们可以是同时的,但只是独立的,所以不一定精确同时。

并行编程则意味着程序的不同部分、不同逻辑确实同时执行。例如,如果你有多台计算机,每台运行自己的程序,那肯定属于并行编程。

通常,并行编程是并发编程的一个较小子集。当我们说“并发”时,意味着它可以同时运行,但不一定必须同时。所以它可能不是同时的。这有点令人困惑,因为并发编程就像它们是独立的,但可以或不必同时进行;而并行编程基本上要求必须同时执行。目前我们不想深入这些细节,稍后再谈。但现在,当我们谈论“无畏并发”时,我们指的是更大的图景,即并发编程的图景,它可以包括并行编程,也可以包括运行完全独立但可能不同时的程序。它们可能会轮流执行。我们稍后会讨论。但目前,当我们谈论无畏并发时,我们指的是并发编程或并行编程,因为并发编程包含了并行编程。

线程基础

现在我们来谈谈线程。你可能需要复习一下操作系统课程中关于线程的内容。显然,线程意味着我们同时运行代码。这里的“同时”我指的是并发。所以它们可以是并行的、完全同时的,也可以是轮流执行的,基本上就是并发编程。我们使用多个线程来实现并发编程的概念模型。我们允许多个线程并行同时执行,但这也会导致微妙的问题。

这些问题在典型的操作系统课程中是常规主题。通常,我们会花大约一周讲竞态条件,另一周讲死锁(这可能是防止竞态条件时产生的问题),再花两周讲线程同步问题,比如典型的生产者-消费者问题。当拥有多个线程时,我们需要在不同线程之间同步。所有这些在多线程编程中都会出现。如果我们不进行多线程编程,不共享内存,就不会有这些问题。如果我们只说多个操作系统进程,每个都有自己的地址空间,那么我们不共享内存。在这些情况下,我们确实没有这些问题。只有当我们开始共享内存时,才会出现竞态条件、线程同步问题、死锁等各种问题。

Rust显然支持多线程编程。标准库使用所谓的内核线程模型,有时也称为一对一线程模型。你也可以使用第三方crate引入其他线程模型,但Rust标准库默认的是内核线程模型,这基本上意味着Rust中的每个线程都对应Linux操作系统内核中的一个任务。所以Rust中的每个线程对应Linux内核中的一个任务。这就是我们通常所说的一对一线程模型。

内核线程模型在现代操作系统中非常典型。Linux内核使用一对一模型,macOS使用一对一模型,Windows也使用一对一模型。所有其他操作系统的历史模型已不再使用。

创建新线程

现在让我们尝试创建一个全新的线程。我们在讨论闭包时见过这段代码,但在展示代码之前,我们想说明这是来自Rust标准库的。我们基本上是说std::thread,然后调用thread::spawn,这是thread库中的一个关联函数。我们向它传递一个闭包。我们传递给spawn函数的是一个闭包,这显然是一种轻量级函数,我们将用它作为这个特定线程的起点。

在这个例子中,这个闭包将有一个for循环,它会打印,并在每次打印之间睡眠一秒。这就是闭包所做的事情。在main函数中(显然也是主线程),thread::spawn会生成新线程,所以那是主线程。主线程中我们也会有另一个for循环。我们当然也可以这样做。

让我们尝试运行一下。

以下是创建并运行线程的代码示例:

use std::thread;
use std::time::Duration;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/b9ed38e15f7dcec9b0d6d4af9a4f2cfe_3.png)

fn main() {
    thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }
}

运行此代码,你会看到主线程和新生成的线程都在运行,并且是并发(或并行)的。每个线程都会打印一条消息,然后暂停一秒,再打印下一条消息。这些消息看起来是交错的,因为它们并发运行。

这些线程在哪里运行?显然,它们运行在我们的CPU核心上,而且不一定使用同一个CPU核心。因为我们使用的是内核线程、一对一线程模型,意味着我们映射到一个内核任务,而一个内核任务不必与另一个内核任务共享同一个CPU核心。它们实际上可以分布在不同CPU核心上。即使像我现在的笔记本电脑,也至少有10个核心(4个性能核心,6个能效核心等)。所以我有足够多的核心。如果你有两个线程,它们很可能运行在两个不同的CPU核心上,但它们仍然共享相同的虚拟地址空间,这就是线程的特点。

使用Join Handle等待线程

刚才,我们实际上有了生成线程的最简单想法,就是让它运行。但我想通过创建一个连接句柄来改进那段代码,这个连接句柄方便地是spawn函数的返回值。所以无论spawn函数返回什么,都将是连接句柄。在我的主线程末尾,我可以尝试join。当我调用handle.join()时,我是在尝试等待生成的(新)线程完成。有时我们称它为子线程,但这里我们就叫它生成的线程。我们想等待它完成。这是我们可以做的事情。因为join实际上返回一个Result,可能是一个错误,所以我将直接unwrap它。这就是等待生成线程完成所需要做的全部。这真的很好。

以下是使用JoinHandle的代码示例:

use std::thread;
use std::time::Duration;

fn main() {
    let handle = thread::spawn(|| {
        for i in 1..10 {
            println!("hi number {} from the spawned thread!", i);
            thread::sleep(Duration::from_millis(1));
        }
    });

    for i in 1..5 {
        println!("hi number {} from the main thread!", i);
        thread::sleep(Duration::from_millis(1));
    }

    handle.join().unwrap();
}

现在运行它,看起来非常相似。但我这里有一个保证:生成的线程将会完成。因为如你所见,生成的线程实际上有10次迭代。上一次运行,我们只允许少于10次迭代完成,因为主线程终止了。当主线程终止时,它生成的所有线程显然也必须终止。现在,我们允许子线程(或生成的线程)在主线程终止前实际完成。这是跨多个线程同步的典型做法。

闭包与线程环境捕获

我们看到了现场演示。现在让我们看看之前讨论闭包时提到的一个非常重要的细节。当我们讨论闭包时,我们谈到了捕获父线程的环境。但在这种情况下,我们在spawn函数中有一个闭包。问题是,我们能否在子线程中捕获父线程的那个环境?我们想这样做。例如,我不想在函数本身内部简单地打印任何东西,我想捕获父线程中发生的情况,那将是一个包含1到3的向量。我想打印那个向量,如果我们有能力从父线程捕获环境,那将很好。

让我们试试。这很简单。问题是,它能成功编译吗?

让我们试试看是否能成功编译。你怎么看?

我保存后,立即可以看到一些编译时错误。你认为,为什么,那里有什么错误?这个应该是警告,所以我需要去掉它。我们确实有一些错误。你认为那里可能是什么?让我们切换到亮色模式以便更容易看清。你认为那里可能是什么问题?

是的。新线程可能比主线程存活得更久。完全正确。当我们进行spawn时,内部有一个捕获环境的闭包。我们是如何捕获环境的?默认情况下,捕获环境的模式是通过不可变引用。我们基本上是获取一个不可变引用。这是默认的。显然,Rust支持所有三种方式:我们可以获取不可变引用(这是默认的)、可变引用,并且我们实际上可以进行所有权转移。我们可以转移所有权。所有这些都有可能,但默认情况下,如果我们这样做,它将是一个不可变引用。我们捕获的是对该向量的引用。这在这里不是一个好主意,因为线程很容易比实际的主线程存活得更久。你可能会说,不,不,它没有存活更久,因为我们那里有一个join。所以,你知道,我们正在等待另一个线程完成,但编译器不知道,对吧?所以在这个意义上,我们在这里做的是使用所有权模型进行检查。在这里,我们基本上说这可能不是,你知道……让我们看看这个问题。

所以我们基本上说错误E0373:闭包可能比当前函数存活得更久。这是我们的生命周期模型,当我们试图获取一个不可变引用时,它借用了当前函数拥有的实际向量。当你借用某物时,你借用的任何东西都不应该比原始值存活得更久。这不是编译器会接受的东西。它可能比这里借用的值存活得更久,因为它在一个不同的线程中。它有一条非常有帮助的信息试图帮你修复这个问题。

它试图说:帮助强制闭包获取V的所有权,使用move关键字。所以,如果你想获取这个值的所有权(你应该这样做,否则它可能会比原始值存活得更久),你可能会使用我们之前讨论闭包时提到的move关键字,以不同的方式捕获它,通过获取所有权从环境中捕获这个值。这就是我们想通过使用这个move来做的。所以,再次强调,这是我们可以做的事情。

让我们试着快速移动它。如你所见,错误消失了。cargo run将能够通过获取所有权来捕获并打印出来。这是我们可以做的事情。

现在,正如我们在讨论thread::spawn中的闭包时提到的,这是非常典型的。事实上,这实际上是我们获取所有权最普遍的方式之一。在其他地方使用闭包时,我们通常不获取所有权。但在这种情况下,我们通常确实获取所有权,因为否则我们无法对这个特定的向量做太多事情,甚至只是打印出来。这不是我们想要的。

Actor模型与消息传递

好了,这就是通过move获取环境所有权的解决方案。现在,我们来谈谈Actor模型。Go语言创建时,我不是它的忠实粉丝。我不喜欢这门语言,我认为它实际上比Rust差得多。所以如果你同意我的观点,就不要用它。

但它的文档中确实有一条好建议,那就是:不要通过共享内存来通信;相反,通过通信来共享内存。简单来说,不要共享内存来通信,而是通过通信来共享内存。这是基本思想,是你必须记住的东西。早在1998年,人们就在博士论文中强调了这个思想。大约十年前,人们在Pony中创建了一门全新的语言来强调这个思想。Go实际上将其作为语言的一等公民,只是为了强调你不应该共享内存。

所以,如果你开始共享内存,如果你真的需要共享内存,你必须非常仔细地思考那个设计决策。或者在当今世界,如果你开始使用大语言模型,而大语言模型生成了很多共享内存类型的代码,你必须质疑它。如果你采用那种代码,它很容易失控,很容易开始表现得非常奇怪、不可预测等等。过去四十年来所有关于并发的问题都会在你开始共享内存时发生。相反,如果你开始使用Actor模型进行通信,那将是一个更好的设计。

唯一的缺点,正如我们之前提到的,是性能上的权衡。在某些情况下,我们称之为特殊情况,我们可能想要共享内存。因为我们希望在线程之间共享状态的最快方式。在那些情况下,你不想承受性能损失。你希望共享内存,因为你想要最快的性能。只有在那些有限的情况下,你才想共享内存。否则,你就使用通信。

所以,如果大语言模型给你,你知道,10000行代码都在做共享内存的事情,你必须质疑它。你必须挑战大语言模型。有些大语言模型,如果你挑战它们,它们会给你更好的方案。如果你挑战它们,它们可以写出更好的代码。这就是设计发挥作用的地方。所以我们想正确地设计它。

这就是我们引入Actor模型的时候。我想在整个图景的早期就介绍这个,在我们深入讨论更多关于线程的内容之前。我想谈谈Actor模型。

Actor是基本的构建块。你可以把Actor看作一个线程。所以一个Actor就是一个线程。你知道,那基本上是有状态的,并且它可以通过状态运行其逻辑。所以它是一个Actor,但它可以实现为一个线程。它不必实现为一个线程。稍后,我们将讨论异步编程。但现在,它可以实现为一个线程。

它是并发计算的基本构建块。现在,这个线程有状态。然而,状态是完全私有的。所以你不会有任何共享的状态。这就是为什么我们说,通过通信而不是共享内存。这个Actor中的所有东西都是完全私有的。当你实际尝试使用这个Actor模型来构建更复杂、更复杂的系统时,你所做的是让这些Actor相互对话。所以这些映射的Actor,它们将相互发送消息。

如果你想从Actor外部改变一个Actor的私有状态,唯一的方法是向它发送一条消息,然后让它自己改变其私有状态。你不会做任何其他事情。没有其他方法可以访问那个状态。访问状态的唯一方法是发送一条消息。所以你会从其他Actor发送一条或多条消息。所以这是Actor。这是另一个Actor。这些是只能向这个特定Actor发送消息的Actor。当然,我们也可以来回发送Actor消息。但这是从我自己的Actor的角度来看。

发送消息有时在编程中我们称之为句柄,但这并不重要你如何实现它。但,你知道,这基本上只是向这个特定Actor发送消息的一种方式。当然,我们可以发送不同类型的消息。我可以向Actor发送多种类型的消息。

如你所见,这实际上是一个非常简单的模型,概念上非常非常简单。你知道,你不允许做任何其他事情。你不能通过直接访问那个Actor来修改状态。你必须向它发送一条消息。当我们说“发送消息”时,我不是在谈论调用函数。我指的是通过一个叫做通道的东西真正发送一条消息。

这是向那个特定Actor发送消息的机制。所以我们必须有一个通道来发送消息,而不是说我要假装发送。不,我们要通过一个通道真正发送它。当然,通道是两个Actor之间共享的东西。但通道唯一能做的就是作为消息的队列中继这些消息,并确保它们被正确中继。这就是通道将做的事情。就这些状态而言,我们无法做任何其他事情。所以没有任何形式的内存共享。Actor模型消除了对基于锁的同步、竞态条件、死锁等任何问题的需求。所有这些都完全消失了。为什么?因为我们不共享内存。

如果有的话,这应该是你的默认模型。你不应该使用共享内存作为默认。这应该是你在并发方面的默认模型。

通道:消息传递的机制

现在,让我们来谈谈这些通道。通道是一个通用的编程概念。我们可以有各种不同的通道,通过它数据从一个线程发送到另一个线程。

一个通道,正如你可以想象的,会有两半。一半是发送器(或多个发送器),另一半是接收器(或多个接收器)。如果我们开始可视化通道,我们可以看到我们可以有一个通道。所以这是一个通道。我们可以有一个或多个发送器,以及一个或多个接收器。所以这是这个特定通道的概念模型,取决于你想要什么。根据你想要的不同语义,可以在Rust标准库或第三方crate中实现。在Rust生态系统中,有许多不同种类的高效通道实现。但根据你的需求,这是你理解通道的实际思路。

发送器有点像河流的上游,你在河里发送橡皮鸭,而河流的下游就是接收器。你也可以认为这是一个通信通道,就像通过网络一样。无论你怎么想。在通道内部,显然,你可以有一个队列。所以在通道内部,你有一个像这样的队列。这是你通道内部的队列。这个队列,再次取决于你想要的语义,可以是一个有界容量队列,也可以是一个无界容量队列。对于有界容量,好处是你知道会消耗多少内存,所以如果不想为该通道消耗大量内存,你可以有一个更紧的界限。对于无界容量,好处是你不会最终丢失或无法将消息发送到通道,你总是可以成功发送,但缺点是如果你不小心,内存使用量会随着程序运行而越来越高。这是你必须考虑的事情。

这就是通道内部的队列。如果发送器或接收器中的任何一个被丢弃。在这种情况下,如果我们有一个接收器,接收器被丢弃了,那么我们基本上认为通道是关闭的。这就是这个通道的语义。非常简单的想法。

我画了一个通道的典型例子,即多生产者、单消费者通道,基本上是多个发送器,一个接收器。你当然可以有超越这个特定想法的东西。但这实际上是最普遍、最常用的。Rust标准库为多线程编程提供了这个。当然,如果你只有一个发送器,你也可以使用这个通道。它不会损失任何性能。如果只有一个发送器,那没问题。这是多生产者单消费者通道的特殊情况。你也可以有多个消费者。换句话说,你也可以有一个多生产者、多消费者通道,这也是可能的,但Rust标准库默认不提供,其他第三方crate可以支持。你也可以有一种叫做一次性通道的东西,基本上它是一种特殊的通道,你只能发送一条消息。然后,完成,通道就完成了。你不能再做任何进一步的操作。当你有一种请求-响应编程模型时,这非常有用,因为你的响应可以很容易地也是一个请求,但很可能响应可以很容易地是一个一次性通道。所以你可以建立一个一次性通道来等待那个响应。这是你可以做的,通常在性能方面开销更小,性能更好。所以与典型的MPSC通道相比,开销更便宜。但这是最典型的。所以在大多数情况下,可能95%的情况下,我们使用MPSC通道没问题,而且性能超级好,因为它针对我们的使用进行了优化。

Rust标准库为多线程模型提供了一个MPSC通道。所以这来自标准库std::sync::mpsc。我们只需调用channel作为一个关联函数,它会返回一个元组。我们只需要解构这个元组,就能得到发送器和接收器。

你可能会说,哦,那只有一个发送器。如果我想要多个发送器怎么办?你怎么想?它只返回一个发送器。如果我想要多个发送器,我想要多个生产者、多个消费者。我只想要多个生产者,我只想要多个发送器。但现在它返回一个发送器和一个接收器。我想让多个发送器向这个通道发送。我想要什么?我该怎么做?是的,完全正确,你只需克隆它。所以这是一个非常简单的概念模型。这是一个非常便宜的克隆。显然,返回的这个发送器可以被克隆。接收器不能被克隆。所有这些从MPSC的语义来看都非常好。你只需克隆它,它将被用作共享同一通道的额外发送器。这真的很好。

你可以有一些发送和接收的方法。所以你可以说tx.sendtx.try_sendrx.recvrx.try_recv。所以有两个变体:sendrecv,与try_sendtry_recv。这两个变体都有它们的用例。所以我们实际上可以同时使用这两个。这两个都返回Result,因为尝试向通道发送时可能会有错误。显然,当你有错误时,你知道,如果是try_send,可能很容易是因为通道已满,你想向一个已满的通道发送。如果是send,当通道关闭时可能会有错误,那么你就无法发送。

这两个变体之间的区别在于,sendrecv阻塞的,意味着它们会阻塞线程的执行,然后等待直到通道有可用容量或变为非空。当我们向通道发送时,我们希望有可用容量。如果没有,我们就在那里等待。当我们从通道接收时,我们希望通道有东西可以接收,即有人向它发送了消息。当它完全空时,我们等待直到它变为非空。这是一个非常简单的语义。通常我们使用那个,因为那是我们习惯的。我们想说,阻塞这个特定线程,直到我们可以完成这个操作。所以这通常是我们想做的。

然而,虽然不典型,但有时我们确实想要非阻塞变体,即try_sendtry_recvtry_sendtry_recv非常有帮助,因为它们是非阻塞的。如果发送或接收失败,它们会立即返回一个错误。有时我们想要非阻塞,因为我们还有其他事情要做。我们不想,你知道,在那里等待。我们在那里还有其他事情要做。所以在这个意义上,我们想使用try_sendtry_recv来获得非阻塞版本。所以实际上有一个可用的两个版本是很好的,这样我们可以选择使用哪一个。需要非阻塞版本的情况相当罕见,但有时我们想要,我们想继续,在同一任务中做其他事情,当我们开始讨论异步Rust中的MPC通道时,这非常典型。当我们谈论异步Rust时,语义在多线程与异步编程方面完全相同,我们在那里仍然有通道,但实际上更典型的是,当我们使用异步Rust时,由于某些原因(我们稍后讨论异步Rust时会谈到),我们开始使用try_sendtry_recv。但现在,有两个变体供你选择。所以这是一个你可以选择的很好的菜单。

使用通道进行消息传递

现在,让我们尝试通过扩展之前的例子来使用通道进行消息传递。我们之前的例子只是简单地创建了一个多生产者单消费者通道。但现在让我们尝试使用thread::spawn创建一个线程。我们想说,让我们尝试发送一条消息。那条消息只是一个简单的字符串“hi”。我们想把它发送到那个特定的通道,只需调用tx.send然后unwrap,基本上获取结果并解包,如果结果是错误(例如通道关闭),它将会panic。在主线程中,我们可以通过获取通道的接收端来接收,然后调用recv。这是一个阻塞版本unwrap。然后我们接收它。所以每当通道里什么都没有时,就在那里等待。主线程将在那里等待,直到通道里有东西。然后它会取出并得到接收到的消息。它将把它打印出来。如果你不想接收,你可以把它改成非阻塞版本。但在这种情况下,显然,我们想要阻塞版本,因为我想把它打印出来。

现在让我们快速测试一下,看看它是如何工作的。

以下是使用通道进行消息传递的代码示例:

use std::sync::mpsc;
use std::thread;

fn main() {
    let (tx, rx) = mpsc::channel();

    thread::spawn(move || {
        let val = String::from("hi");
        tx.send(val).unwrap();
    });

    let received = rx.recv().unwrap();
    println!("Got: {}", received);
}

运行此代码,我们将看到预期的结果,即我们在主线程中接收到的消息。在接收到该消息之前,主线程不会终止,因为它将在该通道的recv上等待。我们不必通过join来等待线程,因为我们只是使用这个非常方便的通道语义在不同线程之间同步,这是我们推荐的。

当然,这不必只是一个简单的字符串。你实际上可以有Actor并将其扩展到结构体、不同类型的消息等。正如我们提到的,接收和发送消息的句柄等等。所有这些都是可能的。所以我们实际上可以尝试将其扩展到一个成熟的Actor模型。

所有权转移与通道

现在,让我们稍微改变一下代码。问题是,这段代码会成功编译吗?现在,唯一的区别是我们在发送之后有打印。所以我们想在生成线程的闭包中,在发送之后打印这个值。这段代码会成功编译吗?你怎么想,这段代码会成功编译吗?我们想打印这个值。在我们把它发送到通道之后。不。所有权已经转移。它在通道里了。所以这是Rust的一个亮点。很多人说Rust只是另一种语言。我提到过,你知道,就像,这只是又一个。不,这正是你需要使用Rust的地方。因为这是一个编译时错误,对吧?所以这给你一个编译时错误,主要是因为所有权模型的便利性与通信通道相结合,这样你就可以轻松地,将运行时错误转化为编译时错误,这样你就不必共享状态等等。你只需发送通道。而且,你知道,消息实际上没有被复制,而是所有权被转移到通道中,然后最终转移到接收者。所以这是我们拥有的东西。如果你在发送后立即开始使用它而没有克隆,它会给你一个运行时错误(抱歉,是编译时错误)。编译时错误基本上说这基本上是String类型,所有权被转移了。它没有实现Copy trait。这就是为什么你不能在移动后使用它。这是非常重要的一点。这就是我们通过将所有权模型与通信通道相结合而利用的优势。所以,我们没有复制字符串,我们基本上只是转移所有权。这真的很好。

总结

本节课中,我们一起学习了Rust并发编程的基础,重点是多线程。我们探讨了Rust如何通过所有权和类型系统将许多并发错误在编译期捕获,从而提升安全性。我们学习了如何创建线程、使用JoinHandle等待线程完成,以及闭包在线程中捕获环境时需要使用move关键字来转移所有权。我们还介绍了更高级的并发模型——Actor模型,其核心思想是“通过通信来共享内存,而非通过共享内存来通信”。最后,我们学习了使用mpsc通道进行线程间消息传递的基本方法,并看到了所有权如何与通道无缝集成,确保数据安全转移。下一节课,我们将继续探讨更复杂的并发主题。

018:无畏并发 - 线程 part2

在本节课中,我们将继续探讨Rust中的并发编程,重点学习如何使用通道(mpsc)进行线程间通信,以及如何安全地共享内存(MutexArc)。我们还将介绍原子类型以及SendSync这两个关键的标记trait。最后,我们会简要展望异步编程的优势。

多生产者,单消费者通道

上一节我们介绍了使用通道进行线程间通信。本节中,我们来看看如何创建多生产者、单消费者(mpsc)的通道,允许多个线程向同一个接收端发送消息。

以下是创建多生产者通道的步骤:

  1. 使用 std::sync::mpsc::channel() 创建一个通道,它会返回一个发送端(tx)和一个接收端(rx)。
  2. 由于发送端没有实现 Copy trait,我们需要通过 clone() 方法来创建新的发送端,以便传递给其他线程。
  3. 每个新线程通过 move 关键字获取其发送端的所有权,然后使用 send() 方法发送消息。
  4. 在主线程中,使用 rx.recv() 阻塞地等待并接收来自所有发送端的消息。
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();
    let tx1 = tx.clone(); // 克隆发送端

    thread::spawn(move || {
        let vals = vec!["hi", "from", "the", "thread"];
        for val in vals {
            tx.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    thread::spawn(move || {
        let vals = vec!["more", "messages", "for", "you"];
        for val in vals {
            tx1.send(val).unwrap();
            thread::sleep(Duration::from_secs(1));
        }
    });

    // 主线程接收并打印所有消息
    for received in rx {
        println!("Got: {}", received);
    }
}

运行此程序,主线程将按接收顺序打印出两个子线程发送的所有消息。这展示了使用通道进行线程间通信的典型模式:不要通过共享内存来通信,而要通过通信来共享内存。每个线程保持其状态私有,仅通过通道传递消息。

使用互斥锁共享内存

虽然通道是推荐的线程间通信方式,但有时出于性能考虑,我们仍需要共享内存。Rust通过 Mutex<T>(互斥锁)来支持安全的共享状态并发。

Mutex<T> 提供了内部可变性,允许我们在多个线程中安全地修改其内部数据。访问被锁保护的数据需要调用 lock() 方法,该方法会阻塞当前线程直到获取锁为止。成功获取锁后,它会返回一个名为 MutexGuard 的智能指针。

以下是使用 Mutex 的基本操作:

  1. 使用 Mutex::new(data) 创建一个保护着数据的互斥锁。
  2. 在需要访问或修改数据时,调用 lock() 方法获取锁。
  3. 对返回的 MutexGuard 进行解引用(*)来访问或修改内部数据。
  4. MutexGuard 离开作用域被 drop 时,锁会自动释放,无需手动解锁。

use std::sync::Mutex;

fn main() {
    let m = Mutex::new(5);
    {
        let mut num = m.lock().unwrap(); // 获取锁
        *num = 6; // 修改数据
    } // 此处 `num`(即 `MutexGuard`)离开作用域,锁被自动释放
    println!("m = {:?}", m);
}

在多线程间共享 Mutex

为了在多个线程间共享同一个 Mutex,我们需要解决所有权问题。由于 Mutex 本身没有实现 Copy trait,不能直接移动到多个线程中。此时,我们需要使用 Arc<T>(原子引用计数)。

Arc<T>Rc<T> 的线程安全版本。它通过原子操作管理引用计数,因此可以安全地在多个线程间共享所有权。结合 Mutex<T>,就构成了 Arc<Mutex<T>> 的经典模式。

以下是创建多线程共享计数器的步骤:

  1. 使用 Arc::new(Mutex::new(data)) 创建被 Arc 包裹的 Mutex
  2. 在每个新线程中,通过 Arc::clone(&counter) 增加引用计数并获得一个新的 Arc 指针,然后通过 move 将其所有权移入线程闭包。
  3. 在线程内部,通过 lock() 获取锁并修改数据。
  4. 主线程等待所有子线程结束后,同样通过 lock() 获取锁并读取最终值。
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..10 {
        let counter = Arc::clone(&counter); // 克隆 Arc
        let handle = thread::spawn(move || {
            let mut num = counter.lock().unwrap(); // 获取锁
            *num += 1; // 修改数据
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap()); // 主线程获取锁并读取
}

原子类型

对于简单的原始类型(如整数、布尔值)的共享,使用完整的 Mutex 可能有些重量级。Rust标准库在 std::sync::atomic 模块中提供了一系列原子类型(如 AtomicUsize, AtomicBool 等)。

原子类型通过硬件级别的原子指令实现无锁(lock-free)的线程安全访问,性能通常高于互斥锁。它们提供了 load(), store(), fetch_add() 等方法进行操作。

以下是使用 AtomicUsize 的示例:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

static GLOBAL_THREAD_COUNT: AtomicUsize = AtomicUsize::new(0);

fn main() {
    let mut handles = vec![];
    for _ in 0..10 {
        let handle = thread::spawn(|| {
            let old_count = GLOBAL_THREAD_COUNT.fetch_add(1, Ordering::Relaxed);
            println!("Previous thread count: {}, current: {}", old_count, old_count + 1);
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
}

其中 Ordering::Relaxed 表示最宽松的内存排序,只保证原子性,不保证操作顺序。

SendSync 标记trait

Rust编译器通过 SendSync 这两个标记trait(marker trait,即内部没有方法的trait)来在编译期保证线程安全。

  • Send:标记一个类型的所有权可以安全地在线程间传递。几乎所有类型都是 Send,一个显著的例外是 Rc<T>,因为其非原子化的引用计数操作不是线程安全的。
  • Sync:标记一个类型的引用可以安全地在多个线程间共享。一个类型 TSync 的,当且仅当其引用 &TSend 的。Mutex<T>Sync 的,而 Rc<T>RefCell<T> 不是。

编译器会自动为合适的类型实现这些trait。当你在泛型约束中指定 T: Send + Sync 时,就要求类型 T 必须满足线程安全的条件。

迈向异步编程

最后,我们简要展望一下异步编程。在处理大量I/O密集型操作时,为每个阻塞操作创建线程(线程模型)会带来显著的上下文切换开销。现代解决方案是使用异步编程栈less协程

其核心思想是:

  • 使用非阻塞的系统调用。
  • 在等待I/O时,通过 await 关键字挂起当前任务。
  • 由异步运行时(async runtime,如 tokioasync-std)在单个或少量线程内调度执行其他就绪的任务,实现极高的并发度。

Rust的异步实现基于栈less协程,是目前性能最高的并发模型之一。我们将在下一讲深入探讨。

本节课中我们一起学习了Rust中更高级的并发工具:多生产者通道、用于安全共享内存的 MutexArc、高性能的原子类型,以及保障线程安全的 SendSync trait。最后,我们了解了异步编程的基本动机和优势,为后续课程打下基础。记住,在Rust中,优先考虑使用通道的消息传递模型,仅在必要时才使用共享内存。

019:异步Rust第一部分

在本节课中,我们将要学习Rust中异步编程的核心概念。这是Rust语言中最具挑战性的部分之一,也是现代高性能网络服务和基础设施的基石。我们将从基本概念开始,逐步深入到异步运行时和Future的实现原理。

概述:从多线程到异步

上一讲我们介绍了多线程编程。本节中我们来看看Rust如何从多线程编程演进到异步编程。

在异步编程中,操作的完成顺序可能与它们的启动顺序不一致。这些操作可以相互交错执行,这与多线程编程在概念上非常相似。

我们上次还提到了CPU密集型(CPU-bound)和I/O密集型(IO-bound)操作的区别。像视频导出这样占用100% CPU的操作是CPU密集型的。而像使用网络浏览器或从互联网下载文件这类需要等待I/O的操作,则是I/O密集型的。I/O密集型操作是异步编程的理想场景。

系统调用与效率问题

许多操作系统内核提供的系统调用本质上是阻塞式的。因为它们会阻塞,我们不得不使用多线程编程。每个Rust线程都与一个内核线程有一对一的映射关系,这样它们可以在不阻塞其他线程的情况下使用阻塞式系统调用。

然而,对于I/O密集型工作负载,使用多线程编程效率并不高。这涉及到上下文切换开销。虽然这种开销比在不同操作系统进程间切换要小,但它仍然存在。随着线程数量的增加,这种开销也会累积,在极端情况下甚至可能超过实际工作的时间。

非阻塞系统调用与回调

一个更好的思路是将许多系统调用从阻塞式转换为非阻塞式。我们可以告诉操作系统:“开始执行这个操作,完成后回调通知我。”这需要在操作系统内核层面进行重大改变。

现代操作系统内核已经实现了这种机制:

  • Windows 最早实现了称为 I/O完成端口 的回调机制。
  • Linux 提供了 Epoll 接口。
  • macOS 提供了 Kqueue 接口。

这些设施大约在25年前就已提供,但用户空间程序(如C++、Rust程序)在利用这些机制方面进展缓慢,因为这非常复杂。直到最近几年,Rust社区才设计出一套优雅的方式来使用这些非阻塞系统调用。

协程:异步的核心

Rust实现异步的方式基于协程。协程与线程的关键区别在于,一个线程可以包含多个并发执行的协程,它们不与内核线程一一映射。

Rust实现的是无栈协程。这是协程的两种变体之一(另一种是有栈协程,Go语言采用此方式)。无栈协程在进行协程间的小型上下文切换时,不将状态存储在用户栈上,因此速度更快。目前,只有Rust和C++支持无栈协程。

并发与并行

理解并发与并行的区别对掌握异步编程至关重要。

以下是两者的类比:

  • 并发 类似于给团队的每个成员分配多个任务。该成员(对应一个CPU核心或内核线程)无法同时处理所有任务,他必须在不同任务间切换,交替推进。这是在单核上模拟的多任务
  • 并行 类似于给团队的每个成员分配一个独立的任务。如果有多个成员(对应多个CPU核心),他们就可以真正同时推进各自的任务。

使用协程,我们可以同时支持并发并行。我们既可以在单个核心上通过协程切换实现高并发,也可以利用多个核心实现真正的并行计算,从而充分发挥现代多核CPU的性能。

Future:代表未来的值

Future 是Rust异步编程的基石。一个 Future 代表一个可能现在尚未就绪,但将在未来某个时刻就绪的值。例如,从一个URL下载数据,这个数据现在没有,但几毫秒或几秒后就会有。

在Rust中,Future 是一个定义在标准库中的特质。任何实现了 Future 特质的类型都是一个Future。每个Future都持有记录其进度和“就绪”含义的信息。不同的任务(协程)对应不同的Future类型。

Future 特质有一个关联类型 Output,它指定了当Future完成时所产生的值的类型。

Async/Await 语法

Rust编译器通过 asyncawait 关键字提供语法支持。

async 关键字可以应用于两种场景:

  1. 函数或方法。
  2. 代码块(例如匿名闭包)。

将一个代码块或函数标记为 async,意味着这段代码可以被中断,并在之后恢复执行。在这段异步代码内部,你可以使用 await 关键字。

await 关键字用于等待一个Future完成。它以后缀形式使用(例如 future.await)。代码执行到 await 时,如果Future尚未就绪,当前的异步任务就可以在此处暂停,让出执行权。异步运行时会转而执行其他就绪的任务。当被等待的Future就绪后(例如,I/O操作完成),原来的任务会恢复执行。

因此,每一个 await 点都是一个潜在的任务切换点

轮询:检查就绪状态

轮询 是Rust异步中的一个核心概念。它指的是检查一个Future,看它的值是否已经可用(就绪)。如果就绪,就可以获取该值并继续执行;否则,任务可能需要暂停。

轮询通常不是由用户代码直接进行的,而是由异步运行时来负责。这是一种协作式调度,与操作系统内核的抢占式调度不同。在协作式调度中,任务主动在 await 点让出控制权,而不是被强制中断。

Hello, Async World!

现在让我们看一些代码。这是一个最简单的异步函数:

async fn say_hello() {
    println!("Hello, async world!");
}

如何调用这个异步函数呢?常规的 main 函数是同步的,而 await 只能在异步上下文中使用。一种常见的方法是使用 tokio 运行时提供的宏来将 main 函数转换为异步函数:

use tokio; // 需要引入tokio库

#[tokio::main]
async fn main() {
    say_hello().await;
}

#[tokio::main] 宏在底层重写了代码,使得同步的 main 函数能够调用异步代码并等待其完成。

异步运行时(Executor)

Rust语言本身不提供内置的异步运行时。这是一个明智的设计决策,它将实现运行时的灵活性交给了第三方库。

异步运行时(也称为执行器)负责调度和执行协程(任务)。它的核心工作是:当一个任务因 await 或等待通道消息而暂停时,运行时选择另一个就绪的任务来执行;当被暂停的任务就绪时(例如I/O完成),运行时再安排它恢复执行。

运行时可以是单线程的,也可以是多线程的。一个功能完备的多线程运行时(如Tokio)非常复杂,但一个简单的单线程运行时可能只需要几百行代码。

以下是几个流行的Rust异步运行时:

  • Tokio: 目前最流行、功能最全面的运行时,由Amazon资助开发,默认是多线程的。
  • async-std: 另一个运行时,旨在提供与标准库类似的API。
  • smol: 一个更小、更简单的运行时。
  • monoio: 字节跳动开源的运行时,采用 “单线程每核心” 模型,专为网络服务器等I/O密集型场景设计,性能极高,并能避免一些数据同步开销。

选择Tokio通常是稳妥的选择,因为它功能强大、生态完善且性能优异。

真实示例:获取网页标题

让我们看一个更实际的例子:异步获取一个网页并提取其标题。

use reqwest; // HTTP客户端库
use scraper; // HTML解析库

async fn page_title(url: &str) -> Option<String> {
    // 发送HTTP GET请求,这是一个I/O操作,需要.await
    let response = reqwest::get(url).await.ok()?;

    // 获取响应体文本,这同样是一个I/O操作,需要.await
    let text = response.text().await.ok()?;

    // 使用scraper解析HTML并提取<title>标签内容
    let document = scraper::Html::parse_document(&text);
    let selector = scraper::Selector::parse("title").ok()?;
    let title_element = document.select(&selector).next()?;
    Some(title_element.inner_html())
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/trt-ece1724-rs-hiperf/img/93658ca2a10b3b3dc0bd7dd8210a8251_10.png)

#[tokio::main]
async fn main() {
    let url = "https://www.rust-lang.org";
    if let Some(title) = page_title(url).await {
        println!("The title is: {}", title);
    } else {
        println!("Failed to get the title");
    }
}

在这个例子中:

  1. reqwest::get(url) 返回一个 Future,我们使用 .await 等待HTTP响应。
  2. response.text() 也返回一个 Future,我们再次使用 .await 等待响应体下载完成。
  3. 剩下的HTML解析工作是CPU密集型的,在同一个任务中同步完成即可。

虽然这个简单例子看起来和同步阻塞调用效果一样,但在像Web服务器这样的场景中,会有成千上万个类似的并发任务。异步运行时可以在一个任务等待I/O时,立刻切换到其他就绪的任务去执行,从而极大地提高系统的并发吞吐能力。

总结

本节课中我们一起学习了Rust异步编程的第一部分。我们了解了从阻塞式多线程到非阻塞式异步的演进动机,认识了协程作为并发单元的概念。我们深入探讨了 Future 作为“未来值”的核心抽象,以及 async/await 关键字如何提供清晰易懂的语法。我们还介绍了异步运行时的角色,以及它如何通过协作式调度来高效管理大量并发任务。最后,我们通过一个获取网页标题的实例,看到了异步Rust在实际中是如何工作的。在接下来的课程中,我们将进一步探索 Future 的实现原理和更高级的异步模式。

020:异步Rust part2

在本节课中,我们将深入学习异步Rust的核心机制,包括如何“去糖化”async函数、理解Future特质、PinUnpin的概念,以及Waker的作用。这是理解异步Rust底层工作原理的关键。

去糖化 Async 函数

上一节我们介绍了异步编程的基本概念,本节中我们来看看如何去除async函数的语法糖,以理解其底层原理。

async关键字为代码提供了便利的语法糖。每次使用async fn,都是在使用这种糖。为了深入理解,我们需要“去糖化”它。通过这个过程,我们希望更好地理解异步Rust。

以下是去糖化后的代码。与在fn关键字前使用async的“含糖”版本不同,我们得到了一个功能完全相同的等价版本。

fn page_title(url: &str) -> impl Future<Output = Option<String>> + ‘_ {
    async move {
        // ... 原有的异步代码块
    }
}

我们在这里显式地返回一个实现了Future特质的东西。在“含糖”版本中,这并不显式,因为我们有async关键字,但它们是相同的。这就像是黑咖啡,而之前的是加了双倍糖和奶的咖啡。

我们如何返回这个Future?我们返回一个实现了Future特质的东西,但需要确保其关联类型OutputOption<String>。记住,“含糖”版本直接返回Option<String>。现在在去糖版本中,我们有一个Output关联类型为Option<String>Future。这意味着当这个异步函数准备就绪后,评估这个Future的结果将是Option<String>

我们没有直接将所有内容放入async函数中,而是使用了去糖版本,将所有内容放入async move块中。可以看到,我们仍然使用async关键字,只是现在它作用于一个代码块而非函数。这个关键字基本上是说:这个代码块将评估为某种Future类型。由于代码块内部返回Option<String>,该Future的关联类型将是Option<String>

async move使这个闭包(即这个代码块,一个匿名闭包)成为一个Future。这就是我们实际使用async关键字的方式。move意味着我们捕获环境,所有权将被转移到实际的闭包中。

以下是刚才提到的要点总结:

  • 首先,我们使用了impl Trait语法。这在我们之前将特质作为参数讨论时提到过。我们用它来表示返回的类型实现了特定的特质。
  • 我们实现Future特质,其关联类型OutputOption<String>。然后返回一个Output关联类型为Option<String>Future
  • Output的类型是Option<String>。这是Future完成后我们将得到的类型。
  • 这与原始“含糖”版本的返回类型相同,因为原始返回类型是Option<String>。现在我们基本上是说FutureOutput等于Option<String>

关于这个去糖版本,还需要说明的是,所有内容都被包裹在这个async move块中,这个代码块原本就在原始版本中。记住,代码块最终就是表达式。这个async块实际上会给你一个评估该表达式的结果:一个由编译器生成的匿名类型,因为async是编译器能理解的关键字。

这个由编译器生成的匿名类型,同样由编译器保证实现了Future特质,并且输出类型是Option<String>。当你对这个特定的Future(无论它返回什么)调用.await时,最终这个Future会变为就绪状态。当它就绪时,它将产生一个类型为Option<String>的值。这个值将与返回类型中的Output类型匹配。

但请记住,我们还有一件事。

回到原始代码,可以看到我们有一个+ ‘_。这是什么意思?‘_是生命周期省略,或者叫匿名生命周期。我们基本上是在说,这里存在某种生命周期问题。我们需要放入一些东西来表明:我们希望这个特定的Future有一个生命周期界限。

请记住,在async move内部,由于move,闭包捕获了urlurl本身是一个引用,是对字符串的引用。这基本上意味着我们在Future内部有一个引用。

无论async move生成的是什么,都是一个由编译器生成的异步匿名类型,也就是一个Future。我们基本上是在这个Future内部捕获了url。那么这里的+ ‘_是做什么的呢?返回的Future将捕获这个url,而url是一个引用。因此,Future将借用这个urlFuture不能比它持有的引用活得更久。这是我们总是在Rust中提到的第一条规则:无论我们做什么,都不能比我们持有的引用活得更久。

为了给编译器提示,我们必须有生命周期说明符。它的生命周期应该与url的生命周期绑定。换句话说,我们想使用这个+ ‘_界限来向编译器暗示(用英语说):这个impl Future可能包含借用,其生命周期是某种匿名生命周期(因为‘_是匿名生命周期),其生命周期最多与其捕获的引用(例如url)一样长。

之所以需要这种‘_,是因为Rust编译器在没有类似说明的情况下不会接受它。如果你没有这个,它会在编译时给你一个错误信息,因为你必须明确告诉我,这个东西保证最多与其内部Future捕获的引用一样长。

不过,这太令人困惑了。好消息是,编译器只在1.7版本之前(比如2022年左右的旧版本)会给出这个错误信息。过去三年,我们有了现代的Rust编译器,现在是1.91。我们不再需要这个了。所以编译器会说:好吧,我要求你写这个太多次了,现在我知道你想要这个了,所以我会为你推导出来。编译器变得更聪明了一点,它理解这就是你想要的。所以即使你没有+ ‘_,它仍然会编译,因为它假定就是+ ‘_

这是个好消息。以上就是这部分内容。

去糖化 Tokio 宏

接下来,让我们尝试去糖化tokio::main。这是一个宏。我们也想对它进行去糖化。

这里我们有tokio::main函数。我们说可以使用那个宏来给main加上async。但这同样更像是一个宏,给我们提供了一种语法糖,只不过我们使用第三方crate的宏来提供糖,而不是编译器直接提供糖。通过宏,第三方crate可以轻松扩展现有代码,并尝试以那种方式给你糖。

这个糖就是async fn。那么我们如何去糖化它呢?这完全等同于下面这样:

fn main() {
    let rt = tokio::runtime::Runtime::new().unwrap();
    rt.block_on(async {
        // ... 原有的异步代码
    })
}

我们仍然有非糖版本,即同步的main函数。但在main内部,我们使用了Tokio的运行时结构体,并调用new方法来创建一个新的异步运行时。一旦我们有了这个新的异步运行时,我们做什么呢?我们调用一个名为block_on的方法。

runtime.block_on是该运行时上的一个方法,基本上是说:我将启动我的调度器来运行你的异步函数或异步代码块。在这个例子中,它是一个异步代码块。我们必须在一个异步代码块中调用await,这就是我们要做的。我们有一个匿名闭包,标记为async,它基本上会生成一个Future,因为所有async块都会生成一个Future。它作为一个表达式,其求值结果是一个Future。它是一个由编译器生成的匿名类型,也就是一个Future。在那个代码块内部,我们可以调用await

所以这个block_on就是“黑咖啡”版本的说法:现在我创建一个全新的运行时,然后我将尝试使用我的block_on来运行你的异步函数或异步代码块。从名称block_on可以看出,这基本上是说:你有所有这些异步的东西,我将阻塞直到所有这些事情完成。这就是我们使用Tokio运行时所做的,与使用宏完全相同。

这种方式实际上灵活得多。例如,你可以将运行时配置为单线程等等。你有很多配置选项。你不必只用new创建一个默认运行时。你可以在创建运行时放入配置函数、选项。所以你可以让它成为多线程、单线程,等等。

这就是我们拥有的main函数的非糖版本。

深入理解 Future

但这还不是结束。我们还想更深入地了解Future。让我们回到最初的问题:在Rust中,Future到底是什么?我们如何理解它?

我们提到过,Future是一个表示可能尚未完成的事物的值。这个Future就像是某种计算,只不过这个计算可以被挂起,因为它可能并不总是就绪的。例如,从网站下载需要时间,它可以被挂起;从通道接收东西,需要等待该通道中的特定消息到达。这些都是可能尚未完成的事情。这就是Future值的含义。

现在,让我们看看正式定义。这是Rust标准库内部的正式定义。我们有一个名为Future的公共特质。

pub trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<‘_>) -> Poll<Self::Output>;
}

当然,我们说有一个关联类型Output。我们就是这样做的:type Output。和迭代器一样。现在我们有一个poll方法。这个poll方法接受self。显然,它是一个方法。self是对self的可变引用,这意味着你可以改变自己。还有另一个参数,即context。这个context是对Context类型值的可变引用。Context类型我们稍后会谈到,但它确实试图给我们一些我们以后可以使用的东西,用来恢复这个特定的计算。

那么它返回什么呢?它返回一个枚举,这个枚举叫做Poll。这个枚举有一个泛型类型T。在这里,我们基本上是说这个泛型类型与关联类型Output相同,也就是Self::Output。这就是我们使用关联类型的原因。

基本上,这意味着我们将返回一个枚举,这个枚举可能是Ready,也可能不是就绪的。如果不是就绪的,它实际上是Pending,这基本上意味着我们正在轮询它。它可能是Ready,也可能是Pending。当它是Ready时,意味着这个Future完成了。当计算完成时,它就是Ready;当它是Pending时,意味着它还没有完成。

这就是我们为Future特质上的poll方法设定的实际契约。任何实现了Future特质的类型(结构体或枚举等),都必须有poll方法。

以下是一些要总结的要点:

  • 我们有type Output。这是Future最终将产生的类型。例如,它可能是我们提到的reqwest::get函数返回的FutureResultOutput的类型可能简单到是一个u32整数。任何类型都是Output的可能,这取决于任务。
  • 我们有一个poll方法。它基本上是说:我们正在轮询Future。这意味着我们在询问Future:你准备好了吗?给我结果。你准备好了吗?如果你没准备好,就是Pending。所以Poll将返回一个枚举ReadyPending
  • 如果我们没有完成,它返回该枚举的Pending变体。如果我们确实完成了,它将给我们一个Ready,但不仅给出Ready,还会放入该枚举变体的值。Rust中的很多东西在这里开始变得清晰起来。我们基本上利用了Rust编程语言的基本优势,可以将值放入任何枚举中。
  • 那将是当Future就绪时,我们从Future中提取的值。

好消息是,在普通代码中,你不必手动编写poll,不必手动实现Futurepoll方法。但尽管这是个好消息,我仍然敦促你去理解poll。理解它会很好,因为如果你不理解它,你将永远无法理解异步Rust。这是异步Rust的关键。换句话说,你想要“黑咖啡”版本。你真正想确切理解语法糖、Future以及async/await语法为你所做的一切,结合你的异步运行时(即执行器,如第三方crate Tokio)的力量。所有这些将协作为你完成。显然,poll将由Tokio运行时或我们拥有的任何其他执行器来实现。

现在,我们必须理解一点:Future就像迭代器一样,被设计为惰性的。这是一个我们想要强调的非常重要的设计原则。这些东西应该被设计成惰性的,而不是急切的。惰性的反面是急切。所以当你实际拥有一个Future时,你不会尝试运行它。为什么当你有一个Future时要尝试运行它呢?不,你不运行它。你只是有一个Future,你不运行它。计算仍然在那里,它在那里,但你不运行它。

例如,这里我们有一个Futureasync { println!(“hi”) },这是一个Future。所以我们将把这个Future赋值给fut。但如果你只有这行代码,什么都不会打印。它不会被执行。你什么时候执行它呢?就像迭代器一样,它们是惰性的。当你放入.await时它们才会被执行。所以fut.await,然后它才会被执行。当然,.await只是编译器和第三方库(即Tokio运行时)之间的协作,它们协作为你提供那个poll。所以.await基本上意味着我将调用poll。当我开始调用poll时,我启动了计算。计算被激活了。这就是我们正在做的。如果你不调用.await(这是激活它的后缀形式),它将是惰性的,不会运行。这完全像迭代器:如果你没有next,迭代器将是惰性的。

这与线程非常不同。在线程中,如果你开始说我要生成一个新线程,它会立即开始运行。所以它不是惰性的,而是急切的。这是线程的设计。Future什么也不做。它将是惰性的,直到一个执行器(即Tokio)轮询它。

这就是我们必须理解的关于Future的内容。

执行器、任务与 Waker

现在,让我们更详细地了解一下执行器,即实际的Tokio运行时,以及任务。

像Tokio、async-std和monoio这样的运行时,它们会提供一个执行器。执行器是做什么的呢?它将维护一个Future队列。显然,因为我们有多个协程,其中一些可能需要运行。在这些执行器中,特别是在Tokio中,我们通常给它起个名字叫“任务”。这就是为什么我们通常说协程和任务,这些术语可以互换使用。

然后,我们将重复地对这些Future调用poll。我们将对这些Future调用poll。这是运行时、执行器的工作。

除此之外,我们还有一样东西:WakerWaker是某种需要被通知的东西。通过调用Waker上的wake方法,我们通知那个Waker。为什么我们需要通知呢?一个I/O操作可能需要几百毫秒甚至几秒钟才能完成。我不想每20毫秒轮询一次,那有点太多了。我希望那个I/O操作能回调我。这得益于Waker结构体,它利用了操作系统内核的功能,即OS内核能够回调实际的用户空间。在用户空间中,谁来接收这个回调呢?将是Waker。所以Waker将唤醒那个特定的Future。这给了运行时一个理由再次开始轮询它。如果你不唤醒它,运行时将不会轮询那个Future

这就是Waker。然而,如果你回到Future的定义,可以看到我们还有一样东西。这是Rust标准库中Future的原始正式定义。我们还有一样东西,那就是Pin<&mut Self>&mut Self我们理解。这基本上是对我自己的可变引用,对吧?所以这很典型。通常我们有&mut self,在这里我们有点明确,self实际上不仅仅是典型的&mut self(对self的可变引用),而是对self的可变引用的固定版本。这是什么意思?什么是Pin<&mut Self>

让我们看看这个。这是运行时要做的事情。它将用context来轮询那个Future。因为那是我们必须传入的参数,是特质的一部分。所以poll接受self。特质的定义基本上是说:当你调用poll时,这个Future必须是固定的。它可以是可变的,但必须是固定的,而不是标准的可变引用。

固定它到底是什么意思?Pin只是一个包装器。它表示:通过这个特定的指针,你不允许移动值本身。值本身驻留在内存的同一位置,直到它离开作用域。通常,我们能够移动它。所以如果我们把某个东西放入迭代器、向量等等,我们基本上是在移动它。移动它本身不是问题,根本不是问题。你可以随时移动它。但在这种特殊情况下,我们基本上是说这个Pin要求(由编译器强制执行)这个不能被移动。

基本上,有了Pin,你就有了一种指针。这个指针是一个固定指针,而这是模型,这是实际内存中不可移动的部分。所以这是不可移动的。这将是实际的坑。这增加了一层间接性,这部分可以移动到任何地方,但这个不能。所以如果你真的想把它放入向量,你想放入它的固定版本,因为指针可以移动到任何地方,但内存本身不能。

这就是Pin的起点。让我们更详细地了解一下这个Pin。它仍然是一个可变引用。换句话说,你仍然可以改变这个特定内存区域、这个特定内存块内部的任何值,但你只是想要一个额外的保证:你可以改变那个T内部的值,但不能将T移动到不同的内存位置。这就是Pin所保证的。

那么,当我们在Future特质定义中实际使用Pin时,我们关心移动。为什么我们关心移动呢?因为这些Futureasync/await Future,有一个特定的属性叫做“自引用”。在很多情况下,它们并不总是自引用的,但在很多情况下,它们是自引用的。

自引用是什么意思?首先,这个内存,这个Future就在这里,这是一个实现了Future特质的类型。这里的Future基本上是编译器使用async关键字生成的匿名类型。这是一个状态机。它包含状态。所以编译器会把它变成一个状态机。这是编译器的工作。它会把它变成一个状态机。它包含当你挂起这个特定任务并恢复另一个任务时需要存储的所有状态。你想存储那些状态,那些状态就存储在这个Future中。

这个特定的状态实际上可能包含引用。基本上,这意味着你有一个字段,这个字段引用了同一内存中的另一个字段。如果你最终移动了这个,那么这个指针就会变成悬垂指针。因为当你移动这个时,你并没有同时更新这个值。所以它变成了一个悬垂指针。当然,我可以改变这个值,但更好的说法是:作为契约的一部分,这个内存位置不能被移动。在这些引用被创建后移动结构体,那些内部引用实际上会悬垂,这就是为什么Rust基本上说:一旦我们开始轮询一个Future,我们必须保证无论我们用什么状态来表示计算,该计算的所有中间状态都必须被固定在内存的特定位置。

这就是为什么在Future特质中,我们必须把Pin放在那里。那么,self: Pin<&mut Self>强制了什么?首先,在调用poll之前,执行器必须已经固定了Future。其次,在poll内部,Future被视为不可移动的。所以你不能移动这里的内存。

这就是固定Future的含义。执行器负责固定Future,否则就是编译时错误。

那么执行器通常做什么呢?这不是任何执行器的一部分,就像典型的代码,我们通常做的。我们所做的是:在我们轮询那个Future之前(在这个例子中,我们这里有一个Future),我们首先固定那个Future,我们调用Pin::new来固定那个Future。这个Pin是标准Rust库的一部分,供我们使用。所以我们基本上是说:不,不,你不能移动这个。我们将通过固定它来增加一层间接性。我可以移动这个,但不能移动这个。现在,在我们固定它之后,我可以调用poll,现在它强制执行了契约。编译器会看到它,会喜欢它,不会有任何问题。当然,poll的返回值将是一个枚举变体,要么是Ready,要么是Pending。如果是Ready,我们将从中得到输出。

那么,如果我们有Pin,我们就会有UnpinUnpin是什么?Unpin实际上是一个标记特质。标记特质是什么?标记特质是一个里面什么都没有的特质。没有数据,没有方法,没有关联函数,什么都没有。基本上,它只是一个标记。它只是用来标记某些属性。

Unpin标记特质基本上是说:如果一个FutureUnpin,如果它真的是固定的,那么执行器必须将它固定在一个稳定的位置。例如,在这个例子中,一个Future实际上不是Unpin。大多数由编译器生成的async Future都会是这样。它将是Unpin,不是Unpin。那么我们将不得不固定它。但通常什么是Unpin的呢?

Unpin基本上作为一个标记特质,它基本上是说:回答这个问题,即使它已经被固定,在内存中移动这个东西是否仍然安全?如果仍然安全,那么这个类型是Unpin的。如果不安全,那么这个类型不是Unpin的,也就是我们的匿名Future。所以如果它不是Unpin的,我们将不得不固定它。如果它是Unpin的,我们并不真的需要固定它。

那么什么是Unpin?哪些类型是Unpin的?首先,如果Self实际上被标记为Unpin,那么Pin就等同于对Self的可变引用。如果类型本身是Unpin的。那么大多数普通类型,它们是Unpin的,你知道,StringVec、原始类型整数,它们是Unpin的。然而,这里的特殊情况是,编译器生成的类型,也就是async Future,将不是Unpin的,或者说是!Unpin(非Unpin)。这意味着它们需要固定。这就是实际执行器在轮询这个特定Future之前固定它的方式。

所以self: Pin<&mut Self>,一个对自身的可变引用,基本上只是Future特质内部的一个通用签名,表示我们有self,它对固定的和未固定的Future都有效。我们强制规定:如果它不是Unpin的,那么在轮询之前必须固定它。所以如果它是Unpin的,那么我们不需要固定它。如果它不是Unpin的,我们将在调用poll之前固定它。

这就是Future特质内部由编译器强制执行的实际契约,一切开始变得清晰起来:你有Pin作为标准库,你可以使用Pin::new;你有Unpin,它是标记特质,给我们知识,给编译器知识,哪个是Unpin的,哪个不是Unpin的。然后你有这个特质来强制执行,并由编译器强制执行。

以上就是关于UnpinPin的内容。

最后但同样重要的是,在深入探讨中,我将谈谈WakerWaker是什么?Waker基本上是一个Future的门铃。Future是一段计算。它运行,你知道,它还没有准备好,但最终它会准备好。当它准备好时,门铃会响。那就是WakerWaker是由执行器创建的一个句柄。Future可以存储它,以后可以调用waker.wake()。当我们调用wake时,我们在说什么?我们基本上是在说:嘿,运行时,我现在可能准备好了,请再次轮询我。稍后。这就是运行时将要做的。

那么Waker存在于哪里呢?Waker存在于context中。记住,当你实际尝试在Future中调用poll时,你必须传入contextWaker就在那里。在context中,你有一个Waker,它返回一个对Waker的引用,那就是Waker所在的地方。当然,实际的Waker可能因不同的任务而不同,当Waker的门铃响起,当waker.wake()被实际调用时,你实际上可以尝试轮询那个特定的Future

所以在poll中,你通常做的是从context中获取那个Waker,然后也许把它存储在某处。我说“也许存储在某处”是因为通常把它存储在某处实际上是个好主意。当然,这通常不是你的事。有时你可以让Tokio执行器来做,但理解这一点是有帮助的。这是你实际要表示的东西,I/O就绪了,知道下载就绪了等等。所以你可能想把那个Waker存储在某处,如果它是一个多线程运行时,甚至用Arc<Mutex>保护那个结构体,只是为了让它成为线程安全的。最终我们将调用wake,那将是我们的门铃,那时我们将再次轮询它。我们不希望在它准备好之前轮询它。

那么为什么我们需要Waker呢?快速总结一下:没有Waker,如果一个Future还没有准备好,执行器将一直持有它,通过忙循环或者一直轮询每个Future,以防万一。这并不理想,因为在很多情况下,这些异步任务是I/O操作,或者真正可以有一个Waker的东西。我们将使用它,并说操作系统内核在这个任务准备好时回调我。这就是操作系统内核使用epoll/kqueue和IOCP接口将要做的,也是运行时在Waker调用wake之后将尝试做的,它将唤醒那个Future,它将再次开始轮询它。否则,它将不会轮询。所以如果你一直轮询,对CPU来说非常糟糕。

异步Rust中的契约是:当poll返回Poll::Pending时,Future还没有准备好。如果它曾经准备好要取得进展,它必须确保有人可以调用waker.wake()。显然,如果它准备好了,只需要有人调用它。最终,当wake被调用时,执行器将安排那个Future再次被轮询。这就是我们刚才提到的总结。

生成任务

现在我们还有10分钟。我们将讨论一些简单的内容。

首先,我们将讨论生成任务。这里我们不再生成线程,我们生成任务。计算就像是,基本上进行一个非阻塞的系统调用。很可能是I/O。操作系统内核将使用epoll/kqueue接口实际回调运行时。然后waker.wake()将被调用。然后我们再次开始轮询。

这就是基本思想。那么我们想生成一个任务而不是线程。然而,为什么我说这是简单的内容呢?这是简单的内容,因为这完全和生成线程的想法一样,只不过现在我们不再拥有这个多线程库。我们基本上拥有我们刚才提到的这个漂亮的协程库,带有运行时、async/await语法以及我们享受的所有这些语法糖。这是我们可以直接使用的东西。

在这个例子中,我们直接使用Tokio。Tokio有spawn,它与标准Rust库中的thread::spawn非常相似。它也有sleep,它有Duration。所有这些都将从Tokio获得,就像在线程中一样。我们可以直接使用相同的东西,所以我们可以实际睡眠。然后我们可以在那个睡眠上调用await。这正是我们想要的。所以我们想等待100毫秒。当然,当我们在那个Future上调用await时。我们将从那个特定任务切换到其他东西。例如,这里我们有其他东西。然后我们可以一个接一个地生成它们,它们基本上将开始运行。当然,它们都将在那个睡眠上挂起。然后最终,它们将醒来。我们将实际打印“hello world”。所以这肯定是我们能做的。当然,main也是async的,因为我们那里有一个await。这意味着我们实际上可以为main函数准备第三个协程。它睡眠1000毫秒。这就是生成任务的简单方式,就像我们在线程方面拥有的多线程库一样。

至于连接任务,同样,完全一样。我们只需要在Tokio术语中生成两个不同的协程或称为两个不同的任务,而不是两个不同的线程。然后我们可以使用它们的句柄。我们可以实际尝试连接。一种方法是直接调用await,因为await将只是等待整个任务完成。我们也可以尝试其他连接方式。例如,我们可以有一个select,我们没有太多时间讨论那个,但我们也可以做那些,但请记住,在前面例子中的连接句柄,当我们有一个多线程库时,spawn的返回值是一个连接句柄。这里的spawn返回一个连接句柄,但连接句柄只是一个Future,它实现了一个Future,这意味着我们可以在它上面调用await,这对我们来说非常方便。

总结

以上就是我今天想讲的全部内容。

请记住,这一讲有些难以理解。你可以想象,当你开始阅读实际参考资料时,它有多难理解。我们在这个特定的知识块中有很多不同的参考资料,但它们都不够好。所以我的讲座实际上不是基于任何这些东西。其中一些,它们没有讲得太深。例如,《Rust编程语言》全新的第17章(去年甚至还不存在),开始谈论关于固定的自引用等等,在某些方面讲得更深,但它没有谈论Waker,没有谈论context等等。所以它在另一面没有讲深。所以它不够好,但对理解这些东西有帮助。《Rustonomicon》那本有点太深了,实际上更难理解发生了什么。《Rust异步编程》那本甚至还没有完成。而Rust异步运行时Tokio运行时,这个组合实际上从1.62版本(2021年)就可用。四年后,这本《Rust异步编程》还没有完成。只有第1到4章大致完成。这些章节实际上给了你一个高层次的概念,但没有给你细节。例如,Future细节、特质细节、实际的轮询、固定、UnpinWaker。所有这些都不是这第1到4章的一部分。然后你还有各种博客文章。例如,这里有一个博客文章的例子,叫做“异步Rust运行时的状态”。同样,它有点过于深入细节,而没有介绍高层次的概念。所以如果你没有基本概念,一开始很难理解。最后,你还有文档。例如,Tokio本身的文档。但文档太多地关注如何用多线程和单线程配置Tokio等等,并没有真正深入高层次的概念:固定是什么意思?Unpin是什么意思?Future、轮询等等是什么意思?它也没有深入探讨当我们使用非阻塞系统调用以及操作系统内核拥有回调用户空间的功能时,它是如何与操作系统内核协同工作的。所有这些都会是某种谜团。

所以在阅读了所有这些之后,它可能仍然或多或少是个谜。我花了很多时间准备这一讲。正如你所看到的,有一张幻灯片甚至不正确。我只想通过这一讲向你传达这个想法,希望你能比没有这一讲更好地理解这些东西。并且,在未来,我希望你能更多地使用异步Rust,因为这实际上是Rust中最好的特性之一。

正如你所看到的,如果你使用monoio运行时,你甚至不需要实现Arc<Mutex>SyncSend特质。所有这些都会更麻烦。现在拥有这个真的很好。

我今天还有一件事要说,那就是课程评估。课程评估是开放的。我发了一封邮件通知大家在线评估课程。确保你在下一讲之前完成,我希望你能完成,因为它不会给你任何额外的学分或分数,但你可以把它想象成隐藏的宝藏,或者你可以把它想象成功德点,它将帮助这门特定课程的未来一代、这门特定课程的未来学生。所以基本上,它是一个实现了Future特质的计算。这就是你要做的。你可以时不时地挂起你的计算,你可以恢复它,但要确保它准备好。输出在下一讲之前产生。谢谢。我期待在下一讲见到你,那是最后一讲。我们下次要讨论很多事情,一定要来参加下一讲。

posted @ 2026-03-29 09:36  布客飞龙I  阅读(8)  评论(0)    收藏  举报