[TOC]
一阶段总结
语法学习
了解了rust的所有权特性。学习使用泛型做为函数参数类型,知道了如何在泛型后追加多个trait。学习给自定义类型添加trait。在做Rustlings的过程中学习并深入了解rust语法特性。使用rust实现了最后的算法题。在不断的编码测试中完成了Rustlings。
互帮互助
在学习过程中虚心请教同学和老师。经常与同学们交流rust语法问题,在讨论和测试中提高对语言的掌握程度。认识了不少勤勉聪慧的能人。徐堃元老师耐心教学,带病上岗,尽职尽责。

几年前就萌生了用 Rust 制作一个操作系统的想法, 但 x86 的复杂度又有点让人望而却步, 而当时恰好遇到了 riscv, 恰好遇到了 rCore. 到现在, 终于有了一个可以亲身加入训练营的机会, 在此感谢各位老师和学长们为此的付出
几年前将自己学习 Rust 的过程汇总成笔记写成教程放在 个人博客 上了. 这里只再简单总结一下一些重要概念内容
所有权 (Ownership)
所有权是 Rust 的一个核心概念, 用于确保内存安全. 它规定了一个变量 (或数据) 在任何时刻都有且仅有一个所有者. 当所有者离开作用域时, 其所拥有的数据将自动释放, 防止内存泄漏. 通过移动赋值和不可变 / 可变借用, 得以控制数据的访问和生命周期
泛型 (Generics) 与生命周期 (Lifetimes)
泛型允许创建适用于多种类型的数据结构和函数, 无需重复实现. 生命周期可被视为一种特殊的泛型. 生命周期注解是用来描述引用的有效期的一种机制. 在函数签名或结构体定义中, 通过类似 'a 这样的符号来表示生命周期参数, 并通过上下文推断或显式声明来确保引用不会超过其底层数据的有效期
Traits (特性)
特质是 Rust 中的一种接口抽象, 它定义了一组方法签名, 供实现特质的类型遵守. 通过 traits, Rust 实现了面向对象编程中的多态性.
智能指针
Rust 提供了一系列智能指针类型, 如 Box<T> Rc<T> Arc<T> RefCell<T> 等, 它们分别用于管理堆上的唯一所有权, 引用计数共享所有权, 线程安全的引用计数共享所有权以及在编译时静态检查之外增加运行时借用检查等场景
多线程与异步编程
std::thread 模块相关API. Rust 的 Ownership 和借用规则有助于防止数据竞争和死锁等问题,Mutex, RwLock 等同步原语进一步加强了线程间的安全通信async/await 关键字,提供了高效非阻塞IO的能力. tokio 和 async-std 等库提供了异步编程的基础设施, 包括任务调度, TCP / HTTP 网络编程等除了这些基本语法, 在练习中也实践了手动实现链表, 双向链表, 堆栈, 深度优先和广度优先算法, 排序算法等. 通过 Rustlings 练习, 可以逐步掌握以上各个概念的实际应用, 通过解决实际问题加深对 Rust 编程特性的理解, 并学会如何在实践中遵循 Rust 的安全性和性能原则, 为接下来的第二阶段打下基础, 以构建安全高效的 OS 内核
此外也感谢群内的大佬们, 没事翻翻大佬的聊天记录总能学到新东西 :)
一些学习链接:
在21年下半年的时候,当时想着给前端的某个项目赋能,由于前端项目过于臃肿、代码质量不高,导致了不单单是开发还是维护都提出了巨大的工程量,提升前端的编译速度和开发效率的情况迫在眉睫,无意中发现Rust在webassembly领域是有一地之席的、在前端基建方面具有与生俱来的优势。
所以很单纯的目的就是想利用Rust这门高性能、安全性的语言来提升前端打包的效率。
目前Rust社区里已经有使用Rust为基础开发的前端打包工具Turbopack。
非常自然的我进入到了Rust这门语言的学习。
由于Rust,我是有基础的。所以在第一阶段我可能更加注重的是Rust基础的巩固,同时加深使用Rust语言的算法描述。
学一门语言,除了学习各类语言相同的编程概念外,其中,学习该语言自身的特性和适用场景也是非常重要的。
Rust作为一门系统级编程语言,采百家之长,自身形成一些Rust语言所特有的语言特性,其中变量的所有权和生命周期就是Rust自身语言特性中最璀璨的一块。
👇👇👇现在给大家总结和分享一下我在开源操作系统训练营第一阶段的总结。👇👇👇
在学习Rust的过程中,如果你想从其他语言迁移到Rust,必须要经过一段时期的思维转换(Paradigm Shift)
从命令式(imperative)编程语言转换到函数式(functional)编程语言、从变量的可变性(mutable)迁移到不可变性(immutable)、从弱类型语言迁移到强类型语言,以及从手工或者自动内存管理到通过生命周期来管理内存,难度是多重叠加。
而 Rust 中最大的思维转换就是变量的所有权和生命周期,这是几乎所有编程语言都未曾涉及的领域。
只列出我认为学习Rust这门语言的内容和我入手其他语言的经验之谈。
先把握大体后追踪语言细节。
Rust这门语言最耀眼的设计就在于变量的所有权机制和生命周期,这也是Rust这门语言我认为最具代表性的特征。
它带给了Rust语言最本质或者说最核心的点、就是内存的管理。致使使用这门语言的人要有强烈的内存分布和管理内存的能力,所有权和生命周期是 Rust 和其它编程语言的主要区别,也是 Rust 其它知识点的基础。
1 |
|
动态数组因为大小在编译期无法确定,所以放在堆上,并且在栈上有一个包含了长度和容量的胖指针指向堆上的内存。
在调用 find_pos() 时,main() 函数中的局部变量 data 和 v 作为参数传递给了 find_pos(),所以它们会被放在 find_pos() 的参数区。

按照大多数编程语言的做法,现在堆上的内存就有了两个引用。不光如此,我们每把 data 作为参数传递一次,堆上的内存就会多一次引用。
但是,这些引用究竟会做什么操作,我们不得而知,也无从限制;而且堆上的内存究竟什么时候能释放,尤其在多个调用栈引用时,很难厘清,取决于最后一个引用什么时候结束。所以,这样一个看似简单的函数调用,给内存管理带来了极大麻烦。
对于堆内存多次引用的问题,我们先来看大多数语言的方案:
C/C++要求开发者手工处理:非常不便。这需要我们在写代码时高度自律,按照前人总结的最佳实践来操作。但人必然会犯错,一个不慎就会导致内存安全问题,要么内存泄露,要么使用已释放内存,导致程序崩溃。
Java等语言使用追踪式GC:通过定期扫描堆上数据还有没有人引用,来替开发者管理堆内存,不失为一种解决之道,但 GC 带来的 STW 问题让语言的使用场景受限,性能损耗也不小。
ObjC/Swift使用自动引用计数(ARC):在编译时自动添加维护引用计数的代码,减轻开发者维护堆内存的负担。但同样地,它也会有不小的运行时性能损耗。
现存方案都是从管理引用的角度思考的,有各自的弊端。我们回顾刚才梳理的函数调用过程,从源头上看,本质问题是堆上内存会被随意引用,那么换个角度,我们是不是可以限制引用行为本身呢?
这个想法打开了新的大门,Rust 就是这样另辟蹊径的。
所以所有权机制规定了以下3点来限制引用。
在这三条所有权规则的约束下,我们看开头的引用问题是如何解决的:

原先 main() 函数中的 data,被移动到 find_pos() 后,就失效了,编译器会保证 main() 函数随后的代码无法访问这个变量,这样,就确保了堆上的内存依旧只有唯一的引用。
本质上来讲这就是所有权机制所保证的
其中又牵扯出了移动(Move)和借用(borrow)、引用(Reference)的规则
变量data的所有权被移动给另一个data1了、后续data1的所有权移动到了sum函数中、导致了变量data和data1
都被移动、无法使用。
可以看到,所有权规则,解决了谁真正拥有数据的生杀大权问题,让堆上数据的多重引用不复存在,这是它最大的优势。
1 | fn main() { |
但是,这也会让代码变复杂,尤其是一些只存储在栈上的简单数据,如果要避免所有权转移之后不能访问的情况,我们就需要手动复制,会非常麻烦,效率也不高。
Rust 考虑到了这一点,提供了两种方案:
如果你不希望值的所有权被转移,在 Move 语义外,Rust 提供了 Copy 语义。如果一个数据结构实现了 Copy trait,那么它就会使用 Copy 语义。这样,在你赋值或者传参时,值会自动按位拷贝(浅拷贝)。
如果你不希望值的所有权被转移,又无法使用 Copy 语义,那你可以“借用”数据。
其实,在 Rust 中,“借用”和“引用”是一个概念,只不过在其他语言中引用的意义和 Rust 不同,所以 Rust 提出了新概念“借用”,便于区分。
本质上来将就是不希望所有权被转移、又无法使用 Copy 语义。
打个比方:我拥有一本书,会有以下情况
我把这本书送给了朋友、那我就丧失了这本书的拥有权(所有权)。
我把这本书借给了朋友、朋友不拥有这本书、但有借用,可以修改内容(可变的引用),当然我也可以提醒他不要在我的书上做标记(不可变的引用)。
其中实现Copy trait的语义的意思就是、朋友将这本书拿去复印了、他拥有了复印这本书的所有权、所以我们俩都拥有了不同的所有权。
当然也会有不同的情况发生,比如说这本书是我们俩一起出钱买的,我们俩都应该拥有这本书的所有权。进而Rust引入Rc、Arc、Cell、RefCell等智能指针来实现一个变量会产生多所有权的情况。
第一阶段的基础还是蛮重要、一些使用Rust语言描述的语言细节还是需要多多关注,为我们后面的内容打好基础。
进一步感谢开源操作系统社区的老师和技术人员,感谢你们辛苦的付出🌾🌾🌾。
期待第二阶段的学习、展开新的篇章。
@FinishTime: 2024-04-23 00:35:51
我是一名软件工程专业的大二本科生,曾参与过2023年秋冬季训练营。
在去年的训练营中,我学习了rust,但因为第一次学习,且当时需要准备期中考试和各科作业,没有按时通过第一阶段。
虽然我后续进入了二阶段群,但没有完成第二阶段的实验部分,今年重新参与,打算认真跟进下去。
我按照“rust语言圣经”上的讲解顺序进行学习,分别学习了:
在一阶段过程中,我遇到了一定的问题,并尝试进行解决。以下内容是我认为,不是那么“较为基础”的问题。
在primitive_types2.rs中,注释要求尝试传入unicode字符,来理解rust中的char。
但是,我通过https://emojidb.org/rust-emojis这个emojis网址,拿到了一个emoji:☢️,进行测试,却直接编译出错:
1 | // primitive_types2.rs |
我与队伍里的成员们进行了初步的实验和讨论,确定报错的原因是:“☢️”无法被解析成字符,必须被解析成字符串。那么,问题来了,为什么它无法被解析成字符,而其它的unicode码可以被解析成字符?
首先,我们进行了实验,从emojis网站中寻找了其他的emoji字符,发现大部分的emoji竟然都能被解析成字符。那问题就是出在“☢️”这个emoji上。
我们队内通过查找rust官方的文档,发现,文档中说,unicode码被分为了两大类,一类是UTF-16,一类是UTF-8。
那么,这两大类的unicode字符有什么区别?
我们通过搜集资料、询问ai等方式,得到:
UTF-16码点在0xD800到0xDFFF的范围内
UTF-8码点在0x80到0x10FFFF的范围内
如图
如图
我们的总结是:UTF-8是8位的,其由一个字节进行表示,而UTF-16是16位的,其由两个字节进行表示,很可能是因为“☢️”是UTF-16编码,导致其无法被解析成rust里的“字符char”。
在编程的过程中,我意外发现,在if语句内,通过不写分号的方式进行返回,会产生问题,程序无法编译,见下图:
1 | // structs3.rs |
在遇到这个问题之后,我自己分析不出来为什么会出现这样的错误。于是,我去找队伍里的学长进行咨询。学长在他的教程书里找到了答案。出现这个问题的原因是,rust的但if语句的返回值必然是返回单元(),因此,我不使用return返回true时,破坏了if语句的语法规则,导致出错。具体教程如下:
在第一阶段中,我在我们专业的操作系统实验课程中,使用rust编程,完成了实验内容,具体完成了:
在第一阶段的学习中,我巩固了我们所掌握的rust基础,并开始学习操作系统相关的知识。在这一部分,我还培养了我的思考能力,深入思考了rust的安全与不安全的地方。同时,通过和群友的交流,我也展开了对更多其它知识点的思考,比如“Copy”、“Clone”特征和“Drop”的冲突之处。
最重要的是,我深深意识到:Talk is cheap, show me the code!
目前,我已经开始了第二阶段部分内容的学习,希望可以顺利的完成第二阶段的所有内容。之后参与到第三阶段的项目中,继续提高自己的能力。
@FinishTime: 2024-06-01 01:39
我尝试实现了用os跟踪所有的task,记录他们的虚拟空间和真实空间,当task有需要时,由os分配真实的物理空间,并完成映射。但是,这样导致os的复杂度增加,并且我完成的代码无法通过测试。
我尝试进行了调试,但发现难以判断到底出现了什么问题,所以我只好换用另一种破坏可见性但简单易实现的方法,不需要os再去跟踪所有的task的空间,而是让task直接能够看到自己的真实空间,并且允许task自己执行mmap和munmap。我仅仅把之前为os实现的核心逻辑代码复制粘贴过来,并配置了一些接口,就通过了ci测试。
而在ch6时,我经受了非常大的折磨!平心而论,要求实现的逻辑并不是很复杂,只是需要层层的传递,最后由“easy-fs”进行实际干活即可。但我遇到了两个极为恶心的问题。
第一是出现了“File is shorter than the first ELf header part”,翻译过来就是,文件比文件描述符还短。我通过调试,发现是在测试“ch5_spawn0”的时候,报了这个错误。我分析,这个问题可能的最直接原因只有三个,要么是文件本身就不存在,要么是该文件没被读取进来,要么是该文件被损坏了。我通过查看测试用例,发现该文件是存在的,那就只可能是该文件没被读进来或者该文件已被损坏。分析概率,我严重怀疑是该文件已被损坏了。毕竟,读入文件的代码只比ch5多了2行,我的ch5已经通过了,况且读入文件的底层实现不是我做的,那很可能不是读入文件的时候出现的问题。最有可能就是我写的“在可以释放内存时进行内存释放”的操作有问题,导致损坏了其他的文件,比如这个文件。
但是,我翻来覆去的看我的代码,应该是没有问题的,如果它存在问题,那其它涉及释放内存的地方怎么就没事呢?如果是偶然情况,那也不可能我每次进行测试,它都是这里崩溃。我开始怀疑,是不是我其它地方的代码写的有问题?所以我去重新检查我所有的代码,并且感觉没有什么问题。这该怎么办?迫于无奈,我将代码回滚到了初始版本,打算重新写。但就在我重写完成后,执行测试,还是那个问题!
又经历了一天的苦思冥想,我决定向万能的群友们请教。在群友们的热心帮助下,我隐约找到了解决方案:
如图
我按照文档,将easy-fs的cache部分改正,果然就没有这个问题了。
(吐槽:easy-fs的cache关我什么事啊?也没说要改他啊…这个问题卡了我大约3天)
但是刚刚那个只是第一个问题!还有一个很坑的问题,我在解决上一个之后,又遇到了“IoError”!报错提示为“VitBlock:IoError”,真是奇怪,怎么还出现IoError了?我再次尝试了大量的解决方法,各种寻找可能的问题,但还是不知道原因。但是,我突然发现,在LOG=TRACE或LOG=DEBUG下,我执行测试,居然没有报错!这是为什么?我意识到了一个可能的关键:时间!在使用LOG时,程序的运行时间会变慢!这可能就给os留足了时间,使其能够完成从存储中的加载,就不会引发错误。而不使用LOG时,可能就因为时间过短,导致os来不及完成加载,从而引发IoError。但这是为什么呢?经过分析,我定位到了出错的核心位置,那里我使用的是if let 语句。后续,我将其改成了let之后再进行match,就解决了问题。
这激起了我的兴趣:通过上述现象,难道说if let是一个异步非阻塞的语句吗?我请教了我的学校里的老师,但老师也不清楚。我尝试在网上找一些能够对其“是否异步”测试的方法,但都没有找到。最后只能暂时放一放,先干3阶段吧。
rCore真是要命,可算过来了。
三阶段,我选择的是项目8,在之前的旁听过程中,老师留了一些作业,因为之前弄rCore,没有管这部分,我会在后续完成这部分。
明天还有作业要做。。。两科呢
提供的 Rust 代码实现了一个通用的二叉堆,支持最小堆和最大堆的功能。下面是这个实现的核心组成部分的详细解释:
结构定义 (Heap<T>):
T: Default:这个约束确保堆中的元素可以被初始化为默认值。这对于确保堆的向量(items)可以在索引0处有一个占位符,简化索引计算非常有用。count:跟踪堆中的元素数量。items:一个向量,存储堆元素。堆使用基于1的索引以简化父子计算,索引0保持为虚拟。comparator:一个函数指针,用于确定元素的排序,允许灵活定义最小堆或最大堆。构造函数 (new(comparator)):
堆操作:
add):在堆的末尾插入一个新元素,然后将其向上移动以维护堆属性。这是通过比较添加的元素与其父元素并根据比较函数进行必要的交换来完成的。next in Iterator implementation):移除根元素(根据堆的类型是最小或最大),将其替换为堆中的最后一个元素,然后将此元素向下移动以维护堆属性。在此过程中,堆的大小减少一个。辅助函数:
parent_idx, children_present, left_child_idx, right_child_idx, smallest_child_idx:这些辅助函数用于计算父和子的索引或检查子节点的存在,这对于在添加和删除操作期间导航堆至关重要。类型特定的堆构造器 (MinHeap 和 MaxHeap):
a < b,对于最大堆是 a > b)轻松实例化 Heap<T> 的方法。测试 (tests module):
Default 的任何类型 T,并可以使用在实例化时传递的任何比较函数,使其可以适应不同的排序需求。next 方法的 Iterator 特质,允许堆在标凈 Rust 习惯用法中使用,如循环和其他基于迭代器的操作。因为之前学C++结识的好友群中发了这个训练营的报名链接,想着正好OS还没有学就想着报名参加了:)。参加本次训练营之前只有csapp的计算机体系结构这一相关经验,对于Rust和RISC-V一无所知。编程能力上,主要是C++的服务器开发,做过几个分布式微服务项目,国外的一些公开课lab(CS144/CS106B/CS50等)。
总体来说第一阶段学习过程还是有点痛苦的,毕竟Rust学习曲线比较陡峭,不过也有许多特性能与C++等语言联系起来,帮助理解。在报名之后就开始翻阅先前训练营的资料开始自学Rust了,总体还是顺利的
第一阶段主要集中于Rust基础语法的学习,并完成rustlings练习题,其次是进行RISC-V指令集的学习
Rust学习过程主要参考阅读了《Rust圣经》、《Rust by Example》、和Rust之旅
之后的时间主要是对《Rust圣经》中的进阶部分和基础部分进行阅读笔记并复习,基础部分难点主要集中在所有权和特征这两个知识点上,对于生命周期以及包和模块的讨论并不是特别深入。进阶部分都难哈哈哈哈,迭代器和智能指针还算比较好理解些,对于迭代器加闭包实现的函数式编程每次都让我觉得很强大,Cell和RefCell提供的内部可变性加上智能指针也给程序提供了很大的灵活性的前提下保证了一定的安全性。生命周期的使用也是一大难点了。之后还有unsafe Rust的使用以及宏编程(对比C/C++的难的多但功能性也更强大)
rustlings的基础语法部分不算特别难,不熟练的查一查就能写得出来。难点集中在test和algorithm章节,主要是test后半部分,一些环境变量的设置,以及rust实现数据结构算法所用到的unsafe rust和智能指针的操作。
之后尝试阅读Learning Rust With Entirely Too Many Linked Lists,和用Rust实现一些算法和数据结构来巩固,多敲一些代码提高熟练度,多查文档。
这部分知识学习首先是阅读了《计算机组成于设计:RISC-V版》的前两个章节,对RISC-V的基本指令和指令格式有一个了解,以及RISC-V的设计和计算机体系结构的8个伟大思想之间的联系。之前阅读csapp并完成lab实验,有些x86_64的经验,对于一些基本指令和作用的理解还算轻松。难点集中在原子指令上。之后主要阅读了《RISC-V手册》,主要重点是第十章的特权架构,涉及之后rCore的特权级切换操作以及虚拟内存的实现(SV39多级页表)
学习后可以看出RISC-V指令集的强大和简单,和x86_64不同的理念。六种基本指令格式,等长指令就能搞定一切,精妙的设计(就是有时候脑子转不过来)
阅读操作系统导论,目前还在虚拟化部分,然后观看导学阶段的OS课录播,补足操作系统的理论知识和概念
经过第一阶段的Rust练习和RISC-V语法的入门阅读,进入第二阶段,完成rCore的五个实验。第二阶段主要是进行操作系统的学习,并通过实验具象操作系统概念的实现。同时也在跟着Writing an OS in Rust写blog_os作为补充。
整体rCore实验难度集中在最后三个实验中,第一二章节引入简单操作系统的架构,从裸机平台构建libOS,将整个OS作为一个库给用户使用,到加入特权级切换,让操作系统找到任务并运行实现批处理OS,第三章加入yield系统调用支持多道程序,并加入时钟中断实现抢占式多任务构成多道程序与分时多任务系统。第四章开启地址空间,进一步完善了OS的安全性,应用不再有直接访问物理内存的能力,并且只能通过虚拟地址读写自己的地址空间,无法访问破坏其它应用的地址空间,同时也给操作系统提供内存管理的灵活性,通过分页机制更好地管理物理页帧,页表管理虚拟地址到物理页帧的映射转换。第五章进一步强化任务的概念为进程,加入一个简单的shell,使得用户与操作系统之间的交互能力提高,可以动态地执行应用。第六章实现了一个easyfs文件系统,对操作系统提供持久化存储。第七章通过文件系统的支持建立进程间通信,让不同应用组合在一起运行。第八章引入线程和并发的概念,让操作系统从宏观层面实现多个应用的并发执行,实现内核态管理用户态运行的线程,并支持互斥锁、信号量和条件变量这三种资源。
由于提早完成了第一阶段任务,得以提前开始第二阶段,前面通过阅读文档并复现代码熟悉整体架构,第二阶段正式开始后完成实验。
额外题目还没实现,后续如果有时间再研究吧:)
我先说一下感觉吧,我感觉rust好难啊,真的,^^
之前是群里面发现了rcore这个文档,跟着看到第二章,发现不懂rust,然后就找教程学rust
后来发现了这个训练营,跟着这个训练营的课程进度,做rustlings练习题
一开始确实不难,到了迭代器,traits,结构体这里,差不多就看不懂了,属于是问了gpt也还是不懂的那种
后面的算法我倒是会,就是不会rust语法,然后问群友,虽然说不是每一题都问吧,也基本上是隔一两个就问
不管怎么说,通过群友们的解答和自己的努力,rustlings是做完了
总结:rust好难,^^
本来想在这里放一个图片的,但是看群里好像图片不太好处理,那就不放了
不过在这里贴一个图片仓库链接:ServiceLogos,作者画得太好看,导致我当天看到就去印贴纸了……
我从稍微理解了我的专业之后就一直都很崇拜 Linus,这位堪称传奇的软件工程师在 1991 年 8 月 25 日——他 21 岁时就在网络上发布了 Linux 内核的源代码。现在是 2024 年 4 月 23 日,我也是 21 岁,追逐吗,梦想吗,我也想像他一样写出这样神奇的代码,33 年后的今天,我也要开始了,Linus。
在写这篇博客时,本人东北大学大三在读,前两年东学西学,Rust 就是之一,说实话,在本学期开始时,我实在找不到什么东西来做,准备先买一本《趣读 Linux 源码》来看,感谢我学弟翊嘉,是他给我推荐了这个操作系统训练营,这样直接上手操作系统的效果比看书要好太多了,真的十分感谢!
我对 Rust 的理解还是比较少,future、unsafe 的内容更是知之甚少,所以大佬求带!看到我这篇总结报告之中有什么不足也请指出!
这一阶段的练习基本上带领我加强了一遍 Rust 的语法,因为我接触 Rust 已经有小半年了,所以对我来说并不算很困难(如果要我 0 基础开始的话可能就会有点受不了了)。话虽如此, rustlings 确实给了我很多细节上的考验,我通过了它们,查缺补漏,学到了很多新东西(这里就当错题集一题一题记录了 XD):
2024_4_24, PS:因为这一个阶段基本上都是一些对 Rust 语法概念上的训练,所以我会着重讲一下对 Rust 一些重要语法概念的理解,本来写了挺多的,但是后面全部都删掉了,因为突然发现我好像也就是鹦鹉学舌,纯纯就是一些对官方文档还有论坛讨论的翻译,所以我会在讲到一个我觉得值得注意的点的时候把链接贴出来,然后提一下这个链接讲了什么,同学们自己去看会好的多
2024_4_25, PS:感觉讲的有点乱了,实际上 Rust 之中的移动、生命周期、所有权的概念真的是耦合在一起的,在讲到一个部分的时候免不了要带一点其它两个部分。
说实话,移动和所有权这个语意在其他语言里面确实存在,但是一般作为规范存在,没有在 Rust 里面管的这么严,在 options3.rs 之中我就犯了这么一个错误:
我在 main 函数之中这样写道:
1 | let y: Option<Point> = Some(Point { x: 100, y: 200 }); |
这样写会报错(value used here after partial move),因为在最后返回 y 之前 Some(p)就已经把外面 y 的所有权移动到 p 上了,此时 y 指向的是一个无效的内存空间。所以现在的解决方案就是把外面 y 借用到 Some(p)之中,一个关键字 ref 可以帮助我们做到这一点。这个关键字其实用法还是挺多的,一般用于模式匹配:
下面是一段在《Rust 编程指南》之中的阐述(在第 10 章 Pattern 小节 Refrence Patterns 部分之中,如果你看的是英文版的 PDF 的话,在 P372 可以找到对这个问题的详细描述):
Rust 的模式匹配支持处理引用的两个特性:Ref 模式借用匹配值的一部分,&模式匹配引用
我个人感觉可以这样理解:Rust 之中的模式匹配就像是在匹配一个正则表达式,&被理解位一个字面值字符,如果写&的话,就会把&引用的那个值“解离”出来,因为匹配了前面的一个&,自然就是匹配后面的这个“值”了。
呃,我又要引用《Rust 编程指南》之中的原话了(毕竟真的这本书很专业)
In an expression, & creates a reference.I a pattern, & matches a refrence
在一个表达式之中,&创造一个引用,在模式之中,&匹配一个引用
当时我也是被震撼,确实有一种对称的美对吧?其实在 rustlings 对应的有关模式匹配训练之中你就可以发现这一点:你可以使用()来创建一个元组,可以使用{}来创建一个结构体,当然就可以使用对应包含()和{}的模式匹配来把其中对应的字段解析出来,_这是一个互逆的过程_,我觉得这一点需要好好注意一下,基本上就是模式匹配直观意义上的精髓所在了。
但是 ref更像是一个正则表达式之中的元字符,用于描述匹配规则:我只想要借用匹配的值,而不是移动它们,如果想要借出可变引用的话就要使用ref mut。所以这里这样改改,加一个ref就成功了:
1 | match y { |
当然,如果匹配的数据实现了Copetrait 的话,你怎么样搞都行,但是在处理有所有权的对象的时候就必须小心处理了,这 Rust 真给我好好上了一课。
在下一部分我想要讲讲我对生命周期和所有权的理解,所以在这里我想先就着这个例子好好借题发挥,先铺垫一下。
实际上options3.rs还能出得更加复杂一点,这种情况就要把ref和&同时用上才能解决。在options3.rs之中,该结构体之中的两个成员都是实现了Copytrait 的,那么如果有具有所有权的成员的话,应该如何处理呢?
就像是下面这种情况(实际上你可以在《Rust 程序设计》的 P374 找到它):
1 | struct Engine { |
如果你像上面这样写的话就好像是把一辆借来的车里头的引擎偷出来了。Rust 会降下它的神罚,引导我们做出正确的选择:
1 | error[E0507]: cannot move out of `borrowed_car.engine` as enum variant `Some` which is behind a shared reference |
这里忍不住想要夸一下,Rust 的错误输出真的是太好了!
所以既然车是借的,引擎也应该是借的对不对?这里使用 ref 来把引擎借出来就没事了。
实际上假如你修正了上面这个ref的问题仍然会报错,😭 了:
1 | error[E0716]: temporary value dropped while borrowed |
这里涉及到一个比较微观的问题:Rust 在背后为我们做了的事。实际上很多时候我们创建的变量的生命周期就只是一个局限于语句(statement)的 temporary lifetime,Rust 为我们做了 lifetime extention 之后才能在 block 之中使用。
在论述这个问题之前,先列一下参考文献吧:
上面这些概念之间的关系就是: The temporary scope of an expression is the scope that is used for the temporary variable that holds the result of that expression when used in a place context。(摘自文献 3)
注意这里的三个关键词:temporary scope、temporary variable 和 place context
处于位置上下文(place context)的表达式就是位置表达式,它代表了一个内存位置(Paths)。这些表达式是局部变量、静态变量、解引用(*expr)表达式、数组索引表达式(expr[expr])、字段引用(expr.f)和括号内的位置表达式(parenthesized place expressions)
突然感觉这里举一个例子会比较好:
1 | let t = &foo(); |
实际上是:
1 | let tmp; |
也就是说,&foo()是一个表达式,现在它处于一个位置上下文之中(&需要一个位置),所以现在需要一个 temporary variable(简称 temporary)来存储这个foo()表达式的结果,也就是上面的tmp,这个tmp的生命周期就是 temporary scope。
而且在这里还做了一个 lifetime-extension:如果没有 let 的话,实际上 temporary 在语句的末尾(遇到;)就会被销毁:
1 | &foo(); |
实际上是
1 | { |
这个立即失效的局部变量显然不符合用户的意图。因此,我们延长 temporary 的生命周期,使其在最内层块的末尾被删除(the end of the innermost block)。
实际上就是把原来局限于语句的生命周期提升了一个一个层次,提升到块了。
怎么样,是不是原来好像很理所当然的一段代码都变得不那么简单了?更多有关这部分概念的解释请参考文献 1,我在这里就不赘述了。
回到在本小节一开始提出的问题,为啥会报错?其实看报错信息就知道了,按照我们上面的思路来思考:
首先,里面这个表达式处于一个位置上下文(place context)之中,因为&期待一个位置,所以得有一个 temporary 来保存这个位置啊:
1 | { |
但是很不幸的是,这个 temporary 首先作用于 Some,而不是 let,所以不会进行 lifetime-extension,在遇到;之后,temporary 就被 drop 掉了。
出现了,悬垂引用!Rust 就 panic 了。(本节完)
“光的波粒二象性”这个比喻是油管的一个博主 ledoo 提出来的。
我觉得讲的真的很不错,所以在这里想提一提。
大概就是说,我们讲到生命周期(lifetime)就是总是从代码层面去分析对不对?去看是不是你变量的第一次声明和最后一次使用跨越了一个 block,但是实际上 Rust 做这个生命周期是为了检查内存的有效性的,你还可以从变量指向内存的位置和保证内存的有效性这两个角度来理解生命周期。(Thinking about lifetime as regions of memory, what can they——the reference point to?)
这就好像是光的波粒二象性一样,有的时候从这个角度去看比较好理解,有的时候从另外一个角度去看则更佳。 我个人觉得用这种角度去理解生命周期标注是最好的。
就拿 lifetimes1.rs 为例吧:
实际上他视频里面就有讲这个很经典的生命周期标注问题
1 | fn longest(x: &str, y: &str) -> &str { |
这道题的正确答案应该是这样:
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
加上生命周期标注,生命标注在我初学 Rust 的时候把我虐的死去活来的,你比如说这里的'a,为什么大家都是标'a?是不是意味着所有传入的 reference 的生命周期都要是相同的?什么?返回值的生命周期也是'a?三个都要相同?啊?这限制也太严格了把?
当然,如果你读过像是 Rust 圣经的文档话,文档里面就会告诉你:实际上呢,这里的意思是以生命周期最小的那个 Reference 为准,不过呢……总感觉怪怪的。
如果你使用指向有效内存的角度来进行分析的话,我感觉就会比较好一点:
x 指向一块内存 memory_x,y 也指向一块内存 memory_y,这两块内存当然可以是不一样的,返回值也指向一块内存,那么在这里'a的作用就是说,我要统一这两块内存,返回值指向memory_x“和”memory_y。这里“和”打双引号,因为现实不可能同时指向两部分嘛。
所以 Rust 怎么检查这个返回值是否有效?答案是在这个返回值最后一次使用之前这两块内存memory_x和memory_y都要有效。
如果使用生命周期约束的话,差不多也是这个意思,比如说'a: 'b比较官方的说法就是'a的生命周期的范围必须包含或等于'b的生命周期的范围。
假如我们使用“内存说”的话,就刚刚好反过来,'b指向的内存包含了'a指向的内存,所以'b有效的时候'a也一定要有效,所以你可以这样读这个限界:'a in 'b。
嗯,大概就是这样。(本节完)
首先,感谢你看到这里!
本来还想写一些 unsafe 还有 cargo 的内容的,但是没有时间了!!!!!唉,期中了,我还有 ddl 要赶呢,在这篇博客之中写的生命周期、所有权、移动的概念(话说好像都在讲生命周期)我认为就是 Rust 的核心了,所以对于第一部分的 Rust 编程学习,内容分量应该也刚刚好吧?(小小的虚荣一下,把我在第一部分之中讲的那个车和引擎的问题出成题感觉也 ٩(•̤̀ᵕ•̤́๑)ᵒᵏᵎᵎᵎᵎ?)
所以我准备移动一下,在第二阶段的报告里面把 unsafe、cargo 连着操作系统讲一下,如果你觉得从我的这篇报告学到了什么东西的话,那就太好了!
2024 年 04 月 25 日 21:14:31