
Rust 语言真的好:连续七年成为全世界最受欢迎的语言、没有 GC 也无需手动内存管理、性能比肩 C++/C 还能直接调用它们的代码、安全性极高 - 总有公司说使用 Rust 后以前的大部分 bug 都将自动消失、全世界最好的包管理工具 Cargo 等等。但...
有人说: "Rust 太难了,学了也没用"
对于后面一句话我们持保留意见,如果以找工作为标准,那国内环境确实还不好,但如果你想成为更优秀的程序员或者是玩转开源,那 Rust 还真是不错的选择,具体原因见下一章。
至于 Rust 难学,那正是本书要解决的问题,如果看完后,你觉得没有学会 Rust,可以找我们退款,哦抱歉,这是开源书,那就退 🌟 吧 :)
如果看到这里,大家觉得这本书的介绍并没有吸引到你,不要立即放弃,强烈建议读一下进入 Rust 编程世界,那里会有不一样的精彩。
配套练习题
对于学习编程而言,读一篇文章不如做几道练习题,此话虽然夸张,但是也不无道理。既然如此,即读书又做练习题,效果会不会更好?再加上练习题是书本的配套呢? :P
- Rust 语言实战, Rust 语言圣经配套习题,支持中英双语,可以在右上角切换
创作感悟
截至目前,Rust 语言圣经已写了 170 余章,110 余万字,历经 1000 多个小时,每一个章节都是手动写就,没有任何机翻和质量上的妥协( 相信深入阅读过的读者都能体会到这一点 )。
曾经有读者问过 "这么好的书为何要开源,而不是出版?",原因很简单:只有完全开源才能完美地呈现出我想要的教学效果。
总之,Rust 要在国内真正发展起来,必须得有一些追逐梦想的人在做着不计付出的事情,而我希望自己能贡献一份微薄之力。
但是要说完全无欲无求,那也是不可能的,看到项目多了一颗 🌟,那感觉...棒极了,因为它代表了读者的认可和称赞。
你们用指尖绘制的星空,那里繁星点点,每一颗都在鼓励着怀揣着开源梦想的程序员披荆斩棘、不断前行,不夸张的说,没有你们,开源世界就没有星光,自然也就不会有今天的开源盛世。
因此,我恳请大家,如果觉得书还可以,就在你的指尖星空绘制一颗新的 🌟,指引我们继续砥砺前行。这个人世间,因善意而美好。
最后,能通过开源在茫茫人海中与大家相识,这感觉真好 :D
🏆 贡献者
非常感谢本教程的所有贡献者,正是有了你们,才有了现在的高质量 Rust 教程!
🏆
Sunface 🥇 |
AllanDowney 🥈 |
EluvK 🥉 |
🏅
JesseAtSZ |
Rustln |
1132719438 |
zongzi531 |

Rust 发展历程
Rust 最早是 Mozilla 雇员 Graydon Hoare 的个人项目。从 2009 年开始,得到了 Mozilla 研究院的资助,2010 年项目对外公布,2010 ~ 2011 年间实现自举。自此以后,Rust 在部分重构 -> 崩溃的边缘反复横跳(历程极其艰辛),终于,在 2015 年 5 月 15 日发布 1.0 版。
在紧锣密鼓的开发过程中,Rust 建立了一个强大且活跃的社区,形成一整套完善稳定的项目贡献机制(Rust 能够飞速发展,与这一点密不可分)。Rust 现在由 Rust 项目开发者社区 维护, Rust 基金会赞助支持。
大家可能疑惑 Rust 为啥用了这么久才到 1.0 版本?与之相比,Go 语言 2009 年发布,却在 2012 年仅用 3 年就发布了 1.0 版本。
- 首先,Rust 语言特性较为复杂,所以需要全盘考虑的问题非常多;
- 其次,Rust 当时的参与者太多,七嘴八舌的声音很多,众口难调,而 Rust 开发团队又非常重视社区的意见;
- 最后,一旦 1.0 快速发布,那绝大部分语言特性就无法再被修改,对于有完美强迫症的 Rust 开发者团队来说,某种程度上的不完美是不可接受的。
因此,Rust 语言用了足足 6 年时间,才发布了尽善尽美的 1.0 版本。
大家知道 Rust 的作者到底因为何事才痛下决心开发一门新的语言吗?
说来挺有趣,在 2006年的某天,作者工作到精疲力尽后,本想回公寓享受下生活,结果发现电梯的程序出 Bug 崩溃了,要知道在国外,修理工可不像在中国那样随时待岗,还要知道,他家在 20 多楼!
最后,他选择了妥协,去酒店待几天等待电梯的修理。
当然,一般人可能就这样算了,毕竟忍几天就过去了嘛。但是这名伟大的程序员显然也不是一般人,他面对害他流离失所的电梯拿起了屠龙宝刀 - Rust。
自此,劈开一个全新的编程世界。
为何又来一门新语言?
简而言之,因为还缺一门无 GC 且无需手动内存管理、性能高、工程性强、语言级安全性以及能同时得到工程派和学院派认可的语言,而 Rust 就是这样的语言。你也可以回忆下熟悉的语言,看是不是有另外一门可以同时满足这些需求:)
至于 Rust 最为人诟病的点,那也就一个:学习曲线陡峭。不过当语言生态起来后,这都不算问题。
缓解内卷
有人说 Rust 作为新语言会增加内卷,其实恰恰相反,Rust 可以缓解内卷。为何不说 C++ 内卷,而说 Java、Python、JS 内卷?不就是后几个相对简单、上手容易嘛?而 Rust 怎么看也是 C++ 级别的上手难度。
其实从我内心不可告人的角度出发,并不希望 Rust 大众化,因为这样可以保饭碗、保薪资,还能拥有行业内的地位。但是从对 Rust 的喜爱角度出发,我还是希望能卷一些。不过,目前来看真的卷不动,现在全世界范围内 Rust 的需求都大于供给,特别是优秀的 Rust 程序员更是难寻。
与 Go 语言相比,成为一名优秀的 Rust 程序员所需的门槛高得多,例如融汇贯通 Rust 语言各种中高级特性、闭着眼睛趟过各种坑、不用回忆无需查找就能立刻写出最合适的包/模块/方法、性能/安全/工程性的权衡选择信手拈来、深层性能优化易如反掌、异步编程小菜一碟,更别说 Rust 之外的操作系统、网络、算法等等相关知识。
所以,Rust 可以缓解内卷,而不是增加内卷。可以说是程序员的福音,不再是被随意替换的螺丝钉。
效率
下面从三个角度来谈谈 Rust 的效率:学习、运行、开发。
学习效率
众所周知,Rust 的学习曲线相当陡峭。起初,对此说法我还嗤之以鼻,随着不断的深入,现在也很认可这个说法。Rust 之难,不在于语言特性,这些都可以很容易学到,而在于:
- 实践中如何融会贯通的运用
- 遇到了坑时(生命周期、借用错误,自引用等)如何迅速、正确的解决
- 大量的标准库方法记忆及熟练使用,这些是保证开发效率的关键
- 心智负担较重,特别是初中级阶段
好在这本书就是干这个的,包君满意,不满意退... 要不还是骂我吧 :D
运行效率
得益于各种零开销抽象、深入到底层的优化潜力、优质的标准库和第三方库实现,Rust 具备非常优秀的性能,和 C、C++ 是 一个级别。
同时 Rust 有一个极大的优点:只要按照正确的方式使用 Rust,无需性能优化,就能有非常优秀的表现,不可谓不惊艳。
现在有不少用 Rust 重写的工具、平台都超过了原来用 C、C++ 实现的版本,将老前辈拍死在沙滩上,俨然成为一种潮流~~
开发效率
Rust 的开发效率可以用先抑后扬来形容。在最初上手写项目时,你的开发速度将显著慢于 Go、Java 等语言,不过,一旦开始熟悉标准库、熟悉生命周期和所有权的常用解决方法,开发效率将大幅提升,甚至当形成肌肉记忆后,开发效率将不会慢于这些语言,而且原生就能写出高质量、安全、高效的代码,可以说中高级 Rust 程序员就是高效程序员的代名词。
个人的好处
学习 Rust 对个人也有极大的好处。
成为更好的程序员
要学好 Rust,你需要深入理解内存、堆栈、引用、变量作用域等这些其它高级语言往往不会深入接触的内容。另外,Rust 会通过语法、编译器和 clippy 这些静态检查工具半帮助半强迫的让你成为更优秀的程序员,写出更好的代码。
同时,当你掌握 Rust 后,就会自发性的想要去做一些更偏底层的开发,这些都可以帮助你更加了解操作系统、网络、性能优化等底层知识,也会间接或者直接的接触到各种算法和数据结构的实现。
慢慢的,你就在成为那个更好的程序员,也是更优秀的自己。
增加不可替代性
语言难学也有好处,一旦掌握,你将具备较强的不可替代性,不再是一个简单的工具人角色。看看现在内卷严重的 Java,工具人有多少!一个人离职,另外一个人很快就能替补上。
当然,我不是说 Rust 会给公司带来这种隐形的维护成本,毕竟这其实是一种双赢,公司收获了更优秀的程序员(不可否认的是 Rust 程序员普遍来说水平确实更高,毕竟都是有很好的编程基础、也很有追求的自驱型人才),而你也收获了更稳定的工作环境,甚至是更高的收入。
团队的好处
先不说安全、可靠性等对公司团队非常友好的特性,就说 Rust 程序只要能跑起来,那代码质量其实就是相当不错的,因为 Rust 编译器实在是一名严师厉友,甚至有些鸡毛。
正因为这较高的质量下限,我们在代码 review 时不用过于担心潜在的各种坑,得益于此,可以实现更加高效的开发、review、merge 流程。
由于 Rust 语言拥有异常强大的编译器和语言特性,Rust 的代码天然就会比其它语言拥有更少的 Bug。同时 Rust 拥有非常完善的工具链、最好的包管理工具,决定了 Rust 非常适合大型团队的协作开发。
也许 Rust 在开发速度上不是最快的,但是从开发 + 维护的角度来看,这个成本在各个语言中绝对是很小的。当然,如果你的公司就追求做出来能用就行,那 Rust 确实有些灰姑娘的感觉。
还有一点很重要,现在的 Rust 程序员往往拥有更出众的能力和学习自驱性,因此团队招到的人天然就保持了较高的底线。如果你有幸招到一个优秀的 Rust 程序员,那真是捡到宝了,他也会同时带动周围的人一起慢慢优秀(优秀的 Rust 程序员比较好辨别,门槛低的语言就并没有那么好辨别)。总之,一个这样的程序员会给团队带来远超他薪资的潜在回报和长远收益。
开源
目前 Rust 的主战场是在开源上,Go 的成功也证明了农村包围城市( 开源包围商业 )的可行性。
- UI 层开发,Rust 的 WASM 发展的如火如荼,隐隐有王者风范,在 JS 的基础设施领域,Rust 也是如鱼得水,例如
swc
、deno
等。同时nextjs
也是押宝 Rust,可以说 Rust 在前端的成功完全是无心插柳柳成荫。 - 基础设施层、数据库、搜索引擎、网络设施、云原生等都在出现 Rust 的身影,而且还不少。
- 系统开发,目前 Linux 已经将 Rust 语言纳入内核,是继 C 语言后第二门支持内核开发的语言,不过刚开始将主要支持驱动开发。
- 系统工具,现在最流行的就是用 Rust 重写之前 C、C++ 写的一票系统工具,还都获得了挺高的关注和很好的效果,例如 sd, exa, ripgrep, fd, bat 等。
- 操作系统,正在使用 Rust 开发的操作系统有好几个,其中最有名的可能就是谷歌的 Fuchsia,Rust 在其中扮演非常重要的角色。
- 区块链,如果 Rust 的份额说第二,应该没人敢稳说自己是第一吧?
类似的还有很多,我们就不一一列举。总之,现在有大量的项目正在被 Rust 重写,同时还有海量的项目在等待被重写,这些都是赚取github 星星和认可的好机会。在其它语言杀成一片红海时,Rust 还留了一大片蓝海等待大家的探索!
相比其他语言 Rust 的优势
由于篇幅有限,我们这里不会讲述详细的对比,就是简单介绍下 Rust 的优势,并不是说 Rust 优于这些语言,大家轻喷:)
Go
Rust 语言表达能力更强,性能更高。同时线程安全方面 Rust 也更强,不容易写出错误的代码。包管理 Rust 也更好,Go 虽然在 1.10 版本后提供了包管理,但是目前还比不上 Rust 。
C++
Rust 与 C++ 的性能旗鼓相当,但是在安全性方面 Rust 会更优,特别是使用第三方库时,Rust 的严格要求会让三方库的质量明显高很多。
语言本身的学习,Rust 的前中期学习曲线会更陡峭,但是在实际的项目开发过程中,C++ 会更难,代码也更难以维护。
Java
除了极少数纯粹的数字计算性能,Rust 的性能全面领先于 Java 。同时 Rust 占用内存小的多,因此实现同等规模的服务,Rust 所需的硬件成本会显著降低。
Python
性能自然是 Rust 完胜,同时 Rust 对运行环境要求较低,这两点差不多就足够抉择了。不过 Python 和 Rust 的彼此适用面其实也不太冲突。
使用现状
- AWS 从 2017 年开始就用 Rust 实现了无服务器计算平台: AWS Lambda 和 AWS Fargate,并且用 Rust 重写了 Bottlerocket OS 和 AWS Nitro 系统,这两个是弹性计算云 (EC2) 的重要服务
- Cloudflare 是 Rust 的重度用户,DNS、无服务计算、网络包监控等基础设施都与 Rust 密不可分
- Dropbox 的底层存储服务完全由 Rust 重写,达到了数万 PB 的规模
- Google 除了在安卓系统的部分模块中使用 Rust 外,还在它最新的操作系统 Fuchsia 中重度使用 Rust
- Facebook 使用 Rust 来增强自己的网页端、移动端和 API 服务的性能,同时还写了 Hack 编程语言的虚拟机
- Microsoft 使用 Rust 为 Azure 平台提供一些组件,其中包括 IoT 的核心服务
- GitHub 和 npmjs.com,使用 Rust 提供高达每天 13 亿次的 npm 包下载
- Rust 目前已经成为全世界区块链平台的首选开发语言
- TiDB,国内最有名的开源分布式数据库
尤其值得一提的是,AWS 实际上在押宝 Rust,内部对 Rust 的使用已经上升到头等公民 first-class 的地位。
Rust 语言版本更新
与其它语言相比,Rust 的更新迭代较为频繁(得益于精心设计过的发布流程以及 Rust 语言开发者团队的严格管理):
- 每 6 周发布一个迭代版本
- 2-3 年发布一个新的大版本,例如 Rust 2018 edition,Rust 2021 edition
好处在于,可以满足不同的用户群体的需求:
- 对于活跃的 Rust 用户,他们总是能很快获取到新的语言内容,毕竟,尝鲜是技术爱好者的共同特点:)
- 对于一般的用户,edition 大版本的发布会告诉他们:Rust 语言相比上次大版本发布,有了重大的改进,值得一看
- 对于 Rust 语言开发者,可以让他们的工作成果更快的被世人所知,不必锦衣夜行
总结
连续 6 年最受欢迎的语言当然不是浪得虚名。 无 GC、效率高、工程性强、强安全性以及能同时得到工程派和学院派认可,这些令 Rust 拥有了自己的特色和生存空间。社区的友善,生态的快速发展,大公司的重仓跟进,一切的一切都在说明 Rust 的璀璨未来。
当然,语言毕竟只是工具,我们不能神话它,但是可以给它一个机会,也许,你最终能收获自己的真爱 :)
相信大家听了这么多 Rust 的优点,已经迫不及待想要开始学习旅程,那么容我引用一句 CS(Counter-Strike:反恐精英) 的经典台词:Ok, Let's Rust.
本书是完全开源的,但是并不意味着质量上的妥协,这里的每一个章节都花费了大量的心血和时间才能完成,为此牺牲了陪伴家人、日常娱乐的时间,虽然我们并不后悔,但是如果能得到读者您的鼓励,我们将感激不尽。
既然是开源,那最大的鼓励不是 money,而是 star:) 如果大家觉得这本书作者真的用心了,就帮我们点一个 🌟 吧,这将是我们继续前行的最大动力
避免从入门到放弃
很多人都在学 Rust ing,也有很多人在放弃 ing。想要顺利学完 Rust,大家需要谨记本文列出的内容,否则这极有可能是又双叒叕从入门到放弃之旅。
Rust 是一门全新的语言,它会带给你前所未有的体验,提升你的通用编程水平,甚至于赋予你全新的编程思想。在此时此刻,大家可能还半信半疑,但是当学完它再回头看时,你肯定也会认同这些貌似浮夸的赞美。
避免试一试的心态
在学习 Go、Python 等编程语言时,你可能会一边工作、一边轻松愉快的学习它们,但是 Rust 不行。原因如文章开头所说,在学习 Rust 的同时你会收获很多语言之外的知识,因此 Rust 在入门阶段比很多编程语言要更难,但是一旦入门,你将收获一个全新的自己,成为一个更加优秀的程序员。
在学习过程中,一开始可能会轻松愉快,但是在开始接触 Rust 核心概念时(所有权、借用、生命周期、智能指针等),难度会陡然提升,此时就需要认真对待起来,否则会为后面埋下难以填补的坑: 结果最后你可能只有两个选择 - 重新学 or 放弃。
因此,在学习过程中,给大家三点建议:
- 要提前做好会遇到困难的准备,因为如上所说,学习 Rust 不仅仅是在学习一门编程语言
- 不要抱着试一试的心态去试一试,否则是浪费时间和消耗学习激情,作为连续七年荣获全世界最受喜欢桂冠的语言,Rust 不仅仅是值得试一试 :)
- 深入学习一本好书或教程
总之,Rust 入门难,但是在你一次次克服艰难险阻的同时,也一次次收获了与众不同的编程经验,最后历经九九八十一难,立地成大佬。 给自己一个机会,也给 Rust 一个机会 :)
深入学习一本好书
Rust 跟其它语言不同,你无法看了一遍语法,然后就能上手写代码,对,我说的就是对比 Go 语言,后者的简单易用是有目共睹的。
这些年,我遇到过太多在网上看了一遍菜鸟教程(或其它简易教程)就上手写 demo 甚至项目的同学,无一例外,都各种碰壁、趟坑,最后要么放弃,要么回炉重造,之前的时间和精力基本等同浪费。
因此,大家一定要舍得投入时间,沉下心去读一本好书,这本书会带你深入浅出地学习使用 Rust 所需的各种知识,还会带你提前趟坑,这些坑往往是需要大量的时间才能领悟的。
在以前我可能会推荐看 Rust Book + async book + nomicon 这几本英文书的组合,但是现在有了一本更适合中国用户的书籍,那就是...你们猜,内容好坏大家一读即知,光就文字而言,那绝对是行云流水般的阅读体验,可以极大提升学习效率,也不再因为反复读也读不懂一句话而烦闷不堪。
千万别从链表或图开始练手
CS(Computer Science:计算机科学)课程中咱们会学习大量的常用数据结构和算法,因此大家都养成了一种好习惯:学习一门新语言,先用它写个链表或图试试。
我的天,在 Rust 中千万别这么干,你是在扼杀自己之前的努力!因为不像其它语言,链表在 Rust 中简直是地狱一般的难度,我见过太多英雄好汉难过链表关,最终黯然退幕。我不希望正在阅读此文的你也成为其中一个 :(
这些自引用类型(一种数据结构,它内部的某个字段又引用了其自身),它们堪称恶魔:不仅仅在蹂躏着新手,还在折磨着老手。有意思的是,它们的难恰恰是 Rust 的优点导致的:无 GC 也无手动内存管理还要做到内存安全。
这些优点并不是凭空产生,而是来源于 Rust 那一套强大、优美的机制,这些机制一旦你学到,就会被它巧妙的构思和设计征服,进而被 Rust 深深吸引!但是一切选择都有利弊,这种机制的弊端就在于实现链表这类数据结构时,会变得非常非常复杂。
你需要糅合各种知识,才能解决这个问题,但是这显然不是一个新手应该独自去面对的。总之,不会链表对于 Rust 的学习和写项目,真的没有任何影响,直接使用大神已经写好的数据结构就可以。
如果想要练手,我们可以换个方向开始,例如书中的入门和进阶实战项目都是非常好的选择。当然如果你就是喜欢征服困难,那没问题,就从链表开始。但是无论选择哪个,本书都将给你莫大的帮助,包括如何实现一个链表!
仔细阅读编译错误
在一些编程语言中,你可能习惯了编译器给出的错误只看前面(或后面)几行,毕竟大部分是没啥大用的堆栈信息,在此过程中, how stupid the 编译器 is
的感想时不时会迸发出来。
但是 Rust 不是,它为我们提供了一个强大无比的编译器,而且会提示我们该如何修改代码以解决错误,简直就是一名优秀的老师!
因此在使用 Rust 过程中,如果你不知该如何解决错误,不妨仔细阅读下编译器或者 IDE 给出的错误提示,绝大多数时候,都可以通过这些提示顺利的解决问题。
同时也不要忽略编译器给出的警告信息(warnings),因为里面包含了 cargo clippy
给出的 lint
提示,这些提示不仅仅包含代码风格,甚至包含了一些隐藏很深的错误!至于这些错误为何不是 error
形式出现,随着学习的深入,你将逐渐理解 Rust 的各种设计选择,包括这个问题。
不要强制自己使用其它编程语言的最佳实践来写 Rust
大多数其它编程语言适用的最佳实践在 Rust 中也可以很好的使用,但是 Rust 并不是一门专门的面向对象或者函数式语言,因此在使用自己喜欢的编程风格时,也要考虑遵循 Rust 应有的实践。
例如纯面向对象或纯函数式编程,在 Rust 中就并不是一个很好的选择。如果你有过 Go 语言的编程经验,相信能更加理解我这里想表达的含义。
不过大家也不用担心,在书中我们会以专题的形式专门讲解 Rust 的最佳实践,看完后自然就明白了。
总结
对于新手而言,最应该避免的就是从链表开始练手,最应该做的就是认真仔细地学习一本优秀而全面的书。
总之,认真学 Rust,既然选择了,就相信自己,你的前方会是星辰大海!
Rust语言中文网

跟我来吧,学完 Rust,你的前面就是星辰大海!
这个社区与其它 Rust 社区有点不一样: 我们聚焦于 Rust 语言的学习研究和实战应用上,不搞花活!
- QQ交流群: 1009730433
- 公众号:
Rust语言中文网

Rusty Book( 锈书 )

在 Rust 元宇宙,最优秀的项目可以称之为 rusty
,用咱中国话来说,就是够锈( 秀 )。
如果你有以下需求,可以来看看锈书,它绝对不会让你失望:
-
想要知道现在优秀的、关注度高的 Rust 项目有哪些
-
发现一些好玩、有趣、酷炫的开源库
-
需要寻找某个类型的库,例如,一个 HTTP 客户端或 ProtoBuffer 编码库,要求是好用、更新活跃、高质量
-
想要寻找常用操作的代码片段,用于熟悉 Rust 或者直接复制粘贴到自己的项目中,例如文件操作、数据库操作、HTTP 请求、排序算法、正则等
在线阅读锈书:Github地址
Datav: 可编程的数据可视化平台和可观测性平台
经常关注新技术的同学,这两年应该都听说过可观测性这个概念,它包含了对 Metrics 指标、Log 日志以及 Trace 链路的监控,特别是针对这三个指标的深度关联,让监控数据不再孤立。
目前整个业界百花齐放,Prometheus、Jaeger、Opentelemetry 各种优秀的世界级开源产品覆盖了可观测性的方方面面,但是在可观测性的可视化方面还缺少一个大杀器,我们期待它能拥有以下特性:
- 覆盖各种可观测性场景
- 拥有丰富可定制选项的图表组件和数据源
- 图表、页面之间的深度可定义交互
- 企业级的多租户、权限管理、导航菜单和全局状态管理
- 优秀的图表性能和交互体验
- 现代化的的 UI 设计,支持数据大屏,完美支持移动端
- 强大的可编程性及二次开发友好性
- 宽松的开源协议、丰富的文档和快速的社区响应支持
这些特性每一个都不简单, Datav 就是为了解决这些问题而生。
Datav 不是全世界最好的可观测性和数据可视化平台,这毫无疑问,至少目前不是。但是它对开发者最友好,并且支持丰富的可编程性的平台
当一个产品它的代码结构清晰简洁、架构干净、使用的技术符合时代主流、文档齐全,当一个产品在各种使用细节上都为开发者专门设计过,当一个产品允许各种深度可定制时,我们可以称其开发者友好
Datav
Datav 是一个专为开发者打造的、可编程的可观测性平台,同时它还是 Grafana 的数据可视化平替。可以帮助用户快速在线构建监控、日志和链路跟踪等可观测性场景,可以不夸张的说,你想要的几乎任何监控可视化场景,Datav 都能帮你实现 ( 如果不行,大家可以提 issue ,合理的需求秒支持 )。
- 开源地址:https://github.com/data-observe/datav
- 官方网站:https://datav.io
- 在线 Demo: https://play.datav.io
开发语言
Datav 目前由以下部分组成:
- UI: React + Typescript + Vitejs
- API 、数据处理和 UI 静态文件服务: Go
- 插件管理: Go
- 数据采集 Agent: Rust
写在最后的感悟
Rust course 开源迄今快 2 年了,我没有收过一分钱的赞赏,曾经有过这个想法,但最终还是被自己否定了。不是因为有多高尚,只是真心希望能为国内的开源做一些微不足道的、不那么金钱相关的贡献。
回头看,现在能获得这么多 Star ,已经远远超出了我当初的预期。说句真心话,这本书的质量配不上这么多 star,毕竟有那么多非常优秀的开源产品都没能获得配的上它们质量的认可度和曝光度。
但可能因为天时地利人和,Rust course 走到了今天这一步,无论如何,非常感谢每一个贡献者,也感谢每一个点了 star 支持本书的读者,你们是最棒的!
正因为对开源的坚持,尽管是拥有近百万行代码的复杂平台,Datav 依然选择了 Apache2.0 作为开源协议,我不希望大家在使用它时,还担心未来商业化潜在的风险。
总之,开源这条路我会坚定走下去,为国为民的大话不敢说,但至少我会尽自己的一份力量,为国内的开源做一些力所能及的贡献。
寻找牛刀,以便小试
其实对于写这种章节,我内心是拒绝的,因为真的很无趣。对于一本书而言,这也更像是一种浪费纸张的行为(好在咱无纸化:-D)。不过没有办法,如果不安装 Rust 环境,总不能让大家用空气运行吧,so,我恶趣味的起了一个这样的章节名。
在本章中,你将学习以下内容:
- 在 macOS、Linux、Windows 上安装 Rust 以及相关工具链
- 搭建 VSCode 所需的环境
- 简单介绍 Cargo
- 实现一个酷炫多国语言版本的“世界,你好”的程序,并且谈谈对 Rust 语言的初印象
安装 Rust
rustup
是 Rust 的安装程序,也是它的版本管理程序。
强烈建议使用 rustup
来安装 Rust,当然如果你有异心,请寻找其它安装方式,然后再从下一节开始阅读。
haha,开个玩笑。读者乃大大,怎么能弃之不顾。
注意:如果你不想用或者不能用 rustup,请参见 Rust 其它安装方法。
至于版本,现在 Rust 稳定版特性越来越全了,因此下载最新稳定版本即可。由于你用的 Rust 版本可能跟本书写作时不一样,一些编译错误和警告可能也会有所不同。
在 Linux 或 macOS 上安装 rustup
打开终端并输入下面命令:
$ curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
这个命令将下载一个脚本并开始安装 rustup
工具,此工具将安装 Rust 的最新稳定版本。可能会提示你输入管理员密码。
如果安装成功,将出现下面这行:
Rust is installed now. Great!
OK,这样就已经完成 Rust 安装啦。
安装 C 语言编译器:(非必需)
Rust 对运行环境的依赖和 Go 语言很像,几乎所有环境都可以无需安装任何依赖直接运行。但是,Rust 会依赖 libc
和链接器 linker
。所以如果遇到了提示链接器无法执行的错误,你需要再手动安装一个 C 语言编译器:
macOS 下:
$ xcode-select --install
Linux 下:
Linux 用户一般应按照相应发行版的文档来安装 GCC
或 Clang
。
例如,如果你使用 Ubuntu,则可安装 build-essential
。
在 Windows 上安装 rustup
Windows 上安装 Rust 需要有 C++
环境,以下为安装的两种方式:
1. x86_64-pc-windows-msvc
(官方推荐)
先安装 Microsoft C++ Build Tools,勾选安装 C++ 环境即可。安装时可自行修改缓存路径与安装路径,避免占用过多 C 盘空间。安装完成后,Rust 所需的 msvc 命令行程序需要手动添加到环境变量中,否则安装 Rust 时 rustup-init
会提示未安装 Microsoft C++ Build Tools,其位于:%Visual Studio 安装位置%\VC\Tools\MSVC\%version%\bin\Hostx64\x64
(请自行替换其中的 %Visual Studio 安装位置%、%version% 字段)下。
如果你不想这么做,可以选择安装 Microsoft C++ Build Tools 新增的“定制”终端 Developer Command Prompt for %Visual Studio version%
或 Developer PowerShell for %Visual Studio version%
,在其中运行 rustup-init.exe
。
准备好 C++ 环境后开始安装 Rust:
在 RUSTUP-INIT 下载系统相对应的 Rust 安装程序,一路默认即可。
PS C:\Users\Hehongyuan> rustup-init.exe
......
Current installation options:
default host triple: x86_64-pc-windows-msvc
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
2、x86_64-pc-windows-gnu
相比于 MSVC 版本来说,GNU 版本具有更轻量,更靠近 Linux 的优势。
首先,根据 MSYS2 官网 配置 MSYS。
若您觉得下载太慢,可以试试由 Caviar-X 提供的 代理。
在安装 mingw-toolchain
后,请将 %MSYS 安装路径%\mingw64\bin
添加到系统变量 PATH
中。
配置好后,在 MSYS 中输入下面的命令来安装 rustup。
$ curl https://sh.rustup.rs -sSf | sh
之后,根据以下输出进行配置。
Current installation options:
default host triple: x86_64-pc-windows-msvc
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
>2
I'm going to ask you the value of each of these installation options.
You may simply press the Enter key to leave unchanged.
Default host triple? [x86_64-pc-windows-msvc]
x86_64-pc-windows-gnu
Default toolchain? (stable/beta/nightly/none) [stable]
stable
Profile (which tools and data to install)? (minimal/default/complete) [default]
complete
Modify PATH variable? (Y/n)
Y
Current installation options:
default host triple: x86_64-pc-windows-gnu
default toolchain: stable
profile: complete
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
>
再之后,按下 1,等待。完成后,您就已经安装了 Rust 和 rustup
。
更新
要更新 Rust,在终端执行以下命令即可更新:
$ rustup update
卸载
要卸载 Rust 和 rustup
,在终端执行以下命令即可卸载:
$ rustup self uninstall
检查安装是否成功
检查是否正确安装了 Rust,可打开终端并输入下面这行,此时能看到最新发布的稳定版本的版本号、提交哈希值和提交日期:
$ rustc -V
rustc 1.56.1 (59eed8a2a 2021-11-01)
$ cargo -V
cargo 1.57.0 (b2e52d7ca 2021-10-21)
注:若发现版本号不同,以您的版本号为准
恭喜,你已成功安装 Rust!
如果没看到此信息:
- 如果你使用的是 Windows,请检查 Rust 或
%USERPROFILE%\.cargo\bin
是否在%PATH%
系统变量中。 - 如果你使用的是 Windows 下的 Linux 子系统,请关闭并重新打开终端,再次执行以上命令。
如果都正确,但 Rust 仍然无法正常工作,那么你可以在很多地方获得帮助。最简单的是加入 Rust 编程学院这个大家庭,QQ 群:1009730433.
本地文档
安装 Rust 的同时也会在本地安装一个文档服务,方便我们离线阅读:运行 rustup doc
让浏览器打开本地文档。
每当遇到标准库提供的类型或函数不知道怎么用时,都可以在 API 文档中查找到!具体参见 在标准库寻找你想要的内容。
墙推 VSCode!
VSCode
从 15 年刚开始推出,我就在使用了。做为第一个吃螃蟹的人,可以说见证了它一路的快速发展,直到现在它已经成为开源世界最火的 IDE 之一(弱弱的说一句,之一也许可以去掉)。
顺便歪楼说一句:我预言过三件事:
- 在 13 年预言
Golang
会火遍全世界。同时创建了 14-19 年最火的 Golang 隐修会社区,可惜因为某些原因被封停了,甚是遗憾。 - 在 15 年预言
VSCode
会成为世界上最好的 IDE;同时我还是jaeger tracing
项目的第一个 star 用户(是的,比作者还早),当时就很看好这个项目的后续发展。 - 现在呢,我在这里正式预言: 未来
Rust
会成为主流编程语言之一,在几乎所有开发领域都将大放光彩。总之牛逼已吹下,希望不要被打脸。:(
下面继续简单介绍下 VSCode,以下内容引用于官网:
Visual Studio Code(VSCode) 是微软 2015 年推出的一个轻量但功能强大的源代码编辑器,基于 Electron 开发,支持 Windows、Linux 和 macOS 操作系统。它内置了对 JavaScript,TypeScript 和 Node.js 的支持并且具有丰富的其它语言和扩展的支持,功能超级强大。Visual Studio Code 是一款免费开源的现代化轻量级代码编辑器,支持几乎所有主流的开发语言的语法高亮、智能代码补全、自定义快捷键、括号匹配和颜色区分、代码片段、代码对比 Diff、GIT 命令等特性,支持插件扩展,并针对网页开发和云端应用开发做了优化。
安装 VSCode 的 Rust 插件
在 VSCode 的左侧扩展目录里,搜索 rust
, 你能看到两个 Rust 插件,如果没有意外,这两个应该分别排名第一和第二:
- 官方的
Rust
,作者是The Rust Programming Language
, 官方出品,牛逼就完了,但是……我们并不推荐(事实上已经不再维护了,官方收编了第二个插件,现在第二个插件的作者也是The Rust Programming Language
),这个插件有几个问题:
- 首先是在代码跳转上支持的很烂,只能在自己的代码库中跳转,一旦跳到别的三方库,那就无法继续跳转,对于查看标准库和三方库的源码带来了极大的困扰
- 其次,不支持类型自动标注,对于 Rust 语言而言,类型说明是非常重要的,特别是在你不知道给变量一个什么类型时,这种 IDE 的自动提示就变得弥足珍贵
- 代码提示不太好用,有些方法既不会提示,也不能跳转
- 社区驱动的
rust-analyzer
,非常推荐,上面说的所有问题,在这个插件上都得到了解决,不得不说,Rust 社区 yyds!
所以,综上所述,我们选择 rust-analyzer
作为 Rust 语言的插件,具体的安装很简单,点击插件,选择安装即可,根据提示可能需要重新加载 IDE。
在搜索 VSCode 插件时,报错:
提取扩展出错,XHR failed
,这个报错是因为网络原因导致,很可能是你的网络不行或者翻墙工具阻拦你的访问,试着关掉翻墙,再进行尝试。
安装完成后,在第一次打开 Rust 项目时,需要安装一些依赖,具体的状态在左下角会进行提示,包括下载、代码构建、building 等。
当插件使用默认设置时,每一次保存代码,都会出进行一次重新编译。
如果你的电脑慢,有一点一定要注意:
在编译器构建代码的同时,不要在终端再运行
cargo run
等命令进行编译,不然会获得一个报错提示,大意是当前文件目录已经被锁定,等待其它使用者释放。如果等了很久 IDE 还是没有释放(虽然我没遇到过,但是存在这个可能性),你可以关掉 IDE,并手动kill
掉rust-analyzer
,然后重新尝试。
安装其它好用的插件
在此,再推荐大家几个好用的插件:
Even Better TOML
,支持 .toml 文件完整特性Error Lens
, 更好的获得错误展示One Dark Pro
, 非常好看的 VSCode 主题CodeLLDB
, Debugger 程序
好了,至此,VSCode 的配置就已经全部结束,是不是很简单?下面让我们来用 Cargo
创建一个 Rust 项目,然后用 VSCode 打开。
认识 Cargo
但凡经历过 C/C++ 或 Go 语言 1.10 版本之前的用户都知道,一个好的包管理工具有多么的重要!!我那个时候是如此的渴望类似 nodejs
的 npm
包管理工具,但是却求而不得。
包管理工具最重要的意义就是任何用户拿到你的代码,都能运行起来,而不会因为各种包版本依赖焦头烂额。
Go 语言在 1.10 版本之前,所有的包都是在 github.com
下存放,导致了所有的项目都公用一套依赖代码,在本地项目复杂后,这简直是一种灾难。
说多了都是泪,笔者目前还有一个早期 Go 的项目 (15 年写的),用到了 iris
(一个坑爹 HTTP 服务),结果现在运行不起来了,因为找不到 iris
当时的那个版本了!!
作为一门现代化语言,Rust
吸收了多个语言的包管理优点,为大家提供超级大杀器: cargo
,真的,再挑剔的开发者,都对它赞不绝口。👍
总而言之,cargo
提供了一系列的工具,从项目的建立、构建到测试、运行直至部署,为 Rust 项目的管理提供尽可能完整的手段。同时,与 Rust 语言及其编译器 rustc
紧密结合,可以说用了后就忘不掉,如同初恋般的感觉。
创建一个"你好,世界"项目
又见"你好,世界",肯定有读者在批评了:你就不能有点创意吗?"世界,你好"难道不配?你是读者,你说了算,那我们就来创建一个"世界,你好"。
上文提到,Rust 语言的包管理工具是 cargo
。不过,我们无需再手动安装,之前安装 Rust 的时候,就已经一并安装了。
终于到了紧张刺激的 new new new 环节:
$ cargo new world_hello
$ cd world_hello
上面的命令使用 cargo new
创建一个项目,项目名是 world_hello
(向读者势力低头的项目名称,泪奔),该项目的结构和配置文件都是由 cargo
生成,意味着我们的项目被 cargo
所管理。
如果你在终端无法使用这个命令,考虑一下
环境变量
是否正确的设置:把cargo
可执行文件所在的目录添加到环境变量中。如果是在 Windows 的 WSL2 子系统下,出现以下错误:
error: command failed: 'rustc'
error: caused by: Permission denied (os error 13)
可尝试先卸载,再使用
sudo
命令进行安装:$ sudo curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
早期的 cargo
在创建项目时,必须添加 --bin
的参数,如下所示:
$ cargo new world_hello --bin
$ cd world_hello
现在的版本,已经无需此参数,cargo
默认就创建 bin
类型的项目,顺便说一句,Rust 项目主要分为两个类型:bin
和 lib
,前者是一个可运行的项目,后者是一个依赖库项目。
下面来看看创建的项目结构:
$ tree
.
├── .git
├── .gitignore
├── Cargo.toml
└── src
└── main.rs
是的,连 git
都给你创建了,不禁令人感叹,不是女儿,胜似女儿,比小棉袄还体贴。
运行项目
有两种方式可以运行项目:
-
cargo run
-
手动编译和运行项目
首先来看看第一种方式,一码胜似千言,在之前创建的 world_hello
目录下运行:
$ cargo run
Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.43s
Running `target/debug/world_hello`
Hello, world!
好了,你已经看到程序的输出:"Hello, world"
。
如果你安装的 Rust 的 host triple
是 x86_64-pc-windows-msvc
并确认 Rust 已经正确安装,但在终端上运行上述命令时,出现类似如下的错误摘要 linking with `link.exe` failed: exit code: 1181
,请使用 Visual Studio Installer 安装 Windows SDK
。
可能有读者不愿意了,说好了"世界,你好"呢?别急,在下一节,我们再对代码进行修改。(认真想来,"你好,世界“强调的是我对世界说你好,而"世界,你好“是世界对我说你好,明显是后者更有包容性和国际范儿,读者真·好眼光。)
上述代码,cargo run
首先对项目进行编译,然后再运行,因此它实际上等同于运行了两个指令,下面我们手动试一下编译和运行项目:
编译
$ cargo build
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
运行
$ ./target/debug/world_hello
Hello, world!
行云流水,但谈不上一气呵成。 细心的读者可能已经发现,在调用的时候,路径 ./target/debug/world_hello
中有一个明晃晃的 debug
字段,没错我们运行的是 debug
模式,在这种模式下,代码的编译速度会非常快,可是福兮祸所伏,运行速度就慢了. 原因是,在 debug
模式下,Rust 编译器不会做任何的优化,只为了尽快的编译完成,让你的开发流程更加顺畅。
作为尊贵的读者,咱自然可以要求更多,比如你想要高性能的代码怎么办? 简单,添加 --release
来编译:
cargo run --release
cargo build --release
试着运行一下我们高性能的 release
程序:
$ ./target/release/world_hello
Hello, world!
cargo check
当项目大了后,cargo run
和 cargo build
不可避免的会变慢,那么有没有更快的方式来验证代码的正确性呢?大杀器来了,接着!
cargo check
是我们在代码开发过程中最常用的命令,它的作用很简单:快速的检查一下代码能否编译通过。因此该命令速度会非常快,能节省大量的编译时间。
$ cargo check
Checking world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
Rust 虽然编译速度还行,但是还是不能与 Go 语言相提并论,因为 Rust 需要做很多复杂的编译优化和语言特性解析,甚至连如何优化编译速度都成了一门学问: 优化编译速度。
Cargo.toml 和 Cargo.lock
Cargo.toml
和 Cargo.lock
是 cargo
的核心文件,它的所有活动均基于此二者。
-
Cargo.toml
是cargo
特有的项目数据描述文件。它存储了项目的所有元配置信息,如果 Rust 开发者希望 Rust 项目能够按照期望的方式进行构建、测试和运行,那么,必须按照合理的方式构建Cargo.toml
。 -
Cargo.lock
文件是cargo
工具根据同一项目的toml
文件生成的项目依赖详细清单,因此我们一般不用修改它,只需要对着Cargo.toml
文件撸就行了。
什么情况下该把
Cargo.lock
上传到 git 仓库里?很简单,当你的项目是一个可运行的程序时,就上传Cargo.lock
,如果是一个依赖库项目,那么请把它添加到.gitignore
中。
现在用 VSCode 打开上面创建的"世界,你好"项目,然后进入根目录的 Cargo.toml
文件,可以看到该文件包含不少信息:
package 配置段落
package
中记录了项目的描述信息,典型的如下:
[package]
name = "world_hello"
version = "0.1.0"
edition = "2021"
name
字段定义了项目名称,version
字段定义当前版本,新项目默认是 0.1.0
,edition
字段定义了我们使用的 Rust 大版本。因为本书很新(不仅仅是现在新,未来也将及时修订,跟得上 Rust 的小步伐),所以使用的是 Rust edition 2021
大版本,详情见 Rust 版本详解
定义项目依赖
使用 cargo
工具的最大优势就在于,能够对该项目的各种依赖项进行方便、统一和灵活的管理。
在 Cargo.toml
中,主要通过各种依赖段落来描述该项目的各种依赖项:
- 基于 Rust 官方仓库
crates.io
,通过版本说明来描述 - 基于项目源代码的 git 仓库地址,通过 URL 来描述
- 基于本地项目的绝对路径或者相对路径,通过类 Unix 模式的路径来描述
这三种形式具体写法如下:
[dependencies]
rand = "0.3"
hammer = { version = "0.5.0"}
color = { git = "https://github.com/bjz/color-rs" }
geometry = { path = "crates/geometry" }
相信聪明的读者已经能看懂该如何引入外部依赖库,这里就不再赘述。详细的说明参见此章:Cargo 依赖管理,但是不建议大家现在去看,只要按照目录浏览,拨云见日指日可待。
基于 cargo 的项目组织结构
前文有提到 cargo
默认生成的项目结构,真实的项目肯定会有所不同,但是在目前的学习阶段,还无需关注。感兴趣的同学可以移步:Cargo 项目结构
至此,大家对 Rust 项目的创建和管理已经有了初步的了解,那么来完善刚才的"世界,你好"
项目吧。
不仅仅是 Hello world
几乎所有教程中安装的最后一个环节都是 hello world
,我们也不能免俗。但是,在 hello world
之后,还有一个相亲,啊呸,Rust 初印象环节,希望大家喜欢。
多国语言的"世界,你好"
还记得大明湖畔等你的 VSCode IDE 和通过 Cargo
创建的 世界,你好 工程吗?
现在使用 VSCode 打开 上一节 中创建的 world_hello
工程,然后进入 main.rs
文件。(此文件是当前 Rust 工程的入口文件,和其它语言几无区别。)
接下来,对世界友人给予热切的问候:
fn greet_world() { let southern_germany = "Grüß Gott!"; let chinese = "世界,你好"; let english = "World, hello"; let regions = [southern_germany, chinese, english]; for region in regions.iter() { println!("{}", ®ion); } } fn main() { greet_world(); }
打开终端,进入 world_hello
工程根目录,运行该程序。(你也可以在 VSCode 中打开终端,方法是点击 VSCode 上方菜单栏中的终端->新建终端,或者直接使用快捷键打开。)
$ cargo run
Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.21s
Running `target/debug/world_hello`
Grüß Gott!
世界,你好
World, hello
你的热情,就像一把火,燃烧了整个世界~ 花点时间来看看上面的代码:
首先,Rust 原生支持 UTF-8 编码的字符串,这意味着你可以很容易的使用世界各国文字作为字符串内容。
其次,关注下 println
后面的 !
,如果你有 Ruby 编程经验,那么你可能会认为这是解构操作符,但是在 Rust 中,这是 宏
操作符,你目前可以认为宏是一种特殊类型函数。
对于 println
来说,我们没有使用其它语言惯用的 %s
、%d
来做输出占位符,而是使用 {}
,因为 Rust 在底层帮我们做了大量工作,会自动识别输出数据的类型,例如当前例子,会识别为 String
类型。
最后,和其它语言不同,Rust 的集合类型不能直接进行循环,需要变成迭代器(这里是通过 .iter()
方法),才能用于迭代循环。在目前来看,你会觉得这一点好像挺麻烦,不急,以后就知道这么做的好处所在。
实际上这段代码可以简写,在 2021 edition 及以后,支持直接写
for region in regions
,原因会在迭代器章节的开头提到,是因为 for 隐式地将 regions 转换成迭代器。
至于函数声明、调用、数组的使用,和其它语言没什么区别,So Easy!
Rust 语言初印象
Rust 这门语言对于 Haskell 和 Java 开发者来说,可能会觉得很熟悉,因为它们在高阶表达方面都很优秀。简而言之,就是可以很简洁的写出原本需要一大堆代码才能表达的含义。但是,Rust 又有所不同:它的性能是底层语言级别的性能,可以跟 C/C++ 相媲美。
上面的 So Easy
的余音仍在绕梁,我希望它能继续下去,可是… 人总是要面对现实,因此让我们来点狠活:
fn main() { let penguin_data = "\ common name,length (cm) Little penguin,33 Yellow-eyed penguin,65 Fiordland penguin,60 Invalid,data "; let records = penguin_data.lines(); for (i, record) in records.enumerate() { if i == 0 || record.trim().len() == 0 { continue; } // 声明一个 fields 变量,类型是 Vec // Vec 是 vector 的缩写,是一个可伸缩的集合类型,可以认为是一个动态数组 // <_>表示 Vec 中的元素类型由编译器自行推断,在很多场景下,都会帮我们省却不少功夫 let fields: Vec<_> = record .split(',') .map(|field| field.trim()) .collect(); if cfg!(debug_assertions) { // 输出到标准错误输出 eprintln!("debug: {:?} -> {:?}", record, fields); } let name = fields[0]; // 1. 尝试把 fields[1] 的值转换为 f32 类型的浮点数,如果成功,则把 f32 值赋给 length 变量 // // 2. if let 是一个匹配表达式,用来从=右边的结果中,匹配出 length 的值: // 1)当=右边的表达式执行成功,则会返回一个 Ok(f32) 的类型,若失败,则会返回一个 Err(e) 类型,if let 的作用就是仅匹配 Ok 也就是成功的情况,如果是错误,就直接忽略 // 2)同时 if let 还会做一次解构匹配,通过 Ok(length) 去匹配右边的 Ok(f32),最终把相应的 f32 值赋给 length // // 3. 当然你也可以忽略成功的情况,用 if let Err(e) = fields[1].parse::<f32>() {...}匹配出错误,然后打印出来,但是没啥卵用 if let Ok(length) = fields[1].parse::<f32>() { // 输出到标准输出 println!("{}, {}cm", name, length); } } }
看完这段代码,不知道你的余音有没有戛然而止,反正我已经在颤抖了。这就是传说中的下马威吗?😵
上面代码中,值得注意的 Rust 特性有:
- 控制流:
for
和continue
连在一起使用,实现循环控制。 - 方法语法:由于 Rust 没有继承,因此 Rust 不是传统意义上的面向对象语言,但是它却从
OO
语言那里偷师了方法的使用record.trim()
,record.split(',')
等。 - 高阶函数编程:函数可以作为参数也能作为返回值,例如
.map(|field| field.trim())
,这里map
方法中使用闭包函数作为参数,也可以称呼为匿名函数
、lambda 函数
。 - 类型标注:
if let Ok(length) = fields[1].parse::<f32>()
,通过::<f32>
的使用,告诉编译器length
是一个f32
类型的浮点数。这种类型标注不是很常用,但是在编译器无法推断出你的数据类型时,就很有用了。 - 条件编译:
if cfg!(debug_assertions)
,说明紧跟其后的输出(打印)只在debug
模式下生效。 - 隐式返回:Rust 提供了
return
关键字用于函数返回,但是在很多时候,我们可以省略它。因为 Rust 是 基于表达式的语言。
在终端中运行上述代码时,会看到很多 debug: ...
的输出,上面有讲,这些都是 条件编译
的输出,那么该怎么消除掉这些输出呢?
读者大大普遍冰雪聪明,肯定已经想到:是的,在 认识 Cargo中,曾经介绍过 --release
参数,因为 cargo run
默认是运行 debug
模式。因此想要消灭那些 debug:
输出,需要更改为其它模式,其中最常用的模式就是 --release
也就是生产发布的模式。
具体运行代码就不给了,留给大家作为一个小练习,建议亲自动手尝试下。
至此,Rust 安装入门就已经结束。相信看到这里,你已经发现了本书与其它书的区别,其中最大的区别就是:这本书就像优秀的国外课本一样,不太枯燥。也希望这本不太枯燥的书,能伴你长行,犹如一杯奶茶,细细品之,唇齿留香。
下载依赖很慢或卡住?
在目前,大家还不需要自己搭建的镜像下载服务,因此只需知道下载依赖库的地址是 crates.io,是由 Rust 官方搭建的镜像下载和管理服务。
但悲剧的是,它的默认镜像地址是在国外,这就导致了某些时候难免会遇到下载缓慢或者卡住的情况,下面我们一起来看看。
下载很慢?
作为国外的语言,下载慢是正常的,隔壁的那位还被墙呢:)
解决下载缓慢有两种途径:
开启命令行或者全局翻墙
经常有同学反馈,我明明开启翻墙了,但是下载依然还是很慢,无论是命令行中下载还是 VSCode 的 rust-analyzer 插件自动拉取。
事实上,翻墙工具默认开启的仅仅是浏览器的翻墙代理,对于命令行或者软件中的访问,并不会代理流量,因此这些访问还是通过正常网络进行的,自然会失败。
因此,大家需要做的是在你使用的翻墙工具中 复制终端代理命令
或者开启全局翻墙。由于每个翻墙软件的使用方式不同,因此具体的还是需要自己研究下。以我使用的 ClashX
为例,点击 复制终端代理命令
后,会自动复制一些 export
文本,将这些文本复制到命令行终端中,执行一下,就可以自动完成代理了。
export https_proxy=http://127.0.0.1:7890 http_proxy=http://127.0.0.1:7890 all_proxy=socks5://127.0.0.1:7891
修改 Rust 的下载镜像为国内的镜像地址
这个效果最直接,一劳永逸,但是就是配置起来略微麻烦。
为了使用 crates.io
之外的注册服务,我们需要对 $HOME/.cargo/config.toml
($CARGO_HOME 下) 文件进行配置,添加新的服务提供商,有两种方式可以实现:增加新的镜像地址和覆盖默认的镜像地址。
新增镜像地址
首先是在 crates.io
之外添加新的注册服务,在 $HOME/.cargo/config.toml
(如果文件不存在则手动创建一个)中添加以下内容:
[registries]
ustc = { index = "https://mirrors.ustc.edu.cn/crates.io-index/" }
这种方式只会新增一个新的镜像地址,因此在引入依赖的时候,需要指定该地址,例如在项目中引入 time
包,你需要在 Cargo.toml
中使用以下方式引入:
[dependencies]
time = { registry = "ustc" }
在重新配置后,初次构建可能要较久的时间,因为要下载更新 ustc
注册服务的索引文件,由于文件比较大,需要等待较长的时间。
此处有两点需要注意:
- cargo 1.68 版本开始支持稀疏索引,不再需要完整克隆 crates.io-index 仓库,可以加快获取包的速度,如:
[source.ustc]
registry = "sparse+https://mirrors.ustc.edu.cn/crates.io-index/"
- cargo search 无法使用镜像
科大镜像
上面使用的是科大提供的注册服务,也是 Rust 最早期的注册服务,感谢大大们的贡献。除此之外,大家还可以选择下面的镜像服务:
字节跳动
最大的优点就是不限速,当然,你的网速如果能跑到 1000Gbps,我们也可以认为它无情的限制了你,咳咳。
[source.crates-io]
replace-with = 'rsproxy'
[source.rsproxy]
registry = "https://rsproxy.cn/crates.io-index"
# 稀疏索引,要求 cargo >= 1.68
[source.rsproxy-sparse]
registry = "sparse+https://rsproxy.cn/index/"
[registries.rsproxy]
index = "https://rsproxy.cn/crates.io-index"
[net]
git-fetch-with-cli = true
覆盖默认的镜像地址
事实上,我们更推荐第二种方式,因为第一种方式在项目大了后,实在是很麻烦,全部修改后,万一以后不用这个镜像了,你又要全部修改成其它的。
而第二种方式,则不需要修改 Cargo.toml
文件,因为它是直接使用新注册服务来替代默认的 crates.io
。
在 $HOME/.cargo/config.toml
添加以下内容:
[source.crates-io]
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"
首先,创建一个新的镜像源 [source.ustc]
,然后将默认的 crates-io
替换成新的镜像源: replace-with = 'ustc'
。
简单吧?只要这样配置后,以往需要去 crates.io
下载的包,会全部从科大的镜像地址下载,速度刷刷的.. 我的 300M 大刀( 宽带 )终于有了用武之地。
这里强烈推荐大家在学习完后面的基本章节后,看一下 Cargo 使用指南章节,对于你的 Rust 之旅会有莫大的帮助!
下载卡住
下载卡住其实就一个原因:下载太慢了。
根据经验来看,卡住不动往往发生在更新索引时。毕竟 Rust 的包越来越多,索引也越来越大,如果不使用国内镜像,卡住还蛮正常的,好在,我们也无需经常更新索引 :P
Blocking waiting for file lock on package cache
不过这里有一个坑,需要大家注意,如果你同时打开了 VSCODE 和命令行,然后修改了 Cargo.toml
,此时 VSCODE 的 rust-analyzer 插件会自动检测到依赖的变更,去下载新的依赖。
在 VSCODE 下载的过程中( 特别是更新索引,可能会耗时很久),假如你又在命令行中运行类似 cargo run
或者 cargo build
的命令,就会提示一行有些看不太懂的内容:
$ cargo build
Blocking waiting for file lock on package cache
Blocking waiting for file lock on package cache
其实这个报错就是因为 VSCODE 的下载太慢了,而且该下载构建还锁住了当前的项目,导致你无法在另一个地方再次进行构建。
解决办法也很简单:
- 增加下载速度,见前面内容
- 耐心等待持有锁的用户构建完成
- 强行停止正在构建的进程,例如杀掉 IDE 使用的 rust-analyzer 插件进程,然后删除
$HOME/.cargo/.package_cache
目录
Rust 基本概念
从现在开始,我们正式踏入了 Rust 大陆,这片广袤而神秘的世界,在这个世界中,将接触到很多之前都没有听过的概念:
- 所有权、借用、生命周期
- 宏编程
- 模式匹配
类似的还有很多,不过不用怕,引用武林外传一句话:咱上面有人。有本书在,一切虚妄终将烟消云散。
本章主要介绍 Rust 的基础语法、数据类型、项目结构等,学完本章,你将对 Rust 代码有一个清晰、完整的认识。
开始之前先通过一段代码来简单浏览下 Rust 的语法:
// Rust 程序入口函数,跟其它语言一样,都是 main,该函数目前无返回值 fn main() { // 使用let来声明变量,进行绑定,a是不可变的 // 此处没有指定a的类型,编译器会默认根据a的值为a推断类型:i32,有符号32位整数 // 语句的末尾必须以分号结尾 let a = 10; // 主动指定b的类型为i32 let b: i32 = 20; // 这里有两点值得注意: // 1. 可以在数值中带上类型:30i32表示数值是30,类型是i32 // 2. c是可变的,mut是mutable的缩写 let mut c = 30i32; // 还能在数值和类型中间添加一个下划线,让可读性更好 let d = 30_i32; // 跟其它语言一样,可以使用一个函数的返回值来作为另一个函数的参数 let e = add(add(a, b), add(c, d)); // println!是宏调用,看起来像是函数但是它返回的是宏定义的代码块 // 该函数将指定的格式化字符串输出到标准输出中(控制台) // {}是占位符,在具体执行过程中,会把e的值代入进来 println!("( a + b ) + ( c + d ) = {}", e); } // 定义一个函数,输入两个i32类型的32位有符号整数,返回它们的和 fn add(i: i32, j: i32) -> i32 { // 返回相加值,这里可以省略return i + j }
注意 在上面的
add
函数中,不要为i+j
添加;
,这会改变语法导致函数返回()
而不是i32
,具体参见语句和表达式。
有几点可以留意下:
- 字符串使用双引号
""
而不是单引号''
,Rust 中单引号是留给单个字符类型(char
)使用的 - Rust 使用
{}
来作为格式化输出占位符,其它语言可能使用的是%s
,%d
,%p
等,由于println!
会自动推导出具体的类型,因此无需手动指定
变量绑定与解构
鉴于本书的目标读者(别慌,来到这里就说明你就是目标读者)已经熟练掌握其它任意一门编程语言,因此这里就不再对何为变量进行赘述,让我们开门见山来谈谈,为何 Rust 选择了手动设定变量的可变性。
为何要手动设置变量的可变性?
在其它大多数语言中,要么只支持声明可变的变量,要么只支持声明不可变的变量( 例如函数式语言 ),前者为编程提供了灵活性,后者为编程提供了安全性,而 Rust 比较野,选择了两者我都要,既要灵活性又要安全性。
能想要学习 Rust,说明我们的读者都是相当有水平的程序员了,你们应该能理解一切选择皆是权衡,那么两者都要的权衡是什么呢?这就是 Rust 开发团队为我们做出的贡献,两者都要意味着 Rust 语言底层代码的实现复杂度大幅提升,因此 Salute to The Rust Team!
除了以上两个优点,还有一个很大的优点,那就是运行性能上的提升,因为将本身无需改变的变量声明为不可变在运行期会避免一些多余的 runtime
检查。
变量命名
在命名方面,和其它语言没有区别,不过当给变量命名时,需要遵循 Rust 命名规范。
Rust 语言有一些关键字(keywords),和其他语言一样,这些关键字都是被保留给 Rust 语言使用的,因此,它们不能被用作变量或函数的名称。在 附录 A 中可找到关键字列表。
变量绑定
在其它语言中,我们用 var a = "hello world"
的方式给 a
赋值,也就是把等式右边的 "hello world"
字符串赋值给变量 a
,而在 Rust 中,我们这样写: let a = "hello world"
,同时给这个过程起了另一个名字:变量绑定。
为何不用赋值而用绑定呢(其实你也可以称之为赋值,但是绑定的含义更清晰准确)?这里就涉及 Rust 最核心的原则——所有权,简单来讲,任何内存对象都是有主人的,而且一般情况下完全属于它的主人,绑定就是把这个对象绑定给一个变量,让这个变量成为它的主人(聪明的读者应该能猜到,在这种情况下,该对象之前的主人就会丧失对该对象的所有权),像极了我们的现实世界,不是吗?
那为什么要引进“所有权”这个新的概念呢?请稍安勿躁,时机一旦成熟,我们就回来继续讨论这个话题。
变量可变性
Rust 的变量在默认情况下是不可变的。前文提到,这是 Rust 团队为我们精心设计的语言特性之一,让我们编写的代码更安全,性能也更好。当然你可以通过 mut
关键字让变量变为可变的,让设计更灵活。
如果变量 a
不可变,那么一旦为它绑定值,就不能再修改 a
。举个例子,在我们的工程目录下使用 cargo new variables
新建一个项目,叫做 variables 。
然后在新建的 variables 目录下,编辑 src/main.rs ,改为下面代码:
fn main() { let x = 5; println!("The value of x is: {}", x); x = 6; println!("The value of x is: {}", x); }
保存文件,再使用 cargo run
运行它,迎面而来的是一条错误提示:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:4:5
|
2 | let x = 5;
| -
| |
| first assignment to `x`
| help: consider making this binding mutable: `mut x`
3 | println!("The value of x is: {}", x);
4 | x = 6;
| ^^^^^ cannot assign twice to immutable variable
error: aborting due to previous error
具体的错误原因是 cannot assign twice to immutable variable x
(无法对不可变的变量进行重复赋值),因为我们想为不可变的 x
变量再次赋值。
这种错误是为了避免无法预期的错误发生在我们的变量上:一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。
这种规则让我们的代码变得非常清晰,只有你想让你的变量改变时,它才能改变,这样就不会造成心智上的负担,也给别人阅读代码带来便利。
但是可变性也非常重要,否则我们就要像 ClojureScript 那样,每次要改变,就要重新生成一个对象,在拥有大量对象的场景,性能会变得非常低下,内存拷贝的成本异常的高。
在 Rust 中,可变性很简单,只要在变量名前加一个 mut
即可, 而且这种显式的声明方式还会给后来人传达这样的信息:嗯,这个变量在后面代码部分会发生改变。
为了让变量声明为可变,将 src/main.rs 改为以下内容:
fn main() { let mut x = 5; println!("The value of x is: {}", x); x = 6; println!("The value of x is: {}", x); }
运行程序将得到下面结果:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target/debug/variables`
The value of x is: 5
The value of x is: 6
选择可变还是不可变,更多的还是取决于你的使用场景,例如不可变可以带来安全性,但是丧失了灵活性和性能(如果你要改变,就要重新创建一个新的变量,这里涉及到内存对象的再分配)。而可变变量最大的好处就是使用上的灵活性和性能上的提升。
例如,在使用大型数据结构或者热点代码路径(被大量频繁调用)的情形下,在同一内存位置更新实例可能比复制并返回新分配的实例要更快。使用较小的数据结构时,通常创建新的实例并以更具函数式的风格来编写程序,可能会更容易理解,所以值得以较低的性能开销来确保代码清晰。
使用下划线开头忽略未使用的变量
如果你创建了一个变量却不在任何地方使用它,Rust 通常会给你一个警告,因为这可能会是个 BUG。但是有时创建一个不会被使用的变量是有用的,比如你正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头:
fn main() { let _x = 5; let y = 10; }
使用 cargo run
运行下试试:
warning: unused variable: `y`
--> src/main.rs:3:9
|
3 | let y = 10;
| ^ help: 如果 y 故意不被使用,请添加一个下划线前缀: `_y`
|
= note: `#[warn(unused_variables)]` on by default
可以看到,两个变量都是只有声明,没有使用,但是编译器却独独给出了 y
未被使用的警告,充分说明了 _
变量名前缀在这里发挥的作用。
值得注意的是,这里编译器还很善意的给出了提示( Rust 的编译器非常强大,这里的提示只是小意思 ): 将 y
修改 _y
即可。这里就不再给出代码,留给大家手动尝试并观察下运行结果。
更多关于 _x
的使用信息,请阅读后面的模式匹配章节。
变量解构
let
表达式不仅仅用于变量的绑定,还能进行复杂变量的解构:从一个相对复杂的变量中,匹配出该变量的一部分内容:
fn main() { let (a, mut b): (bool,bool) = (true, false); // a = true,不可变; b = false,可变 println!("a = {:?}, b = {:?}", a, b); b = true; assert_eq!(a, b); }
解构式赋值
在 Rust 1.59 版本后,我们可以在赋值语句的左式中使用元组、切片和结构体模式了。
struct Struct { e: i32 } fn main() { let (a, b, c, d, e); (a, b) = (1, 2); // _ 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 _ [c, .., d, _] = [1, 2, 3, 4, 5]; Struct { e, .. } = Struct { e: 5 }; assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e]); }
这种使用方式跟之前的 let
保持了一致性,但是 let
会重新绑定,而这里仅仅是对之前绑定的变量进行再赋值。
需要注意的是,使用 +=
的赋值语句还不支持解构式赋值。
这里用到了模式匹配的一些语法,如果大家看不懂没关系,可以在学完模式匹配章节后,再回头来看。
变量和常量之间的差异
变量的值不能更改可能让你想起其他另一个很多语言都有的编程概念:常量(constant)。与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:
- 常量不允许使用
mut
。常量不仅仅默认不可变,而且自始至终不可变,因为常量在编译完成后,已经确定它的值。 - 常量使用
const
关键字而不是let
关键字来声明,并且值的类型必须标注。
我们将在下一节数据类型中介绍,因此现在暂时无需关心细节。
下面是一个常量声明的例子,其常量名为 MAX_POINTS
,值设置为 100,000
。(Rust 常量的命名约定是全部字母都使用大写,并使用下划线分隔单词,另外对数字字面量可插入下划线以提高可读性):
#![allow(unused)] fn main() { const MAX_POINTS: u32 = 100_000; }
常量可以在任意作用域内声明,包括全局作用域,在声明的作用域内,常量在程序运行的整个过程中都有效。对于需要在多处代码共享一个不可变的值时非常有用,例如游戏中允许玩家赚取的最大点数或光速。
在实际使用中,最好将程序中用到的硬编码值都声明为常量,对于代码后续的维护有莫大的帮助。如果将来需要更改硬编码的值,你也只需要在代码中更改一处即可。
变量遮蔽(shadowing)
Rust 允许声明相同的变量名,在后面声明的变量会遮蔽掉前面声明的,如下所示:
fn main() { let x = 5; // 在main函数的作用域内对之前的x进行遮蔽 let x = x + 1; { // 在当前的花括号作用域内,对之前的x进行遮蔽 let x = x * 2; println!("The value of x in the inner scope is: {}", x); } println!("The value of x is: {}", x); }
这个程序首先将数值 5
绑定到 x
,然后通过重复使用 let x =
来遮蔽之前的 x
,并取原来的值加上 1
,所以 x
的值变成了 6
。第三个 let
语句同样遮蔽前面的 x
,取之前的值并乘上 2
,得到的 x
最终值为 12
。当运行此程序,将输出以下内容:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
...
The value of x in the inner scope is: 12
The value of x is: 6
这和 mut
变量的使用是不同的,第二个 let
生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配
,而 mut
声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。
变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。
例如,假设有一个程序要统计一个空格字符串的空格数量:
#![allow(unused)] fn main() { // 字符串类型 let spaces = " "; // usize数值类型 let spaces = spaces.len(); }
这种结构是允许的,因为第一个 spaces
变量是一个字符串类型,第二个 spaces
变量是一个全新的变量且和第一个具有相同的变量名,且是一个数值类型。所以变量遮蔽可以帮我们节省些脑细胞,不用去想如 spaces_str
和 spaces_num
此类的变量名;相反我们可以重复使用更简单的 spaces
变量名。如果你不用 let
:
#![allow(unused)] fn main() { let mut spaces = " "; spaces = spaces.len(); }
运行一下,你就会发现编译器报错:
$ cargo run
Compiling variables v0.1.0 (file:///projects/variables)
error[E0308]: mismatched types
--> src/main.rs:3:14
|
3 | spaces = spaces.len();
| ^^^^^^^^^^^^ expected `&str`, found `usize`
error: aborting due to previous error
显然,Rust 对类型的要求很严格,不允许将整数类型 usize
赋值给字符串类型。usize
是一种 CPU 相关的整数类型,在数值类型中有详细介绍。
万事开头难,到目前为止,都进展很顺利,那下面开始,咱们正式进入 Rust 的类型世界,看看有哪些挑战在前面等着大家。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
基本类型
当一门语言不谈类型时,你得小心,这大概率是动态语言(别拍我,我承认是废话)。但是把类型大张旗鼓的用多个章节去讲的,Rust 是其中之一。
Rust 每个值都有其确切的数据类型,总的来说可以分为两类:基本类型和复合类型。 基本类型意味着它们往往是一个最小化原子类型,无法解构为其它类型(一般意义上来说),由以下组成:
- 数值类型: 有符号整数 (
i8
,i16
,i32
,i64
,isize
)、 无符号整数 (u8
,u16
,u32
,u64
,usize
) 、浮点数 (f32
,f64
)、以及有理数、复数 - 字符串:字符串字面量和字符串切片
&str
- 布尔类型:
true
和false
- 字符类型: 表示单个 Unicode 字符,存储为 4 个字节
- 单元类型: 即
()
,其唯一的值也是()
类型推导与标注
与 Python、JavaScript 等动态语言不同,Rust 是一门静态类型语言,也就是编译器必须在编译期知道我们所有变量的类型,但这不意味着你需要为每个变量指定类型,因为 Rust 编译器很聪明,它可以根据变量的值和上下文中的使用方式来自动推导出变量的类型,同时编译器也不够聪明,在某些情况下,它无法推导出变量类型,需要手动去给予一个类型标注,关于这一点在 Rust 语言初印象中有过展示。
来看段代码:
#![allow(unused)] fn main() { let guess = "42".parse().expect("Not a number!"); }
先忽略 .parse().expect..
部分,这段代码的目的是将字符串 "42"
进行解析,而编译器在这里无法推导出我们想要的类型:整数?浮点数?字符串?因此编译器会报错:
$ cargo build
Compiling no_type_annotations v0.1.0 (file:///projects/no_type_annotations)
error[E0282]: type annotations needed
--> src/main.rs:2:9
|
2 | let guess = "42".parse().expect("Not a number!");
| ^^^^^ consider giving `guess` a type
因此我们需要提供给编译器更多的信息,例如给 guess
变量一个显式的类型标注:let guess: i32 = ...
或者 "42".parse::<i32>()
。
数值类型
我朋友有一个领导(读者:你朋友?黑人问号)说过一句话:所有代码就是 0 和 1 ,简单的很。咱不评价这句话的正确性,但是计算机底层由 01 组成倒是真的。
计算机和数值关联在一起的时间,远比我们想象的要长,因此数值类型可以说是有计算机以来就有的类型,下面内容将深入讨论 Rust 的数值类型以及相关的运算符。
Rust 使用一个相对传统的语法来创建整数(1
,2
,...)和浮点数(1.0
,1.1
,...)。整数、浮点数的运算和你在其它语言上见过的一致,都是通过常见的运算符来完成。
不仅仅是数值类型,Rust 也允许在复杂类型上定义运算符,例如在自定义类型上定义
+
运算符,这种行为被称为运算符重载,Rust 具体支持的可重载运算符见附录 B。
整数类型
整数是没有小数部分的数字。之前使用过的 i32
类型,表示有符号的 32 位整数( i
是英文单词 integer 的首字母,与之相反的是 u
,代表无符号 unsigned
类型)。下表显示了 Rust 中的内置的整数类型:
长度 | 有符号类型 | 无符号类型 |
---|---|---|
8 位 | i8 | u8 |
16 位 | i16 | u16 |
32 位 | i32 | u32 |
64 位 | i64 | u64 |
128 位 | i128 | u128 |
视架构而定 | isize | usize |
类型定义的形式统一为:有无符号 + 类型大小(位数)
。无符号数表示数字只能取正数和0,而有符号则表示数字可以取正数、负数还有0。就像在纸上写数字一样:当要强调符号时,数字前面可以带上正号或负号;然而,当很明显确定数字为正数时,就不需要加上正号了。有符号数字以补码形式存储。
每个有符号类型规定的数字范围是 -(2n - 1) ~ 2n -
1 - 1,其中 n
是该定义形式的位长度。因此 i8
可存储数字范围是 -(27) ~ 27 - 1,即 -128 ~ 127。无符号类型可以存储的数字范围是 0 ~ 2n - 1,所以 u8
能够存储的数字为 0 ~ 28 - 1,即 0 ~ 255。
此外,isize
和 usize
类型取决于程序运行的计算机 CPU 类型: 若 CPU 是 32 位的,则这两个类型是 32 位的,同理,若 CPU 是 64 位,那么它们则是 64 位。
整形字面量可以用下表的形式书写:
数字字面量 | 示例 |
---|---|
十进制 | 98_222 |
十六进制 | 0xff |
八进制 | 0o77 |
二进制 | 0b1111_0000 |
字节 (仅限于 u8 ) | b'A' |
这么多类型,有没有一个简单的使用准则?答案是肯定的, Rust 整型默认使用 i32
,例如 let i = 1
,那 i
就是 i32
类型,因此你可以首选它,同时该类型也往往是性能最好的。isize
和 usize
的主要应用场景是用作集合的索引。
整型溢出
假设有一个 u8
,它可以存放从 0 到 255 的值。那么当你将其修改为范围之外的值,比如 256,则会发生整型溢出。关于这一行为 Rust 有一些有趣的规则:当在 debug 模式编译时,Rust 会检查整型溢出,若存在这些问题,则使程序在编译时 panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。
在当使用 --release
参数进行 release 模式构建时,Rust 不检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理。简而言之,大于该类型最大值的数值会被补码转换成该类型能够支持的对应数字的最小值。比如在 u8
的情况下,256 变成 0,257 变成 1,依此类推。程序不会 panic,但是该变量的值可能不是你期望的值。依赖这种默认行为的代码都应该被认为是错误的代码。
要显式处理可能的溢出,可以使用标准库针对原始数字类型提供的这些方法:
- 使用
wrapping_*
方法在所有模式下都按照补码循环溢出规则处理,例如wrapping_add
- 如果使用
checked_*
方法时发生溢出,则返回None
值 - 使用
overflowing_*
方法返回该值和一个指示是否存在溢出的布尔值 - 使用
saturating_*
方法使值达到最小值或最大值
下面是一个演示wrapping_*
方法的示例:
fn main() { let a : u8 = 255; let b = a.wrapping_add(20); println!("{}", b); // 19 }
浮点类型
浮点类型数字 是带有小数点的数字,在 Rust 中浮点类型数字也有两种基本类型: f32
和 f64
,分别为 32 位和 64 位大小。默认浮点类型是 f64
,在现代的 CPU 中它的速度与 f32
几乎相同,但精度更高。
下面是一个演示浮点数的示例:
fn main() { let x = 2.0; // f64 let y: f32 = 3.0; // f32 }
浮点数根据 IEEE-754
标准实现。f32
类型是单精度浮点型,f64
为双精度。
浮点数陷阱
浮点数由于底层格式的特殊性,导致了如果在使用浮点数时不够谨慎,就可能造成危险,有两个原因:
-
浮点数往往是你想要数字的近似表达 浮点数类型是基于二进制实现的,但是我们想要计算的数字往往是基于十进制,例如
0.1
在二进制上并不存在精确的表达形式,但是在十进制上就存在。这种不匹配性导致一定的歧义性,更多的,虽然浮点数能代表真实的数值,但是由于底层格式问题,它往往受限于定长的浮点数精度,如果你想要表达完全精准的真实数字,只有使用无限精度的浮点数才行 -
浮点数在某些特性上是反直觉的 例如大家都会觉得浮点数可以进行比较,对吧?是的,它们确实可以使用
>
,>=
等进行比较,但是在某些场景下,这种直觉上的比较特性反而会害了你。因为f32
,f64
上的比较运算实现的是std::cmp::PartialEq
特征(类似其他语言的接口),但是并没有实现std::cmp::Eq
特征,但是后者在其它数值类型上都有定义,说了这么多,可能大家还是云里雾里,用一个例子来举例:
Rust 的 HashMap
数据结构,是一个 KV 类型的 Hash Map 实现,它对于 K
没有特定类型的限制,但是要求能用作 K
的类型必须实现了 std::cmp::Eq
特征,因此这意味着你无法使用浮点数作为 HashMap
的 Key
,来存储键值对,但是作为对比,Rust 的整数类型、字符串类型、布尔类型都实现了该特征,因此可以作为 HashMap
的 Key
。
为了避免上面说的两个陷阱,你需要遵守以下准则:
- 避免在浮点数上测试相等性
- 当结果在数学上可能存在未定义时,需要格外的小心
来看个小例子:
fn main() { // 断言0.1 + 0.2与0.3相等 assert!(0.1 + 0.2 == 0.3); }
你可能以为,这段代码没啥问题吧,实际上它会 panic(程序崩溃,抛出异常),因为二进制精度问题,导致了 0.1 + 0.2 并不严格等于 0.3,它们可能在小数点 N 位后存在误差。
那如果非要进行比较呢?可以考虑用这种方式 (0.1_f64 + 0.2 - 0.3).abs() < 0.00001
,具体小于多少,取决于你对精度的需求。
讲到这里,相信大家基本已经明白了,为什么操作浮点数时要格外的小心,但是还不够,下面再来一段代码,直接震撼你的灵魂:
fn main() { let abc: (f32, f32, f32) = (0.1, 0.2, 0.3); let xyz: (f64, f64, f64) = (0.1, 0.2, 0.3); println!("abc (f32)"); println!(" 0.1 + 0.2: {:x}", (abc.0 + abc.1).to_bits()); println!(" 0.3: {:x}", (abc.2).to_bits()); println!(); println!("xyz (f64)"); println!(" 0.1 + 0.2: {:x}", (xyz.0 + xyz.1).to_bits()); println!(" 0.3: {:x}", (xyz.2).to_bits()); println!(); assert!(abc.0 + abc.1 == abc.2); assert!(xyz.0 + xyz.1 == xyz.2); }
运行该程序,输出如下:
abc (f32)
0.1 + 0.2: 3e99999a
0.3: 3e99999a
xyz (f64)
0.1 + 0.2: 3fd3333333333334
0.3: 3fd3333333333333
thread 'main' panicked at 'assertion failed: xyz.0 + xyz.1 == xyz.2',
➥ch2-add-floats.rs.rs:14:5
note: run with `RUST_BACKTRACE=1` environment variable to display
➥a backtrace
仔细看,对 f32
类型做加法时,0.1 + 0.2
的结果是 3e99999a
,0.3
也是 3e99999a
,因此 f32
下的 0.1 + 0.2 == 0.3
通过测试,但是到了 f64
类型时,结果就不一样了,因为 f64
精度高很多,因此在小数点非常后面发生了一点微小的变化,0.1 + 0.2
以 4
结尾,但是 0.3
以3
结尾,这个细微区别导致 f64
下的测试失败了,并且抛出了异常。
是不是blow your mind away? 没关系,在本书的后续章节中类似的直击灵魂的地方还很多,这就是敢号称 Rust语言圣经(Rust Course)
的底气!
NaN
对于数学上未定义的结果,例如对负数取平方根 -42.1.sqrt()
,会产生一个特殊的结果:Rust 的浮点数类型使用 NaN
(not a number)来处理这些情况。
所有跟 NaN
交互的操作,都会返回一个 NaN
,而且 NaN
不能用来比较,下面的代码会崩溃:
fn main() { let x = (-42.0_f32).sqrt(); assert_eq!(x, x); }
出于防御性编程的考虑,可以使用 is_nan()
等方法,可以用来判断一个数值是否是 NaN
:
fn main() { let x = (-42.0_f32).sqrt(); if x.is_nan() { println!("未定义的数学行为") } }
数字运算
Rust 支持所有数字类型的基本数学运算:加法、减法、乘法、除法和取模运算。下面代码各使用一条 let
语句来说明相应运算的用法:
fn main() { // 加法 let sum = 5 + 10; // 减法 let difference = 95.5 - 4.3; // 乘法 let product = 4 * 30; // 除法 let quotient = 56.7 / 32.2; // 求余 let remainder = 43 % 5; }
这些语句中的每个表达式都使用了数学运算符,并且计算结果为一个值,然后绑定到一个变量上。附录 B 中给出了 Rust 提供的所有运算符的列表。
再来看一个综合性的示例:
fn main() { // 编译器会进行自动推导,给予twenty i32的类型 let twenty = 20; // 类型标注 let twenty_one: i32 = 21; // 通过类型后缀的方式进行类型标注:22是i32类型 let twenty_two = 22i32; // 只有同样类型,才能运算 let addition = twenty + twenty_one + twenty_two; println!("{} + {} + {} = {}", twenty, twenty_one, twenty_two, addition); // 对于较长的数字,可以用_进行分割,提升可读性 let one_million: i64 = 1_000_000; println!("{}", one_million.pow(2)); // 定义一个f32数组,其中42.0会自动被推导为f32类型 let forty_twos = [ 42.0, 42f32, 42.0_f32, ]; // 打印数组中第一个值,并控制小数位为2位 println!("{:.2}", forty_twos[0]); }
位运算
Rust的位运算基本上和其他语言一样
运算符 | 说明 |
---|---|
& 位与 | 相同位置均为1时则为1,否则为0 |
| 位或 | 相同位置只要有1时则为1,否则为0 |
^ 异或 | 相同位置不相同则为1,相同则为0 |
! 位非 | 把位中的0和1相互取反,即0置为1,1置为0 |
<< 左移 | 所有位向左移动指定位数,右位补0 |
>> 右移 | 所有位向右移动指定位数,带符号移动(正数补0,负数补1) |
fn main() { // 二进制为00000010 let a:i32 = 2; // 二进制为00000011 let b:i32 = 3; println!("(a & b) value is {}", a & b); println!("(a | b) value is {}", a | b); println!("(a ^ b) value is {}", a ^ b); println!("(!b) value is {} ", !b); println!("(a << b) value is {}", a << b); println!("(a >> b) value is {}", a >> b); let mut a = a; // 注意这些计算符除了!之外都可以加上=进行赋值 (因为!=要用来判断不等于) a <<= b; println!("(a << b) value is {}", a); }
序列(Range)
Rust 提供了一个非常简洁的方式,用来生成连续的数值,例如 1..5
,生成从 1 到 4 的连续数字,不包含 5 ;1..=5
,生成从 1 到 5 的连续数字,包含 5,它的用途很简单,常常用于循环中:
#![allow(unused)] fn main() { for i in 1..=5 { println!("{}",i); } }
最终程序输出:
1
2
3
4
5
序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。如下是一个使用字符类型序列的例子:
#![allow(unused)] fn main() { for i in 'a'..='z' { println!("{}",i); } }
使用 As 完成类型转换
Rust 中可以使用 As 来完成一个类型到另一个类型的转换,其最常用于将原始类型转换为其他原始类型,但是它也可以完成诸如将指针转换为地址、地址转换为指针以及将指针转换为其他指针等功能。你可以在这里了解更多相关的知识。
有理数和复数
Rust 的标准库相比其它语言,准入门槛较高,因此有理数和复数并未包含在标准库中:
- 有理数和复数
- 任意大小的整数和任意精度的浮点数
- 固定精度的十进制小数,常用于货币相关的场景
好在社区已经开发出高质量的 Rust 数值库:num。
按照以下步骤来引入 num
库:
- 创建新工程
cargo new complex-num && cd complex-num
- 在
Cargo.toml
中的[dependencies]
下添加一行num = "0.4.0"
- 将
src/main.rs
文件中的main
函数替换为下面的代码 - 运行
cargo run
use num::complex::Complex; fn main() { let a = Complex { re: 2.1, im: -1.2 }; let b = Complex::new(11.1, 22.2); let result = a + b; println!("{} + {}i", result.re, result.im) }
总结
之前提到了过 Rust 的数值类型和运算跟其他语言较为相似,但是实际上,除了语法上的不同之外,还是存在一些差异点:
- Rust 拥有相当多的数值类型. 因此你需要熟悉这些类型所占用的字节数,这样就知道该类型允许的大小范围以及你选择的类型是否能表达负数
- 类型转换必须是显式的. Rust 永远也不会偷偷把你的 16bit 整数转换成 32bit 整数
- Rust 的数值上可以使用方法. 例如你可以用以下方法来将
13.14
取整:13.14_f32.round()
,在这里我们使用了类型后缀,因为编译器需要知道13.14
的具体类型
数值类型的讲解已经基本结束,接下来,来看看字符和布尔类型。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
字符、布尔、单元类型
这三个类型所处的地位比较尴尬,你说它们重要吧,确实出现的身影不是很多,说它们不重要吧,有时候也是不可或缺,而且这三个类型都有一个共同点:简单,因此我们统一放在一起讲。
字符类型(char)
字符,对于没有其它编程经验的新手来说可能不太好理解(没有编程经验敢来学 Rust 的绝对是好汉),但是你可以把它理解为英文中的字母,中文中的汉字。
下面的代码展示了几个颇具异域风情的字符:
fn main() { let c = 'z'; let z = 'ℤ'; let g = '国'; let heart_eyed_cat = '😻'; }
如果大家是从有年代感的编程语言过来,可能会大喊一声:这 XX 叫字符?是的,在 Rust 语言中这些都是字符,Rust 的字符不仅仅是 ASCII
,所有的 Unicode
值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode
值的范围从 U+0000 ~ U+D7FF
和 U+E000 ~ U+10FFFF
。不过“字符”并不是 Unicode
中的一个概念,所以人在直觉上对“字符”的理解和 Rust 的字符概念并不一致。
由于 Unicode
都是 4 个字节编码,因此字符类型也是占用 4 个字节:
fn main() { let x = '中'; println!("字符'中'占用了{}字节的内存大小",std::mem::size_of_val(&x)); }
输出如下:
$ cargo run
Compiling ...
字符'中'占用了4字节的内存大小
注意,我们还没开始讲字符串,但是这里提前说一下,和一些语言不同,Rust 的字符只能用
''
来表示,""
是留给字符串的。
布尔(bool)
Rust 中的布尔类型有两个可能的值:true
和 false
,布尔值占用内存的大小为 1
个字节:
fn main() { let t = true; let f: bool = false; // 使用类型标注,显式指定f的类型 if f { println!("这是段毫无意义的代码"); } }
使用布尔类型的场景主要在于流程控制,例如上述代码的中的 if
就是其中之一。
单元类型
单元类型就是 ()
,对,你没看错,就是 ()
,唯一的值也是 ()
,一些读者读到这里可能就不愿意了,你也太敷衍了吧,管这叫类型?
只能说,再不起眼的东西,都有其用途,在目前为止的学习过程中,大家已经看到过很多次 fn main()
函数的使用吧?那么这个函数返回什么呢?
没错, main
函数就返回这个单元类型 ()
,你不能说 main
函数无返回值,因为没有返回值的函数在 Rust 中是有单独的定义的:发散函数( diverge function )
,顾名思义,无法收敛的函数。
例如常见的 println!()
的返回值也是单元类型 ()
。
再比如,你可以用 ()
作为 map
的值,表示我们不关注具体的值,只关注 key
。 这种用法和 Go 语言的 struct{} 类似,可以作为一个值用来占位,但是完全不占用任何内存。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
语句和表达式
Rust 的函数体是由一系列语句组成,最后由一个表达式来返回值,例如:
#![allow(unused)] fn main() { fn add_with_extra(x: i32, y: i32) -> i32 { let x = x + 1; // 语句 let y = y + 5; // 语句 x + y // 表达式 } }
语句会执行一些操作但是不会返回一个值,而表达式会在求值后返回一个值,因此在上述函数体的三行代码中,前两行是语句,最后一行是表达式。
对于 Rust 语言而言,这种基于语句(statement)和表达式(expression)的方式是非常重要的,你需要能明确的区分这两个概念, 但是对于很多其它语言而言,这两个往往无需区分。基于表达式是函数式语言的重要特征,表达式总要返回值。
其实,在此之前,我们已经多次使用过语句和表达式。
语句
#![allow(unused)] fn main() { let a = 8; let b: Vec<f64> = Vec::new(); let (a, c) = ("hi", false); }
以上都是语句,它们完成了一个具体的操作,但是并没有返回值,因此是语句。
由于 let
是语句,因此不能将 let
语句赋值给其它值,如下形式是错误的:
#![allow(unused)] fn main() { let b = (let a = 8); }
错误如下:
error: expected expression, found statement (`let`) // 期望表达式,却发现`let`语句
--> src/main.rs:2:13
|
2 | let b = let a = 8;
| ^^^^^^^^^
|
= note: variable declaration using `let` is a statement `let`是一条语句
error[E0658]: `let` expressions in this position are experimental
// 下面的 `let` 用法目前是试验性的,在稳定版中尚不能使用
--> src/main.rs:2:13
|
2 | let b = let a = 8;
| ^^^^^^^^^
|
= note: see issue #53667 <https://github.com/rust-lang/rust/issues/53667> for more information
= help: you can write `matches!(<expr>, <pattern>)` instead of `let <pattern> = <expr>`
以上的错误告诉我们 let
是语句,不是表达式,因此它不返回值,也就不能给其它变量赋值。但是该错误还透漏了一个重要的信息, let
作为表达式已经是试验功能了,也许不久的将来,我们在 stable rust
下可以这样使用。
表达式
表达式会进行求值,然后返回一个值。例如 5 + 6
,在求值后,返回值 11
,因此它就是一条表达式。
表达式可以成为语句的一部分,例如 let y = 6
中,6
就是一个表达式,它在求值后返回一个值 6
(有些反直觉,但是确实是表达式)。
调用一个函数是表达式,因为会返回一个值,调用宏也是表达式,用花括号包裹最终返回一个值的语句块也是表达式,总之,能返回值,它就是表达式:
fn main() { let y = { let x = 3; x + 1 }; println!("The value of y is: {}", y); }
上面使用一个语句块表达式将值赋给 y
变量,语句块长这样:
#![allow(unused)] fn main() { { let x = 3; x + 1 } }
该语句块是表达式的原因是:它的最后一行是表达式,返回了 x + 1
的值,注意 x + 1
不能以分号结尾,否则就会从表达式变成语句, 表达式不能包含分号。这一点非常重要,一旦你在表达式后加上分号,它就会变成一条语句,再也不会返回一个值,请牢记!
最后,表达式如果不返回任何值,会隐式地返回一个 ()
。
fn main() { assert_eq!(ret_unit_type(), ()) } fn ret_unit_type() { let x = 1; // if 语句块也是一个表达式,因此可以用于赋值,也可以直接返回 // 类似三元运算符,在Rust里我们可以这样写 let y = if x % 2 == 1 { "odd" } else { "even" }; // 或者写成一行 let z = if x % 2 == 1 { "odd" } else { "even" }; }
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
函数
Rust 的函数我们在之前已经见过不少,跟其他语言几乎没有什么区别。因此本章的学习之路将轻松和愉快,骚年们,请珍惜这种愉快,下一章你将体验到不一样的 Rust。
在函数界,有一个函数只闻其名不闻其声,可以止小孩啼!在程序界只有 hello,world!
可以与之媲美,它就是 add
函数:
#![allow(unused)] fn main() { fn add(i: i32, j: i32) -> i32 { i + j } }
该函数如此简单,但是又是如此的五脏俱全,声明函数的关键字 fn
,函数名 add()
,参数 i
和 j
,参数类型和返回值类型都是 i32
,总之一切那么的普通,但是又那么的自信,直到你看到了下面这张图:

当你看懂了这张图,其实就等于差不多完成了函数章节的学习,但是这么短的章节显然对不起读者老爷们的厚爱,所以我们来展开下。
函数要点
- 函数名和变量名使用蛇形命名法(snake case),例如
fn add_two() -> {}
- 函数的位置可以随便放,Rust 不关心我们在哪里定义了函数,只要有定义即可
- 每个函数参数都需要标注类型
函数参数
Rust 是强类型语言,因此需要你为每一个函数参数都标识出它的具体类型,例如:
fn main() { another_function(5, 6.1); } fn another_function(x: i32, y: f32) { println!("The value of x is: {}", x); println!("The value of y is: {}", y); }
another_function
函数有两个参数,其中 x
是 i32
类型,y
是 f32
类型,然后在该函数内部,打印出这两个值。这里去掉 x
或者 y
的任何一个的类型,都会报错:
fn main() { another_function(5, 6.1); } fn another_function(x: i32, y) { println!("The value of x is: {}", x); println!("The value of y is: {}", y); }
错误如下:
error: expected one of `:`, `@`, or `|`, found `)`
--> src/main.rs:5:30
|
5 | fn another_function(x: i32, y) {
| ^ expected one of `:`, `@`, or `|` // 期待以下符号之一 `:`, `@`, or `|`
|
= note: anonymous parameters are removed in the 2018 edition (see RFC 1685)
// 匿名参数在 Rust 2018 edition 中就已经移除
help: if this is a parameter name, give it a type // 如果y是一个参数名,请给予它一个类型
|
5 | fn another_function(x: i32, y: TypeName) {
| ~~~~~~~~~~~
help: if this is a type, explicitly ignore the parameter name // 如果y是一个类型,请使用_忽略参数名
|
5 | fn another_function(x: i32, _: y) {
| ~~~~
函数返回
在上一章节语句和表达式中,我们有提到,在 Rust 中函数就是表达式,因此我们可以把函数的返回值直接赋给调用者。
函数的返回值就是函数体最后一条表达式的返回值,当然我们也可以使用 return
提前返回,下面的函数使用最后一条表达式来返回一个值:
fn plus_five(x:i32) -> i32 { x + 5 } fn main() { let x = plus_five(5); println!("The value of x is: {}", x); }
x + 5
是一条表达式,求值后,返回一个值,因为它是函数的最后一行,因此该表达式的值也是函数的返回值。
再来看两个重点:
let x = plus_five(5)
,说明我们用一个函数的返回值来初始化x
变量,因此侧面说明了在 Rust 中函数也是表达式,这种写法等同于let x = 5 + 5;
x + 5
没有分号,因为它是一条表达式,这个在上一节中我们也有详细介绍
再来看一段代码,同时使用 return
和表达式作为返回值:
fn plus_or_minus(x:i32) -> i32 { if x > 5 { return x - 5 } x + 5 } fn main() { let x = plus_or_minus(5); println!("The value of x is: {}", x); }
plus_or_minus
函数根据传入 x
的大小来决定是做加法还是减法,若 x > 5
则通过 return
提前返回 x - 5
的值,否则返回 x + 5
的值。
Rust 中的特殊返回类型
无返回值()
对于 Rust 新手来说,有些返回类型很难理解,而且如果你想通过百度或者谷歌去搜索,都不好查询,因为这些符号太常见了,根本难以精确搜索到。
例如单元类型 ()
,是一个零长度的元组。它没啥作用,但是可以用来表达一个函数没有返回值:
- 函数没有返回值,那么返回一个
()
- 通过
;
结尾的表达式返回一个()
例如下面的 report
函数会隐式返回一个 ()
:
#![allow(unused)] fn main() { use std::fmt::Debug; fn report<T: Debug>(item: T) { println!("{:?}", item); } }
与上面的函数返回值相同,但是下面的函数显式的返回了 ()
:
#![allow(unused)] fn main() { fn clear(text: &mut String) -> () { *text = String::from(""); } }
在实际编程中,你会经常在错误提示中看到该 ()
的身影出没,假如你的函数需要返回一个 u32
值,但是如果你不幸的以 表达式;
的方式作为函数的最后一行代码,就会报错:
#![allow(unused)] fn main() { fn add(x:u32,y:u32) -> u32 { x + y; } }
错误如下:
error[E0308]: mismatched types // 类型不匹配
--> src/main.rs:6:24
|
6 | fn add(x:u32,y:u32) -> u32 {
| --- ^^^ expected `u32`, found `()` // 期望返回u32,却返回()
| |
| implicitly returns `()` as its body has no tail or `return` expression
7 | x + y;
| - help: consider removing this semicolon
还记得我们在语句与表达式中讲过的吗?只有表达式能返回值,而 ;
结尾的是语句,在 Rust 中,一定要严格区分表达式和语句的区别,这个在其它语言中往往是被忽视的点。
永不返回的发散函数 !
当用 !
作函数返回类型的时候,表示该函数永不返回( diverge function ),特别的,这种语法往往用做会导致程序崩溃的函数:
#![allow(unused)] fn main() { fn dead_end() -> ! { panic!("你已经到了穷途末路,崩溃吧!"); } }
下面的函数创建了一个无限循环,该循环永不跳出,因此函数也永不返回:
#![allow(unused)] fn main() { fn forever() -> ! { loop { //... }; } }
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
所有权和借用
Rust 之所以能成为万众瞩目的语言,就是因为其内存安全性。在以往,内存安全几乎都是通过 GC 的方式实现,但是 GC 会引来性能、内存占用以及 Stop the world 等问题,在高性能场景和系统编程上是不可接受的,因此 Rust 采用了与(错)众(误)不(之)同(源)的方式:所有权系统。
理解所有权和借用,对于 Rust 学习是至关重要的,因此我们把本章提到了非常靠前的位置,So,骚年们,准备好迎接狂风暴雨了嘛?
从现在开始,鉴于大家已经掌握了非常基本的语法,有些时候,在示例代码中,将省略 fn main() {}
的模版代码,只要将相应的示例放在 fn main() {}
中,即可运行。
所有权
所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。在计算机语言不断演变过程中,出现了三种流派:
- 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
- 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++
- 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查
其中 Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。
由于所有权是一个新概念,因此读者需要花费一些时间来掌握它,一旦掌握,海阔天空任你飞跃,在本章,我们将通过 字符串
来引导讲解所有权的相关知识。
一段不安全的代码
先来看看一段来自 C 语言的糟糕代码:
int* foo() {
int a; // 变量a的作用域开始
a = 100;
char *c = "xyz"; // 变量c的作用域开始
return &a;
} // 变量a和c的作用域结束
这段代码虽然可以编译通过,但是其实非常糟糕,变量 a
和 c
都是局部变量,函数结束后将局部变量 a
的地址返回,但局部变量 a
存在栈中,在离开作用域后,a
所申请的栈上内存都会被系统回收,从而造成了 悬空指针(Dangling Pointer)
的问题。这是一个非常典型的内存安全问题,虽然编译可以通过,但是运行的时候会出现错误, 很多编程语言都存在。
再来看变量 c
,c
的值是常量字符串,存储于常量区,可能这个函数我们只调用了一次,也可能我们不再会使用这个字符串,但 "xyz"
只有当整个程序结束后系统才能回收这片内存。
所以内存安全问题,一直都是程序员非常头疼的问题,好在, 在 Rust 中这些问题即将成为历史,因为 Rust 在编译的时候就可以帮助我们发现内存不安全的问题,那 Rust 如何做到这一点呢?
在正式进入主题前,先来一个预热知识。
栈(Stack)与堆(Heap)
栈和堆是编程语言最核心的数据结构,但是在很多语言中,你并不需要深入了解栈与堆。 但对于 Rust 这样的系统编程语言,值是位于栈上还是堆上非常重要, 因为这会影响程序的行为和性能。
栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。
栈
栈按照顺序存储值并以相反顺序取出值,这也被称作后进先出。想象一下一叠盘子:当增加更多盘子时,把它们放在盘子堆的顶部,当需要盘子时,再从顶部拿走。不能从中间也不能从底部增加或拿走盘子!
增加数据叫做进栈,移出数据则叫做出栈。
因为上述的实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,你将无法取到你想要的数据。
堆
与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。
当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针, 该过程被称为在堆上分配内存,有时简称为 “分配”(allocating)。
接着,该指针会被推入栈中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。
由上可知,堆是一种缺乏组织的数据结构。想象一下去餐馆就座吃饭: 进入餐馆,告知服务员有几个人,然后服务员找到一个够大的空桌子(堆上分配的内存空间)并领你们过去。如果有人来迟了,他们也可以通过桌号(栈上的指针)来找到你们坐在哪。
性能区别
写入方面:入栈比在堆上分配内存要快,因为入栈时操作系统无需分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备。
读取方面:得益于 CPU 高速缓存,使得处理器可以减少对内存的访问,高速缓存和内存的访问速度差异在 10 倍以上!栈数据往往可以直接存储在 CPU 高速缓存中,而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存。
因此,处理器处理分配在栈上数据会比在堆上的数据更加高效。
所有权与堆栈
当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。
因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。
对于其他很多编程语言,你确实无需理解堆栈的原理,但是在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助。
所有权原则
理解了堆栈,接下来看一下关于所有权的规则,首先请谨记以下规则:
- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
变量作用域
作用域是一个变量在程序中有效的范围, 假如有这样一个变量:
#![allow(unused)] fn main() { let s = "hello"; }
变量 s
绑定到了一个字符串字面值,该字符串字面值是硬编码到程序代码中的。s
变量从声明的点开始直到当前作用域的结束都是有效的:
#![allow(unused)] fn main() { { // s 在这里无效,它尚未声明 let s = "hello"; // 从此处起,s 是有效的 // 使用 s } // 此作用域已结束,s不再有效 }
简而言之,s
从创建开始就有效,然后有效期持续到它离开作用域为止,可以看出,就作用域来说,Rust 语言跟其他编程语言没有区别。
简单介绍 String 类型
之前提到过,本章会用 String
作为例子,因此这里会进行一下简单的介绍,具体的 String
学习请参见 String 类型。
我们已经见过字符串字面值 let s ="hello"
,s
是被硬编码进程序里的字符串值(类型为 &str
)。字符串字面值是很方便的,但是它并不适用于所有场景。原因有二:
- 字符串字面值是不可变的,因为被硬编码到程序代码中
- 并非所有字符串的值都能在编写代码时得知
例如,字符串是需要程序运行时,通过用户动态输入然后存储在内存中的,这种情况,字符串字面值就完全无用武之地。 为此,Rust 为我们提供动态字符串类型: String
, 该类型被分配到堆上,因此可以动态伸缩,也就能存储在编译时大小未知的文本。
可以使用下面的方法基于字符串字面量来创建 String
类型:
#![allow(unused)] fn main() { let s = String::from("hello"); }
::
是一种调用操作符,这里表示调用 String
中的 from
方法,因为 String
存储在堆上是动态的,你可以这样修改它:
#![allow(unused)] fn main() { let mut s = String::from("hello"); s.push_str(", world!"); // push_str() 在字符串后追加字面值 println!("{}", s); // 将打印 `hello, world!` }
言归正传,了解 String
内容后,一起来看看关于所有权的交互。
变量绑定背后的数据交互
转移所有权
先来看一段代码:
#![allow(unused)] fn main() { let x = 5; let y = x; }
代码背后的逻辑很简单, 将 5
绑定到变量 x
;接着拷贝 x
的值赋给 y
,最终 x
和 y
都等于 5
,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。
可能有同学会有疑问:这种拷贝不消耗性能吗?实际上,这种栈上的数据足够简单,而且拷贝非常非常快,只需要复制一个整数大小(i32
,4 个字节)的内存即可,因此在这种情况下,拷贝的速度远比在堆上创建内存来得快的多。实际上,上一章我们讲到的 Rust 基本类型都是通过自动拷贝的方式来赋值的,就像上面代码一样。
然后再来看一段代码:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1; }
此时,可能某个大聪明(善意昵称)已经想到了:嗯,把 s1
的内容拷贝一份赋值给 s2
,实际上,并不是这样。之前也提到了,对于基本类型(存储在栈上),Rust 会自动拷贝,但是 String
不是基本类型,而且是存储在堆上的,因此不能自动拷贝。
实际上, String
类型是一个复杂类型,由存储在栈中的堆指针、字符串长度、字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存,至于长度和容量,如果你有 Go 语言的经验,这里就很好理解:容量是堆内存分配空间的大小,长度是目前已经使用的大小。
总之 String
类型指向了一个堆上的空间,这里存储着它的真实数据,下面对上面代码中的 let s2 = s1
分成两种情况讨论:
-
拷贝
String
和存储在堆上的字节数组 如果该语句是拷贝所有数据(深拷贝),那么无论是String
本身还是底层的堆上数据,都会被全部拷贝,这对于性能而言会造成非常大的影响 -
只拷贝
String
本身 这样的拷贝非常快,因为在 64 位机器上就拷贝了8字节的指针
、8字节的长度
、8字节的容量
,总计 24 字节,但是带来了新的问题,还记得我们之前提到的所有权规则吧?其中有一条就是:一个值只允许有一个所有者,而现在这个值(堆上的真实字符串数据)有了两个所有者:s1
和s2
。
好吧,就假定一个值可以拥有两个所有者,会发生什么呢?
当变量离开作用域后,Rust 会自动调用 drop
函数并清理变量的堆内存。不过由于两个 String
变量指向了同一位置。这就有了一个问题:当 s1
和 s2
离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。
因此,Rust 这样解决问题:当 s1
赋予 s2
后,Rust 认为 s1
不再有效,因此也无需在 s1
离开作用域后 drop
任何东西,这就是把所有权从 s1
转移给了 s2
,s1
在被赋予 s2
后就马上失效了。
再来看看,在所有权转移后再来使用旧的所有者,会发生什么:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); }
由于 Rust 禁止你使用无效的引用,你会看到以下的错误:
error[E0382]: borrow of moved value: `s1`
--> src/main.rs:5:28
|
2 | let s1 = String::from("hello");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("{}, world!", s1);
| ^^ value borrowed here after move
|
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
3 | let s2 = s1.clone();
| ++++++++
For more information about this error, try `rustc --explain E0382`.
现在再回头看看之前的规则,相信大家已经有了更深刻的理解:
- Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
- 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)
如果你在其他语言中听说过术语 浅拷贝(shallow copy) 和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据听起来就像浅拷贝,但是又因为 Rust 同时使第一个变量 s1
无效了,因此这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1
被移动到了 s2
中。那么具体发生了什么,用一张图简单说明:

这样就解决了我们之前的问题,s1
不再指向任何数据,只有 s2
是有效的,当 s2
离开作用域,它就会释放内存。 相信此刻,你应该明白了,为什么 Rust 称呼 let a = b
为变量绑定了吧?
再来看一段代码:
fn main() { let x: &str = "hello, world"; let y = x; println!("{},{}",x,y); }
这段代码,大家觉得会否报错?如果参考之前的 String
所有权转移的例子,那这段代码也应该报错才是,但是实际上呢?
这段代码和之前的 String
有一个本质上的区别:在 String
的例子中 s1
持有了通过String::from("hello")
创建的值的所有权,而这个例子中,x
只是引用了存储在二进制中的字符串 "hello, world"
,并没有持有所有权。
因此 let y = x
中,仅仅是对该引用进行了拷贝,此时 y
和 x
都引用了同一个字符串。如果还不理解也没关系,当学习了下一章节 "引用与借用" 后,大家自然而言就会理解。
克隆(深拷贝)
首先,Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。
如果我们确实需要深度复制 String
中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone
的方法。
#![allow(unused)] fn main() { let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2); }
这段代码能够正常运行,因此说明 s2
确实完整的复制了 s1
的数据。
如果代码性能无关紧要,例如初始化程序时,或者在某段时间只会执行一次时,你可以使用 clone
来简化编程。但是对于执行较为频繁的代码(热点路径),使用 clone
会极大的降低程序性能,需要小心使用!
拷贝(浅拷贝)
浅拷贝只发生在栈上,因此性能很高,在日常编程中,浅拷贝无处不在。
再回到之前看过的例子:
#![allow(unused)] fn main() { let x = 5; let y = x; println!("x = {}, y = {}", x, y); }
但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone
,不过依然实现了类似深拷贝的效果 —— 没有报所有权的错误。
原因是像整型这样的基本类型在编译时是已知大小的,会被存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y
后使 x
无效(x
、y
都仍然有效)。换句话说,这里没有深浅拷贝的区别,因此这里调用 clone
并不会与通常的浅拷贝有什么不同,我们可以不用管它(可以理解成在栈上做了深拷贝)。
Rust 有一个叫做 Copy
的特征,可以用在类似整型这样在栈中存储的类型。如果一个类型拥有 Copy
特征,一个旧的变量在被赋值给其他变量后仍然可用。
那么什么类型是可 Copy
的呢?可以查看给定类型的文档来确认,不过作为一个通用的规则: 任何基本类型的组合可以 Copy
,不需要分配内存或某种形式资源的类型是可以 Copy
的。如下是一些 Copy
的类型:
- 所有整数类型,比如
u32
- 布尔类型,
bool
,它的值是true
和false
- 所有浮点数类型,比如
f64
- 字符类型,
char
- 元组,当且仅当其包含的类型也都是
Copy
的时候。比如,(i32, i32)
是Copy
的,但(i32, String)
就不是 - 不可变引用
&T
,例如转移所有权中的最后一个例子,但是注意: 可变引用&mut T
是不可以 Copy的
函数传值与返回
将值传递给函数,一样会发生 移动
或者 复制
,就跟 let
语句一样,下面的代码展示了所有权、作用域的规则:
fn main() { let s = String::from("hello"); // s 进入作用域 takes_ownership(s); // s 的值移动到函数里 ... // ... 所以到这里不再有效 let x = 5; // x 进入作用域 makes_copy(x); // x 应该移动函数里, // 但 i32 是 Copy 的,所以在后面可继续使用 x } // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走, // 所以不会有特殊操作 fn takes_ownership(some_string: String) { // some_string 进入作用域 println!("{}", some_string); } // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放 fn makes_copy(some_integer: i32) { // some_integer 进入作用域 println!("{}", some_integer); } // 这里,some_integer 移出作用域。不会有特殊操作
你可以尝试在 takes_ownership
之后,再使用 s
,看看如何报错?例如添加一行 println!("在move进函数后继续使用s: {}",s);
。
同样的,函数返回值也有所有权,例如:
fn main() { let s1 = gives_ownership(); // gives_ownership 将返回值 // 移给 s1 let s2 = String::from("hello"); // s2 进入作用域 let s3 = takes_and_gives_back(s2); // s2 被移动到 // takes_and_gives_back 中, // 它也将返回值移给 s3 } // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走, // 所以什么也不会发生。s1 移出作用域并被丢弃 fn gives_ownership() -> String { // gives_ownership 将返回值移动给 // 调用它的函数 let some_string = String::from("hello"); // some_string 进入作用域. some_string // 返回 some_string 并移出给调用的函数 } // takes_and_gives_back 将传入字符串并返回该值 fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域 a_string // 返回 a_string 并移出给调用的函数 }
所有权很强大,避免了内存的不安全性,但是也带来了一个新麻烦: 总是把一个值传来传去来使用它。 传入一个函数,很可能还要从该函数传出去,结果就是语言表达变得非常啰嗦,幸运的是,Rust 提供了新功能解决这个问题。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
引用与借用
上节中提到,如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。 Rust 能否像其它编程语言一样,使用某个变量的指针或者引用呢?答案是可以。
Rust 通过 借用(Borrowing)
这个概念来达成上述的目的,获取变量的引用,称之为借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来,当使用完毕后,也必须要物归原主。
引用与解引用
常规引用是一个指针类型,指向了对象存储的内存地址。在下面代码中,我们创建一个 i32
值的引用 y
,然后使用解引用运算符来解出 y
所使用的值:
fn main() { let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y); }
变量 x
存放了一个 i32
值 5
。y
是 x
的一个引用。可以断言 x
等于 5
。然而,如果希望对 y
的值做出断言,必须使用 *y
来解出引用所指向的值(也就是解引用)。一旦解引用了 y
,就可以访问 y
所指向的整型值并可以与 5
做比较。
相反如果尝试编写 assert_eq!(5, y);
,则会得到如下编译错误:
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}` // 无法比较整数类型和引用类型
|
= help: the trait `std::cmp::PartialEq<&{integer}>` is not implemented for
`{integer}`
不允许比较整数与引用,因为它们是不同的类型。必须使用解引用运算符解出引用所指向的值。
不可变引用
下面的代码,我们用 s1
的引用作为参数传递给 calculate_length
函数,而不是把 s1
的所有权转移给该函数:
fn main() { let s1 = String::from("hello"); let len = calculate_length(&s1); println!("The length of '{}' is {}.", s1, len); } fn calculate_length(s: &String) -> usize { s.len() }
能注意到两点:
- 无需像上章一样:先通过函数参数传入所有权,然后再通过函数返回来传出所有权,代码更加简洁
calculate_length
的参数s
类型从String
变为&String
这里,&
符号即是引用,它们允许你使用值,但是不获取所有权,如图所示:
通过 &s1
语法,我们创建了一个指向 s1
的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。
同理,函数 calculate_length
使用 &
来表明参数 s
的类型是一个引用:
#![allow(unused)] fn main() { fn calculate_length(s: &String) -> usize { // s 是对 String 的引用 s.len() } // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权, // 所以什么也不会发生 }
人总是贪心的,可以拉女孩小手了,就想着抱抱柔软的身子(读者中的某老司机表示,这个流程完全不对),因此光借用已经满足不了我们了,如果尝试修改借用的变量呢?
fn main() { let s = String::from("hello"); change(&s); } fn change(some_string: &String) { some_string.push_str(", world"); }
很不幸,妹子你没抱到,哦口误,你修改错了:
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src/main.rs:8:5
|
7 | fn change(some_string: &String) {
| ------- help: consider changing this to be a mutable reference: `&mut String`
------- 帮助:考虑将该参数类型修改为可变的引用: `&mut String`
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
`some_string`是一个`&`类型的引用,因此它指向的数据无法进行修改
正如变量默认不可变一样,引用指向的值默认也是不可变的,没事,来一起看看如何解决这个问题。
可变引用
只需要一个小调整,即可修复上面代码的错误:
fn main() { let mut s = String::from("hello"); change(&mut s); } fn change(some_string: &mut String) { some_string.push_str(", world"); }
首先,声明 s
是可变类型,其次创建一个可变的引用 &mut s
和接受可变引用参数 some_string: &mut String
的函数。
可变引用同时只能存在一个
不过可变引用并不是随心所欲、想用就用的,它有一个很大的限制: 同一作用域,特定数据只能有一个可变引用:
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }
以上代码会报错:
error[E0499]: cannot borrow `s` as mutable more than once at a time 同一时间无法对 `s` 进行两次可变借用
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here 首个可变引用在这里借用
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here 第二个可变引用在这里借用
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here 第一个借用在这里使用
这段代码出错的原因在于,第一个可变借用 r1
必须要持续到最后一次使用的位置 println!
,在 r1
创建和最后一次使用之间,我们又尝试创建第二个可变借用 r2
。
对于新手来说,这个特性绝对是一大拦路虎,也是新人们谈之色变的编译器 borrow checker
特性之一,不过各行各业都一样,限制往往是出于安全的考虑,Rust 也一样。
这种限制的好处就是使 Rust 在编译期就避免数据竞争,数据竞争可由以下行为造成:
- 两个或更多的指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。而 Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
很多时候,大括号可以帮我们解决一些编译不通过的问题,通过手动限制变量的作用域:
#![allow(unused)] fn main() { let mut s = String::from("hello"); { let r1 = &mut s; } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用 let r2 = &mut s; }
可变引用与不可变引用不能同时存在
下面的代码会导致一个错误:
#![allow(unused)] fn main() { let mut s = String::from("hello"); let r1 = &s; // 没问题 let r2 = &s; // 没问题 let r3 = &mut s; // 大问题 println!("{}, {}, and {}", r1, r2, r3); }
错误如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
// 无法借用可变 `s` 因为它已经被借用了不可变
--> src/main.rs:6:14
|
4 | let r1 = &s; // 没问题
| -- immutable borrow occurs here 不可变借用发生在这里
5 | let r2 = &s; // 没问题
6 | let r3 = &mut s; // 大问题
| ^^^^^^ mutable borrow occurs here 可变借用发生在这里
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here 不可变借用在这里使用
其实这个也很好理解,正在借用不可变引用的用户,肯定不希望他借用的东西,被另外一个人莫名其妙改变了。多个不可变借用被允许是因为没有人会去试图修改数据,每个人都只读这一份数据而不做修改,因此不用担心数据被污染。
注意,引用的作用域
s
从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号}
Rust 的编译器一直在优化,早期的时候,引用的作用域跟变量作用域是一致的,这对日常使用带来了很大的困扰,你必须非常小心的去安排可变、不可变变量的借用,免得无法通过编译,例如以下代码:
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} and {}", r1, r2); // 新编译器中,r1,r2作用域在这里结束 let r3 = &mut s; println!("{}", r3); } // 老编译器中,r1、r2、r3作用域在这里结束 // 新编译器中,r3作用域在这里结束
在老版本的编译器中(Rust 1.31 前),将会报错,因为 r1
和 r2
的作用域在花括号 }
处结束,那么 r3
的借用就会触发 无法同时借用可变和不可变的规则。
但是在新的编译器中,该代码将顺利通过,因为 引用作用域的结束位置从花括号变成最后一次使用的位置,因此 r1
借用和 r2
借用在 println!
后,就结束了,此时 r3
可以顺利借用到可变引用。
NLL
对于这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(}
)结束前就不再被使用的代码位置。
虽然这种借用错误有的时候会让我们很郁闷,但是你只要想想这是 Rust 提前帮你发现了潜在的 BUG,其实就开心了,虽然减慢了开发速度,但是从长期来看,大幅减少了后续开发和运维成本。
悬垂引用(Dangling References)
悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。
让我们尝试创建一个悬垂引用,Rust 会抛出一个编译时错误:
fn main() { let reference_to_nothing = dangle(); } fn dangle() -> &String { let s = String::from("hello"); &s }
这里是错误:
error[E0106]: missing lifetime specifier
--> src/main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| ~~~~~~~~
错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。不过,即使你不理解生命周期,也可以通过错误信息知道这段代码错误的关键信息:
this function's return type contains a borrowed value, but there is no value for it to be borrowed from.
该函数返回了一个借用的值,但是已经找不到它所借用值的来源
仔细看看 dangle
代码的每一步到底发生了什么:
#![allow(unused)] fn main() { fn dangle() -> &String { // dangle 返回一个字符串的引用 let s = String::from("hello"); // s 是一个新字符串 &s // 返回字符串 s 的引用 } // 这里 s 离开作用域并被丢弃。其内存被释放。 // 危险! }
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放,但是此时我们又尝试去返回它的引用。这意味着这个引用会指向一个无效的 String
,这可不对!
其中一个很好的解决方法是直接返回 String
:
#![allow(unused)] fn main() { fn no_dangle() -> String { let s = String::from("hello"); s } }
这样就没有任何错误了,最终 String
的 所有权被转移给外面的调用者。
借用规则总结
总的来说,借用规则如下:
- 同一时刻,你只能拥有要么一个可变引用, 要么任意多个不可变引用
- 引用必须总是有效的
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
复合类型
行百里者半九十,欢迎大家来到这里,虽然还不到中点,但是已经不远了。如果说之前学的基础数据类型是原子,那么本章将讲的数据类型可以认为是分子。
本章的重点在复合类型上,顾名思义,复合类型是由其它类型组合而成的,最典型的就是结构体 struct
和枚举 enum
。例如平面上的一个点 point(x, y)
,它由两个数值类型的值 x
和 y
组合而来。我们无法单独去维护这两个数值,因为单独一个 x
或者 y
是含义不完整的,无法标识平面上的一个点,应该把它们看作一个整体去理解和处理。
来看一段代码,它使用我们之前学过的内容来构建文件操作:
#![allow(unused_variables)] type File = String; fn open(f: &mut File) -> bool { true } fn close(f: &mut File) -> bool { true } #[allow(dead_code)] fn read(f: &mut File, save_to: &mut Vec<u8>) -> ! { unimplemented!() } fn main() { let mut f1 = File::from("f1.txt"); open(&mut f1); //read(&mut f1, &mut vec![]); close(&mut f1); }
接下来我们的学习非常类似原型设计:有的方法只提供 API 接口,但是不提供具体实现。此外,有的变量在声明之后并未使用,因此在这个阶段我们需要排除一些编译器噪音(Rust 在编译的时候会扫描代码,变量声明后未使用会以 warning
警告的形式进行提示),引入 #![allow(unused_variables)]
属性标记,该标记会告诉编译器忽略未使用的变量,不要抛出 warning
警告,具体的常见编译器属性你可以在这里查阅:编译器属性标记。
read
函数也非常有趣,它返回一个 !
类型,这个表明该函数是一个发散函数,不会返回任何值,包括 ()
。unimplemented!()
告诉编译器该函数尚未实现,unimplemented!()
标记通常意味着我们期望快速完成主要代码,回头再通过搜索这些标记来完成次要代码,类似的标记还有 todo!()
,当代码执行到这种未实现的地方时,程序会直接报错。你可以反注释 read(&mut f1, &mut vec![]);
这行,然后再观察下结果。
同时,从代码设计角度来看,关于文件操作的类型和函数应该组织在一起,散落得到处都是,是难以管理和使用的。而且通过 open(&mut f1)
进行调用,也远没有使用 f1.open()
来调用好,这就体现出了只使用基本类型的局限性:无法从更高的抽象层次去简化代码。
接下来,我们将引入一个高级数据结构 —— 结构体 struct
,来看看复合类型是怎样更好的解决这类问题。 开始之前,先来看看 Rust 的重点也是难点:字符串 String
和 &str
。
字符串
在其他语言中,字符串往往是送分题,因为实在是太简单了,例如 "hello, world"
就是字符串章节的几乎全部内容了,但是如果你带着同样的想法来学 Rust,我保证,绝对会栽跟头,因此这一章大家一定要重视,仔细阅读,这里有很多其它 Rust 书籍中没有的内容。
首先来看段很简单的代码:
fn main() { let my_name = "Pascal"; greet(my_name); } fn greet(name: String) { println!("Hello, {}!", name); }
greet
函数接受一个字符串类型的 name
参数,然后打印到终端控制台中,非常好理解,你们猜猜,这段代码能否通过编译?
error[E0308]: mismatched types
--> src/main.rs:3:11
|
3 | greet(my_name);
| ^^^^^^^
| |
| expected struct `std::string::String`, found `&str`
| help: try using a conversion method: `my_name.to_string()`
error: aborting due to previous error
Bingo,果然报错了,编译器提示 greet
函数需要一个 String
类型的字符串,却传入了一个 &str
类型的字符串,相信读者心中现在一定有几头草泥马呼啸而过,怎么字符串也能整出这么多花活?
在讲解字符串之前,先来看看什么是切片?
切片(slice)
切片并不是 Rust 独有的概念,在 Go 语言中就非常流行,它允许你引用集合中部分连续的元素序列,而不是引用整个集合。
对于字符串而言,切片就是对 String
类型中某一部分的引用,它看起来像这样:
#![allow(unused)] fn main() { let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11]; }
hello
没有引用整个 String s
,而是引用了 s
的一部分内容,通过 [0..5]
的方式来指定。
这就是创建切片的语法,使用方括号包括的一个序列:[开始索引..终止索引],其中开始索引是切片中第一个元素的索引位置,而终止索引是最后一个元素后面的索引位置,也就是这是一个 右半开区间
。在切片数据结构内部会保存开始的位置和切片的长度,其中长度是通过 终止索引
- 开始索引
的方式计算得来的。
对于 let world = &s[6..11];
来说,world
是一个切片,该切片的指针指向 s
的第 7 个字节(索引从 0 开始, 6 是第 7 个字节),且该切片的长度是 5
个字节。

在使用 Rust 的 ..
range 序列语法时,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:
#![allow(unused)] fn main() { let s = String::from("hello"); let slice = &s[0..2]; let slice = &s[..2]; }
同样的,如果你的切片想要包含 String
的最后一个字节,则可以这样使用:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[4..len]; let slice = &s[4..]; }
你也可以截取完整的 String
切片:
#![allow(unused)] fn main() { let s = String::from("hello"); let len = s.len(); let slice = &s[0..len]; let slice = &s[..]; }
在对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界,例如中文在 UTF-8 中占用三个字节,下面的代码就会崩溃:
#![allow(unused)] fn main() { let s = "中国人"; let a = &s[0..2]; println!("{}",a); }
因为我们只取
s
字符串的前两个字节,但是本例中每个汉字占用三个字节,因此没有落在边界处,也就是连中
字都取不完整,此时程序会直接崩溃退出,如果改成&s[0..3]
,则可以正常通过编译。 因此,当你需要对字符串做切片索引操作时,需要格外小心这一点, 关于该如何操作 UTF-8 字符串,参见这里。
字符串切片的类型标识是 &str
,因此我们可以这样声明一个函数,输入 String
类型,返回它的切片: fn first_word(s: &String) -> &str
。
有了切片就可以写出这样的代码:
fn main() { let mut s = String::from("hello world"); let word = first_word(&s); s.clear(); // error! println!("the first word is: {}", word); } fn first_word(s: &String) -> &str { &s[..1] }
编译器报错如下:
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src/main.rs:18:5
|
16 | let word = first_word(&s);
| -- immutable borrow occurs here
17 |
18 | s.clear(); // error!
| ^^^^^^^^^ mutable borrow occurs here
19 |
20 | println!("the first word is: {}", word);
| ---- immutable borrow later used here
回忆一下借用的规则:当我们已经有了可变借用时,就无法再拥有不可变的借用。因为 clear
需要清空改变 String
,因此它需要一个可变借用(利用 VSCode 可以看到该方法的声明是 pub fn clear(&mut self)
,参数是对自身的可变借用 );而之后的 println!
又使用了不可变借用,也就是在 s.clear()
处可变借用与不可变借用试图同时生效,因此编译无法通过。
从上述代码可以看出,Rust 不仅让我们的 API
更加容易使用,而且也在编译期就消除了大量错误!
其它切片
因为切片是对集合的部分引用,因此不仅仅字符串有切片,其它集合类型也有,例如数组:
#![allow(unused)] fn main() { let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]); }
该数组切片的类型是 &[i32]
,数组切片和字符串切片的工作方式是一样的,例如持有一个引用指向原始数组的某个元素和长度。
字符串字面量是切片
之前提到过字符串字面量,但是没有提到它的类型:
#![allow(unused)] fn main() { let s = "Hello, world!"; }
实际上,s
的类型是 &str
,因此你也可以这样声明:
#![allow(unused)] fn main() { let s: &str = "Hello, world!"; }
该切片指向了程序可执行文件中的某个点,这也是为什么字符串字面量是不可变的,因为 &str
是一个不可变引用。
了解完切片,可以进入本节的正题了。
什么是字符串?
顾名思义,字符串是由字符组成的连续集合,但是在上一节中我们提到过,Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间。
Rust 在语言级别,只有一种字符串类型: str
,它通常是以引用类型出现 &str
,也就是上文提到的字符串切片。虽然语言级别只有上述的 str
类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String
类型。
str
类型是硬编码进可执行文件,也无法被修改,但是 String
则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String
类型和 &str
字符串切片类型,这两个类型都是 UTF-8 编码。
除了 String
类型的字符串,Rust 的标准库还提供了其他类型的字符串,例如 OsString
, OsStr
, CsString
和 CsStr
等,注意到这些名字都以 String
或者 Str
结尾了吗?它们分别对应的是具有所有权和被借用的变量。
String 与 &str 的转换
在之前的代码中,已经见到好几种从 &str
类型生成 String
类型的操作:
String::from("hello,world")
"hello,world".to_string()
那么如何将 String
类型转为 &str
类型呢?答案很简单,取引用即可:
fn main() { let s = String::from("hello,world!"); say_hello(&s); say_hello(&s[..]); say_hello(s.as_str()); } fn say_hello(s: &str) { println!("{}",s); }
实际上这种灵活用法是因为 deref
隐式强制转换,具体我们会在 Deref
特征进行详细讲解。
字符串索引
在其它语言中,使用索引的方式访问字符串的某个字符或者子串是很正常的行为,但是在 Rust 中就会报错:
#![allow(unused)] fn main() { let s1 = String::from("hello"); let h = s1[0]; }
该代码会产生如下错误:
3 | let h = s1[0];
| ^^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
深入字符串内部
字符串的底层的数据存储格式实际上是[ u8
],一个字节数组。对于 let hello = String::from("Hola");
这行代码来说,Hola
的长度是 4
个字节,因为 "Hola"
中的每个字母在 UTF-8 编码中仅占用 1 个字节,但是对于下面的代码呢?
#![allow(unused)] fn main() { let hello = String::from("中国人"); }
如果问你该字符串多长,你可能会说 3
,但是实际上是 9
个字节的长度,因为大部分常用汉字在 UTF-8 中的长度是 3
个字节,因此这种情况下对 hello
进行索引,访问 &hello[0]
没有任何意义,因为你取不到 中
这个字符,而是取到了这个字符三个字节中的第一个字节,这是一个非常奇怪而且难以理解的返回值。
字符串的不同表现形式
现在看一下用梵文写的字符串 “नमस्ते”
, 它底层的字节数组如下形式:
#![allow(unused)] fn main() { [224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164, 224, 165, 135] }
长度是 18 个字节,这也是计算机最终存储该字符串的形式。如果从字符的形式去看,则是:
#![allow(unused)] fn main() { ['न', 'म', 'स', '्', 'त', 'े'] }
但是这种形式下,第四和六两个字母根本就不存在,没有任何意义,接着再从字母串的形式去看:
#![allow(unused)] fn main() { ["न", "म", "स्", "ते"] }
所以,可以看出来 Rust 提供了不同的字符串展现方式,这样程序可以挑选自己想要的方式去使用,而无需去管字符串从人类语言角度看长什么样。
还有一个原因导致了 Rust 不允许去索引字符串:因为索引操作,我们总是期望它的性能表现是 O(1),然而对于 String
类型来说,无法保证这一点,因为 Rust 可能需要从 0 开始去遍历字符串来定位合法的字符。
字符串切片
前文提到过,字符串切片是非常危险的操作,因为切片的索引是通过字节来进行,但是字符串又是 UTF-8 编码,因此你无法保证索引的字节刚好落在字符的边界上,例如:
#![allow(unused)] fn main() { let hello = "中国人"; let s = &hello[0..2]; }
运行上面的程序,会直接造成崩溃:
thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '中' (bytes 0..3) of `中国人`', src/main.rs:4:14
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这里提示的很清楚,我们索引的字节落在了 中
字符的内部,这种返回没有任何意义。
因此在通过索引区间来访问字符串时,需要格外的小心,一不注意,就会导致你程序的崩溃!
操作字符串
由于 String
是可变字符串,下面介绍 Rust 字符串的修改,添加,删除等常用方法:
追加 (Push)
在字符串尾部可以使用 push()
方法追加字符 char
,也可以使用 push_str()
方法追加字符串字面量。这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut
关键字修饰。
示例代码如下:
fn main() { let mut s = String::from("Hello "); s.push_str("rust"); println!("追加字符串 push_str() -> {}", s); s.push('!'); println!("追加字符 push() -> {}", s); }
代码运行结果:
追加字符串 push_str() -> Hello rust
追加字符 push() -> Hello rust!
插入 (Insert)
可以使用 insert()
方法插入单个字符 char
,也可以使用 insert_str()
方法插入字符串字面量,与 push()
方法不同,这俩方法需要传入两个参数,第一个参数是字符(串)插入位置的索引,第二个参数是要插入的字符(串),索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut
关键字修饰。
示例代码如下:
fn main() { let mut s = String::from("Hello rust!"); s.insert(5, ','); println!("插入字符 insert() -> {}", s); s.insert_str(6, " I like"); println!("插入字符串 insert_str() -> {}", s); }
代码运行结果:
插入字符 insert() -> Hello, rust!
插入字符串 insert_str() -> Hello, I like rust!
替换 (Replace)
如果想要把字符串中的某个字符串替换成其它的字符串,那可以使用 replace()
方法。与替换有关的方法有三个。
1、replace
该方法可适用于 String
和 &str
类型。replace()
方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
fn main() { let string_replace = String::from("I like rust. Learning rust is my favorite!"); let new_string_replace = string_replace.replace("rust", "RUST"); dbg!(new_string_replace); }
代码运行结果:
new_string_replace = "I like RUST. Learning RUST is my favorite!"
2、replacen
该方法可适用于 String
和 &str
类型。replacen()
方法接收三个参数,前两个参数与 replace()
方法一样,第三个参数则表示替换的个数。该方法是返回一个新的字符串,而不是操作原来的字符串。
示例代码如下:
fn main() { let string_replace = "I like rust. Learning rust is my favorite!"; let new_string_replacen = string_replace.replacen("rust", "RUST", 1); dbg!(new_string_replacen); }
代码运行结果:
new_string_replacen = "I like RUST. Learning rust is my favorite!"
3、replace_range
该方法仅适用于 String
类型。replace_range
接收两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符串。该方法是直接操作原来的字符串,不会返回新的字符串。该方法需要使用 mut
关键字修饰。
示例代码如下:
fn main() { let mut string_replace_range = String::from("I like rust!"); string_replace_range.replace_range(7..8, "R"); dbg!(string_replace_range); }
代码运行结果:
string_replace_range = "I like Rust!"
删除 (Delete)
与字符串删除相关的方法有 4 个,他们分别是 pop()
,remove()
,truncate()
,clear()
。这四个方法仅适用于 String
类型。
1、 pop
—— 删除并返回字符串的最后一个字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是一个 Option
类型,如果字符串为空,则返回 None
。
示例代码如下:
fn main() { let mut string_pop = String::from("rust pop 中文!"); let p1 = string_pop.pop(); let p2 = string_pop.pop(); dbg!(p1); dbg!(p2); dbg!(string_pop); }
代码运行结果:
p1 = Some(
'!',
)
p2 = Some(
'文',
)
string_pop = "rust pop 中"
2、 remove
—— 删除并返回字符串中指定位置的字符
该方法是直接操作原来的字符串。但是存在返回值,其返回值是删除位置的字符串,只接收一个参数,表示该字符起始索引位置。remove()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
fn main() { let mut string_remove = String::from("测试remove方法"); println!( "string_remove 占 {} 个字节", std::mem::size_of_val(string_remove.as_str()) ); // 删除第一个汉字 string_remove.remove(0); // 下面代码会发生错误 // string_remove.remove(1); // 直接删除第二个汉字 // string_remove.remove(3); dbg!(string_remove); }
代码运行结果:
string_remove 占 18 个字节
string_remove = "试remove方法"
3、truncate
—— 删除字符串中从指定位置开始到结尾的全部字符
该方法是直接操作原来的字符串。无返回值。该方法 truncate()
方法是按照字节来处理字符串的,如果参数所给的位置不是合法的字符边界,则会发生错误。
示例代码如下:
fn main() { let mut string_truncate = String::from("测试truncate"); string_truncate.truncate(3); dbg!(string_truncate); }
代码运行结果:
string_truncate = "测"
4、clear
—— 清空字符串
该方法是直接操作原来的字符串。调用后,删除字符串中的所有字符,相当于 truncate()
方法参数为 0 的时候。
示例代码如下:
fn main() { let mut string_clear = String::from("string clear"); string_clear.clear(); dbg!(string_clear); }
代码运行结果:
string_clear = ""
连接 (Concatenate)
1、使用 +
或者 +=
连接字符串
使用 +
或者 +=
连接字符串,要求右边的参数必须为字符串的切片引用(Slice)类型。其实当调用 +
的操作符时,相当于调用了 std::string
标准库中的 add()
方法,这里 add()
方法的第二个参数是一个引用的类型。因此我们在使用 +
, 必须传递切片引用类型。不能直接传递 String
类型。+
是返回一个新的字符串,所以变量声明可以不需要 mut
关键字修饰。
示例代码如下:
fn main() { let string_append = String::from("hello "); let string_rust = String::from("rust"); // &string_rust会自动解引用为&str let result = string_append + &string_rust; let mut result = result + "!"; // `result + "!"` 中的 `result` 是不可变的 result += "!!!"; println!("连接字符串 + -> {}", result); }
代码运行结果:
连接字符串 + -> hello rust!!!!
add()
方法的定义:
#![allow(unused)] fn main() { fn add(self, s: &str) -> String }
因为该方法涉及到更复杂的特征功能,因此我们这里简单说明下:
fn main() { let s1 = String::from("hello,"); let s2 = String::from("world!"); // 在下句中,s1的所有权被转移走了,因此后面不能再使用s1 let s3 = s1 + &s2; assert_eq!(s3,"hello,world!"); // 下面的语句如果去掉注释,就会报错 // println!("{}",s1); }
self
是 String
类型的字符串 s1
,该函数说明,只能将 &str
类型的字符串切片添加到 String
类型的 s1
上,然后返回一个新的 String
类型,所以 let s3 = s1 + &s2;
就很好解释了,将 String
类型的 s1
与 &str
类型的 s2
进行相加,最终得到 String
类型的 s3
。
由此可推,以下代码也是合法的:
#![allow(unused)] fn main() { let s1 = String::from("tic"); let s2 = String::from("tac"); let s3 = String::from("toe"); // String = String + &str + &str + &str + &str let s = s1 + "-" + &s2 + "-" + &s3; }
String + &str
返回一个 String
,然后再继续跟一个 &str
进行 +
操作,返回一个 String
类型,不断循环,最终生成一个 s
,也是 String
类型。
s1
这个变量通过调用 add()
方法后,所有权被转移到 add()
方法里面, add()
方法调用后就被释放了,同时 s1
也被释放了。再使用 s1
就会发生错误。这里涉及到所有权转移(Move)的相关知识。
2、使用 format!
连接字符串
format!
这种方式适用于 String
和 &str
。format!
的用法与 print!
的用法类似,详见格式化输出。
示例代码如下:
fn main() { let s1 = "hello"; let s2 = String::from("rust"); let s = format!("{} {}!", s1, s2); println!("{}", s); }
代码运行结果:
hello rust!
字符串转义
我们可以通过转义的方式 \
输出 ASCII 和 Unicode 字符。
fn main() { // 通过 \ + 字符的十六进制表示,转义输出一个字符 let byte_escape = "I'm writing \x52\x75\x73\x74!"; println!("What are you doing\x3F (\\x3F means ?) {}", byte_escape); // \u 可以输出一个 unicode 字符 let unicode_codepoint = "\u{211D}"; let character_name = "\"DOUBLE-STRUCK CAPITAL R\""; println!( "Unicode character {} (U+211D) is called {}", unicode_codepoint, character_name ); // 换行了也会保持之前的字符串格式 // 使用\忽略换行符 let long_string = "String literals can span multiple lines. The linebreak and indentation here ->\ <- can be escaped too!"; println!("{}", long_string); }
当然,在某些情况下,可能你会希望保持字符串的原样,不要转义:
fn main() { println!("{}", "hello \\x52\\x75\\x73\\x74"); let raw_str = r"Escapes don't work here: \x3F \u{211D}"; println!("{}", raw_str); // 如果字符串包含双引号,可以在开头和结尾加 # let quotes = r#"And then I said: "There is no escape!""#; println!("{}", quotes); // 如果还是有歧义,可以继续增加,没有限制 let longer_delimiter = r###"A string with "# in it. And even "##!"###; println!("{}", longer_delimiter); }
操作 UTF-8 字符串
前文提到了几种使用 UTF-8 字符串的方式,下面来一一说明。
字符
如果你想要以 Unicode 字符的方式遍历字符串,最好的办法是使用 chars
方法,例如:
#![allow(unused)] fn main() { for c in "中国人".chars() { println!("{}", c); } }
输出如下
中
国
人
字节
这种方式是返回字符串的底层字节数组表现形式:
#![allow(unused)] fn main() { for b in "中国人".bytes() { println!("{}", b); } }
输出如下:
228
184
173
229
155
189
228
186
186
获取子串
想要准确的从 UTF-8 字符串中获取子串是较为复杂的事情,例如想要从 holla中国人नमस्ते
这种变长的字符串中取出某一个子串,使用标准库你是做不到的。
你需要在 crates.io
上搜索 utf8
来寻找想要的功能。
可以考虑尝试下这个库:utf8_slice。
字符串深度剖析
那么问题来了,为啥 String
可变,而字符串字面值 str
却不可以?
就字符串字面值来说,我们在编译时就知道其内容,最终字面值文本被直接硬编码进可执行文件中,这使得字符串字面值快速且高效,这主要得益于字符串字面值的不可变性。不幸的是,我们不能为了获得这种性能,而把每一个在编译时大小未知的文本都放进内存中(你也做不到!),因为有的字符串是在程序运行得过程中动态生成的。
对于 String
类型,为了支持一个可变、可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容,这些都是在程序运行时完成的:
- 首先向操作系统请求内存来存放
String
对象 - 在使用完成后,将内存释放,归还给操作系统
其中第一部分由 String::from
完成,它创建了一个全新的 String
。
重点来了,到了第二部分,就是百家齐放的环节,在有垃圾回收 GC 的语言中,GC 来负责标记并清除这些不再使用的内存对象,这个过程都是自动完成,无需开发者关心,非常简单好用;但是在无 GC 的语言中,需要开发者手动去释放这些内存对象,就像创建对象需要通过编写代码来完成一样,未能正确释放对象造成的后果简直不可估量。
对于 Rust 而言,安全和性能是写到骨子里的核心特性,如果使用 GC,那么会牺牲性能;如果使用手动管理内存,那么会牺牲安全,这该怎么办?为此,Rust 的开发者想出了一个无比惊艳的办法:变量在离开作用域后,就自动释放其占用的内存:
#![allow(unused)] fn main() { { let s = String::from("hello"); // 从此处起,s 是有效的 // 使用 s } // 此作用域已结束, // s 不再有效,内存被释放 }
与其它系统编程语言的 free
函数相同,Rust 也提供了一个释放内存的函数: drop
,但是不同的是,其它语言要手动调用 free
来释放每一个变量占用的内存,而 Rust 则在变量离开作用域时,自动调用 drop
函数: 上面代码中,Rust 在结尾的 }
处自动调用 drop
。
其实,在 C++ 中,也有这种概念: Resource Acquisition Is Initialization (RAII)。如果你使用过 RAII 模式的话应该对 Rust 的
drop
函数并不陌生。
这个模式对编写 Rust 代码的方式有着深远的影响,在后面章节我们会进行更深入的介绍。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
引用资料
- https://blog.csdn.net/a1595901624/article/details/119294443
元组
元组是由多种类型组合到一起形成的,因此它是复合类型,元组的长度是固定的,元组中元素的顺序也是固定的。
可以通过以下语法创建一个元组:
fn main() { let tup: (i32, f64, u8) = (500, 6.4, 1); }
变量 tup
被绑定了一个元组值 (500, 6.4, 1)
,该元组的类型是 (i32, f64, u8)
,看到没?元组是用括号将多个类型组合到一起,简单吧?
可以使用模式匹配或者 .
操作符来获取元组中的值。
用模式匹配解构元组
fn main() { let tup = (500, 6.4, 1); let (x, y, z) = tup; println!("The value of y is: {}", y); }
上述代码首先创建一个元组,然后将其绑定到 tup
上,接着使用 let (x, y, z) = tup;
来完成一次模式匹配,因为元组是 (n1, n2, n3)
形式的,因此我们用一模一样的 (x, y, z)
形式来进行匹配,元组中对应的值会绑定到变量 x
, y
, z
上。这就是解构:用同样的形式把一个复杂对象中的值匹配出来。
用 .
来访问元组
模式匹配可以让我们一次性把元组中的值全部或者部分获取出来,如果只想要访问某个特定元素,那模式匹配就略显繁琐,对此,Rust 提供了 .
的访问方式:
fn main() { let x: (i32, f64, u8) = (500, 6.4, 1); let five_hundred = x.0; let six_point_four = x.1; let one = x.2; }
和其它语言的数组、字符串一样,元组的索引从 0 开始。
元组的使用示例
元组在函数返回值场景很常用,例如下面的代码,可以使用元组返回多个值:
fn main() { let s1 = String::from("hello"); let (s2, len) = calculate_length(s1); println!("The length of '{}' is {}.", s2, len); } fn calculate_length(s: String) -> (String, usize) { let length = s.len(); // len() 返回字符串的长度 (s, length) }
calculate_length
函数接收 s1
字符串的所有权,然后计算字符串的长度,接着把字符串所有权和字符串长度再返回给 s2
和 len
变量。
在其他语言中,可以用结构体来声明一个三维空间中的点,例如 Point(10, 20, 30)
,虽然使用 Rust 元组也可以做到:(10, 20, 30)
,但是这样写有个非常重大的缺陷:
不具备任何清晰的含义,在下一章节中,会提到一种与元组类似的结构体,元组结构体
,可以解决这个问题。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
结构体
上一节中提到需要一个更高级的数据结构来帮助我们更好的抽象问题,结构体 struct
恰恰就是这样的复合数据结构,它是由其它数据类型组合而来。 其它语言也有类似的数据结构,不过可能有不同的名称,例如 object
、 record
等。
结构体跟之前讲过的元组有些相像:都是由多种类型组合而成。但是与元组不同的是,结构体可以为内部的每个字段起一个富有含义的名称。因此结构体更加灵活更加强大,你无需依赖这些字段的顺序来访问和解析它们。
结构体语法
天下无敌的剑士往往也因为他有一柄无双之剑,既然结构体这么强大,那么我们就需要给它配套一套强大的语法,让用户能更好的驾驭。
定义结构体
一个结构体由几部分组成:
- 通过关键字
struct
定义 - 一个清晰明确的结构体
名称
- 几个有名字的结构体
字段
例如, 以下结构体定义了某网站的用户:
#![allow(unused)] fn main() { struct User { active: bool, username: String, email: String, sign_in_count: u64, } }
该结构体名称是 User
,拥有 4 个字段,且每个字段都有对应的字段名及类型声明,例如 username
代表了用户名,是一个可变的 String
类型。
创建结构体实例
为了使用上述结构体,我们需要创建 User
结构体的实例:
#![allow(unused)] fn main() { let user1 = User { email: String::from("[email protected]"), username: String::from("someusername123"), active: true, sign_in_count: 1, }; }
有几点值得注意:
- 初始化实例时,每个字段都需要进行初始化
- 初始化时的字段顺序不需要和结构体定义时的顺序一致
访问结构体字段
通过 .
操作符即可访问结构体实例内部的字段值,也可以修改它们:
#![allow(unused)] fn main() { let mut user1 = User { email: String::from("[email protected]"), username: String::from("someusername123"), active: true, sign_in_count: 1, }; user1.email = String::from("[email protected]"); }
需要注意的是,必须要将结构体实例声明为可变的,才能修改其中的字段,Rust 不支持将某个结构体某个字段标记为可变。
简化结构体创建
下面的函数类似一个构建函数,返回了 User
结构体的实例:
#![allow(unused)] fn main() { fn build_user(email: String, username: String) -> User { User { email: email, username: username, active: true, sign_in_count: 1, } } }
它接收两个字符串参数: email
和 username
,然后使用它们来创建一个 User
结构体,并且返回。可以注意到这两行: email: email
和 username: username
,非常的扎眼,因为实在有些啰嗦,如果你从 TypeScript 过来,肯定会鄙视 Rust 一番,不过好在,它也不是无可救药:
#![allow(unused)] fn main() { fn build_user(email: String, username: String) -> User { User { email, username, active: true, sign_in_count: 1, } } }
如上所示,当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样。
结构体更新语法
在实际场景中,有一种情况很常见:根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1
实例来构建 user2
:
#![allow(unused)] fn main() { let user2 = User { active: user1.active, username: user1.username, email: String::from("[email protected]"), sign_in_count: user1.sign_in_count, }; }
老话重提,如果你从 TypeScript 过来,肯定觉得啰嗦爆了:竟然手动把 user1
的三个字段逐个赋值给 user2
,好在 Rust 为我们提供了 结构体更新语法
:
#![allow(unused)] fn main() { let user2 = User { email: String::from("[email protected]"), ..user1 }; }
因为 user2
仅仅在 email
上与 user1
不同,因此我们只需要对 email
进行赋值,剩下的通过结构体更新语法 ..user1
即可完成。
..
语法表明凡是我们没有显式声明的字段,全部从 user1
中自动获取。需要注意的是 ..user1
必须在结构体的尾部使用。
结构体更新语法跟赋值语句
=
非常相像,因此在上面代码中,user1
的部分字段所有权被转移到user2
中:username
字段发生了所有权转移,作为结果,user1
无法再被使用。聪明的读者肯定要发问了:明明有三个字段进行了自动赋值,为何只有
username
发生了所有权转移?仔细回想一下所有权那一节的内容,我们提到了
Copy
特征:实现了Copy
特征的类型无需所有权转移,可以直接在赋值时进行 数据拷贝,其中bool
和u64
类型就实现了Copy
特征,因此active
和sign_in_count
字段在赋值给user2
时,仅仅发生了拷贝,而不是所有权转移。值得注意的是:
username
所有权被转移给了user2
,导致了user1
无法再被使用,但是并不代表user1
内部的其它字段不能被继续使用,例如:
#[derive(Debug)] struct User { active: bool, username: String, email: String, sign_in_count: u64, } fn main() { let user1 = User { email: String::from("[email protected]"), username: String::from("someusername123"), active: true, sign_in_count: 1, }; let user2 = User { active: user1.active, username: user1.username, email: String::from("[email protected]"), sign_in_count: user1.sign_in_count, }; println!("{}", user1.active); // 下面这行会报错 println!("{:?}", user1); }
结构体的内存排列
先来看以下代码:
#[derive(Debug)] struct File { name: String, data: Vec<u8>, } fn main() { let f1 = File { name: String::from("f1.txt"), data: Vec::new(), }; let f1_name = &f1.name; let f1_length = &f1.data.len(); println!("{:?}", f1); println!("{} is {} bytes long", f1_name, f1_length); }
上面定义的 File
结构体在内存中的排列如下图所示:
从图中可以清晰地看出 File
结构体两个字段 name
和 data
分别拥有底层两个 [u8]
数组的所有权(String
类型的底层也是 [u8]
数组),通过 ptr
指针指向底层数组的内存地址,这里你可以把 ptr
指针理解为 Rust 中的引用类型。
该图片也侧面印证了:把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段。
元组结构体(Tuple Struct)
结构体必须要有名称,但是结构体的字段可以没有名称,这种结构体长得很像元组,因此被称为元组结构体,例如:
#![allow(unused)] fn main() { struct Color(i32, i32, i32); struct Point(i32, i32, i32); let black = Color(0, 0, 0); let origin = Point(0, 0, 0); }
元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。例如上面的 Point
元组结构体,众所周知 3D 点是 (x, y, z)
形式的坐标点,因此我们无需再为内部的字段逐一命名为:x
, y
, z
。
单元结构体(Unit-like Struct)
还记得之前讲过的基本没啥用的单元类型吧?单元结构体就跟它很像,没有任何字段和属性,但是好在,它还挺有用。
如果你定义一个类型,但是不关心该类型的内容, 只关心它的行为时,就可以使用 单元结构体
:
#![allow(unused)] fn main() { struct AlwaysEqual; let subject = AlwaysEqual; // 我们不关心 AlwaysEqual 的字段数据,只关心它的行为,因此将它声明为单元结构体,然后再为它实现某个特征 impl SomeTrait for AlwaysEqual { } }
结构体数据的所有权
在之前的 User
结构体的定义中,有一处细节:我们使用了自身拥有所有权的 String
类型而不是基于引用的 &str
字符串切片类型。这是一个有意而为之的选择:因为我们想要这个结构体拥有它所有的数据,而不是从其它地方借用数据。
你也可以让 User
结构体从其它对象借用数据,不过这么做,就需要引入生命周期(lifetimes)这个新概念(也是一个复杂的概念),简而言之,生命周期能确保结构体的作用范围要比它所借用的数据的作用范围要小。
总之,如果你想在结构体中使用一个引用,就必须加上生命周期,否则就会报错:
struct User { username: &str, email: &str, sign_in_count: u64, active: bool, } fn main() { let user1 = User { email: "[email protected]", username: "someusername123", active: true, sign_in_count: 1, }; }
编译器会抱怨它需要生命周期标识符:
error[E0106]: missing lifetime specifier
--> src/main.rs:2:15
|
2 | username: &str,
| ^ expected named lifetime parameter // 需要一个生命周期
|
help: consider introducing a named lifetime parameter // 考虑像下面的代码这样引入一个生命周期
|
1 ~ struct User<'a> {
2 ~ username: &'a str,
|
error[E0106]: missing lifetime specifier
--> src/main.rs:3:12
|
3 | email: &str,
| ^ expected named lifetime parameter
|
help: consider introducing a named lifetime parameter
|
1 ~ struct User<'a> {
2 | username: &str,
3 ~ email: &'a str,
|
未来在生命周期中会讲到如何修复这个问题以便在结构体中存储引用,不过在那之前,我们会避免在结构体中使用引用类型。
使用 #[derive(Debug)]
来打印结构体的信息
在前面的代码中我们使用 #[derive(Debug)]
对结构体进行了标记,这样才能使用 println!("{:?}", s);
的方式对其进行打印输出,如果不加,看看会发生什么:
struct Rectangle { width: u32, height: u32, } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; println!("rect1 is {}", rect1); }
首先可以观察到,上面使用了 {}
而不是之前的 {:?}
,运行后报错:
error[E0277]: `Rectangle` doesn't implement `std::fmt::Display`
提示我们结构体 Rectangle
没有实现 Display
特征,这是因为如果我们使用 {}
来格式化输出,那对应的类型就必须实现 Display
特征,以前学习的基本类型,都默认实现了该特征:
fn main() { let v = 1; let b = true; println!("{}, {}", v, b); }
上面代码不会报错,那么结构体为什么不默认实现 Display
特征呢?原因在于结构体较为复杂,例如考虑以下问题:你想要逗号对字段进行分割吗?需要括号吗?加在什么地方?所有的字段都应该显示?类似的还有很多,由于这种复杂性,Rust 不希望猜测我们想要的是什么,而是把选择权交给我们自己来实现:如果要用 {}
的方式打印结构体,那就自己实现 Display
特征。
接下来继续阅读报错:
= help: the trait `std::fmt::Display` is not implemented for `Rectangle`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
上面提示我们使用 {:?}
来试试,这个方式我们在本文的前面也见过,下面来试试:
#![allow(unused)] fn main() { println!("rect1 is {:?}", rect1); }
可是依然无情报错了:
error[E0277]: `Rectangle` doesn't implement `Debug`
好在,聪明的编译器又一次给出了提示:
= help: the trait `Debug` is not implemented for `Rectangle`
= note: add `#[derive(Debug)]` to `Rectangle` or manually `impl Debug for Rectangle`
让我们实现 Debug
特征,Oh No,就是不想实现 Display
特征,才用的 {:?}
,怎么又要实现 Debug
,但是仔细看,提示中有一行: add #[derive(Debug)] to Rectangle
, 哦?这不就是我们前文一直在使用的吗?
首先,Rust 默认不会为我们实现 Debug
,为了实现,有两种方式可以选择:
- 手动实现
- 使用
derive
派生实现
后者简单的多,但是也有限制,具体见附录 D,这里我们就不再深入讲解,来看看该如何使用:
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; println!("rect1 is {:?}", rect1); }
此时运行程序,就不再有错误,输出如下:
$ cargo run
rect1 is Rectangle { width: 30, height: 50 }
这个输出格式看上去也不赖嘛,虽然未必是最好的。这种格式是 Rust 自动为我们提供的实现,看上基本就跟结构体的定义形式一样。
当结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?}
来替代 {:?}
,输出如下:
rect1 is Rectangle {
width: 30,
height: 50,
}
此时结构体的输出跟我们创建时候的代码几乎一模一样了!当然,如果大家还是不满足,那最好还是自己实现 Display
特征,以向用户更美的展示你的私藏结构体。关于格式化输出的更多内容,我们强烈推荐看看这个章节。
还有一个简单的输出 debug 信息的方法,那就是使用 dbg!
宏,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!
dbg!
输出到标准错误输出stderr
,而println!
输出到标准输出stdout
。
下面的例子中清晰的展示了 dbg!
如何在打印出信息的同时,还把表达式的值赋给了 width
:
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } fn main() { let scale = 2; let rect1 = Rectangle { width: dbg!(30 * scale), height: 50, }; dbg!(&rect1); }
最终的 debug 输出如下:
$ cargo run
[src/main.rs:10] 30 * scale = 60
[src/main.rs:14] &rect1 = Rectangle {
width: 60,
height: 50,
}
可以看到,我们想要的 debug 信息几乎都有了:代码所在的文件名、行号、表达式以及表达式的值,简直完美!
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
枚举
枚举(enum 或 enumeration)允许你通过列举可能的成员来定义一个枚举类型,例如扑克牌花色:
#![allow(unused)] fn main() { enum PokerSuit { Clubs, Spades, Diamonds, Hearts, } }
如果在此之前你没有在其它语言中使用过枚举,那么可能需要花费一些时间来理解这些概念,一旦上手,就会发现枚举的强大,甚至对它爱不释手,枚举虽好,可不要滥用哦。
再回到之前创建的 PokerSuit
,扑克总共有四种花色,而这里我们枚举出所有的可能值,这也正是 枚举
名称的由来。
任何一张扑克,它的花色肯定会落在四种花色中,而且也只会落在其中一个花色上,这种特性非常适合枚举的使用,因为枚举值只可能是其中某一个成员。抽象来看,四种花色尽管是不同的花色,但是它们都是扑克花色这个概念,因此当某个函数处理扑克花色时,可以把它们当作相同的类型进行传参。
细心的读者应该注意到,我们对之前的 枚举类型
和 枚举值
进行了重点标注,这是因为对于新人来说容易混淆相应的概念,总而言之:
枚举类型是一个类型,它会包含所有可能的枚举成员, 而枚举值是该类型中的具体某个成员的实例。
枚举值
现在来创建 PokerSuit
枚举类型的两个成员实例:
#![allow(unused)] fn main() { let heart = PokerSuit::Hearts; let diamond = PokerSuit::Diamonds; }
我们通过 ::
操作符来访问 PokerSuit
下的具体成员,从代码可以清晰看出,heart
和 diamond
都是 PokerSuit
枚举类型的,接着可以定义一个函数来使用它们:
fn main() { let heart = PokerSuit::Hearts; let diamond = PokerSuit::Diamonds; print_suit(heart); print_suit(diamond); } fn print_suit(card: PokerSuit) { // 需要在定义 enum PokerSuit 的上面添加上 #[derive(Debug)],否则会报 card 没有实现 Debug println!("{:?}",card); }
print_suit
函数的参数类型是 PokerSuit
,因此我们可以把 heart
和 diamond
传给它,虽然 heart
是基于 PokerSuit
下的 Hearts
成员实例化的,但是它是货真价实的 PokerSuit
枚举类型。
接下来,我们想让扑克牌变得更加实用,那么需要给每张牌赋予一个值:A
(1)-K
(13),这样再加上花色,就是一张真实的扑克牌了,例如红心 A。
目前来说,枚举值还不能带有值,因此先用结构体来实现:
enum PokerSuit { Clubs, Spades, Diamonds, Hearts, } struct PokerCard { suit: PokerSuit, value: u8 } fn main() { let c1 = PokerCard { suit: PokerSuit::Clubs, value: 1, }; let c2 = PokerCard { suit: PokerSuit::Diamonds, value: 12, }; }
这段代码很好的完成了它的使命,通过结构体 PokerCard
来代表一张牌,结构体的 suit
字段表示牌的花色,类型是 PokerSuit
枚举类型,value
字段代表扑克牌的数值。
可以吗?可以!好吗?说实话,不咋地,因为还有简洁得多的方式来实现:
enum PokerCard { Clubs(u8), Spades(u8), Diamonds(u8), Hearts(u8), } fn main() { let c1 = PokerCard::Spades(5); let c2 = PokerCard::Diamonds(13); }
直接将数据信息关联到枚举成员上,省去近一半的代码,这种实现是不是更优雅?
不仅如此,同一个枚举类型下的不同成员还能持有不同的数据类型,例如让某些花色打印 1-13
的字样,另外的花色打印上 A-K
的字样:
enum PokerCard { Clubs(u8), Spades(u8), Diamonds(char), Hearts(char), } fn main() { let c1 = PokerCard::Spades(5); let c2 = PokerCard::Diamonds('A'); }
回想一下,遇到这种不同类型的情况,再用我们之前的结构体实现方式,可行吗?也许可行,但是会复杂很多。
再来看一个来自标准库中的例子:
#![allow(unused)] fn main() { struct Ipv4Addr { // --snip-- } struct Ipv6Addr { // --snip-- } enum IpAddr { V4(Ipv4Addr), V6(Ipv6Addr), } }
这个例子跟我们之前的扑克牌很像,只不过枚举成员包含的类型更复杂了,变成了结构体:分别通过 Ipv4Addr
和 Ipv6Addr
来定义两种不同的 IP 数据。
从这些例子可以看出,任何类型的数据都可以放入枚举成员中: 例如字符串、数值、结构体甚至另一个枚举。
增加一些挑战?先看以下代码:
enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn main() { let m1 = Message::Quit; let m2 = Message::Move{x:1,y:1}; let m3 = Message::ChangeColor(255,255,0); }
该枚举类型代表一条消息,它包含四个不同的成员:
Quit
没有任何关联数据Move
包含一个匿名结构体Write
包含一个String
字符串ChangeColor
包含三个i32
当然,我们也可以用结构体的方式来定义这些消息:
#![allow(unused)] fn main() { struct QuitMessage; // 单元结构体 struct MoveMessage { x: i32, y: i32, } struct WriteMessage(String); // 元组结构体 struct ChangeColorMessage(i32, i32, i32); // 元组结构体 }
由于每个结构体都有自己的类型,因此我们无法在需要同一类型的地方进行使用,例如某个函数它的功能是接受消息并进行发送,那么用枚举的方式,就可以接收不同的消息,但是用结构体,该函数无法接受 4 个不同的结构体作为参数。
而且从代码规范角度来看,枚举的实现更简洁,代码内聚性更强,不像结构体的实现,分散在各个地方。
同一化类型
最后,再用一个实际项目中的简化片段,来结束枚举类型的语法学习。
例如我们有一个 WEB 服务,需要接受用户的长连接,假设连接有两种:TcpStream
和 TlsStream
,但是我们希望对这两个连接的处理流程相同,也就是用同一个函数来处理这两个连接,代码如下:
#![allow(unused)] fn main() { fn new (stream: TcpStream) { let mut s = stream; if tls { s = negotiate_tls(stream) } // websocket是一个WebSocket<TcpStream>或者 // WebSocket<native_tls::TlsStream<TcpStream>>类型 websocket = WebSocket::from_raw_socket( stream, ......) } }
此时,枚举类型就能帮上大忙:
#![allow(unused)] fn main() { enum Websocket { Tcp(Websocket<TcpStream>), Tls(Websocket<native_tls::TlsStream<TcpStream>>), } }
Option 枚举用于处理空值
在其它编程语言中,往往都有一个 null
关键字,该关键字用于表明一个变量当前的值为空(不是零值,例如整型的零值是 0),也就是不存在值。当你对这些 null
进行操作时,例如调用一个方法,就会直接抛出null 异常,导致程序的崩溃,因此我们在编程时需要格外的小心去处理这些 null
空值。
Tony Hoare,
null
的发明者,曾经说过一段非常有名的话:我称之为我十亿美元的错误。当时,我在使用一个面向对象语言设计第一个综合性的面向引用的类型系统。我的目标是通过编译器的自动检查来保证所有引用的使用都应该是绝对安全的。不过在设计过程中,我未能抵抗住诱惑,引入了空引用的概念,因为它非常容易实现。就是因为这个决策,引发了无数错误、漏洞和系统崩溃,在之后的四十多年中造成了数十亿美元的苦痛和伤害。
尽管如此,空值的表达依然非常有意义,因为空值表示当前时刻变量的值是缺失的。有鉴于此,Rust 吸取了众多教训,决定抛弃 null
,而改为使用 Option
枚举变量来表述这种结果。
Option
枚举包含两个成员,一个成员表示含有值:Some(T)
, 另一个表示没有值:None
,定义如下:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
其中 T
是泛型参数,Some(T)
表示该枚举成员的数据类型是 T
,换句话说,Some
可以包含任何类型的数据。
Option<T>
枚举是如此有用以至于它被包含在了 prelude
(prelude 属于 Rust 标准库,Rust 会将最常用的类型、函数等提前引入其中,省得我们再手动引入)之中,你不需要将其显式引入作用域。另外,它的成员 Some
和 None
也是如此,无需使用 Option::
前缀就可直接使用 Some
和 None
。总之,不能因为 Some(T)
和 None
中没有 Option::
的身影,就否认它们是 Option
下的卧龙凤雏。
再来看以下代码:
#![allow(unused)] fn main() { let some_number = Some(5); let some_string = Some("a string"); let absent_number: Option<i32> = None; }
如果使用 None
而不是 Some
,需要告诉 Rust Option<T>
是什么类型的,因为编译器只通过 None
值无法推断出 Some
成员保存的值的类型。
当有一个 Some
值时,我们就知道存在一个值,而这个值保存在 Some
中。当有个 None
值时,在某种意义上,它跟空值具有相同的意义:并没有一个有效的值。那么,Option<T>
为什么就比空值要好呢?
简而言之,因为 Option<T>
和 T
(这里 T
可以是任何类型)是不同的类型,例如,这段代码不能编译,因为它尝试将 Option<i8>
(Option<T>
) 与 i8
(T
) 相加:
#![allow(unused)] fn main() { let x: i8 = 5; let y: Option<i8> = Some(5); let sum = x + y; }
如果运行这些代码,将得到类似这样的错误信息:
error[E0277]: the trait bound `i8: std::ops::Add<std::option::Option<i8>>` is
not satisfied
-->
|
5 | let sum = x + y;
| ^ no implementation for `i8 + std::option::Option<i8>`
|
很好!事实上,错误信息意味着 Rust 不知道该如何将 Option<i8>
与 i8
相加,因为它们的类型不同。当在 Rust 中拥有一个像 i8
这样类型的值时,编译器确保它总是有一个有效的值,我们可以放心使用而无需做空值检查。只有当使用 Option<i8>
(或者任何用到的类型)的时候才需要担心可能没有值,而编译器会确保我们在使用值之前处理了为空的情况。
换句话说,在对 Option<T>
进行 T
的运算之前必须将其转换为 T
。通常这能帮助我们捕获到空值最常见的问题之一:期望某值不为空但实际上为空的情况。
不再担心会错误的使用一个空值,会让你对代码更加有信心。为了拥有一个可能为空的值,你必须要显式的将其放入对应类型的 Option<T>
中。接着,当使用这个值时,必须明确的处理值为空的情况。只要一个值不是 Option<T>
类型,你就 可以 安全的认定它的值不为空。这是 Rust 的一个经过深思熟虑的设计决策,来限制空值的泛滥以增加 Rust 代码的安全性。
那么当有一个 Option<T>
的值时,如何从 Some
成员中取出 T
的值来使用它呢?Option<T>
枚举拥有大量用于各种情况的方法:你可以查看它的文档。熟悉 Option<T>
的方法将对你的 Rust 之旅非常有用。
总的来说,为了使用 Option<T>
值,需要编写处理每个成员的代码。你想要一些代码只当拥有 Some(T)
值时运行,允许这些代码使用其中的 T
。也希望一些代码在值为 None
时运行,这些代码并没有一个可用的 T
值。match
表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。
这里先简单看一下 match
的大致模样,在模式匹配中,我们会详细讲解:
#![allow(unused)] fn main() { 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); let none = plus_one(None); }
plus_one
通过 match
来处理不同 Option
的情况。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
数组
在日常开发中,使用最广的数据结构之一就是数组,在 Rust 中,最常用的数组有两种,第一种是速度很快但是长度固定的 array
,第二种是可动态增长的但是有性能损耗的 Vector
,在本书中,我们称 array
为数组,Vector
为动态数组。
不知道你们发现没,这两个数组的关系跟 &str
与 String
的关系很像,前者是长度固定的字符串切片,后者是可动态增长的字符串。其实,在 Rust 中无论是 String
还是 Vector
,它们都是 Rust 的高级类型:集合类型,在后面章节会有详细介绍。
对于本章节,我们的重点还是放在数组 array
上。数组的具体定义很简单:将多个类型相同的元素依次组合在一起,就是一个数组。结合上面的内容,可以得出数组的三要素:
- 长度固定
- 元素必须有相同的类型
- 依次线性排列
这里再啰嗦一句,我们这里说的数组是 Rust 的基本类型,是固定长度的,这点与其他编程语言不同,其它编程语言的数组往往是可变长度的,与 Rust 中的动态数组 Vector
类似,希望读者大大牢记此点。
创建数组
在 Rust 中,数组是这样定义的:
fn main() { let a = [1, 2, 3, 4, 5]; }
数组语法跟 JavaScript 很像,也跟大多数编程语言很像。由于它的元素类型大小固定,且长度也是固定,因此数组 array
是存储在栈上,性能也会非常优秀。与此对应,动态数组 Vector
是存储在堆上,因此长度可以动态改变。当你不确定是使用数组还是动态数组时,那就应该使用后者,具体见动态数组 Vector。
举个例子,在需要知道一年中各个月份名称的程序中,你很可能希望使用的是数组而不是动态数组。因为月份是固定的,它总是只包含 12 个元素:
#![allow(unused)] fn main() { let months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; }
在一些时候,还需要为数组声明类型,如下所示:
#![allow(unused)] fn main() { let a: [i32; 5] = [1, 2, 3, 4, 5]; }
这里,数组类型是通过方括号语法声明,i32
是元素类型,分号后面的数字 5
是数组长度,数组类型也从侧面说明了数组的元素类型要统一,长度要固定。
还可以使用下面的语法初始化一个某个值重复出现 N 次的数组:
#![allow(unused)] fn main() { let a = [3; 5]; }
a
数组包含 5
个元素,这些元素的初始化值为 3
,聪明的读者已经发现,这种语法跟数组类型的声明语法其实是保持一致的:[3; 5]
和 [类型; 长度]
。
在元素重复的场景,这种写法要简单的多,否则你就得疯狂敲击键盘:let a = [3, 3, 3, 3, 3];
,不过老板可能很喜欢你的这种疯狂编程的状态。
访问数组元素
因为数组是连续存放元素的,因此可以通过索引的方式来访问存放其中的元素:
fn main() { let a = [9, 8, 7, 6, 5]; let first = a[0]; // 获取a数组第一个元素 let second = a[1]; // 获取第二个元素 }
与许多语言类似,数组的索引下标是从 0 开始的。此处,first
获取到的值是 9
,second
是 8
。
越界访问
如果使用超出数组范围的索引访问数组元素,会怎么样?下面是一个接收用户的控制台输入,然后将其作为索引访问数组元素的例子:
use std::io; fn main() { let a = [1, 2, 3, 4, 5]; println!("Please enter an array index."); let mut index = String::new(); // 读取控制台的输出 io::stdin() .read_line(&mut index) .expect("Failed to read line"); let index: usize = index .trim() .parse() .expect("Index entered was not a number"); let element = a[index]; println!( "The value of the element at index {} is: {}", index, element ); }
使用 cargo run
来运行代码,因为数组只有 5 个元素,如果我们试图输入 5
去访问第 6 个元素,则会访问到不存在的数组元素,最终程序会崩溃退出:
Please enter an array index.
5
thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:19:19
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
这就是数组访问越界,访问了数组中不存在的元素,导致 Rust 运行时错误。程序因此退出并显示错误消息,未执行最后的 println!
语句。
当你尝试使用索引访问元素时,Rust 将检查你指定的索引是否小于数组长度。如果索引大于或等于数组长度,Rust 会出现 panic。这种检查只能在运行时进行,比如在上面这种情况下,编译器无法在编译期知道用户运行代码时将输入什么值。
这种就是 Rust 的安全特性之一。在很多系统编程语言中,并不会检查数组越界问题,你会访问到无效的内存地址获取到一个风马牛不相及的值,最终导致在程序逻辑上出现大问题,而且这种问题会非常难以检查。
数组元素为非基础类型
学习了上面的知识,很多朋友肯定觉得已经学会了Rust的数组类型,但现实会给我们一记重锤,实际开发中还会碰到一种情况,就是数组元素是非基本类型的,这时候大家一定会这样写。
#![allow(unused)] fn main() { let array = [String::from("rust is good!"); 8]; println!("{:#?}", array); }
然后你会惊喜的得到编译错误。
error[E0277]: the trait bound `String: std::marker::Copy` is not satisfied
--> src/main.rs:7:18
|
7 | let array = [String::from("rust is good!"); 8];
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::marker::Copy` is not implemented for `String`
|
= note: the `Copy` trait is required because this value will be copied for each element of the array
有些还没有看过特征的小伙伴,有可能不太明白这个报错,不过这个目前可以不提,我们就拿之前所学的所有权知识,就可以思考明白,前面几个例子都是Rust的基本类型,而基本类型在Rust中赋值是以Copy的形式,这时候你就懂了吧,let array=[3;5]
底层就是不断的Copy出来的,但很可惜复杂类型都没有深拷贝,只能一个个创建。
接着就有小伙伴会这样写。
#![allow(unused)] fn main() { let array = [String::from("rust is good!"),String::from("rust is good!"),String::from("rust is good!")]; println!("{:#?}", array); }
作为一个追求极致完美的Rust开发者,怎么能容忍上面这么难看的代码存在!
正确的写法,应该调用std::array::from_fn
#![allow(unused)] fn main() { let array: [String; 8] = std::array::from_fn(|_i| String::from("rust is good!")); println!("{:#?}", array); }
数组切片
在之前的章节,我们有讲到 切片
这个概念,它允许你引用集合中的部分连续片段,而不是整个集合,对于数组也是,数组切片允许我们引用数组的一部分:
#![allow(unused)] fn main() { let a: [i32; 5] = [1, 2, 3, 4, 5]; let slice: &[i32] = &a[1..3]; assert_eq!(slice, &[2, 3]); }
上面的数组切片 slice
的类型是&[i32]
,与之对比,数组的类型是[i32;5]
,简单总结下切片的特点:
- 切片的长度可以与数组不同,并不是固定的,而是取决于你使用时指定的起始和结束位置
- 创建切片的代价非常小,因为切片只是针对底层数组的一个引用
- 切片类型[T]拥有不固定的大小,而切片引用类型&[T]则具有固定的大小,因为 Rust 很多时候都需要固定大小数据类型,因此&[T]更有用,
&str
字符串切片也同理
总结
最后,让我们以一个综合性使用数组的例子,来结束本章节的学习:
fn main() { // 编译器自动推导出one的类型 let one = [1, 2, 3]; // 显式类型标注 let two: [u8; 3] = [1, 2, 3]; let blank1 = [0; 3]; let blank2: [u8; 3] = [0; 3]; // arrays是一个二维数组,其中每一个元素都是一个数组,元素类型是[u8; 3] let arrays: [[u8; 3]; 4] = [one, two, blank1, blank2]; // 借用arrays的元素用作循环中 for a in &arrays { print!("{:?}: ", a); // 将a变成一个迭代器,用于循环 // 你也可以直接用for n in a {}来进行循环 for n in a.iter() { print!("\t{} + 10 = {}", n, n+10); } let mut sum = 0; // 0..a.len,是一个 Rust 的语法糖,其实就等于一个数组,元素是从0,1,2一直增加到到a.len-1 for i in 0..a.len() { sum += a[i]; } println!("\t({:?} = {})", a, sum); } }
做个总结,数组虽然很简单,但是其实还是存在几个要注意的点:
- 数组类型容易跟数组切片混淆,[T;n]描述了一个数组的类型,而[T]描述了切片的类型, 因为切片是运行期的数据结构,它的长度无法在编译期得知,因此不能用[T;n]的形式去描述
[u8; 3]
和[u8; 4]
是不同的类型,数组的长度也是类型的一部分- 在实际开发中,使用最多的是数组切片[T],我们往往通过引用的方式去使用
&[T]
,因为后者有固定的类型大小
至此,关于数据类型部分,我们已经全部学完了,对于 Rust 学习而言,我们也迈出了坚定的第一步,后面将开始更高级特性的学习。未来如果大家有疑惑需要检索知识,一样可以继续回顾过往的章节,因为本书不仅仅是一门 Rust 的教程,还是一本厚重的 Rust 工具书。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
流程控制
80 后应该都对学校的小混混记忆犹新,在那个时代,小混混们往往都认为自己是地下王者,管控着地下事务的流程,在我看来,他们就像代码中的流程控制一样,无处不在,很显眼,但是又让人懒得重视。
言归正传,Rust 程序是从上而下顺序执行的,在此过程中,我们可以通过循环、分支等流程控制方式,更好的实现相应的功能。
使用 if 来做分支控制
if else 无处不在 -- 鲁迅
但凡你能找到一门编程语言没有 if else
,那么一定更要反馈给鲁迅,反正不是我说的:) 总之,只要你拥有其它语言的编程经验,就一定会有以下认知:if else
表达式根据条件执行不同的代码分支:
#![allow(unused)] fn main() { if condition == true { // A... } else { // B... } }
该代码读作:若 condition
的值为 true
,则执行 A
代码,否则执行 B
代码。
先看下面代码:
fn main() { let condition = true; let number = if condition { 5 } else { 6 }; println!("The value of number is: {}", number); }
以上代码有以下几点要注意:
if
语句块是表达式,这里我们使用if
表达式的返回值来给number
进行赋值:number
的值是5
- 用
if
来赋值时,要保证每个分支返回的类型一样(事实上,这种说法不完全准确,见这里),此处返回的5
和6
就是同一个类型,如果返回类型不一致就会报错
error[E0308]: if and else have incompatible types
--> src/main.rs:4:18
|
4 | let number = if condition {
| __________________^
5 | | 5
6 | | } else {
7 | | "six"
8 | | };
| |_____^ expected integer, found &str // 期望整数类型,但却发现&str字符串切片
|
= note: expected type `{integer}`
found type `&str`
使用 else if 来处理多重条件
可以将 else if
与 if
、else
组合在一起实现更复杂的条件分支判断:
fn main() { let n = 6; if n % 4 == 0 { println!("number is divisible by 4"); } else if n % 3 == 0 { println!("number is divisible by 3"); } else if n % 2 == 0 { println!("number is divisible by 2"); } else { println!("number is not divisible by 4, 3, or 2"); } }
程序执行时,会按照自上至下的顺序执行每一个分支判断,一旦成功,则跳出 if
语句块,最终本程序会匹配执行 else if n % 3 == 0
的分支,输出 "number is divisible by 3"
。
有一点要注意,就算有多个分支能匹配,也只有第一个匹配的分支会被执行!
如果代码中有大量的 else if
会让代码变得极其丑陋,不过不用担心,下一章的 match
专门用以解决多分支模式匹配的问题。
循环控制
循环无处不在,上到数钱,下到数年,你能想象的很多场景都存在循环,因此它也是流程控制中最重要的组成部分之一。
在 Rust 语言中有三种循环方式:for
、while
和 loop
,其中 for
循环是 Rust 循环王冠上的明珠。
for 循环
for
循环是 Rust 的大杀器:
fn main() { for i in 1..=5 { println!("{}", i); } }
以上代码循环输出一个从 1 到 5 的序列,简单粗暴,核心就在于 for
和 in
的联动,语义表达如下:
#![allow(unused)] fn main() { for 元素 in 集合 { // 使用元素干一些你懂我不懂的事情 } }
这个语法跟 JavaScript 还蛮像,应该挺好理解。
注意,使用 for
时我们往往使用集合的引用形式,除非你不想在后面的代码中继续使用该集合(比如我们这里使用了 container
的引用)。如果不使用引用的话,所有权会被转移(move)到 for
语句块中,后面就无法再使用这个集合了):
#![allow(unused)] fn main() { for item in &container { // ... } }
对于实现了
copy
特征的数组(例如 [i32; 10] )而言,for item in arr
并不会把arr
的所有权转移,而是直接对其进行了拷贝,因此循环之后仍然可以使用arr
。
如果想在循环中,修改该元素,可以使用 mut
关键字:
#![allow(unused)] fn main() { for item in &mut collection { // ... } }
总结如下:
使用方法 | 等价使用方式 | 所有权 |
---|---|---|
for item in collection | for item in IntoIterator::into_iter(collection) | 转移所有权 |
for item in &collection | for item in collection.iter() | 不可变借用 |
for item in &mut collection | for item in collection.iter_mut() | 可变借用 |
如果想在循环中获取元素的索引:
fn main() { let a = [4, 3, 2, 1]; // `.iter()` 方法把 `a` 数组变成一个迭代器 for (i, v) in a.iter().enumerate() { println!("第{}个元素是{}", i + 1, v); } }
有同学可能会想到,如果我们想用 for
循环控制某个过程执行 10 次,但是又不想单独声明一个变量来控制这个流程,该怎么写?
#![allow(unused)] fn main() { for _ in 0..10 { // ... } }
可以用 _
来替代 i
用于 for
循环中,在 Rust 中 _
的含义是忽略该值或者类型的意思,如果不使用 _
,那么编译器会给你一个 变量未使用的
的警告。
两种循环方式优劣对比
以下代码,使用了两种循环方式:
#![allow(unused)] fn main() { // 第一种 let collection = [1, 2, 3, 4, 5]; for i in 0..collection.len() { let item = collection[i]; // ... } // 第二种 for item in collection { } }
第一种方式是循环索引,然后通过索引下标去访问集合,第二种方式是直接循环集合中的元素,优劣如下:
- 性能:第一种使用方式中
collection[index]
的索引访问,会因为边界检查(Bounds Checking)导致运行时的性能损耗 —— Rust 会检查并确认index
是否落在集合内,但是第二种直接迭代的方式就不会触发这种检查,因为编译器会在编译时就完成分析并证明这种访问是合法的 - 安全:第一种方式里对
collection
的索引访问是非连续的,存在一定可能性在两次访问之间,collection
发生了变化,导致脏数据产生。而第二种直接迭代的方式是连续访问,因此不存在这种风险( 由于所有权限制,在访问过程中,数据并不会发生变化)。
由于 for
循环无需任何条件限制,也不需要通过索引来访问,因此是最安全也是最常用的,通过与下面的 while
的对比,我们能看到为什么 for
会更加安全。
continue
使用 continue
可以跳过当前当次的循环,开始下次的循环:
#![allow(unused)] fn main() { for i in 1..4 { if i == 2 { continue; } println!("{}", i); } }
上面代码对 1 到 3 的序列进行迭代,且跳过值为 2 时的循环,输出如下:
1
3
break
使用 break
可以直接跳出当前整个循环:
#![allow(unused)] fn main() { for i in 1..4 { if i == 2 { break; } println!("{}", i); } }
上面代码对 1 到 3 的序列进行迭代,在遇到值为 2 时的跳出整个循环,后面的循环不再执行,输出如下:
1
while 循环
如果你需要一个条件来循环,当该条件为 true
时,继续循环,条件为 false
,跳出循环,那么 while
就非常适用:
fn main() { let mut n = 0; while n <= 5 { println!("{}!", n); n = n + 1; } println!("我出来了!"); }
该 while
循环,只有当 n
小于等于 5
时,才执行,否则就立刻跳出循环,因此在上述代码中,它会先从 0
开始,满足条件,进行循环,然后是 1
,满足条件,进行循环,最终到 6
的时候,大于 5,不满足条件,跳出 while
循环,执行 我出来了
的打印,然后程序结束:
0!
1!
2!
3!
4!
5!
我出来了!
当然,你也可以用其它方式组合实现,例如 loop
(无条件循环,将在下面介绍) + if
+ break
:
fn main() { let mut n = 0; loop { if n > 5 { break } println!("{}", n); n+=1; } println!("我出来了!"); }
可以看出,在这种循环场景下,while
要简洁的多。
while vs for
我们也能用 while
来实现 for
的功能:
fn main() { let a = [10, 20, 30, 40, 50]; let mut index = 0; while index < 5 { println!("the value is: {}", a[index]); index = index + 1; } }
这里,代码对数组中的元素进行计数。它从索引 0
开始,并接着循环直到遇到数组的最后一个索引(这时,index < 5
不再为真)。运行这段代码会打印出数组中的每一个元素:
the value is: 10
the value is: 20
the value is: 30
the value is: 40
the value is: 50
数组中的所有五个元素都如期被打印出来。尽管 index
在某一时刻会到达值 5,不过循环在其尝试从数组获取第六个值(会越界)之前就停止了。
但这个过程很容易出错;如果索引长度不正确会导致程序 panic。这也使程序更慢,因为编译器增加了运行时代码来对每次循环的每个元素进行条件检查。
for
循环代码如下:
fn main() { let a = [10, 20, 30, 40, 50]; for element in a.iter() { println!("the value is: {}", element); } }
可以看出,for
并不会使用索引去访问数组,因此更安全也更简洁,同时避免 运行时的边界检查
,性能更高。
loop 循环
对于循环而言,loop
循环毋庸置疑,是适用面最高的,它可以适用于所有循环场景(虽然能用,但是在很多场景下, for
和 while
才是最优选择),因为 loop
就是一个简单的无限循环,你可以在内部实现逻辑通过 break
关键字来控制循环何时结束。
使用 loop
循环一定要打起精神,否则你会写出下面的跑满你一个 CPU 核心的疯子代码:
fn main() {
loop {
println!("again!");
}
}
该循环会不停的在终端打印输出,直到你使用 Ctrl-C
结束程序:
again!
again!
again!
again!
^Cagain!
注意,不要轻易尝试上述代码,如果你电脑配置不行,可能会死机!!!
因此,当使用 loop
时,必不可少的伙伴是 break
关键字,它能让循环在满足某个条件时跳出:
fn main() { let mut counter = 0; let result = loop { counter += 1; if counter == 10 { break counter * 2; } }; println!("The result is {}", result); }
以上代码当 counter
递增到 10
时,就会通过 break
返回一个 counter * 2
的值,最后赋给 result
并打印出来。
这里有几点值得注意:
- break 可以单独使用,也可以带一个返回值,有些类似
return
- loop 是一个表达式,因此可以返回一个值
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
模式匹配
模式匹配,这个词,对于非函数语言编程来说,真的还蛮少听到,因为它经常出现在函数式编程里,用于为复杂的类型系统提供一个轻松的解构能力。
曾记否?在枚举和流程控制那章,我们遗留了两个问题,都是关于 match
的,第一个是如何对 Option
枚举进行进一步处理,另外一个是如何用 match
来替代 else if
这种丑陋的多重分支使用方式。那么让我们先一起来揭开 match
的神秘面纱。
match 和 if let
在 Rust 中,模式匹配最常用的就是 match
和 if let
,本章节将对两者及相关的概念进行详尽介绍。
先来看一个关于 match
的简单例子:
enum Direction { East, West, North, South, } fn main() { let dire = Direction::South; match dire { Direction::East => println!("East"), Direction::North | Direction::South => { println!("South or North"); }, _ => println!("West"), }; }
这里我们想去匹配 dire
对应的枚举类型,因此在 match
中用三个匹配分支来完全覆盖枚举变量 Direction
的所有成员类型,有以下几点值得注意:
match
的匹配必须要穷举出所有可能,因此这里用_
来代表未列出的所有可能性match
的每一个分支都必须是一个表达式,且所有分支的表达式最终返回值的类型必须相同- X | Y,类似逻辑运算符
或
,代表该分支可以匹配X
也可以匹配Y
,只要满足一个即可
其实 match
跟其他语言中的 switch
非常像,_
类似于 switch
中的 default
。
match
匹配
首先来看看 match
的通用形式:
#![allow(unused)] fn main() { match target { 模式1 => 表达式1, 模式2 => { 语句1; 语句2; 表达式2 }, _ => 表达式3 } }
该形式清晰的说明了何为模式,何为模式匹配:将模式与 target
进行匹配,即为模式匹配,而模式匹配不仅仅局限于 match
,后面我们会详细阐述。
match
允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行对应的代码,下面让我们来一一详解,先看一个例子:
#![allow(unused)] fn main() { enum Coin { Penny, Nickel, Dime, Quarter, } fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => { println!("Lucky penny!"); 1 }, Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter => 25, } } }
value_in_cents
函数根据匹配到的硬币,返回对应的美分数值。match
后紧跟着的是一个表达式,跟 if
很像,但是 if
后的表达式必须是一个布尔值,而 match
后的表达式返回值可以是任意类型,只要能跟后面的分支中的模式匹配起来即可,这里的 coin
是枚举 Coin
类型。
接下来是 match
的分支。一个分支有两个部分:一个模式和针对该模式的处理代码。第一个分支的模式是 Coin::Penny
,其后的 =>
运算符将模式和将要运行的代码分开。这里的代码就仅仅是表达式 1
,不同分支之间使用逗号分隔。
当 match
表达式执行时,它将目标值 coin
按顺序依次与每一个分支的模式相比较,如果模式匹配了这个值,那么模式之后的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支。
每个分支相关联的代码是一个表达式,而表达式的结果值将作为整个 match
表达式的返回值。如果分支有多行代码,那么需要用 {}
包裹,同时最后一行代码需要是一个表达式。
使用 match
表达式赋值
还有一点很重要,match
本身也是一个表达式,因此可以用它来赋值:
enum IpAddr { Ipv4, Ipv6 } fn main() { let ip1 = IpAddr::Ipv6; let ip_str = match ip1 { IpAddr::Ipv4 => "127.0.0.1", _ => "::1", }; println!("{}", ip_str); }
因为这里匹配到 _
分支,所以将 "::1"
赋值给了 ip_str
。
模式绑定
模式匹配的另外一个重要功能是从模式中取出绑定的值,例如:
#![allow(unused)] fn main() { #[derive(Debug)] enum UsState { Alabama, Alaska, // --snip-- } enum Coin { Penny, Nickel, Dime, Quarter(UsState), // 25美分硬币 } }
其中 Coin::Quarter
成员还存放了一个值:美国的某个州(因为在 1999 年到 2008 年间,美国在 25 美分(Quarter)硬币的背后为 50 个州印刷了不同的标记,其它硬币都没有这样的设计)。
接下来,我们希望在模式匹配中,获取到 25 美分硬币上刻印的州的名称:
#![allow(unused)] fn main() { fn value_in_cents(coin: Coin) -> u8 { match coin { Coin::Penny => 1, Coin::Nickel => 5, Coin::Dime => 10, Coin::Quarter(state) => { println!("State quarter from {:?}!", state); 25 }, } } }
上面代码中,在匹配 Coin::Quarter(state)
模式时,我们把它内部存储的值绑定到了 state
变量上,因此 state
变量就是对应的 UsState
枚举类型。
例如有一个印了阿拉斯加州标记的 25 分硬币:Coin::Quarter(UsState::Alaska)
, 它在匹配时,state
变量将被绑定 UsState::Alaska
的枚举值。
再来看一个更复杂的例子:
enum Action { Say(String), MoveTo(i32, i32), ChangeColorRGB(u16, u16, u16), } fn main() { let actions = [ Action::Say("Hello Rust".to_string()), Action::MoveTo(1,2), Action::ChangeColorRGB(255,255,0), ]; for action in actions { match action { Action::Say(s) => { println!("{}", s); }, Action::MoveTo(x, y) => { println!("point from (0, 0) move to ({}, {})", x, y); }, Action::ChangeColorRGB(r, g, _) => { println!("change color into '(r:{}, g:{}, b:0)', 'b' has been ignored", r, g, ); } } } }
运行后输出:
$ cargo run
Compiling world_hello v0.1.0 (/Users/sunfei/development/rust/world_hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.16s
Running `target/debug/world_hello`
Hello Rust
point from (0, 0) move to (1, 2)
change color into '(r:255, g:255, b:0)', 'b' has been ignored
穷尽匹配
在文章的开头,我们简单总结过 match
的匹配必须穷尽所有情况,下面来举例说明,例如:
enum Direction { East, West, North, South, } fn main() { let dire = Direction::South; match dire { Direction::East => println!("East"), Direction::North | Direction::South => { println!("South or North"); }, }; }
我们没有处理 Direction::West
的情况,因此会报错:
error[E0004]: non-exhaustive patterns: `West` not covered // 非穷尽匹配,`West` 没有被覆盖
--> src/main.rs:10:11
|
1 | / enum Direction {
2 | | East,
3 | | West,
| | ---- not covered
4 | | North,
5 | | South,
6 | | }
| |_- `Direction` defined here
...
10 | match dire {
| ^^^^ pattern `West` not covered // 模式 `West` 没有被覆盖
|
= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
= note: the matched value is of type `Direction`
不禁想感叹,Rust 的编译器真强大,忍不住想爆粗口了,sorry,如果你以后进一步深入使用 Rust 也会像我这样感叹的。Rust 编译器清晰地知道 match
中有哪些分支没有被覆盖, 这种行为能强制我们处理所有的可能性,有效避免传说中价值十亿美金的 null
陷阱。
_
通配符
当我们不想在匹配时列出所有值的时候,可以使用 Rust 提供的一个特殊模式,例如,u8
可以拥有 0 到 255 的有效的值,但是我们只关心 1、3、5 和 7
这几个值,不想列出其它的 0、2、4、6、8、9 一直到 255
的值。那么, 我们不必一个一个列出所有值, 因为可以使用特殊的模式 _
替代:
#![allow(unused)] fn main() { let some_u8_value = 0u8; match some_u8_value { 1 => println!("one"), 3 => println!("three"), 5 => println!("five"), 7 => println!("seven"), _ => (), } }
通过将 _
其放置于其他分支后,_
将会匹配所有遗漏的值。()
表示返回单元类型与所有分支返回值的类型相同,所以当匹配到 _
后,什么也不会发生。
除了_
通配符,用一个变量来承载其他情况也是可以的。
#[derive(Debug)] enum Direction { East, West, North, South, } fn main() { let dire = Direction::South; match dire { Direction::East => println!("East"), other => println!("other direction: {:?}", other), }; }
然而,在某些场景下,我们其实只关心某一个值是否存在,此时 match
就显得过于啰嗦。
if let
匹配
有时会遇到只有一个模式的值需要被处理,其它值直接忽略的场景,如果用 match
来处理就要写成下面这样:
#![allow(unused)] fn main() { let v = Some(3u8); match v { Some(3) => println!("three"), _ => (), } }
我们只想要对 Some(3)
模式进行匹配, 不想处理任何其他 Some<u8>
值或 None
值。但是为了满足 match
表达式(穷尽性)的要求,写代码时必须在处理完这唯一的成员后加上 _ => ()
,这样会增加不少无用的代码。
俗话说“杀鸡焉用牛刀”,我们完全可以用 if let
的方式来实现:
#![allow(unused)] fn main() { if let Some(3) = v { println!("three"); } }
这两种匹配对于新手来说,可能有些难以抉择,但是只要记住一点就好:当你只要匹配一个条件,且忽略其他条件时就用 if let
,否则都用 match
。
matches!宏
Rust 标准库中提供了一个非常实用的宏:matches!
,它可以将一个表达式跟模式进行匹配,然后返回匹配的结果 true
or false
。
例如,有一个动态数组,里面存有以下枚举:
enum MyEnum { Foo, Bar } fn main() { let v = vec![MyEnum::Foo,MyEnum::Bar,MyEnum::Foo]; }
现在如果想对 v
进行过滤,只保留类型是 MyEnum::Foo
的元素,你可能想这么写:
#![allow(unused)] fn main() { v.iter().filter(|x| x == MyEnum::Foo); }
但是,实际上这行代码会报错,因为你无法将 x
直接跟一个枚举成员进行比较。好在,你可以使用 match
来完成,但是会导致代码更为啰嗦,是否有更简洁的方式?答案是使用 matches!
:
#![allow(unused)] fn main() { v.iter().filter(|x| matches!(x, MyEnum::Foo)); }
很简单也很简洁,再来看看更多的例子:
#![allow(unused)] fn main() { let foo = 'f'; assert!(matches!(foo, 'A'..='Z' | 'a'..='z')); let bar = Some(4); assert!(matches!(bar, Some(x) if x > 2)); }
变量遮蔽
无论是 match
还是 if let
,这里都是一个新的代码块,而且这里的绑定相当于新变量,如果你使用同名变量,会发生变量遮蔽:
fn main() { let age = Some(30); println!("在匹配前,age是{:?}",age); if let Some(age) = age { println!("匹配出来的age是{}",age); } println!("在匹配后,age是{:?}",age); }
cargo run
运行后输出如下:
在匹配前,age是Some(30)
匹配出来的age是30
在匹配后,age是Some(30)
可以看出在 if let
中,=
右边 Some(i32)
类型的 age
被左边 i32
类型的新 age
遮蔽了,该遮蔽一直持续到 if let
语句块的结束。因此第三个 println!
输出的 age
依然是 Some(i32)
类型。
对于 match
类型也是如此:
fn main() { let age = Some(30); println!("在匹配前,age是{:?}",age); match age { Some(age) => println!("匹配出来的age是{}",age), _ => () } println!("在匹配后,age是{:?}",age); }
需要注意的是,match
中的变量遮蔽其实不是那么的容易看出,因此要小心!其实这里最好不要使用同名,避免难以理解,如下。
fn main() { let age = Some(30); println!("在匹配前,age是{:?}", age); match age { Some(x) => println!("匹配出来的age是{}", x), _ => () } println!("在匹配后,age是{:?}", age); }
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
解构 Option
在枚举那章,提到过 Option
枚举,它用来解决 Rust 中变量是否有值的问题,定义如下:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
简单解释就是:一个变量要么有值:Some(T)
, 要么为空:None
。
那么现在的问题就是该如何去使用这个 Option
枚举类型,根据我们上一节的经验,可以通过 match
来实现。
因为
Option
,Some
,None
都包含在prelude
中,因此你可以直接通过名称来使用它们,而无需以Option::Some
这种形式去使用,总之,千万不要因为调用路径变短了,就忘记Some
和None
也是Option
底下的枚举成员!
匹配 Option<T>
使用 Option<T>
,是为了从 Some
中取出其内部的 T
值以及处理没有值的情况,为了演示这一点,下面一起来编写一个函数,它获取一个 Option<i32>
,如果其中含有一个值,将其加一;如果其中没有值,则函数返回 None
值:
#![allow(unused)] fn main() { 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); let none = plus_one(None); }
plus_one
接受一个 Option<i32>
类型的参数,同时返回一个 Option<i32>
类型的值(这种形式的函数在标准库内随处所见),在该函数的内部处理中,如果传入的是一个 None
,则返回一个 None
且不做任何处理;如果传入的是一个 Some(i32)
,则通过模式绑定,把其中的值绑定到变量 i
上,然后返回 i+1
的值,同时用 Some
进行包裹。
为了进一步说明,假设 plus_one
函数接受的参数值 x 是 Some(5)
,来看看具体的分支匹配情况:
传入参数 Some(5)
None => None,
首先是匹配 None
分支,因为值 Some(5)
并不匹配模式 None
,所以继续匹配下一个分支。
Some(i) => Some(i + 1),
Some(5)
与 Some(i)
匹配吗?当然匹配!它们是相同的成员。i
绑定了 Some
中包含的值,因此 i
的值是 5
。接着匹配分支的代码被执行,最后将 i
的值加一并返回一个含有值 6
的新 Some
。
传入参数 None
接着考虑下 plus_one
的第二个调用,这次传入的 x
是 None
, 我们进入 match
并与第一个分支相比较。
None => None,
匹配上了!接着程序继续执行该分支后的代码:返回表达式 None
的值,也就是返回一个 None
,因为第一个分支就匹配到了,其他的分支将不再比较。
模式适用场景
模式
模式是 Rust 中的特殊语法,它用来匹配类型中的结构和数据,它往往和 match
表达式联用,以实现强大的模式匹配能力。模式一般由以下内容组合而成:
- 字面值
- 解构的数组、枚举、结构体或者元组
- 变量
- 通配符
- 占位符
所有可能用到模式的地方
match 分支
#![allow(unused)] fn main() { match VALUE { PATTERN => EXPRESSION, PATTERN => EXPRESSION, PATTERN => EXPRESSION, } }
如上所示,match
的每个分支就是一个模式,因为 match
匹配是穷尽式的,因此我们往往需要一个特殊的模式 _
,来匹配剩余的所有情况:
#![allow(unused)] fn main() { match VALUE { PATTERN => EXPRESSION, PATTERN => EXPRESSION, _ => EXPRESSION, } }
if let 分支
if let
往往用于匹配一个模式,而忽略剩下的所有模式的场景:
#![allow(unused)] fn main() { if let PATTERN = SOME_VALUE { } }
while let 条件循环
一个与 if let
类似的结构是 while let
条件循环,它允许只要模式匹配就一直进行 while
循环。下面展示了一个使用 while let
的例子:
#![allow(unused)] fn main() { // Vec是动态数组 let mut stack = Vec::new(); // 向数组尾部插入元素 stack.push(1); stack.push(2); stack.push(3); // stack.pop从数组尾部弹出元素 while let Some(top) = stack.pop() { println!("{}", top); } }
这个例子会打印出 3
、2
接着是 1
。pop
方法取出动态数组的最后一个元素并返回 Some(value)
,如果动态数组是空的,将返回 None
,对于 while
来说,只要 pop
返回 Some
就会一直不停的循环。一旦其返回 None
,while
循环停止。我们可以使用 while let
来弹出栈中的每一个元素。
你也可以用 loop
+ if let
或者 match
来实现这个功能,但是会更加啰嗦。
for 循环
#![allow(unused)] fn main() { let v = vec!['a', 'b', 'c']; for (index, value) in v.iter().enumerate() { println!("{} is at index {}", value, index); } }
这里使用 enumerate
方法产生一个迭代器,该迭代器每次迭代会返回一个 (索引,值)
形式的元组,然后用 (index,value)
来匹配。
let 语句
#![allow(unused)] fn main() { let PATTERN = EXPRESSION; }
是的, 该语句我们已经用了无数次了,它也是一种模式匹配:
#![allow(unused)] fn main() { let x = 5; }
这其中,x
也是一种模式绑定,代表将匹配的值绑定到变量 x 上。因此,在 Rust 中,变量名也是一种模式,只不过它比较朴素很不起眼罢了。
#![allow(unused)] fn main() { let (x, y, z) = (1, 2, 3); }
上面将一个元组与模式进行匹配(模式和值的类型必需相同!),然后把 1, 2, 3
分别绑定到 x, y, z
上。
模式匹配要求两边的类型必须相同,否则就会导致下面的报错:
#![allow(unused)] fn main() { let (x, y) = (1, 2, 3); }
#![allow(unused)] fn main() { error[E0308]: mismatched types --> src/main.rs:4:5 | 4 | let (x, y) = (1, 2, 3); | ^^^^^^ --------- this expression has type `({integer}, {integer}, {integer})` | | | expected a tuple with 3 elements, found one with 2 elements | = note: expected tuple `({integer}, {integer}, {integer})` found tuple `(_, _)` For more information about this error, try `rustc --explain E0308`. error: could not compile `playground` due to previous error }
对于元组来说,元素个数也是类型的一部分!
函数参数
函数参数也是模式:
#![allow(unused)] fn main() { fn foo(x: i32) { // 代码 } }
其中 x
就是一个模式,你还可以在参数中匹配元组:
fn print_coordinates(&(x, y): &(i32, i32)) { println!("Current location: ({}, {})", x, y); } fn main() { let point = (3, 5); print_coordinates(&point); }
&(3, 5)
会匹配模式 &(x, y)
,因此 x
得到了 3
,y
得到了 5
。
let 和 if let
对于以下代码,编译器会报错:
#![allow(unused)] fn main() { let Some(x) = some_option_value; }
因为右边的值可能不为 Some
,而是 None
,这种时候就不能进行匹配,也就是上面的代码遗漏了 None
的匹配。
类似 let
, for
和match
都必须要求完全覆盖匹配,才能通过编译( 不可驳模式匹配 )。
但是对于 if let
,就可以这样使用:
#![allow(unused)] fn main() { if let Some(x) = some_option_value { println!("{}", x); } }
因为 if let
允许匹配一种模式,而忽略其余的模式( 可驳模式匹配 )。
全模式列表
在本书中我们已领略过许多不同类型模式的例子,本节的目标就是把这些模式语法都罗列出来,方便大家检索查阅(模式匹配在我们的开发中会经常用到)。
匹配字面值
#![allow(unused)] fn main() { let x = 1; match x { 1 => println!("one"), 2 => println!("two"), 3 => println!("three"), _ => println!("anything"), } }
这段代码会打印 one
因为 x
的值是 1,如果希望代码获得特定的具体值,那么这种语法很有用。
匹配命名变量
在 match 中,我们有讲过变量遮蔽的问题,这个在匹配命名变量时会遇到:
fn main() { let x = Some(5); let y = 10; match x { Some(50) => println!("Got 50"), Some(y) => println!("Matched, y = {:?}", y), _ => println!("Default case, x = {:?}", x), } println!("at the end: x = {:?}, y = {:?}", x, y); }
让我们看看当 match
语句运行的时候发生了什么。第一个匹配分支的模式并不匹配 x
中定义的值,所以代码继续执行。
第二个匹配分支中的模式引入了一个新变量 y
,它会匹配任何 Some
中的值。因为这里的 y
在 match
表达式的作用域中,而不是之前 main
作用域中,所以这是一个新变量,不是开头声明为值 10 的那个 y
。这个新的 y
绑定会匹配任何 Some
中的值,在这里是 x
中的值。因此这个 y
绑定了 x
中 Some
内部的值。这个值是 5,所以这个分支的表达式将会执行并打印出 Matched,y = 5
。
如果 x
的值是 None
而不是 Some(5)
,头两个分支的模式不会匹配,所以会匹配模式 _
。这个分支的模式中没有引入变量 x
,所以此时表达式中的 x
会是外部没有被遮蔽的 x
,也就是 None
。
一旦 match
表达式执行完毕,其作用域也就结束了,同理内部 y
的作用域也结束了。最后的 println!
会打印 at the end: x = Some(5), y = 10
。
如果你不想引入变量遮蔽,可以使用另一个变量名而非 y
,或者使用匹配守卫(match guard)的方式,稍后在匹配守卫提供的额外条件中会讲解。
单分支多模式
在 match
表达式中,可以使用 |
语法匹配多个模式,它代表 或的意思。例如,如下代码将 x
的值与匹配分支相比较,第一个分支有 或 选项,意味着如果 x
的值匹配此分支的任何一个模式,它就会运行:
#![allow(unused)] fn main() { let x = 1; match x { 1 | 2 => println!("one or two"), 3 => println!("three"), _ => println!("anything"), } }
上面的代码会打印 one or two
。
通过序列 ..=
匹配值的范围
在数值类型中我们有讲到一个序列语法,该语法不仅可以用于循环中,还能用于匹配模式。
..=
语法允许你匹配一个闭区间序列内的值。在如下代码中,当模式匹配任何在此序列内的值时,该分支会执行:
#![allow(unused)] fn main() { let x = 5; match x { 1..=5 => println!("one through five"), _ => println!("something else"), } }
如果 x
是 1、2、3、4 或 5,第一个分支就会匹配。这相比使用 |
运算符表达相同的意思更为方便;相比 1..=5
,使用 |
则不得不指定 1 | 2 | 3 | 4 | 5
这五个值,而使用 ..=
指定序列就简短的多,比如希望匹配比如从 1 到 1000 的数字的时候!
序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。
如下是一个使用字符类型序列的例子:
#![allow(unused)] fn main() { let x = 'c'; match x { 'a'..='j' => println!("early ASCII letter"), 'k'..='z' => println!("late ASCII letter"), _ => println!("something else"), } }
Rust 知道 'c'
位于第一个模式的序列内,所以会打印出 early ASCII letter
。
解构并分解值
也可以使用模式来解构结构体、枚举、元组、数组和引用。
解构结构体
下面代码展示了如何用 let
解构一个带有两个字段 x
和 y
的结构体 Point
:
struct Point { x: i32, y: i32, } fn main() { let p = Point { x: 0, y: 7 }; let Point { x: a, y: b } = p; assert_eq!(0, a); assert_eq!(7, b); }
这段代码创建了变量 a
和 b
来匹配结构体 p
中的 x
和 y
字段,这个例子展示了模式中的变量名不必与结构体中的字段名一致。不过通常希望变量名与字段名一致以便于理解变量来自于哪些字段。
因为变量名匹配字段名是常见的,同时因为 let Point { x: x, y: y } = p;
中 x
和 y
重复了,所以对于匹配结构体字段的模式存在简写:只需列出结构体字段的名称,则模式创建的变量会有相同的名称。下例与上例有着相同行为的代码,不过 let
模式创建的变量为 x
和 y
而不是 a
和 b
:
struct Point { x: i32, y: i32, } fn main() { let p = Point { x: 0, y: 7 }; let Point { x, y } = p; assert_eq!(0, x); assert_eq!(7, y); }
这段代码创建了变量 x
和 y
,与结构体 p
中的 x
和 y
字段相匹配。其结果是变量 x
和 y
包含结构体 p
中的值。
也可以使用字面值作为结构体模式的一部分进行解构,而不是为所有的字段创建变量。这允许我们测试一些字段为特定值的同时创建其他字段的变量。
下文展示了固定某个字段的匹配方式:
struct Point { x: i32, y: i32, } fn main() { let p = Point { x: 0, y: 7 }; match p { Point { x, y: 0 } => println!("On the x axis at {}", x), Point { x: 0, y } => println!("On the y axis at {}", y), Point { x, y } => println!("On neither axis: ({}, {})", x, y), } }
首先是 match
第一个分支,指定匹配 y
为 0
的 Point
;
然后第二个分支在第一个分支之后,匹配 y
不为 0
,x
为 0
的 Point
;
最后一个分支匹配 x
不为 0
,y
也不为 0
的 Point
。
在这个例子中,值 p
因为其 x
包含 0 而匹配第二个分支,因此会打印出 On the y axis at 7
。
解构枚举
下面代码以 Message
枚举为例,编写一个 match
使用模式解构每一个内部值:
enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } fn main() { let msg = Message::ChangeColor(0, 160, 255); match msg { Message::Quit => { println!("The Quit variant has no data to destructure.") } Message::Move { x, y } => { println!( "Move in the x direction {} and in the y direction {}", x, y ); } Message::Write(text) => println!("Text message: {}", text), Message::ChangeColor(r, g, b) => { println!( "Change the color to red {}, green {}, and blue {}", r, g, b ) } } }
这里老生常谈一句话,模式匹配一样要类型相同,因此匹配 Message::Move{1,2}
这样的枚举值,就必须要用 Message::Move{x,y}
这样的同类型模式才行。
这段代码会打印出 Change the color to red 0, green 160, and blue 255
。尝试改变 msg
的值来观察其他分支代码的运行。
对于像 Message::Quit
这样没有任何数据的枚举成员,不能进一步解构其值。只能匹配其字面值 Message::Quit
,因此模式中没有任何变量。
对于另外两个枚举成员,就用相同类型的模式去匹配出对应的值即可。
解构嵌套的结构体和枚举
目前为止,所有的例子都只匹配了深度为一级的结构体或枚举。 match
也可以匹配嵌套的项!
例如使用下面的代码来同时支持 RGB 和 HSV 色彩模式:
enum Color { Rgb(i32, i32, i32), Hsv(i32, i32, i32), } enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(Color), } fn main() { let msg = Message::ChangeColor(Color::Hsv(0, 160, 255)); match msg { Message::ChangeColor(Color::Rgb(r, g, b)) => { println!( "Change the color to red {}, green {}, and blue {}", r, g, b ) } Message::ChangeColor(Color::Hsv(h, s, v)) => { println!( "Change the color to hue {}, saturation {}, and value {}", h, s, v ) } _ => () } }
match
第一个分支的模式匹配一个 Message::ChangeColor
枚举成员,该枚举成员又包含了一个 Color::Rgb
的枚举成员,最终绑定了 3 个内部的 i32
值。第二个,就交给亲爱的读者来思考完成。
解构结构体和元组
我们甚至可以用复杂的方式来混合、匹配和嵌套解构模式。如下是一个复杂结构体的例子,其中结构体和元组嵌套在元组中,并将所有的原始类型解构出来:
#![allow(unused)] fn main() { struct Point { x: i32, y: i32, } let ((feet, inches), Point {x, y}) = ((3, 10), Point { x: 3, y: -10 }); }
这种将复杂类型分解匹配的方式,可以让我们单独得到感兴趣的某个值。
解构数组
对于数组,我们可以用类似元组的方式解构,分为两种情况:
定长数组
#![allow(unused)] fn main() { let arr: [u16; 2] = [114, 514]; let [x, y] = arr; assert_eq!(x, 114); assert_eq!(y, 514); }
不定长数组
#![allow(unused)] fn main() { let arr: &[u16] = &[114, 514]; if let [x, ..] = arr { assert_eq!(x, &114); } if let &[.., y] = arr { assert_eq!(y, 514); } let arr: &[u16] = &[]; assert!(matches!(arr, [..])); assert!(!matches!(arr, [x, ..])); }
忽略模式中的值
有时忽略模式中的一些值是很有用的,比如在 match
中的最后一个分支使用 _
模式匹配所有剩余的值。 你也可以在另一个模式中使用 _
模式,使用一个以下划线开始的名称,或者使用 ..
忽略所剩部分的值。
使用 _
忽略整个值
虽然 _
模式作为 match
表达式最后的分支特别有用,但是它的作用还不限于此。例如可以将其用于函数参数中:
fn foo(_: i32, y: i32) { println!("This code only uses the y parameter: {}", y); } fn main() { foo(3, 4); }
这段代码会完全忽略作为第一个参数传递的值 3
,并会打印出 This code only uses the y parameter: 4
。
大部分情况当你不再需要特定函数参数时,最好修改签名不再包含无用的参数。在一些情况下忽略函数参数会变得特别有用,比如实现特征时,当你需要特定类型签名但是函数实现并不需要某个参数时。此时编译器就不会警告说存在未使用的函数参数,就跟使用命名参数一样。
使用嵌套的 _
忽略部分值
可以在一个模式内部使用 _
忽略部分值:
#![allow(unused)] fn main() { let mut setting_value = Some(5); let new_setting_value = Some(10); match (setting_value, new_setting_value) { (Some(_), Some(_)) => { println!("Can't overwrite an existing customized value"); } _ => { setting_value = new_setting_value; } } println!("setting is {:?}", setting_value); }
这段代码会打印出 Can't overwrite an existing customized value
接着是 setting is Some(5)
。
第一个匹配分支,我们不关心里面的值,只关心元组中两个元素的类型,因此对于 Some
中的值,直接进行忽略。
剩下的形如 (Some(_),None)
,(None, Some(_))
, (None,None)
形式,都由第二个分支 _
进行分配。
还可以在一个模式中的多处使用下划线来忽略特定值,如下所示,这里忽略了一个五元元组中的第二和第四个值:
#![allow(unused)] fn main() { let numbers = (2, 4, 8, 16, 32); match numbers { (first, _, third, _, fifth) => { println!("Some numbers: {}, {}, {}", first, third, fifth) }, } }
老生常谈:模式匹配一定要类型相同,因此匹配 numbers
元组的模式,也必须有五个值(元组中元素的数量也属于元组类型的一部分)。
这会打印出 Some numbers: 2, 8, 32
, 值 4 和 16 会被忽略。
使用下划线开头忽略未使用的变量
如果你创建了一个变量却不在任何地方使用它,Rust 通常会给你一个警告,因为这可能会是个 BUG。但是有时创建一个不会被使用的变量是有用的,比如你正在设计原型或刚刚开始一个项目。这时你希望告诉 Rust 不要警告未使用的变量,为此可以用下划线作为变量名的开头:
fn main() { let _x = 5; let y = 10; }
这里得到了警告说未使用变量 y
,至于 x
则没有警告。
注意, 只使用 _
和使用以下划线开头的名称有些微妙的不同:比如 _x
仍会将值绑定到变量,而 _
则完全不会绑定。
#![allow(unused)] fn main() { let s = Some(String::from("Hello!")); if let Some(_s) = s { println!("found a string"); } println!("{:?}", s); }
s
是一个拥有所有权的动态字符串,在上面代码中,我们会得到一个错误,因为 s
的值会被转移给 _s
,在 println!
中再次使用 s
会报错:
error[E0382]: borrow of partially moved value: `s`
--> src/main.rs:8:22
|
4 | if let Some(_s) = s {
| -- value partially moved here
...
8 | println!("{:?}", s);
| ^ value borrowed here after partial move
只使用下划线本身,则并不会绑定值,因为 s
没有被移动进 _
:
#![allow(unused)] fn main() { let s = Some(String::from("Hello!")); if let Some(_) = s { println!("found a string"); } println!("{:?}", s); }
用 ..
忽略剩余值
对于有多个部分的值,可以使用 ..
语法来只使用部分值而忽略其它值,这样也不用再为每一个被忽略的值都单独列出下划线。..
模式会忽略模式中剩余的任何没有显式匹配的值部分。
#![allow(unused)] fn main() { struct Point { x: i32, y: i32, z: i32, } let origin = Point { x: 0, y: 0, z: 0 }; match origin { Point { x, .. } => println!("x is {}", x), } }
这里列出了 x
值,接着使用了 ..
模式来忽略其它字段,这样的写法要比一一列出其它字段,然后用 _
忽略简洁的多。
还可以用 ..
来忽略元组中间的某些值:
fn main() { let numbers = (2, 4, 8, 16, 32); match numbers { (first, .., last) => { println!("Some numbers: {}, {}", first, last); }, } }
这里用 first
和 last
来匹配第一个和最后一个值。..
将匹配并忽略中间的所有值。
然而使用 ..
必须是无歧义的。如果期望匹配和忽略的值是不明确的,Rust 会报错。下面代码展示了一个带有歧义的 ..
例子,因此不能编译:
fn main() { let numbers = (2, 4, 8, 16, 32); match numbers { (.., second, ..) => { println!("Some numbers: {}", second) }, } }
如果编译上面的例子,会得到下面的错误:
error: `..` can only be used once per tuple pattern // 每个元组模式只能使用一个 `..`
--> src/main.rs:5:22
|
5 | (.., second, ..) => {
| -- ^^ can only be used once per tuple pattern
| |
| previously used here // 上一次使用在这里
error: could not compile `world_hello` due to previous error ^^
Rust 无法判断,second
应该匹配 numbers
中的第几个元素,因此这里使用两个 ..
模式,是有很大歧义的!
匹配守卫提供的额外条件
匹配守卫(match guard)是一个位于 match
分支模式之后的额外 if
条件,它能为分支模式提供更进一步的匹配条件。
这个条件可以使用模式中创建的变量:
#![allow(unused)] fn main() { let num = Some(4); match num { Some(x) if x < 5 => println!("less than five: {}", x), Some(x) => println!("{}", x), None => (), } }
这个例子会打印出 less than five: 4
。当 num
与模式中第一个分支匹配时,Some(4)
可以与 Some(x)
匹配,接着匹配守卫检查 x
值是否小于 5,因为 4 小于 5,所以第一个分支被选择。
相反如果 num
为 Some(10)
,因为 10 不小于 5 ,所以第一个分支的匹配守卫为假。接着 Rust 会前往第二个分支,因为这里没有匹配守卫所以会匹配任何 Some
成员。
模式中无法提供类如 if x < 5
的表达能力,我们可以通过匹配守卫的方式来实现。
在之前,我们提到可以使用匹配守卫来解决模式中变量覆盖的问题,那里 match
表达式的模式中新建了一个变量而不是使用 match
之外的同名变量。内部变量覆盖了外部变量,意味着此时不能够使用外部变量的值,下面代码展示了如何使用匹配守卫修复这个问题。
fn main() { let x = Some(5); let y = 10; match x { Some(50) => println!("Got 50"), Some(n) if n == y => println!("Matched, n = {}", n), _ => println!("Default case, x = {:?}", x), } println!("at the end: x = {:?}, y = {}", x, y); }
现在这会打印出 Default case, x = Some(5)
。现在第二个匹配分支中的模式不会引入一个覆盖外部 y
的新变量 y
,这意味着可以在匹配守卫中使用外部的 y
。相比指定会覆盖外部 y
的模式 Some(y)
,这里指定为 Some(n)
。此新建的变量 n
并没有覆盖任何值,因为 match
外部没有变量 n
。
匹配守卫 if n == y
并不是一个模式所以没有引入新变量。这个 y
正是 外部的 y
而不是新的覆盖变量 y
,这样就可以通过比较 n
和 y
来表达寻找一个与外部 y
相同的值的概念了。
也可以在匹配守卫中使用 或 运算符 |
来指定多个模式,同时匹配守卫的条件会作用于所有的模式。下面代码展示了匹配守卫与 |
的优先级。这个例子中看起来好像 if y
只作用于 6
,但实际上匹配守卫 if y
作用于 4
、5
和 6
,在满足 x
属于 4 | 5 | 6
后才会判断 y
是否为 true
:
#![allow(unused)] fn main() { let x = 4; let y = false; match x { 4 | 5 | 6 if y => println!("yes"), _ => println!("no"), } }
这个匹配条件表明此分支只匹配 x
值为 4
、5
或 6
同时 y
为 true
的情况。
虽然在第一个分支中,x
匹配了模式 4
,但是对于匹配守卫 if y
来说,因为 y
是 false
,因此该守卫条件的值永远是 false
,也意味着第一个分支永远无法被匹配。
下面的文字图解释了匹配守卫作用于多个模式时的优先级规则,第一张是正确的:
(4 | 5 | 6) if y => ...
而第二张图是错误的
4 | 5 | (6 if y) => ...
可以通过运行代码时的情况看出这一点:如果匹配守卫只作用于由 |
运算符指定的值列表的最后一个值,这个分支就会匹配且程序会打印出 yes
。
@绑定
@
(读作 at)运算符允许为一个字段绑定另外一个变量。下面例子中,我们希望测试 Message::Hello
的 id
字段是否位于 3..=7
范围内,同时也希望能将其值绑定到 id_variable
变量中以便此分支中相关的代码可以使用它。我们可以将 id_variable
命名为 id
,与字段同名,不过出于示例的目的这里选择了不同的名称。
#![allow(unused)] fn main() { enum Message { Hello { id: i32 }, } let msg = Message::Hello { id: 5 }; match msg { Message::Hello { id: id_variable @ 3..=7 } => { println!("Found an id in range: {}", id_variable) }, Message::Hello { id: 10..=12 } => { println!("Found an id in another range") }, Message::Hello { id } => { println!("Found some other id: {}", id) }, } }
上例会打印出 Found an id in range: 5
。通过在 3..=7
之前指定 id_variable @
,我们捕获了任何匹配此范围的值并同时将该值绑定到变量 id_variable
上。
第二个分支只在模式中指定了一个范围,id
字段的值可以是 10、11 或 12
,不过这个模式的代码并不知情也不能使用 id
字段中的值,因为没有将 id
值保存进一个变量。
最后一个分支指定了一个没有范围的变量,此时确实拥有可以用于分支代码的变量 id
,因为这里使用了结构体字段简写语法。不过此分支中没有像头两个分支那样对 id
字段的值进行测试:任何值都会匹配此分支。
当你既想要限定分支范围,又想要使用分支的变量时,就可以用 @
来绑定到一个新的变量上,实现想要的功能。
@前绑定后解构(Rust 1.56 新增)
使用 @
还可以在绑定新变量的同时,对目标进行解构:
#[derive(Debug)] struct Point { x: i32, y: i32, } fn main() { // 绑定新变量 `p`,同时对 `Point` 进行解构 let p @ Point {x: px, y: py } = Point {x: 10, y: 23}; println!("x: {}, y: {}", px, py); println!("{:?}", p); let point = Point {x: 10, y: 5}; if let p @ Point {x: 10, y} = point { println!("x is 10 and y is {} in {:?}", y, p); } else { println!("x was not 10 :("); } }
@新特性(Rust 1.53 新增)
考虑下面一段代码:
fn main() { match 1 { num @ 1 | 2 => { println!("{}", num); } _ => {} } }
编译不通过,是因为 num
没有绑定到所有的模式上,只绑定了模式 1
,你可能会试图通过这个方式来解决:
#![allow(unused)] fn main() { num @ (1 | 2) }
但是,如果你用的是 Rust 1.53 之前的版本,那这种写法会报错,因为编译器不支持。
至此,模式匹配的内容已经全部完结,复杂但是详尽,想要一次性全部记住属实不易,因此读者可以先留一个印象,等未来需要时,再来翻阅寻找具体的模式实现方式。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
方法 Method
从面向对象语言过来的同学对于方法肯定不陌生,class
里面就充斥着方法的概念。在 Rust 中,方法的概念也大差不差,往往和对象成对出现:
#![allow(unused)] fn main() { object.method() }
例如读取一个文件写入缓冲区,如果用函数的写法 read(f, buffer)
,用方法的写法 f.read(buffer)
。不过与其它语言 class
跟方法的联动使用不同(这里可能要修改下),Rust 的方法往往跟结构体、枚举、特征(Trait)一起使用,特征将在后面几章进行介绍。
定义方法
Rust 使用 impl
来定义方法,例如以下代码:
#![allow(unused)] fn main() { struct Circle { x: f64, y: f64, radius: f64, } impl Circle { // new是Circle的关联函数,因为它的第一个参数不是self,且new并不是关键字 // 这种方法往往用于初始化当前结构体的实例 fn new(x: f64, y: f64, radius: f64) -> Circle { Circle { x: x, y: y, radius: radius, } } // Circle的方法,&self表示借用当前的Circle结构体 fn area(&self) -> f64 { std::f64::consts::PI * (self.radius * self.radius) } } }
我们这里先不详细展开讲解,只是先建立对方法定义的大致印象。下面的图片将 Rust 方法定义与其它语言的方法定义做了对比:

可以看出,其它语言中所有定义都在 class
中,但是 Rust 的对象定义和方法定义是分离的,这种数据和使用分离的方式,会给予使用者极高的灵活度。
再来看一个例子:
#[derive(Debug)] struct Rectangle { width: u32, height: u32, } impl Rectangle { fn area(&self) -> u32 { self.width * self.height } } fn main() { let rect1 = Rectangle { width: 30, height: 50 }; println!( "The area of the rectangle is {} square pixels.", rect1.area() ); }
该例子定义了一个 Rectangle
结构体,并且在其上定义了一个 area
方法,用于计算该矩形的面积。
impl Rectangle {}
表示为 Rectangle
实现方法(impl
是实现 implementation 的缩写),这样的写法表明 impl
语句块中的一切都是跟 Rectangle
相关联的。
self、&self 和 &mut self
接下来的内容非常重要,请大家仔细看。在 area
的签名中,我们使用 &self
替代 rectangle: &Rectangle
,&self
其实是 self: &Self
的简写(注意大小写)。在一个 impl
块内,Self
指代被实现方法的结构体类型,self
指代此类型的实例,换句话说,self
指代的是 Rectangle
结构体实例,这样的写法会让我们的代码简洁很多,而且非常便于理解:我们为哪个结构体实现方法,那么 self
就是指代哪个结构体的实例。
需要注意的是,self
依然有所有权的概念:
self
表示Rectangle
的所有权转移到该方法中,这种形式用的较少&self
表示该方法对Rectangle
的不可变借用&mut self
表示可变借用
总之,self
的使用就跟函数参数一样,要严格遵守 Rust 的所有权规则。
回到上面的例子中,选择 &self
的理由跟在函数中使用 &Rectangle
是相同的:我们并不想获取所有权,也无需去改变它,只是希望能够读取结构体中的数据。如果想要在方法中去改变当前的结构体,需要将第一个参数改为 &mut self
。仅仅通过使用 self
作为第一个参数来使方法获取实例的所有权是很少见的,这种使用方式往往用于把当前的对象转成另外一个对象时使用,转换完后,就不再关注之前的对象,且可以防止对之前对象的误调用。
简单总结下,使用方法代替函数有以下好处:
- 不用在函数签名中重复书写
self
对应的类型 - 代码的组织性和内聚性更强,对于代码维护和阅读来说,好处巨大
方法名跟结构体字段名相同
在 Rust 中,允许方法名跟结构体的字段名相同:
impl Rectangle { fn width(&self) -> bool { self.width > 0 } } fn main() { let rect1 = Rectangle { width: 30, height: 50, }; if rect1.width() { println!("The rectangle has a nonzero width; it is {}", rect1.width); } }
当我们使用 rect1.width()
时,Rust 知道我们调用的是它的方法,如果使用 rect1.width
,则是访问它的字段。
一般来说,方法跟字段同名,往往适用于实现 getter
访问器,例如:
pub struct Rectangle { width: u32, height: u32, } impl Rectangle { pub fn new(width: u32, height: u32) -> Self { Rectangle { width, height } } pub fn width(&self) -> u32 { return self.width; } } fn main() { let rect1 = Rectangle::new(30, 50); println!("{}", rect1.width()); }
用这种方式,我们可以把 Rectangle
的字段设置为私有属性,只需把它的 new
和 width
方法设置为公开可见,那么用户就可以创建一个矩形,同时通过访问器 rect1.width()
方法来获取矩形的宽度,因为 width
字段是私有的,当用户访问 rect1.width
字段时,就会报错。注意在此例中,Self
指代的就是被实现方法的结构体 Rectangle
。
->
运算符到哪去了?在 C/C++ 语言中,有两个不同的运算符来调用方法:
.
直接在对象上调用方法,而->
在一个对象的指针上调用方法,这时需要先解引用指针。换句话说,如果object
是一个指针,那么object->something()
和(*object).something()
是一样的。Rust 并没有一个与
->
等效的运算符;相反,Rust 有一个叫 自动引用和解引用的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。他是这样工作的:当使用
object.something()
调用方法时,Rust 会自动为object
添加&
、&mut
或*
以便使object
与方法签名匹配。也就是说,这些代码是等价的:#![allow(unused)] fn main() { #[derive(Debug,Copy,Clone)] struct Point { x: f64, y: f64, } impl Point { fn distance(&self, other: &Point) -> f64 { let x_squared = f64::powi(other.x - self.x, 2); let y_squared = f64::powi(other.y - self.y, 2); f64::sqrt(x_squared + y_squared) } } let p1 = Point { x: 0.0, y: 0.0 }; let p2 = Point { x: 5.0, y: 6.5 }; p1.distance(&p2); (&p1).distance(&p2); }
第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者————
self
的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self
),做出修改(&mut self
)或者是获取所有权(self
)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。
带有多个参数的方法
方法和函数一样,可以使用多个参数:
impl Rectangle { fn area(&self) -> u32 { self.width * self.height } fn can_hold(&self, other: &Rectangle) -> bool { self.width > other.width && self.height > other.height } } fn main() { let rect1 = Rectangle { width: 30, height: 50 }; let rect2 = Rectangle { width: 10, height: 40 }; let rect3 = Rectangle { width: 60, height: 45 }; println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2)); println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3)); }
关联函数
现在大家可以思考一个问题,如何为一个结构体定义一个构造器方法?也就是接受几个参数,然后构造并返回该结构体的实例。其实答案在开头的代码片段中就给出了,很简单,参数中不包含 self
即可。
这种定义在 impl
中且没有 self
的函数被称之为关联函数: 因为它没有 self
,不能用 f.read()
的形式调用,因此它是一个函数而不是方法,它又在 impl
中,与结构体紧密关联,因此称为关联函数。
在之前的代码中,我们已经多次使用过关联函数,例如 String::from
,用于创建一个动态字符串。
#![allow(unused)] fn main() { #[derive(Debug)] struct Rectangle { width: u32, height: u32, } impl Rectangle { fn new(w: u32, h: u32) -> Rectangle { Rectangle { width: w, height: h } } } }
Rust 中有一个约定俗成的规则,使用
new
来作为构造器的名称,出于设计上的考虑,Rust 特地没有用new
作为关键字。
因为是函数,所以不能用 .
的方式来调用,我们需要用 ::
来调用,例如 let sq = Rectangle::new(3, 3);
。这个方法位于结构体的命名空间中:::
语法用于关联函数和模块创建的命名空间。
多个 impl 定义
Rust 允许我们为一个结构体定义多个 impl
块,目的是提供更多的灵活性和代码组织性,例如当方法多了后,可以把相关的方法组织在同一个 impl
块中,那么就可以形成多个 impl
块,各自完成一块儿目标:
#![allow(unused)] fn main() { #[derive(Debug)] struct Rectangle { width: u32, height: u32, } impl Rectangle { fn area(&self) -> u32 { self.width * self.height } } impl Rectangle { fn can_hold(&self, other: &Rectangle) -> bool { self.width > other.width && self.height > other.height } } }
当然,就这个例子而言,我们没必要使用两个 impl
块,这里只是为了演示方便。
为枚举实现方法
枚举类型之所以强大,不仅仅在于它好用、可以同一化类型,还在于,我们可以像结构体一样,为枚举实现方法:
#![allow(unused)] enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } impl Message { fn call(&self) { // 在这里定义方法体 } } fn main() { let m = Message::Write(String::from("hello")); m.call(); }
除了结构体和枚举,我们还能为特征(trait)实现方法,这将在下一章进行讲解,在此之前,先来看看泛型。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
泛型和特征
泛型和特征是 Rust 中最最重要的抽象类型,也是你在学习 Rust 路上的拦路虎,但是挑战往往与乐趣并存,一旦学会,在后面学习 Rust 的路上,你将一往无前。
泛型 Generics
Go 语言在 2022 年,就要正式引入泛型,被视为在 1.0 版本后,语言特性发展迈出的一大步,为什么泛型这么重要?到底什么是泛型?Rust 的泛型有几种? 本章将一一为你讲解。
我们在编程中,经常有这样的需求:用同一功能的函数处理不同类型的数据,例如两个数的加法,无论是整数还是浮点数,甚至是自定义类型,都能进行支持。在不支持泛型的编程语言中,通常需要为每一种类型编写一个函数:
fn add_i8(a:i8, b:i8) -> i8 { a + b } fn add_i32(a:i32, b:i32) -> i32 { a + b } fn add_f64(a:f64, b:f64) -> f64 { a + b } fn main() { println!("add i8: {}", add_i8(2i8, 3i8)); println!("add i32: {}", add_i32(20, 30)); println!("add f64: {}", add_f64(1.23, 1.23)); }
上述代码可以正常运行,但是很啰嗦,如果你要支持更多的类型,那么会更繁琐。程序员或多或少都有强迫症,一个好程序员的公认特征就是 —— 懒,这么勤快的写一大堆代码,显然不是咱们的优良传统,是不?
在开始讲解 Rust 的泛型之前,先来看看什么是多态。
在编程的时候,我们经常利用多态。通俗的讲,多态就是好比坦克的炮管,既可以发射普通弹药,也可以发射制导炮弹(导弹),也可以发射贫铀穿甲弹,甚至发射子母弹,没有必要为每一种炮弹都在坦克上分别安装一个专用炮管,即使生产商愿意,炮手也不愿意,累死人啊。所以在编程开发中,我们也需要这样“通用的炮管”,这个“通用的炮管”就是多态。
实际上,泛型就是一种多态。泛型主要目的是为程序员提供编程的便利,减少代码的臃肿,同时可以极大地丰富语言本身的表达能力,为程序员提供了一个合适的炮管。想想,一个函数,可以代替几十个,甚至数百个函数,是一件多么让人兴奋的事情:
fn add<T>(a:T, b:T) -> T { a + b } fn main() { println!("add i8: {}", add(2i8, 3i8)); println!("add i32: {}", add(20, 30)); println!("add f64: {}", add(1.23, 1.23)); }
将之前的代码改成上面这样,就是 Rust 泛型的初印象,这段代码虽然很简洁,但是并不能编译通过,我们会在后面进行详细讲解,现在只要对泛型有个大概的印象即可。
泛型详解
上面代码的 T
就是泛型参数,实际上在 Rust 中,泛型参数的名称你可以任意起,但是出于惯例,我们都用 T
( T
是 type
的首字母)来作为首选,这个名称越短越好,除非需要表达含义,否则一个字母是最完美的。
使用泛型参数,有一个先决条件,必需在使用前对其进行声明:
#![allow(unused)] fn main() { fn largest<T>(list: &[T]) -> T { }
该泛型函数的作用是从列表中找出最大的值,其中列表中的元素类型为 T。首先 largest<T>
对泛型参数 T
进行了声明,然后才在函数参数中进行使用该泛型参数 list: &[T]
(还记得 &[T]
类型吧?这是数组切片)。
总之,我们可以这样理解这个函数定义:函数 largest
有泛型类型 T
,它有个参数 list
,其类型是元素为 T
的数组切片,最后,该函数返回值的类型也是 T
。
下面是一个错误的泛型函数的实现:
fn largest<T>(list: &[T]) -> T { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } fn main() { let number_list = vec![34, 50, 25, 100, 65]; let result = largest(&number_list); println!("The largest number is {}", result); let char_list = vec!['y', 'm', 'a', 'q']; let result = largest(&char_list); println!("The largest char is {}", result); }
运行后报错:
error[E0369]: binary operation `>` cannot be applied to type `T` // `>`操作符不能用于类型`T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- T
| |
| T
|
help: consider restricting type parameter `T` // 考虑对T进行类型上的限制 :
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T {
| ++++++++++++++++++++++
因为 T
可以是任何类型,但不是所有的类型都能进行比较,因此上面的错误中,编译器建议我们给 T
添加一个类型限制:使用 std::cmp::PartialOrd
特征(Trait)对 T
进行限制,特征在下一节会详细介绍,现在你只要理解,该特征的目的就是让类型实现可比较的功能。
还记得我们一开始的 add
泛型函数吗?如果你运行它,会得到以下的报错:
error[E0369]: cannot add `T` to `T` // 无法将 `T` 类型跟 `T` 类型进行相加
--> src/main.rs:2:7
|
2 | a + b
| - ^ - T
| |
| T
|
help: consider restricting type parameter `T`
|
1 | fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
| +++++++++++++++++++++++++++
同样的,不是所有 T
类型都能进行相加操作,因此我们需要用 std::ops::Add<Output = T>
对 T
进行限制:
#![allow(unused)] fn main() { fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T { a + b } }
进行如上修改后,就可以正常运行。
结构体中使用泛型
结构体中的字段类型也可以用泛型来定义,下面代码定义了一个坐标点 Point
,它可以存放任何类型的坐标值:
struct Point<T> { x: T, y: T, } fn main() { let integer = Point { x: 5, y: 10 }; let float = Point { x: 1.0, y: 4.0 }; }
这里有两点需要特别的注意:
- 提前声明,跟泛型函数定义类似,首先我们在使用泛型参数之前必需要进行声明
Point<T>
,接着就可以在结构体的字段类型中使用T
来替代具体的类型 - x 和 y 是相同的类型
第二点非常重要,如果使用不同的类型,那么它会导致下面代码的报错:
struct Point<T> { x: T, y: T, } fn main() { let p = Point{x: 1, y :1.1}; }
错误如下:
error[E0308]: mismatched types //类型不匹配
--> src/main.rs:7:28
|
7 | let p = Point{x: 1, y :1.1};
| ^^^ expected integer, found floating-point number //期望y是整数,但是却是浮点数
当把 1
赋值给 x
时,变量 p
的 T
类型就被确定为整数类型,因此 y
也必须是整数类型,但是我们却给它赋予了浮点数,因此导致报错。
如果想让 x
和 y
既能类型相同,又能类型不同,就需要使用不同的泛型参数:
struct Point<T,U> { x: T, y: U, } fn main() { let p = Point{x: 1, y :1.1}; }
切记,所有的泛型参数都要提前声明:Point<T,U>
! 但是如果你的结构体变成这鬼样:struct Woo<T,U,V,W,X>
,那么你需要考虑拆分这个结构体,减少泛型参数的个数和代码复杂度。
枚举中使用泛型
提到枚举类型,Option
永远是第一个应该被想起来的,在之前的章节中,它也多次出现:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
Option<T>
是一个拥有泛型 T
的枚举类型,它第一个成员是 Some(T)
,存放了一个类型为 T
的值。得益于泛型的引入,我们可以在任何一个需要返回值的函数中,去使用 Option<T>
枚举类型来做为返回值,用于返回一个任意类型的值 Some(T)
,或者没有值 None
。
对于枚举而言,卧龙凤雏永远是绕不过去的存在:如果是 Option
是卧龙,那么 Result
就一定是凤雏,得两者可得天下:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
这个枚举和 Option
一样,主要用于函数返回值,与 Option
用于值的存在与否不同,Result
关注的主要是值的正确性。
如果函数正常运行,则最后返回一个 Ok(T)
,T
是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E)
,E
是错误类型。例如打开一个文件:如果成功打开文件,则返回 Ok(std::fs::File)
,因此 T
对应的是 std::fs::File
类型;而当打开文件时出现问题时,返回 Err(std::io::Error)
,E
对应的就是 std::io::Error
类型。
方法中使用泛型
上一章中,我们讲到什么是方法以及如何在结构体和枚举上定义方法。方法上也可以使用泛型:
struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } } fn main() { let p = Point { x: 5, y: 10 }; println!("p.x = {}", p.x()); }
使用泛型参数前,依然需要提前声明:impl<T>
,只有提前声明了,我们才能在Point<T>
中使用它,这样 Rust 就知道 Point
的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 Point<T>
不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T>
而不再是 Point
。
除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:
struct Point<T, U> { x: T, y: U, } impl<T, U> Point<T, U> { fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> { Point { x: self.x, y: other.y, } } } 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); }
这个例子中,T,U
是定义在结构体 Point
上的泛型参数,V,W
是单独定义在方法 mixup
上的泛型参数,它们并不冲突,说白了,你可以理解为,一个是结构体泛型,一个是函数泛型。
为具体的泛型类型实现方法
对于 Point<T>
类型,你不仅能定义基于 T
的方法,还能针对特定的具体类型,进行方法定义:
#![allow(unused)] fn main() { impl Point<f32> { fn distance_from_origin(&self) -> f32 { (self.x.powi(2) + self.y.powi(2)).sqrt() } } }
这段代码意味着 Point<f32>
类型会有一个方法 distance_from_origin
,而其他 T
不是 f32
类型的 Point<T>
实例则没有定义此方法。这个方法计算点实例与坐标(0.0, 0.0)
之间的距离,并使用了只能用于浮点型的数学运算符。
这样我们就能针对特定的泛型类型实现某个特定的方法,对于其它泛型类型则没有定义该方法。
const 泛型(Rust 1.51 版本引入的重要特性)
在之前的泛型中,可以抽象为一句话:针对类型实现的泛型,所有的泛型都是为了抽象不同的类型,那有没有针对值的泛型?可能很多同学感觉很难理解,值怎么使用泛型?不急,我们先从数组讲起。
在数组那节,有提到过很重要的一点:[i32; 2]
和 [i32; 3]
是不同的数组类型,比如下面的代码:
fn display_array(arr: [i32; 3]) { println!("{:?}", arr); } fn main() { let arr: [i32; 3] = [1, 2, 3]; display_array(arr); let arr: [i32;2] = [1,2]; display_array(arr); }
运行后报错:
error[E0308]: mismatched types // 类型不匹配
--> src/main.rs:10:19
|
10 | display_array(arr);
| ^^^ expected an array with a fixed size of 3 elements, found one with 2 elements
// 期望一个长度为3的数组,却发现一个长度为2的
结合代码和报错,可以很清楚的看出,[i32; 3]
和 [i32; 2]
确实是两个完全不同的类型,因此无法用同一个函数调用。
首先,让我们修改代码,让 display_array
能打印任意长度的 i32
数组:
fn display_array(arr: &[i32]) { println!("{:?}", arr); } fn main() { let arr: [i32; 3] = [1, 2, 3]; display_array(&arr); let arr: [i32;2] = [1,2]; display_array(&arr); }
很简单,只要使用数组切片,然后传入 arr
的不可变引用即可。
接着,将 i32
改成所有类型的数组:
fn display_array<T: std::fmt::Debug>(arr: &[T]) { println!("{:?}", arr); } fn main() { let arr: [i32; 3] = [1, 2, 3]; display_array(&arr); let arr: [i32;2] = [1,2]; display_array(&arr); }
也不难,唯一要注意的是需要对 T
加一个限制 std::fmt::Debug
,该限制表明 T
可以用在 println!("{:?}", arr)
中,因为 {:?}
形式的格式化输出需要 arr
实现该特征。
通过引用,我们可以很轻松的解决处理任何类型数组的问题,但是如果在某些场景下引用不适宜用或者干脆不能用呢?你们知道为什么以前 Rust 的一些数组库,在使用的时候都限定长度不超过 32 吗?因为它们会为每个长度都单独实现一个函数,简直。。。毫无人性。难道没有什么办法可以解决这个问题吗?
好在,现在咱们有了 const 泛型,也就是针对值的泛型,正好可以用于处理数组长度的问题:
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) { println!("{:?}", arr); } fn main() { let arr: [i32; 3] = [1, 2, 3]; display_array(arr); let arr: [i32; 2] = [1, 2]; display_array(arr); }
如上所示,我们定义了一个类型为 [T; N]
的数组,其中 T
是一个基于类型的泛型参数,这个和之前讲的泛型没有区别,而重点在于 N
这个泛型参数,它是一个基于值的泛型参数!因为它用来替代的是数组的长度。
N
就是 const 泛型,定义的语法是 const N: usize
,表示 const 泛型 N
,它基于的值类型是 usize
。
在泛型参数之前,Rust 完全不适合复杂矩阵的运算,自从有了 const 泛型,一切即将改变。
const 泛型表达式
假设我们某段代码需要在内存很小的平台上工作,因此需要限制函数参数占用的内存大小,此时就可以使用 const 泛型表达式来实现:
// 目前只能在nightly版本下使用 #![allow(incomplete_features)] #![feature(generic_const_exprs)] fn something<T>(val: T) where Assert<{ core::mem::size_of::<T>() < 768 }>: IsTrue, // ^-----------------------------^ 这里是一个 const 表达式,换成其它的 const 表达式也可以 { // } fn main() { something([0u8; 0]); // ok something([0u8; 512]); // ok something([0u8; 1024]); // 编译错误,数组长度是1024字节,超过了768字节的参数长度限制 } // --- pub enum Assert<const CHECK: bool> { // } pub trait IsTrue { // } impl IsTrue for Assert<true> { // }
const fn
@todo
泛型的性能
在 Rust 中泛型是零成本的抽象,意味着你在使用泛型时,完全不用担心性能上的问题。
但是任何选择都是权衡得失的,既然我们获得了性能上的巨大优势,那么又失去了什么呢?Rust 是在编译期为泛型对应的多个类型,生成各自的代码,因此损失了编译速度和增大了最终生成文件的大小。
具体来说:
Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。
编译器所做的工作正好与我们创建泛型函数的步骤相反,编译器寻找所有泛型代码被调用的位置并针对具体类型生成代码。
让我们看看一个使用标准库中 Option
枚举的例子:
#![allow(unused)] fn main() { let integer = Some(5); let float = Some(5.0); }
当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T>
的值并发现有两种 Option<T>
:一种对应 i32
另一种对应 f64
。为此,它会将泛型定义 Option<T>
展开为 Option_i32
和 Option_f64
,接着将泛型定义替换为这两个具体的定义。
编译器生成的单态化版本的代码看起来像这样:
enum Option_i32 { Some(i32), None, } enum Option_f64 { Some(f64), None, } fn main() { let integer = Option_i32::Some(5); let float = Option_f64::Some(5.0); }
我们可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
特征 Trait
如果我们想定义一个文件系统,那么把该系统跟底层存储解耦是很重要的。文件操作主要包含四个:open
、write
、read
、close
,这些操作可以发生在硬盘,可以发生在内存,还可以发生在网络IO甚至(...我实在编不下去了,大家来帮帮我)。总之如果你要为每一种情况都单独实现一套代码,那这种实现将过于繁杂,而且也没那个必要。
要解决上述问题,需要把这些行为抽象出来,就要使用 Rust 中的特征 trait
概念。可能你是第一次听说这个名词,但是不要怕,如果学过其他语言,那么大概率你听说过接口,没错,特征跟接口很类似。
在之前的代码中,我们也多次见过特征的使用,例如 #[derive(Debug)]
,它在我们定义的类型(struct
)上自动派生 Debug
特征,接着可以使用 println!("{:?}", x)
打印这个类型;再例如:
#![allow(unused)] fn main() { fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T { a + b } }
通过 std::ops::Add
特征来限制 T
,只有 T
实现了 std::ops::Add
才能进行合法的加法操作,毕竟不是所有的类型都能进行相加。
这些都说明一个道理,特征定义了一组可以被共享的行为,只要实现了特征,你就能使用这组行为。
定义特征
如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。
例如,我们现在有文章 Post
和微博 Weibo
两种内容载体,而我们想对相应的内容进行总结,也就是无论是文章内容,还是微博内容,都可以在某个时间点进行总结,那么总结这个行为就是共享的,因此可以用特征来定义:
#![allow(unused)] fn main() { pub trait Summary { fn summarize(&self) -> String; } }
这里使用 trait
关键字来声明一个特征,Summary
是特征名。在大括号中定义了该特征的所有方法,在这个例子中是: fn summarize(&self) -> String
。
特征只定义行为看起来是什么样的,而不定义行为具体是怎么样的。因此,我们只定义特征方法的签名,而不进行实现,此时方法签名结尾是 ;
,而不是一个 {}
。
接下来,每一个实现这个特征的类型都需要具体实现该特征的相应方法,编译器也会确保任何实现 Summary
特征的类型都拥有与这个签名的定义完全一致的 summarize
方法。
为类型实现特征
因为特征只定义行为看起来是什么样的,因此我们需要为类型实现具体的特征,定义行为具体是怎么样的。
首先来为 Post
和 Weibo
实现 Summary
特征:
#![allow(unused)] fn main() { pub trait Summary { fn summarize(&self) -> String; } pub struct Post { pub title: String, // 标题 pub author: String, // 作者 pub content: String, // 内容 } impl Summary for Post { fn summarize(&self) -> String { format!("文章{}, 作者是{}", self.title, self.author) } } pub struct Weibo { pub username: String, pub content: String } impl Summary for Weibo { fn summarize(&self) -> String { format!("{}发表了微博{}", self.username, self.content) } } }
实现特征的语法与为结构体、枚举实现方法很像:impl Summary for Post
,读作“为 Post
类型实现 Summary
特征”,然后在 impl
的花括号中实现该特征的具体方法。
接下来就可以在这个类型上调用特征的方法:
fn main() { let post = Post{title: "Rust语言简介".to_string(),author: "Sunface".to_string(), content: "Rust棒极了!".to_string()}; let weibo = Weibo{username: "sunface".to_string(),content: "好像微博没Tweet好用".to_string()}; println!("{}",post.summarize()); println!("{}",weibo.summarize()); }
运行输出:
文章 Rust 语言简介, 作者是Sunface
sunface发表了微博好像微博没Tweet好用
说实话,如果特征仅仅如此,你可能会觉得花里胡哨没啥用,接下来就让你见识下 trait
真正的威力。
特征定义与实现的位置(孤儿规则)
上面我们将 Summary
定义成了 pub
公开的。这样,如果他人想要使用我们的 Summary
特征,则可以引入到他们的包中,然后再进行实现。
关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A
实现特征 T
,那么 A
或者 T
至少有一个是在当前作用域中定义的! 例如我们可以为上面的 Post
类型实现标准库中的 Display
特征,这是因为 Post
类型定义在当前的作用域中。同时,我们也可以在当前包中为 String
类型实现 Summary
特征,因为 Summary
定义在当前作用域中。
但是你无法在当前作用域中,为 String
类型实现 Display
特征,因为它们俩都定义在标准库中,其定义所在的位置都不在当前作用域,跟你半毛钱关系都没有,看看就行了。
该规则被称为孤儿规则,可以确保其它人编写的代码不会破坏你的代码,也确保了你不会莫名其妙就破坏了风马牛不相及的代码。
默认实现
你可以在特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法:
#![allow(unused)] fn main() { pub trait Summary { fn summarize(&self) -> String { String::from("(Read more...)") } } }
上面为 Summary
定义了一个默认实现,下面我们编写段代码来测试下:
#![allow(unused)] fn main() { impl Summary for Post {} impl Summary for Weibo { fn summarize(&self) -> String { format!("{}发表了微博{}", self.username, self.content) } } }
可以看到,Post
选择了默认实现,而 Weibo
重载了该方法,调用和输出如下:
#![allow(unused)] fn main() { println!("{}",post.summarize()); println!("{}",weibo.summarize()); }
(Read more...)
sunface发表了微博好像微博没Tweet好用
默认实现允许调用相同特征中的其他方法,哪怕这些方法没有默认实现。如此,特征可以提供很多有用的功能而只需要实现指定的一小部分内容。例如,我们可以定义 Summary
特征,使其具有一个需要实现的 summarize_author
方法,然后定义一个 summarize
方法,此方法的默认实现调用 summarize_author
方法:
#![allow(unused)] fn main() { pub trait Summary { fn summarize_author(&self) -> String; fn summarize(&self) -> String { format!("(Read more from {}...)", self.summarize_author()) } } }
为了使用 Summary
,只需要实现 summarize_author
方法即可:
#![allow(unused)] fn main() { impl Summary for Weibo { fn summarize_author(&self) -> String { format!("@{}", self.username) } } println!("1 new weibo: {}", weibo.summarize()); }
weibo.summarize()
会先调用 Summary
特征默认实现的 summarize
方法,通过该方法进而调用 Weibo
为 Summary
实现的 summarize_author
方法,最终输出:1 new weibo: (Read more from @horse_ebooks...)
。
使用特征作为函数参数
之前提到过,特征如果仅仅是用来实现方法,那真的有些大材小用,现在我们来讲下,真正可以让特征大放光彩的地方。
现在,先定义一个函数,使用特征作为函数参数:
#![allow(unused)] fn main() { pub fn notify(item: &impl Summary) { println!("Breaking news! {}", item.summarize()); } }
impl Summary
,只能说想出这个类型的人真的是起名鬼才,简直太贴切了,顾名思义,它的意思是 实现了Summary
特征 的 item
参数。
你可以使用任何实现了 Summary
特征的类型作为该函数的参数,同时在函数体内,还可以调用该特征的方法,例如 summarize
方法。具体的说,可以传递 Post
或 Weibo
的实例来作为参数,而其它类如 String
或者 i32
的类型则不能用做该函数的参数,因为它们没有实现 Summary
特征。
特征约束(trait bound)
虽然 impl Trait
这种语法非常好理解,但是实际上它只是一个语法糖:
#![allow(unused)] fn main() { pub fn notify<T: Summary>(item: &T) { println!("Breaking news! {}", item.summarize()); } }
真正的完整书写形式如上所述,形如 T: Summary
被称为特征约束。
在简单的场景下 impl Trait
这种语法糖就足够使用,但是对于复杂的场景,特征约束可以让我们拥有更大的灵活性和语法表现能力,例如一个函数接受两个 impl Summary
的参数:
#![allow(unused)] fn main() { pub fn notify(item1: &impl Summary, item2: &impl Summary) {} }
如果函数两个参数是不同的类型,那么上面的方法很好,只要这两个类型都实现了 Summary
特征即可。但是如果我们想要强制函数的两个参数是同一类型呢?上面的语法就无法做到这种限制,此时我们只能使特征约束来实现:
#![allow(unused)] fn main() { pub fn notify<T: Summary>(item1: &T, item2: &T) {} }
泛型类型 T
说明了 item1
和 item2
必须拥有同样的类型,同时 T: Summary
说明了 T
必须实现 Summary
特征。
多重约束
除了单个约束条件,我们还可以指定多个约束条件,例如除了让参数实现 Summary
特征外,还可以让参数实现 Display
特征以控制它的格式化输出:
#![allow(unused)] fn main() { pub fn notify(item: &(impl Summary + Display)) {} }
除了上述的语法糖形式,还能使用特征约束的形式:
#![allow(unused)] fn main() { pub fn notify<T: Summary + Display>(item: &T) {} }
通过这两个特征,就可以使用 item.summarize
方法,以及通过 println!("{}", item)
来格式化输出 item
。
Where 约束
当特征约束变得很多时,函数的签名将变得很复杂:
#![allow(unused)] fn main() { fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {} }
严格来说,上面的例子还是不够复杂,但是我们还是能对其做一些形式上的改进,通过 where
:
#![allow(unused)] fn main() { fn some_function<T, U>(t: &T, u: &U) -> i32 where T: Display + Clone, U: Clone + Debug {} }
使用特征约束有条件地实现方法或特征
特征约束,可以让我们在指定类型 + 指定特征的条件下去实现方法,例如:
#![allow(unused)] fn main() { use std::fmt::Display; struct Pair<T> { x: T, y: T, } impl<T> Pair<T> { fn new(x: T, y: T) -> Self { Self { x, y, } } } 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); } } } }
cmp_display
方法,并不是所有的 Pair<T>
结构体对象都可以拥有,只有 T
同时实现了 Display + PartialOrd
的 Pair<T>
才可以拥有此方法。
该函数可读性会更好,因为泛型参数、参数、返回值都在一起,可以快速的阅读,同时每个泛型参数的特征也在新的代码行中通过特征约束进行了约束。
也可以有条件地实现特征, 例如,标准库为任何实现了 Display
特征的类型实现了 ToString
特征:
#![allow(unused)] fn main() { impl<T: Display> ToString for T { // --snip-- } }
我们可以对任何实现了 Display
特征的类型调用由 ToString
定义的 to_string
方法。例如,可以将整型转换为对应的 String
值,因为整型实现了 Display
:
#![allow(unused)] fn main() { let s = 3.to_string(); }
函数返回中的 impl Trait
可以通过 impl Trait
来说明一个函数返回了一个类型,该类型实现了某个特征:
#![allow(unused)] fn main() { fn returns_summarizable() -> impl Summary { Weibo { username: String::from("sunface"), content: String::from( "m1 max太厉害了,电脑再也不会卡", ) } } }
因为 Weibo
实现了 Summary
,因此这里可以用它来作为返回值。要注意的是,虽然我们知道这里是一个 Weibo
类型,但是对于 returns_summarizable
的调用者而言,他只知道返回了一个实现了 Summary
特征的对象,但是并不知道返回了一个 Weibo
类型。
这种 impl Trait
形式的返回值,在一种场景下非常非常有用,那就是返回的真实类型非常复杂,你不知道该怎么声明时(毕竟 Rust 要求你必须标出所有的类型),此时就可以用 impl Trait
的方式简单返回。例如,闭包和迭代器就是很复杂,只有编译器才知道那玩意的真实类型,如果让你写出来它们的具体类型,估计内心有一万只草泥马奔腾,好在你可以用 impl Iterator
来告诉调用者,返回了一个迭代器,因为所有迭代器都会实现 Iterator
特征。
但是这种返回值方式有一个很大的限制:只能有一个具体的类型,例如:
#![allow(unused)] fn main() { fn returns_summarizable(switch: bool) -> impl Summary { if switch { Post { title: String::from( "Penguins win the Stanley Cup Championship!", ), author: String::from("Iceburgh"), content: String::from( "The Pittsburgh Penguins once again are the best \ hockey team in the NHL.", ), } } else { Weibo { username: String::from("horse_ebooks"), content: String::from( "of course, as you probably already know, people", ), } } } }
以上的代码就无法通过编译,因为它返回了两个不同的类型 Post
和 Weibo
。
`if` and `else` have incompatible types
expected struct `Post`, found struct `Weibo`
报错提示我们 if
和 else
返回了不同的类型。如果想要实现返回不同的类型,需要使用下一章节中的特征对象。
修复上一节中的 largest
函数
还记得上一节中的例子吧,当时留下一个疑问,该如何解决编译报错:
#![allow(unused)] fn main() { error[E0369]: binary operation `>` cannot be applied to type `T` // 无法在 `T` 类型上应用`>`运算符 --> src/main.rs:5:17 | 5 | if item > largest { | ---- ^ ------- T | | | T | help: consider restricting type parameter `T` // 考虑使用以下的特征来约束 `T` | 1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> T { | ^^^^^^^^^^^^^^^^^^^^^^ }
在 largest
函数体中我们想要使用大于运算符(>
)比较两个 T
类型的值。这个运算符是标准库中特征 std::cmp::PartialOrd
的一个默认方法。所以需要在 T
的特征约束中指定 PartialOrd
,这样 largest
函数可以用于内部元素类型可比较大小的数组切片。
由于 PartialOrd
位于 prelude
中所以并不需要通过 std::cmp
手动将其引入作用域。所以可以将 largest
的签名修改为如下:
#![allow(unused)] fn main() { fn largest<T: PartialOrd>(list: &[T]) -> T {} }
但是此时编译,又会出现新的错误:
#![allow(unused)] fn main() { error[E0508]: cannot move out of type `[T]`, a non-copy slice --> src/main.rs:2:23 | 2 | let mut largest = list[0]; | ^^^^^^^ | | | cannot move out of here | help: consider using a reference instead: `&list[0]` error[E0507]: cannot move out of borrowed content --> src/main.rs:4:9 | 4 | for &item in list.iter() { | ^---- | || | |hint: to prevent move, use `ref item` or `ref mut item` | cannot move out of borrowed content }
错误的核心是 cannot move out of type [T], a non-copy slice
,原因是 T
没有实现 Copy
特性,因此我们只能把所有权进行转移,毕竟只有 i32
等基础类型才实现了 Copy
特性,可以存储在栈上,而 T
可以指代任何类型(严格来说是实现了 PartialOrd
特征的所有类型)。
因此,为了让 T
拥有 Copy
特性,我们可以增加特征约束:
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } fn main() { let number_list = vec![34, 50, 25, 100, 65]; let result = largest(&number_list); println!("The largest number is {}", result); let char_list = vec!['y', 'm', 'a', 'q']; let result = largest(&char_list); println!("The largest char is {}", result); }
如果并不希望限制 largest
函数只能用于实现了 Copy
特征的类型,我们可以在 T
的特征约束中指定 Clone
特征 而不是 Copy
特征。并克隆 list
中的每一个值使得 largest
函数拥有其所有权。使用 clone
函数意味着对于类似 String
这样拥有堆上数据的类型,会潜在地分配更多堆上空间,而堆分配在涉及大量数据时可能会相当缓慢。
另一种 largest
的实现方式是返回在 list
中 T
值的引用。如果我们将函数返回值从 T
改为 &T
并改变函数体使其能够返回一个引用,我们将不需要任何 Clone
或 Copy
的特征约束而且也不会有任何的堆分配。尝试自己实现这种替代解决方式吧!
通过 derive
派生特征
在本书中,形如 #[derive(Debug)]
的代码已经出现了很多次,这种是一种特征派生语法,被 derive
标记的对象会自动实现对应的默认特征代码,继承相应的功能。
例如 Debug
特征,它有一套自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s)
的形式打印该结构体的对象。
再如 Copy
特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy
特征,进而可以调用 copy
方法,进行自我复制。
总之,derive
派生出来的是 Rust 默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。
详细的 derive
列表参见附录-派生特征。
调用方法需要引入特征
在一些场景中,使用 as
关键字做类型转换会有比较大的限制,因为你想要在类型转换上拥有完全的控制,例如处理转换错误,那么你将需要 TryInto
:
use std::convert::TryInto; fn main() { let a: i32 = 10; let b: u16 = 100; let b_ = b.try_into() .unwrap(); if a < b_ { println!("Ten is less than one hundred."); } }
上面代码中引入了 std::convert::TryInto
特征,但是却没有使用它,可能有些同学会为此困惑,主要原因在于如果你要使用一个特征的方法,那么你需要将该特征引入当前的作用域中,我们在上面用到了 try_into
方法,因此需要引入对应的特征。
但是 Rust 又提供了一个非常便利的办法,即把最常用的标准库中的特征通过 std::prelude
模块提前引入到当前作用域中,其中包括了 std::convert::TryInto
,你可以尝试删除第一行的代码 use ...
,看看是否会报错。
几个综合例子
为自定义类型实现 +
操作
在 Rust 中除了数值类型的加法,String
也可以做加法,因为 Rust 为该类型实现了 std::ops::Add
特征,同理,如果我们为自定义类型实现了该特征,那就可以自己实现 Point1 + Point2
的操作:
use std::ops::Add; // 为Point结构体派生Debug特征,用于格式化输出 #[derive(Debug)] struct Point<T: Add<T, Output = T>> { //限制类型T必须实现了Add特征,否则无法进行+操作。 x: T, y: T, } impl<T: Add<T, Output = T>> Add for Point<T> { type Output = Point<T>; fn add(self, p: Point<T>) -> Point<T> { Point{ x: self.x + p.x, y: self.y + p.y, } } } fn add<T: Add<T, Output=T>>(a:T, b:T) -> T { a + b } fn main() { let p1 = Point{x: 1.1f32, y: 1.1f32}; let p2 = Point{x: 2.1f32, y: 2.1f32}; println!("{:?}", add(p1, p2)); let p3 = Point{x: 1i32, y: 1i32}; let p4 = Point{x: 2i32, y: 2i32}; println!("{:?}", add(p3, p4)); }
自定义类型的打印输出
在开发过程中,往往只要使用 #[derive(Debug)]
对我们的自定义类型进行标注,即可实现打印输出的功能:
#[derive(Debug)] struct Point{ x: i32, y: i32 } fn main() { let p = Point{x:3,y:3}; println!("{:?}",p); }
但是在实际项目中,往往需要对我们的自定义类型进行自定义的格式化输出,以让用户更好的阅读理解我们的类型,此时就要为自定义类型实现 std::fmt::Display
特征:
#![allow(dead_code)] use std::fmt; use std::fmt::{Display}; #[derive(Debug,PartialEq)] enum FileState { Open, Closed, } #[derive(Debug)] struct File { name: String, data: Vec<u8>, state: FileState, } impl Display for FileState { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match *self { FileState::Open => write!(f, "OPEN"), FileState::Closed => write!(f, "CLOSED"), } } } impl Display for File { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "<{} ({})>", self.name, self.state) } } impl File { fn new(name: &str) -> File { File { name: String::from(name), data: Vec::new(), state: FileState::Closed, } } } fn main() { let f6 = File::new("f6.txt"); //... println!("{:?}", f6); println!("{}", f6); }
以上两个例子较为复杂,目的是为读者展示下真实的使用场景长什么样,因此需要读者细细阅读,最终消化这些知识对于你的 Rust 之路会有莫大的帮助。
最后,特征和特征约束,是 Rust 中极其重要的概念,如果你还是没搞懂,强烈建议回头再看一遍,或者寻找相关的资料进行补充学习。如果已经觉得掌握了,那么就可以进入下一节的学习。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
特征对象
在上一节中有一段代码无法通过编译:
#![allow(unused)] fn main() { fn returns_summarizable(switch: bool) -> impl Summary { if switch { Post { // ... } } else { Weibo { // ... } } } }
其中 Post
和 Weibo
都实现了 Summary
特征,因此上面的函数试图通过返回 impl Summary
来返回这两个类型,但是编译器却无情地报错了,原因是 impl Trait
的返回值类型并不支持多种不同的类型返回,那如果我们想返回多种类型,该怎么办?
再来考虑一个问题:现在在做一款游戏,需要将多个对象渲染在屏幕上,这些对象属于不同的类型,存储在列表中,渲染的时候,需要循环该列表并顺序渲染每个对象,在 Rust 中该怎么实现?
聪明的同学可能已经能想到一个办法,利用枚举:
#[derive(Debug)] enum UiObject { Button, SelectBox, } fn main() { let objects = [ UiObject::Button, UiObject::SelectBox ]; for o in objects { draw(o) } } fn draw(o: UiObject) { println!("{:?}",o); }
Bingo,这个确实是一个办法,但是问题来了,如果你的对象集合并不能事先明确地知道呢?或者别人想要实现一个 UI 组件呢?此时枚举中的类型是有些缺少的,是不是还要修改你的代码增加一个枚举成员?
总之,在编写这个 UI 库时,我们无法知道所有的 UI 对象类型,只知道的是:
- UI 对象的类型不同
- 需要一个统一的类型来处理这些对象,无论是作为函数参数还是作为列表中的一员
- 需要对每一个对象调用
draw
方法
在拥有继承的语言中,可以定义一个名为 Component
的类,该类上有一个 draw
方法。其他的类比如 Button
、Image
和 SelectBox
会从 Component
派生并因此继承 draw
方法。它们各自都可以覆盖 draw
方法来定义自己的行为,但是框架会把所有这些类型当作是 Component
的实例,并在其上调用 draw
。不过 Rust 并没有继承,我们得另寻出路。
特征对象定义
为了解决上面的所有问题,Rust 引入了一个概念 —— 特征对象。
在介绍特征对象之前,先来为之前的 UI 组件定义一个特征:
#![allow(unused)] fn main() { pub trait Draw { fn draw(&self); } }
只要组件实现了 Draw
特征,就可以调用 draw
方法来进行渲染。假设有一个 Button
和 SelectBox
组件实现了 Draw
特征:
#![allow(unused)] fn main() { pub struct Button { pub width: u32, pub height: u32, pub label: String, } impl Draw for Button { fn draw(&self) { // 绘制按钮的代码 } } struct SelectBox { width: u32, height: u32, options: Vec<String>, } impl Draw for SelectBox { fn draw(&self) { // 绘制SelectBox的代码 } } }
此时,还需要一个动态数组来存储这些 UI 对象:
#![allow(unused)] fn main() { pub struct Screen { pub components: Vec<?>, } }
注意到上面代码中的 ?
吗?它的意思是:我们应该填入什么类型,可以说就之前学过的内容里,你找不到哪个类型可以填入这里,但是因为 Button
和 SelectBox
都实现了 Draw
特征,那我们是不是可以把 Draw
特征的对象作为类型,填入到数组中呢?答案是肯定的。
特征对象指向实现了 Draw
特征的类型的实例,也就是指向了 Button
或者 SelectBox
的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体调用的类型方法。
可以通过 &
引用或者 Box<T>
智能指针的方式来创建特征对象。
Box<T>
在后面章节会详细讲解,大家现在把它当成一个引用即可,只不过它包裹的值会被强制分配在堆上。
trait Draw { fn draw(&self) -> String; } impl Draw for u8 { fn draw(&self) -> String { format!("u8: {}", *self) } } impl Draw for f64 { fn draw(&self) -> String { format!("f64: {}", *self) } } // 若 T 实现了 Draw 特征, 则调用该函数时传入的 Box<T> 可以被隐式转换成函数参数签名中的 Box<dyn Draw> fn draw1(x: Box<dyn Draw>) { // 由于实现了 Deref 特征,Box 智能指针会自动解引用为它所包裹的值,然后调用该值对应的类型上定义的 `draw` 方法 x.draw(); } fn draw2(x: &dyn Draw) { x.draw(); } fn main() { let x = 1.1f64; // do_something(&x); let y = 8u8; // x 和 y 的类型 T 都实现了 `Draw` 特征,因为 Box<T> 可以在函数调用时隐式地被转换为特征对象 Box<dyn Draw> // 基于 x 的值创建一个 Box<f64> 类型的智能指针,指针指向的数据被放置在了堆上 draw1(Box::new(x)); // 基于 y 的值创建一个 Box<u8> 类型的智能指针 draw1(Box::new(y)); draw2(&x); draw2(&y); }
上面代码,有几个非常重要的点:
draw1
函数的参数是Box<dyn Draw>
形式的特征对象,该特征对象是通过Box::new(x)
的方式创建的draw2
函数的参数是&dyn Draw
形式的特征对象,该特征对象是通过&x
的方式创建的dyn
关键字只用在特征对象的类型声明上,在创建时无需使用dyn
因此,可以使用特征对象来代表泛型或具体的类型。
继续来完善之前的 UI 组件代码,首先来实现 Screen
:
#![allow(unused)] fn main() { pub struct Screen { pub components: Vec<Box<dyn Draw>>, } }
其中存储了一个动态数组,里面元素的类型是 Draw
特征对象:Box<dyn Draw>
,任何实现了 Draw
特征的类型,都可以存放其中。
再来为 Screen
定义 run
方法,用于将列表中的 UI 组件渲染在屏幕上:
#![allow(unused)] fn main() { impl Screen { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } } }
至此,我们就完成了之前的目标:在列表中存储多种不同类型的实例,然后将它们使用同一个方法逐一渲染在屏幕上!
再来看看,如果通过泛型实现,会如何:
#![allow(unused)] fn main() { pub struct Screen<T: Draw> { pub components: Vec<T>, } impl<T> Screen<T> where T: Draw { pub fn run(&self) { for component in self.components.iter() { component.draw(); } } } }
上面的 Screen
的列表中,存储了类型为 T
的元素,然后在 Screen
中使用特征约束让 T
实现了 Draw
特征,进而可以调用 draw
方法。
但是这种写法限制了 Screen
实例的 Vec<T>
中的每个元素必须是 Button
类型或者全是 SelectBox
类型。如果只需要同质(相同类型)集合,更倾向于采用泛型+特征约束这种写法,因其实现更清晰,且性能更好(特征对象,需要在运行时从 vtable
动态查找需要调用的方法)。
现在来运行渲染下咱们精心设计的 UI 组件列表:
fn main() { let screen = Screen { components: vec![ Box::new(SelectBox { width: 75, height: 10, options: vec![ String::from("Yes"), String::from("Maybe"), String::from("No") ], }), Box::new(Button { width: 50, height: 10, label: String::from("OK"), }), ], }; screen.run(); }
上面使用 Box::new(T)
的方式来创建了两个 Box<dyn Draw>
特征对象,如果以后还需要增加一个 UI 组件,那么让该组件实现 Draw
特征,则可以很轻松的将其渲染在屏幕上,甚至用户可以引入我们的库作为三方库,然后在自己的库中为自己的类型实现 Draw
特征,然后进行渲染。
在动态类型语言中,有一个很重要的概念:鸭子类型(duck typing),简单来说,就是只关心值长啥样,而不关心它实际是什么。当一个东西走起来像鸭子,叫起来像鸭子,那么它就是一只鸭子,就算它实际上是一个奥特曼,也不重要,我们就当它是鸭子。
在上例中,Screen
在 run
的时候,我们并不需要知道各个组件的具体类型是什么。它也不检查组件到底是 Button
还是 SelectBox
的实例,只要它实现了 Draw
特征,就能通过 Box::new
包装成 Box<dyn Draw>
特征对象,然后被渲染在屏幕上。
使用特征对象和 Rust 类型系统来进行类似鸭子类型操作的优势是,无需在运行时检查一个值是否实现了特定方法或者担心在调用时因为值没有实现方法而产生错误。如果值没有实现特征对象所需的特征, 那么 Rust 根本就不会编译这些代码:
fn main() { let screen = Screen { components: vec![ Box::new(String::from("Hi")), ], }; screen.run(); }
因为 String
类型没有实现 Draw
特征,编译器直接就会报错,不会让上述代码运行。如果想要 String
类型被渲染在屏幕上,那么只需要为其实现 Draw
特征即可,非常容易。
注意 dyn
不能单独作为特征对象的定义,例如下面的代码编译器会报错,原因是特征对象可以是任意实现了某个特征的类型,编译器在编译期不知道该类型的大小,不同的类型大小是不同的。
而 &dyn
和 Box<dyn>
在编译期都是已知大小,所以可以用作特征对象的定义。
#![allow(unused)] fn main() { fn draw2(x: dyn Draw) { x.draw(); } }
10 | fn draw2(x: dyn Draw) {
| ^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `(dyn Draw + 'static)`
help: function arguments must have a statically known size, borrowed types always have a known size
特征对象的动态分发
回忆一下泛型章节我们提到过的,泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch),因为是在编译期完成的,对于运行期性能完全没有任何影响。
与静态分发相对应的是动态分发(dynamic dispatch),在这种情况下,直到运行时,才能确定需要调用什么方法。之前代码中的关键字 dyn
正是在强调这一“动态”的特点。
当使用特征对象时,Rust 必须使用动态分发。编译器无法知晓所有可能用于特征对象代码的类型,所以它也不知道应该调用哪个类型的哪个方法实现。为此,Rust 在运行时使用特征对象中的指针来知晓需要调用哪个方法。动态分发也阻止编译器有选择的内联方法代码,这会相应的禁用一些优化。
下面这张图很好的解释了静态分发 Box<T>
和动态分发 Box<dyn Trait>
的区别:

结合上文的内容和这张图可以了解:
- 特征对象大小不固定:这是因为,对于特征
Draw
,类型Button
可以实现特征Draw
,类型SelectBox
也可以实现特征Draw
,因此特征没有固定大小 - 几乎总是使用特征对象的引用方式,如
&dyn Draw
、Box<dyn Draw>
- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
ptr
和vptr
),因此占用两个指针大小 - 一个指针
ptr
指向实现了特征Draw
的具体类型的实例,也就是当作特征Draw
来用的类型的实例,比如类型Button
的实例、类型SelectBox
的实例 - 另一个指针
vptr
指向一个虚表vtable
,vtable
中保存了类型Button
或类型SelectBox
的实例对于可以调用的实现于特征Draw
的方法。当调用方法时,直接从vtable
中找到方法并调用。之所以要使用一个vtable
来保存各实例的方法,是因为实现了特征Draw
的类型有多种,这些类型拥有的方法各不相同,当将这些类型的实例都当作特征Draw
来使用时(此时,它们全都看作是特征Draw
类型的实例),有必要区分这些实例各自有哪些方法可调用
- 虽然特征对象没有固定大小,但它的引用类型的大小是固定的,它由两个指针组成(
简而言之,当类型 Button
实现了特征 Draw
时,类型 Button
的实例对象 btn
可以当作特征 Draw
的特征对象类型来使用,btn
中保存了作为特征对象的数据指针(指向类型 Button
的实例数据)和行为指针(指向 vtable
)。
一定要注意,此时的 btn
是 Draw
的特征对象的实例,而不再是具体类型 Button
的实例,而且 btn
的 vtable
只包含了实现自特征 Draw
的那些方法(比如 draw
),因此 btn
只能调用实现于特征 Draw
的 draw
方法,而不能调用类型 Button
本身实现的方法和类型 Button
实现于其他特征的方法。也就是说,btn
是哪个特征对象的实例,它的 vtable
中就包含了该特征的方法。
Self 与 self
在 Rust 中,有两个self
,一个指代当前的实例对象,一个指代特征或者方法类型的别名:
trait Draw { fn draw(&self) -> Self; } #[derive(Clone)] struct Button; impl Draw for Button { fn draw(&self) -> Self { return self.clone() } } fn main() { let button = Button; let newb = button.draw(); }
上述代码中,self
指代的就是当前的实例对象,也就是 button.draw()
中的 button
实例,Self
则指代的是 Button
类型。
当理解了 self
与 Self
的区别后,我们再来看看何为对象安全。
特征对象的限制
不是所有特征都能拥有特征对象,只有对象安全的特征才行。当一个特征的所有方法都有如下属性时,它的对象才是安全的:
- 方法的返回类型不能是
Self
- 方法没有任何泛型参数
对象安全对于特征对象是必须的,因为一旦有了特征对象,就不再需要知道实现该特征的具体类型是什么了。如果特征方法返回了具体的 Self
类型,但是特征对象忘记了其真正的类型,那这个 Self
就非常尴尬,因为没人知道它是谁了。但是对于泛型类型参数来说,当使用特征时其会放入具体的类型参数:此具体类型变成了实现该特征的类型的一部分。而当使用特征对象时其具体类型被抹去了,故而无从得知放入泛型参数类型到底是什么。
标准库中的 Clone
特征就不符合对象安全的要求:
#![allow(unused)] fn main() { pub trait Clone { fn clone(&self) -> Self; } }
因为它的其中一个方法,返回了 Self
类型,因此它是对象不安全的。
String
类型实现了 Clone
特征, String
实例上调用 clone
方法时会得到一个 String
实例。类似的,当调用 Vec<T>
实例的 clone
方法会得到一个 Vec<T>
实例。clone
的签名需要知道什么类型会代替 Self
,因为这是它的返回值。
如果违反了对象安全的规则,编译器会提示你。例如,如果尝试使用之前的 Screen
结构体来存放实现了 Clone
特征的类型:
#![allow(unused)] fn main() { pub struct Screen { pub components: Vec<Box<dyn Clone>>, } }
将会得到如下错误:
error[E0038]: the trait `std::clone::Clone` cannot be made into an object
--> src/lib.rs:2:5
|
2 | pub components: Vec<Box<dyn Clone>>,
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone`
cannot be made into an object
|
= note: the trait cannot require that `Self : Sized`
这意味着不能以这种方式使用此特征作为特征对象。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
深入了解特征
特征之于 Rust 更甚于接口之于其他语言,因此特征在 Rust 中很重要也相对较为复杂,我们决定把特征分为两篇进行介绍,第一篇在之前已经讲过,现在就是第二篇:关于特征的进阶篇,会讲述一些不常用到但是你该了解的特性。
关联类型
在方法一章中,我们讲到了关联函数,但是实际上关联类型和关联函数并没有任何交集,虽然它们的名字有一半的交集。
关联类型是在特征定义的语句块中,申明一个自定义类型,这样就可以在特征的方法签名中使用该类型:
#![allow(unused)] fn main() { pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; } }
以上是标准库中的迭代器特征 Iterator
,它有一个 Item
关联类型,用于替代遍历的值的类型。
同时,next
方法也返回了一个 Item
类型,不过使用 Option
枚举进行了包裹,假如迭代器中的值是 i32
类型,那么调用 next
方法就将获取一个 Option<i32>
的值。
还记得 Self
吧?在之前的章节提到过, Self
用来指代当前调用者的具体类型,那么 Self::Item
就用来指代该类型实现中定义的 Item
类型:
impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { // --snip-- } } fn main() { let c = Counter{..} c.next() }
在上述代码中,我们为 Counter
类型实现了 Iterator
特征,变量 c
是特征 Iterator
的实例,也是 next
方法的调用者。 结合之前的黑体内容可以得出:对于 next
方法而言,Self
是调用者 c
的具体类型: Counter
,而 Self::Item
是 Counter
中定义的 Item
类型: u32
。
聪明的读者之所以聪明,是因为你们喜欢联想和举一反三,同时你们也喜欢提问:为何不用泛型,例如如下代码:
#![allow(unused)] fn main() { pub trait Iterator<Item> { fn next(&mut self) -> Option<Item>; } }
答案其实很简单,为了代码的可读性,当你使用了泛型后,你需要在所有地方都写 Iterator<Item>
,而使用了关联类型,你只需要写 Iterator
,当类型定义复杂时,这种写法可以极大的增加可读性:
#![allow(unused)] fn main() { pub trait CacheableItem: Clone + Default + fmt::Debug + Decodable + Encodable { type Address: AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash; fn is_null(&self) -> bool; } }
例如上面的代码,Address
的写法自然远比 AsRef<[u8]> + Clone + fmt::Debug + Eq + Hash
要简单的多,而且含义清晰。
再例如,如果使用泛型,你将得到以下的代码:
#![allow(unused)] fn main() { trait Container<A,B> { fn contains(&self,a: A,b: B) -> bool; } fn difference<A,B,C>(container: &C) -> i32 where C : Container<A,B> {...} }
可以看到,由于使用了泛型,导致函数头部也必须增加泛型的声明,而使用关联类型,将得到可读性好得多的代码:
#![allow(unused)] fn main() { trait Container{ type A; type B; fn contains(&self, a: &Self::A, b: &Self::B) -> bool; } fn difference<C: Container>(container: &C) {} }
默认泛型类型参数
当使用泛型类型参数时,可以为其指定一个默认的具体类型,例如标准库中的 std::ops::Add
特征:
#![allow(unused)] fn main() { trait Add<RHS=Self> { type Output; fn add(self, rhs: RHS) -> Self::Output; } }
它有一个泛型参数 RHS
,但是与我们以往的用法不同,这里它给 RHS
一个默认值,也就是当用户不指定 RHS
时,默认使用两个同样类型的值进行相加,然后返回一个关联类型 Output
。
可能上面那段不太好理解,下面我们用代码来举例:
use std::ops::Add; #[derive(Debug, PartialEq)] struct Point { x: i32, y: i32, } impl Add for Point { type Output = Point; fn add(self, other: Point) -> Point { Point { x: self.x + other.x, y: self.y + other.y, } } } fn main() { assert_eq!(Point { x: 1, y: 0 } + Point { x: 2, y: 3 }, Point { x: 3, y: 3 }); }
上面的代码主要干了一件事,就是为 Point
结构体提供 +
的能力,这就是运算符重载,不过 Rust 并不支持创建自定义运算符,你也无法为所有运算符进行重载,目前来说,只有定义在 std::ops
中的运算符才能进行重载。
跟 +
对应的特征是 std::ops::Add
,我们在之前也看过它的定义 trait Add<RHS=Self>
,但是上面的例子中并没有为 Point
实现 Add<RHS>
特征,而是实现了 Add
特征(没有默认泛型类型参数),这意味着我们使用了 RHS
的默认类型,也就是 Self
。换句话说,我们这里定义的是两个相同的 Point
类型相加,因此无需指定 RHS
。
与上面的例子相反,下面的例子,我们来创建两个不同类型的相加:
#![allow(unused)] fn main() { use std::ops::Add; struct Millimeters(u32); struct Meters(u32); impl Add<Meters> for Millimeters { type Output = Millimeters; fn add(self, other: Meters) -> Millimeters { Millimeters(self.0 + (other.0 * 1000)) } } }
这里,是进行 Millimeters + Meters
两种数据类型的 +
操作,因此此时不能再使用默认的 RHS
,否则就会变成 Millimeters + Millimeters
的形式。使用 Add<Meters>
可以将 RHS
指定为 Meters
,那么 fn add(self, rhs: RHS)
自然而言的变成了 Millimeters
和 Meters
的相加。
默认类型参数主要用于两个方面:
- 减少实现的样板代码
- 扩展类型但是无需大幅修改现有的代码
之前的例子就是第一点,虽然效果也就那样。在 +
左右两边都是同样类型时,只需要 impl Add
即可,否则你需要 impl Add<SOME_TYPE>
,嗯,会多写几个字:)
对于第二点,也很好理解,如果你在一个复杂类型的基础上,新引入一个泛型参数,可能需要修改很多地方,但是如果新引入的泛型参数有了默认类型,情况就会好很多,添加泛型参数后,使用这个类型的代码需要逐个在类型提示部分添加泛型参数,就很麻烦;但是有了默认参数(且默认参数取之前的实现里假设的值的情况下)之后,原有的使用这个类型的代码就不需要做改动了。
归根到底,默认泛型参数,是有用的,但是大多数情况下,咱们确实用不到,当需要用到时,大家再回头来查阅本章即可,手上有剑,心中不慌。
调用同名的方法
不同特征拥有同名的方法是很正常的事情,你没有任何办法阻止这一点;甚至除了特征上的同名方法外,在你的类型上,也有同名方法:
#![allow(unused)] fn main() { trait Pilot { fn fly(&self); } trait Wizard { fn fly(&self); } struct Human; impl Pilot for Human { fn fly(&self) { println!("This is your captain speaking."); } } impl Wizard for Human { fn fly(&self) { println!("Up!"); } } impl Human { fn fly(&self) { println!("*waving arms furiously*"); } } }
这里,不仅仅两个特征 Pilot
和 Wizard
有 fly
方法,就连实现那两个特征的 Human
单元结构体,也拥有一个同名方法 fly
(这世界怎么了,非要这么卷吗?程序员何苦难为程序员,哎)。
既然代码已经不可更改,那下面我们来讲讲该如何调用这些 fly
方法。
优先调用类型上的方法
当调用 Human
实例的 fly
时,编译器默认调用该类型中定义的方法:
fn main() { let person = Human; person.fly(); }
这段代码会打印 *waving arms furiously*
,说明直接调用了类型上定义的方法。
调用特征上的方法
为了能够调用两个特征的方法,需要使用显式调用的语法:
fn main() { let person = Human; Pilot::fly(&person); // 调用Pilot特征上的方法 Wizard::fly(&person); // 调用Wizard特征上的方法 person.fly(); // 调用Human类型自身的方法 }
运行后依次输出:
This is your captain speaking.
Up!
*waving arms furiously*
因为 fly
方法的参数是 self
,当显式调用时,编译器就可以根据调用的类型( self
的类型)决定具体调用哪个方法。
这个时候问题又来了,如果方法没有 self
参数呢?稍等,估计有读者会问:还有方法没有 self
参数?看到这个疑问,作者的眼泪不禁流了下来,大明湖畔的关联函数,你还记得嘛?
但是成年人的世界,就算再伤心,事还得做,咱们继续:
trait Animal { fn baby_name() -> String; } struct Dog; impl Dog { fn baby_name() -> String { String::from("Spot") } } impl Animal for Dog { fn baby_name() -> String { String::from("puppy") } } fn main() { println!("A baby dog is called a {}", Dog::baby_name()); }
就像人类妈妈会给自己的宝宝起爱称一样,狗妈妈也会。狗妈妈称呼自己的宝宝为Spot,其它动物称呼狗宝宝为puppy,这个时候假如有动物不知道该如何称呼狗宝宝,它需要查询一下。
Dog::baby_name()
的调用方式显然不行,因为这只是狗妈妈对宝宝的爱称,可能你会想到通过下面的方式查询其他动物对狗狗的称呼:
fn main() { println!("A baby dog is called a {}", Animal::baby_name()); }
铛铛,无情报错了:
#![allow(unused)] fn main() { error[E0283]: type annotations needed // 需要类型注释 --> src/main.rs:20:43 | 20 | println!("A baby dog is called a {}", Animal::baby_name()); | ^^^^^^^^^^^^^^^^^ cannot infer type // 无法推断类型 | = note: cannot satisfy `_: Animal` }
因为单纯从 Animal::baby_name()
上,编译器无法得到任何有效的信息:实现 Animal
特征的类型可能有很多,你究竟是想获取哪个动物宝宝的名称?狗宝宝?猪宝宝?还是熊宝宝?
此时,就需要使用完全限定语法。
完全限定语法
完全限定语法是调用函数最为明确的方式:
fn main() { println!("A baby dog is called a {}", <Dog as Animal>::baby_name()); }
在尖括号中,通过 as
关键字,我们向 Rust 编译器提供了类型注解,也就是 Animal
就是 Dog
,而不是其他动物,因此最终会调用 impl Animal for Dog
中的方法,获取到其它动物对狗宝宝的称呼:puppy。
言归正题,完全限定语法定义为:
#![allow(unused)] fn main() { <Type as Trait>::function(receiver_if_method, next_arg, ...); }
上面定义中,第一个参数是方法接收器 receiver
(三种 self
),只有方法才拥有,例如关联函数就没有 receiver
。
完全限定语法可以用于任何函数或方法调用,那么我们为何很少用到这个语法?原因是 Rust 编译器能根据上下文自动推导出调用的路径,因此大多数时候,我们都无需使用完全限定语法。只有当存在多个同名函数或方法,且 Rust 无法区分出你想调用的目标函数时,该用法才能真正有用武之地。
特征定义中的特征约束
有时,我们会需要让某个特征 A 能使用另一个特征 B 的功能(另一种形式的特征约束),这种情况下,不仅仅要为类型实现特征 A,还要为类型实现特征 B 才行,这就是 supertrait
(实在不知道该如何翻译,有大佬指导下嘛?)
例如有一个特征 OutlinePrint
,它有一个方法,能够对当前的实现类型进行格式化输出:
#![allow(unused)] fn main() { use std::fmt::Display; trait OutlinePrint: Display { fn outline_print(&self) { let output = self.to_string(); let len = output.len(); println!("{}", "*".repeat(len + 4)); println!("*{}*", " ".repeat(len + 2)); println!("* {} *", output); println!("*{}*", " ".repeat(len + 2)); println!("{}", "*".repeat(len + 4)); } } }
等等,这里有一个眼熟的语法: OutlinePrint: Display
,感觉很像之前讲过的特征约束,只不过用在了特征定义中而不是函数的参数中,是的,在某种意义上来说,这和特征约束非常类似,都用来说明一个特征需要实现另一个特征,这里就是:如果你想要实现 OutlinePrint
特征,首先你需要实现 Display
特征。
想象一下,假如没有这个特征约束,那么 self.to_string
还能够调用吗( to_string
方法会为实现 Display
特征的类型自动实现)?编译器肯定是不愿意的,会报错说当前作用域中找不到用于 &Self
类型的方法 to_string
:
#![allow(unused)] fn main() { struct Point { x: i32, y: i32, } impl OutlinePrint for Point {} }
因为 Point
没有实现 Display
特征,会得到下面的报错:
error[E0277]: the trait bound `Point: std::fmt::Display` is not satisfied
--> src/main.rs:20:6
|
20 | impl OutlinePrint for Point {}
| ^^^^^^^^^^^^ `Point` cannot be formatted with the default formatter;
try using `:?` instead if you are using a format string
|
= help: the trait `std::fmt::Display` is not implemented for `Point`
既然我们有求于编译器,那只能选择满足它咯:
#![allow(unused)] fn main() { use std::fmt; impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } }
上面代码为 Point
实现了 Display
特征,那么 to_string
方法也将自动实现:最终获得字符串是通过这里的 fmt
方法获得的。
在外部类型上实现外部特征(newtype)
在特征章节中,有提到孤儿规则,简单来说,就是特征或者类型必需至少有一个是本地的,才能在此类型上定义特征。
这里提供一个办法来绕过孤儿规则,那就是使用newtype 模式,简而言之:就是为一个元组结构体创建新类型。该元组结构体封装有一个字段,该字段就是希望实现特征的具体类型。
该封装类型是本地的,因此我们可以为此类型实现外部的特征。
newtype
不仅仅能实现以上的功能,而且它在运行时没有任何性能损耗,因为在编译期,该类型会被自动忽略。
下面来看一个例子,我们有一个动态数组类型: Vec<T>
,它定义在标准库中,还有一个特征 Display
,它也定义在标准库中,如果没有 newtype
,我们是无法为 Vec<T>
实现 Display
的:
error[E0117]: only traits defined in the current crate can be implemented for arbitrary types
--> src/main.rs:5:1
|
5 | impl<T> std::fmt::Display for Vec<T> {
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^------
| | |
| | Vec is not defined in the current crate
| impl doesn't use only types from inside the current crate
|
= note: define and implement a trait or new type instead
编译器给了我们提示: define and implement a trait or new type instead
,重新定义一个特征,或者使用 new type
,前者当然不可行,那么来试试后者:
use std::fmt; struct Wrapper(Vec<String>); impl fmt::Display for Wrapper { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) } } fn main() { let w = Wrapper(vec![String::from("hello"), String::from("world")]); println!("w = {}", w); }
其中,struct Wrapper(Vec<String>)
就是一个元组结构体,它定义了一个新类型 Wrapper
,代码很简单,相信大家也很容易看懂。
既然 new type
有这么多好处,它有没有不好的地方呢?答案是肯定的。注意到我们怎么访问里面的数组吗?self.0.join(", ")
,是的,很啰嗦,因为需要先从 Wrapper
中取出数组: self.0
,然后才能执行 join
方法。
类似的,任何数组上的方法,你都无法直接调用,需要先用 self.0
取出数组,然后再进行调用。
当然,解决办法还是有的,要不怎么说 Rust 是极其强大灵活的编程语言!Rust 提供了一个特征叫 Deref
,实现该特征后,可以自动做一层类似类型转换的操作,可以将 Wrapper
变成 Vec<String>
来使用。这样就会像直接使用数组那样去使用 Wrapper
,而无需为每一个操作都添加上 self.0
。
同时,如果不想 Wrapper
暴露底层数组的所有方法,我们还可以为 Wrapper
去重载这些方法,实现隐藏的目的。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
集合类型
在 Rust 标准库中有这样一批原住民,它们天生贵族,当你看到的一瞬间,就能爱上它们,上面是我瞎编的,其实主要是离了它们不行,不信等会我介绍后,你放个狠话,偏不用它们试试?
集合在 Rust 中是一类比较特殊的类型,因为 Rust 中大多数数据类型都只能代表一个特定的值,但是集合却可以代表一大堆值。而且与语言级别的数组、字符串类型不同,标准库里的这些家伙是分配在堆上,因此都可以进行动态的增加和减少。
瞧,第一个集合排着整齐的队列登场了,它里面的每个元素都雄赳赳气昂昂跟在另外一个元素后面,大小、宽度、高度竟然全部一致,真是令人惊叹。 它就是 Vector
类型,允许你创建一个动态数组,它里面的元素是一个紧挨着另一个排列的。
紧接着,第二个集合在全场的嘘声和羡慕眼光中闪亮登场,只见里面的元素排成一对一对的,彼此都手牵着手,非对方莫属,这种情深深雨蒙蒙的样子真是...挺欠扁的。 它就是 HashMap
类型,该类型允许你在里面存储 KV
对,每一个 K
都有唯一的 V
与之配对。
最后,请用热烈的掌声迎接我们的 String
集合,哦,抱歉,String
集合天生低调,见不得前两个那样,因此被气走了,你可以去这里找它。
言归正传,本章所讲的 Vector
、HashMap
再加上之前的 String
类型,是标准库中最最常用的集合类型,可以说,几乎任何一段代码中都可以找到它们的身影,那么先来看看 Vector
。
动态数组 Vector
动态数组类型用 Vec<T>
表示,事实上,在之前的章节,它的身影多次出现,我们一直没有细讲,只是简单的把它当作数组处理。
动态数组允许你存储多个值,这些值在内存中一个紧挨着另一个排列,因此访问其中某个元素的成本非常低。动态数组只能存储相同类型的元素,如果你想存储不同类型的元素,可以使用之前讲过的枚举类型或者特征对象。
总之,当我们想拥有一个列表,里面都是相同类型的数据时,动态数组将会非常有用。
创建动态数组
在 Rust 中,有多种方式可以创建动态数组。
Vec::new
使用 Vec::new
创建动态数组是最 rusty 的方式,它调用了 Vec
中的 new
关联函数:
#![allow(unused)] fn main() { let v: Vec<i32> = Vec::new(); }
这里,v
被显式地声明了类型 Vec<i32>
,这是因为 Rust 编译器无法从 Vec::new()
中得到任何关于类型的暗示信息,因此也无法推导出 v
的具体类型,但是当你向里面增加一个元素后,一切又不同了:
#![allow(unused)] fn main() { let mut v = Vec::new(); v.push(1); }
此时,v
就无需手动声明类型,因为编译器通过 v.push(1)
,推测出 v
中的元素类型是 i32
,因此推导出 v
的类型是 Vec<i32>
。
如果预先知道要存储的元素个数,可以使用
Vec::with_capacity(capacity)
创建动态数组,这样可以避免因为插入大量新数据导致频繁的内存分配和拷贝,提升性能
vec![]
还可以使用宏 vec!
来创建数组,与 Vec::new
有所不同,前者能在创建同时给予初始化值:
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; }
同样,此处的 v
也无需标注类型,编译器只需检查它内部的元素即可自动推导出 v
的类型是 Vec<i32>
(Rust 中,整数默认类型是 i32
,在数值类型中有详细介绍)。
更新 Vector
向数组尾部添加元素,可以使用 push
方法:
#![allow(unused)] fn main() { let mut v = Vec::new(); v.push(1); }
与其它类型一样,必须将 v
声明为 mut
后,才能进行修改。
Vector 与其元素共存亡
跟结构体一样,Vector
类型在超出作用域范围后,会被自动删除:
#![allow(unused)] fn main() { { let v = vec![1, 2, 3]; // ... } // <- v超出作用域并在此处被删除 }
当 Vector
被删除后,它内部存储的所有内容也会随之被删除。目前来看,这种解决方案简单直白,但是当 Vector
中的元素被引用后,事情可能会没那么简单。
从 Vector 中读取元素
读取指定位置的元素有两种方式可选:
- 通过下标索引访问。
- 使用
get
方法。
#![allow(unused)] fn main() { let v = vec![1, 2, 3, 4, 5]; let third: &i32 = &v[2]; println!("第三个元素是 {}", third); match v.get(2) { Some(third) => println!("第三个元素是 {third}"), None => println!("去你的第三个元素,根本没有!"), } }
和其它语言一样,集合类型的索引下标都是从 0
开始,&v[2]
表示借用 v
中的第三个元素,最终会获得该元素的引用。而 v.get(2)
也是访问第三个元素,但是有所不同的是,它返回了 Option<&T>
,因此还需要额外的 match
来匹配解构出具体的值。
细心的同学会注意到这里使用了两种格式化输出的方式,其中第一种我们在之前已经见过,而第二种是后续新版本中引入的写法,也是更推荐的用法,具体介绍请参见格式化输出章节。
下标索引与 .get
的区别
这两种方式都能成功的读取到指定的数组元素,既然如此为什么会存在两种方法?何况 .get
还会增加使用复杂度,这就涉及到数组越界的问题了,让我们通过示例说明:
#![allow(unused)] fn main() { let v = vec![1, 2, 3, 4, 5]; let does_not_exist = &v[100]; let does_not_exist = v.get(100); }
运行以上代码,&v[100]
的访问方式会导致程序无情报错退出,因为发生了数组越界访问。 但是 v.get
就不会,它在内部做了处理,有值的时候返回 Some(T)
,无值的时候返回 None
,因此 v.get
的使用方式非常安全。
既然如此,为何不统一使用 v.get
的形式?因为实在是有些啰嗦,Rust 语言的设计者和使用者在审美这方面还是相当统一的:简洁即正义,何况性能上也会有轻微的损耗。
既然有两个选择,肯定就有如何选择的问题,答案很简单,当你确保索引不会越界的时候,就用索引访问,否则用 .get
。例如,访问第几个数组元素并不取决于我们,而是取决于用户的输入时,用 .get
会非常适合,天知道那些可爱的用户会输入一个什么样的数字进来!
同时借用多个数组元素
既然涉及到借用数组元素,那么很可能会遇到同时借用多个数组元素的情况,还记得在所有权和借用章节咱们讲过的借用规则嘛?如果记得,就来看看下面的代码 :)
#![allow(unused)] fn main() { let mut v = vec![1, 2, 3, 4, 5]; let first = &v[0]; v.push(6); println!("The first element is: {first}"); }
先不运行,来推断下结果,首先 first = &v[0]
进行了不可变借用,v.push
进行了可变借用,如果 first
在 v.push
之后不再使用,那么该段代码可以成功编译(原因见引用的作用域)。
可是上面的代码中,first
这个不可变借用在可变借用 v.push
后被使用了,那么妥妥的,编译器就会报错:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable 无法对v进行可变借用,因此之前已经进行了不可变借用
--> src/main.rs:6:5
|
4 | let first = &v[0];
| - immutable borrow occurs here // 不可变借用发生在此处
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here // 可变借用发生在此处
7 |
8 | println!("The first element is: {}", first);
| ----- immutable borrow later used here // 不可变借用在这里被使用
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` due to previous error
其实,按理来说,这两个引用不应该互相影响的:一个是查询元素,一个是在数组尾部插入元素,完全不相干的操作,为何编译器要这么严格呢?
原因在于:数组的大小是可变的,当旧数组的大小不够用时,Rust 会重新分配一块更大的内存空间,然后把旧数组拷贝过来。这种情况下,之前的引用显然会指向一块无效的内存,这非常 rusty —— 对用户进行严格的教育。
其实想想,在长大之后,我们感激人生路上遇到过的严师益友,正是因为他们,我们才在正确的道路上不断前行,虽然在那个时候,并不能理解他们,而 Rust 就如那个良师益友,它不断的在纠正我们不好的编程习惯,直到某一天,你发现自己能写出一次性通过的漂亮代码时,就能明白它的良苦用心。
若读者想要更深入的了解
Vec<T>
,可以看看Rustonomicon,其中从零手撸一个动态数组,非常适合深入学习。
迭代遍历 Vector 中的元素
如果想要依次访问数组中的元素,可以使用迭代的方式去遍历数组,这种方式比用下标的方式去遍历数组更安全也更高效(每次下标访问都会触发数组边界检查):
#![allow(unused)] fn main() { let v = vec![1, 2, 3]; for i in &v { println!("{i}"); } }
也可以在迭代过程中,修改 Vector
中的元素:
#![allow(unused)] fn main() { let mut v = vec![1, 2, 3]; for i in &mut v { *i += 10 } }
存储不同类型的元素
在本节开头,有讲到数组的元素必须类型相同,但是也提到了解决方案:那就是通过使用枚举类型和特征对象来实现不同类型元素的存储。先来看看通过枚举如何实现:
#[derive(Debug)] enum IpAddr { V4(String), V6(String) } fn main() { let v = vec![ IpAddr::V4("127.0.0.1".to_string()), IpAddr::V6("::1".to_string()) ]; for ip in v { show_addr(ip) } } fn show_addr(ip: IpAddr) { println!("{:?}",ip); }
数组 v
中存储了两种不同的 ip
地址,但是这两种都属于 IpAddr
枚举类型的成员,因此可以存储在数组中。
再来看看特征对象的实现:
trait IpAddr { fn display(&self); } struct V4(String); impl IpAddr for V4 { fn display(&self) { println!("ipv4: {:?}",self.0) } } struct V6(String); impl IpAddr for V6 { fn display(&self) { println!("ipv6: {:?}",self.0) } } fn main() { let v: Vec<Box<dyn IpAddr>> = vec![ Box::new(V4("127.0.0.1".to_string())), Box::new(V6("::1".to_string())), ]; for ip in v { ip.display(); } }
比枚举实现要稍微复杂一些,我们为 V4
和 V6
都实现了特征 IpAddr
,然后将它俩的实例用 Box::new
包裹后,存在了数组 v
中,需要注意的是,这里必须手动地指定类型:Vec<Box<dyn IpAddr>>
,表示数组 v
存储的是特征 IpAddr
的对象,这样就实现了在数组中存储不同的类型。
在实际使用场景中,特征对象数组要比枚举数组常见很多,主要原因在于特征对象非常灵活,而编译器对枚举的限制较多,且无法动态增加类型。
Vector 常用方法
初始化 vec 的更多方式:
fn main() { let v = vec![0; 3]; // 默认值为 0,初始长度为 3 let v_from = Vec::from([0, 0, 0]); assert_eq!(v, v_from); }
动态数组意味着我们增加元素时,如果容量不足就会导致 vector 扩容(目前的策略是重新申请一块 2 倍大小的内存,再将所有元素拷贝到新的内存位置,同时更新指针数据),显然,当频繁扩容或者当元素数量较多且需要扩容时,大量的内存拷贝会降低程序的性能。
可以考虑在初始化时就指定一个实际的预估容量,尽量减少可能的内存拷贝:
fn main() { let mut v = Vec::with_capacity(10); v.extend([1, 2, 3]); // 附加数据到 v println!("Vector 长度是: {}, 容量是: {}", v.len(), v.capacity()); v.reserve(100); // 调整 v 的容量,至少要有 100 的容量 println!("Vector(reserve) 长度是: {}, 容量是: {}", v.len(), v.capacity()); v.shrink_to_fit(); // 释放剩余的容量,一般情况下,不会主动去释放容量 println!("Vector(shrink_to_fit) 长度是: {}, 容量是: {}", v.len(), v.capacity()); }
Vector 常见的一些方法示例:
#![allow(unused)] fn main() { let mut v = vec![1, 2]; assert!(!v.is_empty()); // 检查 v 是否为空 v.insert(2, 3); // 在指定索引插入数据,索引值不能大于 v 的长度, v: [1, 2, 3] assert_eq!(v.remove(1), 2); // 移除指定位置的元素并返回, v: [1, 3] assert_eq!(v.pop(), Some(3)); // 删除并返回 v 尾部的元素,v: [1] assert_eq!(v.pop(), Some(1)); // v: [] assert_eq!(v.pop(), None); // 记得 pop 方法返回的是 Option 枚举值 v.clear(); // 清空 v, v: [] let mut v1 = [11, 22].to_vec(); // append 操作会导致 v1 清空数据,增加可变声明 v.append(&mut v1); // 将 v1 中的所有元素附加到 v 中, v1: [] v.truncate(1); // 截断到指定长度,多余的元素被删除, v: [11] v.retain(|x| *x > 10); // 保留满足条件的元素,即删除不满足条件的元素 let mut v = vec![11, 22, 33, 44, 55]; // 删除指定范围的元素,同时获取被删除元素的迭代器, v: [11, 55], m: [22, 33, 44] let mut m: Vec<_> = v.drain(1..=3).collect(); let v2 = m.split_off(1); // 指定索引处切分成两个 vec, m: [22], v2: [33, 44] }
当然也可以像数组切片的方式获取 vec 的部分元素:
fn main() { let v = vec![11, 22, 33, 44, 55]; let slice = &v[1..=3]; assert_eq!(slice, &[22, 33, 44]); }
更多细节,阅读 Vector 的标准库文档。
Vector 的排序
在 rust 里,实现了两种排序算法,分别为稳定的排序 sort
和 sort_by
,以及非稳定排序 sort_unstable
和 sort_unstable_by
。
当然,这个所谓的 非稳定
并不是指排序算法本身不稳定,而是指在排序过程中对相等元素的处理方式。在 稳定
排序算法里,对相等的元素,不会对其进行重新排序。而在 不稳定
的算法里则不保证这点。
总体而言,非稳定
排序的算法的速度会优于 稳定
排序算法,同时,稳定
排序还会额外分配原数组一半的空间。
整数数组的排序
以下是对整数列进行排序的例子。
fn main() { let mut vec = vec![1, 5, 10, 2, 15]; vec.sort_unstable(); assert_eq!(vec, vec![1, 2, 5, 10, 15]); }
浮点数数组的排序
我们尝试使用上面的方法来对浮点数进行排序:
fn main() { let mut vec = vec![1.0, 5.6, 10.3, 2.0, 15f32]; vec.sort_unstable(); assert_eq!(vec, vec![1.0, 2.0, 5.6, 10.3, 15f32]); }
结果,居然报错了,
error[E0277]: the trait bound `f32: Ord` is not satisfied
--> src/main.rs:29:13
|
29 | vec.sort_unstable();
| ^^^^^^^^^^^^^ the trait `Ord` is not implemented for `f32`
|
= help: the following other types implement trait `Ord`:
i128
i16
i32
i64
i8
isize
u128
u16
and 4 others
note: required by a bound in `core::slice::<impl [T]>::sort_unstable`
--> /home/keijack/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/slice/mod.rs:2635:12
|
2635 | T: Ord,
| ^^^ required by this bound in `core::slice::<impl [T]>::sort_unstable`
For more information about this error, try `rustc --explain E0277`.
原来,在浮点数当中,存在一个 NAN
的值,这个值无法与其他的浮点数进行对比,因此,浮点数类型并没有实现全数值可比较 Ord
的特性,而是实现了部分可比较的特性 PartialOrd
。
如此,如果我们确定在我们的浮点数数组当中,不包含 NAN
值,那么我们可以使用 partial_cmp
来作为大小判断的依据。
fn main() { let mut vec = vec![1.0, 5.6, 10.3, 2.0, 15f32]; vec.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap()); assert_eq!(vec, vec![1.0, 2.0, 5.6, 10.3, 15f32]); }
OK,现在可以正确执行了。
对结构体数组进行排序
有了上述浮点数排序的经验,我们推而广之,那么对结构体是否也可以使用这种自定义对比函数的方式来进行呢?马上来试一下:
#[derive(Debug)] struct Person { name: String, age: u32, } impl Person { fn new(name: String, age: u32) -> Person { Person { name, age } } } fn main() { let mut people = vec![ Person::new("Zoe".to_string(), 25), Person::new("Al".to_string(), 60), Person::new("John".to_string(), 1), ]; // 定义一个按照年龄倒序排序的对比函数 people.sort_unstable_by(|a, b| b.age.cmp(&a.age)); println!("{:?}", people); }
执行后输出:
[Person { name: "Al", age: 60 }, Person { name: "Zoe", age: 25 }, Person { name: "John", age: 1 }]
结果正确。
从上面我们学习过程当中,排序需要我们实现 Ord
特性,那么如果我们把我们的结构体实现了该特性,是否就不需要我们自定义对比函数了呢?
是,但不完全是,实现 Ord
需要我们实现 Ord
、Eq
、PartialEq
、PartialOrd
这些属性。好消息是,你可以 derive
这些属性:
#[derive(Debug, Ord, Eq, PartialEq, PartialOrd)] struct Person { name: String, age: u32, } impl Person { fn new(name: String, age: u32) -> Person { Person { name, age } } } fn main() { let mut people = vec![ Person::new("Zoe".to_string(), 25), Person::new("Al".to_string(), 60), Person::new("Al".to_string(), 30), Person::new("John".to_string(), 1), Person::new("John".to_string(), 25), ]; people.sort_unstable(); println!("{:?}", people); }
执行输出
[Person { name: "Al", age: 30 }, Person { name: "Al", age: 60 }, Person { name: "John", age: 1 }, Person { name: "John", age: 25 }, Person { name: "Zoe", age: 25 }]
需要 derive
Ord
相关特性,需要确保你的结构体中所有的属性均实现了 Ord
相关特性,否则会发生编译错误。derive
的默认实现会依据属性的顺序依次进行比较,如上述例子中,当 Person
的 name
值相同,则会使用 age
进行比较。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
KV 存储 HashMap
和动态数组一样,HashMap
也是 Rust 标准库中提供的集合类型,但是又与动态数组不同,HashMap
中存储的是一一映射的 KV
键值对,并提供了平均复杂度为 O(1)
的查询方法,当我们希望通过一个 Key
去查询值时,该类型非常有用,以致于 Go 语言将该类型设置成了语言级别的内置特性。
Rust 中哈希类型(哈希映射)为 HashMap<K,V>
,在其它语言中,也有类似的数据结构,例如 hash map
,map
,object
,hash table
,字典
等等,引用小品演员孙涛的一句台词:大家都是本地狐狸,别搁那装貂 :)。
创建 HashMap
跟创建动态数组 Vec
的方法类似,可以使用 new
方法来创建 HashMap
,然后通过 insert
方法插入键值对。
使用 new 方法创建
#![allow(unused)] fn main() { use std::collections::HashMap; // 创建一个HashMap,用于存储宝石种类和对应的数量 let mut my_gems = HashMap::new(); // 将宝石类型和对应的数量写入表中 my_gems.insert("红宝石", 1); my_gems.insert("蓝宝石", 2); my_gems.insert("河边捡的误以为是宝石的破石头", 18); }
很简单对吧?跟其它语言没有区别,聪明的同学甚至能够猜到该 HashMap
的类型:HashMap<&str,i32>
。
但是还有一点,你可能没有注意,那就是使用 HashMap
需要手动通过 use ...
从标准库中引入到我们当前的作用域中来,仔细回忆下,之前使用另外两个集合类型 String
和 Vec
时,我们是否有手动引用过?答案是 No,因为 HashMap
并没有包含在 Rust 的 prelude
中(Rust 为了简化用户使用,提前将最常用的类型自动引入到作用域中)。
所有的集合类型都是动态的,意味着它们没有固定的内存大小,因此它们底层的数据都存储在内存堆上,然后通过一个存储在栈中的引用类型来访问。同时,跟其它集合类型一致,HashMap
也是内聚性的,即所有的 K
必须拥有同样的类型,V
也是如此。
跟
Vec
一样,如果预先知道要存储的KV
对个数,可以使用HashMap::with_capacity(capacity)
创建指定大小的HashMap
,避免频繁的内存分配和拷贝,提升性能。
使用迭代器和 collect 方法创建
在实际使用中,不是所有的场景都能 new
一个哈希表后,然后悠哉悠哉的依次插入对应的键值对,而是可能会从另外一个数据结构中,获取到对应的数据,最终生成 HashMap
。
例如考虑一个场景,有一张表格中记录了足球联赛中各队伍名称和积分的信息,这张表如果被导入到 Rust 项目中,一个合理的数据结构是 Vec<(String, u32)>
类型,该数组中的元素是一个个元组,该数据结构跟表格数据非常契合:表格中的数据都是逐行存储,每一个行都存有一个 (队伍名称, 积分)
的信息。
但是在很多时候,又需要通过队伍名称来查询对应的积分,此时动态数组就不适用了,因此可以用 HashMap
来保存相关的队伍名称 -> 积分映射关系。 理想很丰满,现实很骨感,如何将 Vec<(String, u32)>
中的数据快速写入到 HashMap<String, u32>
中?
一个动动脚趾头就能想到的笨方法如下:
fn main() { use std::collections::HashMap; let teams_list = vec![ ("中国队".to_string(), 100), ("美国队".to_string(), 10), ("日本队".to_string(), 50), ]; let mut teams_map = HashMap::new(); for team in &teams_list { teams_map.insert(&team.0, team.1); } println!("{:?}",teams_map) }
遍历列表,将每一个元组作为一对 KV
插入到 HashMap
中,很简单,但是……也不太聪明的样子,换个词说就是 —— 不够 rusty。
好在,Rust 为我们提供了一个非常精妙的解决办法:先将 Vec
转为迭代器,接着通过 collect
方法,将迭代器中的元素收集后,转成 HashMap
:
fn main() { use std::collections::HashMap; let teams_list = vec![ ("中国队".to_string(), 100), ("美国队".to_string(), 10), ("日本队".to_string(), 50), ]; let teams_map: HashMap<_,_> = teams_list.into_iter().collect(); println!("{:?}",teams_map) }
代码很简单,into_iter
方法将列表转为迭代器,接着通过 collect
进行收集,不过需要注意的是,collect
方法在内部实际上支持生成多种类型的目标集合,因此我们需要通过类型标注 HashMap<_,_>
来告诉编译器:请帮我们收集为 HashMap
集合类型,具体的 KV
类型,麻烦编译器您老人家帮我们推导。
由此可见,Rust 中的编译器时而小聪明,时而大聪明,不过好在,它大聪明的时候,会自家人知道自己事,总归会通知你一声:
error[E0282]: type annotations needed // 需要类型标注
--> src/main.rs:10:9
|
10 | let teams_map = teams_list.into_iter().collect();
| ^^^^^^^^^ consider giving `teams_map` a type // 给予 `teams_map` 一个具体的类型
所有权转移
HashMap
的所有权规则与其它 Rust 类型没有区别:
- 若类型实现
Copy
特征,该类型会被复制进HashMap
,因此无所谓所有权 - 若没实现
Copy
特征,所有权将被转移给HashMap
中
例如我参选帅气男孩时的场景再现:
fn main() { use std::collections::HashMap; let name = String::from("Sunface"); let age = 18; let mut handsome_boys = HashMap::new(); handsome_boys.insert(name, age); println!("因为过于无耻,{}已经被从帅气男孩名单中除名", name); println!("还有,他的真实年龄远远不止{}岁", age); }
运行代码,报错如下:
error[E0382]: borrow of moved value: `name`
--> src/main.rs:10:32
|
4 | let name = String::from("Sunface");
| ---- move occurs because `name` has type `String`, which does not implement the `Copy` trait
...
8 | handsome_boys.insert(name, age);
| ---- value moved here
9 |
10 | println!("因为过于无耻,{}已经被除名", name);
| ^^^^ value borrowed here after move
提示很清晰,name
是 String
类型,因此它受到所有权的限制,在 insert
时,它的所有权被转移给 handsome_boys
,所以最后在使用时,会遇到这个无情但是意料之中的报错。
如果你使用引用类型放入 HashMap 中,请确保该引用的生命周期至少跟 HashMap
活得一样久:
fn main() { use std::collections::HashMap; let name = String::from("Sunface"); let age = 18; let mut handsome_boys = HashMap::new(); handsome_boys.insert(&name, age); std::mem::drop(name); println!("因为过于无耻,{:?}已经被除名", handsome_boys); println!("还有,他的真实年龄远远不止{}岁", age); }
上面代码,我们借用 name
获取了它的引用,然后插入到 handsome_boys
中,至此一切都很完美。但是紧接着,就通过 drop
函数手动将 name
字符串从内存中移除,再然后就报错了:
handsome_boys.insert(&name, age);
| ----- borrow of `name` occurs here // name借用发生在此处
9 |
10 | std::mem::drop(name);
| ^^^^ move out of `name` occurs here // name的所有权被转移走
11 | println!("因为过于无耻,{:?}已经被除名", handsome_boys);
| ------------- borrow later used here // 所有权转移后,还试图使用name
最终,某人因为过于无耻,真正的被除名了 :)
查询 HashMap
通过 get
方法可以获取元素:
#![allow(unused)] fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50); let team_name = String::from("Blue"); let score: Option<&i32> = scores.get(&team_name); }
上面有几点需要注意:
get
方法返回一个Option<&i32>
类型:当查询不到时,会返回一个None
,查询到时返回Some(&i32)
&i32
是对HashMap
中值的借用,如果不使用借用,可能会发生所有权的转移
还可以继续拓展下,上面的代码中,如果我们想直接获得值类型的 score
该怎么办,答案简约但不简单:
#![allow(unused)] fn main() { let score: i32 = scores.get(&team_name).copied().unwrap_or(0); }
这里留给大家一个小作业: 去官方文档中查询下 Option
的 copied
方法和 unwrap_or
方法的含义及该如何使用。
还可以通过循环的方式依次遍历 KV
对:
#![allow(unused)] fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert(String::from("Blue"), 10); scores.insert(String::from("Yellow"), 50); for (key, value) in &scores { println!("{}: {}", key, value); } }
最终输出:
Yellow: 50
Blue: 10
更新 HashMap 中的值
更新值的时候,涉及多种情况,咱们在代码中一一进行说明:
fn main() { use std::collections::HashMap; let mut scores = HashMap::new(); scores.insert("Blue", 10); // 覆盖已有的值 let old = scores.insert("Blue", 20); assert_eq!(old, Some(10)); // 查询新插入的值 let new = scores.get("Blue"); assert_eq!(new, Some(&20)); // 查询Yellow对应的值,若不存在则插入新值 let v = scores.entry("Yellow").or_insert(5); assert_eq!(*v, 5); // 不存在,插入5 // 查询Yellow对应的值,若不存在则插入新值 let v = scores.entry("Yellow").or_insert(50); assert_eq!(*v, 5); // 已经存在,因此50没有插入 }
具体的解释在代码注释中已有,这里不再进行赘述。
在已有值的基础上更新
另一个常用场景如下:查询某个 key
对应的值,若不存在则插入新值,若存在则对已有的值进行更新,例如在文本中统计词语出现的次数:
#![allow(unused)] fn main() { use std::collections::HashMap; 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); }
上面代码中,新建一个 map
用于保存词语出现的次数,插入一个词语时会进行判断:若之前没有插入过,则使用该词语作 Key
,插入次数 0 作为 Value
,若之前插入过则取出之前统计的该词语出现的次数,对其加一。
有两点值得注意:
or_insert
返回了&mut v
引用,因此可以通过该可变引用直接修改map
中对应的值- 使用
count
引用时,需要先进行解引用*count
,否则会出现类型不匹配
哈希函数
你肯定比较好奇,为何叫哈希表,到底什么是哈希。
先来设想下,如果要实现 Key
与 Value
的一一对应,是不是意味着我们要能比较两个 Key
的相等性?例如 "a" 和 "b",1 和 2,当这些类型做 Key
且能比较时,可以很容易知道 1
对应的值不会错误的映射到 2
上,因为 1
不等于 2
。因此,一个类型能否作为 Key
的关键就是是否能进行相等比较,或者说该类型是否实现了 std::cmp::Eq
特征。
f32 和 f64 浮点数,没有实现
std::cmp::Eq
特征,因此不可以用作HashMap
的Key
。
好了,理解完这个,再来设想一点,若一个复杂点的类型作为 Key
,那怎么在底层对它进行存储,怎么使用它进行查询和比较? 是不是很棘手?好在我们有哈希函数:通过它把 Key
计算后映射为哈希值,然后使用该哈希值来进行存储、查询、比较等操作。
但是问题又来了,如何保证不同 Key
通过哈希后的两个值不会相同?如果相同,那意味着我们使用不同的 Key
,却查到了同一个结果,这种明显是错误的行为。
此时,就涉及到安全性跟性能的取舍了。
若要追求安全,尽可能减少冲突,同时防止拒绝服务(Denial of Service, DoS)攻击,就要使用密码学安全的哈希函数,HashMap
就是使用了这样的哈希函数。反之若要追求性能,就需要使用没有那么安全的算法。
高性能三方库
因此若性能测试显示当前标准库默认的哈希函数不能满足你的性能需求,就需要去 crates.io
上寻找其它的哈希函数实现,使用方法很简单:
#![allow(unused)] fn main() { use std::hash::BuildHasherDefault; use std::collections::HashMap; // 引入第三方的哈希函数 use twox_hash::XxHash64; // 指定HashMap使用第三方的哈希函数XxHash64 let mut hash: HashMap<_, _, BuildHasherDefault<XxHash64>> = Default::default(); hash.insert(42, "the answer"); assert_eq!(hash.get(&42), Some(&"the answer")); }
目前,
HashMap
使用的哈希函数是SipHash
,它的性能不是很高,但是安全性很高。SipHash
在中等大小的Key
上,性能相当不错,但是对于小型的Key
(例如整数)或者大型Key
(例如字符串)来说,性能还是不够好。若你需要极致性能,例如实现算法,可以考虑这个库:ahash。
最后,如果你想要了解 HashMap
更多的用法,请参见本书的标准库解析章节:HashMap 常用方法
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
认识生命周期
各位读者,之前的集合章节挺简单吧?是不是安逸了挺久了?要不咱们加点料?来试试 Rust 中令人闻风丧胆的生命周期?
生命周期,简而言之就是引用的有效作用域。在大多数时候,我们无需手动的声明生命周期,因为编译器可以自动进行推导,用类型来类比下:
- 就像编译器大部分时候可以自动推导类型 <-> 一样,编译器大多数时候也可以自动推导生命周期
- 在多种类型存在时,编译器往往要求我们手动标明类型 <-> 当多个生命周期存在,且编译器无法推导出某个引用的生命周期时,就需要我们手动标明生命周期
Rust 生命周期之所以难,是因为这个概念对于我们来说是全新的,没有其它编程语言的经验可以借鉴。当你觉得难的时候,不用过于担心,这个难对于所有人都是平等的,多点付出就能早点解决此拦路虎,同时本书也会尽力帮助大家减少学习难度(生命周期很可能是 Rust 中最难的部分)。
悬垂指针和生命周期
生命周期的主要作用是避免悬垂引用,它会导致程序引用了本不该引用的数据:
#![allow(unused)] fn main() { { let r; { let x = 5; r = &x; } println!("r: {}", r); } }
这段代码有几点值得注意:
let r;
的声明方式貌似存在使用null
的风险,实际上,当我们不初始化它就使用时,编译器会给予报错r
引用了内部花括号中的x
变量,但是x
会在内部花括号}
处被释放,因此回到外部花括号后,r
会引用一个无效的x
此处 r
就是一个悬垂指针,它引用了提前被释放的变量 x
,可以预料到,这段代码会报错:
error[E0597]: `x` does not live long enough // `x` 活得不够久
--> src/main.rs:7:17
|
7 | r = &x;
| ^^ borrowed value does not live long enough // 被借用的 `x` 活得不够久
8 | }
| - `x` dropped here while still borrowed // `x` 在这里被丢弃,但是它依然还在被借用
9 |
10 | println!("r: {}", r);
| - borrow later used here // 对 `x` 的借用在此处被使用
在这里 r
拥有更大的作用域,或者说活得更久。如果 Rust 不阻止该悬垂引用的发生,那么当 x
被释放后,r
所引用的值就不再是合法的,会导致我们程序发生异常行为,且该异常行为有时候会很难被发现。
借用检查
为了保证 Rust 的所有权和借用的正确性,Rust 使用了一个借用检查器(Borrow checker),来检查我们程序的借用正确性:
#![allow(unused)] fn main() { { let r; // ---------+-- 'a // | { // | let x = 5; // -+-- 'b | r = &x; // | | } // -+ | // | println!("r: {}", r); // | } // ---------+ }
这段代码和之前的一模一样,唯一的区别在于增加了对变量生命周期的注释。这里,r
变量被赋予了生命周期 'a
,x
被赋予了生命周期 'b
,从图示上可以明显看出生命周期 'b
比 'a
小很多。
在编译期,Rust 会比较两个变量的生命周期,结果发现 r
明明拥有生命周期 'a
,但是却引用了一个小得多的生命周期 'b
,在这种情况下,编译器会认为我们的程序存在风险,因此拒绝运行。
如果想要编译通过,也很简单,只要 'b
比 'a
大就好。总之,x
变量只要比 r
活得久,那么 r
就能随意引用 x
且不会存在危险:
#![allow(unused)] fn main() { { let x = 5; // ----------+-- 'b // | let r = &x; // --+-- 'a | // | | println!("r: {}", r); // | | // --+ | } // ----------+ }
根据之前的结论,我们重新实现了代码,现在 x
的生命周期 'b
大于 r
的生命周期 'a
,因此 r
对 x
的引用是安全的。
通过之前的内容,我们了解了何为生命周期,也了解了 Rust 如何利用生命周期来确保引用是合法的,下面来看看函数中的生命周期。
函数中的生命周期
先来考虑一个例子 - 返回两个字符串切片中较长的那个,该函数的参数是两个字符串切片,返回值也是字符串切片:
fn main() { let string1 = String::from("abcd"); let string2 = "xyz"; let result = longest(string1.as_str(), string2); println!("The longest string is {}", result); }
#![allow(unused)] fn main() { fn longest(x: &str, y: &str) -> &str { if x.len() > y.len() { x } else { y } } }
这段 longest
实现,非常标准优美,就连多余的 return
和分号都没有,可是现实总是给我们重重一击:
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter // 参数需要一个生命周期
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is
borrowed from `x` or `y`
= 帮助: 该函数的返回值是一个引用类型,但是函数签名无法说明,该引用是借用自 `x` 还是 `y`
help: consider introducing a named lifetime parameter // 考虑引入一个生命周期
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ^^^^ ^^^^^^^ ^^^^^^^ ^^^
喔,这真是一个复杂的提示,那感觉就好像是生命周期去非诚勿扰相亲,结果在初印象环节就 23 盏灯全灭。等等,先别急,如果你仔细阅读,就会发现,其实主要是编译器无法知道该函数的返回值到底引用 x
还是 y
,因为编译器需要知道这些,来确保函数调用后的引用生命周期分析。
不过说来尴尬,就这个函数而言,我们也不知道返回值到底引用哪个,因为一个分支返回 x
,另一个分支返回 y
...这可咋办?先来分析下。
我们在定义该函数时,首先无法知道传递给函数的具体值,因此到底是 if
还是 else
被执行,无从得知。其次,传入引用的具体生命周期也无法知道,因此也不能像之前的例子那样通过分析生命周期来确定引用是否有效。同时,编译器的借用检查也无法推导出返回值的生命周期,因为它不知道 x
和 y
的生命周期跟返回值的生命周期之间的关系是怎样的(说实话,人都搞不清,何况编译器这个大聪明)。
因此,这时就回到了文章开头说的内容:在存在多个引用时,编译器有时会无法自动推导生命周期,此时就需要我们手动去标注,通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。
生命周期标注语法
生命周期标注并不会改变任何引用的实际作用域 -- 鲁迅
鲁迅说过的话,总是值得重点标注,当你未来更加理解生命周期时,你才会发现这句话的精髓和重要!现在先简单记住,标记的生命周期只是为了取悦编译器,让编译器不要难为我们,记住了吗?没记住,再回头看一遍,这对未来你遇到生命周期问题时会有很大的帮助!
在很多时候编译器是很聪明的,但是总有些时候,它会化身大聪明,自以为什么都很懂,然后去拒绝我们代码的执行,此时,就需要我们通过生命周期标注来告诉这个大聪明:别自作聪明了,听我的就好。
例如一个变量,只能活一个花括号,那么就算你给它标注一个活全局的生命周期,它还是会在前面的花括号结束处被释放掉,并不会真的全局存活。
生命周期的语法也颇为与众不同,以 '
开头,名称往往是一个单独的小写字母,大多数人都用 'a
来作为生命周期的名称。 如果是引用类型的参数,那么生命周期会位于引用符号 &
之后,并用一个空格来将生命周期和引用参数分隔开:
#![allow(unused)] fn main() { &i32 // 一个引用 &'a i32 // 具有显式生命周期的引用 &'a mut i32 // 具有显式生命周期的可变引用 }
一个生命周期标注,它自身并不具有什么意义,因为生命周期的作用就是告诉编译器多个引用之间的关系。例如,有一个函数,它的第一个参数 first
是一个指向 i32
类型的引用,具有生命周期 'a
,该函数还有另一个参数 second
,它也是指向 i32
类型的引用,并且同样具有生命周期 'a
。此处生命周期标注仅仅说明,这两个参数 first
和 second
至少活得和'a 一样久,至于到底活多久或者哪个活得更久,抱歉我们都无法得知:
#![allow(unused)] fn main() { fn useless<'a>(first: &'a i32, second: &'a i32) {} }
函数签名中的生命周期标注
继续之前的 longest
函数,从两个字符串切片中返回较长的那个:
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { if x.len() > y.len() { x } else { y } } }
需要注意的点如下:
- 和泛型一样,使用生命周期参数,需要先声明
<'a>
x
、y
和返回值至少活得和'a
一样久(因为返回值要么是x
,要么是y
)
该函数签名表明对于某些生命周期 'a
,函数的两个参数都至少跟 'a
活得一样久,同时函数的返回引用也至少跟 'a
活得一样久。实际上,这意味着返回值的生命周期与参数生命周期中的较小值一致:虽然两个参数的生命周期都是标注了 'a
,但是实际上这两个参数的真实生命周期可能是不一样的(生命周期 'a
不代表生命周期等于 'a
,而是大于等于 'a
)。
回忆下“鲁迅”说的话,再参考上面的内容,可以得出:在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过。
因此 longest
函数并不知道 x
和 y
具体会活多久,只要知道它们的作用域至少能持续 'a
这么长就行。
当把具体的引用传给 longest
时,那生命周期 'a
的大小就是 x
和 y
的作用域的重合部分,换句话说,'a
的大小将等于 x
和 y
中较小的那个。由于返回值的生命周期也被标记为 'a
,因此返回值的生命周期也是 x
和 y
中作用域较小的那个。
说实话,这段文字我写的都快崩溃了,不知道你们读起来如何,实在***太绕了。。那就干脆用一个例子来解释吧:
fn main() { let string1 = String::from("long string is long"); { let string2 = String::from("xyz"); let result = longest(string1.as_str(), string2.as_str()); println!("The longest string is {}", result); } }
在上例中,string1
的作用域直到 main
函数的结束,而 string2
的作用域到内部花括号的结束 }
,那么根据之前的理论,'a
是两者中作用域较小的那个,也就是 'a
的生命周期等于 string2
的生命周期,同理,由于函数返回的生命周期也是 'a
,可以得出函数返回的生命周期也等于 string2
的生命周期。
现在来验证下上面的结论:result
的生命周期等于参数中生命周期最小的,因此要等于 string2
的生命周期,也就是说,result
要活得和 string2
一样久,观察下代码的实现,可以发现这个结论是正确的!
因此,在这种情况下,通过生命周期标注,编译器得出了和我们肉眼观察一样的结论,而不再是一个蒙圈的大聪明。
再来看一个例子,该例子证明了 result
的生命周期必须等于两个参数中生命周期较小的那个:
fn main() { let string1 = String::from("long string is long"); let result; { let string2 = String::from("xyz"); result = longest(string1.as_str(), string2.as_str()); } println!("The longest string is {}", result); }
Bang,错误冒头了:
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {}", result);
| ------ borrow later used here
在上述代码中,result
必须要活到 println!
处,因为 result
的生命周期是 'a
,因此 'a
必须持续到 println!
。
在 longest
函数中,string2
的生命周期也是 'a
,由此说明 string2
也必须活到 println!
处,可是 string2
在代码中实际上只能活到内部语句块的花括号处 }
,小于它应该具备的生命周期 'a
,因此编译出错。
作为人类,我们可以很清晰的看出 result
实际上引用了 string1
,因为 string1
的长度明显要比 string2
长,既然如此,编译器不该如此矫情才对,它应该能认识到 result
没有引用 string2
,让我们这段代码通过。只能说,作为尊贵的人类,编译器的发明者,你高估了这个工具的能力,它真的做不到!而且 Rust 编译器在调教上是非常保守的:当可能出错也可能不出错时,它会选择前者,抛出编译错误。
总之,显式的使用生命周期,可以让编译器正确的认识到多个引用之间的关系,最终帮我们提前规避可能存在的代码风险。
小练习:尝试着去更改 longest
函数,例如修改参数、生命周期或者返回值,然后推测结果如何,最后再跟编译器的输出进行印证。
深入思考生命周期标注
使用生命周期的方式往往取决于函数的功能,例如之前的 longest
函数,如果它永远只返回第一个参数 x
,生命周期的标注该如何修改(该例子就是上面的小练习结果之一)?
#![allow(unused)] fn main() { fn longest<'a>(x: &'a str, y: &str) -> &'a str { x } }
在此例中,y
完全没有被使用,因此 y
的生命周期与 x
和返回值的生命周期没有任何关系,意味着我们也不必再为 y
标注生命周期,只需要标注 x
参数和返回值即可。
函数的返回值如果是一个引用类型,那么它的生命周期只会来源于:
- 函数参数的生命周期
- 函数体中某个新建引用的生命周期
若是后者情况,就是典型的悬垂引用场景:
#![allow(unused)] fn main() { fn longest<'a>(x: &str, y: &str) -> &'a str { let result = String::from("really long string"); result.as_str() } }
上面的函数的返回值就和参数 x
,y
没有任何关系,而是引用了函数体内创建的字符串,那么很显然,该函数会报错:
error[E0515]: cannot return value referencing local variable `result` // 返回值result引用了本地的变量
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
主要问题就在于,result
在函数结束后就被释放,但是在函数结束后,对 result
的引用依然在继续。在这种情况下,没有办法指定合适的生命周期来让编译通过,因此我们也就在 Rust 中避免了悬垂引用。
那遇到这种情况该怎么办?最好的办法就是返回内部字符串的所有权,然后把字符串的所有权转移给调用者:
fn longest<'a>(_x: &str, _y: &str) -> String { String::from("really long string") } fn main() { let s = longest("not", "important"); }
至此,可以对生命周期进行下总结:生命周期语法用来将函数的多个引用参数和返回值的作用域关联到一起,一旦关联到一起后,Rust 就拥有充分的信息来确保我们的操作是内存安全的。
结构体中的生命周期
不仅仅函数具有生命周期,结构体其实也有这个概念,只不过我们之前对结构体的使用都停留在非引用类型字段上。细心的同学应该能回想起来,之前为什么不在结构体中使用字符串字面量或者字符串切片,而是统一使用 String
类型?原因很简单,后者在结构体初始化时,只要转移所有权即可,而前者,抱歉,它们是引用,它们不能为所欲为。
既然之前已经理解了生命周期,那么意味着在结构体中使用引用也变得可能:只要为结构体中的每一个引用标注上生命周期即可:
struct ImportantExcerpt<'a> { part: &'a str, } fn main() { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().expect("Could not find a '.'"); let i = ImportantExcerpt { part: first_sentence, }; }
ImportantExcerpt
结构体中有一个引用类型的字段 part
,因此需要为它标注上生命周期。结构体的生命周期标注语法跟泛型参数语法很像,需要对生命周期参数进行声明 <'a>
。该生命周期标注说明,结构体 ImportantExcerpt
所引用的字符串 str
必须比该结构体活得更久。
从 main
函数实现来看,ImportantExcerpt
的生命周期从第 4 行开始,到 main
函数末尾结束,而该结构体引用的字符串从第一行开始,也是到 main
函数末尾结束,可以得出结论结构体引用的字符串活得比结构体久,这符合了编译器对生命周期的要求,因此编译通过。
与之相反,下面的代码就无法通过编译:
#[derive(Debug)] struct ImportantExcerpt<'a> { part: &'a str, } fn main() { let i; { let novel = String::from("Call me Ishmael. Some years ago..."); let first_sentence = novel.split('.').next().expect("Could not find a '.'"); i = ImportantExcerpt { part: first_sentence, }; } println!("{:?}",i); }
观察代码,可以看出结构体比它引用的字符串活得更久,引用字符串在内部语句块末尾 }
被释放后,println!
依然在外面使用了该结构体,因此会导致无效的引用,不出所料,编译报错:
error[E0597]: `novel` does not live long enough
--> src/main.rs:10:30
|
10 | let first_sentence = novel.split('.').next().expect("Could not find a '.'");
| ^^^^^^^^^^^^^^^^ borrowed value does not live long enough
...
14 | }
| - `novel` dropped here while still borrowed
15 | println!("{:?}",i);
| - borrow later used here
生命周期消除
实际上,对于编译器来说,每一个引用类型都有一个生命周期,那么为什么我们在使用过程中,很多时候无需标注生命周期?例如:
#![allow(unused)] fn main() { fn first_word(s: &str) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] } }
该函数的参数和返回值都是引用类型,尽管我们没有显式的为其标注生命周期,编译依然可以通过。其实原因不复杂,编译器为了简化用户的使用,运用了生命周期消除大法。
对于 first_word
函数,它的返回值是一个引用类型,那么该引用只有两种情况:
- 从参数获取
- 从函数体内部新创建的变量获取
如果是后者,就会出现悬垂引用,最终被编译器拒绝,因此只剩一种情况:返回值的引用是获取自参数,这就意味着参数和返回值的生命周期是一样的。道理很简单,我们能看出来,编译器自然也能看出来,因此,就算我们不标注生命周期,也不会产生歧义。
实际上,在 Rust 1.0 版本之前,这种代码果断不给通过,因为 Rust 要求必须显式的为所有引用标注生命周期:
#![allow(unused)] fn main() { fn first_word<'a>(s: &'a str) -> &'a str { }
在写了大量的类似代码后,Rust 社区抱怨声四起,包括开发者自己都忍不了了,最终揭锅而起,这才有了我们今日的幸福。
生命周期消除的规则不是一蹴而就,而是伴随着 总结-改善
流程的周而复始,一步一步走到今天,这也意味着,该规则以后可能也会进一步增加,我们需要手动标注生命周期的时候也会越来越少,hooray!
在开始之前有几点需要注意:
- 消除规则不是万能的,若编译器不能确定某件事是正确时,会直接判为不正确,那么你还是需要手动标注生命周期
- 函数或者方法中,参数的生命周期被称为
输入生命周期
,返回值的生命周期被称为输出生命周期
三条消除规则
编译器使用三条消除规则来确定哪些场景不需要显式地去标注生命周期。其中第一条规则应用在输入生命周期上,第二、三条应用在输出生命周期上。若编译器发现三条规则都不适用时,就会报错,提示你需要手动标注生命周期。
-
每一个引用参数都会获得独自的生命周期
例如一个引用参数的函数就有一个生命周期标注:
fn foo<'a>(x: &'a i32)
,两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32)
, 依此类推。 -
若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期
例如函数
fn foo(x: &i32) -> &i32
,x
参数的生命周期会被自动赋给返回值&i32
,因此该函数等同于fn foo<'a>(x: &'a i32) -> &'a i32
-
若存在多个输入生命周期,且其中一个是
&self
或&mut self
,则&self
的生命周期被赋给所有的输出生命周期拥有
&self
形式的参数,说明该函数是一个方法
,该规则让方法的使用便利度大幅提升。
规则其实很好理解,但是,爱思考的读者肯定要发问了,例如第三条规则,若一个方法,它的返回值的生命周期就是跟参数 &self
的不一样怎么办?总不能强迫我返回的值总是和 &self
活得一样久吧?! 问得好,答案很简单:手动标注生命周期,因为这些规则只是编译器发现你没有标注生命周期时默认去使用的,当你标注生命周期后,编译器自然会乖乖听你的话。
让我们假装自己是编译器,然后看下以下的函数该如何应用这些规则:
例子 1
#![allow(unused)] fn main() { fn first_word(s: &str) -> &str { // 实际项目中的手写代码 }
首先,我们手写的代码如上所示时,编译器会先应用第一条规则,为每个参数标注一个生命周期:
#![allow(unused)] fn main() { fn first_word<'a>(s: &'a str) -> &str { // 编译器自动为参数添加生命周期 }
此时,第二条规则就可以进行应用,因为函数只有一个输入生命周期,因此该生命周期会被赋予所有的输出生命周期:
#![allow(unused)] fn main() { fn first_word<'a>(s: &'a str) -> &'a str { // 编译器自动为返回值添加生命周期 }
此时,编译器为函数签名中的所有引用都自动添加了具体的生命周期,因此编译通过,且用户无需手动去标注生命周期,只要按照 fn first_word(s: &str) -> &str {
的形式写代码即可。
例子 2 再来看一个例子:
#![allow(unused)] fn main() { fn longest(x: &str, y: &str) -> &str { // 实际项目中的手写代码 }
首先,编译器会应用第一条规则,为每个参数都标注生命周期:
#![allow(unused)] fn main() { fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str { }
但是此时,第二条规则却无法被使用,因为输入生命周期有两个,第三条规则也不符合,因为它是函数,不是方法,因此没有 &self
参数。在套用所有规则后,编译器依然无法为返回值标注合适的生命周期,因此,编译器就会报错,提示我们需要手动标注生命周期:
error[E0106]: missing lifetime specifier
--> src/main.rs:1:47
|
1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
| ------- ------- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
note: these named lifetimes are available to use
--> src/main.rs:1:12
|
1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
| ^^ ^^
help: consider using one of the available lifetimes here
|
1 | fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &'lifetime str {
| +++++++++
不得不说,Rust 编译器真的很强大,还贴心的给我们提示了该如何修改,虽然。。。好像。。。。它的提示貌似不太准确。这里我们更希望参数和返回值都是 'a
生命周期。
方法中的生命周期
先来回忆下泛型的语法:
#![allow(unused)] fn main() { struct Point<T> { x: T, y: T, } impl<T> Point<T> { fn x(&self) -> &T { &self.x } } }
实际上,为具有生命周期的结构体实现方法时,我们使用的语法跟泛型参数语法很相似:
#![allow(unused)] fn main() { struct ImportantExcerpt<'a> { part: &'a str, } impl<'a> ImportantExcerpt<'a> { fn level(&self) -> i32 { 3 } } }
其中有几点需要注意的:
impl
中必须使用结构体的完整名称,包括<'a>
,因为生命周期标注也是结构体类型的一部分!- 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则
下面的例子展示了第三规则应用的场景:
#![allow(unused)] fn main() { impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part(&self, announcement: &str) -> &str { println!("Attention please: {}", announcement); self.part } } }
首先,编译器应用第一规则,给予每个输入参数一个生命周期:
#![allow(unused)] fn main() { impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &str { println!("Attention please: {}", announcement); self.part } } }
需要注意的是,编译器不知道 announcement
的生命周期到底多长,因此它无法简单的给予它生命周期 'a
,而是重新声明了一个全新的生命周期 'b
。
接着,编译器应用第三规则,将 &self
的生命周期赋给返回值 &str
:
#![allow(unused)] fn main() { impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'a str { println!("Attention please: {}", announcement); self.part } } }
Bingo,最开始的代码,尽管我们没有给方法标注生命周期,但是在第一和第三规则的配合下,编译器依然完美的为我们亮起了绿灯。
在结束这块儿内容之前,再来做一个有趣的修改,将方法返回的生命周期改为'b
:
#![allow(unused)] fn main() { impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str { println!("Attention please: {}", announcement); self.part } } }
此时,编译器会报错,因为编译器无法知道 'a
和 'b
的关系。 &self
生命周期是 'a
,那么 self.part
的生命周期也是 'a
,但是好巧不巧的是,我们手动为返回值 self.part
标注了生命周期 'b
,因此编译器需要知道 'a
和 'b
的关系。
有一点很容易推理出来:由于 &'a self
是被引用的一方,因此引用它的 &'b str
必须要活得比它短,否则会出现悬垂引用。因此说明生命周期 'b
必须要比 'a
小,只要满足了这一点,编译器就不会再报错:
#![allow(unused)] fn main() { impl<'a: 'b, 'b> ImportantExcerpt<'a> { fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str { println!("Attention please: {}", announcement); self.part } } }
Bang,一个复杂的玩意儿被甩到了你面前,就问怕不怕?
就关键点稍微解释下:
'a: 'b
,是生命周期约束语法,跟泛型约束非常相似,用于说明'a
必须比'b
活得久- 可以把
'a
和'b
都在同一个地方声明(如上),或者分开声明但通过where 'a: 'b
约束生命周期关系,如下:
#![allow(unused)] fn main() { impl<'a> ImportantExcerpt<'a> { fn announce_and_return_part<'b>(&'a self, announcement: &'b str) -> &'b str where 'a: 'b, { println!("Attention please: {}", announcement); self.part } } }
总之,实现方法比想象中简单:加一个约束,就能暗示编译器,尽管引用吧,反正我想引用的内容比我活得久,爱咋咋地,我怎么都不会引用到无效的内容!
静态生命周期
在 Rust 中有一个非常特殊的生命周期,那就是 'static
,拥有该生命周期的引用可以和整个程序活得一样久。
在之前我们学过字符串字面量,提到过它是被硬编码进 Rust 的二进制文件中,因此这些字符串变量全部具有 'static
的生命周期:
#![allow(unused)] fn main() { let s: &'static str = "我没啥优点,就是活得久,嘿嘿"; }
这时候,有些聪明的小脑瓜就开始开动了:当生命周期不知道怎么标时,对类型施加一个静态生命周期的约束 T: 'static
是不是很爽?这样我和编译器再也不用操心它到底活多久了。
嗯,只能说,这个想法是对的,在不少情况下,'static
约束确实可以解决生命周期编译不通过的问题,但是问题来了:本来该引用没有活那么久,但是你非要说它活那么久,万一引入了潜在的 BUG 怎么办?
因此,遇到因为生命周期导致的编译不通过问题,首先想的应该是:是否是我们试图创建一个悬垂引用,或者是试图匹配不一致的生命周期,而不是简单粗暴的用 'static
来解决问题。
但是,话说回来,存在即合理,有时候,'static
确实可以帮助我们解决非常复杂的生命周期问题甚至是无法被手动解决的生命周期问题,那么此时就应该放心大胆的用,只要你确定:你的所有引用的生命周期都是正确的,只是编译器太笨不懂罢了。
总结下:
- 生命周期
'static
意味着能和程序活得一样久,例如字符串字面量和特征对象 - 实在遇到解决不了的生命周期标注问题,可以尝试
T: 'static
,有时候它会给你奇迹
事实上,关于
'static
, 有两种用法:&'static
和T: 'static
,详细内容请参见此处。
一个复杂例子: 泛型、特征约束
手指已经疲软无力,我好想停止,但是华丽的开场都要有与之匹配的谢幕,那我们就用一个稍微复杂点的例子来结束:
#![allow(unused)] fn main() { use std::fmt::Display; fn longest_with_an_announcement<'a, T>( x: &'a str, y: &'a str, ann: T, ) -> &'a str where T: Display, { println!("Announcement! {}", ann); if x.len() > y.len() { x } else { y } } }
依然是熟悉的配方 longest
,但是多了一段废话: ann
,因为要用格式化 {}
来输出 ann
,因此需要它实现 Display
特征。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
总结
我不知道支撑我一口气写完的勇气是什么,也许是不做完不爽夫斯基,也许是一些读者对本书的期待,不管如何,这章足足写了 17000 字,可惜不是写小说,不然肯定可以获取很多月票 :)
从本章开始,最大的收获就是可以在结构体中使用引用类型了,说实话,为了引入这个特性,我已经憋了足足 N 个章节……
但是,还没完,是的,就算是将近两万字,生命周期的旅程依然没有完结,在本书的进阶部分,我们将介绍一些关于生命周期的高级特性,这些特性你在其它中文书中目前还看不到的。
返回值和错误处理
飞鸽传书、八百里加急,自古以来,掌权者最需要的就是及时获得对某个事物的信息反馈,在此过程中,也定义了相应的应急处理措施。
社会演变至今,这种思想依然没变,甚至来到计算中的微观世界,也是如此。及时、准确的获知系统在发生什么,是程序设计的重中之重。因此能够准确的分辨函数返回值是正确的还是错误的、以及在发生错误时该怎么快速处理,成了程序设计语言的必备功能。
Go 语言为人诟病的其中一点就是 if err != nil {} 的大量使用,缺乏一些程序设计的美感,不过我倒是觉得这种简单的方式也有其好处,就是阅读代码时的流畅感很强,你不需要过多的思考各种语法是什么意思。与 Go 语言不同,Rust 博采众家之长,实现了颇具自身色彩的返回值和错误处理体系,本章我们就高屋建瓴地来学习,更加深入的讲解见错误处理。
Rust 的错误哲学
错误对于软件来说是不可避免的,因此一门优秀的编程语言必须有其完整的错误处理哲学。在很多情况下,Rust 需要你承认自己的代码可能会出错,并提前采取行动,来处理这些错误。
Rust 中的错误主要分为两类:
- 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
- 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的
很多编程语言,并不会区分这些错误,而是直接采用异常的方式去处理。Rust 没有异常,但是 Rust 也有自己的卧龙凤雏:Result<T, E>
用于可恢复错误,panic!
用于不可恢复错误。
panic 深入剖析
在正式开始之前,先来思考一个问题:假设我们想要从文件读取数据,如果失败,你有没有好的办法通知调用者为何失败?如果成功,你有没有好的办法把读取的结果返还给调用者?
panic! 与不可恢复错误
上面的问题在真实场景会经常遇到,其实处理起来挺复杂的,让我们先做一个假设:文件读取操作发生在系统启动阶段。那么可以轻易得出一个结论,一旦文件读取失败,那么系统启动也将失败,这意味着该失败是不可恢复的错误,无论是因为文件不存在还是操作系统硬盘的问题,这些只是错误的原因不同,但是归根到底都是不可恢复的错误(梳理清楚当前场景的错误类型非常重要)。
对于这些严重到影响程序运行的错误,触发 panic
是很好的解决方式。在 Rust 中触发 panic
有两种方式:被动触发和主动调用,下面依次来看看。
被动触发
先来看一段简单又熟悉的代码:
fn main() { let v = vec![1, 2, 3]; v[99]; }
心明眼亮的同学立马就能看出这里发生了严重的错误 —— 数组访问越界,在其它编程语言中无一例外,都会报出严重的异常,甚至导致程序直接崩溃关闭。
而 Rust 也不例外,运行后将看到如下报错:
$ cargo run
Compiling panic v0.1.0 (file:///projects/panic)
Finished dev [unoptimized + debuginfo] target(s) in 0.27s
Running `target/debug/panic`
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
上面给出了非常详细的报错信息,包含了具体的异常描述以及发生的位置,甚至你还可以加入额外的命令来看到异常发生时的堆栈信息,这个会在后面详细展开。
总之,类似的 panic
还有很多,而被动触发的 panic
是我们日常开发中最常遇到的,这也是 Rust 给我们的一种保护,毕竟错误只有抛出来,才有可能被处理,否则只会偷偷隐藏起来,寻觅时机给你致命一击。
主动调用
在某些特殊场景中,开发者想要主动抛出一个异常,例如开头提到的在系统启动阶段读取文件失败。
对此,Rust 为我们提供了 panic!
宏,当调用执行该宏时,程序会打印出一个错误信息,展开报错点往前的函数调用堆栈,最后退出程序。
切记,一定是不可恢复的错误,才调用
panic!
处理,你总不想系统仅仅因为用户随便传入一个非法参数就崩溃吧?所以,只有当你不知道该如何处理时,再去调用 panic!.
首先,来调用一下 panic!
,这里使用了最简单的代码实现,实际上你在程序的任何地方都可以这样调用:
fn main() { panic!("crash and burn"); }
运行后输出:
thread 'main' panicked at 'crash and burn', src/main.rs:2:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
以上信息包含了两条重要信息:
main
函数所在的线程崩溃了,发生的代码位置是src/main.rs
中的第 2 行第 5 个字符(包含该行前面的空字符)- 在使用时加上一个环境变量可以获取更详细的栈展开信息:
- Linux/macOS 等 UNIX 系统:
RUST_BACKTRACE=1 cargo run
- Windows 系统(PowerShell):
$env:RUST_BACKTRACE=1 ; cargo run
- Linux/macOS 等 UNIX 系统:
下面让我们针对第二点进行详细展开讲解。
backtrace 栈展开
在真实场景中,错误往往涉及到很长的调用链甚至会深入第三方库,如果没有栈展开技术,错误将难以跟踪处理,下面我们来看一个真实的崩溃例子:
fn main() { let v = vec![1, 2, 3]; v[99]; }
上面的代码很简单,数组只有 3
个元素,我们却尝试去访问它的第 100
号元素(数组索引从 0
开始),那自然会崩溃。
我们的读者里不乏正义之士,此时肯定要质疑,一个简单的数组越界访问,为何要直接让程序崩溃?是不是有些小题大作了?
如果有过 C 语言的经验,即使你越界了,问题不大,我依然尝试去访问,至于这个值是不是你想要的(100
号内存地址也有可能有值,只不过是其它变量或者程序的!),抱歉,不归我管,我只负责取,你要负责管理好自己的索引访问范围。上面这种情况被称为缓冲区溢出,并可能会导致安全漏洞,例如攻击者可以通过索引来访问到数组后面不被允许的数据。
说实话,我宁愿程序崩溃,为什么?当你取到了一个不属于你的值,这在很多时候会导致程序上的逻辑 BUG! 有编程经验的人都知道这种逻辑上的 BUG 是多么难被发现和修复!因此程序直接崩溃,然后告诉我们问题发生的位置,最后我们对此进行修复,这才是最合理的软件开发流程,而不是把问题藏着掖着:
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
好的,现在成功知道问题发生的位置,但是如果我们想知道该问题之前经过了哪些调用环节,该怎么办?那就按照提示使用 RUST_BACKTRACE=1 cargo run
或 $env:RUST_BACKTRACE=1 ; cargo run
来再一次运行程序:
thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 99', src/main.rs:4:5
stack backtrace:
0: rust_begin_unwind
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/std/src/panicking.rs:517:5
1: core::panicking::panic_fmt
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/panicking.rs:101:14
2: core::panicking::panic_bounds_check
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/panicking.rs:77:5
3: <usize as core::slice::index::SliceIndex<[T]>>::index
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/slice/index.rs:184:10
4: core::slice::index::<impl core::ops::index::Index<I> for [T]>::index
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/slice/index.rs:15:9
5: <alloc::vec::Vec<T,A> as core::ops::index::Index<I>>::index
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/alloc/src/vec/mod.rs:2465:9
6: world_hello::main
at ./src/main.rs:4:5
7: core::ops::function::FnOnce::call_once
at /rustc/59eed8a2aac0230a8b53e89d4e99d55912ba6b35/library/core/src/ops/function.rs:227:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
上面的代码就是一次栈展开(也称栈回溯),它包含了函数调用的顺序,当然按照逆序排列:最近调用的函数排在列表的最上方。因为咱们的 main
函数基本是最先调用的函数了,所以排在了倒数第二位,还有一个关注点,排在最顶部最后一个调用的函数是 rust_begin_unwind
,该函数的目的就是进行栈展开,呈现这些列表信息给我们。
要获取到栈回溯信息,你还需要开启 debug
标志,该标志在使用 cargo run
或者 cargo build
时自动开启(这两个操作默认是 Debug
运行方式)。同时,栈展开信息在不同操作系统或者 Rust 版本上也有所不同。
panic 时的两种终止方式
当出现 panic!
时,程序提供了两种方式来处理终止流程:栈展开和直接终止。
其中,默认的方式就是 栈展开
,这意味着 Rust 会回溯栈上数据和函数调用,因此也意味着更多的善后工作,好处是可以给出充分的报错信息和栈调用信息,便于事后的问题复盘。直接终止
,顾名思义,不清理数据就直接退出程序,善后工作交与操作系统来负责。
对于绝大多数用户,使用默认选择是最好的,但是当你关心最终编译出的二进制可执行文件大小时,那么可以尝试去使用直接终止的方式,例如下面的配置修改 Cargo.toml
文件,实现在 release
模式下遇到 panic
直接终止:
#![allow(unused)] fn main() { [profile.release] panic = 'abort' }
线程 panic
后,程序是否会终止?
长话短说,如果是 main
线程,则程序会终止,如果是其它子线程,该线程会终止,但是不会影响 main
线程。因此,尽量不要在 main
线程中做太多任务,将这些任务交由子线程去做,就算子线程 panic
也不会导致整个程序的结束。
具体解析见 panic 原理剖析。
何时该使用 panic!
下面让我们大概罗列下何时适合使用 panic
,也许经过之前的学习,你已经能够对 panic
的使用有了自己的看法,但是我们还是会罗列一些常见的用法来加深你的理解。
先来一点背景知识,在前面章节我们粗略讲过 Result<T, E>
这个枚举类型,它是用来表示函数的返回结果:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
当没有错误发生时,函数返回一个用 Result
类型包裹的值 Ok(T)
,当错误时,返回一个 Err(E)
。对于 Result
返回我们有很多处理方法,最简单粗暴的就是 unwrap
和 expect
,这两个函数非常类似,我们以 unwrap
举例:
#![allow(unused)] fn main() { use std::net::IpAddr; let home: IpAddr = "127.0.0.1".parse().unwrap(); }
上面的 parse
方法试图将字符串 "127.0.0.1"
解析为一个 IP 地址类型 IpAddr
,它返回一个 Result<IpAddr, E>
类型,如果解析成功,则把 Ok(IpAddr)
中的值赋给 home
,如果失败,则不处理 Err(E)
,而是直接 panic
。
因此 unwrap
简而言之:成功则返回值,失败则 panic
,总之不进行任何错误处理。
示例、原型、测试
这几个场景下,需要快速地搭建代码,错误处理会拖慢编码的速度,也不是特别有必要,因此通过 unwrap
、expect
等方法来处理是最快的。
同时,当我们回头准备做错误处理时,可以全局搜索这些方法,不遗漏地进行替换。
你确切的知道你的程序是正确时,可以使用 panic
因为 panic
的触发方式比错误处理要简单,因此可以让代码更清晰,可读性也更加好,当我们的代码注定是正确时,你可以用 unwrap
等方法直接进行处理,反正也不可能 panic
:
#![allow(unused)] fn main() { use std::net::IpAddr; let home: IpAddr = "127.0.0.1".parse().unwrap(); }
例如上面的例子,"127.0.0.1"
就是 ip
地址,因此我们知道 parse
方法一定会成功,那么就可以直接用 unwrap
方法进行处理。
当然,如果该字符串是来自于用户输入,那在实际项目中,就必须用错误处理的方式,而不是 unwrap
,否则你的程序一天要崩溃几十万次吧!
可能导致全局有害状态时
有害状态大概分为几类:
- 非预期的错误
- 后续代码的运行会受到显著影响
- 内存安全的问题
当错误预期会出现时,返回一个错误较为合适,例如解析器接收到格式错误的数据,HTTP 请求接收到错误的参数甚至该请求内的任何错误(不会导致整个程序有问题,只影响该次请求)。因为错误是可预期的,因此也是可以处理的。
当启动时某个流程发生了错误,对后续代码的运行造成了影响,那么就应该使用 panic
,而不是处理错误后继续运行,当然你可以通过重试的方式来继续。
上面提到过,数组访问越界,就要 panic
的原因,这个就是属于内存安全的范畴,一旦内存访问不安全,那么我们就无法保证自己的程序会怎么运行下去,也无法保证逻辑和数据的正确性。
panic 原理剖析
本来不想写这块儿内容,因为真的难写,但是转念一想,既然号称圣经,那么本书就得与众不同,避重就轻显然不是该有的态度。
当调用 panic!
宏时,它会
- 格式化
panic
信息,然后使用该信息作为参数,调用std::panic::panic_any()
函数 panic_any
会检查应用是否使用了panic hook
,如果使用了,该hook
函数就会被调用(hook
是一个钩子函数,是外部代码设置的,用于在panic
触发时,执行外部代码所需的功能)- 当
hook
函数返回后,当前的线程就开始进行栈展开:从panic_any
开始,如果寄存器或者栈因为某些原因信息错乱了,那很可能该展开会发生异常,最终线程会直接停止,展开也无法继续进行 - 展开的过程是一帧一帧的去回溯整个栈,每个帧的数据都会随之被丢弃,但是在展开过程中,你可能会遇到被用户标记为
catching
的帧(通过std::panic::catch_unwind()
函数标记),此时用户提供的catch
函数会被调用,展开也随之停止:当然,如果catch
选择在内部调用std::panic::resume_unwind()
函数,则展开还会继续。
还有一种情况,在展开过程中,如果展开本身 panic
了,那展开线程会终止,展开也随之停止。
一旦线程展开被终止或者完成,最终的输出结果是取决于哪个线程 panic
:对于 main
线程,操作系统提供的终止功能 core::intrinsics::abort()
会被调用,最终结束当前的 panic
进程;如果是其它子线程,那么子线程就会简单的终止,同时信息会在稍后通过 std::thread::join()
进行收集。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
可恢复的错误 Result
还记得上一节中,提到的关于文件读取的思考题吧?当时我们解决了读取文件时遇到不可恢复错误该怎么处理的问题,现在来看看,读取过程中,正常返回和遇到可以恢复的错误时该如何处理。
假设,我们有一台消息服务器,每个用户都通过 websocket 连接到该服务器来接收和发送消息,该过程就涉及到 socket 文件的读写,那么此时,如果一个用户的读写发生了错误,显然不能直接 panic
,否则服务器会直接崩溃,所有用户都会断开连接,因此我们需要一种更温和的错误处理方式:Result<T, E>
。
之前章节有提到过,Result<T, E>
是一个枚举类型,定义如下:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
泛型参数 T
代表成功时存入的正确值的类型,存放方式是 Ok(T)
,E
代表错误时存入的错误值,存放方式是 Err(E)
,枯燥的讲解永远不及代码生动准确,因此先来看下打开文件的例子:
use std::fs::File; fn main() { let f = File::open("hello.txt"); }
以上 File::open
返回一个 Result
类型,那么问题来了:
如何获知变量类型或者函数的返回类型
有几种常用的方式,此处更推荐第二种方法:
- 第一种是查询标准库或者三方库文档,搜索
File
,然后找到它的open
方法- 在 Rust IDE 章节,我们推荐了
VSCode
IDE 和rust-analyzer
插件,如果你成功安装的话,那么就可以在VSCode
中很方便的通过代码跳转的方式查看代码,同时rust-analyzer
插件还会对代码中的类型进行标注,非常方便好用!- 你还可以尝试故意标记一个错误的类型,然后让编译器告诉你:
#![allow(unused)] fn main() { let f: u32 = File::open("hello.txt"); }
错误提示如下:
error[E0308]: mismatched types
--> src/main.rs:4:18
|
4 | let f: u32 = File::open("hello.txt");
| ^^^^^^^^^^^^^^^^^^^^^^^ expected u32, found enum
`std::result::Result`
|
= note: expected type `u32`
found type `std::result::Result<std::fs::File, std::io::Error>`
上面代码,故意将 f
类型标记成整形,编译器立刻不乐意了,你是在忽悠我吗?打开文件操作返回一个整形?来,大哥来告诉你返回什么:std::result::Result<std::fs::File, std::io::Error>
,我的天呐,怎么这么长的类型!
别慌,其实很简单,首先 Result
本身是定义在 std::result
中的,但是因为 Result
很常用,所以就被包含在了 prelude
中(将常用的东东提前引入到当前作用域内),因此无需手动引入 std::result::Result
,那么返回类型可以简化为 Result<std::fs::File,std::io::Error>
,你看看是不是很像标准的 Result<T, E>
枚举定义?只不过 T
被替换成了具体的类型 std::fs::File
,是一个文件句柄类型,E
被替换成 std::io::Error
,是一个 IO 错误类型。
这个返回值类型说明 File::open
调用如果成功则返回一个可以进行读写的文件句柄,如果失败,则返回一个 IO 错误:文件不存在或者没有访问文件的权限等。总之 File::open
需要一个方式告知调用者是成功还是失败,并同时返回具体的文件句柄(成功)或错误信息(失败),万幸的是,这些信息可以通过 Result
枚举提供:
use std::fs::File; fn main() { let f = File::open("hello.txt"); let f = match f { Ok(file) => file, Err(error) => { panic!("Problem opening the file: {:?}", error) }, }; }
代码很清晰,对打开文件后的 Result<T, E>
类型进行匹配取值,如果是成功,则将 Ok(file)
中存放的的文件句柄 file
赋值给 f
,如果失败,则将 Err(error)
中存放的错误信息 error
使用 panic
抛出来,进而结束程序,这非常符合上文提到过的 panic
使用场景。
好吧,也没有那么合理 :)
对返回的错误进行处理
直接 panic
还是过于粗暴,因为实际上 IO 的错误有很多种,我们需要对部分错误进行特殊处理,而不是所有错误都直接崩溃:
use std::fs::File; use std::io::ErrorKind; fn main() { let f = File::open("hello.txt"); let f = match f { Ok(file) => file, Err(error) => match error.kind() { ErrorKind::NotFound => match File::create("hello.txt") { Ok(fc) => fc, Err(e) => panic!("Problem creating the file: {:?}", e), }, other_error => panic!("Problem opening the file: {:?}", other_error), }, }; }
上面代码在匹配出 error
后,又对 error
进行了详细的匹配解析,最终结果:
- 如果是文件不存在错误
ErrorKind::NotFound
,就创建文件,这里创建文件File::create
也是返回Result
,因此继续用match
对其结果进行处理:创建成功,将新的文件句柄赋值给f
,如果失败,则panic
- 剩下的错误,一律
panic
虽然很清晰,但是代码还是有些啰嗦,我们会在简化错误处理一章重点讲述如何写出更优雅的错误。
失败就 panic: unwrap 和 expect
上一节中,已经看到过这两兄弟的简单介绍,这里再来回顾下。
在不需要处理错误的场景,例如写原型、示例时,我们不想使用 match
去匹配 Result<T, E>
以获取其中的 T
值,因为 match
的穷尽匹配特性,你总要去处理下 Err
分支。那么有没有办法简化这个过程?有,答案就是 unwrap
和 expect
。
它们的作用就是,如果返回成功,就将 Ok(T)
中的值取出来,如果失败,就直接 panic
,真的勇士绝不多 BB,直接崩溃。
use std::fs::File; fn main() { let f = File::open("hello.txt").unwrap(); }
如果调用这段代码时 hello.txt 文件不存在,那么 unwrap
就将直接 panic
:
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
expect
跟 unwrap
很像,也是遇到错误直接 panic
, 但是会带上自定义的错误提示信息,相当于重载了错误打印的函数:
use std::fs::File; fn main() { let f = File::open("hello.txt").expect("Failed to open hello.txt"); }
报错如下:
thread 'main' panicked at 'Failed to open hello.txt: Os { code: 2, kind: NotFound, message: "No such file or directory" }', src/main.rs:4:37
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
可以看出,expect
相比 unwrap
能提供更精确的错误信息,在有些场景也会更加实用。
传播错误
咱们的程序几乎不太可能只有 A->B
形式的函数调用,一个设计良好的程序,一个功能涉及十几层的函数调用都有可能。而错误处理也往往不是哪里调用出错,就在哪里处理,实际应用中,大概率会把错误层层上传然后交给调用链的上游函数进行处理,错误传播将极为常见。
例如以下函数从文件中读取用户名,然后将结果进行返回:
#![allow(unused)] fn main() { use std::fs::File; use std::io::{self, Read}; fn read_username_from_file() -> Result<String, io::Error> { // 打开文件,f是`Result<文件句柄,io::Error>` let f = File::open("hello.txt"); let mut f = match f { // 打开文件成功,将file句柄赋值给f Ok(file) => file, // 打开文件失败,将错误返回(向上传播) Err(e) => return Err(e), }; // 创建动态字符串s let mut s = String::new(); // 从f文件句柄读取数据并写入s中 match f.read_to_string(&mut s) { // 读取成功,返回Ok封装的字符串 Ok(_) => Ok(s), // 将错误向上传播 Err(e) => Err(e), } } }
有几点值得注意:
- 该函数返回一个
Result<String, io::Error>
类型,当读取用户名成功时,返回Ok(String)
,失败时,返回Err(io:Error)
File::open
和f.read_to_string
返回的Result<T, E>
中的E
就是io::Error
由此可见,该函数将 io::Error
的错误往上进行传播,该函数的调用者最终会对 Result<String,io::Error>
进行再处理,至于怎么处理就是调用者的事,如果是错误,它可以选择继续向上传播错误,也可以直接 panic
,亦或将具体的错误原因包装后写入 socket 中呈现给终端用户。
但是上面的代码也有自己的问题,那就是太长了(优秀的程序员身上的优点极多,其中最大的优点就是懒),我自认为也有那么一点点优秀,因此见不得这么啰嗦的代码,下面咱们来讲讲如何简化它。
传播界的大明星: ?
大明星出场,必须得有排面,来看看 ?
的排面:
#![allow(unused)] fn main() { use std::fs::File; use std::io; use std::io::Read; 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) } }
看到没,这就是排面,相比前面的 match
处理错误的函数,代码直接减少了一半不止,但是,一山更比一山难,看不懂啊!
其实 ?
就是一个宏,它的作用跟上面的 match
几乎一模一样:
#![allow(unused)] fn main() { let mut f = match f { // 打开文件成功,将file句柄赋值给f Ok(file) => file, // 打开文件失败,将错误返回(向上传播) Err(e) => return Err(e), }; }
如果结果是 Ok(T)
,则把 T
赋值给 f
,如果结果是 Err(E)
,则返回该错误,所以 ?
特别适合用来传播错误。
虽然 ?
和 match
功能一致,但是事实上 ?
会更胜一筹。何解?
想象一下,一个设计良好的系统中,肯定有自定义的错误特征,错误之间很可能会存在上下级关系,例如标准库中的 std::io::Error
和 std::error::Error
,前者是 IO 相关的错误结构体,后者是一个最最通用的标准错误特征,同时前者实现了后者,因此 std::io::Error
可以转换为 std:error::Error
。
明白了以上的错误转换,?
的更胜一筹就很好理解了,它可以自动进行类型提升(转换):
#![allow(unused)] fn main() { fn open_file() -> Result<File, Box<dyn std::error::Error>> { let mut f = File::open("hello.txt")?; Ok(f) } }
上面代码中 File::open
报错时返回的错误是 std::io::Error
类型,但是 open_file
函数返回的错误类型是 std::error::Error
的特征对象,可以看到一个错误类型通过 ?
返回后,变成了另一个错误类型,这就是 ?
的神奇之处。
根本原因是在于标准库中定义的 From
特征,该特征有一个方法 from
,用于把一个类型转成另外一个类型,?
可以自动调用该方法,然后进行隐式类型转换。因此只要函数返回的错误 ReturnError
实现了 From<OtherError>
特征,那么 ?
就会自动把 OtherError
转换为 ReturnError
。
这种转换非常好用,意味着你可以用一个大而全的 ReturnError
来覆盖所有错误类型,只需要为各种子错误类型实现这种转换即可。
强中自有强中手,一码更比一码短:
#![allow(unused)] fn main() { use std::fs::File; use std::io; use std::io::Read; fn read_username_from_file() -> Result<String, io::Error> { let mut s = String::new(); File::open("hello.txt")?.read_to_string(&mut s)?; Ok(s) } }
瞧见没? ?
还能实现链式调用,File::open
遇到错误就返回,没有错误就将 Ok
中的值取出来用于下一个方法调用,简直太精妙了,从 Go 语言过来的我,内心狂喜(其实学 Rust 的苦和痛我才不会告诉你们)。
不仅有更强,还要有最强,我不信还有人比我更短(不要误解):
#![allow(unused)] fn main() { use std::fs; use std::io; fn read_username_from_file() -> Result<String, io::Error> { // read_to_string是定义在std::io中的方法,因此需要在上面进行引用 fs::read_to_string("hello.txt") } }
从文件读取数据到字符串中,是比较常见的操作,因此 Rust 标准库为我们提供了 fs::read_to_string
函数,该函数内部会打开一个文件、创建 String
、读取文件内容最后写入字符串并返回,因为该函数其实与本章讲的内容关系不大,因此放在最后来讲,其实只是我想震你们一下 :)
? 用于 Option 的返回
?
不仅仅可以用于 Result
的传播,还能用于 Option
的传播,再来回忆下 Option
的定义:
#![allow(unused)] fn main() { pub enum Option<T> { Some(T), None } }
Result
通过 ?
返回错误,那么 Option
就通过 ?
返回 None
:
#![allow(unused)] fn main() { fn first(arr: &[i32]) -> Option<&i32> { let v = arr.get(0)?; Some(v) } }
上面的函数中,arr.get
返回一个 Option<&i32>
类型,因为 ?
的使用,如果 get
的结果是 None
,则直接返回 None
,如果是 Some(&i32)
,则把里面的值赋给 v
。
其实这个函数有些画蛇添足,我们完全可以写出更简单的版本:
#![allow(unused)] fn main() { fn first(arr: &[i32]) -> Option<&i32> { arr.get(0) } }
有一句话怎么说?没有需求,制造需求也要上……大家别跟我学习,这是软件开发大忌。只能用代码洗洗眼了:
#![allow(unused)] fn main() { fn last_char_of_first_line(text: &str) -> Option<char> { text.lines().next()?.chars().last() } }
上面代码展示了在链式调用中使用 ?
提前返回 None
的用法, .next
方法返回的是 Option
类型:如果返回 Some(&str)
,那么继续调用 chars
方法,如果返回 None
,则直接从整个函数中返回 None
,不再继续进行链式调用。
新手用 ? 常会犯的错误
初学者在用 ?
时,老是会犯错,例如写出这样的代码:
#![allow(unused)] fn main() { fn first(arr: &[i32]) -> Option<&i32> { arr.get(0)? } }
这段代码无法通过编译,切记:?
操作符需要一个变量来承载正确的值,这个函数只会返回 Some(&i32)
或者 None
,只有错误值能直接返回,正确的值不行,所以如果数组中存在 0 号元素,那么函数第二行使用 ?
后的返回类型为 &i32
而不是 Some(&i32)
。因此 ?
只能用于以下形式:
let v = xxx()?;
xxx()?.yyy()?;
带返回值的 main 函数
在了解了 ?
的使用限制后,这段代码你很容易看出它无法编译:
use std::fs::File; fn main() { let f = File::open("hello.txt")?; }
运行后会报错:
$ cargo run
...
the `?` operator can only be used in a function that returns `Result` or `Option` (or another type that implements `FromResidual`)
--> src/main.rs:4:48
|
3 | fn main() {
| --------- this function should return `Result` or `Option` to accept `?`
4 | let greeting_file = File::open("hello.txt")?;
| ^ cannot use the `?` operator in a function that returns `()`
|
= help: the trait `FromResidual<Result<Infallible, std::io::Error>>` is not implemented for `()`
因为 ?
要求 Result<T, E>
形式的返回值,而 main
函数的返回是 ()
,因此无法满足,那是不是就无解了呢?
实际上 Rust 还支持另外一种形式的 main
函数:
use std::error::Error; use std::fs::File; fn main() -> Result<(), Box<dyn Error>> { let f = File::open("hello.txt")?; Ok(()) }
这样就能使用 ?
提前返回了,同时我们又一次看到了Box<dyn Error>
特征对象,因为 std::error:Error
是 Rust 中抽象层次最高的错误,其它标准库中的错误都实现了该特征,因此我们可以用该特征对象代表一切错误,就算 main
函数中调用任何标准库函数发生错误,都可以通过 Box<dyn Error>
这个特征对象进行返回。
至于 main
函数可以有多种返回值,那是因为实现了 std::process::Termination 特征,目前为止该特征还没进入稳定版 Rust 中,也许未来你可以为自己的类型实现该特征!
try!
在 ?
横空出世之前( Rust 1.13 ),Rust 开发者还可以使用 try!
来处理错误,该宏的大致定义如下:
#![allow(unused)] fn main() { macro_rules! try { ($e:expr) => (match $e { Ok(val) => val, Err(err) => return Err(::std::convert::From::from(err)), }); } }
简单看一下与 ?
的对比:
#![allow(unused)] fn main() { // `?` let x = function_with_error()?; // 若返回 Err, 则立刻返回;若返回 Ok(255),则将 x 的值设置为 255 // `try!()` let x = try!(function_with_error()); }
可以看出 ?
的优势非常明显,何况 ?
还能做链式调用。
总之,try!
作为前浪已经死在了沙滩上,在当前版本中,我们要尽量避免使用 try!。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
包和模块
当工程规模变大时,把代码写到一个甚至几个文件中,都是不太聪明的做法,可能存在以下问题:
- 单个文件过大,导致打开、翻页速度大幅变慢
- 查询和定位效率大幅降低,类比下,你会把所有知识内容放在一个几十万字的文档中吗?
- 只有一个代码层次:函数,难以维护和协作,想象一下你的操作系统只有一个根目录,剩下的都是单层子目录会如何:
disaster
- 容易滋生 Bug
同时,将大的代码文件拆分成包和模块,还允许我们实现代码抽象和复用:将你的代码封装好后提供给用户,那么用户只需要调用公共接口即可,无需知道内部该如何实现。
因此,跟其它语言一样,Rust 也提供了相应概念用于代码的组织管理:
- 项目(Packages):一个
Cargo
提供的feature
,可以用来构建、测试和分享包 - 包(Crate):一个由多个模块组成的树形结构,可以作为三方库进行分发,也可以生成可执行文件进行运行
- 模块(Module):可以一个文件多个模块,也可以一个文件一个模块,模块可以被认为是真实项目中的代码组织单元
下面,让我们一一来学习这些概念以及如何在实践中运用。
包和 Package
当读者按照章节顺序读到本章时,意味着你已经几乎具备了参与真实项目开发的能力。但是真实项目远比我们之前的 cargo new
的默认目录结构要复杂,好在,Rust 为我们提供了强大的包管理工具:
- 项目(Package):可以用来构建、测试和分享包
- 工作空间(WorkSpace):对于大型项目,可以进一步将多个包联合在一起,组织成工作空间
- 包(Crate):一个由多个模块组成的树形结构,可以作为三方库进行分发,也可以生成可执行文件进行运行
- 模块(Module):可以一个文件多个模块,也可以一个文件一个模块,模块可以被认为是真实项目中的代码组织单元
定义
其实项目 Package
和包 Crate
很容易被搞混,甚至在很多书中,这两者都是不分的,但是由于官方对此做了明确的区分,因此我们会在本章节中试图(挣扎着)理清这个概念。
包 Crate
对于 Rust 而言,包是一个独立的可编译单元,它编译后会生成一个可执行文件或者一个库。
一个包会将相关联的功能打包在一起,使得该功能可以很方便的在多个项目中分享。例如标准库中没有提供但是在三方库中提供的 rand
包,它提供了随机数生成的功能,我们只需要将该包通过 use rand;
引入到当前项目的作用域中,就可以在项目中使用 rand
的功能:rand::XXX
。
同一个包中不能有同名的类型,但是在不同包中就可以。例如,虽然 rand
包中,有一个 Rng
特征,可是我们依然可以在自己的项目中定义一个 Rng
,前者通过 rand::Rng
访问,后者通过 Rng
访问,对于编译器而言,这两者的边界非常清晰,不会存在引用歧义。
项目 Package
鉴于 Rust 团队标新立异的起名传统,以及包的名称被 crate
占用,库的名称被 library
占用,经过斟酌, 我们决定将 Package
翻译成项目,你也可以理解为工程、软件包。
由于 Package
就是一个项目,因此它包含有独立的 Cargo.toml
文件,以及因为功能性被组织在一起的一个或多个包。一个 Package
只能包含一个库(library)类型的包,但是可以包含多个二进制可执行类型的包。
二进制 Package
让我们来创建一个二进制 Package
:
$ cargo new my-project
Created binary (application) `my-project` package
$ ls my-project
Cargo.toml
src
$ ls my-project/src
main.rs
这里,Cargo 为我们创建了一个名称是 my-project
的 Package
,同时在其中创建了 Cargo.toml
文件,可以看一下该文件,里面并没有提到 src/main.rs
作为程序的入口,原因是 Cargo 有一个惯例:src/main.rs
是二进制包的根文件,该二进制包的包名跟所属 Package
相同,在这里都是 my-project
,所有的代码执行都从该文件中的 fn main()
函数开始。
使用 cargo run
可以运行该项目,输出:Hello, world!
。
库 Package
再来创建一个库类型的 Package
:
$ cargo new my-lib --lib
Created library `my-lib` package
$ ls my-lib
Cargo.toml
src
$ ls my-lib/src
lib.rs
首先,如果你试图运行 my-lib
,会报错:
$ cargo run
error: a bin target must be available for `cargo run`
原因是库类型的 Package
只能作为三方库被其它项目引用,而不能独立运行,只有之前的二进制 Package
才可以运行。
与 src/main.rs
一样,Cargo 知道,如果一个 Package
包含有 src/lib.rs
,意味它包含有一个库类型的同名包 my-lib
,该包的根文件是 src/lib.rs
。
易混淆的 Package 和包
看完上面,相信大家看出来为何 Package
和包容易被混淆了吧?因为你用 cargo new
创建的 Package
和它其中包含的包是同名的!
不过,只要你牢记 Package
是一个项目工程,而包只是一个编译单元,基本上也就不会混淆这个两个概念了:src/main.rs
和 src/lib.rs
都是编译单元,因此它们都是包。
典型的 Package
结构
上面创建的 Package
中仅包含 src/main.rs
文件,意味着它仅包含一个二进制同名包 my-project
。如果一个 Package
同时拥有 src/main.rs
和 src/lib.rs
,那就意味着它包含两个包:库包和二进制包,这两个包名也都是 my-project
—— 都与 Package
同名。
一个真实项目中典型的 Package
,会包含多个二进制包,这些包文件被放在 src/bin
目录下,每一个文件都是独立的二进制包,同时也会包含一个库包,该包只能存在一个 src/lib.rs
:
.
├── Cargo.toml
├── Cargo.lock
├── src
│ ├── main.rs
│ ├── lib.rs
│ └── bin
│ └── main1.rs
│ └── main2.rs
├── tests
│ └── some_integration_tests.rs
├── benches
│ └── simple_bench.rs
└── examples
└── simple_example.rs
- 唯一库包:
src/lib.rs
- 默认二进制包:
src/main.rs
,编译后生成的可执行文件与Package
同名 - 其余二进制包:
src/bin/main1.rs
和src/bin/main2.rs
,它们会分别生成一个文件同名的二进制可执行文件 - 集成测试文件:
tests
目录下 - 基准性能测试
benchmark
文件:benches
目录下 - 项目示例:
examples
目录下
这种目录结构基本上是 Rust 的标准目录结构,在 GitHub
的大多数项目上,你都将看到它的身影。
理解了包的概念,我们再来看看构成包的基本单元:模块。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
模块 Module
在本章节,我们将深入讲讲 Rust 的代码构成单元:模块。使用模块可以将包中的代码按照功能性进行重组,最终实现更好的可读性及易用性。同时,我们还能非常灵活地去控制代码的可见性,进一步强化 Rust 的安全性。
创建嵌套模块
小旅馆,sorry,是小餐馆,相信大家都挺熟悉的,学校外的估计也没少去,那么咱就用小餐馆为例,来看看 Rust 的模块该如何使用。
使用 cargo new --lib restaurant
创建一个小餐馆,注意,这里创建的是一个库类型的 Package
,然后将以下代码放入 src/lib.rs
中:
#![allow(unused)] fn main() { // 餐厅前厅,用于吃饭 mod front_of_house { mod hosting { fn add_to_waitlist() {} fn seat_at_table() {} } mod serving { fn take_order() {} fn serve_order() {} fn take_payment() {} } } }
以上的代码创建了三个模块,有几点需要注意的:
- 使用
mod
关键字来创建新模块,后面紧跟着模块名称 - 模块可以嵌套,这里嵌套的原因是招待客人和服务都发生在前厅,因此我们的代码模拟了真实场景
- 模块中可以定义各种 Rust 类型,例如函数、结构体、枚举、特征等
- 所有模块均定义在同一个文件中
类似上述代码中所做的,使用模块,我们就能将功能相关的代码组织到一起,然后通过一个模块名称来说明这些代码为何被组织在一起。这样其它程序员在使用你的模块时,就可以更快地理解和上手。
模块树
在上一节中,我们提到过 src/main.rs
和 src/lib.rs
被称为包根(crate root),这个奇葩名称的来源(我不想承认是自己翻译水平太烂-,-)是由于这两个文件的内容形成了一个模块 crate
,该模块位于包的树形结构(由模块组成的树形结构)的根部:
crate
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
这颗树展示了模块之间彼此的嵌套关系,因此被称为模块树。其中 crate
包根是 src/lib.rs
文件,包根文件中的三个模块分别形成了模块树的剩余部分。
父子模块
如果模块 A
包含模块 B
,那么 A
是 B
的父模块,B
是 A
的子模块。在上例中,front_of_house
是 hosting
和 serving
的父模块,反之,后两者是前者的子模块。
聪明的读者,应该能联想到,模块树跟计算机上文件系统目录树的相似之处。不仅仅是组织结构上的相似,就连使用方式都很相似:每个文件都有自己的路径,用户可以通过这些路径使用它们,在 Rust 中,我们也通过路径的方式来引用模块。
用路径引用模块
想要调用一个函数,就需要知道它的路径,在 Rust 中,这种路径有两种形式:
- 绝对路径,从包根开始,路径名以包名或者
crate
作为开头 - 相对路径,从当前模块开始,以
self
,super
或当前模块的标识符作为开头
让我们继续经营那个惨淡的小餐馆,这次为它实现一个小功能: 文件名:src/lib.rs
#![allow(unused)] fn main() { mod front_of_house { mod hosting { fn add_to_waitlist() {} } } pub fn eat_at_restaurant() { // 绝对路径 crate::front_of_house::hosting::add_to_waitlist(); // 相对路径 front_of_house::hosting::add_to_waitlist(); } }
上面的代码为了简化实现,省去了其余模块和函数,这样可以把关注点放在函数调用上。eat_at_restaurant
是一个定义在包根中的函数,在该函数中使用了两种方式对 add_to_waitlist
进行调用。
绝对路径引用
因为 eat_at_restaurant
和 add_to_waitlist
都定义在一个包中,因此在绝对路径引用时,可以直接以 crate
开头,然后逐层引用,每一层之间使用 ::
分隔:
#![allow(unused)] fn main() { crate::front_of_house::hosting::add_to_waitlist(); }
对比下之前的模块树:
crate
└── eat_at_restaurant
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
└── serving
├── take_order
├── serve_order
└── take_payment
可以看出,绝对路径的调用,完全符合了模块树的层级递进,非常符合直觉,如果类比文件系统,就跟使用绝对路径调用可执行程序差不多:/front_of_house/hosting/add_to_waitlist
,使用 crate
作为开始就和使用 /
作为开始一样。
相对路径引用
再回到模块树中,因为 eat_at_restaurant
和 front_of_house
都处于包根 crate
中,因此相对路径可以使用 front_of_house
作为开头:
#![allow(unused)] fn main() { front_of_house::hosting::add_to_waitlist(); }
如果类比文件系统,那么它类似于调用同一个目录下的程序,你可以这么做:front_of_house/hosting/add_to_waitlist
,嗯也很符合直觉。
绝对还是相对?
如果只是为了引用到指定模块中的对象,那么两种都可以,但是在实际使用时,需要遵循一个原则:当代码被挪动位置时,尽量减少引用路径的修改,相信大家都遇到过,修改了某处代码,导致所有路径都要挨个替换,这显然不是好的路径选择。
回到之前的例子,如果我们把 front_of_house
模块和 eat_at_restaurant
移动到一个模块中 customer_experience
,那么绝对路径的引用方式就必须进行修改:crate::customer_experience::front_of_house ...
,但是假设我们使用的相对路径,那么该路径就无需修改,因为它们两个的相对位置其实没有变:
crate
└── customer_experience
└── eat_at_restaurant
└── front_of_house
├── hosting
│ ├── add_to_waitlist
│ └── seat_at_table
从新的模块树中可以很清晰的看出这一点。
再比如,其它的都不动,把 eat_at_restaurant
移动到模块 dining
中,如果使用相对路径,你需要修改该路径,但如果使用的是绝对路径,就无需修改:
crate
└── dining
└── eat_at_restaurant
└── front_of_house
├── hosting
│ ├── add_to_waitlist
不过,如果不确定哪个好,你可以考虑优先使用绝对路径,因为调用的地方和定义的地方往往是分离的,而定义的地方较少会变动。
代码可见性
让我们运行下面(之前)的代码:
#![allow(unused)] fn main() { mod front_of_house { mod hosting { fn add_to_waitlist() {} } } pub fn eat_at_restaurant() { // 绝对路径 crate::front_of_house::hosting::add_to_waitlist(); // 相对路径 front_of_house::hosting::add_to_waitlist(); } }
意料之外的报错了,毕竟看上去确实很简单且没有任何问题:
error[E0603]: module `hosting` is private
--> src/lib.rs:9:28
|
9 | crate::front_of_house::hosting::add_to_waitlist();
| ^^^^^^^ private module
错误信息很清晰:hosting
模块是私有的,无法在包根进行访问,那么为何 front_of_house
模块就可以访问?因为它和 eat_at_restaurant
同属于一个包根作用域内,同一个模块内的代码自然不存在私有化问题(所以我们之前章节的代码都没有报过这个错误!)。
模块不仅仅对于组织代码很有用,它还能定义代码的私有化边界:在这个边界内,什么内容能让外界看到,什么内容不能,都有很明确的定义。因此,如果希望让函数或者结构体等类型变成私有化的,可以使用模块。
Rust 出于安全的考虑,默认情况下,所有的类型都是私有化的,包括函数、方法、结构体、枚举、常量,是的,就连模块本身也是私有化的。在中国,父亲往往不希望孩子拥有小秘密,但是在 Rust 中,父模块完全无法访问子模块中的私有项,但是子模块却可以访问父模块、父父..模块的私有项。
pub 关键字
类似其它语言的 public
或者 Go 语言中的首字母大写,Rust 提供了 pub
关键字,通过它你可以控制模块和模块中指定项的可见性。
由于之前的解释,我们知道了只需要将 hosting
模块标记为对外可见即可:
#![allow(unused)] fn main() { mod front_of_house { pub mod hosting { fn add_to_waitlist() {} } } /*--- snip ----*/ }
但是不幸的是,又报错了:
error[E0603]: function `add_to_waitlist` is private
--> src/lib.rs:12:30
|
12 | front_of_house::hosting::add_to_waitlist();
| ^^^^^^^^^^^^^^^ private function
哦?难道模块可见还不够,还需要将函数 add_to_waitlist
标记为可见的吗? 是的,没错,模块可见性不代表模块内部项的可见性,模块的可见性仅仅是允许其它模块去引用它,但是想要引用它内部的项,还得继续将对应的项标记为 pub
。
在实际项目中,一个模块需要对外暴露的数据和 API 往往就寥寥数个,如果将模块标记为可见代表着内部项也全部对外可见,那你是不是还得把那些不可见的,一个一个标记为 private
?反而是更麻烦的多。
既然知道了如何解决,那么我们为函数也标记上 pub
:
#![allow(unused)] fn main() { mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } /*--- snip ----*/ }
Bang,顺利通过编译,感觉自己又变强了。
使用 super
引用模块
在用路径引用模块中,我们提到了相对路径有三种方式开始:self
、super
和 crate
或者模块名,其中第三种在前面已经讲到过,现在来看看通过 super
的方式引用模块项。
super
代表的是父模块为开始的引用方式,非常类似于文件系统中的 ..
语法:../a/b
文件名:src/lib.rs
#![allow(unused)] fn main() { fn serve_order() {} // 厨房模块 mod back_of_house { fn fix_incorrect_order() { cook_order(); super::serve_order(); } fn cook_order() {} } }
嗯,我们的小餐馆又完善了,终于有厨房了!看来第一个客人也快可以有了。。。在厨房模块中,使用 super::serve_order
语法,调用了父模块(包根)中的 serve_order
函数。
那么你可能会问,为何不使用 crate::serve_order
的方式?额,其实也可以,不过如果你确定未来这种层级关系不会改变,那么 super::serve_order
的方式会更稳定,未来就算它们都不在包根了,依然无需修改引用路径。所以路径的选用,往往还是取决于场景,以及未来代码的可能走向。
使用 self
引用模块
self
其实就是引用自身模块中的项,也就是说和我们之前章节的代码类似,都调用同一模块中的内容,区别在于之前章节中直接通过名称调用即可,而 self
,你得多此一举:
#![allow(unused)] fn main() { fn serve_order() { self::back_of_house::cook_order() } mod back_of_house { fn fix_incorrect_order() { cook_order(); crate::serve_order(); } pub fn cook_order() {} } }
是的,多此一举,因为完全可以直接调用 back_of_house
,但是 self
还有一个大用处,在下一节中我们会讲。
结构体和枚举的可见性
为何要把结构体和枚举的可见性单独拎出来讲呢?因为这两个家伙的成员字段拥有完全不同的可见性:
- 将结构体设置为
pub
,但它的所有字段依然是私有的 - 将枚举设置为
pub
,它的所有字段也将对外可见
原因在于,枚举和结构体的使用方式不一样。如果枚举的成员对外不可见,那该枚举将一点用都没有,因此枚举成员的可见性自动跟枚举可见性保持一致,这样可以简化用户的使用。
而结构体的应用场景比较复杂,其中的字段也往往部分在 A 处被使用,部分在 B 处被使用,因此无法确定成员的可见性,那索性就设置为全部不可见,将选择权交给程序员。
模块与文件分离
在之前的例子中,我们所有的模块都定义在 src/lib.rs
中,但是当模块变多或者变大时,需要将模块放入一个单独的文件中,让代码更好维护。
现在,把 front_of_house
前厅分离出来,放入一个单独的文件中 src/front_of_house.rs
:
#![allow(unused)] fn main() { pub mod hosting { pub fn add_to_waitlist() {} } }
然后,将以下代码留在 src/lib.rs
中:
#![allow(unused)] fn main() { mod front_of_house; pub use crate::front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } }
so easy!其实跟之前在同一个文件中也没有太大的不同,但是有几点值得注意:
mod front_of_house;
告诉 Rust 从另一个和模块front_of_house
同名的文件中加载该模块的内容- 使用绝对路径的方式来引用
hosting
模块:crate::front_of_house::hosting;
需要注意的是,和之前代码中 mod front_of_house{..}
的完整模块不同,现在的代码中,模块的声明和实现是分离的,实现是在单独的 front_of_house.rs
文件中,然后通过 mod front_of_house;
这条声明语句从该文件中把模块内容加载进来。因此我们可以认为,模块 front_of_house
的定义还是在 src/lib.rs
中,只不过模块的具体内容被移动到了 src/front_of_house.rs
文件中。
在这里出现了一个新的关键字 use
,联想到其它章节我们见过的标准库引入 use std::fmt;
,可以大致猜测,该关键字用来将外部模块中的项引入到当前作用域中来,这样无需冗长的父模块前缀即可调用:hosting::add_to_waitlist();
,在下节中,我们将对 use
进行详细的讲解。
当一个模块有许多子模块时,我们也可以通过文件夹的方式来组织这些子模块。
在上述例子中,我们可以创建一个目录 front_of_house
,然后在文件夹里创建一个 hosting.rs
文件,hosting.rs
文件现在就剩下:
#![allow(unused)] fn main() { pub fn add_to_waitlist() {} }
现在,我们尝试编译程序,很遗憾,编译器报错:
error[E0583]: file not found for module `front_of_house`
--> src/lib.rs:3:1
|
1 | mod front_of_house;
| ^^^^^^^^^^^^^^^^^^
|
= help: to create the module `front_of_house`, create file "src/front_of_house.rs" or "src/front_of_house/mod.rs"
是的,如果需要将文件夹作为一个模块,我们需要进行显示指定暴露哪些子模块。按照上述的报错信息,我们有两种方法:
- 在
front_of_house
目录里创建一个mod.rs
,如果你使用的rustc
版本1.30
之前,这是唯一的方法。 - 在
front_of_house
同级目录里创建一个与模块(目录)同名的 rs 文件front_of_house.rs
,在新版本里,更建议使用这样的命名方式来避免项目中存在大量同名的mod.rs
文件( Python 点了个踩
)。
而无论是上述哪个方式创建的文件,其内容都是一样的,你需要定义你的子模块(子模块名与文件名相同):
#![allow(unused)] fn main() { pub mod hosting; // pub mod serving; }
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
使用 use 及受限可见性
如果代码中,通篇都是 crate::front_of_house::hosting::add_to_waitlist
这样的函数调用形式,我不知道有谁会喜欢,也许靠代码行数赚工资的人会很喜欢,但是强迫症肯定受不了,悲伤的是程序员大多都有强迫症。。。
因此我们需要一个办法来简化这种使用方式,在 Rust 中,可以使用 use
关键字把路径提前引入到当前作用域中,随后的调用就可以省略该路径,极大地简化了代码。
基本引入方式
在 Rust 中,引入模块中的项有两种方式:绝对路径和相对路径,这两者在前面章节都有讲过,就不再赘述,先来看看使用绝对路径的引入方式。
绝对路径引入模块
#![allow(unused)] fn main() { mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } use crate::front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } }
这里,我们使用 use
和绝对路径的方式,将 hosting
模块引入到当前作用域中,然后只需通过 hosting::add_to_waitlist
的方式,即可调用目标模块中的函数,相比 crate::front_of_house::hosting::add_to_waitlist()
的方式要简单的多,那么还能更简单吗?
相对路径引入模块中的函数
在下面代码中,我们不仅要使用相对路径进行引入,而且与上面引入 hosting
模块不同,直接引入该模块中的 add_to_waitlist
函数:
#![allow(unused)] fn main() { mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } use front_of_house::hosting::add_to_waitlist; pub fn eat_at_restaurant() { add_to_waitlist(); add_to_waitlist(); add_to_waitlist(); } }
很明显,三兄弟又变得更短了,不过,怎么觉得这句话怪怪的。。
引入模块还是函数
从使用简洁性来说,引入函数自然是更甚一筹,但是在某些时候,引入模块会更好:
- 需要引入同一个模块的多个函数
- 作用域中存在同名函数
在以上两种情况中,使用 use front_of_house::hosting;
引入模块要比 use front_of_house::hosting::add_to_waitlist;
引入函数更好。
例如,如果想使用 HashMap
,那么直接引入该结构体是比引入模块更好的选择,因为在 collections
模块中,我们只需要使用一个 HashMap
结构体:
use std::collections::HashMap; fn main() { let mut map = HashMap::new(); map.insert(1, 2); }
其实严格来说,对于引用方式并没有需要遵守的惯例,主要还是取决于你的喜好,不过我们建议:优先使用最细粒度(引入函数、结构体等)的引用方式,如果引起了某种麻烦(例如前面两种情况),再使用引入模块的方式。
避免同名引用
根据上一章节的内容,我们只要保证同一个模块中不存在同名项就行,模块之间、包之间的同名,谁管得着谁啊,话虽如此,一起看看,如果遇到同名的情况该如何处理。
模块::函数
#![allow(unused)] fn main() { use std::fmt; use std::io; fn function1() -> fmt::Result { // --snip-- } fn function2() -> io::Result<()> { // --snip-- } }
上面的例子给出了很好的解决方案,使用模块引入的方式,具体的 Result
通过 模块::Result
的方式进行调用。
可以看出,避免同名冲突的关键,就是使用父模块的方式来调用,除此之外,还可以给予引入的项起一个别名。
as
别名引用
对于同名冲突问题,还可以使用 as
关键字来解决,它可以赋予引入项一个全新的名称:
#![allow(unused)] fn main() { use std::fmt::Result; use std::io::Result as IoResult; fn function1() -> Result { // --snip-- } fn function2() -> IoResult<()> { // --snip-- } }
如上所示,首先通过 use std::io::Result
将 Result
引入到作用域,然后使用 as
给予它一个全新的名称 IoResult
,这样就不会再产生冲突:
Result
代表std::fmt::Result
IoResult
代表std:io::Result
引入项再导出
当外部的模块项 A
被引入到当前模块中时,它的可见性自动被设置为私有的,如果你希望允许其它外部代码引用我们的模块项 A
,那么可以对它进行再导出:
#![allow(unused)] fn main() { mod front_of_house { pub mod hosting { pub fn add_to_waitlist() {} } } pub use crate::front_of_house::hosting; pub fn eat_at_restaurant() { hosting::add_to_waitlist(); hosting::add_to_waitlist(); hosting::add_to_waitlist(); } }
如上,使用 pub use
即可实现。这里 use
代表引入 hosting
模块到当前作用域,pub
表示将该引入的内容再度设置为可见。
当你希望将内部的实现细节隐藏起来或者按照某个目的组织代码时,可以使用 pub use
再导出,例如统一使用一个模块来提供对外的 API,那该模块就可以引入其它模块中的 API,然后进行再导出,最终对于用户来说,所有的 API 都是由一个模块统一提供的。
使用第三方包
之前我们一直在引入标准库模块或者自定义模块,现在来引入下第三方包中的模块,关于如何引入外部依赖,我们在 Cargo 入门中就有讲,这里直接给出操作步骤:
- 修改
Cargo.toml
文件,在[dependencies]
区域添加一行:rand = "0.8.3"
- 此时,如果你用的是
VSCode
和rust-analyzer
插件,该插件会自动拉取该库,你可能需要等它完成后,再进行下一步(VSCode 左下角有提示)
好了,此时,rand
包已经被我们添加到依赖中,下一步就是在代码中使用:
use rand::Rng; fn main() { let secret_number = rand::thread_rng().gen_range(1..101); }
这里使用 use
引入了第三方包 rand
中的 Rng
特征,因为我们需要调用的 gen_range
方法定义在该特征中。
crates.io,lib.rs
Rust 社区已经为我们贡献了大量高质量的第三方包,你可以在 crates.io
或者 lib.rs
中检索和使用,从目前来说查找包更推荐 lib.rs
,搜索功能更强大,内容展示也更加合理,但是下载依赖包还是得用crates.io
。
你可以在网站上搜索 rand
包,看看它的文档使用方式是否和我们之前引入方式相一致:在网上找到想要的包,然后将你想要的包和版本信息写入到 Cargo.toml
中。
使用 {}
简化引入方式
对于以下一行一行的引入方式:
#![allow(unused)] fn main() { use std::collections::HashMap; use std::collections::BTreeMap; use std::collections::HashSet; use std::cmp::Ordering; use std::io; }
可以使用 {}
来一起引入进来,在大型项目中,使用这种方式来引入,可以减少大量 use
的使用:
#![allow(unused)] fn main() { use std::collections::{HashMap,BTreeMap,HashSet}; use std::{cmp::Ordering, io}; }
对于下面的同时引入模块和模块中的项:
#![allow(unused)] fn main() { use std::io; use std::io::Write; }
可以使用 {}
的方式进行简化:
#![allow(unused)] fn main() { use std::io::{self, Write}; }
self
上面使用到了模块章节提到的 self
关键字,用来替代模块自身,结合上一节中的 self
,可以得出它在模块中的两个用途:
use self::xxx
,表示加载当前模块中的xxx
。此时self
可省略use xxx::{self, yyy}
,表示,加载当前路径下模块xxx
本身,以及模块xxx
下的yyy
使用 *
引入模块下的所有项
对于之前一行一行引入 std::collections
的方式,我们还可以使用
#![allow(unused)] fn main() { use std::collections::*; }
以上这种方式来引入 std::collections
模块下的所有公共项,这些公共项自然包含了 HashMap
,HashSet
等想手动引入的集合类型。
当使用 *
来引入的时候要格外小心,因为你很难知道到底哪些被引入到了当前作用域中,有哪些会和你自己程序中的名称相冲突:
use std::collections::*; struct HashMap; fn main() { let mut v = HashMap::new(); v.insert("a", 1); }
以上代码中,std::collection::HashMap
被 *
引入到当前作用域,但是由于存在另一个同名的结构体,因此 HashMap::new
根本不存在,因为对于编译器来说,本地同名类型的优先级更高。
在实际项目中,这种引用方式往往用于快速写测试代码,它可以把所有东西一次性引入到 tests
模块中。
受限的可见性
在上一节中,我们学习了可见性这个概念,这也是模块体系中最为核心的概念,控制了模块中哪些内容可以被外部看见,但是在实际使用时,光被外面看到还不行,我们还想控制哪些人能看,这就是 Rust 提供的受限可见性。
例如,在 Rust 中,包是一个模块树,我们可以通过 pub(crate) item;
这种方式来实现:item
虽然是对外可见的,但是只在当前包内可见,外部包无法引用到该 item
。
所以,如果我们想要让某一项可以在整个包中都可以被使用,那么有两种办法:
- 在包根中定义一个非
pub
类型的X
(父模块的项对子模块都是可见的,因此包根中的项对模块树上的所有模块都可见) - 在子模块中定义一个
pub
类型的Y
,同时通过use
将其引入到包根
#![allow(unused)] fn main() { mod a { pub mod b { pub fn c() { println!("{:?}",crate::X); } #[derive(Debug)] pub struct Y; } } #[derive(Debug)] struct X; use a::b::Y; fn d() { println!("{:?}",Y); } }
以上代码充分说明了之前两种办法的使用方式,但是有时我们会遇到这两种方法都不太好用的时候。例如希望对于某些特定的模块可见,但是对于其他模块又不可见:
#![allow(unused)] fn main() { // 目标:`a` 导出 `I`、`bar` and `foo`,其他的不导出 pub mod a { pub const I: i32 = 3; fn semisecret(x: i32) -> i32 { use self::b::c::J; x + J } pub fn bar(z: i32) -> i32 { semisecret(I) * z } pub fn foo(y: i32) -> i32 { semisecret(I) + y } mod b { mod c { const J: i32 = 4; } } } }
这段代码会报错,因为与父模块中的项对子模块可见相反,子模块中的项对父模块是不可见的。这里 semisecret
方法中,a
-> b
-> c
形成了父子模块链,那 c
中的 J
自然对 a
模块不可见。
如果使用之前的可见性方式,那么想保持 J
私有,同时让 a
继续使用 semisecret
函数的办法是将该函数移动到 c
模块中,然后用 pub use
将 semisecret
函数进行再导出:
#![allow(unused)] fn main() { pub mod a { pub const I: i32 = 3; use self::b::semisecret; pub fn bar(z: i32) -> i32 { semisecret(I) * z } pub fn foo(y: i32) -> i32 { semisecret(I) + y } mod b { pub use self::c::semisecret; mod c { const J: i32 = 4; pub fn semisecret(x: i32) -> i32 { x + J } } } } }
这段代码说实话问题不大,但是有些破坏了我们之前的逻辑,如果想保持代码逻辑,同时又只让 J
在 a
内可见该怎么办?
#![allow(unused)] fn main() { pub mod a { pub const I: i32 = 3; fn semisecret(x: i32) -> i32 { use self::b::c::J; x + J } pub fn bar(z: i32) -> i32 { semisecret(I) * z } pub fn foo(y: i32) -> i32 { semisecret(I) + y } mod b { pub(in crate::a) mod c { pub(in crate::a) const J: i32 = 4; } } } }
通过 pub(in crate::a)
的方式,我们指定了模块 c
和常量 J
的可见范围都只是 a
模块中,a
之外的模块是完全访问不到它们的。
限制可见性语法
pub(crate)
或 pub(in crate::a)
就是限制可见性语法,前者是限制在整个包内可见,后者是通过绝对路径,限制在包内的某个模块内可见,总结一下:
pub
意味着可见性无任何限制pub(crate)
表示在当前包可见pub(self)
在当前模块可见pub(super)
在父模块可见pub(in <path>)
表示在某个路径代表的模块中可见,其中path
必须是父模块或者祖先模块
一个综合例子
// 一个名为 `my_mod` 的模块 mod my_mod { // 模块中的项默认具有私有的可见性 fn private_function() { println!("called `my_mod::private_function()`"); } // 使用 `pub` 修饰语来改变默认可见性。 pub fn function() { println!("called `my_mod::function()`"); } // 在同一模块中,项可以访问其它项,即使它是私有的。 pub fn indirect_access() { print!("called `my_mod::indirect_access()`, that\n> "); private_function(); } // 模块也可以嵌套 pub mod nested { pub fn function() { println!("called `my_mod::nested::function()`"); } #[allow(dead_code)] fn private_function() { println!("called `my_mod::nested::private_function()`"); } // 使用 `pub(in path)` 语法定义的函数只在给定的路径中可见。 // `path` 必须是父模块(parent module)或祖先模块(ancestor module) pub(in crate::my_mod) fn public_function_in_my_mod() { print!("called `my_mod::nested::public_function_in_my_mod()`, that\n > "); public_function_in_nested() } // 使用 `pub(self)` 语法定义的函数则只在当前模块中可见。 pub(self) fn public_function_in_nested() { println!("called `my_mod::nested::public_function_in_nested"); } // 使用 `pub(super)` 语法定义的函数只在父模块中可见。 pub(super) fn public_function_in_super_mod() { println!("called my_mod::nested::public_function_in_super_mod"); } } pub fn call_public_function_in_my_mod() { print!("called `my_mod::call_public_funcion_in_my_mod()`, that\n> "); nested::public_function_in_my_mod(); print!("> "); nested::public_function_in_super_mod(); } // `pub(crate)` 使得函数只在当前包中可见 pub(crate) fn public_function_in_crate() { println!("called `my_mod::public_function_in_crate()"); } // 嵌套模块的可见性遵循相同的规则 mod private_nested { #[allow(dead_code)] pub fn function() { println!("called `my_mod::private_nested::function()`"); } } } fn function() { println!("called `function()`"); } fn main() { // 模块机制消除了相同名字的项之间的歧义。 function(); my_mod::function(); // 公有项,包括嵌套模块内的,都可以在父模块外部访问。 my_mod::indirect_access(); my_mod::nested::function(); my_mod::call_public_function_in_my_mod(); // pub(crate) 项可以在同一个 crate 中的任何地方访问 my_mod::public_function_in_crate(); // pub(in path) 项只能在指定的模块中访问 // 报错!函数 `public_function_in_my_mod` 是私有的 //my_mod::nested::public_function_in_my_mod(); // 试一试 ^ 取消该行的注释 // 模块的私有项不能直接访问,即便它是嵌套在公有模块内部的 // 报错!`private_function` 是私有的 //my_mod::private_function(); // 试一试 ^ 取消此行注释 // 报错!`private_function` 是私有的 //my_mod::nested::private_function(); // 试一试 ^ 取消此行的注释 // 报错! `private_nested` 是私有的 //my_mod::private_nested::function(); // 试一试 ^ 取消此行的注释 }
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
注释和文档
好的代码会说话,好的程序员不写注释,这些都是烂大街的“编程界俚语”。但是,如果你真的遇到一个不写注释的项目或程序员,那一定会对它/他“刮目相看”。
在之前的章节我们学习了包和模块如何使用,在此章节将进一步学习如何书写文档注释,以及如何使用 cargo doc
生成项目的文档,最后将以一个包、模块和文档的综合性例子,来将这些知识融会贯通。
注释的种类
在 Rust 中,注释分为三类:
- 代码注释,用于说明某一块代码的功能,读者往往是同一个项目的协作开发者
- 文档注释,支持
Markdown
,对项目描述、公共 API 等用户关心的功能进行介绍,同时还能提供示例代码,目标读者往往是想要了解你项目的人 - 包和模块注释,严格来说这也是文档注释中的一种,它主要用于说明当前包和模块的功能,方便用户迅速了解一个项目
通过这些注释,实现了 Rust 极其优秀的文档化支持,甚至你还能在文档注释中写测试用例,省去了单独写测试用例的环节,我直呼好家伙!
代码注释
显然之前的刮目相看是打了引号的,想要去掉引号,该写注释的时候,就老老实实的,不过写时需要遵循八字原则:围绕目标,言简意赅,记住,洋洋洒洒那是用来形容文章的,不是形容注释!
代码注释方式有两种:
行注释 //
fn main() { // 我是Sun... // face let name = "sunface"; let age = 18; // 今年好像是18岁 }
如上所示,行注释可以放在某一行代码的上方,也可以放在当前代码行的后方。如果超出一行的长度,需要在新行的开头也加上 //
。
当注释行数较多时,你还可以使用块注释
块注释/* ..... */
fn main() { /* 我 是 S u n ... 哎,好长! */ let name = "sunface"; let age = "???"; // 今年其实。。。挺大了 }
如上所示,只需要将注释内容使用 /* */
进行包裹即可。
你会发现,Rust 的代码注释跟其它语言并没有区别,主要区别其实在于文档注释这一块,也是本章节内容的重点。
文档注释
当查看一个 crates.io
上的包时,往往需要通过它提供的文档来浏览相关的功能特性、使用方式,这种文档就是通过文档注释实现的。
Rust 提供了 cargo doc
的命令,可以用于把这些文档注释转换成 HTML
网页文件,最终展示给用户浏览,这样用户就知道这个包是做什么的以及该如何使用。
文档行注释 ///
本书的一大特点就是废话不多,因此我们开门见山:
#![allow(unused)] fn main() { /// `add_one` 将指定值加1 /// /// # Examples /// /// ``` /// let arg = 5; /// let answer = my_crate::add_one(arg); /// /// assert_eq!(6, answer); /// ``` pub fn add_one(x: i32) -> i32 { x + 1 } }
以上代码有几点需要注意:
- 文档注释需要位于
lib
类型的包中,例如src/lib.rs
中 - 文档注释可以使用
markdown
语法!例如# Examples
的标题,以及代码块高亮 - 被注释的对象需要使用
pub
对外可见,记住:文档注释是给用户看的,内部实现细节不应该被暴露出去
咦?文档注释中的例子,为什看上去像是能运行的样子?竟然还是有 assert_eq
这种常用于测试目的的宏。 嗯,你的感觉没错,详细内容会在本章后面讲解,容我先卖个关子。
文档块注释 /** ... */
与代码注释一样,文档也有块注释,当注释内容多时,使用块注释可以减少 ///
的使用:
#![allow(unused)] fn main() { /** `add_two` 将指定值加2 Examples ``` let arg = 5; let answer = my_crate::add_two(arg); assert_eq!(7, answer); ``` */ pub fn add_two(x: i32) -> i32 { x + 2 } }
查看文档 cargo doc
锦衣不夜行,这是中国人的传统美德。我们写了这么漂亮的文档注释,当然要看看网页中是什么效果咯。
很简单,运行 cargo doc
可以直接生成 HTML
文件,放入target/doc目录下。
当然,为了方便,我们使用 cargo doc --open
命令,可以在生成文档后,自动在浏览器中打开网页,最终效果如图所示:

非常棒,而且非常简单,这就是 Rust 工具链的强大之处。
常用文档标题
之前我们见到了在文档注释中该如何使用 markdown
,其中包括 # Examples
标题。除了这个标题,还有一些常用的,你可以在项目中酌情使用:
- Panics:函数可能会出现的异常状况,这样调用函数的人就可以提前规避
- Errors:描述可能出现的错误及什么情况会导致错误,有助于调用者针对不同的错误采取不同的处理方式
- Safety:如果函数使用
unsafe
代码,那么调用者就需要注意一些使用条件,以确保unsafe
代码块的正常工作
话说回来,这些标题更多的是一种惯例,如果你非要用中文标题也没问题,但是最好在团队中保持同样的风格 :)
包和模块级别的注释
除了函数、结构体等 Rust 项的注释,你还可以给包和模块添加注释,需要注意的是,这些注释要添加到包、模块的最上方!
与之前的任何注释一样,包级别的注释也分为两种:行注释 //!
和块注释 /*! ... */
。
现在,为我们的包增加注释,在 src/lib.rs
包根的最上方,添加:
#![allow(unused)] fn main() { /*! lib包是world_hello二进制包的依赖包, 里面包含了compute等有用模块 */ pub mod compute; }
然后再为该包根的子模块 src/compute.rs
添加注释:
#![allow(unused)] fn main() { //! 计算一些你口算算不出来的复杂算术题 /// `add_one`将指定值加1 /// }
运行 cargo doc --open
查看下效果:

包模块注释,可以让用户从整体的角度理解包的用途,对于用户来说是非常友好的,就和一篇文章的开头一样,总是要对文章的内容进行大致的介绍,让用户在看的时候心中有数。
至此,关于如何注释的内容,就结束了,那么注释还能用来做什么?可以玩出花来吗?答案是Yes
.
文档测试(Doc Test)
相信读者之前都写过单元测试用例,其中一个很蛋疼的问题就是,随着代码的进化,单元测试用例经常会失效,过段时间后(为何是过段时间?应该这么问,有几个开发喜欢写测试用例 =,=),你发现需要连续修改不少处代码,才能让测试重新工作起来。然而,在 Rust 中,大可不必。
在之前的 add_one
中,我们写的示例代码非常像是一个单元测试的用例,这是偶然吗?并不是。因为 Rust 允许我们在文档注释中写单元测试用例!方法就如同之前做的:
#![allow(unused)] fn main() { /// `add_one` 将指定值加1 /// /// # Examples11 /// /// ``` /// let arg = 5; /// let answer = world_hello::compute::add_one(arg); /// /// assert_eq!(6, answer); /// ``` pub fn add_one(x: i32) -> i32 { x + 1 } }
以上的注释不仅仅是文档,还可以作为单元测试的用例运行,使用 cargo test
运行测试:
Doc-tests world_hello
running 2 tests
test src/compute.rs - compute::add_one (line 8) ... ok
test src/compute.rs - compute::add_two (line 22) ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 1.00s
可以看到,文档中的测试用例被完美运行,而且输出中也明确提示了 Doc-tests world_hello
,意味着这些测试的名字叫 Doc test
文档测试。
需要注意的是,你可能需要使用类如
world_hello::compute::add_one(arg)
的完整路径来调用函数,因为测试是在另外一个独立的线程中运行的
造成 panic 的文档测试
文档测试中的用例还可以造成 panic
:
#![allow(unused)] fn main() { /// # Panics /// /// The function panics if the second argument is zero. /// /// ```rust /// // panics on division by zero /// world_hello::compute::div(10, 0); /// ``` pub fn div(a: i32, b: i32) -> i32 { if b == 0 { panic!("Divide-by-zero error"); } a / b } }
以上测试运行后会 panic
:
---- src/compute.rs - compute::div (line 38) stdout ----
Test executable failed (exit code 101).
stderr:
thread 'main' panicked at 'Divide-by-zero error', src/compute.rs:44:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
如果想要通过这种测试,可以添加 should_panic
:
#![allow(unused)] fn main() { /// # Panics /// /// The function panics if the second argument is zero. /// /// ```rust,should_panic /// // panics on division by zero /// world_hello::compute::div(10, 0); /// ``` }
通过 should_panic
,告诉 Rust 我们这个用例会导致 panic
,这样测试用例就能顺利通过。
保留测试,隐藏文档
在某些时候,我们希望保留文档测试的功能,但是又要将某些测试用例的内容从文档中隐藏起来:
/// ``` /// # // 使用#开头的行会在文档中被隐藏起来,但是依然会在文档测试中运行 /// # fn try_main() -> Result<(), String> { /// let res = world_hello::compute::try_div(10, 0)?; /// # Ok(()) // returning from try_main /// # } /// # fn main() { /// # try_main().unwrap(); /// # /// # } /// ``` pub fn try_div(a: i32, b: i32) -> Result<i32, String> { if b == 0 { Err(String::from("Divide-by-zero")) } else { Ok(a / b) } }
以上文档注释中,我们使用 #
将不想让用户看到的内容隐藏起来,但是又不影响测试用例的运行,最终用户将只能看到那行没有隐藏的 let res = world_hello::compute::try_div(10, 0)?;
:

文档注释中的代码跳转
Rust 在文档注释中还提供了一个非常强大的功能,那就是可以实现对外部项的链接:
跳转到标准库
#![allow(unused)] fn main() { /// `add_one` 返回一个[`Option`]类型 pub fn add_one(x: i32) -> Option<i32> { Some(x + 1) } }
此处的 [Option
] 就是一个链接,指向了标准库中的 Option
枚举类型,有两种方式可以进行跳转:
- 在 IDE 中,使用
Command + 鼠标左键
(macOS),CTRL + 鼠标左键
(Windows) - 在文档中直接点击链接
再比如,还可以使用路径的方式跳转:
#![allow(unused)] fn main() { use std::sync::mpsc::Receiver; /// [`Receiver<T>`] [`std::future`]. /// /// [`std::future::Future`] [`Self::recv()`]. pub struct AsyncReceiver<T> { sender: Receiver<T>, } impl<T> AsyncReceiver<T> { pub async fn recv() -> T { unimplemented!() } } }
使用完整路径跳转到指定项
除了跳转到标准库,你还可以通过指定具体的路径跳转到自己代码或者其它库的指定项,例如在 lib.rs
中添加以下代码:
#![allow(unused)] fn main() { pub mod a { /// `add_one` 返回一个[`Option`]类型 /// 跳转到[`crate::MySpecialFormatter`] pub fn add_one(x: i32) -> Option<i32> { Some(x + 1) } } pub struct MySpecialFormatter; }
使用 crate::MySpecialFormatter
这种路径就可以实现跳转到 lib.rs
中定义的结构体上。
同名项的跳转
如果遇到同名项,可以使用标示类型的方式进行跳转:
#![allow(unused)] fn main() { /// 跳转到结构体 [`Foo`](struct@Foo) pub struct Bar; /// 跳转到同名函数 [`Foo`](fn@Foo) pub struct Foo {} /// 跳转到同名宏 [`foo!`] pub fn Foo() {} #[macro_export] macro_rules! foo { () => {} } }
文档搜索别名
Rust 文档支持搜索功能,我们可以为自己的类型定义几个别名,以实现更好的搜索展现,当别名命中时,搜索结果会被放在第一位:
#![allow(unused)] fn main() { #[doc(alias = "x")] #[doc(alias = "big")] pub struct BigX; #[doc(alias("y", "big"))] pub struct BigY; }
结果如下图所示:
一个综合例子
这个例子我们将重点应用几个知识点:
- 文档注释
- 一个项目可以包含两个包:二进制可执行包和
lib
包(库包),它们的包根分别是src/main.rs
和src/lib.rs
- 在二进制包中引用
lib
包 - 使用
pub use
再导出 API,并观察文档
首先,使用 cargo new art
创建一个 Package art
:
Created binary (application) `art` package
系统提示我们创建了一个二进制 Package
,根据之前章节学过的内容,可以知道该 Package
包含一个同名的二进制包:包名为 art
,包根为 src/main.rs
,该包可以编译成二进制然后运行。
现在,在 src
目录下创建一个 lib.rs
文件,同样,根据之前学习的知识,创建该文件等于又创建了一个库类型的包,包名也是 art
,包根为 src/lib.rs
,该包是是库类型的,因此往往作为依赖库被引入。
将以下内容添加到 src/lib.rs
中:
#![allow(unused)] fn main() { //! # Art //! //! 未来的艺术建模库,现在的调色库 pub use self::kinds::PrimaryColor; pub use self::kinds::SecondaryColor; pub use self::utils::mix; pub mod kinds { //! 定义颜色的类型 /// 主色 pub enum PrimaryColor { Red, Yellow, Blue, } /// 副色 #[derive(Debug,PartialEq)] pub enum SecondaryColor { Orange, Green, Purple, } } pub mod utils { //! 实用工具,目前只实现了调色板 use crate::kinds::*; /// 将两种主色调成副色 /// ```rust /// use art::utils::mix; /// use art::kinds::{PrimaryColor,SecondaryColor}; /// assert!(matches!(mix(PrimaryColor::Yellow, PrimaryColor::Blue), SecondaryColor::Green)); /// ``` pub fn mix(c1: PrimaryColor, c2: PrimaryColor) -> SecondaryColor { SecondaryColor::Green } } }
在库包的包根 src/lib.rs
下,我们又定义了几个子模块,同时将子模块中的三个项通过 pub use
进行了再导出。
接着,将下面内容添加到 src/main.rs
中:
use art::kinds::PrimaryColor; use art::utils::mix; fn main() { let blue = PrimaryColor::Blue; let yellow = PrimaryColor::Yellow; println!("{:?}",mix(blue, yellow)); }
在二进制可执行包的包根 src/main.rs
下,我们引入了库包 art
中的模块项,同时使用 main
函数作为程序的入口,该二进制包可以使用 cargo run
运行:
Green
至此,库包完美提供了用于调色的 API,二进制包引入这些 API 完美的实现了调色并打印输出。
最后,再来看看文档长啥样:

总结
在 Rust 中,注释分为三个主要类型:代码注释、文档注释、包和模块注释,每个注释类型都拥有两种形式:行注释和块注释,熟练掌握包模块和注释的知识,非常有助于我们创建工程性更强的项目。
如果读者看到这里对于包模块还是有些模糊,强烈建议回头看看相关的章节以及本章节的最后一个综合例子。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
格式化输出
提到格式化输出,可能很多人立刻就想到 "{}"
,但是 Rust 能做到的远比这个多的多,本章节我们将深入讲解格式化输出的各个方面。
满分初印象
先来一段代码,看看格式化输出的初印象:
#![allow(unused)] fn main() { println!("Hello"); // => "Hello" println!("Hello, {}!", "world"); // => "Hello, world!" println!("The number is {}", 1); // => "The number is 1" println!("{:?}", (3, 4)); // => "(3, 4)" println!("{value}", value=4); // => "4" println!("{} {}", 1, 2); // => "1 2" println!("{:04}", 42); // => "0042" with leading zeros }
可以看到 println!
宏接受的是可变参数,第一个参数是一个字符串常量,它表示最终输出字符串的格式,包含其中形如 {}
的符号是占位符,会被 println!
后面的参数依次替换。
print!
,println!
,format!
它们是 Rust 中用来格式化输出的三大金刚,用途如下:
print!
将格式化文本输出到标准输出,不带换行符println!
同上,但是在行的末尾添加换行符format!
将格式化文本输出到String
字符串
在实际项目中,最常用的是 println!
及 format!
,前者常用来调试输出,后者常用来生成格式化的字符串:
fn main() { let s = "hello"; println!("{}, world", s); let s1 = format!("{}, world", s); print!("{}", s1); print!("{}\n", "!"); }
其中,s1
是通过 format!
生成的 String
字符串,最终输出如下:
hello, world
hello, world!
eprint!
,eprintln!
除了三大金刚外,还有两大护法,使用方式跟 print!
,println!
很像,但是它们输出到标准错误输出:
#![allow(unused)] fn main() { eprintln!("Error: Could not complete task") }
它们仅应该被用于输出错误信息和进度信息,其它场景都应该使用 print!
系列。
{} 与
与其它语言常用的 %d
,%s
不同,Rust 特立独行地选择了 {}
作为格式化占位符(说到这个,有点想吐槽下,Rust 中自创的概念其实还挺多的,真不知道该夸奖还是该吐槽-,-),事实证明,这种选择非常正确,它帮助用户减少了很多使用成本,你无需再为特定的类型选择特定的占位符,统一用 {}
来替代即可,剩下的类型推导等细节只要交给 Rust 去做。
与 {}
类似,{:?}
也是占位符:
{}
适用于实现了std::fmt::Display
特征的类型,用来以更优雅、更友好的方式格式化文本,例如展示给用户{:?}
适用于实现了std::fmt::Debug
特征的类型,用于调试场景
其实两者的选择很简单,当你在写代码需要调试时,使用 {:?}
,剩下的场景,选择 {}
。
Debug
特征
事实上,为了方便我们调试,大多数 Rust 类型都实现了 Debug
特征或者支持派生该特征:
#[derive(Debug)] struct Person { name: String, age: u8 } fn main() { let i = 3.1415926; let s = String::from("hello"); let v = vec![1, 2, 3]; let p = Person{name: "sunface".to_string(), age: 18}; println!("{:?}, {:?}, {:?}, {:?}", i, s, v, p); }
对于数值、字符串、数组,可以直接使用 {:?}
进行输出,但是对于结构体,需要派生Debug
特征后,才能进行输出,总之很简单。
Display
特征
与大部分类型实现了 Debug
不同,实现了 Display
特征的 Rust 类型并没有那么多,往往需要我们自定义想要的格式化方式:
#![allow(unused)] fn main() { let i = 3.1415926; let s = String::from("hello"); let v = vec![1, 2, 3]; let p = Person { name: "sunface".to_string(), age: 18, }; println!("{}, {}, {}, {}", i, s, v, p); }
运行后可以看到 v
和 p
都无法通过编译,因为没有实现 Display
特征,但是你又不能像派生 Debug
一般派生 Display
,只能另寻他法:
- 使用
{:?}
或{:#?}
- 为自定义类型实现
Display
特征 - 使用
newtype
为外部类型实现Display
特征
下面来一一看看这三种方式。
{:#?}
与 {:?}
几乎一样,唯一的区别在于它能更优美地输出内容:
// {:?}
[1, 2, 3], Person { name: "sunface", age: 18 }
// {:#?}
[
1,
2,
3,
], Person {
name: "sunface",
}
因此对于 Display
不支持的类型,可以考虑使用 {:#?}
进行格式化,虽然理论上它更适合进行调试输出。
为自定义类型实现 Display
特征
如果你的类型是定义在当前作用域中的,那么可以为其实现 Display
特征,即可用于格式化输出:
struct Person { name: String, age: u8, } use std::fmt; impl fmt::Display for Person { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!( f, "大佬在上,请受我一拜,小弟姓名{},年芳{},家里无田又无车,生活苦哈哈", self.name, self.age ) } } fn main() { let p = Person { name: "sunface".to_string(), age: 18, }; println!("{}", p); }
如上所示,只要实现 Display
特征中的 fmt
方法,即可为自定义结构体 Person
添加自定义输出:
大佬在上,请受我一拜,小弟姓名sunface,年芳18,家里无田又无车,生活苦哈哈
为外部类型实现 Display
特征
在 Rust 中,无法直接为外部类型实现外部特征,但是可以使用newtype
解决此问题:
struct Array(Vec<i32>); use std::fmt; impl fmt::Display for Array { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "数组是:{:?}", self.0) } } fn main() { let arr = Array(vec![1, 2, 3]); println!("{}", arr); }
Array
就是我们的 newtype
,它将想要格式化输出的 Vec
包裹在内,最后只要为 Array
实现 Display
特征,即可进行格式化输出:
数组是:[1, 2, 3]
至此,关于 {}
与 {:?}
的内容已介绍完毕,下面让我们正式开始格式化输出的旅程。
位置参数
除了按照依次顺序使用值去替换占位符之外,还能让指定位置的参数去替换某个占位符,例如 {1}
,表示用第二个参数替换该占位符(索引从 0 开始):
fn main() { println!("{}{}", 1, 2); // =>"12" println!("{1}{0}", 1, 2); // =>"21" // => Alice, this is Bob. Bob, this is Alice println!("{0}, this is {1}. {1}, this is {0}", "Alice", "Bob"); println!("{1}{}{0}{}", 1, 2); // => 2112 }
具名参数
除了像上面那样指定位置外,我们还可以为参数指定名称:
fn main() { println!("{argument}", argument = "test"); // => "test" println!("{name} {}", 1, name = 2); // => "2 1" println!("{a} {c} {b}", a = "a", b = 'b', c = 3); // => "a 3 b" }
需要注意的是:带名称的参数必须放在不带名称参数的后面,例如下面代码将报错:
#![allow(unused)] fn main() { println!("{abc} {1}", abc = "def", 2); }
#![allow(unused)] fn main() { error: positional arguments cannot follow named arguments --> src/main.rs:4:36 | 4 | println!("{abc} {1}", abc = "def", 2); | ----- ^ positional arguments must be before named arguments | | | named argument }
格式化参数
格式化输出,意味着对输出格式会有更多的要求,例如只输出浮点数的小数点后两位:
fn main() { let v = 3.1415926; // Display => 3.14 println!("{:.2}", v); // Debug => 3.14 println!("{:.2?}", v); }
上面代码只输出小数点后两位。同时我们还展示了 {}
和 {:?}
的用法,后面如无特殊区别,就只针对 {}
提供格式化参数说明。
接下来,让我们一起来看看 Rust 中有哪些格式化参数。
宽度
宽度用来指示输出目标的长度,如果长度不够,则进行填充和对齐:
字符串填充
字符串格式化默认使用空格进行填充,并且进行左对齐。
fn main() { //----------------------------------- // 以下全部输出 "Hello x !" // 为"x"后面填充空格,补齐宽度5 println!("Hello {:5}!", "x"); // 使用参数5来指定宽度 println!("Hello {:1$}!", "x", 5); // 使用x作为占位符输出内容,同时使用5作为宽度 println!("Hello {1:0$}!", 5, "x"); // 使用有名称的参数作为宽度 println!("Hello {:width$}!", "x", width = 5); //----------------------------------- // 使用参数5为参数x指定宽度,同时在结尾输出参数5 => Hello x !5 println!("Hello {:1$}!{}", "x", 5); }
数字填充:符号和 0
数字格式化默认也是使用空格进行填充,但与字符串左对齐不同的是,数字是右对齐。
fn main() { // 宽度是5 => Hello 5! println!("Hello {:5}!", 5); // 显式的输出正号 => Hello +5! println!("Hello {:+}!", 5); // 宽度5,使用0进行填充 => Hello 00005! println!("Hello {:05}!", 5); // 负号也要占用一位宽度 => Hello -0005! println!("Hello {:05}!", -5); }
对齐
fn main() { // 以下全部都会补齐5个字符的长度 // 左对齐 => Hello x ! println!("Hello {:<5}!", "x"); // 右对齐 => Hello x! println!("Hello {:>5}!", "x"); // 居中对齐 => Hello x ! println!("Hello {:^5}!", "x"); // 对齐并使用指定符号填充 => Hello x&&&&! // 指定符号填充的前提条件是必须有对齐字符 println!("Hello {:&<5}!", "x"); }
精度
精度可以用于控制浮点数的精度或者字符串的长度
fn main() { let v = 3.1415926; // 保留小数点后两位 => 3.14 println!("{:.2}", v); // 带符号保留小数点后两位 => +3.14 println!("{:+.2}", v); // 不带小数 => 3 println!("{:.0}", v); // 通过参数来设定精度 => 3.1416,相当于{:.4} println!("{:.1$}", v, 4); let s = "hi我是Sunface孙飞"; // 保留字符串前三个字符 => hi我 println!("{:.3}", s); // {:.*}接收两个参数,第一个是精度,第二个是被格式化的值 => Hello abc! println!("Hello {:.*}!", 3, "abcdefg"); }
进制
可以使用 #
号来控制数字的进制输出:
#b
, 二进制#o
, 八进制#x
, 小写十六进制#X
, 大写十六进制x
, 不带前缀的小写十六进制
fn main() { // 二进制 => 0b11011! println!("{:#b}!", 27); // 八进制 => 0o33! println!("{:#o}!", 27); // 十进制 => 27! println!("{}!", 27); // 小写十六进制 => 0x1b! println!("{:#x}!", 27); // 大写十六进制 => 0x1B! println!("{:#X}!", 27); // 不带前缀的十六进制 => 1b! println!("{:x}!", 27); // 使用0填充二进制,宽度为10 => 0b00011011! println!("{:#010b}!", 27); }
指数
fn main() { println!("{:2e}", 1000000000); // => 1e9 println!("{:2E}", 1000000000); // => 1E9 }
指针地址
#![allow(unused)] fn main() { let v= vec![1, 2, 3]; println!("{:p}", v.as_ptr()) // => 0x600002324050 }
转义
有时需要输出 {
和}
,但这两个字符是特殊字符,需要进行转义:
fn main() { // "{{" 转义为 '{' "}}" 转义为 '}' "\"" 转义为 '"' // => Hello "{World}" println!(" Hello \"{{World}}\" "); // 下面代码会报错,因为占位符{}只有一个右括号},左括号被转义成字符串的内容 // println!(" {{ Hello } "); // 也不可使用 '\' 来转义 "{}" // println!(" \{ Hello \} ") }
在格式化字符串时捕获环境中的值(Rust 1.58 新增)
在以前,想要输出一个函数的返回值,你需要这么做:
fn get_person() -> String { String::from("sunface") } fn main() { let p = get_person(); println!("Hello, {}!", p); // implicit position println!("Hello, {0}!", p); // explicit index println!("Hello, {person}!", person = p); }
问题倒也不大,但是一旦格式化字符串长了后,就会非常冗余,而在 1.58 后,我们可以这么写:
fn get_person() -> String { String::from("sunface") } fn main() { let person = get_person(); println!("Hello, {person}!"); }
是不是清晰、简洁了很多?甚至还可以将环境中的值用于格式化参数:
#![allow(unused)] fn main() { let (width, precision) = get_format(); for (name, score) in get_scores() { println!("{name}: {score:width$.precision$}"); } }
但也有局限,它只能捕获普通的变量,对于更复杂的类型(例如表达式),可以先将它赋值给一个变量或使用以前的 name = expression
形式的格式化参数。
目前除了 panic!
外,其它接收格式化参数的宏,都可以使用新的特性。对于 panic!
而言,如果还在使用 2015版本
或 2018版本
,那 panic!("{ident}")
依然会被当成 正常的字符串来处理,同时编译器会给予 warn
提示。而对于 2021版本
,则可以正常使用:
fn get_person() -> String { String::from("sunface") } fn main() { let person = get_person(); panic!("Hello, {person}!"); }
输出:
thread 'main' panicked at 'Hello, sunface!', src/main.rs:6:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
总结
把这些格式化都牢记在脑中是不太现实的,也没必要,我们要做的就是知道 Rust 支持相应的格式化输出,在需要之时,读者再来查阅本文即可。
还是那句话,<<Rust 语言圣经>>不仅仅是 Rust 学习书籍,还是一本厚重的工具书!
至此,Rust 的基础内容学习已经全部完成,下面我们将学习 Rust 的高级进阶内容,正式开启你的高手之路。
构建一个简单命令行程序
在前往更高的山峰前,我们应该驻足欣赏下身后的风景,虽然是半览众山不咋小,但总比身在此山中无法窥全貌要强一丢丢。
在本章中,我们将一起构建一个命令行程序,目标是尽可能帮大家融会贯通之前的学到的知识。
linux 系统中的 grep
命令很强大,可以完成各种文件搜索任务,我们肯定做不了那么强大,但是假冒一个伪劣的版本还是可以的,它将从命令行参数中读取指定的文件名和字符串,然后在相应的文件中找到包含该字符串的内容,最终打印出来。
这里推荐一位大神写的知名 Rust 项目 ripgrep ,绝对是
grep
真正的高替品,值得学习和使用
实现基本功能
无论功能设计的再怎么花里胡哨,对于一个文件查找命令而言,首先得指定文件和待查找的字符串,它们需要用户从命令行给予输入,然后我们在程序内进行读取。
接收命令行参数
国际惯例,先创建一个新的项目 minigrep
,该名字充分体现了我们的自信:就是不如 grep
。
cargo new minigrep
Created binary (application) `minigrep` project
$ cd minigrep
首先来思考下,如果要传入文件路径和待搜索的字符串,那这个命令该长啥样,我觉得大概率是这样:
cargo run -- searchstring example-filename.txt
--
告诉 cargo
后面的参数是给我们的程序使用的,而不是给 cargo
自己使用,例如 --
前的 run
就是给它用的。
接下来就是在程序中读取传入的参数,这个很简单,下面代码就可以:
// in main.rs use std::env; fn main() { let args: Vec<String> = env::args().collect(); dbg!(args); }
首先通过 use
引入标准库中的 env
包,然后 env::args
方法会读取并分析传入的命令行参数,最终通过 collect
方法输出一个集合类型 Vector
。
可能有同学疑惑,为啥不直接引入 args
,例如 use std::env::args
,这样就无需 env::args
来繁琐调用,直接args.collect()
即可。原因很简单,args
方法只会使用一次,啰嗦就啰嗦点吧,把相同的好名字让给 let args..
这位大哥不好吗?毕竟人家要出场多次的。
不可信的输入
所有的用户输入都不可信!不可信!不可信!
重要的话说三遍,我们的命令行程序也是,用户会输入什么你根本就不知道,例如他输入了一个非 Unicode 字符,你能阻止吗?显然不能,但是这种输入会直接让我们的程序崩溃!
原因是当传入的命令行参数包含非 Unicode 字符时,
std::env::args
会直接崩溃,如果有这种特殊需求,建议大家使用std::env::args_os
,该方法产生的数组将包含OsString
类型,而不是之前的String
类型,前者对于非 Unicode 字符会有更好的处理。至于为啥我们不用,两个理由,你信哪个:1. 用户爱输入啥输入啥,反正崩溃了,他就知道自己错了 2.
args_os
会引入额外的跨平台复杂性
collect
方法其实并不是std::env
包提供的,而是迭代器自带的方法(env::args()
会返回一个迭代器),它会将迭代器消费后转换成我们想要的集合类型,关于迭代器和 collect
的具体介绍,请参考这里。
最后,代码中使用 dbg!
宏来输出读取到的数组内容,来看看长啥样:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.61s
Running `target/debug/minigrep`
[src/main.rs:5] args = [
"target/debug/minigrep",
]
$ cargo run -- needle haystack
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 1.57s
Running `target/debug/minigrep needle haystack`
[src/main.rs:5] args = [
"target/debug/minigrep",
"needle",
"haystack",
]
上面两个版本分别是无参数和两个参数,其中无参数版本实际上也会读取到一个字符串,仔细看,是不是长得很像我们的程序名,Bingo! env::args
读取到的参数中第一个就是程序的可执行路径名。
存储读取到的参数
在编程中,给予清晰合理的变量名是一项基本功,咱总不能到处都是 args[1]
、args[2]
这样的糟糕代码吧。
因此我们需要两个变量来存储文件路径和待搜索的字符串:
use std::env; fn main() { let args: Vec<String> = env::args().collect(); let query = &args[1]; let file_path = &args[2]; println!("Searching for {}", query); println!("In file {}", file_path); }
很简单的代码,来运行下:
$ cargo run -- test sample.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep test sample.txt`
Searching for test
In file sample.txt
输出结果很清晰的说明了我们的目标:在文件 sample.txt
中搜索包含 test
字符串的内容。
事实上,就算作为一个简单的程序,它也太过于简单了,例如用户不提供任何参数怎么办?因此,错误处理显然是不可少的,但是在添加之前,先来看看如何读取文件内容。
文件读取
既然读取文件,那么首先我们需要创建一个文件并给予一些内容,来首诗歌如何?"我啥也不是,你呢?"
I'm nobody! Who are you?
我啥也不是,你呢?
Are you nobody, too?
牛逼如你也是无名之辈吗?
Then there's a pair of us - don't tell!
那我们就是天生一对,嘘!别说话!
They'd banish us, you know.
你知道,我们不属于这里。
How dreary to be somebody!
因为这里属于没劲的大人物!
How public, like a frog
他们就像青蛙一样呱噪,
To tell your name the livelong day
成天将自己的大名
To an admiring bog!
传遍整个无聊的沼泽!
在项目根目录创建 poem.txt
文件,并写入如上的优美诗歌(可能翻译的很烂,别打我,哈哈,事实上大家写入英文内容就够了)。
接下来修改 main.rs
来读取文件内容:
use std::env; use std::fs; fn main() { // --省略之前的内容-- println!("In file {}", file_path); let contents = fs::read_to_string(file_path) .expect("Should have been able to read the file"); println!("With text:\n{contents}"); }
首先,通过 use std::fs
引入文件操作包,然后通过 fs::read_to_string
读取指定的文件内容,最后返回的 contents
是 std::io::Result<String>
类型。
运行下试试,这里无需输入第二个参数,因为我们还没有实现查询功能:
$ cargo run -- the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
完美,虽然代码还有很多瑕疵,例如所有内容都在 main
函数,这个不符合软件工程,没有错误处理,功能不完善等。不过没关系,万事开头难,好歹我们成功迈开了第一步。
好了,是时候重构赚波 KPI 了,读者:are you serious? 这就开始重构了?
增加模块化和错误处理
但凡稍微没那么糟糕的程序,都应该具有代码模块化和错误处理,不然连玩具都谈不上。
梳理我们的代码和目标后,可以整理出大致四个改进点:
- 单一且庞大的函数。对于
minigrep
程序而言,main
函数当前执行两个任务:解析命令行参数和读取文件。但随着代码的增加,main
函数承载的功能也将快速增加。从软件工程角度来看,一个函数具有的功能越多,越是难以阅读和维护。因此最好的办法是将大的函数拆分成更小的功能单元。 - 配置变量散乱在各处。还有一点要考虑的是,当前
main
函数中的变量都是独立存在的,这些变量很可能被整个程序所访问,在这个背景下,独立的变量越多,越是难以维护,因此我们还可以将这些用于配置的变量整合到一个结构体中。 - 细化错误提示。 目前的实现中,我们使用
expect
方法来输出文件读取失败时的错误信息,这个没问题,但是无论任何情况下,都只输出Should have been able to read the file
这条错误提示信息,显然是有问题的,毕竟文件不存在、无权限等等都是可能的错误,一条大一统的消息无法给予用户更多的提示。 - 使用错误而不是异常。 假如用户不给任何命令行参数,那我们的程序显然会无情崩溃,原因很简单:
index out of bounds
,一个数组访问越界的panic
,但问题来了,用户能看懂吗?甚至于未来接收的维护者能看懂吗?因此需要增加合适的错误处理代码,来给予使用者给详细友善的提示。还有就是需要在一个统一的位置来处理所有错误,利人利己!
分离 main 函数
关于如何处理庞大的 main
函数,Rust 社区给出了统一的指导方案:
- 将程序分割为
main.rs
和lib.rs
,并将程序的逻辑代码移动到后者内 - 命令行解析属于非常基础的功能,严格来说不算是逻辑代码的一部分,因此还可以放在
main.rs
中
按照这个方案,将我们的代码重新梳理后,可以得出 main
函数应该包含的功能:
- 解析命令行参数
- 初始化其它配置
- 调用
lib.rs
中的run
函数,以启动逻辑代码的运行 - 如果
run
返回一个错误,需要对该错误进行处理
这个方案有一个很优雅的名字: 关注点分离(Separation of Concerns)。简而言之,main.rs
负责启动程序,lib.rs
负责逻辑代码的运行。从测试的角度而言,这种分离也非常合理: lib.rs
中的主体逻辑代码可以得到简单且充分的测试,至于 main.rs
?确实没办法针对其编写额外的测试代码,但是它的代码也很少啊,很容易就能保证它的正确性。
关于如何在 Rust 中编写测试代码,请参见如下章节:https://course.rs/test/intro.html
分离命令行解析
根据之前的分析,我们需要将命令行解析的代码分离到一个单独的函数,然后将该函数放置在 main.rs
中:
// in main.rs fn main() { let args: Vec<String> = env::args().collect(); let (query, file_path) = parse_config(&args); // --省略-- } fn parse_config(args: &[String]) -> (&str, &str) { let query = &args[1]; let file_path = &args[2]; (query, file_path) }
经过分离后,之前的设计目标完美达成,即精简了 main
函数,又将配置相关的代码放在了 main.rs
文件里。
看起来貌似是杀鸡用了牛刀,但是重构就是这样,一步一步,踏踏实实的前行,否则未来代码多一些后,你岂不是还要再重来一次重构?因此打好项目的基础是非常重要的!
聚合配置变量
前文提到,配置变量并不适合分散的到处都是,因此使用一个结构体来统一存放是非常好的选择,这样修改后,后续的使用以及未来的代码维护都将更加简单明了。
fn main() { let args: Vec<String> = env::args().collect(); let config = parse_config(&args); println!("Searching for {}", config.query); println!("In file {}", config.file_path); let contents = fs::read_to_string(config.file_path) .expect("Should have been able to read the file"); // --snip-- } struct Config { query: String, file_path: String, } fn parse_config(args: &[String]) -> Config { let query = args[1].clone(); let file_path = args[2].clone(); Config { query, file_path } }
值得注意的是,Config
中存储的并不是 &str
这样的引用类型,而是一个 String
字符串,也就是 Config
并没有去借用外部的字符串,而是拥有内部字符串的所有权。clone
方法的使用也可以佐证这一点。大家可以尝试不用 clone
方法,看看该如何解决相关的报错 :D
clone
的得与失在上面的代码中,除了使用
clone
,还有其它办法来达成同样的目的,但clone
无疑是最简单的方法:直接完整的复制目标数据,无需被所有权、借用等问题所困扰,但是它也有其缺点,那就是有一定的性能损耗。因此是否使用
clone
更多是一种性能上的权衡,对于上面的使用而言,由于是配置的初始化,因此整个程序只需要执行一次,性能损耗几乎是可以忽略不计的。总之,判断是否使用
clone
:
- 是否严肃的项目,玩具项目直接用
clone
就行,简单不好吗?- 要看所在的代码路径是否是热点路径(hot path),例如执行次数较多的显然就是热点路径,热点路径就值得去使用性能更好的实现方式
好了,言归正传,从 C
语言过来的同学可能会觉得上面的代码已经很棒了,但是从 OO 语言角度来说,还差了那么一点意思。
下面我们试着来优化下,通过构造函数来初始化一个 Config
实例,而不是直接通过函数返回实例,典型的,标准库中的 String::new
函数就是一个范例。
fn main() { let args: Vec<String> = env::args().collect(); let config = Config::new(&args); // --snip-- } // --snip-- impl Config { fn new(args: &[String]) -> Config { let query = args[1].clone(); let file_path = args[2].clone(); Config { query, file_path } } }
修改后,类似 String::new
的调用,我们可以通过 Config::new
来创建一个实例,看起来代码是不是更有那味儿了 :)
错误处理
回顾一下,如果用户不输入任何命令行参数,我们的程序会怎么样?
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'index out of bounds: the len is 1 but the index is 1', src/main.rs:27:21
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
结果喜闻乐见,由于 args
数组没有任何元素,因此通过索引访问时,会直接报出数组访问越界的 panic
。
报错信息对于开发者会很明确,但是对于使用者而言,就相当难理解了,下面一起来解决它。
改进报错信息
还记得在错误处理章节,我们提到过 panic
的两种用法: 被动触发和主动调用嘛?上面代码的出现方式很明显是被动触发,这种报错信息是不可控的,下面我们先改成主动调用的方式:
#![allow(unused)] fn main() { // in main.rs // --snip-- fn new(args: &[String]) -> Config { if args.len() < 3 { panic!("not enough arguments"); } // --snip-- }
目的很明确,一旦传入的参数数组长度小于 3,则报错并让程序崩溃推出,这样后续的数组访问就不会再越界了。
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep`
thread 'main' panicked at 'not enough arguments', src/main.rs:26:13
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
不错,用户看到了更为明确的提示,但是还是有一大堆 debug
输出,这些我们其实是不想让用户看到的。这么看来,想要输出对用户友好的信息, panic
是不太适合的,它更适合告知开发者,哪里出现了问题。
返回 Result 来替代直接 panic
那只能祭出之前学过的错误处理大法了,也就是返回一个 Result
:成功时包含 Config
实例,失败时包含一条错误信息。
有一点需要额外注意下,从代码惯例的角度出发,new
往往不会失败,毕竟新建一个实例没道理失败,对不?因此修改为 build
会更加合适。
#![allow(unused)] fn main() { impl Config { fn build(args: &[String]) -> Result<Config, &'static str> { if args.len() < 3 { return Err("not enough arguments"); } let query = args[1].clone(); let file_path = args[2].clone(); Ok(Config { query, file_path }) } } }
这里的 Result
可能包含一个 Config
实例,也可能包含一条错误信息 &static str
,不熟悉这种字符串类型的同学可以回头看看字符串章节,代码中的字符串字面量都是该类型,且拥有 'static
生命周期。
处理返回的 Result
接下来就是在调用 build
函数时,对返回的 Result
进行处理了,目的就是给出准确且友好的报错提示, 为了让大家更好的回顾我们修改过的内容,这里给出整体代码:
use std::env; use std::fs; use std::process; fn main() { let args: Vec<String> = env::args().collect(); // 对 build 返回的 `Result` 进行处理 let config = Config::build(&args).unwrap_or_else(|err| { println!("Problem parsing arguments: {err}"); process::exit(1); }); println!("Searching for {}", config.query); println!("In file {}", config.file_path); let contents = fs::read_to_string(config.file_path) .expect("Should have been able to read the file"); println!("With text:\n{contents}"); } struct Config { query: String, file_path: String, } impl Config { fn build(args: &[String]) -> Result<Config, &'static str> { if args.len() < 3 { return Err("not enough arguments"); } let query = args[1].clone(); let file_path = args[2].clone(); Ok(Config { query, file_path }) } }
上面代码有几点值得注意:
- 当
Result
包含错误时,我们不再调用panic
让程序崩溃,而是通过process::exit(1)
来终结进程,其中1
是一个信号值(事实上非 0 值都可以),通知调用我们程序的进程,程序是因为错误而退出的。 unwrap_or_else
是定义在Result<T,E>
上的常用方法,如果Result
是Ok
,那该方法就类似unwrap
:返回Ok
内部的值;如果是Err
,就调用闭包中的自定义代码对错误进行进一步处理
综上可知,config
变量的值是一个 Config
实例,而 unwrap_or_else
闭包中的 err
参数,它的类型是 'static str
,值是 "not enough arguments" 那个字符串字面量。
运行后,可以看到以下输出:
$ cargo run
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/minigrep`
Problem parsing arguments: not enough arguments
终于,我们得到了自己想要的输出:既告知了用户为何报错,又消除了多余的 debug 信息,非常棒。可能有用户疑惑,cargo run
底下还有一大堆 debug
信息呢,实际上,这是 cargo run
自带的,大家可以试试编译成二进制可执行文件后再调用,会是什么效果。
分离主体逻辑
接下来可以继续精简 main
函数,那就是将主体逻辑( 例如业务逻辑 )从 main
中分离出去,这样 main
函数就保留主流程调用,非常简洁。
// in main.rs fn main() { let args: Vec<String> = env::args().collect(); let config = Config::build(&args).unwrap_or_else(|err| { println!("Problem parsing arguments: {err}"); process::exit(1); }); println!("Searching for {}", config.query); println!("In file {}", config.file_path); run(config); } fn run(config: Config) { let contents = fs::read_to_string(config.file_path) .expect("Should have been able to read the file"); println!("With text:\n{contents}"); } // --snip--
如上所示,main
函数仅保留主流程各个环节的调用,一眼看过去非常简洁清晰。
继续之前,先请大家仔细看看 run
函数,你们觉得还缺少什么?提示:参考 build
函数的改进过程。
使用 ? 和特征对象来返回错误
答案就是 run
函数没有错误处理,因为在文章开头我们提到过,错误处理最好统一在一个地方完成,这样极其有利于后续的代码维护。
#![allow(unused)] fn main() { //in main.rs use std::error::Error; // --snip-- fn run(config: Config) -> Result<(), Box<dyn Error>> { let contents = fs::read_to_string(config.file_path)?; println!("With text:\n{contents}"); Ok(()) } }
值得注意的是这里的 Result<(), Box<dyn Error>>
返回类型,首先我们的程序无需返回任何值,但是为了满足 Result<T,E>
的要求,因此使用了 Ok(())
返回一个单元类型 ()
。
最重要的是 Box<dyn Error>
, 如果按照顺序学到这里,大家应该知道这是一个Error
的特征对象(为了使用 Error
,我们通过 use std::error::Error;
进行了引入),它表示函数返回一个类型,该类型实现了 Error
特征,这样我们就无需指定具体的错误类型,否则你还需要查看 fs::read_to_string
返回的错误类型,然后复制到我们的 run
函数返回中,这么做一个是麻烦,最主要的是,一旦这么做,意味着我们无法在上层调用时统一处理错误,但是 Box<dyn Error>
不同,其它函数也可以返回这个特征对象,然后调用者就可以使用统一的方式来处理不同函数返回的 Box<dyn Error>
。
明白了 Box<dyn Error>
的重要战略地位,接下来大家分析下,fs::read_to_string
返回的具体错误类型是怎么被转化为 Box<dyn Error>
的?其实原因在之前章节都有讲过,这里就不直接给出答案了,参见 ?-传播界的大明星。
运行代码看看效果:
$ cargo run the poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
warning: unused `Result` that must be used
--> src/main.rs:19:5
|
19 | run(config);
| ^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: this `Result` may be an `Err` variant, which should be handled
warning: `minigrep` (bin "minigrep") generated 1 warning
Finished dev [unoptimized + debuginfo] target(s) in 0.71s
Running `target/debug/minigrep the poem.txt`
Searching for the
In file poem.txt
With text:
I'm nobody! Who are you?
Are you nobody, too?
Then there's a pair of us - don't tell!
They'd banish us, you know.
How dreary to be somebody!
How public, like a frog
To tell your name the livelong day
To an admiring bog!
没任何问题,不过 Rust 编译器也给出了善意的提示,那就是 Result
并没有被使用,这可能意味着存在错误的潜在可能性。
处理返回的错误
fn main() { // --snip-- println!("Searching for {}", config.query); println!("In file {}", config.file_path); if let Err(e) = run(config) { println!("Application error: {e}"); process::exit(1); } }
先回忆下在 build
函数调用时,我们怎么处理错误的?然后与这里的方式做一下对比,是不是发现了一些区别?
没错 if let
的使用让代码变得更简洁,可读性也更加好,原因是,我们并不关注 run
返回的 Ok
值,因此只需要用 if let
去匹配是否存在错误即可。
好了,截止目前,代码看起来越来越美好了,距离我们的目标也只差一个:将主体逻辑代码分离到一个独立的文件 lib.rs
中。
分离逻辑代码到库包中
对于 Rust 的代码组织( 包和模块 )还不熟悉的同学,强烈建议回头温习下这一章。
首先,创建一个 src/lib.rs
文件,然后将所有的非 main
函数都移动到其中。代码大概类似:
#![allow(unused)] fn main() { use std::error::Error; use std::fs; pub struct Config { pub query: String, pub file_path: String, } impl Config { pub fn build(args: &[String]) -> Result<Config, &'static str> { // --snip-- } } pub fn run(config: Config) -> Result<(), Box<dyn Error>> { // --snip-- } }
为了内容的简洁性,这里忽略了具体的实现,下一步就是在 main.rs
中引入 lib.rs
中定义的 Config
类型。
use std::env; use std::process; use minigrep::Config; fn main() { // --snip-- let args: Vec<String> = env::args().collect(); let config = Config::build(&args).unwrap_or_else(|err| { println!("Problem parsing arguments: {err}"); process::exit(1); }); println!("Searching for {}", config.query); println!("In file {}", config.file_path); if let Err(e) = minigrep::run(config) { // --snip-- println!("Application error: {e}"); process::exit(1); } }
很明显,这里的 mingrep::run
的调用,以及 Config
的引入,跟使用其它第三方包已经没有任何区别,也意味着我们成功的将逻辑代码放置到一个独立的库包中,其它包只要引入和调用就行。
呼,一顿书写猛如虎,回头一看。。。这么长的篇幅就写了这么点简单的代码??只能说,我也希望像很多国内的大学教材一样,只要列出定理和解题方法,然后留下足够的习题,就万事大吉了,但是咱们不行。
接下来,到了最喜(令)闻(人)乐(讨)见(厌)的环节:写测试代码,一起来开心吧。
测试驱动开发
开始之前,推荐大家先了解下如何在 Rust 中编写测试代码,这块儿内容不复杂,先了解下有利于本章的继续阅读
在之前的章节中,我们完成了对项目结构的重构,并将进入逻辑代码编程的环节,但在此之前,我们需要先编写一些测试代码,也是最近颇为流行的测试驱动开发模式(TDD, Test Driven Development):
- 编写一个注定失败的测试,并且失败的原因和你指定的一样
- 编写一个成功的测试
- 编写你的逻辑代码,直到通过测试
这三个步骤将在我们的开发过程中不断循环,直到所有的代码都开发完成并成功通过所有测试。
注定失败的测试用例
既然要添加测试,那之前的 println!
语句将没有大的用处,毕竟 println!
存在的目的就是为了让我们看到结果是否正确,而现在测试用例将取而代之。
接下来,在 lib.rs
文件中,添加 tests
模块和 test
函数:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use super::*; #[test] fn one_result() { let query = "duct"; let contents = "\ Rust: safe, fast, productive. Pick three."; assert_eq!(vec!["safe, fast, productive."], search(query, contents)); } } }
测试用例将在指定的内容中搜索 duct
字符串,目测可得:其中有一行内容是包含有目标字符串的。
但目前为止,还无法运行该测试用例,更何况还想幸灾乐祸的看其失败,原因是 search
函数还没有实现!毕竟是测试驱动、测试先行。
#![allow(unused)] fn main() { // in lib.rs pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { vec![] } }
先添加一个简单的 search
函数实现,非常简单粗暴的返回一个空的数组,显而易见测试用例将成功通过,真是一个居心叵测的测试用例!
注意这里生命周期 'a
的使用,之前的章节有详细介绍,不太明白的同学可以回头看看。
喔,这么复杂的代码,都用上生命周期了!嘚瑟两下试试:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished test [unoptimized + debuginfo] target(s) in 0.97s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... FAILED
failures:
---- tests::one_result stdout ----
thread 'main' panicked at 'assertion failed: `(left == right)`
left: `["safe, fast, productive."]`,
right: `[]`', src/lib.rs:44:9
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::one_result
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
error: test failed, to rerun pass `--lib`
太棒了!它失败了...
务必成功的测试用例
接着就是测试驱动的第二步:编写注定成功的测试。当然,前提条件是实现我们的 search
函数。它包含以下步骤:
- 遍历迭代
contents
的每一行 - 检查该行内容是否包含我们的目标字符串
- 若包含,则放入返回值列表中,否则忽略
- 返回匹配到的返回值列表
遍历迭代每一行
Rust 提供了一个很便利的 lines
方法将目标字符串进行按行分割:
#![allow(unused)] fn main() { // in lib.rs pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { for line in contents.lines() { // do something with line } } }
这里的 lines
返回一个迭代器,关于迭代器在后续章节会详细讲解,现在只要知道 for
可以遍历取出迭代器中的值即可。
在每一行中查询目标字符串
#![allow(unused)] fn main() { // in lib.rs pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { for line in contents.lines() { if line.contains(query) { // do something with line } } } }
与之前的 lines
函数类似,Rust 的字符串还提供了 contains
方法,用于检查 line
是否包含待查询的 query
。
接下来,只要返回合适的值,就可以完成 search
函数的编写。
存储匹配到的结果
简单,创建一个 Vec
动态数组,然后将查询到的每一个 line
推进数组中即可:
#![allow(unused)] fn main() { // in lib.rs pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { let mut results = Vec::new(); for line in contents.lines() { if line.contains(query) { results.push(line); } } results } }
至此,search
函数已经完成了既定目标,为了检查功能是否正确,运行下我们之前编写的测试用例:
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished test [unoptimized + debuginfo] target(s) in 1.22s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 1 test
test tests::one_result ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
测试通过,意味着我们的代码也完美运行,接下来就是在 run
函数中大显身手了。
在 run 函数中调用 search 函数
#![allow(unused)] fn main() { // in src/lib.rs pub fn run(config: Config) -> Result<(), Box<dyn Error>> { let contents = fs::read_to_string(config.file_path)?; for line in search(&config.query, &contents) { println!("{line}"); } Ok(()) } }
好,再运行下看看结果,看起来我们距离成功从未如此之近!
$ cargo run -- frog poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.38s
Running `target/debug/minigrep frog poem.txt`
How public, like a frog
酷!成功查询到包含 frog
的行,再来试试 body
:
$ cargo run -- body poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep body poem.txt`
I'm nobody! Who are you?
Are you nobody, too?
How dreary to be somebody!
完美,三行,一行不少,为了确保万无一失,再来试试查询一个不存在的单词:
cargo run -- monomorphization poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep monomorphization poem.txt`
至此,章节开头的目标已经全部完成,接下来思考一个小问题:如果要为程序加上大小写不敏感的控制命令,由用户进行输入,该怎么实现比较好呢?毕竟在实际搜索查询中,同时支持大小写敏感和不敏感还是很重要的。
答案留待下一章节揭晓。
使用环境变量来增强程序
在上一章节中,留下了一个悬念,该如何实现用户控制的大小写敏感,其实答案很简单,你在其它程序中肯定也遇到过不少,例如如何控制 panic
后的栈展开? Rust 提供的解决方案是通过命令行参数来控制:
RUST_BACKTRACE=1 cargo run
与之类似,我们也可以使用环境变量来控制大小写敏感,例如:
IGNORE_CASE=1 cargo run -- to poem.txt
既然有了目标,那么一起来看看该如何实现吧。
编写大小写不敏感的测试用例
还是遵循之前的规则:测试驱动,这次是对一个新的大小写不敏感函数进行测试 search_case_insensitive
。
还记得 TDD 的测试步骤嘛?首先编写一个注定失败的用例:
#![allow(unused)] fn main() { // in src/lib.rs #[cfg(test)] mod tests { use super::*; #[test] fn case_sensitive() { let query = "duct"; let contents = "\ Rust: safe, fast, productive. Pick three. Duct tape."; assert_eq!(vec!["safe, fast, productive."], search(query, contents)); } #[test] fn case_insensitive() { let query = "rUsT"; let contents = "\ Rust: safe, fast, productive. Pick three. Trust me."; assert_eq!( vec!["Rust:", "Trust me."], search_case_insensitive(query, contents) ); } } }
可以看到,这里新增了一个 case_insensitive
测试用例,并对 search_case_insensitive
进行了测试,结果显而易见,函数都没有实现,自然会失败。
接着来实现这个大小写不敏感的搜索函数:
#![allow(unused)] fn main() { pub fn search_case_insensitive<'a>( query: &str, contents: &'a str, ) -> Vec<&'a str> { let query = query.to_lowercase(); let mut results = Vec::new(); for line in contents.lines() { if line.to_lowercase().contains(&query) { results.push(line); } } results } }
跟之前一样,但是引入了一个新的方法 to_lowercase
,它会将 line
转换成全小写的字符串,类似的方法在其它语言中也差不多,就不再赘述。
还要注意的是 query
现在是 String
类型,而不是之前的 &str
,因为 to_lowercase
返回的是 String
。
修改后,再来跑一次测试,看能否通过。
$ cargo test
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished test [unoptimized + debuginfo] target(s) in 1.33s
Running unittests src/lib.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 2 tests
test tests::case_insensitive ... ok
test tests::case_sensitive ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Running unittests src/main.rs (target/debug/deps/minigrep-9cd200e5fac0fc94)
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Doc-tests minigrep
running 0 tests
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
Ok,TDD的第二步也完成了,测试通过,接下来就是最后一步,在 run
中调用新的搜索函数。但是在此之前,要新增一个配置项,用于控制是否开启大小写敏感。
#![allow(unused)] fn main() { // in lib.rs pub struct Config { pub query: String, pub file_path: String, pub ignore_case: bool, } }
接下来就是检查该字段,来判断是否启动大小写敏感:
#![allow(unused)] fn main() { pub fn run(config: Config) -> Result<(), Box<dyn Error>> { let contents = fs::read_to_string(config.file_path)?; let results = if config.ignore_case { search_case_insensitive(&config.query, &contents) } else { search(&config.query, &contents) }; for line in results { println!("{line}"); } Ok(()) } }
现在的问题来了,该如何控制这个配置项呢。这个就要借助于章节开头提到的环境变量,好在 Rust 的 env
包提供了相应的方法。
#![allow(unused)] fn main() { use std::env; // --snip-- impl Config { pub fn build(args: &[String]) -> Result<Config, &'static str> { if args.len() < 3 { return Err("not enough arguments"); } let query = args[1].clone(); let file_path = args[2].clone(); let ignore_case = env::var("IGNORE_CASE").is_ok(); Ok(Config { query, file_path, ignore_case, }) } } }
env::var
没啥好说的,倒是 is_ok
值得说道下。该方法是 Result
提供的,用于检查是否有值,有就返回 true
,没有则返回 false
,刚好完美符合我们的使用场景,因为我们并不关心 Ok<T>
中具体的值。
运行下试试:
$ cargo run -- to poem.txt
Compiling minigrep v0.1.0 (file:///projects/minigrep)
Finished dev [unoptimized + debuginfo] target(s) in 0.0s
Running `target/debug/minigrep to poem.txt`
Are you nobody, too?
How dreary to be somebody!
看起来没有问题,接下来测试下大小写不敏感:
$ IGNORE_CASE=1 cargo run -- to poem.txt
Are you nobody, too?
How dreary to be somebody!
To tell your name the livelong day
To an admiring bog!
大小写不敏感后,查询到的内容明显多了很多,也很符合我们的预期。
最后,给大家留一个小作业:同时使用命令行参数和环境变量的方式来控制大小写不敏感,其中环境变量的优先级更高,也就是两个都设置的情况下,优先使用环境变量的设置。
重定向错误信息的输出
迄今为止,所有的输出信息,无论 debug 还是 error 类型,都是通过 println!
宏输出到终端的标准输出( stdout
),但是对于程序来说,错误信息更适合输出到标准错误输出(stderr)。
这样修改后,用户就可以选择将普通的日志类信息输出到日志文件 1,然后将错误信息输出到日志文件 2,甚至还可以输出到终端命令行。
目前的错误输出位置
我们先来观察下,目前的输出信息包括错误,是否是如上面所说,都写到标准错误输出。
测试方式很简单,将标准错误输出的内容重定向到文件中,看看是否包含故意生成的错误信息即可。
$ cargo run > output.txt
首先,这里的运行没有带任何参数,因此会报出类如文件不存在的错误,其次,通过 >
操作符,标准输出上的内容被重定向到文件 output.txt
中,不再打印到控制上。
大家先观察下控制台,然后再看看 output.txt
,是否发现如下的错误信息已经如期被写入到文件中?
Problem parsing arguments: not enough arguments
所以,可以得出一个结论,如果错信息输出到标准输出,那么它们将跟普通的日志信息混在一起,难以分辨,因此我们需要将错误信息进行单独输出。
标准错误输出 stderr
将错误信息重定向到 stderr
很简单,只需在打印错误的地方,将 println!
宏替换为 eprintln!
即可。
fn main() { let args: Vec<String> = env::args().collect(); let config = Config::build(&args).unwrap_or_else(|err| { eprintln!("Problem parsing arguments: {err}"); process::exit(1); }); if let Err(e) = minigrep::run(config) { eprintln!("Application error: {e}"); process::exit(1); } }
接下来,还是同样的运行命令:
$ cargo run > output.txt
Problem parsing arguments: not enough arguments
可以看到,日志信息成功的重定向到 output.txt
文件中,而错误信息由于 eprintln!
的使用,被写入到标准错误输出中,默认还是输出在控制台中。
再来试试没有错误的情况:
$ cargo run -- to poem.txt > output.txt
这次运行参数很正确,因此也没有任何错误信息产生,同时由于我们重定向了标准输出,因此相应的输出日志会写入到 output.txt
中,打开可以看到如下内容:
Are you nobody, too?
How dreary to be somebody!
至此,简易搜索程序 minigrep
已经基本完成,下一章节将使用迭代器进行部分改进,请大家在看完迭代器章节后,再回头阅读。
使用迭代器来改进我们的程序
本章节是可选内容,请大家在看完迭代器章节后,再来阅读
在之前的 minigrep
中,功能虽然已经 ok,但是一些细节上还值得打磨下,下面一起看看如何使用迭代器来改进 Config::build
和 search
的实现。
移除 clone
的使用
虽然之前有讲过为什么这里可以使用 clone
,但是也许总有同学心有芥蒂,毕竟程序员嘛,都希望代码处处完美,而不是丑陋的处处妥协。
#![allow(unused)] fn main() { impl Config { pub fn build(args: &[String]) -> Result<Config, &'static str> { if args.len() < 3 { return Err("not enough arguments"); } let query = args[1].clone(); let file_path = args[2].clone(); let ignore_case = env::var("IGNORE_CASE").is_ok(); Ok(Config { query, file_path, ignore_case, }) } } }
之前的代码大致长这样,两行 clone
着实有点啰嗦,好在,在学习完迭代器后,我们知道了 build
函数实际上可以直接拿走迭代器的所有权,而不是去借用一个数组切片 &[String]
。
这里先不给出代码,下面统一给出。
直接使用返回的迭代器
在之前的实现中,我们的 args
是一个动态数组:
fn main() { let args: Vec<String> = env::args().collect(); let config = Config::build(&args).unwrap_or_else(|err| { eprintln!("Problem parsing arguments: {err}"); process::exit(1); }); // --snip-- }
当时还提到了 collect
方法的使用,相信大家学完迭代器后,对这个方法会有更加深入的认识。
现在呢,无需数组了,直接传入迭代器即可:
fn main() { let config = Config::build(env::args()).unwrap_or_else(|err| { eprintln!("Problem parsing arguments: {err}"); process::exit(1); }); // --snip-- }
如上所示,我们甚至省去了一行代码,原因是 env::args
可以直接返回一个迭代器,再作为 Config::build
的参数传入,下面再来改写 build
方法。
#![allow(unused)] fn main() { impl Config { pub fn build( mut args: impl Iterator<Item = String>, ) -> Result<Config, &'static str> { // --snip-- }
为了可读性和更好的通用性,这里的 args
类型并没有使用本身的 std::env::Args
,而是使用了特征约束的方式来描述 impl Iterator<Item = String>
,这样意味着 arg
可以是任何实现了 String
迭代器的类型。
还有一点值得注意,由于迭代器的所有权已经转移到 build
内,因此可以直接对其进行修改,这里加上了 mut
关键字。
移除数组索引的使用
数组索引会越界,为了安全性和简洁性,使用 Iterator
特征自带的 next
方法是一个更好的选择:
#![allow(unused)] fn main() { impl Config { pub fn build( mut args: impl Iterator<Item = String>, ) -> Result<Config, &'static str> { // 第一个参数是程序名,由于无需使用,因此这里直接空调用一次 args.next(); let query = match args.next() { Some(arg) => arg, None => return Err("Didn't get a query string"), }; let file_path = match args.next() { Some(arg) => arg, None => return Err("Didn't get a file path"), }; let ignore_case = env::var("IGNORE_CASE").is_ok(); Ok(Config { query, file_path, ignore_case, }) } } }
喔,上面使用了迭代器和模式匹配的代码,看上去是不是很 Rust?我想我们已经走在了正确的道路上。
使用迭代器适配器让代码更简洁
为了帮大家更好的回忆和对比,之前的 search
长这样:
#![allow(unused)] fn main() { // in lib.rs pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { let mut results = Vec::new(); for line in contents.lines() { if line.contains(query) { results.push(line); } } results } }
引入了迭代器后,就连古板的 search
函数也可以变得更 rusty 些:
#![allow(unused)] fn main() { pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> { contents .lines() .filter(|line| line.contains(query)) .collect() } }
Rock,让我们的函数编程 Style rock 起来,这种一行到底的写法有时真的让人沉迷。
总结
至此,整个大章节全部结束,本章没有试图覆盖已学的方方面面( 也许未来会 ),而是聚焦于 Rust 的一些核心知识:所有权、生命周期、借用、模式匹配等等。
强烈推荐大家忘记已有的一切,自己重新实现一遍 minigrep
,甚至可以根据自己的想法和喜好,来完善一些,也欢迎在评论中附上自己的练习项目,供其它人学习参考( 提个小建议,项目主页写清楚新增的功能、亮点等 )。
从下一章开始,我们将正式开始 Rust 进阶学习,请深呼吸一口,然后问自己:你..准备好了吗?
Rust 高级进阶
恭喜你,学会 Rust 基础后,金丹大道已在向你招手,大部分 Rust 代码对你来说都是家常便饭,简单得很。可是,对于一门难度传言在外的语言,怎么可能如此简单的就被征服,最难的生命周期,咱还没见过长啥样呢。
从本章开始,我们将进入 Rust 的进阶学习环节,与基础环节不同的是,由于你已经对 Rust 有了一定的认识,因此我们不会再对很多细节进行翻来覆去的详细讲解,甚至会一带而过。
总之,欢迎来到高级 Rust 的世界,全新的 Boss,全新的装备,你准备好了吗?
生命周期
何为高阶?一个字:难,二个字:很难,七个字:其实也没那么难。至于到底难不难,还是交给各位看官评判吧 :D
大家都知道,生命周期在 Rust 中是最难的部分之一,因此相关内容被分成了两个章节:基础和进阶,其中基础部分已经在之前学习过,下面一起来看看真正的难
字怎么写。
深入生命周期
其实关于生命周期的常用特性,在上一节中,我们已经概括得差不多了,本章主要讲解生命周期的一些高级或者不为人知的特性。对于新手,完全可以跳过本节内容,进行下一章节的学习。
不太聪明的生命周期检查
在 Rust 语言学习中,一个很重要的部分就是阅读一些你可能不经常遇到,但是一旦遇到就难以理解的代码,这些代码往往最令人头疼的就是生命周期,这里我们就来看看一些本以为可以编译,但是却因为生命周期系统不够聪明导致编译失败的代码。
例子 1
#[derive(Debug)] struct Foo; impl Foo { fn mutate_and_share(&mut self) -> &Self { &*self } fn share(&self) {} } fn main() { let mut foo = Foo; let loan = foo.mutate_and_share(); foo.share(); println!("{:?}", loan); }
上面的代码中,foo.mutate_and_share()
虽然借用了 &mut self
,但是它最终返回的是一个 &self
,然后赋值给 loan
,因此理论上来说它最终是进行了不可变借用,同时 foo.share
也进行了不可变借用,那么根据 Rust 的借用规则:多个不可变借用可以同时存在,因此该代码应该编译通过。
事实上,运行代码后,你将看到一个错误:
error[E0502]: cannot borrow `foo` as immutable because it is also borrowed as mutable
--> src/main.rs:12:5
|
11 | let loan = foo.mutate_and_share();
| ---------------------- mutable borrow occurs here
12 | foo.share();
| ^^^^^^^^^^^ immutable borrow occurs here
13 | println!("{:?}", loan);
| ---- mutable borrow later used here
编译器的提示在这里其实有些难以理解,因为可变借用仅在 mutate_and_share
方法内部有效,出了该方法后,就只有返回的不可变借用,因此,按理来说可变借用不应该在 main
的作用范围内存在。
对于这个反直觉的事情,让我们用生命周期来解释下,可能你就很好理解了:
struct Foo; impl Foo { fn mutate_and_share<'a>(&'a mut self) -> &'a Self { &'a *self } fn share<'a>(&'a self) {} } fn main() { 'b: { let mut foo: Foo = Foo; 'c: { let loan: &'c Foo = Foo::mutate_and_share::<'c>(&'c mut foo); 'd: { Foo::share::<'d>(&'d foo); } println!("{:?}", loan); } } }
以上是模拟了编译器的生命周期标注后的代码,可以注意到 &mut foo
和 loan
的生命周期都是 'c
。
还记得生命周期消除规则中的第三条吗?因为该规则,导致了 mutate_and_share
方法中,参数 &mut self
和返回值 &self
的生命周期是相同的,因此,若返回值的生命周期在 main
函数有效,那 &mut self
的借用也是在 main
函数有效。
这就解释了可变借用为啥会在 main
函数作用域内有效,最终导致 foo.share()
无法再进行不可变借用。
总结下:&mut self
借用的生命周期和 loan
的生命周期相同,将持续到 println
结束。而在此期间 foo.share()
又进行了一次不可变 &foo
借用,违背了可变借用与不可变借用不能同时存在的规则,最终导致了编译错误。
上述代码实际上完全是正确的,但是因为生命周期系统的“粗糙实现”,导致了编译错误,目前来说,遇到这种生命周期系统不够聪明导致的编译错误,我们也没有太好的办法,只能修改代码去满足它的需求,并期待以后它会更聪明。
例子 2
再来看一个例子:
#![allow(unused)] fn main() { use std::collections::HashMap; use std::hash::Hash; fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V where K: Clone + Eq + Hash, V: Default, { match map.get_mut(&key) { Some(value) => value, None => { map.insert(key.clone(), V::default()); map.get_mut(&key).unwrap() } } } }
这段代码不能通过编译的原因是编译器未能精确地判断出某个可变借用不再需要,反而谨慎的给该借用安排了一个很大的作用域,结果导致后续的借用失败:
error[E0499]: cannot borrow `*map` as mutable more than once at a time
--> src/main.rs:13:17
|
5 | fn get_default<'m, K, V>(map: &'m mut HashMap<K, V>, key: K) -> &'m mut V
| -- lifetime `'m` defined here
...
10 | match map.get_mut(&key) {
| - ----------------- first mutable borrow occurs here
| _________|
| |
11 | | Some(value) => value,
12 | | None => {
13 | | map.insert(key.clone(), V::default());
| | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ second mutable borrow occurs here
14 | | map.get_mut(&key).unwrap()
15 | | }
16 | | }
| |_________- returning this value requires that `*map` is borrowed for `'m`
分析代码可知在 match map.get_mut(&key)
方法调用完成后,对 map
的可变借用就可以结束了。但从报错看来,编译器不太聪明,它认为该借用会持续到整个 match
语句块的结束(第 16 行处),这便造成了后续借用的失败。
类似的例子还有很多,由于篇幅有限,就不在这里一一列举,如果大家想要阅读更多的类似代码,可以看看<<Rust 代码鉴赏>>一书。
无界生命周期
不安全代码(unsafe
)经常会凭空产生引用或生命周期,这些生命周期被称为是 无界(unbound) 的。
无界生命周期往往是在解引用一个裸指针(裸指针 raw pointer)时产生的,换句话说,它是凭空产生的,因为输入参数根本就没有这个生命周期:
#![allow(unused)] fn main() { fn f<'a, T>(x: *const T) -> &'a T { unsafe { &*x } } }
上述代码中,参数 x
是一个裸指针,它并没有任何生命周期,然后通过 unsafe
操作后,它被进行了解引用,变成了一个 Rust 的标准引用类型,该类型必须要有生命周期,也就是 'a
。
可以看出 'a
是凭空产生的,因此它是无界生命周期。这种生命周期由于没有受到任何约束,因此它想要多大就多大,这实际上比 'static
要强大。例如 &'static &'a T
是无效类型,但是无界生命周期 &'unbounded &'a T
会被视为 &'a &'a T
从而通过编译检查,因为它可大可小,就像孙猴子的金箍棒一般。
我们在实际应用中,要尽量避免这种无界生命周期。最简单的避免无界生命周期的方式就是在函数声明中运用生命周期消除规则。若一个输出生命周期被消除了,那么必定因为有一个输入生命周期与之对应。
生命周期约束 HRTB
生命周期约束跟特征约束类似,都是通过形如 'a: 'b
的语法,来说明两个生命周期的长短关系。
'a: 'b
假设有两个引用 &'a i32
和 &'b i32
,它们的生命周期分别是 'a
和 'b
,若 'a
>= 'b
,则可以定义 'a:'b
,表示 'a
至少要活得跟 'b
一样久。
#![allow(unused)] fn main() { struct DoubleRef<'a,'b:'a, T> { r: &'a T, s: &'b T } }
例如上述代码定义一个结构体,它拥有两个引用字段,类型都是泛型 T
,每个引用都拥有自己的生命周期,由于我们使用了生命周期约束 'b: 'a
,因此 'b
必须活得比 'a
久,也就是结构体中的 s
字段引用的值必须要比 r
字段引用的值活得要久。
T: 'a
表示类型 T
必须比 'a
活得要久:
#![allow(unused)] fn main() { struct Ref<'a, T: 'a> { r: &'a T } }
因为结构体字段 r
引用了 T
,因此 r
的生命周期 'a
必须要比 T
的生命周期更短(被引用者的生命周期必须要比引用长)。
在 Rust 1.30 版本之前,该写法是必须的,但是从 1.31 版本开始,编译器可以自动推导 T: 'a
类型的约束,因此我们只需这样写即可:
#![allow(unused)] fn main() { struct Ref<'a, T> { r: &'a T } }
来看一个使用了生命周期约束的综合例子:
#![allow(unused)] fn main() { struct ImportantExcerpt<'a> { part: &'a str, } impl<'a: 'b, 'b> ImportantExcerpt<'a> { fn announce_and_return_part(&'a self, announcement: &'b str) -> &'b str { println!("Attention please: {}", announcement); self.part } } }
上面的例子中必须添加约束 'a: 'b
后,才能成功编译,因为 self.part
的生命周期与 self
的生命周期一致,将 &'a
类型的生命周期强行转换为 &'b
类型,会报错,只有在 'a
>= 'b
的情况下,'a
才能转换成 'b
。
闭包函数的消除规则
先来看一段简单的代码:
#![allow(unused)] fn main() { fn fn_elision(x: &i32) -> &i32 { x } let closure_slision = |x: &i32| -> &i32 { x }; }
乍一看,这段代码比古天乐还平平无奇,能有什么问题呢?来,拄拐走两圈试试:
error: lifetime may not live long enough
--> src/main.rs:39:39
|
39 | let closure = |x: &i32| -> &i32 { x }; // fails
| - - ^ returning this value requires that `'1` must outlive `'2`
| | |
| | let's call the lifetime of this reference `'2`
| let's call the lifetime of this reference `'1`
咦?竟然报错了,明明两个一模一样功能的函数,一个正常编译,一个却报错,错误原因是编译器无法推测返回的引用和传入的引用谁活得更久!
真的是非常奇怪的错误,学过上一节的读者应该都记得这样一条生命周期消除规则:如果函数参数中只有一个引用类型,那该引用的生命周期会被自动分配给所有的返回引用。我们当前的情况完美符合, function
函数的顺利编译通过,就充分说明了问题。
先给出一个结论:这个问题,可能很难被解决,建议大家遇到后,还是老老实实用正常的函数,不要秀闭包了。
对于函数的生命周期而言,它的消除规则之所以能生效是因为它的生命周期完全体现在签名的引用类型上,在函数体中无需任何体现:
#![allow(unused)] fn main() { fn fn_elision(x: &i32) -> &i32 {..} }
因此编译器可以做各种编译优化,也很容易根据参数和返回值进行生命周期的分析,最终得出消除规则。
可是闭包,并没有函数那么简单,它的生命周期分散在参数和闭包函数体中(主要是它没有确切的返回值签名):
#![allow(unused)] fn main() { let closure_slision = |x: &i32| -> &i32 { x }; }
编译器就必须深入到闭包函数体中,去分析和推测生命周期,复杂度因此急剧提升:试想一下,编译器该如何从复杂的上下文中分析出参数引用的生命周期和闭包体中生命周期的关系?
由于上述原因(当然,实际情况复杂的多),Rust 语言开发者目前其实是有意针对函数和闭包实现了两种不同的生命周期消除规则。
用
Fn
特征解决闭包生命周期之前我们提到了很难解决,但是并没有完全堵死(论文字的艺术- , -) 这不 @Ykong1337 同学就带了一个解决方法,为他点赞!
fn main() { let closure_slision = fun(|x: &i32| -> &i32 { x }); assert_eq!(*closure_slision(&45), 45); // Passed ! } fn fun<T, F: Fn(&T) -> &T>(f: F) -> F { f }
NLL (Non-Lexical Lifetime)
之前我们在引用与借用那一章其实有讲到过这个概念,简单来说就是:引用的生命周期正常来说应该从借用开始一直持续到作用域结束,但是这种规则会让多引用共存的情况变得更复杂:
fn main() { let mut s = String::from("hello"); let r1 = &s; let r2 = &s; println!("{} and {}", r1, r2); // 新编译器中,r1,r2作用域在这里结束 let r3 = &mut s; println!("{}", r3); }
按照上述规则,这段代码将会报错,因为 r1
和 r2
的不可变引用将持续到 main
函数结束,而在此范围内,我们又借用了 r3
的可变引用,这违反了借用的规则:要么多个不可变借用,要么一个可变借用。
好在,该规则从 1.31 版本引入 NLL
后,就变成了:引用的生命周期从借用处开始,一直持续到最后一次使用的地方。
按照最新的规则,我们再来分析一下上面的代码。r1
和 r2
不可变借用在 println!
后就不再使用,因此生命周期也随之结束,那么 r3
的借用就不再违反借用的规则,皆大欢喜。
再来看一段关于 NLL
的代码解释:
#![allow(unused)] fn main() { let mut u = 0i32; let mut v = 1i32; let mut w = 2i32; // lifetime of `a` = α ∪ β ∪ γ let mut a = &mut u; // --+ α. lifetime of `&mut u` --+ lexical "lifetime" of `&mut u`,`&mut u`, `&mut w` and `a` use(a); // | | *a = 3; // <-----------------+ | ... // | a = &mut v; // --+ β. lifetime of `&mut v` | use(a); // | | *a = 4; // <-----------------+ | ... // | a = &mut w; // --+ γ. lifetime of `&mut w` | use(a); // | | *a = 5; // <-----------------+ <--------------------------+ }
这段代码一目了然,a
有三段生命周期:α
,β
,γ
,每一段生命周期都随着当前值的最后一次使用而结束。
在实际项目中,NLL
规则可以大幅减少引用冲突的情况,极大的便利了用户,因此广受欢迎,最终该规则甚至演化成一个独立的项目,未来可能会进一步简化我们的使用,Polonius
:
Reborrow 再借用
学完 NLL
后,我们就有了一定的基础,可以继续学习关于借用和生命周期的一个高级内容:再借用。
先来看一段代码:
#[derive(Debug)] struct Point { x: i32, y: i32, } impl Point { fn move_to(&mut self, x: i32, y: i32) { self.x = x; self.y = y; } } fn main() { let mut p = Point { x: 0, y: 0 }; let r = &mut p; let rr: &Point = &*r; println!("{:?}", rr); r.move_to(10, 10); println!("{:?}", r); }
以上代码,大家可能会觉得可变引用 r
和不可变引用 rr
同时存在会报错吧?但是事实上并不会,原因在于 rr
是对 r
的再借用。
对于再借用而言,rr
再借用时不会破坏借用规则,但是你不能在它的生命周期内再使用原来的借用 r
,来看看对上段代码的分析:
fn main() { let mut p = Point { x: 0, y: 0 }; let r = &mut p; // reborrow! 此时对`r`的再借用不会导致跟上面的借用冲突 let rr: &Point = &*r; // 再借用`rr`最后一次使用发生在这里,在它的生命周期中,我们并没有使用原来的借用`r`,因此不会报错 println!("{:?}", rr); // 再借用结束后,才去使用原来的借用`r` r.move_to(10, 10); println!("{:?}", r); }
再来看一个例子:
#![allow(unused)] fn main() { use std::vec::Vec; fn read_length(strings: &mut Vec<String>) -> usize { strings.len() } }
如上所示,函数体内对参数的二次借用也是典型的 Reborrow
场景。
那么下面让我们来做件坏事,破坏这条规则,使其报错:
fn main() { let mut p = Point { x: 0, y: 0 }; let r = &mut p; let rr: &Point = &*r; r.move_to(10, 10); println!("{:?}", rr); println!("{:?}", r); }
果然,破坏永远比重建简单 :) 只需要在 rr
再借用的生命周期内使用一次原来的借用 r
即可!
生命周期消除规则补充
在上一节中,我们介绍了三大基础生命周期消除规则,实际上,随着 Rust 的版本进化,该规则也在不断演进,这里再介绍几个常见的消除规则:
impl 块消除
#![allow(unused)] fn main() { impl<'a> Reader for BufReader<'a> { // methods go here // impl内部实际上没有用到'a } }
如果你以前写的impl
块长上面这样,同时在 impl
内部的方法中,根本就没有用到 'a
,那就可以写成下面的代码形式。
#![allow(unused)] fn main() { impl Reader for BufReader<'_> { // methods go here } }
'_
生命周期表示 BufReader
有一个不使用的生命周期,我们可以忽略它,无需为它创建一个名称。
歪个楼,有读者估计会发问:既然用不到 'a
,为何还要写出来?如果你仔细回忆下上一节的内容,里面有一句专门用粗体标注的文字:生命周期参数也是类型的一部分,因此 BufReader<'a>
是一个完整的类型,在实现它的时候,你不能把 'a
给丢了!
生命周期约束消除
#![allow(unused)] fn main() { // Rust 2015 struct Ref<'a, T: 'a> { field: &'a T } // Rust 2018 struct Ref<'a, T> { field: &'a T } }
在本节的生命周期约束中,也提到过,新版本 Rust 中,上面情况中的 T: 'a
可以被消除掉,当然,你也可以显式的声明,但是会影响代码可读性。关于类似的场景,Rust 团队计划在未来提供更多的消除规则,但是,你懂的,计划未来就等于未知。
一个复杂的例子
下面是一个关于生命周期声明过大的例子,会较为复杂,希望大家能细细阅读,它能帮你对生命周期的理解更加深入。
struct Interface<'a> { manager: &'a mut Manager<'a> } impl<'a> Interface<'a> { pub fn noop(self) { println!("interface consumed"); } } struct Manager<'a> { text: &'a str } struct List<'a> { manager: Manager<'a>, } impl<'a> List<'a> { pub fn get_interface(&'a mut self) -> Interface { Interface { manager: &mut self.manager } } } fn main() { let mut list = List { manager: Manager { text: "hello" } }; list.get_interface().noop(); println!("Interface should be dropped here and the borrow released"); // 下面的调用会失败,因为同时有不可变/可变借用 // 但是Interface在之前调用完成后就应该被释放了 use_list(&list); } fn use_list(list: &List) { println!("{}", list.manager.text); }
运行后报错:
error[E0502]: cannot borrow `list` as immutable because it is also borrowed as mutable // `list`无法被借用,因为已经被可变借用
--> src/main.rs:40:14
|
34 | list.get_interface().noop();
| ---- mutable borrow occurs here // 可变借用发生在这里
...
40 | use_list(&list);
| ^^^^^
| |
| immutable borrow occurs here // 新的不可变借用发生在这
| mutable borrow later used here // 可变借用在这里结束
这段代码看上去并不复杂,实际上难度挺高的,首先在直觉上,list.get_interface()
借用的可变引用,按理来说应该在这行代码结束后,就归还了,但是为什么还能持续到 use_list(&list)
后面呢?
这是因为我们在 get_interface
方法中声明的 lifetime
有问题,该方法的参数的生命周期是 'a
,而 List
的生命周期也是 'a
,说明该方法至少活得跟 List
一样久,再回到 main
函数中,list
可以活到 main
函数的结束,因此 list.get_interface()
借用的可变引用也会活到 main
函数的结束,在此期间,自然无法再进行借用了。
要解决这个问题,我们需要为 get_interface
方法的参数给予一个不同于 List<'a>
的生命周期 'b
,最终代码如下:
struct Interface<'b, 'a: 'b> { manager: &'b mut Manager<'a> } impl<'b, 'a: 'b> Interface<'b, 'a> { pub fn noop(self) { println!("interface consumed"); } } struct Manager<'a> { text: &'a str } struct List<'a> { manager: Manager<'a>, } impl<'a> List<'a> { pub fn get_interface<'b>(&'b mut self) -> Interface<'b, 'a> where 'a: 'b { Interface { manager: &mut self.manager } } } fn main() { let mut list = List { manager: Manager { text: "hello" } }; list.get_interface().noop(); println!("Interface should be dropped here and the borrow released"); // 下面的调用可以通过,因为Interface的生命周期不需要跟list一样长 use_list(&list); } fn use_list(list: &List) { println!("{}", list.manager.text); }
至此,生命周期终于完结,两章超级长的内容,可以满足几乎所有对生命周期的学习目标。学完生命周期,意味着你正式入门了 Rust,只要再掌握几个常用概念,就可以上手写项目了。
&'static 和 T: 'static
Rust 的难点之一就在于它有不少容易混淆的概念,例如 &str
、str
与 String
, 再比如本文标题那两位。不过与字符串也有不同,这两位对于普通用户来说往往是无需进行区分的,但是当大家想要深入学习或使用 Rust 时,它们就会成为成功路上的拦路虎了。
与生命周期的其它章节不同,本文短小精悍,阅读过程可谓相当轻松愉快,话不多说,let's go。
'static
在 Rust 中是相当常见的,例如字符串字面值就具有 'static
生命周期:
fn main() { let mark_twain: &str = "Samuel Clemens"; print_author(mark_twain); } fn print_author(author: &'static str) { println!("{}", author); }
除此之外,特征对象的生命周期也是 'static
,例如这里所提到的。
除了 &'static
的用法外,我们在另外一种场景中也可以见到 'static
的使用:
use std::fmt::Display; fn main() { let mark_twain = "Samuel Clemens"; print(&mark_twain); } fn print<T: Display + 'static>(message: &T) { println!("{}", message); }
在这里,很明显 'static
是作为生命周期约束来使用了。 那么问题来了, &'static
和 T: 'static
的用法到底有何区别?
&'static
&'static
对于生命周期有着非常强的要求:一个引用必须要活得跟剩下的程序一样久,才能被标注为 &'static
。
对于字符串字面量来说,它直接被打包到二进制文件中,永远不会被 drop
,因此它能跟程序活得一样久,自然它的生命周期是 'static
。
但是,&'static
生命周期针对的仅仅是引用,而不是持有该引用的变量,对于变量来说,还是要遵循相应的作用域规则 :
use std::{slice::from_raw_parts, str::from_utf8_unchecked}; fn get_memory_location() -> (usize, usize) { // “Hello World” 是字符串字面量,因此它的生命周期是 `'static`. // 但持有它的变量 `string` 的生命周期就不一样了,它完全取决于变量作用域,对于该例子来说,也就是当前的函数范围 let string = "Hello World!"; let pointer = string.as_ptr() as usize; let length = string.len(); (pointer, length) // `string` 在这里被 drop 释放 // 虽然变量被释放,无法再被访问,但是数据依然还会继续存活 } fn get_str_at_location(pointer: usize, length: usize) -> &'static str { // 使用裸指针需要 `unsafe{}` 语句块 unsafe { from_utf8_unchecked(from_raw_parts(pointer as *const u8, length)) } } fn main() { let (pointer, length) = get_memory_location(); let message = get_str_at_location(pointer, length); println!( "The {} bytes at 0x{:X} stored: {}", length, pointer, message ); // 如果大家想知道为何处理裸指针需要 `unsafe`,可以试着反注释以下代码 // let message = get_str_at_location(1000, 10); }
上面代码有两点值得注意:
&'static
的引用确实可以和程序活得一样久,因为我们通过get_str_at_location
函数直接取到了对应的字符串- 持有
&'static
引用的变量,它的生命周期受到作用域的限制,大家务必不要搞混了
T: 'static
相比起来,这种形式的约束就有些复杂了。
首先,在以下两种情况下,T: 'static
与 &'static
有相同的约束:T
必须活得和程序一样久。
use std::fmt::Debug; fn print_it<T: Debug + 'static>( input: T) { println!( "'static value passed in is: {:?}", input ); } fn print_it1( input: impl Debug + 'static ) { println!( "'static value passed in is: {:?}", input ); } fn main() { let i = 5; print_it(&i); print_it1(&i); }
以上代码会报错,原因很简单: &i
的生命周期无法满足 'static
的约束,如果大家将 i
修改为常量,那自然一切 OK。
见证奇迹的时候,请不要眨眼,现在我们来稍微修改下 print_it
函数:
use std::fmt::Debug; fn print_it<T: Debug + 'static>( input: &T) { println!( "'static value passed in is: {:?}", input ); } fn main() { let i = 5; print_it(&i); }
这段代码竟然不报错了!原因在于我们约束的是 T
,但是使用的却是它的引用 &T
,换而言之,我们根本没有直接使用 T
,因此编译器就没有去检查 T
的生命周期约束!它只要确保 &T
的生命周期符合规则即可,在上面代码中,它自然是符合的。
再来看一个例子:
use std::fmt::Display; fn main() { let r1; let r2; { static STATIC_EXAMPLE: i32 = 42; r1 = &STATIC_EXAMPLE; let x = "&'static str"; r2 = x; // r1 和 r2 持有的数据都是 'static 的,因此在花括号结束后,并不会被释放 } println!("&'static i32: {}", r1); // -> 42 println!("&'static str: {}", r2); // -> &'static str let r3: &str; { let s1 = "String".to_string(); // s1 虽然没有 'static 生命周期,但是它依然可以满足 T: 'static 的约束 // 充分说明这个约束是多么的弱。。 static_bound(&s1); // s1 是 String 类型,没有 'static 的生命周期,因此下面代码会报错 r3 = &s1; // s1 在这里被 drop } println!("{}", r3); } fn static_bound<T: Display + 'static>(t: &T) { println!("{}", t); }
static 到底针对谁?
大家有没有想过,到底是 &'static
这个引用还是该引用指向的数据活得跟程序一样久呢?
答案是引用指向的数据,而引用本身是要遵循其作用域范围的,我们来简单验证下:
fn main() { { let static_string = "I'm in read-only memory"; println!("static_string: {}", static_string); // 当 `static_string` 超出作用域时,该引用不能再被使用,但是数据依然会存在于 binary 所占用的内存中 } println!("static_string reference remains alive: {}", static_string); }
以上代码不出所料会报错,原因在于虽然字符串字面量 "I'm in read-only memory" 的生命周期是 'static
,但是持有它的引用并不是,它的作用域在内部花括号 }
处就结束了。
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。(本节暂无习题解答)
总结
总之, &'static
和 T: 'static
大体上相似,相比起来,后者的使用形式会更加复杂一些。
至此,相信大家对于 'static
和 T: 'static
也有了清晰的理解,那么我们应该如何使用它们呢?
作为经验之谈,可以这么来:
- 如果你需要添加
&'static
来让代码工作,那很可能是设计上出问题了 - 如果你希望满足和取悦编译器,那就使用
T: 'static
,很多时候它都能解决问题
一个小知识,在 Rust 标准库中,有 48 处用到了 &'static ,112 处用到了
T: 'static
,看来取悦编译器不仅仅是菜鸟需要的,高手也经常用到 :)
函数式编程
罗马不是一天建成的,编程语言亦是如此,每一门编程语言在借鉴前辈的同时,也会提出自己独有的特性,Rust 即是如此。当站在巨人肩膀上时,一个人所能看到的就更高更远,恰好,我们看到了函数式语言的优秀特性,例如:
- 使用函数作为参数进行传递
- 使用函数作为函数返回值
- 将函数赋值给变量
见猎心喜,我们忍不住就借鉴了过来,于是你能看到本章的内容,天下语言一大。。。跑题了。
关于函数式编程到底是什么的争论由来已久,本章节并不会踏足这个泥潭,因此我们在这里主要关注的是函数式特性:
- 闭包 Closure
- 迭代器 Iterator
- 模式匹配
- 枚举
其中后两个在前面章节我们已经深入学习过,因此本章的重点就是闭包和迭代器,这些函数式特性可以让代码的可读性和易写性大幅提升。对于 Rust 语言来说,掌握这两者就相当于你同时拥有了倚天剑屠龙刀,威力无穷。
闭包 Closure
闭包这个词语由来已久,自上世纪 60 年代就由 Scheme
语言引进之后,被广泛用于函数式编程语言中,进入 21 世纪后,各种现代化的编程语言也都不约而同地把闭包作为核心特性纳入到语言设计中来。那么到底何为闭包?
闭包是一种匿名函数,它可以赋值给变量也可以作为参数传递给其它函数,不同于函数的是,它允许捕获调用者作用域中的值,例如:
fn main() { let x = 1; let sum = |y| x + y; assert_eq!(3, sum(2)); }
上面的代码展示了非常简单的闭包 sum
,它拥有一个入参 y
,同时捕获了作用域中的 x
的值,因此调用 sum(2)
意味着将 2(参数 y
)跟 1(x
)进行相加,最终返回它们的和:3
。
可以看到 sum
非常符合闭包的定义:可以赋值给变量,允许捕获调用者作用域中的值。
使用闭包来简化代码
传统函数实现
想象一下,我们要进行健身,用代码怎么实现(写代码什么鬼,健身难道不应该去健身房嘛?答曰:健身太累了,还是虚拟健身好,点到为止)?这里是我的想法:
use std::thread; use std::time::Duration; // 开始健身,好累,我得发出声音:muuuu... fn muuuuu(intensity: u32) -> u32 { println!("muuuu....."); thread::sleep(Duration::from_secs(2)); intensity } fn workout(intensity: u32, random_number: u32) { if intensity < 25 { println!( "今天活力满满,先做 {} 个俯卧撑!", muuuuu(intensity) ); println!( "旁边有妹子在看,俯卧撑太low,再来 {} 组卧推!", muuuuu(intensity) ); } else if random_number == 3 { println!("昨天练过度了,今天还是休息下吧!"); } else { println!( "昨天练过度了,今天干干有氧,跑步 {} 分钟!", muuuuu(intensity) ); } } fn main() { // 强度 let intensity = 10; // 随机值用来决定某个选择 let random_number = 7; // 开始健身 workout(intensity, random_number); }
可以看到,在健身时我们根据想要的强度来调整具体的动作,然后调用 muuuuu
函数来开始健身。这个程序本身很简单,没啥好说的,但是假如未来不用 muuuuu
函数了,是不是得把所有 muuuuu
都替换成,比如说 woooo
?如果 muuuuu
出现了几十次,那意味着我们要修改几十处地方。
函数变量实现
一个可行的办法是,把函数赋值给一个变量,然后通过变量调用:
use std::thread; use std::time::Duration; // 开始健身,好累,我得发出声音:muuuu... fn muuuuu(intensity: u32) -> u32 { println!("muuuu....."); thread::sleep(Duration::from_secs(2)); intensity } fn workout(intensity: u32, random_number: u32) { let action = muuuuu; if intensity < 25 { println!( "今天活力满满, 先做 {} 个俯卧撑!", action(intensity) ); println!( "旁边有妹子在看,俯卧撑太low, 再来 {} 组卧推!", action(intensity) ); } else if random_number == 3 { println!("昨天练过度了,今天还是休息下吧!"); } else { println!( "昨天练过度了,今天干干有氧, 跑步 {} 分钟!", action(intensity) ); } } fn main() { // 强度 let intensity = 10; // 随机值用来决定某个选择 let random_number = 7; // 开始健身 workout(intensity, random_number); }
经过上面修改后,所有的调用都通过 action
来完成,若未来声(动)音(作)变了,只要修改为 let action = woooo
即可。
但是问题又来了,若 intensity
也变了怎么办?例如变成 action(intensity + 1)
,那你又得哐哐哐修改几十处调用。
该怎么办?没太好的办法了,只能祭出大杀器:闭包。
闭包实现
上面提到 intensity
要是变化怎么办,简单,使用闭包来捕获它,这是我们的拿手好戏:
use std::thread; use std::time::Duration; fn workout(intensity: u32, random_number: u32) { let action = || { println!("muuuu....."); thread::sleep(Duration::from_secs(2)); intensity }; if intensity < 25 { println!( "今天活力满满,先做 {} 个俯卧撑!", action() ); println!( "旁边有妹子在看,俯卧撑太low,再来 {} 组卧推!", action() ); } else if random_number == 3 { println!("昨天练过度了,今天还是休息下吧!"); } else { println!( "昨天练过度了,今天干干有氧,跑步 {} 分钟!", action() ); } } fn main() { // 动作次数 let intensity = 10; // 随机值用来决定某个选择 let random_number = 7; // 开始健身 workout(intensity, random_number); }
在上面代码中,无论你要修改什么,只要修改闭包 action
的实现即可,其它地方只负责调用,完美解决了我们的问题!
Rust 闭包在形式上借鉴了 Smalltalk
和 Ruby
语言,与函数最大的不同就是它的参数是通过 |parm1|
的形式进行声明,如果是多个参数就 |param1, param2,...|
, 下面给出闭包的形式定义:
#![allow(unused)] fn main() { |param1, param2,...| { 语句1; 语句2; 返回表达式 } }
如果只有一个返回表达式的话,定义可以简化为:
#![allow(unused)] fn main() { |param1| 返回表达式 }
上例中还有两点值得注意:
- 闭包中最后一行表达式返回的值,就是闭包执行后的返回值,因此
action()
调用返回了intensity
的值10
let action = ||...
只是把闭包赋值给变量action
,并不是把闭包执行后的结果赋值给action
,因此这里action
就相当于闭包函数,可以跟函数一样进行调用:action()
闭包的类型推导
Rust 是静态语言,因此所有的变量都具有类型,但是得益于编译器的强大类型推导能力,在很多时候我们并不需要显式地去声明类型,但是显然函数并不在此列,必须手动为函数的所有参数和返回值指定类型,原因在于函数往往会作为 API 提供给你的用户,因此你的用户必须在使用时知道传入参数的类型和返回值类型。
与函数相反,闭包并不会作为 API 对外提供,因此它可以享受编译器的类型推导能力,无需标注参数和返回值的类型。
为了增加代码可读性,有时候我们会显式地给类型进行标注,出于同样的目的,也可以给闭包标注类型:
#![allow(unused)] fn main() { let sum = |x: i32, y: i32| -> i32 { x + y } }
与之相比,不标注类型的闭包声明会更简洁些:let sum = |x, y| x + y
,需要注意的是,针对 sum
闭包,如果你只进行了声明,但是没有使用,编译器会提示你为 x, y
添加类型标注,因为它缺乏必要的上下文:
#![allow(unused)] fn main() { let sum = |x, y| x + y; let v = sum(1, 2); }
这里我们使用了 sum
,同时把 1
传给了 x
,2
传给了 y
,因此编译器才可以推导出 x,y
的类型为 i32
。
下面展示了同一个功能的函数和闭包实现形式:
#![allow(unused)] fn main() { fn add_one_v1 (x: u32) -> u32 { x + 1 } let add_one_v2 = |x: u32| -> u32 { x + 1 }; let add_one_v3 = |x| { x + 1 }; let add_one_v4 = |x| x + 1 ; }
可以看出第一行的函数和后面的闭包其实在形式上是非常接近的,同时三种不同的闭包也展示了三种不同的使用方式:省略参数、返回值类型和花括号对。
虽然类型推导很好用,但是它不是泛型,当编译器推导出一种类型后,它就会一直使用该类型:
#![allow(unused)] fn main() { let example_closure = |x| x; let s = example_closure(String::from("hello")); let n = example_closure(5); }
首先,在 s
中,编译器为 x
推导出类型 String
,但是紧接着 n
试图用 5
这个整型去调用闭包,跟编译器之前推导的 String
类型不符,因此报错:
error[E0308]: mismatched types
--> src/main.rs:5:29
|
5 | let n = example_closure(5);
| ^
| |
| expected struct `String`, found integer // 期待String类型,却发现一个整数
| help: try using a conversion method: `5.to_string()`
结构体中的闭包
假设我们要实现一个简易缓存,功能是获取一个值,然后将其缓存起来,那么可以这样设计:
- 一个闭包用于获取值
- 一个变量,用于存储该值
可以使用结构体来代表缓存对象,最终设计如下:
#![allow(unused)] fn main() { struct Cacher<T> where T: Fn(u32) -> u32, { query: T, value: Option<u32>, } }
等等,我都跟着这本教程学完 Rust 基础了,为何还有我不认识的东东?Fn(u32) -> u32
是什么鬼?别急,先回答你第一个问题:骚年,too young too naive,你以为 Rust 的语法特性就基础入门那一些吗?太年轻了!如果是长征,你才刚到赤水河。
其实,可以看得出这一长串是 T
的特征约束,再结合之前的已知信息:query
是一个闭包,大概可以推测出,Fn(u32) -> u32
是一个特征,用来表示 T
是一个闭包类型?Bingo,恭喜你,答对了!
那为什么不用具体的类型来标注 query
呢?原因很简单,每一个闭包实例都有独属于自己的类型,即使于两个签名一模一样的闭包,它们的类型也是不同的,因此你无法用一个统一的类型来标注 query
闭包。
而标准库提供的 Fn
系列特征,再结合特征约束,就能很好的解决了这个问题. T: Fn(u32) -> u32
意味着 query
的类型是 T
,该类型必须实现了相应的闭包特征 Fn(u32) -> u32
。从特征的角度来看它长得非常反直觉,但是如果从闭包的角度来看又极其符合直觉,不得不佩服 Rust 团队的鬼才设计。。。
特征 Fn(u32) -> u32
从表面来看,就对闭包形式进行了显而易见的限制:该闭包拥有一个u32
类型的参数,同时返回一个u32
类型的值。
需要注意的是,其实 Fn 特征不仅仅适用于闭包,还适用于函数,因此上面的
query
字段除了使用闭包作为值外,还能使用一个具名的函数来作为它的值
接着,为缓存实现方法:
#![allow(unused)] fn main() { impl<T> Cacher<T> where T: Fn(u32) -> u32, { fn new(query: T) -> Cacher<T> { Cacher { query, value: None, } } // 先查询缓存值 `self.value`,若不存在,则调用 `query` 加载 fn value(&mut self, arg: u32) -> u32 { match self.value { Some(v) => v, None => { let v = (self.query)(arg); self.value = Some(v); v } } } } }
上面的缓存有一个很大的问题:只支持 u32
类型的值,若我们想要缓存 &str
类型,显然就行不通了,因此需要将 u32
替换成泛型 E
,该练习就留给读者自己完成,具体代码可以参考这里
捕获作用域中的值
在之前代码中,我们一直在用闭包的匿名函数特性(赋值给变量),然而闭包还拥有一项函数所不具备的特性:捕获作用域中的值。
fn main() { let x = 4; let equal_to_x = |z| z == x; let y = 4; assert!(equal_to_x(y)); }
上面代码中,x
并不是闭包 equal_to_x
的参数,但是它依然可以去使用 x
,因为 equal_to_x
在 x
的作用域范围内。
对于函数来说,就算你把函数定义在 main
函数体中,它也不能访问 x
:
fn main() { let x = 4; fn equal_to_x(z: i32) -> bool { z == x } let y = 4; assert!(equal_to_x(y)); }
报错如下:
error[E0434]: can't capture dynamic environment in a fn item // 在函数中无法捕获动态的环境
--> src/main.rs:5:14
|
5 | z == x
| ^
|
= help: use the `|| { ... }` closure form instead // 使用闭包替代
如上所示,编译器准确地告诉了我们错误,甚至同时给出了提示:使用闭包来替代函数,这种聪明令我有些无所适从,总感觉会显得我很笨。
闭包对内存的影响
当闭包从环境中捕获一个值时,会分配内存去存储这些值。对于有些场景来说,这种额外的内存分配会成为一种负担。与之相比,函数就不会去捕获这些环境值,因此定义和使用函数不会拥有这种内存负担。
三种 Fn 特征
闭包捕获变量有三种途径,恰好对应函数参数的三种传入方式:转移所有权、可变借用、不可变借用,因此相应的 Fn
特征也有三种:
FnOnce
,该类型的闭包会拿走被捕获变量的所有权。Once
顾名思义,说明该闭包只能运行一次:
fn fn_once<F>(func: F) where F: FnOnce(usize) -> bool, { println!("{}", func(3)); println!("{}", func(4)); } fn main() { let x = vec![1, 2, 3]; fn_once(|z|{z == x.len()}) }
仅实现 FnOnce
特征的闭包在调用时会转移所有权,所以显然不能对已失去所有权的闭包变量进行二次调用:
error[E0382]: use of moved value: `func`
--> src\main.rs:6:20
|
1 | fn fn_once<F>(func: F)
| ---- move occurs because `func` has type `F`, which does not implement the `Copy` trait
// 因为`func`的类型是没有实现`Copy`特性的 `F`,所以发生了所有权的转移
...
5 | println!("{}", func(3));
| ------- `func` moved due to this call // 转移在这
6 | println!("{}", func(4));
| ^^^^ value used here after move // 转移后再次用
|
这里面有一个很重要的提示,因为 F
没有实现 Copy
特征,所以会报错,那么我们添加一个约束,试试实现了 Copy
的闭包:
fn fn_once<F>(func: F) where F: FnOnce(usize) -> bool + Copy,// 改动在这里 { println!("{}", func(3)); println!("{}", func(4)); } fn main() { let x = vec![1, 2, 3]; fn_once(|z|{z == x.len()}) }
上面代码中,func
的类型 F
实现了 Copy
特征,调用时使用的将是它的拷贝,所以并没有发生所有权的转移。
true
false
如果你想强制闭包取得捕获变量的所有权,可以在参数列表前添加 move
关键字,这种用法通常用于闭包的生命周期大于捕获变量的生命周期时,例如将闭包返回或移入其他线程。
#![allow(unused)] fn main() { use std::thread; let v = vec![1, 2, 3]; let handle = thread::spawn(move || { println!("Here's a vector: {:?}", v); }); handle.join().unwrap(); }
FnMut
,它以可变借用的方式捕获了环境中的值,因此可以修改该值:
fn main() { let mut s = String::new(); let update_string = |str| s.push_str(str); update_string("hello"); println!("{:?}",s); }
在闭包中,我们调用 s.push_str
去改变外部 s
的字符串值,因此这里捕获了它的可变借用,运行下试试:
error[E0596]: cannot borrow `update_string` as mutable, as it is not declared as mutable
--> src/main.rs:5:5
|
4 | let update_string = |str| s.push_str(str);
| ------------- - calling `update_string` requires mutable binding due to mutable borrow of `s`
| |
| help: consider changing this to be mutable: `mut update_string`
5 | update_string("hello");
| ^^^^^^^^^^^^^ cannot borrow as mutable
虽然报错了,但是编译器给出了非常清晰的提示,想要在闭包内部捕获可变借用,需要把该闭包声明为可变类型,也就是 update_string
要修改为 mut update_string
:
fn main() { let mut s = String::new(); let mut update_string = |str| s.push_str(str); update_string("hello"); println!("{:?}",s); }
这种写法有点反直觉,相比起来前面的 move
更符合使用和阅读习惯。但是如果你忽略 update_string
的类型,仅仅把它当成一个普通变量,那么这种声明就比较合理了。
再来看一个复杂点的:
fn main() { let mut s = String::new(); let update_string = |str| s.push_str(str); exec(update_string); println!("{:?}",s); } fn exec<'a, F: FnMut(&'a str)>(mut f: F) { f("hello") }
这段代码中update_string
没有使用mut关键字修饰,而上文提到想要在闭包内部捕获可变借用,需要用关键词把该闭包声明为可变类型。我们确实这么做了———exec(mut f: F)
表明我们的exec
接收的是一个可变类型的闭包。这段代码中update_string
看似被声明为不可变闭包,但是exec(mut f: F)
函数接收的又是可变参数,为什么可以正常执行呢?
rust不可能接受类型不匹配的形参和实参通过编译,我们提供的实参又是可变的,这说明update_string
一定是一个可变类型的闭包,我们不妨看看rust-analyzer自动给出的类型标注:
#![allow(unused)] fn main() { let mut s: String = String::new(); let update_string: impl FnMut(&str) = |str| s.push_str(str); }
rust-analyzer给出的类型标注非常清晰的说明了 update_string
实现了 FnMut
特征。
为什么update_string
没有用mut
修饰却是一个可变类型的闭包?事实上,FnMut
只是trait的名字,声明变量为FnMut
和要不要mut没啥关系,FnMut
是推导出的特征类型,mut
是rust语言层面的一个修饰符,用于声明一个绑定是可变的。Rust从特征类型系统和语言修饰符两方面保障了我们的程序正确运行。
我们在使用FnMut
类型闭包时需要捕获外界的可变借用,因此我们常常搭配mut
修饰符使用。但我们要始终记住,二者是相互独立的。
因此,让我们再回头分析一下这段代码:在main
函数中,首先创建了一个可变的字符串s
,然后定义了一个可变类型闭包update_string
,该闭包接受一个字符串参数并将其追加到s
中。接下来调用了exec
函数,并将update_string
闭包的所有权移交给它。最后打印出了字符串s
的内容。
细心的读者可能注意到,我们在上文的分析中提到update_string
闭包的所有权被移交给了exec
函数。这说明update_string
没有实现Copy
特征,但并不是所有闭包都没有实现Copy
特征,闭包自动实现Copy
特征的规则是,只要闭包捕获的类型都实现了Copy
特征的话,这个闭包就会默认实现Copy
特征。
我们来看一个例子:
#![allow(unused)] fn main() { let s = String::new(); let update_string = || println!("{}",s); }
这里取得的是s
的不可变引用,所以是能Copy
的。而如果拿到的是s
的所有权或可变引用,都是不能Copy
的。我们刚刚的代码就属于第二类,取得的是s
的可变引用,没有实现Copy
。
#![allow(unused)] fn main() { // 拿所有权 let s = String::new(); let update_string = move || println!("{}", s); exec(update_string); // exec2(update_string); // 不能再用了 // 可变引用 let mut s = String::new(); let mut update_string = || s.push_str("hello"); exec(update_string); // exec1(update_string); // 不能再用了 }
Fn
特征,它以不可变借用的方式捕获环境中的值 让我们把上面的代码中exec
的F
泛型参数类型修改为Fn(&'a str)
:
fn main() { let mut s = String::new(); let update_string = |str| s.push_str(str); exec(update_string); println!("{:?}",s); } fn exec<'a, F: Fn(&'a str)>(mut f: F) { f("hello") }
然后运行看看结果:
error[E0525]: expected a closure that implements the `Fn` trait, but this closure only implements `FnMut`
--> src/main.rs:4:26 // 期望闭包实现的是`Fn`特征,但是它只实现了`FnMut`特征
|
4 | let update_string = |str| s.push_str(str);
| ^^^^^^-^^^^^^^^^^^^^^
| | |
| | closure is `FnMut` because it mutates the variable `s` here
| this closure implements `FnMut`, not `Fn` //闭包实现的是FnMut,而不是Fn
5 |
6 | exec(update_string);
| ---- the requirement to implement `Fn` derives from here
从报错中很清晰的看出,我们的闭包实现的是 FnMut
特征,需要的是可变借用,但是在 exec
中却给它标注了 Fn
特征,因此产生了不匹配,再来看看正确的不可变借用方式:
fn main() { let s = "hello, ".to_string(); let update_string = |str| println!("{},{}",s,str); exec(update_string); println!("{:?}",s); } fn exec<'a, F: Fn(String) -> ()>(f: F) { f("world".to_string()) }
在这里,因为无需改变 s
,因此闭包中只对 s
进行了不可变借用,那么在 exec
中,将其标记为 Fn
特征就完全正确。
move 和 Fn
在上面,我们讲到了 move
关键字对于 FnOnce
特征的重要性,但是实际上使用了 move
的闭包依然可能实现了 Fn
或 FnMut
特征。
因为,一个闭包实现了哪种 Fn 特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们。move
本身强调的就是后者,闭包如何捕获变量:
fn main() { let s = String::new(); let update_string = move || println!("{}",s); exec(update_string); } fn exec<F: FnOnce()>(f: F) { f() }
我们在上面的闭包中使用了 move
关键字,所以我们的闭包捕获了它,但是由于闭包对 s
的使用仅仅是不可变借用,因此该闭包实际上还实现了 Fn
特征。
细心的读者肯定发现我在上段中使用了一个 还
字,这是什么意思呢?因为该闭包不仅仅实现了 FnOnce
特征,还实现了 Fn
特征,将代码修改成下面这样,依然可以编译:
fn main() { let s = String::new(); let update_string = move || println!("{}",s); exec(update_string); } fn exec<F: Fn()>(f: F) { f() }
三种 Fn 的关系
实际上,一个闭包并不仅仅实现某一种 Fn
特征,规则如下:
- 所有的闭包都自动实现了
FnOnce
特征,因此任何一个闭包都至少可以被调用一次 - 没有移出所捕获变量的所有权的闭包自动实现了
FnMut
特征 - 不需要对捕获变量进行改变的闭包自动实现了
Fn
特征
用一段代码来简单诠释上述规则:
fn main() { let s = String::new(); let update_string = || println!("{}",s); exec(update_string); exec1(update_string); exec2(update_string); } fn exec<F: FnOnce()>(f: F) { f() } fn exec1<F: FnMut()>(mut f: F) { f() } fn exec2<F: Fn()>(f: F) { f() }
虽然,闭包只是对 s
进行了不可变借用,实际上,它可以适用于任何一种 Fn
特征:三个 exec
函数说明了一切。强烈建议读者亲自动手试试各种情况下使用的 Fn
特征,更有助于加深这方面的理解。
关于第二条规则,有如下示例:
fn main() { let mut s = String::new(); let update_string = |str| -> String {s.push_str(str); s }; exec(update_string); } fn exec<'a, F: FnMut(&'a str) -> String>(mut f: F) { f("hello"); }
5 | let update_string = |str| -> String {s.push_str(str); s };
| ^^^^^^^^^^^^^^^ - closure is `FnOnce` because it moves the variable `s` out of its environment
| // 闭包实现了`FnOnce`,因为它从捕获环境中移出了变量`s`
| |
| this closure implements `FnOnce`, not `FnMut`
此例中,闭包从捕获环境中移出了变量 s
的所有权,因此这个闭包仅自动实现了 FnOnce
,未实现 FnMut
和 Fn
。再次印证之前讲的一个闭包实现了哪种 Fn 特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们,跟是否使用 move
没有必然联系。
如果还是有疑惑?没关系,我们来看看这三个特征的简化版源码:
#![allow(unused)] fn main() { pub trait Fn<Args> : FnMut<Args> { extern "rust-call" fn call(&self, args: Args) -> Self::Output; } pub trait FnMut<Args> : FnOnce<Args> { extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output; } pub trait FnOnce<Args> { type Output; extern "rust-call" fn call_once(self, args: Args) -> Self::Output; } }
看到没?从特征约束能看出来 Fn
的前提是实现 FnMut
,FnMut
的前提是实现 FnOnce
,因此要实现 Fn
就要同时实现 FnMut
和 FnOnce
,这段源码从侧面印证了之前规则的正确性。
从源码中还能看出一点:Fn
获取 &self
,FnMut
获取 &mut self
,而 FnOnce
获取 self
。
在实际项目中,建议先使用 Fn
特征,然后编译器会告诉你正误以及该如何选择。
闭包作为函数返回值
看到这里,相信大家对于如何使用闭包作为函数参数,已经很熟悉了,但是如果要使用闭包作为函数返回值,该如何做?
先来看一段代码:
#![allow(unused)] fn main() { fn factory() -> Fn(i32) -> i32 { let num = 5; |x| x + num } let f = factory(); let answer = f(1); assert_eq!(6, answer); }
上面这段代码看起来还是蛮正常的,用 Fn(i32) -> i32
特征来代表 |x| x + num
,非常合理嘛,肯定可以编译通过, 可惜理想总是难以照进现实,编译器给我们报了一大堆错误,先挑几个重点来看看:
fn factory<T>() -> Fn(i32) -> i32 {
| ^^^^^^^^^^^^^^ doesn't have a size known at compile-time // 该类型在编译器没有固定的大小
Rust 要求函数的参数和返回类型,必须有固定的内存大小,例如 i32
就是 4 个字节,引用类型是 8 个字节,总之,绝大部分类型都有固定的大小,但是不包括特征,因为特征类似接口,对于编译器来说,无法知道它后面藏的真实类型是什么,因为也无法得知具体的大小。
同样,我们也无法知道闭包的具体类型,该怎么办呢?再看看报错提示:
help: use `impl Fn(i32) -> i32` as the return type, as all return paths are of type `[closure@src/main.rs:11:5: 11:21]`, which implements `Fn(i32) -> i32`
|
8 | fn factory<T>() -> impl Fn(i32) -> i32 {
嗯,编译器提示我们加一个 impl
关键字,哦,这样一说,读者可能就想起来了,impl Trait
可以用来返回一个实现了指定特征的类型,那么这里 impl Fn(i32) -> i32
的返回值形式,说明我们要返回一个闭包类型,它实现了 Fn(i32) -> i32
特征。
完美解决,但是,在特征那一章,我们提到过,impl Trait
的返回方式有一个非常大的局限,就是你只能返回同样的类型,例如:
#![allow(unused)] fn main() { fn factory(x:i32) -> impl Fn(i32) -> i32 { let num = 5; if x > 1{ move |x| x + num } else { move |x| x - num } } }
运行后,编译器报错:
error[E0308]: `if` and `else` have incompatible types
--> src/main.rs:15:9
|
12 | / if x > 1{
13 | | move |x| x + num
| | ---------------- expected because of this
14 | | } else {
15 | | move |x| x - num
| | ^^^^^^^^^^^^^^^^ expected closure, found a different closure
16 | | }
| |_____- `if` and `else` have incompatible types
|
嗯,提示很清晰:if
和 else
分支中返回了不同的闭包类型,这就很奇怪了,明明这两个闭包长的一样的,好在细心的读者应该回想起来,本章节前面咱们有提到:就算签名一样的闭包,类型也是不同的,因此在这种情况下,就无法再使用 impl Trait
的方式去返回闭包。
怎么办?再看看编译器提示,里面有这样一行小字:
= help: consider boxing your closure and/or using it as a trait object
哦,相信你已经恍然大悟,可以用特征对象!只需要用 Box
的方式即可实现:
#![allow(unused)] fn main() { fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> { let num = 5; if x > 1{ Box::new(move |x| x + num) } else { Box::new(move |x| x - num) } } }
至此,闭包作为函数返回值就已完美解决,若以后你再遇到报错时,一定要仔细阅读编译器的提示,很多时候,转角都能遇到爱。
闭包的生命周期
这块儿内容在进阶生命周期章节中有讲,这里就不再赘述,读者可移步此处进行回顾。
课后习题
迭代器 Iterator
如果你询问一个 Rust 资深开发:写 Rust 项目最需要掌握什么?相信迭代器往往就是答案之一。无论你是编程新手亦或是高手,实际上大概率都用过迭代器,虽然自己可能并没有意识到这一点:)
迭代器允许我们迭代一个连续的集合,例如数组、动态数组 Vec
、HashMap
等,在此过程中,只需关心集合中的元素如何处理,而无需关心如何开始、如何结束、按照什么样的索引去访问等问题。
For 循环与迭代器
从用途来看,迭代器跟 for
循环颇为相似,都是去遍历一个集合,但是实际上它们存在不小的差别,其中最主要的差别就是:是否通过索引来访问集合。
例如以下的 JS 代码就是一个循环:
let arr = [1, 2, 3];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
在上面代码中,我们设置索引的开始点和结束点,然后再通过索引去访问元素 arr[i]
,这就是典型的循环,来对比下 Rust 中的 for
:
#![allow(unused)] fn main() { let arr = [1, 2, 3]; for v in arr { println!("{}",v); } }
首先,不得不说这两语法还挺像!与 JS 循环不同,Rust
中没有使用索引,它把 arr
数组当成一个迭代器,直接去遍历其中的元素,从哪里开始,从哪里结束,都无需操心。因此严格来说,Rust 中的 for
循环是编译器提供的语法糖,最终还是对迭代器中的元素进行遍历。
那又有同学要发问了,在 Rust 中数组是迭代器吗?因为在之前的代码中直接对数组 arr
进行了迭代,答案是 No
。那既然数组不是迭代器,为啥咱可以对它的元素进行迭代呢?
简而言之就是数组实现了 IntoIterator
特征,Rust 通过 for
语法糖,自动把实现了该特征的数组类型转换为迭代器(你也可以为自己的集合类型实现此特征),最终让我们可以直接对一个数组进行迭代,类似的还有:
#![allow(unused)] fn main() { for i in 1..10 { println!("{}", i); } }
直接对数值序列进行迭代,也是很常见的使用方式。
IntoIterator
特征拥有一个 into_iter
方法,因此我们还可以显式的把数组转换成迭代器:
#![allow(unused)] fn main() { let arr = [1, 2, 3]; for v in arr.into_iter() { println!("{}", v); } }
迭代器是函数语言的核心特性,它赋予了 Rust 远超于循环的强大表达能力,我们将在本章中一一为大家进行展现。
惰性初始化
在 Rust 中,迭代器是惰性的,意味着如果你不使用它,那么它将不会发生任何事:
#![allow(unused)] fn main() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); for val in v1_iter { println!("{}", val); } }
在 for
循环之前,我们只是简单的创建了一个迭代器 v1_iter
,此时不会发生任何迭代行为,只有在 for
循环开始后,迭代器才会开始迭代其中的元素,最后打印出来。
这种惰性初始化的方式确保了创建迭代器不会有任何额外的性能损耗,其中的元素也不会被消耗,只有使用到该迭代器的时候,一切才开始。
next 方法
对于 for
如何遍历迭代器,还有一个问题,它如何取出迭代器中的元素?
先来看一个特征:
#![allow(unused)] fn main() { pub trait Iterator { type Item; fn next(&mut self) -> Option<Self::Item>; // 省略其余有默认实现的方法 } }
呦,该特征竟然和迭代器 iterator
同名,难不成。。。没错,它们就是有一腿。迭代器之所以成为迭代器,就是因为实现了 Iterator
特征,要实现该特征,最主要的就是实现其中的 next
方法,该方法控制如何从集合中取值,最终返回值的类型是关联类型 Item
。
因此,之前问题的答案已经很明显:for
循环通过不停调用迭代器上的 next
方法,来获取迭代器中的元素。
既然 for
可以调用 next
方法,是不是意味着我们也可以?来试试:
fn main() { let arr = [1, 2, 3]; let mut arr_iter = arr.into_iter(); assert_eq!(arr_iter.next(), Some(1)); assert_eq!(arr_iter.next(), Some(2)); assert_eq!(arr_iter.next(), Some(3)); assert_eq!(arr_iter.next(), None); }
果不其然,将 arr
转换成迭代器后,通过调用其上的 next
方法,我们获取了 arr
中的元素,有两点需要注意:
next
方法返回的是Option
类型,当有值时返回Some(i32)
,无值时返回None
- 遍历是按照迭代器中元素的排列顺序依次进行的,因此我们严格按照数组中元素的顺序取出了
Some(1)
,Some(2)
,Some(3)
- 手动迭代必须将迭代器声明为
mut
可变,因为调用next
会改变迭代器其中的状态数据(当前遍历的位置等),而for
循环去迭代则无需标注mut
,因为它会帮我们自动完成
总之,next
方法对迭代器的遍历是消耗性的,每次消耗它一个元素,最终迭代器中将没有任何元素,只能返回 None
。
例子:模拟实现 for 循环
因为 for
循环是迭代器的语法糖,因此我们完全可以通过迭代器来模拟实现它:
#![allow(unused)] fn main() { let values = vec![1, 2, 3]; { let result = match IntoIterator::into_iter(values) { mut iter => loop { match iter.next() { Some(x) => { println!("{}", x); }, None => break, } }, }; result } }
IntoIterator::into_iter
是使用完全限定的方式去调用 into_iter
方法,这种调用方式跟 values.into_iter()
是等价的。
同时我们使用了 loop
循环配合 next
方法来遍历迭代器中的元素,当迭代器返回 None
时,跳出循环。
IntoIterator 特征
其实有一个细节,由于 Vec
动态数组实现了 IntoIterator
特征,因此可以通过 into_iter
将其转换为迭代器,那如果本身就是一个迭代器,该怎么办?实际上,迭代器自身也实现了 IntoIterator
,标准库早就帮我们考虑好了:
#![allow(unused)] fn main() { impl<I: Iterator> IntoIterator for I { type Item = I::Item; type IntoIter = I; #[inline] fn into_iter(self) -> I { self } } }
最终你完全可以写出这样的奇怪代码:
fn main() { let values = vec![1, 2, 3]; for v in values.into_iter().into_iter().into_iter() { println!("{}",v) } }
into_iter, iter, iter_mut
在之前的代码中,我们统一使用了 into_iter
的方式将数组转化为迭代器,除此之外,还有 iter
和 iter_mut
,聪明的读者应该大概能猜到这三者的区别:
into_iter
会夺走所有权iter
是借用iter_mut
是可变借用
其实如果以后见多识广了,你会发现这种问题一眼就能看穿,into_
之类的,都是拿走所有权,_mut
之类的都是可变借用,剩下的就是不可变借用。
使用一段代码来解释下:
fn main() { let values = vec![1, 2, 3]; for v in values.into_iter() { println!("{}", v) } // 下面的代码将报错,因为 values 的所有权在上面 `for` 循环中已经被转移走 // println!("{:?}",values); let values = vec![1, 2, 3]; let _values_iter = values.iter(); // 不会报错,因为 values_iter 只是借用了 values 中的元素 println!("{:?}", values); let mut values = vec![1, 2, 3]; // 对 values 中的元素进行可变借用 let mut values_iter_mut = values.iter_mut(); // 取出第一个元素,并修改为0 if let Some(v) = values_iter_mut.next() { *v = 0; } // 输出[0, 2, 3] println!("{:?}", values); }
具体解释在代码注释中,就不再赘述,不过有两点需要注意的是:
.iter()
方法实现的迭代器,调用next
方法返回的类型是Some(&T)
.iter_mut()
方法实现的迭代器,调用next
方法返回的类型是Some(&mut T)
,因此在if let Some(v) = values_iter_mut.next()
中,v
的类型是&mut i32
,最终我们可以通过*v = 0
的方式修改其值
Iterator 和 IntoIterator 的区别
这两个其实还蛮容易搞混的,但我们只需要记住,Iterator
就是迭代器特征,只有实现了它才能称为迭代器,才能调用 next
。
而 IntoIterator
强调的是某一个类型如果实现了该特征,它可以通过 into_iter
,iter
等方法变成一个迭代器。
消费者与适配器
消费者是迭代器上的方法,它会消费掉迭代器中的元素,然后返回其类型的值,这些消费者都有一个共同的特点:在它们的定义中,都依赖 next
方法来消费元素,因此这也是为什么迭代器要实现 Iterator
特征,而该特征必须要实现 next
方法的原因。
消费者适配器
只要迭代器上的某个方法 A
在其内部调用了 next
方法,那么 A
就被称为消费性适配器:因为 next
方法会消耗掉迭代器上的元素,所以方法 A
的调用也会消耗掉迭代器上的元素。
其中一个例子是 sum
方法,它会拿走迭代器的所有权,然后通过不断调用 next
方法对里面的元素进行求和:
fn main() { let v1 = vec![1, 2, 3]; let v1_iter = v1.iter(); let total: i32 = v1_iter.sum(); assert_eq!(total, 6); // v1_iter 是借用了 v1,因此 v1 可以照常使用 println!("{:?}",v1); // 以下代码会报错,因为 `sum` 拿到了迭代器 `v1_iter` 的所有权 // println!("{:?}",v1_iter); }
如代码注释中所说明的:在使用 sum
方法后,我们将无法再使用 v1_iter
,因为 sum
拿走了该迭代器的所有权:
#![allow(unused)] fn main() { fn sum<S>(self) -> S where Self: Sized, S: Sum<Self::Item>, { Sum::sum(self) } }
从 sum
源码中也可以清晰看出,self
类型的方法参数拿走了所有权。
迭代器适配器
既然消费者适配器是消费掉迭代器,然后返回一个值。那么迭代器适配器,顾名思义,会返回一个新的迭代器,这是实现链式方法调用的关键:v.iter().map().filter()...
。
与消费者适配器不同,迭代器适配器是惰性的,意味着你需要一个消费者适配器来收尾,最终将迭代器转换成一个具体的值:
#![allow(unused)] fn main() { let v1: Vec<i32> = vec![1, 2, 3]; v1.iter().map(|x| x + 1); }
运行后输出:
warning: unused `Map` that must be used
--> src/main.rs:4:5
|
4 | v1.iter().map(|x| x + 1);
| ^^^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_must_use)]` on by default
= note: iterators are lazy and do nothing unless consumed // 迭代器 map 是惰性的,这里不产生任何效果
如上述中文注释所说,这里的 map
方法是一个迭代者适配器,它是惰性的,不产生任何行为,因此我们还需要一个消费者适配器进行收尾:
#![allow(unused)] fn main() { let v1: Vec<i32> = vec![1, 2, 3]; let v2: Vec<_> = v1.iter().map(|x| x + 1).collect(); assert_eq!(v2, vec![2, 3, 4]); }
collect
上面代码中,使用了 collect
方法,该方法就是一个消费者适配器,使用它可以将一个迭代器中的元素收集到指定类型中,这里我们为 v2
标注了 Vec<_>
类型,就是为了告诉 collect
:请把迭代器中的元素消费掉,然后把值收集成 Vec<_>
类型,至于为何使用 _
,因为编译器会帮我们自动推导。
为何 collect
在消费时要指定类型?是因为该方法其实很强大,可以收集成多种不同的集合类型,Vec<T>
仅仅是其中之一,因此我们必须显式的告诉编译器我们想要收集成的集合类型。
还有一点值得注意,map
会对迭代器中的每一个值进行一系列操作,然后把该值转换成另外一个新值,该操作是通过闭包 |x| x + 1
来完成:最终迭代器中的每个值都增加了 1
,从 [1, 2, 3]
变为 [2, 3, 4]
。
再来看看如何使用 collect
收集成 HashMap
集合:
use std::collections::HashMap; fn main() { let names = ["sunface", "sunfei"]; let ages = [18, 18]; let folks: HashMap<_, _> = names.into_iter().zip(ages.into_iter()).collect(); println!("{:?}",folks); }
zip
是一个迭代器适配器,它的作用就是将两个迭代器的内容压缩到一起,形成 Iterator<Item=(ValueFromA, ValueFromB)>
这样的新的迭代器,在此处就是形如 [(name1, age1), (name2, age2)]
的迭代器。
然后再通过 collect
将新迭代器中(K, V)
形式的值收集成 HashMap<K, V>
,同样的,这里必须显式声明类型,然后 HashMap
内部的 KV
类型可以交给编译器去推导,最终编译器会推导出 HashMap<&str, i32>
,完全正确!
闭包作为适配器参数
之前的 map
方法中,我们使用闭包来作为迭代器适配器的参数,它最大的好处不仅在于可以就地实现迭代器中元素的处理,还在于可以捕获环境值:
#![allow(unused)] fn main() { struct Shoe { size: u32, style: String, } fn shoes_in_size(shoes: Vec<Shoe>, shoe_size: u32) -> Vec<Shoe> { shoes.into_iter().filter(|s| s.size == shoe_size).collect() } }
filter
是迭代器适配器,用于对迭代器中的每个值进行过滤。 它使用闭包作为参数,该闭包的参数 s
是来自迭代器中的值,然后使用 s
跟外部环境中的 shoe_size
进行比较,若相等,则在迭代器中保留 s
值,若不相等,则从迭代器中剔除 s
值,最终通过 collect
收集为 Vec<Shoe>
类型。
实现 Iterator 特征
之前的内容我们一直基于数组来创建迭代器,实际上,不仅仅是数组,基于其它集合类型一样可以创建迭代器,例如 HashMap
。 你也可以创建自己的迭代器 —— 只要为自定义类型实现 Iterator
特征即可。
首先,创建一个计数器:
#![allow(unused)] fn main() { struct Counter { count: u32, } impl Counter { fn new() -> Counter { Counter { count: 0 } } } }
我们为计数器 Counter
实现了一个关联函数 new
,用于创建新的计数器实例。下面我们继续为计数器实现 Iterator
特征:
#![allow(unused)] fn main() { impl Iterator for Counter { type Item = u32; fn next(&mut self) -> Option<Self::Item> { if self.count < 5 { self.count += 1; Some(self.count) } else { None } } } }
首先,将该特征的关联类型设置为 u32
,由于我们的计数器保存的 count
字段就是 u32
类型, 因此在 next
方法中,最后返回的是实际上是 Option<u32>
类型。
每次调用 next
方法,都会让计数器的值加一,然后返回最新的计数值,一旦计数大于 5,就返回 None
。
最后,使用我们新建的 Counter
进行迭代:
#![allow(unused)] fn main() { let mut counter = Counter::new(); assert_eq!(counter.next(), Some(1)); assert_eq!(counter.next(), Some(2)); assert_eq!(counter.next(), Some(3)); assert_eq!(counter.next(), Some(4)); assert_eq!(counter.next(), Some(5)); assert_eq!(counter.next(), None); }
实现 Iterator 特征的其它方法
可以看出,实现自己的迭代器非常简单,但是 Iterator
特征中,不仅仅是只有 next
一个方法,那为什么我们只需要实现它呢?因为其它方法都具有默认实现,所以无需像 next
这样手动去实现,而且这些默认实现的方法其实都是基于 next
方法实现的。
下面的代码演示了部分方法的使用:
#![allow(unused)] fn main() { let sum: u32 = Counter::new() .zip(Counter::new().skip(1)) .map(|(a, b)| a * b) .filter(|x| x % 3 == 0) .sum(); assert_eq!(18, sum); }
其中 zip
,map
,filter
是迭代器适配器:
zip
把两个迭代器合并成一个迭代器,新迭代器中,每个元素都是一个元组,由之前两个迭代器的元素组成。例如将形如[1, 2, 3, 4, 5]
和[2, 3, 4, 5]
的迭代器合并后,新的迭代器形如[(1, 2),(2, 3),(3, 4),(4, 5)]
map
是将迭代器中的值经过映射后,转换成新的值[2, 6, 12, 20]filter
对迭代器中的元素进行过滤,若闭包返回true
则保留元素[6, 12],反之剔除
而 sum
是消费者适配器,对迭代器中的所有元素求和,最终返回一个 u32
值 18
。
enumerate
在之前的流程控制章节,针对 for
循环,我们提供了一种方法可以获取迭代时的索引:
#![allow(unused)] fn main() { let v = vec![1u64, 2, 3, 4, 5, 6]; for (i,v) in v.iter().enumerate() { println!("第{}个值是{}",i,v) } }
相信当时,很多读者还是很迷茫的,不知道为什么要这么复杂才能获取到索引,学习本章节后,相信你有了全新的理解,首先 v.iter()
创建迭代器,其次
调用 Iterator
特征上的方法 enumerate
,该方法产生一个新的迭代器,其中每个元素均是元组 (索引,值)
。
因为 enumerate
是迭代器适配器,因此我们可以对它返回的迭代器调用其它 Iterator
特征方法:
#![allow(unused)] fn main() { let v = vec![1u64, 2, 3, 4, 5, 6]; let val = v.iter() .enumerate() // 每两个元素剔除一个 // [1, 3, 5] .filter(|&(idx, _)| idx % 2 == 0) .map(|(_, val)| val) // 累加 1+3+5 = 9 .fold(0u64, |sum, acm| sum + acm); println!("{}", val); }
迭代器的性能
前面提到,要完成集合遍历,既可以使用 for
循环也可以使用迭代器,那么二者之间该怎么选择呢,性能有多大差距呢?
理论分析不会有结果,直接测试最为靠谱:
#![allow(unused)] #![feature(test)] fn main() { extern crate rand; extern crate test; fn sum_for(x: &[f64]) -> f64 { let mut result: f64 = 0.0; for i in 0..x.len() { result += x[i]; } result } fn sum_iter(x: &[f64]) -> f64 { x.iter().sum::<f64>() } #[cfg(test)] mod bench { use test::Bencher; use rand::{Rng,thread_rng}; use super::*; const LEN: usize = 1024*1024; fn rand_array(cnt: u32) -> Vec<f64> { let mut rng = thread_rng(); (0..cnt).map(|_| rng.gen::<f64>()).collect() } #[bench] fn bench_for(b: &mut Bencher) { let samples = rand_array(LEN as u32); b.iter(|| { sum_for(&samples) }) } #[bench] fn bench_iter(b: &mut Bencher) { let samples = rand_array(LEN as u32); b.iter(|| { sum_iter(&samples) }) } } }
上面的代码对比了 for
循环和迭代器 iterator
完成同样的求和任务的性能对比,可以看到迭代器还要更快一点。
test bench::bench_for ... bench: 998,331 ns/iter (+/- 36,250)
test bench::bench_iter ... bench: 983,858 ns/iter (+/- 44,673)
迭代器是 Rust 的 零成本抽象(zero-cost abstractions)之一,意味着抽象并不会引入运行时开销,这与 Bjarne Stroustrup
(C++ 的设计和实现者)在 Foundations of C++(2012)
中所定义的 零开销(zero-overhead)如出一辙:
In general, C++ implementations obey the zero-overhead principle: What you don’t use, you don’t pay for. And further: What you do use, you couldn’t hand code any better.
一般来说,C++的实现遵循零开销原则:没有使用时,你不必为其买单。 更进一步说,需要使用时,你也无法写出更优的代码了。 (翻译一下:用就完事了)
总之,迭代器是 Rust 受函数式语言启发而提供的高级语言特性,可以写出更加简洁、逻辑清晰的代码。编译器还可以通过循环展开(Unrolling)、向量化、消除边界检查等优化手段,使得迭代器和 for
循环都有极为高效的执行效率。
所以请放心大胆的使用迭代器,在获得更高的表达力的同时,也不会导致运行时的损失,何乐而不为呢!
学习其它方法
迭代器用的好不好,就在于你是否掌握了它的常用方法,且能活学活用,因此多多看看标准库是有好处的,只有知道有什么方法,在需要的时候你才能知道该用什么,就和算法学习一样。
同时,本书在后续章节还提供了对迭代器常用方法的深入讲解,方便大家学习和查阅。
深入类型
Rust 是强类型语言,同时也是强安全语言,这些特性导致了 Rust 的类型注定比一般语言要更深入也更困难。
本章将深入讲解一些进阶的 Rust 类型以及类型转换,希望大家喜欢。
类型转换
Rust 是类型安全的语言,因此在 Rust 中做类型转换不是一件简单的事,这一章节我们将对 Rust 中的类型转换进行详尽讲解。
高能预警:本章节有些难,可以考虑学了进阶后回头再看
as
转换
先来看一段代码:
fn main() { let a: i32 = 10; let b: u16 = 100; if a < b { println!("Ten is less than one hundred."); } }
能跟着这本书一直学习到这里,说明你对 Rust 已经有了一定的理解,那么一眼就能看出这段代码注定会报错,因为 a
和 b
拥有不同的类型,Rust 不允许两种不同的类型进行比较。
解决办法很简单,只要把 b
转换成 i32
类型即可,Rust 中内置了一些基本类型之间的转换,这里使用 as
操作符来完成: if a < (b as i32) {...}
。那么为什么不把 a
转换成 u16
类型呢?
因为每个类型能表达的数据范围不同,如果把范围较大的类型转换成较小的类型,会造成错误,因此我们需要把范围较小的类型转换成较大的类型,来避免这些问题的发生。
使用类型转换需要小心,因为如果执行以下操作
300_i32 as i8
,你将获得44
这个值,而不是300
,因为i8
类型能表达的的最大值为2^7 - 1
,使用以下代码可以查看i8
的最大值:
#![allow(unused)] fn main() { let a = i8::MAX; println!("{}",a); }
下面列出了常用的转换形式:
fn main() { let a = 3.1 as i8; let b = 100_i8 as i32; let c = 'a' as u8; // 将字符'a'转换为整数,97 println!("{},{},{}",a,b,c) }
内存地址转换为指针
#![allow(unused)] fn main() { let mut values: [i32; 2] = [1, 2]; let p1: *mut i32 = values.as_mut_ptr(); let first_address = p1 as usize; // 将p1内存地址转换为一个整数 let second_address = first_address + 4; // 4 == std::mem::size_of::<i32>(),i32类型占用4个字节,因此将内存地址 + 4 let p2 = second_address as *mut i32; // 访问该地址指向的下一个整数p2 unsafe { *p2 += 1; } assert_eq!(values[1], 3); }
强制类型转换的边角知识
- 转换不具有传递性
就算
e as U1 as U2
是合法的,也不能说明e as U2
是合法的(e
不能直接转换成U2
)。
TryInto 转换
在一些场景中,使用 as
关键字会有比较大的限制。如果你想要在类型转换上拥有完全的控制而不依赖内置的转换,例如处理转换错误,那么可以使用 TryInto
:
use std::convert::TryInto; fn main() { let a: u8 = 10; let b: u16 = 1500; let b_: u8 = b.try_into().unwrap(); if a < b_ { println!("Ten is less than one hundred."); } }
上面代码中引入了 std::convert::TryInto
特征,但是却没有使用它,可能有些同学会为此困惑,主要原因在于如果你要使用一个特征的方法,那么你需要引入该特征到当前的作用域中,我们在上面用到了 try_into
方法,因此需要引入对应的特征。但是 Rust 又提供了一个非常便利的办法,把最常用的标准库中的特征通过std::prelude
模块提前引入到当前作用域中,其中包括了 std::convert::TryInto
,你可以尝试删除第一行的代码 use ...
,看看是否会报错。
try_into
会尝试进行一次转换,并返回一个 Result
,此时就可以对其进行相应的错误处理。由于我们的例子只是为了快速测试,因此使用了 unwrap
方法,该方法在发现错误时,会直接调用 panic
导致程序的崩溃退出,在实际项目中,请不要这么使用,具体见panic部分。
最主要的是 try_into
转换会捕获大类型向小类型转换时导致的溢出错误:
fn main() { let b: i16 = 1500; let b_: u8 = match b.try_into() { Ok(b1) => b1, Err(e) => { println!("{:?}", e.to_string()); 0 } }; }
运行后输出如下 "out of range integral type conversion attempted"
,在这里我们程序捕获了错误,编译器告诉我们类型范围超出的转换是不被允许的,因为我们试图把 1500_i16
转换为 u8
类型,后者明显不足以承载这么大的值。
通用类型转换
虽然 as
和 TryInto
很强大,但是只能应用在数值类型上,可是 Rust 有如此多的类型,想要为这些类型实现转换,我们需要另谋出路,先来看看在一个笨办法,将一个结构体转换为另外一个结构体:
#![allow(unused)] fn main() { struct Foo { x: u32, y: u16, } struct Bar { a: u32, b: u16, } fn reinterpret(foo: Foo) -> Bar { let Foo { x, y } = foo; Bar { a: x, b: y } } }
简单粗暴,但是从另外一个角度来看,也挺啰嗦的,好在 Rust 为我们提供了更通用的方式来完成这个目的。
强制类型转换
在某些情况下,类型是可以进行隐式强制转换的,虽然这些转换弱化了 Rust 的类型系统,但是它们的存在是为了让 Rust 在大多数场景可以工作(说白了,帮助用户省事),而不是报各种类型上的编译错误。
首先,在匹配特征时,不会做任何强制转换(除了方法)。一个类型 T
可以强制转换为 U
,不代表 impl T
可以强制转换为 impl U
,例如下面的代码就无法通过编译检查:
trait Trait {} fn foo<X: Trait>(t: X) {} impl<'a> Trait for &'a i32 {} fn main() { let t: &mut i32 = &mut 0; foo(t); }
报错如下:
error[E0277]: the trait bound `&mut i32: Trait` is not satisfied
--> src/main.rs:9:9
|
9 | foo(t);
| ^ the trait `Trait` is not implemented for `&mut i32`
|
= help: the following implementations were found:
<&'a i32 as Trait>
= note: `Trait` is implemented for `&i32`, but not for `&mut i32`
&i32
实现了特征 Trait
, &mut i32
可以转换为 &i32
,但是 &mut i32
依然无法作为 Trait
来使用。
点操作符
方法调用的点操作符看起来简单,实际上非常不简单,它在调用时,会发生很多魔法般的类型转换,例如:自动引用、自动解引用,强制类型转换直到类型能匹配等。
假设有一个方法 foo
,它有一个接收器(接收器就是 self
、&self
、&mut self
参数)。如果调用 value.foo()
,编译器在调用 foo
之前,需要决定到底使用哪个 Self
类型来调用。现在假设 value
拥有类型 T
。
再进一步,我们使用完全限定语法来进行准确的函数调用:
- 首先,编译器检查它是否可以直接调用
T::foo(value)
,称之为值方法调用 - 如果上一步调用无法完成(例如方法类型错误或者特征没有针对
Self
进行实现,上文提到过特征不能进行强制转换),那么编译器会尝试增加自动引用,例如会尝试以下调用:<&T>::foo(value)
和<&mut T>::foo(value)
,称之为引用方法调用 - 若上面两个方法依然不工作,编译器会试着解引用
T
,然后再进行尝试。这里使用了Deref
特征 —— 若T: Deref<Target = U>
(T
可以被解引用为U
),那么编译器会使用U
类型进行尝试,称之为解引用方法调用 - 若
T
不能被解引用,且T
是一个定长类型(在编译期类型长度是已知的),那么编译器也会尝试将T
从定长类型转为不定长类型,例如将[i32; 2]
转为[i32]
- 若还是不行,那...没有那了,最后编译器大喊一声:汝欺我甚,不干了!
下面我们来用一个例子来解释上面的方法查找算法:
#![allow(unused)] fn main() { let array: Rc<Box<[T; 3]>> = ...; let first_entry = array[0]; }
array
数组的底层数据隐藏在了重重封锁之后,那么编译器如何使用 array[0]
这种数组原生访问语法通过重重封锁,准确的访问到数组中的第一个元素?
- 首先,
array[0]
只是Index
特征的语法糖:编译器会将array[0]
转换为array.index(0)
调用,当然在调用之前,编译器会先检查array
是否实现了Index
特征。 - 接着,编译器检查
Rc<Box<[T; 3]>>
是否有实现Index
特征,结果是否,不仅如此,&Rc<Box<[T; 3]>>
与&mut Rc<Box<[T; 3]>>
也没有实现。 - 上面的都不能工作,编译器开始对
Rc<Box<[T; 3]>>
进行解引用,把它转变成Box<[T; 3]>
- 此时继续对
Box<[T; 3]>
进行上面的操作 :Box<[T; 3]>
,&Box<[T; 3]>
,和&mut Box<[T; 3]>
都没有实现Index
特征,所以编译器开始对Box<[T; 3]>
进行解引用,然后我们得到了[T; 3]
[T; 3]
以及它的各种引用都没有实现Index
索引(是不是很反直觉:D,在直觉中,数组都可以通过索引访问,实际上只有数组切片才可以!),它也不能再进行解引用,因此编译器只能祭出最后的大杀器:将定长转为不定长,因此[T; 3]
被转换成[T]
,也就是数组切片,它实现了Index
特征,因此最终我们可以通过index
方法访问到对应的元素。
过程看起来很复杂,但是也还好,挺好理解,如果你现在不能彻底理解,也不要紧,等以后对 Rust 理解更深了,同时需要深入理解类型转换时,再来细细品读本章。
再来看看以下更复杂的例子:
#![allow(unused)] fn main() { fn do_stuff<T: Clone>(value: &T) { let cloned = value.clone(); } }
上面例子中 cloned
的类型是什么?首先编译器检查能不能进行值方法调用, value
的类型是 &T
,同时 clone
方法的签名也是 &T
: fn clone(&T) -> T
,因此可以进行值方法调用,再加上编译器知道了 T
实现了 Clone
,因此 cloned
的类型是 T
。
如果 T: Clone
的特征约束被移除呢?
#![allow(unused)] fn main() { fn do_stuff<T>(value: &T) { let cloned = value.clone(); } }
首先,从直觉上来说,该方法会报错,因为 T
没有实现 Clone
特征,但是真实情况是什么呢?
我们先来推导一番。 首先通过值方法调用就不再可行,因为 T
没有实现 Clone
特征,也就无法调用 T
的 clone
方法。接着编译器尝试引用方法调用,此时 T
变成 &T
,在这种情况下, clone
方法的签名如下: fn clone(&&T) -> &T
,接着我们现在对 value
进行了引用。 编译器发现 &T
实现了 Clone
类型(所有的引用类型都可以被复制,因为其实就是复制一份地址),因此可以推出 cloned
也是 &T
类型。
最终,我们复制出一份引用指针,这很合理,因为值类型 T
没有实现 Clone
,只能去复制一个指针了。
下面的例子也是自动引用生效的地方:
#![allow(unused)] fn main() { #[derive(Clone)] struct Container<T>(Arc<T>); fn clone_containers<T>(foo: &Container<i32>, bar: &Container<T>) { let foo_cloned = foo.clone(); let bar_cloned = bar.clone(); } }
推断下上面的 foo_cloned
和 bar_cloned
是什么类型?提示: 关键在 Container
的泛型参数,一个是 i32
的具体类型,一个是泛型类型,其中 i32
实现了 Clone
,但是 T
并没有。
首先要复习一下复杂类型派生 Clone
的规则:一个复杂类型能否派生 Clone
,需要它内部的所有子类型都能进行 Clone
。因此 Container<T>(Arc<T>)
是否实现 Clone
的关键在于 T
类型是否实现了 Clone
特征。
上面代码中,Container<i32>
实现了 Clone
特征,因此编译器可以直接进行值方法调用,此时相当于直接调用 foo.clone
,其中 clone
的函数签名是 fn clone(&T) -> T
,由此可以看出 foo_cloned
的类型是 Container<i32>
。
然而,bar_cloned
的类型却是 &Container<T>
,这个不合理啊,明明我们为 Container<T>
派生了 Clone
特征,因此它也应该是 Container<T>
类型才对。万事皆有因,我们先来看下 derive
宏最终生成的代码大概是啥样的:
#![allow(unused)] fn main() { impl<T> Clone for Container<T> where T: Clone { fn clone(&self) -> Self { Self(Arc::clone(&self.0)) } } }
从上面代码可以看出,派生 Clone
能实现的根本是 T
实现了Clone
特征:where T: Clone
, 因此 Container<T>
就没有实现 Clone
特征。
编译器接着会去尝试引用方法调用,此时 &Container<T>
引用实现了 Clone
,最终可以得出 bar_cloned
的类型是 &Container<T>
。
当然,也可以为 Container<T>
手动实现 Clone
特征:
#![allow(unused)] fn main() { impl<T> Clone for Container<T> { fn clone(&self) -> Self { Self(Arc::clone(&self.0)) } } }
此时,编译器首次尝试值方法调用即可通过,因此 bar_cloned
的类型变成 Container<T>
。
这一块儿内容真的挺复杂,每一个坚持看完的读者都是真正的勇士,我也是:为了写好这块儿内容,作者足足花了 4 个小时!
变形记(Transmutes)
前方危险,敬请绕行!
类型系统,你让开!我要自己转换这些类型,不成功便成仁!虽然本书大多是关于安全的内容,我还是希望你能仔细考虑避免使用本章讲到的内容。这是你在 Rust 中所能做到的真真正正、彻彻底底、最最可怕的非安全行为,在这里,所有的保护机制都形同虚设。
先让你看看深渊长什么样,开开眼,然后你再决定是否深入: mem::transmute<T, U>
将类型 T
直接转成类型 U
,唯一的要求就是,这两个类型占用同样大小的字节数!我的天,这也算限制?这简直就是无底线的转换好吧?看看会导致什么问题:
- 首先也是最重要的,转换后创建一个任意类型的实例会造成无法想象的混乱,而且根本无法预测。不要把
3
转换成bool
类型,就算你根本不会去使用该bool
类型,也不要去这样转换 - 变形后会有一个重载的返回类型,即使你没有指定返回类型,为了满足类型推导的需求,依然会产生千奇百怪的类型
- 将
&
变形为&mut
是未定义的行为- 这种转换永远都是未定义的
- 不,你不能这么做
- 不要多想,你没有那种幸运
- 变形为一个未指定生命周期的引用会导致无界生命周期
- 在复合类型之间互相变换时,你需要保证它们的排列布局是一模一样的!一旦不一样,那么字段就会得到不可预期的值,这也是未定义的行为,至于你会不会因此愤怒, WHO CARES ,你都用了变形了,老兄!
对于第 5 条,你该如何知道内存的排列布局是一样的呢?对于 repr(C)
类型和 repr(transparent)
类型来说,它们的布局是有着精确定义的。但是对于你自己的"普通却自信"的 Rust 类型 repr(Rust)
来说,它可不是有着精确定义的。甚至同一个泛型类型的不同实例都可以有不同的内存布局。 Vec<i32>
和 Vec<u32>
它们的字段可能有着相同的顺序,也可能没有。对于数据排列布局来说,什么能保证,什么不能保证目前还在 Rust 开发组的工作任务中呢。
你以为你之前凝视的是深渊吗?不,你凝视的只是深渊的大门。 mem::transmute_copy<T, U>
才是真正的深渊,它比之前的还要更加危险和不安全。它从 T
类型中拷贝出 U
类型所需的字节数,然后转换成 U
。 mem::transmute
尚有大小检查,能保证两个数据的内存大小一致,现在这哥们干脆连这个也丢了,只不过 U
的尺寸若是比 T
大,会是一个未定义行为。
当然,你也可以通过裸指针转换和 unions
(todo!)获得所有的这些功能,但是你将无法获得任何编译提示或者检查。裸指针转换和 unions
也不是魔法,无法逃避上面说的规则。
transmute
虽然危险,但作为一本工具书,知识当然要全面,下面列举两个有用的 transmute
应用场景 :)。
- 将裸指针变成函数指针:
#![allow(unused)] fn main() { fn foo() -> i32 { 0 } let pointer = foo as *const (); let function = unsafe { // 将裸指针转换为函数指针 std::mem::transmute::<*const (), fn() -> i32>(pointer) }; assert_eq!(function(), 0); }
- 延长生命周期,或者缩短一个静态生命周期寿命:
#![allow(unused)] fn main() { struct R<'a>(&'a i32); // 将 'b 生命周期延长至 'static 生命周期 unsafe fn extend_lifetime<'b>(r: R<'b>) -> R<'static> { std::mem::transmute::<R<'b>, R<'static>>(r) } // 将 'static 生命周期缩短至 'c 生命周期 unsafe fn shorten_invariant_lifetime<'b, 'c>(r: &'b mut R<'static>) -> &'b mut R<'c> { std::mem::transmute::<&'b mut R<'static>, &'b mut R<'c>>(r) } }
以上例子非常先进!但是是非常不安全的 Rust 行为!
课后练习
Rust By Practice,支持代码在线编辑和运行,并提供详细的习题解答。
深入 Rust 类型
弱弱地、不负责任地说,Rust 的学习难度之恶名,可能有一半来源于 Rust 的类型系统,而其中一半的一半则来自于本章节的内容。在本章,我们将重点学习如何创建自定义类型,以及了解何为动态大小的类型。
newtype
何为 newtype
?简单来说,就是使用元组结构体的方式将已有的类型包裹起来:struct Meters(u32);
,那么此处 Meters
就是一个 newtype
。
为何需要 newtype
?Rust 这多如繁星的 Old 类型满足不了我们吗?这是因为:
- 自定义类型可以让我们给出更有意义和可读性的类型名,例如与其使用
u32
作为距离的单位类型,我们可以使用Meters
,它的可读性要好得多 - 对于某些场景,只有
newtype
可以很好地解决 - 隐藏内部类型的细节
一箩筐的理由~~ 让我们先从第二点讲起。
为外部类型实现外部特征
在之前的章节中,我们有讲过,如果在外部类型上实现外部特征必须使用 newtype
的方式,否则你就得遵循孤儿规则:要为类型 A
实现特征 T
,那么 A
或者 T
必须至少有一个在当前的作用范围内。
例如,如果想使用 println!("{}", v)
的方式去格式化输出一个动态数组 Vec
,以期给用户提供更加清晰可读的内容,那么就需要为 Vec
实现 Display
特征,但是这里有一个问题: Vec
类型定义在标准库中,Display
亦然,这时就可以祭出大杀器 newtype
来解决:
use std::fmt; struct Wrapper(Vec<String>); impl fmt::Display for Wrapper { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "[{}]", self.0.join(", ")) } } fn main() { let w = Wrapper(vec![String::from("hello"), String::from("world")]); println!("w = {}", w); }
如上所示,使用元组结构体语法 struct Wrapper(Vec<String>)
创建了一个 newtype
Wrapper,然后为它实现 Display
特征,最终实现了对 Vec
动态数组的格式化输出。
更好的可读性及类型异化
首先,更好的可读性不等于更少的代码(如果你学过 Scala,相信会深有体会),其次下面的例子只是一个示例,未必能体现出更好的可读性:
use std::ops::Add; use std::fmt; struct Meters(u32); impl fmt::Display for Meters { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "目标地点距离你{}米", self.0) } } impl Add for Meters { type Output = Self; fn add(self, other: Meters) -> Self { Self(self.0 + other.0) } } fn main() { let d = calculate_distance(Meters(10), Meters(20)); println!("{}", d); } fn calculate_distance(d1: Meters, d2: Meters) -> Meters { d1 + d2 }
上面代码创建了一个 newtype
Meters,为其实现 Display
和 Add
特征,接着对两个距离进行求和计算,最终打印出该距离:
目标地点距离你30米
事实上,除了可读性外,还有一个极大的优点:如果给 calculate_distance
传一个其它的类型,例如 struct MilliMeters(u32);
,该代码将无法编译。尽管 Meters
和 MilliMeters
都是对 u32
类型的简单包装,但是它们是不同的类型!
隐藏内部类型的细节
众所周知,Rust 的类型有很多自定义的方法,假如我们把某个类型传给了用户,但是又不想用户调用这些方法,就可以使用 newtype
:
struct Meters(u32); fn main() { let i: u32 = 2; assert_eq!(i.pow(2), 4); let n = Meters(i); // 下面的代码将报错,因为`Meters`类型上没有`pow`方法 // assert_eq!(n.pow(2), 4); }
不过需要偷偷告诉你的是,这种方式实际上是掩耳盗铃,因为用户依然可以通过 n.0.pow(2)
的方式来调用内部类型的方法 :)
类型别名(Type Alias)
除了使用 newtype
,我们还可以使用一个更传统的方式来创建新类型:类型别名
#![allow(unused)] fn main() { type Meters = u32 }
嗯,不得不说,类型别名的方式看起来比 newtype
顺眼的多,而且跟其它语言的使用方式几乎一致,但是:
类型别名并不是一个独立的全新的类型,而是某一个类型的别名,因此编译器依然会把 Meters
当 u32
来使用:
#![allow(unused)] fn main() { type Meters = u32; let x: u32 = 5; let y: Meters = 5; println!("x + y = {}", x + y); }
上面的代码将顺利编译通过,但是如果你使用 newtype
模式,该代码将无情报错,简单做个总结:
- 类型别名仅仅是别名,只是为了让可读性更好,并不是全新的类型,
newtype
才是! - 类型别名无法实现为外部类型实现外部特征等功能,而
newtype
可以
类型别名除了让类型可读性更好,还能减少模版代码的使用:
#![allow(unused)] fn main() { let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi")); fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) { // --snip-- } fn returns_long_type() -> Box<dyn Fn() + Send + 'static> { // --snip-- } }
f
是一个令人眼花缭乱的类型 Box<dyn Fn() + Send + 'static>
,如果仔细看,会发现其实只有一个 Send
特征不认识,Send
是什么在这里不重要,你只需理解,f
就是一个 Box<dyn T>
类型的特征对象,实现了 Fn()
和 Send
特征,同时生命周期为 'static
。
因为 f
的类型贼长,导致了后面我们在使用它时,到处都充斥这些不太优美的类型标注,好在类型别名可解君忧:
#![allow(unused)] fn main() { type Thunk = Box<dyn Fn() + Send + 'static>; let f: Thunk = Box::new(|| println!("hi")); fn takes_long_type(f: Thunk) { // --snip-- } fn returns_long_type() -> Thunk { // --snip-- } }
Bang!是不是?!立刻大幅简化了我们的使用。喝着奶茶、哼着歌、我写起代码撩起妹,何其快哉!
在标准库中,类型别名应用最广的就是简化 Result<T, E>
枚举。
例如在 std::io
库中,它定义了自己的 Error
类型:std::io::Error
,那么如果要使用该 Result
就要用这样的语法:std::result::Result<T, std::io::Error>;
,想象一下代码中充斥着这样的东东是一种什么感受?颤抖吧。。。
由于使用 std::io
库时,它的所有错误类型都是 std::io::Error
,那么我们完全可以把该错误对用户隐藏起来,只在内部使用即可,因此就可以使用类型别名来简化实现:
#![allow(unused)] fn main() { type Result<T> = std::result::Result<T, std::io::Error>; }
Bingo,这样一来,其它库只需要使用 std::io::Result<T>
即可替代冗长的 std::result::Result<T, std::io::Error>
类型。
更香的是,由于它只是别名,因此我们可以用它来调用真实类型的所有方法,甚至包括 ?
符号!
!永不返回类型
在函数那章,曾经介绍过 !
类型:!
用来说明一个函数永不返回任何值,当时可能体会不深,没事,在学习了更多手法后,保证你有全新的体验:
fn main() { let i = 2; let v = match i { 0..=3 => i, _ => println!("不合规定的值:{}", i) }; }
上面函数,会报出一个编译错误:
error[E0308]: `match` arms have incompatible types // match的分支类型不同
--> src/main.rs:5:13
|
3 | let v = match i {
| _____________-
4 | | 0..3 => i,
| | - this is found to be of type `{integer}` // 该分支返回整数类型
5 | | _ => println!("不合规定的值:{}", i)
| | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected integer, found `()` // 该分支返回()单元类型
6 | | };
| |_____- `match` arms have incompatible types
原因很简单: 要赋值给 v
,就必须保证 match
的各个分支返回的值是同一个类型,但是上面一个分支返回数值、另一个分支返回元类型 ()
,自然会出错。
既然 println
不行,那再试试 panic
fn main() { let i = 2; let v = match i { 0..=3 => i, _ => panic!("不合规定的值:{}", i) }; }
神奇的事发生了,此处 panic
竟然通过了编译。难道这两个宏拥有不同的返回类型?
你猜的没错:panic
的返回值是 !
,代表它决不会返回任何值,既然没有任何返回值,那自然不会存在分支类型不匹配的情况。
Sized 和不定长类型 DST
在 Rust 中类型有多种抽象的分类方式,例如本书之前章节的:基本类型、集合类型、复合类型等。再比如说,如果从编译器何时能获知类型大小的角度出发,可以分成两类:
- 定长类型( sized ),这些类型的大小在编译时是已知的
- 不定长类型( unsized ),与定长类型相反,它的大小只有到了程序运行时才能动态获知,这种类型又被称之为 DST
首先,我们来深入看看何为 DST。
动态大小类型 DST
读者大大们之前学过的几乎所有类型,都是固定大小的类型,包括集合 Vec
、String
和 HashMap
等,而动态大小类型刚好与之相反:编译器无法在编译期得知该类型值的大小,只有到了程序运行时,才能动态获知。对于动态类型,我们使用 DST
(dynamically sized types)或者 unsized
类型来称呼它。
上述的这些集合虽然底层数据可动态变化,感觉像是动态大小的类型。但是实际上,这些底层数据只是保存在堆上,在栈中还存有一个引用类型,该引用包含了集合的内存地址、元素数目、分配空间信息,通过这些信息,编译器对于该集合的实际大小了若指掌,最最重要的是:栈上的引用类型是固定大小的,因此它们依然是固定大小的类型。
正因为编译器无法在编译期获知类型大小,若你试图在代码中直接使用 DST 类型,将无法通过编译。
现在给你一个挑战:想出几个 DST 类型。俺厚黑地说一句,估计大部分人都想不出这样的一个类型,就连我,如果不是查询着资料在写,估计一时半会儿也想不到一个。
先来看一个最直白的:
试图创建动态大小的数组
#![allow(unused)] fn main() { fn my_function(n: usize) { let array = [123; n]; } }
以上代码就会报错(错误输出的内容并不是因为 DST,但根本原因是类似的),因为 n
在编译期无法得知,而数组类型的一个组成部分就是长度,长度变为动态的,自然类型就变成了 unsized 。
切片
切片也是一个典型的 DST 类型,具体详情参见另一篇文章: 易混淆的切片和切片引用。
str
考虑一下这个类型:str
,感觉有点眼生?是的,它既不是 String
动态字符串,也不是 &str
字符串切片,而是一个 str
。它是一个动态类型,同时还是 String
和 &str
的底层数据类型。 由于 str
是动态类型,因此它的大小直到运行期才知道,下面的代码会因此报错:
#![allow(unused)] fn main() { // error let s1: str = "Hello there!"; let s2: str = "How's it going?"; // ok let s3: &str = "on?" }
Rust 需要明确地知道一个特定类型的值占据了多少内存空间,同时该类型的所有值都必须使用相同大小的内存。如果 Rust 允许我们使用这种动态类型,那么这两个 str
值就需要占用同样大小的内存,这显然是不现实的: s1
占用了 12 字节,s2
占用了 15 字节,总不至于为了满足同样的内存大小,用空白字符去填补字符串吧?
所以,我们只有一条路走,那就是给它们一个固定大小的类型:&str
。那么为何字符串切片 &str
就是固定大小呢?因为它的引用存储在栈上,具有固定大小(类似指针),同时它指向的数据存储在堆中,也是已知的大小,再加上 &str
引用中包含有堆上数据内存地址、长度等信息,因此最终可以得出字符串切片是固定大小类型的结论。
与 &str
类似,String
字符串也是固定大小的类型。
正是因为 &str
的引用有了底层堆数据的明确信息,它才是固定大小类型。假设如果它没有这些信息呢?那它也将变成一个动态类型。因此,将动态数据固定化的秘诀就是使用引用指向这些动态数据,然后在引用中存储相关的内存位置、长度等信息。
特征对象
#![allow(unused)] fn main() { fn foobar_1(thing: &dyn MyThing) {} // OK fn foobar_2(thing: Box<dyn MyThing>) {} // OK fn foobar_3(thing: MyThing) {} // ERROR! }
如上所示,只能通过引用或 Box
的方式来使用特征对象,直接使用将报错!
总结:只能间接使用的 DST
Rust 中常见的 DST
类型有: str
、[T]
、dyn Trait
,它们都无法单独被使用,必须要通过引用或者 Box
来间接使用 。
我们之前已经见过,使用 Box
将一个没有固定大小的特征变成一个有固定大小的特征对象,那能否故技重施,将 str
封装成一个固定大小类型?留个悬念先,我们来看看 Sized
特征。
Sized 特征
既然动态类型的问题这么大,那么在使用泛型时,Rust 如何保证我们的泛型参数是固定大小的类型呢?例如以下泛型函数:
#![allow(unused)] fn main() { fn generic<T>(t: T) { // --snip-- } }
该函数很简单,就一个泛型参数 T,那么如何保证 T
是固定大小的类型?仔细回想下,貌似在之前的课程章节中,我们也没有做过任何事情去做相关的限制,那 T
怎么就成了固定大小的类型了?奥秘在于编译器自动帮我们加上了 Sized
特征约束:
#![allow(unused)] fn main() { fn generic<T: Sized>(t: T) { // --snip-- } }
在上面,Rust 自动添加的特征约束 T: Sized
,表示泛型函数只能用于一切实现了 Sized
特征的类型上,而所有在编译时就能知道其大小的类型,都会自动实现 Sized
特征,例如。。。。也没啥好例如的,你能想到的几乎所有类型都实现了 Sized
特征,除了上面那个坑坑的 str
,哦,还有特征。
每一个特征都是一个可以通过名称来引用的动态大小类型。因此如果想把特征作为具体的类型来传递给函数,你必须将其转换成一个特征对象:诸如 &dyn Trait
或者 Box<dyn Trait>
(还有 Rc<dyn Trait>
)这些引用类型。
现在还有一个问题:假如想在泛型函数中使用动态数据类型怎么办?可以使用 ?Sized
特征(不得不说这个命名方式很 Rusty,竟然有点幽默):
#![allow(unused)] fn main() { fn generic<T: ?Sized>(t: &T) { // --snip-- } }
?Sized
特征用于表明类型 T
既有可能是固定大小的类型,也可能是动态大小的类型。还有一点要注意的是,函数参数类型从 T
变成了 &T
,因为 T
可能是动态大小的,因此需要用一个固定大小的指针(引用)来包裹它。
Box<str>
在结束前,再来看看之前遗留的问题:使用 Box
可以将一个动态大小的特征变成一个具有固定大小的特征对象,能否故技重施,将 str
封装成一个固定大小类型?
先回想下,章节前面的内容介绍过该如何把一个动态大小类型转换成固定大小的类型: 使用引用指向这些动态数据,然后在引用中存储相关的内存位置、长度等信息。
好的,根据这个,我们来一起推测。首先,Box<str>
使用了一个引用来指向 str
,嗯,满足了第一个条件。但是第二个条件呢?Box
中有该 str
的长度信息吗?显然是 No
。那为什么特征就可以变成特征对象?其实这个还蛮复杂的,简单来说,对于特征对象,编译器无需知道它具体是什么类型,只要知道它能调用哪几个方法即可,因此编译器帮我们实现了剩下的一切。
来验证下我们的推测:
fn main() { let s1: Box<str> = Box::new("Hello there!" as str); }
报错如下:
error[E0277]: the size for values of type `str` cannot be known at compilation time
--> src/main.rs:2:24
|
2 | let s1: Box<str> = Box::new("Hello there!" as str);
| ^^^^^^^^ doesn't have a size known at compile-time
|
= help: the trait `Sized` is not implemented for `str`
= note: all function arguments must have a statically known size
提示得很清晰,不知道 str
的大小,因此无法使用这种语法进行 Box
进装,但是你可以这么做:
#![allow(unused)] fn main() { let s1: Box<str> = "Hello there!".into(); }
主动转换成 str
的方式不可行,但是可以让编译器来帮我们完成,只要告诉它我们需要的类型即可。
整数转换为枚举
在 Rust 中,从枚举到整数的转换很容易,但是反过来,就没那么容易,甚至部分实现还挺邪恶, 例如使用transmute
。
一个真实场景的需求
在实际场景中,从整数到枚举的转换有时还是非常需要的,例如你有一个枚举类型,然后需要从外面传入一个整数,用于控制后续的流程走向,此时就需要用整数去匹配相应的枚举(你也可以用整数匹配整数-, -,看看会不会被喷)。
既然有了需求,剩下的就是看看该如何实现,这篇文章的水远比你想象的要深,且看八仙过海各显神通。
C 语言的实现
对于 C 语言来说,万物皆邪恶,因此我们不讨论安全,只看实现,不得不说很简洁:
#include <stdio.h>
enum atomic_number {
HYDROGEN = 1,
HELIUM = 2,
// ...
IRON = 26,
};
int main(void)
{
enum atomic_number element = 26;
if (element == IRON) {
printf("Beware of Rust!\n");
}
return 0;
}
但是在 Rust 中,以下代码:
enum MyEnum { A = 1, B, C, } fn main() { // 将枚举转换成整数,顺利通过 let x = MyEnum::C as i32; // 将整数转换为枚举,失败 match x { MyEnum::A => {} MyEnum::B => {} MyEnum::C => {} _ => {} } }
就会报错: MyEnum::A => {} mismatched types, expected i32, found enum MyEnum
。
使用三方库
首先可以想到的肯定是三方库,毕竟 Rust 的生态目前已经发展的很不错,类似的需求总是有的,这里我们先使用num-traits
和num-derive
来试试。
在Cargo.toml
中引入:
[dependencies]
num-traits = "0.2.14"
num-derive = "0.3.3"
代码如下:
use num_derive::FromPrimitive; use num_traits::FromPrimitive; #[derive(FromPrimitive)] enum MyEnum { A = 1, B, C, } fn main() { let x = 2; match FromPrimitive::from_i32(x) { Some(MyEnum::A) => println!("Got A"), Some(MyEnum::B) => println!("Got B"), Some(MyEnum::C) => println!("Got C"), None => println!("Couldn't convert {}", x), } }
除了上面的库,还可以使用一个较新的库: num_enums
。
TryFrom + 宏
在 Rust 1.34 后,可以实现TryFrom
特征来做转换:
#![allow(unused)] fn main() { use std::convert::TryFrom; impl TryFrom<i32> for MyEnum { type Error = (); fn try_from(v: i32) -> Result<Self, Self::Error> { match v { x if x == MyEnum::A as i32 => Ok(MyEnum::A), x if x == MyEnum::B as i32 => Ok(MyEnum::B), x if x == MyEnum::C as i32 => Ok(MyEnum::C), _ => Err(()), } } } }
以上代码定义了从i32
到MyEnum
的转换,接着就可以使用TryInto
来实现转换:
use std::convert::TryInto; fn main() { let x = MyEnum::C as i32; match x.try_into() { Ok(MyEnum::A) => println!("a"), Ok(MyEnum::B) => println!("b"), Ok(MyEnum::C) => println!("c"), Err(_) => eprintln!("unknown number"), } }
但是上面的代码有个问题,你需要为每个枚举成员都实现一个转换分支,非常麻烦。好在可以使用宏来简化,自动根据枚举的定义来实现TryFrom
特征:
#![allow(unused)] fn main() { #[macro_export] macro_rules! back_to_enum { ($(#[$meta:meta])* $vis:vis enum $name:ident { $($(#[$vmeta:meta])* $vname:ident $(= $val:expr)?,)* }) => { $(#[$meta])* $vis enum $name { $($(#[$vmeta])* $vname $(= $val)?,)* } impl std::convert::TryFrom<i32> for $name { type Error = (); fn try_from(v: i32) -> Result<Self, Self::Error> { match v { $(x if x == $name::$vname as i32 => Ok($name::$vname),)* _ => Err(()), } } } } } back_to_enum! { enum MyEnum { A = 1, B, C, } } }
邪恶之王 std::mem::transmute
这个方法原则上并不推荐,但是有其存在的意义,如果要使用,你需要清晰的知道自己为什么使用。
在之前的类型转换章节,我们提到过非常邪恶的transmute
转换,其实,当你知道数值一定不会超过枚举的范围时(例如枚举成员对应 1,2,3,传入的整数也在这个范围内),就可以使用这个方法完成变形。
最好使用#[repr(..)]来控制底层类型的大小,免得本来需要 i32,结果传入 i64,最终内存无法对齐,产生奇怪的结果
#[repr(i32)] enum MyEnum { A = 1, B, C } fn main() { let x = MyEnum::C; let y = x as i32; let z: MyEnum = unsafe { std::mem::transmute(y) }; // match the enum that came from an int match z { MyEnum::A => { println!("Found A"); } MyEnum::B => { println!("Found B"); } MyEnum::C => { println!("Found C"); } } }
既然是邪恶之王,当然得有真本事,无需标准库、也无需 unstable 的 Rust 版本,我们就完成了转换!awesome!??
总结
本文列举了常用(其实差不多也是全部了,还有一个 unstable 特性没提到)的从整数转换为枚举的方式,推荐度按照出现的先后顺序递减。
但是推荐度最低,不代表它就没有出场的机会,只要使用边界清晰,一样可以大放光彩,例如最后的transmute
函数.
智能指针
在各个编程语言中,指针的概念几乎都是相同的:指针是一个包含了内存地址的变量,该内存地址引用或者指向了另外的数据。
在 Rust 中,最常见的指针类型是引用,引用通过 &
符号表示。不同于其它语言,引用在 Rust 中被赋予了更深层次的含义,那就是:借用其它变量的值。引用本身很简单,除了指向某个值外并没有其它的功能,也不会造成性能上的额外损耗,因此是 Rust 中使用最多的指针类型。
而智能指针则不然,它虽然也号称指针,但是它是一个复杂的家伙:通过比引用更复杂的数据结构,包含比引用更多的信息,例如元数据,当前长度,最大可用长度等。总之,Rust 的智能指针并不是独创,在 C++ 或者其他语言中也存在相似的概念。
Rust 标准库中定义的那些智能指针,虽重但强,可以提供比引用更多的功能特性,例如本章将讨论的引用计数智能指针。该智能指针允许你同时拥有同一个数据的多个所有权,它会跟踪每一个所有者并进行计数,当所有的所有者都归还后,该智能指针及指向的数据将自动被清理释放。
引用和智能指针的另一个不同在于前者仅仅是借用了数据,而后者往往可以拥有它们指向的数据,然后再为其它人提供服务。
在之前的章节中,实际上我们已经见识过多种智能指针,例如动态字符串 String
和动态数组 Vec
,它们的数据结构中不仅仅包含了指向底层数据的指针,还包含了当前长度、最大长度等信息,其中 String
智能指针还提供了一种担保信息:所有的数据都是合法的 UTF-8
格式。
智能指针往往是基于结构体实现,它与我们自定义的结构体最大的区别在于它实现了 Deref
和 Drop
特征:
Deref
可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如*T
Drop
允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作
智能指针在 Rust 中很常见,我们在本章不会全部讲解,而是挑选几个最常用、最有代表性的进行讲解:
Box<T>
,可以将值分配到堆上Rc<T>
,引用计数类型,允许多所有权存在Ref<T>
和RefMut<T>
,允许将借用规则检查从编译期移动到运行期进行
Box<T>
堆对象分配
关于作者帅不帅,估计争议还挺多的,但是如果说 Box<T>
是不是 Rust 中最常见的智能指针,那估计没有任何争议。因为 Box<T>
允许你将一个值分配到堆上,然后在栈上保留一个智能指针指向堆上的数据。
之前我们在所有权章节简单讲过堆栈的概念,这里再补充一些。
Rust 中的堆栈
高级语言 Python/Java 等往往会弱化堆栈的概念,但是要用好 C/C++/Rust,就必须对堆栈有深入的了解,原因是两者的内存管理方式不同:前者有 GC 垃圾回收机制,因此无需你去关心内存的细节。
栈内存从高位地址向下增长,且栈内存是连续分配的,一般来说操作系统对栈内存的大小都有限制,因此 C 语言中无法创建任意长度的数组。在 Rust 中,main
线程的栈大小是 8MB
,普通线程是 2MB
,在函数调用时会在其中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的对象自动进入 Drop
流程,最后栈顶指针自动移动到上一个调用栈顶,无需程序员手动干预,因而栈内存申请和释放是非常高效的。
与栈相反,堆上内存则是从低位地址向上增长,堆内存通常只受物理内存限制,而且通常是不连续的,因此从性能的角度看,栈往往比堆更高。
相比其它语言,Rust 堆上对象还有一个特殊之处,它们都拥有一个所有者,因此受所有权规则的限制:当赋值时,发生的是所有权的转移(只需浅拷贝栈上的引用或智能指针即可),例如以下代码:
fn main() { let b = foo("world"); println!("{}", b); } fn foo(x: &str) -> String { let a = "Hello, ".to_string() + x; a }
在 foo
函数中,a
是 String
类型,它其实是一个智能指针结构体,该智能指针存储在函数栈中,指向堆上的字符串数据。当被从 foo
函数转移给 main
中的 b
变量时,栈上的智能指针被复制一份赋予给 b
,而底层数据无需发生改变,这样就完成了所有权从 foo
函数内部到 b
的转移。
堆栈的性能
很多人可能会觉得栈的性能肯定比堆高,其实未必。 由于我们在后面的性能专题会专门讲解堆栈的性能问题,因此这里就大概给出结论:
- 小型数据,在栈上的分配性能和读取性能都要比堆上高
- 中型数据,栈上分配性能高,但是读取性能和堆上并无区别,因为无法利用寄存器或 CPU 高速缓存,最终还是要经过一次内存寻址
- 大型数据,只建议在堆上分配和使用
总之,栈的分配速度肯定比堆上快,但是读取速度往往取决于你的数据能不能放入寄存器或 CPU 高速缓存。 因此不要仅仅因为堆上性能不如栈这个印象,就总是优先选择栈,导致代码更复杂的实现。
Box 的使用场景
由于 Box
是简单的封装,除了将值存储在堆上外,并没有其它性能上的损耗。而性能和功能往往是鱼和熊掌,因此 Box
相比其它智能指针,功能较为单一,可以在以下场景中使用它:
- 特意的将数据分配在堆上
- 数据较大时,又不想在转移所有权时进行数据拷贝
- 类型的大小在编译期无法确定,但是我们又需要固定大小的类型时
- 特征对象,用于说明对象实现了一个特征,而不是某个特定的类型
以上场景,我们在本章将一一讲解,后面车速较快,请系好安全带。
使用 Box<T>
将数据存储在堆上
如果一个变量拥有一个数值 let a = 3
,那变量 a
必然是存储在栈上的,那如果我们想要 a
的值存储在堆上就需要使用 Box<T>
:
fn main() { let a = Box::new(3); println!("a = {}", a); // a = 3 // 下面一行代码将报错 // let b = a + 1; // cannot add `{integer}` to `Box<{integer}>` }
这样就可以创建一个智能指针指向了存储在堆上的 3
,并且 a
持有了该指针。在本章的引言中,我们提到了智能指针往往都实现了 Deref
和 Drop
特征,因此:
println!
可以正常打印出a
的值,是因为它隐式地调用了Deref
对智能指针a
进行了解引用- 最后一行代码
let b = a + 1
报错,是因为在表达式中,我们无法自动隐式地执行Deref
解引用操作,你需要使用*
操作符let b = *a + 1
,来显式的进行解引用 a
持有的智能指针将在作用域结束(main
函数结束)时,被释放掉,这是因为Box<T>
实现了Drop
特征
以上的例子在实际代码中其实很少会存在,因为将一个简单的值分配到堆上并没有太大的意义。将其分配在栈上,由于寄存器、CPU 缓存的原因,它的性能将更好,而且代码可读性也更好。
避免栈上数据的拷贝
当栈上数据转移所有权时,实际上是把数据拷贝了一份,最终新旧变量各自拥有不同的数据,因此所有权并未转移。
而堆上则不然,底层数据并不会被拷贝,转移所有权仅仅是复制一份栈中的指针,再将新的指针赋予新的变量,然后让拥有旧指针的变量失效,最终完成了所有权的转移:
fn main() { // 在栈上创建一个长度为1000的数组 let arr = [0;1000]; // 将arr所有权转移arr1,由于 `arr` 分配在栈上,因此这里实际上是直接重新深拷贝了一份数据 let arr1 = arr; // arr 和 arr1 都拥有各自的栈上数组,因此不会报错 println!("{:?}", arr.len()); println!("{:?}", arr1.len()); // 在堆上创建一个长度为1000的数组,然后使用一个智能指针指向它 let arr = Box::new([0;1000]); // 将堆上数组的所有权转移给 arr1,由于数据在堆上,因此仅仅拷贝了智能指针的结构体,底层数据并没有被拷贝 // 所有权顺利转移给 arr1,arr 不再拥有所有权 let arr1 = arr; println!("{:?}", arr1.len()); // 由于 arr 不再拥有底层数组的所有权,因此下面代码将报错 // println!("{:?}", arr.len()); }
从以上代码,可以清晰看出大块的数据为何应该放入堆中,此时 Box
就成为了我们最好的帮手。
将动态大小类型变为 Sized 固定大小类型
Rust 需要在编译时知道类型占用多少空间,如果一种类型在编译时无法知道具体的大小,那么被称为动态大小类型 DST。
其中一种无法在编译时知道大小的类型是递归类型:在类型定义中又使用到了自身,或者说该类型的值的一部分可以是相同类型的其它值,这种值的嵌套理论上可以无限进行下去,所以 Rust 不知道递归类型需要多少空间:
#![allow(unused)] fn main() { enum List { Cons(i32, List), Nil, } }
以上就是函数式语言中常见的 Cons List
,它的每个节点包含一个 i32
值,还包含了一个新的 List
,因此这种嵌套可以无限进行下去,Rust 认为该类型是一个 DST 类型,并给予报错:
error[E0072]: recursive type `List` has infinite size //递归类型 `List` 拥有无限长的大小
--> src/main.rs:3:1
|
3 | enum List {
| ^^^^^^^^^ recursive type has infinite size
4 | Cons(i32, List),
| ---- recursive without indirection
此时若想解决这个问题,就可以使用我们的 Box<T>
:
#![allow(unused)] fn main() { enum List { Cons(i32, Box<List>), Nil, } }
只需要将 List
存储到堆上,然后使用一个智能指针指向它,即可完成从 DST 到 Sized 类型(固定大小类型)的华丽转变。
特征对象
在 Rust 中,想实现不同类型组成的数组只有两个办法:枚举和特征对象,前者限制较多,因此后者往往是最常用的解决办法。
trait Draw { fn draw(&self); } struct Button { id: u32, } impl Draw for Button { fn draw(&self) { println!("这是屏幕上第{}号按钮", self.id) } } struct Select { id: u32, } impl Draw for Select { fn draw(&self) { println!("这个选择框贼难用{}", self.id) } } fn main() { let elems: Vec<Box<dyn Draw>> = vec![Box::new(Button { id: 1 }), Box::new(Select { id: 2 })]; for e in elems { e.draw() } }
以上代码将不同类型的 Button
和 Select
包装成 Draw
特征的特征对象,放入一个数组中,Box<dyn Draw>
就是特征对象。
其实,特征也是 DST 类型,而特征对象在做的就是将 DST 类型转换为固定大小类型。
Box 内存布局
先来看看 Vec<i32>
的内存布局:
#![allow(unused)] fn main() { (stack) (heap) ┌──────┐ ┌───┐ │ vec1 │──→│ 1 │ └──────┘ ├───┤ │ 2 │ ├───┤ │ 3 │ ├───┤ │ 4 │ └───┘ }
之前提到过 Vec
和 String
都是智能指针,从上图可以看出,该智能指针存储在栈中,然后指向堆上的数组数据。
那如果数组中每个元素都是一个 Box
对象呢?来看看 Vec<Box<i32>>
的内存布局:
#![allow(unused)] fn main() { (heap) (stack) (heap) ┌───┐ ┌──────┐ ┌───┐ ┌─→│ 1 │ │ vec2 │──→│B1 │─┘ └───┘ └──────┘ ├───┤ ┌───┐ │B2 │───→│ 2 │ ├───┤ └───┘ │B3 │─┐ ┌───┐ ├───┤ └─→│ 3 │ │B4 │─┐ └───┘ └───┘ │ ┌───┐ └─→│ 4 │ └───┘ }
上面的 B1
代表被 Box
分配到堆上的值 1
。
可以看出智能指针 vec2
依然是存储在栈上,然后指针指向一个堆上的数组,该数组中每个元素都是一个 Box
智能指针,最终 Box
智能指针又指向了存储在堆上的实际值。
因此当我们从数组中取出某个元素时,取到的是对应的智能指针 Box
,需要对该智能指针进行解引用,才能取出最终的值:
fn main() { let arr = vec![Box::new(1), Box::new(2)]; let (first, second) = (&arr[0], &arr[1]); let sum = **first + **second; }
以上代码有几个值得注意的点:
- 使用
&
借用数组中的元素,否则会报所有权错误 - 表达式不能隐式的解引用,因此必须使用
**
做两次解引用,第一次将&Box<i32>
类型转成Box<i32>
,第二次将Box<i32>
转成i32
Box::leak
Box
中还提供了一个非常有用的关联函数:Box::leak
,它可以消费掉 Box
并且强制目标值从内存中泄漏,读者可能会觉得,这有啥用啊?
其实还真有点用,例如,你可以把一个 String
类型,变成一个 'static
生命周期的 &str
类型:
fn main() { let s = gen_static_str(); println!("{}", s); } fn gen_static_str() -> &'static str{ let mut s = String::new(); s.push_str("hello, world"); Box::leak(s.into_boxed_str()) }
在之前的代码中,如果 String
创建于函数中,那么返回它的唯一方法就是转移所有权给调用者 fn move_str() -> String
,而通过 Box::leak
我们不仅返回了一个 &str
字符串切片,它还是 'static
生命周期的!
要知道真正具有 'static
生命周期的往往都是编译期就创建的值,例如 let v = "hello, world"
,这里 v
是直接打包到二进制可执行文件中的,因此该字符串具有 'static
生命周期,再比如 const
常量。
又有读者要问了,我还可以手动为变量标注 'static
啊。其实你标注的 'static
只是用来忽悠编译器的,但是超出作用域,一样被释放回收。而使用 Box::leak
就可以将一个运行期的值转为 'static
。
使用场景
光看上面的描述,大家可能还是云里雾里、一头雾水。
那么我说一个简单的场景,你需要一个在运行期初始化的值,但是可以全局有效,也就是和整个程序活得一样久,那么就可以使用 Box::leak
,例如有一个存储配置的结构体实例,它是在运行期动态插入内容,那么就可以将其转为全局有效,虽然 Rc/Arc
也可以实现此功能,但是 Box::leak
是性能最高的。
总结
Box
背后是调用 jemalloc
来做内存管理,所以堆上的空间无需我们的手动管理。与此类似,带 GC 的语言中的对象也是借助于 Box
概念来实现的,一切皆对象 = 一切皆 Box, 只不过我们无需自己去 Box
罢了。
其实很多时候,编译器的鞭笞可以助我们更快的成长,例如所有权规则里的借用、move、生命周期就是编译器在教我们做人,哦不是,是教我们深刻理解堆栈、内存布局、作用域等等你在其它 GC 语言无需去关注的东西。刚开始是很痛苦,但是一旦熟悉了这套规则,写代码的效率和代码本身的质量将飞速上升,直到你可以用 Java 开发的效率写出 Java 代码不可企及的性能和安全性,最终 Rust 语言所谓的开发效率低、心智负担高,对你来说终究不是个事。
因此,不要怪 Rust,它只是在帮我们成为那个更好的程序员,而这些苦难终究成为我们走向优秀的垫脚石。
Deref 解引用
在开始之前,我们先来看一段代码:
#![allow(unused)] fn main() { #[derive(Debug)] struct Person { name: String, age: u8 } impl Person { fn new(name: String, age: u8) -> Self { Person { name, age} } fn display(self: &mut Person, age: u8) { let Person{name, age} = &self; } } }
以上代码有一个很奇怪的地方:在 display
方法中,self
是 &mut Person
的类型,接着我们对其取了一次引用 &self
,此时 &self
的类型是 &&mut Person
,然后我们又将其和 Person
类型进行匹配,取出其中的值。
那么问题来了,Rust 不是号称安全的语言吗?为何允许将 &&mut Person
跟 Person
进行匹配呢?答案就在本章节中,等大家学完后,再回头自己来解决这个问题 :) 下面正式开始咱们的新章节学习。
何为智能指针?能不让你写出 ****s
形式的解引用,我认为就是智能: ),智能指针的名称来源,主要就在于它实现了 Deref
和 Drop
特征,这两个特征可以智能地帮助我们节省使用上的负担:
Deref
可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如*T
Drop
允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作
先来看看 Deref
特征是如何工作的。
通过 *
获取引用背后的值
在正式讲解 Deref
之前,我们先来看下常规引用的解引用。
常规引用是一个指针类型,包含了目标数据存储的内存地址。对常规引用使用 *
操作符,就可以通过解引用的方式获取到内存地址对应的数据值:
fn main() { let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y); }
这里 y
就是一个常规引用,包含了值 5
所在的内存地址,然后通过解引用 *y
,我们获取到了值 5
。如果你试图执行 assert_eq!(5, y);
,代码就会无情报错,因为你无法将一个引用与一个数值做比较:
error[E0277]: can't compare `{integer}` with `&{integer}` //无法将{integer} 与&{integer}进行比较
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`
|
= help: the trait `PartialEq<&{integer}>` is not implemented for `{integer}`
// 你需要为{integer}实现用于比较的特征PartialEq<&{integer}>
智能指针解引用
上面所说的解引用方式和其它大多数语言并无区别,但是 Rust 中将解引用提升到了一个新高度。考虑一下智能指针,它是一个结构体类型,如果你直接对它进行 *myStruct
,显然编译器不知道该如何办,因此我们可以为智能指针结构体实现 Deref
特征。
实现 Deref
后的智能指针结构体,就可以像普通引用一样,通过 *
进行解引用,例如 Box<T>
智能指针:
fn main() { let x = Box::new(1); let sum = *x + 1; }
智能指针 x
被 *
解引用为 i32
类型的值 1
,然后再进行求和。
定义自己的智能指针
现在,让我们一起来实现一个智能指针,功能上类似 Box<T>
。由于 Box<T>
本身很简单,并没有包含类如长度、最大长度等信息,因此用一个元组结构体即可。
#![allow(unused)] fn main() { struct MyBox<T>(T); impl<T> MyBox<T> { fn new(x: T) -> MyBox<T> { MyBox(x) } } }
跟 Box<T>
一样,我们的智能指针也持有一个 T
类型的值,然后使用关联函数 MyBox::new
来创建智能指针。由于还未实现 Deref
特征,此时使用 *
肯定会报错:
fn main() { let y = MyBox::new(5); assert_eq!(5, *y); }
运行后,报错如下:
error[E0614]: type `MyBox<{integer}>` cannot be dereferenced
--> src/main.rs:12:19
|
12 | assert_eq!(5, *y);
| ^^
为智能指针实现 Deref 特征
现在来为 MyBox
实现 Deref
特征,以支持 *
解引用操作符:
#![allow(unused)] fn main() { use std::ops::Deref; impl<T> Deref for MyBox<T> { type Target = T; fn deref(&self) -> &Self::Target { &self.0 } } }
很简单,当解引用 MyBox
智能指针时,返回元组结构体中的元素 &self.0
,有几点要注意的:
- 在
Deref
特征中声明了关联类型Target
,在之前章节中介绍过,关联类型主要是为了提升代码可读性 deref
返回的是一个常规引用,可以被*
进行解引用
之前报错的代码此时已能顺利编译通过。当然,标准库实现的智能指针要考虑很多边边角角情况,肯定比我们的实现要复杂。
*
背后的原理
当我们对智能指针 Box
进行解引用时,实际上 Rust 为我们调用了以下方法:
#![allow(unused)] fn main() { *(y.deref()) }
首先调用 deref
方法返回值的常规引用,然后通过 *
对常规引用进行解引用,最终获取到目标值。
至于 Rust 为何要使用这个有点啰嗦的方式实现,原因在于所有权系统的存在。如果 deref
方法直接返回一个值,而不是引用,那么该值的所有权将被转移给调用者,而我们不希望调用者仅仅只是 *T
一下,就拿走了智能指针中包含的值。
需要注意的是,*
不会无限递归替换,从 *y
到 *(y.deref())
只会发生一次,而不会继续进行替换然后产生形如 *((y.deref()).deref())
的怪物。
函数和方法中的隐式 Deref 转换
对于函数和方法的传参,Rust 提供了一个极其有用的隐式转换:Deref
转换。若一个类型实现了 Deref
特征,那它的引用在传给函数或方法时,会根据参数签名来决定是否进行隐式的 Deref
转换,例如:
fn main() { let s = String::from("hello world"); display(&s) } fn display(s: &str) { println!("{}",s); }
以上代码有几点值得注意:
String
实现了Deref
特征,可以在需要时自动被转换为&str
类型&s
是一个&String
类型,当它被传给display
函数时,自动通过Deref
转换成了&str
- 必须使用
&s
的方式来触发Deref
(仅引用类型的实参才会触发自动解引用)
连续的隐式 Deref 转换
如果你以为 Deref
仅仅这点作用,那就大错特错了。Deref
可以支持连续的隐式转换,直到找到适合的形式为止:
fn main() { let s = MyBox::new(String::from("hello world")); display(&s) } fn display(s: &str) { println!("{}",s); }
这里我们使用了之前自定义的智能指针 MyBox
,并将其通过连续的隐式转换变成 &str
类型:首先 MyBox
被 Deref
成 String
类型,结果并不能满足 display
函数参数的要求,编译器发现 String
还可以继续 Deref
成 &str
,最终成功的匹配了函数参数。
想象一下,假如 Rust
没有提供这种隐式转换,我们该如何调用 display
函数?
fn main() { let m = MyBox::new(String::from("Rust")); display(&(*m)[..]); }
结果不言而喻,肯定是 &s
的方式优秀得多。总之,当参与其中的类型定义了 Deref
特征时,Rust 会分析该类型并且连续使用 Deref
直到最终获得一个引用来匹配函数或者方法的参数类型,这种行为完全不会造成任何的性能损耗,因为完全是在编译期完成。
但是 Deref
并不是没有缺点,缺点就是:如果你不知道某个类型是否实现了 Deref
特征,那么在看到某段代码时,并不能在第一时间反应过来该代码发生了隐式的 Deref
转换。事实上,不仅仅是 Deref
,在 Rust 中还有各种 From/Into
等等会给阅读代码带来一定负担的特征。还是那句话,一切选择都是权衡,有得必有失,得了代码的简洁性,往往就失去了可读性,Go 语言就是一个刚好相反的例子。
再来看一下在方法、赋值中自动应用 Deref
的例子:
fn main() { let s = MyBox::new(String::from("hello, world")); let s1: &str = &s; let s2: String = s.to_string(); }
对于 s1
,我们通过两次 Deref
将 &str
类型的值赋给了它(赋值操作需要手动解引用);而对于 s2
,我们在其上直接调用方法 to_string
,实际上 MyBox
根本没有没有实现该方法,能调用 to_string
,完全是因为编译器对 MyBox
应用了 Deref
的结果(方法调用会自动解引用)。
Deref 规则总结
在上面,我们零碎的介绍了不少关于 Deref
特征的知识,下面来通过较为正式的方式来对其规则进行下总结。
一个类型为 T
的对象 foo
,如果 T: Deref<Target=U>
,那么,相关 foo
的引用 &foo
在应用的时候会自动转换为 &U
。
粗看这条规则,貌似有点类似于 AsRef
,而跟 解引用
似乎风马牛不相及,实际里面有些玄妙之处。
引用归一化
Rust 编译器实际上只能对 &v
形式的引用进行解引用操作,那么问题来了,如果是一个智能指针或者 &&&&v
类型的呢? 该如何对这两个进行解引用?
答案是:Rust 会在解引用时自动把智能指针和 &&&&v
做引用归一化操作,转换成 &v
形式,最终再对 &v
进行解引用:
- 把智能指针(比如在库中定义的,Box、Rc、Arc、Cow 等)从结构体脱壳为内部的引用类型,也就是转成结构体内部的
&v
- 把多重
&
,例如&&&&&&&v
,归一成&v
关于第二种情况,这么干巴巴的说,也许大家会迷迷糊糊的,我们来看一段标准库源码:
#![allow(unused)] fn main() { impl<T: ?Sized> Deref for &T { type Target = T; fn deref(&self) -> &T { *self } } }
在这段源码中,&T
被自动解引用为 T
,也就是 &T: Deref<Target=T>
。 按照这个代码,&&&&T
会被自动解引用为 &&&T
,然后再自动解引用为 &&T
,以此类推, 直到最终变成 &T
。
PS: 以下是 LLVM
编译后的部分中间层代码:
#![allow(unused)] fn main() { // Rust 代码 let mut _2: &i32; let _3: &&&&i32; bb0: { _2 = (*(*(*_3))) } }
几个例子
#![allow(unused)] fn main() { fn foo(s: &str) {} // 由于 String 实现了 Deref<Target=str> let owned = "Hello".to_string(); // 因此下面的函数可以正常运行: foo(&owned); }
#![allow(unused)] fn main() { use std::rc::Rc; fn foo(s: &str) {} // String 实现了 Deref<Target=str> let owned = "Hello".to_string(); // 且 Rc 智能指针可以被自动脱壳为内部的 `owned` 引用: &String ,然后 &String 再自动解引用为 &str let counted = Rc::new(owned); // 因此下面的函数可以正常运行: foo(&counted); }
#![allow(unused)] fn main() { struct Foo; impl Foo { fn foo(&self) { println!("Foo"); } } let f = &&Foo; f.foo(); (&f).foo(); (&&f).foo(); (&&&&&&&&f).foo(); }
三种 Deref 转换
在之前,我们讲的都是不可变的 Deref
转换,实际上 Rust 还支持将一个可变的引用转换成另一个可变的引用以及将一个可变引用转换成不可变的引用,规则如下:
- 当
T: Deref<Target=U>
,可以将&T
转换成&U
,也就是我们之前看到的例子 - 当
T: DerefMut<Target=U>
,可以将&mut T
转换成&mut U
- 当
T: Deref<Target=U>
,可以将&mut T
转换成&U
来看一个关于 DerefMut
的例子:
struct MyBox<T> { v: T, } impl<T> MyBox<T> { fn new(x: T) -> MyBox<T> { MyBox { v: x } } } use std::ops::Deref; impl<T> Deref for MyBox<T> { type Target = T; fn deref(&self) -> &Self::Target { &self.v } } use std::ops::DerefMut; impl<T> DerefMut for MyBox<T> { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.v } } fn main() { let mut s = MyBox::new(String::from("hello, ")); display(&mut s) } fn display(s: &mut String) { s.push_str("world"); println!("{}", s); }
以上代码有几点值得注意:
- 要实现
DerefMut
必须要先实现Deref
特征:pub trait DerefMut: Deref
T: DerefMut<Target=U>
解读:将&mut T
类型通过DerefMut
特征的方法转换为&mut U
类型,对应上例中,就是将&mut MyBox<String>
转换为&mut String
对于上述三条规则中的第三条,它比另外两条稍微复杂了点:Rust 可以把可变引用隐式的转换成不可变引用,但反之则不行。
如果从 Rust 的所有权和借用规则的角度考虑,当你拥有一个可变的引用,那该引用肯定是对应数据的唯一借用,那么此时将可变引用变成不可变引用并不会破坏借用规则;但是如果你拥有一个不可变引用,那同时可能还存在其它几个不可变的引用,如果此时将其中一个不可变引用转换成可变引用,就变成了可变引用与不可变引用的共存,最终破坏了借用规则。
总结
Deref
可以说是 Rust 中最常见的隐式类型转换,而且它可以连续的实现如 Box<String> -> String -> &str
的隐式转换,只要链条上的类型实现了 Deref
特征。
我们也可以为自己的类型实现 Deref
特征,但是原则上来说,只应该为自定义的智能指针实现 Deref
。例如,虽然你可以为自己的自定义数组类型实现 Deref
以避免 myArr.0[0]
的使用形式,但是 Rust 官方并不推荐这么做,特别是在你开发三方库时。
Drop 释放资源
在 Rust 中,我们之所以可以一拳打跑 GC 的同时一脚踢翻手动资源回收,主要就归功于 Drop
特征,同时它也是智能指针的必备特征之一。
学习目标
如何自动和手动释放资源及执行指定的收尾工作
Rust 中的资源回收
在一些无 GC 语言中,程序员在一个变量无需再被使用时,需要手动释放它占用的内存资源,如果忘记了,那么就会发生内存泄漏,最终臭名昭著的 OOM
问题可能就会发生。
而在 Rust 中,你可以指定在一个变量超出作用域时,执行一段特定的代码,最终编译器将帮你自动插入这段收尾代码。这样,就无需在每一个使用该变量的地方,都写一段代码来进行收尾工作和资源释放。不禁让人感叹,Rust 的大腿真粗,香!
没错,指定这样一段收尾工作靠的就是咱这章的主角 - Drop
特征。
一个不那么简单的 Drop 例子
struct HasDrop1; struct HasDrop2; impl Drop for HasDrop1 { fn drop(&mut self) { println!("Dropping HasDrop1!"); } } impl Drop for HasDrop2 { fn drop(&mut self) { println!("Dropping HasDrop2!"); } } struct HasTwoDrops { one: HasDrop1, two: HasDrop2, } impl Drop for HasTwoDrops { fn drop(&mut self) { println!("Dropping HasTwoDrops!"); } } struct Foo; impl Drop for Foo { fn drop(&mut self) { println!("Dropping Foo!") } } fn main() { let _x = HasTwoDrops { two: HasDrop2, one: HasDrop1, }; let _foo = Foo; println!("Running!"); }
上面代码虽然长,但是目的其实很单纯,就是为了观察不同情况下变量级别的、结构体内部字段的 Drop
,有几点值得注意:
Drop
特征中的drop
方法借用了目标的可变引用,而不是拿走了所有权,这里先设置一个悬念,后边会讲- 结构体中每个字段都有自己的
Drop
来看看输出:
Running!
Dropping Foo!
Dropping HasTwoDrops!
Dropping HasDrop1!
Dropping HasDrop2!
嗯,结果符合预期,每个资源都成功的执行了收尾工作,虽然 println!
这种收尾工作毫无意义 =,=
Drop 的顺序
观察以上输出,我们可以得出以下关于 Drop
顺序的结论
- 变量级别,按照逆序的方式,
_x
在_foo
之前创建,因此_x
在_foo
之后被drop
- 结构体内部,按照顺序的方式,结构体
_x
中的字段按照定义中的顺序依次drop
没有实现 Drop 的结构体
实际上,就算你不为 _x
结构体实现 Drop
特征,它内部的两个字段依然会调用 drop
,移除以下代码,并观察输出:
#![allow(unused)] fn main() { impl Drop for HasTwoDrops { fn drop(&mut self) { println!("Dropping HasTwoDrops!"); } } }
原因在于,Rust 自动为几乎所有类型都实现了 Drop
特征,因此就算你不手动为结构体实现 Drop
,它依然会调用默认实现的 drop
函数,同时再调用每个字段的 drop
方法,最终打印出:
Dropping HasDrop1!
Dropping HasDrop2!
手动回收
当使用智能指针来管理锁的时候,你可能希望提前释放这个锁,然后让其它代码能及时获得锁,此时就需要提前去手动 drop
。
但是在之前我们提到一个悬念,Drop::drop
只是借用了目标值的可变引用,所以,就算你提前调用了 drop
,后面的代码依然可以使用目标值,但是这就会访问一个并不存在的值,非常不安全,好在 Rust 会阻止你:
#[derive(Debug)] s