追記 2019/12/06 17:14

@lo48576さんが借用や引数の評価順序について過去に記事にされていました。
ここでは誤魔化して書いているTwo-phase borrowや複合代入演算子での挙動についても言及されているので併せてお読みください。またコメント欄でも評価順序を利用したおもしろいコード例も提示してくださっています!

おことわり

この記事はRustの関数やメソッドの呼び出しに関する挙動を興味範囲で大雑把に調べたものです。
また当方、rustcやllvm、MIRが分からない軟弱者なので、不正確な記述があります。
温かい目で見守っていただけると幸いです

事の始まり

先日C++を書いている時に出会ってしまった事件。
以下のようなコードを書いていました。plus_oneはint& xを受け取ってxを変更しつつ値を返します。

int plus_one(int &x) {
    x += 1;
    return x;
}

void print(int x, int y) {
    std::cout << x << "," << y << std::endl;
}

int main() {
    int a = 0;
    print(plus_one(a), a);
}

久しぶりにC++を書いていると書かなくても良いmutを書いたりconstを書き忘れたりしちゃいますね。
それはさておき、このコードをそれぞれ g++9.2.0 と clang++9.0.0 でコンパイル&&実行すると、

$ ./a.out
1,0
$ ./a.out
1,1

となってしまいます。どうやら未規定動作1を踏んでしまったらしく、
関数呼び出しprint(plus_one(), a)の引数plus_one()とaの評価順序は決まっていないようです。

こういう挙動を知ってしまったからにはRustでの挙動を知りたくなるのがRustaceanの性です。

関数呼び出しの引数の評価順序

Rustで同様なコードを書くと以下のようになります。

fn plus_one(x: &mut i32) -> i32 {
    *x += 1;
    *x
}

fn print_values(x: i32, y: i32) {
    println!("{},{}", x, y);
}

fn main() {
    let mut a = 0;
    print_values(plus_one(&mut a), a);
}

これもrustcでコンパイル&&実行してみました。

$ ./a
1,1

どうやらclangと同様に左から右へ評価されるようです

しかしC++と違い、Rustのコンパイラはほぼrustc一択2なので、偶然このような実装になっているのか、評価順序が仕様で決められているのかどうか分かりません。確証を得たいところですが、残念ながら調べてみたところThe Rust Referenceには記載されているところが見つかりませんでした3。

メソッド呼び出しでは?

関数呼び出しでは上記のような挙動を示します。それではメソッドの呼び出しではどうでしょうか?

struct A {
    value: i32,
}

impl A {
    fn new(value: i32) -> Self {
        Self { value }
    }

    fn print(self, value: i32) {
        println!("{},{}", self.value, value);
    }
}

fn main() {
    {println!("lhs"); A::new(0)}.print({println!("rhs"); 1});
}
$ ./a
lhs
rhs
0,1

やはりここでも左から右へ実行されていますね。(間に挟まってるprintの実行が最後じゃないか!という突っ込みは無しで )

メソッド呼び出しは糖衣構文

Rustのメソッド呼び出しは幾つかの他の言語4と同様に一種の糖衣構文なので、この挙動は理解しやすいでしょう。

// 以下の二つのコードは同じ。
a.print(1);
A::print(a, 1);

上記の二つのコードで関数呼び出しとメソッド呼び出しは共にa→1の順に評価されてそうです。

さらっとRustのメソッド呼び出しは糖衣構文だと言って先のコードを示しましたが、実は上記のコードは上で定義したA::printに関してはおおかた正しいのですが、一般には正しくありません。

自動参照、自動参照外し

まず、Rustはメソッド呼び出しの際、自動参照および参照外しが行われます5。
つまりメソッド宣言の第一引数はself、&self、&mut selfのいずれかであるので、必要に応じて*、&、&mutを渡された引数につけて適切なものに変換します。ちなみに参照外しの*は値がCopyトレイトを実装している時のみコンパイル可能です。

error[E0507]: cannot move out of `*b` which is behind a shared reference
  --> a.rs:20:5
   |
20 |     b.print(1);
   |     ^ move occurs because `*b` has type `A`, which does not implement the `Copy` trait

error: aborting due to previous error

For more information about this error, try `rustc --explain E0507`.

コードにして書いてみると、x.method(y)は必要があれば以下のいずれかに変換されます。

(*x).method(y)     // 参照外し
(&x).method(y)     // 参照(不変)
(&mut x).method(y) // 参照(可変)

脱糖衣

自動参照、参照外しがなされた後はX::method(x, y)のような形に脱糖衣されます。
が、methodが&mut selfを受け取る時は以下のような特殊な挙動になります。

{
    let tmp1 = y;
    let tmp0 = &mut x;
    X::method(tmp0, tmp1)
}

メソッドの第一引数の評価(自動参照)は最後になっています。この挙動によって以下のようなコードがコンパイル可能になります。

let mut vec = vec![0];
vec.push(vec.len());
assert_eq!(vec, vec![0, 1]);

もしこのコードの2行目をtmp0やtmp1を用いずに脱糖衣するならば

Vec::push(&mut vec, vec.len());

のようになり、borrowチェッカーに怒られます

     Vec::push(&mut vec, vec.len());
     --------- --------  ^^^ immutable borrow occurs here
     |         |
     |         mutable borrow occurs here
     mutable borrow later used by call

ちなみに、vec.push(vec.len())のようなコードは比較的新しいrustcではコンパイルできますが、NLLが導入される前のrustcには普通に怒られます。このような挙動ができるのはNLL導入時にライフタイムについて整備されたことによる寄与が大きいようです。NLLさまさまですね。

さいごに

ここではあくまで、メソッド呼び出しはただの糖衣構文で脱糖衣されてコンパイルされるような書き方をしていますが、実際はエッジケースを回避すべくややこしい事を行っているようです6。ここらについては興味本位で調べてみたはいいものの、実力不足でふわっとした理解しかできず、曖昧な記事を書くことになって申し訳ないです。
近いうちにMIRの勉強がてら、今回参考にしたRFC2025の翻訳をしつつ理解を深めれればなぁと思う次第です。

当方C++完全理解者ではないので「未規定動作」については こちらの記事 などをあたってください。 ↩

他のRustコンパイラとしては「mrustc」がありますが試していません。 ↩

The Rust Referenceに明記しようという Issue は立っていました。中の人たちもLTR(Left to Right)が妥当だという感じでした。 ↩

PythonとかD言語とか。D言語ではUFCSと呼ぶらしいですが、Rustの挙動はそれとは少し違います。D言語のUFCSについては こちらの記事 を、RustはUFCSとは違うよねっていう話は こちら を見てください。 ↩

cf. プログラミング言語Rust, 2nd Edition/メソッド記法 ↩

cf. RFC2025 ↩

广告
将在 10 秒后关闭
bannerAds