最終更新日:2023/10/21 原本2022-03-25

基本からしっかり学ぶRust入門(9):コレクションとジェネリクス―Rustのジェネリクスをベクターで理解する

Rustについて基本からしっかり学んでいく本連載。第9回は、Rustのコレクションとジェネリクスについて。


連載:基礎からしっかり学ぶRust入門

 本連載のサンプルコードをGitHubで公開しています。こちらからダウンロードしてみてください。具体的な利用方法は連載第1回を参考にしてください。


 コレクションは、モダンなプログラミング言語では必須のユーティリティーです。本連載第9回では、コレクションの種類(ベクター、ハッシュマップ、文字列)を紹介し、コレクションには欠かせないジェネリクスも紹介します。

コレクションとは?

 コレクション(collection)とは、文字通り複数の要素を集めたデータ構造を言います。要素を収納することからコンテナとも呼ばれます。第2回で取り上げた配列もコレクションと言えますが、Rustでは以下のものをコレクションとしています。

 コレクションは、配列に比べると要素の追加や削除が容易で、要素のアクセスも添え字に限らないなどの柔軟性を備えます。まずは、Rustの備える3つのコレクションについて、概要をあらかじめまとめておきます。

ベクター

 ベクター(Vector)は、同じデータ型の値を複数持つことのできるコレクションです。他の言語ではリストと呼ばれることもあります。同じデータ型というのがポイントで、データ型を混在させることはできません。また、値の順序関係が維持されます。

 配列では要素数が固定されていて、領域の余剰と不足という問題が常にありましたが、ベクターを用いることで可変長のリストを簡単に作成できます。ベクターでは、初期値を用いた変数宣言、値の追加と破棄、値の参照、値の挿入と削除などが基本的な操作になります。


ベクターのイメージ

文字列

 文字列はこれまでも散々出てきましたが、実はこれもコレクションです。データ型がu8(符号なし8ビット整数)であるベクターのラッパーが文字列型、すなわちString型です。String型では、UTF-8エンコードされた文字列を8bitすなわちバイト列として保持しています。

 ベクターのラッパーであるので、ベクターと同様のメソッドを備えており、値の追加と取り出し、値の参照、値の挿入と削除といった操作を可能にしています。ただし、UTF-8エンコードされた文字列の操作には文字の境界を意識する必要があり、ベクターほど単純ではありません。

【補足】UTF-8エンコード

 UTF-8とは、Unicode文字体系におけるエンコーディング方式の1つです。従来のASCII文字(7ビット文字)はそのまま1バイトで表現し、残りの文字を2~6バイトで表現します。例えば、平仮名は3バイトで表現されます。ASCII文字との親和性が高いため、インターネットの世界を中心に多く使われています。UTF-8は可変長のエンコーディング方式であるため、文字列の途中を見てもそれが文字の始まりなのか、そうでないのか見分けることは困難です。これが、文字列の操作をベクターほど単純なものにはできない理由です。

ハッシュマップ

 ハッシュマップ(HashMap)は、キーと値の組み合わせのリストで、他の言語では単にマップ、もしくはハッシュテーブル、連想配列とも呼ばれます。キーに対するキーと値のメモリ上の位置は、ハッシュ関数という仕組みを用いて決められることから、このような名称になっています。

 ベクターと同様に、キーのデータ型、値のデータ型は、ひとつのハッシュマップでは同一の必要があります。ベクターが順序関係を維持した値の保持に用いられるのに対し、ハッシュマップではキーによって一対一に対応する値の組み合わせを保持するのに用います。

 キーによって値が呼び出されるため、順序は意味を持たず、例えば値の追加順を想定した操作は行うことができません。その替わり、キーがあれば対応する値を即座に呼び出せるので、ランダムアクセスに優れています。

 ハッシュマップでは、初期値を用いた変数宣言、値の追加と削除、値の参照などが基本的な操作になります。


ハッシュマップのイメージ

 ベクターとハッシュマップには異なる特性があるので、それぞれの長所を生かしながら適材適所で利用する必要があります。

【補足】コレクションのメモリ管理

 配列のためのメモリはスコープに応じて静的な領域やスタック上に確保されますが、コレクションのためのメモリはヒープ上に確保されます。そのため、動的な増減が容易になっていますが、パフォーマンスは配列に比べて若干不利になります。データの目的や量を見極めて、配列で済むのか、コレクションを使うべきなのか、検討しましょう。

ジェネリクスとは?

 コレクションを扱う際には避けて通れないのがジェネリクス(generics)です。ジェネリクスとは、一般的な、総称的なという意味で、プログラミング言語の世界では同一のプログラムコードで異なるデータ型の処理を可能にする仕組みを言います。

 ジェネリクスは、Javaをはじめとしてモダンなプログラミング言語では必須の機能として実装されています。C++でも、ジェネリクスに相当する機能をテンプレートとして実装しています。ジェネリクスはコレクションでよく使われますが、コレクション専用というわけではなく、データ型を抽象化してコードの再利用を容易にする仕組みとして広く利用されています。

 コレクションでは複数の要素を収納しますが、そのデータ型はさまざまであることが普通です。例えば、整数型のリストと浮動小数点型のリストがあるとき、それらが備えるべきメソッドなどが全く同じであるにもかかわらず、異なる構造体(とメソッド)として実装しなければならないとしたら、効率が良いとはいえません。ジェネリクスでデータ型を抽象化し、データ型に基づいてリストを処理できれば効率的といえるでしょう。


ジェネリクス

ジェネリック型を利用する

 Rustのコレクションは、このジェネリクスを使っていますので、異なるデータ型のコレクションを1つのコレクション型にまとめています。例えば、ベクターはVec、ハッシュマップはHashMapです。これらが扱うデータ型は、コレクション型に型パラメーターを付け加えて指定します。

ベクター
Vec<i32>	Vecがi32型を扱う
Vec<i64>	Vecがf64型を扱う
ハッシュマップ
HashMap<String, i32>	HashMapがキーにString型、値にi32型を扱う
HashMap<i32, String>	HashMapがキーにi32型、値にString型を扱う

 型パラメーターとは、このように山かっこ(< >)で挟んでデータ型を列挙したものをいいます。実際については後述しますが、HashMapではキーと値という2つの要素を組で扱うため、型パラメーターも2つになるのです。

 このようなジェネリクスによるデータ型は、ジェネリック型と呼ばれます。ジェネリック型は、型を使用するあらゆる場面で登場します。例えば、変数の宣言、関数(メソッド)の定義、構造体(enum型を含む)の定義などです。このうち、変数の宣言すなわちジェネリック型の利用については、今回で取り上げていきます。関数、構造体などの定義については連載の後の回でトレイトとともに取り上げたいと思います。

 なお、ジェネリック型の定義では個別の型は用いずに、抽象化された型パラメーターを用います。これらは、<T>, <K, V>, <V, E>などと記述します。それぞれ、型を表すtype、キーを表すkey、値を表すvalue、エラーを表すerrorから来る型名で、他の言語も含めて同様であることが多くなっています。例えば、ベクター型は値のデータ型を指定するのでVec<T>、ハッシュマップ型はキーと値のデータ型を指定するのでHashMap<K, V>と記述します。

【補足】型引数と型パラメーター

 Javaでは、定義時に用いられるTなどは型パラメーター(type parameter)、指定時に用いられるIntegerなどは型引数(type argument)と、明確に区別されています。メソッドの引数における仮引数、実引数と同じ関係です。ただ、Rustのドキュメントではこれらをtype parameterと特に区別なく表記しているので、本連載でも型パラメーターという呼称を使用することにします。

ベクター

 ここから、具体的なコレクションの操作に入っていきます。今回はベクターを紹介します。

ベクター変数を宣言する

 ベクター変数の宣言は、例えば以下のようになります。値の型(i32)を指定するために型パラメーターを指定しています。

let v: Vec<i32> = Vec::new();

 ここでは、変数vとしてVec<i32>型のインスタンスを生成しています。このように、ベクターはVec型を型パラメーターとともに指定し、構造体を生成して使うことになります。ここで、型注釈を使用して型を明示しているのに注意してください。Vec型のnew()メソッドを呼び出しただけではベクターの中身は空なので、値の型を特定できないからです。型注釈を省略するには、次で紹介するpush()メソッドなどでデータ型を明確にするか、次のvec!マクロを使った初期化記法を用います。

vec!マクロによる初期値の設定

 初期値のあるベクターを宣言するなら、vec!マクロを使うのが簡便です。vec!マクロを使うと、配列リテラルをそのままベクターの初期値として使用できます。初期値から型推論を行うので、ジェネリクス型の指定も不要です。

let months = vec!["January", "February", "March", "April"];
println!("Count: {}, {:?}", months.len(), months);
  // Count: 4, ["January", "February", "March", "April"]
src/bin/vector_macro.rs

ベクターに値を追加する

 ベクターは動的なデータ構造なので、初期化後に値を追加できます。ベクターへの値の追加は、push()メソッドで行います。値の追加は、ベクターの最後尾に対して行われますので、追加順は常に保持されています。以下の例は、初期値のあるベクターにあとから4個の値を追加しています。

let mut months = Vec::new();    (1)
months.push("January");
months.push("February");
months.push("March");
months.push("April");
println!("Count: {}, {:?}", months.len(), months);      (2)
  // Count: 4, ["January", "February", "March", "April"]
src/bin/vector_push.rs

 (1)では、ベクターの初期化に型注釈がないことに注意してください。続くpush()メソッドの呼び出しで引数に文字列リテラルが与えられているため、ベクターの型も自動的に文字列リテラルへの参照(&str)になります。

 (2)では、len()メソッドでベクターの要素数を取得しています。println!()のフォーマット引数{:?}は、ベクターのDisplayトレイトを用いてベクターの値を一覧表示するという指示です。

【補足】文字列をベクターに入れるとき

 数値型などでは問題になりませんが、String型のように代入などで所有権の移動を伴うデータ型の場合、ベクターに入れるときには要注意です。まずは、ベクターは収納する値のデータ型は同一でなければなりませんので、文字列リテラル(&str)とString型は混在できません(これらの違いについては第5回を参照)。ベクターに文字列リテラルとString型を同時に収納したいときには、文字列リテラルをString::from()メソッドでString型として扱う必要があります。また、所有権が移動することにも注意が必要です。所有権を移動させたくないときは、文字列への参照(&String)のベクターとするとよいでしょう。このとき以下のように、String型の参照変数をいったん作成してからベクターへ追加することに注意してください。

let mut months: Vec<&String> = Vec::new();
let jan = &String::from("January");
months.push(jan);
let feb = &String::from("February");
months.push(feb);
…略…

ベクターの値を参照する

 ベクターに収納されている値の参照は、2通りの方法で行うことができます。ひとつは配列と同様に添え字を使って要素を指定するする方法、もうひとつはget()メソッドを使う方法です。これらはどちらを使っても同じということはなく、エラーの判定などが必要かといった状況に応じた使い分けが必要です。

 添え字を使う方法はとてもシンプルで、配列と同様にブラケット([ ])を使って添え字を指定します。添え字は、0からはじまる整数であるのも同じです。0なら1番目、1なら2番目の値が参照されます。

let mut months = Vec::new();
months.push("January");
months.push("February");
months.push("March");
months.push("April");
let month = &months[1]; 
println!("今月は {}", month);       // 今月は February
src/bin/vector_ref_index.rs

 ここでベクターの要素から参照を取得していることに注意してください。この場合は不変参照で、すぐにその参照を使用してブロックも終了していますから、こう書かなければならないというわけではありません。しかし、参照が有効なときにベクターを以下の例のように変更しようとすると、コンパイルエラーとなります。

let month = &months[1];         // 参照が有効になる
months.push("May");             // ベクターに値を追加する
println!("今月は {}", month);   // 参照を使用する
src/bin/vector_ref_index_mod.rs
error[E0502]: cannot borrow `months` as mutable because it is also borrowed as immutable
 --> src/bin/vector_ref_index_mod.rs:8:5
  |
7 |     let month = &months[1]; 
  |                  ------ immutable borrow occurs here
8 |     months.push("May");
  |     ^^^^^^^^^^^^^^^^^^ mutable borrow occurs here
9 |     println!("今月は {}", month);
  |                           ----- immutable borrow later used here

 不変の借用の発生中に、可変の借用が発生した、というわけです。これは第6回で紹介したように参照ないし借用のルール違反です。ベクターでは、値の追加時にメモリ領域の移動が起きることがあり、こうなるとすでにある参照が無効な参照になってしまいます。このようなことにならないように、ボローチェッカーがコンパイル時に指摘してくれるのです。

 また、参照変数に代入するベクターの値を範囲外にしてみると、Panicとなります。長さが4なのに添え字が5だったというわけです。

let month = &months[5];        // 添え字が範囲外
println!("今月は {}", month);
src/bin/vector_ref_index_out.rs
thread 'main' panicked at 'index out of bounds: the len is 4 but the index is 5', src/bin/vector_ref_index_out.rs:7:27

 get()メソッドを使う方法は、添え字のブラケットの代わりにget()を使うだけで、0からの整数を引数に指定することは変わりません。ただし、添え字では値そのものが返ってきたのに対し、get()メソッドではOption<T>型(これもジェネリック型です)の値が返ってくることが異なります。Option<T>型は、第8回に取り上げたResult<T, E>型と同様に列挙型であり、メソッドの戻り値が複数のデータ型となり得る場合のラッパーです。そのため、値の取り出しにはunwrap()メソッドを用いています。

println!("今月は {}", months.get(2).unwrap());  // 今月は February
src/bin/vector_ref_get.rs

 Option<T>型では、有効な値がない場合はNone列挙子を、そうでない場合はSome(&elem)列挙子を保持します(&elemは値への参照)。つまり、戻り値を利用したエラー判定を行うことができます。第8回で触れたように、unwrap()メソッドによって正常時には値そのものが返り、エラー時にはpanic!()が呼び出されます。

ベクターの値を走査する

 ベクターにある値を全て取り出すには、配列と同様にfor……in文を使用します。以下の例では、for……in文を使用してベクターの値を全部表示しています。

for month in &months {
    print!("{} ", month);
}
// January February March April
src/bin/vector_scan.rs

 ここでも、ベクター変数には参照を用いることに注意してください。参照を用いないと、ループ内でベクターの内容が変更されてもそれを検知できません。なお、上記で用いたのは不変の参照ですが、これを可変の参照にするとベクターの値を書き換えることができます。以下の例は、可変の参照を用いて全てのベクターの値を大文字に変換しています。

months.push(String::from("January"));
…中略…
for month in &mut months {
    *month = month.to_uppercase();
}
println!("{}", months[0]);
// JANUARY
src/bin/vector_scan_mod.rs

 ここでは、ベクターの値を変更するために型を&strではなくStringにしています。String型のto_uppercase()メソッドで大文字に変換していますが、その結果を代入するのに参照外し演算子(*)を用いていることに注意してください。Rustでは、参照を使ったメソッドの呼び出しなどでは参照外し演算子がなくても自動的に参照外しが行われますが、この例のように参照を使って値を書き換えるといった場合には、明示的に参照外しをする必要があります。

ベクターの値を取り出して削除する

 ベクターの値を取り出して、その値を削除するにはpop()メソッドを用います。値を追加するpush()メソッドの対となるメソッドです。これらのメソッドを使うと、ベクターをFILO(First In Last Out)形式であるスタックのように扱うことができます。取り出した値の型は上記のOption<T>型ですので、戻り値に基づくエラー処理やunwrap()メソッドの使用については同様となります。

println!("取り出したのは {} で要素数は {}", months.pop().unwrap(), months.len());
  // 取り出したのは April で要素数は 3
src/bin/vector_pop.rs

 pop()メソッドの呼び出しで、ベクターの要素数が減っていることを確認できます。

ベクターに値を挿入、削除する

 push()/pop()メソッドではFILO形式(スタック)のように値の追加や取り出しを行っていましたが、リストの途中に挿入したり、途中の要素を削除するにはそれぞれinsert()メソッド、remove()メソッドを使用します。insert()メソッドには挿入位置(0からの整数値)と値を引数に与えます。戻り値はなく、挿入位置がベクターの大きさを超えた場合にはpanicとなります。remove()メソッドには削除位置(0からの整数値)を引数に与えます。戻り値は削除した値で、削除位置がベクターの大きさを超えた場合にはinsert()メソッドと同様にpanicとなります。

let mut months = Vec::new();
months.push("January");         // 0
months.push("March");           // 1
months.push("April");           // 2
months.insert(1, "February");   (1)
println!("Count: {}, {:?}", months.len(), months);
  // Count: 4, ["January", "February", "March", "April"]
months.remove(0);               (2)
println!("Count: {}, {:?}", months.len(), months);
  // Count: 3, ["February", "March", "April"]
src/bin/vector_insert_remove.rs

 (1)では、位置1すなわち“March”の位置に“February”を挿入しています。(2)では、先頭の値すなわち“January”を削除しています。要素数の増減と一覧の内容を確認してください。

まとめ

 今回は、データ型を抽象化するジェネリクスと、ジェネリクスを生かしたデータ型でコレクションの1つでもあるベクターを紹介しました。

 次回は、今回紹介できなかったコレクションとして、文字列とハッシュマップを紹介します。