追記 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の翻訳をしつつ理解を深めれればなぁと思う次第です。