结合实际工作和编码的经验,谈一下rust这门编程语言,以及些非语言性质的领悟。
在2018年h2我在公司的工作内容很多方面涉及到修复令人头疼的内存释放不掉的问题。(我这里不再说内存泄漏了,因为脚本语言就没有内存泄漏的的概念。)但是即便有devtools的堆快照神助,依然是非常令人恶心。具体而言就是某些操作前后截取快照,compare,看看那些对象多了,为什么多了,是被谁持有释放不掉?然后修复问题。这个话说是轻巧,但是这个引用链看起来真的是头晕目眩,各种闭包,各种浏览器内置的对象,甚至编译工具,React本身就是有问题的。至此我也不觉得垃圾回收是万能的内存管理方案,只要不顾一切,总是能把事情搞砸。垃圾回收的另一个致命的问题是当我们试图支持无限大场景,实现了一套磁盘/网络交换系统,但是由于我们无法控制垃圾回收何时触发,内存是否能真正释放,所以最后整个项目以失败告终。
虽然那个半年做的非常失败,我现在觉得那段时间对我的提升是巨大的,包括在那年的h2retro中总结的一样,比如,当我们实现一个功能,不能说看到那个对象有我们要的东西,就直接引用过来,直接搞。整个项目里全是引用乱指,对象之间相互依赖,没有职责能力划分,一团麻花。所以后来我写代码是会比较关注对象引用结构的。我总结下来一个比较重要的实践是:当一个对象持有另一个的时候却仅仅是为了messaging,他们没有依赖上的关系时,我们应该将另一个对象作为方法参数来引入,而不是直接持有这个对象。等等,有一系列诸如此类的感想吧。在当时业余我也逐步开始着重做我的图形引擎,作为框架作者的角度来思考事情,这些事情包括但不仅限与上述的引用关系,状态的划分,逻辑的组织,接口的设计,这些从现在的视角下都是常识,但基本上都是从反面例子中自行体会和总结。
作为一个图形渲染引擎工程师,我的工作其实主要解决渲染性能方面的问题,但不幸的是我们是在web平台上做渲染,只能使用非常糟糕的JavaScript(当然其实是Typescript)和落后的WebGL。很多时候,我们编写一些我们以为可能运行的高效的代码,但是实际执行速度感人,很难猜测js引擎能否优化如何优化,当所有算法/做法层面的潜力都尝试干净了,我们完全无法在实现层面作出任何有效的改进,这令人窒息。
总结为技术上的瓶颈就是:用脚本语言在做一个追求一个高性能的底层引擎项目时逃离不了的若干问题,以及连锁导致代码质量崩溃的问题。
最早听说rust是在17年左右,主要是知乎和 hacker news。 我经常听到一些关于这门语言很酷的点,比如这门语言是Mozilla为了写浏览器引擎而做的语言,当然除了servo,rust后来的确成就了quantum firefox的传奇。出于上面遇到的瓶颈,于是在令人颇为不适的工作之外我试图开始接触较为底层的技术,当时经过一番研究和权衡选择rust而不是c++主要原因在于:c++太难了坑太多了,rust虽然也不简单,但是难有难的道理,而不是难在坑点上,既然as a better c++,我也不在乎哪一天把这c++ best practice/ good parts 写回c++里。重在思想上的学习而不是语法和历史坑点的掌握,精通回的四种写法并没有实际的用处。另一个不得不说的点是,cargo等工具链是在是太好用了,完全不用搞依赖,搞配置,搞编译搞环境,又免去了太多我认为不是重点的东西。
开始正式学习和业余使用是在18年末,顿时真的觉得解决了我很多的困扰。以至于在19年下半年开始,我开始全量使用rust进行业余项目的开发。因为毕竟不是脚本了,自然免去了垃圾回收的诸多问题,同时实践上rust的确证明了自己是c++级别的性能。在此我想更多的谈一下取舍的问题和代码质量上的正面作用。
在我入行的第一年,我就深刻意识到一个真理,就是人一定是不能被相信的,如果一个事情不是由机器做,而是人,那么没过多久就一定出问题,而试图让人不出问题的体系效率一定是非常低下,成本一定是非常高昂的。我们为什么要有静态类型,js写的不是很爽吗,为什么要发明ts?因为静态类型系统使得类型检查从运行期转移到了编译期,使得我们不执行代码就能保证代码里没有类型错误。同样的,就rust的重要特点:内存和并发安全而言,就是c++写的不是很爽吗,为什么要发明rust?因为rust的类型系统能够在编译期检查引用有效性和并发安全性,使得我们不执行代码就能保证代码里没有内存错误和并发不安全情况。我个人认为这一点比ts之于js还要重要,因为类型错误通过测试代码能保证检查的出来,而内存错误,尤其是不安全的并发,不但不能保证能检查出来,甚至实际出错点和错误暴露点相隔甚远,后期事后排查修复成本巨大,这基本上没有其他一门成熟语言可以做到。
关于义务和权利同样的类比:ts的代码因为采用了静态类型,导致一些非常动态/完全动态的代码无法表达,但又同时能随意as any as known绕过类型系统,将检查的义务返回给开发者,由开发者承担类型检查的责任和风险以换取表达上的自由不被类型系统所约束的权利。rust的代码因为采用了静态生命周期检查,导致一些生命周期非常动态/完全动态的代码无法表达,而只能将一些检查以某些手段延迟到运行期进行,但如果开发者对逻辑充满信心非常了解也不愿意牺牲性能,rust又能同时随意unsafe绕过类型系统,将检查的义务返回给开发者,由开发者承担生命周期检查的责任和风险以换取表达上的自由不被类型系统所约束的权利。
关于喜好同样的类比,大部分的人在写了ts之后就不愿意写js了,同样的少部分人无论如何都不愿意写ts,这不可强求,毕竟有些人生性自由不愿收到一点点限制。rust同样如此。但我想只要是写过一点成规模正经代码的,恐怕都能意识到我入行第一年意识到的问题,而作出正确的选择。从编程语言历史发展的角度来看,语言的发展几乎是往特性中添加约束的过程,汇编没有任何约束,后来提倡if else loop结构化的编程而避免goto,后来我们提倡RAII。。
相信对”权利和义务“ 的讨论能够说明一些所谓批评说rust写个链表都费劲的人其实是并不理解设计上的取舍,严谨和自由始终是相互平衡的,更严谨也并没有限制自由,所以这些批评是完全不成立的。
因为没有垃圾回收的语言过于危险和复杂,所以相当多的语言都牺牲了极致的性能来换取安全性。而rust成为第一个“我全都要”的语言,并且我认为做的相当成功,“A language empowering everyone to build reliable and efficient software.”
除了更多的编译期检查带来的硬性约束,我想从几个具体的角度再来谈谈一些对项目山比较soft但是又solid的impact:
不使用unsafe及内部可变性的rust的代码无法表达任何包含自引用类型的数据结构,这使得正常情况下你的整体应用的数据结构会尽可能的形成一颗树,这潜在意义上意味着你没法轻易的随意引用不应该持有的数据,从而不得不完全的根据逻辑/模块设计数据结构,往往数据结构设计合理了,就成功一半了。如果不加思考的使用错误的设计,在后续写逻辑的时候编译器会非常抱怨,你又如果强行用rc refcell等内部可变性机制绕过,那么最后代码会写的很丑陋。这一层限制反而对项目质量产生了正面影响。
同样的,更多的约束带来更好的质量:语言为提供了数据访问能否独占的能力,在不使用内部可变性的情况下只要独占访问才能修改数据,一些好的模式可以利用这一特点来保证特定数据结构在给定环节不被意外修改,比如在webgpu renderpass录制command时,renderpass持有encoder来避免encoder调用mutable方法产生bug,比如webgl的ctx状态缓存,在给定drawcall提交时交由提交者独占,防止错误的状态同步。而这种独占对于阅读代码的效率也有正面作用,你很容易追踪这个链路上数据是如何被修改,访问,分享的。
虽然所有权让一些共享所有权的情况变得难受一点,也导致了上述数据组织上的额外思考,但从接口的角度而言,对比其他语言,引用/指针的所有权只能通过文档来说明,如果用户稍加不注意则会搞出可怕的事情来,而现在这种事情是不存在的了,我要是不move给你,你是没法持有我的数据的,ownership是编译器检查的,这就是对质量的强力保证。在对外结构的设计上也大可直接干脆和安全的多了。
因为没有继承的缘故,所以用户不得不以组合代替继承,以trait提取抽象,这虽然对于大部分熟悉于面向对象的开发者难以适应。但是!当你适应了从面向对象到面向数据的范式转变之后你就再也回不到过去了,关于DOD我不是很想再展开,当你顿悟了ECS,CGS,背后的深刻的灵活和强大,就能感受感受到思想上的解放。当你从数据和数据流的角度观察和编写程序,你仿佛就是在搭建一个处理数据的工厂流水线,安全精确的利用各种语言特性摆弄拼接各种形状的内存容器和算法来完成你的任务。而这一切都通过编码过程中和编译器的反复对话完成,不断的polish不断的演进。
除了数据思想,在接触这门语言另一个领悟是来自trait系统。我觉得trait完全不亚于内存安全,也值得算是杀手级的特性。一套语法同时统一编译期和运行期的多态,这才是真正的大道至简好不好(golang是什么垃圾)。我在rust编码过程中的最大乐趣就是写trait,trait真的是那种描述抽象最佳的做法,当你用trait把一些非常微妙的共性提取出来,把一些结构上数据上职责上的依赖,精确优美的切割的干净利落,能感受到一种逻辑上精神上的升华。当你需要极致性能的时候,泛型能展开内联调用生成高度优化的代码,当你需要动态灵活的时候,dyn trait又能轻松的把东西包装起来。即便我没怎么写过c++的人,阅读了effective c++时对其中的最佳实践,瞬间从等价的rust概念完成了相互理解(比如这里对应的虚函数,模版)之后,更觉得trait才是正确而优美的存在。
这里仅罗列了我的视角下比较亮眼之处,相信随着语言继续发展和使用的深入,有机会可撰写和记录更多心得。
在过去的三个月,我已经开始在公司内fulltime写rust了,并且取得了非常正面的效果,完美解决了性能/质量方面的问题。这在过去恐怕是难以想象的。作为第一个吃螃蟹的人,在此诚实推荐这门语言👍