前置篇 (3讲)
加餐|这个专栏你可以怎么学,Rust是否值得学?
- 从控制代码缺陷的角度,为什么说 Rust 解决了开发者在实践过程中遇到的很多问题,而这些问题目前大部分语言都没有很好地解决;
- 为什么 Rust 未来可期,比较一下 Rust 和 Golang
- 分享一些 Rust 的学习资料。
代码缺陷
软件开发,最基本的要求就是控制缺陷。
为控制缺陷,软件工程中,定义了各种各样的流程,从代码的格式,到 linting,到 code review,再到单元测试、集成测试、手工测试。
这些手段就像一个个漏斗,不断筛查代码,把缺陷一层层过滤掉,让软件在交付到时尽善尽美。在开发过程中可能出现的缺陷分类,从上往下看:
(课程里的图片都是用 excalidraw 绘制的)
语法缺陷
大部分的编程语言都会在你写代码的时候,给到详尽的提示,告诉你语法错误出现在哪里。
Rust ,提供了 Rust Language Server / Rust Analyzer 第一时间报告语法错误,用第三方 IDE 如 VSCode,会有这些工具的集成。
类型安全缺陷
需要语言本身的类型系统,帮助你把缺陷找出来,大部分非类型安全的语言,对这类错误束手无策。
以 Python/Elixir 为例,如果你期望函数的参数使用类型 A,但实际用了类型 B,只有代码在真正运行的时候才能被检查出来,把错误发现的时机大大延后了。
现在脚本语言也倾向尽可能让开发者多写一些类型标注,但不是语言原生,很难强制。写脚本语言的代码,要特别注意类型安全。
内存和资源安全缺陷
几乎所有的语言中都会有内存安全问题。
内存自动管理的语言,自动管理机制可以帮你解决大部分内存问题,不会出现内存使用了没有释放、使用了已释放内存、使用了悬停指针等等情况。
大部分语言,如 Java / Python / Golang / Elixir 等,通过语言的运行时解决了内存安全问题。
但是这只是大部分被解决了,还有比如逻辑上存在的内存泄漏的问题,如一个带 TTL 的缓存,如果没设计好,表中的内容超时后并没有被删除,就会导致内存使用一直增长。这种因为设计缺陷导致的内存泄漏,现在所有语言都没有能够解决这个问题,只能说尽可能地解决。
资源安全缺陷也是大部分语言都会有的问题,诸如文件 /socket 这样的资源,如果分配出来但没有很好释放,就会带来资源的泄漏,支持 GC 的语言对此也无能为力,很多时候只能靠程序员手工释放。
然而资源的释放并不简单,尤其是在做异常处理或者非正常流程的时候,很容易忘记要释放已经分配的资源。
Rust 可以说基本上解决了主要的内存和资源的安全问题,通过所有权、借用检查和生命周期检查,来保证内存和资源一旦被分配,在其生命周期结束时,会被释放掉。
并发安全缺陷
支持多线程的语言中,如两个线程访问同一个变量,如果没有做合适的临界区保护,就很容易发生并发安全问题。
Rust 通过所有权规则和类型系统,主要是两个 trait:Send/Sync 来解决这个问题。
很多高级语言会把线程概念屏蔽掉,只允许开发者使用语言提供的运行时来保证并发安全,如 Golang 用 channel 和 Goroutine 、Erlang 用 Erlang process,只要在这个框架下,并发处理就是安全的。
这样可以处理绝大多数并发场景,但遇到某些情况就容易导致效率不高,甚至阻塞其它并发任务。如当有一个长时间运行的 CPU 密集型任务,使用单独的线程来处理要好得多。
处理并发有很多手段,但大部分语言为了并发安全,把不少手段都屏蔽了,开发者无法接触到。Rust 都提供,有很好的并发安全保障,在合适的场景,安全地使用合适的工具。
错误处理缺陷
错误处理作为代码的一个分支,会占到代码量的 30% 甚至更多。
实际工程中,当函数频繁嵌套,整个过程会变得非常复杂,一旦处理不好就会引入缺陷。常见的问题是系统出错了,但抛出的错误并没有得到处理,导致程序在后续的运行中崩溃。
很多语言并没有强制开发者一定要处理错误,Rust 用 Result<T, E> 类型来保证错误的类型安全,还强制你必须处理这个类型返回的值,避免开发者丢弃错误。
代码风格和常见错误引发的缺陷
很多语言都会提供代码格式化工具和 linter 来消灭这类缺陷。Rust 有内置的 cargo fmt 和 cargo clippy 来帮助开发者统一代码风格,避免常见的开发错误。
再往下的三类缺陷是语言和编译器无法帮助解决的。
- 对逻辑缺陷,要有不错的单元测试覆盖率;
- 对功能缺陷,要通过足够好的集成测试,把用户主要使用的功能测试一遍;
- 对用户体验缺陷,需要端到端的测试,甚至手工测试,才能发现。
Rust 把尽可能多的缺陷扼杀在摇篮中。Rust 在编译时解决掉的很多缺陷,如资源释放安全、并发安全和错误处理方面的缺陷,在其他大多数语言中并没有完整的解决方案。
Rust 语言,让时间和精力都尽可能的放在对逻辑、功能、用户体验缺陷的优化上。
引入缺陷的代价
引入缺陷的代价的角度,Rust 的处理方式到底有什么好处。
任何系统不引入缺陷是不可能的。
- 写代码的时候就发现缺陷,纠正的时间是毫秒到秒级;
- 测试的时候检测出来,是秒到分钟级。
- 缺陷在从 code review 到集成到 master 才被发现,时间非常长。
- 到用户使用的时候才发现,那可能是以周、月,甚至以年为单位。
我之前做防火墙系统时,一个新功能的 bug 往往在一年甚至两年之后,才在用户的生产环境中被暴露出来,这个时候再去解决缺陷的代价就非常大。
所以, Rust 在设计之初,尽可能把大量缺陷在编译期(秒和分钟级)检测出来,不至于把缺陷带到后续环境,最大程度的保证代码质量。
虽然 Rust 初学者前期需要和编译器做艰难斗争,非常值得,代码编译通过,基本上代码的安全性没有太大问题。
语言发展前景判断
Rust 会不会一统前后端天下?不会。
每种语言都有各自的优劣和适用场景,谈不上谁一定取代谁。社区的形成、兴盛和衰亡是一个长久的过程,就像“世界上最好的语言 PHP”也还在顽强地生长着。
如何判断一门新的语言的发展前景呢?下图是我用 pandas 处理过的 modulecounts 的数据,这个数据统计了主流语言的库的数量。可以看到 2019 年初 Rust crates 的起点并不高,只有两万出头,两年后就有六万多了。
(图略)
作为一门新的语言,Rust 生态虽然绝对数量不高,但增长率一直遥遥领先,过去两年多的增长速度差不多是第二名 NPM 的两倍。很遗憾,Golang 的库没有一个比较好的统计渠道,所以这里没法比较 Golang 的数据。但和 JavaScript / Java / Python 等语言的对比足以说明 Rust 的潜力。
Rust 和 Golang
网上有很多详尽的分析,这一篇比较不错可以看看。
Rust 和 Golang 重叠的领域主要在服务开发领域。
Golang 的优点是简单、上手快。并发模型,直接用即可。对于日程紧迫、有很多服务要写,且不在乎极致性能的开发团队,Golang 是不错的选择。
Golang 考虑如何能适应新时代的并发需求,用了运行时、使用调度器调度 Goroutine ,运行时中有 GC 来帮助开发者管理内存。
为了语法简便,在语言诞生之初便不支持泛型,目前 Golang 最被诟病的一点。每个类型都需要做一遍实现。
Golang 可能会在 2022 年的 1.18 版本添加对泛型的支持,但泛型对 Golang 来说是一把达摩克利斯之剑,它带来很多好处,会大大破坏 Golang 的简洁和极速的编译体验。既然 Golang 已经变得不简单,不那么容易上手,我为何不学 Rust 呢?
Rust 的很多设计思路和 Golang 相反。
Go 相对小巧,类型系统很简单;而 Rust 借鉴了 Haskell,有完整的类型系统,支持泛型。为了性能的考虑,Rust 在处理泛型函数的时候会做单态化( Monomorphization ),泛型函数里每个用到的类型会编译出一份代码 --> Rust 编译速度缓慢。
Rust 面向系统级的开发,Go 不适合面向系统级开发,使用场景更多是应用程序、服务等的开发(庞大的运行时,不适合做直接和机器打交道的底层开发)。
Rust 的诞生目标就是取代 C/C++,想做更好的系统层面的开发工具,在语言设计之初就要求不能有运行时。类似 Golang 运行时的库如 Tokio,都是第三方库,不在语言核心中,把是否需要引入运行时的自由度给到开发者。
Rust 社区里有句话说得好:
Go for the code that has to ship tomorrow, Rust for the code that has to keep running for the next five years. (go面向明天必须发布的代码,Rust面向未来五年必须继续运行的代码)
我对 Rust 的前途持非常乐观的态度。在系统开发层面可以取代一部分 C/C++ 的场景、在服务开发层面可以和 Java/Golang 竞争、在高性能前端应用通过编译成 WebAssembly,可以部分取代 JavaScript,同时,方便地通过 FFI 为各种流行的脚本语言提供安全的、高性能的底层库。
未来 Rust 会像水一样,无处不在且善利万物。
一些资料,怎么配合这门课程使用。
官方学习资料
官方的 Rust book,涵盖了语言的方方面面,是入门 Rust 最权威的免费资料。比较细碎,有些需要重点解释的内容又一笔带过,让人读完还是云里雾里的。
我记得当时学习 Deref trait 时,官方文档这段文字直接把我看懵了:
Rust does deref coercion when it finds types and trait implementations in three cases:
- From &T to &U when T: Deref<Target=U>
- From &mut T to &mut U when T: DerefMut<Target=U>
- From &mut T to &U when T: Deref<Target=U>
这本书适合学习语言的概貌,对于一时理解不了的内容,需要自己花时间另找资料,或者自己通过练习来掌握。想巩固所学的内容,可以翻阅这本书。
官方的 Rust 死灵书(The Rustonomicon),讲述 Rust 的高级特性,主要是如何撰写和使用 unsafe Rust,不适合初学者。学完进阶内容之后,再阅读这本书。
Rust 代码的文档系统 docs.rs 是所有编程语言中使用起来最舒服,也是体验最一致的。无论是标准库的文档,还是第三方库的文档,都是用相同的工具生成的,非常便于阅读,你自己撰写的 crate,发布后也会放在 docs.rs 里。在平时学习和撰写代码的时候,用好这些文档会对你的学习效率和开发效率大有裨益。
标准库的文档 建议你在学到某个数据类型或者概念时再去阅读,在每一讲中涉及的内容,我都会放上标准库的链接,可以延伸阅读。
为了帮助 Rust 初学者进一步巩固 Rust 学习的效果,Rust 出品了 rustlings,涵盖了大量的小练习,可以用来夯实对知识和概念的理解。有兴趣、有余力的同学可以尝试一下。
其他学习资料
书籍、博客、视频。
汉东的《Rust 编程之道》,详尽深入,是不可多得的 Rust 中文书。汉东在极客时间有一门 Rust 视频课程,可以订阅。英文书有 Programming Rust,目前出了第二版,我读过第一版,写得不错,面面俱到,适合从头读到尾,也适合查漏补缺。
博客我主要会看 This week in Rust,可以订阅其邮件列表,每期扫一下感兴趣的主题再深度阅读。
公众号主要用于获取信息,可以了解社区的一些动态,有 Rust 语言中文社区、Rust 碎碎念,这两个公众号有时会推 This week in Rust 里的内容,甚至会有翻译。
Rust 语言开源杂志,每月一期,囊括了大量优秀的 Rust 文章。不过这个杂志的主要受众,我感觉还是对 Rust 有一定掌握的开发者,建议你在学完了进阶篇后再读里面的文章效果更好。
在 Rust 社区里,也有很多不错的视频资源。社区里不少人推荐 Beginner’s Series to: Rust,这是微软的系列 Rust 培训,比较新。讲得有些慢,1.5 倍速播放节省时间。我自己主要订阅了 Jon Gjengset 的 YouTube 频道,他的视频面向中高级 Rust 用户,适合学习完本课程后再去观看。
bilibili 上,也有大量的 Rust 培训资料,但需要自己先甄别。我做了几期“程序君的 Rust 培训”,作为课程的补充资料。
坚定对学习 Rust 的信心。相信我,不管你未来是否使用 Rust,单单是学习 Rust 的过程,就能让你成为一个更好的程序员。
参考资料
1. 配合课程使用:官方的 Rust book、微软推出的一系列 Rust 培训 Beginner’s Series to: Rust、英文书 Programming Rust 查漏补缺
2. 学完课程后进阶学习:官方的 Rust 死灵书(The Rustonomicon)、每月一期的 Rust 语言开源杂志、 Jon Gjengset 的 YouTube 频道、张汉东的《Rust 编程之道》、我的 B 站上的“程序君的 Rust 培训”系列。
3. 学有余力的练习:Rust 代码的文档系统 docs.rs 、小练习 rustlings
4. 社区动态:博客 This week in Rust 、公众号 Rust 语言中文社区、 公众号 Rust 碎碎念
5. 如果你对这个专栏怎么学还有疑惑,欢迎围观几个同学的学习方法和经历,在课程目录最后的“学习锦囊”系列,听听课代表们怎么说,相互借鉴,共同进步。直达链接也贴在这里:学习锦囊(一)、学习锦囊(二)、学习锦囊(三)
老师好,今天阅读到这段话的时候不是很理解。“Rust 的诞生目标就是取代 C/C++,想要做出更好的系统层面的开发工具,所以在语言设计之初就要求不能有运行时” 这个不能有运行时如何理解,C/C++没有运行时吗?这里我的记忆有些模糊了,也去查了些资料,还是不太理解,Rust也没有运行时吗?没有运行时它是直接由编译器生成到机器码吗?
作者回复: 对,Rust 和 C/C++ 都是编译成机器码,直接面对具体的 CPU 架构。所以 Rust 代码需要为每个平台单独编译,这是它和 Java/DotNet 等语言的主要区别;但是像 golang 这样的语言,它也直接编译成机器码,但 golang,即便一个最简单的 hello world,编译出来的代码也包含了一个庞大的运行时,处理调度,GC 等工作。Rust 没有这些额外的运行时。
这个网站(https://rustwiki.org/)有很多学习 Rust 的中文文档。
Rust里循环引用就会内存泄漏(比如用RefCell<Rc<T>>),但简单的循环引用只要是mark-and-sweap的GC都可以处理。如果应用里有很多带循环引用的数据结构,用Rust写带来的心智负担就比用带GC的语言写大很多。
作者回复: 对于引用计数引发的循环引用,可以用 weak 解决:https://doc.rust-lang.org/std/rc/struct.Weak.html。循环引用的使用场景确实 tracing GC 处理起来更简单。