本文作者在文章的前部分用了大量笔墨详细描述了自己尝试 Rust 受挫的经历,后半部分分析了 Rust 的问题及发展。自发布以来,这篇文章在 r/rust 上得到了 500 多个赞,在 HN 上有 700 多条评论。我们将其翻译出来,以飨读者,也希望大家可以理性讨论。
在使用 Rust 的过程中,相信很多朋友都有过类似的吐槽:真不确定自己要掌握多少语言知识、多少独门编程技巧和多么旺盛的好奇心,才能坚持做完这项最最琐碎的工作。绝望之下,我们往往会去 rust/issues 寻找解决办法,然后突然发现了一种在理论上根本不成立的 API 设计思路。这种矛盾源自某种微妙的语言 bug,简直神奇。
我从四年前开始接触 Rust。目前为止,我跟同事们合作开发了teloxide和dptree,写过相关的书和文章,也翻译了不少语言发布的公告。我还设法用 Rust 编写过一些生产代码,甚至有幸在一场关注 Rust 的在线研讨上发过言。
虽然也算是身经百战,但我还是动不动就会跟 Rust 的借用检查器和类型系统“闹出”些不愉快。现在的我,虽然已经慢慢理解了 Rust “无法返回对临时值的引用”之类的错误,也设计出一些启发式的策略来处理生命周期问题,但最近一个意外再次打击了我的信心……
初次尝试:用来处理更新的函数
我们正打算编写一个聊天机器人,来改善用户的使用体验。通过长轮询或 webhooks,我们开始一个个获取服务器更新流。我们有一个面向全体更新的处理程序向量,其中每个处理程序都会接收对更新的引用,再把后续解析返回至()。这个处理程序向量由 Dispatcher 所有,每次有更新传入时,Dispatcher 都会按顺序执行各个处理程序。
下面,咱们试试具体实现。这里省略掉处理程序的执行部分,只关注 push_handler 函数。初次尝试:省略处理程序的执行,只关注 push_handler 函数。第一次尝试(游乐场):
在这里,我们使用由 HRTB 生命周期 for<'a>限制的动态类型 Fn trait 来表示每个更新处理程序。因为我们希望返回的 future 由 &'a Update 函数参数中的 'a 部分决定。之后,我们又定义了拥有 Vec的 Dispatcher 类型。在 push_handler 当中,我们接受一个静态类型的泛型 H 来返回 Fut;为了将此类型的值推送至 self.0,我们需要将处理程序打包至新的装箱处理程序当中,再使用 Box::pin 将返回的 future 转换为来自 futures 箱的 BoxFuture。
下面来看看这个解决思路行不行得通:
很遗憾,这办法行不通。
问题在于 push_handler 会接收一个具体的生命周期'a ,也就是我们试图将 HRTB 的生命周期归结成 for<'a>。在这种情况下,我们就需要证明 for<'a, 'b> 'a: 'b (其中 'b 为来自 push_handler 的'a),这显然不成立。
对于这个问题,我们可以尝试几种不同的处理方法:替换掉 Fut 泛型,转而强制要求 user handler 返回由 for<'a>限定的 BoxFuture:
现在编译部分没问题了,但最终得到的 API 还是有问题:理想情况下,我们并不希望用户通过 Box::pin 打包每个处理程序。毕竟 push_handler 才是专门干这个的,负责把静态类型的处理程序转换成动态类型空间中的等效形式。但如果我们强行要求处理程序保持静态,又会如何?
要探究答案,我们可以用异构列表来试试。
第二次尝试:异构列表
异构列表这名称看着唬人,实际上就是大家熟悉的元组。也就是说,我们需要的是(H1, H2, H3, ...),其中每个 H 代表不同的处理程序类型。但同时,push_handler 和 execute 操作又要求我们能够迭代这个元组——单靠原版 Rust 肯定是不行。要达成这个效果,我们就得借助其他一些神奇的表达机制。
首先来看我们的异构列表表示:
是不是有点不知所云?确实如此,我们只是想要构建起 Dispatcher<H1, Dispatcher<H2, Dispatcher<H3, DispatcherEnd>>>这种形式的类型,其等同于(H1, H2, H3)元组。因此,我们现在可以使用简单的类型归纳来定义 push_handler 函数:
有些朋友可能不太熟悉所谓类型级归纳,其实这就是一种常规递归,只是适用对象是类型(trait)、而非值:
这里的 base case 就是 impl PushHandler for DispatcherEnd。我们构建一个 dispatcher,其中只包含一个处理程序。
而 step case 则是 impl<H, Tail, NewH> PushHandler for Dispatcher<H, Tail>。这里我们只将归纳传播至 self.tail。我们再以同样的方式实现 execute:
但这还不够。因为我们 Execute<'a> 的实现要依赖于具体的'a,而且 dispatcher 还要能够处理不同生命周期的更新,所以最后一步就是面向全部更新生命周期对 execute 进行抽象:
好了,现在我们要来测试这种神奇的解决方案:
可惜还是行不通:
到这里,很多朋友应该体会到借用检查器的可惜之处了吧?而且无论怎么调整,以上代码都没办法正常编译。原因如下:传递给 dp.push_handler 的闭包接收到一条具体生命周期为'1 的 upd,但因为 where 子句中引入了 HRTB 边界,所以 execute 要求 Dp 只在生命周期 '0 上实现 Execute<'0>。但如果我们用常规函数试试运气,代码倒是可以正常编译:
这里会将 Update 传递为标准输出。
这种借用检查器的特殊行为确实不太合理,毕竟函数和闭包不仅各自 trait 不同,而且处理生命周期的方式也有所区别。虽然接受引用的闭包要受到特定生命周期的限制,但像我们使用的 dbg_update 这类函数应该可以在一切生命周期'a 上接受 &'a Update 才对。以下示例代码就演示了这种区别:
由于调用了 dbg_update,所以我们会得到以下编译错误:
这是因为 dbg_update 闭包只能处理一个特定的生命周期,而第一与第二个 upd 的生命周期显然并不一样。
相比之下,作为函数的 dbg_update 在这里倒是可以完美运行:
我们甚至可以很方便地使用 let () = ...;来追踪该函数的确切签名:
跟预想的一样,签名为 for<'r> fn(&'r Update):
话虽如此,但这样一个包含异构列表的答案也不符合我们的预期:它太过混乱、僵化、复杂,而且也装不进闭包。另外,这里不建议在 Rust 中使用复杂的类型机制。如果大家在处理 dispatcher 类型时突然遇到类型检查失败,那麻烦可就大了。想象一下,我们正在维护一套用 Rust 编写的生产系统,而且需要尽快修复一些关键 bug。而在完成了代码库的必要更改之后,却看到了以下编译输出:
在现实用例中,实际错误可能比演示的还要多 20 倍。
第三次尝试:使用 Arc
在刚开始接触 Rust 的时候,我曾经以为引用要比智能指针更简单。但现在我基本只用 Rc/Arc 了,毕竟牺牲一点点性能就可以跟生命周期保持距离,这有什么不好?而且信不信由你,前面提到的所有问题,都是由 type Handler, 'a 中的单一生命周期引起的。
让我们把它替换成 Arc 的形式:
成了,这不就正常编译了吗!我们甚至都不需要在每个闭包里手动指定 Arc ——类型推断就能帮我们完成繁琐的操作。
Rust 的问题
“随心所欲地并发”这话,大家都听过吧?虽然原则上也没错,但这句话其实很有误导性。没错,我们不用再怕数据竞争,可除此之外还有别的麻烦随之而来。
其实在前文的演示中,我们还没涉及到 Rust 的全部特性和缺陷,这些毛病其实相当不少。首先就是装箱 future 的大量使用:之前提到的所有 BoxFuture 类型,以及 Box::new 和 Box::pin 相应的优化,都没办法用泛型来替代。如果大家多少了解一点 Rust,就会知道 Vec 只能容纳固定大小的类型,所以才需要把 BoxFuture 放在 type Handler 之内。但在 Execute trait 中使用 BoxFuture(而非 async 函数签名)时,这个问题就不那么容易被发现。
这背后的原因也很复杂,但简单来说就是,我们没办法在 traits 中定义 async fn 函数;相反,大家只能使用其他类型擦除方法,例如 async-trait 板条箱或者手动 future 装箱,也就是我们在示例中采取的办法。事实上,async-trait 走的也是这个路线,但我还是会尽量少用,因为它会使用过程宏来处理编译时错误。
另外,返回 BoxFuture 这个办法也有自己的问题:首先就是我们得牢记为每个 async fn 指定 #[must_use],否则即使是在没有.await 的情况下调用 execute,编译器也不会给出任何警告。从本质上讲,装箱静态实体实在太多,所以 futures 箱会经常暴露在常见 traits 的动态变体面前,包括 BoxStream, LocalBoxFuture 以及 LocalBoxStream (后两个不要求 Send)。
其次,upd 的显式类型注释又是另一个大问题:
编译器输出:
(如果去掉类型注释 &Update,则编译成功。)
相信很多朋友都看不懂这里到底出了什么错,这很正常,我们可以参阅一个问题 #70791(https://github.com/rust-lang/rust/issues/70791)。查看问题标签列表中的 C-Bug,可以看到它将问题归类为编译器 bug。
截至本文撰稿时,rustc 还有 3107 个未解决的 C-bug 问题和 114 个未解决的 C-bug+A-lifttimes 问题。还记得之前提到的 async fn 有效,但等效闭包却无效的情况吗?这也是编译器 bug,具体参考问题 #70263(https://github.com/rust-lang/rust/issues/70263)。还有更多 2020 年之前就发现的老问题,例如问题 #41078(https://github.com/rust-lang/rust/issues/41078)和问题 #42940(https://github.com/rust-lang/rust/issues/42940)。
另外,就连注册处理程序这种简单的任务,我们也得尽量想办法让它绕过 Rust 语言,否则就容易受到 rustc 问题的影响。在 Rust 中设置接口就像是趟雷区:要想成功,就得小心翼翼在理想接口和可用功能之间求取平衡。
有些朋友可能要说,编程语言不都这样吗?那可不是,Rust 的问题特殊得多、也烦人得多。
我们在使用其他稳定生产语言的时候,一般至少可以预判理想中的接口要如何适应语言语义,但在用 Rust 编程时,设计 API 的过程总会受到语言自身的种种限制和影响。刚开始,我们当然想正常通过借用检查器验证引用,用类型机制处理程序实体,但最终结果永远是 Box、Pin 和 Arc 满天飞、身陷 Rust 类型系统那孱弱的表达能力难以自拔。
作为这一段的结尾,我们来看同样的需求在 Golang 中的实现方法:
dispatcher.go
Rust 为什么这么难用?
首先,面对这类问题的时候,希望大家能抛掉“因为 XX 就是逊啦”或者“因为 XX 的设计者太弱智”这类粗暴又毫无意义的情绪宣泄。
那么,Rust 为什么这么难用?
首先,Rust 是一种系统语言。作为系统编程语言,Rust 绝对不能阻止程序员直接接触底层计算机内存的管理机制。也正因为如此,Rust 才向程序员们开放了其他高级语言所极力隐藏的种种细节。例如:指针、引用和相关等元素,内存分配器、不同字符串类型、各种 Fn trats、std::pin 板条箱等等。
其次,Rust 是一种静态语言。具有静态类型系统(或等效功能)的语言,更倾向于在静态和动态层级上复制功能,借此引入静态-动态二元性。将静态抽象转换为动态对应抽象,被称为向上转换;由动态转换到静态则称为向下转换。在 push_handler 当中,我们使用向上转换将静态处理程序转换为动态 Handler 类型,再把它推送给最终向量。
另外,Rust 在设计上还高度强调直观性和内存安全性。正是这种复杂的组合,在计算机语言的设计中强调了人为边界的重要性。
说到这里,大家应该能够理解为什么 Rust 用起来总感觉哪里有毛病。事实上,它能运行起来本身就已经是个奇迹了。计算机语言是一种由无数组件紧密交织而成的体系:每当引入新的语言抽象时,都得保证它能跟系统的其余部分良好配合,避免引发 bug 或不一致。所以,我们真的应该感谢和敬佩那些愿意全职开发这类语言的贡献者,最好能给他们捐点款。
Rust 还能不能变得更好?
现在,我们假设 Rust 的所有问题一夜之间都被解决了,而且整个 rustc 和 std 也都经过了正式验证。就是说,Rust 突然就获得了包含多个 1 级实现的完整语言规范、能够跟 GCC 比肩的硬件平台支持能力、稳定的 ABI(虽然还不清楚具体该怎么处理泛型),结果会怎么样?那 Rust 当然就是系统编程的理想语言喽。
我们也可以从另一个角度设想,Rust 的问题确实消失了,而且变成了一种彻头彻尾的高级语言。那它就足以干掉一切现有主流编程语言。毕竟 Rust 的默认功能相当丰富,支持多态,包管理器也非常方便。相比之下,愚蠢的 JavaScript 语义、恐怖的 Java 企业应用、C 中的 NULL 指针问题、C++的不可控 UB、C#中多到毫无必要的同种功能实现等等,简直就是一场荒谬的畸形秀。
但现实告诉我们,即使这些语言各自有着不同的缺点,人们仍然用它编写生产软件,而且当前的 Rust 还远远挤不进编程语言的第一梯队。
另外,我估计 Rust 永远也达不到 Java 或者 Python 那样的人气。这背后的原因更多在于社区、而非技术:由于 Rust 语言天生更为复杂,所以相关的专业工程师数量不可能比得上 Java 或者 Python。
更糟糕的是,Rust 工程师的稀缺也让他们的平均薪酬相对更高。毕竟作为企业雇主,确实没必要用更多的钱和更长的招聘周期来无脑支持 Rust。
最后再做这样的设想:Rust 的问题全都消失了,它变成了一套高级且统一的功能集。这可能也是 Rust 开发者们的终极目标:让它成为一种面向大众的高级泛化编程语言。有趣的是,设计这样一种语言可能反而比开发现有 Rust 的难度更低,毕竟我们可以把所有低级细节都隐藏在那层厚厚的语言运行时外壳之下。
好日子会到来的
所以我好像突然想通了,为什么不开发这样一个终极版的 Rust 呢?但我可不打算亲自动手,毕竟这工作没准得耗上十年、二十年,而且最终成果能在编程语言中脱颖而出的几率也实在不高。
在我看来,目前常用生产语言的成功其实有着很强的随机性——我们虽然能从特定的流行语言身上总结出一些明确的优势,但却没法解释为什么其他一些更好的替代语言始终默默无闻。有大企业的支持?无意中契合了下一阶段的 IT 发展趋势?但大企业为什么要支持,这种契合又是怎么达成的?残酷的现实告诉我们:有些事情就是随机发生,再强的技能、再无私的奉献都改变不了。
所以如果大家也想创造一种面向未来的编程语言,我建议大家三思而后行——除非你既勇猛无比,又是个无可救药的理想主义者。
作者的一点澄清
有人指出,dispatcher 例子主要影响的是库维护者群体,应用程序开发者一般不会受到这类特殊问题的影响。这话其实没毛病,但我写这篇文章主要是想讨论 Rust 语言的设计思路。
Rust 并不适合泛型 async 编程,这是事实。当我们输入 async 时,总会观察到语言中的其他功能突然崩溃:引用、闭包、类型系统等等。从语言设计的角度来看,这正好体现了 Rust 完全不符合“正交语言”(如果在编程时调用程序语言不用考虑其是否影响其它语言特性,就称此语言为正交程序语言)的基本原则。我在原文中想要表达的,其实就是这个意思。
另外,能不能编写出高质量的库,在很大程度上反映出了语言的真正潜力。毕竟库的任务就是处理泛化代码,所以直接对应语言设计得提供的功能表示能力。这种能力也会直接影响到常规应用编程:我们的库越优雅,日常任务的解决难度就越低。例如:不具备 GATs,我们就无法获得泛化运行时接口,并只通过一行代码就直接把日志中的 Tokio 全部替换为正确的 Tokyo。
另一位热心的朋友还整理出一份完整的 async Rust 故障列表(https://www.reddit.com/r/rust/comments/v3cktw/comment/ib0mp49/?utm_source=share&utm_medium=web2x&context=3),其中包括函数着色、异步 Drop 和库代码重复等问题。
受篇幅所限,我在本文中无法一一列举这些内容。但在使用 Rust 之前,建议大家想看看要使用泛化异步代码时可能面对的种种问题,别被吓着哦:)
原文链接:
https://hirrolot.github.io/posts/rust-is-hard-or-the-misery-of-mainstream-programming.html