用Rust写脚本语言(二十四):这语言好难学

Cowsay:用Rust写脚本语言(零):目录zhuanlan.zhihu.com图标


whoiscc/shattuckgithub.com图标

一个月时间里,我已经在Shattuck这个项目上完成了近12k的增删。

任凭我想象力再丰富,我也猜不到为了完成一个正确的运行时,我会经历如此之多的艰难险阻。如果可以回到过去,我最想做的事情就是对着那个刚写完《作用域与栈帧》的自己一顿毒打

畜生!你写了甚么!

我也曾写了不少代码。一次又一次地尝试与C++和平共处,又一次又一次地被伤透了心;还有用Cython实现的大规模真·多线程数据处理业务,半Python半C++真的让人经常怀疑自己已经神志不清了;至于在已经被时代忘记的CoffeeScript反复尝试基于各种框架的2048之类的(框架自然也是自己写的)(有多少人考虑过在两次滑动的动画如何正确地重叠呢),用ES6,Python等等企图实现从后到前各个层面上的静态站生成器之类的,相比之下都「软」到没眼看了。但是回过头来,Rust才是带给我最多感悟和好习惯的那门语言。

今天,我想在这里简单地总结三点,使用Rust给我带来的独有的体验。我需要稍微转变一下脑筋,比如像学Rust这样认真地重新学一下Haskell。

当我们到达现场时,就只剩下一地的$.了……

第一,对于与生命周期相关的报错,多考虑出错的本质。

相信对于绝大多数的Rust初学者来说,「三段论」式的报错都是绝对的噩梦。

首先呢,它的生命周期不能超出……但是呢,它的生命周期至少有……只有这样才能满足……所以,无法为它推导出一个合适的生命周期。

对于写惯了C的人来说,这类编译错误是完全陌生的。这种陌生不仅仅是来自于对生命周期概念的懵懂,而且是因为这和C语言的编译错误完全是两类不同的错误。

C语言的编译错误,不说全部,几乎都是「局部性」的错误。这个局部不是作用域意义上的局部,而是代码组织上的。通常来说,一个报错可以通过修改被编译器圈出来的那部分代码来修复,而不需要考虑程序中的任何其他部分。是的,如果被圈出来的是个函数,也许修改了它的签名会导致调用函数的代码不再正确。但是编译器会在这个错误被修正以后继续报新的错误,把受到牵连的代码也圈起来。我们只是预测了编译器的行为,就算不去预测,「你说什么我改什么」同样可以顺利完成工作。

但是Rust中,尤其是与生命周期相关的错误,不具有「局部性」。如果只关注被圈起来的代码,那么很快就会陷入死循环:可能的写法就那么几种,而每一种编译器都不满意。再加上Rust编译器的报错是分批次的:所有的名字错误都解决了才报与可变性相关的错误,一直到其他类型的错误全都解决完了才报生命周期相关的错误,本来就已经筋疲力尽的用户再面对这样的死循环会感到加倍的绝望。

事实上,解决Rust的编译错误就是需要一定的「见微知著」的能力。解决这样的错误经常需要对大批报错范围以外的代码进行调整,至于对哪里进行怎样的调整编译器不会给予任何提示。这是一种Rust独有的编译错误,它的出现经常不是说「你的写法和你的想法不一样/我不能通过你的写法读懂你的想法」,而是「你的想法本身有问题」。目前还没有哪种编译器能准确地报告「你的想法哪里有问题」,Rust比C++最大的进步就在于,它可以将想法有问题的代码拦截下来,让用户在编译期就意识到自己的设计失误,而不是运行期。虽然拦截的手法不甚精致,但是我在写Safe Rust期间的确一次都没有用到调试器。

因此,在遇到这种「三段论」形式的报错时,切不可如往常一般把注意力完全集中在那一点点细节上,反而要像真正的调试一样,放宽视野,问问自己,这段代码是做什么的?每一个实例的所有者是谁?在哪里创建,在哪里转交,在哪里被销毁?在C语言指针的世界里,任何一个指针所指向的实例抓过来就可以用,返回走了就不管生死了,在Rust里这是完全行不通的。在没有彻底转变思维定式之前,设计出的Rust结构类型经常会有一些生命周期相关的细小瑕疵,而这些瑕疵最终会在类型的方法实现甚至是调用时才引发编译器的不满。这时对报错处的代码再怎么调整也不能解决问题,重新设计结构体才是解决问题的关键。

当然,随着经验和教训的积攒,设计符合Rust思想的模型的手法日益娴熟,这样的报错会逐渐减少至一个正常的水平。这时,下面一点就开始出现了。


第二,Rust提供的一切抽象,都与硬件密不可分。

在逐渐熟悉所有权和生命周期系统以后,我们会慢慢感受到Rust冷酷面孔之下的热情。其所提供的enumtrait等概念都可以让一个原C程序员感受到家的温暖。更进一步的,我们会逐渐接触trait object,为我们的类型和函数添加具有复杂约束的类型参数。总而言之,我们的编译思路会越来越脚本语言化。这时,Rust所提供的抽象就开始越来越束手束脚起来。

如果统计一下我在Rust编程群里的发言的话,估计超过一半都是「xxx为什么不可以xxx?」

虽然Rust经常被拿来和Go对标,但是Rust是一门非常「精打细算」的语言,和Go的思路并不相同,对于此前非常熟悉Swift的我来说更是难以适应。在这方面Rust更接近C++,虽然没有恪守零开销抽象,但是在概念的完备性,和性能以及实现的复杂度之间的平衡上,Rust还是非常偏向后者的。

举一个具体的例子。Rust的trait object在一定程度上可以看做是C++多态的替代品。但是由于Rust对实例生命周期的严格管理,一个&dyn Foo比起Foo &简直不要太菜。如果说Rust所提供的这些看起来非常「脚本语言」的抽象真的可以像脚本语言一样灵活使用的话,那也就不用我辛辛苦苦写Shattuck了(。稍微夸张一点说,它们只是「很像」它们在脚本语言中的兄弟而已,实质上完全不同。就像CPU中具有硬件层面上非常完善的「异常处理」系统,甚至像Java一样区分了可以恢复的错误(如缺页)和不可恢复的异常(如除0),但是这只是浮于表面低得相似而已,完全不可以把CPU提供的错误处理当做脚本语言的错误处理来用。

Rust提供了这些抽象,正确的使用方法应该是,先遵循硬件模型设计正确的数据结构和算法,然后通过使用这些抽象来简化和整理向外界暴露的接口。 为了说明这一点,我尽量简略地讲述一下Shattuck中对象概念的演变历史——说起这个真的极有可能由于过于激动导致篇幅失控……

最初Object是一个trait,对象实例被送入运行时之前由用户将其打包为Box<dyn Object>。这时的Object的概念其实就和Java或Python里一切对象的基类没什么区别,只是为了确保其大小编译期可知所以才放入Box,这一点就预示了它的悲惨结局。

接下来我意识到我需要将其动态向下转型。在Rust中「安全」地实现这一点的唯一途径是Any。于是要变成Box<dyn Object + Any>了吗?这种写法并不合法,其代表的含义也超出了Rust所能实现的语义范围。因此我只能将Any设置为Object的super trait,并且通过冗余的as_any方法实现我想要的功能。到这里,Rust提供抽象的局限性逐渐体现出来。

(跳过若干。)最后,我需要将一个不能安全跨过线程的对象实例转换为一个可以安全共享的对象实例。这需要用户实现一个方法,吃掉原来的对象吐出一个新对象。这就点了原来这个模型的死穴:我只能拿到&dyn Object,没办法把对象的所有权交给用户!一个符合脚本语言思想的模型,最终还是没有在Rust中活下来。

后来,我花了很长时间,强迫自己思考一个问题:

如果现在是用C,我会怎么设计这个概念?

得出的结论似乎很幼稚:

struct Object {
    void *internal;
}

那么,现在我希望每个对象能把它自己转变为某个线程安全的类型SyncObject的实例

struct Object {
    void *internal;
    struct SyncObject *(*make_sync_f)(void *);
}

struct SyncObject *object_into_sync(struct Object *object) {
    return object.make_sync_f(object.internal);
}

用Rust就能不这么写了吗?不可能。

type MakeSyncFn = fn(Object) -> Result<SyncObject>;

pub struct Object {
    content: Box<dyn Any>,
    get_holdee_f: GetObjectHoldee,
    make_sync_f: MakeSyncFn,
}

impl Object {
    // explicit different name with ToSync::to_sync
    pub fn into_sync(self) -> Result<SyncObject> {
        (self.make_sync_f)(self)
    }
}

(这里用Box<dyn Any>而不是*mut Any代替void *,因为似乎不在堆上几乎没有意义。)

在设计核心的设计时,Rust提供的任何抽象都不可能帮助我们减少实际存在的复杂度,该有的东西一样都缺不得。

但是呢,我们可以用一个trait来使得用户用起Object来更加方便

pub trait ToSync {
    type Target: Any + Send + Sync;
    fn to_sync(self) -> Result<Self::Target>;
}

trait MakeSync {
    fn make_sync(object: Object) -> Result<SyncObject>;
}

impl<T: Any + ToSync> MakeSync for T {
    fn make_sync(object: Object) -> Result<SyncObject> {
        Ok(SyncObject::new(object.take::<T>()?.to_sync()?))
    }
}

impl Object {
    pub fn new<T: Any + GetHoldee + ToSync>(content: T) -> Self {
        Self {
            content: Box::new(content),
            get_holdee_f: T::get_object_holdee,
            make_sync_f: T::make_sync,
        }
    }

(其实MakeSync也可以没有,只是为了降低我自己的心智负担。)

最终,用户只需要实现ToSync,这是一个和Object以及SyncObject都没关系的trait。这样一来,我就可以给用户提供一个看似「魔幻」的事实:

任何一个类型,只要实现了AnyGetHoldeeToSync,就可以成为Object了!

而不需要用户不知道从哪里给我们找一个函数指针传进来。但这并不会改变Object所必须包含的函数指针。C要有的,Rust都必须要有,顶多好看点。


第三,一切设计不能脱离实际。

这可真是我最感到惊奇的一点了。在Rust中,你可以轻而易举地写出一个类型,然而用户没有任何办法好好使用它!同时,编译器也不会对你有任何抱怨。当然了,不是说C++中不能做到这一点,但是通常都需要涉及模板元编程等等高深的技巧。然而在Rust中,这种情况变得常见了起来。这也许应当归罪于trait相关的语法设计,希望随着Rust的发展可以适当缓解这个问题。

作为一个例子,可以看看我某一时期对as_ref的设计。一个Object的实际类型有两种可能:线程不安全的类型,或者由于要共享将其转换成的线程安全的类型。当用户向下转型时,绝大多数情况下他对于这个Object会是两者中的哪一个一无所知, 因此只能两个类型都试一试,这样看起来实在难受。于是我最初的设计是,假定两个类型都实现了同一个trait Foo,它们就可以实现Deref<Target = dyn Foo>。于是

fn as_ref<L, S, I>(&self) -> Result<&I, ErrorT>
where
    I: ?Sized
    L: Any + Deref<Target = I>
    S: Any + Deref<Target = I>

想法很美好。然而

  1. 一个类型只能实现一次Deref,这个宝贵的机会就这样被占用了太可惜了。
  2. 对于LS是同一个类型的情况及其不友好,因为T&TAny::type_id是不一样的
  3. 对于获取可变引用的版本as_mut,一个&mut I无法像&mut L一样被临时解引用,因为它的大小编译期不知道,无法分配栈上空间

最终的结果就是这个接口的可用性极差。这不是Rust的错,只怪我错误地使用了Deref和trait object的功能。

最后我把它拆成了两个接口。as_ref<T>负责LS都是T的情况,而另一个接口是

pub fn as_dual_ref<L, S, LF, SF, R>(&self, local_fn: LF, shared_fn: SF) -> Result<R>
where
    L: Any,
    S: Any,
    LF: FnOnce(&L) -> Result<R>,
    SF: FnOnce(&S) -> Result<R>,

用起来舒服多了。所有的类型都可以推导出来,太舒服了。

所以说,设计Rust的接口时,在脑海中凭空构思用户使用的方式都是不够的,要真正地通过单元测试之类的方式落实到代码上去。

也许是我的脑回路比较奇怪才会碰到这类问题吧……

这也许是我近期最后一篇长文了。以后我还是会尽量多用英文来写。

祝愿各位读者的Rust编程之路秀发浓密。

Cowsay:用Rust写脚本语言(零):目录zhuanlan.zhihu.com图标

编辑于 2019-06-15