读书导读 · MIT / Apache-2.0
《Rust 程序设计语言》全书摘要与逐章导读
引文按 MIT 或 Apache-2.0 许可使用。免费阅读原书。
《Rust 程序设计语言》讲了什么AI 辅助
一套所有权模型,从数据设计一路扩展到多线程服务器
一门语言怎么能既安全又快?Rust 的回答:把保证搬进编译器。本书分层搭建。它从社区和工具(rustup、Cargo、crate)以及小程序开始,然后安装核心模型:默认不可变的绑定、静态类型、所有权、借用和切片。结构体、枚举、Option 和模式匹配,把这个模型变成数据设计;模块和私有性把它扩展到包;泛型、trait 和单态化,在不产生运行时代价的情况下消除重复;Result、? 和 panic! 把失败放进类型系统。一个 minigrep 项目把这些部件转化为工程实践——关注点分离、测试、闭包、迭代器。后面的章节把同样的所有权规则向外推:用于堆数据、共享数据和内部可变数据的智能指针;用于无畏并发的线程、通道以及 Mutex/Arc;用于 I/O 密集型工作的异步 future 和运行时;用于没有继承的多态的 trait 对象和模式;unsafe 作为一道狭窄、经审计的逃生口。最后一个多线程网络服务器,靠跟着编译器错误走、在 TCP 上手工搭建,把几乎所有东西重新组合起来。自始至终,论证是一个重复的主张:现在接受摩擦,这样拒绝整类 bug 的是编译器,而不是运行时的运气。
一则讲透全书的寓言AI 辅助
用一个故事装下全书最核心的想法,并老实交代故事在哪里失真。
白天写下来,否则夜里会把你烧掉
从前有个村子,每盏灯笼都恰好有一位保管人,村口的大门由一位老太太把守,在你的文书对齐之前,她不让任何人通过。一盏灯笼可以交给另一位保管人,但这样第一位保管人的手里就空了,她不能假装还拿着;相比之下,便宜的锡哨子谁要都可以复制一个。旅人可以借灯笼的光——许多人可以同时借着它读书,但一旦有人想剪灯芯,其他所有读书的人都得退后,而且谁也不许从一座已经烧毁的房子里把光带出来。一个年轻的信使等得不耐烦了。“放我过去,”他说,“我会小心的;我会记得哪盏灯笼是谁的。”守门人拒绝了:“我不信半夜里的记忆。在这儿,在白天,把你的文书排整齐,夜晚就烧不到你。”于是他在门口站了一个小时,咒骂着,每当她指着一行,他就重写一遍他的路线单,直到文书终于对上了——她一言不发地打开了大门,过关不收他分文。他跑了一整夜,一次也没有撞过,一次也没有去够一盏已经熄灭的灯笼。后来村子需要更多的人手,同一位守门人检查了夜班队、一条链上带着计数登记的共享灯笼、一次只有一个工人能持有钥匙的密封库房,以及承诺会回来、而不是闲站着的信使。她还留着一扇狭窄的侧门,有标记,有见证,给那些必须不带文书过关的人,但她把他们的名字写进一本册子,并请一位邻居去读它。
故事里的每样东西代表什么
| 故事里 | 书里 |
|---|---|
| 每盏灯笼一位保管人,交出去之后手里是空的 | 所有权:每个值恰好有一个所有者,赋值会移动拥有堆数据的值,并使原来的失效 |
| 可以随意复制的锡哨子 | Copy 类型,复制而不是移动 |
| 借来的光:许多读者,但剪灯芯的人排斥所有其他人;烧毁的房子里不能带出光 | 引用与借用:共享借用可以共存,可变借用排斥所有其他借用,悬垂引用会被拒绝 |
| 在白天检查文书、过关不收费的守门人 | 编译器作为守门人:编译期检查以零运行时开销提供内存安全——没有垃圾回收,没有手动 free |
| 每当她指着一行,信使就重写他的路线单 | 由编译器驱动、基于项目的方法:现在接受摩擦,让每个编译器错误指引下一块内容 |
| 他“我会小心的”这番恳求被拒绝 | 核心主张:保证必须能被编译器验证,而不是托付给运行时的自律 |
| 夜班队、带计数登记的链式灯笼、单钥匙的库房 | 无畏并发:同样的所有权和类型检查覆盖线程、Rc/Arc 计数,以及由 Mutex 保护的共享状态 |
| 承诺会回来、而不是闲站着的信使 | 异步 future 在运行时上被 await,而不是每次调用都阻塞一个线程 |
寓言在哪里失真
这则寓言的扭曲之处,在于让守门人的检查看起来完全免费,又完全是她的功劳。书承认了故事容纳不下的代价:边界检查、RefCell 在运行时的借用检查、原子引用计数,以及每个具体类型一份编译出的特化,都是过了大门之后付出的真实代价。它也容纳不下书里根本不是所有权的那一半——Option 和 Result 把缺失和失败放进类型系统,配上穷尽的 match,trait 和 trait 对象提供没有继承的多态,模块和私有性塑造公共 API,以及测试去弥补类型检查弥补不了的缝隙,因为守门人验证文书,却从不验证意图。而侧门是最深的扭曲:安全的叙事需要一条被许可的打破它的途径,所以最终的保证落在人工审计上(以及只看得到真正运行过的代码的 Miri),而不是守门人身上。最后,整个村子是被论证的,而不是被度量的——书的证据是它自己的小示例清单,一个命令行工具和一个有意没有完成的网络服务器,没有任何基准测试。
《Rust 程序设计语言》的核心论点
全书捍卫的 10 个论点,按论证需要的顺序排列。
- 长期被接受的、在高层人体工学和低层控制之间的取舍是不必要的,因为安全的抽象和泛型,编译出的代码和手写的具体代码一样快。
“在编程语言设计中,高层的人体工学和底层的控制往往互相矛盾;Rust 挑战这种冲突。”
“High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.”
Introduction查看原文 → - Rust 系统性地拿程序员的便利,去换取编译器能验证的保证,而不是信任运行时的自律,就连它的集合 API 也是由内存布局而不是便利塑造的。
- 编译期的所有权检查以零运行时代价提供内存安全,所以既不需要垃圾回收,也不需要手动 free,而引用是对所有权必要的补充,而不是可有可无的便利。
- 缺失和失败属于类型系统,而不属于空值或异常:因为 Option<T> 和 Result<T,E> 与 T 是不同的,而且 match 必须是穷尽的,编译器迫使调用者在部署之前承认它们。
- 表明契约被打破的失败,值得立即终止,而可恢复的失败应当作为值返回,并传播给一个掌握函数所缺少的上下文的调用者,永远不要以原始 panic 的形式暴露给用户。
- 并发安全不是与内存安全分开的问题——同样的所有权和类型检查,把并发 bug 推到了编译期——然而一门低层的语言仍应提供好几种并发工具,而不是一个教条的机制。
- async 只是编译器生成的状态机之上的语法糖,而为运行时声明任务,胜过每个阻塞调用一个线程,因为每个线程的系统开销最终会占主导,而程序可以利用操作系统看不到的并发。
- Rust 在把数据与过程打包、并通过私有性和 trait 对象强制执行封装方面是面向对象的,但有意省略了继承,因为继承共享了比预期更多的东西,并缩窄了设计的灵活性。
- 默认私有的模块,加上深思熟虑的 pub use 重导出,是正确的默认值,因为隐藏内部使作者能在不破坏调用者的情况下改变内部代码,而公共 API 应当为用户而塑造,而不是为作者的文件布局。
- 因为类型检查验证的是类型而不是意图,正确性必须由带有信息丰富的失败报告的测试来断言,而程序最好的成长方式,是让编译器错误指引下一块内容,然后重构,使每个函数负责一项任务。
逐章导读
每一章都在本页:论证怎样展开、关键论点、常见误读和核心概念。从目录里选一章。
导言
第4章的所有权系统,究竟是如何不带运行时代价地兑现所承诺的安全的?
导言是怎样展开的AI 辅助
本章不是从定义开始的,而是从一个论点开始。在一段简短的出版说明之后,它欢迎读者,并立刻划出全书的核心前提:高层的人体工学和低层的控制“往往互相矛盾”,而 Rust“挑战这种冲突”。这是个修辞上高效的动作——它在解释这门语言的任何事情之前,先告诉读者 Rust 为解决什么问题而存在,所以此后的每个主张,都可以被读作对那一个命题的证据。
展开:一次受众调查,从宽到窄
正文随后通过问“Rust 是为谁而设”并以五幅相继的肖像来回答而展开。这个顺序是有意的,而不是随意的:它从开发者团队开始,那里论证的是,编译器作为“守门人”,抓住别的语言留给评审和测试的 bug——并且工具(Cargo、rustfmt、语言服务器)被提出来,作为系统编程不必感觉原始的证据。从那里它向外拓宽到学生(可及性,一个欢迎人的社区),公司(一长串生产用途,以 Firefox 这个显眼的例子结尾),以及开源开发者(一份贡献的邀请)。最后一幅肖像,重视速度和稳定的人,是绕回开篇的那一幅:通过零成本抽象,“安全的代码”也意在成为“快的代码”。这一节以最强的形式重述论点作结——Rust“最大的雄心,是消除程序员几十年来所接受的取舍”。
转折:从论证到指示
一个清晰的支点随之而来。“本书为谁而写”和“如何使用本书”把语域从说服转向定向:文字不再做关于 Rust 的主张,而开始做关于读者在书中路径的主张。先决条件被设定(你以前编过程;用什么语言无所谓),一个结构上的区分被引入(概念章节对项目章节,第2、12、21章被指名为项目),然后是逐章的巡礼,从安装和 Cargo,经过所有权、结构体和枚举、模块、集合、错误处理、泛型和生命周期、闭包和迭代器、智能指针、并发和异步、OOP 比较、模式,到高级主题——结束于多线程网络服务器。附录 A 到 G 最后列出。值得注意的是,这番巡礼还提供了一条分支路线:不耐烦的读者从第2章开始,一丝不苟的读者跳到第3章,再回来。
收尾:许可,然后是一条实用的警告
本章以放松它刚刚规定的顺序结束——“读这本书没有错误的方式”——然后加上唯一一条真正是教学而不是组织性的建议:学会读编译器的错误信息,本身就是学习 Rust 的一部分。因此,本书将有意包含无法编译的例子,并警告读者,运行一个随机的片段可能按设计失败。一个吉祥物(Ferris)被引入,作为此类代码的视觉标记,之后一行指向 GitHub 上源码的提示结束了全章。
关键论点,以及支撑它们的原文
Rust 的构建,是为了消解令人愉快的高层语言与对低层细节的真正控制之间的通常取舍。
作者同时从两头论证这一点:编译器拒绝那些通常让低层工作痛苦的、难以捉摸的 bug,而特性被设计成编译出与手写等价物一样快的代码,所以控制几乎不花人体工学的代价,人体工学也几乎不花速度的代价。
“在编程语言设计中,高层的人体工学和底层的控制往往互相矛盾;Rust 挑战这种冲突。”
“High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.”
Introduction查看原文 →
Rust 还把现代开发者工具带进了一个历来缺乏它们的领域,这是同一个人体工学论证的一部分。
点名了三件具体的工具:Cargo 在整个生态系统中一致地处理依赖和构建,rustfmt 强制执行一种共享的代码风格,Rust 语言服务器为编辑器提供补全和行内错误——这些都是系统程序员以前没有的便利。
“在编程语言设计中,高层的人体工学和底层的控制往往互相矛盾;Rust 挑战这种冲突。”
“High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.”
Introduction查看原文 →
这不是纸上的愿望:这门语言已经在承担真实的生产工作。
作者指出,数百家大小公司用 Rust 做命令行工具、网络服务、DevOps、嵌入式设备、音视频转码、生物信息学、搜索、物联网、机器学习,以及 Firefox 浏览器的主要部分。注意这是一份采用者广度的清单,而不是与其他语言的度量比较。
“在编程语言设计中,高层的人体工学和底层的控制往往互相矛盾;Rust 挑战这种冲突。”
“High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.”
Introduction查看原文 →
另外 1 个论点
Rust 所提供的“速度”,既涵盖程序跑得多快,也涵盖你能多快地持续修改它们。
作者把 Rust 的编译期检查,与没有它们的语言里那些脆弱的遗留代码——那里开发者不敢碰能工作的代码——相对照;因为这些检查在新特性和重构中都成立,这里的稳定意味着修改的自由,而零成本的目标也让由此产生的安全代码保持快速。
“在编程语言设计中,高层的人体工学和底层的控制往往互相矛盾;Rust 挑战这种冲突。”
“High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.”
Introduction查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
编译器在评审之前就阻止 bug
低层代码藏着微妙的错误,别的语言只能靠大量测试和专家评审才暴露出来。Rust 的编译器拒绝构建含有这些错误的代码,包括并发方面的,所以一个技能参差的团队争论的是程序逻辑,而不是追捕缺陷。
机制 2
廉价的抽象让安全保持快速
Rust 的目标是看起来高层、却编译成与手写低层版本做同样机器工作的特性。因为抽象不增加运行时代价,选择安全的写法,并不意味着接受一个更慢的程序。
机制 3
坏掉的例子教人读错误
既然编译器阻止有缺陷的代码,进步就取决于理解它说的是什么。所以本章说,书会印出失败的例子连同确切的信息,把每一次拒绝变成通往可工作代码的路标,而不是死胡同。
常见误读
如果编译器拒绝构建有 bug 的代码,Rust 项目几乎不需要测试。
编译器只阻止它能检查的那几类错误,比如内存和并发故障;你的程序是否算出正确的答案,超出它的能力之外,这就是为什么作者专门用一整章讲测试,恰恰因为 Rust 的安全保证不涵盖逻辑。
“在 Rust 中,编译器扮演守门人的角色,拒绝编译带有这些难以捉摸的 bug 的代码,包括并发 bug。”
“In Rust, the compiler plays a gatekeeper role by refusing to compile code with these elusive bugs, including concurrency bugs.”
Introduction查看原文 →
零成本抽象意味着任何 Rust 程序自动跑得和手工调优的低层代码一样快。
作者把这呈现为语言所追求的方向——编译成快的低层代码的高层特性——所以它是特性被设计方式的一个性质,而不是你写的任何东西都出来是最优的保证。
“通过追求零成本抽象——编译成与手写代码一样快的更低层代码的更高层特性——Rust 力求让安全的代码同时也是快的代码。”
“By striving for zero-cost abstractions—higher-level features that compile to lower-level code as fast as code written manually—Rust endeavors to make safe code be fast code as well.”
Introduction查看原文 →
导言的核心概念
- 零成本抽象
- 编译成与手写低层代码一样快的更高层特性,所以安全的代码也可以是快的。
- 适用处: 是语言特性的设计目标,而不是每个程序自动最优的承诺。
- 编译器作为守门人
- 编译器拒绝构建含有难以捉摸的低层和并发 bug 的代码,所以团队争论的是逻辑,而不是追捕 bug。
- 适用处: 它阻止它能检查的那几类 bug;逻辑的正确性仍需要测试。
- 概念章节与项目章节
- 概念章节讲授 Rust 的一个方面;项目章节构建小程序,应用所学的东西。
- 适用处: 只有第2章、第12章和第21章是项目章节。
另外 0 个概念
导言划线与批注AI 陪读
“高层的人体工学和底层的控制往往互相矛盾”
“High-level ergonomics and low-level control are often at odds”
查看原文 →整本书的论点压缩在一个从句里。注意它被框定为设计空间的冲突,而不是技术上的冲突——Rust 的推销是,这个取舍一直是习惯,而不是定律。
“编译器扮演守门人的角色,拒绝编译”
“the compiler plays a gatekeeper role by refusing to compile”
查看原文 →被当作解放来兜售,但它也是权威的转移:评审的负担从资深的人转移到一台无法与之争辩的机器上。与借用检查器搏斗,是这里没人印出来的成本项。
“Rust 语言服务器为集成开发提供支持”
“The Rust Language Server powers integrated development”
查看原文 →一本把章节编号更新到 21 的书里,一块过时的化石。RLS 多年前就已被弃用,改用 rust-analyzer——工具那一段,比它所称赞的语言老得更快。
另外 5 条批注
“加密货币、生物信息学、搜索引擎”
“cryptocurrencies, bioinformatics, search engines”
查看原文 →公司采用的清单做着论证的工作:以广度证明成熟。但一份领域的清单不是一份结果的清单——从来没有人列出被放弃的 Rust 重写。
“没有这些检查的语言里那些脆弱的遗留代码,开发者往往不敢去修改”
“the brittle legacy code in languages without these checks, which developers are often afraid to modify”
查看原文 →本章情感上最精确的一句话。Rust 真正的卖点不是速度或安全——而是重新获得碰自己代码的许可。
“如果你完全是编程新手,你最好”
“If you’re entirely new to programming, you would be better served”
查看原文 →诚实,却悄悄地与两页之前的“学生”一节相矛盾,那一节承诺让系统概念“对更多的人变得可及,尤其是编程新手”。欢迎的地垫上有一级台阶。
“我们会提供许多无法编译的例子,连同错误信息”
“we’ll provide many examples that don’t compile along with the error message”
查看原文 →一种真正不寻常的教学承诺:把失败当作主要的文本来教,而不是当作附录。大多数语言书都把它们的坏代码藏起来。
“在大多数情况下,我们会引导你找到任何无法编译的代码的正确版本。”
“In most situations, we’ll lead you to the correct version of any code that doesn’t compile.”
查看原文 →这句话连续出现了两次——第二次孤零零地搁在 Ferris 表格应该在的地方。一条说明,在转换成纯文本时丢了它的图片。
读后感AI 陪读
章节结构坦白了行文从未坦白的东西:Rust 哪里痛
一篇同时干三件活的引言——宣言、受众分流和用户手册——前一件明显做得比后一件好。宣言很紧凑:人体工学与控制被陈述为一个假的两难,零成本抽象是逃生口,还有一个真正动人的观察:不安全语言最深的代价,是对你自己代码库的恐惧。受众分流是它摇晃的地方:编程新手在一节里受到欢迎,又在另一节里被客气地改道,而作为成熟度的、公司领域的广度清单,证明了采用,却没有证明满意。自始至终,编译器被拟人化为盟友和守门人,而本章从未点出这笔交易的价格——与一个总是赢的工具争论的漫长学徒期。阅读地图是最有用的部分,也是最有揭示性的:第2章可以跳过,所有权在结构体之前就有自己的一章,异步需要在线程之后加一章,这是对语言实际上哪里学起来痛的一幅素描。两个文字化石——Rust 语言服务器,和一处在 Ferris 的表格丢失的地方被重复的说明——提醒我们,这是一份活的、打了补丁的文档,对一本其主题推崇无畏重构的书来说,这本身相当符合品牌调性。
在 FeedLitra 里读导言,每段旁边都有这些卡片。
开始读书第 1 章:入门
一旦项目真的有了依赖,Cargo.lock 的行为是怎样的?
第 1 章是怎样展开的AI 辅助
如何开篇。 本章一开始,把整件事框定为一次旅程,并立刻与读者订下契约:三个要点,许诺安装、一个 Hello, world! 程序和 Cargo。这三件事是本章的骨架,而文字随后恰恰按这个顺序兑现它。第一个乐章——安装——是纯粹的后勤,而且是有意防御性的,而不是概念性的。在任何 Rust 出现之前,文字停下来教记法($ 和 > 是提示符,不是要键入的字符),然后按平台拆分(Linux/macOS 通过 rustup 的 curl 脚本,Windows 通过网站和 Visual Studio),然后预料到失败:你还需要一个链接器,所以安装一个 C 编译器;如果 rustc --version 什么也没给你,就检查 %PATH%/$PATH;如果那也不行,就去问社区。只有在逃生口之后,它才加上内务——rustup update、rustup self uninstall、用于离线文档的 rustup doc、对编辑器的耸肩(“几乎任何文本编辑器都能完成工作”),以及用 cargo new get-dependencies 加 cargo add 的预缓存步骤,使后面的章节无需网络也能工作。这里的姿态是一份清除障碍的安装指南,还不是教学文本。
如何展开。 第二个乐章“Hello, World!”,从安装工具链转向使用它,并按一贯的“先做后解释”的节奏展开。先是机制:创建 ~/projects/hello_world(带一个 CMD 变体),把文件命名为 main.rs,注意 .rs 扩展名和下划线的约定,键入清单 1-1,然后 rustc main.rs 和 ./main(或 .\main)。成功以情感来标记——“恭喜……欢迎!”——然后文字才绕回头,剖析读者已经运行过的东西。“Rust 程序的剖析”把四行拆成值得记住的几个论断:main 始终是入口点;花括号包裹每个函数体,附带一条风格说明,和一个指向 rustfmt 的提示;println! 是个宏,! 就是线索;字符串是个参数;分号结束表达式。“编译与执行”然后把镜头从语法拓宽到过程,用 ls/dir /B 让产物可见(main,Windows 上还有 main.exe 和 main.pdb),并画出赋予这一步意义的比较:Rust 是预先编译的,所以二进制文件不带工具链就能传递,不像 .rb、.py 或 .js 文件——“语言设计中,一切都是取舍。”(注意产物列表内部是不一致的:它说 PowerShell 显示“同样的三个文件”,而 ls 的例子列出两个,周围的文字说 Linux 和 macOS 上是两个。)
转折。 铰链是该节的收尾一行:rustc 对“简单的程序”来说“没问题”,但项目会长大。这句话让读者刚学的工具退役,并为第三个乐章“Hello, Cargo!”提供了动机。本章现在在更好的基础设施上重建同一个微不足道的程序,教学法自始至终是比较性的——Cargo 的每个特性都被呈现为与 rustc 工作流的差异。cargo new hello_cargo 产生的是结构,而不是单个文件:Cargo.toml、src/main.rs、一个 Git 仓库和 .gitignore(在已有仓库内被抑制,可用 --vcs=git 强制)。清单 1-2 被逐行读过——[package]、name、version、edition,以及一个空的 [dependencies],它明确是给第2章的一张期票——而crate 这个术语在这里被引入。这个约定随后被陈述成一条原则(代码在 src,其余一切在顶层;“万物各有其位”),并提供 cargo init,作为通往读者几页之前手工建起的项目的桥梁。
如何收尾。 从这里起,本章按便利程度递增,走过命令集:cargo build(二进制文件在 target/debug,外加自动管理的 Cargo.lock),cargo run(一步构建并执行——而演示被用来展示缓存,因为第二次运行跳过了“Compiling”),以及 cargo check(不产生可执行文件,因此更快,是 Rustacean 在写代码时运行的命令)。一份要点式的回顾巩固了这四个,文字然后宣布平台之分结束——Cargo 的命令在各处都相同,所以不再有特定于操作系统的指示。随后是两个简短的尾声:cargo build --release 用于优化的 target/release 构建,带着 dev/release 配置的取舍,和只对发布二进制文件做基准测试的警告;以及“利用 Cargo 的约定”,它承认 Cargo 对玩具项目增益甚少,同时展示回报——git clone、cd、cargo build,就是你加入任何现有 Rust 项目的方式。小结把开篇的要点作为成就镜像回来,并以交给读者下一步路径的选择结束:要更多练习就去第2章的猜数游戏,或先去第3章的基础。于是弧线是后勤 → 一个被解释的最小程序 → 同一个程序为真实工作而重新配备工具,读者从“没有安装 Rust”被送到“配备了一位在职 Rust 程序员的工作流”。
关键论点,以及支撑它们的原文
Cargo 靠的是它所强加的结构赢得位置,而不是在微小的程序上有什么速度优势。
作者承认,对单文件的 hello world,Cargo 相比直接用 rustc 增益甚少,而它在 src/main.rs 里生成的代码与手写的相同。它增加的是脚手架:一个 Cargo.toml、src 目录、带 .gitignore 的 Git 仓库,以及之后记录确切依赖版本的 Cargo.lock。回报被陈述为取决于增长——一旦项目跨越多个文件或需要一个依赖,让 Cargo 协调构建就更容易——而作者用这样一个终态来说明:任何现有项目都可以用三条普通命令克隆、进入并构建。
“Cargo 期望你的源文件位于 src 目录之内。”
“Cargo expects your source files to live inside the src directory.”
§1.3 Hello, Cargo!查看原文 →
实际的日常循环是写代码时 cargo check,执行时 cargo run,而不是 cargo build 加上手敲的二进制文件路径。
直接顺着前面关于约定的那一点,作者用两个观察把它们变成习惯。cargo check 被描述为快得多,因为它跳过了产生可执行文件,所以适合持续验证;cargo run 一条命令就编译并启动,而记录显示它在没有改动时不打印“Compiling”一行,因为 Cargo 检测到文件未变,就直接运行现有的二进制文件。这就是作者说大多数开发者会用 cargo run、而不是去记 ./target/debug/hello_cargo 的原因。
“Cargo 期望你的源文件位于 src 目录之内。”
“Cargo expects your source files to live inside the src directory.”
§1.3 Hello, Cargo!查看原文 →
把编译与执行分开,是 Rust 接受的一个取舍,而不是白赚的胜利。
作者从接收者一方来推理:把一个 .rb、.py 或 .js 文件交给某人,他们必须装有匹配的语言,而编译好的 Rust 可执行文件,在一台根本没有 Rust 的机器上也能运行。但同样的比较也反过来成立——在那些动态语言里,一条命令就同时编译并运行,而 Rust 要求两步。本章以语言设计中一切都是取舍这句话,结束了这个比较。
“语言设计中,一切都是取舍。”
“Everything is a trade-off in language design.”
§1.2 Hello, World!查看原文 →
另外 1 个论点
调试和发布是两种有意不同的构建,计时度量属于发布那种。
因为编译是单独的一步,作者可以给它附上两种配置。默认的调试构建保持不优化,所以开发期间重新构建很快,其输出落在 target/debug;cargo build --release 打开优化,使程序运行得更快,却使编译花的时间更长,并把结果放在 target/release。由此作者得出明确的指示:要对发布的可执行文件做基准测试,因为调试二进制文件的计时,反映的是快速重建的配置,而不是交付的性能。
“语言设计中,一切都是取舍。”
“Everything is a trade-off in language design.”
§1.2 Hello, World!查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么跳过二进制文件能加快检查
把验证过的代码变成可运行的可执行文件,是构建昂贵的尾巴。作者指出 cargo check 做完编译期验证就停下,所以它早得多地返回一个裁决;这就是为什么许多 Rust 用户在写代码时反复运行它,把 cargo build 留到他们真正需要某个能运行的东西的时候。
机制 2
编译一次,在没装 Rust 的地方运行
因为 Rust 是预先编译的,rustc 在任何东西运行之前,就把源文件变成一个完成的二进制文件。你把那个二进制文件交给的人,从不碰编译器,不像脚本需要解释器在场。代价是,构建和运行是两条独立的命令,而不是一条。
机制 3
固定的布局让 Cargo 能够接管
Cargo 假定源码在 src 里,顶层放配置、README 和许可文件。因为布局是可预测的,而不是每个项目各自不同,Cargo 能定位代码、获取并构建依赖,并在每个操作系统上暴露相同的命令,这就是一个陌生人的项目在克隆之后马上就能构建的原因。
常见误读
println! 不过是 Rust 拼写 print 函数的方式,感叹号只是装饰或强调。
感叹号是表示你正在调用宏的信号;同名的函数会写成不带标记的 println。作者把宏描述为编写代码的代码,用来扩展 Rust 的语法,并警告说它们并不总是遵守函数遵循的规则,把完整的讲解留给后面的章节。
“首先,println! 调用的是一个 Rust 宏。”
“First, println! calls a Rust macro.”
§1.2 Hello, World!查看原文 →
一旦 rustup 报告“Rust is installed now. Great!”,这台机器就完全具备构建 Rust 程序的条件了。
还需要一个链接器,来把编译的输出合并成单个文件,而安装程序不提供。大多数系统已经有了,但当出现链接器错误时,修复办法是安装一个 C 编译器——macOS 上是 xcode-select --install,Linux 上是 GCC 或 Clang(比如 Ubuntu 的 build-essential)。作者又加了第二个无论如何都该有一个的理由:一些常见的 Rust 包依赖 C 代码。
“我们将通过 rustup 下载 Rust,这是一个管理 Rust 版本和相关工具的命令行工具。”
“We’ll download Rust through rustup, a command line tool for managing Rust versions and associated tools.”
§1.1 Installation查看原文 →
第 1 章的核心概念
- rustup
- 一个安装和管理 Rust 版本及相关工具的命令行工具。
- 适用处: 可选的:还有其他安装方法,而且可能仍需要单独的链接器或 C 编译器。
- Cargo
- Rust 的构建系统和包管理器,构建代码并获取依赖。
- 适用处: 对单文件程序,相比直接用 rustc 增益甚少;它的价值在有多个文件或依赖时出现。
- 宏(println!)
- 生成代码的代码;! 标记的是一次宏调用,而不是普通的函数调用。
- 适用处: 宏并不总是遵循函数的规则;细节推迟到第20章。
- 预先编译
- 你编译一次,就可以把可执行文件交给没装 Rust 的人。
- 适用处: 代价是多一个单独的编译步骤,不像动态语言一条命令就能运行。
另外 0 个概念
第 1 章划线与批注AI 陪读
“cargo add rand@0.10.1 trpl@0.2.0”
查看原文 →被固定的版本悄悄承认,“任何更新的稳定版本都能工作”适用于编译器,而不适用于生态系统。crate 没有得到稳定性的承诺。
“在 Linux 和 macOS 上,你会看到两个文件。在 Windows 的 PowerShell 里,你会看到与使用 CMD 时同样的三个文件。”
“On Linux and macOS, you’ll see two files. With PowerShell on Windows, you’ll see the same three files that you would see using CMD.”
查看原文 →前面文本说 PowerShell 上的 ls 显示可执行文件——但这里却滑到了三个文件,而下面的 CMD 列表恰好显示三个。“两个文件”这句话,和上面 ls 的输出(main、main.rs)是一致的;格格不入的是 PowerShell 的说法,Windows 上的读者得到了互相矛盾的预期。
“Rust 的宏是一种编写生成代码的代码、来扩展 Rust 语法的方式”
“Rust macros are a way to write code that generates code to extend Rust syntax”
查看原文 →可爱的是,第一个程序的第一行就已经是个宏了——这门语言不越过它自己的函数语法就没法说你好。书把解释推迟了十九章。
另外 4 条批注
“Finished dev [unoptimized + debuginfo] target(s) in 2.85 secs”
查看原文 →过时的输出:现代的 Cargo 打印的是 dev profile,而 secs 早就变成了 s。承诺向前兼容的这一章,自己却在展示过去。
“语言设计中,一切都是取舍。”
“Everything is a trade-off in language design.”
查看原文 →本章唯一一句里没有命令的话。一句平淡的旁白,悄悄为此后的每一项妥协颁发了许可——预先编译、借用检查器、整本书。
“万物各有其位,万物各在其位。”
“There’s a place for everything, and everything is in its place.”
查看原文 →一种童谣节奏做着意识形态的工作:约定被兜售为舒适,而不是约束。Cargo 的主张是穿着整洁的外衣到来的。
“这使你成为一名 Rust 程序员——欢迎!”
“That makes you a Rust programmer—welcome!”
查看原文 →敲六行字就授予了身份。再加上“Rustacean”,这一章在教你任何一个概念之前,就把你纳入了一个社区——社会性的安装,与软件的安装并行。
读后感AI 陪读
一章关于稳定性的内容,其示例比 Rust 本身老得更快
一章真正的主题不是 Rust、而是归属的内容:它在同一口气里安装了一套工具链和一个部落(“Rustacean”、“欢迎!”),并靠无情地具体来赢得信任——每个主张都是一条你可以运行的命令,每条命令都印出了它的输出。教学法对推迟是诚实的:宏到第20章,版本到附录 E,所有权根本看不到,最后一段甚至提供了两种合法的阅读顺序。但文本明显风化了。它的 Cargo 记录打印 Finished dev [unoptimized + debuginfo] ... secs,是 Cargo 很久以前就放弃了的格式;而且它对 PowerShell 显示两个文件还是三个文件自相矛盾。这里有更深的讽刺:一章以承诺稳定性保证开篇——“任何更新的稳定版本都应当能按本书的内容如预期工作”——自己却是书里被时间侵蚀得最厉害的部分,因为它的内容是工具的输出,而不是语言的语义。被固定的 rand@0.10.1 trpl@0.2.0 把边界说得明明白白:编译器承诺不破坏你,生态系统什么也不承诺。而最能说明问题的单个细节是,println! 是个宏。Rust 不跳出普通的函数语法就没法说你好,本章注意到这一点,然后在十九章里把它搁到一边——全书整个方法的一个精确的缩影:展示东西能工作,点出其奇怪之处,许诺以后解释。
在 FeedLitra 里读第 1 章,每段旁边都有这些卡片。
开始读书第 2 章:编写猜数游戏
对 Ordering 做 match,是如何完成这次比较的?
第 2 章是怎样展开的AI 辅助
开篇——一个小程序,一块一块能工作的部件地组装起来。 本章从只有一个 Cargo 项目开始,它的组织性动作,是在深入解释任何东西之前,先让某样可运行的东西出现在屏幕上。一个 let mut 绑定创建一个可增长的缓冲区,io::stdin().read_line 填充它,而 {} 占位符把它打印回来。教学发生在接缝里:read_line 并不是简单地返回文本,而是返回一个可能代表失败的值,作者演示了如果你忽略它,语言会做什么——删掉 expect 调用并重新构建,会产生一个编译器警告,说结果必须被使用。这个小实验是本章第一个真正的主张:Rust 不会让一个可能的失败被悄悄丢掉,这就是为什么初学者的程序保留一个失败即崩溃的调用,即使真正的恢复还没有教。读者得到能工作的回显代码,和一个被标为临时的诚实占位符。
展开——引入外部代码,以及这对你意味着什么承诺。 输入能工作之后,本章需要一个秘密数字,所以它加入了 rand crate。叙事在这里从语言语法拓宽到构建系统。依赖一节中的版本行,并不是对“最新的任何东西”的请求——它声明一个最低版本和一个兼容的范围,而在第一次构建时,Cargo 把那个范围解析为一个具体的版本,并把它记录在锁文件里。以后的构建重读这份记录,而不是重新解析,所以同样的源码持续针对同一个库编译。作者用版本使之可见:一个落在 0.10.1 的项目,在重新构建时保持在那里,而显式的更新命令只会把它移到 0.10.2,同时拒绝可用的 0.999.0,因为那个发布版位于依赖行所允许的范围之外。教训是默认可复现,升级是一个有意的动作。
转折——第一次尝试比较失败了,而失败就是重点。 现在程序有了一个键入的猜测和一个秘密数字,显而易见的下一步是 cmp,加上对三个 Ordering 分支的 match。它编译不过。错误报告了一个不匹配——期待整数的地方是一个字符串引用——而作者的解读刻意不是“我的比较是错的”,而是“静态类型系统拒绝把文本与数字比较”。修复是本章最整齐的一段推理。名字 guess 被重用于一个新值:trim 剥掉所键入行末尾的换行,parse 转换清理后的文本,而显式的 u32 标注说明把它转换成什么。写下这一个标注,同时确定了两边,因为随机值除了默认的整数回退之外没有别的约束;一旦它与一个 u32 比较,编译器也在那里推断出 u32,比较于是终于通过了类型检查。
收尾——把它变成游戏,然后让它宽容。 最后一段把单次的程序转化为可玩的程序。把提示包进无尽的循环,买到了重复的猜测,却去掉了程序的出口:一次示例会话在宣布获胜之后,仍继续要求另一个数字,唯一的逃脱办法,是键入非数字的东西,让运行崩溃。作者把这点出为一个糟糕的出口,恰恰因为每次输错输入都会这样,并在获胜分支之后加了一个 break。这留下一处粗糙的边缘,而最后一步把它磨平,办法是用对 parse 返回值的 match 取代 expect:成功分支产出数字,失败分支运行 continue,所以坏值永远到不了会崩溃的辅助函数,循环只是再问一遍——键入 foo 现在得到一个新的提示,而不是结束运行。本章没有夸大这一点,指出失败分支上的兜底模式匹配每个解析错误,并同样丢弃它们的细节。这是初学者级别的权宜之计,对一个做完了的游戏足够好,真正的错误恢复被明确留给后面的章节。
关键论点,以及支撑它们的原文
Rust 拒绝让你悄悄丢掉一个可能含有失败的结果,把错误处理推到明处。
作者删掉了失败即崩溃的调用并重新构建:编译器打印了一条警告,说未使用的结果必须被使用,这就是为什么初学者的程序,即使在真正的恢复还没有教之前,也保留那个调用。
“Rust 警告说,你没有使用 read_line 返回的 Result 值,这表明程序没有处理一个可能的错误。”
“Rust warns that you haven’t used the Result value returned from read_line, indicating that the program hasn’t handled a possible error.”
Chapter 2. Programming a Guessing Game查看原文 →
Cargo 靠冻结它已经选好的版本、直到你要求新的,来给你可复现的构建。
作者表明,一个解析到 rand 0.10.1 的项目,在重新构建时会留在那里,而显式的更新命令只会把它移到 0.10.2,同时拒绝可用的 0.999.0,因为那个发布版落在依赖行所允许的兼容范围之外。
“换句话说,由于 Cargo.lock 文件,你的项目将一直保持在 0.10.1,直到你显式地升级。”
“In other words, your project will remain at 0.10.1 until you explicitly upgrade, thanks to the Cargo.lock file.”
Chapter 2. Programming a Guessing Game查看原文 →
第一次尝试把猜测与秘密数字比较失败了,因为语言的静态类型不会把文本与数字比较。
构建这个比较,产生了一个类型不匹配的错误,报告说在出现整数的地方,期待的是一个字符串引用;作者把这解读为强静态类型系统拒绝了这个配对,而不是比较代码里的 bug。
“错误的核心是说类型不匹配。Rust 有一个强大的静态类型系统。”
“The core of the error states that there are mismatched types. Rust has a strong, static type system.”
Chapter 2. Programming a Guessing Game查看原文 →
另外 3 个论点
把数字类型写在转换后的猜测上,就足以同时确定秘密数字的类型。
直接顺着那个类型错误,作者推理说,随机值否则会回退到默认的有符号整数;既然对它唯一的另一个约束是与一个 u32 猜测的比较,这个标注就传播开来,两边于是对齐。
“此外,这个示例程序中的 u32 标注,以及与 secret_number 的比较,意味着 Rust 会推断 secret_number 也应当是 u32。”
“Additionally, the u32 annotation in this example program and the comparison with secret_number means Rust will infer that secret_number should be a u32 as well.”
Chapter 2. Programming a Guessing Game查看原文 →
把提示包进无尽的循环,买到了重复的猜测,却让程序失去了出口,直到加上一个 break。
一次示例会话在宣布获胜之后,仍继续要求另一次猜测,唯一的出路,是键入不是数字的东西,这会让运行崩溃而结束;作者指出这是个糟糕的出口,因为任何坏输入都会如此,并在获胜之后加了一个 break。
“在 You win! 之后加上 break 这一行,使程序在用户猜对秘密数字时退出循环。”
“Adding the break line after You win! makes the program exit the loop when the user guesses the secret number correctly.”
Chapter 2. Programming a Guessing Game查看原文 →
把结果变成对成功与失败的 match,把对坏输入的崩溃,转换成又一个礼貌的提示。
在完成的程序里,键入 foo 得到一个新的提示,而不是结束运行,因为失败分支跳到下一轮循环;作者指出了这个修复的局限,即兜底的模式不加区别地吞掉每一个解析失败。
“所以,实际上,程序忽略了 parse 可能遇到的所有错误!”
“So, effectively, the program ignores all errors that parse might encounter!”
Chapter 2. Programming a Guessing Game查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
锁文件如何让构建保持一致
第一次构建时,Cargo 把每个依赖解析为一个具体版本,并写进锁文件;以后的构建重读那份记录,而不是再次解析,所以同样的代码持续针对同一个库编译。只有显式的更新命令才会重新解析,而且即便如此,它也留在兼容的范围之内。
机制 2
为什么 u32 标签同时固定了两边
输入以文本的形式到来,所以作者为一个新值重用名字 guess:trim 去掉末尾的换行,parse 把清理后的文本变成标注里所写的数字类型。因为秘密数字唯一的另一条线索是默认的整数,把它与一个 u32 比较,使编译器也在那里推断出 u32,于是两边终于匹配。
机制 3
为什么 match 分支取代了崩溃
parse 交回的要么是成功值,要么是失败值,而 match 逐个分支地测试它,在第一个吻合的模式处停下。把成功分支导向解析出的数字,把失败分支导向 continue,意味着坏文本再也到不了会崩溃的辅助函数;循环只是开始另一轮并再问一遍。
常见误读
在依赖一节里列出一个库的版本,意味着每次构建都会拿走存在的更新的发布版。
那一行陈述的是一个最低版本外加一个兼容的范围,而锁文件钉住实际选中的确切版本,所以重新构建不会变;就连显式的更新命令,也只在兼容的范围内移动,这就是为什么它接受 0.10.2,却忽略 0.999.0。
“换句话说,由于 Cargo.lock 文件,你的项目将一直保持在 0.10.1,直到你显式地升级。”
“In other words, your project will remain at 0.10.1 until you explicitly upgrade, thanks to the Cargo.lock file.”
Chapter 2. Programming a Guessing Game查看原文 →
用下划线匹配失败的情形并继续,就算恰当地处理了错误。
下划线模式匹配每个失败值,并把它们的细节丢掉,所以程序一视同仁地忽略所有解析错误;作者把它呈现为初学者级别的权宜之计,真正的错误恢复被推迟到后面的章节。
“所以,实际上,程序忽略了 parse 可能遇到的所有错误!”
“So, effectively, the program ignores all errors that parse might encounter!”
Chapter 2. Programming a Guessing Game查看原文 →
第 2 章的核心概念
- 默认可变性
- Rust 变量除非你写 mut,否则不能改变。
- 适用处: 适用于绑定,也适用于引用,引用默认也是不可变的。
- Result 枚举
- 一个值,要么是带结果的 Ok,要么是带失败信息的 Err。
- 适用处: expect 只在 Err 时崩溃;真正的恢复要到第9章。
- 关联函数
- 实现在类型本身上的函数,用 :: 调用,比如 String::new。
- 适用处: 不同于用点语法在实例上调用的方法。
- crate 与 SemVer 说明符
- crate 是一捆 Rust 源码;像 0.10.1 这样的版本意味着 ^0.10.1。
- 适用处: 兼容的范围止于 0.11.0 之下;Cargo.lock 钉住确切的构建。
- match 表达式
- 由分支组成的构造;每个分支把一个模式,与值符合它时运行的代码配成一对。
- 适用处: 它在第一次成功匹配之后就停止,所以后面的分支从不被检查。
- 遮蔽
- 为一个新值重用一个现有的变量名,常常是另一种类型。
- 适用处: 在这里只用来把一种类型转成另一种;详细规则在第3章。
另外 2 个概念
- trim 与 parse
- trim 剥掉周围的空白,包括换行;parse 把清理后的字符串转换成标注的数字类型。
- 适用处: parse 只对逻辑上能变成数字的字符有效,所以它返回 Result。
- 兜底的下划线
- _ 模式匹配每个 Err 值,不管其内容。
- 适用处: 它丢弃错误的细节,所以程序不加区别地忽略所有解析错误。
第 2 章划线与批注AI 陪读
“把 {} 想成把一个值固定在原位的小螃蟹钳子”
“Think of {} as little crab pincers that hold a value in place”
查看原文 →在一个其余平淡的教程口吻里,唯一一个异想天开的意象。Rust 的吉祥物是一只螃蟹,所以这个比喻不是装饰——而是被夹带进教学法的社区内部玩笑。
“像变量一样,引用默认是不可变的”
“like variables, references are immutable by default”
查看原文 →注意这个修辞策略:Rust 的每个难懂的概念,都被引入作为一个已经咽下去的概念的平行物。可变性是滩头阵地;引用乘着它进来。
“引用是个复杂的特性,而 Rust 的主要优势之一,就是使用引用有多么安全和容易。”
“References are a complex feature, and one of Rust’s major advantages is how safe and easy it is to use references.”
查看原文 →复杂,却容易——这句话在一口气里自相矛盾。这是书在对人人都碰壁的那个特性做损害控制。
另外 11 条批注
“warning: unused `Result` that must be used”
查看原文 →书有意让你看到失败。Rust 真正的老师是编译器,而作者们知道这一点——他们在训练你读诊断,而不是背规则。
“我们只想在出问题时让这个程序崩溃,所以可以使用 expect”
“we just want to crash this program when a problem occurs, so we can use expect”
查看原文 →至少对捷径是诚实的。但 expect 是一千个初学者代码库僵化的地方——教程的脚手架成了某个人的生产习惯。
“请注意,这段代码现在还不能编译,我们会解释的。”
“Note that this code won’t compile just yet, as we will explain.”
查看原文 →他们有意制造一个类型错误。把 String 对整数展示成一次亲身经历的碰撞,比任何一段关于静态类型的文字教得都多。
“Cargo 忽略 0.999.0 这个版本。”
“Cargo ignores the 0.999.0 release.”
查看原文 →一个荒谬的假版本号,让脱字符规则变得可感。0.999.0 无法从 0.10.1 到达——这一点之所以落地,恰恰因为差距是滑稽的。
“幸好,Rust 允许我们用一个新值遮蔽 guess 先前的值”
“helpfully Rust allows us to shadow the previous value of guess with a new one”
查看原文 →“幸好(helpfully)”在做说服的工作。遮蔽也是新手读别人代码时最令人困惑的唯一特性——书在读者能反对之前就把它卖了出去。
“这个示例程序中的 u32 标注,以及与 secret_number 的比较,意味着 Rust 会推断 secret_number 也应当是 u32”
“the u32 annotation in this example program and the comparison with secret_number means Rust will infer that secret_number should be a u32 as well”
查看原文 →这是本章安静的亮点:一个类型决定向后流过整个程序。底部的一个标注,向上够到并确定了一个更早声明的变量。
“比如,如果字符串里含有 A👍%,就没有办法把它转换成数字。”
“If, for example, the string contained A👍%, there would be no way to convert that to a number.”
查看原文 →那个表情符号是个玩笑,却也是个真实的要点——Rust 的字符串默认是 UTF-8,所以这不是个假想的输入。
“但还有另一种办法,可以逃脱这个贪得无厌的怪物”
“But there’s another way to escape this insatiable monster”
查看原文 →把一个 bug 当作出口来教。诚实而好笑,但在收回之前,它悄悄地把“崩溃”当作面向用户的特性,常态化了整整两页。
“退出循环也意味着退出程序,因为循环是 main 的最后一部分。”
“Exiting the loop also means exiting the program, because the loop is the last part of main.”
查看原文 →一行提醒:控制流是有形的——你把花括号放在哪里,决定了 break 的含义。
“Err(_) => continue,”
查看原文 →从 expect 到 match 的整个转变,装进两行,而 continue 作为 match 分支的值才是真正的惊喜:控制流坐在一个本应是表达式的位置上。书滑过了这一点,没有点名。
“所以,实际上,程序忽略了 parse 可能遇到的所有错误!”
“So, effectively, the program ignores all errors that parse might encounter!”
查看原文 →对一件书在别处警告要避免的事,措辞欢快。悄悄吞掉每一个错误,恰恰是第9章将不得不撤销的习惯。
读后感AI 陪读
Rust 靠分阶段的失败来教学,而不是靠讲解
猜数游戏这一章,是全书唯一一场真正的入门仪式,把它当作一条单一的弧线来读,会揭示一种有意的构造:它不是一份关于游戏的教程,而是一份关于如何在编译器手下活下来的教程。它在一个紧凑的螺旋中展开——搭起一个 Cargo 项目的脚手架,读一行输入,把它打印出来,加一个依赖,比较两个值,修复比较所暴露的类型冲突,把整个东西包进循环,最后把崩溃升级为优雅的恢复。螺旋的每一圈都使前一圈的一部分失效,而这正是重点:这一章靠修订而不是靠讲解来教学。我看得最透的,胜过其他任何东西的,是分阶段失败的教学法。未使用的 Result 警告,有意编译不过的 String 对整数的比较,被当作“逃生口”提供、然后又被收回的无限循环——这些都不是一份对初学者友好的草稿的偶然。作者们已经决定,读者真正的老师是 rustc,所以本章隐藏的课程,是学会把诊断读作指示,而不是拒绝。对第一章来说,这是个真正不寻常的赌注,也是本章最好的品质。与之并行的是第二个策略:每个难懂的想法,都打扮成一个熟悉的想法到来。可变性是滩头阵地,引用乘着“像变量一样,引用默认是不可变的”进来,遮蔽凭副词“helpfully”被预先批准,而整件事被偶尔的螃蟹钳子玩笑,和假的 0.999.0 版本号润滑着——幽默恰恰放在摩擦本会出现的地方。静悄悄的杰作,是一个单独的 u32 标注向后流过整个程序,确定了几页之前声明的一个变量的类型的那一刻;作为向上游传播的力量的类型推断,是本章里最真正有 Rust 形状的东西,却只得到一句话。我最尖锐的判断,是关于本章的安慰修辞。有三次,它在读者能反对之前就把东西卖了出去:引用在同一口气里是“复杂,然而安全而容易”,这是对人人都碰壁的那个特性的损害控制;expect 被引入,作为一条心照不宣的捷径,却仍会在一千个初学者的代码库里成为永久的习惯;而收尾的重构落在 Err(_) => continue 上,配着一句欢快的注解“程序忽略所有错误”——恰恰是第9章将不得不撤销的那种条件反射。书还从它自己最有意思的动作旁边溜了过去:continue 坐在一个本应放表达式的位置上,这是 Rust 中控制流和值是同一类东西的第一个真正的暗示。所以我要对读者说的是:敲一遍,弄坏它,把错误大声读出来——编译器的输出才是这一章真正的文本。然后把它的便利当作暂时的。expect、被下划线吞掉的错误、那个只因为碰巧是 main 里最后一件事才退出的循环——这些都是保质期很短的脚手架,而本章做的诚实的事,就是承认这一点。信任分阶段的失败;不信任令人宽心的副词。
在 FeedLitra 里读第 2 章,每段旁边都有这些卡片。
开始读书第 3 章:通用编程概念
不可变性与后面的所有权和借用有什么关系?
第 3 章是怎样展开的AI 辅助
开篇:一个值得捍卫的默认值。 本章不是从语法开始的,而是从一种立场开始——在 Rust 里,变量除非你另有说明,否则是不可变的。作者让编译器自己来说话:let x = 5; 之后跟着 x = 6;,被直接拒绝为“cannot assign twice to immutable variable”,编译器还好心地指向 mut。他没有为这个限制道歉,而是把它重新框定为一个特性。如果程序的一部分假定某个值永远不动,而另一部分悄悄改变了它,由此产生的 bug 追踪起来苦不堪言;在编译期拒绝这次赋值,在它能存在之前,就删除了这一整类问题。从那里起,开篇向外展开成你被给予的替代品:用于真正修改的 mut,用于固定值的常量,和用于重新绑定的遮蔽。遮蔽得到了最仔细的处理,因为它很容易被误认为是更软的 mut。作者靠一对对比来了结这个问题:let spaces = “ ”; let spaces = spaces.len(); 能编译,因为每个 let 创建一个全新的变量,可以有新的类型,而同一想法的 mut 版本,以类型不匹配的错误失败。教训是精确的——遮蔽转换一个值,可能跨越类型,而它留下的绑定又是不可变的。开篇然后把这一切落到 Rust 的类型系统上:每个值都有静态类型,取自标量家族(整数、浮点数、bool、char)或复合家族(元组、数组)。
展开:保证是在哪里付费的。 确立了值和类型之后,本章转向当你伸手进去,以及当你把代码打包起来时会发生什么。数组索引是第一个演示,也是对代价的第一次诚实核算。一个程序向用户索要一个五项数组中的元素;键入 10,运行就以“index out of bounds: the len is 5 but the index is 10”结束,而随后的 println! 永远不会触发。作者明确指出,这个检查无法提前到编译期,因为索引来自用户输入——所以保证是在运行时、有意地买下的。他强调的取舍,是立即停止程序,对比交回恰好位于数组之后的任何字节;Rust 选择前者。这一节随后转向要求参数带类型的函数,和悄悄支配本章其余部分的区分:语句对表达式。一个块产出它最后的表达式,而作者用一个小小的破坏行为把这一点说透——他取一个能工作的 plus_one,其函数体是 x + 1,并加上一个分号。分号把表达式变成了什么也不产出的语句,声明的 i32 返回值不再匹配空的单元值,编译器报告类型不匹配,同时提示去掉分号可能就能修好。注释和 if 补全了这一段,if 的条件限制为 bool,而 if 本身表现为一个表达式。
转折:从单个值到重复。 本章以 Rust 的三种循环形式转向真正的控制流,而这个转向把面向表达式的思维带了下去,而不是丢掉它。先是 loop,带着 break 和 continue 和标签——但作者真正的兴趣,是循环不必只重复工作;它可以交回一个结果。他把值放在 break 后面:一个计数器在达到 10 时以 break counter * 2; 退出,把 20 赋给外围的变量。循环成了值的来源,这与一个块产出它的最后一个表达式是同一个想法,现在应用于重复。while 随后用于条件重复,for 用于遍历集合。
收尾:一个为 for 辩护的论证,和一个向前的指引。 本章不是以三种循环的中立菜单结束的;而是以一个建议和一个理由结束。作者论证说,用 while 按索引遍历一个集合,既更冒险又更慢。他通过把数组缩小到四个元素、而条件仍读作 index < 4 来演示风险——代码 panic 了,读者可以看到,手写的边界与数据之间的耦合有多脆弱。他然后越过演示去推理:for 版本根本不会跑出末尾,而且它去掉了每次迭代的长度比较,这就是他称之为最安全、最惯用的选择的原因。值得指出,速度的主张是归于那些被去掉的检查,而不是归于任何度量——它是来自机制的论证,而不是基准测试。本章以练习和一个指向所有权的向前指引结束。本章一路上提出的一条诚实的警告,值得带着往前走:Rust 的严格并不是均匀的。对于 u8,值 256 在调试构建中结束程序,却在发布构建中悄悄回绕为 0(257 回绕为 1,如此类推)——所以你在开发时依赖的溢出保护,并不是你交付的二进制文件的行为。
关键论点,以及支撑它们的原文
Rust 拒绝编译对不可变变量的赋值,是个特性,而不是个麻烦。
作者展示了 let x = 5; 之后跟着 x = 6; 被拒绝为“cannot assign twice to immutable variable”,编译器建议用 mut;他推理说,如果一段代码假定一个值永不移动,而另一段偶尔改变它,由此产生的 bug 以后很难追踪。在编译期抓住它,就去掉了那一整类问题。
“当我们试图改变一个被指定为不可变的值时,得到编译期错误是很重要的,因为这种情形恰恰可能导致 bug。”
“It’s important that we get compile-time errors when we attempt to change a value that’s designated as immutable, because this very situation can lead to bugs.”
§3.1 Variables and Mutability查看原文 →
建立在不可变性之上,作者论证说,遮蔽不只是 mut 的第二种风味:它回答的是不同的需求。
他对比了两段代码:重新声明 let spaces = “ ”; let spaces = spaces.len(); 能编译,因为每个 let 创建一个新变量,而把 spaces 标为 mut 并赋以它的长度,则以类型不匹配的错误失败。因此遮蔽让你转换一个值,甚至转成另一种类型,而最终的绑定保持不可改变。
“通过使用 let,我们可以对一个值执行几次变换,但在这些变换完成之后,让变量是不可变的。”
“By using let, we can perform a few transformations on a value but have the variable be immutable after those transformations have completed.”
§3.1 Variables and Mutability查看原文 →
面对一个无效的索引,Rust 宁愿立即停止程序,也不让它读它不该读的内存。
一个程序索要一个五项数组中的元素;键入 10,运行就以“index out of bounds: the len is 5 but the index is 10”结束,而末尾的 println! 永远不执行。作者指出,这个检查无法更早,因为索引来自用户输入,所以保证是在运行时付费的。
“Rust 通过立即退出,而不是允许这次内存访问并继续运行,来保护你免受这种错误。”
“Rust protects you against this kind of error by immediately exiting instead of allowing the memory access and continuing.”
§3.2 Data Types查看原文 →
另外 3 个论点
一个游离的分号,可以悄悄地把一个返回值的函数体变成返回无的函数体。
作者取一个能工作的 plus_one 函数,其函数体是 x + 1,并加上一个分号;编译器随后报告类型不匹配,因为声明的 i32 返回值不再匹配空的单元值,它甚至提示去掉分号可能就能修好。这个例子建立在先前的语句对表达式的区分之上。
“plus_one 函数的定义说它将返回一个 i32,但语句不会求值为一个值,这用 () 来表达,即单元类型。”
“The definition of the function plus_one says that it will return an i32, but statements don’t evaluate to a value, which is expressed by (), the unit type.”
§3.3 Functions查看原文 →
从单个值转向重复,作者表明,循环不必只重复——它还可以交回一个结果。
他把值放在 break 后面:一个计数器循环在计数器达到 10 时以 break counter * 2; 退出,把 20 赋给外围的变量,所以循环被用作一个值的来源,而不只是一块重复的工作。
“为此,你可以把想要返回的值,加在你用来停止循环的 break 表达式之后;这个值将从循环中返回,这样你就可以使用它,如下所示:”
“To do this, you can add the value you want returned after the break expression you use to stop the loop; that value will be returned out of the loop so that you can use it, as shown here:”
§3.5 Control Flow查看原文 →
在本章结尾,作者论证说,用 while 按索引遍历一个集合,既比用 for 更冒险,又更慢。
他把数组缩小到四个元素,而条件仍读作 index < 4——代码 panic 了。进一步推理,for 版本根本不会跑出末尾,并且去掉了每次迭代的长度比较,这就是他称之为最安全、最惯用的选择的原因。他把速度上的收益归于那些被去掉的检查,而不是任何度量。
“然而,这种做法容易出错;如果索引值或测试条件不正确,我们可能会让程序 panic。”
“However, this approach is error-prone; we could cause the program to panic if the index value or test condition is incorrect.”
§3.5 Control Flow查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
分号把值变成了无
在 Rust 中,表达式产出一个值,但加上分号会把它转换成什么也不产出的语句。所以以 x + 1; 结尾的函数体,交回的是空的单元类型,而不是数字,一个声明返回 i32 的函数,会因类型不匹配的错误而编译失败。
机制 2
内存访问之前的索引检查
读取 a[i] 之前,先检查 i 是否小于数组长度。因为用户键入的索引在编译期是未知的,检查必须在运行时发生,而一旦失败,程序就当场停止,而不是交回恰好位于数组之后的任何字节。
机制 3
重用名字会产生新变量
用同一个名字再写一次 let,并不会覆盖旧变量,而是创建一个新的,所以新值可以有不同的类型。这就是为什么一个文本值可以被它的长度所取代,而得到的绑定仍然是不可变的,除非你要求 mut。
常见误读
既然再写一次 let 和 mut 都能让你把新值放在旧名字之下,遮蔽基本上就是 mut 的一种宽松形式。
用 mut,变量保持它原来的类型,所以把一个长度(一个数字)赋给原先装着文本的变量,会被拒绝为类型不匹配;遮蔽则改用 let 声明一个全新的变量,可以有不同的类型,而且那个新绑定又是不可变的,除非你也写 mut。
“通过使用 let,我们可以对一个值执行几次变换,但在这些变换完成之后,让变量是不可变的。”
“By using let, we can perform a few transformations on a value but have the variable be immutable after those transformations have completed.”
§3.1 Variables and Mutability查看原文 →
因为 Rust 如此严格,溢出的算术总会被抓住并停止。
作者区分了两种构建模式:对于 u8,值 256 在调试构建中结束程序,却在发布构建中悄悄回绕为 0,257 变成 1,如此类推——所以你在开发时可以依赖的溢出保护,并不是所交付的二进制文件的行为。
“对于 u8,值 256 变成 0,值 257 变成 1,如此类推。”
“In the case of a u8, the value 256 becomes 0, the value 257 becomes 1, and so on.”
§3.2 Data Types查看原文 →
第 3 章的核心概念
- 默认不可变
- let 绑定除非你写 mut,否则不能被重新赋值。
- 适用处: 你仍然可以用 mut 选择退出;绑定的不可变性本身并不是深层的所有权或线程安全。
- 常量
- 始终不可变的命名值,用 const 声明,显式写类型,在编译期固定。
- 适用处: 只允许常量表达式;没有运行时计算的值,也从不 mut。
- 遮蔽
- 用一个新的 let 重用一个名字,创建一个可能有不同类型的新变量。
- 适用处: 需要 let 关键字,并持续到被再次遮蔽或作用域结束;与 mut 不同,它可以改变类型。
- 静态类型
- 每个值都有编译期已知的类型,是推断的或标注的。
- 适用处: 当有许多可能的类型时,推断会失败,所以需要标注。
- 标量类型与复合类型
- 单值类型(整数、浮点数、bool、char)对分组类型(元组、数组)。
- 适用处: 元组混合类型、长度固定;数组是同类型、长度固定、栈分配的。
- 带边界检查的索引
- 在读取 a[i] 之前,Rust 验证 i 小于数组长度,如果不是就 panic。
- 适用处: 当索引在编译期不可能知道时,比如用户输入,这个检查在运行时执行。
另外 6 个概念
- 语句对表达式
- 语句做事而不返回任何东西;表达式求值为一个值,而分号把表达式变成语句。
- 适用处: let 绑定和函数定义是语句,所以它们不能被用作值。
- 编译器作为守门人
- Rust 拒绝编译类型对不上的代码,而不是去猜测或强制转换。
- 适用处: 适用于参数标注、if 条件,以及 if/else 各分支类型的匹配。
- loop 关键字
- 永远重复一个块,直到代码或用户停止它。
- 适用处: 需要 break 或 return 才能从代码中终止。
- 循环标签
- 以单引号开头的名字,让 break/continue 以外层循环为目标。
- 适用处: 只在嵌套循环时需要;否则适用最内层的循环。
- for 循环
- 对集合或范围中的每一项运行一次代码。
- 适用处: 对集合和范围有效;rev 反转一个范围。
- 零成本抽象
- 一种更高层的形式,运行时的成本不高于手动的版本。
- 适用处: 这里只狭窄地表明:for 循环去掉了每次迭代的边界比较。
第 3 章划线与批注AI 陪读
“它们并不意味着你不是个好程序员!有经验的 Rustacean 仍然会遇到编译器错误。”
“they do not mean that you’re not a good programmer! Experienced Rustaceans still get compiler errors.”
查看原文 →一本技术手册停下来安抚你的自尊——不寻常,也说明问题。它承认借用检查器时代的学习曲线痛到需要一番鼓劲。
“Rust 编译器保证,当你声明一个值不会改变时,它真的不会改变,所以你不必自己去追踪它。”
“The Rust compiler guarantees that when you state that a value won’t change, it really won’t change, so you don’t have to keep track of it yourself.”
查看原文 →这才是真正的推销:不可变性不是道德上的卫生,而是把簿记从你的脑子里卸载给机器。之后 Rust 的每一项限制,都遵循这同一笔交易。
“编译器能够在编译期求值一组有限的操作”
“The compiler is able to evaluate a limited set of operations at compile time”
查看原文 →用 60*60*3 而不是 10800——常量的存在,是为了让人能审计这个算术。可读性作为验证,而不是便利。
另外 17 条批注
“我们可以对一个值做几次变换,但在这些变换完成之后,变量是不可变的”
“we can perform a few transformations on a value but have the variable be immutable after those transformations have completed”
查看原文 →遮蔽是不让你失去保证的逃生口——构造期间可变,之后不可变。优雅,也是让每个读一串 let x 的新手困惑的东西。
“依赖整数溢出的回绕行为,被视为一个错误。”
“Relying on integer overflow’s wrapping behavior is considered an error.”
查看原文 →调试时 panic,发布时悄悄回绕——所以同样的代码,因标志而有两种语义。书把依赖它称为错误,但它仍是你在生产中可能绊倒、却在测试中永远碰不到的、已交付的行为。
“字符”在 Unicode 里其实不是一个概念,所以你人类直觉里的“字符”,可能与 Rust 里的 char 不吻合”
“a “character” isn’t really a concept in Unicode, so your human intuition for what a “character” is may not match up with what a char is in Rust”
查看原文 →到目前为止最诚实的一句话。Rust 没有糊弄这个泄漏——它把 char 点名为一个方便的谎言,并把清算推迟到第8章。
“如果你不确定该用数组还是向量,那么很可能你应该用向量。”
“If you’re unsure whether to use an array or a vector, chances are you should use a vector.”
查看原文 →直白,值得注意:本章认真地教了数组,然后告诉你大多不要用它们。无论如何教栈分配的情形,是为了建立第4章的直觉,而不是为了实践。
“这个检查必须在运行时发生,尤其是在这种情形下,因为编译器不可能知道用户会输入什么值”
“This check has to happen at runtime, especially in this case, because the compiler can’t possibly know what value a user will enter”
查看原文 →对静态保证的局限的一个安静的让步:这里的安全,是靠运行时代价和一次崩溃买来的,而不是证明。Rust 的承诺是“没有无效的内存访问”,从来不是“没有失败”。
“要求在函数定义里写类型标注,意味着编译器几乎不需要你在别处使用它们”
“Requiring type annotations in function definitions means the compiler almost never needs you to use them elsewhere”
查看原文 →一处不错的设计诚实:摩擦不是税,而是边界契约。标注门口,推断内部。
“let x = (let y = 6);”
查看原文 →本章靠有意弄坏东西来教学。注意错误信息悄悄透露了什么——let 允许出现在 if/while 条件里,这暗示了文字没有深究的 let 链。
“你可以写 x = y = 6,让 x 和 y 都有值 6;在 Rust 里并非如此”
“you can write x = y = 6 and have both x and y have the value 6; that is not the case in Rust”
查看原文 →Rust 放弃了 C 的一个便利,来杀死经典的 if (x = 0) 这一类 bug。这里的每一项限制,都是某个人已经付过款的交易。
“如果你在表达式的末尾加上分号,你就把它变成了语句,它就不会再返回值了。”
“If you add a semicolon to the end of an expression, you turn it into a statement, and it will then not return a value.”
查看原文 →一个字符改变了一个函数的含义。这单一的一句话,解释了大多数初学者的 Rust 错误——而编译器的“请去掉这个分号”的提示就是证明。
“隐式返回 `()`,因为它的函数体没有尾表达式或 `return` 表达式”
“implicitly returns `()` as its body has no tail or `return` expression”
查看原文 →单元类型不请自来,作为无的形状。值得注意,Rust 没有无声的“没有值”——缺失本身是有类型的。
“Rust 不会自动尝试把非布尔类型转换成布尔类型”
“Rust will not automatically try to convert non-Boolean types to a Boolean”
查看原文 →没有真值性。JavaScript 多年来 if (0)/if (“”) 的民间传说消失了——你必须说出你真正的意思。
“编译器会更复杂,对代码做出的保证也更少”
“the compiler would be more complex and would make fewer guarantees about the code”
查看原文 →难得一见的时刻,书从编译器的利益而不是程序员的利益出发来论证。这条规则不是任意的;它是你所购买的保证的价格。
“你可以在 break 表达式之后添加想要返回的值”
“you can add the value you want returned after the break expression”
查看原文 →break 携带一个值,使 loop 也成为表达式——这是语句一节里同样的“一切都求值”的逻辑,现在应用于控制流。这一章比它看上去的要统一。
“它也很慢,因为编译器添加了运行时代码来执行条件检查”
“It’s also slow, because the compiler adds runtime code to perform the conditional check”
查看原文 →我要稍微反驳一下:对固定大小数组的边界检查,常常被优化掉。for 真正的胜利是正确性,而本章自己的第一个论证已经提出了它。
“这段代码好看一点,不是吗?”
“This code is a bit nicer, isn’t it?”
查看原文 →书唯一一次直截了当的哄劝。按谁的标准好看?它从未说——它只是假定你会点头,这就是品味被传递而不是被论证的方式。
“大多数 Rustacean 会用 for 循环”
“most Rustaceans would use a for loop”
查看原文 →诉诸部落习俗,而不是诉诸编译器。注意,安全论证和社区习惯论证被融合进了一句话——方便。
“for number in (1..4).rev”
查看原文 →悄悄地是本章最棘手的一行:1..4 排除 4,然后 rev 把它翻转,所以你必须同时持有两条差一规则,才能得到 3,2,1。却被当作更简单的选项来兜售。
读后感AI 陪读
认真地讲了数组,然后承认你无论如何都会用向量
把它当作一个整体来读,这一章悄悄地富有论辩性。它以一番鼓劲开篇——编译器错误并不意味着你是个糟糕的程序员——而这番宽慰就是个暗示:接下来的一切,都是一连串被收回的便利,而书知道它需要把你留在房间里。默认不可变,然后 mut、const 和遮蔽作为三种形状不同的出口,是开场的动作,而书直白地陈述了真正的推销:当你声明一个值不会变,编译器保证它,于是你不用再自己追踪它。那一笔单一的交易——现在有摩擦,簿记卸载给机器——是本章每条规则的引擎,而本章比它特性巡礼的表面所暗示的要统一得多。类型一节看起来像一张参考表,却一直在流露诚实:const 的存在,部分是为了让人可以审计 60*60*3,而不是眯着眼看 10800,可读性作为验证;char 被直截了当地点名为关于 Unicode 的一个方便的谎言,清算被推迟到第8章;数组被认真地教了,然后紧接着是“直接用向量”,承认这一课是第4章栈对堆的直觉,而不是实践。最尖锐的裂缝是整数溢出:调试时 panic,发布时回绕,所以同样的源码,因构建标志而有两种语义,而把依赖它称为“一个错误”,并不能让你在生产中可能碰到、却在测试中永远碰不到的行为下架。数组边界检查是对此的诚实伴侣——一个运行时检查,一次崩溃,靠代价而不是证明买来。Rust 的承诺从来不是“没有失败”,而只是“没有无效的内存访问”,而本章毫不退缩地这么说。中间的转折,语句对表达式,是本章的设计哲学变得可见而不是被陈述的地方。
类型标注只在函数边界上是必需的——标注门口,推断内部——所以摩擦是契约,而不是税。赋值什么也不返回,以失去 C 的 x = y = 6 为代价,杀死了 if (x = 0)。缺失本身是有类型的:没有尾表达式的函数返回 (),即无的形状,不请自来,却已声明。而一个分号把表达式变成语句,把一个有返回的函数变成一个 () 函数——这单一的一句话,解释了大多数初学者的 Rust 错误,而编译器的“请去掉这个分号”的提示,证明语言预料到了你的错误。if 的规则延伸了同样的逻辑:没有真值性,没有隐式强制转换,并且——难得地——是一个从编译器的利益而不是你的利益出发的论证,因为自动转换会意味着一个更复杂的编译器,做出更少的保证。你被要求用击键来为证明付费。循环一节通过把“一切都求值”应用于控制流,结束了这条弧线:break 携带一个值,所以 loop 也是个表达式,而标签在没有 goto 的情况下解决了嵌套 break 的问题。但对 for 的论证,是我会推得最用力的地方。本章叠了三个主张——更安全,更快因为边界检查消失了,以及“大多数 Rustacean 都会用它”——并在一句话里,把一个编译器的论证和一个部落习俗的论证融合了起来。速度的主张比听上去更站不住脚;对固定大小数组的边界检查,常常被完全优化掉。for 真正的理由是正确性,而本章自己的第一个论证已经赢得了它。
继续阅读
然后是“这段代码好看一点,不是吗?”——唯一一次直截了当的哄劝,用一个从未点名的标准衡量的好看,假定你会点头。这就是品味被传递而不是被论证的方式。而作为更简单的选项提供的那个例子,for number in (1..4).rev(),悄悄地是本章最棘手的一行:先是排他的范围,然后被反转,同时持有两条差一规则,来产生 3,2,1。我要对读者说的是:别把它读成在有趣的章节之前要记住的语法。把它读成销售凭证。这里的每一项限制——不可变性、没有真值性、没有链式赋值、强制的签名、只接受 bool 的条件——都是某个人已经用来换取保证的便利,而本章对哪些保证是真正的证明、哪些是带着 panic 的运行时检查,出奇地坦率。现在就学会辨别其中的差别,因为第4章的所有权是同一笔交易,价格高出十倍,而且不会附带鼓劲的话。
在 FeedLitra 里读第 3 章,每段旁边都有这些卡片。
开始读书第 4 章:理解所有权
引用如何让你使用一个值而不取得所有权?
第 4 章是怎样展开的AI 辅助
开篇:一个被框定为内存布局的问题。 本章不是从语法开始的,而是从一个关于数据住在哪里的问题开始。栈和堆被引入,作为激发性的对比——栈便宜而固定,堆需要分配和簿记——而作者从这个对比推出了需要一套纪律,来管理堆内存何时被归还。这套纪律以一小组规则的形式到来:每个值有一个单一的所有者,当所有者的作用域关闭时,值就被丢弃。演示刻意很朴素:一个声明在块内的 String,在右花括号处被释放,哪里都没写 free。要说的是,困难的手动要求——恰好一次分配对应恰好一次释放——已经交给了编译器,由它自己插入清理调用。
第一个展开:为什么赋值必须移动。 确立了基于作用域的清理之后,作者紧逼其后果。把一个 String 赋给另一个,只复制指针、长度和容量,这会让两个变量指向同一个堆缓冲区——而按刚刚确立的作用域规则,两者都会被释放。这就是重复释放,本章的解决办法,是当场使原来的变量失效,使赋值成为移动而不是复制。clone 然后作为明确的、可见的替代被提出,用于堆数据确实需要复制的时候。只在栈上的 Copy 类型被划出来,作为廉价的例外,而整套移动或复制的逻辑,被表明在值传入和传出函数时同样适用。贯穿始终的论证是,这一切在运行时都不花任何成本:没有垃圾回收器,没有引用计数,因为违反规则的代码就是编译不过。
转折:所有权在繁文缛节上付费变得太贵。 本章的支点是对摩擦的承认,而不是一个新特性。因为值移动进函数,一个只想检查一个 String 的函数,必须把它交回去——作者展示了 calculate_length 返回一个 (String, usize) 元组,除了返回调用者自己的数据之外毫无理由。这被点名为对一个常规操作来说过多的工作,而引用作为补救被提出:引用借用而不拥有,所以当引用的作用域结束时,值依然存在。借用规则随之而来,并且是被论证的,而不是被断言的。数据竞争被定义为三个条件同时成立——两个指向同一数据的指针,至少一个在写,没有同步——而一个可变引用或任意多个不可变引用的规则,直接消除了第一个条件。当不可变借用仍然存活时试图做 &mut 借用,会产生借用检查错误,而不是运行时的异常行为。作者小心地缓和了听上去严厉的部分:引用的作用域在其最后一次使用处结束,所以不重叠的借用可以自由共存。规则禁止的是时间上的重叠,而不是两种引用出现在同一个函数里。
第二个展开:切片修复了先前的设计看不见的 bug。 本章然后反转了自己早先的做法,来说明一个关于设计的要点。展示了一个返回索引 5 的 first_word,随后是对 clear 的调用——而索引与字符串的状态没有联系,变得毫无意义,却编译得毫无怨言。切片版本借用的是数据本身,所以 clear 与未完成的不可变借用冲突,编译器立刻提出反对。论证是,把一个范围与它的数据绑定,把一类无声的过时值 bug,转化成了编译错误。
收尾:零成本的推广。 最后的乐章拓宽了这个想法。因为字符串字面量本来就是指向二进制文件的字符串切片,声明为 first_word(s: &str) 的函数,直接接受字面量、String 的切片、整个 String 的切片,甚至一个普通的 &String——适用范围更广,签名却更简单。这个概念然后完全逃出了文本:对一个五元素整数数组的 &a[1..3],产生一个等于 [2, 3] 的切片,&[T] 是一般的“指针加长度”形式。本章把它的三条线索——所有权、借用、切片——收拢成一个主张结束:它们合起来提供的内存安全,在程序运行之前就已完全定下。
关键论点,以及支撑它们的原文
所有权买到了内存安全,却不用在程序运行时为它付费。
作者的论证是,所有的检查都发生在执行之前:违反一条规则,代码就是编译不过,所以在运行时没有簿记要做——没有垃圾回收器,没有引用计数。
“所有权的任何特性都不会在程序运行时拖慢你的程序。”
“None of the features of ownership will slow down your program while it’s running.”
§4.1 What is Ownership?查看原文 →
让作用域来决定内存何时被释放,取代了容易出错的、手动配对的分配与释放。
作者指出了恰好一次分配与恰好一次释放相匹配的困难要求,然后展示了声明在块里的 String 在右花括号处被释放,哪里都没有写显式的 free 调用。
“我们需要让恰好一次分配,与恰好一次释放配对。”
“We need to pair exactly one allocate with exactly one free.”
§4.1 What is Ownership?查看原文 →
从每个函数里交还所有权,繁文缛节太多,这就是引用成为必要的原因。
在确立了值会移动进函数之后,作者展示了 calculate_length 返回一个 (String, usize) 元组,纯粹是为了让调用者拿回它的 String,并称这是对本应是常规的事情做了过多的工作。借用随后取代了它:因为引用不拥有这个值,当引用的作用域结束时,值依然存在,如带标注的版本所演示的。
“但对于一个本应常见的概念来说,这太繁文缛节,也太费事了。”
“But this is too much ceremony and a lot of work for a concept that should be common.”
§4.1 What is Ownership?查看原文 →
另外 3 个论点
把借用限制为一次一个可变引用或多个不可变引用,才使 Rust 能在程序运行之前抓住数据竞争。
作者把数据竞争定义为三件事同时发生——两个指向同一数据的指针,其中一个在写,没有同步——而规则恰恰禁止这种组合;在不可变借用仍然存活时试图做 &mut 借用,会产生编译错误。这个限制不像听上去那么严厉,因为引用的作用域在其最后一次使用处结束,所以不重叠的借用是被允许的。
“在任何给定的时刻,你要么可以有一个可变引用,要么可以有任意多个不可变引用。”
“At any given time, you can have either one mutable reference or any number of immutable references.”
§4.2 References and Borrowing查看原文 →
返回切片而不是普通的索引,使一整类过时数据的 bug 成为不可能。
反转先前的设计,作者先展示了 first_word 返回索引 5,然后清空字符串:索引与字符串的状态没有联系,所以这个现在毫无意义的值编译得干干净净。切片版本借用的是数据本身,所以在切片仍被使用时调用 clear,与未完成的不可变借用冲突,编译器立即报告问题。
“必须担心 word 中的索引与 s 中的数据失去同步,这既乏味又容易出错!”
“Having to worry about the index in word getting out of sync with the data in s is tedious and error-prone!”
§4.3 The Slice Type查看原文 →
切片是一种一般的“指针加长度”的想法,而把切片作为参数,让函数更广泛可用,且不花成本。
因为字符串字面量本来就是字符串切片,声明为 first_word(s: &str) 的函数,直接接受字面量、String 的切片、整个 String 的切片,甚至一个普通的 &String 引用。同样的想法延伸到文本之外:对一个五元素整数数组的 &a[1..3],产生一个等于 [2, 3] 的切片。
“把函数定义为接受字符串切片,而不是对 String 的引用,使我们的 API 更通用、更有用,而不损失任何功能:”
“Defining a function to take a string slice instead of a reference to a String makes our API more general and useful without losing any functionality:”
§4.3 The Slice Type查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
作用域结束触发自动清理
每个值有一个所有者,当该所有者的作用域关闭时,编译器插入对 drop 的调用,归还内存。作者展示了声明在块内的 String 在右花括号处被释放,所以程序员过去手工做的一次分配对一次释放的配对,自动地、恰好一次地发生了。
机制 2
赋值移动,以避免重复释放
赋值一个 String 只复制指针、长度和容量,所以两个变量会指向同一个堆缓冲区。如果两者都保持有效,两者都会在作用域结束时被释放——一次重复释放。因此 Rust 使原来的变量失效,使赋值成为移动;clone 是复制堆数据的显式办法。
机制 3
排他借用排除了数据竞争
数据竞争需要两个指向同一数据的指针,至少一个在写,并且没有同步。只允许一个可变引用或任意多个不可变引用,直接消除了第一个条件,所以编译器可以拒绝有竞争的程序——把 &mut 与存活的不可变借用混用,会以借用检查错误失败,而不是在运行时异常行为。
常见误读
既然 Rust 在有不可变引用时禁止可变引用,你在单个函数里,对一个值就永远只能创建一种引用。
要紧的是时间上的重叠,而不是出现在同一个函数里。引用的作用域在其最后一次使用处结束,所以一旦先前的不可变借用读完了,对同一个值的可变借用就被接受;不重叠的作用域可以各自持有自己的可变引用。
“在任何给定的时刻,你要么可以有一个可变引用,要么可以有任意多个不可变引用。”
“At any given time, you can have either one mutable reference or any number of immutable references.”
§4.2 References and Borrowing查看原文 →
把一个 String 赋给另一个,要么复制文本,要么让两个变量都保持可用,就像其他大多数语言里的赋值那样。
只有指针、长度和容量被复制,而原来的变量当场失效——否则两个所有者会在作用域结束时释放同一个堆缓冲区,也就是作者警告的重复释放。复制实际的数据需要调用 clone,作者把它呈现为一个可见的标记,表明正在发生可能昂贵的工作。
“为了确保内存安全,在 let s2 = s1; 这一行之后,Rust 认为 s1 不再有效。”
“To ensure memory safety, after the line let s2 = s1;, Rust considers s1 as no longer valid.”
§4.1 What is Ownership?查看原文 →
第 4 章的核心概念
- 所有权
- 一套规则,说每个值恰好有一个所有者,并在该所有者的作用域结束时死亡。
- 适用处: 主要是关于堆数据;只在栈上的值不需要这样的追踪。
- 栈与堆
- 栈以后进先出的顺序存放固定大小的值;堆把大小未知或会变的数据,放在指针之后。
- 适用处: 只因为所有权的存在是为了管理堆数据,所以才有关系。
- 移动
- 赋值一个 String,会复制指针、长度和容量,然后使原来的变量失效。
- 适用处: 适用于没有 Copy trait 的类型;要深拷贝则改用 clone。
- drop
- Rust 在右花括号处自动调用的函数,用来归还一个值的内存。
- 适用处: 当一个变量被重新赋以新值时,它也会立即运行。
- Copy trait
- 一个标注,标记存储在栈上的类型,其值被简单地复制,而不是移动。
- 适用处: 不能与 Drop 结合,也永远不适用于需要分配的类型。
- 引用
- 指向另一个变量所拥有的数据的地址,保证在其生命周期内有效。
- 适用处: 引用从不拥有数据,所以丢弃它永远不会释放那个值。
另外 7 个概念
- 借用
- 创建引用的动作:你使用别人的值,然后把它还回去。
- 适用处: 被借用的值是不可变的,除非你用 &mut 借用。
- 可变引用
- 一种 &mut 借用,允许改变,却同时排斥所有其他引用。
- 适用处: 不重叠的作用域可以各自持有自己的可变引用。
- 悬垂引用
- 指向已被释放的内存的引用;Rust 拒绝编译这样的代码。
- 适用处: 预防依赖于生命周期,后面在第10章详述。
- 切片
- 对集合内一段连续元素的引用,存储起点和长度。 对一段字符串数据的不可变引用,持有起始指针和长度。
- 适用处: 字符串切片的索引必须落在有效的 UTF-8 字符边界上。 只适用于 UTF-8 字符串数据;是不可变的,所以不能用来改变所有者。
- 编译器作为守门人
- Rust 在编译期拒绝不安全的程序,而不是让它们在运行时失败。 借用规则在程序运行之前就被强制执行,所以整个错误家族永远到不了运行时。
- 适用处: 它标出潜在的 bug,即使代码本来能工作,也会让人感到沮丧。 在编译期阻止冲突的借用;它并不消除好好设计 API 的需要。
- 一般切片(&[T])
- 同样的“指针加长度”思想,应用于数组和其他集合,而不只是字符串。
- 适用处: 指向现有集合中一段连续的元素;它什么也不拥有。
- 解引用强制转换
- 编译器的一个便利,让 &String 可以用在期望 &str 的地方。
- 适用处: 后面在第15章解释;在这里它只是为更一般的参数类型辩护。
第 4 章划线与批注AI 陪读
“所有权的任何特性都不会在程序运行时拖慢你的程序。”
“None of the features of ownership will slow down your program while it’s running.”
查看原文 →整个推销压缩成一行——安全在编译期付费,而不是运行时。注意它是在任何证据之前,作为一个承诺被陈述的;本章在为将要到来的痛苦买耐心。
“因为所有权对许多程序员来说是个新概念,确实需要一些时间来习惯。”
“Because ownership is a new concept for many programmers, it does take some time to get used to.”
查看原文 →一本技术手册停下来说“坚持下去”,是一种承认:这一章是读者放弃的地方。这番鼓励是承重的。
“想象你在餐厅里被领到座位上。”
“Think of being seated at a restaurant.”
查看原文 →餐厅的类比一箭双雕——先用于分配,然后重用于缓存局部性。这种重用是聪明的:一个心智模型,两课。
另外 15 条批注
“我们需要让恰好一次分配,与恰好一次释放配对。”
“We need to pair exactly one allocate with exactly one free.”
查看原文 →就在上面列出的四种失败模式,全都出自一条簿记规则。Rust 的回答不是一个更好的程序员——而是把簿记从人的工作中删除。
“但因为 Rust 还使第一个变量失效,它不叫做浅拷贝,而称为移动。”
“But because Rust also invalidates the first variable, instead of being called a shallow copy, it’s known as a move.”
查看原文 →整章的支点,而且纯粹是关于什么被拿走了。移动是浅拷贝加一次删除——删除就是整个发明。
“Rust 永远不会自动创建你数据的“深”拷贝。”
“Rust will never automatically create “deep” copies of your data.”
查看原文 →这是一条成本可见性的原则,而不是内存原则。开销必须被写明——这就是为什么 clone 是个你必须敲出来的词。
“当你看到对 clone 的调用时,你就知道有一些任意的代码正在被执行,而那些代码可能很昂贵。”
“When you see a call to clone, you know that some arbitrary code is being executed and that code may be expensive.”
查看原文 →语法作为性能的异味。别的语言把这藏在一个 = 后面;Rust 让读代码的人,而不只是写代码的人,能够看到账单。
“如果一个类型,或它的任何部分,实现了 Drop trait,Rust 就不会让我们给这个类型标注 Copy。”
“Rust won’t let us annotate a type with Copy if the type, or any of its parts, has implemented the Drop trait.”
查看原文 →Copy 和 Drop 必然是互斥的——如果清理要紧,复制就不可能免费。这条规则不是任意的策略;它从一次分配对一次释放的算术中掉出来。
“但对于一个本应常见的概念来说,这太繁文缛节,也太费事了。”
“But this is too much ceremony and a lot of work for a concept that should be common.”
查看原文 →优雅的教学结构:先让读者感受到纯所有权的摩擦,然后再提供引用作为解脱。那段元组代码的存在,只是为了被怨恨。
“与指针不同,引用保证在该引用的生命周期内,指向某种特定类型的有效值。”
“Unlike a pointer, a reference is guaranteed to point to a valid value of a particular type for the life of that reference.”
查看原文 →把引用与 C 指针区分开来的那一句话,几乎是随口丢出的。本章其余部分一切困难的东西——生命周期、借用规则——都是那个保证的代价。
“我们把创建引用的动作称为借用。就像在现实生活中,如果一个人拥有某样东西,你可以向他们借。”
“We call the action of creating a reference borrowing. As in real life, if a person owns something, you can borrow it from them.”
查看原文 →这个比喻在这里做了很多工作,但注意它在后面悄悄破裂了:在现实生活里,你不能把同一本书借给两个人,却可以让满满一屋子人从你肩膀上看。&/&mut 的划分恰恰就是那种不对称——这个类比之所以能存活,只因为阅读是非破坏性的。
“剧透警告:行不通!”
“Spoiler alert: It doesn’t work!”
查看原文 →书靠有意领你走到墙边来教学。欢快的语气作为麻醉剂——你把 E0596 当作玩笑而不是失败来遇见。
“不可变引用的使用者,不期望值突然在他们底下改变!”
“Users of an immutable reference don’t expect the value to suddenly change out from under them!”
查看原文 →这是使整条规则变得直观的那一句话——限制不是官僚主义,而是兑现类型已经对其读者做出的承诺。
“编译器可以判断,在作用域结束之前的某一点,这个引用不再被使用了。”
“The compiler can tell that the reference is no longer being used at a point before the end of the scope.”
查看原文 →这一节里悄悄地最重要的让步:规则是关于使用的生命周期,而不是词法上的花括号。没有 NLL,这一章读起来会像暴政。
“它只有在 &String 的上下文中才是一个有意义的数字”
“it’s only a meaningful number in the context of the &String”
查看原文 →切片真正的论点,而且根本不是关于字符串的:一个没有与其来源绑定的派生值,是一个等着定日程的 bug。Rust 的回答,是让这条绑定成为类型的一部分。
“这个程序编译没有任何错误”
“This program compiles without any errors”
查看原文 →本章最可怕的一行。基于索引的代码在编译器看来是正确的,在我们看来是错的——这是诚实的承认:安全只覆盖你在类型中编码的东西。
“Rust 不仅让我们的 API 更容易使用,还在编译期消除了整整一类错误!”
“Not only has Rust made our API easier to use, but it has also eliminated an entire class of errors at compile time!”
查看原文 →注意顺序里的障眼法:人体工学在先,安全在后。本章真正的论证是,安全的设计碰巧也是更好的设计——否则就没人会容忍借用检查器。
“这也是字符串字面量不可变的原因;&str 是不可变引用。”
“This is also why string literals are immutable; &str is an immutable reference.”
查看原文 →一个你在第3章背下来的事实,突然成了后果,而不是规则。那种追溯的点击,是本章最好的时刻。
读后感AI 陪读
本章以一个承诺开篇,制造挣扎,然后揭示删除
把它当作一个整体来读,这一章既是修辞工程,也是技术阐述,它的形状值得点出来:它以一个承诺(没有运行时代价)开篇,随即承认读者会挣扎,然后用其余的篇幅,把这两个陈述都挣回来。运动是栈 → 堆 → 所有权 → 移动 → 借用 → 切片,每一步都是由前一步留下的痛苦所驱动的。餐厅的类比先服务于分配,然后是缓存局部性;一次分配对一次释放的规则,不仅产生了 drop,还产生了 Copy/Drop 的互斥;有意笨拙的、返回元组的代码,纯粹是为了让引用作为解脱而不是更多的规则而到来。本章靠有意把你领到墙边来教学——“剧透警告:行不通!”——用欢快的语气作为麻醉剂,让编译器错误像笑话而不是失败那样落地。我看穿的是:核心的发明是一次删除。移动是浅拷贝加上源的失效,而那个失效就是全部的想法;下游的一切——&/&mut 的不对称,把引用与 C 指针区分开来的、在其生命周期内有效的保证,借用规则——都是让这次删除可检查的代价。有两个软肋值得指出。借用的比喻悄悄破裂了:真实的出借,对“许多读者同时”没有类比,而这个类比之所以能存活,只因为阅读是非破坏性的,而这恰恰是 &/&mut 的划分所编码的事实。而诚实的坦白在切片一节:基于索引的 first_word“编译没有任何错误”,却仍然是错的。那是本章承认它自己的边界——安全只覆盖你设法在类型中编码的东西,而一个与其来源脱钩的派生数字,是个有预定到达时间的 bug。Rust 的回答,是让这条联系成为类型的一部分,这就是为什么切片不是一个字符串特性,而是一条普遍原则。我最尖锐的判断是关于顺序和优先级。注意 clone 是个你必须敲出来的词,而且 Rust 永远不会悄悄地深拷贝:这些是成本可见性的规则,而不是内存规则——读代码的人,而不只是写它的人,得以看到账单。还要注意,当回报落地时,书先讲的是人体工学(“更容易使用”),其次才提到消除一类错误。这个顺序才是真正的论证:安全的设计碰巧也是更好的 API,而没有这个巧合,就没人会容忍借用检查器。安静的英雄是非词法生命周期——编译器推理的是一个引用在哪里不再被使用,而不是花括号在哪里关闭。没有它,这一章读起来会像暴政;有了它,这些规则感觉像是在追踪某种真实的东西。而整段文字里最直观的理由,是一句关于礼貌、而不是机制的话:不可变引用的使用者,不期望值在他们底下改变。我要对读者说的是:别试图背规则;从一次分配对一次释放,和对读者的那个承诺中重建它们,它们就几乎是可推导的。预期这一章感觉像限制,并把它认作被从你身上卸掉的簿记。对着移动示意图坐一会儿,直到删除感觉不可避免。并且把最后那一下点击——字符串字面量之所以不可变,是因为 &str 是不可变引用,一个第3章的事实被追溯地从规则降级为后果——当作其余的 Rust 将如何落地的模型:你早先背下来的东西,原来是你现在真正理解的某样东西的后果。
在 FeedLitra 里读第 4 章,每段旁边都有这些卡片。
开始读书第 5 章:用结构体组织相关联的数据
生命周期如何让一个结构体持有引用?
第 5 章是怎样展开的AI 辅助
开篇:元组满足不了的需求。 本章一开始引入结构体,作为把相关的值打包在单一名字之下的办法,并把这项能力与读者已经知道的元组对照着定位。定义性的内容在前,而且很快:如何声明一个结构体,如何实例化一个,如何修改一个字段(这要求整个绑定是可变的),当变量已经与字段同名时的字段初始化简写,以及用另一个实例构建一个实例的结构体更新语法——并带着一条安静但重要的说明:这种语法会移动,所以之后源实例可能不再整体可用。元组结构体和类单元结构体,作为同一想法的变体随之而来。这一节以一个作者当作有意的设计决定、而不是细节的要点收尾:字段通常应当持有拥有所有权的类型,因为这保证每个实例的数据,恰好在实例本身存活的期间保持有效。反面的案例很有启发——试图存一个 &str,编译器就会以 error[E0106],缺少生命周期说明符,加以拒绝。结构体里的引用是可能的,但只有在引入了生命周期之后,作者明确推迟了这一点。
展开:作为论证的矩形重构。 本章随后停止解释,开始演示,带着一个小小的计算——矩形的面积——走过三个版本。第一个使用两个单独的变量,传递两个不相关的参数;第二个把它们捆进一个元组,得到了单一的参数,却失去了所有的命名,所以函数体读作 dimensions.0 * dimensions.1,而调用者必须记住哪个槽位表示宽。作者最尖锐的观察是关于潜在的风险:单就面积而言,调换宽和高没有任何变化,所以缺陷是看不见的——但一旦程序把矩形画在屏幕上,这种混淆就成了真正的 bug。因此基于位置的访问不只是难看;它是一个等着用例的缺陷。第三个版本引入一个 Rectangle 结构体和一个借用它的 area 函数,而这个主张得到推广:一个从不陈述其参数彼此如何关联的签名,是一个设计缺陷,而把它们捆进一个命名的类型是修复。借用而不是取得所有权,让 main 之后还能继续使用那个矩形。一段相关的离题处理打印,并纠正了一个自然的预期:一个普通的 println!(“{}”, rect) 不会编译,因为结构体没有得到 Display 的实现(error[E0277])。面向开发者的输出必须选择加入——派生 Debug,然后用 {:?} 或美观的 {:#?} 来格式化。
转折:从靠近数据的函数到在其内部的方法。 支点出现在作者把 area 移进一个 impl Rectangle 块,使它成为第一个参数是 self 的方法时。关键在于,这一举动的论据是组织,而不是新的能力——方法恰恰做了函数所做的事。得到的是,你能用一个类型做的一切都住在一处,免去了将来的读者翻找一个库来发现这个类型提供什么。从这里起,本章让 self 的形式承载含义:&self 借用来读,&mut self 借用来写,而光秃秃的 self 消耗,后者很少见,主要保留给把值转换成别的东西的方法。所以 area 取 &self,恰恰是因为独立的函数取了 &Rectangle 的同一个原因,而 can_hold 也通过引用取另一个矩形,这就是为什么 main 仍拥有 rect2,并能在下一行使用它。本章还先发制人地处理了一个关于命名的误读:一个方法可以与一个字段同名,作者定义了一个返回 self.width > 0 的 width 方法,同时字段仍然存在——调用语法把它们区分开来。这是一种暴露私有字段为只读访问的有意模式,而 Rust 从不替你生成这样的 getter。
收尾:人体工学与构造函数。 本章的最后两个动作兑现前面一个。第一,自动引用和解引用:因为一次方法调用恰好有一个接收者,其类型由它的 impl 块固定,编译器可以判断签名想要 &、&mut 还是解引用,并插入它。所以 p1.distance(&p2) 和显式的 (&p1).distance(&p2) 编译成同一个调用,前者读起来干净得多——作者指出,这是 Rust 做这种推断的少数几个地方之一,而且它去掉了像 C++ 那样需要单独的箭头运算符。更广的主张是,让接收者的借用隐式,是所有权在实践中感觉舒服、而不只是正确的很大一部分原因。第二,关联函数:impl 块中不带 self 的函数,用 :: 调用,并充当构造函数,就像 Rectangle::square(3),其中 Self 在返回类型和函数体里都充当该类型的别名。本章以指出允许多个 impl 块结尾——一种结构上的许可,强化了组织的主题,因为类型的行为和它的构造函数,可以按清晰所需的任何方式分组,同时仍都属于类型本身。
关键论点,以及支撑它们的原文
结构体比元组更灵活,因为给字段命名让你不必依赖位置。
作者带着同一个面积计算走过三个版本。分开的变量传递两个不相关的参数;元组传递一个参数,却因为没有元素名,而被迫写出晦涩的 dimensions.0 * dimensions.1。他指出,单就面积而言,宽/高的混淆没有任何变化,但一旦程序把矩形画在屏幕上,这种混淆就成了真正的 bug——所以基于位置的访问是潜在的缺陷,而不只是难看。
“加上这些名字,意味着结构体比元组更灵活:你不必依赖数据的顺序,来指定或访问一个实例的值。”
“Adding these names means that structs are more flexible than tuples: You don’t have to rely on the order of the data to specify or access the values of an instance.”
§5.1 Defining and Instantiating Structs查看原文 →
一个没有陈述其参数之间关系的签名是个设计缺陷,而把值捆进一个结构体是修复。
这直接顺着对元组的批评而来。作者观察到,area 本意是衡量一个矩形,然而它的两参数签名从未说这两个数字属于一起。把它重写成 area 取一个借用的 Rectangle,使签名精确地说出本意——用该实例的 width 和 height 字段计算面积——而借用而不是取得所有权,让 main 之后还能继续使用那个矩形。
“area 函数本应计算一个矩形的面积,但我们写的这个函数有两个参数,而我们的程序里没有任何地方表明这些参数是相关的。”
“The area function is supposed to calculate the area of one rectangle, but the function we wrote has two parameters, and it’s not clear anywhere in our program that the parameters are related.”
§5.2 An Example Program Using Structs查看原文 →
结构体字段通常应当持有拥有所有权的类型,这样每个实例拥有它自己的数据。
作者把这呈现为有意的选择:拥有所有权的字段保证数据在整个结构体有效期间都是有效的。他的反面案例,是试图改存字符串切片,编译器以 error[E0106],缺少生命周期说明符,加以拒绝。他承认结构体可以持有引用,但只有在引入了生命周期之后,他把这推迟了。
“这是一个有意的选择,因为我们想让这个结构体的每个实例拥有它所有的数据,并且让这些数据在整个结构体有效期间都有效。”
“This is a deliberate choice because we want each instance of this struct to own all of its data and for that data to be valid for as long as the entire struct is valid.”
§5.1 Defining and Instantiating Structs查看原文 →
另外 3 个论点
方法优于普通函数的主要论据是组织,而不是新的能力。
把 area 与 Rectangle 绑在一起之后,作者解释说,把你能对一个类型做的一切都放在一个 impl 块里,免去了将来的读者翻找一个库来发现这个类型提供什么。同一个块里也放着不需要实例的关联函数——Rectangle::square(3) 构建一个正方形,Self 在返回类型和函数体里都充当该类型的别名——所以构造函数与行为并排而居。
“使用方法而不是函数的主要原因,除了提供方法语法和不必在每个方法的签名里重复 self 的类型之外,就是组织。”
“The main reason for using methods instead of functions, in addition to providing method syntax and not having to repeat the type of self in every method’s signature, is for organization.”
§5.3 Methods查看原文 →
方法取 self 的哪种形式,是在声明它对实例做什么:消耗它、读它,还是修改它。
作者给每种形式配上一个理由。area 取 &self,恰恰是独立函数取 &Rectangle 的同一个原因:它只读取存储的数据,从不写入。can_hold 同样通过引用取另一个矩形,因为只需要读取,这就是为什么 main 仍拥有 rect2,并能在下一行再次使用它。用光秃秃的 self 取得所有权,被指出是很少见的,主要保留给把值转换成别的东西的方法。
“方法可以获取 self 的所有权,不可变地借用 self(就像我们这里所做的),或者可变地借用 self,就像它们可以对任何其他参数所做的那样。”
“Methods can take ownership of self, borrow self immutably, as we’ve done here, or borrow self mutably, just as they can any other parameter.”
§5.3 Methods查看原文 →
让方法接收者的借用隐式,是 Rust 的所有权规则用起来舒服的很大一部分原因。
这是前一点的回报。既然接收者的类型是已知的,编译器自己添加所需的引用或解引用;作者展示 p1.distance(&p2) 和显式的 (&p1).distance(&p2) 编译成同一个调用,前者读起来干净得多。他指出,这种自动引用是 Rust 做这种推断的少数几个地方之一,而且它去掉了像 C++ 那样需要单独的箭头运算符。
“Rust 让方法接收者的借用隐式,这在很大程度上使所有权在实践中符合人体工学。”
“The fact that Rust makes borrowing implicit for method receivers is a big part of making ownership ergonomic in practice.”
§5.3 Methods查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
名字取代位置
元组只给它的值排序,所以调用者必须记住哪个槽位是宽、哪个是高,而计算读作 dimensions.0 * dimensions.1。给这些值命名字段,就把那份知识搬进了类型本身,于是不必再追踪顺序,调换也不会再悄悄地改变含义。
机制 2
拥有所有权的字段比使用活得更久
作者有意给字段拥有所有权的类型,这样每个实例持有自己的数据,而数据恰好在整个结构体存活的期间保持有效。换成借用的 &str 字段就破坏了这一点,编译器会以缺少生命周期说明符的错误拒绝它,因为借用需要对出借者能活多久有一个明确的保证。
机制 3
接收者隐式借用
因为一次方法调用有一个无歧义的接收者,其类型由它的 impl 块固定,编译器可以判断签名想要 &、&mut 还是解引用,并自动插入它。这就是为什么 p1.distance(&p2) 和 (&p1).distance(&p2) 是一回事,也是所有权在调用处感觉轻量的原因。
常见误读
既然本章展示了如何打印一个结构体,对 Rectangle 用普通的带 {} 的 println! 应该能行。
结构体没有得到 Display 的实现,所以这种尝试会编译失败,报 error[E0277],说 Rectangle 没有实现 std::fmt::Display。面向开发者的打印必须通过派生 Debug,然后用 {:?} 或美观的 {:#?} 形式来格式化,才能选择加入。
方法不能与字段同名,而与字段同名的方法,只是一个 Rust 本可以替你写好的 getter。
这里的命名不受限制:作者定义了一个 width 方法,返回 self.width 是否大于零,而 width 字段仍然存在,调用的形式把它们区分开来。这样与字段同名的方法,是一种暴露私有字段为只读访问的有意模式,而 Rust 从不自动生成它们。
“在这里,我们选择让 width 方法在实例的 width 字段中的值大于 0 时返回 true,值为 0 时返回 false:我们可以在同名的方法里出于任何目的使用一个字段。”
“Here, we’re choosing to make the width method return true if the value in the instance’s width field is greater than 0 and false if the value is 0: We can use a field within a method of the same name for any purpose.”
§5.3 Methods查看原文 →
第 5 章的核心概念
- struct(结构体)
- 一个命名的自定义类型,把相关的、命名的字段捆成一个有意义的值。
- 适用处: 字段不能单独可变;整个实例必须是 mut。
- 字段初始化简写
- 当参数的名字相同时,只写字段名。
- 适用处: 只在参数名和字段名完全匹配时适用。
- 结构体更新语法
..other从另一个同类型的实例中填充未设置的字段。- 适用处: 它移动非 Copy 的数据,使源实例中被移动的字段失效。
- 元组结构体
- 一个有名字、但字段没有名字、只有类型的结构体。
- 适用处: 即使字段类型相同,每个定义也是不同的类型。
- 类单元结构体
- 一个没有字段的结构体,用来在没有数据的情况下承载 trait 行为。
- 适用处: 只有在不需要往类型里存数据时才有用。
- 派生的 Debug trait
- 一个选择加入的属性,使结构体可以面向开发者打印。
- 适用处: 结构体不提供 Display;必须使用 :? 或 :#?。
另外 4 个概念
- 方法
- 定义在 impl 块内、第一个参数是 self 的函数,用点语法在实例上调用。
- 适用处: 需要一个实例;impl 里不带 self 的函数不是方法。
- 关联函数
- 任何定义在 impl 块里的函数;不带 self,就不需要实例,用 :: 语法调用。
- 适用处: new 是约定,不是语言关键字。
- 自动引用与解引用
- Rust 在方法调用处插入 &、&mut 或 *,使接收者与签名匹配,取代了 C++ 的 -> 运算符。
- 适用处: 适用于方法的接收者,是少数有这种行为的地方之一。
- getter
- 一个与字段同名、只返回它的方法,使得对私有字段可以有只读的公共访问。
- 适用处: Rust 从不自动生成 getter。
第 5 章划线与批注AI 陪读
“加上这些名字,意味着结构体比元组更灵活:你不必依赖数据的顺序”
“Adding these names means that structs are more flexible than tuples: You don’t have to rely on the order of the data”
查看原文 →“灵活”是个错词——给字段命名实际上约束你更多。你得到的是摆脱位置记忆的自由,这更应该称为清晰,而不是灵活。
“注意,整个实例必须是可变的;Rust 不允许我们只把某些字段标记为可变。”
“Note that the entire instance must be mutable; Rust doesn’t allow us to mark only certain fields as mutable.”
查看原文 →一个悄悄很大的设计决定,被当作脚注溜进来。可变性属于绑定,而不是数据——这就是逐字段的 mut 不可能存在的原因。
“这是因为它移动了数据,就像我们在“变量与数据的交互:移动”一节里看到的那样”
“this is because it moves the data, just as we saw in the “Variables and Data Interacting with Move” section”
查看原文 →这是便利里的刺:..user1 看起来像无害的复制,却悄悄地掏空了 user1。底下带着所有权牙齿的、人体工学的语法。
另外 13 条批注
“即使两种类型都由三个 i32 值构成,接受 Color 类型参数的函数也不能接受 Point 作为参数”
“a function that takes a parameter of type Color cannot take a Point as an argument, even though both types are made up of three i32 values”
查看原文 →一行说完元组结构体的全部要点:身份压过形状。类型系统让你做出数据本身无法表达的区分。
“把宽和高搞混,对面积的计算没有影响,但如果我们想把矩形画在屏幕上,就有影响了!”
“Mixing up the width and height wouldn’t matter for the area calculation, but if we want to draw the rectangle on the screen, it would matter!”
查看原文 →论证的诚实版本。元组在这里并没有错——它们在后来错了,当代码增长的时候。未命名数据的代价是被推迟的,这恰恰就是人们对它投入的关注不足的原因。
“由于这种含糊,Rust 不会试图猜测我们想要什么”
“Due to this ambiguity, Rust doesn’t try to guess what we want”
查看原文 →Rust 反复出现的气质:在默认值会是任意的地方,拒绝默认。别的语言欣然打印 <Object at 0x...>;Rust 让你说出输出是给哪个受众的。
“直接面向终端用户消费的输出”
“output intended for direct end user consumption”
查看原文 →Display/Debug 的划分不是技术上的,而是关于谁在读。一个类型可以有两种展示方式,因为它有两种受众——大多数语言从不费心去划出的区分。
“它获取表达式的所有权(不同于 println!,后者获取引用),打印文件和行号”
“which takes ownership of an expression (as opposed to println!, which takes a reference), prints the file and line number”
查看原文 →一个必须把值交回来的调试宏——所有权把就连 printf 式调试,也变成了谁持有什么的问题。优雅的是,dbg! 把它返回,这样你就可以原地包裹任何表达式。
“&self 实际上是 self: &Self 的简写。”
“The &self is actually short for self: &Self.”
查看原文 →脱糖是澄清的动作——self 不是像 this 那样的魔法关键字,而是一个带语法糖的普通第一参数。这也就是它为什么能是 self、&self 或 &mut self 的原因。
“这种技术通常用于方法把 self 转换成别的东西,而你想防止调用者使用原来的实例的时候”
“this technique is usually used when the method transforms self into something else and you want to prevent the caller from using the original instance”
查看原文 →消耗 self 作为一个特性:API 编码了“这个东西现在用完了”。大多数语言里,你根本无法在签名里表达这样的保证。
“我们可以在同名方法中出于任何目的使用同名的字段。”
“We can use a field within a method of the same name for any purpose.”
查看原文 →悄悄承认 Rust 在这里容忍真正的含糊,只是靠括号来解决。对编译器是可读的,对略读的人则令人困惑——书从未这么说。
“Rust 不像某些其他语言那样,为结构体字段自动实现它们”
“Rust does not implement them automatically for struct fields as some other languages do”
查看原文 →陈述得很中性,但这是一种设计立场:没有免费的 getter,就没有“每个字段都想被读取”的错觉。把样板代码作为有意的减速带。
“给定方法的接收者和名字,Rust 可以确凿地判断该方法是在读(&self)、修改(&mut self)还是消耗(self)。”
“Given the receiver and name of a method, Rust can figure out definitively whether the method is reading (&self), mutating (&mut self), or consuming (self).”
查看原文 →真正的论证落在这里:隐式之所以安全,只因为签名已经固定了意图。别处 Rust 之所以拒绝猜测,恰恰因为没有这样的锚点。
“这些通常叫做 new,但 new 不是一个特殊的名字,也不是内置于语言的”
“These are often called new, but new isn’t a special name and isn’t built into the language”
查看原文 →书里最好的句子之一。在别的语言里看起来像关键字的东西,在这里只是一个约定——构造从仪式被降格为一个普通的函数。
“返回类型和函数体中的 Self 关键字,是 impl 关键字后面出现的那个类型的别名”
“The Self keywords in the return type and in the body of the function are aliases for the type that appears after the impl keyword”
查看原文 →现在小,之后承重:Self 让一个 impl 块可以在不知道自己具体名字的情况下被编写——泛型和 trait 的种子。
“这里没有理由把这些方法拆分到多个 impl 块里,但这是有效的语法。”
“There’s no reason to separate these methods into multiple impl blocks here, but this is valid syntax.”
查看原文 →令人钦佩的诚实教学:这里有一项眼下没有用处的能力。在动机之前引入机制是一场赌博,而作者知道这一点,所以有了指向第10章的期票。
读后感AI 陪读
两章拴在一起,接缝处揭示了设计决定
这一章比它看上去的要短,因为它其实是两章,由一个演练的例子拴在一起,而有趣的事情就发生在接缝处。前半部分是关于命名:结构体是一个槽位有名字的元组,而书把这当作“灵活性”来推销,诚实的词其实是清晰——给字段命名,免去了记住位置的负担,却也把你钉住了,这是灵活的反面。从这里起,本章走过一道小心的阶梯,是不给东西命名的各种方式:元组结构体,保留位置,却增加了身份(Color 和 Point 可以都是三个 i32,却仍是不兼容的类型——类型系统让你画出数据自己无法表达的区分),以及类单元结构体,完全没有数据,存在纯粹是为了成为一个你可以挂上行为的名字。一路上有两个细节,是以与它们的分量太不相称的随意语气丢出来的。一个是可变性属于整个实例,从不属于单个字段——这一行读起来像一个限制,其实是整个模型透出来了:mut 描述的是一个绑定,而不是那些字节,所以逐字段的可变性不是被拒绝,而是无法表述的。另一个是结构体更新语法,它看起来像廉价的复制,实际上是移动:..user1 可以悄悄让 user1 无法使用。这是本章唯一真正的陷阱,底下带着所有权的牙齿的、人体工学的语法,值得的不只是一个向后的交叉引用。
中间的矩形重构,是本章最好的教学,也是悄悄地最不诚实的论证,其方式值得看穿。分开的宽和高管用;元组管用;结构体管用。书承认,元组对计算面积来说并没有错——把两个数字搞混,得到同样的答案——只在后来,当你试图把这个东西画出来的时候才变错。那个推迟才是真正的教训:未命名数据的代价,不是在你写下它的那一刻付的,这恰恰就是人们一直写它的原因。挂在这个重构上的是 Debug 的岔路,我认为那是本章哲学上最有揭示性的一页。Rust 默认不会打印你的结构体,而所陈述的理由——这个行为会是含糊的,所以 Rust 拒绝去猜——是本章反复出现的气质的缩影。别的语言欣然交给你 <Object at 0x…>。Rust 让你选择加入,然后把选择加入一分为二:给终端用户的 Display,给程序员的 Debug。这个划分不是技术上的区分,而是受众的区分,几乎没有别的语言费心去做。dbg! 为它封顶:一个获取所有权、又把值交回来的调试宏,这样你就可以原地包裹任何表达式。就连 printf 式调试也得回答谁持有什么的问题。
继续阅读
后半部分从数据转向行为,而关键的一句话是最小的那句:&self 是 self: &Self 的语法糖。一旦你看出这一点,方法就不再是一个特性,而成了一种安排——self 是一个普通的第一参数,这恰恰就是它为什么能以 self、&self 或 &mut self 出现的原因。这三件套是本章真正的有效载荷。&self 读,&mut self 写,而 self 消耗,这让一个签名可以说“这个实例现在用完了”——大多数语言除了文档注释之外,无处编码这样的保证。而在这里,Rust 做了一件它在 Debug 上拒绝做的事:它让你隐式地在引用上调用方法,没有 -> 运算符,调用处也没有显式的借用。书的辩护是本章最尖锐的论证——给定接收者和方法名,意图已由签名固定,所以没有什么需要去猜。Rust 只在默认值会是任意的地方才拒绝默认;有锚点的地方,它欣然替你做这件事。这是一种一贯的立场,而不是一时的心情。
两个诚实的承认和一个遗漏收尾。Rust 不生成 getter,书中性地陈述了这一点,但显然是一种立场:把样板代码作为减速带,这样就没有人以为每个字段都想被读取。而 new 被揭示不过是一个约定——不是关键字,不特殊,只是人们碰巧同意的一个关联函数——这把构造从仪式降格为一次普通的调用,是书里最好的句子之一。Self 作为 impl 目标的别名,在这里看起来像个便利,后来却是承重的;它是编写一个 impl、而不知道自己具体名字的种子,而那就是 trait 和泛型开始的地方。本章还展示了多个 impl 块,同时承认眼下没有理由使用它们,这是令人钦佩的坦率教学,但仍是一场赌博:一个先于其动机引入的机制,只靠一张指向第10章的期票来赎回。书从未指出的一件事是,字段和方法可以同名,只靠括号来区分——对编译器是清楚的,对略读代码的人则是真正令人困惑的。
我要对读者说的是:别把它读成 Rust 终于有了对象的那一章。这里没有继承,还没有封装的故事,没有任何类似类的东西。你实际得到的是两件正交的工具——一种给成组的数据命名的办法,和一种把函数附到一个类型上、同时在签名里确切声明它们打算对它做什么的办法。第4章的所有权规则在你学这个时并不暂停;它们直接伸进去,在 ..user1 里,在 dbg! 里,在 self 的三种拼写之间的选择里。如果本章里有一行看起来像顺带陈述的小小限制,那就是该放慢的信号——这里的脚注,才是设计真正住的地方。
在 FeedLitra 里读第 5 章,每段旁边都有这些卡片。
开始读书第 6 章:枚举与模式匹配
if let 如何缩短对 Option 的 match 模式?
第 6 章是怎样展开的AI 辅助
开篇:枚举各种可能性。 本章一开始引入枚举,作为一种不是靠组合字段、而是靠列出各种选项来定义的类型——该类型的一个值,恰好是一组声明的变体之一。作者的切入点是 IP 地址,它有两种且仅有两种,所以类型本身可以给它们命名。他随后做了区分 Rust 枚举与更简单的带标签常量的关键动作:数据可以直接挂在每个变体上。IP 类型被重写为 V4(String) 和 V6(String),而先前把一个种类字段与一个载荷配对的包装结构体就完全消失了。他把这一点推得更远,给 V4 四个 u8 值,同时让 V6 仍是一个 String——每个变体携带它自己形状的数据,这是单个结构体表达不了的——而 Message 枚举在更大的尺度上展示了同样的想法,变体里可以什么也不装、装一个类似结构体的主体、或一个值的元组。
展开:作为类型的缺失。 从那里起,本章走到它最重要的例子,Option<T>,标准库对一个可能在、也可能不在的值的编码。论证是结构性的,而不是风格性的:因为 Option<T> 是与 T 不同的类型,两者不可互换,而空引用所招致的错误,就无法悄悄地犯下。作者通过把一个普通的 i8 加到一个 Option<i8> 上来演示这一点;代码被以错误 E0277 拒绝,所以内部的值必须先被拆成真正的数字,才允许做算术。空值型语言会在运行时才发现的东西,这个设计在编译器处就抓住了。自然随之而来的问题——如何拆开它?——打开了本章的后半部分,而答案是 match,这个根据手头是哪个变体而运行不同代码、并同时绑定该变体所持有的任何东西的构造。硬币的例子承载了这个工作的两半:写成 Coin::Quarter(state) 的分支,既选择了分支,又把 state 绑定到内部的 UsState::Alaska,这样分支的主体就可以使用它,而这个绑定只活在那个分支里。
转折:穷尽性,和分支的顺序。 本章随后从 match 能做什么,转向它要求什么。一个 match 必须涵盖被匹配类型的每一个可能的值;缺口不是无声的穿透,而是编译错误。从 plus_one 里去掉 None 分支,会产生 error[E0004],而信息直截了当地点出了未覆盖的情形。这就是前面两条线索汇合之处:Option 是它自己的类型,意味着缺失必须被表示,而穷尽匹配意味着它必须被处理——合起来,就是使十亿美元的空值错误在这里不可得的原因。兜底模式,无论是有名字的绑定还是 _ 占位符,满足对其余一切的覆盖要求,但它们带着一条由 match 如何工作而来的位置约束。分支自上而下地测试,第一个匹配者胜出,所以兜底应当放在最后;写在更早的位置,它会吸收本该由具体分支认领的那些值。它只是看起来像最后一道后备——它只是一个匹配一切的模式,而它的位置才使它表现得像一个默认值。
收尾:简洁的形式,及其代价。 本章以两个速记收尾,明确被呈现为取舍,而不是改进。当只有一个变体有意思时,完整的 match 迫使你写一个纯粹为了满足穷尽性的 _ => () 分支;if let 删掉了那个填充。但作者小心地指出这是一笔购买:简洁是用覆盖检查付的钱,所以你忘记的情形不再被标出。这个交换是否正确取决于情形,这就是为什么不应把 if let 读成仅仅是 match 的较短拼写。最后的精炼,let...else,处理的是另一种别扭。用 if let 写成的州币函数,分裂成两个分道扬镳的分支,一个产出一个值,另一个从函数中返回;用 let...else 重写它,压平了那个分裂,使绑定落在外层作用域,只有失败的情形提前退出——让函数的主线逻辑留在成功的路径上,也就是读者期望找到它的地方。
关键论点,以及支撑它们的原文
把数据直接附在枚举变体上,比在结构体上拴一个种类字段更紧凑,并让每个变体携带它自己形状的数据。
作者把一个 IP 地址类型重写为变体 V4(String) 和 V6(String),这完全去掉了包装结构体;然后他推得更远,把 V4 存为四个 u8 值,而 V6 仍是一个 String——一种单个结构体表达不了的组合。
“使用枚举而不是结构体还有另一个好处:每个变体可以有不同的类型和数量的关联数据。”
“There’s another advantage to using an enum rather than a struct: Each variant can have different types and amounts of associated data.”
§6.1 Defining an Enum查看原文 →
让 Option<T> 成为与 T 不同的类型,才抓住了经典的空值错误。
作者展示了把一个普通的 i8 加到一个 Option<i8> 上的代码;它以错误 E0277 拒绝编译,所以这个值必须先被拆成真正的数字,才允许做算术。
“简言之,因为 Option<T> 和 T(其中 T 可以是任何类型)是不同的类型,编译器不会让我们把 Option<T> 的值当作一定是有效的值来使用。”
“In short, because Option<T> and T (where T can be any type) are different types, the compiler won’t let us use an Option<T> value as if it were definitely a valid value.”
§6.1 Defining an Enum查看原文 →
带模式绑定的 match,是既按变体选择分支、又取出其内部存储的数据的工具。
在硬币的例子里,写成 Coin::Quarter(state) 的分支,把 state 绑定到内部的值——UsState::Alaska——这样分支的代码就能打印它;这个绑定只存在于那个分支内部。
“match 表达式是一种恰好做这件事的控制流构造,当与枚举一起使用时:它会根据所持有的枚举变体而运行不同的代码,并且那段代码可以使用匹配值内部的数据。”
“The match expression is a control flow construct that does just this when used with enums: It will run different code depending on which variant of the enum it has, and that code can use the data inside the matching value.”
§6.1 Defining an Enum查看原文 →
另外 3 个论点
是编译器对涵盖每种情形的坚持,使十亿美元的空值错误在这里成为不可能,这直接顺着 Option 是它自己的类型而来。
当 None 分支从 plus_one 中去掉时,编译失败,报 error[E0004],信息明确说 None 没有被涵盖,所以缺口被指了出来,而不是留给以后浮现。
“尤其是对 Option<T> 而言,当 Rust 阻止我们忘记明确处理 None 的情形时,它保护我们不去假定自己有一个值,而实际上我们可能拿到的是空值,从而使前面讨论的那个十亿美元的错误成为不可能。”
“Especially in the case of Option<T>, when Rust prevents us from forgetting to explicitly handle the None case, it protects us from assuming that we have a value when we might have null, thus making the billion-dollar mistake discussed earlier impossible.”
§6.2 The match Control Flow Construct查看原文 →
用 if let 取代 match 是一次有意的交换:你靠放弃覆盖保证来买简洁,所以它合不合适取决于情形。
作者指出,只处理一个变体的 match,迫使你写一个烦人的 _ => () 分支,纯粹为了满足覆盖,而 if let 删掉了那个填充——但随之而去的,还有本来会标出你忘记的情形的检查。
“然而,你失去了 match 所强制执行的穷尽检查,那种检查确保你没有忘记处理任何情形。”
“However, you lose the exhaustive checking match enforces that ensures that you aren’t forgetting to handle any cases.”
§6.3 Concise Control Flow with if let and let...else查看原文 →
let...else 让函数的主线逻辑留在成功的路径上,而不是把它分在两个分道扬镳的分支之间。
在用 if let 写成的州币例子里,一个分支产出一个值,另一个则从函数中返回;用 let...else 重写它,压平了那个分裂,使绑定落在外层作用域,只有失败的情形提前退出。
“注意,这样它就留在函数主体的“顺利路径”上,而不像 if let 那样,两个分支的控制流有显著的不同。”
“Notice that it stays on the “happy path” in the main body of the function this way, without having significantly different control flow for two branches the way the if let did.”
§6.3 Concise Control Flow with if let and let...else查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
缺失成为一个单独的类型
作者让标准库把一个可能缺失的值包装在 Option<T> 里,它是与 T 本身不同的类型。因为这两种类型不可互换,任何在需要真值的地方使用可能缺失的值的企图,都会在编译器处,而不是运行时被拦住。
机制 2
每种情形都必须被涵盖
match 必须涵盖被匹配类型的所有可能的值,所以编译器拒绝一个留有未处理变体的 match,并点出缺失的那个。对 Option 来说,这意味着 None 的情形永远不会被悄悄跳过,所以忘记处理空,就成了编译错误。
机制 3
分支按顺序尝试
模式自上而下地检查,第一个匹配者胜出。因此兜底模式对所有剩余的值满足覆盖,但如果把它放在具体分支之前,它会把它们吞掉,这就是它必须放在最后的原因。
常见误读
if let 不过是 match 的较短拼写,所以没有理由不偏好它。
较短的形式去掉了每种情形都被考虑到的保证;作者把它呈现为一笔交易:你获得简洁,却失去了你已处理一切的检查,这就是为什么它合不合适取决于情形。
“然而,你失去了 match 所强制执行的穷尽检查,那种检查确保你没有忘记处理任何情形。”
“However, you lose the exhaustive checking match enforces that ensures that you aren’t forgetting to handle any cases.”
§6.3 Concise Control Flow with if let and let...else查看原文 →
兜底分支可以放在 match 的任何地方,因为它只在没有更具体的适用时才起作用。
分支按顺序测试,第一个匹配的运行,所以兜底必须写在最后;放得更早,它会吸收本该由具体分支处理的值。
“注意,我们必须把兜底分支放在最后,因为模式是按顺序求值的。”
“Note that we have to put the catch-all arm last because the patterns are evaluated in order.”
§6.2 The match Control Flow Construct查看原文 →
第 6 章的核心概念
- 枚举变体
- 一种自定义类型,列出一个值可能采取的所有选项形式,一次只能是一种。
- 适用处: 只在各种可能性已知且互斥时才合适。
- Option 枚举
- 一个标准库的枚举,带 Some(T) 和 None,用来编码值的存在或不存在,而不是空值。
- 适用处: 在做 T 的运算之前,你必须把 Option<T> 转换成 T。
- match 表达式
- 一种控制流,按顺序把一个值与各个模式比较,并运行第一个匹配分支的代码。 一种控制流构造,其各分支合起来必须涵盖被匹配类型的每一个可能的值。
- 适用处: 与 if 不同,被匹配的表达式可以是任何类型,而不只是布尔。 穷尽性是按类型检查的;兜底分支无需列出所有的值就能满足它。
- 变体构造函数
- 定义一个带数据的枚举变体,也定义了一个构建该枚举实例的函数。
- 适用处: 适用于持有数据的变体,不适用于被用作普通值的单元变体。
- 模式绑定
- match 分支可以给被匹配值的一部分命名,从变体中取出数据。
- 适用处: 这个绑定只存在于该分支的代码内部。
- 兜底的下划线
- _ 模式匹配任何值而不绑定它,所以 Rust 不给出未使用变量的警告。
- 适用处: 必须放在最后,因为模式是按顺序求值的;如果需要这个值,就改用一个有名字的变量。
另外 2 个概念
- if let
- 速记,只在值匹配一个模式时才运行代码,忽略所有其他情形。
- 适用处: 获得简洁,却失去了 match 所强制执行的穷尽检查。
- let...else
- 如果模式匹配,就在外层作用域里绑定它,否则 else 分支必须从函数中返回。
- 适用处: else 分支必须发散;它用于“值存在/提前返回”的模式。
第 6 章划线与批注AI 陪读
“你排除的特性同样重要”
“the features you exclude are important too”
查看原文 →全章安静的论点。Rust 的身份,既建立在拒绝之上,也同样建立在添加之上——而这是书承认设计是道德的、而不只是技术的那一句话。
“但我无法抗拒放进一个空引用的诱惑,仅仅因为它太容易实现了。”
“But I couldn’t resist the temptation to put in a null reference, simply because it was so easy to implement.”
查看原文 →霍尔的自白被引作一个警示故事,但注意它实际上责怪的是什么:实现的容易。代价几十年后落在了用户身上。对编译器作者的便利,对其他所有人的十亿美元税。
“问题其实不在概念,而在特定的实现。”
“The problem isn’t really with the concept but with the particular implementation.”
查看原文 →一个公平而慷慨的动作——空值没有被驱除,而是被重新类型化。Option<T> 把缺失保留为一个合法的状态,却剥去了它伪装成存在的外衣。
另外 12 条批注
“我们定义的每个枚举变体的名字,也成为构造该枚举实例的一个函数。”
“The name of each enum variant that we define also becomes a function that constructs an instance of the enum.”
查看原文 →被当作“另一个细节”溜进来,但这就是 .map(Some) 和 ids.into_iter().map(IpAddr::V6) 之所以能工作的原因。变体是函数,是一个承重的事实,而不是脚注。
“每枚硬币都落进它遇到的、它适合的第一个洞”
“each coin falls through the first hole it encounters that it fits into”
查看原文 →这个比喻做了实事:它编码了对顺序的依赖,这是初学者经常绊倒的地方。后面的分支可能是死代码,而机器的意象,在规则被陈述之前,就让这一点直观了。
“好家伙!”
“Intense!”
查看原文 →难得的时刻,书打破语域,承认它自己那一大堆输出。这种诚实是策略性的——一个盯着十行 trait 说明的初学者,需要许可不去读完所有这些。
“从而使前面讨论的十亿美元的错误成为不可能”
“thus making the billion-dollar mistake discussed earlier impossible”
查看原文 →本章在这里闭合了自己的圈:霍尔的悔恨和穷尽性检查,一直是同一个论证。但“不可能”做了很重的活;unwrap() 仍然只差一次击键。
“所以如果我们在兜底分支之后添加分支,Rust 会警告我们!”
“so Rust will warn us if we add arms after a catch-all!”
查看原文 →漂亮:编译器不仅强制执行穷尽性,还监管死掉的模式。顺序很要紧,但你不会悄无声息地把它弄错。
“这告诉 Rust 我们不打算使用这个值,所以 Rust 不会就未使用的变量警告我们”
“This tells Rust we aren’t going to use the value, so Rust won’t warn us about an unused variable”
查看原文 →_ 与其说是模式,不如说是给编译器的一条消息。Rust 一再把意图变成语法——你声明你本意要忽略什么。
“我们没有忘记任何东西”
“we haven’t forgotten anything”
查看原文 →整章的情感核心,四个字。穷尽性不是关于正确性理论,而是关于忘记一个情形的恐惧。
“这是需要添加的、烦人的样板代码”
“which is annoying boilerplate code to add”
查看原文 →书承认它自己的美德是一种代价。这种诚实就是 if let 一节之所以落地的原因——它是靠读者已经感觉到的一种恼怒挣得的。
“获得简洁是否是对失去穷尽检查的适当取舍”
“whether gaining conciseness is an appropriate trade-off for losing exhaustive checking”
查看原文 →没有提供建议。Rust 给你锋利的工具和钝的安全的工具,然后拒绝替你选择——取舍由你自己承担。
“if let 的一个分支产出一个值,另一个则完全从函数中返回。”
“One branch of the if let produces a value, and the other one returns from the function entirely.”
查看原文 →尖锐的诊断:问题不是行数,而是这两个分支不是同一种东西。不对称的控制流穿着对称的语法。
“它留在函数主体的“顺利路径”上”
“it stays on the “happy path” in the main body of the function”
查看原文 →所以 let...else 的存在,主要是为了压平缩进,并把失败放在边缘。一整个语法形式,因可读性而被辩护——对特性为什么被加进来,不寻常地坦率。
“编译器会确保你的函数只得到每个函数所期望类型的值。”
“The compiler will make certain your functions only get values of the type each function expects.”
查看原文 →本章结束时,把所有这些严格重新命名为一种服务。枚举加 match 不是限制——它们是你把领域的规则推进类型系统、并停止手动检查它们的办法。
读后感AI 陪读
Rust 不再是提供特性的语言,而成了一门有立场的语言
这一章很短,却是 Rust 不再是一门有特性的语言、而成为一门有立场的语言的那一章。它开篇天真——枚举是值被一一列举出来的类型——然后做了使枚举值得一章篇幅的事:它把数据直接挂在变体上。IpAddr 从一个结构体外加一个标签,变成单一的类型,其中 V4 和 V6 携带它们各自形状不同的载荷;Message 展示了四个变体,四个互不相干的内部,住在同一个名字之下。这就是结构上的主张:一个类型可以意味着“恰好这几种形状之一”,而编译器知道是哪一种。从这里起,本章挣得了它真正的主题。Option<T> 被引入,不是作为一种便利,而是作为被有意省略的东西的替代品,而霍尔的引文是本章的铰链。让我印象深刻的是,这番自白实际上责怪的是什么——不是糟糕的理论,而是实现的容易。空值构建起来便宜,活起来昂贵,而账单几十年后在别人的代码库里到来。书的回应是慷慨的,而不是得意的:缺失的概念没问题,实现才是罪行。Option<T> 把缺失保留为一个合法的、可命名的状态,同时剥去了它伪装成存在的外衣。你不能把 Some(5) 加到 5 上,而这种摩擦就是全部的重点——Rust 的 T 是一个承诺,只有 Option<T> 承认怀疑。然后 match 到来,作为使其中任何东西可用的钥匙,而本章聪明地先把它作为一台机器来教,然后才作为规则来教:硬币落进它们适合的第一个洞。那个意象夹带了对顺序的依赖,这恰恰是初学者绊倒的地方,它还与书归在“另一个细节”下的两个更安静的事实配对——变体名字本身就是构造函数(`.
map(Some) 之所以能工作的承重原因),以及 match 是个表达式,其各分支的类型必须一致。穷尽性是高潮,而书用四个字道出了它的情感核心:我们没有忘记任何东西。不是正确性理论——而是对你没想到的那种情形的恐惧。兜底(有名字的,或 ,或 => ())满足这个检查,而编译器甚至监管被搁在其后的分支,所以你可以把顺序弄错,但不会悄无声息地弄错。_ 与其说是模式,不如说是对编译器说的一句话:我本意是忽略这个。我最尖锐的保留,是关于“不可能”这个词。本章宣称穷尽性使十亿美元的错误成为不可能,而在“编译器不会让你读取一个可能不在那里的值而不说明”的严格意义上,这是真的。但 unwrap() 只差一次击键,而纪律既是社会性的,也是机械性的。Rust 并不消除这个错误;它把它从看不见的,重新安置到明确的,从运行时的意外,安置到一行你可以 grep 到、并被问责的代码。那是个巨大的收益,但与不可能不是一回事。最后一个乐章是书到目前为止最坦率的部分。花了好几页论证穷尽性之后,它用自己的话承认了代价——烦人的样板代码——然后才把 if let 作为解脱提供,这就是解脱之所以落地的原因:它是靠你已经感觉到的恼怒挣得的。而它拒绝告诉你该用哪个。它提出取舍,简洁对穷尽检查,然后把它留在你的桌上。let...else 得到所有理由里最微妙的一个:诊断不是行数,而是 if let` 的两个分支不是同一种东西——一个产出一个值,一个离开函数——不对称的控制流穿着对称的语法。
继续阅读
修复是一种形式,其存在的全部理由,是顺利路径应当保持不缩进,而失败应当住在边缘。一个为可读性而加的特性,而书如实说了。我要对读者说的是:别把这一章归到语法下。枚举是你停止写验证代码的办法。你能使之无法表示的每个无效状态,都是一个你删掉的运行时检查,和一条你永远不必维护的注释,而编译器保证一个函数只收到它期望的形状,就是红利。所以把严格读作一种服务,而不是一种税——但读“不可能”时要扬起一条眉毛。编译器关上了门;你仍然得做到不去够那把钥匙。
在 FeedLitra 里读第 6 章,每段旁边都有这些卡片。
开始读书第 7 章:包、crate 与模块
use、as 和通配运算符如何重塑作用域内有什么?
第 7 章是怎样展开的AI 辅助
开篇:问题,然后是词汇。 本章不是从语法开始的,而是从一个动机开始——随着程序的增长,你需要某种办法来把相关的代码分组,把实现细节挡在视线之外,并控制哪些名字在哪里可用。它随后把回答这个需求的工具箱,作为一个单一的家族摆了出来:包、crate、模块、路径、use 和 pub。第一个具体动作,是表明 crate 的入口点是由约定而不是声明来确定的:一个新生成的项目除了 Cargo.toml 和 src/main.rs 之外什么也没有,而那个包已经持有一个以项目命名的二进制 crate,尽管配置文件从未提到它。组织原则立刻确立——条目除非标记为 pub,否则是私有的,这意味着作者暴露一个有意很小的公共面,并保持自由,可以重写其背后的一切。
展开:私有性,以及它是单向的。 从那里起,本章弄清楚这个默认值实际上意味着什么。可见性是有方向的:子模块把它自己的内部对其父级封起来,但子级因为是写在它周围的上下文内部的,仍可以向上够到其祖先的条目——餐厅的后台办公室,顾客从不进入,而经理们自由出入。这也是对 pub 的一个关键限制被演示、而不仅仅是被断言的地方。一份清单把 hosting 模块标为公有,却仍然编译失败,编译器报告里面的函数是私有的。让模块公有,打开了门;里面的每个条目仍需要它自己的 pub 才能到达。与此同时,super 作为写出相对于父模块的路径的办法而到来。
转折:规则不再是统一的。 本章的中段是材料变得更有意思的地方,因为“加上 pub”这条简单的规则,开始按条目种类和惯用法分裂。默认的可见性有意不同:结构体字段起初是私有的,而公有的枚举暴露每一个变体。理由是务实的——变体被隐藏的枚举几乎没用,所以强迫给每一个都加标注纯粹是摩擦,而结构体字段往往更宜保持封闭。早餐的例子使之有形:用餐者选面包(公有字段),厨师选水果(私有字段),而正因为那个私有字段,这个结构体需要一个公共构造函数,才能从外部构建它。同样的不对称在风格上重现。对于函数,惯用法是引入父模块,并调用 hosting::add_to_waitlist,使来源在调用处可见;对于结构体和枚举,你一路写到类型本身,以 HashMap 的完整路径为范例。值得注意,作者承认,那个约定的后半部分并不依赖任何有力的论据——只是碰巧成了习惯。围绕这些的是其余的 use 特性:用于别名的 as,用于重导出一个整理过的公共形状的 pub use,通过 Cargo.toml 引入外部 crate,嵌套路径,和通配运算符。这里也有一个作用域的警告,它是本章最常见的绊脚点:use 是在某个特定作用域里创建的捷径,而不是整个文件范围的导入——它不会伸进子模块,子模块需要它们自己的 use,或通过 super 的路径。
收尾:把代码移进文件,以及回报。 最后一段转向文件布局,而关键的洞见是,mod 是声明,而不是导入。它给模块命名一次,并告诉编译器哪个文件承载它的主体;没有哪一行 use 决定什么被编译。Rust 接受那个主体的两种布局,file.rs 或 file/mod.rs,而实际被禁止的只有一种组合——对同一个模块两者都用。在不同模块之间混用风格是合法的,只是难读;作者偏好较新的形式,理由很平凡:一棵满是名为 mod.rs 的文件的树,在编辑器里导航起来很痛苦。因为子级的文件必须住在以其父级命名的目录里,遵守这条规则使目录结构免费地成为模块树的一幅图。本章然后落在整条弧线一直在建立的要点上:抽取之后,eat_at_restaurant 内部的调用原封不动地编译,而 src/lib.rs 里的 pub use 重导出也不需要编辑。把代码拆到文件里,只是外观上的。crate、模块、路径、use 和 pub 关乎的是什么可见,以及一个模块如何称呼另一个模块的条目——而无论代码碰巧放在哪里,这一点都保持不变。
关键论点,以及支撑它们的原文
哪个文件是 crate 的起点,由命名约定决定,而不是由你在 Cargo.toml 里声明的任何东西决定。
作者指向一个新项目:运行新建项目的命令,只产生一个 Cargo.toml 和 src/main.rs,而那个包已经包含一个带项目名字的二进制 crate——配置文件里不需要列出任何东西。
“Cargo 遵循一个约定:src/main.rs 是与包同名的二进制 crate 的 crate 根。”
“Cargo follows a convention that src/main.rs is the crate root of a binary crate with the same name as the package.”
§7.1 Packages and Crates查看原文 →
把模块标记为公有,打开了门,却没打开房间:里面的每个条目仍需要它自己的 pub,才能从外部被调用。
作者展示了一份清单,其中 hosting 模块被设为公有,而代码仍然编译失败,编译器报告里面的函数是私有的——证明模块层面的 pub 只暴露模块的名字。
“但 hosting 的内容仍然是私有的;使模块公有,并不会使它的内容公有。”
“But the contents of hosting are still private; making the module public doesn’t make its contents public.”
§7.3 Paths for Referring to an Item in the Module Tree查看原文 →
默认可见性有意因条目种类而异:结构体字段起初是私有的,而公有的枚举暴露它所有的变体。
作者推理说,变体被隐藏的枚举几乎没用,所以给每一个加标注只会令人烦恼,而结构体字段往往更宜保持私有。早餐的例子使这一点具体:用餐者选面包(公有字段),而厨师选水果(私有字段),而这样的结构体于是需要一个公共构造函数,才能从外部构建。
“枚举如果变体不是公有的,就不大有用;在每种情形下都要给所有枚举变体加 pub 标注会很烦人,所以枚举变体的默认值是公有的。”
“Enums aren’t very useful unless their variants are public; it would be annoying to have to annotate all enum variants with pub in every case, so the default for enum variants is to be public.”
§7.3 Paths for Referring to an Item in the Module Tree查看原文 →
另外 3 个论点
惯用法同样是不对称的:对函数,引入它的父模块,而对结构体和枚举,一路写到类型本身。
顺着路径和 pub 如何使一次调用合法,作者对比了两份清单——引入模块并调用 hosting::add_to_waitlist 是惯用的,而引入函数本身被标为不惯用——并把 HashMap 的完整路径立为类型的范例。作者坦率承认,类型那一半并不依赖任何有力的推理;它只是碰巧成了习惯的约定。
“在调用函数时指明父模块,使人清楚这个函数不是本地定义的,同时仍把完整路径的重复降到最低。”
“Specifying the parent module when calling the function makes it clear that the function isn’t locally defined while still minimizing repetition of the full path.”
§7.4 Bringing Paths Into Scope with the use Keyword查看原文 →
Rust 为模块代码保留了两种合法的文件布局,而实际被禁止的只有一种特定的组合。
一个模块的主体可以住在 file.rs 里,或 file/mod.rs 里,对同一个模块两种风格都用,是编译错误;在同一个项目中,跨不同模块混用两种风格是允许的,只是读起来令人困惑。作者偏好较新风格的理由是务实的:一个满是名为 mod.rs 的文件的项目,在编辑器里很难区分。
“在同一个项目中,对不同的模块混用两种风格是允许的,但可能让浏览你项目的人感到困惑。”
“Using a mix of both styles for different modules in the same project is allowed but might be confusing for people navigating your project.”
§7.5 Separating Modules into Different Files查看原文 →
把模块移到单独的文件里,纯粹是外观上的——模块树、路径和调用处都不受触动。
抽取之后,作者指出 eat_at_restaurant 内部的调用不用任何修改就能编译,而 src/lib.rs 里的 pub use 重导出那一行,也不需要改动。这收拢了本章的弧线:crate、模块、路径、use 和 pub 关乎的,都是控制什么是可见的,以及一个模块如何称呼另一个模块的条目,不管代码放在哪个文件里。
“eat_at_restaurant 里的函数调用不用任何修改就能工作,尽管定义位于不同的文件里。”
“The function calls in eat_at_restaurant will work without any modification, even though the definitions live in different files.”
§7.5 Separating Modules into Different Files查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
默认私有保护调用者
因为条目除非标记为 pub 否则是隐藏的,外部代码只能依赖作者选择暴露的小小公共接口。作者于是可以自由地重写被隐藏的内部,而不破坏外部的任何东西,而调用者只需要知道一个操作做什么,而不必知道它如何工作。
机制 2
可见性只朝一个方向走
子模块包裹它自己的细节,所以父级无法够到其内部的私有条目。但子级是写在它周围的上下文内部的,仍可以使用祖先的条目——就像顾客从不见到、而经理自由出入的餐厅后台办公室。
机制 3
mod 把编译器指向一个文件
mod 声明给模块命名一次,并告诉编译器哪个文件承载它的主体,而不是粘贴文本进来。因为子级的文件必须放在以其父级命名的目录里,遵守这条规则,就自动使文件夹和文件的布局成为模块树的一幅图。
常见误读
一旦你在文件顶部附近写了一行 use,那个短名字在文件的任何地方都管用,包括嵌套的模块里。
use 捷径更像是在某个特定作用域里创建的符号链接:它只在被写下的地方成立,不会延伸到子模块,子模块需要它们自己的 use 行(或通过 super 的路径)。私有性规则仍适用于这条捷径所指向的任何东西。
“注意,use 只会为 use 所在的特定作用域创建捷径。”
“Note that use only creates the shortcut for the particular scope in which the use occurs.”
§7.4 Bringing Paths Into Scope with the use Keyword查看原文 →
mod 和 use 是做同一件导入工作的两种方式,所以加上一行 use,就会使一个模块的文件被编译。
这两个关键字干不同的活:mod 声明一个模块一次,并告诉编译器哪个文件持有它的代码,而 use 只是为树中已有的东西创建一个更短的名字。声明不是导入,没有哪一行 use 决定哪些文件被编译。
“mod 关键字声明模块,而 Rust 会在一个与模块同名的文件里,寻找属于该模块的代码。”
“The mod keyword declares modules, and Rust looks in a file with the same name as the module for the code that goes into that module.”
§7.5 Separating Modules into Different Files查看原文 →
第 7 章的核心概念
- crate
- 编译器一次处理的最小代码块;要么是带 main 的二进制,要么是用于分享的库。
- 适用处: 传给 rustc 的单个文件已经算一个 crate;一个包可以有许多二进制,但至多一个库。
- 包
- 一个或多个 crate 的捆绑,带一个描述如何构建它们的 Cargo.toml。
- 适用处: 至少需要一个 crate;至多一个库 crate。
- 模块树
- 嵌套的模块结构,根在一个名为 crate 的隐式模块,很像文件系统的目录树。
- 适用处: 兄弟共享一个父级;父级不能伸进子级的私有条目。
- 路径
- 给一个条目命名的方式:绝对路径从 crate 根开始,相对路径从当前模块开始。
- 适用处: 两者都用 :: 分隔;偏好哪个,取决于定义和调用处是否一起移动。
- 默认私有
- 条目对父模块是隐藏的,除非标记为 pub,所以内部细节可以安全地改变。
- 适用处: 模块上的 pub 只暴露模块名,不暴露其内容;子级仍可以使用祖先的条目。
- 作用域
- 嵌套的上下文,其已定义名字的集合在代码中的某个位置是可见的。
- 适用处: 两个条目不能在同一作用域里同名;use 创建通向某个作用域的捷径。
另外 8 个概念
- super 路径前缀
- 在父模块中开始一条相对路径,就像文件系统路径中的 ..。
- 适用处: 当一个条目和它的父级很可能一起移动时有用;否则绝对路径可能更稳定。
- 结构体对枚举上的 pub
- 公有的结构体保留私有字段,除非每个都标记 pub;公有的枚举暴露它所有的变体。
- 适用处: 有任何私有字段的结构体,需要一个公共关联函数,才能从外部被构造。
- use 关键字
- 为一条路径创建一个短名字,就像符号链接,只在它被写下的作用域中有效。
- 适用处: 私有性仍会被检查,而且这条捷径不会伸进子模块。
- pub use 重导出
- 把 pub 和 use 结合,使外部代码可以通过一条更短的、重新设计过的路径够到一个条目。
- 适用处: 当内部模块结构与调用者思考领域的方式不同时最有价值。
- 作为文件加载器的 mod 声明
- mod 给模块命名一次,并告诉编译器哪个文件持有它的主体;它不是文本包含。
- 适用处: 子模块的文件必须住在以其父级命名的目录里,否则编译器会找错地方。
- 通配运算符
- use 的 * 形式,一次性把一条路径的所有公有条目引入作用域。
- 适用处: 模糊了名字的来源,并使导入在依赖变化时发生变化;主要用于测试和 prelude。
- mod 关键字
- 声明一个模块,并告诉编译器去一个同名的文件里找它的代码。
- 适用处: 声明不是导入;use 不决定哪些文件被编译。
- mod.rs 风格
- 较旧的目录布局,其中模块的代码住在它文件夹里的 mod.rs 文件中。
- 适用处: 仍被支持,但不能与同一模块的 file.rs 风格结合。
第 7 章划线与批注AI 陪读
“这是另一种限制你必须记在脑子里的细节量的办法。”
“This is another way to limit the amount of detail you have to keep in your head.”
查看原文 →封装真正的论据不是编译器的强制,而是认知负荷。好在书先点出了人的代价。
“crate 是 Rust 编译器一次考虑的最小代码量。”
“A crate is the smallest amount of code that the Rust compiler considers at a time.”
查看原文 →按编译单元,而不是按文件夹或文件的定义。这是关键的重新框定——crate 是编译器视角的概念,而不是文件系统的概念。
“在你的文本编辑器里打开 Cargo.toml,注意其中没有提到 src/main.rs。”
“Open Cargo.toml in your text editor and note that there’s no mention of src/main.rs.”
查看原文 →靠缺席来教学——指着没有写的东西,来揭示约定优于配置。一个悄悄有效的教学动作。
另外 12 条批注
“如果你想让一个函数或结构体之类的条目私有,就把它放进一个模块。”
“If you want to make an item like a function or struct private, you put it in a module.”
查看原文 →值得停下来的倒装措辞:私有性不是你加上的关键字,而是你把东西放进去的边界。模块是保密的单位。
“办公室经理可以看到并做他们所运营的餐厅里的一切”
“office managers can see and do everything in the restaurant they operate”
查看原文 →这个比喻在这里物有所值——不对称的可见性,作为规则感觉任意,作为组织架构图则显而易见。
“让模块公有,并不会让它的内容公有”
“making the module public doesn’t make its contents public”
查看原文 →这个两个错误的序列是有意的脚手架:它们让你失败两次,这样 pub 就不再感觉像一个魔法开关。门开了 ≠ 柜子开了。
“你不仅是作者,你还是一个客户!”
“not only are you the author, but you’re also a client!”
查看原文 →本章最好的 API 设计建议,被夹带在一个侧栏里。强迫二进制文件通过公共 API,是个结构性的把戏,而不是纪律上的。
“我们的偏好一般是指定绝对路径”
“Our preference in general is to specify absolute paths”
查看原文 →他们给出了推理——定义和调用处各自独立移动——而不只是一条规则。不过我要指出,super 后来削弱了它作为一概而论的默认值。
“厨师根据当季和库存里有什么,来决定搭配哪种水果”
“the chef decides which fruit accompanies the meal based on what’s in season and in stock”
查看原文 →这个比喻在这里物有所值:私有性不是官僚主义,而是调用者无权断言其值的那个字段。封装由领域的易变性来辩护。
“这个结构体需要提供一个公共的关联函数,来构造一个实例”
“the struct needs to provide a public associated function that constructs an instance”
查看原文 →悄悄地是这一节里最重要的一句话——一个私有字段,构造函数就成了必须的 API。私有性传播进设计,而不只是访问。
“枚举的变体如果不是公有的,枚举就不大有用”
“Enums aren’t very useful unless their variants are public”
查看原文 →所以这种不对称是务实的,而不是有原则的。Rust 的“默认私有”,在默认值只会令人烦恼的地方都有例外——诚实,但这意味着你要背,而不是推导。
“这种惯用法背后没有强烈的理由:只是已经出现的约定”
“There’s no strong reason behind this idiom: It’s just the convention that has emerged”
查看原文 →在一本语言书里,难得而令人钦佩的坦率。半页从清晰的角度为函数的惯用法辩护,然后结构体的惯用法根本没有得到论证——约定被承认为约定。
“我们可以用一种结构来编写我们的代码,而暴露另一种结构”
“we can write our code with one structure but expose a different structure”
查看原文 →这是本章真正的论点,迟到而低调:你在其中构建的模块树,和你发布的那一棵,是两个不同的产物。之前的一切只是机制。
“mod 不是你可能在其他编程语言里见过的“include”操作”
“mod is not an “include” operation that you may have seen in other programming languages”
查看原文 →直接瞄准 C 的习惯。声明一次,然后用路径指向它——文件不是意义的单位,树才是;文件名只需要与它保持一致。
“你的项目最终可能有许多名为 mod.rs 的文件,当你把它们同时打开时,可能会令人困惑”
“your project can end up with many files named mod.rs, which can get confusing when you have them open”
查看原文 →反对旧风格的决定性论证,是编辑器标签页的可读性。工具的人体工学,作为语言设计的压力——小,而完全真实。
读后感AI 陪读
一章之内,从簿记走到设计哲学
这一章以簿记开篇,以设计哲学告终,而这两种状态之间的距离,正是它值得重读的原因。它一开始用参考手册那种平淡的语气,摆出一套词汇——包、crate、模块、路径、use、pub——而第一个真正的洞见是一个定义:crate 是编译器一次考虑的最小代码量。这一句话重新定位了一切。Rust 的组织单位是编译器视角的概念,而不是文件系统的概念,而本章用其余的篇幅,悄悄地针对读者所继承的直觉捍卫着这个主张。Cargo.toml 对 src/main.rs 的沉默,靠缺席来教授约定;mod 被明确声明不是 include;文件名原来只需要与一棵独立于它们而存在的树保持一致。如果你来自 C,这一章就是一次漫长而耐心的纠正。中间一段是它教得最好的地方,而且是靠让你失败来教的。围绕 pub 的两个错误的序列——模块是公有的,内容仍是私有的——的存在,是为了打破 pub 是一个单一魔法开关的信念;门开了不等于柜子开了。餐厅的比喻,通常是人们容忍的那类东西,实际上两次赢得了它的位置:不对称的可见性(子级看得见上面,父级看不见下面)作为规则读起来任意,作为组织架构图读起来显而易见,而厨师挑选当季水果,把私有性从官僚主义变成了一个关于调用者无权断言哪些字段的声明。最尖锐的观察藏在平白的文字里:给结构体一个私有字段,公共构造函数就成了必须。私有性不只是限制访问,它还传播进 API 的形状。这是本章最重要的一句话,而它被不加强调地说了出来。
我想诚实地指出设计是务实、而不是有原则的地方。枚举变体全是公有的,因为否则枚举会很烦人——一个明智的例外,但是一个你要背下来、而不是推导出来的例外,这意味着 Rust 的私有性故事里有一道接缝。书对嵌套路径和导入结构体的惯用法同样坦率:半页的篇幅从清晰的角度为函数导入的惯用法辩护,然后结构体的惯用法根本没有得到论证,只是“已经出现的约定”。在一本语言书里,难得的坦率,比制造出来的理由更可信。同样,对绝对路径对相对路径的偏好附有推理——定义和调用处各自独立移动——尽管 super 后来出现,悄悄削弱了它作为一概而论的默认值。而反对 mod.rs 的决定性论证,原来是编辑器标签页的可读性:工具的人体工学,作为真正的语言设计压力,小而完全真实。本章几乎顺带说出的两件事,是我会向读者强调的。第一,它在一开始就设定、且从未放弃的框架:封装真正的理由是认知负荷,而不是编译器的强制——对你必须在脑子里持有多少细节的限制。第二,通过 pub use 迟到而低调地出现的论点:你可以用一种结构来构建你的代码,而暴露另一种结构。内部的树和发布的树,是两个独立的产物。之前的一切——路径、super、pub、嵌套导入、文件拆分——都是服务于那种分离的机制。从 pub use 倒着读,这一章不再是簿记,而成了一篇关于 API 设计的短论,其最好的建议被夹带在一个侧栏里:把逻辑放进库里,保持 main.rs 很薄,你就成了你自己的第一个客户。
继续阅读
这是个结构性的把戏,而不是纪律的问题,这恰恰就是它管用的原因。
在 FeedLitra 里读第 7 章,每段旁边都有这些卡片。
开始读书第 8 章:常用集合
UTF-8 的字节存储,为什么使 String 的整数索引毫无意义?
第 8 章是怎样展开的AI 辅助
开篇:一个共享的前提。 本章一开始,把它的三个主题都框定在同一个标题之下——住在堆上、而不是住在一个已知大小的固定槽位里的集合。这个框架后来做了实事,因为本章引入的几乎每条规则,都由堆存储而来:可以增长的数据,增长时可能不得不移动的数据,以及在编译期大小未知的数据。定下这个前提之后,文字先去最简单的情形,Vec<T>,并走过一个列表的寻常生命周期:怎么创建一个,怎么往上 push,怎么从中读取。
第一个展开:读取,以及第一次转入约束。 读取一节是本章做出第一个有意论证的地方。Rust 提供两种到达元素的方式,而这一对被呈现为给程序员的选择,而不是冗余:在一个五项的列表上,&v[100] 使程序停下,而 v.get(100) 返回一个“这里什么也没有”的值——所以一个越界的索引,既可以被当作值得为之崩溃的 bug,也可以被当作在正常控制流里要处理的情形。紧接着教完如何读取之后,本章转过头来,解释为什么读取与增长冲突。一个示例程序被拒绝了,因为当向量被可变借用以追加时,一个不可变借用仍然存活,而理由是机械的:元素并排而坐,所以增长可能需要更大的块,和把一切重新搬迁,那会让先前的引用指向不再属于向量的内存。这里确立的模式——先展示便利,再展示约束及其物理上的理由——就是本章的组织习惯。迭代、用于在一个向量中存储混合类型的枚举,以及丢弃,补全了这一节。
转向 String,以及本章核心的拒绝。 这个过渡是设计出来的,而不是随意的:String 本身就是对持有 UTF-8 的 Vec<u8> 的包装,所以本章可以把它的向量直觉带过去,同时推翻其中一个。整数索引,在向量上被允许,在这里就是被禁止的,而文字从内存布局来论证这一点。西里尔文的问候语 Здравствуйте 报告的长度是 24,而不是 12,因为每个字母占两个字节——所以“位置 n”没有单一的含义。即使在纯 ASCII 里,这个操作也会误导:“hi” 的第一个槽位是数字 104,而不是字母 h。又加了第二个理由:索引被期望无论大小,成本都相同,而到达第 n 个字符需要从头解码字节,成本随文本增长,所以这个保证无法兑现。拒绝之后,本章提供了替代品——说出你指的是哪种视图。它摆出三种读法(字节、标量值、字形簇),然后是工具:对 “Зд” 调用 chars 产生两项,而 bytes 产生四个数字(208、151、208、180)。范围切片被提供为另一种显式选项,一条警告被直白地陈述,而不是被埋着——边界仍然是字节位置,在一个多字节字符中间切开会中止程序,这是一个在运行时而不是编译期浮现的失败。
收尾:哈希映射,以及最后一个交给读者的决定。 HashMap<K,V> 作为与先前内容的对比而到来:列表按数字位置索引,而本章的示例——游戏中的队伍得分——被选中,是因为你想要问 “Blue”,而不是槽位 0。创建、get,以及插入拥有所有权的值时发生的所有权移动,先来;然后本章最后一次回到它反复出现的主题,即便利不能隐藏一个选择。为 “Blue” 两次插入一个值,只留下较新的 25,这表明普通的插入会悄悄覆盖;所以更新被呈现为三个明确的选项——替换、仅在缺失时插入、或把旧值与新值合并。合并的情形得到收尾的例子:一个单词计数循环请求每个单词的条目,只为新单词提供一个起始计数零,然后通过返回的句柄递增,得到 world 为二,hello 和 wonderful 各为一。关于默认 SipHash 哈希器的简短说明结束了本章,给读者留下与它开篇时同样的立场——足够好地理解存储,以便知道每个 API 为什么长成这个样子。
关键论点,以及支撑它们的原文
Rust 有意提供两种到达列表元素的方式,让程序员来决定在越界位置上该发生什么。
对一个五项的列表,写 &v[100] 会让程序当场崩溃,而 v.get(100) 则交回一个“这里什么也没有”的值;作者呈现这一对,是为了让一个坏索引,既可以被当作值得为之停下的 bug,也可以被当作要处理的寻常情形。
“Rust 提供这两种引用元素的方式,这样当你试图使用一个超出现有元素范围的索引值时,你可以选择程序的行为。”
“Rust provides these two ways to reference an element so that you can choose how the program behaves when you try to use an index value outside the range of existing elements.”
§8.1 Storing Lists of Values with Vectors查看原文 →
在往列表里添加东西时,不能持有对它的引用,这条规则的存在,是因为追加可能会重新定位整个缓冲区。
刚展示完如何读取元素,作者就转向为什么读取与增长冲突:一个示例程序被编译器拒绝,因为在一个较早的不可变借用仍在使用时,可变地借用了列表,而给出的理由是,如果列表超出了它的空间并移动,旧的引用就会指向不再属于它的内存。
“在这种情况下,指向第一个元素的引用将指向已被释放的内存。”
“In that case, the reference to the first element would be pointing to deallocated memory.”
§8.1 Storing Lists of Values with Vectors查看原文 →
按数字从字符串里挑出一个字符,根本就是不允许的,而理由在于字符串在内存里是如何保存的。
编译器报告这个类型不能用整数索引,而作者通过测量文本来解释为什么:西里尔文的问候语“Здравствуйте”报告的长度是 24 而不是 12,因为它的每个字母需要两个字节。即使是纯 ASCII,索要 “hi” 的第一个槽位,也会得到数字 104,而不是字母 h——几乎没人想要的结果——所以拒绝这个操作,防止了这样的意外。
“错误说明了一切:Rust 的字符串不支持索引。”
“The error tells the story: Rust strings don’t support indexing.”
§8.2 Storing UTF-8 Encoded Text with Strings查看原文 →
另外 3 个论点
既然对“位置 n 上是什么”没有唯一正确的答案,补救办法是明确说出你指的是文本的哪种视图——字节、字符,还是人类感知的字母。
作者展示了三种可能的读法,然后是陈述其中一种的工具:按字符迭代“Зд”产生两项,而按字节迭代同一段文本则产生四个数字(208、151、208、180)。范围切片是另一种显式的选项,但带着一条明说的警告——在落入一个字符内部的位置切开,比如取一个多字节单词的第一个字节,会中止程序,而这个检查是在运行时而不是编译期发生的。
“对字符串的片段进行操作的最佳办法,是明确说出你想要的是字符还是字节。”
“The best way to operate on pieces of strings is to be explicit about whether you want characters or bytes.”
§8.2 Storing UTF-8 Encoded Text with Strings查看原文 →
哈希映射赢得位置,是靠让你通过适合问题的任何类型的键,而不是通过数字位置来找数据。
作者把它们与列表对比,并以一个游戏作为例证:分数存在每支队伍的名字之下,所以你索要的是“Blue”,而不是槽位 0,这恰恰是列表给不了你的查找。
“当你想要查找数据,不是像向量那样用索引,而是用一个可以是任何类型的键时,哈希映射很有用。”
“Hash maps are useful when you want to look up data not by using an index, as you can with vectors, but by using a key that can be of any type.”
§8.3 Storing Keys with Associated Values in Hash Maps查看原文 →
顺着这种基于键的视图,改变一个存储的值,迫使你做出一个明确的决定:替换旧值,仅在键缺失时添加,还是把旧值与新值合并。
为“Blue”第二次插入一个值,只留下较新的 25,较早的 10 没了——证明普通的插入会悄悄替换。对于合并的情形,一个单词计数循环向映射索要每个单词的条目,只在该单词是新的时提供一个起始计数零,然后通过返回的句柄增加计数,得到“world”为二,“hello”和“wonderful”各为一。
“当你想改变哈希映射中的数据时,你必须决定如何处理一个键已经被赋了值的情形。”
“When you want to change the data in a hash map, you have to decide how to handle the case when a key already has a value assigned.”
§8.3 Storing Keys with Associated Values in Hash Maps查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 push 使引用失效
向量把它的元素并排存放,所以添加一个可能需要更大的一块内存,和把每个元素都搬到那里去。先前取得的引用于是会指向已释放的内存,这就是编译器拒绝让借用在追加期间保持存活的原因。
机制 2
为什么按字节索引毫无意义
字符串其实是对持有 UTF-8 的字节向量的包装,而一个写出来的字符可能占据好几个字节。索引会交回一个字节,它可能只是一个字母的片段,所以语言拒绝了,而不是返回一个悄悄误导的值。
机制 3
为什么按字符索引不可能是常数时间
索引通常被期望,无论集合多长,成本都相同。然而,要到达第 n 个字符,程序就得从头走起,一路解码字节,成本随文本增长,所以那个保证无法兑现。
常见误读
字符串的长度告诉你它包含多少个字母,所以它可以充当字符数。
那个数字数的是 UTF-8 编码的字节,只有当每个字符碰巧只占一个字节时,它才等于字母数。作者的测量把差距说得明明白白:一句十二个字母的西里尔文问候语报告的是 24,因为每个字母以两个字节编码。
“因此,答案是,为了避免返回一个意外的值,造成可能不会立即被发现的 bug,Rust 压根不编译这段代码,并在开发过程的早期就防止误解。”
“The answer, then, is that to avoid returning an unexpected value and causing bugs that might not be discovered immediately, Rust doesn’t compile this code at all and prevents misunderstandings early in the development process.”
§8.2 Storing UTF-8 Encoded Text with Strings查看原文 →
既然数字索引被阻止,从字符串里取一个范围就是安全的替代,不会出错。
范围切片被提供为显式的选项,而不是保证安全的选项:边界仍然是字节位置,而在一个多字节字符内部切开,会在运行时中止程序,并抱怨字节索引不是字符边界——这是编译器不会替你抓住的失败,这就是为什么作者力劝对它们保持谨慎。
“对字符串的片段进行操作的最佳办法,是明确说出你想要的是字符还是字节。”
“The best way to operate on pieces of strings is to be explicit about whether you want characters or bytes.”
§8.2 Storing UTF-8 Encoded Text with Strings查看原文 →
第 8 章的核心概念
- Vec<T>
- 一个可增长的列表,把一种类型的值在内存中彼此相邻地存放。
- 适用处: 只能持有单一类型,除非你把各种变体包在一个枚举里。
- 带边界检查的索引
- 使用 &v[i],越界的索引会 panic,而 get 则返回一个 Option。
- 适用处: 当坏索引是正常的运行时可能性、而不是 bug 时,选择 get。
- 借用
- 持有对向量的引用,会阻止可能移动其内存的修改。
- 适用处: 迭代也同样适用:循环的引用阻止插入或移除。
- String
- 对字节向量的、可增长的、拥有所有权的、UTF-8 编码的包装。
- 适用处: 不是核心语言的一部分,也不能按整数索引。
- 枚举变体
- 不同的值形状共用一个枚举类型,让一个向量可以持有混合的数据。
- 适用处: 当运行时类型的集合并非已知穷尽时会失败;这时用 trait 对象。
- 解引用强制转换
- 编译器在调用 add 时,自动把 &String 转换成 &str。
- 适用处: 解释了为什么 s1 + &s2 能编译,尽管 add 想要的是 &str。
另外 6 个概念
- 作为 Vec<u8> 包装的 String
- String 其实是个字节向量,所以它的长度数的是字节,不是字母。
- 适用处: 只有对 1 字节的 ASCII 字符,字节长度才等于字母数。
- 字节、标量值与字形簇
- 读取字符串数据的三种方式:原始字节、char 值,和人类所说的字母。
- 适用处: 字形簇标准库不提供;需要用 crate。
- HashMap<K, V>
- 一种通过哈希函数,把一种类型的键映射到另一种类型的值的集合。
- 适用处: 键和值都是同质的;不在 prelude 里;迭代顺序是任意的。
- entry API 与 or_insert
- 检查一个键,要么返回其现有值的可变引用,要么插入一个默认值。
- 适用处: 只在键缺失时才插入;返回的引用,借用在作用域结束时结束。
- 移动
- 把一个拥有所有权的 String 插入哈希映射,会转移所有权,使原来的变量失效。
- 适用处: 像 i32 这样的 Copy 类型则是被复制;插入的引用必须比映射活得更久。
- SipHash 默认哈希器
- 默认的哈希函数用速度换取对拒绝服务攻击的抵抗。
- 适用处: 如果性能剖析表明它太慢,可以换用自定义的哈希器。
第 8 章划线与批注AI 陪读
“每种集合都有不同的能力和代价,为你当前的情形选一个合适的,是你会随时间培养的一项技能。”
“Each kind of collection has different capabilities and costs, and choosing an appropriate one for your current situation is a skill you’ll develop over time.”
查看原文 →一句诚实的话,悄悄地拒绝教学:它告诉你品味无法交付,只能积累。教程难得承认自己的天花板。
“Rust 提供这两种引用元素的方式,这样你就可以选择程序的行为”
“Rust provides these two ways to reference an element so that you can choose how the program behaves”
查看原文 →整个设计哲学,一行说完——语言不替你选择崩溃还是恢复;它让这个选择明确,并在语法上可见。
“为什么对第一个元素的引用,要关心向量末尾的改动?”
“Why should a reference to the first element care about changes at the end of the vector?”
查看原文 →作者在解释之前,先说出了读者的反对——而答案是内存布局。好的教学:让直觉先大声地错。
另外 16 条批注
“在这种情况下,指向第一个元素的引用将指向已被释放的内存。”
“In that case, the reference to the first element would be pointing to deallocated memory.”
查看原文 →这是连续性并不免费的真正原因。向量靠地址的不稳定,来为它的缓存友好付费。
“字符串是比许多程序员所认为的更复杂的数据结构”
“strings being a more complicated data structure than many programmers give them credit for”
查看原文 →诚实的诊断:Rust 没有让字符串变难,它只是不再隐藏它们一直都是这样。别的语言让你舒舒服服地继续错下去。
“我们为什么用对 s2 的引用,与我们使用 + 运算符时被调用的方法的签名有关”
“the reason we used a reference to s2, has to do with the signature of the method that’s called when we use the + operator”
查看原文 →可爱的祛魅:s1 + &s2 的不对称不是任意的语法,而是一个函数签名透了出来。读一读类型,怪异就消散了。
“看起来它在做大量的拷贝,但其实没有;这个实现比拷贝更高效”
“it looks like it’s making a lot of copies, but it isn’t; the implementation is more efficient than copying”
查看原文 →让 + 令人困惑的那个动作,恰恰是让它快的动作。人体工学被牺牲了,去换取一个你看不到的分配的复用。
“这里有六个 char 值,但第四个和第六个不是字母”
“There are six char values here, but the fourth and sixth are not letters”
查看原文 →这是教训落地的地方。char 不是一个字母——哪里都不是,从来不是。Rust 只是让你去看那道缝隙。
“Rust 不允许我们对 String 做索引来获得字符的最后一个原因是,索引操作被期望总是花常数时间(O(1))”
“A final reason Rust doesn’t allow us to index into a String to get a character is that indexing operations are expected to always take constant time (O(1))”
查看原文 →注意论证类型的转变——从正确性转向性能契约。Rust 拒绝提供一个其代价会与它的语法不符的操作。
“所以,如果你真的需要用索引来创建字符串切片,Rust 要求你更具体一点。”
“If you really need to use indices to create string slices, therefore, Rust asks you to be more specific.”
查看原文 →不是禁止,而是要求承诺:范围仍然让你在字符中间切片,并在运行时 panic。具体性给你买到的是许可,而不是安全。
“end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)”
查看原文 →错误信息做着文档做不到的教学工作:它点出冒犯的字符及其字节跨度。Rust 的诊断是它教学法的一部分。
“从字符串中获取字形簇,如天城文,是复杂的,所以标准库没有提供这个功能。”
“Getting grapheme clusters from strings, as with the Devanagari script, is complex, so this functionality is not provided by the standard library.”
查看原文 →诚实,但注意它意味着什么:人类实际感知为“一个字母”的那一层,是 Rust 拒绝提供的唯一一层。默认的正确性,在用户的眼前戛然而止。
“它防止你在开发生命周期的后期,不得不处理涉及非 ASCII 字符的错误”
“it prevents you from having to handle errors involving non-ASCII characters later in your development life cycle”
查看原文 →整个取舍的论证,一个从句说完——痛苦被提前了,而不是被消除。值得问一问,在第8章感受到这种摩擦的初学者,是把它体验为预防,还是惩罚。
“在我们三个常用的集合中,这个是最少使用的,所以它没有被包含在 prelude 中自动引入作用域的特性里。”
“Of our three common collections, this one is the least often used, so it’s not included in the features brought into scope automatically in the prelude.”
查看原文 →难得一窥语言的人体工学是如何被配给的:prelude 是一场人气竞赛。use 那一行,是对多年前某人做出的判断的一种税。
“let score = scores.get(&team_name).copied().unwrap_or(0);”
查看原文 →读一个数字要三次方法调用——而本章在展示每个环节之后,才解释它。这是 Option 税对学习者变得可见的时刻。
“这种技术比我们自己写逻辑要干净得多,此外,它与借用检查器合作得也更好。”
“This technique is much cleaner than writing the logic ourselves and, in addition, plays more nicely with the borrow checker.”
查看原文 →第二个理由才是真正的理由。entry 的存在,是因为朴素的 contains_key 再 insert 的模式会借用两次;API 是由借用检查器塑造的,而不只是由品味。
“let count = map.entry(word).or_insert(0); *count += 1;”
查看原文 →两行完成单词计数,而 * 是整个教训:你持有一个指向映射内部的活指针,而编译器已经证明在此期间没有别人能碰它。
“为了更好的安全而付出的性能下降这个代价是值得的”
“the trade-off for better security that comes with the drop in performance is worth it”
查看原文 →一个被陈述为事实的价值判断——而且是个站得住脚的,因为另一种选择是一个远程攻击者把你的映射塌缩成链表。不过,“值得”是书在替你选择,而退出的办法被埋在后面。
“把字符串转换成 Pig Latin。每个单词的第一个辅音被移到末尾”
“Convert strings to Pig Latin. The first consonant of each word is moved to the end”
查看原文 →一个狡猾的练习:它看起来像个玩具,但“第一个辅音”迫使你去问,第一个字符究竟是什么——恰恰是这一段刚刚花了好几页搭起来的陷阱。
读后感AI 陪读
每一项约束都揭示了其他语言所隐藏的代价
这一章一开始许诺三个集合,却悄悄承认它无法教授关于它们的最重要的事——该选哪一个。这个承认给此后的一切定了调:这是一章关于其他语言所隐藏的代价的内容,它按诚实程度递增的顺序展开。Vec<T> 先来,看上去友好,直到索引分裂成两种形式,而这个分裂原来是一种哲学——Rust 不会替你选择崩溃还是恢复,它让这个选择在语法中可见。然后借用检查器拒绝了一个看似无辜的 push,读者的反对被大声说了出来,然后才被回答,而答案是内存布局:连续性买来缓存友好,付出的代价是不稳定的地址。那是本章真正的方法——让直觉大声地错,然后表明规则从来不是任意的,只是没被解释。针对混合类型的枚举技巧,是缩影中同一个动作:一个限制,靠预先给变化命名而绕过去。字符串是本章不再舒服的地方。关键的重新框定是:Rust 没有让字符串变难;它只是不再让你心安理得地出错。String 是 Vec<u8> 的包装,所以整数索引被禁止,而三种视图——字节、标量值、字形簇——暴露出“一个字符”从来是三个不同的问题。六个字符的天城文例子把它落实了:char 不是一个字母,从来不是,在任何语言里都不是。论证随后换了语域,从正确性转向性能契约——索引必须是 O(1),而 Rust 不会发布一个其代价与它的语法不符的操作。注意这要付出什么:范围仍被允许,仍会在字符中间 panic,所以具体性买到的是许可,而不是安全;而字形簇,人类实际感知的那一层,恰是标准库拒绝提供的那一层。
默认的正确性,在用户的眼前戛然而止。编译器的错误信息,点出冒犯的字符及其字节跨度,最终做了文字做不到的教学。HashMap 收尾本章,并表明人体工学是如何被配给的:它在 prelude 之外,因为它用得最少,所以那一行 use 是对某人旧判断的一种税。读取一个值需要 get().copied().unwrap_or(0)——一个数字三个环节,Option 税被看得见——而 entry/or_insert 之所以赢得位置,主要因为朴素的 contains_key 再 insert 会借用两次;API 是由借用检查器塑造的,而不仅仅是由品味。单词计数例子里的 * 是整个教训的压缩:一个指向映射内部的活指针,编译器证明没有别的东西能够到它。就连 SipHash 也符合这个模式——一个站得住脚的默认值,被陈述为定论,而退出的办法被埋着,书又一次替你选择。我最尖锐的判断是关于痛苦去了哪里。本章自己的辩护是,现在的摩擦防止以后的非 ASCII bug,这是真的,但挪动的痛苦不等于消除的痛苦,而一个在第8章遇到这个的初学者,可能体验不到预防——他们可能体验到惩罚。Pig Latin 练习是本章最狡猾的地方,一个玩具,悄悄要求你回答第一个字符究竟是什么。所以对读者说:别把这些读成三个要背的 API。把它们读成三个熟悉的便利被撤回、而一个理由被提供来代替的地方,并且每次都逼自己去检查那个理由——因为这些理由是好的,它们不可互换,而第一页拒绝教你的品味,恰恰就是靠留意哪个理由适用于哪里而建立起来的。
在 FeedLitra 里读第 8 章,每段旁边都有这些卡片。
开始读书第 9 章:错误处理
? 运算符如何缩短清单 9-6 中嵌套的 match?
第 9 章是怎样展开的AI 辅助
开篇:一个划分,而不是一个机制。 本章一开始,是画一条线,而不是教一个 API。失败有两种,Rust 给每种配了自己的工具:不可恢复的用 panic!,可恢复的用 Result<T, E>。框架性的主张来得早——一个迫使你承认可能的错误的编译器,造就更坚固的程序,因为麻烦在你还在写代码的时候就浮现了,而不是发布之后。作者随后靠展示你几乎不花什么就能得到多少,来让这种纪律的代价显得很小:即使是光秃秃的 panic!(“crash and burn”),也会把信息连同程序放弃之处的确切文件、行和列一起打印出来,而从上往下读到你自己写的第一个文件的回溯,对埋得更深的失败做的是同一份工作。越界的向量读取为这个划分的另一半定了锚:索要三个元素的向量的第 99 个元素没有合理的答案,所以 Rust 拒绝并停下,而 C 会交还恰好位于末尾之后的任何内存——未定义行为和一个经典的安全漏洞。标准库恰恰为了防止这一点,而接受运行时的中止,作者小心地指出,这个例子是构造出来的演示,而不是野外发现的 bug。
展开:从处理到传播。 有了这个划分,本章按复杂程度递增的顺序,走过可恢复的一侧。先是对 Result 的 match,然后是这样的观察:并非每个失败都值得同样的回应——打开文件的例子先匹配外层的 Result,再对 error.kind() 做一次匹配,当场创建一个缺失的文件,同时仍对其他任何错误 panic。作者随即承认,叠起来的 match 读起来很差,而基于闭包的方法,比如 unwrap_or_else,把同样的意思说得更干净。unwrap 和 expect 在这里作为捷径登场,值得精确地说清它们是什么:它们产出 Ok 的值,并在 Err 时 panic,这意味着使用它们,是把一个可恢复的失败转化成程序终止。它们与其说是一种错误处理,不如说是停止处理的一个决定;expect 在生产代码里赢得偏好,只因为它让你记录哪个假设失败了。
支点:让调用者来决定。 本章真正的动作,是从在本地处理错误,转向把错误交给上层。read_username_from_file 被声明为返回 Result<String, io::Error>,并有意对接下来该发生什么不持意见——File::open 失败时它直接把 Err 交回,read_to_string 的错误也原样传递。理由是认知上的,而不是风格上的:调用者常常知道函数所不知道的事。所有这些仪式于是压缩进 ?,而本章对 ? 为什么有返回类型要求的解释,是本章最清楚的一段机制。? 是提前返回的简写;失败时,错误从函数的返回槽离开,所以外围的签名必须能够承载它。这就是 ? 在返回 Result 或 Option 的函数里管用、却不能用在普通的 fn main() -> () 里的全部原因。在存在转换实现的地方,From 悄悄地把逃出的错误,在途中重新定型为函数声明的错误类型。链式调用、允许的上下文,以及 main 返回 Result,都出自这一个想法。
转折:何时 panic 是正确的。 用了大部分篇幅论证 Result 之后,本章转过身来,为它的反面辩护。默认值依然成立:返回 Result 给调用者选择,而对每个失败都 panic,则是代表调用者裁定无可奈何——一个函数很少有资格作出的裁决。但作者在坏状态——也就是被打破的假设、保证、契约或不变量——处划了一条线。在那里,错误在于调用代码,没有合理的恢复可以提供,而 panic 是正确的回答;标准库对越界的行为,恰恰就是这一策略的实践。应当放进 Result 的,是你应该预期到的失败——格式错误的解析器输入,速率限制。所以认为好代码基本上永远不该 panic 的读法,错过了本章的实际立场,那是一种分工,而不是一种偏好。
收尾:把检查推进类型。 最后一节把这一切转化为一种设计技巧。Guess::new 对 1 到 100 之外的任何值 panic,而因为 value 字段是私有的,外部代码无法往里塞一个数字——构造必须经过 new。因此每个存在的 Guess 都已经被验证过,于是类型本身就意味着“有效的值”,而接收一个的函数继承这个事实,不必重新检查。一个公共的 getter 授予读取,而不重新打开写入。作者提炼出的回报是关于签名的:接受或返回 Guess 的函数,在它的类型中陈述了 1 到 100 的约束,把检查从函数体搬进接口,这样本会在每个调用处重复的验证,由类型系统一次性承载。本章以重述 panic!/Result 的对比,并指向泛型结束——这是自然的下一步,因为让保证活在类型里,恰恰就是泛型所推广的东西。
关键论点,以及支撑它们的原文
让编译器迫使你承认可能的错误,造就更坚固的程序,因为问题在你还在写代码时就浮现了,而不是发布之后。
作者展示了被指向一个失败是多么不费力:即使是光秃秃的 panic!(“crash and burn”),也会把信息连同程序放弃之处的确切文件、行和列一起打印出来,所以麻烦的来源立刻就能定位。从上往下读到你自己写的第一个文件的回溯,服务于同样的目的。
“这个要求使你的程序更健壮,因为它确保你在把代码部署到生产环境之前,就会发现错误并适当地处理它们!”
“This requirement makes your program more robust by ensuring that you’ll discover errors and handle them appropriately before deploying your code to production!”
Chapter 9. Error Handling查看原文 →
无法恢复的错误应当当场使程序停止,因为它们是 bug 的症状,而不是值得重试的状况。
对一个三元素的向量在位置 99 处索引,是作者的例证:Rust 停下,而 C 会悄悄读到数据的末尾之外,并为一个安全问题打开大门。作者指出,这恰恰就是标准库对越界访问 panic 的原因。他指出这是一个有意构造的演示,而不是野外发现的 bug。
“不可恢复的错误总是 bug 的症状,比如试图访问数组末尾之外的位置,所以我们想要立即停止程序。”
“Unrecoverable errors are always symptoms of bugs, such as trying to access a location beyond the end of an array, and so we want to immediately stop the program.”
Chapter 9. Error Handling查看原文 →
并非每个失败都值得同样的回应:通过检查返回的是哪种错误,单个调用处可以对每一种作出不同的反应。
作者的打开文件的例子先匹配外层的 Result,再对 error.kind() 匹配:缺失的文件当场被创建,而任何其他种类的错误仍会 panic。他承认这样叠起来的 match 读起来很差,而基于闭包的方法,比如 unwrap_or_else,把同样的意思表达得更干净。
“所以,我们对 greeting_file_result 做 match,但我们在内部还对 error.kind() 做了一个 match。”
“So, we match on greeting_file_result, but we also have an inner match on error.kind”
§9.2 Recoverable Errors with Result查看原文 →
另外 3 个论点
函数可以不在本地做决定,而是提前返回错误,让它的调用者来选择,因为调用者常常知道函数所不知道的事。
read_username_from_file 被声明为返回 Result<String, io::Error>:当 File::open 失败时,它用 return 直接把 Err 交回,read_to_string 的错误也原样传递。这个函数有意对接下来该发生什么不持意见——那是调用代码的事。
“对这些值该怎么处理,由调用代码来决定。”
“It’s up to the calling code to decide what to do with those values.”
§9.2 Recoverable Errors with Result查看原文 →
返回 Result 应当是默认,而 panic 是留给被打破的契约的例外。
交回一个 Result 给调用者选择;而对每个失败都求助于 panic!,则是代表调用者裁定无可奈何——一个函数很少有资格作出的裁决。作者随后把这一点反过来:当调用者违反了函数所记录的契约时,panic 是对的,因为错误在于调用代码,而且不存在合理的恢复。你应当预期到的失败,比如格式错误的解析器输入或碰到速率限制,仍属于 Result。
“当你选择返回一个 Result 值时,你给了调用代码选择。”
“When you choose to return a Result value, you give the calling code options.”
§9.3 To panic! or Not to panic!查看原文 →
在检查本会处处重复的地方,类型系统可以改而承载保证,办法是使无效的值无法被构造。
Guess::new 对 1 到 100 之外的任何值 panic,所以一个存在的 Guess 本身就已在范围之内;接收一个的任何函数继承这个事实。作者的推理直接随之而来:一个接受或返回 Guess 的签名,在类型本身中陈述了 1 到 100 的约束,所以函数体不再需要重新检查它。
“幸运的是,你可以利用 Rust 的类型系统(以及编译器所做的类型检查)来替你做许多检查。”
“Fortunately, you can use Rust’s type system (and thus the type checking done by the compiler) to do many of the checks for you.”
§9.3 To panic! or Not to panic!查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 ? 需要匹配的返回类型
? 运算符是提前返回的简写:失败时,它把错误直接交回当前函数之外。因为错误通过返回槽离开,外围函数的返回类型必须能够承载它,这就是 ? 在返回 Result 或 Option 的函数里管用、却不能用在普通的 fn main() -> () 里的原因。当存在转换实现时,逃出的错误在途中也被转换成函数声明的错误类型。
机制 2
私有性把一个类型变成保证
字段除非标记为公有,否则是私有的,所以外部代码无法把一个数字直接塞进 Guess,必须经过 Guess::new,1 到 100 的检查就住在那里。既然每个现存的实例都通过了那个检查,类型本身现在就意味着“有效的值”,而接收 Guess 的函数可以跳过重新验证。读回数字的唯一办法是一个公共 getter,它授予读取,而不重新打开写入。
机制 3
边界检查拿崩溃换安全
索要三个元素的向量的第 99 个元素没有合理的答案,所以 Rust 拒绝读取并 panic。C 则会交还位于数据结构末尾之后的任何内存,这是未定义行为,也是一个经典的安全漏洞。标准库恰恰为了避免那种缓冲区过读的发生,而接受运行时的中止。
常见误读
既然 Result 把决定权交给调用者,而 panic 把它拿走,写得好的代码就应该总是返回 Result,基本上永远不 panic。
Result 是默认,但作者在坏状态——被打破的假设、保证、契约或不变量——处划了一条线。在那里,错误在于调用代码,没有合理的恢复可以提供,所以 panic 是正确的回应;标准库对越界访问恰恰就是这样做的。应当放进 Result 的,是你应该预期到的失败,比如格式不佳的输入或速率限制。
“违反契约时 panic 是合理的,因为违反契约总是表明调用方一侧的 bug,而且这不是你想让调用代码必须明确处理的那种错误。”
“Panicking when the contract is violated makes sense because a contract violation always indicates a caller-side bug, and it’s not a kind of error you want the calling code to have to explicitly handle.”
§9.3 To panic! or Not to panic!查看原文 →
把 unwrap 和 expect 读成处理 Result 的便捷办法,仿佛它们是一种错误处理。
它们是产出 Ok 的值、并在 Err 时 panic 的捷径,所以使用它们,是把一个可恢复的失败转化成程序终止。expect 至少让你记录哪个假设失败了,这就是为什么生产代码倾向于偏好它而不是 unwrap。
“在生产质量的代码中,大多数 Rustacean 选择 expect 而不是 unwrap,并提供更多的上下文,说明为什么预期这个操作总会成功。”
“In production-quality code, most Rustaceans choose expect rather than unwrap and give more context about why the operation is expected to always succeed.”
§9.2 Recoverable Errors with Result查看原文 →
第 9 章的核心概念
- 可恢复的与不可恢复的错误
- 你可以报告并重试的失败,对比应当立即使程序停止的 bug 症状。
- 适用处: 不可恢复指的是 bug 症状,而不仅仅是严重的失败。
- panic! 宏
- 打印一条信息,展开并清理栈,然后退出;可以由错误的动作触发,也可以直接调用。
- 适用处: 展开可以通过 [profile] 一节里的 panic = 'abort' 换成中止。
- Result 枚举
- 一个带 Ok(T) 和 Err(E) 的枚举,携带成功值或错误信息。 一种返回类型,表明一个操作可能以可恢复的方式失败,而调用者必须处理。
- 适用处: T 和 E 是泛型参数,所以同一个类型可以服务于许多成功/错误的配对。 对于不可恢复的状态,panic! 才是正确的工具。
- 回溯
- 为到达 panic 而被调用的函数的列表;从顶部读起,直到你看到一个你写的文件。
- 适用处: 需要调试符号,不带 --release 时默认启用。
- 带边界检查的索引
- Rust 拒绝越界的索引,而不是像 C 那样读取外来的内存。
- 适用处: 防止缓冲区过读,代价是运行时 panic。
- 传播错误
- 把错误返回给调用者,而不是在函数内部处理它。
- 适用处: 当调用者有更多上下文来决定回应时合适。
另外 7 个概念
- unwrap 与 expect
- 返回 Ok 的值或 panic 的捷径;expect 让你提供信息。
- 适用处: 生产代码通常偏好 expect,这样失败的假设就被记录下来了。
- ? 运算符
- 一种速记:成功时返回内部的值,失败时带着错误提前返回。
- 适用处: 只能用在返回类型实现了 FromResidual 的函数中,比如 Result 或 Option;不能混用 Result 和 Option。
- From trait 转换
- 通过 ? 的错误,被自动转换成函数声明的错误类型。
- 适用处: 需要存在一个 impl From<SourceError> for TargetError。
- 坏状态
- 一个假设、保证、契约或不变量被打破的情形。
- 适用处: panic 适合意料之外的坏状态;像格式错误的输入这样预期中的失败,应当返回 Result。
- 自定义验证类型
- 一种类型,其构造函数强制执行有效性,所以后面的代码可以信任这个值。
- 适用处: 验证住在构造函数里;内部字段保持私有,配一个 getter。
- getter
- 一个公共方法,唯一的工作是返回一个私有字段的值。
- 适用处: 只因为字段是私有的才需要;它授予读取访问,却不允许直接写入。
- 默认私有
- 字段保持私有,除非标记为公有,所以外部代码不能直接设置它们。
- 适用处: 这里是私有性迫使构造必须经过 Guess::new。
第 9 章划线与批注AI 陪读
“Rust 把错误分成两大类:可恢复的错误和不可恢复的错误”
“Rust groups errors into two major categories: recoverable and unrecoverable errors”
查看原文 →整章都挂在这个划分上,但注意它是个打扮成自然事实的设计决定。越界索引之所以“不可恢复”,只是因为 Rust 决定它是。
“不可恢复的错误总是 bug 的症状”
“Unrecoverable errors are always symptoms of bugs”
查看原文 →“总是”在这里做了很重的活。许多 panic 来自跨越信任边界的坏输入,而不是程序员的错误。
“Rust 没有异常”
“Rust doesn’t have exceptions”
查看原文 →被陈述为纯粹的缺席,其实是一笔交易:错误成了类型签名的一部分,而不是一条看不见的第二控制流通道。代价是啰嗦,而本章其余的精力,都花在为此道歉。
另外 15 条批注
“程序使用的内存,随后需要由操作系统清理。”
“Memory that the program was using will then need to be cleaned up by the operating system.”
查看原文 →一次安静的坦白:操作系统才是真正的最后手段的垃圾回收器。Rust 的安全叙事倚靠一个它从不宣传的后盾。
“在 C 里,试图读取超出数据结构末尾的内容,是未定义行为。”
“In C, attempting to read beyond the end of a data structure is undefined behavior.”
查看原文 →这是本章修辞的铰链:panic 看起来强硬,直到你看到替代方案是无声的安全漏洞。崩溃被重新框定为一个特性。
“从顶部开始读,直到你看到你写的文件”
“start from the top and read until you see files you wrote”
查看原文 →到目前为止最实用的一句话,却被埋在段落中间。调试建议是社会性的:找到责任变成你的那一行。
“好多 match!”
“That’s a lot of match!”
查看原文 →作者们打破口吻,承认他们自己的例子令人不快——然后把解药推迟到第13章。在人体工学的捷径之前先教原语,是有意的,但这确实意味着读者对 Rust 错误处理的第一印象是“乏味”。
“大多数 Rustacean 选择 expect 而不是 unwrap”
“most Rustaceans choose expect rather than unwrap”
查看原文 →是文化说明,而不是语言规则。真正的内容是:expect 的信息,是写下你所押注的假设的地方,这样崩溃就把你的推理解释回给你。
“我们没有足够的信息,知道调用代码实际上想做什么”
“We don’t have enough information on what the calling code is actually trying to do”
查看原文 →传播的诚实理由:错误处理是关于谁持有上下文,而不是谁持有失败。把错误往上推,是对认知局限的承认。
“通过 from 函数,它定义在 From trait 里”
“go through the from function, defined in the From trait”
查看原文 →很容易略过,但这就是 ? 能扩展的原因。没有自动转换,这个运算符就只会在类型完全一致时才管用,整个人体工学上的胜利就会蒸发。
“fn read_username_from_file() -> Result<String, io::Error> { fs::read_to_string("hello.txt”
查看原文 →四份细心的脚手架清单塌缩成一行——而笑点是,标准库已经做了。一个狡猾的教训:最好的错误处理,往往是你没有写的代码。
“当然,使用 fs::read_to_string 并没有给我们机会解释所有的错误处理,所以我们先用了更长的办法。”
“Of course, using fs::read_to_string doesn’t give us the opportunity to explain all the error handling, so we did it the longer way first.”
查看原文 →难得一见,书承认了它自己教学上的编排。诚实,但也悄悄地承认,前面几页是演戏。
“你是在代表调用代码,做出一个情形不可恢复的决定”
“you’re making the decision that a situation is unrecoverable on behalf of the calling code”
查看原文 →这是整章的伦理核心:panic 是对别人的程序宣称权威。错误设计,作为一个谁来决定的问题。
“编译器还没有聪明到能看出这个字符串总是一个有效的 IP 地址”
“the compiler isn’t smart enough to see that this string is always a valid IP address”
查看原文 →注意负担落在谁身上——不是编译器的错,而是你要去写成文档。expect 的信息成了写给未来自己的便条,而不是运行时特性。
“违反契约总是表明调用方一侧的 bug,而且这不是你想让调用代码必须明确处理的那种错误”
“a contract violation always indicates a caller-side bug, and it’s not a kind of error you want the calling code to have to explicitly handle”
查看原文 →尖锐的区分终于被说清了:Result 是为世界出错,panic 是为程序员出错。本章前面的一切,都是在绕着这条线打转。
“试图向你的函数传“什么也没有”的代码甚至无法编译,所以你的函数不必在运行时检查那种情形。”
“Code trying to pass nothing to your function won’t even compile, so your function doesn’t have to check for that case at runtime.”
查看原文 →真正的笑点——最好的错误处理,是你使之无法表示的错误。类型作为只需付一次费的检查。
“重要的是,value 字段是私有的,这样使用 Guess 结构体的代码就不被允许直接设置 value”
“It’s important that the value field be private so that code using the Guess struct is not allowed to set value directly”
查看原文 →这里的私有性不是为了隐藏,而是承重的:没有它,不变量就蒸发了。new 这个单一的咽喉点,是使这个类型有意义的东西。
“Rust 也从可执行文件返回整数,以与这个约定兼容。”
“Rust also returns integers from executables to be compatible with this convention.”
查看原文 →一个小小的提醒:即使是一门对类型如此一丝不苟的语言,在边界上也要向一个几十年前的 C 习惯鞠躬。
读后感AI 陪读
标准库已经写好了,却还从头教错误处理
这一章一开始就插下一面旗帜——错误要么是可恢复的,要么是不可恢复的——此后的一切都建立在这一刀之上。值得看穿的是,这一刀是个决定,而不是个发现:越界索引之所以不可恢复,是因为 Rust 这么说,而不可恢复的错误“总是” bug 这一主张,悄悄地忽略了每一个作为来自你代码之外的敌意输入而到来的 panic。尽管如此,本章靠展示替代方案来赢得这个选择:C 的未定义行为把同样的错误变成了无声的安全漏洞,所以崩溃从笨拙的强硬被重新框定为一个特性。还要注意 panic 路径所倚靠的东西——操作系统清理被抛弃的内存——这是一个 Rust 的安全叙事从不在宣传册上提的、最后手段的回收器。从那里起,本章用笨办法走过 Result 路径:match,然后 unwrap 和 expect,然后传播,然后是四份细心的、嵌套匹配的清单,它们塌缩成对 fs::read_to_string 的一次调用,作者们坦率承认,他们先用长办法做,是为了教学。诚实,也有点自我归罪——这里最好的错误处理,原来是标准库已经写好的代码。? 运算符是人体工学上的回报,而最容易被略过的细节,正是让它得以扩展的那个:自动的 From 转换,没有它,? 就只会在错误类型完全一致时才管用。真正的论证出现得晚。传播不是懒惰,而是认知上的诚实——被调用者不知道调用者想做什么,所以它把失败交给持有上下文的人。对称地,panic 是对别人的程序宣称权威,代表他们宣布一个情形无望。这是本章的伦理中心,而违反契约的规则终于干净地陈述了它:Result 是为世界出错,panic! 是为程序员出错。Guess 类型通过完全改变问题来闭合这个圈——让坏状态无法表示,检查就在签名里一次付清,而不是在运行时永远付下去。私有字段不是整洁;它是承重的,因为 new 这个单一的咽喉点,是唯一使该类型有意义的东西。对读者要说两件事。第一,你的 expect 信息是你所押注的假设的文档;把它们写成这样:最终的崩溃把你的推理解释回给你——编译器不会知道你硬编码的地址是有效的,而那个负担是你的。第二,当回溯滚过时,从顶部读起,直到你碰到一个你写的文件:调试部分是社会性的,是找到责任变成你的那一行的艺术。并且保持框架宽松——Rust“没有异常”被兜售为缺席,实际上是一笔交易,用啰嗦换来错误活在类型签名里,而不是一个看不见的第二条控制流。本章把大部分精力花在为那种啰嗦道歉,其余的则展示如何缩减它;最后一条小小的说明,说可执行文件仍返回 C 风格的整数码,是个恰当的尾声,一门一丝不苟的语言,在一接触外部世界的那一刻,就向一个几十年前的习惯鞠躬。
在 FeedLitra 里读第 9 章,每段旁边都有这些卡片。
开始读书第 10 章:泛型、trait 与生命周期
你究竟如何为 NewsArticle 和 SocialPost 编写并实现一个 trait?
第 10 章是怎样展开的AI 辅助
开篇:作为问题的重复,作为答案的泛型。 本章不是从语法开始的,而是从一种烦恼开始——同一个循环的两份拷贝,每份都在一个列表里找最大的数字。作者的第一个动作,是任何读者已经知道的:提取一个函数,这样重复的主体就住在一处,并对 &[i32] 进行操作。从那里开始的意义,在于把此后的一切框定为同一个动作,在更高一层上做。提取函数是对值参数化,而泛型是对类型参数化。从那单一的想法出发,开篇的一节以可预测的扫描向外展开:泛型函数,然后是泛型结构体、枚举和方法。一个拒绝 Point { x: 5, y: 4.0 } 的 Point<T>,诚实地标出了这个工具的边界——一个参数迫使它占据的每个位置都一致,所以混合的点需要两个。而因为关于抽象的自然担忧是成本,开篇立刻用单态化把它堵上:在程序运行之前,编译器为实际用到的每个具体类型写出一份特化的副本,所以一个用 5 和 5.0 触碰过的 Option<T>,成了两个普通的定义。没有任何泛型留到运行时去解析,抽象在速度上与手写的重复相当。
展开:trait 命名行为,约束使之可检查。 泛型一节以一个未解决的音符收尾——如果编译器对 T 一无所知,泛型函数体能做的事很有限——而这个缺口,就是 trait 一节所填补的。trait 被引入为一组没有主体的必需签名:Summary 声明 summarize(&self) -> String 并止于分号,留给 NewsArticle 和 SocialPost 去提供它们自己的内容。共享的名字由 trait 固定,行为由实现者固定。从这个基础出发,本章是稳步向外建造的,而不是跳跃。默认实现颠倒了工作的平衡,让一个 trait 附带它大部分的功能,只向实现者索要一个片段——在 SocialPost 上只实现 summarize_author,就足以产生 “1 new post: (Read more from @horse_ebooks...)”——附带一条警告:覆盖的实现无法回调它所取代的默认实现。然后是作为参数的 trait:简单情形用 impl Trait,精确情形用显式约束,累积用 + 和 where 子句,返回位置的 impl Trait,最后是条件实现和通用实现,行为被授予每个满足某个条件的类型。贯穿其中的是孤儿规则:trait 或类型至少有一个必须是本地的,这就是为什么 Display 不能在聚合器 crate 内部实现在 Vec<T> 上。辩护是务实的,而不是教条的——没有这个限制,两个独立的 crate 可以定义同样的实现,而编译器没有原则性的办法去选择。使这一节成为本章真正引擎的是,约束把圈子闭回泛型的方式:比较一个不受约束的 T 的两个值,直接被拒绝,但 T: PartialOrd 既解锁了函数体内的比较,又拒绝了类型不支持它的调用者。承诺是在编译时被检查的,而不是在运行中途发现的。
转折:从类型到引用。 转入生命周期,是作为延续来处理的,而不是新的主题——同样的泛型机制,现在应用于引用保持有效多久。铰链是一次具体的失败。一个返回两个字符串参数之一的 longest 函数,招致 error[E0106]: missing lifetime specifier,因为编译器看不出返回的引用借用自哪个参数。用 'a 标注全部三个位置,靠声明一个契约来解决它:结果只在两个输入生命周期中较短的那个之内有效。作者选择让它被拒绝的那个例子是有启发的——string1 明显是较长的字符串,然而代码失败了,因为 string2 被提前丢弃。检查器推理的是作用域,而不是值,这就是整个教训。作者还为把标注留在签名里、而不是推断它辩护:一个猜测意图的编译器,会在远离其实际原因的地方报告错误。三条省略规则随后作为务实的制衡到来,吸收寻常的情形——first_word 免费得到一个完整的签名,因为它单一的输入生命周期只有一处可流。规则在 longest 处恰恰卡住,因为两个输入生命周期没有提供选择输出的依据,而剩下的含糊以错误而不是猜测的形式浮现。持有引用的结构体出于同样的原因需要它们自己的标注,'static 结束了这一节。
两个值得点名的错误。 本章小心地说明了标注不是什么。标注描述的是已经存在的生命周期之间的关系;它无法延长任何值的实际生命。从函数内部构建的 String 返回 result.as_str(),仍会以 error[E0515] 失败,再多的进一步标注也无济于事——修复办法是返回拥有所有权的数据。与此相关,当编译器建议 'static 时,那是一份症状报告,而不是一个建议。'static 断言一个引用可以活过整个运行,对字符串字面量是真的,对你自己的数据则很少是;这个建议通常意味着有悬垂引用或生命周期不匹配潜伏着,正确的回应是修复结构,而不是放宽主张。
收尾:三者合于一个签名。 分别引入了这些工具之后,本章最后表明,它们一直就是打算组合起来的。longest_with_an_announcement 声明 <'a, T>,在 where 子句中约束 T: Display,并返回 &'a str。每一部分恰恰做了本章分派给它的工作:类型参数提供灵活性,约束保证函数体实际执行的打印,而生命周期排除了悬垂的结果。收尾的小结得出了结构一直在建立的寓意——这三者合起来,使代码可复用而不使之不安全,而整个保证是在编译时定下的,所以程序运行时什么也不用付。
关键论点,以及支撑它们的原文
泛型消除重复的方式,与提取函数相同,只是高了一层——对类型而不是对值参数化。
作者先把一个找最大数字的循环的两份拷贝,折叠成一个对 &[i32] 的函数,然后在类型层面,对结构体、枚举和方法重复同一个动作;带一个参数 T 的 Point 在 Point { x: 5, y: 4.0 } 上失败,表明了这个限度,因为单个参数迫使它占据的每个位置都是同一类型。
“就像函数体可以对一个抽象的列表而不是具体的值进行操作一样,泛型允许代码对抽象的类型进行操作。”
“In the same way that the function body can operate on an abstract list instead of specific values, generics allow code to operate on abstract types.”
Chapter 10. Generic Types, Traits, and Lifetimes查看原文 →
trait 命名一组必需的签名,同时让每个类型自由地表现得不同。
Summary trait 声明 summarize(&self) -> String 后面跟着分号,而不是主体,NewsArticle 和 SocialPost 各自提供它们自己的实现——共享的名字由 trait 固定,内容由实现者固定。
“实现这个 trait 的每个类型,都必须为方法的主体提供它自己的自定义行为。”
“Each type implementing this trait must provide its own custom behavior for the body of the method.”
§10.2 Defining Shared Behavior with Traits查看原文 →
在此基础上,trait 可以自己附带它大部分的功能,只向实现者索要一小块。
有了默认的主体,在 SocialPost 上只实现 summarize_author,就足以得到打印出的结果 “1 new post: (Read more from @horse_ebooks...)”;作者补充的警告是,覆盖的实现无法回调同一方法的默认版本。
“这样,一个 trait 可以提供许多有用的功能,而只要求实现者指定其中的一小部分。”
“In this way, a trait can provide a lot of useful functionality and only require implementors to specify a small part of it.”
§10.2 Defining Shared Behavior with Traits查看原文 →
另外 3 个论点
实现被有意限制,使独立的 crate 无法冲突。
trait 或类型至少有一个必须对你的 crate 是本地的——这就是为什么 Display 不能在聚合器 crate 内部实现在 Vec<T> 上——因为没有那条规则,两个 crate 可以定义同样的实现,而编译器就没有办法选择。
“没有这条规则,两个 crate 可以为同一个类型实现同一个 trait,而 Rust 就不知道该用哪个实现。”
“Without the rule, two crates could implement the same trait for the same type, and Rust wouldn’t know which implementation to use.”
§10.2 Defining Shared Behavior with Traits查看原文 →
从 trait 转向生命周期:当一个函数返回一个引用时,签名必须说明它借用自哪个参数。
longest 招致 error[E0106]: missing lifetime specifier,因为编译器无法判断返回的引用来自 x 还是 y。用 'a 标记这三个,声明了结果只在两个参数生命周期中较短的那个之内有效,这就是为什么作者那个 string2 被提前丢弃的例子被拒绝,尽管读者能看出 string1 是较长的字符串——检查器推理的是作用域,而不是值。把标注留在签名里是一份契约,而作者论证说,如果编译器改而推断意图,错误会指向远离其真正原因的地方。
“帮助文字揭示,返回类型需要一个泛型生命周期参数,因为 Rust 无法判断所返回的引用指向 x 还是 y。”
“The help text reveals that the return type needs a generic lifetime parameter on it because Rust can’t tell whether the reference being returned refers to x or y.”
§10.3 Validating References with Lifetimes查看原文 →
这三种工具是互补的,本章把它们放进一个签名里作结。
longest_with_an_announcement 声明 <'a, T>,在 where 子句中约束 T: Display,并返回 &'a str:类型参数提供灵活性,约束保证函数体实际执行的打印,而生命周期排除了悬垂的结果——这一切都在编译时被检查,所以运行时什么也不用付。
“因为生命周期是一种泛型,生命周期参数 'a 和泛型类型参数 T 的声明,放在函数名之后尖括号里的同一个列表中。”
“Because lifetimes are a type of generic, the declarations of the lifetime parameter 'a and the generic type parameter T go in the same list inside the angle brackets after the function name.”
§10.3 Validating References with Lifetimes查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
单态化使泛型免费
在程序运行之前,编译器填入实际用到的每个具体类型,并写出泛型定义的一份特化副本。一个用 5 和 5.0 用过的 Option<T>,变成两个独立的具体定义,所以在运行时没有任何泛型留待解析,代码在速度上与手写的重复相当。
机制 2
trait 约束把行为变成可检查的承诺
在泛型的函数体内,编译器只允许它能证明每个被允许的类型都支持的操作,所以比较一个不受约束的 T 的两个值被拒绝。写下 T: PartialOrd 添加了承诺,它既在函数体里解锁了操作,又拒绝了类型缺乏它的调用者——失败落在编译时,而不是运行期间。
机制 3
省略规则吸收了常见的情形
编译器走过三种固定的模式来自动分配生命周期;first_word 靠它们得到一个完整的签名,因为一个输入生命周期流向输出。对 longest,规则在两个输入生命周期、无法选择输出处卡住,而剩余的含糊以错误而不是猜测的形式浮现,这就是为什么那个签名需要显式的标注。
常见误读
加上生命周期标注会让数据活得更久,所以可以靠更用力地标注,来让借用检查器的抱怨安静下来。
标注只描述现有的生命周期彼此之间如何相关;它无法延长任何值的实际生命。从函数内部创建的 String 返回 result.as_str(),仍会以 error[E0515] 失败,而真正的修复是返回拥有所有权的数据,而不是引用。
“生命周期标注的语法有点不寻常:生命周期参数的名字必须以撇号(')开头,通常全是小写,而且很短,就像泛型类型一样。”
“Lifetime annotations have a slightly unusual syntax: The names of lifetime parameters must start with an apostrophe (') and are usually all lowercase and very short, like generic types.”
§10.3 Validating References with Lifetimes查看原文 →
当编译器建议 'static 时,那是应当采用的推荐修复。
'static 说一个引用可以活过整个运行,这对字符串字面量是真的,对你的数据则很少是;这样的建议通常表示一个试图悬垂的引用,或生命周期之间的不匹配,所以正确的做法是修复那个问题,而不是放宽标注。
“所有字符串字面量都有 'static 生命周期,我们可以这样标注:”
“All string literals have the 'static lifetime, which we can annotate as follows:”
§10.3 Validating References with Lifetimes查看原文 →
第 10 章的核心概念
- 泛型
- 代表具体类型的占位符,使一个定义适用于许多类型。
- 适用处: 单个参数 T 迫使它所有的位置都是同一类型;不同的类型需要单独的参数,如 T 和 U。
- 单态化
- 在编译时,编译器为所用的每个具体类型写出一份泛型代码的特化副本。
- 适用处: 去除了运行时代价,却在编译时复制了代码。
- trait 约束
- 一项要求,要求泛型类型实现某个 trait,在函数体里解锁该行为。
- 适用处: 没有像 PartialOrd 这样的约束,编译器会拒绝该类型可能不支持的操作。
- 零成本抽象
- 程序运行时你不必为之付费的抽象。 泛型和生命周期的检查发生在编译时,所以灵活的代码跑得和手写的具体代码一样快。
- 适用处: 通过编译期特化适用于泛型,不适用于运行时分发。 适用于这种静态分析;像 trait 对象这样的动态方法,推迟到第18章。
- trait
- 一组命名的方法签名,类型必须实现它们才能共享行为。
- 适用处: 除非给出默认的主体,否则只声明签名。
- 孤儿规则
- 只有当 trait 或类型对你的 crate 是本地的,你才可以为一个类型实现一个 trait。
- 适用处: 阻止外部 trait 用在外部类型上,例如 Display 用在 Vec<T> 上。
另外 7 个概念
- trait 约束
- 写在泛型参数之后的一项约束,要求它实现给定的 trait。 一条规则,说泛型类型必须实现一个 trait,这里是 Display,这样值才能被打印。
- 适用处: impl Trait 是语法糖;它不能强制两个参数共享一个类型。 只需要函数体实际用到的行为,比如用 {} 打印。
- 通用实现
- 为每个已经满足某个其他 trait 约束的类型实现一个 trait。
- 适用处: 在标准库中被广泛使用,例如 ToString 对所有 Display 类型。
- 悬垂引用
- 指向已经离开作用域的数据的引用。 指向已被清理的数据的引用;没有哪个标注能让返回局部变量的引用变得合法。
- 适用处: 在编译期而不是运行时被阻止。 补救是返回拥有所有权的数据,而不是添加生命周期语法。
- 生命周期
- 引用保持有效的作用域;泛型的第二种。 像 'a 这样的名字,写在尖括号里和引用的 & 之后,把一个签名中的几个引用联系在一起。
- 适用处: 多半是推断的;只有当关系含糊时才需要标注。 它只表达关系;无法延长任何值的实际生命周期。
- 借用检查器
- 比较作用域,来决定借用是否有效的编译器组件。
- 适用处: 拒绝被引用者的生命周期比引用的生命周期短的代码。
- 生命周期省略规则
- 编译器应用的三种确定性的模式,这样常见的签名就不需要显式的生命周期。
- 适用处: 它们并非完整的推断;剩余的含糊变成编译器错误,而不是猜测。
- 静态生命周期
- 'static 标记一个可以活过整个程序运行的引用,就像所有字符串字面量那样。
- 适用处: 通常是错误的修复:这样的错误建议常常藏着一个悬垂引用或生命周期不匹配。
第 10 章划线与批注AI 陪读
“生命周期让我们给编译器提供足够的关于借用值的信息,这样它就能确保引用在比没有我们帮助时更多的情形下是有效的”
“Lifetimes allow us to give the compiler enough information about borrowed values so that it can ensure that references will be valid in more situations than it could without our help”
查看原文 →注意这个框架:生命周期不限制你,它们扩展编译器接受的东西。大多数初学者体验到的恰恰相反。诚实的推销,却落地得远早于读者能感受到的时候。
“let mut largest = &number_list[0];”
查看原文 →一个引用,而不是拷贝——被选来使同样的代码在跳到泛型 T 时仍能存活,那里 T 可能不是 Copy。书在这里没有这么说;设计决定藏在一行里。
“尽管这段代码能工作,重复代码是乏味而且容易出错的。”
“Although this code works, duplicating code is tedious and error-prone.”
查看原文 →重复两次的代码几乎算不上罪行;书布了一个夸大的冒犯,好让抽象感觉是挣来的。教学上有用,思想上有点廉价。
另外 17 条批注
“T 是 type 的缩写,是大多数 Rust 程序员的默认选择。”
“Short for type, T is the default choice of most Rust programmers.”
查看原文 →半段话谈一个命名约定。值得——一旦你内化了 T 只是一个像 x 那样的参数名,泛型就不再像巫术。
“largest 的函数体并不适用于 T 可能是的所有可能类型”
“the body of largest won’t work for all possible types that T could be”
查看原文 →这是整个哲学,一行说完:一个泛型函数必须针对每一个可能的 T 来检查,而不只是你碰巧调用它时用的那些。这就是 trait 约束之所以存在的原因——C++ 模板则在实例化时失败。
“如果你发现你的代码里需要很多泛型类型,这可能表明你的代码需要重构成更小的部分。”
“If you’re finding you need lots of generic types in your code, it could indicate that your code needs restructuring into smaller pieces.”
查看原文 →难得一见的品味而不是机制的时刻。编译器不会在 Point<T,U,V,W> 处阻止你;只有判断力能。
“T 不是 f32 类型的 Point<T> 的其他实例,将没有定义这个方法”
“other instances of Point<T> where T is not of type f32 will not have this method defined”
查看原文 →方法有条件地存在于类型参数之上,是一个与类继承真正不同的心智模型——能力附着在一个形状上,而不是一个对象上。这一句话的分量,比它所处的位置所暗示的更重。
“我们使用泛型不付运行时代价”
“we pay no runtime cost for using generics”
查看原文 →真,却悄悄地不完整:代价转移到了编译时间和二进制体积上。单态化是个取舍,而不是免费的午餐——书只兜售了它的一面。
“用户必须把 trait 和类型一起引入作用域”
“the user must bring the trait into scope as well as the types”
查看原文 →日常的摩擦点。一个方法可以存在于你面前的值上,却仍然不可见——这也是使以后给别人的类型添加方法变得安全的原因。
“这条规则确保别人的代码不会破坏你的代码,反之亦然”
“This rule ensures that other people’s code can’t break your code and vice versa”
查看原文 →孤儿规则被框定为礼貌,但它其实是关于解析的全局唯一性。注意书跳过的代价:你不能把 Display 加到 Vec 上,所以你把它包进一个新类型——人人都碰到的摩擦。
“注意,不可能从一个覆盖的实现中调用默认实现”
“Note that it isn’t possible to call the default implementation from an overriding implementation”
查看原文 →被一句平淡的话丢了出来,但这是与面向对象继承的真正不对称——这里没有 super.summarize()。Trait 给你默认值,而不是一个父类。
“一个 trait 可以提供许多有用的功能,而只要求实现者指定其中的一小部分”
“a trait can provide a lot of useful functionality and only require implementors to specify a small part of it”
查看原文 →这是这一节实际的设计教训:挑选最小的必需核心,推导其余。summarize_author 就是那道接缝。
“如果我们想让这个函数允许 item1 和 item2 有不同的类型,使用 impl Trait 是合适的”
“Using impl Trait is appropriate if we want this function to allow item1 and item2 to have different types”
查看原文 →好时刻——书不再把 impl Trait 仅仅称为“语法糖”,而是表明这两种形式实际上意味着不同的东西。改变语义的糖,不是糖。
“由于 impl Trait 语法在编译器中的实现所受的限制,不被允许”
“isn’t allowed due to restrictions around how the impl Trait syntax is implemented in the compiler”
查看原文 →难得诚实的耸肩:这不是一条原则,而是一个单态化的事实。impl Trait 是在编译期选定的一个具体类型;分支则需要 vtable。
“但 Rust 把这些错误移到编译期,这样我们在代码甚至还能运行之前,就被迫修复问题”
“But Rust moves these errors to compile time so that we’re forced to fix the problems before our code is even able to run”
查看原文 →整章的论点,终于被陈述出来了。而点睛之笔是下一句:没有运行时检查,意味着安全是免费的。
“生命周期标注不改变任何引用能活多久。”
“Lifetime annotations don’t change how long any of the references live.”
查看原文 →解锁生命周期的那一句话。初学者把 'a 读作“让它活得更久”;它是一种描述,而不是命令——更接近类型,而不是指令。
“我们也不知道,因为这个函数体中的 if 块返回对 x 的引用,而 else 块返回对 y 的引用”
“we don’t know either, because the if block in the body of this function returns a reference to x and the else block returns a reference to y”
查看原文 →可爱的教学法——编译器的无知就是你的无知。这个错误不再感觉像官僚主义。
“泛型生命周期 'a 将得到具体的生命周期,等于 x 和 y 的生命周期中较小的那个”
“the generic lifetime 'a will get the concrete lifetime that is equal to the smaller of the lifetimes of x and y”
查看原文 →'a 不是 x 的生命,也不是 y 的——而是交集。一个标签,把两个不相等的作用域坍缩到它们的重叠部分,这就是为什么这个契约是健全的,却是保守的。
“作为人类,我们可以看着这段代码,看出 string1 比 string2 长”
“As humans, we can look at this code and see that string1 is longer than string2”
查看原文 →诚实的承认:编译器拒绝实际上安全的代码。生命周期是一种保守的近似,而不是真相——当你在和借用检查器搏斗时,值得记住这一点。
“最好的修复是返回一个拥有所有权的数据类型,而不是引用”
“the best fix would be to return an owned data type rather than a reference”
查看原文 →真正的教训藏在一句随口的话里:当生命周期变得不可能时,答案通常是所有权,而不是更聪明的标注。再多的 'a 也修不了一个设计问题。
读后感AI 陪读
Rust 在运行之前检查所有泛型类型,C++ 则在实例化时失败
本章分三拍行进,它们其实是一个论证:泛型消除重复,trait 告诉编译器泛型被允许做什么,而生命周期告诉它借来的数据保持有效多久——这一切都在程序运行之前就定下。开篇对它自己的方法是最诚实的:先提取函数,再把类型参数化。那个顺序才是教训,而不是泛型的语法。但这番布景有点戏剧化——一个循环的两份拷贝并不真的是罪行,而书夸大了这个冒犯,好让抽象感觉是挣来的。比整个一节都更值得的安静细节是 let mut largest = &number_list[0]:一个引用而不是拷贝,被选来使具体的代码在跳到一个可能不是 Copy 的 T 时仍能存活。这个设计决定在一行里做出,却从未被解释。最深的一句话是 largest 的函数体并不适用于所有可能的 T——一个泛型必须对它可能接收的每一个类型都通过类型检查,而不只是你碰巧调用它时用的那些。这单一的承诺,就是 trait 约束之所以存在的全部原因,也是 Rust 与 C++ 模板分道扬镳的岔路口,后者在稍晚的实例化时失败,伴着一堵噪音的墙。单态化被兜售为免费的,而在运行时它确实是;书只是从未提到账单转移到了编译时间和二进制体积上。这是个取舍,却被呈现为礼物。Trait 随后作为约束的词汇落地,而设计的教训比语法更尖锐:要求最小的核心——summarize_author——并把其他一切推导为默认值。那些不对称被平淡地丢出来,值得更多的分量。
没有 super.summarize();trait 给你默认值,而不是一个父类。能力可以有条件地附着在一个形状上,而不是永久地附着在一个对象上,所以一个方法可以存在于 Point<f32>,而别处都没有——这是一个与继承真正不同的心智模型。trait 必须在作用域内才可见,这是日常的摩擦,也恰恰是使扩展别人的类型变得安全的东西。孤儿规则被框定为 crate 之间的礼貌;它其实是关于解析的全局唯一性,而它的真实代价——你不能把 Display 放到 Vec 上,所以你求助于新类型——没有被提到。而 impl Trait 恰恰在正确的时刻不再被称为语法糖:这两种形式意味着不同的东西,而分支返回的限制,是对编译器实现的一个诚实的耸肩,而不是一条原则。生命周期是读起来充满敌意、其实并非如此的部分。本章自己的开篇主张——标注让编译器在更多的情形下接受引用——是真的,却落地得远早于任何读者能够感受到它的时候;感受到的体验,数周内都是相反的。两句话做了真正的解锁。第一:标注不改变任何东西活多久。'a 是一种描述,更接近类型而不是命令,而每个把它读作“让它活着”的初学者,都仍然困惑。第二:编译器对 longest 返回哪个分支的无知,就是你的无知——一旦错误被重新框定为一个你也回答不了的问题,它就不再是官僚主义。从那里起,机制随之而来:'a 成为输入生命周期的交集,两者中较小的那个,这就是为什么这个契约是健全的,也是为什么它是保守的。
继续阅读
书然后直接承认,借用检查器会拒绝一个人类看得出是安全的代码。那个承认是本章里最有用的一件事,值得在将来的搏斗中牢记:生命周期是安全的一种近似,而不是关于它的真相。一次性的修复才是真正的结论——当标注变得不可能时,就返回拥有所有权的数据。再多更聪明的 'a,也修复不了一个关于谁拥有什么的设计问题。省略规则和 'static 作为便利收尾,而最后那个组合的签名,与其说是顶点,不如说是证明这三个特性一直是同一个特性。我要对读者说的是:这一章是用三套词汇讲述的一个编译期故事,它只两次显示了它的品味——一次是警告说许多泛型参数意味着你的代码想要拆分,一次是暗示所有权胜过标注。比起演练的例子,更相信这两条旁白。当借用检查器下次拒绝你时,问问你是在描述代码实际保证的一种关系,还是在希望凭空造出一种。
在 FeedLitra 里读第 10 章,每段旁边都有这些卡片。
开始读书第 11 章:编写自动化测试
测试主体里的三个动作,如何对应到你会写的一个真实测试?
第 11 章是怎样展开的AI 辅助
如何开篇:编译器无法弥合的缺口。 本章不是从语法开始的,而是从一个关于 Rust 最强卖点的局限的承认开始。作者放下一个函数,其声明的任务是给一个数字加二,然后指出,不管函数体是加二、加十,还是减五十,编译器同样满意——每一种版本都通过类型检查,因为类型描述的是形状,而不是意图。那个例子完成了整个框定的工作:“做我本意要做的事”意义上的正确性,处于类型系统能证明的东西之外,所以必须有别的东西来承载它。从那个缺口出发,本章直接转入基本机制:用 #[test] 标注的函数,cargo test 打印的报告,和断言宏。这里的次序要紧——assert! 作为通用检查先来,然后 assert_eq! 和 assert_ne! 作为更适合相等的工具到来,其理由是它们所打印的内容。当 add_two 被故意弄坏成 add_three 时,输出点出了 left: 5 和 right: 4,差异就这样交到了你手里;而光秃秃的 assert! 只会说有什么东西是假的。can_hold 的例子从另一个方向重复了这个教训,抓住了一个翻转的 >,因为 8 不小于 5,而被比较的值必须实现 PartialEq 和 Debug 的要求,被引入作为那种可读性的代价。
如何展开:从打印的值,到有意的失败,再到运行器本身。 确立了看到值才使失败有用之后,作者推广了这个洞见,而不是丢掉它。自定义的失败信息被呈现为同一想法再推进一步:问候语测试起初只报告断言失败了,而一旦被重写为携带实际的值,它就打印出这个值是 Hello!——这告诉你名字被丢掉了,而不仅仅是检查没有成立。本章随后转向相反的情形,本应失败的代码。should_panic 被引入,并立刻受到批评:它单独存在时,对任何 panic 都通过,包括来自不相关 bug 的,而作者通过调换猜数值代码中的两个 panic 主体来证明这一点,在那里测试仍然 panic,却因为信息缺少 expected 片段而失败。一个子串就足够了,而要具体到什么程度,则被留作关于信息有多独特或多动态的判断。返回 Result 的测试补全了形式,然后注意力从编写测试转向运行它们:默认的并行执行,作者用两个测试覆盖同一个 test-output.txt、使输家因干扰而不是因代码坏了而失败来说明;通过的测试的输出被捕获,这解释了为什么一个打印“I got the value 4”的 println! 似乎消失了,直到套件用显示输出的标志重新运行;外加名字过滤和被忽略的测试。一个小而承重的细节随之而行:这样的标志放在双破折号之后,这样它们到达测试二进制文件,而不是 Cargo。
如何转折、如何收尾:同一个触及范围,从两面来看。 最后的乐章把测试重新框定为组织的问题,它系于关于 Rust 模块树的一个单一事实。因为私有性是以模块为作用域的,子 tests 模块可以用 use super::* 把父级的一切拉进来,并直接调用一个未导出的助手——清单 11-12 就用 internal_adder 这样做了。作者的结论是有意收缩的:你是否应当测试内部,是品味的问题,而不是语言的约束。然后同样的触及范围从外部来看,被发现止于 crate 的边界。tests/ 下的文件被编译为单独的 crate,所以每个都必须按名字导入库,不需要 cfg(test) 门,并且只能触及库所导出的东西。这不是一个要绕过的局限,而是编写它们的意义:它们像外部用户那样演练公共接口,这就是为什么认为集成测试也能戳内部的常见假设是错误的。本章以那个边界的实际后果结束——共享的助手属于 tests/common/mod.rs,这样它们不会被误认为测试文件,而一个只由 src/main.rs 组成的项目,没有任何东西可以让集成测试引入作用域,这才是保持 main.rs 很薄、把值得测试的逻辑移进 src/lib.rs 的真正论证,在那里,二进制文件和 tests 目录都能够到它。
关键论点,以及支撑它们的原文
强大的类型系统仍然给测试留有余地,因为它检查类型,却不检查你本意要代码做什么。
作者以一个本意是加二的函数开篇:编译器欣然接受它,不管函数体是加 2、加 10,还是减 50,因为所有这些都通过类型检查。“做了本意要做的”意义上的正确性,根本不在编译器能证明的范围之内,所以本章从这个缺口出发,进入编写测试的机制。
“但 Rust 无法检查这个函数是否会恰好做我们想要的事,即返回参数加 2,而不是,比如说,参数加 10 或参数减 50!”
“But Rust can’t check that this function will do precisely what we intend, which is return the parameter plus 2 rather than, say, the parameter plus 10 or the parameter minus 50!”
Chapter 11. Writing Automated Tests查看原文 →
对于相等检查,assert_eq! 和 assert_ne! 比在 assert! 里嵌一个比较是更好的工具,因为它们会报告两个值。
当作者有意把 add_two 弄坏成 add_three 时,失败的输出点出了 left: 5 和 right: 4,直指差异;光秃秃的 assert! 只会说断言是假的。同样的回报出现在 can_hold 的例子里,把 > 翻转成 < 被抓住,因为 8 不小于 5。被比较的值必须实现 PartialEq 和 Debug,这就是为什么自定义类型要派生 Debug。
“如果断言失败,它们还会打印那两个值,这使得更容易看出测试为什么失败;反过来,assert! 宏只表明它对 == 表达式得到了一个假值,而不打印导致假值的那些值。”
“They’ll also print the two values if the assertion fails, which makes it easier to see why the test failed; conversely, the assert! macro only indicates that it got a false value for the == expression, without printing the values that led to the false value.”
§11.1 How to Write Tests查看原文 →
在表明了打印值有助于调试之后,作者延伸这个想法:携带实际值的自定义失败信息,把一句无用的“assertion failed”变成了一份可用的报告。
问候语测试最初只报告断言失败了。用自定义信息重写之后,它打印出这个值是 Hello!,立刻揭示出名字被丢掉了,而不仅仅说检查没有成立。
“我们可以在测试输出中看到实际得到的值,这会帮助我们调试实际发生了什么,而不是我们预期会发生什么。”
“We can see the value we actually got in the test output, which would help us debug what happened instead of what we were expecting to happen.”
§11.1 How to Write Tests查看原文 →
另外 3 个论点
单独的 should_panic 作为测试太粗糙;加上 panic 信息的一个预期片段,就把测试与你本意要演练的失败路径联系了起来。
一个标记为 should_panic 的测试,对任何 panic 都通过,包括来自不相关 bug 的。作者通过调换猜数值代码中的两个 panic 主体来演示这一点:测试确实 panic 了,却因为 panic 信息缺少预期的字符串而失败。一个子串就够了,而要具体到什么程度,取决于信息有多独特或多动态。
“即使测试因与我们预期不同的原因而 panic,should_panic 测试也会通过。”
“A should_panic test would pass even if the test panics for a different reason from the one we were expecting.”
§11.1 How to Write Tests查看原文 →
Rust 让你直接测试私有函数,所以是否测试内部的争论,是品味的问题,而不是语言的限制。
因为私有性是以模块树为作用域的,子 tests 模块可以用 use super::* 把父级的一切拉进来,然后调用一个未导出的内部助手——作者的清单 11-12 就用 internal_adder 这样做了。这只在同一个 crate 之内成立。
“无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数。”
“Regardless of which testing ideology you adhere to, Rust’s privacy rules do allow you to test private functions.”
§11.3 Test Organization查看原文 →
从另一面来看,那种触及止于 crate 的边界,这就是为什么二进制项目应当保持 main.rs 很薄,并把它的逻辑移进库文件。
只有库 crate 才暴露其他 crate 可以导入的条目,而二进制 crate 是意在自己独立运行的。因此,只由 src/main.rs 组成的项目,没有任何东西可以让集成测试引入作用域,所以值得测试的逻辑必须住在 src/lib.rs 里,在那里,二进制文件和 tests 目录都能使用它。
“只有库 crate 才暴露其他 crate 可以使用的函数;二进制 crate 是意在自己独立运行的。”
“Only library crates expose functions that other crates can use; binary crates are meant to be run on their own.”
§11.3 Test Organization查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
测试线程中的 panic 把它标为失败
每个测试函数在它自己的线程上运行,而框架关注那个线程是否死掉。当一个断言或一次显式的 panic 展开了线程,框架就把测试记录为失败,并重新打印 panic 信息加上源码行,这就是为什么“失败”和“panic”在这里意思相同。
机制 2
并行运行在共享状态上冲突
默认情况下,框架同时启动测试,所以它们都碰的任何资源就成了竞争。作者的案例:两个测试写同一个 test-output.txt,彼此覆盖,输家报告的失败,是由干扰而不是由代码坏了造成的。给每个测试单独的资源,或强制单个测试线程,就能去掉这个冲突。
机制 3
每个 tests 文件是它自己的 crate
tests 目录下的文件被编译为单独的 crate,而不是作为库的一部分,所以每个都必须按名字导入库,才能看到任何东西。因为它们在库之外,不需要 cfg(test) 门,而且只有标记为公有的条目才能触及——这恰恰就是使它们表现得像外部用户的原因。
常见误读
我的测试用 println! 打印了东西,输出却从未出现,所以要么是打印,要么是测试运行器坏了。
通过的测试的输出,被有意捕获并隐藏,以保持报告可读;作者指出,像“I got the value 4”这样的打印行,在正常的输出中无处可寻,只有当套件用显示输出的标志重新运行时才会显示。像这样的标志放在双破折号之后,这样它们到达测试二进制文件,而不是 cargo 本身。
“注意,在这个输出里,我们哪里也看不到 I got the value 4,它是在通过的那个测试运行时打印的。”
“Note that nowhere in this output do we see I got the value 4, which is printed when the test that passes runs.”
§11.2 Controlling How Tests Are Run查看原文 →
既然 Rust 允许测试私有函数,放在 tests 目录里的测试也可以戳内部的助手。
这两种测试的触及范围不同。对私有条目的访问,来自测试模块与代码位于同一个 crate 之内;tests 目录里的文件编译为独立的 crate,只能调用库所导出的东西,这正是编写它们的意义——它们像外部用户那样演练公共接口。
“无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数。”
“Regardless of which testing ideology you adhere to, Rust’s privacy rules do allow you to test private functions.”
§11.3 Test Organization查看原文 →
第 11 章的核心概念
- 正确性
- 代码在多大程度上真正做到了你本意要它做的事。
- 适用处: 很难证明;编译器只保证其中一部分。
- test 属性
- 在函数上方写 #[test],告诉测试运行器把它作为测试来运行。
- 适用处: 非测试的助手函数也可能住在 tests 模块里,所以标记是必需的。
- assert_eq! 与 assert_ne!
- 比较两个值的宏,在检查失败时打印两边。
- 适用处: 被比较的值必须实现 PartialEq 和 Debug。
- 派生的 Debug trait
- 派生 Debug,让失败的断言能够打印你自己的结构体和枚举。
- 适用处: 自定义类型上的相等断言还需要 PartialEq。
- should_panic 属性
- 标记一个只有在其主体 panic 时才通过的测试,用来检查错误条件。
- 适用处: 除非给出一个预期的子串,否则不精确;不能与返回 Result 的测试结合。
- expected 参数
- 可选的文字,panic 信息必须包含它,should_panic 测试才通过。
- 适用处: 一个子串就够了;要指定多少,取决于 panic 信息有多独特或多动态。
另外 6 个概念
- Result 枚举
- 测试可以返回 Result<(), String>,成功时返回 Ok,失败时返回 Err,而不是 panic。
- 适用处: 使问号运算符可用,却禁止 #[should_panic];用 assert!(value.is_err()) 断言 Err。
- 测试框架的控制
- -- 之后的标志调节测试二进制文件:线程数、输出显示、名字过滤、被忽略的测试。
- 适用处: -- 之前的参数交给 cargo test,-- 之后的交给测试二进制文件。
- 单元测试对集成测试
- 单元测试与代码并排,可以触及私有条目;集成测试只从外部使用公共接口。
- 适用处: 集成测试不需要 #[cfg(test)],因为它们住在另一个目录里。
- 集成测试
- 放在使用它的库之外的测试,像任何外部用户那样使用它。
- 适用处: 只对库 crate 有效;每个文件是它自己的 crate,只限于公共 API。
- 默认私有
- 条目除非标记为 pub 否则是私有的,但子测试模块仍可以触及祖先的私有条目。
- 适用处: 这种触及只在同一个 crate 之内管用,从 tests 目录则不行。
- mod.rs 风格
- 把共享的助手文件命名为 tests/common/mod.rs,这样 Rust 把它当作模块,而不是测试 crate。
- 适用处: 适用于 tests 的子目录;这样的文件不会得到测试输出小节。
第 11 章划线与批注AI 陪读
“程序测试可以是显示 bug 存在的一种非常有效的办法,但对于显示它们的不存在,则是无望地不足”
“program testing can be a very effective way to show the presence of bugs, but it is hopelessly inadequate for showing their absence”
查看原文 →以对你自己这一章最强的论证开篇,然后一句话耸耸肩就把它打发了。诚实,但耸肩才做了真正的工作——测试靠实践辩护,而不是逻辑。
“Rust 的类型系统扛起了这个负担的很大一部分,但类型系统不能抓住一切。”
“Rust’s type system shoulders a huge part of this burden, but the type system cannot catch everything.”
查看原文 →这一章存在的全部理由。对一门兜售编译期保证的语言来说,承认编译器无法检查意图,是必要的让步。
“返回参数加 2,而不是,比如说,参数加 10 或参数减 50”
“return the parameter plus 2 rather than, say, the parameter plus 10 or the parameter minus 50”
查看原文 →漂亮的演示:类型约束形状,从不约束含义。u64 -> u64 被无限多个错误的函数所满足。
另外 17 条批注
“每个测试都在一个新线程里运行,当主线程看到一个测试线程已经死掉,这个测试就被标记为失败。”
“Each test is run in a new thread, and when the main thread sees that a test thread has died, the test is marked as failed.”
查看原文 →一句话解释了整个机制:“失败”不是框架定义的概念,只是一个死掉的线程。以 panic 为协议。
“在 Rust 里,它们叫做 left 和 right,而我们指定期望值和代码产生的值的顺序无关紧要”
“in Rust, they’re called left and right, and the order in which we specify the value we expect and the value the code produces doesn’t matter”
查看原文 →说得过去——但 expected/actual 不是吹毛求疵,而是文档。对称的命名意味着失败信息无法告诉你,哪个数字是愿望,哪个是事实。
“我们很确定问候语开头的 Hello 文字会改变”
“we’re pretty sure the Hello text at the beginning of the greeting will change”
查看原文 →藏在一个宏演示里的真正有价值的教训:在你所相信的需求层面写断言,而不是在你碰巧拥有的字符串层面。
“如果函数里的代码 panic,测试就通过;如果函数里的代码没有 panic,测试就失败。”
“The test passes if the code inside the function panics; the test fails if the code inside the function doesn’t panic.”
查看原文 →光秃秃的 should_panic 危险地粗糙——任何 panic,来自任何一行,出于任何理由,都算作成功。一个会 panic 的笔误也能通过这个测试。
“即使测试因与我们预期不同的原因而 panic,should_panic 测试也会通过。”
“A should_panic test would pass even if the test panics for a different reason from the one we were expecting.”
查看原文 →诚实的承认:一个通过的测试可能在对你撒谎。整个虚假信心的类型,一句话说完。
“你选择指定什么,取决于 panic 信息中有多少是独特的或动态的,以及你希望你的测试有多精确。”
“What you choose to specify depends on how much of the panic message is unique or dynamic and how precise you want your test to be.”
查看原文 →没有给出规则,只是把一个取舍交还给你。匹配过于精确,会使测试在措辞改变时就坏掉;过于宽松,会让 bug 漏过。
“要断言一个操作返回 Err 变体,不要对 Result<T, E> 的值使用问号运算符。相反,使用 assert!(value.is_err())。”
“To assert that an operation returns an Err variant, don’t use the question mark operator on the Result<T, E> value. Instead, use assert!(value.is_err”
查看原文 →容易绊倒:? 传播的是 Err,并且让恰恰想要它的那个测试失败。便利的运算符颠倒了你的意图。
“不是因为代码不正确,而是因为测试在并行运行时彼此干扰了”
“not because the code is incorrect but because the tests have interfered with each other while running in parallel”
查看原文 →并行是默认的,所以这种失败模式也是默认的。Rust 保护你程序里的共享状态,然后交给你一个完全没有保护的共享文件系统。
“默认情况下,如果一个测试通过,Rust 的测试库会捕获任何打印到标准输出的东西。”
“By default, if a test passes, Rust’s test library captures anything printed to standard output.”
查看原文 →悄悄解释了你的调试 println! 为什么消失了——也是为什么那么多人为了看到输出,而求助于一个故意失败的断言。
“我们不能用这种办法指定多个测试的名字;只有给 cargo test 的第一个值会被使用。”
“We can’t specify the names of multiple tests in this way; only the first value given to cargo test will be used.”
查看原文 →悄悄忽略多余的参数,而不是报错,是你在纳闷为什么一半的测试没有运行之后才会发现的那类事。
“无论你坚持哪种测试意识形态,Rust 的私有性规则确实允许你测试私有函数。”
“Regardless of which testing ideology you adhere to, Rust’s privacy rules do allow you to test private functions.”
查看原文 →一门语言做出了立场中立的选择——模块的子级能看到祖先,所以这是从模块系统中掉出来的,而根本不是一个测试方面的决定。
“注意,如果某一节中有任何测试失败,后面的几节将不会运行。”
“Note that if any test in a section fails, the following sections will not be run.”
查看原文 →在你断定集成测试没问题之前,值得记住这一点——它们可能根本没有被执行过。
“我们会在测试输出里看到 common.rs 文件的一个新小节,即使这个文件不包含任何测试函数”
“we’ll see a new section in the test output for the common.rs file, even though this file doesn’t contain any test functions”
查看原文 →一文件一 crate 的规则在这里泄漏了:一个助手模块被当作测试目标。第7章的直觉在积极地误导你。
“我们不创建 tests/common.rs,而是创建 tests/common/mod.rs”
“instead of creating tests/common.rs, we’ll create tests/common/mod.rs”
查看原文 →修复是一个文件名,而不是代码。Rust 在这里的约定悄悄地是承重的——目录形式是说“这不是测试”的唯一办法。
“这是 Rust 也理解的较旧的命名约定”
“This is the older naming convention that Rust also understands”
查看原文 →“较旧的”在做道歉的工作。第7章告诉我们偏好新风格;而在这里,被弃用的形式是唯一管用的形式。遗留的东西被保留着,因为它碰巧承载了含义。
“tests 目录子目录中的文件不会被编译成单独的 crate”
“Files in subdirectories of the tests directory don’t get compiled as separate crates”
查看原文 →这才是真正的规则——上面的一切都是它的后果。书先说症状,把机制埋在距离结尾一句话的地方。
“二进制 crate 是意在自己独立运行的”
“binary crates are meant to be run on their own”
查看原文 →一个打扮成自然事实的设计决定。它是编译器的策略,而整个 src/main.rs 很薄的模式,就是为绕过它而存在的。
读后感AI 陪读
一个通过的测试,可能在对你撒谎,关于代码是否管用
这一章引用迪科斯彻来反对自己开篇——测试可以显示 bug 的存在,却永远无法显示它们的不存在——然后干脆继续往下,这是它做得最诚实的事:Rust 中的测试,靠实践而不是证明来辩护。这个框架上的让步,对一门兜售编译期保证的语言来说是要紧的。类型钉住形状,从不钉住含义;u64 -> u64 被无限多个错误的函数所满足,所以返回参数加十的 add_two,会完美地通过类型检查。测试是意图被写下来的地方。
从那里起,机制干净地展开,而最有启发的一句话几乎是被随手丢出的:每个测试都在它自己的线程里运行,而一个死掉的线程就是一次失败。框架根本不定义正确性的概念——panic 就是协议,assert! 和 assert_eq! 不过是 panic 的发生器,下游的一切随之而来。这种简省是优雅的,也是本章反复出现的主题的来源:粗糙的信号。不带 expected 的 should_panic,在任何一行的任何 panic 上都通过,所以一个崩溃的笔误算作成功——而书直截了当地这么说,承认一个通过的测试可能在对你撒谎。在必须在过于精确的信息匹配与过于宽松的匹配之间做选择时,它把取舍交还给读者,而不是假装存在一条规则。返回 Result 的测试,看起来是摆脱 unwrap 的整洁出路,直到你注意到 ? 传播的恰恰是你试图断言的那个 Err,颠倒了你的意图;于是有了 assert!(value.is_err())。
继续阅读
配置一节读起来像一份悄悄塑造行为的默认值清单:并行执行是开着的,所以通过共享状态的跨测试干扰,是默认的失败模式——Rust 保护你程序里的共享内存,然后交给你一个没有保护的文件系统;输出捕获是开着的,这就是为什么调试用的 println! 消失了,也是为什么人们为了看到它们,故意写一个会失败的断言;而 cargo test name 悄悄地忽略第一个之后的每个参数。
组织的一半是最有启发的,主要在于它披露得晚的东西。单元测试能够触及私有函数,不是因为某种测试哲学,而是因为模块的子级能看到它们的祖先——这项能力是从第7章掉出来的,而不是本章授予的。相比之下,集成测试编译为单独的 crate,只看得到公共 API,这使它们成为对你接口的真正测试。然后一文件一 crate 的规则咬了人:tests/common.rs 里的一个共享助手,成了它自己的测试目标,并在输出里显示为一个空的小节,而修复办法不是代码,而是文件名——tests/common/mod.rs,本章早先告诉你要避免的那个“较旧的”约定,恰恰因为它现在承载了含义而被保留着。真正的规则——tests 的子目录不被编译为 crate——在症状之后,距离结尾只有一句话。而最后那条说二进制 crate 无法做集成测试的说明,是穿着自然法则外衣的编译器策略;整个“库之上的薄 main.rs”的模式,就是它的变通办法。
我要对读者说的是:认真对待输出,而不只是裁决。一个绿色的套件,可能意味着来自错误那一行的 panic,一个因为较早的一节失败而从未运行的测试,或一个冒充测试目标的助手文件。在你真正相信的需求的层面上断言——本章自己那个期待问候语的前缀会改变的例子,是其中最好的建议。并且把组织一节读作模块系统的内容,因为它就是:Rust 给你一条访问的轴(私有,通过 super::*)和一条隔离的轴(单独的 crate,只有公共 API),这里几乎所有的意外,都是 crate 边界的后果,而与测试本身无关。一点小小的牢骚:把断言的参数命名为 left 和 right,作为对称是说得过去的,但 expected/actual 是文档,而失败信息再也无法告诉你,哪个数字是愿望,哪个是事实。
在 FeedLitra 里读第 11 章,每段旁边都有这些卡片。
开始读书第 12 章:一个 I/O 项目:构建命令行程序
把逻辑移进 lib.rs,会怎样改变你能测试的东西?
第 12 章是怎样展开的AI 辅助
开头——一个项目,而不是一堂课。 本章打破一次教一个特性的模式,改为设定一个目标:构建 minigrep,那个经典搜索工具的精简克隆。第一版有意做得粗糙。命令行参数通过 env::args().collect() 进来,目标文件用 fs::read_to_string 一口吞下,一切都发生在 main 里。这很快得到一个能跑的程序,这正是要点:本章需要一个具体的、有缺陷的成品来动手。随后它精确地点出缺陷——main 同时干着两件事(解析参数和执行工作),配置值作为向量里按索引取的槽位到处漂着,无论出了什么错,读文件都只报一条含糊的消息,而不带参数运行则以索引越界的抱怨而死。作者的框定论证也在这里到来,并支配本章其余部分:这些弱点在程序很小时无害,但日后理清代价高昂,所以修好它们最便宜的时刻就是现在。
第一次发展——先结构,后正确。 最初的重构处理第二个缺陷,把零散的值捆进一个带 Config::new 构造函数的 Config 结构体,并在 main.rs 和 lib.rs 之间划分职责。途中一个小的设计决定得到了明确的辩护:Config 克隆它的字符串,而不是借用它们。这很容易被误读成初学者的捷径,更强的程序员会换成引用,但文本把它呈现为一次有意的取舍。main 里的 args 向量拥有参数值,只是把它们借出去,所以由引用构建的 Config,会把程序缠进在这里一无所获的生命周期问题里。拥有自己的 String 花一点时间和内存,却让代码易读——一个合理的价格。
转折——从对程序员 panic,到向用户报告。 本章的枢纽是它对错误的处理,并把问题重新框定为受众的问题,而不是机制的问题。索引越界的 panic,是一条只有读源码的人才看得懂的消息;用户从中什么也学不到,就像一条 expect 字符串无法区分文件缺失与权限被拒。修法是把错误处理集中到一处,并让它对用户说话。参数检查移进一个返回 Result 的构造函数,在到来的参数太少时交回 Err(“not enough arguments”)——而构造函数从 new 改名为 build,恰是因为程序员期望叫 new 的函数不会失败。main 成了呈现错误并决定退出状态的那一层:unwrap_or_else 走一个两行的错误分支,打印消息并调用 process::exit(1),以非零状态干净地停下,没有回溯的杂乱,而同样的 if let Err(e) 形状处理主逻辑。与此同时,run 函数返回 Result<(), Box<dyn Error>>,让 ? 运算符把失败向上传播,而不是就地中止。
第二次发展——库,以及测试驱动设计。 错误控制住之后,search 移进 src/lib.rs,这使它可测试,并让测试驱动开发接手。次序是明确的:一个返回空向量的 search,保证 one_result 测试失败,然后是最小的通过实现——一个把每个使 line.contains(query) 成立的行推进去的循环——只有测试变绿之后,才进行清理。作者认为,先写测试,使设计对函数应当做什么保持诚实。这个函数所需要的生命周期标注,产生了本章最尖锐的警示。没有标注时,编译器拒绝这个签名(E0106),无法判断返回的引用是借自查询还是借自文本;编译器自己的帮助文本,建议给每个参数同一个生命周期。这个建议在语义上是错的。返回的切片指向被搜索的文本,所以只有 contents 应当与返回值共享生命周期,query 根本不需要——这是一个照抄编译器的建议,会产出能编译、却误述了函数实际所做的代码的例子。
收尾——两个小的扩展,各有各的要点。 minigrep 用那首诗的文件做了验证,然后本章加了两个功能,补全它的主题。不区分大小写的搜索由 IGNORE_CASE 环境变量驱动,实现的范围有意收得极小:变量的值无关紧要,所以 env::var(“IGNORE_CASE”).is_ok() 产出存进配置的 ignore_case 字段的布尔值,它再选择 search_case_insensitive。最后一处改动,回到本章转折处的受众问题。因为 println! 只能写到标准输出,把程序的输出重定向到文件,会把错误消息吞进那个文件,用户永远看不到。把两处错误打印改成 eprintln!,就把两条通道分开:重定向的文件里只有成功的匹配,而失败仍到达屏幕。本章于是结束在它开始的地方——一个能跑的程序,它的结构,而不只是它的行为,已被做得有意为之。
关键论点,以及支撑它们的原文
趁程序还小,早点整理它的结构。
作者推断,初稿的弱点在这个规模上无害,但随着程序增长,理清它们代价会变高,所以修好它们最便宜的时刻就是现在。
“开发程序时尽早开始重构是个好习惯,因为重构较少量的代码要容易得多。”
“It’s a good practice to begin refactoring early on when developing a program because it’s much easier to refactor smaller amounts of code.”
§12.2 Reading a File查看原文 →
minigrep 第一个能跑的版本有四个具体的缺陷:main 同时干两件事,配置值到处漂着,读文件只报一个含糊的错误,不带参数运行则以一条索引消息崩溃。
作者表明,读文件可能以几种方式失败——文件可能缺失,或权限被拒——然而同一条 expect 消息,在每种情况下说的都一样,所以用户对究竟出了什么错一无所知。
“为了改进我们的程序,我们要修复四个问题,它们与程序的结构和它处理潜在错误的方式有关。”
“To improve our program, we’ll fix four problems that have to do with the program’s structure and how it’s handling potential errors.”
§12.3 Refactoring to Improve Modularity and Error Handling查看原文 →
顺着这些缺陷,错误处理应当住在一处,并对用户说话,而不是对程序员说话。
不带参数运行程序,会打印一句索引越界的抱怨,只有读源码的人才看得懂。作者的修法,是把参数检查移进一个返回 Result 的构造函数,在到来的参数少于三个时交回 Err(“not enough arguments”);构造函数从 new 改名为 build,恰是因为程序员期望叫 new 的函数永远不会失败。
“最好所有的错误处理代码都在一个地方,这样如果错误处理逻辑需要改变,将来的维护者只需要查阅一处代码。”
“It would be best if all the error-handling code were in one place so that future maintainers had only one place to consult the code if the error-handling logic needed to change.”
§12.3 Refactoring to Improve Modularity and Error Handling查看原文 →
另外 3 个论点
main 是呈现错误、并决定程序退出状态的合适之处。
unwrap_or_else 的错误分支只有两行:打印错误字符串,然后调用 process::exit(1),它立刻以非零状态停下程序,没有回溯的杂乱。同样的模式在主逻辑上重复,那里 if let Err(e) 打印应用的错误并以 1 退出。
“process::exit 函数会立刻停止程序,并返回传给它的数字作为退出状态码。”
“The process::exit function will stop the program immediately and return the number that was passed as the exit status code.”
§12.3 Refactoring to Improve Modularity and Error Handling查看原文 →
先写会失败的测试,再写能通过它的最小代码,然后改进代码。
作者从一个返回空向量的 search 函数开始,确保 one_result 测试必然失败,然后填入一个把每个使 line.contains(query) 成立的行推进去的循环;测试变绿,只有这时才跟着清理。其主张是:先写测试,使设计对代码应当做什么保持诚实。
“在写出让测试通过的代码之前先写测试,有助于在整个过程中保持高测试覆盖率。”
“Writing the test before you write the code that makes the test pass helps maintain high test coverage throughout the process.”
§12.4 Adding Functionality with Test Driven Development查看原文 →
对不区分大小写的选项,只要问一个环境变量是否存在就够了。
作者指出 IGNORE_CASE 的值无关紧要——只有它是否存在要紧——所以 env::var(“IGNORE_CASE”).is_ok() 产出存进配置 ignore_case 字段的布尔值,再由它选择不区分大小写的搜索。
“我们不关心环境变量的值,只关心它是已设置还是未设置,所以我们检查 is_ok,而不用 unwrap、expect,或我们见过的 Result 上任何其他方法。”
“We don’t care about the value of the environment variable, just whether it’s set or unset, so we’re checking is_ok rather than using unwrap, expect, or any of the other methods we’ve seen on Result.”
§12.5 Working with Environment Variables查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么只有 contents 得到生命周期
没有标注时,编译器拒绝 search 函数(E0106),因为它无法判断返回的引用是借自查询还是借自文本。既然返回的切片指向被搜索的文本,把输出的生命周期只绑到 contents 上,就准确告诉借用检查器,哪份数据必须比结果活得久。
机制 2
两条输出通道,两个去处
println! 只能写到标准输出,所以当 shell 把输出重定向到文件时,错误消息会落进文件,从屏幕上消失。把两处错误打印改成 eprintln!,就把它们送进另一条错误通道,于是重定向的文件里只有成功的匹配,而失败仍到达用户。
机制 3
为什么配置要拷贝它的字符串
main 里的 args 向量拥有参数值,只是把它们借出去,所以一旦 main 的数据所有权不清楚,持有引用的 Config 就会撞上借用规则。克隆让 Config 拥有自己的 String,使代码保持简单,代价是比持有引用多花一些时间和内存。
常见误读
当编译器的错误消息建议怎样加生命周期标注时,照抄那个建议就是正确的修法。
作者指出,这里的帮助文本提议给每个参数同一个生命周期,这在语义上是错的:返回的切片出自被搜索的文本,所以只有 contents 应当与返回值共享生命周期,query 不需要。
“注意,帮助文本建议给所有参数和输出类型指定同一个生命周期参数,这是不对的!”
“Note that the help text suggests specifying the same lifetime parameter for all the parameters and the output type, which is incorrect!”
§12.4 Adding Functionality with Test Driven Development查看原文 →
调用 clone 来构建配置,是初学者的捷径,更好的程序员会换成引用。
这份拷贝是一次有意的取舍:args 向量拥有参数值,只是把它们借出去,所以通过 clone 取得所有权,让代码保持直接,并绕开了生命周期的折腾,接受多花一点时间和内存作为交换。
“main 里的 args 变量是参数值的拥有者,只是让 parse_config 函数借用它们,这意味着如果 Config 试图取得 args 里那些值的所有权,我们就会违反 Rust 的借用规则。”
“The args variable in main is the owner of the argument values and is only letting the parse_config function borrow them, which means we’d violate Rust’s borrowing rules if Config tried to take ownership of the values in args.”
§12.3 Refactoring to Improve Modularity and Error Handling查看原文 →
第 12 章的核心概念
- 二进制项目里的关注点分离
- 让 main 保持小——解析参数、建立配置、调用 run、处理它的错误——把真正的逻辑放进 lib.rs,使它可以被测试。
- 适用处: 适用于 main 越长越大的二进制 crate;很小的解析逻辑可以留在 main 里。
- Config 结构体
- 一个把相关的配置值(query、file_path)用具名字段分组的结构体,而不是返回一个零散的元组。
- 适用处: 当返回的值明显属于同一个相关的目的时有用;这里拥有所有权的 String 需要克隆。
- 关联函数
- 在 impl 块里定义、并在类型上调用的函数,如 Config::new,与 String::new 的做法一致。
- 适用处: 当这个函数构造它自己类型的实例时,是惯用法。
- Iterator trait
- env::args 返回参数的迭代器;collect 把产出的值变成一个集合,比如向量。
- 适用处: collect 通常需要类型标注,因为 Rust 无法推断目标集合。
- Result 枚举
- 一种返回类型,表示成功或带描述的错误,由调用者决定怎么办。
- 适用处: 对预期中的用法错误有用;编程 bug 仍可能该用 panic!。
- Box<dyn Error>
- 一种 trait 对象的错误类型,意思是“某个实现了 Error 的值”,而不点名是哪一个。
- 适用处: 获得灵活性,却失去了可用于匹配的具体错误类型。
另外 5 个概念
- 二进制目标(Binary target)
- src/main.rs 这个 crate 只负责把东西接起来,而可复用的逻辑活在库 crate 里。
- 适用处: 只有在 src/lib.rs 里标了 pub 的项,才能被二进制调用。
- 借用检查器
- 生命周期标注告诉 Rust,返回的引用借自哪个输入。
- 适用处: 编译器建议把所有生命周期统一,在这里语义上可能是错的。
- 环境变量配置
- 从 shell 环境里读取一次的设置,使该会话里之后的每次运行表现相同。
- 适用处: 这里只有存在与否要紧,值无关紧要;对每次运行各自的选择,命令行标志更合适。
- 标准错误流
- 错误消息的第二条输出通道,与正常结果流分开。
- 适用处: println! 只能写到标准输出,所以错误需要 eprintln!。
- 遮蔽
- 为一个新值重用变量名,这里是把小写化的 query 绑定到原来的之上。
- 适用处: 新的 query 是 String,不是切片,所以 contains 需要加一个 & 号。
第 12 章划线与批注AI 陪读
“grep(全局搜索一个正则表达式并打印)”
“grep (globally search a regular expression and print)”
查看原文 →把缩写拆开是个漂亮的动作——它悄悄告诉你,这个工具比它的名字变得不透明还要早。不过要注意:他们造的 minigrep 根本不做正则。
“用户可以把成功的输出重定向到文件,同时仍在屏幕上看到错误消息”
“the user can redirect successful output to a file while still seeing error messages onscreen”
查看原文 →这才是 stderr 的真正理由,用一个用户故事陈述,而不是一条规则。大多数教程只说“用 stderr”,却从不展示两条流为什么必须可分离。
“Andrew Gallant 已经做出了一个功能齐全、非常快的 grep 版本,叫作 ripgrep”
“Andrew Gallant, has already created a fully featured, very fast version of grep, called ripgrep”
查看原文 →诚实的框定:你不是在造新东西,而是在为别人真实的代码造一把阅读的钥匙。本章有意给自己设了天花板。
另外 17 条批注
“虽然你在 Rust 里很少需要标注类型,collect 却是你经常需要标注的一个函数”
“Although you very rarely need to annotate types in Rust, collect is one function you do often need to annotate”
查看原文 →揭示机制的例外:推断从值向外流,但 collect 对它的输出是泛型的,所以上游没有东西可供推断。值得停下来想想。
“一首艾米莉·狄金森的诗,会很合适!”
“an Emily Dickinson poem that will work well!”
查看原文 →选“I'm Nobody! Who are you?”给一个搜词出现处的工具当测试数据,比作者承认的还要好笑——而且它真的有你需要的重复词。
“经常检查进度是好的,这有助于在问题出现时找出原因。”
“It’s good to check your progress often, to help identify the cause of problems when they occur.”
查看原文 →这里教的方法不是模块化,而是步子的大小。小的重构让归咎的范围保持很小——这才是真正可迁移的技能。
“我们返回了一个元组,却又立刻把这个元组拆成各个部分。这表明也许我们还没有找到正确的抽象”
“we’re returning a tuple, but then we immediately break that tuple into individual parts again. This is a sign that perhaps we don’t have the right abstraction yet”
查看原文 →本章最好的一句:一种具体的、可检查的味道,而不是对好设计含糊的诉求。先打包再立刻拆包,意味着这个分组从来不是真的。
“与其第一遍就想把代码过度优化,不如有一个有点低效的能跑的程序。”
“It’s better to have a working program that’s a bit inefficient than to try to hyperoptimize code on your first pass.”
查看原文 →教学上慷慨,也化解了真正的隐患——初学者把 Rustacean 对克隆的羞辱当成禁令,吓得不敢动。第13章还是会悄悄把这些克隆删掉。
“我们还要把函数名从 new 改成 build,因为许多程序员期望 new 函数永不失败”
“We’re also going to change the function name from new to build because many programmers expect new functions to never fail”
查看原文 →一条只靠期望维持的约定——编译器毫不在乎。命名,是 API 传达“这可能失败”的唯一通道。
“对编程问题而言,调用 panic! 比对用法问题更合适”
“A call to panic! is more appropriate for a programming problem than a usage problem”
查看原文 →干净的坐标轴:panic 用于我代码里的 bug,Result 用于你输入里的错误。我读过的几乎每个糟糕的 Rust 命令行工具,都把这个弄反了。
“因为你无法直接测试 main 函数,这种结构让你能测试程序的全部逻辑”
“Because you can’t test the main function directly, this structure lets you test all of your program’s logic”
查看原文 →所以 main.rs/lib.rs 的拆分不是审美——是可测试性迫使出一种架构。不可测试的区域被缩小,直到读一遍就足以证明它。
“我们不再得到所有那些额外的输出”
“we no longer get all the extra output”
查看原文 →真正的教训藏在 diff 里:panic! 把实现细节泄漏给用户。退出码和朴素的消息是一条边界,不是一种方便。
“表明我们调用 run 只是为了它的副作用的惯用方式”
“the idiomatic way to indicate that we’re calling run for its side effects only”
查看原文 →Ok(()) 是类型系统承认有些函数的存在,只是为了扰动世界。诚实的丑陋。
“注意,帮助文本建议给所有参数和输出类型指定同一个生命周期参数,这是不对的!”
“Note that the help text suggests specifying the same lifetime parameter for all the parameters and the output type, which is incorrect!”
查看原文 →罕见而宝贵:书告诉你,编译器的建议是错的。能编译 ≠ 正确——生命周期编码了机器推断不出的意图。
“结果不是边找到边打印的;我们会讨论用迭代器来修复这一点的一种可能办法”
“results aren’t printed as they’re found; we’ll discuss a possible way to fix this using iterators”
查看原文 →一个有意埋下的缺陷,被标出而不是隐藏。靠延期的债来教学——但读到这里就停的读者,发出去的是慢的版本。
“把 search 函数定义成总是返回一个空向量”
“defining the search function to always return an empty vector”
查看原文 →TDD 里最好笑的诚实时刻:写一个谎,只为改变失败的颜色。panic 式失败和断言式失败,是不同等级的认知。
“我们加了一行新文本:“Duct tape.”
“We’ve added a new line with the text "Duct tape.”
查看原文 →在碰新代码之前,先给旧测试加一个擦边的反例——这点小小的纪律,防止不区分大小写悄悄吃掉区分大小写。
“虽然 to_lowercase 会处理基本的 Unicode,它并不是 100% 准确的。”
“While to_lowercase will handle basic Unicode, it won’t be 100 percent accurate.”
查看原文 →一句话的活板门。教程里每一条“真实应用”的警告,都是某个人会原样发出去的 bug。
“所以我们用 is_ok 检查,而不用 unwrap、expect,或我们见过的 Result 上任何其他方法”
“so we’re checking is_ok rather than using unwrap, expect, or any of the other methods we’ve seen on Result”
查看原文 →漂亮:方法的选择,由你真正需要知道的东西来辩护。是存在与否,不是值。不过这意味着 IGNORE_CASE=0 或 IGNORE_CASE=false 仍会把它打开——一个书没有指出的真瑕疵。
“决定是命令行参数还是环境变量应该优先”
“Decide whether the command line argument or the environment variable should take precedence”
查看原文 →这个练习交给你的是一个真正的设计判断,而不是语法操练。顺带一提,约定是:显式的标志胜过环境里的变量。
读后感AI 陪读
从三十行重构成一个经过测试的库,而不是造 grep
这一章是本书的第一个真正的程序,它的形状就是教训:不是 grep,而是从 30 行的 main 走向一个经过测试的库。它展开成一个循环——写点能跑的东西,察觉一种味道,修好它,检查进度——而作者们对在你面前跑这个循环,坦率得不同寻常。弧线很干净:用 env::args().collect() 取参数(collect 的类型标注是推断的诚实例外,因为它对输出是泛型的,上游没有东西可供推断);fs::read_to_string;然后是 parse_config,然后是 Config 结构体,因为返回一个元组、却立刻把它解构,是本章最尖锐的诊断——先打包再拆包,意味着这个分组从来不是真的。从这里起,Config::new 变成 Config::build(改名是因为“new 不该失败”这条约定,编译器完全不在乎——命名是 API 唯一能传达可失败性的通道),panic! 变成 Result,main 变成 unwrap_or_else 加 process::exit,逻辑迁到 src/lib.rs。这次拆分不是审美:既然 main 无法测试,这个架构就是可测试性在反推,把不可测试的区域缩小,直到读一遍就足以证明它。然后才是正经的 TDD——一个返回空向量的搜索函数,让失败的等级从 panic 变成断言——然后是 IGNORE_CASE 背后的不区分大小写搜索,然后是 eprintln!,使重定向的标准输出里只有结果。
我看穿的是:本章有意给自己设了天花板。它一开头就点名 ripgrep,尽管拆解了那个缩写,却没造任何正则,并且埋下它标注而不是隐藏的缺陷——那些克隆(“宁可要一个有点低效的能跑的程序”)、结果不是边找边输出、to_lowercase 的 Unicode 警告。这是慷慨的教学法,也是个真正的隐患:读到这里就停的读者,会把慢的版本发出去,而每一句“对真实应用并不 100% 准确”的旁白,都是某个人会原样抄走的 bug。用 is_ok 检查环境变量,理由正当——你要的是存在与否,不是值——但这意味着 IGNORE_CASE=0 和 IGNORE_CASE=false 都会启用它,一个书从没指出的瑕疵。而我最想保留的时刻是:书告诉你,编译器自己给出的生命周期建议是错的。能编译 ≠ 正确。把输出绑到 contents 而不是 query 的那个生命周期,编码了机器推断不出的意图。
继续阅读
我最尖锐的判断:这里最好的两个想法并不是 Rust 特有的。一个是步子的大小——频繁编译让归咎的范围保持很小,这才是模块化高谈阔论底下真正可迁移的技能。另一个是错误的坐标轴:panic! 用于我代码里的 bug,Result 用于你输入里的错误。我读过的几乎每个糟糕的 Rust 命令行工具,都把它颠倒了,而证据就在那份 diff 里:panic 的额外输出不再把实现细节泄漏给用户。退出码、stderr 上的朴素消息,以及书真的费心讲的那个用户故事——把成功重定向到文件,仍在屏幕上看到失败——是一条边界,不是一种方便。就连 Ok(()) 也配得上它的位置:类型系统承认有些函数的存在,只是为了扰动世界。
对读者:别把它当作 minigrep 的菜谱来读。把它当作某人在压力下重构的 diff 日志来读,并偷走那些触发器——元组的味道,new/build 的改名,无法测试的 main 这条约束,panic/Result 的坐标轴。然后诚实地做收尾的练习,因为它交给你的是一个真正的设计判断,而不是语法操练;顺带一提,约定是:显式的标志胜过环境里的变量。还有,别在这里停下:第13章用迭代器删掉了那些克隆,恰是本章公开举下的那笔债。
在 FeedLitra 里读第 12 章,每段旁边都有这些卡片。
开始读书第 13 章:函数式语言特性:迭代器与闭包
迭代器如何复用这些同样的 Fn trait 约束?
第 13 章是怎样展开的AI 辅助
开篇:从零开始引入闭包。 本章宣布自己是转向 Rust 的函数式传承,它从两个主题中较小的那个开始:闭包,你可以存在变量里、或交给另一个函数的匿名函数。本章的第一个动作,是确立它们与普通函数有什么不同。它们可以捕获周围的环境,也可以跳过函数所要求的类型标注。对第二点给出的推理是关于受众而不是便利:函数签名是别的程序员依之编写代码的接口,所以必须写明,而闭包通常住在一个局部变量里,从未对库的用户点过名,所以不需要僵硬的声明。因此编译器根据第一次调用推断参数和返回类型——但本章小心地补充说,推断会落定:猜出的类型被锁定,用不同类型再调用一次是类型不匹配错误,而不是再猜一次。
展开:最小捕获,然后是 Fn trait。 从那里起,本章建立它关于捕获的核心规则。闭包只取它的函数体实际需要的最小访问——不可变借用、可变借用,或所有权。库存的例子说明了良性的情形:|| self.most_stocked() 只读,所以它捕获一个不可变引用,Inventory 在别处仍可使用,这就是标准库代码能够执行它一无所知的用户逻辑的原因。本章随后产生了最小值不够的情形:把闭包发送到一个生成的线程,那里主线程可能在线程结束之前丢弃一个借用的值,所以编译器坚持用 move 来强制所有权,就像在被 join 之前打印 From thread: {list:?} 的那个线程里。这种捕获行为随后成为 Fn trait 的基础。函数体如何对待它的捕获,决定闭包实现 Fn、FnMut 还是 FnOnce,而既然调用处检查那组 trait,这些 trait 决定了闭包可以被传到哪里。本章把一个函数所要求的约束框定为关于调用频率的陈述:unwrap_or_else 至多调用它的闭包一次,所以 FnOnce 就够了;sort_by_key 对每个元素调用一次,所以它要求 FnMut。重要的是——也针对一种自然的误读——FnOnce 是最宽松的要求,因为每个闭包都实现它。把闭包窄化为仅有 FnOnce 的,是把一个被捕获的值移出去的函数体,这就是为什么推入一个被移动的值的那个闭包,会被拒绝,报“cannot move out of a captured variable in an FnMut closure”,而仅仅借用一个计数器来递增它的那个则能编译。
转折:从闭包到迭代器。 本章以 FnMut 为支点转入它的第二个主题。迭代器被呈现为惰性的——在被消耗之前什么也不做——并立足于单一的必需方法 next,整个 Iterator trait 由它而来。方法分为消耗适配器,用尽迭代器,和迭代器适配器,产生另一个迭代器,因此最终必须被消耗。闭包在这里作为适配器所取的东西重新出现,这就是使本章的两半成为一章而不是两章的原因。
应用:重构 minigrep。 回报是回到第12章的项目。Config::build 和 search 被改写成迭代器风格,而本章就消失的东西来论证收益。克隆消失了;可变的中间状态也消失了,带着不断增长的 results 向量的循环,塌缩成 contents.lines().filter(|line| line.contains(query)).collect()。底层的要点是,迭代器吸收了程序员手写、又会写错的索引簿记,而本章指出了进一步的后果:没有共享的可变变量,搜索的并发版本以后就变得可行。
收尾:性能问题,谨慎地回答。 显而易见的反对是成本,所以本章对两个版本做了基准测试,在福尔摩斯的文本里搜索“the”——循环大约 19,620,300 ns/iter,迭代器大约 19,234,900 ns/iter,实际上是平手。作者坦率地说这只是一份文本和一个查询,并要求更广泛的基准测试,但把这个结果推广成零成本抽象的原则:因为迭代器和闭包代码是被编译掉的而不是被解释的,经过循环展开和去除数组边界检查之类的优化,它成了程序员本会手写的大致相同的汇编。注意这个主张是什么、不是什么——它是许可你使用高层风格而不必担心惩罚,而不是承诺把循环重写一遍就会让程序更快。本章在这个调子上结束,并指向 Cargo 用于分享这个项目的特性。
关键论点,以及支撑它们的原文
闭包可以跳过函数所要求的类型标注。
作者的推理是关于受众:函数的类型是别人依之编程的接口的一部分,所以必须写明,而闭包通常住在一个局部变量里,从未对库的用户点过名,使得一个僵硬的、声明了的接口没有必要。
“函数上需要类型标注,因为类型是暴露给你用户的显式接口的一部分。”
“Type annotations are required on functions because the types are part of an explicit interface exposed to your users.”
§13.1 Closures查看原文 →
因为闭包只抓取它需要的那么多访问,被不可变捕获的值在闭包周围仍然可用。
库存的例子把 || self.most_stocked() 传给标准库;闭包只需要读,所以它捕获一个对 Inventory 的不可变引用,这让库代码能够运行它一无所知的用户逻辑,同时该值在别处仍然可用。
“闭包将根据函数体对被捕获的值做什么,来决定使用其中哪一个。”
“The closure will decide which of these to use based on what the body of the function does with the captured values.”
§13.1 Closures查看原文 →
把闭包发送到新线程,需要用 move 强制所有权。
建立在最小捕获规则之上,作者展示了最小值不安全的一个案例:主线程可能在生成的线程结束之前丢弃借用的值,所以编译器坚持要把 list 移进去,就像生成 move || println!(“From thread: {list:?}”) 然后 join 该线程的那个例子。
“因此,编译器要求把 list 移进给新线程的闭包,这样引用才是有效的。”
“Therefore, the compiler requires that list be moved into the closure given to the new thread so that the reference will be valid.”
§13.1 Closures查看原文 →
另外 3 个论点
一个函数所要求的 Fn trait 约束,其实是关于它将多频繁地调用闭包的陈述。
比较了两个标准库的案例:unwrap_or_else 的函数体至多调用闭包一次——当 Option 是 Some 时从不调用,是 None 时恰好一次——所以 FnOnce 就够了,而 sort_by_key 对切片中的每个元素调用一次闭包,因此要求 FnMut。作者指出,既然每个闭包都实现 FnOnce,那个约束是调用者可能面对的最宽松的要求,而不是最严格的。
“在 trait 约束中使用 FnOnce,表达了 unwrap_or_else 不会多次调用 f 的约束。”
“Using FnOnce in the trait bound expresses the constraint that unwrap_or_else will not call f more than once.”
§13.1 Closures查看原文 →
用迭代器适配器重写 search 函数,消除了可变的中间状态,并为以后的并行执行留出了余地。
带着不断增长的 results 向量的循环,变成了 contents.lines().filter(|line| line.contains(query)).collect();同样关于迭代器吸收了程序员手写、又会写错的索引簿记的要点,在这里适用,而没有共享的可变变量,作者说搜索的未来并发版本变得可行。
“去掉可变状态,也许能实现让搜索并行进行的未来改进,因为我们不必管理对 results 向量的并发访问。”
“Removing the mutable state might enable a future enhancement to make searching happen in parallel because we wouldn’t have to manage concurrent access to the results vector.”
§13.3 Improving Our I/O Project查看原文 →
选择迭代器风格而不是显式循环,不花任何可度量的运行时性能。
在福尔摩斯的文本里搜索“the”的基准测试,循环版本约 19,620,300 ns/iter,迭代器版本约 19,234,900 ns/iter——实际上是平手。作者把这呈现为一份文本、一个查询的结果,并要求更广泛的基准测试,然后把它推广成结束本章的零成本抽象原则。
“这两种实现有相似的性能!”
“The two implementations have similar performance!”
§13.4 Performance in Loops vs. Iterators查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
闭包的函数体如何选择它的捕获
闭包只取它的函数体实际需要的最小访问——不可变借用、可变借用,或所有权——而这个选择反过来决定它满足 Fn/FnMut/FnOnce 中的哪几个 trait。因为调用处检查的是那组 trait,把一个被捕获的值移出去的函数体,最终只有 FnOnce,在需要重复调用的地方被拒绝。
机制 2
猜出的类型在首次使用时被冻结
闭包不是公共接口,所以编译器根据第一次调用推断它的参数和返回类型,而不是要求标注。但推断落定于一个具体类型并把它锁定,所以之后用不同类型的调用是类型不匹配,而不是一次新的推断。
机制 3
为什么抽象在编译期消失
迭代器和闭包代码是被编译掉的,而不是在运行时被解释:循环展开和去除数组边界检查之类的优化,把它变成程序员本会手写的大致相同的汇编。这就是为什么可以自由使用高层风格而不付速度的惩罚。
常见误读
FnOnce 约束意味着函数只接受受限的闭包,而使用 move 或拥有数据,就是使闭包只能被调用一次的原因。
FnOnce 是最宽松的要求,因为每个闭包都实现它;把闭包窄化为仅有 FnOnce 的,是把一个被捕获的值移出去的函数体,因为这样的函数体无法运行第二次。这就是为什么推入一个被移动的值的闭包,被 sort_by_key 拒绝,报“cannot move out of a captured variable in an FnMut closure”的错误,而仅仅可变地借用一个计数器来递增它的闭包则编译得很好。
“在 trait 约束中使用 FnOnce,表达了 unwrap_or_else 不会多次调用 f 的约束。”
“Using FnOnce in the trait bound expresses the constraint that unwrap_or_else will not call f more than once.”
§13.1 Closures查看原文 →
“零成本抽象”意味着把循环换成迭代器和闭包,会使程序更快。
这个主张只是说,高层形式不增加运行时开销——它编译成大致手写质量的汇编。作者自己的基准测试,让两个版本的表现大致相同,在一份文本和一个查询上,并明确把结论框定为许可你不必担心惩罚地使用迭代器,而不是承诺任何重写都会在速度上获胜。
“这两种实现有相似的性能!”
“The two implementations have similar performance!”
§13.4 Performance in Loops vs. Iterators查看原文 →
第 13 章的核心概念
- 闭包
- 一个你可以存在变量里或传来传去的匿名函数,它也可以使用它被写下之处的值。
- 适用处: 与 fn 条目不同,它捕获环境,但它通常是短命的,不属于公共接口。
- 闭包类型推断
- 编译器根据使用情况猜测闭包的参数和返回类型,然后永久固定它们。
- 适用处: 每个参数只有一个具体类型;用另一种类型的第二次调用是个错误,而罕见的情形仍需要标注。
- 闭包捕获模式
- 闭包不可变借用、可变借用,或取得所有权,由其函数体做什么来选择。
- 适用处:
move覆盖最小的选择,主要用于把数据发送到新线程。 - Fn trait
- FnOnce、FnMut 和 Fn 标记一个闭包可以被调用多少次、多无害地调用。
- 适用处: 以累加方式实现;把捕获移出去的闭包只得到 FnOnce。普通函数也实现适用的那些。
- move 关键字
- 在参数列表之前写
move,强制闭包拥有它所用的东西。 - 适用处: 当闭包可能活得比原来的所有者更久时需要,例如生成的线程。
- Iterator trait
- 一个标准库的 trait,其唯一必需的方法是 next,返回 Some(item) 直到序列以 None 结束。
- 适用处: 需要定义一个关联的 Item 类型;其他方法带有默认实现。
另外 5 个概念
- 惰性迭代器
- 创建一个迭代器什么也不做;只有当一个消耗的方法用尽它时,工作才发生。
- 适用处: 光有适配器只产生警告,不产生结果。
- 消费适配器
- 像 sum 这样在内部调用 next、并取得迭代器所有权的方法。
- 适用处: 之后不能再使用该迭代器。
- 迭代器适配器
- 像 map 和 filter 这样返回新迭代器、而不消耗旧的的方法。
- 适用处: 在像 collect 这样的消费适配器运行之前,什么也不产生。
- 零成本抽象
- 编译成与手写的低层版本一样高效的代码的高层构造。 被编译掉的高层构造,所以使用它们不花额外的运行时速度。
- 适用处: 意味着没有运行时开销,不是说每次重写总是更快。 只关乎运行时性能,不关乎编译时间或代码体积。
- impl Trait 语法
- 一个写作 impl Iterator<Item = String> 的参数类型,接受任何满足该约束的类型。
- 适用处: 在这里它还转移了参数的所有权。
第 13 章划线与批注AI 陪读
“与函数不同,闭包可以从它们被定义的作用域中捕获值。”
“Unlike functions, closures can capture values from the scope in which they’re defined.”
查看原文 →这一句话是整章的引擎。其他一切——Fn trait、move 关键字——都是对这一种能力的后果的簿记。
“标准库不需要知道关于我们定义的 Inventory 或 ShirtColor 类型的任何东西”
“The standard library didn’t need to know anything about the Inventory or ShirtColor types we defined”
查看原文 →说得好:闭包是你把自己的上下文夹带进别人的泛型代码的办法。控制反转,不用行话来陈述。
“函数上需要类型标注,因为类型是暴露给你用户的显式接口的一部分。”
“Type annotations are required on functions because the types are part of an explicit interface exposed to your users.”
查看原文 →这个理由是社会性的,不是技术性的。标注不是给编译器的——而是与其他人类的契约。闭包逃过了,因为没有别人读它们。
另外 14 条批注
“这些类型随后被锁进 example_closure 中的闭包”
“Those types are then locked into the closure in example_closure”
查看原文 →“锁”是诚实的词。推断不是泛型——闭包得到一个类型,由首次使用决定。很容易把定义处的灵活误当成多态。
“闭包将根据函数体对被捕获的值做什么,来决定使用其中哪一个。”
“The closure will decide which of these to use based on what the body of the function does with the captured values.”
查看原文 →注意这个口误:“函数的主体”应当是闭包。很小,但说明问题——作者们自己也一直在这两个概念之间滑动。
“我们在调用闭包之后不再使用它,所以可变借用结束。”
“We don’t use the closure again after the closure is called, so the mutable borrow ends.”
查看原文 →借用的生命周期由最后一次使用而不是作用域设定。这是安静的 NLL 规则在做工作,而书拒绝点它的名。
“试着去掉 move 关键字,或者在定义闭包之后在主线程中使用 list,看看你会得到什么编译器错误!”
“Try removing the move keyword or using list in the main thread after the closure is defined to see what compiler errors you get!”
查看原文 →书第三次把教训外包给一个练习。有效的教学法,但这也意味着错误信息——这里真正的老师——留在了书页之外。
“闭包会以累加的方式,自动实现这些 Fn trait 中的一个、两个或全部三个”
“Closures will automatically implement one, two, or all three of these Fn traits, in an additive fashion”
查看原文 →值得坐下来想想:这些 trait 形成了一个克制的层次结构。闭包对它的捕获做得越少,赢得的 trait 就越多。能力源于自我克制。
“因为所有闭包都实现 FnOnce,unwrap_or_else 接受所有三种闭包,并尽可能地灵活。”
“Because all closures implement FnOnce, unwrap_or_else accepts all three kinds of closures and is as flexible as it can be.”
查看原文 →藏在库签名里的设计教训:要求你能遵守的最弱的约束。FnOnce 不是对调用者的限制,而是被调用者的承诺。
“这是一种牵强、绕弯子的(而且行不通的)计数办法”
“This is a contrived, convoluted way (that doesn’t work) to try to count”
查看原文 →书在句子中间为自己的例子道歉。诚实,却削弱了教训——读者可能把这个错误当作愚蠢设定的产物而打发掉,而不是一个真正的约束。
“在 Rust 中,迭代器是惰性的,意思是在你调用消耗迭代器的方法之前,它们不起任何作用”
“In Rust, iterators are lazy, meaning they have no effect until you call methods that consume the iterator”
查看原文 →一个词,“惰性”,悄悄地把迭代从一个循环,重新框定为一个可以传来传去的值。回报在本章后面到来;基础工作在这里。
“我们在使用 for 循环时不需要让 v1_iter 可变,因为循环取得了 v1_iter 的所有权,并在幕后使它可变。”
“We didn’t need to make v1_iter mutable when we used a for loop, because the loop took ownership of v1_iter and made it mutable behind the scenes.”
查看原文 →for 循环一直在悄悄做脏活。很高兴看到语法糖被剥开——可变并没有消失,只是被藏起来了。
“note: iterators are lazy and do nothing unless consumed”
查看原文 →一个做教学的编译器警告。语言靠拒绝让你悄悄浪费惰性,来教授惰性。
“因为 map 取一个闭包,我们可以指定我们想对每一项执行的任何操作。”
“Because map takes a closure, we can specify any operation we want to perform on each item.”
查看原文 →这是本章实际的论点:闭包是可定制的洞,迭代器是可复用的框架。标题的两半终于咔嗒一声合上了。
“去掉可变状态,也许能实现让搜索并行进行的未来改进”
“Removing the mutable state might enable a future enhancement to make searching happen in parallel”
查看原文 →为函数式风格所做的真正论证不是优雅,而是不可变性是并行的先决条件。Rayon 被预告了,却没有点名。
“结果将在每个匹配的行被找到时打印出来,因为 run 函数里的 for 循环能够利用迭代器的惰性”
“the results will be printed as each matching line is found because the for loop in the run function is able to take advantage of the laziness of the iterator”
查看原文 →惰性从一个烦人的警告,变成了一个可观察的特性——到第一个结果的延迟。同一个性质,相反的价值取向,相隔三十段。
“你确实用到的,你手写也不可能写得更好。”
“What you do use, you couldn’t hand code any better.”
查看原文 →借用斯特劳斯特鲁普的标准是个精明的修辞动作——它承认基准测试的论证很弱,转而倚靠一条借来的原则。
读后感AI 陪读
两半严丝合缝:迭代器搭框架,闭包做定制
本章把自己呈现为对两个特性的巡礼,但它其实是一个分两个乐章传达的论证,而铰链句子在头几段就到来了:与函数不同,闭包可以从它们被定义的作用域中捕获值。此后的一切都是后果的管理。Fn/FnMut/FnOnce 这三件套不是要背的分类,而是类型系统对一个被捕获的值遭受了什么的核算;move 是当拥有一个值的作用域活不过需要它的代码时的逃生口。从这个角度读,这一章有了脊梁,而衬衫库存的例子赢得了它的位置:标准库的 unwrap_or_else 对 ShirtColor 一无所知,却在你的数据上运行你的逻辑。那是不用这个词解释的控制反转,也是为什么捕获根本要紧的最清楚的陈述。
本章安静的优雅,在于它的两半如何咬合。Map 取一个闭包,所以迭代器提供可复用的框架,而闭包提供可定制的洞——标题不再是两个主题的列表,而成了一个单一的想法。惰性是另一条贯穿的线,而书比它所表现的更聪明:它起初作为编译器警告出现,责备你浪费了一个迭代器,三十段之后又作为一个你能观察到的特性重现,匹配的行在被找到时就打印出来,而不是在完整扫描之后。同一个性质,相反的价值取向,没有大张旗鼓。minigrep 的重构是论证落地之处,而诚实的回报不是更短的代码——而是关于去掉可变状态可能实现未来并行化的那一行。那是对 Rayon 的预告,却没有点名,也是本章为函数式风格做出的最强论证。
继续阅读
我要反驳的是:教学法一再把真正的教学卸载了出去。有三次,书说试试看会得到什么错误,这意味着编译器的信息——所有权和借用真正的老师——留在了书页之外。FnMut 例子里的借用止于最后一次使用,而不是作用域的末尾,而书让非词法生命周期做了这项工作,却没有点名;一个还没有内化 NLL 的读者会觉得那一段很神秘。它在句子中间为自己的 sort_by_key 反例是牵强的而道歉,这邀请读者把一个真正的约束当作一个愚蠢设定的怪癖而打发掉。而且它失言了,写成“函数的主体”而本意是闭包——一个小小的暗示,说明就连作者们也在两者之间飘移。还要留意类型推断里的陷阱:闭包的参数类型被首次使用锁定了。那在定义处看起来像灵活,却与泛型毫无相似之处。
收尾的零成本主张建立在一次基准测试和一句借自斯特劳斯特鲁普的话上——你确实用到的,你手写也不可能写得更好。这是个精明的动作,因为光靠基准测试撑不起它;起说服作用的是原则。我接受这个结论,同时留意修辞。我要对读者说的是:别把 Fn trait 当作以后再查的琐事。它们编码了一种本章几乎顺带陈述的设计纪律——要求你能遵守的最弱的约束,因为参数上的 FnOnce 不是对调用者的限制,而是被调用者关于它将对你的数据做什么的承诺。能力在这里源于自我克制:闭包对它的捕获做的越少,它赢得的 trait 就越多,它能去的地方也越多。内化这个反转,本章其余部分就不再是簿记,而开始成为设计建议。
在 FeedLitra 里读第 13 章,每段旁边都有这些卡片。
开始读书第 14 章:更多关于 Cargo 和 Crates.io
工作空间怎样在各个包之间共享同一个 Cargo.lock?
第 14 章是怎样展开的AI 辅助
开头:先从最小的杠杆开始。 本章从发布配置档开始,这个选择是有意的——它从你一直在用却没注意到的那部分 Cargo 起步。作者的动作,是点出一个取舍,而不是一个特性:更多的优化工作,意味着更长的编译时间,而两个默认配置档,处在这条光谱的两端。从这里起,默认值自己就说明了自己。开发意味着不断重新构建,所以 dev 以 opt-level 0 出厂,使每次编译尽可能快;发布的二进制很少构建、常常运行,所以 release 以 3 出厂。确立了默认值是有道理而不是任意的之后,作者随后表明,任何一个都可以在 Cargo.toml 里按配置档覆盖——这是本章模式的缩影:先解释默认值为什么存在,再展示那个旋钮。
发展:从你自己的构建,到别人的使用。 本章的大部分向外转,转到在 crates.io 上发布,而这里的次序是按主题,不是按流程。文档先行,关键主张是:用 /// 和 //! 写的文档注释,不只是被渲染成 HTML——cargo test 会编译并运行其中的示例。证据是测试输出本身,add_one 示例有一个通过的文档测试,另外还有这样的观察:改动函数或示例,使 assert_eq! 不再成立,测试就会失败。这就是为什么要写示例:它们自己守着自己的诚实,使文档无法悄悄漂离代码。讨论接着从为 API 写文档,转到塑造 API,用的是 art crate:在任何重导出之前,用户必须写 use art::kinds::PrimaryColor,并在作者内部的模块嵌套里穿行,才能找到任何东西;pub use 把这些项提到顶层,使方便的路径可用,而内部结构保持不动。这里附带的诚实提醒——得到一个好 API 是迭代的,与其说是科学,不如说是艺术——很要紧,因为它把 pub use 框定为修订的工具,而不是公式。
转折:一个无法收回的决定。 实际的步骤随后而来——账户设置,crates.io 要求的元数据——然后本章到了它唯一真正不可逆的点,也是它最尖锐的推理。作者并不是简单地断言已发布的版本是永久的;论证是从注册表的目的出发的。crates.io 作为永久的档案而存在,使依赖它的构建继续能用,而允许删除,会使这一保证不可能。永久性因此是它真的有用这件事的一个后果。提供的唯一补救是 yank,限度被直白地陈述:yank 阻止新项目把那个版本当作依赖,但它不移除任何代码,锁文件已经钉住那个版本的项目仍能构建,而不小心上传的任何秘密仍在外面,必须轮换。--undo 的存在是定论性的细节——一个可逆的操作不可能是删除。本章在这里最容易被误读成提供了一个撤销按钮,而文本明确地关上了这扇门。
发展续:工作空间,和一次共享的解析。 发布安顿好之后,本章转向把代码组织到多个 crate 里,并具体搭起 add 工作空间——一个根 Cargo.toml 列出成员 adder 和 add_one,带显式的路径依赖。两件共享的东西承载了解释。工作空间根目录下的一个 target 目录,存在的理由是成员通常彼此依赖、它们编译出的产物重叠;每个 crate 一个目录,会迫使每个 crate 把每个依赖都重新编译进它自己的文件夹。一个 Cargo.lock 存在,是为了让成员保持兼容:把 rand 同时加进 adder 和 add_one,解析出记录一次的单个版本,使每个成员都针对同样的代码编译,并附带声明的限度:真正不兼容的要求会各自解析,而 Cargo 仍把存在的版本数量降到最少。
制衡,与收尾。 作者随后立刻把这个共享的解析,掉头对准那个显而易见的错误推断。共享的版本不是共享的权限。在 adder/src/main.rs 里加 use rand;,而 rand 在工作空间的别处在用,会产生 error[E0432]: unresolved import 'rand';把 rand 列进 adder 自己的 Cargo.toml 就修好了,而且什么新东西也不会下载,因为版本已经解析好了。展示失败,而不只是警告它,才使规则牢牢记住。本章接着从构建,向外拓宽回消费:用 cargo install 装来自 crates.io 的二进制,以及 Cargo 从你的路径里拾取的自定义 cargo-* 子命令。弧线圆满了——它开头是在你自己的一个 crate 上调整 Cargo 的行为,结尾是 Cargo 被别人写的 crate 所扩展。
关键论点,以及支撑它们的原文
编译器的优化级别,应当与你正在做的事相称,这就是 Cargo 默认出厂两个不同配置档的原因。
作者指出,应用更多优化会拉长编译时间,然后展示具体的默认值:cargo build 用的 dev 配置档是 opt-level 0,cargo build --release 用的 release 配置档是 3——每个配置档都可在 Cargo.toml 里单独配置。
“应用更多优化会延长编译时间,所以如果你在开发中、经常编译代码,你会想要更少的优化来更快地编译,即使产出的代码运行得更慢。”
“Applying more optimizations extends compiling time, so if you’re in development and compiling your code often, you’ll want fewer optimizations to compile faster even if the resultant code runs slower.”
§14.1 Customizing Builds with Release Profiles查看原文 →
把示例写进文档注释是值得的,因为它们自己守着自己的诚实。
承接文档示例会被执行这一点,作者展示了测试输出,列出 add_one 示例的一个通过的文档测试,并指出,改动函数或示例,使 assert_eq! 不再成立,那个测试就会失败。
“在文档注释里加示例代码块,有助于演示如何使用你的库,还有一个额外的好处:运行 cargo test 会把文档里的代码示例当作测试来运行!”
“Adding example code blocks in your documentation comments can help demonstrate how to use your library and has an additional bonus: Running cargo test will run the code examples in your documentation as tests!”
§14.2 Publishing a Crate to Crates.io查看原文 →
一个 crate 的公共 API,应当围绕人们会怎样使用它来塑造,而不是围绕作者碰巧怎样嵌套模块。
作者以 art crate 为案例:在任何重导出之前,用户得写 use art::kinds::PrimaryColor,并在内部层级里挖掘才能找到任何东西;加上 pub use 把这些项提到顶层,使方便的路径可用,而内部结构保持完好。作者补充了提醒:得到一个好 API 是迭代的,与其说是科学,不如说是艺术。
“选择 pub use,让你在如何在内部组织 crate 的结构上有灵活性,并把这种内部结构,与你呈现给用户的东西解耦。”
“Choosing pub use gives you flexibility in how you structure your crate internally and decouples that internal structure from what you present to your users.”
§14.2 Publishing a Crate to Crates.io查看原文 →
另外 3 个论点
一个版本一旦发布到 crates.io,就永远在那里了。
作者的推理从注册表的目的出发:crates.io 是要成为永久的档案,使依赖它的构建继续能用,而允许删除版本会使这不可能。所以提供的唯一补救是 yank,作者指出,它无法撤回不小心上传的秘密。
“Crates.io 的一个主要目标,是充当代码的永久档案,使所有依赖 crates.io 上 crate 的项目的构建都能继续工作。”
“One major goal of Crates.io is to act as a permanent archive of code so that builds of all projects that depend on crates from crates.io will continue to work.”
§14.2 Publishing a Crate to Crates.io查看原文 →
整个工作空间保持单一的锁文件,才使成员 crate 彼此保持兼容。
作者把 rand 加进 adder 和 add_one,并表明 Cargo 把它解析成记录在工作空间单一 Cargo.lock 里的一个版本,所以每个成员都针对同样的代码编译。声明的限度:如果成员真的要求互不兼容的版本,Cargo 会各自解析,但仍把存在的版本数量降到最少。
“让工作空间里的所有 crate 使用相同的依赖,意味着这些 crate 将总是彼此兼容。”
“Making all crates in the workspace use the same dependencies means the crates will always be compatible with each other.”
§14.3 Cargo Workspaces查看原文 →
反过来看,这种共享的解析,并不赋予一个 crate 使用某个依赖的权利——每个 crate 仍须声明它用了什么。
作者直接演示了失败:在 rand 已在工作空间别处使用的情形下,在 adder/src/main.rs 里加 use rand;,会产生 error[E0432]: unresolved import rand。把 rand 列进 adder 自己的 Cargo.toml 就修好了,而且不会有额外的下载,因为版本已经解析好了。
“不过,即使 rand 在工作空间的某处被使用,我们也不能在工作空间里的其他 crate 里使用它,除非我们也把 rand 加进它们的 Cargo.toml 文件。”
“However, even though rand is used somewhere in the workspace, we can’t use it in other crates in the workspace unless we add rand to their Cargo.toml files as well.”
§14.3 Cargo Workspaces查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么 dev 和 release 的优化不同
更多的优化工作意味着更长的编译时间,两个配置档处在这个取舍的两端。开发要不断重新构建,所以 dev 默认 opt-level 0,使编译尽可能快;发布的二进制很少构建、常常运行,所以默认 3。任一默认值都可以在 Cargo.toml 里按配置档覆盖。
机制 2
文档示例作为真正的测试运行
写在 /// 注释里的示例代码,不只是被渲染进 HTML 文档——cargo test 会把它当作文档测试编译并运行。所以如果函数变了而示例没变(或反过来),示例的断言就会失败,测试运行会把它报出来,这就是文档无法悄悄漂离代码的原因。
机制 3
一个构建文件夹,不再重复重建
工作空间成员通常彼此依赖,所以它们编译出的产物重叠。因此 Cargo 把所有输出放进工作空间根目录下的单个 target 目录,而不是每个 crate 一个;否则每个 crate 都得把它依赖的每个 crate,再一次全部编译进自己的文件夹。
常见误读
如果你发布了有问题的东西,或不小心泄露了密钥,你可以撤回(yank)那个版本,实际上就撤销了这次上传。
yank 只阻止新项目把那个版本当作依赖;代码本身从不被移除,锁文件已经钉住它的项目仍能构建,而你上传的任何秘密仍在外面,必须轮换。yank 还可以用 --undo 撤销,这进一步证明它什么也没删。
“撤回(yank)一个版本,会阻止新项目依赖该版本,同时允许所有已经依赖它的现有项目继续。”
“Yanking a version prevents new projects from depending on that version while allowing all existing projects that depend on it to continue.”
§14.2 Publishing a Crate to Crates.io查看原文 →
既然工作空间共享一个锁文件、每个依赖解析出一个版本,那么一旦任何一个成员引入了某个 crate,其他成员也就能导入它。
共享的解析涵盖的是版本,不是权限:每个成员 crate 仍需要把依赖写进自己的 Cargo.toml,作者也展示了不这样做会得到的未解析导入错误。不过加上这一行很便宜——版本已经解析好了,意味着什么新东西也不会下载。
“不过,即使 rand 在工作空间的某处被使用,我们也不能在工作空间里的其他 crate 里使用它,除非我们也把 rand 加进它们的 Cargo.toml 文件。”
“However, even though rand is used somewhere in the workspace, we can’t use it in other crates in the workspace unless we add rand to their Cargo.toml files as well.”
§14.3 Cargo Workspaces查看原文 →
第 14 章的核心概念
- 发布配置档(release profile)
- 具名、可定制的编译配置:dev 用于 cargo build,release 用于 cargo build --release。
- 适用处: 每个配置档独立配置;除非在 Cargo.toml 里覆盖,否则默认值生效。
- 文档注释
- 写在某一项之前的 /// 注释,经 cargo doc 生成 HTML 文档,并作为测试运行。
- 适用处: 描述公共 API 的用法,而不是实现细节。
- 内含项注释
- //! 注释,为包含它的项写文档,通常是 crate 根或某个模块。
- 适用处: 用在 src/lib.rs 或某个模块里,而不是写在后面某一项之前。
- 用 pub use 重导出
- 在第二个更方便的位置把某一项变成公有,使公共 API 不必照搬内部模块。
- 适用处: 内部结构仍然可用;两条路径都行。
- yank(撤回)
- 给一个已发布的版本打上标记,使新项目不能依赖它,而现有锁文件仍能继续工作。
- 适用处: 不会删除代码或泄露的秘密;可以用 --undo 撤销。
- 工作空间
- 一个顶层目录,其 Cargo.toml 列出一起构建的成员包。
- 适用处: 成员必须列在 [workspace] 的 members 键里;每个 crate 仍需要自己的 Cargo.toml。
另外 4 个概念
- 共享的 target 目录
- 工作空间根目录下的一个编译产物文件夹,而不是每个 crate 一个。
- 适用处: 即使从某个成员目录里运行 cargo build,也适用。
- 单一的 Cargo.lock
- 整个工作空间一个锁文件,每个依赖只钉一个版本。
- 适用处: 一个 crate 除非在自己的 Cargo.toml 里列出某个依赖,否则仍不能使用它。
- cargo install
- 把来自 crates.io 的二进制 crate,装进安装根目录的 bin 文件夹,供本地使用。
- 适用处: 只对有二进制目标的包有效;不能替代系统包。
- 自定义 Cargo 子命令
- $PATH 里任何名为 cargo-something 的二进制,都可以当作
cargo something运行。 - 适用处: 要求该二进制在 $PATH 上,并以 cargo- 前缀命名。
第 14 章划线与批注AI 陪读
“你只会以发布模式编译一次,但会多次运行编译出的程序”
“You’ll only compile in release mode once, but you’ll run the compiled program many times”
查看原文 →整个 opt-level 默认值,都从一个不对称里推出来:编译多、要的是便宜,运行多、要的是快。好在书给出了理由,而不是一个数字。
“但没有什么比因为代码变了而不再管用的示例更糟了”
“But nothing is worse than examples that don’t work because the code has changed”
查看原文 →文档测试是本章最锐利的想法:会腐烂的文档,被测试运行器逼着保持诚实。文档成了一笔你无法忽视的债务。
“你不必重新安排内部的组织:相反,你可以重导出这些项”
“you don’t have to rearrange your internal organization: Instead, you can re-export items”
查看原文 →pub use 其实是一个解耦的动作——内部的分类给维护者,扁平的表面给用户。和外观模式是同一种本能。
另外 12 条批注
“创建一个有用的公共 API 结构,与其说是科学,不如说是艺术”
“Creating a useful public API structure is more an art than a science”
查看原文 →对一本语言书来说,这是不寻常的诚实。这里别的一切都有正确答案;API 的形状没有。
“允许删除版本,会使实现那个目标变得不可能。”
“Allowing version deletions would make fulfilling that goal impossible.”
查看原文 →永久性是一次有意的取舍:每个下游构建都继续能用,而你永远无法收回一个错误。left-pad 是这一段里的幽灵。
“例如,它无法删除不小心上传的秘密。”
“It cannot, for example, delete accidentally uploaded secrets.”
查看原文 →直白而受欢迎——yank 是路由的改变,不是抹除。值得一提的是,本章用不同的话说了两遍,一次关于令牌,一次关于代码。
“每个 crate 都得重新编译工作空间里其他每个 crate”
“each crate would have to recompile each of the other crates in the workspace”
查看原文 →共享的 target 不是为了整洁,而是缓存复用。这个解释为一个否则看起来令人意外的布局辩护。
“Cargo 并不假定工作空间里的 crate 会彼此依赖”
“Cargo doesn’t assume that crates in a workspace will depend on each other”
查看原文 →同处一地不是依赖。让依赖图保持显式,而不是随环境而来——与 Rust 一贯拒绝推断关系的做法一致。
“这确保了所有 crate 都使用所有依赖的同一个版本。”
“This ensures that all crates are using the same version of all dependencies.”
查看原文 →单一的锁文件,就是工作空间全部的哲学要点——一份一致性保证,靠一个文件买来。这里其余的一切都是管道。
“我们不能在工作空间里的其他 crate 里使用它,除非我们也把 rand 加进它们的 Cargo.toml 文件”
“we can’t use it in other crates in the workspace unless we add rand to their Cargo.toml files as well”
查看原文 →漂亮的不对称:共享的是版本,不共享的是可见性。锁文件是公有的,清单保持私有——依赖声明仍是一种同意的行为。
“Cargo 会解析它们每一个,但仍会尽量解析出尽可能少的版本。”
“Cargo will resolve each of them but will still try to resolve as few versions as possible.”
查看原文 →一次安静的承认:上面的承诺有限度。本章卖的是统一性,然后又承认它只是尽力而为。
“最后一节表明,在 add_one crate 里一个文档测试都没有找到”
“the last section shows that zero documentation tests were found in the add_one crate”
查看原文 →靠空输出来教学——带读者走过三段什么都没发生的小节。乏味,但这是你学会读懂 cargo 的布局、而不是怕它的办法。
“工作空间里的每个 crate 都需要单独发布”
“each crate in the workspace will need to be published separately”
查看原文 →工作空间是开发时的一个虚构。Crates.io 从来看不到它。在你围绕它设计十个 crate 的单体仓库之前,值得知道这一点。
“如果你的 $PATH 里有一个名为 cargo-something 的二进制,你就可以把它当作 Cargo 子命令来运行”
“If a binary in your $PATH is named cargo-something, you can run it as if it were a Cargo subcommand”
查看原文 →靠命名约定扩展——没有插件 API,没有注册表,只有一个前缀。最便宜的可扩展性,也是最耐久的。
“Rust 的标准库小而稳定,但 crate 很容易分享”
“Rust’s standard library is small and stable, but crates are easy to share”
查看原文 →收尾那句把一个限制重新框定为一个策略:std 库保持单薄,是因为 crates.io 可以走得更快。整章都是在为这桩交易辩护。
读后感AI 陪读
看出那些不对称之后,Cargo 的规则终于说得通了
这一章是 Rust 不再是一门语言、开始成为一个生态的地方。它从一件几乎小得微不足道的事开始——发布配置档,opt-level 0 对 3——但书费心解释了为什么:你编译一次,却运行许多次,所以两种配置档优化的是一种不对称的两端。这一步定下了基调。接下来几乎每条 Cargo 的约定,在你看出它由之而来的不对称或取舍之前,都显得任意,而本章真正的工作,是让这些变得可见,而不是罗列命令。
从这里起,它一圈圈向外走。先是文档:/// 注释、Markdown 小节,以及那个悄悄漂亮的事实——文档示例会被编译并作为测试运行。这是本章最锐利的想法——会腐烂的文档,由测试运行器守着不敢作假,它把文档从一件你许诺要更新的事,变成一件你不更新就会让构建失败的事。然后是公共 API 的形状,在那里 pub use 让内部的分类和外部的表面各走各的:维护者保留自己的嵌套,用户得到一个扁平的外观。书在这里承认,好的 API 结构“与其说是科学,不如说是艺术”,在一本别的一切都有正确答案的文本里,这是一个不寻常的让步,正因为罕见,才值得信任。
继续阅读
发布那一节,是赌注转向社会性的地方。crates.io 上的版本,设计上就是永久的——删除会弄坏每一个依赖它们的下游构建——所以 cargo yank 只阻止新的依赖者,书直白地说,它无法撤回一个泄露的秘密。永久性被当作特性来卖,它也确实是,但它是一桩交易:下游构建永不损坏,而你永远无法收回一个错误。left-pad 事件是那一段里没点名的幽灵。
工作空间那一节最长,也最容易被误读。工作空间是三样东西:一个共享的 target 目录(是缓存复用,不是整洁——否则每个成员都要重新编译其他成员),一个 Cargo.lock(使所有成员解析出同样的依赖版本),以及 -p 标志,用来分别指定成员。注意它不是什么:同处一地不是依赖,所以路径依赖必须显式声明;共享的锁文件也不共享可见性——每个成员仍要在自己的清单里列出 rand 才能使用。共享的是版本,不共享的是同意。本章甚至悄悄承认:如果成员要求互不兼容的版本,Cargo 会解析出多个,只是尽量把数量减到最少——统一性的承诺是尽力而为。最深的实践要点出现在接近结尾处,几乎是顺带一提:工作空间的成员各自发布,而 crates.io 根本看不到工作空间。它是开发时的一个虚构。在你假定十个 crate 的单体仓库是一个整体、去设计它之前,要知道这一点。
最后一段几乎是轻松的——cargo install 把二进制 crate 装进 ~/.cargo/bin,以及靠命名约定实现的可扩展性:PATH 上任何 cargo-something,都会变成 cargo something。没有插件 API,没有注册表,只有一个前缀。它是现有最便宜的扩展机制,也大概是最耐久的,因为没有什么需要版本化或弃用。
教学风格有一个值得点名的怪癖:它花了真实的篇幅在什么都没发生的输出上——三段测试,零个文档测试被找到。读起来乏味,但这是有意的,也是你不再害怕 Cargo 的输出、开始读它的办法。
我会对读者说的:本章以这样一句收尾——Rust 的标准库小而稳定,是因为 crates.io 很容易分享进去,这句话追溯地解释了此前的一切。文档测试、永久的版本、显式的依赖声明、单一的锁文件——这些是让一个单薄的 std 得以存活的基础设施。本章读起来像一次工具游览,实际上是在为语言已经替你做下的一桩交易辩护。这样读,那些约定就不再像琐事了。
在 FeedLitra 里读第 14 章,每段旁边都有这些卡片。
开始读书第 15 章:智能指针
Rc<T> 如何追踪多个所有者,并在没有所有者剩下时清理?
第 15 章是怎样展开的AI 辅助
开篇:重新定义指针可以是什么。 本章从读者已经知道的东西——普通的引用,一个借来的地址,别无其他——开始,并把它用作衬托。智能指针,它论证说,有两点不同:它们通常拥有它们指向的数据,并且通过两个 trait 携带额外的行为,Deref(使它们可以像引用一样与 * 一起使用)和 Drop(给它们清理代码)。有了这个框架,Box<T> 作为最小的情形到来:它增加堆分配和一层间接引用,别无其他。回报立刻在 cons list 中到来。一个直接持有 List 的 Cons 变体无限嵌套,所以编译器无法计算大小,并以 E0072 拒绝这个类型,称之为“无限大”;把变体改写为 Cons(i32, Box<List>) 则能编译,因为无论指向什么,指针的大小都是固定的,而这个值现在花的是一个 i32 加一个指针大小的空间。这是本章第一个真正的主张——堆上的间接引用,才使递归数据在 Rust 里根本可以表达——它也为它预告的议程做了铺垫:Rc<T>、RefCell<T>、内部可变性和引用循环。
展开:两个 trait,然后是共享所有权。 中间一段填补了机制。对于 Deref,关键的动作是机械的:编译器把 *y 改写成 *(y.deref()),替换一次而不是递归,同样的机制在函数和方法的调用处自动运行,把 deref 调用链起来,直到实参与形参类型匹配——这就是为什么 &MyBox<String> 可以交给期望 &str 的东西,无运行时代价,对可变的情形有文档化的限度。Drop 通过 CustomSmartPointer 来呈现,它在每个值离开作用域时打印一句告别,哪里都没有手动调用,而它的变量按创建的相反顺序被丢弃;那句打印代表一个类型所需要的任何真实的清理,而要点是自动插入意味着释放恰好发生一次,没有簿记,也没有重复清理的机会。Rc<T> 然后回答 Box<T> 无法回答的一个情形:用盒子构建共享的 cons list 失败,因为 Cons 拥有它的内容,而列表 a 被移进了 b。换成 Rc::clone,让 b 和 c 都指向 a,克隆只是递增一个计数器,而不是深拷贝,而打印出的强引用计数攀升 1、2、3,并在内层作用域结束时回落到 2,使真正的理由变得可见:引用计数恰恰适用于编译器无法知道哪个所有者最后结束的那些情形。针对常见的误读,值得指出——Rc<T> 只发出不可变的引用,而且是单线程的。
转折:拿编译期的证明换运行时的检查。 坚持借用检查是编译期的保证之后,本章限定了它自己的立场。静态分析必然是保守的:分析器必须拒绝它无法证明正确的程序,停机问题是分析所能判定的东西的经典界限。RefCell<T> 是为那些你看得出正确、而编译器看不出的代码准备的逃生口——同样的借用规则,在程序运行时执行。用途是具体的:必须通过一个 &self 方法记录调用的模拟对象,以及用于有多个所有者、却仍能改变的数据的 Rc<RefCell<T>>——通过 borrow_mut 加 10,使列表 a、b 和 c 都报告 15。但账单在伴随的例子里到期,它干净地编译,然后测试失败,伴着一个报告 RefCell 已经被可变借用的 panic。这笔交易是明确的:用 panic 代替编译器错误,外加一点运行时代价。
收尾:泄漏,以及 Weak<T> 作为设计好的答案。 最后一个乐章反转了前一节的收益。产生了共享可变数据的恰恰那种组合,也使泄漏成为可能,而 Rust 不会抓住它们。作者通过让 a 指向 b、同时 b 指向 a,把工具对准了读者:两个强引用计数都停在 1,什么也不会被释放,而打印这个结构会使栈溢出。Rust 没有循环检测——泄漏内存是内存安全的,只是不在保证所排除的事情之内——所以循环是留给测试和代码评审去抓的逻辑 bug,而不是借用错误;在这个小程序里,后果之所以轻微,只因为进程立刻退出。修复是结构性的,而不是侦探式的:让双向链接的一个方向拥有,另一个只是引用。Rc::downgrade 抬高一个单独的弱引用计数,它对值是否被丢弃没有发言权,所以在树的例子里,父级通过 Rc 拥有它的子级,而每个子级用 Weak 指回去,因为丢弃一个子级不应该把父级带走。计数确认了这个设计——在作用域内,branch 显示一个强引用、一个弱引用,leaf 两个强引用、没有弱引用——而且因为 upgrade 返回 Option<Rc<T>>,编译器迫使已被丢弃的情形得到处理,没有办法去跟随一个悬垂指针。于是本章结束在它开始的地方,所有权,但已经表明,拥有、共享、改变,和仅仅观察,是四项不同的工作,需要四种不同的工具。
关键论点,以及支撑它们的原文
通过 Box<T> 的堆上间接引用,才使递归数据类型在 Rust 里根本可以表达。
作者先展示了一个 cons list,其 Cons 变体直接持有 List:它编译失败,报错误 E0072,编译器称这个类型无限大。把变体改写为 Cons(i32, Box<List>) 则能编译,而这个值现在只花一个 i32 加盒子的指针数据。
“因为 Box<T> 是一个指针,Rust 总是知道一个 Box<T> 需要多少空间:指针的大小不会因它所指向的数据量而改变。”
“Because a Box<T> is a pointer, Rust always knows how much space a Box<T> needs: A pointer’s size doesn’t change based on the amount of data it’s pointing to.”
§15.1 Using Box<T> to Point to Data on the Heap查看原文 →
因为 Rust 自动插入清理代码,资源恰好被释放一次,而程序员不必做任何簿记。
CustomSmartPointer 的例子在每个值离开作用域时打印它自己的告别信息,哪里都没有手动调用,而且变量按创建的相反顺序被丢弃。作者指出,因此你不可能意外地对同一个值清理两次。那句打印代表一个类型所需要的任何真实的清理逻辑。
“你也不必担心意外清理仍在使用的值所导致的问题:确保引用始终有效的所有权系统,也确保当值不再被使用时,drop 只被调用一次。”
“You also don’t have to worry about problems resulting from accidentally cleaning up values still in use: The ownership system that makes sure references are always valid also ensures that drop gets called only once when the value is no longer being used.”
§15.3 Running Code on Cleanup with the Drop Trait查看原文 →
引用计数恰恰是在编译器无法知道哪个所有者最后结束时的正确工具。
用 Box<T> 构建同样的共享 cons list 失败,因为 Cons 变体拥有它的内容,而列表 a 被移进了 b;换成 Rc::clone,让 b 和 c 都指向 a,克隆只是递增一个计数器,而不是深拷贝。打印出的强引用计数攀升 1、2、3,并在内层作用域结束时回落到 2,表明数据恰好活到仍有人持有它为止。
“当我们想在堆上分配一些数据,供程序的多个部分读取,而我们无法在编译期确定哪个部分最后用完这些数据时,我们使用 Rc<T> 类型。”
“We use the Rc<T> type when we want to allocate some data on the heap for multiple parts of our program to read and we can’t determine at compile time which part will finish using the data last.”
§15.4 Rc<T>, the Reference Counted Smart Pointer查看原文 →
另外 3 个论点
确立了借用检查是编译期的保证之后,作者随后限定了它:静态分析本质上是保守的,所以 RefCell<T> 的存在,是为了把同样的规则移到运行时,代价是 panic 而不是编译器错误。
这个逃生口的论证是,静态分析器必须拒绝它无法证明正确的程序,停机问题是分析无法判定的东西的经典案例;RefCell<T> 是为那些你看得出正确、而编译器看不出的代码准备的。账单在伴随的例子里到期,它干净地编译,然而测试失败,伴着一个说 RefCell 已被可变借用的 panic,外加一点运行时代价。
“当你确信你的代码遵循借用规则、而编译器无法理解并保证这一点时,RefCell<T> 类型很有用。”
“The RefCell<T> type is useful when you’re sure your code follows the borrowing rules but the compiler is unable to understand and guarantee that.”
§15.5 RefCell<T> and the Interior Mutability Pattern查看原文 →
反转前一点,产生共享可变数据的恰恰那种组合,也使内存泄漏成为可能,而 Rust 不会抓住它们。
把 RefCell 包进 Rc,得到有多个所有者、却仍能改变的数据:通过 borrow_mut 加 10,使列表 a、b 和 c 都报告 15。作者随后通过让 a 指向 b、同时 b 指向 a,把同样的工具对准读者;两个强引用计数都停在 1,什么也不会被释放,而打印这个结构会使栈溢出。Rust 不提供循环检测,所以避免这一点是程序员的工作,最好靠测试和代码评审来处理。在这个小程序里,泄漏的后果之所以轻微,只因为进程立刻退出。
“如果你有一个持有 RefCell<T> 的 Rc<T>,你就能得到一个可以有多个所有者、而且你能改变的值!”
“If you have an Rc<T> that holds a RefCell<T>, you can get a value that can have multiple owners and that you can mutate!”
§15.5 RefCell<T> and the Interior Mutability Pattern查看原文 →
循环是靠设计而不是靠检测来预防的:让双向链接的一个方向拥有,另一个只是引用。
在树的例子里,父节点通过 Rc 拥有它的子节点,而子节点用 Weak 指回父节点,因为丢掉一个子节点不该把父节点一起带走。计数印证了这一点:在作用域内,branch 有一个强引用和一个弱引用,leaf 有两个强引用、没有弱引用,而 leaf.parent 的那个弱引用对 branch 会不会被丢弃毫无影响,所以作用域结束时不会泄漏。因为 upgrade 返回的是 Option<Rc<T>>,编译器会强制你处理“已经被丢弃”的情形,也就不可能顺着悬空指针走下去。
“然而,子节点不应该拥有它的父节点:如果我们丢掉一个子节点,父节点仍然应该存在。”
“However, a child should not own its parent: If we drop a child node, the parent should still exist.”
§15.6 Reference Cycles Can Leak Memory查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
盒子固定了递归类型的大小
一个直接包含它自己的类型无限嵌套,所以编译器无法计算它的大小,并拒绝它,称之为无限大。把内部的值放在 Box<T> 后面,以一个指针取代了这种嵌套,其大小始终相同,所以外层的类型成了一个整数加一个指针大小的空间。
机制 2
* 如何作用于你自己的类型
当一个类型实现 Deref 时,编译器把每个 y 改写成 (y.deref()),替换一次而不是永远递归。同样的机制在函数和方法的参数处自动运行,把 deref 调用链起来,直到引用与期望的参数类型匹配,这就是为什么 &MyBox<String> 可以传到想要 &str 的地方。
机制 3
两个计数器决定何时释放
只有当强引用计数降到零时,Rc<T> 才释放它的堆上的值,所以两个彼此持有对方的 Rc 句柄的值,让彼此的计数永远停在一,内存从未被释放。Rc::downgrade 则抬高一个单独的弱引用计数,它对值是否被丢弃没有发言权,所以一个反向引用可以存在,而不把数据钉在活着的状态。
常见误读
既然 Rc<T> 让一个值有了好几个所有者,那么这些所有者中的任何一个都可以去修改共享的数据。
Rc<T> 只给出不可变引用,所以共享数据默认是只读的;要修改,就得把它和 RefCell<T> 搭配使用,由后者在程序运行时检查借用规则。作者还指出,Rc 只能用于单线程,而且 Rc::clone 只是把计数加一,并不会复制数据。
“注意,Rc<T> 只用于单线程场景。”
“Note that Rc<T> is only for use in single-threaded scenarios.”
§15.4 Rc<T>, the Reference Counted Smart Pointer查看原文 →
Rust 的安全保证意味着编译器会拦住你造成内存泄漏,所以引用循环会像借用错误一样被抓出来。
在 Rust 里,泄漏内存是内存安全的,并不在它所保证排除的事情之列;一圈 Rc 值构成的循环照样能编译通过,只是计数永远到不了零。这是一种逻辑错误,要靠测试、代码审查和有意识地使用 Weak<T> 去抓,而不是靠编译器。
“完全防止内存泄漏并不在 Rust 的保证之列,也就是说,内存泄漏在 Rust 里是内存安全的。”
“Preventing memory leaks entirely is not one of Rust’s guarantees, meaning memory leaks are memory safe in Rust.”
§15.6 Reference Cycles Can Leak Memory查看原文 →
第 15 章的核心概念
- 智能指针
- 一种表现得像指针、通常却拥有其数据,并通过 Deref 和 Drop 增加能力的结构体。
- 适用处: 并非每个持有指针的结构体都够格;这个模式取决于实现 Deref 和 Drop。
- Box<T>
- 一种把值存储在堆上、只在栈上留下一个指针的智能指针。
- 适用处: 除了间接引用和堆分配之外不增加任何能力,所以把一个孤零零的 i32 放进盒子里,很少是值得的。
- 递归类型
- 一种可以包含另一个它自己的值的类型,这使它的编译期大小在没有间接引用时无从得知。
- 适用处: Rust 拒绝直接递归;必须用指针来打断这条链。
- 解引用强制转换
- Rust 自动把对实现了 Deref 的类型的引用,转换成对其目标类型的引用。 编译器自动反复调用 deref,使引用与函数期望的参数类型匹配。
- 适用处: 只适用于函数和方法的参数,也只适用于实现了 Deref 的类型。 只在实现了 Deref/DerefMut 的地方管用;不可变引用永远不会强制转换成可变引用。
- drop
- 你通过 Drop trait 实现的代码,Rust 在值离开作用域时自动运行它。
- 适用处: 不能手动调用;用 std::mem::drop 来强制提前清理。
- Rc<T>
- 一种引用计数的指针,让一个堆上的值有多个所有者,在计数降到零时被释放。
- 适用处: 只限单线程,并通过不可变引用共享数据以供读取。
另外 5 个概念
- 内部可变性
- 一种模式,让你即使在不可变引用之后也能改变数据,用包在安全 API 里的 unsafe 代码实现。 改变一个外部代码视为不可变的值内部的数据。 一个单线程的容器,其借用在程序运行时被计数和检查。
- 适用处: 只有当借用规则在运行时得到保证时才安全;违反会 panic,而不是编译失败。 不绕过借用规则;它们改为在运行时被强制执行。 只限单线程;Mutex<T> 是第16章里讨论的线程安全版本。
- 测试替身
- 在测试期间用作替身的类型,就像演员的替身。
- 适用处: Rust 没有内置的模拟对象;你要自己写一个结构体。
- 内存泄漏
- 因为计数永远降不到零而从不被清理的内存。 两个 Rc 值彼此指向,所以两者的强引用计数都永远降不到 0,泄漏了堆内存。
- 适用处: 内存泄漏在 Rust 中是内存安全的,其保证并不阻止它。 Rust 不会抓住它;它是一个留给测试和代码评审去抓的逻辑 bug。
- Weak<T>
- 一种从 Rc::downgrade 得到的、不拥有所有权的智能指针;它递增 weak_count,而不是 strong_count。
- 适用处: 不能直接使用;你必须调用 upgrade 并处理 None,因为值可能已经被丢弃。
- weak_count
- Rc<T> 上的第二个计数器,追踪存在多少个 Weak<T> 引用。
- 适用处: 它不需要为 0,Rc<T> 实例就能被清理。
第 15 章划线与批注AI 陪读
“引用只借用数据,而在许多情况下,智能指针拥有它们所指向的数据。”
“While references only borrow data, in many cases smart pointers own the data they point to.”
查看原文 →这是 Rust 里真正的分界线,而书把它埋在一个短段落里。在这里,是所有权,而不是元数据,使一个指针“智能”。
“cons list 在 Rust 中不是一个常用的数据结构。”
“The cons list isn’t a commonly used data structure in Rust.”
查看原文 →令人耳目一新的诚实:教学示例承认自己是个玩具。代价是读者可能连同结构一起,把教训也打发掉。
“recursive type `List` has infinite size”
查看原文 →注意本章的诀窍:编译器错误就是老师。文字只是叙述 rustc 已经说过的,包括修复办法。
另外 17 条批注
“这个实现现在更像是把各项并排放置,而不是一个套在另一个里面”
“this implementation is now more like placing the items next to one another rather than inside one another”
查看原文 →到目前为止最好的一句话。间接引用被重新框定为空间上的——嵌套变成了相邻,无穷就塌缩了。
“我们的版本不会把它的数据存储在堆上。”
“Our version will not store its data on the heap.”
查看原文 →坦率地承认 MyBox 只是名字上的盒子。它干净地隔离了 Deref,却悄悄地教着,“智能指针”是关于行为的,而不是位置。
“值将被从 self 里移出”
“the value would be moved out of self”
查看原文 →这是 Deref 不再是语法糖、撞上所有权的地方。括号外面多出来的 * 不是冗余——而是借用检查器透出来了。
“所以利用解引用强制转换,没有运行时惩罚!”
“so there is no runtime penalty for taking advantage of deref coercion!”
查看原文 →零成本的说法落地了,但值得注意那个不是运行时的隐藏成本:可读性。强制转换让类型在你底下悄悄变换。
“把不可变引用转换成可变引用,要求最初的不可变引用是对该数据唯一的不可变引用”
“Converting an immutable reference to a mutable reference would require that the initial immutable reference is the only immutable reference to that data”
查看原文 →这个不对称是缩影中的整个借用系统:你总是可以削弱一个保证,永远无法凭空制造一个。
“你仍然不会泄漏资源!”
“you still won’t leak resources!”
查看原文 →欢快,但夸大了——Drop 在作用域退出时运行,而引用循环(这一章本身就会引入)欣然泄漏。书在开一张 Rc/RefCell 会退回的支票。
“变量按创建的相反顺序被丢弃,所以 d 在 c 之前被丢弃。”
“Variables are dropped in the reverse order of their creation, so d was dropped before c.”
查看原文 →栈的纪律在可观察的输出中浮现。悄悄地,这就是析构函数可以安全地引用在它们之前创建的东西的原因。
“Rust 不让我们显式调用 drop,因为 Rust 仍会在 main 的末尾自动对该值调用 drop。”
“Rust doesn’t let us call drop explicitly, because Rust would still automatically call drop on the value at the end of main.”
查看原文 →优雅:这个禁令不是说教式的,而是算术。而 drop(c) 之所以管用,只因为它取得了所有权——移动才是取消之后调用的东西。书从未把这一点完全大声说出来。
“把 Rc<T> 想象成家庭房里的一台电视机。”
“Imagine Rc<T> as a TV in a family room.”
查看原文 →这个类比是温暖的,却有漏洞——电视机没有“最后一个人必须离开”的概念。不过,“骚乱”是对释放后使用的一个不错的名字。
“我们就是在指明,list 中的每个元素都至少活得和整个 list 一样久”
“we would be specifying that every element in the list will live at least as long as the entire list”
查看原文 →这才是使用 Rc 的真正论证:不是生命周期失败了,而是它们要求你对程序结构做出一个你不总能做出的承诺。
“Rust 的约定是在这种情形下使用 Rc::clone”
“Rust’s convention is to use Rc::clone in this case”
查看原文 →一个纯粹为了让成本对读者清晰可读而存在的约定。难得的例子,风格规则服务于性能评审而不是美学。
“我们不必调用一个函数来减少引用计数”
“We don’t have to call a function to decrease the reference count”
查看原文 →在这里,本章的两个 trait 终于咬合了——Drop 是 Rc::clone 看不见的另一半。按这个顺序给它们排序,是个不错的结构性回报。
“这个模式在数据结构内部使用 unsafe 代码,来弯曲 Rust 通常的规则”
“the pattern uses unsafe code inside a data structure to bend Rust’s usual rules”
查看原文 →Rust 中的安全原来不是 unsafe 的缺席,而是它的封闭——有人审计过一次,这样你就永远不必审计。
“如果 Rust 拒绝一个正确的程序,程序员会感到不便,但不会发生任何灾难性的事情”
“if Rust rejects a correct program, the programmer will be inconvenienced, but nothing catastrophic can occur”
查看原文 →错误的不对称被直白地陈述——这是编译器的整个伦理,比旁边点名停机问题更有价值。
“Messenger trait 有一个名为 send 的方法,它接受对 self 的不可变引用”
“the Messenger trait has one method called send that takes an immutable reference to self”
查看原文 →陷阱在这里设下,而书知道:&self 对真正的发送者是正确的,对记录器则是不可能的。RefCell 的用例,是作为一个你无法控制的接口而到来的。
“我们不想仅仅为了测试而改动 Messenger trait。”
“We do not want to change the Messenger trait solely for the sake of testing.”
查看原文 →真正的约束是设计纪律,而不是编译器。注意书悄悄拒绝了编译器自己的建议——难得一见的时刻,工具对意图是错的。
“代码带着信息 already borrowed: BorrowMutError 而 panic 了”
“the code panicked with the message already borrowed: BorrowMutError”
查看原文 →上面的实际输出说的是“RefCell already borrowed”——文字和记录不一致。工具链信息变化留下的一道接缝。
读后感AI 陪读
Rust 的智能指针阶梯,每一级都向你收一笔费
本章是 Rust 不再是一门规则的语言、而成为一门取舍的语言的一章,它靠跨四个乐章建立一个单一的论证,而不是编目四种类型,来赢得这种转变。它从正确的区分开篇——智能指针通常拥有它们指向的东西,而引用只是借用——而值得一提的是,这条分界线得到的是一句话而不是一节,因为整章真正转而依赖的,是所有权,而不是任何额外的元数据。从那里起,这架梯子是有意的:Box<T> 买到间接引用和可知的大小,Deref 买到假装盒子不在那里的人体工学,Drop 买到你从不安排的清理,Rc<T> 买到共享的所有权,RefCell<T> 以运行时检查为代价买到可变性,而 Weak<T> 买回 Rc 悄悄失去的正确性。每一级都花了前一级没有花的东西,而本章足够诚实,一路上点出了大部分的价格。
教学法值得看穿。有两次,书把教学交给了编译器——recursive type List has infinite size 是真正的老师,文字讲述 rustc 的诊断及其建议的修复——而本章最好的一行,是随之而来的空间上的重新框定:盒子使 list 把各项并排而不是一个套一个地放置,无穷就这样塌缩了。那是最好的解释。但这些例子也坦率地是假的,书这么说了:cons list 公认不是 Rust 程序员使用的结构,MyBox 根本不在堆上放任何东西。两个承认都买到了对每个概念的干净隔离,代价是读者可能连同玩具一起,把教训也扔掉。尤其是 MyBox,教的是本章从未直接说出的东西——“智能指针”命名的是一组行为,而不是内存中的一个位置。
继续阅读
本章最尖锐的地方,在它的不对称,而它们彼此押韵。解引用强制转换不会从 & 制造出 &mut,因为你总是可以削弱一个保证,却永远无法凭空变出一个——缩影中的借用系统。编译器自己的伦理是同样的形状,被直白地陈述,比旁边那个停机问题的旁白有用得多:拒绝一个正确的程序只是给程序员带来不便,而接受一个不正确的程序则是灾难性的,所以保守不是学究气,而是算术。禁止手动调用 drop 也是算术——Rust 会在作用域结束时再次运行它——而 drop(c) 之所以管用,只因为取得所有权移动了这个值,从而取消了之后的调用。书展示了这一点,却从未完全说出来。同样,Drop 和 Rc 在后面咬合得很好:你从不手动减少计数,因为 Drop 是 Rc::clone 看不见的另一半,这是按这个顺序而不是按字母顺序呈现这两个 trait 的一个真正的结构性回报。
我要指出两处接缝。说有了 Drop“你仍然不会泄漏资源”的欢快承诺,是同一章后来要退回的一张支票——引用循环欣然泄漏,而最后一个乐章的存在就是为了收拾这句话之后的残局。而 BorrowMutError 的记录与周围的文字不一致,是工具链信息变化留下的小小伤疤,主要用来提醒我们,书里的输出比论证老得更快。把 Rc 比作家庭房里电视机的类比是温暖的,但在同一个方向漏了:电视机不给最后离开的人强加义务,而这恰恰是引用计数存在要去强制执行的义务。
我要对读者说的是:求助于 Rc<T> 的理由,不是生命周期让你失望了,而是生命周期标注要求你对程序结构做出一个承诺——每个元素都活得比整个 list 长——而你有时确实做不出这个承诺。把 Rc::clone 读作一个为让成本在评审时清晰可读而设计的约定,一条难得服务于性能而不是品味的风格规则。把 RefCell<T> 当作用于你无法控制的接口的工具:模拟对象的例子是诚实的案例,其中 &self 对真正的消息发送者是正确的,对记录器则是不可能的,而书明确拒绝了编译器改动 trait 的建议——设计纪律压倒了一个对类型正确、对意图错误的工具。接受这些类型是由包含在经过审计的数据结构里的 unsafe 代码构建的,这揭示了 Rust 中的安全究竟意味着什么:不是 unsafe 的缺席,而是它的封闭,审计一次,让你永远不必审计。并且记住成本的账本。解引用强制转换在运行时是免费的,读起来却不是免费的,因为类型在你底下悄悄变换。RefCell 把借用错误从编译时间挪到 panic 时间。没有 Weak 的 Rc 把内存安全的保证变成内存泄漏的保证。本章真正的教训是,Rust 的编译期检查是一个默认值,而不是一座牢笼——而每一扇通向其外的门,都清楚地标着你正在放弃什么。
在 FeedLitra 里读第 15 章,每段旁边都有这些卡片。
开始读书第 16 章:无畏并发
Sync 和 Send 如何把这些保证延伸到你自己的类型上?
第 16 章是怎样展开的AI 辅助
开篇:重新定义问题。 本章不是从某个 API 开始,而是从一个主张开始:并发的安全从哪里来。Rust 把它看作读者已经知道的两样东西的延伸——所有权和类型检查——于是很大一部分并发错误不再是运行时现象,而成了编译错误。作者在乎这件事的理由是实用的,而不是理论的:不正确的并发代码拒绝编译,还会附上一段说明错在哪里,所以 bug 是在写代码的时候就修掉的,而不是很久以后才在生产环境里冒出来。第二个框架性的要点为后面的一切做了结构上的铺垫:因为 Rust 是一门底层语言,它有意提供好几种并发方式,而不是只认一种教义。Erlang 是对照的例子——那里的消息传递很优雅,但对共享状态的支持晦涩难用,这是只押注一种模型要付的代价。这种不选赢家的态度,正是本章后面让通道和共享状态并行登场的依据。
展开一:线程,以及第一课——等待不是可选项。 从框架转到具体:派生线程,然后是 join,然后是 move 闭包。这里的顺序是教学顺序,而不是字母顺序。第一个线程示例被允许出岔子——一个被要求数到 9 的派生线程,在主线程结束并关停一切之前只数到了 5,具体在哪里被截断,取决于操作系统怎样调度线程。修法是对 spawn 返回的句柄调用 join,由此确立:除非你等它,否则派生线程不保证能跑完,甚至不保证能启动。move 闭包接着作为下一个自然的障碍登场:因为派生线程可能比创建它的代码活得更久,编译器无法证明借来的引用在线程的整个生命周期里一直有效,所以直接拒绝这次借用。加上 move,闭包就取得它所捕获内容的所有权,悬空引用的风险随之消除——代价是(作者老实地指出了)原来的作用域不能再使用这个值。
展开二:通道,由所有权来承担安全的工作。 通道是开头那个主张第一次明显兑现的地方。send 移动这个值:使用它的权利被交给接收一方,之后对发送端变量的任何读取,都会被普通的所有权规则当作编译错误抓出来。值得记住的是,这根本不是什么特殊的并发特性——一整类“发送之后又修改”的竞争,变成了编译器给你的一条消息,而不是一个时有时无的运行时 bug。接着文章把这个机制扩展到实际用法:在一条通道上发多条消息,以及克隆出多个生产者去喂一个消费者。
转折:从传递消息到共享内存。 搭完一种模型之后,本章转向另一种,而最有教益的失败就出在这个转折上。共享状态从用于互斥的 Mutex 开始,而显而易见的下一步——伸手去拿 Rc,让几个线程共享所有权——被试了,也被拒了。Rc 更新引用计数时没有任何并发保护,所以两个线程可能把它搞坏;语言表达这种危险的办法,就是干脆不把 Rc 标成 Send,于是编译器拒绝把它移到线程边界的另一侧。Arc 解决了这个问题:接口不变,但原子地更新计数;再把数据包进 Mutex 来串行化访问,两者合起来就得到了能用的共享计数器:Arc<Mutex<T>>。随之而来的有两点提醒。Arc 不只是一个更好的 Rc——原子性要付出性能代价,所以单线程代码仍然应该用 Rc;而且 Arc 只提供共享所有权,这正是跨线程修改必须在它里面嵌一个 Mutex 的原因。接着,Send 和 Sync 这两个标记 trait 被揭示为一直在支撑这一切的东西,随之还有一个值得注意的观察:本章几乎所有的并发特性都来自标准库,只有这两个 trait 是内建在语言里的——这也是为什么外部 crate 可以在同一个基础上再添加并发工具。
收尾:连同限度一起陈述的保证。 本章最后把两条线收进一个工具箱——用通道传递消息,用 Mutex 和 Arc 这类智能指针类型共享状态——并重申回报:类型系统和借用检查器合起来排除了数据竞争和无效引用,所以代码一旦编译通过,你就可以信任它在多个线程上的表现。按这种说法,让并发编程不那么可怕的,是编译期检查,而不是运行时时刻保持警惕。这个承诺的范围需要小心对待,因为它很容易被读过头。它具体涵盖的是数据竞争和无效引用,而不是每一种并发问题。作者自己举的反例把界线画得很清楚:如果一个操作需要两把锁,而两个线程各拿着其中一把,两边就会永远等下去,编译器对此一个字也不会说。编译通过,通过的是安全,不是设计。
关键论点,以及支撑它们的原文
内存安全和并发安全,结果需要的是同样的工具:所有权和类型系统对两者都管用。
作者的理由是,不正确的并发代码干脆拒绝编译,还会附上一段说明错在哪里,所以错误是在写代码的时候就修掉的,而不是很久以后才在生产环境里冒出来。
“随着时间推移,团队发现,所有权和类型系统是一套强大的工具,可以帮助管理内存安全和并发问题!”
“Over time, the team discovered that the ownership and type systems are a powerful set of tools to help manage memory safety and concurrency problems!”
Chapter 16. Fearless Concurrency查看原文 →
因为 Rust 是一门底层语言,它有意提供好几种并发方式,而不是只认一种教义。
作者拿 Erlang 做对照:它的消息传递机制很优雅,但对共享状态的支持晦涩难用——这是只押注一种模型要付的代价。正是这种开放态度,为本章让通道和共享状态并行登场做了铺垫。
“因此,Rust 提供了多种工具,以适合你的情形和需求的任何方式对问题建模。”
“Therefore, Rust offers a variety of tools for modeling problems in whatever way is appropriate for your situation and requirements.”
Chapter 16. Fearless Concurrency查看原文 →
除非你等它,否则派生线程不保证能跑完,甚至不保证能启动。
在作者的第一个线程示例里,派生线程被要求数到 9,却在主线程结束并关停一切之前只数到了 5;对 spawn 返回的句柄调用 join,会让程序等待,线程这才数到了 9。具体在哪里被截断,取决于操作系统怎样调度线程。
“注意,当 Rust 程序的主线程完成时,所有已生成的线程都会被关闭,无论它们是否已经运行完毕。”
“Note that when the main thread of a Rust program completes, all spawned threads are shut down, whether or not they have finished running.”
§16.1 Using Threads to Run Code Simultaneously查看原文 →
另外 3 个论点
这套安全机制大多在标准库里,而不在语言本身。
作者观察到,本章讲到的几乎每一项并发特性都来自标准库,只有 Send 和 Sync 这两个标记 trait 内建在语言里——这意味着外部 crate 可以在同一个基础上再添加并发工具。
“然而,在内建于语言而非标准库的关键并发概念中,有 std::marker 里的 Send 和 Sync 这两个 trait。”
“However, among the key concurrency concepts that are embedded in the language rather than the standard library are the std::marker traits Send and Sync.”
§16.4 Extensible Concurrency with Send and Sync查看原文 →
标准库为两种并发风格都提供了可以跨线程安全使用的构件。
作者点出了具体的部件:用来传递消息的通道,以及用来共享状态的 Mutex、Arc 这类智能指针类型——正是本章依次展开的两条线,现在被当作一个工具箱摆了出来。
“Rust 标准库提供了用于消息传递的通道,以及在并发环境中可以安全使用的智能指针类型,比如 Mutex<T> 和 Arc<T>。”
“The Rust standard library provides channels for message passing and smart pointer types, such as Mutex<T> and Arc<T>, that are safe to use in concurrent contexts.”
§16.4 Extensible Concurrency with Send and Sync查看原文 →
让人不再害怕并发编程的,是编译期的检查,而不是程序运行时的时刻警惕。
作者的论证是:类型系统和借用检查器合起来排除了数据竞争和无效引用,所以代码一旦编译通过,你就可以信任它在多个线程上的表现。这个保证针对的是这几类特定的 bug,而不是每一种可能的并发问题。
“并发编程不再是一个需要害怕的概念:放手去让你的程序并发起来吧,无所畏惧!”
“Concurrent programming is no longer a concept to be afraid of: Go forth and make your programs concurrent, fearlessly!”
§16.4 Extensible Concurrency with Send and Sync查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么线程闭包必须取得所有权
派生线程可能比创建它的代码活得更久,所以编译器无法证明借来的引用在线程的整个生命周期里一直有效,只好直接拒绝这次借用。加上 move,闭包就取得它所捕获内容的所有权,悬空引用的风险随之消除——不过这并不让原来的作用域还能继续使用这个值。
机制 2
发送一个值就是交出使用它的权利
通道的一次发送把值的所有权转交给接收一方,所以发送端的变量此后不再有效。之后对它的任何读取,都会被所有权规则当作编译错误抓出来,这意味着一整类“发送之后又修改”的竞争,变成了编译器给你的一条消息,而不是运行时的一个 bug。
机制 3
为什么 Rc 不行,而 Arc 加 Mutex 行
Rc 更新引用计数时没有任何并发保护,所以两个线程可能把计数搞坏;语言表达这一点的办法,是不把 Rc 标成可发送的,于是编译器拒绝把它移到另一个线程。Arc 接口不变,但原子地更新计数,再把数据包进 Mutex 来串行化访问,于是多个线程就能安全地共享并修改同一个值。
常见误读
只要一个多线程程序能编译通过,它就一定完全没有并发 bug。
这个承诺要窄得多,也具体得多:这些检查排除的是数据竞争和无效引用。逻辑层面的失败仍然可能发生——作者指出,如果一个操作需要两把锁,而两个线程各拿着其中一把,两边就会永远等下去,这是编译器一个字也不会提的死锁。编译通过,通过的是安全,不是设计。
“并发编程不再是一个需要害怕的概念:放手去让你的程序并发起来吧,无所畏惧!”
“Concurrent programming is no longer a concept to be afraid of: Go forth and make your programs concurrent, fearlessly!”
§16.4 Extensible Concurrency with Send and Sync查看原文 →
Arc 就是更好的 Rc,所以不妨到处都用它。
Arc 的区别只在于它的引用计数是原子更新的,而这种原子性有性能代价,所以单线程代码仍然应该用 Rc。Arc 也只提供共享所有权;要跨线程修改,就得在它里面放一个 Mutex,这就是能用的共享计数器是 Arc 包着 Mutex 的原因。
“幸运的是,Arc<T> 是一种类似 Rc<T> 的类型,在并发情形下使用是安全的。”
“Fortunately, Arc<T> is a type like Rc<T> that is safe to use in concurrent situations.”
§16.3 Shared-State Concurrency查看原文 →
第 16 章的核心概念
- 无畏并发
- Rust 在编译期就抓住并发错误,让你重构时无所畏惧,这是对这一点的昵称。
- 适用处: 它消除的是可在编译期检测到的几类 bug,并不免去认真设计多线程程序的必要。
- 1:1 线程模型
- 标准库里的每个语言线程,恰好对应一个操作系统线程。
- 适用处: crate 中还有其他模型,各有不同的取舍;async 是另一种独立的方式。
- JoinHandle<T>
- thread::spawn 返回的一个有所有权的值,它的 join 方法会一直阻塞,直到那个线程结束。
- 适用处: join 调用在哪里,决定了线程是交错执行还是依次运行。
- 线程里的 move 闭包
- 加上 move 会强制派生线程的闭包取得所捕获值的所有权,而不是借用它们。
- 适用处: 它改写的是保守的借用默认值,但打破不了所有权规则。
- 通道(mpsc)
- 一条单向的管道,有发送端和接收端;多个发送者,一个接收者。
- 适用处: 任何一端被丢弃后通道即关闭;recv 会阻塞,而 try_recv 立即返回。
- Mutex
- 一种守卫,通过锁让同一时刻只有一个线程能碰数据。
- 适用处: 它防止的是数据竞争,而不是死锁这类逻辑错误。
另外 6 个概念
- Arc<T>
- 一种引用计数指针,计数以原子方式更新,因此可以跨线程共享。
- 适用处: 原子计数有性能代价,所以单线程代码更应该用 Rc<T>。
- Send
- 一个标记 trait,表示一个值的所有权可以移到另一个线程。
- 适用处: Rc<T> 和裸指针不是 Send。
- Sync
- 一个标记 trait,表示一个类型可以被多个线程同时安全地引用。
- 适用处: RefCell<T> 和 Cell 家族不是 Sync,因为它们运行时的借用检查不是线程安全的。
- 所有权
- 被移动的值不能再被它原来的持有者使用,这条规则在这里用到了通道的发送上。
- 适用处: 通道类似单一所有权;共享内存类似多重所有权。
- 借用检查器
- 这个编译器组件和类型系统一起,排除并发代码中的数据竞争和无效引用。
- 适用处: 它保证的是不存在数据竞争和无效引用,而不是不存在所有并发 bug,比如死锁。
- 编译器作为守门人
- 代码一旦编译通过,程序员就可以信任它在多个线程上运行时不会出现难以捉摸的 bug。
- 适用处: 适用于这些检查所覆盖的保证;编译通过,通过的是安全,不是逻辑。
第 16 章划线与批注AI 陪读
“团队发现,所有权和类型系统是一套强大的工具,可以帮助管理内存安全和并发问题!”
“the team discovered that the ownership and type systems are a powerful set of tools to help manage memory safety and concurrency problems!”
查看原文 →这里真正的故事出于偶然:一个为内存而造的工具,恰好也适用于线程。Rust 最好的特性只是个副产品。
“许多语言对于它们提供的并发问题解决方案都很教条。”
“Many languages are dogmatic about the solutions they offer for handling concurrent problems.”
查看原文 →框架很巧妙——Rust 没有立场,被改写成了身为底层语言而被迫拥有的美德,而不是拿不定主意。
“Rust 试图减轻使用线程的负面影响,但在多线程环境下编程仍然需要仔细思考。”
“Rust attempts to mitigate the negative effects of using threads, but programming in a multithreaded context still takes careful thought”
查看原文 →品牌名下一句老实的但书。“无畏”从来不等于“省力”,本章悄悄承认了这一点。
另外 15 条批注
“当 Rust 程序的主线程结束时,所有派生出来的线程都会被关停。”
“when the main thread of a Rust program completes, all spawned threads are shut down”
查看原文 →一个伪装成演示的竞态条件——这个例子的魅力在于,它的输出故意不可靠。
“一些小细节,比如在哪里调用 join,会影响你的线程是否同时运行。”
“Small details, such as where join is called, can affect whether or not your threads run at the same time.”
查看原文 →两个看上去一模一样的程序,并发行为却完全不同。行的顺序现在就是语义。
“move 关键字覆盖了 Rust 保守的借用默认值;它并不让我们违反所有权规则。”
“The move keyword overrides Rust’s conservative default of borrowing; it doesn’t let us violate the ownership rules.”
查看原文 →到目前为止最好的一句话:move 是在选择由哪一条规则来约束你,而不是一个逃生出口。初学者总把它误读成后者。
“不要通过共享内存来通信;而要通过通信来共享内存。”
“Do not communicate by sharing memory; instead, share memory by communicating.”
查看原文 →借自 Go,但 Rust 真能把它落实——编译器让这句口号具有约束力,而不只是一种愿景。
“我们的并发错误造成了一个编译期错误。”
“Our concurrency mistake has caused a compile-time error.”
查看原文 →这一行就是整章:著名的数据竞争坍缩成了一个普通的移动错误。不需要任何新机制。
“这正是并发既有趣又困难的原因。”
“This is what makes concurrency interesting as well as difficult.”
查看原文 →本章里唯一一句承认这些保证有下限的话:Rust 解决的是内存安全,而不是非确定性。交错执行仍然得由你自己来推理。
“不要通过共享内存来通信。”
“Do not communicate by sharing memory;”
查看原文 →作为挑衅被引了出来,随后又悄悄放过——本章余下的篇幅,教的恰恰是这句口号所警告的东西。
“如果一位小组讨论嘉宾说完话后忘了把麦克风交出去,其他人就都没法发言了。”
“If a panelist forgets to hand the microphone off when they’re finished with it, no one else is able to speak.”
查看原文 →好比喻,而且注意它换来了什么:失败模式被说成是忘记,而这恰恰是 Drop 消灭掉的那种失败。这个类比是为了让下一页看起来像一场营救而造的。
“多亏了 Rust 的类型系统和所有权规则,你不可能把加锁和解锁搞错”
“thanks to Rust’s type system and ownership rules, you can’t get locking and unlocking wrong”
查看原文 →说过头了,而本章自己也知道——二十段之后,死锁就被承认了。你不可能忘记解锁;但你完全可以加错锁。
“m 的类型是 Mutex<i32>,而不是 i32,所以必须调用 lock 才能使用里面的那个 i32 值。”
“The type of m is Mutex<i32>, not i32, so we must call lock to be able to use the i32 value.”
查看原文 →这才是真正的机制,而且平淡得几乎无聊:让数据除了通过那道门之外根本够不着。安全来自类型管道,而不是纪律。
“我们再次编译,得到了……不一样的错误!编译器教了我们很多东西”
“Once again, we compile and get… different errors! The compiler is teaching us a lot”
查看原文 →教学法就是设计上的论点:三次刻意的失败带你从移动错误走到 Send,再走到 Arc。把错误安排成课程,由编译器当老师。
“线程安全是有性能代价的,你只想在真正需要的时候才付这个代价”
“thread safety comes with a performance penalty that you only want to pay when you really need to”
查看原文 →对“为什么不把一切都做成原子的”这个问题的老实回答——也是 Rc 和 Arc 同时存在、而不是只有一个安全默认选项的原因。零成本意味着由你选择要不要付这份成本。
“任何类型 T,只要 &T(T 的不可变引用)实现了 Send,T 就实现了 Sync”
“any type T implements Sync if &T (an immutable reference to T) implements Send”
查看原文 →优雅的化简:共享被定义成发送一个引用,所以一个原语就涵盖了两者。这也是 RefCell 不行的原因——它在运行时的借用计数器,不能安全地把引用发送出去。
“我们到目前为止谈过的几乎每一项并发特性,都属于标准库,而不是语言”
“almost every concurrency feature we’ve talked about so far in this chapter has been part of the standard library, not the language”
查看原文 →真正的论点,留到了最后。语言只贡献了 Send 和 Sync;其余一切都是库,而且可以由你来写。很薄的内核,可扩展的安全。
“一旦你的代码编译通过,就可以放心,它会在多个线程上愉快地运行”
“Once you get your code to compile, you can rest assured that it will happily run on multiple threads”
查看原文 →本章自己先前的但书——非确定性、死锁、逻辑错误——让“放心”这个落脚点显得太舒服了。无畏的是数据竞争,而不是并发。
读后感AI 陪读
三次分阶段的编译器失败,把你带向并发的 Rust
这一章开头像是夸耀,其实是一段坦白:Rust 团队本想解决内存安全问题,却几乎是无意中发现,同一套所有权和类型机制也适用于线程。这段来历决定了后面的一切。这里的并发不是后加的新子系统,而是把旧规则对准一个新目标——所以本章真正的论点是:不需要任何新机制,只需要你已经学过的那套纪律。它分三步展开:线程与 move 闭包,通道,然后是通过 Mutex 和 Arc 实现的共享状态,最后落到 Send 和 Sync 上。这个推进顺序有意走教学路线而不是百科路线:著名的数据竞争被降格成了一个普通的移动错误,三次刻意安排的编译失败带你从借用错误走到缺少 Send,再走到 Arc<Mutex<T>>,由编译器来当老师。把错误当课程,这是设计上的论点,而不只是教学风格。我看穿的是:这个框架的修辞成分比它承认的要多。Rust 不选定某一种并发模型,被说成是有原则的开放,而不是身为底层语言的一种约束。Go 的口号——通过通信来共享内存——被当作旗帜引了出来,随后又被悄悄放过,因为本章后面教的恰恰是它所警告的共享内存做法;Rust 真正多出来的是,如果你选了那个口号,它能替你强制执行。麦克风的比喻是为了让下一页看起来像一场营救而设计的:它把失败说成是忘记释放锁,而这恰恰是 Drop 消灭掉的那种失败,于是“加锁解锁你不可能搞错”听上去像真理,其实只对了一半。你不可能忘记解锁;但你完全可以按错误的顺序加锁,二十段之后,死锁也确实被承认了。
机制一旦被揭开,其实平淡得几乎无聊:Mutex<i32> 不是 i32,所以数据除了通过那道门之外根本够不着。安全来自类型管道,而不是程序员的美德——这份平淡正是要点。同样,Sync 的定义是一个优雅的化简,而不是第二个概念:共享就是发送一个引用,所以一个原语就涵盖了两者,也一口气解释了为什么 RefCell 不行、Rc 不行,而 Arc 行。Rc 和 Arc 同时存在,而不是只有一个安全的默认选项,这正是“零成本”信条亮相的地方:你选择原子性,就是选择付它的价钱。我最尖锐的判断落在本章的两端。它真正的论点放在了最后一页,几乎被埋没:这里几乎没有一样是语言本身。通道、Mutex、Arc 都是库代码;语言只贡献了 Send 和 Sync。一个很薄的内核加上可扩展的安全,比标题所承诺的任何东西都有意思得多,这也意味着你自己的抽象可以继承同样的保证。但最后那句话——只要让它编译通过,就可以放心它会在多个线程上愉快地运行——对这样一章来说,落脚得太舒服了:它自己已经承认过,非确定性要你自己去推理,join 放在哪里就是语义,死锁依然可能发生。读者应该带走的是:“无畏”是有范围的,不是绝对的。它指的是不怕数据竞争和悬空引用,这是一份巨大且真正罕见的礼物,但不包括交错执行、活性或逻辑。要像本章所说的那样去读 move——而初学者从来不这么读——它不是对所有权的逃避,而是在选择由哪一条所有权规则来约束你。
继续阅读
也要像本章对待编译器的拒绝那样去对待它:把它当作你能收到的最便宜的并发 bug 报告,在程序运行之前就送到你手上。
在 FeedLitra 里读第 16 章,每段旁边都有这些卡片。
开始读书第 17 章:异步编程基础:Async、Await、Future 与流
block_on 和在更大的异步程序内部 await,有什么不同?
第 17 章是怎样展开的AI 辅助
开篇:一个操作系统看不见的问题。 本章不是从语法开始,而是从一个处境开始——一个下载管理器,必须在许多传输同时进行的时候让界面保持响应,而网络调用只会一直阻塞到所有字节都到齐。这个例子的要点在认识论层面:程序知道自己的哪些等待时段是无害的,而系统调用不知道。接着作者把并发(在一个工作者上交错执行任务)和并行(更多的工作者)区分开来,并引入撑起本章其余部分的两个词汇:future,代表尚未完成的工作的一个值;以及 async/await,用来编写和消费它们的记法。第一个纠正马上就到:future 是惰性的。创建一个并不会启动任何事情,就像构造一个迭代器不会产出任何元素一样;必须有什么东西去轮询它。这种惰性也是开篇为它最后一个要素提出的理由——因为标准库不带驱动,运行时必须来自某个 crate,这里是包装了 tokio 的 trpl。
展开:线程的词汇,在异步里再来一遍。 各部件有了名字之后,本章按一份刻意熟悉的清单往下走。两个 URL 用 select 赛跑,结果以 Either::Left 或 Either::Right 的形式到来——胜者由位置来标识。然后,书里前面的线程示例被一个个重建:带 join 句柄的 spawn_task,join(与 select 不同,它是公平的,会在它的各个参数之间交替),由 while let 消费的异步通道,以及一次处理多个 future 的 join!。熟悉是方法,但差异也是,其中最清楚的是那个卡住的程序。接收循环只有在通道关闭时才会结束,而通道只有在所有发送者都被丢弃时才会关闭;留在外层作用域里的一个发送者,会让循环永远等下去,于是同时等待两半的 join 永远不会返回。修法——把发送的块写成 async move,让它拥有发送者并在完成时将其丢弃——很小,但它教的是本章这一节真正的功课:这些 API 看上去像线程的 API,区别在于所有权、顺序和公平性。同一节还给出了结构性的论断:这里的并发来自把几个 future 一起 await,而不是来自派生线程。在一个 async 块里,await 按书写顺序执行,所以每一次发送和睡眠都会在接收者看到任何东西之前完成;把两个循环拆进不同的块再 join,真正的交错就出现了,没有额外的线程——而且顺序可以预测,因为 join 是公平的。
转折:控制权只在你写了 await 的地方易手。 到这里本章不再添加特性,而是解释底下的那台机器,之后的一切都由它而来。异步函数就是一个返回 future 的函数,其函数体是一个 async move 块,每个 await 标出一个执行可以停下、把处理器交还给运行时的地方;因为必须能够恢复,编译器会把函数体改写成一台隐藏的状态机,记录走到了哪一步以及还需要哪些数据。作者把那个枚举亲手写了出来——一个初始状态,加上每个 await 一个变体——于是这些记账工作变得可见,而不是魔法。其后果是,让出是协作式的:两个 await 之间的代码一路跑到底,所以一个没有 await 点的 future 会饿死它的邻居,下一个例子展示的正是这一点,一个 future 把它所有慢操作都跑完,另一个才开始。睡眠能恢复交错,但 yield_now 说出了真正的意图——让运行时轮一次——而且更快,因为 sleep 总要花至少大约一毫秒;这个但书也保持着诚实:让出很便宜但不是免费的,把计算密集的工作切碎可能让程序变慢。同样的洞察接着变得有成效:因为 future 能和 future 组合,timeout 完全不需要任何新东西——用不公平的 select 让调用者的 future 和一个 sleep 赛跑,再读 Left 或 Right 看谁赢了。再往前推,本章观察到迭代器和通道接收端之间的区别只是时间,并把这种推广命名为流:需要等待的迭代。把普通迭代器转换成流会暴露出接缝,因为代码只有在导入 StreamExt 之后才能编译,那个方便的 next 就住在扩展 trait 上。这两种抽象的背后是 Future 本身——一个返回 Ready 或 Pending 的 poll 方法——以及一个悬而未决的问题:future 的集合必须被固定。
收尾:地基,以及每件工具该用在哪里。 最后一段兑现了那个悬而未决的问题。由 async 块构造的 future 在 Rust 的值当中很特别,因为它们可以持有指向自身所存数据的引用,状态机会让借用的部分跨 await 点一直活着;移动这样的值,这些内部引用仍然指向一个现在已经无效、可以被重新利用的地址。Pin 就是禁止移动、从而让这些引用保持稳固的包装器,Unpin 标出普通的、可以移动的情形,而对每个 future 应用 pin!,正是让 join_all 能对形态各异的 future 通过编译的办法。本章小心地划定了范围:Pin 不是一种自带行为的奇特指针,而是编译器强制执行的一种约束,可移动才是常态——String 仍然可以通过 Pin 被替换,因为它没有指向自身的引用——固定只在真有一个被固定的指针在起作用时才有意义。直接 await 一个 future 会替你把它固定;只有当 future 被间接持有时(比如放在集合里),才需要显式固定。有了这些,Stream 就可以被准确地表述为一个显式的合并:序列及其可选的元素来自 Iterator,包在外面的就绪检查来自 Future 的 poll,由此得到 poll_next,再由 StreamExt 提供好用的 next。随后本章以不选赢家作结。线程适合受处理速度限制的工作,异步适合受数据进出速度限制的工作——而且不必二选一,一个阻塞的线程把数字一到十发送给一个 await 它们的 async 块,就演示了这一点。任务是运行时自己的并发单元,夹在线程和 future 之间,正是它让这种混用变得切实可行。
关键论点,以及支撑它们的原文
程序可以找到操作系统看不见的、让工作重叠起来的机会,因为它对自己的结构了解得更细。
作者开头的例子是一个下载管理器:它的界面应该在许多下载同时进行时保持响应,而典型的操作系统网络调用只会一直阻塞到所有数据都到齐。程序知道自己的哪些等待时段是无害的;系统调用不知道。
“在许多情况下,因为我们对自己程序的理解,比操作系统精细得多,我们可以发现操作系统看不到的并发机会。”
“In many cases, because we understand our programs at a much more granular level than the operating system does, we can spot opportunities for concurrency that the operating system can’t see.”
Chapter 17. Fundamentals of Asynchronous Programming: Async, Await, Futures, and Streams查看原文 →
异步 Rust 里的并发,来自把几个 future 一起 await,而不是来自启动更多的操作系统线程。
作者先展示了局限:在一个 async 块里,await 按书写顺序发生,所以在消息传递的那段代码里,每一次发送和睡眠都会在接收循环拿到任何东西之前完成。把每个循环放进它自己的 async 块再把它们 join 起来,就产生了真正的交错,而且没有派生额外的线程;因为 join 在它们之间公平地交替,输出顺序是可以预测的,而不是交给系统调度器去定。
“在一个给定的 async 块内,await 关键字在代码中出现的顺序,也是它们在程序运行时被执行的顺序。”
“Within a given async block, the order in which await keywords appear in the code is also the order in which they’re executed when the program runs.”
§17.2 Applying Concurrency with Async查看原文 →
让出是协作式的:两个 await 之间的代码一路跑到底,所以一个没有 await 点的 future 会饿死它的邻居,而 yield_now 是把控制权交还出去的干净办法。
在作者的例子里,一个 future 把它所有慢操作都跑完,另一个才开始,因为 await 点之间的一切都是普通的同步代码;插入 sleep 之后,两者又重新交错起来。接着作者转向 yield_now,它说出了真正的意图——让运行时轮一次——而且更快,因为所用的 sleep 总要等至少大约一毫秒。但书里保留了这个但书:让出很便宜但不是免费的,把计算密集的工作切碎,可能让程序整体变得更慢。
“await 点之间的一切都是同步的。”
“Everything between await points is synchronous.”
§17.3 Working With Any Number of Futures查看原文 →
另外 3 个论点
因为 future 能和别的 future 组合,小小的异步辅助工具可以拼装成大型的工具。
作者用两个现成的部件搭出了一个 timeout:用不公平的 select 让调用者的 future 和一个 sleep 赛跑,再把结果读作 Left 或 Right,看谁赢了。除了把已经介绍过的部件组合起来,不需要任何新东西。
“因为 future 能和别的 future 组合,你可以用较小的异步构件搭出真正强大的工具。”
“Because futures compose with other futures, you can build really powerful tools using smaller async building blocks.”
§17.3 Working With Any Number of Futures查看原文 →
流是异步通道接收端的自然推广:需要等待的迭代。
作者是这样走到这一步的:他注意到迭代器和通道接收端之间的区别是时间——一个是同步的,另一个必须被 await。把普通迭代器转换成流,就暴露出接缝:代码只有在导入扩展 trait 之后才能编译,因为那个好用的 next 方法住在那里,而不在流的定义本身上。在 trait 层面,这个设计是一次显式的合并——序列的概念及其可选的元素来自 Iterator,包在外面的就绪检查来自 Future 的 poll。
“第一个区别是时间:迭代器是同步的,而通道接收端是异步的。”
“The first difference is time: iterators are synchronous, while the channel receiver is asynchronous.”
§17.4 Streams: Futures in Sequence查看原文 →
线程和异步适合不同的瓶颈,本来就是要一起用的。
作者的经验法则是,线程配受处理速度限制的工作,异步配受数据进出速度限制的工作,然后演示了根本不必二选一:一个阻塞的线程发送数字一到十,同时一个 async 块在 await 它们,两种模型在同一个程序里协作。任务作为运行时自己的并发单元,夹在线程和 future 之间,这正是让这种混用变得切实可行的原因。
“而且如果你既需要并行又需要并发,你不必在线程和异步之间做选择。”
“And if you need both parallelism and concurrency, you don’t have to choose between threads and async.”
§17.6 Futures, Tasks, and Threads查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
await 点变成一台隐藏的状态机
异步函数其实是一个返回 future 的函数,其函数体是一个 async move 块,每个 await 都是代码可以停下、把处理器交还给运行时的地方。因为执行可以稍后恢复,编译器把函数体变成一台看不见的状态机,记录走到了哪一步,以及还需要哪些数据。作者手写的那个枚举——一个初始状态,加上每个 await 一个变体——恰好展示了你本来得自己去写的那些记账工作。
机制 2
为什么收发程序会卡住
只要还有消息到达,接收循环就会一直 await,只有在通道关闭时才会停下,而通道只有在接收端被明确关闭、或者所有发送者都被丢弃时才会关闭。如果发送者还活在外层作用域里,循环就永远不会结束,等待两半的那个 join 也就永远不会返回。把发送的块写成 async move 块,让它拥有发送者,发送者就会在那个块结束时被丢弃,循环就此打破。
机制 3
自引用的 future 不能移动
和大多数 Rust 值不同,由 async 块构造的 future 可以持有指向自身所存数据的引用,因为状态机会让借用的部分跨 await 点一直活着。如果这样的值被移动,这些内部引用仍会指向旧地址,而那个地址现在已经无效,还可能被分配给别的用途。Pin 就是禁止移动、从而让这些引用保持有效的包装器;用 pin! 包住每个 future,正是让一个由形态各异的 future 组成的集合能通过编译的办法。
常见误读
创建一个 future 就启动了工作,就像派生一个线程会立即开始运行它的闭包一样。
future 是惰性的:在有东西 await 它们之前,它们什么都不做,就像迭代器在你要下一个元素之前什么也不产出一样。这也是为什么异步程序需要一个运行时来驱动 future 直到完成——标准库里没有,所以运行时来自某个 crate。
“我们必须明确地 await 这两个 future,因为 Rust 中的 future 是惰性的:除非你用 await 关键字要求它们,否则它们什么也不做。”
“We have to explicitly await both of these futures, because futures in Rust are lazy: they don’t do anything until you ask them to with the await keyword.”
§17.1 Futures and the Async Syntax查看原文 →
Pin 是一种自带行为的特殊指针,而且每个 future 都得先被固定才能使用。
Pin 是包在指针外面的一个包装器,表达的是编译器强制执行的一种约束,而可移动才是常态——一个被标为可以安全移动的值,比如 String,仍然可以通过 Pin 被替换,因为它没有指向自身的引用。只有当指向该值的被固定指针真的在起作用时,固定才变得有意义:直接 await 一个 future 会替你把它固定,只有当 future 被间接持有时(比如放在集合里),才需要显式固定。
“首先,Unpin 是“正常”的情形,而 !Unpin 是特殊情形。”
“First, Unpin is the “normal” case, and !Unpin is the special case.”
§17.5 A Closer Look at the Traits for Async查看原文 →
第 17 章的核心概念
- Future trait
- 一个尚未就绪、但终将就绪的值,实现为一个 trait,这样许多异步类型就共用一个接口。 一个带有 Output 类型和 poll 方法的 trait,poll 返回 Ready 或 Pending。 一个由运行时而不是操作系统管理的并发单元。
- 适用处: future 在被 await 之前什么都不做;它只追踪自己的进度,以及“就绪”意味着什么。 大多数 future 在返回 Ready 之后就不该再被轮询;await 会编译成运行时驱动的循环中的 poll。 位于线程和 future 之间;一个任务可以在其内部的各个 future 之间切换。
- async 与 await 关键字
- async 把一个块或函数标记为可被中断;await 标出一个它可以暂停、直到某个 future 就绪的点。
- 适用处: await 只能在异步函数或异步块里使用,而且 main 不能是 async。
- 并发与并行
- 并发是一个工作者在尚未完成的任务之间切换;并行是多个工作者在同一时刻同时推进。
- 适用处: 两者可以交叠,而依赖关系可以让其中任何一个变成串行。
- 异步运行时
- 一个负责调度并驱动异步操作直至完成的 crate;Rust 默认不自带。
- 适用处: 不同的运行时针对不同的硬件做取舍,从大型 Web 服务器到微控制器。
- 惰性的 future
- future 在被明确 await 之前不做任何工作,就像迭代器在 next 被调用之前什么都不做一样。
- 适用处: 不同于 thread::spawn 的闭包,后者会立即开始。
- CPU 密集型与 I/O 密集型
- 一种任务受处理速度限制,另一种受数据进出的速度限制。
- 适用处: 两者都能从中断中受益,但原因不同。
另外 10 个概念
- trpl::select 和 Either
- 运行两个 future,并以 Left 或 Right 的形式报告哪个先完成,而不是成功或失败。
- 适用处: 只表达“二者取一”,不表达错误;trpl::select 是 futures crate 里 select 的简化版本。
- 异步运行时中的公平性
- trpl::join 在各个 future 之间平均交替,所以输出顺序是确定的,不像操作系统的线程调度。
- 适用处: 运行时并不被要求对每一种操作都保证公平。
- async move 块
- 把所有权移进 async 块,这样像通道发送者这样的值会在块结束时被丢弃。
- 适用处: 每个发送的块都需要;否则 tx 会一直活着,接收循环永远不会结束。
- while let 条件循环
- 只要 rx.recv() 匹配 Some 就一直 await 下去,在通道关闭产出 None 时结束。
- 适用处: 之所以用它,是因为 Rust 还没有针对异步产生的元素的 for 循环。
- 协作式多任务
- 每个 future 自己决定什么时候让运行时轮一次,办法是到达一个 await 点。
- 适用处: 只有每个 future 都避免阻塞过久才行得通;await 点之间的代码是完全同步的。
- future 饥饿
- 一个做长时间工作却不 await 的 future,会阻止所有其他 future 取得进展。
- 适用处: 有时可以接受;把计算密集型的工作拆开可能更慢,所以要测量。
- yield_now
- 一个可 await 的东西,立即把控制权交还给运行时,不用睡眠。
- 适用处: 便宜但不是免费的;像 sleep 这样的计时器有大约一毫秒的最小粒度。
- Stream
- 随时间陆续到来的一个异步元素序列——需要 await 的迭代。
- 适用处: next 来自 StreamExt,而不是 Stream,而且两者都还不在标准库里。
- Pin
- 一个保证值不会在内存中移动的包装器,被间接持有的 future 需要它。 一个包在指针外面的包装器,阻止被指向的值在内存里移动。 一个标记 trait,表示一个类型即使在 Pin 之后也可以安全移动。
- 适用处: 直接 await 一个 future 会隐式地固定它;放在集合里的装箱 future 需要显式固定。 只对带有内部引用的类型才有意义;它是编译器强制执行的一种约束,而不是一种自带行为的指针。 Unpin 是常态;只有当指向该类型的被固定指针在起作用时才有意义。
- Stream trait
- 一个异步序列:poll_next 返回 Poll<Option<Item>>,把迭代和就绪状态混合在一起。
- 适用处: 还不在标准库里;常见的定义来自 futures crate。
第 17 章划线与批注AI 陪读
“异步编程是一种抽象,让我们能用潜在的暂停点和最终的结果来表达代码”
“Asynchronous programming is an abstraction that lets us express our code in terms of potential pausing points and eventual results”
查看原文 →注意这个老实的词:抽象。整章的承诺是把协调藏起来——而大多数异步的痛苦,都来自这层隐藏的泄漏。
“因为我们对自己程序的理解,比操作系统要细致得多”
“because we understand our programs at a much more granular level than the operating system does”
查看原文 →这才是异步真正的理由,而且是认识论层面的,不是技术层面的:你知道一些调度器不知道的事。从操作系统中断转向用户代码,这个转折很漂亮。
“这些线程所占用的系统资源开销,最终会成为一个问题”
“the overhead of the system resources used by those threads would eventually become a problem”
查看原文 →这是整章唯一撑起辩护的一句话——却没有量化。“最终”正是基准测试躲藏的地方。
另外 17 条批注
“当你对一个项目感到厌倦或卡住时,就切换到另一个”
“when you get bored or stuck on one project, you switch to the other”
查看原文 →这个类比很讨喜,但偷偷塞进了主观意愿。运行时不会厌倦;它只在你写下的 await 点上切换。这个差距后面会变得重要。
“现在你的并发工作也变成串行了。”
“Now your concurrent work has also become serial.”
查看原文 →悄悄地成了这一节最有用的一句:并发不是工具的属性,而是依赖关系的属性。再多的 await 语法也救不了你的真实依赖。
“Rust 里的 future 是惰性的:在你用 await 关键字要求它们之前,它们什么都不做”
“futures in Rust are lazy: they don’t do anything until you ask them to with the await keyword”
查看原文 →这是与 JS 真正的分歧,作者两次强调是对的。JS 的 promise 已经在运行了;Rust 的 future 是一份没人下厨的食谱。
“写一个 async fn,等同于写一个返回该返回类型的 future 的函数”
“writing async fn is equivalent to writing a function that returns a future of the return type”
查看原文 →脱糖是这一章里最体贴的东西——它把异步从一个魔法关键字,变成了一个你可以推理的类型层面的事实。
“程序的 main 函数可以初始化一个运行时,但它本身不是运行时。”
“A program’s main function can initialize a runtime, but it’s not a runtime itself.”
查看原文 →对一个看似武断的编译器错误,这是个优雅的回答。Rust 拒绝捆绑运行时是一种关于嵌入式目标的哲学立场,代价是样板代码。
“这是一台看不见的状态机,就好像你写了这样一个枚举”
“This is an invisible state machine, as if you’d written an enum like this”
查看原文 →手写的枚举是本章最好的教学装置。原本你后面会撞上的借用错误,一下子变得可以预料,而不再神秘——状态必须持有你的引用。
“Either 里没有内置成功或失败的概念”
“there is no notion of success or failure baked into Either”
查看原文 →API 设计上很克制:一场竞速里没有哪个输家配得上叫错误。用 Result 就会把一个价值判断强加给一个时间上的事实。
“这是因为 trpl::join 函数是公平的,也就是说,它检查每个 future 的次数相同”
“That is because the trpl::join function is fair, meaning it checks each future equally often”
查看原文 →整个对比真正的回报在这里:确定性。线程把调度交给操作系统;异步把调度交给一个你可以真正选择、也可以推理的运行时。
“更大的区别在于,我们不需要再派生另一个操作系统线程”
“The bigger difference is that we didn’t need to spawn another operating system thread”
查看原文 →本章悄悄拆了它刚教给你的那个例子的台。spawn_task 是教学用的脚手架,不是推荐做法。
“我们不 await send 调用,因为它不会阻塞。它也不需要这样做,因为我们往里发送的这个通道是无界的”
“we don’t await the send call, because it doesn’t block. It doesn’t need to, because the channel we’re sending it into is unbounded”
查看原文 →注意这条因果链:无界,所以 send 是同步的。有界通道会迫使 await——也就带来背压。一个很大的设计后果,被塞进了一个从句里。
“这个块无法结束,因为它在阻塞等待 trpl::join 完成,这又把我们带回了这份清单的开头。”
“The block can’t end because it is blocked on trpl::join completing, which takes us back to the top of this list.”
查看原文 →这一段里写得最好的地方——一个八步的推理绕回了自己,于是行文本身就字面意义上演出了它所描述的死锁。
“关键在于 future 被 await 的顺序,而不是它们被创建的顺序。”
“The key is the order in which the futures are awaited, not in which they’re created.”
查看原文 →这是该纹在读者身上的一句话。future 在被 await 之前是惰性的——创建的顺序只是记账,而这恰恰是从急切的 promise 转过来的人容易栽跟头的地方。
“一个 future 饿死了其他 future”
“one future starving other futures”
查看原文 →对协作式调度的老实自白:没有抢占,意味着每个 await 点都是你对同伴许下的承诺,而漏掉一个,就是编译器抓不到的背叛。
“像 sleep 所用的那种计时器,其粒度往往有限”
“timers such as the one used by sleep often have limits on how granular they can be”
查看原文 →漂亮的一笔——把 sleep 当作让出的招数,不只是含糊,而且是可测量地错误。意图和性能在 yield_now 上汇合,这很少见,值得留意。
“试图拆分一个计算密集型任务,可能会让它显著变慢,所以有时让某个操作短暂地阻塞,对整体性能反而更好”
“trying to break up a compute-bound task might make it significantly slower, so sometimes it’s better for overall performance to let an operation block briefly”
查看原文 →用三段代码教会你让出之后,它又告诉你别过头。拒绝把异步当作万能的好东西,很清爽。
“trpl::select 的实现并不公平:它总是按参数传入的顺序来轮询”
“The implementation of trpl::select is not fair: it always polls arguments in the order in which they are passed”
查看原文 →一个很好的承认:这个“原语”是有偏向的。timeout 之所以管用,只是因为参数顺序这个实现细节——可移植的代码不应该依赖它。
“因为流就是 future,我们可以把它们和任何其他种类的 future 一起使用”
“Because streams are futures, we can use them with any other kind of future”
查看原文 →有点草率——流并不是 future,它只是像 future 那样被轮询。这里的含糊,恰恰是后面 Stream trait 那一节要来澄清的。
读后感AI 陪读
理解状态机,就能预测每一个 pin 和借用错误
通读下来,本章与其说是一篇语法教程,不如说是一场关于调度权应该落在哪里的持续论证,而且它的结构对这场论证的代价毫不讳言。它以统领后面一切的那个词开篇——抽象——并给出一个认识论层面而非技术层面的理由:你对自己程序的理解比操作系统更细致,所以应该允许你说出它可以在哪里暂停。名义上推动本章的那个线程开销的说法,没有被量化(“最终”正是基准测试躲藏的地方),这一点很说明问题:这里为异步辩护的真正理由不是速度,而是控制。从这里开始,本章按有意安排的顺序搭建——并发与并行,惰性的 future,被脱糖成返回 future 的函数的 async/await,缺席的运行时,揭示出那台看不见的状态机的手写枚举——这些部件后面都会一一兑现。这个枚举是本书异步内容里最好的一个教学装置,因为它把后面每一个神秘的 pin 和借用错误,都变成了你本来可以预料到的东西:状态机必须持有你的引用,所以它不能移动。中间一段有意把线程那一章用异步的形式重新搭了一遍(用 Either 的 select、spawn_task 和 join 句柄、公平的 join、通道、async move、join!),随后又悄悄拆了自己的脚手架,说明重点从来就不是去派生什么。最好的文笔出现在死锁的逐步推演里:一个八步的推理,绕回了它自己的第一行,于是行文本身就演出了它所解释的那种卡死。我带走的最尖锐的判断是,这一章一直在绕着一个真理打转,却从来没有把它当作论点说出来:并发是依赖关系的属性,而不是工具的属性。
“现在你的并发工作也变成串行了”和“关键在于 future 被 await 的顺序,而不是被创建的顺序”是同一句话的两个角度,再多的 await 语法也救不了一个真正的依赖,或是一个没人去轮询的惰性 future。推论则是协作式调度的自白:没有抢占,每一个 await 点都是对你的同伴许下的承诺,而漏掉一个,就是编译器抓不到的背叛——于是有了饥饿,于是用 yield_now 而不是 sleep 那招,后者的计时器粒度使它不只是含糊,而是可测量地错误。本章的几处拒绝也值得称赞。它拒绝把异步当作万能的好东西,在连讲了三段让出代码之后告诉你,把计算密集型的工作切碎,可能让它变得更慢。它拒绝把成功和失败塞进 Either,因为一场竞速里没有哪个输家配得上叫错误。它承认 select 并不公平,timeout 之所以管用,只是因为参数是按顺序轮询的——这是一个可移植的代码不该依赖的实现细节。而 Rust 拒绝自带运行时,付出的是 trpl/tokio 样板代码的代价,这是对嵌入式目标的一种立场,而不是疏漏。最弱的时刻是那些压缩:“因为流就是 future”按字面说是错的(流只是像 future 那样被轮询;poll_next 才是从 Iterator 的序列通往 Future 的就绪状态的真正桥梁),还有 send 之所以不需要 await,只是因为通道是无界的,这件事把背压——有界通道所迫使的整个设计问题——埋进了一个从句里。
继续阅读
我会这样告诉另一位读者:把脱糖当作主干,把例子当作可以丢弃的东西。抓住三件事,其余自然跟上。future 是一份没人下厨的食谱,所以在 await 之前什么都不会发生;状态机必须保持不动,这就是 Pin 和 Unpin 的用途,也是 future 的集合需要 pin! 的原因;以及结尾处的分工——CPU 密集型的工作用线程,I/O 密集型的用异步,两者毫无愧色地结合——不是一种含糊其辞,而是本章的结论。慢慢读那段死锁追踪,亲手把枚举写出来一次,并对每一个异步设计追问:到底什么依赖什么,我又在哪里许诺了要让出?
在 FeedLitra 里读第 17 章,每段旁边都有这些卡片。
开始读书第 18 章:面向对象编程特性
限制 trait 对象的 dyn 兼容性规则到底是什么?
第 18 章是怎样展开的AI 辅助
开篇:把一个定义问题当作分拣工具。 本章不是从某项技巧开始,而是从一个问题开始——Rust 是面向对象的吗?——然后拒绝在抽象层面回答它。它转而把经典的四人组(Gang of Four)标准逐条拿来对照。关于对象,Rust 轻松过关:结构体和枚举持有数据,impl 块提供作用于数据的过程,这恰好就是定义所要求的捆绑。关于封装,它同样过关,作者用 AveragedCollection 具体地说明了这一点:它把列表和缓存的平均值都藏在 add、remove 和 average 之后,所以缓存永远不会与列表脱节,内部也可以被重写而不破坏任何调用者。然后第三条标准打破了这种格局。继承根本不存在,而本章小心地把这说成是一个设计决策,而不是一个缺口:子类会继承父类的每一个特征,不管这些特征对它有没有意义,而单继承语言让这种挤压更紧。关上了那扇门之后,本章有义务再打开另一扇,这给了后面的正文它的议程。
展开:以 trait 对象作为通往多态的替代路线。 替代方案是比继承更窄意义上的多态——只是能处理多种类型的代码——GUI 的例子说明了为什么惯用的工具在这里不够用。一个泛型的 Screen<T: Draw> 只能被单态化为恰好一种具体类型,所以它不能同时容纳一个按钮和一个文本框;而带着 Vec<Box<dyn Draw>> 的 Screen 可以,因为 trait 对象是一个指针加上 dyn Trait,编译器不再追踪它背后是什么。本章明确指出,这种擦除既是特性也是代价:允许异质列表存在的这种具体类型的丢失,同样迫使方法查找发生在运行时,并断绝了内联。而这里的灵活性并不是去掉检查的鸭子类型——把一个 String 塞进列表,构建就会以 error[E0277] 停下,说 String 没有实现 Draw。作者还补充了让这个工具保持分寸的条件:当集合是同质的时候,泛型仍然更可取,因为单态化不用付运行时查找的开销。
转折:同一个工作流构建两次,第二次构建重新框定了第一次。 机制到手之后,本章转向状态模式,实现了一个博客文章的生命周期。在第一版里,Draft、PendingReview 和 Published 各自实现一个共享的 State trait,并各自拥有“它之后是什么”的规则:转换方法按值接收 self,并在一个 box 里返回下一个状态,所以 Draft 的 request_review 产出一个装箱的 PendingReview,而 PendingReview 的返回它自己,让重复的审核请求无害。Post 从不检查或根据它的状态分支;它只做转发。回报是,增加一个阶段只意味着在一处多写一个结构体和一个 trait 实现,Post 和每一个调用点都原封不动。随后本章逼近弱点:因为每个状态把它的后继写死了,在 PendingReview 和 Published 之间插入一个 Scheduled 状态就需要改 PendingReview 本身,而显而易见的补救——一个返回 self 的默认 trait 方法——被 dyn 兼容性规则拒绝了,因为当 State 被用作 trait 对象时,具体的返回类型是未知的。这是本章的支点:模式管用,但它的接缝清晰可见。
第二次构建:把约束从运行时行为推给类型检查器。 重写给每个阶段一个它自己的结构体,所以只有在那个阶段合法的方法才存在。Post::new 返回一个 DraftPost,它没有 content 方法,而不是有一个返回空字符串的方法;request_review 和 approve 消耗 self 并交回另一个类型,所以移动之后没有过时的 DraftPost 或 PendingReviewPost 幸存下来。因为 content 是私有的,也没有别的函数能产出 Post,通往一篇可读文章的唯一路径就是按顺序走完各步,而乱序的调用是编译错误,而不是一个悄悄为空的字符串。作者最后一锤定音的动作是删掉旧的关于草稿内容为空的断言——不是因为行为变了,而是因为那段测试代码不再能编译。
收尾:一次权衡,而不是一个裁决。 本章结束时拒绝给第二版加冕。完整的封装被放弃了:转换不再住在 Post 内部,而是返回新的实例,所以调用代码在每一步之后必须用一个新的 let 重新绑定 post,这意味着编译期的安全是靠让状态变化漏到 main 里来换取的。哪种设计合适,取决于具体情况。结尾的说明把这种立场推广开来——面向对象的模式在 Rust 里是几种选择之一,而因为所有权使别的设计成为可能,从别处引进的模式应该拿来与它们比较,而不是默认采用。
关键论点,以及支撑它们的原文
按经典的四人组定义——把数据和作用于数据的过程捆绑在一起——来判断,Rust 确实算是面向对象的。
结构体和枚举承载数据,impl 块提供方法,而且除非标了 pub,字段保持私有。作者的 AveragedCollection 把列表和缓存的平均值都藏起来,只暴露 add、remove 和 average,所以缓存永远不会与列表脱节,内部也可以被重写而不破坏调用者。
“按这个定义,Rust 是面向对象的:结构体和枚举有数据,而 impl 块在结构体和枚举上提供方法。”
“Using this definition, Rust is object oriented: Structs and enums have data, and impl blocks provide methods on structs and enums.”
§18.1 Characteristics of Object-Oriented Languages查看原文 →
Rust 里没有继承,是有意为之,不是疏漏。
作者的理由是,继承往往共享了比你实际想共享的更多的代码:子类会自动继承父类的每一个特征,于是对它毫无意义的方法也变得可以调用,而只提供单继承的语言让这种挤压更糟。排除了继承之后,本章就得另找一条通往多态的路。
“继承往往有共享了比必要更多的代码的风险。”
“Inheritance is often at risk of sharing more code than necessary.”
§18.1 Characteristics of Object-Oriented Languages查看原文 →
这条路就是 trait 对象,它买到了类似鸭子类型的灵活性,同时检查仍由编译器来做。
一个泛型的 Screen<T: Draw> 每次只能装下一种具体类型,而持有 Vec<Box<dyn Draw>> 的 Screen 则能在同一个列表里接受一个装箱的 Button 和一个装箱的 TextField;塞进一个 String,构建就会以 error[E0277] 停下,说 String 没有实现 Draw。作者给出的条件是:当集合是同质的时候,泛型仍然更可取,因为单态化避免了运行时查找。
“一个泛型类型参数一次只能被替换为一个具体类型,而 trait 对象允许在运行时由多个具体类型来充当该 trait 对象。”
“A generic type parameter can be substituted with only one concrete type at a time, whereas trait objects allow for multiple concrete types to fill in for the trait object at runtime.”
§18.2 Using Trait Objects to Abstract over Shared Behavior查看原文 →
另外 3 个论点
在博客的例子里,把工作流规则放进状态对象,无论有多少个状态,Post 自己的方法都保持不变。
Draft 的 request_review 返回一个装箱的 PendingReview,而 PendingReview 的返回它自己,所以重复的审核请求无害,Post 只是转发调用。因此增加一个阶段,只意味着在一处多写一个结构体和它的 trait 实现,Post 和每一个用到 Post 的地方都原封不动。
“现在我们可以开始看到状态模式的好处了:Post 上的 request_review 方法,无论它的状态值是什么,都是一样的。每个状态负责它自己的规则。”
“Now we can start seeing the advantages of the state pattern: The request_review method on Post is the same no matter its state value. Each state is responsible for its own rules.”
§18.3 Implementing an Object-Oriented Design Pattern查看原文 →
这个设计的另一面是,各个状态彼此耦合,而且有些转换代码被重复了。
因为每个状态编码了接下来是哪个状态,在 PendingReview 和 Published 之间塞进一个 Scheduled 状态,就迫使你去改 PendingReview 本身。显而易见的补救——一个只返回 self 的默认 trait 方法——也行不通:当 State 被用作 trait 对象时,具体的返回类型在编译期是未知的,所以 dyn 兼容性规则会拒绝它。
“状态模式的一个缺点是,因为状态自己实现状态之间的转换,所以有些状态彼此耦合。”
“One downside of the state pattern is that, because the states implement the transitions between states, some of the states are coupled to each other.”
§18.3 Implementing an Object-Oriented Design Pattern查看原文 →
把同一个工作流重写成每个状态都是它自己的类型,就把约束从运行时行为转移到了类型检查器。
Post::new 现在交回一个 DraftPost,它根本没有 content 方法,而不是有一个返回空字符串的方法;request_review 和 approve 消耗 self 并返回新的类型,所以没有过时的 DraftPost 或 PendingReviewPost 逗留不去。因为 content 是私有的,也没有别的函数返回 Post,得到一篇可读文章的唯一办法就是按顺序走完各步——作者甚至删掉了旧的关于草稿内容为空的断言,因为那段代码不再能编译。
“但是,我们不再给草稿文章一个返回空字符串的 content 方法,而是让草稿文章根本没有 content 方法。”
“But instead of having a content method on a draft post that returns an empty string, we’ll make it so that draft posts don’t have the content method at all.”
§18.3 Implementing an Object-Oriented Design Pattern查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
trait 对象在运行时分派
trait 对象是一个指针加上 dyn Trait,所以编译器不再追踪它背后的具体类型,把方法查找推迟到运行时。这种擦除恰恰是让一个集合能同时容纳按钮和文本框的原因,也是每次调用都要付查找开销、且不能被内联的原因。
机制 2
每个状态拥有它自己的转换
每个状态是一个独立的对象,实现一个共享的 State trait,转换方法按值接收 self,并在一个 box 里返回下一个状态,所以旧状态是被消耗掉的,而不是被检查的。因此 Post 从不根据它持有哪个状态而分支;它只是委托,这就是新规则会落在新结构体里,而不是落在 Post 里的原因。
机制 3
类型让坏状态无法通过编译
给每个阶段一个它自己的结构体,意味着只有在那个阶段合法的方法才存在,而转换消耗 self,交回另一个类型。于是草稿没有 content 方法,也没有过时的实例在移动之后幸存,所以乱序的调用就成了类型错误,而不是一个悄悄为空的字符串。
常见误读
没有继承,Rust 就没法让一段代码服务于多种类型,所以你最终得为每种类型重复同样的逻辑。
作者所说的多态,只是指能处理多种类型的代码,而 Rust 提供了两种办法:带 trait 约束的泛型,在编译期解析;或者 trait 对象,在运行时解析。Draw 的例子用一个循环就驱动了按钮和文本框,任何地方都没有子类化。
“但它实际上是一个更一般的概念,指的是能处理多种类型数据的代码。”
“But it’s actually a more general concept that refers to code that can work with data of multiple types.”
§18.1 Characteristics of Object-Oriented Languages查看原文 →
用类型编码的版本就是做对了的状态模式,所以应该总是优先选它。
作者把它当作一次权衡,而不是一次升级:转换不再封装在 Post 里面,而是返回新的实例,所以调用代码在每一步之后必须用一个新的 let 重新绑定 post——编译期的安全是靠让状态变化漏进 main 来换取的。哪种设计合适,取决于具体情况,因为 Rust 的所有权使一些替代方案成为可能,它们的取舍与面向对象的模式不同。
“为了重新赋值 post,我们需要对 main 做的那些改动意味着,这个实现已经不太遵循面向对象的状态模式了:状态之间的转换不再完全封装在 Post 的实现之内。”
“The changes we needed to make to main to reassign post mean that this implementation doesn’t quite follow the object-oriented state pattern anymore: The transformations between the states are no longer encapsulated entirely within the Post implementation.”
§18.3 Implementing an Object-Oriented Design Pattern查看原文 →
第 18 章的核心概念
- trait 对象
- 一个指针加上 dyn Trait,可以容纳任何实现了该 trait 的类型,并在运行时查找它的方法。
- 适用处: 它只抽象共享的行为;你不能往里添加数据。
- 动态分派
- 编译器在编译期无法知道调用的是哪个方法,所以选择发生在运行时。
- 适用处: 要付运行时查找的开销,并阻碍内联;受 dyn 兼容性规则限制。
- 默认私有
- 除非标了 pub,否则字段保持私有,所以内部可以改变而不破坏使用者。
- 适用处: 只有公有方法签名保持稳定才行得通。
- 多态
- 能处理多种类型的数据的代码,不一定要靠子类化。
- 适用处: 在 Rust 里,它来自泛型加 trait 约束,或来自 trait 对象。
- 状态模式
- 一个值持有若干状态对象中的一个;每个对象定义自己的行为,以及接下来是哪个状态。
- 适用处: 状态实现转换,所以增加一个状态可能迫使你去改相邻的状态。
- trait
- 一个共享的行为定义;这里 State 为所有文章状态列出了 request_review、approve 和 content。
- 适用处: 作为 trait 对象,它不能有一个返回 self 的默认方法,因为具体类型在编译期是未知的。
另外 4 个概念
- Option 枚举
- 把状态包在 Option 里,让 take() 能把旧状态移出来并留下 None,因为结构体不能有空字段。
- 适用处: 只是因为转换会消耗旧状态的所有权才需要。
- 编译器作为守门人
- 给每个状态它自己的类型,意味着误用——比如读取草稿的内容——干脆无法编译。 编译器拒绝从未发布的文章里读取 content 的代码,在上线之前就抓住了这个 bug。
- 适用处: 用看得见的类型变化,换来隐藏的封装。 只能抓住那些可以表达成类型错误的错误。
- 所有权
- 转换方法按值接收 self,消耗掉旧状态或旧的文章结构体,使它不能被再次使用。 面向对象语言里没有的一项 Rust 特性,这就是面向对象的模式并不总是最合适的原因。
- 适用处: 对于 trait 对象版本,只有在持有该类型的 Box 上调用才有效。 在这里是作为一种设计约束被提及,而不是重新解释。
- 把状态编码进类型系统
- 每个工作流状态是一个独立的类型,所以只有在那个状态下有效的方法才存在。
- 适用处: 转换返回新的实例,所以它们不再被封装在一个类型内部。
第 18 章划线与批注AI 陪读
“有许多相互竞争的定义在描述什么是面向对象编程,按其中一些定义,Rust 是面向对象的,按另一些则不是。”
“Many competing definitions describe what OOP is, and by some of these definitions Rust is object oriented but by others it is not.”
查看原文 →优雅的一闪避。书拒绝了身份问题,把它换成一张特性清单——这也让它永远不必为 Rust 的会员卡辩护。
“尽管带方法的结构体和枚举不叫对象,它们提供的功能是一样的”
“Even though structs and enums with methods aren’t called objects, they provide the same functionality”
查看原文 →用命名来立规矩:Rust 恰恰是把这个词挡在门外,好让你别把跟着它一起来的思维习惯带进来。
“self.average = total as f64 / self.list.len() as f64;”
查看原文 →把最后一项也移除,len() 就是 0——缓存的平均值就成了 NaN。封装的道理是对的;算术却悄悄地不对。
另外 17 条批注
“我们可以对 list 字段使用 HashSet<i32> 而不是 Vec<i32>”
“we could use a HashSet<i32> instead of a Vec<i32> for the list field”
查看原文 →说得轻描淡写,但 HashSet 会悄悄丢掉重复项,改变“平均”的含义。签名保住了;语义没有。
“不借助宏的话,没有办法定义一个继承父结构体的字段和方法实现的结构体。”
“There is no way to define a struct that inherits the parent struct’s fields and method implementations without using a macro.”
查看原文 →本章在这里不再含糊其辞,直接说了不。在一连串“按某些定义”之后,让人耳目一新。
“继承往往有共享了比必要更多的代码的风险。”
“Inheritance is often at risk of sharing more code than necessary.”
查看原文 →终于说出了真正的论点:继承把你想复用的东西和你不想复用的东西耦合在了一起。此前的一切都是分类学;这是一个设计上的论断。
“trait 对象与其他语言里的对象不同,我们不能往 trait 对象里添加数据。”
“Trait objects differ from objects in other languages in that we can’t add data to a trait object.”
查看原文 →本章中段的老实自白。trait 对象是半个对象——有行为而没有状态——作者直说了出来,而不是去兜售它。
“如果你只会有同质的集合,用泛型和 trait 约束更可取”
“If you’ll only ever have homogeneous collections, using generics and trait bounds is preferable”
查看原文 →注意真正的判断规则根本不是哲学性的:你在编译期知道这些类型吗?关于“面向对象对 Rust”的一切都坍缩成这一个问题。
“类似于动态类型语言里的鸭子类型概念”
“similar to the concept of duck typing in dynamically typed languages”
查看原文 →好类比,只是稍微太宽厚了。鸭子类型让你在运行时加一声新的嘎嘎叫;dyn Draw 需要在编译期就有那个 impl。共有的是对具体类型的漠不关心,而不是共有的自由。
“help: the trait `Draw` is implemented for `Button”
查看原文 →编译器主动递上了一个就在手边的正确答案。错误信息即教学——而本章懂得直接展示它,而不是去解释它。
“用户不会在状态上出错,比如在审核之前就发布一篇文章”
“users can’t make a mistake with the states, such as publishing a post before it’s reviewed”
查看原文 →值得抓住的一个强论断。在这第一版里,对草稿调用 approve() 不会报错——它悄悄地什么都不做。这不是在防止错误,而是在吞掉错误。
“因为 Post 的 state 字段是私有的,所以没有办法创建出处于其他任何状态的 Post!”
“Because the state field of Post is private, there is no way to create a Post in any other state!”
查看原文 →私有性在干不变量的活。那个感叹号,是书在悄悄承认:这是这个模式能买到的唯一保证。
“我们把 content 字段设成一个新的空 String”
“we set the content field to a new, empty String”
查看原文 →值得注意,content 住在 Post 上,而不是状态上——这正是两页之后将要裂开的那条缝。
“这种语法意味着,只有在持有该类型的 Box 上调用,这个方法才有效。”
“This syntax means the method is only valid when called on a Box holding the type.”
查看原文 →self: Box<Self> 是整章的枢纽:所有权转移是 Rust 拼写“旧状态已不复存在”的方式。面向对象语言只是改一个指针然后祈祷。
“因为 Rust 不允许结构体里有未填充的字段”
“because Rust doesn’t let us have unpopulated fields in structs”
查看原文 →著名的 Option::take 舞步。Option 根本不是在建模“缺席”——它是为了满足借用检查器而设的一个暂存槽。这是模式带来的建模噪音,而不是领域本身的。
“虽然方便,但让 State 上的 content 方法来决定 Post 的内容,是在模糊界线”
“While convenient, having the content method on State determine the content of the Post is blurring the lines”
查看原文 →书在句子进行到一半时就嗅到了自己的这股味道。State 现在伸手进了 Post 的字段——它承诺的封装,在往相反的方向泄漏。
“我们知道它永远不会 panic,因为我们知道 Post 上的方法确保”
“we know will never panic because we know the methods on Post ensure”
查看原文 →一个从句里有两个“我们知道”。现实中每一个 unwrap 都是这句话,而这句话有时是错的。
“我们就得改动 PendingReview 里的代码,让它转换到 Scheduled 而不是 Published”
“we would have to change the code in PendingReview to transition to Scheduled instead”
查看原文 →真正的控诉:加一个状态,要去改一个已有的状态。这个模式把行为局部化了,却把状态图的接线散布到了它自己的各个节点上。
“trait 并不确切知道具体的 self 是什么,所以返回类型在编译期是未知的”
“the trait doesn’t know what the concrete self will be exactly, so the return type isn’t known at compile time”
查看原文 →动态分派和所有权转移朝相反的方向拉扯——你不可能既有一个 dyn trait,又有一个返回 Self 的默认实现。重复不是马虎,而是一种结构性的税。
“我们会让草稿文章根本没有 content 方法”
“we’ll make it so that draft posts don’t have the content method at all”
查看原文 →倒转:别再给出错误的回答,让问题没法被问出口。这就是 Rust 的整套招数,一行说完,也是以面向对象为代价的一个类型层面的玩笑。
读后感AI 陪读
这一章把它唯一真正的设计论证,埋在一句旁白里
这一章是全书最圆滑的一章,细读之下,也是最具颠覆性的一章。它以一个自己根本没打算回答的问题开篇——Rust 是面向对象的吗?——并立刻把身份问题换成了一张清单:对象、封装、继承。这一招很优雅,但也很取巧:用一张自己编出来的评分表给 Rust 打分,书就永远不必为一张会员卡辩护。头两个格子得到的是有保留的肯定(带方法的结构体和枚举干了对象的活;pub 干了封装的活),而在称呼上的克制是有意的——Rust 不肯用“对象”这个词,这样你就不会把跟着这个词一起来的思维习惯带进来。到了继承,含糊其辞终于停了:不借助宏,你就是没法继承字段和实现,没有商量。然后是本章唯一真正的设计论断,藏在一句读起来像旁白的话里——继承有可能共享了比你想共享的更多的代码。此前的一切都是分类学;这才是一个论证。值得注意的是,用来教封装的那个求平均值的集合例子里,埋着一个除以零得到 NaN 的坑,而随口提出的换成 HashSet 的建议,会悄悄改变“平均”的含义。道理是对的;算术和语义却不对。中间一幕,trait 对象,是本章最老实的地方。它直截了当地承认,你没法往 trait 对象里加数据——trait 对象只是半个对象,有行为而没有状态——而不是把 dyn 兜售成继承的替代品。鸭子类型的类比恰当,但稍微太宽厚了:鸭子类型让你在运行时加一声嘎嘎叫,而 dyn Draw 仍然要求在编译期就有那个 impl。
它们共有的是对具体类型的漠不关心,而不是自由。当判断规则终于出现时,它根本不是哲学性的:同质的集合要用泛型和 trait 约束,异质的集合要用 trait 对象。你在编译期知道这些类型吗?整个“面向对象对 Rust”的争论都坍缩成了这个单一的问题,而 dyn 的运行时开销,就是不知道的代价。状态模式是本章真正的内容,最好把它读成一个故意失败的故事。第一版很漂亮:Draft、PendingReview、Published,各自拥有自己的转换,以 self: Box<Self> 为枢纽——所有权转移是 Rust 拼写“旧状态已不复存在”的方式,而面向对象语言只是重新赋值一个指针然后祈祷。但裂缝是结构性的,不是表面的。Option::take 那套舞步的存在只是为了满足借用检查器,是领域从没要求过的建模噪音。unwrap 的理由是一个从句里的两个“我们知道”——恰恰是现实中每一次 panic 背后的那句话。content 住在 Post 上,而 State 却伸手进去计算它,书在句子进行到一半时就嗅到了自己的这股味道。动态分派和所有权转移互相拉扯,所以返回 Self 的默认实现不可能存在,重复是一种税,而不是马虎。加一个 Scheduled 状态就得改 PendingReview——这个模式把行为局部化了,却把状态图的接线涂抹到了它自己的各个节点上。
继续阅读
最糟糕的是,用户“不会在状态上出错”的承诺在第一版里是假的:对草稿调用 approve() 不会报错,它悄悄地什么都不做。那不是在防止错误,而是在吞掉错误。第二版是点睛之笔。把每个状态编码成它自己的类型,对草稿调用 approve() 就不是行为不当了——它根本不存在。草稿压根没有 content 方法。别再给出错误的回答;让问题没法被问出口。这就是 Rust 的招数,一行说完,也是以面向对象为代价的一个类型层面的玩笑。老实的代价是封装变薄了——调用者现在得靠 let post = post.request_review() 一路护送值,状态机有一部分就活在他们的代码里。你拿一只封死的盒子,换来一个骗不了的编译器。我会这样告诉读者:别把它读成“Rust 也能做状态模式”。要把它读成一次演示:这个模式本来就是为了绕过一种无法表达“这个操作现在不可用”的类型系统而存在的变通办法。Rust 的类型系统可以。本章的结束语——面向对象的模式是几种选择之一——含蓄到近乎恶作剧,因为它刚刚花了二十页展示了更好的选择。要翻译模式,而不是移植模式;当一个模式的任务是防范无效调用时,问问类型系统能不能让那个调用根本写不出来。
在 FeedLitra 里读第 18 章,每段旁边都有这些卡片。
开始读书第 19 章:模式与匹配
match 守卫如何让你比较外层变量,而不是遮蔽它?
第 19 章是怎样展开的AI 辅助
开篇:模式早已无处不在。 本章不是从新机制开始,而是把读者一直在写的东西重新框定一遍。它解释说,模式是匹配数据形状、而不只是其值的一种语法——作者的第一招,是指出普通 let 语句里那个光秃秃的名字,本身就坐在一个模式的位置上。从这里开始,例子逐步展开:let (x, y, z) = (1, 2, 3) 一行就把一个元组拆成三个绑定,而像 print_coordinates(&(x, y): &(i32, i32)) 这样的函数签名,在函数体开始之前就在参数列表里解构了它的实参。这个开篇的回报是一份清单:模式在 match 分支、let、if let、while let、for 循环和函数参数里都是合法的。读者读完这一节时,已经了解到模式并不是只属于 match 的特性,而是语言中无处不在的一部分。
展开:以可反驳性作为组织规则。 既然确立了模式出现在许多地方,本章就需要解释为什么这些地方不能互换——这个解释就是可反驳性。这个区别很简单:有些结构在模式失败时提供一个分支可以退而求其次,有些则没有。let 绑定、函数参数和 for 循环没有退路,所以它们的模式必须对每一个可能的值都成功;给它们喂一个像 Some(x) 这样可能失败的模式,当值是 None 时变量就无物可存,所以编译器以 error[E0005] 直接拒绝。随后作者把这个测试反过来跑,表明这条规则是对称的,而不仅仅是限制:let...else 提供了缺失的退路块,所以给它一个总是匹配的模式,会让 else 分支无法到达,并招来一个警告。可反驳性就这样成了解释开篇那整份清单的透镜——哪种结构接受哪种模式,不是任意的惯例,而是失败有没有地方可去的结果。
转折:对语法的系统巡礼。 有了支配性的规则,本章从概念模式切换到百科模式,按结构由浅入深的顺序走一遍模式的全部词汇。首先是结构体的解构,有字段简写,也能在字段位置上与字面量匹配——这里作者顺手点出一个关于语义而非语法的要点:因为 match 一旦某个分支符合就停止检查,所以分支的顺序是程序含义的一部分。Point { x: 0, y: 7 } 落到第二个分支,报告说它在 y 轴上的 7 处;放在后面的分支永远看不到它。接着是枚举的解构,其组织性的洞察是对应关系:模式的写法映照着变体的数据是怎么声明的,所以像 Message::Write 这样类元组的变体,就用元组形状的模式。这种对应关系在嵌套时依然成立——匹配 Message::ChangeColor(Color::Hsv(..)) 在一个表达式里穿过两层枚举,一次取出内部的三个整数——并且延伸到混合的结构体和元组。巡礼随后转向不绑定:_、_name 和 ..。作者在这里划出的区别容易错过,却举足轻重。光秃秃的 _ 匹配而不创建绑定,所以什么都不会移动,原来的值仍然可用;_s 尽管有前导下划线,却是一次真正的绑定,因此取得所有权。这恰好就是为什么 if let Some(_s) = s 会破坏后面对 s 的使用,而 Some(_) 编译得干干净净。下划线前缀是向读者表明意图,并让未使用变量的警告闭嘴;它并不改变绑定做的事。
收尾:表达能力及其价格。 本章以两个超出纯结构的特性作结,这个结尾带来的是代价,而不是一阵花哨。match 守卫附加一个普通的条件,在模式符合之后再求值,所以 Some(x) if x % 2 == 0 可以为 Some(4) 选中偶数分支——这是任何单独的模式都说不出的测试。但编译器无法对任意表达式做推理,所以它判断不了这些带守卫的分支合起来是否覆盖了每个值,也就不再尝试证明这个 match 是穷尽的。表达能力是靠放弃完整性检查换来的。作者随后从另一边展示了同样的交易:从一串 if let 分支里去掉最后的 else,有些情形就没有被处理,而编译器一声不吭。这是本章对一个自然假设最尖锐的纠正——以为 if let 只是更短的 match。不是的。match 有义务穷尽,编译器强制执行这个义务;if let 链不带这样的义务,这使它成为处理一个值的所有情形时不那么安全的工具。最后一件,@ 绑定,以更安静的调子收束,在测试的同时捕获,让一个模式一笔就约束一个值并保留它——对一个全章主题一直是“一个表达式能承载多少结构”的章节来说,是一个恰当的收尾。
关键论点,以及支撑它们的原文
模式不是只属于 match 的特殊特性;普通的 Rust 代码里到处都是模式。
作者指出,一条普通 let 语句里的名字,本来就坐在模式的位置上,然后展示了 let (x, y, z) = (1, 2, 3) 把一个元组拆成三个变量,以及函数签名 print_coordinates(&(x, y): &(i32, i32)) 直接在参数列表里解构它的实参。
“在像 let x = 5; 这样的语句里,PATTERN(模式)位置上是一个变量名,变量名只是模式一种特别简单的形式。”
“In statements like let x = 5; with a variable name in the PATTERN slot, the variable name is just a particularly simple form of a pattern.”
§19.1 All the Places Patterns Can Be Used查看原文 →
一个模式是否可能匹配失败,决定了它被允许出现在哪种结构里。
在“模式无处不在”这一观察的基础上,作者把那些要求总是匹配的模式的地方,与容忍失败的地方区分开:把可能失败的 Some(x) 放进 let,会产生 error[E0005],因为并非每个值都被覆盖;而反过来的错误,给 let...else 一个总是成功的模式,则招来一个警告,说 else 块永远不会运行。
“函数参数、let 语句和 for 循环只能接受不可反驳的模式,因为当值不匹配时,程序无法做任何有意义的事。”
“Function parameters, let statements, and for loops can only accept irrefutable patterns because the program cannot do anything meaningful when values don’t match.”
§19.2 Refutability: Whether a Pattern Might Fail to Match查看原文 →
match 分支的顺序是程序含义的一部分,因为匹配在第一个符合的分支处就停止了。
作者指出,match 一旦有模式成功就不再检查,并用 Point { x: 0, y: 7 } 来演示,它落到第二个分支,打印出它在 y 轴上的 7 处——更靠后的分支永远看不到它。
“记住,match 表达式一旦找到第一个匹配的模式,就会停止检查分支,所以尽管 Point { x: 0, y: 0 } 既在 x 轴上也在 y 轴上,这段代码在 0 处只会打印 On the x axis。”
“Remember that a match expression stops checking arms once it has found the first matching pattern, so even though Point { x: 0, y: 0 } is on the x axis and the y axis, this code would only print On the x axis at 0.”
§19.3 Pattern Syntax查看原文 →
另外 2 个论点
解构枚举的写法,要映照变体的数据是怎么声明的。
作者把两者对齐:像 Message::Write 这样类元组的变体,用元组形状的模式匹配,而这种对应关系在嵌套时依然成立——匹配 Message::ChangeColor(Color::Hsv(..)) 在一个表达式里穿过两层枚举,一次取出内部的三个整数。
“对于类元组的枚举变体,比如持有一个单元素元组的 Message::Write,以及持有一个三元素元组的 Message::ChangeColor,模式与我们为匹配元组而指定的模式类似。”
“For tuple-like enum variants, like Message::Write that holds a tuple with one element and Message::ChangeColor that holds a tuple with three elements, the pattern is similar to the pattern we specify to match tuples.”
§19.3 Pattern Syntax查看原文 →
match 守卫让你表达任何模式都说不出的条件,但代价是失去编译器的完整性检查。
作者展示了 Some(x) if x % 2 == 0 为 Some(4) 选中偶数分支,这是一个完全无法写成模式的测试;正是这种任意的条件,阻止了编译器去验证覆盖情况。从另一边看,作者指出,从一串 if let 分支里去掉最后的 else,有些情形就没有被处理,而编译器一声不吭。
“这种额外表达能力的缺点是,当涉及 match 守卫表达式时,编译器不会尝试检查穷尽性。”
“The downside of this additional expressiveness is that the compiler doesn’t try to check for exhaustiveness when match guard expressions are involved.”
§19.3 Pattern Syntax查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么有些模式在 let 里被禁止
let 绑定、函数参数或 for 循环没有可以退而求其次的分支,所以模式必须对每一个可能的值都成功;像 Some(x) 这样的模式可能失败,让变量无物可存,这就是编译器以 error[E0005] 拒绝它的原因。let...else 把这个反过来:它提供退路块,所以给它一个总是匹配的模式,会让 else 无法到达,并招来一个警告。
机制 2
守卫会关掉穷尽性检查
match 守卫是在模式符合之后求值的一个普通条件,而编译器无法对任意表达式做推理。因为它判断不了这些带守卫的分支合起来是否覆盖了每个值,所以就不再尝试证明这个 match 是完整的,于是表达能力是靠放弃完整性检查换来的。
机制 3
下划线忽略而不取得所有权
光秃秃的 _ 匹配一个值但不创建绑定,所以什么都不会被移动,原来的变量之后仍然可用。以下划线开头的名字,比如 _s,仍然是一次真正的绑定,因此仍然取得所有权,这就是为什么一个版本能编译,而另一个会破坏后面对该值的使用。
常见误读
if let 只是 match 更短的写法,所以用来处理一个值的所有情形同样安全。
match 必须是穷尽的,编译器会强制执行,而一串 if let 分支不带这样的义务;作者警告说,漏掉收尾的 else,可能让有些情形悄悄地没被处理,完全不报任何错误。
“如果我们省略最后的 else 块,从而漏掉了对某些情形的处理,编译器不会提醒我们可能存在的逻辑 bug。”
“If we omitted the last else block and therefore missed handling some cases, the compiler would not alert us to the possible logic bug.”
§19.1 All the Places Patterns Can Be Used查看原文 →
把被忽略的绑定写成 s 和写成 是一回事,因为两者都表明你不在乎这个值。
只有光秃秃的 _ 完全跳过绑定,所以什么都不移动;_s 是一次真正的绑定,会取得所有权,这就是为什么 if let Some(s) = s 会破坏后面对 s 的使用,而 Some() 编译起来毫无问题。
“_x 这种语法仍然会把值绑定到变量上,而 _ 根本不绑定。”
“The syntax _x still binds the value to the variable, whereas _ doesn’t bind at all.”
§19.3 Pattern Syntax查看原文 →
第 19 章的核心概念
- 模式绑定
- 给被匹配的值的各个部分起名字,好让代码使用它们。
- 适用处: 只能用在接受该模式可反驳性的结构里。
- 可反驳性
- 一个模式是否可能对某个可能的值匹配失败。
- 适用处: 函数参数、let 和 for 循环要求不可反驳的模式。
- let...else
- 一种 let,带一个可反驳的模式,外加一个处理不匹配情形的块。
- 适用处: 如果给它一个总是匹配的模式就会警告,因为 else 无法到达。
- match 表达式
- 一种按顺序尝试各个模式、并运行模式符合的那个分支的结构。
- 适用处: 必须是穷尽的,不像 if let。
- 遮蔽
- 新作用域里的模式变量,遮蔽了外层同名的变量。
- 适用处: 内层的绑定在该结构的作用域结束时结束。
- 字段初始化简写
- 在结构体模式里只写字段名,就会创建一个同名的变量。
- 适用处: 只有当你想让变量与字段同名时才行得通。
另外 4 个概念
- match 守卫
- 模式之后的一个额外的 if 条件,必须同样成立,这个分支才会被选中。
- 适用处: 只在 match 表达式中可用,if let 或 while let 里不行;会关闭穷尽性检查。
- @ 绑定
- at 运算符在同一个模式测试一个值的同时,把它存进一个变量。
- 适用处: 当范围或字面量的测试否则会丢掉对该值的访问时很有用。
- 兜底的下划线
- _ 匹配任何值但不绑定它,所以什么都不会被移动。
- 适用处: 不同于 _x,后者仍然会绑定,并可能取得所有权。
- 剩余模式 ..
- 忽略值中没有在模式的其他地方明确匹配的所有部分。
- 适用处: 必须没有歧义;一个元组模式里只能用一次。
第 19 章划线与批注AI 陪读
“你每次这样使用 let 语句的时候,其实都在使用模式,尽管你可能没有意识到!”
“Every time you’ve used a let statement like this you’ve been using patterns, although you might not have realized it!”
查看原文 →书最爱用的修辞手法:追溯式的揭示。它没有教你新东西,而是告诉你,你本来就在做的事有个名字。讨喜又黏人。
“因为名字 x 就是整个模式,这个模式实际上的意思是“不管值是什么,都把一切绑定到变量 x 上”。”
“Because the name x is the whole pattern, this pattern effectively means “bind everything to the variable x, whatever the value is.”
查看原文 →整章的论点在这里悄悄落地:赋值只是匹配的退化情形。一旦你看到这一点,let 就不再特殊了。
“我们不能把这两个条件合并成 if let Ok(age) = age && age > 30。”
“We can’t combine these two conditions into if let Ok(age) = age && age > 30.”
查看原文 →一个真实的疣,被坦率地承认了。绑定要到大括号打开之后才存在——作用域胜过便利。值得注意的是,后来的 Rust 版本恰恰放宽了这一点。
另外 14 条批注
“编译器不检查穷尽性,而 match 表达式则会检查”
“the compiler doesn’t check for exhaustiveness, whereas with match expressions it does”
查看原文 →if let 的老实价码。你用简洁换来的,是一次沉默——原来编译器会冲你大喊的地方。
“一旦发送端断开,就会产生一个 Err”
“produces an Err once the sender side disconnects”
查看原文 →优雅:循环的终止条件不是计数器,而是发送者的消亡。通道一端的生命周期成了控制流。
“编译器会警告不可反驳的模式,因为按定义,它们是为处理可能的失败而设的”
“the compiler warns against irrefutable patterns because, by definition, they’re intended to handle possible failure”
查看原文 →Rust 管的不只是正确性,还有意图——写一个不可能失败的条件语句,是在表明你自己的困惑,而它如实说了出来。
“这是一个新的 y 变量,不是我们在开头声明的那个值为 10 的 y”
“this is a new y variable, not the y we declared at the beginning with the value 10”
查看原文 →本章是先把它摆成一道谜题,再去解释的——很好。match 分支里的遮蔽,是人们最常读错自己代码的唯一一处。
“Rust 能判断范围是否为空的类型,只有 char 和数值”
“the only types for which Rust can tell if a range is empty or not are char and numeric values”
查看原文 →一个被表述为后果而不是规则的限制。这个限制不是随意的口味;它是编译器真正能验证的东西的边界。
“尽管 Point { x: 0, y: 0 } 既在 x 轴上也在 y 轴上,这段代码在 0 处只会打印 On the x axis”
“even though Point { x: 0, y: 0 } is on the x axis and the y axis, this code would only print On the x axis at 0”
查看原文 →分支的顺序是语义,不是风格。模式看上去是声明式的,但它们是自上而下解析的——重叠要由你来管理。
“当你需要某个类型签名,但你实现里的函数体不需要其中某个参数时,你正在实现一个 trait”
“you’re implementing a trait when you need a certain type signature but the function body in your implementation doesn’t need one of the parameters”
查看原文 →签名里用 _ 的真正理由:形状是由外部决定的。Rust 的警告是关于意图的一场对话,而 _ 就是你说“对,我是故意的”的方式。
“不应该允许用户覆盖某项设置已有的自定义值”
“the user should not be allowed to overwrite an existing customization of a setting”
查看原文 →漂亮的障眼法——一条业务规则被伪装成语法演示。Some(_) 这种匹配的存在,恰恰是为了“我在乎它在不在,不在乎它是什么”。
“_x 这种语法仍然会把值绑定到变量上,而 _ 根本不绑定。”
“The syntax _x still binds the value to the variable, whereas _ doesn’t bind at all.”
查看原文 →这是整节里唯一真正会咬人的一行。_x 看上去像个弱化的 _,其实是一次乔装打扮的完整绑定——而所有权不读命名约定。
“当涉及 match 守卫表达式时,编译器不会尝试检查穷尽性”
“the compiler doesn’t try to check for exhaustiveness when match guard expressions are involved”
查看原文 →代价说得很直白,然后就放下了。你换来了任意的表达能力,付出的是穷尽性保证——而那正是 Rust 对 match 的全部卖点。
“match 守卫 if n == y 不是模式,因此不会引入新变量。”
“The match guard if n == y is not a pattern and therefore doesn’t introduce new variables.”
查看原文 →这个解法是概念性的,不是机巧的:把比较从模式语言里挪到普通表达式的领地。模式绑定;条件读取。一旦你看到这一行,示例 19-11 的陷阱就不再神秘了。
“4 | 5 | 6 if y => println!("yes"),”
查看原文 →优先级的问题,只有靠有人把带括号的版本写下来才能定案。书老实承认它“看上去可能像”另一种读法——这恰恰是你会交付出去的那个 bug。
“变量名 second 对 Rust 来说没有任何特殊含义”
“The variable name second doesn’t mean anything special to Rust”
查看原文 →对那位以为给东西起个名就让它有意义的读者的一点温和的讽刺。编译器拒绝这个模式,不是因为它错了,而是因为它没有唯一的答案——是歧义,不是错误。
“使用 @ 让我们可以在一个模式里测试一个值,并把它保存到变量中。”
“Using @ lets us test a value and save it in a variable within one pattern.”
查看原文 →前面两段是靠展示没有 @ 你会失去什么来解释它的——一个知道自己匹配上了、却不知道匹配上了什么的范围分支。在这里,用缺席来教,比下定义更管用。
读后感AI 陪读
一个不可能失败的 if let,是一种坦白
这一章是 Rust 不再像一门有个聪明的 match 语句的语言,而开始像一门建立在一个到处应用的单一思想之上的语言的地方。它从列举模式所在的地方开篇——match 分支、let、if let、while let、for、函数参数——这份列举本身就是论证:如果 let x = 5 是一个模式,那么绑定从来就不是一个单独的特性,只是用一个不会失败的模式做匹配。前半部分结尾的可反驳/不可反驳之分并不是官僚式的琐事;它是决定你被允许在哪里写哪种结构的规则,而编译器对不可反驳的 if let 发出的警告表明,Rust 不只管正确性,也管意图——一个不可能失败的条件语句就是一种坦白,编译器也把它当作坦白来读。后半部分是语法图集:用字段简写或字面量解构结构体,嵌套的枚举,结构体里的元组里的结构体,用 _、_name 和 .. 处理你不想要的部分,用守卫表达模式语言说不出的条件,以及用 @ 处理你需要同时测试和捕获的情形。它读起来像参考手册,但排序却像一个论证,每一件工具都是由没有它就做不成的事引出来的——尤其是 @,是用缺席来教的,用那个知道自己匹配上了、却不知道匹配上了什么的范围分支。
我把它当作一个整体读下来,看穿的是:本章对一项它从未明说的权衡,悄悄地很老实。模式看上去是声明式的,实际上是过程式的。分支的顺序就是语义:原点上的一个点只会打印“On the x axis”,因为是自上而下的解析,而不是逻辑,选出了赢家,管理重叠是你的事,不是编译器的。而且两次——对 if let,然后对 match 守卫——书明明白白地说了你失去了穷尽性检查,而那正是 match 值钱的全部理由。这才是本章真正的形状:表达能力的扩张,一步一步地,用编译器的沉默换来。书说了出来,没有渲染,然后往前走。同样老实的,是承认 if let Ok(age) = age && age > 30 不管用,因为绑定要到大括号打开之后才存在,还有范围模式只限于 char 和数字,因为只有这些类型的空与否是编译器能验证的——这些限制被呈现为可检查之物的后果,而不是口味。(后来的 Rust 放宽了其中的第一条;这个疣背后的道理仍然值得理解。)
继续阅读
我能给出的最尖锐的判断是:本章里的陷阱全是相似性的陷阱,而不是难度的陷阱。_x 看上去像一个较弱的 _,其实是一次完整的绑定,会移动所有权,因为借用检查器不读命名约定。在 match 分支里绑定的 y 看上去像外层作用域里的那个 y,其实是一个遮蔽了它的新变量——这就是为什么守卫 if n == y 是解法,也是为什么这个解法是概念性的,而不是机巧的:模式绑定,条件读取,当你想和已经存在的东西比较时,就必须离开模式语言。4 | 5 | 6 if y 看上去好像可能会把守卫绑定到最后一个备选项上,其实没有。每一种情况里,代码编译通过或者失败的原因都是一眼看不出来的,唯一可靠的防御,就是知道一句话的哪一半是模式,哪一半是表达式。
所以我会这样告诉读者:别把它当作一个浏览一下、以后再查的语法附录。把它读成追溯性地解释了书里其余部分的那一章——你已经写过的每一个 let、每一个 for、每一个函数签名。然后在脑子里留着一句话当作决策程序:模式绑定,条件读取。 对你写的任何结构,问问自己你是在解构形状,还是在求值真假,上面大多数的困惑都会在你交付之前消散。而当你伸手去拿 if let 或守卫时,请留意那张账单。简洁是真的,而且往往是对的;只是要知道,你为它付出的代价,是一次编译器本来会替你做的检查。
在 FeedLitra 里读第 19 章,每段旁边都有这些卡片。
开始读书第 20 章:高级特性
剩下的 unsafe 超能力(unsafe trait、联合体字段)是怎么运作的?
第 20 章是怎样展开的AI 辅助
开篇:一个先给出理由、再去使用的逃生口。 本章自称是对高级特性的一次概览,随后立刻收窄到最需要辩护的那一个:unsafe Rust。这里的顺序是有意的。在展示任何一个裸指针之前,作者先解释了为什么根本必须有一个逃生口,而论证取自静态分析的本性。Rust 的检查都在程序运行之前进行,所以编译器必须事先判断代码是否守住了它的保证;当它没有足够的信息来证明这一点时,它就拒绝。作者把这种保守框定为一个有意的取舍——拒掉一些其实正确的程序,比放过一个内存 bug 代价更小——并把 unsafe 呈现为举证责任从编译器转移到程序员的那个点。只有在这之后,机制才登场:这个关键字解锁的五种超能力、裸指针,以及对 unsafe 函数的调用。这个先后顺序之所以要紧,是因为它先发制人地挡住了本章最可能的误读。unsafe 不是一个关掉安全机制的开关;它恰恰授予五种能力,别无其他,借用检查器仍然在检查它们周围的一切。裸指针让这种狭窄变得具体——创建一个是普通的安全代码,只有解引用才需要这个关键字。
第一段展开:以封装约束作为工作纪律。 从赤裸的机制出发,本章转向让它们可以忍受的实践,而 split_at_mut 就是那个演练案例。不安全的操作位于一个函数内部的一小块里,而这个函数的签名承诺安全,所以没有任何调用者需要写 unsafe。作者的理由朴实而务实:把区域保持得很小,你去找的时候,任何内存 bug 都是局部的。但这个例子带着一个更尖锐的要点,关于安全究竟从哪里来。函数里对“分割点落在切片长度之内”的断言,保证了它构造的每个裸指针都是有效的——这个承诺之所以成立,是因为实现确实强制了这个条件,而不是因为签名这么说。作者指出,同样的底层调用若指向一个任意地址,就是未定义行为,以此强调这一点。周边的内容把同一片领域向外延伸:extern 和双向的 FFI,可变静态变量,然后是 unsafe trait 和联合体。
unsafe 讨论中的转折:工具能解决什么,不能解决什么。 Miri 为这一段收尾,而且是以一种谨慎克制的调子收尾。作者的要点是不对称。Miri 只检查实际运行的代码,所以一份干净的报告对没走过的路径什么也没说;当它确实标出什么时——就像它在本章例子里对整数转指针和悬垂引用所做的那样——一个真实的 bug 就存在。它证实的是出问题了,从不证明完好。作者仍然建议运行它,这是老实的结论:一个只给出单边判决的工具,仍然值得拥有。
第二幕:高级 trait,在这里设计选择是被比较的,而不是被罗列的。 本章转向 trait,从关联类型开始,而呈现方式是对比式的。用泛型参数的话,一个计数器类型可以同时为许多元素类型实现迭代器 trait,这就迫使每个调用点都要加标注;关联类型只允许一种实现,所以编译器无需帮助就能推断出元素类型。从这场对比提炼出的规则是:当一个类型应该恰好实现一次某个 trait 时,关联类型是合适的工具。从这里开始,本章依次走过用于运算符重载的默认类型参数、用来消除同名方法歧义的完全限定语法、父 trait——然后抵达 newtype 模式,它得到了一份持平的账本。因为孤儿规则禁止在外部类型上实现外部 trait,作者把一个字符串向量包进一个本地结构体里,并通过它唯一的字段实现 display。收益是外部 trait 的实现、单位的区分,以及隐藏的内部;代价是包装器不继承向量的任何方法,除非你手工转发,或者实现解引用来整体委托。
第三幕:类型,以及第二个陷阱。 随后是类型别名,而它们的位置恰好是为了被误认为 newtype——这就是作者当场纠正这种混淆的原因。别名只是同一个类型的另一种拼写,所以不会出现额外的检查:通过别名声明的值可以与一个普通整数相加,也可以传给任何接受该整数的地方。别名的存在是为了缩短长类型名;让混用的单位变成编译错误的,是那个单字段的包装结构体,一个真正不同的类型。接下来是 never 类型 ! 和动态大小类型,而 DST 的解释是本章最清楚的一段内存推理:一个类型的每个值都必须占据同样的空间,可字符串切片却各不相同——一个需要 12 字节,另一个需要 15 字节。所以光秃秃的 str 不能是一个变量,而引用存储的是一个地址加一个长度,两个大小已知的机器字,正是这份元数据让值变得可用。Sized 和 ?Sized 把这条规则形式化。fn 类型的函数指针补全了这一节,具名函数和枚举的初始化器可以代替闭包,作为 map 的实参;而闭包朝另一个方向走,作为返回值时,必须通过 impl Trait 或 Box<dyn Fn> 离开,因为每一次使用 impl Trait 都会产生它自己独特的不透明类型。
收尾:宏,以及被老实说出来的代价。 本章以元编程结束,并且在开启这一节时,先确立了宏在哪些地方不是可选项。构建向量的那个宏接受任意数量、任意类型的实参,这是任何单一函数签名都表达不了的;宏还在类型检查之前展开,所以它们可以生成像为某个类型实现 trait 这样的东西。作者把代价和能力摆在一起——宏的定义更难读、更难维护,而且必须在被调用之前就处于作用域内——这让这一节读起来不像是在鼓吹。先是声明式的 macro_rules!,然后是用 proc_macro、syn 和 quote 构建的三种过程宏。自定义 derive 的例子用 quote!、stringify! 和 into 一直做到了完成,之后简要对比了类属性的和类函数的过程宏,本章就此打住。整体的形状值得留意:本章以论证 Rust 规则的一个例外开篇,以论证 Rust 常规编译的一个例外收尾,两个例外都用说出来的代价加以对冲,而不是被兜售。
关键论点,以及支撑它们的原文
Rust 需要一个 unsafe 模式,因为它的编译器有意拒绝那些它无法证明正确的、其实正确的程序。
作者从静态分析的工作方式出发来论证:检查器必须在运行之前就做出判断,而当它缺少信息时,它宁可拒绝,也不冒险放进一个内存 bug。这种保守意味着一些其实完全没问题的代码被拒之门外,而 unsafe 就是程序员转而承担责任的方式。
“尽管代码可能没问题,但如果 Rust 编译器没有足够的信息来确信,它就会拒绝这段代码。”
“Although the code might be okay, if the Rust compiler doesn’t have enough information to be confident, it will reject the code.”
§20.1 Unsafe Rust查看原文 →
由此推出,unsafe 代码应该保持在尽可能小的块里,并封在一个安全的接口之内。
作者的实际理由是调试:小的 unsafe 区域让你去找的时候,任何内存 bug 都是局部的。演练的案例是一个切分切片的函数,它内部使用裸指针,却不需要 unsafe 标记,因为它的长度检查使这些指针可证明有效——而同样的底层调用若指向一个任意地址,就是未定义行为。
“让 unsafe 块保持小;以后你调查内存 bug 时会感谢自己的。”
“Keep unsafe blocks small; you’ll be thankful later when you investigate memory bugs.”
§20.1 Unsafe Rust查看原文 →
工具可以证实 unsafe 代码有问题,却永远不能证明它是健全的。
作者指出,Miri 只检查一次运行中实际执行到的代码,所以一份干净的报告对没走过的路径什么也没说;当它确实标出什么时——就像它在本章例子里对整数转指针和悬垂引用所做的那样——一个真实的 bug 就存在。这个判决是不对称的,而作者仍然建议运行它。
“换句话说:如果 Miri 确实抓到了一个问题,你就知道有 bug,但仅仅因为 Miri 没抓到 bug,并不意味着没有问题。”
“Put another way: If Miri does catch a problem, you know there’s a bug, but just because Miri doesn’t catch a bug doesn’t mean there isn’t a problem.”
§20.1 Unsafe Rust查看原文 →
另外 3 个论点
转到 trait,当一个类型应该只实现一次某个 trait 时,关联类型是合适的工具。
作者比较了几种设计:用泛型参数的话,一个计数器类型可以同时为许多元素类型实现迭代器 trait,迫使你在每次调用时都标注元素类型。关联类型让唯一一种实现成为唯一的选项,所以编译器自己就能推断出元素类型。
“有了关联类型,我们不需要标注类型,因为我们不能对一个类型多次实现同一个 trait。”
“With associated types, we don’t need to annotate types, because we can’t implement a trait on a type multiple times.”
§20.2 Advanced Traits查看原文 →
把一个类型包进单字段结构体里,能买到外部 trait 的实现、单位的区分和隐藏的内部,代价是失去内部类型的方法。
由于孤儿规则禁止在外部类型上实现外部 trait,作者把一个字符串向量包进一个本地结构体里,并通过那个唯一的字段在它上面实现 display。代价是包装器没有向量自己的任何方法,除非你手工转发,或者实现解引用来整体委托。
“使用这种技巧的缺点是,Wrapper 是一个新类型,所以它没有它所持有的那个值的方法。”
“The downside of using this technique is that Wrapper is a new type, so it doesn’t have the methods of the value it’s holding.”
§20.2 Advanced Traits查看原文 →
宏在函数够不着的地方赢得了自己的位置:可变数量的参数,以及必须在编译器解释含义之前就存在的代码。
构建向量的那个宏接受任意数量、任意类型的实参,这是任何单一函数签名都描述不了的;宏还在类型检查之前展开,所以它们可以生成像为某个类型实现 trait 这样的东西。作者拿代价来平衡这一点——宏的定义更难读、更难维护,而且必须在被调用之前就带入作用域。
“此外,宏是在编译器解释代码含义之前展开的,所以宏可以,比如说,为某个给定的类型实现一个 trait。”
“Also, macros are expanded before the compiler interprets the meaning of the code, so a macro can, for example, implement a trait on a given type.”
§20.5 Macros查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
为什么保守的编译器需要一个逃生口
Rust 的检查在程序运行之前就进行了,所以当编译器缺少信息、无法证明一段代码守住了它的保证时,它就拒绝这段代码。作者指出,这种倾向是有意为之的:拒绝一些有效的程序,比接受一个无效的程序代价更小。unsafe 关键字就是由程序员而不是编译器来承担证明的地方。
机制 2
把经过审查的 unsafe 代码包进安全的 API 里
不安全的操作被限制在一个函数内部的一小块里,而这个函数的签名承诺安全,所以调用者自己永远不必写 unsafe。作者的 split_at_mut 就是这么做的:对“分割点在切片长度之内”的断言,保证了它构造的每个裸指针都是有效的,所以这个函数可以保持调用上的安全。这个承诺之所以成立,只是因为实现真的强制了这个条件——同样的 raw-parts 调用若指向一个任意地址,就是未定义行为。
机制 3
为什么无大小的值必须放在指针后面
给定类型的每个值都必须占据同样多的内存,但字符串切片的长度各不相同——作者把一个需要 12 字节的和另一个需要 15 字节的做了对比。所以光秃秃的 str 不能是一个变量;指向它的引用存储的是一个地址加一个长度,两个大小已知的机器字,这份元数据才让这个值得以使用。
常见误读
写下 unsafe 就关掉了 Rust 的安全机制,所以在那个块里通常的规则不再适用。
这个关键字恰恰授予五种额外的能力,别无其他;借用检查器和其他检查仍然在检查块里的其余一切。作者用裸指针来说明这种狭窄:你可以在普通的安全代码里随意创建它们,只有解引用它们这个动作才需要 unsafe。
“必须明白,unsafe 并不会关掉借用检查器,也不会停用 Rust 的任何其他安全检查:如果你在 unsafe 代码里使用一个引用,它仍然会被检查。”
“It’s important to understand that unsafe doesn’t turn off the borrow checker or disable any of Rust’s other safety checks: If you use a reference in unsafe code, it will still be checked.”
§20.1 Unsafe Rust查看原文 →
用 type 关键字给一个类型起第二个名字,是获得包装结构体所提供的那种单位安全的一种轻量办法。
别名只是同一个类型的另一种拼写,所以不会出现额外的检查:用别名声明的值可以和一个普通整数相加,也可以传到期望该整数的地方。别名的存在是为了减少长类型名带来的重复;让混用单位成为编译错误的,是那个单字段的包装结构体,因为它是一个真正不同的类型。
“换句话说,如果我们在某个地方把 Kilometers 和 i32 的值混在一起,编译器不会给我们报错。”
“In other words, if we mix up Kilometers and i32 values somewhere, the compiler will not give us an error.”
§20.3 Advanced Types查看原文 →
第 20 章的核心概念
- Unsafe Rust
- Rust 内部的第二门语言,不强制内存安全保证,用 unsafe 关键字进入。
- 适用处: 它不会关掉借用检查器或其他检查;它只解锁五种操作。
- 裸指针
- *const T 和 *mut T 这两种指针类型,可以无视借用规则、可以为空,也可以指向无效内存。
- 适用处: 可以在安全代码里创建,但解引用需要 unsafe 块。
- 对 unsafe 代码的安全抽象
- 把经过审查的 unsafe 操作包进一个带安全 API 的函数里,让调用者永远不必写 unsafe。
- 适用处: 只有当实现真正保证了契约时才成立,就像 mid <= len 这个断言那样。
- 外部函数接口(FFI)
- extern 机制,让 Rust 调用其他语言的函数,也让它们调用 Rust 的函数。
- 适用处: extern 块必须标为 unsafe,不过其中单个条目可以标为 safe 作为一种承诺。
- 可变静态变量
- 一个有固定内存地址的全局变量,可以声明为 mut;读取或写入它是 unsafe 的。
- 适用处: 访问不可变的静态变量是安全的;只有可变静态变量因为数据竞争才需要 unsafe。
- Unsafe trait
- 当某个方法有一个编译器无法检查的不变量时,这个 trait 就是 unsafe 的,所以 trait 和它的 impl 都需要 unsafe 关键字。
- 适用处: Send 和 Sync 是常见的情形,比如持有裸指针的类型。
另外 22 个概念
- 联合体(Union)
- 一种类似结构体的类型,同一时刻只有一个声明的字段在使用;读取字段是 unsafe 的。
- 适用处: 主要用于与 C 的联合体交互。
- Miri
- 一个官方的动态工具,在 nightly Rust 上运行,在你的程序或测试真正运行时检测未定义行为。
- 适用处: 只能抓住实际执行到的代码里的问题,而且并非每一种不健全都能抓到。
- 关联类型
- trait 内部的一个类型占位符,每个实现者恰好填写一次,比如 Iterator 里的 Item。
- 适用处: 与泛型不同,带关联类型的 trait 只能为一个类型实现一次。
- 默认类型参数
- <PlaceholderType=ConcreteType> 这种语法,比如 Add<Rhs=Self>,给泛型参数一个后备类型。
- 适用处: 用于在不破坏代码的前提下扩展一个 trait,或者允许少见的自定义。
- 运算符重载
- 通过实现 std::ops 里对应的 trait(比如 Add)来自定义像 + 这样的运算符。
- 适用处: 你不能发明新的运算符,也不能重载任意的运算符。
- 完全限定语法
- 写成 <Type as Trait>::function(),在名字冲突时说明你指的是哪个实现。
- 适用处: 主要用于没有 self 的关联函数,此时类型无法被推断。
- 父 trait
- 一个被另一个 trait 所要求的 trait,所以实现者必须同时满足两者,外层的 trait 可以调用内层 trait 的条目。
- 适用处: 声明为
trait OutlinePrint: fmt::Display;没有它的话,内层 trait 的方法就不在作用域里。 - 孤儿规则
- 只有当 trait 或类型有一个是你自己 crate 里的本地项时,你才可以为这个类型实现这个 trait。
- 适用处: newtype 包装器可以绕开它,因为包装器本身是本地的。
- newtype 模式
- 把一个类型包进一个单字段的元组结构体里,以实现外部 trait、强制单位、或隐藏内部。
- 适用处: 包装器没有内部类型的方法,除非你委托或实现 Deref;没有运行时开销。
- 类型别名
- 通过
type关键字给现有类型起的第二个名字;不是一个独立的类型。 - 适用处: 不提供额外的类型检查——把 Kilometers 和 i32 混用,照样能编译。
- never 类型
- 类型
!,没有任何值,用作永不返回的函数的返回类型。 - 适用处: ! 类型的表达式可以强制转换成任何类型,这就是 continue、panic! 和无限循环能放进 match 分支的原因。
- 动态大小类型
- 像 str 这样的类型,或者一个 trait,其大小只有在运行时才知道。
- 适用处: 必须放在携带大小元数据的指针后面,比如 &str、Box<str>、Box<dyn Trait>。
- Sized trait
- 自动的标记 trait,用于编译期已知大小的类型;对每个泛型都是隐式约束。
- 适用处:
?Sized放宽了它,也是唯一允许使用 ?Trait 语法的 trait;此时参数必须放在指针后面。 - 函数指针
fn类型,具名函数可以强制转换成它,让你在本该放闭包的地方传入现有的函数。- 适用处: fn 是类型,不是 trait;它实现了 Fn、FnMut 和 FnOnce,所以除了与 C 交互之外,通常更适合用针对闭包 trait 的泛型。
- 变体构造函数
- 每个枚举变体的名字同时也是一个初始化函数,可以用在期望闭包的地方。
- 适用处: 只适用于带数据的变体;编译出来的代码与等价的闭包相同。
- 不透明类型
- Rust 为每一次单独使用 impl Trait 而虚构出来的那个隐藏的具体类型。
- 适用处: 两个返回相同 impl Trait 的函数,类型仍然互不兼容;用 Box<dyn Fn> 来统一它们。
- 声明式宏
- macro_rules! 宏,对照源代码的结构匹配模式,并在编译期替换成相应的代码。
- 适用处: 模式匹配的是代码结构,而不是值,所以第19章的模式语法在这里不适用。
- 过程宏
- 一个接收 TokenStream 并返回 TokenStream 的函数,有三种:自定义 derive、类属性的和类函数的。
- 适用处: 必须放在自己的、带 proc-macro = true 的 crate 里,出错时必须 panic 而不是返回 Result。
- quote! 宏
- 一个让你把想要输出的 Rust 代码写成模板的宏,用 #name 替换变量。
- 适用处: 它的输出是一种中间表示,而不是 TokenStream,所以必须用 into 转换。
- stringify! 宏
- 一个内建宏,在编译期把一个 Rust 表达式变成字符串字面量,而不是对它求值。
- 适用处: 不同于 format!/println!,后者先求值再分配一个 String。
- 类属性宏
- 过程宏,定义你可以附加到条目上的全新属性,而不只是 derive 的输出。
- 适用处: 接收两个 TokenStream:属性的内容,以及被标注条目的主体。
- 类函数宏
- 像函数调用一样被调用的过程宏,用完整的 Rust 代码解析任意的 token。
- 适用处: 当处理超出了类似 match 的模式语法所能做到的时候,选它而不是 macro_rules!。
第 20 章划线与批注AI 陪读
“Rust 内部藏着第二门语言,它不强制执行这些内存安全保证”
“Rust has a second language hidden inside it that doesn’t enforce these memory safety guarantees”
查看原文 →一个被框成特性的惊人坦白。整个安全的品牌都建立在一扇一直存在的门上——书只是等了 19 章才让你看到。
“宁可拒绝一些有效的程序,也比接受一些无效的程序要好”
“it’s better for it to reject some valid programs than to accept some invalid programs”
查看原文 →静态分析诚实的内核:编译器并不聪明,它只是悲观。unsafe 是悲观被人推翻的地方。
“unsafe 并不会关掉借用检查器,也不会停用 Rust 的任何其他安全检查”
“unsafe doesn’t turn off the borrow checker or disable any of Rust’s other safety checks”
查看原文 →纠正每个人直觉的一句话。unsafe 不是一种模式,而是五把具体的钥匙——比这个词所暗示的窄得多。名字起得糟,设计得好。
另外 17 条批注
“让 unsafe 块保持小;以后你调查内存 bug 时会感谢自己的。”
“Keep unsafe blocks small; you’ll be thankful later when you investigate memory bugs.”
查看原文 →这个关键字真正的理由不是预防,而是可以 grep。unsafe 是一件伪装成权限系统的调试辅助。
“借用切片的不同部分从根本上说是可以的,因为这两个切片并不重叠,但 Rust 没有聪明到能知道这一点。”
“Borrowing different parts of a slice is fundamentally okay because the two slices aren’t overlapping, but Rust isn’t smart enough to know this.”
查看原文 →split_at_mut 是完美的教学案例:程序员的证明微不足道(mid ≤ len),编译器的证明则不可能。安全最终落在一个由人检查过的断言上。
“把一个函数标记为安全,并不本质上让它安全!”
“Marking a function as safe does not inherently make it safe!”
查看原文 →safe 是一种承诺,而不是一种属性——和 unsafe 是同一个把戏,只是反过来。这里 Rust 的关键字是言语行为,而不是验证。
“每当我们写一个 unsafe 函数时,惯例是写一条以 SAFETY 开头的注释”
“Whenever we write an unsafe function, it is idiomatic to write a comment starting with SAFETY”
查看原文 →令人吃惊的是,契约住在一条注释里。在类型系统所能表达的边界上,Rust 退回到了散文——只靠文化来强制。
“借用检查器是一个在编译期工作的静态工具,而 Miri 是一个在运行时工作的动态工具。”
“Whereas the borrow checker is a static tool that works at compile time, Miri is a dynamic tool that works at runtime.”
查看原文 →一旦你走出编译器的证明,你就回到了测试——恰恰是 Rust 自称已经超越的那种体制。Miri 悄悄地恢复了旧的交易。
“如果 Miri 确实抓到了问题,你就知道有 bug,但 Miri 没抓到 bug,并不意味着没有问题”
“If Miri does catch a problem, you know there’s a bug, but just because Miri doesn’t catch a bug doesn’t mean there isn’t a problem”
查看原文 →诚实的不对称,说得很直白:这个工具大致可靠,但从不完备。教程很少承认自己的安全网有洞。
“关联类型处在中间:它们比书中其余部分讲的特性用得少,但比本章讨论的许多其他特性更常用”
“Associated types are somewhere in the middle: They’re used more rarely than features explained in the rest of the book but more commonly than many of the other features discussed in this chapter”
查看原文 →书不断按使用频率给自己的内容打分。一个安静的教学承诺:你可以略读,但这里别略读。
“有了关联类型,我们不需要标注类型,因为我们不能对一个类型多次实现同一个 trait”
“With associated types, we don’t need to annotate types, because we can’t implement a trait on a type multiple times”
查看原文 →整堂课就在一个分句里:限制就是好用。放弃通用性,在每一个调用点换来了沉默。
“为了在不破坏现有代码的前提下扩展一个类型”
“To extend a type without breaking existing code”
查看原文 →默认类型参数其实是伪装成类型工具的版本管理工具——向后兼容被偷渡进了类型系统。
“Millimeters(self.0 + (other.0 * 1000))”
查看原文 →有意思的是,自定义 Rhs 的经典例子是单位换算——newtype 模式悄悄地防止了一类“火星轨道器”式的 bug。
“因为 Animal::baby_name 没有 self 参数,而且可能有其他类型实现了 Animal trait,Rust 无法弄清楚该用哪个实现”
“Because Animal::baby_name doesn’t have a self parameter, and there could be other types that implement the Animal trait, Rust can’t figure out which implementation”
查看原文 →没有 self 就是整个故事。方法分派不是魔法,只不过是多一份证据——把它拿掉,编译器就两眼一抹黑。
“这种技巧类似于给 trait 添加一个 trait 约束”
“This technique is similar to adding a trait bound to the trait”
查看原文 →父 trait 去神秘化:不是继承,只是处在不同位置的一个约束。值得抵制“super”这个词所邀请的面向对象读法。
“使用这种技巧的缺点是,Wrapper 是一个新类型,所以它没有它所持有的那个值的方法”
“The downside of using this technique is that Wrapper is a new type, so it doesn’t have the methods of the value it’s holding”
查看原文 →绕开孤儿规则的办法收取了样板代码的过路费。然后又拿 Deref 当作逃生口——而它本身恰恰是 Rust 在别处警告不要滥用的做法。
“newtype 模式是实现封装、隐藏实现细节的一种轻量方式”
“The newtype pattern is a lightweight way to achieve encapsulation to hide implementation details”
查看原文 →一个零成本的包装器,干着别处私有字段和模块干的事——别的语言伸手去拿访问关键字的地方,Rust 一再伸手去拿类型系统。
“如果我们在某个地方把 Kilometers 和 i32 的值混在一起,编译器不会给我们报错”
“if we mix up Kilometers and i32 values somewhere, the compiler will not give us an error”
查看原文 →老实的承认:别名买来了可读性,在安全上什么也没换回来。给一个东西起名,不等于让它成为一个东西。
“thunk 是指稍后才会被求值的代码的一个说法”
“thunk is a word for code to be evaluated at a later time”
查看原文 →一个安静的论点:命名是设计工作,不是装饰——别名告诉读者这个类型是拿来干什么的,而完整的写法从来没做到这一点。
“可是,一个你永远没法创建值的类型,有什么用?”
“But what use is a type you can never create values for?”
查看原文 →好招——在读者嘟囔出声之前,先把他们的怀疑大声问出来。答案很可爱:! 恰恰因为它是空的才有用,所以它能强制转换到任何地方而不撒谎。
读后感AI 陪读
讲了十九章安全之后,Rust 承认它有一个逃生口
这一章以一个不是附录的附录的姿态出现:用了十九章教你信任编译器之后,书打开了一道活板门,承认 Rust 里面还有第二门语言,那里所有的保证都不成立。它的展开是有意安排的——先是逃生口(裸指针、unsafe fn、双向的 FFI、可变静态变量、unsafe trait、联合体),然后是为构建库而不是构建程序的人准备的表达机制(关联类型、默认类型参数、完全限定语法、父 trait、newtype、类型别名、never 类型、?Sized、函数指针、返回闭包),最后是元编程:macro_rules! 和三种过程宏,建立在 proc_macro、syn 和 quote 之上。一路读下来,这是 Rust 不再是一门安全的语言、而成为一门用来构建安全之物的语言的那一章——受众的转变多于难度的转变。
我反复看穿的是,“unsafe”这个词起得很糟,却设计得很好。它什么都没有关掉;借用检查器照旧运行;它恰恰授予五种特定的能力。它真正的价值不是预防,而是可搜索——在人的证明代替机器的证明的区域上,一个可以 grep 的标记。而这种替代是本章老实的核心。split_at_mut 是完美的例子:mid ≤ len 对人来说微不足道,对编译器来说却不可能,于是安全落在了一个由人检查过的断言上。这类函数的契约活在一条 //SAFETY 注释里,只靠文化来强制。“把一个函数标成安全,并不本质上让它安全”是同一个把戏的反面——这些关键字是言语行为,是承诺,而不是验证。Miri 以少有的坦率补全了这幅图景:一旦你离开编译器的证明,就回到了测试的体制里,命中意味着有 bug,没命中则什么也说明不了。Rust 著名的交易有一个底线,而本章让你看到了这个底线。
继续阅读
讲高级 trait 的那一半与此更安静地押韵,而这个押韵是我最尖锐的判断:Rust 最好的特性通常是被当作便利出售的限制。关联类型比泛型更好用,恰恰因为你放弃了对同一个 trait 实现两次的可能——这个约束在每一个调用点买来了沉默。默认类型参数是一件偷渡进类型系统的版本管理工具,让你扩展而不破坏。父 trait 不是继承,尽管“super”这个词在邀请面向对象的读法;它们是处在不同位置的一个约束。newtype 模式用一个零成本的包装器做了别的语言用访问关键字做的事,并免费得到了类型安全——Millimeters 加 Meters 是编译器现在能抓到的“火星轨道器”式 bug——同时在样板代码上收取了实打实的过路费,而书在别处警告过的做法,Deref,在这里被当作逃生口提了出来。相比之下,类型别名是老实的反面例子:Kilometers = i32 买来了可读性,在安全上却什么也没换回来。给一个东西起名,不等于让它成为一个东西。而 never 类型是所有反转中最可爱的一个——一个恰恰因为它是空的才有用的类型,能够强制转换到任何地方而从不撒谎。
我会这样告诉读者:本章是按使用频率给自己打分的,你应该认真对待这个分级,而不是把它全当作同等高级。关联类型、newtype,以及通过 impl Trait 或 Box<dyn Fn> 返回闭包,你真的会用到。联合体、可变静态变量和函数式过程宏,你多半是在别人的代码里读到。把 unsafe 的内容当作先读后写的技能——真正要紧的纪律是:保持代码块很小,把 SAFETY 注释写得就像会有陌生人来审计一样,并把每一块 unsafe 区域包进一个安全的抽象里,让不安全止步于一道边界,而不是向外泄漏。关于宏,请留意本章点名却没有细说的那项权衡:宏给你的是任意数量的参数和会写代码的代码,代价是定义比函数更难读、更难维护,再加上过程宏要另外一整个 crate。这就是本章的教训缩影——这里的每一种能力都是拿东西换来的,而 Rust 的与众不同在于,它让你看到价格。
在 FeedLitra 里读第 20 章,每段旁边都有这些卡片。
开始读书第 21 章:最终项目:构建多线程网络服务器
if/else 块中重复的读文件代码将如何重构?
第 21 章是怎样展开的AI 辅助
开篇:确定项目的前提。 本章一上来就宣布自己是全书的最终项目,并在写任何代码之前勾勒计划。开头就做了两个框架性的选择,它们塑造了之后的一切:不用外部 crate,也不用 async。作者承认,生产品质的线程池和服务器包已经存在,并且可以自由获取——却有意把它们搁在一边。理由不是库不好,而是这是一门系统语言,程序员可以自己选择往多低的层次去做;在这里,回报是理解这套机制,而不是快速交付。这一点定下来之后,本章回顾了 TCP 和 HTTP 这两种请求—响应协议,然后把一个监听器绑定到 127.0.0.1:7878。第一个版本读取传入的请求行,并回写一个固定的 200 响应,提供 hello.html——不管被问到什么,都是同一个页面。这个有意为之的缺陷马上就被纠正了:请求的第一行与根路径 GET 的确切文本作比较,匹配就提供问候页面,其他一切则收到 404 状态行和一个单独的错误页面。要点是,一个真正的服务器必须读取请求,并据此作出不同的回答。
第一段展开:重构,然后故意把它弄坏。 一旦出现第三种情形,if/else 就变成了模式匹配——对一个切片做匹配,因为匹配不会替你解引用——而两个响应分支则坍缩成一个元组解构,同时选出状态行和文件名。代码现在干净而且能用,而恰恰在这个时候,作者破坏了它。一个特殊的 /sleep 路径在回复之前暂停五秒。请求这个路径,然后立刻请求主页,主页会整整闲置五秒:那唯一的线程仍被第一个请求占着。一个接一个地处理请求,意味着一个慢请求会冻住排在它后面的所有人。显而易见的补救——每个连接派生一个线程——试过了,而且确实管用,睡眠请求运行的同时主页加载了出来。但没有任何东西限制创建多少个线程,所以流量够大时,机器最终会被压垮。选出这个设计的,是这项约束,而不是优雅:一小群固定的、等待的线程,从一个共享队列里取任务,既保住了并发,又给资源使用设了一个硬上限。
转折:让编译器来规定结构。 本章的方法在这里变了。作者没有自底向上地构建 ThreadPool,而是写出了他们希望能调用的代码——以理想的形式创建线程池、提交任务——并坦率承认它不会编译。随后每一个编译器的抱怨都规定了下一块要加的东西:一个构造函数,一个带有文档化 panic 条件的大小检查,以及从标准库派生线程的函数里抄来的闭包约束,这正是“只能调用一次、可发送、不借用”这些要求的来源。一个 Worker 类型出现,用来拥有每个派生出来的线程。有两个警告值得带着往前走。第一,在项目编译得干干净净、却绝对什么也没做的那个阶段,作者直截了当地指出,编译器驱动的开发只能把你带到通过类型检查的代码——它对行为只字不提,而做完的线程池仍然依赖出错就崩溃的捷径,真正的代码会把它们换成妥善的错误处理。第二,线程池的全部目的就是那个上限:有 N 个线程,第 N+1 个请求仍然要在队列里等着。这个修法消除了单线程的瓶颈;它并没有取消等待。
第二段展开:让线程池真正运转的三个机制。 本章随后依次面对真正的困难。通道的接收端只有一个消费者,也不能被复制,所以在派生循环里把它交给每个 worker,会把它移走,编译器会拒绝第二次迭代;把它包进 Arc<Mutex<...>>,每个 worker 就都能拥有一个句柄,而同一时刻只有一个在取任务。execute 把每个闭包装箱成一个 Job,并通过通道发送出去。接下来是一个容易错过的微妙之处:这个互斥锁没有手动解锁,所以锁恰好持续到守卫值存活的那么久。一个普通的 let 在语句结束时就丢弃守卫,所以任务是在锁已释放的情况下运行的;while let 则会让守卫活过整个块,让其他 worker 等待,悄悄地把线程池变回一个只有一个的队列。最后是关闭。Worker 们在 recv 上循环,所以它们永远不会自己退出,对它们 join 会永远挂起。先丢弃发送者,让 recv 返回错误,每个 worker 跳出循环,然后 join 才能完成——但要注意,信号是在下一次 recv 时才被察觉的,不是在任务进行到一半时。
收尾:一次真实的追踪,然后告别。 本章以走一遍做完的服务器的一次真实运行结束,而不是断言它能用。主线程阻塞在 join worker 0 上,因为那个 worker 还没有撞上通道错误;与此同时,一个靠后的 worker 收到并运行了一个任务;然后每个线程都得到了错误,离开了它的循环。得出的教训是,先 join 哪个 worker,与哪个先察觉到关闭毫无关系——作者还指出,这只是一种交错执行的结果,消息的顺序在不同次运行之间会有变化。确认优雅关闭之后,给出了完整的 main.rs 和 lib.rs 清单,为想继续往下走的读者提出了一组扩展的想法,本章——也是全书——以向读者道谢作结。
关键论点,以及支撑它们的原文
当目标是理解时,亲手搭建服务器,而不是去拿现成的库,是正确的选择。
作者指出,现成的、生产品质的线程池和服务器包已经发布,可以自由获取,却仍然把它们搁在一边:因为这是一门系统语言,程序员可以自己选择往多低的层次去做,而在这里,回报是学到这套机制,而不是快速交付。
“因为 Rust 是一门系统编程语言,我们可以选择想要处理的抽象层次,并可以深入到比其他语言所能或所实际的更低的层次。”
“Because Rust is a systems programming language, we can choose the level of abstraction we want to work with and can go to a lower level than is possible or practical in other languages.”
Chapter 21. Final Project: Building a Multithreaded Web Server查看原文 →
一个真正的服务器必须读取传入的请求,并据此作出不同的回答,而不是对一切都返回同一个页面。
作者先写了一个不管被问到什么都发送同一个文件的版本,然后把请求的第一行与根路径 GET 的确切文本作比较:匹配就以 200 状态提供问候页面,其他一切则得到 404 状态行和一个单独的错误页面。一旦出现第三种情形,if/else 就换成了模式匹配——对一个切片做匹配,因为匹配不会替你解引用。
“我们想根据请求定制我们的响应,只对格式良好的、对 / 的请求才送回 HTML 文件。”
“We want to customize our responses depending on the request and only send back the HTML file for a well-formed request to”
§21.1 Building a Single-Threaded Web Server查看原文 →
一个接一个地处理请求,意味着一个慢请求会冻住排在它后面的所有人。
有了一个能用的服务器之后,作者故意把它弄坏:一个特殊的路径在回复之前睡五秒。请求这个路径,然后立刻请求主页,就会看到主页整整闲置了五秒,因为那唯一的线程仍在忙着第一个请求。
“目前,服务器会依次处理每个请求,意思是在第一个连接处理完毕之前,它不会处理第二个连接。”
“Right now, the server will process each request in turn, meaning it won’t process a second connection until the first connection is finished processing.”
§21.2 From Single-Threaded to Multithreaded Server查看原文 →
另外 3 个论点
给每个请求一个自己的线程确实能治好这种冻结,但必须设上限,这就是答案是一个固定线程池的原因。
紧接着阻塞演示,每个连接派生一个线程被证明是管用的——睡眠请求运行的同时主页加载了出来——但作者指出,没有任何东西限制创建多少线程,所以流量够大时,机器最终会被压垮。一小群固定的、等待的线程从一个共享队列里取任务,既保住了并发,又给线程数设了一个硬上限。
“我们会把线程池里的线程数限制为一个很小的数字,以防范 DoS 攻击;如果我们让程序为每个到来的请求创建一个新线程,那么有人向我们的服务器发出 1000 万次请求,就可能用光服务器的所有资源,使请求的处理陷入停顿,造成严重破坏。”
“We’ll limit the number of threads in the pool to a small number to protect us from DoS attacks; if we had our program create a new thread for each request as it came in, someone making 10 million requests to our server could wreak havoc by using up all our server’s resources and grinding the processing of requests to a halt.”
§21.2 From Single-Threaded to Multithreaded Server查看原文 →
在还没有任何东西可调用之前,先写出你希望能调用的代码,会带来更好的结构。
作者先写出理想的线程池创建和任务提交代码,并预先承认它不会编译,然后让每一个编译器的抱怨来规定下一块要加的东西——一个构造函数,然后是一个带有文档化 panic 条件的大小检查,然后是从标准库派生线程的函数里抄来的闭包约束,这正是“只能调用一次、可发送、不借用”这些要求的来源。
“在设计代码的时候,先写客户端接口可以帮助引导你的设计。”
“When you’re trying to design code, writing the client interface first can help guide your design.”
§21.2 From Single-Threaded to Multithreaded Server查看原文 →
先 join 哪个 worker,与哪个 worker 先察觉到关闭毫无关系,所以主线程可能坐在那里等着一个,而其他的还在接收并做完手头的工作。
作者走了一遍做完的服务器的一次实际运行:主线程阻塞在 join 第一个 worker 上,因为那个 worker 还没有撞上通道错误,与此同时,一个靠后的 worker 收到了一个任务,之后每个线程都得到了错误,离开了它的循环。作者指出,这只是一种交错执行的结果,消息的顺序在不同次运行之间会有变化。
“Worker 0 还没有从 recv 得到错误,所以主线程阻塞了,等着 Worker 0 结束。”
“Worker 0 had not yet gotten an error from recv, so the main thread blocked, waiting for Worker 0 to finish.”
§21.3 Graceful Shutdown and Cleanup查看原文 →
它是怎样运作的
本章论证所依靠的 3 个机制。
机制 1
共享同一个接收端
通道的接收端只有一个消费者,也不能被复制,所以在派生循环里把它交给每个 worker,会把它移走,编译器会拒绝第二次迭代。把它包进 Arc<Mutex<...>>,每个 worker 就都能拥有一个句柄,而同一时刻只有一个在取任务。
机制 2
锁何时放手
这里的互斥锁没有手动解锁;锁恰好持续到守卫值存活的那么久。一个普通的 let 在语句结束时就丢弃守卫,所以任务是在没加锁的情况下运行的,而 while let 会让守卫活过整个块,让其他 worker 等待,把线程池变回一个只有一个的队列。
机制 3
关闭通道以停止
Worker 们在 recv 上循环,所以它们永远不会自己退出,对它们 join 会永远挂起。先丢弃发送者,让 recv 返回错误,每个 worker 跳出它的循环,然后 join 才能完成——不过信号是在下一次 recv 时才被察觉的,不是在任务进行到一半时。
常见误读
既然编译器一路上抓住了每一个错误,最终能编译通过的版本就一定行为正确。
让编译器错误来驱动设计,只能把你带到通过类型检查的代码;它对行为只字不提。作者在项目编译得干干净净、却绝对什么也没做的那个阶段,直截了当地说明了这一点,而做完的线程池仍然依赖出错就崩溃的捷径,真正的代码得把它们换成妥善的错误处理。
“我们的项目编译通过了,可它绝对什么也没做!”
“Our project compiles, but it does absolutely nothing!”
§21.2 From Single-Threaded to Multithreaded Server查看原文 →
线程池一旦就位,就没有哪个请求需要等待另一个请求了。
线程池拿无限的线程换成了固定数量的线程,而这个上限正是它的全部意义——防止资源耗尽。有 N 个线程的线程池,第 N+1 个及以后的请求,仍然要坐在队列里,直到有 worker 空闲出来;这个修法消除了单线程的瓶颈,却没有让等待变得不可能。
“我们会把线程池里的线程数限制为一个很小的数字,以防范 DoS 攻击;如果我们让程序为每个到来的请求创建一个新线程,那么有人向我们的服务器发出 1000 万次请求,就可能用光服务器的所有资源,使请求的处理陷入停顿,造成严重破坏。”
“We’ll limit the number of threads in the pool to a small number to protect us from DoS attacks; if we had our program create a new thread for each request as it came in, someone making 10 million requests to our server could wreak havoc by using up all our server’s resources and grinding the processing of requests to a halt.”
§21.2 From Single-Threaded to Multithreaded Server查看原文 →
第 21 章的核心概念
- TCP 与 HTTP:请求—响应协议
- TCP 在机器之间搬运字节,却不说这些字节意味着什么;HTTP 叠在它之上,定义请求和响应的文本,两者都假定客户端发问、服务器回答。
- 适用处: HTTP 在技术上可以跑在其他协议上,而完整的协议细节不在本书范围之内。
- TcpListener 与连接尝试
- 绑定一个端口会返回一个监听器,它的 incoming() 迭代器产出的是连接尝试,而不是有保证的连接,所以每一项都可能是错误。
- 适用处: 这里的错误往往与操作系统有关,比如同时打开的连接数的限制。
- 请求行与 CRLF 成帧
- HTTP 请求以方法、URI 和版本开头,以 CRLF 结束这一行,并以一个空行表示请求结束,这就是为什么代码要一直取行直到遇到空行。
- 适用处: GET 请求不带消息体,所以只出现请求行和头部。
- unwrap 与 expect
- 服务器遇到任何错误都直接停止,而不是处理它,这让学习用的代码保持简短,但在生产环境中是不可接受的。
- 适用处: 明确是为了简单而选的;真正的应用需要优雅的错误处理。
- 线程池
- 一组固定的、预先派生好的线程,从一个共享队列里取任务,这样你得到了并发,同时线程数有硬上限。 一组固定的工作线程,共享一个任务通道,使请求得以并发处理。
- 适用处: 有 N 个线程时,超过 N 个长时间运行的请求仍然要排队等待。 大小在创建时就固定了;做完的版本仍然用 unwrap,而不是健壮的错误处理。
- 编译器驱动的开发
- 先写出你希望存在的调用代码,再让每一个编译器错误告诉你下一块该构建什么。
- 适用处: 它只把你驱动到能编译的代码;对行为是否正确只字不提。
另外 7 个概念
- Worker
- 一个持有 id 和一个线程句柄的结构体;它等待代码,并在自己那个已经在运行的线程里运行它。
- 适用处: 标准库没有办法让一个已派生的线程等待稍后才到的代码,所以这一层必须手工构建。
- match 表达式
- 对一个值做模式匹配;这里在请求有三种情形而不是两种时使用。
- 适用处: match 不会自动引用或解引用,所以必须先对请求行做切片。
- Arc<Mutex<T>> 共享队列
- 包住通道的接收端,让许多线程都能拥有它,而同一时刻只有一个在取任务。
- 适用处: 之所以需要,是因为 mpsc 只有一个消费者,而 recv 会修改接收端。
- Job 类型别名
- 一个装箱闭包的简短名字,这个闭包可以被发送到另一个线程,并只被调用一次。
- 适用处: 只适合一次性的工作;future 就需要不同的类型。
- MutexGuard 的生命周期
- 锁恰好被持有到守卫值存活的那么久,所以你把它绑定在哪里,决定了它什么时候解锁。
- 适用处: let 在语句结束时丢弃临时值;while let/if let/match 则把它们持有到块结束。
- 优雅关闭
- 关闭发送者,使 recv 报错,让每个 worker 跳出循环并被 join。 在程序结束之前,让正在运行的线程做完手头的工作并退出它们的循环,而不是直接杀掉它们。
- 适用处: 发送者需要 Option::take,线程需要 Vec::drain,因为 join 会消耗所有权。 这里它取决于丢弃发送者并 join 每个 worker;它不会打断已经在运行的任务。
- drop
- 释放一个值,使它的清理代码运行;丢弃通道的发送者,就是通知接收者不会再有任务到来。
- 适用处: 丢弃发送者只会在下一次 recv 调用时结束循环,而不是在任务进行到一半时。
第 21 章划线与批注AI 陪读
“我们在本章的目的是帮助你学习,而不是走捷径”
“our intention in this chapter is to help you learn, not to take the easy route”
查看原文 →对显而易见的反对意见的一次礼貌的先发制人——既然 crates.io 已经解决了,为什么还要自己动手写。真正的论证在后面:Rust 让你选择你的抽象层级,所以下降是一种特性,而不是绕路。
“7878 是用电话键盘敲出来的 rust”
“7878 is rust typed on a telephone”
查看原文 →埋在一个任意的技术选择里的玩笑。这些细小的人的指纹,是这本书读起来和规范不一样的原因。
“我们其实并不是在遍历连接。相反,我们是在遍历连接尝试”
“we’re not actually iterating over connections. Instead, we’re iterating over connection attempts”
查看原文 →到目前为止最尖锐的一句话。整个错误处理的设计都从这一个区别里掉了出来——迭代器产出 Result,恰恰因为尝试并不等于成功。
另外 17 条批注
“浏览器有时还会向服务器打开多个连接,却不发送任何请求”
“Browsers also sometimes open multiple connections to the server without sending any requests”
查看原文 →注意这里的老实:输出不是确定的,而他们说了出来,然后解释了三个相互竞争的原因。大多数教程只会假装你看到的是一行。
“浏览器通过连续发送两个换行符来表示一个 HTTP 请求结束”
“The browser signals the end of an HTTP request by sending two newline characters in a row”
查看原文 →take_while(|line| !line.is_empty()) 是整个 HTTP 成帧规则压缩进一个组合子里。优雅——也恰恰是它一遇到消息体就会崩的原因。
“我们添加了 Content-Length 头,它被设成了响应体的大小”
“we add the Content-Length header, which is set to the size of our response body”
查看原文 →悄悄埋着一个 bug:对 String 来说,contents.len() 是字节数,碰巧是对的——但文字说的是“大小”,而一个按字符思考的读者,遇到非 ASCII 的 HTML 就会算错。
“match 不会像相等性方法那样做自动的引用和解引用”
“match doesn’t do automatic referencing and dereferencing, like the equality method does”
查看原文 →大多数读者都会撞上、并怪到自己头上的一个摩擦点。好在它被大声说了出来——&request_line[..] 在你知道原因之前,看上去就是噪音。
“你可以看到我们的服务器有多原始:真正的库会用啰嗦得多的少得多的办法来处理对多个请求的识别!”
“You can see how primitive our server is: Real libraries would handle the recognition of multiple requests in a much less verbose way!”
查看原文 →书一直在拆自己创造物的台。这让人放下戒心,也确实起了作用:它阻止你把一个教学用的制品误当作该模仿的设计。
“我们会把线程池里的线程数限制在一个很小的数目,以保护我们免受 DoS 攻击”
“We’ll limit the number of threads in the pool to a small number to protect us from DoS attacks”
查看原文 →好框架:这个上限不是一种优化,而是一种防御。有界的资源是一种安全属性,而不是性能属性。
“先写客户端接口,可以帮助引导你的设计”
“writing the client interface first can help guide your design”
查看原文 →整章的方法浓缩成一行——这也是为什么假的 ThreadPool::new(4) 会在什么都还不存在之前就被写出来。
“我们在这里将使用编译器驱动的开发”
“we’ll use compiler-driven development here”
查看原文 →一个真正好笑的反转:错误信息成了待办清单。只有在编译器这样健谈的语言里才算老实。
“它对应着 FnOnce 里的 Once”
“which matches the Once in FnOnce”
查看原文 →从名字倒推语义——很可爱,而它在这里管用,只是因为这个领域里每个任务确实恰好只运行一次。
“但这句话并不普遍成立。我们的项目编译通过了,可它绝对什么也没做!”
“But this saying is not universally true. Our project compiles, but it does absolutely nothing!”
查看原文 →书在这句口号最诱人的那一刻,拆了自己社区的口号的台。类型安全保证的是形状,永远不是意图。
“想想在餐厅厨房里工作的人们”
“Think of people working in the kitchen at a restaurant”
查看原文 →这个类比恰好出现在抽象不再能被命名的时候——Worker 之所以存在,是因为 spawn 要求工作现在就做,而我们想要会等待的线程。
“Rust 提供的通道实现是多生产者、单消费者的。”
“The channel implementation that Rust provides is multiple producer, single consumer.”
查看原文 →这里的错误不是一个失误——而是库的设计与线程池的形状相撞。Arc<Mutex<Receiver>> 是那个手工把 MPSC 变成 MPMC 的补丁。
“我们用 unwrap 的原因是,我们知道失败的情形不会发生,但编译器不知道这一点。”
“The reason we use unwrap is that we know the failure case won’t happen, but the compiler doesn’t know that.”
查看原文 →老实地承认:这里的 unwrap 是一个人的承诺,而不是一个证明。两章之后线程池可以关闭了——这个承诺悄悄地变成了假的,这就是为什么 execute 后来无论如何都需要 as_ref().unwrap()。
“Mutex<T> 确保同一时刻只有一个 Worker 线程在尝试请求任务。”
“The Mutex<T> ensures that only one Worker thread at a time is trying to request a job.”
查看原文 →值得注意,这个互斥锁与其说是在保护数据完整性,不如说是在串行化对一个不能共享的接收端的访问——这个并发原语干的是管道活。
“while let(以及 if let 和 match)直到相关联的块结束才会丢弃临时值”
“while let (and if let and match) does not drop temporary values until the end of the associated block”
查看原文 →这是整段里最尖锐的一段:看上去很地道的重写能编译、能运行,却悄无声息地毁掉了并行性。一个借用检查器看不见的正确性 bug,藏在一条关于临时值的作用域规则里。
“当你发现自己为了某种变通,把一个你知道总是存在的东西包进 Option 里时,最好去找找别的办法”
“when you find yourself wrapping something you know will always be present in an Option as a workaround like this, it’s a good idea to look for alternative approaches”
查看原文 →好品味的建议——然后书立刻对 sender 恰恰用了那个 Option 变通办法,而最后的参考清单也把 Worker.thread 改回了 Option。附录与教训相矛盾。
“丢弃 sender 会关闭通道,这表明不会再有消息被发送。”
“Dropping sender closes the channel, which indicates no more messages will be sent.”
查看原文 →优雅:关闭不是一个标志或一条毒丸消息,而是发送者的缺席。所有权本身成了信号——非常 Rust。
读后感AI 陪读
压轴的不是一个独立的项目:它是整本书的化身
最终项目是这本书对自己的论证,搭成了一个能运行的成品:花了二十五章教零件,现在它问你,能不能把它们同时握在手里,并且刻意拒绝走捷径——不用 crate,不用 async——因为重点从来不是那个服务器。本章的脊梁是一场从协议到原语的缓慢下降。它开头几乎很温和:TCP 和 HTTP 作为一次请求—响应的握手,一个监听 127.0.0.1:7878 的监听器(用电话键盘敲出来的 rust——正是这些细小的人的指纹,把这本书和规范区分开来),对请求行做一次带缓冲的读取,一个写死的 200,然后是 hello.html,然后是 404 分支。整段里最尖锐的一句话,是那个安静的纠正:我们遍历的不是连接,而是连接的尝试——整个错误处理的形状,都从这一个区别里掉了出来,而书愿意承认浏览器的行为让输出不确定(三个相互竞争的原因,都点了名),是同样的老实换了一个音域。成帧的组合子,在非空行上用 take_while,既真的优雅,也真的脆弱:它是 HTTP 空行规则的整体,浓缩在一个表达式里,也恰恰是服务器一遇到带有消息体的请求就挂掉的原因。类似的安静陷阱还藏在 Content-Length 那一行,那里“size”指的是字节,只是碰巧对 ASCII 是对的。然后是转折:/sleep,一个要五秒的处理函数,以及一个再也加载不了静态页面的浏览器——并发不是作为一个特性被引入,而是作为一次你能亲眼看着发生的失败。
随后是本章真正的方法,被直截了当地说了出来:先写客户端接口,再让编译器列举出还缺什么。编译器驱动的开发是一种好笑的反转,只有一门老实的语言才撑得起,而书在恰到好处的时刻戳破了它自己的口号——它编译通过了,而且绝对什么也没做。类型安全保证的是形状,永远不是意图。设计随后在压力之下自己拼装起来。线程上限被框定为对 DoS 的防御,而不是一个调优旋钮,这悄悄地把有界资源重新定性为一种安全属性。Worker 恰好出现在抽象不再能被命名的时候——spawn 坚持要工作现在就做,而线程池需要会等待的线程——于是厨房的类比赶来填补这个缺口。Arc<Mutex<Receiver>> 不是一个被纠正的错误,而是库的 MPSC 设计与线程池的 MPMC 形状相撞,被手工打了补丁;那里的互斥锁干的是管道活,串行化对一个不能共享的接收端的访问,而不是守护数据完整性。整个项目的皇冠,是 let 对比 while let 的那一段:看上去很地道的重写能编译,能运行,能提供页面,却悄无声息地毁掉了并行性,因为临时值活到块的结尾,守卫从未被丢弃。一个借用检查器看不见的正确性 bug,住在一条作用域规则里——这是本章最有价值的一页,也是“能编译”是地板而不是天花板的最好证据。关闭也是以同样的方式落地的:丢弃发送者就关闭了通道,所以终止是一个信号的缺席,而不是一颗毒丸或一个标志。所有权本身成了协议。有两点老实的说明值得保留。
继续阅读
对发送者的 unwrap 明确是一个人的承诺,而不是一个证明——而一旦 Drop 让关闭成为现实,这个承诺就到期了,这就是为什么最终代码无论如何都需要 as_ref().unwrap()。还有,书建议你,把一个总是存在的值包进 Option 是一种值得避免的味道,然后却恰恰对 sender 用了那个变通办法,并在参考清单里把 Worker.thread 也改回了 Option。教训和附录彼此不一致;把它读作品味是一个方向,而不是一条规则的证据。我会这样告诉读者:本章一直在拆自己创造物的台——“你可以看到我们的服务器有多原始”——而这种自嘲是承重的。不要模仿这段代码;要模仿产生它的那个顺序。写出你希望存在的调用点,让错误变成你的待办清单,并把你的怀疑留给编译器安静下来的那些地方,因为真正的 bug 就住在那里。然后书说了谢谢,而且是真心的。
在 FeedLitra 里读第 21 章,每段旁边都有这些卡片。
开始读书论证在哪里吃力
不是反驳,而是细心的读者会想追问的地方。
- 安全被宣传为在运行时零成本,然而边界检查、RefCell 的运行时借用检查和原子引用计数,恰恰就是运行时成本。
“Rust 通过立即退出,而不是允许这次内存访问并继续运行,来保护你免受这种错误。”
“Rust protects you against this kind of error by immediately exiting instead of allowing the memory access and continuing.”
§3.2 Data Types查看原文 → - 穷尽匹配被认为使空值不可能,但同一本书又提供了 _、if let 和 let...else,其便利让程序员能跳过提供这个保证的覆盖。
“尤其是对 Option<T> 而言,当 Rust 阻止我们忘记明确处理 None 的情形时,它保护我们不去假定自己有一个值,而实际上我们可能拿到的是空值,从而使前面讨论的那个十亿美元的错误成为不可能。”
“Especially in the case of Option<T>, when Rust prevents us from forgetting to explicitly handle the None case, it protects us from assuming that we have a value when we might have null, thus making the billion-dollar mistake discussed earlier impossible.”
§6.2 The match Control Flow Construct查看原文 → - panic 被谴责为面向用户的输出,同时又充当测试机制,和对越界索引的、获得认可的响应。
“不可恢复的错误总是 bug 的症状,比如试图访问数组末尾之外的位置,所以我们想要立即停止程序。”
“Unrecoverable errors are always symptoms of bugs, such as trying to access a location beyond the end of an array, and so we want to immediately stop the program.”
Chapter 9. Error Handling查看原文 → - 提供许多并发工具,削弱了单一无畏路径的承诺:仍然是程序员,而不是编译器,必须在通道、Mutex/Arc、线程和异步之间做选择,而书在论证了异步在开销上胜出之后,仍把线程留给 CPU 密集型工作。
“因此,Rust 提供了多种工具,以适合你的情形和需求的任何方式对问题建模。”
“Therefore, Rust offers a variety of tools for modeling problems in whatever way is appropriate for your situation and requirements.”
Chapter 16. Fearless Concurrency查看原文 →
另外 4 处张力,以及 8 条证据上的局限
- 安全的叙事需要一条官方认可的打破它的途径,所以保证最终落在对 unsafe 块的人工审计上,而不是编译器上。
“让 unsafe 块保持小;以后你调查内存 bug 时会感谢自己的。”
“Keep unsafe blocks small; you’ll be thankful later when you investigate memory bugs.”
§20.1 Unsafe Rust查看原文 → - 所有权的单一所有者规则,被呈现为安全机制,然而引用、克隆和引用计数紧接着就被引入,因为这条规则直接使用的成本太高。
“但对于一个本应常见的概念来说,这太繁文缛节,也太费事了。”
“But this is too much ceremony and a lot of work for a concept that should be common.”
§4.1 What is Ownership?查看原文 → - 默认私有保护了作者改变内部的自由,却迫使持续不断的 pub、use 和 pub use 工作,所以模块系统隐藏条目,只是为了有意把同样这些条目重新暴露出来。
“Rust 选择让模块系统以这种方式运作,使隐藏内部实现细节成为默认。”
“Rust chose to have the module system function this way so that hiding inner implementation details is the default.”
§7.3 Paths for Referring to an Item in the Module Tree查看原文 → - 零成本的说法忽略了每个具体类型一份特化的编译时间和代码体积代价,正如线程池里单一的共享互斥锁,把线程池本该并行化的分发步骤串行化了。
“好消息是,使用泛型类型不会使你的程序运行得比使用具体类型更慢。”
“The good news is that using generic types won’t make your program run any slower than it would with concrete types.”
§10.1 Generic Data Types查看原文 →
证据上的局限
- 证据基础几乎完全是书自己的小示例清单和编译器错误码,而不是在真实代码库上度量出的结果。
“在编程语言设计中,高层的人体工学和底层的控制往往互相矛盾;Rust 挑战这种冲突。”
“High-level ergonomics and low-level control are often at odds in programming language design; Rust challenges that conflict.”
Introduction查看原文 → - 性能与效率的说法,是靠对代码如何编译或系统开销的推理来论证的,没有报告任何基准测试。
“好消息是,使用泛型类型不会使你的程序运行得比使用具体类型更慢。”
“The good news is that using generic types won’t make your program run any slower than it would with concrete types.”
§10.1 Generic Data Types查看原文 → - 所有权的论证,建立在对重复释放的推理之上,而不是经验性的缺陷数据。
“所有权的任何特性都不会在程序运行时拖慢你的程序。”
“None of the features of ownership will slow down your program while it’s running.”
§4.1 What is Ownership?查看原文 → - 工程教训来自一个小的命令行程序和一个有意没有完成的手工搭建的网络服务器,所以它们在更大系统上的普适性是被断言的,而不是被演示的。
“最好把功能分开,让每个函数负责一项任务。”
“It’s best to separate functionality so that each function is responsible for one task.”
§12.3 Refactoring to Improve Modularity and Error Handling查看原文 → - 并发演示依赖于操作系统的调度,和标准库不附带的外部运行时 crate,所以关于顺序和公平性的说法,只对所用的配置成立。
“注意,当 Rust 程序的主线程完成时,所有已生成的线程都会被关闭,无论它们是否已经运行完毕。”
“Note that when the main thread of a Rust program completes, all spawned threads are shut down, whether or not they have finished running.”
§16.1 Using Threads to Run Code Simultaneously查看原文 → - 所展示的内部可变性模式是单线程的,而引用计数的叙述省略了循环和泄漏,限定了智能指针这个故事能走多远。
“在 Rust 中,由于所有权和借用的概念,引用和智能指针之间还有一个额外的区别:引用只借用数据,而在许多情况下,智能指针拥有它们所指向的数据。”
“In Rust, with its concept of ownership and borrowing, there is an additional difference between references and smart pointers: While references only borrow data, in many cases smart pointers own the data they point to.”
Chapter 15. Smart Pointers查看原文 → - Miri 只分析真正执行的代码,所以它能表明未定义行为的存在,却永远无法表明它的不存在。
“换句话说:如果 Miri 确实抓到了一个问题,你就知道有 bug,但仅仅因为 Miri 没抓到 bug,并不意味着没有问题。”
“Put another way: If Miri does catch a problem, you know there’s a bug, but just because Miri doesn’t catch a bug doesn’t mean there isn’t a problem.”
§20.1 Unsafe Rust查看原文 → - 有几处验证只是单一案例:一个从未触及字形簇的多字节字符串例子,以及只对照一行字面请求检查的路由,没有涵盖其他方法、头部或格式错误的输入。
“因此,答案是,为了避免返回一个意外的值,造成可能不会立即被发现的 bug,Rust 压根不编译这段代码,并在开发过程的早期就防止误解。”
“The answer, then, is that to avoid returning an unexpected value and causing bugs that might not be discovered immediately, Rust doesn’t compile this code at all and prevents misunderstandings early in the development process.”
§8.2 Storing UTF-8 Encoded Text with Strings查看原文 →
全书概念导图
5 组概念:通俗的叫法、一句话解释,以及原书自己的用语。
1 · 没有速度代价的人体工学
- 泛型和抽象编译出与手写一样的速度
- 更高层的构造编译成与手写低层代码一样高效的代码:类型占位符让一个定义服务于许多具体类型,而编译器为实际用到的每个类型发出一份特化的副本。原书用语: Zero-cost abstraction via monomorphization
- 编译器在代码运行之前就拒绝不匹配的代码
- 编译器拒绝类型或内存使用对不上的代码,而不是去猜测或强制转换,而这种模式延伸到设计:给每个状态或工作流阶段它自己的类型,使无效的操作编译不过。原书用语: Compiler as gatekeeper
- 测试证明类型检查无法验证的行为
- 正确性是类型检查弥合不了的缺口,转而由带 assert_eq!/assert_ne!、should_panic、返回 Result 的测试和 Debug 输出的 #[test] 函数来断言;测试框架的标志可以调整线程、输出和过滤,代码旁边的单元测试可以接触私有条目,而集成测试只使用公共接口,常常是测试先行。原书用语: Automated tests
- 通过构建程序、跟着错误信息走来学习
- 本书把概念章节与构建程序的章节交替,并教授先写出你希望存在的调用代码,让每个编译器错误指引下一块内容,然后重构,使每个函数负责一项任务,并把逻辑移出 main。原书用语: Compiler-driven, project-based method
2 · 一个所有者,没有垃圾回收器
- 每个值有一个所有者,结束时被清理
- 每个值恰好有一个所有者,当该所有者的作用域结束时被丢弃;赋值会移动拥有堆数据的值并使原来的失效,而 Copy 类型则是复制。同一条规则支配着通道发送、线程捕获,以及消耗 self 的状态转换。原书用语: Ownership
- 借用一个值来读它或改它,而不拿走它
- 引用是指向别处拥有的数据的、保证有效的地址;借用是使用一个值再把它还回去,可变借用排斥所有其他引用,悬垂引用被拒绝,切片借用一段连续的元素,而未完成的借用会阻止可能移动集合内存的修改。原书用语: References, borrowing, and slices
- 拥有堆数据并清理它的类指针类型
- 表现得像指针、却拥有其数据的结构体,通过 Deref 和 Drop 获得能力:栈存放固定大小的值,而堆存放大小未知的数据,Box 增加了让递归类型有已知大小的间接引用,解引用强制转换自动转换引用,Rc 允许多个所有者,而 RefCell 允许在运行时检查的内部可变性。原书用语: Smart pointers
- Vec、String 和 HashMap,由内存的布局方式塑造
- 由堆支撑的容器,其 API 遵循它们的内存布局:Vec 连续地、可增长地存放一种类型,String 是一个可增长的 UTF-8 字节向量,可以按字节、标量值或字形簇来读,而 HashMap 通过哈希,用 entry API 把键映射到值。原书用语: Standard collections
3 · 缺失和失败变成了类型
- 枚举列出一个值可能的形式,包括什么都没有
- 一种自定义类型,列出值可能采取的各种形式,每个变体带着它自己的类型和数据量,这也让一个集合能容纳混合的形状;Option 的 Some/None 编码了存在或不存在,而不是空值,并且是与 T 不同的类型。原书用语: Enums and Option<T>
- match 按形状选择分支,且必须涵盖每种情形
- 模式描述数据的形状并给其各部分命名;match 运行第一个吻合的分支,且必须涵盖每一个可能的值,而 _、if let、let...else、while let 和 for 则只处理一个模式,一个模式是否可能匹配失败,决定了它合法地可以出现在哪里。原书用语: Pattern matching and refutability
- 把失败作为值返回,只在违背承诺时才 panic
- 可恢复的失败是一个值——Ok 或 Err,? 提前返回并通过 From 转换——而被打破的契约或不变量会 panic,打印一条消息、展开并退出;unwrap 和 expect 走的就是这条路,而越界访问两种形式都提供:会 panic 的索引,或返回 Option 的 get。原书用语: Error handling: Result, ?, and panic!
- 类型在编译期固定,值除非选择加入否则冻结
- 每个值的类型在编译期已知,尽可能推断,但函数参数上是必需的;let 绑定除非标记为 mut 否则是固定的,常量则始终是固定的,而遮蔽在同一个名字下引入一个新变量(可能是另一种类型)。原书用语: Static types and immutable-by-default bindings
4 · 共享行为,隐藏内部
- trait 为许多类型都能实现的共享行为命名
- trait 命名一组类型实现的方法签名,以共享行为(只能在 trait 或类型是本地的地方实现);约束要求泛型类型实现它们,而通用实现涵盖每个满足另一个约束的类型;指针后面的 dyn Trait 持有任何实现者,分发推迟到运行时,提供没有子类化的多态。原书用语: Traits, trait bounds, and trait objects
- 默认私有;只发布你想保持稳定的 API
- crate 是编译器的工作单位,包把它们和一个 Cargo.toml 捆在一起;模块嵌套在 crate 根之下,通过绝对或相对路径(包括 super)到达。条目除非标记为 pub,否则对父模块是私有的,use 缩短路径,而 pub use 重导出一条为用户而塑造的公共路径,而不是照搬内部结构。原书用语: Modules, privacy, and public API design
- Cargo 构建你的项目,并引入社区的 crate
- rustup 安装工具链;Cargo 搭建项目骨架,预先构建成独立的二进制文件,按 SemVer 说明符获取 crate,锁定解析出的版本,并管理发布配置、文档注释与文档测试、共享同一个 target 目录的工作空间、自定义子命令,以及 crates.io 上的撤回。原书用语: Cargo and the crate ecosystem
- 在你要求下一个值之前,迭代器什么也不做
- 一个 trait,唯一必需的方法 next,产出 Some 直到 None,并且在被消耗之前不做任何工作:像 sum 这样的消费适配器在内部驱动它,像 map 和 filter 这样的适配器返回新的迭代器——future 采取的是同样的惰性形状。原书用语: Iterators and laziness
5 · 编译器检查的并发
- 线程通过通道传递数据,或在锁之下共享它
- 无畏并发意味着编译器能抓住并发的错误:每个生成的线程是一个返回可 join 句柄的操作系统线程,通道通过转移所有权传递消息,Mutex 加 Arc 在锁之下共享状态,Send 和 Sync 是类型层面的许可,而由 worker 组成的线程池,在固定的上限下,从 Arc<Mutex<receiver>> 队列里拉取任务。原书用语: Threads, channels, and shared state
- 异步把等待的工作变成运行时调度的任务
- future 是一个现在还没就绪、但最终会解析出来的值;async 标记一个可中断的块,await 是它可能让出的点。Rust 不带运行时,所以由一个外部的 crate 来调度和驱动 future——适合交错的、I/O 密集型的并发,不同于并行的、CPU 密集型的工作。原书用语: Async, futures, and runtimes
- 闭包把它们捕获的数据带进线程和任务
- 匿名的、可存储的函数,其参数和返回类型由首次使用推断;闭包捕获其函数体所需的最小访问——不可变借用、可变借用,或所有权(由 move 强制)——而这个选择决定了它是 Fn、FnMut 还是 FnOnce,进而决定了它能不能发送到线程,或者能不能被装箱为只发送一次的任务。原书用语: Closures and capture modes
值得停下来的段落AI 陪读
几处值得停下来的原文,每处附一句它为什么对全书要紧。
“所有权的任何特性都不会在程序运行时拖慢你的程序。”
“None of the features of ownership will slow down your program while it’s running.”
查看原文 →全书核心的期票——安全在编译期开账单——而之后的每一章,要么在兑现它,要么在悄悄重新谈判它。
“高层的人体工学和底层的控制往往互相矛盾”
“High-level ergonomics and low-level control are often at odds”
查看原文 →全书的论点句:Rust 的赌注是,人体工学与控制之间的取舍是一种习惯,而不是定律。
“编译器扮演守门人的角色,拒绝编译”
“the compiler plays a gatekeeper role by refusing to compile”
查看原文 →点出书从未定价的那个隐藏的转移:评审的权威,从资深的人转移到一台你无法与之争辩的机器,这是第9到第15章的情感脊梁。
另外 9 条批注
“Rust 内部藏着第二门语言,它不强制执行这些内存安全保证”
“Rust has a second language hidden inside it that doesn’t enforce these memory safety guarantees”
查看原文 →十九章的保证,建立在一扇始终未锁的门上,直到这里,书才打开它。
“宁可拒绝一些有效的程序,也比接受一些无效的程序要好”
“it’s better for it to reject some valid programs than to accept some invalid programs”
查看原文 →解释了书中每一场与借用检查器的搏斗的那一行:编译器并不聪明,而是有意悲观。
“你排除的特性同样重要”
“the features you exclude are important too”
查看原文 →对 Rust 的身份是由拒绝构成的最清楚的陈述——空值、异常、继承、数据竞争——这是半个章节列表背后的组织逻辑。
“Rust 编译器保证,当你声明一个值不会改变时,它真的不会改变,所以你不必自己去追踪它。”
“The Rust compiler guarantees that when you state that a value won’t change, it really won’t change, so you don’t have to keep track of it yourself.”
查看原文 →整门语言的生成原则:每一项限制,都是从你的脑子里卸载给机器的簿记,而所有权、生命周期和 Send/Sync,都是更大规模上同样的交易。
“Rust 的类型系统扛起了这个负担的很大一部分,但类型系统不能抓住一切。”
“Rust’s type system shoulders a huge part of this burden, but the type system cannot catch everything.”
查看原文 →书最重要的让步——编译期的保证止于意图——也是它让测试、错误处理和 unsafe 这几章不至于多余的原因。
“引用只借用数据,而在许多情况下,智能指针拥有它们所指向的数据。”
“While references only borrow data, in many cases smart pointers own the data they point to.”
查看原文 →事后解决了书中最深的、反复出现的问题:从第9章起,从 String 到 Rc 的一切,一直是沿着所有权这条轴组织的,而不是指针元数据。
“团队发现,所有权和类型系统是一套强大的工具,可以帮助管理内存安全和并发问题!”
“the team discovered that the ownership and type systems are a powerful set of tools to help manage memory safety and concurrency problems!”
查看原文 →书安静地承认,它最好的特性是个副作用——内存纪律原来就是并发纪律,这是任何路线图都没预料到的。
“Rust 没有异常”
“Rust doesn’t have exceptions”
查看原文 →错误从一个看不见的控制流通道,搬进了类型签名,这与 Option 是同一个动作,也是 Rust 的啰嗦是结构性的、而不是风格性的原因。
“我们其实并不是在遍历连接。相反,我们是在遍历连接尝试”
“we’re not actually iterating over connections. Instead, we’re iterating over connection attempts”
查看原文 →最终项目最尖锐的一行,追溯地为整个第14章辩护:Result 之所以出现,是因为尝试不等于成功,而整个由类型编码的易错性设计,都从这个区分而来。
读后感AI 陪读
程序员视作自然的那些取舍,其实是历史的偶然
从头读到尾,《Rust 程序设计语言》不是一次特性巡礼,而是一个持续的论证:程序员当作自然而接受的那些取舍——安全对速度,控制对人体工学——是历史的偶然,其中大多数可以通过把知识搬进类型系统、并在程序运行之前就解决掉而消解。从前言里管理维护的机构承诺,到最后的线程池,一切都是对这一个主张的展开。脊梁分四个乐章。第一,适应:安装和猜数游戏让你上手的,与其说是语法,不如说是与编译器的工作关系,而《通用编程概念》开始了全书真正的节奏——一份便利被收回,然后作为保证被偿还(默认不可变、必需的标注、边界检查、for 优于带索引的 while)。第二,核心:所有权、借用和切片,用检查器强制执行的规则,取代了 GC 和手动 free;结构体和枚举让数据形状变得明确,直到 Option<T> 加穷尽的 match 使空值错误无法表示;模块、集合、错误,以及泛型/trait/生命周期,然后把同样的纪律延伸到可见性、堆的成本、失败和有效性。第三,工程现实:测试之所以存在,恰恰因为类型钉住形状,却从不钉住意图,而 I/O 项目、Cargo 和工作空间表明,编译器的保证止于设计判断和生态惯例开始的边界。第四,诚实的撤退:智能指针、并发、异步、OOP 特性、模式和 unsafe 各是一个标了价的出口——RefCell 用运行时 panic 换掉编译期检查,匹配守卫用表现力换掉穷尽性,trait 对象用静态分发换异构性,unsafe 用验证换恰好五种超能力——而书的诚信,在于点明每个出口的代价,而不是假装规则从不弯曲。
全书最尖锐的判断,也是最容易错过的,是 Rust 著名的安全根本不主要是关于内存的;而是关于把人的警惕搬进由机器检查的结构。并发就是证明:没有为线程发明新的子系统,只是把所有权规则瞄准了一个新的目标,而 send 移动它的值,与第9章的移动是同一个想法。推论是,书的这些范畴是决定,而不是发现——“不可恢复”的错误之所以不可恢复,是因为 Rust 宣布它们如此,而博客文章状态机优于经典状态模式,是关于你想在哪里付出的论证,不是一个中立的事实。第二尖锐的判断是零成本的说法,迭代器那一章是在汇编里真正挣得它,而不是断言它;那一个演示,就是整本书偏好更高层构造的许可证。我要对读者说的是:你绝不能略读的章节是《所有权》,你绝不能解读为无能的搏斗是与借用检查器的搏斗——书开篇的宽慰,说编译器错误并不意味着你是个糟糕的程序员,是承重的,因为挫折就是课程。把项目章节当作散文来读,而不是当作要抄的代码;I/O 项目从三十行的 main 走到一个经过测试的库,以及网络服务器对 crate 和异步的拒绝,教的是在编译器压力之下重构的方法,那是任何参考手册都传达不了的。而一旦你走到逃生口,就不要再问一个特性做什么,而要开始问它保证什么——新类型买到的是类型安全,而别名买到的只是简短,这个区分,在智能指针、trait 对象和 unsafe 里反复出现,才是书想留给你的真正技能。二十三章的规则,是为了让你在最后五章的取舍上变得称职。
网络书评怎么说
书评人一致称这是标准的、权威的 Rust 指南,称赞它讲解清楚、示例好,并涵盖了生态系统——cargo、构建系统、包管理器和测试。抱怨也反复出现:大约 500 页,对有经验的程序员来说太长太慢,借用检查器、生命周期、trait 和并发这几章让人觉得不堪重负,而社区的惯例几乎没得到关注。
综合已发表书评的看法;不展示评分,完整书评请点来源链接。
来源与授权
MIT / Apache-2.0原书
- 《Rust 程序设计语言》(The Rust Programming Language),作者 Steve Klabnik、Carol Nichols、Chris Krycho,及 Rust 社区贡献者。Copyright © The Rust Project Developers。可任选 MIT 许可证(https://opensource.org/licenses/MIT)或 Apache 许可证 2.0 版(https://www.apache.org/licenses/LICENSE-2.0)使用。免费阅读原书 · 许可证
- 摘要、论点、批注和读后感由 FeedLitra 根据全书原文 AI 辅助生成,引文已对照原文核对。最近更新:2026 年 10 月 21 日。
在 FeedLitra 里一边读《Rust 程序设计语言》,一边看这些卡片。
开始读书