ジェネリックなデータ型
関数シグニチャや構造体などの要素の定義を生成するのにジェネリクスを使用することができ、 それはさらに他の多くの具体的なデータ型と使用することもできます。まずは、 ジェネリクスで関数、構造体、enum、メソッドを定義する方法を見ましょう。それから、 ジェネリクスがコードのパフォーマンスに与える影響を議論します。
関数定義では
ジェネリクスを使用する関数を定義する時、通常、引数や戻り値のデータ型を指定する関数のシグニチャにジェネリクスを配置します。 そうすることでコードがより柔軟になり、コードの重複を阻止しつつ、関数の呼び出し元により多くの機能を提供します。
largest関数を続けます。リスト10-4はどちらもスライスから最大値を探す2つの関数を示しています。
ファイル名: src/main.rs
fn largest_i32(list: &[i32]) -> i32 { let mut largest = list[0]; for &item in list.iter() { if item > largest { largest = item; } } largest } fn largest_char(list: &[char]) -> char { let mut largest = list[0]; for &item in list.iter() { 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'); }
リスト10-4: 名前とシグニチャの型のみが異なる2つの関数
largest_i32関数は、リスト10-3で抽出したスライスから最大のi32を探す関数です。
largest_char関数は、スライスから最大のcharを探します。関数本体には同じコードがあるので、
単独の関数にジェネリックな型引数を導入してこの重複を排除しましょう。
これから定義する新しい関数の型を引数にするには、ちょうど関数の値引数のように型引数に名前をつける必要があります。
型引数の名前にはどんな識別子も使用できますが、Tを使用します。というのも、慣習では、
Rustの引数名は短く(しばしばたった1文字になります)、Rustの型の命名規則がキャメルケースだからです。
"type"の省略形なので、Tが多くのRustプログラマの既定の選択なのです。
関数の本体で引数を使用するとき、コンパイラがその名前の意味を把握できるようにシグニチャでその引数名を宣言しなければなりません。
同様に、型引数名を関数シグニチャで使用する際には、使用する前に型引数名を宣言しなければなりません。
ジェネリックなlargest関数を定義するために、型名宣言を山カッコ(<>)内、関数名と引数リストの間に配置してください。
こんな感じに:
fn largest<T>(list: &[T]) -> T {
この定義は以下のように解読します: 関数largestは、なんらかの型Tに関してジェネリックであると。
この関数にはlistという引数が1つあり、これは型Tの値のスライスです。
largest関数は同じT型の値を返します。
リスト10-5は、シグニチャにジェネリックなデータ型を使用してlargest関数定義を組み合わせたものを示しています。
このリストはさらに、この関数をi32値かchar値のどちらかで呼べる方法も表示しています。
このコードはまだコンパイルできないことに注意してください。ですが、この章の後ほど修正します。
ファイル名: src/main.rs
fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
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);
}
リスト10-5: ジェネリックな型引数を使用するものの、まだコンパイルできないlargest関数の定義
直ちにこのコードをコンパイルしたら、以下のようなエラーが出ます:
error[E0369]: binary operation `>` cannot be applied to type `T`
(エラー: 2項演算`>`は、型`T`に適用できません)
--> src/main.rs:5:12
|
5 | if item > largest {
| ^^^^^^^^^^^^^^
|
= note: an implementation of `std::cmp::PartialOrd` might be missing for `T`
(注釈: `std::cmp::PartialOrd`の実装が`T`に対して存在しない可能性があります)
注釈がstd::cmp::PartialOrdに触れています。これは、トレイトです。トレイトについては、次の節で語ります。
とりあえず、このエラーは、largestの本体は、Tがなりうる全ての可能性のある型に対して動作しないと述べています。
本体で型Tの値を比較したいので、値が順序付け可能な型のみしか使用できないのです。比較を可能にするために、
標準ライブラリには型に実装できるstd::cmp::PartialOrdトレイトがあります(このトレイトについて詳しくは付録Cを参照されたし)。
ジェネリックな型が特定のトレイトを持つと指定する方法は「トレイト境界」節で習うでしょうが、
先にジェネリックな型引数を使用する他の方法を探究しましょう。
構造体定義では
構造体を定義して<>記法で1つ以上のフィールドにジェネリックな型引数を使用することもできます。
リスト10-6は、Point<T>構造体を定義してあらゆる型のxとy座標を保持する方法を示しています。
ファイル名: src/main.rs
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 }; }
リスト10-6: 型Tのxとy値を保持するPoint<T>構造体
構造体定義でジェネリクスを使用する記法は、関数定義のものと似ています。まず、山カッコ内に型引数の名前を構造体名の直後に宣言します。 そうすると、本来具体的なデータ型を記述する構造体定義の箇所に、ジェネリックな型を使用できます。
ジェネリックな型を1つだけ使用してPoint<T>を定義したので、この定義は、Point<T>構造体がなんらかの型Tに関して、
ジェネリックであると述べていて、その型がなんであれ、xとyのフィールドは両方その同じ型になっていることに注意してください。
リスト10-7のように、異なる型の値のあるPoint<T>のインスタンスを生成すれば、コードはコンパイルできません。
ファイル名: src/main.rs
struct Point<T> {
x: T,
y: T,
}
fn main() {
let wont_work = Point { x: 5, y: 4.0 };
}
リスト10-7: どちらも同じジェネリックなデータ型Tなので、xとyというフィールドは同じ型でなければならない
この例で、xに整数値5を代入すると、このPoint<T>のインスタンスに対するジェネリックな型Tは整数になるとコンパイラに知らせます。
それからyに4.0を指定する時に、このフィールドはxと同じ型と定義したはずなので、このように型不一致エラーが出ます:
error[E0308]: mismatched types
--> src/main.rs:7:38
|
7 | let wont_work = Point { x: 5, y: 4.0 };
| ^^^ expected integral variable, found
floating-point variable
|
= note: expected type `{integer}`
found type `{float}`
xとyが両方ジェネリックだけれども、異なる型になり得るPoint構造体を定義するには、
複数のジェネリックな型引数を使用できます。例えば、リスト10-8では、Pointの定義を変更して、
型TとUに関してジェネリックにし、xが型Tで、yが型Uになります。
ファイル名: src/main.rs
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 }; }
リスト10-8: Point<T, U>は2つの型に関してジェネリックなので、xとyは異なる型の値になり得る
これで、示されたPointインスタンスは全部使用可能です!所望の数だけ定義でジェネリックな型引数を使用できますが、
数個以上使用すると、コードが読みづらくなります。コードで多くのジェネリックな型が必要な時は、
コードの小分けが必要なサインかもしれません。
enum定義では
構造体のように、列挙子にジェネリックなデータ型を保持するenumを定義することができます。
標準ライブラリが提供しているOption<T> enumをもう一度見ましょう。このenumは第6章で使用しました:
#![allow(unused)] fn main() { enum Option<T> { Some(T), None, } }
この定義はもう、あなたにとってより道理が通っているはずです。ご覧の通り、Option<T>は、
型Tに関してジェネリックで2つの列挙子のあるenumです: その列挙子は、型Tの値を保持するSomeと、
値を何も保持しないNoneです。Option<T> enumを使用することで、オプショナルな値があるという抽象的な概念を表現でき、
Option<T>はジェネリックなので、オプショナルな値の型に関わらず、この抽象を使用できます。
enumも複数のジェネリックな型を使用できます。第9章で使用したResult enumの定義が一例です:
#![allow(unused)] fn main() { enum Result<T, E> { Ok(T), Err(E), } }
Result enumは2つの型T、Eに関してジェネリックで、2つの列挙子があります: 型Tの値を保持するOkと、
型Eの値を保持するErrです。この定義により、Result enumを、成功する(なんらかの型Tの値を返す)か、
失敗する(なんらかの型Eのエラーを返す)可能性のある処理がある、あらゆる箇所に使用するのが便利になります。
事実、ファイルを開くのに成功した時にTに型std::fs::Fileが入り、ファイルを開く際に問題があった時にEに型std::io::Errorが入ったものが、
リスト9-3でファイルを開くのに使用したものです。
自分のコード内で、保持している値の型のみが異なる構造体やenum定義の場面を認識したら、 代わりにジェネリックな型を使用することで重複を避けることができます。
メソッド定義では
(第5章のように、)定義にジェネリックな型を使うメソッドを構造体やenumに実装することもできます。リスト10-9は、
リスト10-6で定義したPoint<T>構造体にxというメソッドを実装したものを示しています。
ファイル名: src/main.rs
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()); }
リスト10-9: 型Tのxフィールドへの参照を返すxというメソッドをPoint<T>構造体に実装する
ここで、フィールドxのデータへの参照を返すxというメソッドをPoint<T>に定義しました。
implの直後にTを宣言しなければならないことに注意してください。こうすることで、型Point<T>にメソッドを実装していることを指定するために、Tを使用することができます。
implの後にTをジェネリックな型として宣言することで、コンパイラは、Pointの山カッコ内の型が、
具体的な型ではなくジェネリックな型であることを認識できるのです。
例えば、ジェネリックな型を持つPoint<T>インスタンスではなく、Point<f32>だけにメソッドを実装することもできるでしょう。
リスト10-10では、具体的な型f32を使用しています。つまり、implの後に型を宣言しません。
#![allow(unused)] fn main() { struct Point<T> { x: T, y: T, } impl Point<f32> { fn distance_from_origin(&self) -> f32 { (self.x.powi(2) + self.y.powi(2)).sqrt() } } }
リスト10-10: ジェネリックな型引数Tに対して特定の具体的な型がある構造体にのみ適用されるimplブロック
このコードは、Point<f32>にはdistance_from_originというメソッドが存在するが、
Tがf32ではないPoint<T>の他のインスタンスにはこのメソッドが定義されないことを意味します。
このメソッドは、この点が座標(0.0, 0.0)の点からどれだけ離れているかを測定し、
浮動小数点数にのみ利用可能な数学的処理を使用します。
構造体定義のジェネリックな型引数は、必ずしもその構造体のメソッドシグニチャで使用するものと同じにはなりません。
例を挙げれば、リスト10-11は、リスト10-8のPoint<T, U>にメソッドmixupを定義しています。
このメソッドは、他のPointを引数として取り、この引数はmixupを呼び出しているselfのPointとは異なる型の可能性があります。
このメソッドは、(型Tの)selfのPointのx値と渡した(型Wの)Pointのy値から新しいPointインスタンスを生成します。
ファイル名: src/main.rs
struct Point<T, U> { x: T, y: U, } impl<T, U> Point<T, U> { fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> { 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); }
リスト10-11: 構造体定義とは異なるジェネリックな型を使用するメソッド
mainで、x(値は5)にi32、y(値は10.4)にf64を持つPointを定義しました。p2変数は、
x(値は"Hello")に文字列スライス、y(値はc)にcharを持つPoint構造体です。
引数p2でp1にmixupを呼び出すと、p3が得られ、xはi32になります。xはp1由来だからです。
p3変数のyは、charになります。yはp2由来だからです。println!マクロの呼び出しは、
p3.x = 5, p3.y = cと出力するでしょう。
この例の目的は、一部のジェネリックな引数はimplで宣言され、他の一部はメソッド定義で宣言される場面をデモすることです。
ここで、ジェネリックな引数TとUはimplの後に宣言されています。構造体定義にはまるからです。
ジェネリックな引数VとWはfn mixupの後に宣言されています。何故なら、このメソッドにしか関係ないからです。
ジェネリクスを使用したコードのパフォーマンス
ジェネリックな型引数を使用すると、実行時にコストが発生するのかな、と思うかもしれません。 嬉しいことにRustでは、ジェネリクスを、具体的な型があるコードよりもジェネリックな型を使用したコードを実行するのが遅くならないように実装しています。
コンパイラはこれを、ジェネリクスを使用しているコードの単相化をコンパイル時に行うことで達成しています。 単相化(monomorphization)は、コンパイル時に使用されている具体的な型を入れることで、 ジェネリックなコードを特定のコードに変換する過程のことです。
この過程において、コンパイラは、リスト10-5でジェネリックな関数を生成するために使用した手順と真逆のことをしています: コンパイラは、ジェネリックなコードが呼び出されている箇所全部を見て、 ジェネリックなコードが呼び出されている具体的な型のコードを生成するのです。
標準ライブラリのOption<T> enumを使用する例でこれが動作する方法を見ましょう:
#![allow(unused)] fn main() { let integer = Some(5); let float = Some(5.0); }
コンパイラがこのコードをコンパイルすると、単相化を行います。その過程で、コンパイラはOption<T>のインスタンスに使用された値を読み取り、
2種類のOption<T>を識別します: 一方はi32で、もう片方はf64です。そのように、
コンパイラは、Option<T>のジェネリックな定義をOption_i32とOption_f64に展開し、
それにより、ジェネリックな定義を特定の定義と置き換えます。
単相化されたバージョンのコードは、以下のようになります。ジェネリックなOption<T>が、
コンパイラが生成した特定の定義に置き換えられています:
ファイル名: src/main.rs
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); }
Rustでは、ジェネリックなコードを各インスタンスで型を指定したコードにコンパイルするので、 ジェネリクスを使用することに対して実行時コストを払うことはありません。コードを実行すると、 それぞれの定義を手作業で複製した時のように振る舞います。単相化の過程により、 Rustのジェネリクスは実行時に究極的に効率的になるのです。