对于编程语言,有一种特殊的感受,我一般形容为仿佛在写C++。
简单的说,就是特性互相之间打架。
在C++当中这个现象已经形容了恐怖的闭环:为了解决特性打架,专门加入了新的特性,然后更多的特性带来更多的打架。
大部分其他语言其实还好,仅仅是偶尔的既视感,并没有实质性的危害到语言本身。
也有一些语言是完全没有这种现象的。但那些语言多半是秉持极简的思路设计的。为了规避形似C++而付出了代价。
在Rust当中互相打架的特性向来不多。
我过去曾觉得trait object的设计让我产生了面对C++一般的难受。我以为它会像编译时分发一样工作,结果发现它们有很大的区别。
但后来我很快意识到,这不过是Rust的仁慈,让静态和动态分发复用了相似的语法,它们本来就是截然不同的存在。
一个trait的设计的时候大概就要定下来,它是应该当作静态分发还是动态分发的trait。很少有trait要同时做两种用法。
然后在2018 edition里强制了dyn
关键字,从而进一步让两种特性泾渭分明。不再会让人有打架的误解了。
类似的还有对trait的blanket implementation,因为rust遵循open world assumption而导致的对impl的严格限制。过去很难搞懂到底用户会不会被允许以我预期的方式使用我的trait。
现在规则有了一定程度的简化,并且这个特性其实很少会接触到,所以大部分时间都被我忘记了。
在绝大多数时候Rust给我一种特性不但完全不打架,而且充分默契,设计的一致性和完整性爆表的感觉。经典的例子莫过于,对应于三种生命周期语义:移动,独占借用和共享借用,闭包有着三种对应的FnOnce
,FnMut
和Fn
,迭代器一般也有三种对应的into_iter
,iter_mut
和iter
。设计美感拉满。
生命周期可能会成为一个例外,而且是永久性的,伴随这门语言走到终结。
生命周期是隐式的。很多时候一些复杂的生命周期是写不出来的。这是Rust坦然承认的一点。就像C89里就是没法生命循环作用域变量一样。这些作用域,从概念上是存在的,但语言没有表达的能力。
在类型签名上面这导致了无法避免的冲突:类型签名是对外的接口,是编译器自动推导的边界。无论如何,类型签名必须是显式的。
于是,Rust一开始引入了生命周期参数。
然后发现不够用,于是又加上了高阶生命周期参数。
然后发现还是不行,只好又添加了协变、逆变和不变规则,并且完全以编译器开洞的方式来决定适用的规则。
说真的,当我在死灵书中看到在学Haskell时没怎么看懂的高阶类型的时候,看到在学Scala时没怎么看懂的协变逆变的时候,我是感到震惊的。我一度以为Rust比我想象中还要学术,属于是在学习曲线陡峭程度方面直接开摆了。
然后,当我又读到这些特性一开始只是为了表达生命周期而不得不引入的时候,我又震惊了一次。我还从来没见过这门语言为了任何特性妥协到这种程度。
哪怕到了今天,一般意义的高阶类型(即以普通类型作为参数的类型)也只有引入作为trait的关联类型的计划,而且除了编程语言专家们以外似乎也没有人特别关心。因为大部分人大多数时间并用不上。
而协变逆变更是完全没有拓展的意思,连对应的unstable trait都没有。因为真的,用不上。
这些真的就是生命周期标记所带来的,纯粹的负担和代价。
虽然这份代价算是很隐蔽了。虽然我也没有什么替代方案,并没有能力设计出一套更好的解决方案。Rust的一部分有着形似C++的隐患,这已经成为了永久的事实。
如果要我仅限在Rust和C++之间选择,我是没有任何犹豫的。当我已经完全在用写Rust的思路去写C++的时候,我就明白了。
但设计一门好的编程语言,这条路还远远没有抵达终点。