用Rust写脚本语言(五):方法对象

用Rust写脚本语言(五):方法对象

目录

项目地址:whoiscc/shattuck。一天之内写太多话会显著降低内容质量,但我还是得写,因为再不写就要忘掉了。

这次的主角是方法。由于还没有实现字节码亦或是语法树,main.rs中使用一个无用的native方法作为例子。

//

extern crate shattuck;
use shattuck::core::runtime::{Runtime, RuntimeError};
use shattuck::objects::int::IntObject;
use shattuck::objects::method::MethodObject;

#[derive(Clone)]
struct DummyMethod;

impl MethodObject for DummyMethod {
    fn run(&self, interp: &mut Runtime) -> Result<(), RuntimeError> {
        println!("I am running!");
        let context: &IntObject = interp.get_object(interp.context())?;
        println!("{:?}", context);
        Ok(())
    }
}

fn main() -> Result<(), RuntimeError> {
    let mut interp = Runtime::new(128)?;
    let t0 = interp.append_object(Box::new(IntObject(42)))?;
    let t1 = interp.append_object(Box::new(DummyMethod))?;
    interp.set_context(t0);
    interp.run_method(t1)?;
    interp.garbage_collect();
    Ok(())
}

运行到最后会看到垃圾收集器的log显示1 alive, 3 dead,其中活着的是最初的栈帧内的最初的作用域,而死了的除了t0t1 以外(它俩作为临时对象,没有通过insert_name方法以任何名字绑定到任何作用域上,所以会被回收),还有为了执行方法所创建的栈帧内的作用域对象。

在进入正题之前,我想先介绍一下在DummyObject::run中完成的对context进行的向下转型。


在动态语言当中向下转型几乎是和native拓展绑定在一起的。对于以C语言作为宿主的脚本语言,「从运行时中拉取某个对象并且作为某种特定类型的值出现在native代码中 」这一功能通常是和void *脱不开干系的。Rust不接受void *,并且提供了最基本的反射功能Anytrait,这也是我们实现向下转型几乎唯一的safe的机会。

具有'static生命周期的Rust类型都会自动实现Anytrait,这个trait的内容是为每一个具体的类型提供一个唯一TypeId类型的唯一标识符。由于只有具体类型(结构和枚举)才有标识符,因此我们几乎没有什么优雅的方法在运行时识别一个实例是否实现了某个trait(此处伏笔)。

更新之后的Objecttrait开始需要实现它的类型必须实现Any,同时由于我们可以向下转型了,对Debug的要求自然也不复存在,对于想打印的变量在具体的类型上实现Debug就好了。

对于实现了Any的类型,Rust会为它的实例提供一个downcast_ref方法,用这个方法就可以尝试将这个实例的引用转换为任意类型的引用,失败了就返回None

有没有发现上面这句话有点矛盾?如果我都已经知道这个实例的类型了(从而得知它实现了Any),那么尝试将其转换成任何类型都没什么意义,毕竟只能转换为具体的类型。

因此,downcast_ref方法理论上应该永远用在&dyn Any类型的名字上。

于是,上面的例子中用到的get_object方法是这样实现的

pub fn get_object<T: 'static>(&self, name: Name) -> Result<&T, RuntimeError> {
    let obj = self.mem.get_object(name.addr())?.as_any();
    obj.downcast_ref::<T>().ok_or(RuntimeError::TypeMismatch {
        expected: TypeId::of::<T>(),
        actual: obj.type_id(),
    })
}

其中最值得注意的便是as_any方法。它是什么呢?

在没有调用as_any之前,问号所「返回」的是一个&dyn Object类型的实例。也就是说,我只知道它是某个类型的值的引用,而且这个类型实现了Object

我们可以对&dyn Object的实例调用type_id方法吗?可以。因为这个方法是Anytrait所要求实现的(也是Rust为几乎每一个类型实现好的),所以可以调用它。

那么我们可以调用downcast_ref吗?不可以。因为只有实现了Anytrait的类型才具有downcast_ref方法。

完了,这人写文章写傻了。

其实我自己都有点这么觉得。我明明规定了,任何一个实现了Object的类型都必须实现Any,并且obj是一个实现了Object的类型的值的引用,为什么Rust就不能为obj提供downcast_ref呢?

(此处的obj是指没有调用as_any之前的临时实例。)

然而这就是不行。Rust是不支持向上转型trait的!具体原因看得我云里雾里,又是虚方法表又是什么的,恕本人才疏学浅不能在此复述。

因此,我只能通过一个看上去什么都没做的AsAnytrait来绕过这条限制

pub trait AsAny {
    fn as_any(&self) -> &dyn Any;
}

impl<T: Object> AsAny for T {
    fn as_any(&self) -> &dyn Any {
        self
    }
}

这里我说它是「看上去」什么都没做,自然意味着它实际是做了事的。如果我没有理解错的话,它的用处就是把Object所使用的那部分虚表去掉了,从而使编译器在get_object方法内有办法定位Anytrait所实现的一系列虚方法, 自然也包括downcast_ref。在C++中,这大概对应着Any &obj(有多态)和Any obj(没有多态)的区别。

在Rust标准库中,还有一个trait叫做AsRef<T>,它要求实现一个方法fn as_ref(&self) -> &T

诶?听起来很合适啊!为什么不直接为实现了Object的类型实现AsRef<dyn Any>,却要重新发明一个trait呢?

我也是真的想啊,但是在我还没开始打fn,编译器就弹出了一条要我足足理解了三分钟的报错:对AsRef的实现发生了冲突。

事实上,编译器所说的冲突并不一定真的已经发生了,只要存在冲突发生的可能,它就不满意。

那么这种可能在哪里呢?标准库为AsRef提供了一系列放之四海而皆准的实现,其中有一条是这样的:如果一个类型T实现了AsRef<U>(也就是&T可以转换成&U),那么编译器自动视作&T也实现了AsRef<U>。Rust中没有「引用的引用」这种概念,因此所谓的&&T其实还是对T的引用,所以这没什么问题。

一旦我们为所有实现了Object的类型实现了AsRef<dyn Any>,会有什么问题?

假如某个哲学家,为任何一个类型的引用&T实现了Object会怎样?

由于我们为每一个实现了Object的类型实现了AsRef<dyn Any>,那么&TAsRef<dyn Any>自然应该由我们来实现。

但如果T也实现了AsRef<dyn Any>,那么标准库会认为&T的实现应该遵循它的版本。冲突就此发生。

冤枉啊!明明我们的实现内容一模一样,都是一个self摆在那,听谁的有什么区别吗?

但这没有办法,Rust并没有办法判断它们是否一样(并且有了上面关于虚函数表的讨论,我正在思考它们是不是真的一样)。为了防止上面所描述的冲突发生,我们只好创建一个新的traitAsRef。这回编译器开心了,因为这个trait标准库听都没听说过。


关于方法对象的设计,可以说是到目前为止我所遇到的最难的问题了。看看提交历史就能发现:

知道#9和#10中间那10个小时我在干什么吗?在睡觉。(

总而言之,也许这些历史观赏起来有一定的趣味性,但是正是由于它们都是能编译得过的设计(只是为了能编译得过作了太多妥协),把它们写出来反而变得没有意义了。

经过了仔细地考虑,我得出了两个最基本的结论,亦或是底线:

  • MethodObject必须是一个trait而不是struct
  • run_method方法不可以为执行的方法确定它所在的上下文

关于第一点,那自然是因为这门语言没有继承(都写了这么久了我究竟在想什么……),给构造函数传入一个fn类型的函数指针太过于限制,而传入一个实现了Fn的类型的实例又遭遇了诸多困难(结果还是把历史版本都说了个遍……)。如果它是一个trait,那么我们立刻就会面临两个严峻的问题:

  • 正如上文所说,我们没有合适的办法在运行时,拿着一个实现了Object的类型的值,确定这个值所对应的类型有没有实现MethodObject
  • 如上一篇文章末尾提到的,我们需要方法对象可以被复制,也就是必须实现Clone。说到Clone……还记得object safety吗?

尝试过了各种操作以后,我得出了现在的解决方案。

首先,为Objecttrait添加一个方法,以及它的默认实现

pub trait Object: Any + AsAny {
    fn get_property(&self, _key: &str) -> Option<Name> {
        None
    }

    fn set_property(&mut self, _key: &str, _new_prop: Name) {
        //
    }

    // downcast
    fn as_method(&self) -> Option<Box<dyn MethodObject>> {
        None
    }
}

(我顺手给所有的方法都加上了默认实现,反正看起来挺合理的。)

接下来,在MethodObjecttrait中覆盖原本的默认实现,提供一个新的默认实现

pub trait MethodObject: Object {
    fn run(&self, runtime: &mut Runtime) -> Result<(), RuntimeError>;
}

impl<T: 'static + MethodObject + Clone> Object for T {
    fn as_method(&self) -> Option<Box<dyn MethodObject>> {
        Some(Box::new(self.clone()))
    }
}

顺便注意一下Clone所出现的位置。这是某种意义上对第二个难题的妥协。我们原本希望表达的含义是,「对于任何一个实现了MethodObject的类型,我们既要求它必须实现Clone,又确保一定会把它当作一个方法对象看待」,然而实际上传达出来的则是,「我们并不要求实现了MethodObject的类型一定要实现Clone,但是只有当它实现了Clone的时候,我们才把它当作一个方法对象来看待」。这有可能导致一些马虎鬼(指自己)忘记为自己实现了MethodObject的类型实现Clone,然后发现编译一切正常,运行起来却疯狂报NotCallable错误的情况。然而除了给它加上「请务必实现Clone!!!」的文档以外,暂时还想不到什么好的办法。

于是,非常尴尬的一幕出现了:同样是as_*方法,一个必须出现在Object以内,一个却必须出现在Object以外。

为什么as_method必须是Object的一部分?如果我们创建一个AsMethodtrait,并要求实现Object的类型一定要实现它,并且为它提供一个默认实现会怎样?

那么,我们就没有机会为MethodObject覆盖这个默认实现了。Rust中并不支持「所有实现了Object但是没有实现MethodObjectT: Object + !MethodObject)」这种语义,也不支持类似于「最紧匹配优先」这种规则,查到的资料只是模棱两可的表示「这会造成语义上的大量冲突」,想想曾经拿C++模板元编程的经历我决定相信它。

那么,as_any又为什么不能成为Object的一部分了呢?如果你真的这样做了,编译器就会提示你,「我无法确定Self的占地面积,你要不加个Sized试试看?」如果你照做了,那完了,关于object safety的报错像潮水一样涌来。

我们一步一步来分析这一现象。首先要求实现Object的类型实现Sized会打破object safety这一点很好理解。因为Sized要求类型在编译时可以确定实例占用的内存大小,这可以理解为实现一个类似于C语言sizeof功能的函数,而这对于Object来说显然是不行的,可以回忆一下,对着上次那个Vec里的元素调用sizeof(注意是编译时),编译器该如何回答。

那么为什么放在Object之外就可以确定占用的内存大小,拿进来就不行了呢?要注意在AsAny中,我们是为每一个实现了Object的类型T 添加一个方法as_any,最终获得这个方法的是一个在编译时可以确定下来的类型T。而拿进来以后,获得这个方法的主体变成了一个在编译时无法确定具体类型的dyn Object,自然就无法确定占用的内存大小了。

最后,也是最让人感到困惑的一点:为什么需要在编译时确定占用的内存呢?我返回的不是一个&dyn Any类型的值吗?作为一个引用(亦或是指针,按照我一个被ACM洗脑了的同学的看法),不管什么类型的引用,它所占用的空间不都应该是一样大的吗?

关于这一点,我还是没有完全搞清楚,只是知道,在这个场景下,Rust的运行时不仅要为这个引用存放它所引用的实例的内存地址,还要存放这个实例所占用的空间大小,而这个值必须在编译时确定,被硬编码进机器指令中。因此,并不是一个引用就永远只对应着一个指针,还有可能夹杂一些别的东西。


长久以来,我对于使用Rust的体验总有一个无法脚踏实地的感觉。但是又很难描述这种感觉的来源。现如今,我想也许一个比较更要的原因就是,我对于引用、trait这些抽象概念的实际实现方式知之甚少。以前写C++时也会遇到类似的问题,但是由于C++常用的抽象和硬件的吻合度比较高,因此不会有特别明显的体会。这些底层的知识又无法从面相普罗大众的文档中学到,可真是让人有点头疼了。

目录

编辑于 2019-05-11 03:54