paint-brush
Rust の所有権と借用によってメモリの安全性が強化されます@senthilnayagan
2,203 測定値
2,203 測定値

Rust の所有権と借用によってメモリの安全性が強化されます

Senthil Nayagan31m2022/07/15
Read on Terminal Reader
Read this story w/o Javascript

長すぎる; 読むには

Rust の所有権と借用は、実際に何が起こっているのかを理解していないと混乱する可能性があります。これは、以前に学んだプログラミング スタイルを新しいパラダイムに適用する場合に特に当てはまります。私たちはこれをパラダイムシフトと呼んでいます。プログラムが本当にメモリ セーフでない場合、その機能に関する保証はほとんどありません。

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Rust の所有権と借用によってメモリの安全性が強化されます
Senthil Nayagan HackerNoon profile picture

実際に何が起こっているのかを理解していないと、Rust の所有権と借用が混乱する可能性があります。これは、以前に学んだプログラミング スタイルを新しいパラダイムに適用する場合に特に当てはまります。私たちはこれをパラダイムシフトと呼んでいます。所有権は斬新なアイデアですが、最初は理解するのが難しいですが、取り組むほど簡単になります。


Rust の所有権と借用について詳しく説明する前に、まず「メモリの安全性」と「メモリ リーク」とは何か、そしてプログラミング言語がそれらをどのように処理するかを理解しましょう。

メモリの安全性とは何ですか?

メモリの安全性とは、メモリ ポインタまたは参照が常に有効なメモリを参照するソフトウェア アプリケーションの状態を指します。メモリが破損する可能性があるため、プログラムがメモリセーフでない場合、プログラムの動作に関する保証はほとんどありません。簡単に言えば、プログラムが実際にメモリセーフでない場合、その機能に関する保証はほとんどありません。メモリが安全でないプログラムを扱う場合、悪意のある者がこの欠陥を利用して秘密情報を読み取ったり、他人のマシンで任意のコードを実行したりできます。


Freepik によって設計されています。


疑似コードを使用して、有効なメモリとは何かを見てみましょう。


 // pseudocode #1 - shows valid reference { // scope starts here int x = 5 int y = &x } // scope ends here


上記の疑似コードでは、値10が割り当てられた変数xを作成しました。 &演算子またはキーワードを使用して参照を作成します。したがって、 &x構文を使用すると、 xの値を参照する参照を作成できます。簡単に言うと、 5を所有する変数xxへの参照である変数yを作成しました。


変数xyの両方が同じブロックまたはスコープ内にあるため、変数yにはxの値を参照する有効な参照があります。その結果、変数yの値は5になります。


以下の疑似コードを見てください。ご覧のとおり、 xのスコープは、それが作成されたブロックに限定されています。 xのスコープ外にアクセスしようとすると、ダングリング参照が発生します。ぶら下がり参照…?正確には何ですか?


 // pseudocode #2 - shows invalid reference aka dangling reference { // scope starts here int x = 5 } // scope ends here int y = &x // can't access x from here; creates dangling reference


ダングリング リファレンス

ダングリング参照は、他の誰かに与えられた、または解放された (解放された) メモリ位置を指すポインタです。プログラム (別名process ) が、解放または消去されたメモリを参照すると、クラッシュしたり、非決定論的な結果を引き起こしたりする可能性があります。


そうは言っても、メモリの安全性は、プログラマーが無効なデータを処理できるようにする一部のプログラミング言語の特性です。その結果、メモリの安全性の欠如によりさまざまな問題が発生し、次の主要なセキュリティの脆弱性を引き起こす可能性があります。


  • 範囲外読み取り
  • 境界外書き込み
  • 解放後使用


メモリの安全性が損なわれることによって引き起こされる脆弱性は、他の多くの重大なセキュリティ脅威の根源です。残念ながら、これらの脆弱性を明らかにすることは、開発者にとって非常に困難な場合があります。

メモリ リークとは

メモリ リークとは何か、およびその結果は何かを理解することが重要です。


Freepik によって設計されています。


メモリ リークは、開発者が不要になったヒープメモリの割り当てられたブロックを解放できない、意図しない形式のメモリ消費です。これは、メモリの安全性とは正反対です。さまざまなメモリの種類については後で詳しく説明しますが、現時点では、スタックにはコンパイル時に既知の固定長の変数が格納されるのに対し、後で実行時に変更される可能性のある変数のサイズはheapに配置する必要があることを知っておいてください。


ヒープ メモリの割り当てと比較すると、スタック メモリの割り当てはより安全であると見なされます。これは、プログラマまたはプログラム ランタイム自体のいずれかによって、メモリが関連性または不要になったときにメモリが自動的に解放されるためです。


ただし、プログラマがヒープ上にメモリを生成し、ガベージ コレクタがない状態でメモリを削除しない場合 (C および C++ の場合)、メモリ リークが発生します。また、メモリの割り当てを解除せずにメモリのチャンクへのすべての参照を失うと、メモリ リークが発生します。私たちのプログラムは引き続きそのメモリを所有しますが、それを再び使用する方法はありません。


多少のメモリ リークは問題ではありませんが、プログラムが大量のメモリを割り当て、その割り当てを解除しない場合、プログラムのメモリ フットプリントは増加し続け、結果としてサービス拒否が発生します。


プログラムが終了すると、オペレーティング システムは、それが所有するすべてのメモリを即座に回復します。その結果、メモリ リークは実行中のプログラムにのみ影響します。プログラムが終了すると、効果はありません。


メモリ リークの主な影響について見ていきましょう。


メモリ リークは、使用可能なメモリ (ヒープ メモリ) の量を減らすことによって、コンピューターのパフォーマンスを低下させます。最終的には、システムの全体または一部が正しく動作しなくなったり、大幅に遅くなったりします。クラッシュは、一般的にメモリ リークに関連しています。


メモリ リークを防ぐ方法を見つけるためのアプローチは、使用しているプログラミング言語によって異なります。メモリ リークは、小さくてほとんど「気付かない問題」として始まる場合がありますが、非常に急速にエスカレートし、影響を受けるシステムを圧倒する可能性があります。可能な限り、それらを監視し、それらを成長させておくのではなく、修正するための措置を講じる必要があります。

メモリの安全性とメモリ リーク

メモリ リークとメモリの安全性の問題は、防止と修復の観点から最も注目されている 2 つのタイプの問題です。一方を修正しても他方が自動的に修正されるわけではないことに注意してください。


図 1: メモリの安全性とメモリ リークの比較。


さまざまな種類のメモリとその動作

先に進む前に、コードが実行時に使用するさまざまな種類のメモリを理解することが重要です。


メモリには次の 2 種類があり、メモリの構造が異なります。

  • プロセッサ レジスタ

  • 静的

  • スタック

  • ヒープ


プロセッサ レジスタ静的メモリの両方のタイプは、この記事の範囲外です。

スタック メモリとそのしくみ

スタックは、データを受信した順序で格納し、逆の順序で削除します。項目は後入れ先出し (LIFO) 順でスタックからアクセスできます。スタックにデータを追加することを「プッシュ」と呼び、スタックからデータを削除することを「ポッピング」と呼びます。


スタックに格納されるすべてのデータは、既知の固定サイズである必要があります。コンパイル時のサイズが不明なデータ、または後で変更される可能性のあるサイズのデータは、代わりにヒープに格納する必要があります。


開発者は、スタック メモリの割り当て解放について心配する必要はありません。スタック メモリの割り当てと割り当て解除は、コンパイラによって「自動的に行われます」。これは、スタック上のデータが関連性を失った (範囲外になった) 場合、介入を必要とせずに自動的に削除されることを意味します。


この種のメモリ割り当ては、一時メモリ割り当てとも呼ばれます。これは、関数の実行が終了するとすぐに、その関数に属するすべてのデータがスタックから「自動的に」フラッシュされるためです。


Rust のすべてのプリミティブ型はスタック上に存在します。数値、文字、スライス、ブール値、固定サイズの配列、プリミティブを含むタプル、関数ポインターなどの型はすべてスタックに配置できます。


ヒープメモリとその仕組み

スタックとは異なり、データをヒープに置くとき、一定量のスペースを要求します。メモリ アロケータは、ヒープ内の十分な大きさの空いている場所を見つけ、使用中としてマークし、その場所のアドレスへの参照を返します。これは、割り当てと呼ばれます。


アロケーターは新しいデータを配置するために空の場所を探す必要がないため、ヒープへの割り当てはスタックへのプッシュよりも遅くなります。さらに、ヒープ上のデータにアクセスするにはポインターをたどる必要があるため、スタック上のデータにアクセスするよりも遅くなります。コンパイル時に割り当ておよび割り当て解除されるスタックとは異なり、ヒープ メモリはプログラムの命令の実行中に割り当ておよび割り当て解除されます。


一部のプログラミング言語では、ヒープ メモリを割り当てるためにキーワードnewを使用します。このnewキーワード (別名operator ) は、ヒープでのメモリ割り当ての要求を示します。ヒープに十分なメモリがある場合、 new演算子はメモリを初期化し、新しく割り当てられたメモリの一意のアドレスを返します。


ヒープ メモリは、プログラマまたはランタイムによって "明示的に" 割り当て解除されることに注意してください。

他のさまざまなプログラミング言語はメモリの安全性をどのように保証していますか?

メモリ管理、特にヒープ メモリに関しては、プログラミング言語に次の特性を持たせたいと考えています。

  • メモリが不要になったら、実行時のオーバーヘッドなしで、できるだけ早くメモリを解放することをお勧めします。
  • 解放されたデータへの参照 (別名ダングリング参照) を維持するべきではありません。そうしないと、クラッシュやセキュリティの問題が発生する可能性があります。


メモリの安全性は、プログラミング言語によって次の方法でさまざまな方法で保証されます。

  • 明示的なメモリー解放(C、C++ で採用)
  • 自動または暗黙のメモリー解放(Java、Python、および C# で採用)
  • リージョンベースのメモリ管理
  • 線形または固有の型システム


領域ベースのメモリ管理線形型のシステムはどちらも、この記事の範囲を超えています。

手動または明示的なメモリ割り当て解除

明示的なメモリ管理を使用する場合、プログラマは割り当てられたメモリを「手動で」解放または消去する必要があります。 「割り当て解除」演算子 (たとえば、C のdelete ) は、明示的なメモリ割り当て解除を行う言語に存在します。


C や C++ などのシステム言語ではガベージ コレクションのコストが高すぎるため、明示的なメモリ割り当てが引き続き存在します。


メモリを解放する責任をプログラマーに任せることには、プログラマーが変数のライフサイクルを完全に制御できるという利点があります。ただし、解放演算子を誤って使用すると、実行中にソフトウェア障害が発生する可能性があります。実際、この手動の割り当てとリリースのプロセスはエラーを起こしやすいです。一般的なコーディング エラーには次のようなものがあります。

  • ダングリングリファレンス
  • メモリーリーク


それにもかかわらず、ガベージ コレクションよりも手動のメモリ管理を優先しました。これは、より多くの制御が可能で、パフォーマンスが向上するためです。システムプログラミング言語の目標は、可能な限り「金属に近づく」ことであることに注意してください。言い換えれば、トレードオフで便利な機能よりも優れたパフォーマンスを優先します。


解放した値へのポインターが使用されないようにすることは、完全に私たち (開発者) の責任です。


最近では、これらのエラーを回避するためのいくつかの実証済みのパターンがありましたが、最終的には、正しいメモリ管理方法を一貫して適用する必要がある厳密なコード規律を維持する必要があります。


重要なポイントは次のとおりです。

  • メモリ管理をより細かく制御できます。
  • ぶら下がり参照とメモリ リークの結果、安全性が低下します。
  • 開発期間が長くなります。

自動または暗黙的なメモリ割り当て解除

自動メモリ管理は、Java を含む最新のすべてのプログラミング言語で不可欠な機能になっています。


自動メモリ割り当て解除の場合、ガベージ コレクタは自動メモリ マネージャとして機能します。これらのガベージ コレクターは定期的にヒープを調べ、使用されていないメモリのチャンクをリサイクルします。これらは、私たちに代わってメモリの割り当てと解放を管理します。したがって、メモリ管理タスクを実行するためにコードを記述する必要はありません。ガベージ コレクターによってメモリ管理の責任から解放されるので、これは素晴らしいことです。もう1つの利点は、開発時間を短縮できることです。


一方、ガベージ コレクションには多くの欠点があります。ガベージ コレクションの間、プログラムは一時停止し、次に進む前に何をクリーンアップする必要があるかを判断するのに時間を費やす必要があります。


さらに、自動メモリ管理では、より多くのメモリが必要になります。これは、メモリと CPU サイクルの両方を消費するメモリ割り当て解除をガベージ コレクタが実行するためです。その結果、特にリソースが限られている大規模なアプリケーションでは、自動メモリ管理によってアプリケーションのパフォーマンスが低下する可能性があります。


重要なポイントは次のとおりです。

  • 開発者がメモリを手動で解放する必要がなくなります。
  • ダングリング参照やメモリ リークのない、効率的なメモリの安全性を提供します。
  • よりシンプルでわかりやすいコード。
  • 開発サイクルの高速化。
  • メモリ管理をあまり制御できません。
  • メモリと CPU サイクルの両方を消費するため、レイテンシが発生します。

Rust はメモリの安全性をどのように保証しますか?

一部の言語では、プログラムの実行中に使用されなくなったメモリを探すガベージ コレクションが提供されます。また、プログラマが明示的にメモリを割り当てて解放する必要があるものもあります。これらのモデルにはどちらも利点と欠点があります。ガベージ コレクションは、おそらく最も広く使用されていますが、いくつかの欠点があります。リソースとパフォーマンスを犠牲にして、開発者の生活を楽にします。


そうは言っても、1つは効率的なメモリ管理制御を提供し、もう1つはダングリング参照とメモリリークを排除することでより高い安全性を提供します。 Rust は両方の長所を兼ね備えています。


図 2: Rust はメモリ管理をより適切に制御し、メモリの問題なしでより高い安全性を提供します。


Rust は、メモリの安全性を確保するためにコンパイラが検証する一連のルールを備えた所有権モデルに基づいて、他の 2 つとは異なるアプローチをとっています。これらの規則のいずれかに違反すると、プログラムはコンパイルされません。実際、所有権は、実行時のガベージ コレクションをメモリの安全性のためのコンパイル時のチェックに置き換えます。


明示的なメモリ管理 vs. 暗黙的なメモリ管理 vs. Rust の所有権モデル。


所有権は、私のような多くのプログラマーにとって新しい概念であるため、慣れるまでに時間がかかります。

所有

この時点で、データがメモリに格納される方法についての基本的な理解が得られました。 Rust での所有権を詳しく見てみましょう。 Rust の最大の特徴は所有権です。これにより、コンパイル時のメモリの安全性が保証されます。


まず、「所有権」を最も文字通りの意味で定義しましょう。所有権とは、「何か」を合法的に「所有」し、「支配」している状態です。そうは言っても、所有者が誰であり、所有者が所有および管理しているものを特定する必要があります。 Rust では、各値にはownerと呼ばれる変数があります。簡単に言えば、変数は所有者であり、変数の値は所有者が所有および制御するものです。


図 3: 変数バインディングは、所有者とその値/リソースを示しています。


所有権モデルでは、メモリを所有する変数がスコープ外になると、メモリは自動的に解放 (解放) されます。値がスコープ外になるか、何らかの理由でその有効期間が終了すると、デストラクタが呼び出されます。デストラクタ、特に自動化されたデストラクタは、参照を削除してメモリを解放することにより、プログラムから値の痕跡を取り除く関数です。

借入チェッカー

Rust は借用チェッカーを通じて所有権を実装します。静的アナライザー.借用チェッカーは、プログラム全体でデータが使用されている場所を追跡する Rust コンパイラのコンポーネントであり、所有権の規則に従うことで、データを解放する必要がある場所を特定できます。さらに、ボロー チェッカーは、割り当て解除されたメモリが実行時にアクセスできないようにします。同時突然変異 (変更) によって引き起こされるデータ競合の可能性も排除します。

所有規則

前述のように、所有権モデルは所有権ルールと呼ばれる一連のルールに基づいて構築されており、これらのルールは比較的単純です。 Rust コンパイラ (rustc) は、次の規則を適用します。

  • Rust では、各値にはその所有者と呼ばれる変数があります。
  • 一度に存在できる所有者は 1 人だけです。
  • 所有者が範囲外になると、値は削除されます。


次のメモリ エラーは、これらのコンパイル時の所有権チェック ルールによって保護されます。

  • ダングリング参照:これは、ポインタが参照していたデータが含まれていないメモリ アドレスを参照が指す場所です。このポインターは、null またはランダム データを指します。
  • 解放後に使用:これは、メモリが解放された後にアクセスされる場所であり、クラッシュする可能性があります。このメモリ ロケーションは、ハッカーがコードを実行するために使用することもできます。
  • 二重解放:これは、割り当てられたメモリが解放され、その後再び解放される場所です。これにより、プログラムがクラッシュし、機密情報が公開される可能性があります。これにより、ハッカーは選択したコードを実行することもできます。
  • セグメンテーション違反:これは、プログラムがアクセスを許可されていないメモリにアクセスしようとする場所です。
  • バッファ オーバーラン:これは、データの量がメモリ バッファの記憶容量を超え、プログラムがクラッシュする原因となる場所です。


各所有権ルールの詳細に入る前に、 copymove 、およびcloneの違いを理解することが重要です。

コピー

固定サイズの型 (特にプリミティブ型) は、スタックに格納し、そのスコープが終了したときにポップオフできます。コードの別の部分で同じ値が必要な場合は、すばやく簡単にコピーして新しい独立した変数を作成できます。別のスコープ。スタック メモリのコピーは安価で高速であるため、固定サイズのプリミティブ型はコピーセマンティクスを持つと言われています。完全なレプリカ (複製) を低コストで作成します。


固定サイズのプリミティブ型は、コピーを作成するためのコピー トレイトを実装していることに注意してください。


 let x = "hello"; let y = x; println!("{}", x) // hello println!("{}", y) // hello


Rust には、2 種類の文字列があります。 String (ヒープが割り当てられ、拡張可能) と&str (固定サイズで変更不可) です。


xはスタックに格納されるため、その値をコピーしてyの別のコピーを生成する方が簡単です。これは、ヒープに格納されている値には当てはまりません。スタック フレームは次のようになります。


図 4: x と y の両方に独自のデータがあります。

データを複製すると、プログラムの実行時間とメモリ消費量が増加します。したがって、コピーは大量のデータには適していません。

動く

Rust 用語では、「移動」とは、メモリの所有権が別の所有者に譲渡されることを意味します。ヒープに格納されている複合型の場合を考えてみましょう。


 let s1 = String::from("hello"); let s2 = s1;


2 行目 (つまりlet s2 = s1; ) がs1の値のコピーを作成し、それをs2にバインドすると仮定するかもしれません。しかし、そうではありません。


以下を見て、フードの下でStringに何が起こっているかを確認してください。 String は 3 つの部分で構成され、スタックに格納されます。実際のコンテンツ (この場合はこんにちは) はheapに格納されます。

  • ポインタ- 文字列の内容を保持するメモリを指します。
  • 長さ- Stringの内容が現在使用しているメモリ量 (バイト単位) です。
  • Capacity - Stringがアロケータから受け取ったメモリの総量 (バイト単位) です。


つまり、メタデータはスタックに保持され、実際のデータはヒープに保持されます。


図 5: スタックはメタデータを保持し、ヒープは実際のコンテンツを保持します。


s1s2に割り当てると、 Stringメタデータがコピーされます。つまり、スタックにあるポインター、長さ、および容量がコピーされます。ポインターが参照するヒープ上のデータはコピーしません。メモリ内のデータ表現は次のようになります。


図 6: 変数 s2 は、s1 のポインター、長さ、および容量のコピーを取得します。


Rust がヒープ データもコピーした場合にメモリがどのように見えるかは、以下のような表現ではないことに注意してください。 Rust がこれを実行した場合、ヒープ データが大きい場合、 s2 = s1操作はランタイム パフォーマンスの点で非常に遅くなる可能性があります。


図 7: Rust がヒープ データをコピーした場合、let s2 = s1 が行う別の可能性はデータ レプリケーションです。ただし、Rust はデフォルトではコピーしません。


複合型がスコープ外になると、Rust はdrop関数を呼び出して明示的にヒープ メモリの割り当てを解除することに注意してください。ただし、図 6 の両方のデータ ポインターは同じ場所を指していますが、これは Rust の動作ではありません。詳細については、後ほど説明します。


前述のように、 s1s2に割り当てると、変数s2s1のメタデータ (ポインター、長さ、および容量) のコピーを受け取ります。しかし、 s1s2に割り当てられるとどうなるでしょうか? Rust はもはやs1が有効であるとは見なしません。はい、あなたはそれを正しく読みました。


このlet s2 = s1の代入について少し考えてみましょう。この割り当ての後、Rust がまだs1を有効であると見なした場合に何が起こるかを考えてみてください。 s2s1が範囲外になると、両方とも同じメモリを解放しようとします。うーん、それは良くない。これはdouble free errorと呼ばれ、メモリの安全性に関するバグの 1 つです。メモリの破損は、メモリを 2 回解放することで発生する可能性があり、セキュリティ リスクを引き起こします。


メモリの安全性を確保するために、Rust は行let s2 = s1の後でs1を無効と見なしました。したがって、 s1がスコープから外れた場合、Rust は何も解放する必要はありません。 s2が作成された後にs1を使用しようとするとどうなるかを調べます。


 let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.


Rust が無効化された参照を使用できないため、以下のようなエラーが発生します。


 $ cargo run Compiling playground v0.0.1 (/playground) error[E0382]: borrow of moved value: `s1` --> src/main.rs:6:28 | 3 | let s1 = String::from("hello"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait 4 | let s2 = s1; | -- value moved here 5 | 6 | println!("{}, world!", s1); | ^^ value borrowed here after move | = note: this error originates in the macro `$crate::format_args_nl` (in Nightly builds, run with -Z macro-backtrace for more info) For more information about this error, try `rustc --explain E0382`.


Rust はlet s2 = s1という行の後にs1のメモリの所有権をs2に「移動」したため、 s1は無効であると見なされました。 s1 が無効化された後のメモリ表現は次のとおりです。


図 8: s1 が無効化された後のメモリ表現。


s2だけが有効なままである場合、範囲外になると、それだけでメモリが解放されます。その結果、Rust では二重解放エラーの可能性が排除されます。それは素晴らしいです!

クローン

スタック データだけでなく、 Stringのヒープ データを深くコピーたい場合は、 cloneというメソッドを使用できます。 clone メソッドの使用例を次に示します。


 let s1 = String::from("hello"); let s2 = s1.clone(); println!("s1 = {}, s2 = {}", s1, s2);


clone メソッドを使用すると、ヒープ データは s2 にコピーされます。これは完全に機能し、次の動作を生成します。


図 9: clone メソッドを使用すると、ヒープ データが s2 にコピーされます。


clone メソッドを使用すると、重大な結果が生じます。データをコピーするだけでなく、2 つの間の変更を同期しません。一般に、クローンは慎重に計画し、その結果を十分に認識している必要があります。


ここまでで、コピー、移動、クローンを区別できるはずです。ここで、各所有権ルールを詳しく見てみましょう。

所有権規則 1

各値には、その所有者と呼ばれる変数があります。これは、すべての値が変数によって所有されていることを意味します。以下の例では、変数sが文字列へのポインターを所有し、2 行目の変数xが値 1 を所有しています。


 let s = String::from("Rule 1"); let n = 1;

所有権のルール 2

ある時点で値の所有者は 1 人だけです。多くのペットを飼うことができますが、所有モデルに関して言えば、いつでも 1 つの値しかありません :-)


Freepik によって設計されています。


コンパイル時に既知の固定サイズであるプリミティブを使用した例を見てみましょう。


 let x = 10; let y = x; let z = x;


10 を取り、それをxに割り当てました。つまり、 xは 10 を所有しています。次に、 xを取得してyに代入し、それをzにも代入します。所有者は一度に 1 人しか存在できないことはわかっていますが、ここではエラーは発生していません。ここで何が起こっているかというと、x を新しい変数に代入するたびに、コンパイラがxのコピーを作成しているということです。


このためのスタック フレームは次のようになります: x = 10y = 10およびz = 10 。ただし、これはx = 10y = x 、およびz = xのように当てはまらないようです。ご存知のように、 xはこの値 10 の唯一の所有者であり、 yzもこの値を所有することはできません。


図 10: コンパイラは x のコピーを y と z の両方に作成しました。


スタック メモリのコピーは安価で高速であるため、固定サイズのプリミティブ型はコピーセマンティクスを持つと言われますが、複合型は所有権を移動します (前述のとおり)。したがって、この場合、コンパイラはコピーを作成します。


このときの挙動は変数バインディング他のプログラミング言語と同じです。所有権のルールを説明するには、複雑なデータ型が必要です。


ヒープに保存されているデータを見て、Rust がそれをクリーンアップするタイミングをどのように理解するかを見てみましょう。 String 型は、このユース ケースの優れた例です。 String の所有権関連の動作に焦点を当てます。ただし、これらの原則は他の複雑なデータ型にも適用されます。


ご存知のように、複合型はヒープ上のデータを管理し、その内容はコンパイル時には不明です。前に見たのと同じ例を見てみましょう。


 let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. We'll get an error.



String型の場合、サイズが膨張してヒープに格納されることがあります。これの意味は:

  • 実行時に、メモリ アロケータからメモリを要求する必要があります (最初の部分と呼びましょう)。
  • Stringの使用が終了したら、このメモリをアロケータに戻す (解放する) 必要があります (これを 2 番目の部分と呼びましょう)。


私たち (開発者) は最初の部分を処理しました: String::fromを呼び出すと、その実装は必要なメモリを要求します。この部分は、プログラミング言語間でほぼ共通です。


ただし、第 2 部は異なります。ガベージ コレクター (GC) を備えた言語では、GC が使用されなくなったメモリを追跡してクリーンアップするため、心配する必要はありません。ガベージ コレクターのない言語では、メモリが不要になった時期を特定し、明示的に解放するように要求するのは私たちの責任です。これを正しく行うことは、常に困難なプログラミング タスクでした。

  • 忘れると記憶が無駄になります。
  • 早すぎると変数が無効になります。
  • 2回やるとバグります。


Rust は、私たちの生活を楽にする斬新な方法でメモリの解放を処理します。メモリを所有する変数がスコープ外になると、メモリは自動的に返されます。


ビジネスに戻りましょう。 Rust では、複雑な型の場合、値を変数に代入する、関数に渡す、関数から返すなどの操作は、値をコピーせずに移動します。簡単に言うと、複合型は所有権を移動します。


複合型がスコープ内になくなると、Rust は drop 関数を呼び出して明示的にヒープ メモリの割り当てを解除します。


所有権のルール 3

所有者が範囲外になると、値は削除されます。前のケースをもう一度考えてみましょう。


 let s1 = String::from("hello"); let s2 = s1; println!("{}, world!", s1); // Won't compile. The value of s1 has already been dropped.


s1s2に代入された後 ( let s2 = s1代入ステートメントで)、 s1の値がドロップされました。したがって、 s1はこの代入後は無効になります。 s1 が削除された後のメモリ表現は次のとおりです。


図 11: s1 が削除された後のメモリ表現。

所有権の移動方法

Rust プログラムで、ある変数から別の変数に所有権を譲渡するには、次の 3 つの方法があります。

  1. ある変数の値を別の変数に代入する (既に説明しました)。
  2. 関数に値を渡す。
  3. 関数からの戻り。

関数に値を渡す

関数に値を渡す方法は、変数に値を代入する方法と似ています。代入と同様に、変数を関数に渡すと、変数が移動またはコピーされます。次の例を見てください。これは、コピーと移動の両方の使用例を示しています。


 fn main() { let s = String::from("hello"); // s comes into scope move_ownership(s); // s's value moves into the function... // so it's no longer valid from this // point forward let x = 5; // x comes into scope makes_copy(x); // x would move into the function // It follows copy semantics since it's // primitive, so we use x afterward } // Here, x goes out of scope, then s. But because s's value was moved, nothing // special happens. fn move_ownership(some_string: String) { // some_string comes into scope println!("{}", some_string); } // Here, some_string goes out of scope and `drop` is called. // The occupied memory is freed. fn makes_copy(some_integer: i32) { // some_integer comes into scope println!("{}", some_integer); } // Here, some_integer goes out of scope. Nothing special happens.


move_ownershipの呼び出しの後に s を使用しようとすると、Rust はコンパイル時エラーをスローします。

関数から戻る

戻り値によって所有権を譲渡することもできます。次の例は、値を返す関数を示しています。注釈は前の例と同じです。


 fn main() { let s1 = gives_ownership(); // gives_ownership moves its return // value into s1 let s2 = String::from("hello"); // s2 comes into scope let s3 = takes_and_gives_back(s2); // s2 is moved into // takes_and_gives_back, which also // moves its return value into s3 } // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing // happens. s1 goes out of scope and is dropped. fn gives_ownership() -> String { // gives_ownership will move its // return value into the function // that calls it let some_string = String::from("yours"); // some_string comes into scope some_string // some_string is returned and // moves out to the calling // function } // This function takes a String and returns it fn takes_and_gives_back(a_string: String) -> String { // a_string comes into // scope a_string // a_string is returned and moves out to the calling function }


変数の所有権は、常に同じパターンに従います。値は、別の変数に代入されると移動されます。データの所有権が別の変数に移されていない限り、ヒープ上のデータを含む変数がスコープ外になると、その値はdropによって消去されます。


これにより、所有権モデルとは何か、Rust が値を処理する方法 (値を相互に割り当てたり、関数に渡したり関数から渡したりするなど) にどのように影響するかについての基本的な理解が得られることを願っています。


持続する。もう一つ…


Rust の所有権モデルには、すべての優れた機能と同様に、特定の欠点があります。 Rust の作業を開始すると、特定の不便さにすぐに気付きます。所有権を取得してから各関数で所有権を返すのは少し不便であることに気付いたかもしれません。

Freepik によって設計されています。


関数を再度使用したい場合、その関数によって返される他のデータに加えて、関数に渡すすべてのものを返さなければならないのは面倒です。関数が値の所有権を取得せずに値を使用するようにしたい場合はどうすればよいでしょうか?


次の例を考えてみましょう。所有権がprint_vector関数に転送されると、最初にそれを所有していたmain関数 ( println!内) で変数vを使用できなくなるため、以下のコードはエラーになります。


 fn main() { let v = vec![10,20,30]; print_vector(v); println!("{}", v[0]); // this line gives us an error } fn print_vector(x: Vec<i32>) { println!("Inside print_vector function {:?}",x); }


所有権の追跡は簡単に思えるかもしれませんが、大規模で複雑なプログラムを扱うようになると複雑になることがあります。そのため、所有権を譲渡せずに価値を譲渡する方法が必要であり、そこで借用の概念が登場します。

借入

文字通りの意味での借用とは、返すという約束で何かを受け取ることを指します。 Rust のコンテキストでは、借用は、ある時点で所有者に返さなければならないため、所有権を主張せずに値にアクセスする方法です。


Freepik によって設計されています。


値を借用するときは、そのメモリ アドレスを&演算子で参照します。 &参照と呼ばれます。参照自体は特別なものではなく、中身は単なるアドレスです。 C ポインターに精通している方にとって、参照とは、別の変数に属する (別の変数が所有する) 値を含むメモリへのポインターです。 Rust では参照を null にできないことに注意してください。実際、参照はポインターです。これはポインタの最も基本的なタイプです。ほとんどの言語には 1 つのタイプのポインターしかありませんが、Rust には 1 つだけではなく、さまざまな種類のポインターがあります。ポインターとそのさまざまな種類は別のトピックであり、個別に説明します。


簡単に言えば、Rust では、ある値への参照を作成することを、値を借用することと呼び、最終的にはその所有者に返さなければなりません。


以下の簡単な例を見てみましょう。


 let x = 5; let y = &x; println!("Value y={}", y); println!("Address of y={:p}", y); println!("Deref of y={}", *y);


上記により、次の出力が生成されます。


 Value y=5 Address of y=0x7fff6c0f131c Deref of y=5


ここで、 y変数は変数xが所有する数値を借用しますが、 xは引き続き値を所有します。 yxへの参照と呼びます。借用はyがスコープ外になると終了し、 yは値を所有していないため、値は破棄されません。値を借用するには、 &演算子で参照します。 p フォーマット{:p}は、16 進数で表されるメモリ位置として出力されます。


上記のコードで、"*" (つまり、アスタリスク) は、参照変数を操作する逆参照演算子です。この逆参照演算子を使用すると、ポインターのメモリ アドレスに格納されている値を取得できます。


関数が借用によって所有権を取得せずに値を使用する方法を見てみましょう。


 fn main() { let v = vec![10,20,30]; print_vector(&v); println!("{}", v[0]); // can access v here as references can't move the value } fn print_vector(x: &Vec<i32>) { println!("Inside print_vector function {:?}", x); }


所有権を譲渡する (つまり、値渡し) のではなく、参照 ( &v ) (別名pass-by-reference ) をprint_vector関数に渡します。その結果、main 関数でprint_vector関数を呼び出した後、 vにアクセスできます。

逆参照演算子を使用して値へのポインターをたどる

前述のように、参照は一種のポインターであり、ポインターは別の場所に格納されている値を指す矢印と考えることができます。以下の例を考えてみましょう:


 let x = 5; let y = &x; assert_eq!(5, x); assert_eq!(5, *y);


上記のコードでは、 i32型の値への参照を作成し、逆参照演算子を使用してデータへの参照に従います。変数xは、 i32型の値5を保持します。 yxへの参照に等しく設定します。


スタック メモリは次のように表示されます。


スタック メモリの表現。


x5に等しいと断言できます。ただし、 yの値に対してアサーションを行いたい場合は、 *yを使用して参照している値への参照に従う必要があります (したがって、ここでは逆参照)。 yを逆参照すると、 yが指している整数値にアクセスでき、これを5と比較できます。


assert_eq!(5, y);と書こうとすると、代わりに、次のコンパイル エラーが発生します。


 error[E0277]: can't compare `{integer}` with `&{integer}` --> src/main.rs:11:5 | 11 | assert_eq!(5, y); | ^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}`


これらは型が異なるため、数値と数値への参照を比較することはできません。したがって、逆参照演算子を使用して、それが指している値への参照をたどる必要があります。

参照はデフォルトで不変

変数と同様に、参照はデフォルトで不変です — mutで変更可能にできますが、その所有者も変更可能である場合に限ります:


 let mut x = 5; let y = &mut x;


不変参照は共有参照とも呼ばれ、可変参照は排他的参照とも呼ばれます。


以下のケースを考えてみましょう。 &mutの代わりに&演算子を使用しているため、参照への読み取り専用アクセスを許可しています。ソースnが変更可能であっても、 ref_to_nanother_ref_to_nは読み取り専用の n 借用であるため、変更できません。


 let mut n = 10; let ref_to_n = &n; let another_ref_to_n = &n;


借用チェッカーは以下のエラーを出します:


 error[E0596]: cannot borrow `x` as mutable, as it is not declared as mutable --> src/main.rs:4:9 | 3 | let x = 5; | - help: consider changing this to be mutable: `mut x` 4 | let y = &mut x; | ^^^^^^ cannot borrow as mutable


貸出ルール

なぜ借用が常に移動よりも優先されるとは限らないのか疑問に思うかもしれません.もしそうなら、なぜ Rust はムーブセマンティックを持っているのですか? また、デフォルトで借用しないのはなぜですか?その理由は、Rust で値を借用することが常に可能であるとは限らないためです。貸出は、特定の場合にのみ許可されます。


借用には独自のルール セットがあり、借用チェッカーがコンパイル時に厳密に適用します。これらのルールは、データ競合を防ぐために導入されました。それらは次のとおりです。

  1. 借用者の範囲は、元の所有者の範囲を超えることはできません。
  2. 複数の不変参照が存在する可能性がありますが、可変参照は 1 つだけです。
  3. 所有者は不変参照または可変参照を持つことができますが、両方を同時に持つことはできません。
  4. すべての参照は有効である必要があります (null にすることはできません)。

参照は所有者より長生きしてはなりません

参照のスコープは、値の所有者のスコープ内に含まれている必要があります。そうしないと、参照が解放された値を参照し、 use-after-freeエラーが発生する可能性があります。


 let x; { let y = 0; x = &y; } println!("{}", x);


上記のプログラムは、所有者yがスコープ外になった後にxを逆参照しようとします。 Rust は、このuse-after-freeエラーを防ぎます。

多くの不変参照、ただし許可される可変参照は 1 つだけ

一度に特定のデータへの不変参照 (別名共有参照) をいくつでも持つことができますが、一度に許可される可変参照 (別名排他的参照) は 1 つだけです。このルールは、データ競合を排除するために存在します。 2 つの参照が同時に同じメモリ位置を指し、そのうちの少なくとも 1 つが書き込み中であり、それらのアクションが同期されていない場合、これはデータ競合として知られています。


不変参照はデータを変更しないため、好きなだけ多くの不変参照を使用できます。一方、借用は、コンパイル時のデータ競合の可能性を防ぐために、一度に 1 つの変更可能な参照 ( &mut ) のみを保持するように制限します。


これを見てみましょう:


 fn main() { let mut s = String::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2); }


sへの 2 つの変更可能な参照 ( r1r2 ) を作成しようとする上記のコードは失敗します。


 error[E0499]: cannot borrow `s` as mutable more than once at a time --> src/main.rs:6:14 | 5 | let r1 = &mut s; | ------ first mutable borrow occurs here 6 | let r2 = &mut s; | ^^^^^^ second mutable borrow occurs here 7 | 8 | println!("{}, {}", r1, r2); | -- first borrow later used here


閉会の辞

うまくいけば、これで所有権と借用の概念が明確になります。また、所有と借用のバックボーンである借用チェッカーについても簡単に触れました。最初に述べたように、所有権は経験豊富な開発者でも最初は理解するのが難しいかもしれない斬新なアイデアですが、取り組むほど簡単になります。これは、Rust でメモリの安全性がどのように強化されるかを簡単に説明したものです。概念を理解するのに十分な情報を提供しながら、この投稿をできるだけ理解しやすいものにしようとしました。 Rust の所有権機能の詳細については、Rust のオンラインドキュメントを参照してください。


Rust は、パフォーマンスが重要な場合に最適な選択肢であり、他の多くの言語を悩ませている問題点を解決するため、急な学習曲線を伴う重要な一歩を踏み出します。 Rust は 6 年連続で Stack Overflow で最も愛されている言語であり、Rust を使用する機会があった多くの人々が Rust に恋をしたことを意味します。 Rust コミュニティは成長を続けています。


Rust Survey 2021 結果によると: 2021 年は、間違いなく Rust の歴史の中で最も重要な年でした。 Rust Foundation の設立、2021 年版、そしてこれまで以上に大きなコミュニティが見られました。 Rust は、将来に向けて力強い道を進んでいるように見えます。


ハッピーラーニング!


Freepik によって設計されています。