ヒープのデータを指すBox<T>
を使用する
最も素直なスマートポインタはボックスであり、その型はBox<T>
と記述されます。
ボックスにより、スタックではなくヒープにデータを格納することができます。スタックに残るのは、
ヒープデータへのポインタです。スタックとヒープの違いを再確認するには、第4章を参照されたし。
ボックスは、データをスタックの代わりにヒープに格納する以外は、パフォーマンスのオーバーヘッドはありません。 しかし、特別な能力がたくさんあるわけでもありません。以下のような場面で最もよく使われるでしょう。
- コンパイル時にはサイズを知ることができない型があり、正確なサイズを要求する文脈でその型の値を使用する時
- 多くのデータがあり、その所有権を移したいが、その際にデータがコピーされないようにしたい時
- 値を所有する必要があり、特定の型であることではなく、特定のトレイトを実装する型であることのみ気にかけている時
「ボックスで再帰的な型を可能にする」節で1つ目の場合について実際に説明します。 2番目の場合、多くのデータの所有権を転送するには、データがスタック上でコピーされるので、長い時間がかかり得ます。 この場面でパフォーマンスを向上させるために、多くのデータをヒープ上にボックスとして格納することができます。 そして、小さなポインタのデータのみがスタック上でコピーされる一方、それが参照しているデータはヒープ上の1箇所に留まります。 3番目のケースはトレイトオブジェクトとして知られています。第17章の「トレイトオブジェクトで異なる型の値を許容する」の節は、 すべてその話題に捧げられています。 従って、ここで学ぶことは第17章でもまた使うことになります!
Box<T>
を使ってヒープにデータを格納する
Box<T>
のこのユースケースを議論する前に、Box<T>
の記法と、Box<T>
内に格納された値を読み書きする方法について講義しましょう。
リスト15-1は、ボックスを使用してヒープにi32
の値を格納する方法を示しています。
ファイル名: src/main.rs
fn main() { let b = Box::new(5); println!("b = {}", b); }
変数b
を定義してBox
の値を保持します。Box
は値5
を指し、値5
はヒープに確保されています。このプログラムは、b = 5
と出力するでしょう。つまりこの場合、このデータがスタックにあるのと同じような方法でボックスのデータにアクセスできます。
所有された値と全く同じでスコープを抜けるとき、実際b
はmain
の終わりで抜けるのですが、
ボックスはメモリから解放されます。メモリの解放は(スタックに格納されている)ボックスと(ヒープに格納されている)指しているデータに対して起きます。
ヒープに単独の値を置いても嬉しいことはほとんどないので、このように単独でボックスを使用することはあまりありません。
単独のi32
のような値はデフォルトではスタックに置かれます。ほとんどの場合ではその方が適切です。
ボックスのおかげで定義できるようになる型を見てみましょう。ボックスがなければそれらの型は定義できません。
ボックスで再帰的な型を可能にする
コンパイル時にコンパイラが知っておかねばならないのは、ある型が占有する領域の大きさです。コンパイル時にサイズがわからない型の1つ として 再帰的な型があります。この型の値は、値の一部として同じ型の他の値を持つ場合があります。値のこうしたネストは、理論的には無限に続く可能性があるので、コンパイラは再帰的な型の値が必要とする領域を知ることができないのです。 しかしながら、ボックスのサイズはわかっているので、再帰的な型の定義にボックスを挟むことで再帰的な型を作ることができます。
コンスリストは関数型プログラミング言語では一般的なデータ型ですが、これを再帰的な型の例として探究しましょう。 我々が定義するコンスリストは、再帰を除けば素直です。故に、これから取り掛かる例に現れる概念は、 再帰的な型が関わるもっと複雑な場面に遭遇したときには必ず役に立つでしょう。
コンスリストについてもっと詳しく
コンスリストは、Lispプログラミング言語とその方言に由来するデータ構造です。Lispでは、
cons
関数("construct function"の省略形です)は2つの引数から新しいペアを構成します。
この引数は通常、単独の値と別のペアからなります。これらのペアを含むペアがリストをなすのです。
cons関数という概念は、より一般的な関数型プログラミングの俗語にもなっています。"to cons x onto y"はコンテナyの先頭に要素xを置くことで新しいコンテナのインスタンスを生成することを意味します。
コンスリストの各要素は、2つの要素を含みます。現在の要素の値と次の要素です。リストの最後の要素は、
Nil
と呼ばれる値だけを含み、次の要素を持ちません。コンスリストは、繰り返しcons
関数を呼び出すことで生成されます。
繰り返しの基底ケースを示すのに標準的に使われる名前はNil
です。これは第6章の"null"や"nil"の概念とは異なることに注意してください。
"null"や"nil"は、無効だったり存在しない値です。
関数型プログラミング言語ではコンスリストは頻繁に使われますが、Rustではあまり使用されないデータ構造です。
Rustで要素のリストがあるときはほとんど、Vec<T>
を使用するのがよりよい選択になります。
より複雑な他の再帰的なデータ型は様々な場面で役に立ちます。しかしコンスリストから始めることで、
ボックスのおかげで再帰的なデータ型を定義できるわけを、あまり気を散らすことなく調べることができるのです。
リスト15-2には、コンスリストのenum定義が含まれています。このコードはまだコンパイルできないことに注意してください。
List
型のサイズが分からないからです。
これについてはこの後説明します。
ファイル名: src/main.rs
enum List {
Cons(i32, List),
Nil,
}
注釈: この例のために
i32
値だけを保持するコンスリストを実装します。第10章で議論したように、 ジェネリクスを使用してどんな型の値も格納できるコンスリストを定義して実装することもできたでしょう。
このList
型を使用してリスト1, 2, 3
を格納すると、リスト15-3のコードのような見た目になるでしょう。
ファイル名: src/main.rs
use List::{Cons, Nil};
fn main() {
let list = Cons(1, Cons(2, Cons(3, Nil)));
}
最初のCons
値は、1
と別のList
値を保持しています。このList
値は別のCons
値で、
2
とまた別のList
値を保持しています。このList
値はまたまた別のCons
値で、
3
とList
値を保持していますが、このList
値でついにNil
になります。Nil
はリストの終端を通知する非再帰的な列挙子です。
リスト15-3のコードをコンパイルしようとすると、リスト15-4に示したエラーが出ます。
error[E0072]: recursive type `List` has infinite size
(エラー: 再帰的な型`List`は無限のサイズです)
--> src/main.rs:1:1
|
1 | enum List {
| ^^^^^^^^^ recursive type has infinite size
2 | Cons(i32, List),
| ----- recursive without indirection
|
= help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
make `List` representable
(助言: 間接参照(例: `Box`、`Rc`、あるいは`&`)をどこかに挿入して、`List`を表現可能にしてください)
エラーは、この型は「無限のサイズである」と表示しています。理由は、再帰的な列挙子を含むList
を定義したからです。
つまり、List
は自身の別の値を直接保持しているのです。結果として、コンパイラはList
値を格納するのに必要な領域が計算できません。
このエラーが出た理由を少し噛み砕きましょう。まず、非再帰的な型の値を格納するのに必要な領域をどうコンパイラが決定しているかを見ましょう。
非再帰的な型のサイズを計算する
第6章でenum定義を議論した時にリスト6-2で定義したMessage
enumを思い出してください。
#![allow(unused)] fn main() { enum Message { Quit, Move { x: i32, y: i32 }, Write(String), ChangeColor(i32, i32, i32), } }
Message
値一つにメモリを確保するために必要な領域を決定するために、コンパイラは、
各列挙子を見てどの列挙子が最も領域を必要とするかを確認します。コンパイラは、
Message::Quit
は全く領域を必要とせず、Message::Move
はi32
値を2つ格納するのに十分な領域が必要、などと確かめます。
ただ1つの列挙子しか使用されないので、Message
値一つが必要とする最大の領域は、
最大の列挙子を格納するのに必要になる領域です。
これをコンパイラがリスト15-2のList
enumのような再帰的な型が必要とする領域を決定しようとする時に起こることと比較してください。
コンパイラはCons
列挙子を見ることから始めます。この列挙子には、型i32
値が一つと型List
の値が一つ保持されます。
故に、Cons
は1つのi32
とList
のサイズに等しい領域を必要とします。List
が必要とするメモリ量を計算するのに、
コンパイラはCons
列挙子から列挙子を観察します。Cons
列挙子は型i32
を1つと型List
の値1つを保持し、
この過程は無限に続きます。図15-1のようにですね。
Box<T>
で既知のサイズの再帰的な型を得る
コンパイラは、再帰的に定義された型に必要なメモリ量を計算できないので、リスト15-4ではエラーを返します。 しかし、エラーにはこんな役立つ提案が含まれているのです。
= help: insert indirection (e.g., a `Box`, `Rc`, or `&`) at some point to
make `List` representable
この提案において「間接参照」は、値を直接格納するのではなく、データ構造を変更して値を間接的に格納することを意味します。これは値の代わりに値へのポインタを格納することによって可能になります。
Box<T>
はポインタなので、コンパイラにはBox<T>
が必要とする領域が必ずわかります。すなわち、ポインタのサイズは指しているデータの量に左右されません。つまり、別のList
値を直接置く代わりに、
Cons
列挙子の中にBox<T>
を配置することができます。Box<T>
は、
Cons
列挙子の中ではなく、ヒープに置かれる次のList
値を指します。概念的には、
依然として我々のリストは他のリストを「保持する」リストによって作られたものです。
しかし、今やこの実装は、要素をお互いの中に配置するというより、隣り合うように配置するような感じになります。
リスト15-2のList
enumの定義とリスト15-3のList
の使用をリスト15-5のコードに変更することができ、
これはコンパイルが通ります。
ファイル名: src/main.rs
enum List { Cons(i32, Box<List>), Nil, } use List::{Cons, Nil}; fn main() { let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil)))))); }
Cons
列挙子は、1つのi32
のサイズに加えてボックスのポインタデータを格納する領域を必要とするでしょう。
Nil
列挙子は値を格納しないので、Cons
列挙子よりも必要な領域は小さいです。これで、
どんなList
値もi32
1つのサイズに加えてボックスのポインタデータのサイズを必要とすることがわかりました。
ボックスを使うことで無限に続く再帰の連鎖を断ち切ったので、コンパイラはList
値を格納するのに必要なサイズを計算できます。
図15-2は、Cons
列挙子の今の見た目を示しています。
ボックスは、間接参照とヒープメモリ確保だけを提供します。他のスマートポインタ型に見られるような別の特別な能力は何もありません。 これらの特別な能力が招くパフォーマンスのオーバーヘッドもないので、 コンスリストのように間接参照だけが必要な機能である場合には便利でしょう。 より多くのボックスのユースケースは第17章でもお見かけするでしょう。
Box<T>
型がスマートポインタなのは、Deref
トレイトを実装しているからです。
このトレイトによりBox<T>
の値を参照のように扱うことができます。
Box<T>
値がスコープを抜けると、ボックスが参照しているヒープデータも片付けられます。これはDrop
トレイト実装のおかげです。
これら2つのトレイトをより詳しく探究しましょう。これら2つのトレイトは、他のスマートポインタ型が提供する機能にとってさらに重要なものです。それらついてはこの章の残りで議論します。