ボローチェッカーとの闘い
Rust はボローチェッカーと戦う言語だといわれることがありますが、その戦いのゴングは次のようなコードで初めて鳴るのではないでしょうか。
let mut x = 1;
let r = &x;
x = 2;
println!("{}", r);
このようなコードは次のようなエラーで怒られます1。
error[E0506]: cannot assign to `x` because it is borrowed
--> src/main.rs:6:5
|
4 | let r = &x;
| -- borrow of `x` occurs here
5 |
6 | x = 2;
| ^^^^^ assignment to borrowed `x` occurs here
7 |
8 | println!("{}", r);
| - borrow later used here
error: aborting due to previous error; 1 warning emitted
他の言語にはこのようなルールは無いので、初めて出くわしたときは面食らう人が多いと思います。
The book には、このルールは次のように書かれています。
- 任意のタイミングで、一つの可変参照か不変な参照いくつでものどちらかを行える。
この文章は微妙にわかりにくいと思いますが、要は下記のテーブルのように、 & は複数持てる、 &mut は一つだけ持てる、ただし & と &mut は同時には持てない、ということです。
& | &mut | |
---|---|---|
OK | 2 | 0 |
OK | 0 | 1 |
ダメ | 1 | 1 |
ダメ | 0 | 2 |
また、このルールは「一つのスコープの中で」適用されるので、関数呼び出しなどを通して新たなスコープを導入すれば、可変な参照が同時に2つ以上存在することは可能です。
fn f(x: &mut i32) -> i32 {
println!("{}", x);
*x
}
fn main() {
let mut x = 1;
let y = &mut x;
f(y);
println!("{}", x);
}
まぁ、そういうルールがあるってのはわかるけど、それがデータ安全性にどう役立つの?というところは The Book にも突っ込んで書かれていないので、もやもやする人も多いと思います。 RAII のように C++ でも馴染みのある機構でもないのでなおさらです。
Aliasing
可変な借用が複数あるとまずいのは、aliasingの問題のためです。aliasingとは同じ変数を複数の名前でアクセスすることです。たとえば
fn f(a: &mut i32, b: &mut i32) {
*a = 1;
*b = 2;
*b = *a;
}
という関数は、 *a
と *b
に最終的に 1 が代入されるので、*b = 2
は最適化で除去することができるように見えます。しかし呼び出し元が下記のように同じ変数への参照を a
と b
に渡していた場合、実際には a
に 2 が代入されるロジックになるので、最適化によってロジックが変わってしまいます。
let mut a = 0;
f(&mut a, &mut a);
このため、CおよびC++ではポインタや参照が同じ型を持っていた場合、同じ実体を参照している可能性があるとみなして最適化を制限します。これをstrict aliasingといい、FortranがCより速いといわれることがある理由です。Rustではこのように同じ実体への複数の参照を作ること自体ができませんので、オプティマイザはaliasingの可能性を考慮せずに最適化できます。
また、aliasingしている型がenum型だった場合はさらに深刻なことが起きます。
fn g(a: &Option<i32>, b: &mut Option<i32>) {
if let Some(ref v) = a {
*b = None;
println!("{}", v);
}
}
この関数ではRustの標準データ型である Option
を使っていますが、 *a
が Some
だったときに *b
に None
を代入しています。しかしもし呼び出し元が同じ実体への参照を渡すと、
let mut c = Some(1);
g(&c, &mut c);
v
という変数は Some
の中身を参照しているので、他の参照を介して None
に変更されてしまうと、無効なバリアントを参照することになります。これは恐るべき未定義動作で、プログラムがどんな動作をしてもおかしくありません。この例では不変な参照1個と、可変な参照1個で未定義動作が生じてしまいます。このためRustでは可変な参照があるときは不変な参照は1つも作れません。
まとめ
このようにRustの借用のルールは、データの整合性を静的に保証するために必要なものです。もしこのルールがなければ、穴のある保証になってしまいます。理由が分かれば、ボローチェッカーは戦う相手ではなく、安心して背中を任せられる戦友となるのではないでしょうか。
なお、ボローチェッカーは静的なチェック機構ですが、たまに実行時に参照のルールをチェックする必要が生じます。木構造やリファレンスカウンタ式のスマートポインタなどです。この場合には RefCell が使われます。よく見られるのは Rc<RefCell<Type>>
といったような型です。
よくある誤解
データの整合性の保証には、必ずしもマルチスレッドが関わっているわけではありません。ボローチェッカーのルールはあくまでもシングルスレッドの aliasing の問題のために作られたものです。マルチスレッドプログラミングでの競合を避けるのにも役立つというのは、後になって発見されたことです。
また、ボローチェッカーだけをオフにできないかというのもたまに聞かれますが、ボローチェッカーはデータの整合性を保証するために必要なものであり、単独でOn/Offできるようなものではありません。ボローチェッカーがなければ strict aliasing の対策などを導入する必要が出てきて、言語設計が大幅に影響を受けてしまいます2。これは Rust 言語の根幹に結びついており、言語の一部となっているといっていいでしょう。
私も最初にこのルールを見たときは納得がいきませんでした。"The book"は良く書かれたドキュメントですが、この点についてもう少し掘り下げて説明してほしかったと思います。