(译)Rust Object Safety

链接:http://huonw.github.io/blog/2015/01/object-safety/#sized-self

Object Safety

Rust中每一个trait对象的构造都必须满足各种严格限制要求。这些要求统称为Object Safety。接下来,文章帮助读者了解这些要求存在的原因以及它们背后的相关的编译器行为。

什么是Object Safe

Object Safety的概念见于文档RFC 255。它是为了解决在代码编写中,一些原本只能使用trait泛型的地方地方能够使用trait对象来替代。前者是静态分发,后者则是动态分发。代码分别如下

// 静态分发
fn func<T: Foo + ?Sized>(x: &T) { ... }
// 动态分发
fn func(x: &dyn Foo) { ... }

基于以上需求,RFC提出这么一个概念:trait对象。只要开发中满足object safety的要求,便可以使用通过&dyn trait等方式来使用trait对象。而如果没有object safety等要求加以限制,任意方法体可能会造成不可预估的错误。

现在看一个例子

fn func<T: Foo + ?Sized>(x: &T) { ... }

如果编写API时,能够使用如下方式进行调用,将非常方便

fn call_func(a:&dyn Foo){
	...
	func(a);
	...
}

这里变量a属于动态分发类型。但如果没有对传入的动态分发类型进行限制,方法体对其操作可能失控,从而不安全。如调用传入对象所没有的方法等。因此衍生出object safe规则,用来确保编译器能够判定方法体对传入的动态分发类型的操作正确。即只有满足object safe规则,才能使用trait对象。

现在我们可以直接得到一个信息:带有泛型方法的trait不是object safe(后面会有解释)。因此,有如下代码

trait Bad {
    fn generic_method<A>(&self, value: A);
}

fn func<T: Bad + ?Sized>(x: &T) {
    x.generic_method("foo"); // A = &str
    x.generic_method(1_u8); // A = u8
}

上述trait不是object safe,因此无法使用fun(obj)这种方式进行调用。因此需要保证编译器能够检查并阻止类似的情况发生,能有以下几种方法

  1. 只允许泛型静态分配。自然不需要去检查是否object safe。
  2. 检查方法体内的代码,确保方法体不会违规操作传进来的动态分配类型
  3. 确保不会传进来动态分配类型

方法1明显不符合我们目前的需求。方法二破坏了Rust的一个设计初衷,即类型检查仅限于方法签名,不会进入方法体中对每一行都进行检验。方法三就是Rust目前采用的。通过object safe,编译器在API调用fn func\<T:Bad+?Size\>时能够发现并禁止传入&dyn Bad。这里的难点是必须考虑到所有的不满足object safe的方法签名,否则就会出现问题。Rust在这方面是吃过亏,曾经出现过编译通过实际上并不object safe的情况。

工作原理

RFC 546PR 20341,trait对象正式引入Rust。在实现中,Rust暗地里自动为每一个trait接口创建一个trait类型(也就是trait对象),并为trait类型实现trait接口。每一个trait方法的调用都通过其在虚方法表中对应的方法来调用。伪代码如下

trait Foo {
    fn method1(&self);
    fn method2(&mut self, x: i32, y: String) -> usize;
}

// autogenerated impl
impl<'a> Foo for Foo+'a {
    fn method1(&self) {
        // `self` is an `&Foo` trait object.

        // load the right function pointer and call it with the opaque data pointer
        (self.vtable.method1)(self.data)
    }
    fn method2(&mut self, x: i32, y: String) -> usize {
        // `self` is an `&mut Foo` trait object

        // as above, passing along the other arguments
        (self.vtable.method2)(self.data, x, y)
    }
}

Object Safe规则列举

Sized Self
trait Foo: Sized {
    fn method(&self);
}

这里的问题是实现了Foo trait的结构都是Sized的,但是编译器默认实现的trait对象(上面的impl Foo for Foo)就是非法的。因为trait对象是Unsized的,出现了冲突。trait应该继承?Sized。或者修改如下

trait Foo {
    fn method(&self) where Self:Sized;
}

允许仅在Sized的结构实现这个方法,trait对象不用实现。这样就不会出现编译错误。当然这种不用编译进trait对象的方法可以规避许多object unsafe的场景。

By-value self
trait Foo {
    fn method(self);
}

这种写法一开始编译器判定是非法的,但目前已经允许。但不允许提供默认的方法体。如下

trait Foo {
    // compile fail
    fn method(self){}
}

因为这样trait对象复制该方法的默认实现到自己虚方法表时,self是unsized的。而这不符合Rust的编译要求。Rust编译器要求所有的结构都是确定大小的。

Static method

这里给出的理由是无意义。trait对象是编译器生成的一个内部对象,关于它的静态方法,开发者是无法调用的。

Reference Self

这里有两种情况。如下

trait Foo {
	// compile fail
   	fn method(&self, other: &Self);
    
    or
    
   	// compile fail
    fn method(&self) -> &Self;
}

&Self意味着类型必须与self一致。上文可以知道,trait对象是编译器暗地里自动实现的一个struct。

impl<'a> Foo for Foo+'a {
    fn method(&self, other: &(Foo+'a))
        (self.vtable.method)(self.data, /* what goes here? */)
    }

	or

	fn method(&self) -> &(Foo+'a) 
        ...
    }
	
}

这里的最大问题就是类型擦除。当一个实现了Foo的类型转化为trait对象时,它数据放置在self.data,实现的trait方法放置在self.method,但是它真正的类型是被擦除的。而且这一过程是发生在运行时。编译器无法在编译期知道传入两个&dyn Foo是否是同一类型。这是非法的。

即便编译期能够探测到二者的类型是否一致且发现二者不一致,如各自self.data不同,那么如何处理这个不一致也是一个问题。

Generic method

关于泛型的结论前面已经给出了。这里解决原因。

Rust为了获得高性能选择了零抽象,对于泛型方法的编译都是扩展成一个个具体的方法的。伪代码如下

trait Foo {
    fn method<A>(&self, a: A);
}

一个含有泛型方法在trait会被编译成如下。对每一个类型都生成对应的一个方法。

trait Foo {
    fn method_u8(&self);     // A = u8
    fn method_i8(&self);     // A = i8
    fn method_String(&self); // A = String
    fn method_unit(&self);   // A = ()
    // ...
}

尽管以上具体类型方法能调用的场景寥寥,但是Rust仍然生成了巨量的方法,仅仅因为万分之一,甚至更小的被调用的可能性。如果允许trait对象引入静态方法,那么可以想象为了对应每一个具体方法,vtable将无比膨胀。

疑问

  1. 泛型方法为了高性能选择了单态化。因此trait对象为了防止虚方法表膨胀而选择将其作为object unsafe的原因之一。这其中是否是因为运行期间,具体方法转化为动态类型时,庞大的虚方法表也会造成巨大的性能问题。
上一篇:Qt:程序异常结束,并crashed


下一篇:亚马逊澳洲站产品认证,美国亚马逊电器要不要认证