借用チェッカー
所有権: Rust の根幹
Bitterless Rust では所有権を「動的なやつは渡すと消える」と説明した.ここでは正確に理解しよう.
Rust の所有権システムは 3 つのルールで定義される:
- Rust のすべての値には,所有者 (owner) と呼ばれる変数が一つだけ存在する
- 所有者がスコープを抜けると,値は破棄 (drop) される
- 所有権は移動 (move) できるが,同時に複数の所有者を持つことはできない
fn main() {
let s1 = String::from("hello"); // s1 が所有者
let s2 = s1; // 所有権が s1 → s2 に移動
// println!("{}", s1); // コンパイルエラー: s1 はもう有効でない
println!("{}", s2); // OK: s2 が所有者
} // ← s2 がスコープを抜け,String のメモリが解放される
これは「ムーブセマンティクス」と呼ばれる.Copy トレイトを実装しない型(String, Vec<T>, Box<T> など,ヒープにデータを持つ型)は,代入や関数への受け渡し時に所有権が移動する.
借用 (Borrowing)
所有権を移動させずに値を使いたい場合,参照 を取る.これを 借用 (borrowing) と呼ぶ:
fn calculate_length(s: &String) -> usize {
s.len()
}
fn main() {
let s = String::from("hello");
let len = calculate_length(&s); // s を借用する(所有権は移動しない)
println!("'{}' has length {}", s, len); // s はまだ使える
}
&s は s への不変参照を作る.calculate_length は s の所有権を取得せず,一時的に借りるだけだ.
借用のルール
Rust の借用チェッカーは,以下のルールを コンパイル時に 強制する:
- 任意の時点で,一つの可変参照 (
&mut T) または任意個の不変参照 (&T) のどちらかが存在できる(両方同時は不可) - 参照は常に有効でなければならない(ダングリング参照の禁止)
fn main() {
let mut s = String::from("hello");
let r1 = &s; // OK: 不変参照 1 つ目
let r2 = &s; // OK: 不変参照 2 つ目(複数の不変参照は共存可能)
println!("{} {}", r1, r2);
let r3 = &mut s; // OK: r1, r2 はもう使われないので,可変参照を取れる
r3.push_str(" world");
println!("{}", r3);
}
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &mut s; // コンパイルエラー: 不変参照と可変参照が同時に存在
println!("{}", r1);
}
このルールが防ぐバグ:
- データ競合 (Data Race): 同じデータへの同時読み書き
- イテレータの無効化: コレクションをイテレートしながら変更
- Use-After-Free: 解放済みメモリへのアクセス
これらは C/C++ で最も頻繁に発生する深刻なバグであり,Rust では借用チェッカーがコンパイル時にすべて排除する.
ライフタイム
ライフタイムは,参照が有効である期間をコンパイラに伝えるための仕組みだ.多くの場合,コンパイラがライフタイムを自動推論(ライフタイム省略規則)するが,明示的な注釈が必要な場合もある.
なぜライフタイムが必要か
// この関数は,2 つの参照のうちどちらかを返す
// 返り値のライフタイムは,引数のどちらのライフタイムに紐づくのか?
fn longer(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
// コンパイルエラー: ライフタイムが不明
コンパイラは,返り値の参照が a と b のどちらの寿命に紐づくかを判断できない.ライフタイム注釈で明示する:
fn longer<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
'a はライフタイムパラメータだ.この注釈は「a と b は少なくとも 'a の間は有効であり,返り値も 'a の間は有効である」ことを意味する.呼び出し側では,a と b のライフタイムの短い方が 'a として採用される.
fn main() {
let s1 = String::from("long string");
{
let s2 = String::from("xyz");
let result = longer(&s1, &s2);
println!("{}", result); // OK: result は s2 のスコープ内で使われている
}
// ここでは s2 が存在しないため,
// longer(&s1, &s2) の結果をここで使おうとするとコンパイルエラー
}
ライフタイム省略規則
多くの場合,ライフタイムの注釈は省略できる.コンパイラは以下の規則で自動推論する:
- 各参照パラメータに独自のライフタイムが割り当てられる
- 参照パラメータが 1 つだけなら,そのライフタイムが出力のライフタイムになる
- メソッド(
&self/&mut selfを持つ関数)では,selfのライフタイムが出力のライフタイムになる
// 省略形
fn first_word(s: &str) -> &str { /* ... */ }
// 展開形(コンパイラが推論する内容)
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ }
構造体のライフタイム
参照をフィールドに持つ構造体には,ライフタイムパラメータが必要:
struct Excerpt<'a> {
text: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let excerpt = Excerpt { text: first_sentence };
println!("{}", excerpt.text);
}
// excerpt は novel より長く生存できない(ライフタイムで保証される)
'static ライフタイム
'static はプログラムの全期間にわたって有効であるライフタイムだ:
// 文字列リテラルは 'static ライフタイムを持つ
let s: &'static str = "hello";
// 'static 境界: 値が参照を含まないか,含む場合はすべて 'static
fn spawn_thread<F: FnOnce() + Send + 'static>(f: F) {
// f はスレッドに渡されるため,任意の期間生存する必要がある
std::thread::spawn(f);
}
'static は「メモリリークする」という意味ではなく,「必要な限りいつまでも有効でいられる」という意味だ.所有された値(String, Vec<T> など)はすべて 'static 境界を満たす.なぜなら,借用ではなく所有しているため,任意の期間保持できるからだ.
ライフタイムのジェネリクス
ライフタイムは型パラメータと同様にジェネリックにできる:
use std::fmt::Display;
fn longest_with_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement: {}", ann);
if x.len() > y.len() { x } else { y }
}
ライフタイムパラメータ 'a と型パラメータ T を同じ関数で使っている.ジェネリクスの章で見たトレイト境界と同様に,ライフタイムも型システムの一部として統一的に扱われる.
複数のライフタイムパラメータ
fn first_of_second<'a, 'b>(first: &'a str, second: &'b str) -> &'a str {
// 'a と 'b は独立したライフタイム
// 返り値は first と同じライフタイムを持つ
first
}
ライフタイム境界
// 'b は少なくとも 'a と同じかそれ以上長い
fn example<'a, 'b: 'a>(x: &'a str, y: &'b str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
'b: 'a は「'b は 'a より長く生きる(outlives)」を意味する.これにより,ライフタイム間の関係を精密に表現できる.
並行性と所有権: Send と Sync
Rust の所有権システムは,並行プログラミングの安全性も保証する.これを支えるのが Send と Sync の 2 つのマーカートレイトだ:
Send: この型の値を別のスレッドに転送 (move) できるSync: この型の参照 (&T) を複数のスレッドから安全にアクセスできる(つまり&TがSendである)
ほとんどの型は自動的に Send かつ Sync を実装する.Rc<T> のようにスレッド安全でない型は,コンパイラが自動的に Send / Sync の実装を除外する.
use std::thread;
fn main() {
let data = vec![1, 2, 3];
// data の所有権がスレッドに移動する
let handle = thread::spawn(move || {
println!("{:?}", data);
});
// println!("{:?}", data); // コンパイルエラー: 所有権は移動済み
handle.join().unwrap();
}
Arc と Mutex
複数のスレッドからデータを共有する場合,Arc (Atomic Reference Counted) と Mutex を使う:
Arc: スレッド安全な参照カウント
use std::sync::Arc;
use std::thread;
fn main() {
let data = Arc::new(vec![1, 2, 3, 4, 5]);
let mut handles = vec![];
for i in 0..3 {
let data = Arc::clone(&data); // 参照カウントを増やす(データはコピーしない)
handles.push(thread::spawn(move || {
println!("Thread {}: {:?}", i, data);
}));
}
for handle in handles {
handle.join().unwrap();
}
}
Arc はスレッド安全な参照カウントスマートポインタだ.Arc::clone() は参照カウントをアトミックにインクリメントするだけで,中のデータはコピーしない.参照カウントがゼロになったとき,データが解放される.
シングルスレッドでは Rc<T> を使い,マルチスレッドでは Arc<T> を使う.Rc<T> は Send を実装しないため,スレッドに渡そうとするとコンパイルエラーになる.これは実行時エラーではなくコンパイル時エラーだ.
Mutex: 排他的アクセス
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
// ← lock のスコープを抜けると自動的にアンロック (Drop)
}));
}
for handle in handles {
handle.join().unwrap();
}
println!("Result: {}", *counter.lock().unwrap()); // 10
}
Mutex<T> は排他的ロックを提供する..lock() でロックを取得し,返される MutexGuard がスコープを抜けると自動的にアンロックされる(Drop トレイトによる RAII).
重要なのは,Mutex<T> なしに Arc<T> の中のデータを変更することはコンパイルエラーになる 点だ.Rust の型システムは「共有されたデータへの排他的アクセスにはロックが必要」というルールをコンパイル時に強制する.
RwLock: 読み書きロック
use std::sync::RwLock;
let lock = RwLock::new(5);
// 複数の読み取りロックは同時に取得可能
{
let r1 = lock.read().unwrap();
let r2 = lock.read().unwrap();
println!("{} {}", r1, r2);
}
// 書き込みロックは排他的
{
let mut w = lock.write().unwrap();
*w += 1;
}
RwLock は借用チェッカーのルール — 「複数の不変参照または一つの可変参照」 — をランタイムに拡張したものと捉えることができる.
借用チェッカーの価値
借用チェッカーは,Rust を学ぶ上での最大の障壁として知られている.「コンパイラとの格闘」「ボローチェッカーに怒られる」 — こうした経験は Rust プログラマの通過儀礼だ.
しかし,借用チェッカーが排除するバグのカテゴリを考えると,そのコストは十分に正当化される:
| バグのカテゴリ | C/C++ での対処 | Rust での対処 |
|---|---|---|
| Use-After-Free | 実行時に未定義動作 | コンパイルエラー |
| ダングリングポインタ | 実行時にクラッシュ(運が良ければ) | コンパイルエラー |
| データ競合 | ThreadSanitizer などのツール | コンパイルエラー |
| 二重解放 | 実行時にクラッシュ | コンパイルエラー |
| イテレータ無効化 | 実行時に未定義動作 | コンパイルエラー |
これらすべてが 実行時 ではなく コンパイル時 に検出される.バグをプロダクション環境で発見するのではなく,コードを書いた瞬間に発見できる.
借用チェッカーは単なる制約ではなく,コードの正しさの証明 を自動的に行うシステムだ.コンパイルが通ったコードは,上記のバグカテゴリに関して安全であることが構造的に保証される.