ジェネリックなデータ型
関数シグネチャや構造体のような項目の定義を作成し、それをさまざまな具体的なデータ型で使えるようにするために、ジェネリクスを使用します。まず、ジェネリクスを使って関数、構造体、列挙型、メソッドを定義する方法を見ていきます。次に、ジェネリクスがコードのパフォーマンスにどのような影響を与えるかを説明します。
関数定義において
ジェネリクスを使う関数を定義するときは、通常であれば引数や戻り値のデータ型を指定する関数のシグネチャに、ジェネリクスを配置します。そうすることで、コードがより柔軟になり、コードの重複を防ぎつつ、関数の呼び出し側により多くの機能を提供できます。
先ほどの largest 関数を続けて見ていきましょう。リスト10-4は、どちらもスライス内の最大の値を見つける2つの関数を示しています。続いて、これらをジェネリクスを使った1つの関数にまとめます。
fn largest_i32(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn largest_char(list: &[char]) -> &char {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest_i32(&number_list);
println!("The largest number is {result}");
assert_eq!(*result, 100);
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest_char(&char_list);
println!("The largest char is {result}");
assert_eq!(*result, 'y');
}
largest_i32 関数は、リスト10-3で切り出した、スライス内の最大の i32 を見つける関数です。largest_char 関数は、スライス内の最大の char を見つけます。関数本体のコードは同じなので、1つの関数にジェネリック型パラメータを導入することで重複をなくしましょう。
新しい1つの関数で型をパラメーター化するには、関数の値パラメータと同じように、型パラメータにも名前を付ける必要があります。型パラメータ名には任意の識別子を使えます。しかし、ここでは T を使います。というのも、Rust では慣例として、型パラメータ名は短く、しばしば1文字だけであり、Rust の型命名規則は UpperCamelCase だからです。型 を表す T は、ほとんどの Rust プログラマにとって定番の選択です。
関数本体でパラメータを使うときは、コンパイラがその名前の意味を理解できるように、シグネチャ内でそのパラメータ名を宣言しなければなりません。同様に、関数シグネチャ内で型パラメータ名を使うときも、それを使う前に型パラメータ名を宣言しなければなりません。ジェネリックな largest 関数を定義するには、次のように、関数名と引数リストの間にある山括弧 <> の中に型名の宣言を置きます。
fn largest<T>(list: &[T]) -> &T {
この定義は、「関数 largest は何らかの型 T に対してジェネリックである」と読みます。この関数には list という名前の引数が1つあり、これは型 T の値からなるスライスです。largest 関数は、同じ型 T の値への参照を返します。
リスト10-5は、シグネチャでジェネリックなデータ型を使った、統合後の largest 関数の定義を示しています。このリストでは、i32 値のスライスと char 値のスライスのどちらでもこの関数を呼び出せることも示しています。なお、このコードはまだコンパイルできません。
fn largest<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let result = largest(&number_list);
println!("The largest number is {result}");
let char_list = vec!['y', 'm', 'a', 'q'];
let result = largest(&char_list);
println!("The largest char is {result}");
}
このコードを今すぐコンパイルすると、次のエラーが出ます。
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0369]: binary operation `>` cannot be applied to type `&T`
--> src/main.rs:5:17
|
5 | if item > largest {
| ---- ^ ------- &T
| |
| &T
|
help: consider restricting type parameter `T` with trait `PartialOrd`
|
1 | fn largest<T: std::cmp::PartialOrd>(list: &[T]) -> &T {
| ++++++++++++++++++++++
For more information about this error, try `rustc --explain E0369`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
ヘルプテキストでは std::cmp::PartialOrd に触れています。これはトレイトであり、次の節でトレイトについて説明します。ここではひとまず、このエラーは largest の本体が T に取りうるすべての型に対しては動作しないことを示している、と理解してください。本体で型 T の値を比較したいので、使えるのは値を順序付けできる型だけです。比較を可能にするために、標準ライブラリには型に実装できる std::cmp::PartialOrd トレイトがあります(このトレイトの詳細は付録Cを参照してください)。リスト10-5を修正するには、ヘルプテキストの提案に従って、T に有効な型を PartialOrd を実装するものだけに制限できます。そうすればこのリストはコンパイルできるようになります。標準ライブラリは i32 と char の両方に PartialOrd を実装しているからです。
構造体定義において
<> 構文を使って、1つ以上のフィールドでジェネリック型パラメータを使用する構造体を定義することもできます。リスト10-6では、任意の型の x 座標値と y 座標値を保持する Point<T> 構造体を定義しています。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 5, y: 10 };
let float = Point { x: 1.0, y: 4.0 };
}
構造体定義でジェネリクスを使う構文は、関数定義で使ったものと似ています。まず、構造体名の直後の山括弧の中に型パラメータ名を宣言します。次に、通常であれば具体的なデータ型を指定する場所で、そのジェネリック型を構造体定義内に使用します。
Point<T> を定義するためにジェネリック型を1つしか使っていないため、この定義は Point<T> 構造体が何らかの型 T に対してジェネリックであり、フィールド x と y は どちらも その同じ型であることを意味します。リスト10-7のように、異なる型の値を持つ Point<T> のインスタンスを作成すると、コードはコンパイルされません。
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
この例では、整数値 5 を x に代入した時点で、この Point<T> のインスタンスにおいてジェネリック型 T は整数になることをコンパイラに伝えています。その後、y に 4.0 を指定すると、y は x と同じ型になるよう定義されているため、次のような型不一致エラーが発生します。
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integer, found floating-point number
For more information about this error, try `rustc --explain E0308`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
x と y がどちらもジェネリクスでありながら異なる型を取れる Point 構造体を定義するには、複数のジェネリック型パラメータを使えます。たとえばリスト10-8では、Point の定義を型 T と U に対してジェネリックなものに変更し、x は型 T、y は型 U としています。
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let both_integer = Point { x: 5, y: 10 };
let both_float = Point { x: 1.0, y: 4.0 };
let integer_and_float = Point { x: 5, y: 4.0 };
}
これで、ここに示した Point のインスタンスはすべて許可されます! 定義では必要なだけ多くのジェネリック型パラメータを使えますが、数が多すぎるとコードが読みにくくなります。コード内で多数のジェネリック型が必要になっているなら、それはコードをより小さな部品へ再構成する必要があることを示しているのかもしれません。
列挙型定義において
構造体で行ったのと同様に、列挙型でも、そのバリアント内にジェネリックなデータ型を保持するよう定義できます。第6章で使った、標準ライブラリが提供する Option<T> 列挙型をもう一度見てみましょう。
#![allow(unused)]
fn main() {
enum Option<T> {
Some(T),
None,
}
}
これで、この定義がより理解しやすくなったはずです。見てのとおり、
Option<T> 列挙型は型 T に対してジェネリックで、2つのバリアントを持っています。Some は型 T の値を1つ保持し、None バリアントは何の値も保持しません。
Option<T> 列挙型を使うことで、オプショナルな値という抽象的な概念を表現できます。また、Option<T> はジェネリックなので、オプショナルな値の型が何であってもこの抽象化を使えます。
列挙型は複数のジェネリック型も使えます。第9章で使った Result
列挙型の定義は、その一例です。
#![allow(unused)]
fn main() {
enum Result<T, E> {
Ok(T),
Err(E),
}
}
Result 列挙型は2つの型 T と E に対してジェネリックで、2つのバリアントを持ちます。
Ok は型 T の値を保持し、Err は型
E の値を保持します。この定義により、成功する可能性がある操作(何らかの型 T の値を返す)や失敗する可能性がある操作(何らかの型 E のエラーを返す)があるあらゆる場所で、Result 列挙型を便利に使えます。実際、これはリスト9-3でファイルを開くときに使ったもので、ファイルを正常に開けた場合には T に std::fs::File 型が入り、ファイルを開く際に問題があった場合には E に
std::io::Error 型が入っていました。
コードの中で、保持する値の型だけが異なる複数の構造体や列挙型の定義がある状況に気づいたら、ジェネリック型を使うことで重複を避けられます。
メソッド定義において
構造体や列挙型に対してメソッドを実装でき(第5章で行ったとおりです)、その定義の中でもジェネリック型を使えます。リスト10-9は、リスト10-6で定義した
Point<T> 構造体に、x という名前のメソッドを実装したものを示しています。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
ここでは、Point<T> に対して x という名前のメソッドを定義しており、
フィールド x のデータへの参照を返します。
Point<T> 型に対してメソッドを実装していることを示すのに T を使えるよう、impl の直後で T を宣言しなければならないことに注意してください。
impl の後で T をジェネリック型として宣言することで、Rust は Point
の山かっこ内の型が具体的な型ではなくジェネリック型であると識別できます。
このジェネリックパラメータには、構造体定義で宣言したジェネリックパラメータとは別の名前を選ぶこともできましたが、同じ名前を使うのが慣例です。ジェネリック型を宣言した impl の中にメソッドを書くと、そのメソッドは、最終的にそのジェネリック型にどの具体的な型が代入されるかにかかわらず、その型のあらゆるインスタンスに対して定義されます。
型に対してメソッドを定義するときに、ジェネリック型に制約を設けることもできます。たとえば、どんなジェネリック型でもよい Point<T> のインスタンスではなく、
Point<f32> のインスタンスに対してだけメソッドを実装できます。リスト10-10では、具体的な型 f32 を使っているため、impl の後に型を何も宣言していません。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
impl Point<f32> {
fn distance_from_origin(&self) -> f32 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
fn main() {
let p = Point { x: 5, y: 10 };
println!("p.x = {}", p.x());
}
このコードは、Point<f32> 型には distance_from_origin
メソッドがある一方、T が f32 型ではない Point<T> の他のインスタンスには、このメソッドが定義されないことを意味します。このメソッドは、座標 (0.0, 0.0) の点から自分たちの点までがどれだけ離れているかを測り、浮動小数点型でしか使えない数学的演算を利用します。
構造体定義のジェネリック型パラメータは、同じ構造体のメソッドシグネチャで使うものと必ずしも同じではありません。リスト10-11では、例をより明確にするために、Point 構造体にはジェネリック型 X1 と Y1 を使い、mixup メソッドシグネチャには X2 と Y2 を使っています。このメソッドは、self
の Point(型は X1)から x の値を取り、渡された Point(型は Y2)からy の値を取って、新しい Point インスタンスを作ります。
struct Point<X1, Y1> {
x: X1,
y: Y1,
}
impl<X1, Y1> Point<X1, Y1> {
fn mixup<X2, Y2>(self, other: Point<X2, Y2>) -> Point<X1, Y2> {
Point {
x: self.x,
y: other.y,
}
}
}
fn main() {
let p1 = Point { x: 5, y: 10.4 };
let p2 = Point { x: "Hello", y: 'c' };
let p3 = p1.mixup(p2);
println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}
main では、x に i32(値は 5)を持ち、y に f64
(値は 10.4)を持つ Point を定義しています。p2 変数は、
x に文字列スライス(値は "Hello")を持ち、y に char
(値は c)を持つ Point 構造体です。p1 に対して引数 p2 で mixup
を呼び出すと p3 が得られます。x は p1 から来るので、p3 の x は i32
になります。y は p2 から来るので、p3 の y は char
になります。println! マクロ呼び出しは p3.x = 5, p3.y = c を出力します。
この例の目的は、一部のジェネリックパラメータが impl
で宣言され、一部がメソッド定義で宣言される状況を示すことです。ここでは、ジェネリックパラメータ X1 と Y1 は構造体定義に対応するので impl
の後で宣言されます。ジェネリックパラメータ X2 と Y2 はメソッドにしか関係しないので、fn mixup の後で宣言されます。
ジェネリクスを使うコードのパフォーマンス
ジェネリック型パラメータを使うと実行時コストがかかるのではないか、と疑問に思うかもしれません。うれしいことに、ジェネリック型を使っても、具体的な型を使った場合よりプログラムの実行が遅くなることはありません。
Rust は、コンパイル時にジェネリクスを使ったコードに対して単相化を行うことでこれを実現しています。単相化 とは、コンパイル時に使われる具体的な型を埋め込むことによって、ジェネリックなコードを具体的なコードに変換するプロセスです。このプロセスで、コンパイラはリスト10-5でジェネリック関数を作るために行った手順とは逆のことをします。コンパイラは、ジェネリックなコードが呼び出されているすべての箇所を見て、そのジェネリックなコードが呼び出されている具体的な型向けのコードを生成します。
これがどのように動くのか、標準ライブラリのジェネリックな
Option<T> 列挙型を使って見てみましょう。
#![allow(unused)]
fn main() {
let integer = Some(5);
let float = Some(5.0);
}
Rust がこのコードをコンパイルすると、単相化が行われます。その過程で、コンパイラは Option<T>
のインスタンスで使われている値を読み取り、2種類の Option<T> を特定します。1つは i32 で、もう1つは
f64 です。そのため、ジェネリックな Option<T> の定義は、i32 用と f64
用に特化した2つの定義へと展開され、ジェネリックな定義がそれら具体的な定義に置き換えられます。
単相化されたコードのバージョンは、次のようなものに似ています(説明のため、ここではコンパイラが実際とは異なる名前を使っています)。
enum Option_i32 {
Some(i32),
None,
}
enum Option_f64 {
Some(f64),
None,
}
fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}
ジェネリックな Option<T> は、コンパイラによって作成された具体的な定義に
置き換えられます。Rust はジェネリックなコードを、各インスタンスで型が指定された
コードにコンパイルするため、ジェネリクスを使用しても実行時コストは発生しません。コードが
実行されるとき、その動作は、各定義を手作業で複製していた場合とまったく同じです。
単相化のプロセスにより、Rust のジェネリクスは実行時に非常に効率的になります。