🚨

Rust の借用規則はなぜ必要?

に公開

はじめに

Rust には借用規則というものが存在しますが、これがなかなか厳しい制限です。

ガベージコレクション(GC)のある言語ではほとんど意識する必要がない概念である一方で、Rust では明示的に扱うことになります。
借用にはもれなくライフタイムがついて回り、これらが Rust を難しく感じる一因となっているのは間違いないでしょう。何気なくメソッドを呼び出そうとしたらコンパイルエラーになった、というのは多くの方が経験したことがあるのではないでしょうか。

この記事では借用規則についてちょっとだけ深く掘り下げてみたいと思います。
といってもコンパイラの実装を確認するとか大層なことはしないで、普段面倒に感じる借用規則にはこんな意味があるんだ、というのをふんわりと紹介していこうと思います。

なお、Rust との比較のために少しだけC++のコードが出てきますが、雰囲気で読めると思いますのでC++を知らなくても大丈夫です。

借用規則が必要な理由

Rust における参照 (reference) とは、その名前の通り別の値を参照するものです。
C/C++やGoに慣れている人であればポインタのようなものと考える人もいるかもしれません。実際、参照は内部的には参照先のアドレスを持っているだけの場合もあります。

参照には色々な使い方がありますが、よくある使い方としては、

  • 関数やメソッドの引数として渡し、所有権を移動させることなく値を利用する。
  • (特に、サイズの大きな)値をクローンすることなく利用する。
  • コンパイル時にサイズが確定しない構造体を定義する。

などなどあります。

さて、the book には、参照のルールとして以下が記載されています。

  • At any given time, you can have either one mutable reference or any number of immutable references.
    任意のタイミングで、一つの可変参照か不変な参照いくつでものどちらかを行える。
  • References must always be valid.
    参照は常に有効でなければならない。

これらが借用規則 (borrowing rules) と呼ばれるものです。
Rust を記述する上で当たり前のように守ることになるルールですが、ではなぜこのルールが必要なのでしょうか。

安全性

Rust はメモリ安全性がウリの言語ですので、まずはこの観点で考えていきます。

参照が無効

分かりやすい例から見てみましょう。無効になってしまった参照(ダングリング参照)を使おうとする例です。

❌無効な参照を使用
let r;
{
    let x = 42;
    r = &x; // `x` does not live long enough
}
println!("{r}");

これは、コメントに書いたようなコンパイルエラーが出ます。
ブロックスコープを抜けると x は解放(drop)されます。それなのにブロックを抜けた後で解放された値への参照を使おうとしているため、コンパイラがエラーにしているのです。

実は、C++においても変数はスコープを抜けると解放されますが、これと同じようなコードがコンパイルできてしまいます[1]。無効になったポインタの間接参照(デリファレンス)は未定義動作となり、危険です。

C++ ダングリングポインタを使用(✖未定義動作)
#include <iostream>

int main() {
    int *r; // ポインタ型(C++はデフォルトでミュータブル)
    {
        int x = 42;
        r = &x;
    }
    std::cout << *r; // r を間接参照した値を標準出力に表示
}

参照が無効になってしまうというのは、参照している変数がムーブされてしまうときにも起こります。Rust ではこちらもコンパイルエラーになります。

❌参照先のムーブ後に参照を使用
let s1 = "hello".to_string();
let r = &s1;
let s2 = s1; // cannot move out of `s1` because it is borrowed
println!("{r} {s2}");

借用規則に「参照が常に有効でなければならない」というルールがあることで、Rust ではこういったことが起こらないようになっているわけです。

複数の借用

次に、不変参照と可変参照を同時に使うケースを見てみます。こちらはコンパイルが通りません。

❌不変借用中に可変借用
let mut x = 42;
let r1 = &x;
let r2 = &mut x; // cannot borrow `x` as mutable because it is also borrowed as immutable
*r2 = 1;
println!("{r1}");

Rust における不変参照は、参照先が別の参照を持っている場合はその先も全て含めて、変更が禁止されています(UnsafeCell を除く)。そのため、不変参照があるときに可変参照を作って値を変更するようなことはできなくなっています。

サラッと書きましたが、Rust 以外の多くの言語で deep immutability を保証するのは一般的に高コストです。Rust の不変参照がゼロコストでこれを保証できるのは、借用規則のおかげなのです。あとでもう一度この話題に触れます。

では、可変参照を2つ作る場合はどうでしょうか。

❌複数の可変借用
let mut x = 42;
let r1 = &mut x;
let r2 = &mut x; // cannot borrow `x` as mutable more than once at a time
*r2 = 1;
println!("{r1}");

先ほどのように参照先の値が不変でなければならないという制限はありません。r2 の参照先を書き換えると r1 の参照先も書き変わってしまうという気持ち悪さはあるものの、可変参照を2つ作ること自体は一応問題ないように思えます。

ちなみに、C++において同様のコードを記述した場合、今回は定義済み動作となり決定的です。

C++ 参照先の値の書き換えが可能なポインタを2つ作成
#include <iostream>

int main() {
    int x = 42;
    int *const r1 = &x; // 変数自体(ポインタ)への再代入は禁止するが、参照先の書き換えは許可
    int *const r2 = &x;
    *r2 = 1;

    std::cout << *r1 << std::endl; // 1
    std::cout << *r2 << std::endl; // 1
}

問題が起こるのは例えば次のようなケースです。
Playground で実行すると、おそらく最後の行でアサーションに失敗すると思います(未定義動作を含むのでうまくいかないかもしれません)。

✖生の可変ポインタを利用
let mut v = vec![0];
let first = unsafe { &mut *v.as_mut_ptr() }; // v の先頭要素のポインタから可変参照を取得
*first = 42; // v の先頭要素を書き換える。この時点では安全。
assert_eq!(v[0], 42); // 先頭要素は42に変わった

for i in 1..1_000_000 { // v に要素を100万個追加
    v.push(i); // push メソッド呼び出し時に v を可変借用する
}

*first = 100; // 未定義動作!
assert_eq!(v[0], 100); // おそらくここでアサーション失敗

普通に複数回の可変借用を行おうとしても許可されないので、代わりに as_mut_ptr メソッドでベクタのバッファの生ポインタを取得してから可変借用しています。

2回目の *first への代入で v の先頭要素を書き換えることを意図していましたが、実際はもう解放済みのメモリへ書き込もうとするでしょう(繰り返しますが、未定義動作なので直観に反する全く異なる動作となる可能性は否定できません)。
v の書き換えは起こらないため、先頭要素は 42 のままとなりアサーションに失敗します。

なぜこのようなことが起きるかという理由については the book でも言及されているので、こちらを引用します。

because vectors put the values next to each other in memory, adding a new element onto the end of the vector might require allocating new memory and copying the old elements to the new space, if there isn’t enough room to put all the elements next to each other where the vector is currently stored. In that case, the reference to the first element would be pointing to deallocated memory.

新たな要素をベクタの終端に追加するとき、いまベクタのある場所に全要素を隣り合わせに配置するだけのスペースがないなら、新しいメモリを割り当て、古い要素を新しいスペースにコピーする必要があります。 その場合、最初の要素を指す参照は、解放されたメモリを指すことになるでしょう。

このように、Rust の借用規則によって不正な(可能性のある)操作を制限することが、メモリ安全性に大きく寄与しています。

パフォーマンス

Rust はメモリ安全性もですが、パフォーマンスも非常に重要視されています。
借用規則はパフォーマンスを上げるための最適化にも役に立ちます。次はこの観点で見ていきましょう。

借用規則については、The Rustonomicon に記載されている、参照が従うルールの言い換えとも言えます。

  • A reference cannot outlive its referent
    参照は、参照先より長く生きることはできません。
  • A mutable reference cannot be aliased
    可変参照は、別名を持つことができません。

このエイリアスがないというのは、冗長な読み書きを減らすのに役立ちます。The Rustonomiconの例を引用して見ていきます。

最適化前
fn compute(input: &u32, output: &mut u32) {
    if *input > 10 {
        *output = 1;
    }
    if *input > 5 {
        *output *= 2;
    }
}

compute 関数を実行すると、input が10より大きければ if のアームが2つとも実行され、output は最終的に 2 になります。このとき、単純に考えると *input でのメモリからの読み込み(load)は2回、*output に対するメモリへの書き込み(store)は2回発生することとなります。

これを次のように最適化してみます。

最適化後
fn compute(input: &u32, output: &mut u32) {
    let cached_input = *input;
    if cached_input > 10 {
        *output = 2;
    } else if cached_input > 5 {
        *output *= 2;
    }
}

*input での読み取り(load)、*output に対する書き込み(store)は、両者とも最大で1回となりました。

この最適化はエイリアシングが発生していないことを前提としています。
もし仮に、inputoutput が同じ変数を指していたらどうでしょうか。Rust だとコンパイルエラーになるので、C++で示します。

C++ input と output は参照先が同じ
#include <iostream>

// 最適化をしない場合
static void compute(const unsigned int *input, unsigned int *output) {
    if (*input > 10) {
        *output = 1;
    }
    if (*input > 5) {
        *output *= 2;
    }
}

// 最適化をした場合
static void compute_optimized(const unsigned int *input, unsigned int *output) {
    const int cached_input = *input;
    if (cached_input > 10) {
        *output = 2;
    } else if (cached_input > 5) {
        *output *= 2;
    }
}

int main() {
    unsigned int value1 = 20;
    compute(&value1, &value1);
    std::cout << value1 << std::endl;  // 1

    unsigned int value2 = 20;
    compute_optimized(&value2, &value2);
    std::cout << value2 << std::endl;  // 2
}

compute 関数が最適化前、compute_optimized 関数が最適化後の関数に相当します。

compute 関数の引数 input, output として両方とも同じ value1 変数のポインタを渡すと、*output = 1; によって *input は 1 になります。そのため *input > 5 は偽となって *output を2倍する処理がスキップされ、最終的に呼び出し元の value1 は 1 になってしまいます。
一方、compute_optimized 関数中では *input は引数で受け取った時から不変であり、cached_input > 5 が真となって、最終的には呼び出し元の value2 は 2 となります。

このように結果に差が出てしまうため、C++では先ほどのような最適化を無保証で行うことはできません。

エイリアスを持たないというのを言い換えれば、可変参照されている変数が持っている領域を、(unsafe な手段を利用しない限り)参照することができないということです。
不変参照から参照を辿って到達可能な領域を、別の可変参照を通じて変更するようなことはできません。これは Rust の不変参照が「深い不変」であることとも関係していると言えます。

なお、ここで紹介したのはあくまでこういう最適化も可能になるという一例ですので、実際にこのような変換が行われるとは限りません[2]

借用と内部可変性

ここまで借用規則で見てきたように、Rust は厳しい制限を安全性のためだけでなく最適化を行うためにもフル活用しています。不変参照については、Rust コンパイラはそれが指す領域は通常一切変わらないという前提で最適化を行うため、不変参照を可変参照のように扱うことも許可されていません。

しかし、このルールは実用的なプログラムを作るうえで大変厳しい制限です。
例えば、参照だけでは双方向リストや一般的なグラフのようなデータ構造を素直に表現することができませんし、マルチスレッド文脈ではスレッド間でデータ共有を行うことも困難です。

そのため、Rust には内部可変性 (interior mutability) を実現する公式の抜け道が用意されています。それが UnsafeCell です。

UnsafeCellの構造体定義は値をラップしているだけの非常に単純なものです。

1.87時点の UnsafeCell の定義を一部引用(アトリビュートやコメントは省略)
pub struct UnsafeCell<T: ?Sized> {
    value: T,
}

UnsafeCellコンパイラによって特殊扱いされている型の一つで、UnsafeCell の不変参照に対して、コンパイラはラップされている値が不変であることを前提とした最適化を行わなくなります。これによりラップされている値の変更が可能になるのです。

ただし、UnsafeCell でも借用規則に違反することはできません

UnsafeCellで取得したポインタから可変参照を取得して使用(✖未定義動作)
use std::cell::UnsafeCell;
let x = UnsafeCell::new(42);
let r1 = unsafe { &mut *x.get() };
let r2 = unsafe { &mut *x.get() };
*r2 = 1;
assert_eq!(*r1, 1); // 未定義動作!

最後の行での *r1 の読み取りは未定義動作です。UnsafeCell はあくまでもラップされている値が不変でなくてもよくなるだけであって、可変参照のエイリアスはどんな場合でもしてはいけません。

例のように UnsafeCell はラップした値のポインタを取得するのが主な使い方であり、unsafe で扱うことが多いため、使い方を誤ると未定義動作を引き起こしかねません。
そのため普通は UnsafeCell をラップして安全に使えるようにした CellRefCell を使います。内部可変性と言って馴染み深いのはこちらの方かと思います。

ではどうやって安全性を担保しているのかと言えば、Cell はそもそもラップした値の直接の借用は基本的に不可能になっていますし、RefCell については借用のたびにランタイムでチェックしています。

RefCell の仕組みをものすごくざっくりと説明すると、RefCell 内部で借用の状況をカウントしていて、borrowborrow_mut メソッドでラップした値を借用しようとするときに借用規則に違反していないかチェックしています。

1.87時点の RefCell の定義を単純化したもの
pub struct RefCell<T: ?Sized> {
    borrow: Cell<isize>,
    value: UnsafeCell<T>,
}
RefCell を使用(✖最後の行でpanic)
use std::cell::RefCell;
let x = RefCell::new(42); // borrow counter は 0
{
    // borrow_mut() を呼ぶと borrow counter は -1 になる
    let mut r1 = x.borrow_mut(); // borrow counter は -1
    *r1 = 1;
} // r1 が drop されると borrow counter は 0 に戻る

// borrow() を呼ぶと borrow counter は +1 される
let _r2 = x.borrow(); // borrow counter は 1
let _r3 = x.borrow(); // borrow counter は 2
*x.borrow_mut() = 100; // borrow counter が0以外のときに borrow_mut() を呼ぶと panic

例では borrow メソッドで取得した Ref 等を変数に束縛していますが、取得した参照は値の取得・変更が終わったら基本的には即座に捨てるのがよいと思います。

RefCell は実用的なプログラムを書く上では欠かせないものです。しかし、次のようなデメリット(というよりトレードオフ)があることは認識しておきましょう。ただ、パフォーマンスに関しては、とてもシビアな要件が求められていたりヘビーなループで濫用するのでない限り、大半の場合(特にアプリ開発)は気にする必要はないと思います。

  • 借用チェックがランタイムになるため、コンパイル時に問題があることに気づけない可能性がある。
  • 借用のたびに比較・計算を行うため、僅かにパフォーマンスに影響がある。
  • もっと言うと UnsafeCell を使う時点で、コンパイラによる不変を前提とした最適化が行われなくなるため僅かにパフォーマンスに影響がある(可能性がある)。

他にも RcMutex などで内部可変性のために UnsafeCell が使われています。興味がある方はぜひ調べてみてください。

おわりに

借用規則に違反してしまっても、多くの場合はコンパイルエラーかランタイムエラーでまともにプログラムを実行できないため、極論を言えば借用規則が必要な理由は知らなくてもプログラムを書くことは十分に可能だと思います。最近だとAIエージェントが典型的なコードは生成してくれるから尚更そうです。

この記事は明日すぐ役に立つ実践的な内容というわけではないですが、Rust は裏ではこんなことをやってくれているんだ、そしてそれを意識しなくても一定のルールを守っていれば安全性と高パフォーマンスを両立することができるんだ、というのを知るきっかけになれば嬉しいです。

さて、この記事では話を広げすぎないようにライフタイムについてはあえて踏み込みませんでした。今は借用というもののルール自体については話したけどその適用範囲については説明をぼかした感じですので、そのうちゆるりと紹介する記事を書きたいですね。

脚注
  1. 比較的最近の一部コンパイラでコンパイラオプションを適切に設定すると、警告は出る場合もあります。 ↩︎

  2. 一応補足すると、Rust は実行可能なバイナリを生成するまでに複数のステップを経てコードを変換しており、その過程で最適化が行われます。最適化前のRustコードを最適化後のRustコードに変換しているわけではないです。 ↩︎

Discussion