「Rustは安全でも難しい」といわれる理由――メモリ安全を実現する「所有権」の仕組み:基本からしっかり学ぶRust入門(5)
Rustについて基本からしっかり学んでいく本連載。今回はRustにおける核心的な機能でRustによるメモリ安全なプログラミングを実現する機能の代表となる所有権を解説します。
所有権とは?
本連載の第1回〜第4回では、Rustの基本的な文法を主にC/C++と対比させて紹介してきました。そこで取り上げなかったものに「ポインタ」があります。ポインタはC/C++では非常に便利である反面、メモリ安全性を妨げるとされています。Rustにもポインタがありますが、C/C++とは違い安全に利用できます。安全にポインタを利用できるようにしているのが「所有権」であり、「借用」です。まずは、所有権のルールを見ていきましょう。
所有権の基本的なルール
所有権とは、文字通り変数が値を所有できる権利のことです。そして、その権利を持つ変数を「所有者」といいます。Rustではある値の所有者は常に1つとなるように決められています。さらに、変数がスコープから抜けるときには、一緒に値も破棄されます。これらが所有権の基本的なルールになります。
もしこのルールから外れたコードを書いた場合は、それはコンパイラに捕捉されてエラーとなるためプログラムを実行できません。そのため所有権のルールは非常に重要と言えますが、けして難しく考える必要はありません。スカラー型などの変数と文字列型(String型)の変数を例にとり、その振る舞いを見ながら理解していきましょう。
【補足】Rustにおけるスコープ
Rustにも、プログラミングで一般的な概念としてのスコープ(scope)があります。スコープとは「範囲」という意味ですが、Rustにおいては変数の有効な範囲ということになります。すでにmain()関数をはじめとする幾つかの関数を見てきていますが、関数の内部は代表的なスコープです。またif式やwhile式などのブロックの内部もスコープになります。もっとも大きなスコープはプログラム全体です。スコープ内で宣言された変数は、そのスコープ内でのみ有効です。
スカラー型における所有権
整数型や浮動小数点型といったスカラー型の変数は、変数間の代入において所有権は基本的に複製されます。これを「所有権の複製(コピー)」といいます。変数間の代入などにおいて所有権は複製されるので、値の所有者は常に1個でなければならないというルールは守られます。この動作を確かめてみましょう。なお、この回のサンプルはownershipsパッケージに作っていきます。
let x = 1; let y = x; println!("xは{}です。", x); // 「xは1です。」 println!("yは{}です。", y); // 「yは1です。」
何の変哲もないコードです。所有権という意識も不要ではないでしょうか。まず変数xが1で初期化され、続けて変数yが変数xの値で初期化されます。変数xは値として1を所有しているので、変数yも同じく1を所有することになります。しかし、所有権が複製されるので、この2つの1は別物ということになります。
内部的な話をすると、スカラー型(この場合はi32型)である変数xも変数yも、値のためのメモリ領域はスタックと呼ばれるメモリ領域に確保され、変数yの初期化においては変数xの場所から値をコピーしてくるという動作になります。
【補足】スタックとヒープ
スタック(stack)とは、プログラムが使用する一時的なメモリ領域です。関数の内部で宣言される変数は、基本的にスタック上に領域が確保されます。スタックを使う利点は、変数用のメモリコストが低いこと、解放も自動であることです。デメリットは、スタックの大きさは固定されている場合が多く、大きなデータの置き場所には向かず、使い切ってしまうとプログラムを異常終了させるしか手段がなくなるという点です。これを解決するのがヒープ(heap)です。ヒープとは摘み草の山という意味ですが、山(ヒープ)から大きな量の草(メモリ)を持ってきたり、山に草を返したり、山の大きさを変えることができるなど、スタックに比べ融通性に富みますが、反面獲得コストは高くなります。
文字列型における所有権
文字列型(String型)も同様に見ていきましょう。String型をはじめとする非スカラー型の場合は、変数間の代入において所有権は複製でなく移動となります。これを「所有権の移動(ムーブ)」といいます。変数間の代入で所有権が移動するため、値の所有者は常に1個でなければならないというルールは守られます。しかしスカラー型とは異なった振る舞いをするようになります。これを確かめてみましょう。
let s1 = String::from("Hello, Rust!"); let s2 = s1; println!("s1は{}です。", s1); // コンパイルエラーになるので実行されない println!("s2は{}です。", s2); // コンパイルエラーになるので実行されない
こちらも、何の変哲もないコードに見えます。変数s1が"Hello, Rust!"で初期化され、続けて変数s2が変数s1の値で初期化されます。変数s1は値として"Hello, Rust!"を所有しているので、変数s2も同じく"Hello, Rust!"を所有することになりそうですが、所有権が移動するため、コンパイルすると以下のようにエラーとなります。
error[E0382]: borrow of moved value: `s1` (1) --> src/bin/string.rs:4:26 | 2 | let s1 = String::from("Hello, Rust!"); | -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait (2) 3 | let s2 = s1; | -- value moved here (3) 4 | println!("s1は{}です。", s1); | ^^ value borrowed here after move (4)
それぞれのエラーの意味は次のようになります。
(1)変数s1の移動(move)された値を借用(borrow)しようとしている
(2)変数s1はString型でCopyトレイト(※)を実装していないので移動が起きようとしている
(3)移動が発生した
(4)移動後に借用しようとしている
エラーの根因は、変数s1がString型だからです。ここで、変数s1の初期化に用いているString::from()という部分に注目です。この形式は、String型の関数であるfrom()を呼び出す(つまり"Hello, Rust!"という文字列リテラルからString型の値を生成する)ことです。
なぜ変数s1に"Hello, Rust!"を直接使って初期化しないのかというと、こうすると変数s1は&strという文字列リテラルを参照するデータ型になり、String型とは別物になるためです。参照も別途あらためて解説しますが、参照では所有権の移動を伴わずに値を使うことができるとだけ、ここではとどめておきます。なお、この変数s1をString型にするための記述は、以下のようにシンプルに書くこともできます。
let s1 = "Hello, Rust!".to_string();
少し内部的な話をすると、String型である変数s1も変数s2も値のためのメモリ領域はスカラー型と同様にスタックに確保されます。しかし、文字列の実体はスタックではなくヒープに別に確保されます。変数s2の初期化においては変数s1のスタック上の場所から値をコピーしてくるのですが、このときヒープにある文字列の実体の場所もコピーされるので、2つの異なる変数が同じ文字列の実体を保有することになります。以下の図のような状態になります。
String型は図の通り、内部的には文字列の実体があるヒープ上の場所へのポインタ、文字列長、場所の容量(文字列の長さの上限)を保持しています。変数s1と変数s2が同じ文字列を所有するので、それぞれのポインタの指す場所は同一になります。これはメモリ安全という観点で好ましくない状態です。
話を戻しますと、変数s1がString型であるために変数s2の初期化において所有権の移動が行われ、変数s1は文字列データ"Hello, Rust!"への所有権を失ってしまうのです。ですので、その後にprintln!()の引数に使用した時点でエラーになります。変数s1には所有権がないので、データとしては何も持っていない、ということになるのです。
(※)トレイトについて
トレイト(trait)とは、特性や特質といった意味です。Rustでは、複数の型で利用目的や呼び出し方法が共通の関数があるとき、それらはトレイトとしてまとめることができます。例えばコピートレイトとは、複製に関する関数をとりまとめています。コピートレイトを備える型では、代入において単なるバイト列をコピーします。スカラー型はこのCopyトレイトを備えているので、スタック上での値のコピーが行われるのです。
先ほど4つのエラーを解説したメッセージにあるように、String型はコピートレイトを備えないので(スカラー型以外の型は基本的にコピートレイトを備えない)、代入時に特別な関数は呼び出されないということになります。代わりにcloneトレイトを備えますが、これに含まれるclone()関数を使えば安全に複製をすることができます(後述)。
Javaなどのインタフェースに相当する機能とも言えますが、このトレイトは後続の回で詳しく触れる予定です。
所有権とメモリ安全
String型におけるこのような振る舞いが、メモリ安全にどのように結び付くか見てみましょう。所有権という仕組みがなく、先述したソースコードが問題なくコンパイルされると仮定すれば、変数s1と変数s2は同じ値を共有している、すなわち2番目の図のような状態になります。
ここで、所有権の基本的なルールの最後、スコープから抜けるときに値は破棄されるということを思い出してください。スコープを抜けるときにスタック上の値は破棄され、String型ならヒープ上の領域も解放されます。このとき、変数s1と変数s2が同じヒープ上の場所の情報を持っているとすると、二重解放の問題が出てきます。これは、Cなどでもよく起きる問題の一つです。
しかしRustでは、所有権の仕組みがあるので、二重解放という問題は起きません。所有権の有無をコンパイル時にチェックし、所有権がなければコンパイルエラーにするのです。プログラム実行前の段階で二重解放を防いでしまおうというアプローチです。
なお、所有権の移動で所有権を失った変数を使用さえしなければ、コンパイルエラーにはなりません。この変数がスコープから抜けるときに、所有権がないことで何も行われないため、問題にならないのです。
【補足】C/C++におけるメモリ管理
Cの場合、動的なメモリの確保はmalloc()関数が、その解放はfree()関数が代表的な方法でした。2021年現在となっては好ましくない仕組みで、free()を忘れればメモリリークが、malloc()そのものを忘れたりfree()を2回実行したりしてしまえばダングリングポインタの問題が、それぞれ発生します。
C++はデストラクタという仕組みで、スコープから変数が抜ける時点でのリソース解放というものを可能にしていましたが、解放のコードはプログラマーが責任を持って書く必要がありました。
Javaなどの中間言語型では、GC(ガベージコレクタ)が、不要になったメモリ領域を解放していました。一見良さそうなこの方法にも、メリットとデメリットがありました。メリットは、プログラマーがメモリ解放を明示しなくても済むこと。デメリットは、プログラマーが解放を指示したりタイミングを知ったりすることもできないためシステムプログラミングに向かないことです。
上記の問題に対して、Rustでは所有権の仕組みでメモリの解放の重複を防ぎ、そのタイミングも完全に把握できます。解放のコードも標準のライブラリにすでに含まれている(String型ではdrop関数が呼ばれる)ので、プログラマーがわざわざ記述する必要もありません。既存言語の抱える問題点を解決したのがRustにおけるメモリ管理と言えます。
所有権の複製
String型では代入で所有権が移動し、それをコンパイラが把握していることで所有権を持たない変数の使用や二重解放の問題を防いでくれることを説明しました。では、意図して文字列データを複製して使いたい、すなわち移動ではなく複製にするにはどうしたらいいのでしょうか。String型では単なる代入では文字列そのものの複製にはなりません。所有権を複製するには、String型のclone()関数を使うなどして、新しいString型のオブジェクトを作成する必要があります。
let s1 = String::from("Hello, Rust!"); let s2 = s1.clone(); println!("s1は{}です。", s1); println!("s2は{}です。", s2);
上記のソースコードはコンパイルエラーになりません。clone()関数は、その名の通りで文字列を複製させる、cloneトレイトの関数です。複製した文字列を別のString型の値として返すため、所有権を複製するのに利用できます。ただし、clone()関数の中では、文字列の実体を複製するためのメモリをヒープに確保し、文字列そのものをコピーするといったやや負荷の高い処理が含まれます。単にコンパイルエラーを避けたい場合や、文字列を複製する必要性のない場合には使用を避けるべきでしょう。
複合型における所有権
スカラー型では、その細かな型の違い(浮動小数点型、文字型)にかかわらず、所有権の複製は整数型と同様に動作します。では複合型ではどうでしょうか。次のソースコードを見てみましょう。
// スカラー型のみの配列 let sa = [1, 3, 2]; let sb = sa; // スカラー型とString型の配列 let ca = [String::from("a"), String::from("b")]; let cb = ca; println!("sa[0]は{}、sb[0]は{}です。", sa[0], sb[0]); (1) println!("ca[0]は{}、cb[0]は{}です。", ca[0], cb[0]); (2)
上記のソースコードは(2)の箇所で、所有権を持たない変数caを使用するためコンパイルエラーになります。(1)は問題ありません。配列saはスカラー型の要素で初期化していますが、配列caはString型の要素で初期化していますので、配列自体はString型配列となり、String型と同様に代入では所有権の移動になります。つまり、配列では何型の配列か次第で所有権の振る舞いが変わると言えます。
タプルも同様のことが言えます。タプルの値のいずれかがスカラー型でない場合、代入における所有権の移動が起きるので、タプルそのものに所有権が移動します。スカラー型だけなら所有権は複製となります。
// スカラー型のみのタプル let sa = (1, 3, 2); (1) let sb = sa; // スカラー型とString型の配列 let ca = (1, String::from("b")); (2) let cb = ca; println!("sa[0]は{}、sb[0]は{}です。", sa.0, sb.0); println!("ca[0]は{}、cb[0]は{}です。", ca.0, cb.0);
上記のソースコードでは、(2)の箇所で宣言しているタプルがスカラー型とString型の混合なので、所有権の移動が起きます。そのためその後使用するとエラーになります。(1)の箇所で宣言されているタプルはスカラー型のみなので問題ありません。
関数の呼び出しと所有権
ここまで、変数の代入(初期化)による所有権の複製と移動を解説しましたが、関数の呼び出しでも所有権は複製され、移動します。
スカラー型を扱う関数の場合
スカラー型を引数にとる、戻り値として返す関数は、所有権が複製されるため動作は単純です。従来のプログラミング言語の関数呼び出しや戻り値の受け取りと感覚は基本的に同一です。例として次のソースコードを見てみましょう。
fn main() { let x = 100; let y = function_copy(x); // 整数型なので値が複製される println!("xは{}、yは{}です。", x, y); // 「xは100、yは100です。」 } // 引数を出力してそのまま返す関数 fn function_copy(a: i32) -> i32 { println!("function_copy: 引数aの値は{}です。", a); // 「function_copy: 引数aの値は100です。」 a }
変数xは整数型なので、function_copy()関数に渡す際に値はコピーされます。よって所有権は複製されます。function_copy()関数の方では引数aを自由に使用し、関数終了(スコープから抜ける)と同時に破棄しますが、戻り値として値が変数yにコピーされて、ここでまた所有権が複製されます。所有権は複製されているので、function_copy()関数を呼び出した後も変数xは有効で、println!にて変数yの値とともに問題なく出力できます。
文字列型を扱う戻り値のない関数の場合
次に、所有権の移動が起きるString型を扱う関数を見てみましょう。
fn main() { let s = String::from("Hello, world!"); function_move(s); // String型なのでムーブになる println!("sは「{}」です。", s); } fn function_move(m: String) { println!("function_move: 引数mの値は「{}」です。", m); } // mはここで廃棄される
このコードをコンパイルするとコンパイルエラーになります。main()関数において変数sがString型として初期化され、function_move()関数に渡されていますが、変数sはString型であるため、変数sが持っていた所有権はfunction_move()の引数である変数mに移動します。function_move()内では変数mの内容を出力しますが、関数終了に伴って変数mを破棄します。つまり、呼び出し元から渡された文字列のためのメモリ領域はここで解放されます。
function_move()関数の呼び出しで、変数sは所有権を失っているので、関数呼び出し後に変数sを使おうとしてコンパイルエラーになるのです。function_move()関数に変数sを渡した後も使い続けたい場合はString型の値を複製するか、次に示すように関数の戻り値で受け取るなどする必要があります。
文字列型を扱う戻り値のある関数の場合
String型の戻り値のある関数も見てみましょう。以下のソースコードを見てください。
fn main() { let mut s = String::from("Hello, world!"); s = function_move(s); println!("sは「{}」です。", s); // 「sは「Hello, world!」です。」 } fn function_move(m: String) -> String { println!("function_move: 引数mの値は「{}」です。", m); // 「function_move: 引数mの値は「Hello, world!」です。」 m }
関数が値を返すようにして、呼び出した側では戻り値を元の変数に代入しています。function_move()関数の呼び出しで所有権が移動しますが、function_move()関数では引数mの値を出力したあと、そのまま引数mを返しています。返す際に所有権が移動するので、function_move()関数から抜けるときに値の破棄は行われません。
つまり、引数に渡して失われた所有権が戻ってくることを意味しています。所有権がどこか別に移動したままなのが困る場合は、戻り値を変数に代入しておく必要があります。
まとめ
今回は、Rustの持つメモリ安全性を実現する仕組みである所有権を解説しました。Rustの内部実装的な話もあり難易度が高かったかも知れませんが、メモリの利用にさまざまな制約を付与することで、メモリ安全を確保していることが実感いただけたと思います。次回は、制約の中での利便性を向上させる借用と参照を解説します。
筆者紹介
WINGSプロジェクト
有限会社 WINGSプロジェクトが運営する、テクニカル執筆コミュニティー(代表山田祥寛)。主にWeb開発分野の書籍/記事執筆、翻訳、講演等を幅広く手掛ける。2021年10月時点での登録メンバーは55人で、現在も執筆メンバーを募集中。興味のある方は、どしどし応募頂きたい。著書、記事多数。
・サーバーサイド技術の学び舎 - WINGS(https://wings.msn.to/)
・RSS(https://wings.msn.to/contents/rss.php)
・Twitter: @yyamada(https://twitter.com/yyamada)
・Facebook(https://www.facebook.com/WINGSProject)
Copyright © ITmedia, Inc. All Rights Reserved.
関連記事
- なぜわざわざ学習コストを払ってまでRustを採用するのか? Webエンジニア目線でRustを考察
Web開発者としての興味、関心に基づきRustを端的に紹介し、その強みや弱みについて理解を深める本連載。第1回では、Rustを採用するモチベーションとは何かを整理、考察します。 - 実装言語を「Go」から「Rust」に変更、ゲーマー向けチャットアプリ「Discord」の課題とは
ゲーマー向けチャットアプリケーション「Discord」では、基盤サービスの一つである「Read States」が十分に高速化できない問題が明らかになった。開発チームは既存のコードをさらに改善することで対応しようとした。だが、Rust言語で再実装したところ、最適化を施す以前からパフォーマンスが向上した。なぜだろうか。開発チームがその理由を語る。 - 「Rust」言語はCよりも遅いのか、研究者がベンチマーク結果を解説
ミュンヘン工科大学の研究チームのメンバーはRust言語で開発したネットワークデバイスドライバの処理速度をC言語のものと比較した。その結果、Rust版の速度低下は最大でも数%にとどまっていた。なぜ処理性能がわずかに遅くなるのか、その理由も説明した。