Rust 中的隐式行为之“自动解引用”

下述代码中,X 并没有提供 call(self) 方法(Self = X、self = X ),x.call() 按理应该要写成 (&x).call() 才行,代码为何编译不会报错,而能如期运行呢?

trait Call {
    fn call(&self);
}

struct X {}

impl Call for X {
    fn call(&self) {
        println!("X &self");
    }
}

fn main() {
    let x = X {};
    x.call();
}

这便涉及到了 rust 中的 “自动解引用或借用”(Auto dereference or borrowing)的相关知识,倘若 rust 没有提供这种隐式地(implicitly)转换行为,我们则需要这么写:

(&x).call();
// 或是:
X::call(&x);

看着好像也还行,然而当项目中类型变得越来越多和复杂,你或许会写出这样的代码:

X::call(&********x);

当项目里充满了 *& 符号时,我们的代码似乎变成了件艺术品 —— 一个让人难以理解的东西。所以自动解引用的功能是有存在的必要的,它大大简化了调用形式。

现在我们来理解它的机制,从而在面对 rust 代码回更加自如,毕竟恐惧源于未知(呔,不要讲大道理)。

这是官网文档的 method-call-expr 里的相关说明:

A method call consists of an expression (the receiver) followed by a single dot, an expression path segment, and a parenthesized expression-list. Method calls are resolved to associated methods on specific traits, either statically dispatching to a method if the exact self-type of the left-hand-side is known, or dynamically dispatching if the left-hand-side expression is an indirect trait object.

When looking up a method call, the receiver may be automatically dereferenced or borrowed in order to call a method. This requires a more complex lookup process than for other functions, since there may be a number of possible methods to call. The following procedure is used:

The first step is to build a list of candidate receiver types. Obtain these by repeatedly dereferencing the receiver expression's type, adding each type encountered to the list, then finally attempting an unsized coercion at the end, and adding the result type if that is successful. Then, for each candidate T, add &T and &mut T to the list immediately after T. ...

这部分的说明写在了 method-call-expr 的页面里,method-call-expr 指的是“方法调用表达式”,也就是我们经常写到的 foo.bar() 这种使用“点”来调用方法的形式,其中 foo 被称作 receiver,也就是说对于这种表达式,便会涉及到自动解引用

此外,foo.bar() 也可以写成 T::bar(foo)(此处假设 foo 的类型是 T)的形式,这个是“调用表达式”(Call expression)而不是 Method call expression。


对于“方法调用表达式”,最终会执行哪个方法,会涉及到一个查询过程,其过程大致是:

首先构建一个 receiver 的候选类型列表,这个列表通过不断重复地对 receiver 解引用进行填充,直到不能再解引用,然后会执行一次 unsized coercion 若有的话,在得到了候选类型列表后,对于每一个候选类型 T,会再把 &T&mut T 挨着添加到 T 后面,最终便得到了这个候选类型列表,然后挨个从列表的每个类型里查找对应的方法,其中会先查找该类型自身实现的方法,然后查找该类型实现的 traits 里的方法。

对上述解释的进一步(自认为通俗点的)理解如下: 有一个类型为 T 的变量 foo,对于 foo.bar(); 中的 bar() 最终调用的是哪里的实现,其查询步骤如下:

  1. 先查找 T 自己实现的函数中是否有类型匹配的函数,即是否有存在 impl T { fn bar(self){} },有则调用它,没有则查询 T 所实现的 traits 中是否有 fn bar(self){},有则调用,没有则下一步:
  2. 依次添加 &&mut 后再按照步骤 1 进行查找,即先查找 &Tfn bar(self){},再查找 &mut Tbar(self) 签名的函数,没有则下一步:
  3. T 进行解引用(deref)并重复步骤 1 和 2,假设 *T = U,即 T 解引用后会得到类型 U,然后开始对 U 执行步骤 1、2、3,若仍未找到则编译报错。

下面我们通过具体的例子来进一步理解:

trait Call {
    fn call(self);
}

struct X {}

impl Call for &X {
    fn call(self) {
        println!("impl Call for &X")
    }
}

impl Deref for X {
    type Target = Y;

    fn deref(&self) -> &Self::Target {
        &Y {}
    }
}

struct Y {}

impl Call for Y {
    fn call(self) {
        println!("Y &self");
    }
}

fn main() {
    let x = X {};
    x.call();
}

上述代码的 x.call() 的 receiver x 的类型是 X,按照解引用规则,查询调用函数的步骤如下:

  1. 查找 X 里是否存在 fn call(self){},未找到,再查找 X 实现的 traits 里是否有,此处没有 Call trait,故继续下一步
  2. 查找 &X 里是否存在 fn call(self){},其中找到了 impl Call for &X {..},于是打印输出“impl Call for &X” 此时 x.call() 的原始形式应该是 &x.call()

如果我们注释掉 impl Call for &X {..} 内容,则解析步骤变成了这样:

  1. 查找 X 里是否存在 fn call(self){},未找到,再查找 X 实现的 traits 里是否有,此处没有 Call trait,下一步
  2. 查找 &X 里是否存在 fn call(self){},未找到,查找 &mut X 里是否存在 fn call(self){},未找到,下一步
  3. X 实现了 Deref 返回了 Y,故开始对 Y 执行步骤 1 和 2:最终在 impl Call for Y 里找到了 fn call(self){} 前面的函数,最终输出“Y &self” 此时 x.call() 的原始形式应该是 (&*x).call()

这种非开发人员手动控制而是编译器隐式转换的行为,让我们在写函数调用简便了很多,但若不理解其内部机制,也会对我们产生很多困惑,让我们对程序执行的预期结果产生不确定感。 突然感觉 Javascript 里基于原型链的函数查找理解起来更不是一回事了呢。

希望本文对读者们有所帮助,若文章内容有误,还请勿手下留情得指出!


一些参考