Rustの所有権を深く理解する

Rustの「所有権」は、学習曲線が急峻になる原因の一つと言われています。本記事では、『Rustプログラミング言語』の第4章を基礎としながら、この概念について解説します。重要な解説を補足し、読者がより深く理解できるようにします。

  1. 予備知識:ヒープとスタック

まず説明しましょう:なぜ所有権を理解するためにスタックとヒープの知識が必要なのでしょうか?『Rustプログラミング言語』の本では、読者に「前書き」のような説明をせずに一気にスタックの仕組みを説明し始めています。解説の観点からすると、これは十分ではありません。

実際のところ、後の学習を通じてわかるように、Rustの言語レベルでは、変数がスコープを離れたときに自動的に解放されるかどうか、そして所有権の移動と変数がスタック上にあるかヒープ上にあるかには必然的な関係はありません。しかし、もっと低レベルな変数の作成と破壊のプロセスを理解しなければ、これらのメカニズムの動機や、それが機能する根本的な理由を理解するのは困難です。これがなぜ、Rustの所有権を説明する記事のほとんどが、読者に一定のスタックに関する知識を求める理由です。

スタックとヒープについては、C/C++プログラマーにとっては比較的馴染み深いでしょう。なぜなら、C/C++におけるポインタ、スマートポインタ、オブジェクトの作成と破壊などの機構は、開発者がスタックを理解することを要求するからです。Rustも同じです。一方、JavaやPythonなどの言語のプログラマーにとっては、これらの言語にはガベージコレクション(GC)の仕組みがあるため、開発者がオブジェクト解放時のリソースクリーンアップ作業を担当する必要がなく、スタックに関する知識が薄いかもしれません。

スタックとヒープは大きな話題であるため、本記事では詳しく説明できません。これを読者の焦点から逸らしてしまうからです。幸いなことに、以前の『プログラミングの基礎概念:仮想メモリ、スタック、スタックフレーム、ヒープ』の記事では、スタックについて必要な説明がなされており、その知識は本記事の内容を理解するのに十分です。もしこの知識を復習する必要があると感じたなら、まずその記事を読んでから戻ってきてください。

また、本記事では頻繁に「ビット単位のコピー(bitwise copy)」という用語が出てきます。ここで事前に説明しておきましょう。他の言語ではあまり使われない用語ですが、実は私たちがよく言う「浅いコピー(shallow copy)」のことです。ビット単位のコピーとは、ビット単位でコピーすることを意味し、C言語のmemcpy関数の動作と同じです:純粋なメモリのバイトコピーです。これに対するのが「深いコピー(deep copy)」です。特に説明がない限り、本記事で言う「コピー」は通常「ビット単位のコピー」を指します。

  1. 所有権の起源

2.1 問題の発生

以下のコードを考えてみましょう。どの言語でも、その挙動は基本的に同じです:

let x = 5;
let y = x;
println!("{x}");

変数xに整数値5が割り当てられ、xの値がyに「コピー」され、yの値も5になります。xとyは両方ともスタック上にあり、これはごく普通のことです、特に説明する必要はありません。Rustでは、「面白くなってくる」のは以下の例からです:

let s1 = String::from("hello");
let s2 = s1;
println!("{s1}"); // この行でプログラムはエラーになる!

ほとんど同じコードですが、型がi32からStringに変わっただけでプログラムはエラーになります!なぜならs1はもう有効な変数ではないからです!let s2 = s1が実行されると、s1は"hello"文字列の所有権を失い、もう使用できなくなります!これはどのように起こったのでしょうか?そしてなぜこのように処理するのでしょうか?

まず、文字列オブジェクトとして、s1の値(オブジェクトインスタンス)はヒープ上に割り当てられています!C/C++でもJavaでも同じことです。Rustも同じです。なぜなら:String型の長さは可変であり予測不可能だからです。それをスタック上に割り当てることはできません。したがってString::from関数は文字列"hello"を格納するためにヒープ上にスペースを要求します。同時に、スタック上には文字列"hello"がヒープ上にあるアドレスを格納するポインタがあり、プログラムが文字列にアクセスできるようになります。ここまでのRustの処理はC++と非常によ似ています。以下はs1に"hello"文字列が割り当てられた後のメモリの状態です:

この時点で、s1はC++のポインタに非常に似ていますが、アドレスを記録するだけでなく、文字列の長さと現在のアロケータから取得したバイト数(つまりcapacity)も記録しています。

2番目の変数s2が現れたとき、2つの選択肢があります。1つ目の選択肢は、スタック上でs1(ポインタ)をコピーすると同時に、それが指す文字列もコピーして、2つの完全に独立した変数を作ることです:

一般的に、この処理方法は「深いコピー」と呼ばれます。深いコピーでは所有権が明確ですが、コストは非常に高くなります。メモリでもコピー動作でもリソースを消費するため、どの言語でも「デフォルト」のコピー動作ではありません。2つ目の選択肢は、s1のスタック上の部分をコピーする(ポインタを一つコピーする)が、文字列はコピーせず、コピーしたポインタが同じ文字列(同じアドレス)を指すようにすることです。つまり以下のようになります:

一般的に、この処理方法は「浅いコピー」と呼ばれ、ビット単位のコピーでもあります。これはC++でポインタ変数をコピーするのと同じ処理方法であり、ヒープ上に割り当てられたオブジェクトに対してほとんどの言語がデフォルトで採用する処理方法です。しかし、この処理方法は多くの問題を引き起こします。つまり、今や2つの「ポインタ」がヒープ上の同じ「値」を指しているため、プログラムはいつそれを「適切に」破棄すべきでしょうか?そしてそのうちの1つの変数の「名前」で破棄した後、もう1つの変数はどうなるでしょうか?まだ他の場所で使用されている可能性はないでしょうか?見てください、問題はすぐに複雑になります

この問題に対して、主流のプログラミング言語は2つの全く異なる処理方法を使用しています。1つはJavaを代表とする「GC派」で、言語自体(JVM)が変数の使用状況を追跡・分析し、適切なタイミングでそれらを破棄します。これの利点は、プログラマーが気にする必要が全くなく、メモリリークが発生しないことです。もう1つはC++を代表とする「責任が大きいほど能力が大きい」派で、完全にプログラマー自身がコード内でオブジェクトの解放を管理することに依存します。しかし、これは簡単なことではなく、C++アプリケーションの多くのバグはこれが原因で発生します。

2.2 Rustの解決策

Rustは独自の「第三の」ソリューションを選択しました:所有権に基づくメモリ管理。このソリューションは一連の厳格なルールを制定し、コンパイル時の厳密なチェックで変数のメモリ使用を「厳密に制御」します。これにより、純粋な手動メモリ管理による潜在的なメモリ安全問題を回避しながら、非常に高い効率を保証します。これらのルールはすべて「所有権」という概念を中心に構築されており、公式には3つの核心原則が示されています:

  1. Rustのすべての値には所有者がいる。
  2. 値は任意の時点で1人の所有者しか持たない。
  3. 所有者がスコープを離れると、その値は自動的に破棄(drop)される。

しかし、2つの核心原則に要約できます:

  1. 各値には1人の所有者しか存在しない
  2. 所有者がスコープを離れると、値は自動的に破棄(drop)される

この処理モデルはC++のRAII(リソースの取得は初期化)思想に深く影響を受けています。詳しく説明しましょう。先ほどの例を振り返ります:

{}で囲まれた部分がスコープを構成します(より多くのシナリオでは関数体内です)。最初のルールに基づき、"hello"文字列の最初の所有者はs1です。let s2 = s1が実行されると、所有権の移動が発生し、s2が"hello"文字列の唯一の所有者になります。この時点でs1はもう使用できません!したがってプログラムはエラーになります。注意してください、この時点でs1は無効ですが、破棄されているわけではありません。実際の破棄(drop)はスコープの終了時に発生し、s2で行われます。最後に、スコープを離れると、Rustは自動的にdrop関数を呼び出してs2が所有する値を解放します。

  1. 所有権を支える言語メカニズム

Rustの所有権の2つの核心原則は、言語レベルでの強力なサポートがなければ「実現」できません。これらの関連メカニズムを深く理解しましょう。

3.1 自動破棄:所有者がスコープを離れるときに値が自動的に解放されることを保証する

Rustコンパイラは保証します:変数がそのスコープを離れると、自動的に破棄操作が実行されます(Copy型を除く)。以下の重要なルールがあります:

  • 変数の型がDropトレイトを実装している場合、そのDrop::dropメソッドが呼び出されますが、これは自動破棄メカニズムが自身の方法で再帰的にそのフィールドを破棄するのを妨げません
  • 変数の型がDropトレイトを実装していない場合、自動的に破棄コード(drop glue)が生成され、再帰的に変数のすべてのフィールドが破棄されます
  • Dropトレイトを実装しているかどうかに関わらず、Rustコンパイラは常に自動的に破棄コードを生成して実行します。これは底上げメカニズムです
  • Drop::drop内でフィールドを手動でdropする必要はありません(すべきでもありません)。リソース(ファイル、接続、ソケットなど)の解放に専念してください

いくつかの点を説明します:

  • Dropトレイトにはdropメソッドがあり、変数がスコープを離れるとRustコンパイラによって自動的に呼び出されます。したがって、dropメソッドは特定のデストラクタです。自動破棄メカニズムがあるため、実際にはほとんどの場合、Dropを実装する必要はありません。本当に自分でdropロジックを書く必要があるシナリオは、ファイルを閉じたり、データベース接続やネットワークソケットを解放したりするなど、特定のリソースタイプを解放する必要がある場合です。これは典型的なデストラクタの使用シナリオであり、C++でも同様です。
  • Drop Glueとは:自動生成された破棄コードで、コードであり、メカニズムではありません。対応するメカニズムは:自動破壊です。
  • 変数の型がDropトレイトを実装しているが何も書かれていない場合でも、コンパイラは依然として再帰的にすべてのフィールドをdropします。つまり、drop glueは開発者がdrop関数でカスタマイズしたコードとは無関係です。drop関数がある場合、自動破壊の際に「ついで」呼び出されるだけです。

3.2 コンパイル時チェック:所有権の移動を保証する

自動破棄メカニズムのような多くの暗黙的な動作とは異なり、所有権の移動はコードだけから判断できます。したがって、自動破棄メカニズムは実行時メカニズムですが、所有権の移動はコンパイル時にチェックして確定できます。これは理解しやすいことです。あるスコープ内で変数が2回目の代入に使用されるとき、簡単に判断できます。コンパイラは直接エラーを出してコンパイルを停止すればよいのです。

すべての型の変数がコピー時にmoveセマンティクスに従うわけではありません。記事冒頭のx、yという2つの整数変数のコピーはcopyセマンティクスに従っています。この違いの原因は、整数型はCopyトレイトを実装していますが、String型は実装していないからです。Rustでは、型がCopyトレイトを実装している場合、コピー時にcopyセマンティクスが実行され、そうでない場合はデフォルトのmoveセマンティクスが実行されます。Copyトレイトについては後で詳しく説明します。また、所有権の移動はコピー時にのみ発生するわけではなく、関数に引数を渡すときと関数から戻り値を受け取るときにも所有権の移動が関係します。この内容については後で個別に説明します。

3.3 両者の関係

「所有権の移動」と「所有者がスコープを離れるときに値が自動的に解放される」は2つの独立した言語メカニズムと考えられますが、Rustが追求する「所有権に基づく」メモリ管理を実現するために、それらは連携しなければなりません。その中で最も重要な関係の連鎖は:moveを通じて常に値に1人の所有者を保たせることで、所有者がスコープを離れたときに所有している値を自動的に解放させることができます。さもなければdouble freeの問題が発生します

  1. 所有権セマンティクスと型の結びつき

Copyトレイトの役割を再整理しましょう:Copyトレイトを実装した型はコピー時にcopy(ビット単位のコピー)セマンティクスを実行し、Copyトレイトを実装していない型はコピー時にデフォルトのmoveセマンティクスを実行します。つまり:Rustは所有権のmove/copyセマンティクスを「型」に結びつけています。これは非常に注意深く考えるべき問題です:なぜ所有権のセマンティクスと型は強く関連しているのでしょうか?その理由は:Rustは「ある型に対してビット単位のコピー(memcpy)を実行したとき、完全に合法で独立し、セマンティクス的に等価な値が得られるかどうか」が型の内在的な性質であり、特定の使用時の一時的な選択ではないと考えているからです

異なるデータ型を観察すると、「ビット単位のコピー(memcpy)」後の振る舞いが異なることがわかります。整数、浮動小数点、ブールなどのデータ型は「ビット単位のコピー(memcpy)」に問題はありません。これは直感的で、さらに説明するのは難しいでしょう。メモリ中での「形」が非常にシンプルだからです。「ビット単位のコピー(memcpy)」はその「全体」をコピーしている、と単純に理解できます。しかしString型に変わると、「ビット単位のコピー(memcpy)」は問題を引き起こします:すべてのデータ(ヒープ上に割り当てられた文字列)を完全にコピーしていないため、コピーされた2つの変数の「完全性」と「独立性」を破壊し、常に相互に結合し、相互に影響し合います(ポインタがぶら下がる、二重解放など)。まさにこのような根源的な認識に基づき、Rustは「ならば所有権のセマンティクスを追加し、String型の値を常に1つの変数にしっかりと結びつけ、ある変数から別の変数に移動することは許可するが、2つ以上の変数が同時に所有することは絶対に許可しない」と考えたのです。これがなぜRustが所有権のmove/copyセマンティクスを「型」に結びつけ、かつそれを型に結びつけることができる根本的な理由です!

  1. CopyとDropの関係

「自動的に破棄されるかどうか」と「代入時にcopyかmoveか」は2つの独立したが交差する次元です。その交差部分の論理は非常に複雑で、注意深く分析する必要がありますが、この部分をスキップしてもプログラミングへの影響は大きくありません。結論だけ覚えておけば十分です:

  • Dropを実装した複合型:そのフィールドは任意の型(Copyでも非Copyでも)を持つことができます
  • Copyを実装した複合型:そのすべてのフィールドはCopy型でなければなりません

ここでの重要な論理は:「複合型の中にスコープを離れるときにクリーンアップが必要なフィールドが1つでもあるか?」です。1つでもあれば、その型全体を自動的に破棄しなければなりません

  1. 関数の引数と戻り値における「所有権」の移動

関数に引数を渡したり、関数の戻り値を受け取ったりすることも、変数のコピーのプロセスであり、必然的に「所有権」の移動が関係します。この部分は複雑ではなく、いくつかの例を見れば明らかになります。

6.1 関数に引数として渡すとき

変数が実引数として「値渡し」で関数に渡されるときも、「コピー」のプロセスです。したがって同じmove/copyセマンティクスのルールに従います。以下の例コードはそのことを非常に直接的に説明しています:

fn main() {
  let s = String::from("hello");  // sがスコープに入る

  takes_ownership(s);             // sの値が関数内に移動する...
                                  // ...ここではもう有効ではない

  let x = 5;                      // xがスcopeに入る

  makes_copy(x);                  // xが関数内に移動するべきだが、
                                  // i32はCopyなので、xはその後も使用できる

} // ここでxが最初にスコープを離れ、次にsがスコープを離れる。しかしsの値は移動されているため、
  // 特別な操作は何も起きない

fn takes_ownership(some_string: String) { // some_stringがスコープに入る
  println!("{}", some_string);
} // ここでsome_stringがスコープを離れ、`drop`メソッドが呼び出される。メモリが解放される

fn makes_copy(some_integer: i32) { // some_integerがスコープに入る
  println!("{}", some_integer);
} // ここでsome_integerがスコープを離れる。特別な操作は何も起きない

6.2 関数からの戻り値として受け取るとき

関数からの戻り値についても同様です。しかし、注意すべき詳細があります。以下のコードで、takes_and_gives_back()関数のa_stringが関数本体を離れるとき、所有権を移譲していることに注意してください。誤って「自動的に破棄される」と解釈しないでください。これは関数本体中の中間変数ではなく、戻り値です!戻り値としてmoveセマンティクスを実行し、所有権を移譲しているだけで、自動的に破棄されるわけではありません

fn main() {
  let s1 = gives_ownership();         // gives_ownershipが戻り値を
                                      // s1に移動させる

  let s2 = String::from("hello");     // s2がスコープに入る

  let s3 = takes_and_gives_back(s2);  // s2がtakes_and_gives_backに移動し、
                                      // その戻り値がs3に移動する
} // ここでs3がスコープを離れ、破棄される。s2もスコープを離れるが、移動されているため何も起きない。
  // s1もスコープを離れ、破棄される

fn gives_ownership() -> String {           // gives_ownershipが戻り値を呼び出し元の関数に移動させる
                                          
  let some_string = String::from("yours"); // some_stringがスコープに入る

  some_string                              // some_stringを返し、呼び出し元の関数に移動させる
}

// takes_and_gives_backが渡された文字列を受け取り、その値を返す
fn takes_and_gives_back(a_string: String) -> String { // a_stringがスコープに入る

  a_string  // a_stringを返し、呼び出し元の関数に移動させる
}
  1. 参照と借用

7.1 参照

前節の関数の引数と戻り値を考えてみましょう。「所有権」の概念があるため、変数が現在のスコープから関数に引数として渡されると、現在のスコープの変数は即座に所有権を失います。しかし、多くの場合、そうしたくありません。なぜなら、その後の処理でそれを引き続き使用する必要があるからです。このような場合、実行が完了した関数から「戻り値」としてそれを取り戻すしかありません。そして、その関数に他の結果を返す必要がある場合は、タプルを使用してその変数と他の戻り値をカプセル化する必要があります。これは非常に「歪んだ」ことです。通常のプログラミングでは誰もそんなことをしません。このような問題を解決するために、Rustは「参照」を導入しました:

let s1 = String::from("hello"); let s = &s1; // sはs1の参照

「文字列の長さを求める」関数を考えてみましょう。関数の設計目標からだけで以下を推測できます:

  1. 関数は文字列を読み取り専用で使用するだけ
  2. 文字列の長さを求めた後、その文字列は他の場所でまだ使用される(長さを計算しただけでそれを破棄してはならない)

この関数に渡される文字列は典型的な「参照」型です。以下のコードの&s1がそれです:

fn main() {
    let s1 = String::from("hello");

    let len = calculate_length(&s1); // 参照ではなく変数自体を関数に渡す

    println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

そのメモリ内の状態は以下のようになります:

参照が通常の変数と異なる点は、値の「所有権」を取得しないことです。したがって、参照がスコープを離れても値が自動的に破棄されることはありません! 以下のコードがこれを説明しています:

fn calculate_length(s: &String) -> usize { // sはStringへの参照
    s.len()
} // ここでsがスコープを離れる。しかし参照値の所有権を持っていないため、
  // 何も起きない

最後に、「借用」(borrowing)について関連して説明します。参照を使用するプロセスと行動が「借用」です。 関数が参照型の引数を宣言し、それを呼び出すときに変数の参照を渡すと、関数の視点から見ると、それは変数を「借用」しています。変数の所有権はあなた(呼び出し側)の手にあります。

7.2 可変参照

参照を通じて値を読み取るだけで値を変更することはできません。これは安全上の考慮からですが、時には不便なこともあります。そこでRustは「可変参照」を導入しましたが、メモリ安全性を保証するため非常に厳しい制約を追加しています。

let mut s1 = String::from("hello"); let s = &mut s1; // sはs1の可変参照

  • 制約ルール1:同一時間に、1つの値に対して1つの可変参照しか存在できない

Rustが「可変参照」を提供してくれたからといって、思いのままに使えると考えてはいけません。すぐに以下のような問題に遭遇します:

同じスコープ内で同じ値に対して2つ以上の可変参照を作成すると、Rustはエラーを出します。Rustがこの制約を導入した本来の目的は「データ競合」(data race)を避けることです。これはマルチスレッドプログラミングの典型的な問題です。2つ以上のポインタが同じデータを指し、少なくとも1つのポインタがデータを書き込む場合、synchronized保護がないとこの問題が発生します。マルチスレッドで転送操作を実行する場合、「口座残高」は典型的な「競合データ」です。「口座残高」に同期保護をかけないと、複数回の相互転送の後、最終的にすべての口座の合計金額が変わってしまいます。これは「データ腐食」と呼ばれ、データ競合が引き起こす誤りの一種です。そこでRustは「一括で」2つ以上の可変参照を同時に存在させないようにしています。

  • 制約ルール2:ある値にすでに不変参照がある場合、可変参照を宣言することはできません。ただし、可変参照を宣言する前にすべての不変参照が使用されていない場合を除く

この原則の前半部分は非常に厳格ですが、後半部分にはいくつか「回旋余地」があります。重要なのは「参照が使用されていない」とはどういうことかです。以下の例を見てみましょう。この例では、コンパイルエラーは2つの間違いが続いた結果です。まず、同じスコープ内で2つの不変参照を作成した後、さらに1つの可変参照を作成しています。この時点で注意すべきですが、まだエラーにはなりません。可変参照を宣言した後、前に宣言した不変参照を再び使用したときに「赤線を踏み」てしまいます。

以下のように書き換えればエラーになりません。これは補足条件「可変参照を宣言する前にすべての不変参照が使用されていない」をよく表しています:

コードには合計で2つの不変参照と1つの可変参照がありますが、これは許可されています。重要な点は、可変参照r3を宣言した後、2つの不変参照r1r2がもう一度出てこないことです。それらの「最後の使用」はプログラムの5行目で、r3を作成する前です。Rustのコンパイラはこの状況を判断する能力があります。この状況は「非語彙的ライフタイム(Non-Lexical Lifetimes、略してNLL)」と呼ばれ、参照がスコープ終了前にまだ使用されているかどうかを判断するものです。

7.3 ぶら下がり参照

ぶら下がり参照はC++のダングリングポインタと同じ意味で、参照が指している値が他の場所で解放されているのに、参照が引き続き使用されている状態です。Rustが参照に前述の厳しいルールを課しているため、コンパイラはこの状況を判断でき、コンパイルチェックによってぶら下がり参照の発生を防ぐことができます。以下の例を見てみましょう:

エラーの理由は理解しやすいでしょう。dangle()sの参照をreference_to_nothingに返そうとします。しかしsdangle()関数内で作成されたもので、関数のスコープが終わると自動的に破棄されます(参照を返しているので所有権は移動しません)。したがって、返されたこの参照&sは無効な位置を指しています。そしてRustはこの状況を判断してエラーを出します。実際、これは参照の誤った使用例です。この例を正しく実装するには、関数内で作成された値そのものを返すべきです。値を返す場合はmoveセマンティクスがトリガーされ、所有権が呼び出し側、つまり関数の外部に移動します。そうすればsが破棄されることはありません:

  1. スライス型:データの一部を参照しても参照「枠」を1つ消費する

8.1 スライス型とは何か?

所有権に関する説明の最後のトピックはスライス型です。この部分に初めて触れるとき、多くの人は誤った認識を持つことがあります:Rustのスライスを他の言語のコレクション関連のスライス、例えばPythonやScalaのスライスだと理解してしまいます。操作やコレクション型だと考えてしまうのです。しかし文字列スライス、配列スライスなど異なる型のスライスがあることに気づけば、Rustのスライスは単純なものではないと気づくはずです。おそらくC++のテンプレート関数を連想するかもしれませんが、それは関数ではなく、データの抽象化であり、行動の抽象化ではないので、より型に近いものです

『C++ Primer』の本では、参照とポインタを**複合型(Compound Type)**と分類しています。つまり、それらは別の型から構築された型です。ポインタや参照の宣言形式を少し考えればこの説が理解できます。入門段階では、Rustのスライス型をC++の複合型、つまり別の型から構築された型と理解することができます。ポインタや参照と並ぶ型です。もちろん、スライス型の実際の状況はさらに複雑で、DSTや太いポインタなどのメカニズムが関係しているため、今は詳しく説明しません。

「文字列スライス」を例にスライス型を理解しましょう。文字列の一部を切り取る操作は非常に一般的です。他の言語にも対応するテンプレート関数があります。Rustでは文字列スライスがあります:

let s = String::from("hello world");
let world = &s[6..11];

コードは理解しやすいですが、問題はこのworldです:worldはスライスで、sの6バイト目へのポインタと長さの値5を含んでいます。そのメモリ内の構造は以下のようです:

この図を初めて見たときは合理的に思えるかもしれません。しかし、前の7.1節の参照sの図示と比較すると、顕著な違いに気づくでしょう。worldは直接ヒープ上の文字列を指しています。この点から見るとsに似ていますが、同時に確かに参照型であり、文字列の所有権を取得していません。このように見ると、&str型は非常に特別です:

&strのより深い解説はDST(動的サイズ型)、太いポインタ(Fat Pointer)などの他の概念に関わるため、後で別の記事で詳しく説明します。現時点では、&strは参照セマンティクスを内蔵した独立した型であることを明確に覚えておいてください。

8.2 ローカル参照も参照である

スライスの機能は非常に直接的で、上の例は一見して明らかです。特に注意すべき点は:ある値の「一部」を参照していても、Rustはそれを値全体の参照と同等に扱うということです!これは7.2節で説明したルールを適用する際に特に顕著に現れます。例を見てみましょう。first_word関数があります。これは文字列の参照を受け取り、最初の単語を見つけて「文字列スライス」の形式(&str)でその単語を返します。以下の関数はこのfirst_word関数を使用して"hello world"文字列の最初の単語を見つけて出力するコードです:

上記のコードはs.clear()の行でエラーになります。なぜならs.clear()メソッドは匿名の引数&mut selfを必要とするからです。これはC++のクラスメンバ関数と同じです。このメソッドを呼び出すことは必ずsの可変参照を作成することになります。しかし、その前にすでにsのスライスがあります。前述したように、ローカル参照も参照です。したがって、wordはsの不変参照を1つ消費していることになります。そのため、8行目でこの不変参照を再び使用すると、ルール2に違反してしまいます。

この例が伝えたい主な点は:スライスはある値の「部分的」な参照であっても、その値にとっては実在の参照であり、その値の直接参照と同じ地位にあるということです。だからこそルール2に違反してしまうのです。つまり:Rustは「部分的借用」と「全体借用」の安全レベルを区別しません。ある参照が底層データを観察できる限り、全体であろうと一部であろうと、Rustはそのデータを破壊する可能性のある方法で変更しようとする人を阻止しなければならないのです。

タグ: rust 所有権 参照 借用 スライス

7月15日 16:58 投稿