RustのBorrowingとLifetimeを実例で学ぶ
1. 概要
BorrowingとLifetimeは、Rust初心者がコンパイラと最も「格闘する」2つの概念です。この記事では、borrowing(&T、&mut T)とlifetime annotation('a)を、Order、Customerなどの身近なドメイン例を通じて解説します。読み終わる頃には、borrow checkerのエラーメッセージが読め、lifetime annotationが必要な場面とコンパイラが自動推論できる場面を判断できるようになります。
2. Ownershipを素早く復習
Borrowingの前に、覚えておくべきこと:Rustの各値には唯一のownerがいます。値をvalue型で渡すと、ownershipがmoveします。
struct Customer {
name: String,
}
fn print_customer(customer: Customer) {
println!("顧客: {}", customer.name);
}
fn main() {
let customer = Customer { name: String::from("Nguyễn Văn A") };
print_customer(customer);
// println!("{}", customer.name); // エラー: customerは移動済み
}
print_customerはCustomerをvalue型で受け取るため、customerは関数内に移動します。コメント行をアンコメントするとエラーが出ます:
error[E0382]: borrow of moved value: `customer`
Borrowingはまさにこの問題を解決するためにあります。ownershipを奪わずに、別の関数がデータを「借りる」ことができます。
3. イミュータブルborrowing: &T
&Tはデータを読むだけで修正が不要な場合に使います。
struct Order {
items: Vec<f64>,
}
fn calculate_total(order: &Order) -> f64 {
order.items.iter().sum()
}
fn main() {
let order = Order { items: vec![100.0, 250.0, 50.0] };
let total = calculate_total(&order);
println!("合計: {}", total);
println!("商品数: {}", order.items.len()); // order は依然として使用可能
}
合計: 400
商品数: 3
calculate_totalは&Orderを受け取ります。つまり、イミュータブル参照です。orderの所有権はmainに留まり、関数は読み取り目的で一時的に「借りる」だけです。そのため、関数呼び出し後もmainはorderを正常に使用できます。同じ時刻に複数の&Orderを作成できます(&mut Orderが混在しない限り)。
4. ミュータブルborrowing: &mut T
参照経由でデータを修正する場合は&mut Tを使います。
struct Order {
items: Vec<f64>,
discount_percent: f64,
}
fn apply_discount(order: &mut Order, percent: f64) {
order.discount_percent = percent;
}
fn main() {
let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
apply_discount(&mut order, 10.0);
println!("割引: {}%", order.discount_percent);
}
割引: 10%
2つの重要なポイントがあります。変数orderはmutで宣言する必要があり、関数呼び出しは&mut orderを渡す必要があります。&Tと異なり、同じ時刻に**1つの&mut T**しか存在できず、別の&Tと共存することもできません。
5. Borrow checker: 「複数の読者か1人の書き手」ルール
コアとなるルール:1つのデータについて、同じ時刻に**複数の&Tか1つの&mut T**のいずれかのみを持つことができ、混在することはできません。
fn main() {
let mut order = Order { items: vec![100.0, 200.0], discount_percent: 0.0 };
for item in &order.items {
order.items.push(*item); // エラー: order.items はループでイミュータブルに借用中
}
}
for item in &order.itemsはループ全体に及ぶイミュータブル参照を作成します。ループ内でorder.items.push(...)は同じデータをミュータブルに借用する必要があります。これは衝突です。コンパイラは以下を報告します:
error[E0502]: cannot borrow `order.items` as mutable because it is
also borrowed as immutable
一般的な修正方法:追加する値を一時的なVecに集めて、ループ終了後にextendする。つまり、2つの借用を異なる時間帯に分けます。
注記: borrow checkerは「実際の生存スコープ」(non-lexical lifetimes)に基づいて分析します。ブロック
{}に基づくのではなく、2つの借用が時間的に重ならなければ、同じ関数内でも有効です。
6. Lifetimeとは
Lifetimeは、「この参照がどの程度の期間生きるか」を説明するRustの方法です。これにより、参照元のデータがdropされた後に参照を使用することがないようにコンパイラが保証します。ほとんどの場合、lifetimeを書く必要はありません。コンパイラが自動推論します。ただし、関数が複数の参照を受け取ってそのうち1つを返すとき、コンパイラは返される参照がどのパラメータから「借りられているのか」を明確にするよう求めます。
6.1. 関数のlifetime annotation
fn longest_name<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
fn main() {
let customer1 = String::from("Nguyễn Văn A");
let customer2 = String::from("Trần Thị B dài hơn");
let longest = longest_name(&customer1, &customer2);
println!("最長の名前: {}", longest);
}
最長の名前: Trần Thị B dài hơn
'aは新しい生存期間を作成するのではなく、単なる名前であり制約です。a、b、そして戻り値すべてが少なくとも'aの期間生きていなければなりません。関数が実行時にaとbのどちらを返すか、コンパイラは知りません。そのため、入力と出力が同じlifetimeを使うことを強制し、すべてのケースで安全を保証します。
誤った書き方をした場合を見てみましょう。宣言されたlifetimeに関係のない参照を返すと:
fn longest_name_broken<'a>(a: &'a str, _b: &str) -> &'a str {
let local = String::from("一時的");
&local // エラー: localは関数終了時にdropされる
}
error[E0515]: cannot return value referencing local variable `local`
6.2. Structのlifetime
参照を保持するstructも、lifetimeアノテーションが必要です。Rustはstructが借りたデータより長く「生きない」ことを保証する必要があります。
struct Customer {
name: String,
}
struct OrderSummary<'a> {
customer: &'a Customer,
total: f64,
}
fn build_summary<'a>(customer: &'a Customer, total: f64) -> OrderSummary<'a> {
OrderSummary { customer, total }
}
fn main() {
let customer = Customer { name: String::from("Lê Văn C") };
let summary = build_summary(&customer, 500.0);
println!("{} - 合計: {}", summary.customer.name, summary.total);
}
OrderSummary<'a>はCustomerを所有しません。参照を保持するだけです。Lifetime'aはsummaryがcustomerより長く生きられないことを強制します。summaryの使用前にcustomerがdropされれば、コンパイル時にコンパイラがそれを検出します。dangling pointerが実行時に逃げることはありません。
6.3. Lifetimeを書かなくてよいとき(elision)
Rustには3つのlifetime自動推論ルール(lifetime elision)があり、ほとんどの関数に適用されます:
- 各参照パラメータに一意のlifetimeが割り当てられます。
- 入力lifetimeがちょうど1つの場合、それが出力に割り当てられます。
&selfまたは&mut selfがある場合、selfのlifetimeが出力に割り当てられます。
fn first_word(text: &str) -> &str {
// ルール2: 入力lifetimeが1つ -> 出力に自動割り当て
match text.find(' ') {
Some(index) => &text[..index],
None => text,
}
}
この関数は'aを書く必要がありません。参照パラメータがちょうど1つだからです。コンパイラは出力がtextから借りられていることを自動推論します。複数以上の入力参照があり、出力がルール2、3に当てはまらない場合にのみ、lifetimeを手動で書く必要があります。
7. 実例:sliceを返すparser
fn first_word(text: &str) -> &str {
match text.find(' ') {
Some(index) => &text[..index],
None => text,
}
}
fn main() {
let order_note = String::from("giao hàng nhanh trong ngày");
let word = first_word(&order_note);
println!("最初の単語: {}", word);
}
最初の単語: giao
first_wordは新しいStringを作成しません。order_noteのメモリ領域内にあるslice(&str)を返すだけです。これはRust のparserでのよくあるパターンです。余分なアロケーションを避けて、元のデータに「ポイント」するだけです。Lifetimeはwordがorder_noteより長く生きられないことを保証します。
誤った書き方をして、コンパイラの保護を確認してみましょう:
fn first_word_broken() -> &str {
let text = String::from("giao hàng nhanh");
first_word(&text) // text は関数終了時にdropされる
}
error[E0106]: missing lifetime specifier
コンパイラは出力に有効なlifetimeを割り当てることが不可能なため、即座にコンパイルを拒否します。textは呼び出し側で返される値が使用される前に消滅するからです。
first_wordのテスト
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tra_ve_dung_tu_dau_tien() {
let note = "giao hang nhanh trong ngay";
assert_eq!(first_word(note), "giao");
}
#[test]
fn tra_ve_ca_cau_khi_khong_co_khoang_trang() {
let note = "ship";
assert_eq!(first_word(note), "ship");
}
}
cargo testを実行:
running 2 tests
test tests::tra_ve_ca_cau_khi_khong_co_khoang_trang ... ok
test tests::tra_ve_dung_tu_dau_tien ... ok
8. よくあるエラー
- ローカル変数への参照を返す:変数は関数終了時にdropされますが、参照は呼び出し側で「生きている」状態になります。コンパイラはlifetimeエラーでそれをブロックするため、実行時には決して通りません。
&Tを長く保ったあと&mut Tを借用する:.iter()の結果を変数に保存してから同じスコープで元のstructを変更する場合に一般的です。修正方法:ブロック{}を使ってイミュータブル借用のスコープを狭めるか、読み取りと書き込みロジックを2つのステップに分けてください。- Borrow checkerを避けるために
clone()を乱用する:clone()は常にコンパイルされますが、データのコピーに費用がかかります。実際に独立したコピーが必要な場合のみ使用してください。borrow エラーを「修正する」ために使うのではなく。 - 余分なlifetime annotationを書く:関数に参照パラメータが1つだけなら、
'aを手動で書かないでください。elisionルールが処理します。余分に書くだけでコードが混乱します。
注記: コードをコンパイルするために
'staticを追加する必要があることに気づいたら、立ち止まって設計を再検討してください。'staticはデータをプログラム全体の期間生存させます。これは通常、ownershipの実際の問題を解決する代わりに、問題を避けているサインです。
9. まとめ
Borrowingを使うと、ownershipを奪わずにデータを「借りる」ことができます。コンパイル時にborrow checkerによってチェックされる「複数の読者か1人の書き手」というルールが適用されます。Lifetimeは、参照がどの程度の期間生きるかについてコンパイラとあなたが同じ理解を持つための言語です。特に関数が参照を返したり、structが参照を内部に保持したりする場合に重要です。ほとんどの場合、コンパイラはelisionルールによってlifetimeを自動推論します。複数以上の入力参照があり、出力の出所が曖昧な場合にのみ、lifetimeを手動で書く必要があります。