ヒープのデータを指す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);
}

リスト15-1: ボックスを使用してi32の値をヒープに格納する

変数bを定義してBoxの値を保持します。Boxは値5を指し、値5はヒープに確保されています。このプログラムは、b = 5と出力するでしょう。つまりこの場合、このデータがスタックにあるのと同じような方法でボックスのデータにアクセスできます。 所有された値と全く同じでスコープを抜けるとき、実際bmainの終わりで抜けるのですが、 ボックスはメモリから解放されます。メモリの解放は(スタックに格納されている)ボックスと(ヒープに格納されている)指しているデータに対して起きます。

ヒープに単独の値を置いても嬉しいことはほとんどないので、このように単独でボックスを使用することはあまりありません。 単独の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,
}

リスト15-2: i32値のコンスリストデータ構造を表すenumを定義する最初の試行

注釈: この例のために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)));
}

リスト15-3: List enumを使用してリスト1, 2, 3を格納する

最初のCons値は、1と別のList値を保持しています。このList値は別のCons値で、 2とまた別のList値を保持しています。このList値はまたまた別のCons値で、 3List値を保持していますが、この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`を表現可能にしてください)

リスト15-4: 再帰的なenumを定義しようとすると得られるエラー

エラーは、この型は「無限のサイズである」と表示しています。理由は、再帰的な列挙子を含む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::Movei32値を2つ格納するのに十分な領域が必要、などと確かめます。 ただ1つの列挙子しか使用されないので、Message値一つが必要とする最大の領域は、 最大の列挙子を格納するのに必要になる領域です。

これをコンパイラがリスト15-2のList enumのような再帰的な型が必要とする領域を決定しようとする時に起こることと比較してください。 コンパイラはCons列挙子を見ることから始めます。この列挙子には、型i32値が一つと型Listの値が一つ保持されます。 故に、Consは1つのi32Listのサイズに等しい領域を必要とします。Listが必要とするメモリ量を計算するのに、 コンパイラはCons列挙子から列挙子を観察します。Cons列挙子は型i32を1つと型Listの値1つを保持し、 この過程は無限に続きます。図15-1のようにですね。

無限のコンスリスト

図15-1: 無限のCons列挙子からなる無限のList

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))))));
}

リスト15-5: 既知のサイズにするためにBox<T>を使用するListの定義

Cons列挙子は、1つのi32のサイズに加えてボックスのポインタデータを格納する領域を必要とするでしょう。 Nil列挙子は値を格納しないので、Cons列挙子よりも必要な領域は小さいです。これで、 どんなList値もi321つのサイズに加えてボックスのポインタデータのサイズを必要とすることがわかりました。 ボックスを使うことで無限に続く再帰の連鎖を断ち切ったので、コンパイラはList値を格納するのに必要なサイズを計算できます。 図15-2は、Cons列挙子の今の見た目を示しています。

有限のコンスリスト

図15-2: ConsBoxを保持しているので、無限にサイズがあるわけではないList

ボックスは、間接参照とヒープメモリ確保だけを提供します。他のスマートポインタ型に見られるような別の特別な能力は何もありません。 これらの特別な能力が招くパフォーマンスのオーバーヘッドもないので、 コンスリストのように間接参照だけが必要な機能である場合には便利でしょう。 より多くのボックスのユースケースは第17章でもお見かけするでしょう。

Box<T>型がスマートポインタなのは、Derefトレイトを実装しているからです。 このトレイトによりBox<T>の値を参照のように扱うことができます。 Box<T>値がスコープを抜けると、ボックスが参照しているヒープデータも片付けられます。これはDropトレイト実装のおかげです。 これら2つのトレイトをより詳しく探究しましょう。これら2つのトレイトは、他のスマートポインタ型が提供する機能にとってさらに重要なものです。それらついてはこの章の残りで議論します。