// book chapter
動かして学ぶRustプログラミング問題集第17章 ジェネリクス.
第13章から第16章までのプロジェクト構成編で、書いたコードをモジュールやクレートに整理し、外からどう見せるかを決められるようになりました。この章からは話題が変わり、トレイト編(第17章〜第22章)に入ります。扱うのは、Rustの型システムの中核である「型をまたいで共通の仕組みを書く」ための道具です。
トレイト編の見取り図は次のとおりです。この第17章ではジェネリクス、つまり「具体的な型は後から決める」書き方を学びます。第18章では型に共通の振る舞いを約束させるトレイト、第19章では#[derive]で自動実装できる標準トレイト、第20章ではジェネリクスとトレイトを結び付けるトレイト境界とimpl Trait、第21章では実行時に実装を選ぶトレイトオブジェクトと関連型、第22章では参照の有効期間を型に書くライフタイム注釈へ進みます。
ジェネリクスは、すでに何度も使ってきています。第12章のOption<T>やResult<T, E>の<T>がそれです。あのときは「どんな型でも入る」という意味の書き方だと紹介するにとどめましたが、この章では自分で<T>付きの関数・構造体・列挙型を定義し、i32用とf64用の同じコードを書き分けなくて済むようにしていきます。
なお、この章の型引数Tに対してできるのは「値を受け取る・包む・取り出す・入れ替える」といった、値の中身に触らない操作だけです。Tの値同士を足したり表示したりするには、第18章のトレイトと第20章のトレイト境界が必要になります。その制約も含めて、まずはジェネリクスそのものの形を6問で押さえます。
進め方は第16章までと同じです。各問題の冒頭に関連する辞書へのリンクを挙げているので、まずはリンク先で必要な知識を確認してから取り組んでください。
01 - ジェネリック関数
ジェネリクスと関数に関する問題です。
タプルの前後を入れ替える関数が、型ごとにswap_i32とswap_f64の2つに分かれています。さらに&strのタプルでも使いたくなりました。3つ目の関数を追加するのではなく、型引数を使った1つの関数swapにまとめ、mainのコメントアウトされた行を有効にして期待する出力に合わせてください。
(2, 1)
(2.5, 1.5)
(白組, 赤組)fn swap_i32(pair: (i32, i32)) -> (i32, i32) {
(pair.1, pair.0)
}
fn swap_f64(pair: (f64, f64)) -> (f64, f64) {
(pair.1, pair.0)
}
fn main() {
let numbers = swap_i32((1, 2));
println!("({}, {})", numbers.0, numbers.1);
let decimals = swap_f64((1.5, 2.5));
println!("({}, {})", decimals.0, decimals.1);
// let teams = swap(("赤組", "白組"));
// println!("({}, {})", teams.0, teams.1);
}Playgroundで開く解答例と解説
fn swap_i32(pair: (i32, i32)) -> (i32, i32) {
(pair.1, pair.0)
}
fn swap_f64(pair: (f64, f64)) -> (f64, f64) {
(pair.1, pair.0)
}
fn swap<T>(pair: (T, T)) -> (T, T) {
(pair.1, pair.0)
}
fn main() {
let numbers = swap_i32((1, 2));
let numbers = swap((1, 2));
println!("({}, {})", numbers.0, numbers.1);
let decimals = swap_f64((1.5, 2.5));
let decimals = swap((1.5, 2.5));
println!("({}, {})", decimals.0, decimals.1);
// let teams = swap(("赤組", "白組"));
// println!("({}, {})", teams.0, teams.1);
let teams = swap(("赤組", "白組"));
println!("({}, {})", teams.0, teams.1);
}Playgroundで開くswap_i32とswap_f64の本体は(pair.1, pair.0)で完全に同じです。違うのは型だけなので、その型のほうを引数にしてしまうのがジェネリクスの考え方です。
関数名の直後に<T>と書くと、「この関数の中ではTという名前の型を使う。具体的に何の型かは呼び出し側が決める」という宣言になります。宣言したTは、引数や戻り値の型の中でi32や&strと同じように使えます。(T, T)なら「同じ型Tの値を2つ持つタプル」です。
呼び出す側は、swap::<i32>((1, 2))のように型を書く必要はありません。(1, 2)を渡せばTはi32、(1.5, 2.5)ならf64、("赤組", "白組")なら&strと、引数から型推論で決まります。第1章以来ずっとlet x = 1で型注釈を省いてきたのと同じ仕組みが、型引数にも働いています。
実行時のコストはゼロ
「型が決まっていない関数」が実行時に動いているように見えますが、そうではありません。コンパイラは、swapが実際に使われた型(ここではi32・f64・&strの3つ)ごとに、専用の関数を別々に生成します。つまり問題のコードで手書きしていたswap_i32とswap_f64を、コンパイラが代わりに書いてくれるわけです。この処理を単相化と呼びます。実行時には手書きした場合とまったく同じコードが動くので、ジェネリクスを使ってもプログラムは遅くなりません。
02 - ジェネリック構造体
ジェネリクスと構造体に関する問題です。
Point構造体は整数の座標しか持てません。mainのコメントアウトされた行にある小数の座標も作れるように、Pointを型引数を持つ構造体に書き換えて、期待する出力に合わせてください。
整数の点: (3, 5)
小数の点: (1.5, 2.5)struct Point {
x: i32,
y: i32,
}
fn main() {
let integer = Point { x: 3, y: 5 };
println!("整数の点: ({}, {})", integer.x, integer.y);
// let float = Point { x: 1.5, y: 2.5 };
// println!("小数の点: ({}, {})", float.x, float.y);
}Playgroundで開く解答例と解説
struct Point {
x: i32,
y: i32,
struct Point<T> {
x: T,
y: T,
}
fn main() {
let integer = Point { x: 3, y: 5 };
println!("整数の点: ({}, {})", integer.x, integer.y);
// let float = Point { x: 1.5, y: 2.5 };
// println!("小数の点: ({}, {})", float.x, float.y);
let float = Point { x: 1.5, y: 2.5 };
println!("小数の点: ({}, {})", float.x, float.y);
}Playgroundで開く関数と同じように、構造体も名前の直後に<T>を付けると型引数を持てます。宣言したTはフィールドの型として使えるので、x: T・y: Tと書けば「座標の型は後から決める」構造体になります。
インスタンスを作るときの書き方は、第9章で学んだPoint { x: 3, y: 5 }のままです。3と5を渡せばTはi32、1.5と2.5ならf64と、フィールドの値から推論されます。明示したい場合はlet float: Point<f64> = ...のように型注釈で書けます。
ここで1つ押さえておきたいのは、Point<i32>とPoint<f64>は別の型だということです。Point<T>は「型を1つ受け取って型を作る型」で、Tを埋めてはじめて具体的な型になります。第12章でOption<u32>とu32が別の型だったのと同じで、Point<i32>の値をPoint<f64>が必要な場所には渡せません。この性質は次の問題03で確かめます。
03 - 型引数は1つの型に決まる
ジェネリクスと型推論に関する問題です。
次のコードはコンパイルエラー(E0308)になります。エラーメッセージを読んで原因を考え、Pointの定義は変えずに**main側だけを修正**してコンパイルが通るようにしてください。
(5, 2.5)struct Point<T> {
x: T,
y: T,
}
fn main() {
let point = Point { x: 5, y: 2.5 };
println!("({}, {})", point.x, point.y);
}Playgroundで開く解答例と解説
struct Point<T> {
x: T,
y: T,
}
fn main() {
let point = Point { x: 5, y: 2.5 };
let point = Point { x: 5.0, y: 2.5 };
println!("({}, {})", point.x, point.y);
}Playgroundで開くエラーメッセージはmismatched types(E0308)で、expected integer, found floating-point numberと続きます。「整数を期待していたのに小数が来た」という意味です。
Point<T>のTは、1つのインスタンスの中では必ず1つの型に決まります。x: 5を見たコンパイラはTを整数だと推論し、その時点でyも同じ整数型であることが確定します。そこへ2.5という小数が来たので、yの場所でエラーになりました。x: T, y: Tと書いた以上、「xはi32でyはf64」という組み合わせは存在しません。
修正は、どちらかに型を合わせることです。解答例ではxを5.0にして両方をf64にしました。println!はf64の5.0を5と表示するので、期待する出力のとおりになります。
04 - 複数の型引数
ジェネリクスと構造体に関する問題です。
問題03と同じコードです。今度はmainには手を入れず、xとyに別々の型を入れられるようにPointの定義側を修正してコンパイルが通るようにしてください。
(5, 2.5)struct Point<T> {
x: T,
y: T,
}
fn main() {
let point = Point { x: 5, y: 2.5 };
println!("({}, {})", point.x, point.y);
}Playgroundで開く解答例と解説
struct Point<T> {
x: T,
y: T,
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let point = Point { x: 5, y: 2.5 };
println!("({}, {})", point.x, point.y);
}Playgroundで開く型引数は、カンマで区切って何個でも宣言できます。Point<T, U>としてx: T・y: Uとすれば、xとyの型は独立に決まるようになります。Point { x: 5, y: 2.5 }ならTはi32、Uはf64です。
もちろん、TとUが違う型でなければならないわけではありません。Point { x: 1, y: 2 }と書けばTもUもi32になります。「同じでもよいし違ってもよい」のが複数の型引数で、「必ず同じ」を表すのが問題03のPoint<T>です。
型引数の数は必要最小限に
別々の型を入れられるほうが柔軟に見えますが、型引数を増やすほど定義は読みにくくなります。座標のように「xとyは同じ型であってほしい」場面ではPoint<T>のままにしておくほうが、意図が明確で、異なる型を混ぜてしまうミスもコンパイラが防いでくれます。型引数を増やすのは、本当に異なる型を組み合わせる必要があるときだけにしましょう。
05 - ジェネリックなimplブロック
ジェネリクスとimplブロックとメソッドに関する問題です。
Pointのxとyを入れ替えるswapメソッドが、Point<i32>に対してだけ定義されています。mainのコメントアウトされた行にあるPoint<f64>でも同じメソッドを使えるように、implブロックをどのTに対しても有効な形に書き換えて、期待する出力に合わせてください。
(5, 3)
(2.5, 1.5)struct Point<T> {
x: T,
y: T,
}
impl Point<i32> {
fn swap(self) -> Point<i32> {
Point { x: self.y, y: self.x }
}
}
fn main() {
let integer = Point { x: 3, y: 5 }.swap();
println!("({}, {})", integer.x, integer.y);
// let float = Point { x: 1.5, y: 2.5 }.swap();
// println!("({}, {})", float.x, float.y);
}Playgroundで開く解答例と解説
struct Point<T> {
x: T,
y: T,
}
impl Point<i32> {
fn swap(self) -> Point<i32> {
impl<T> Point<T> {
fn swap(self) -> Point<T> {
Point { x: self.y, y: self.x }
}
}
fn main() {
let integer = Point { x: 3, y: 5 }.swap();
println!("({}, {})", integer.x, integer.y);
// let float = Point { x: 1.5, y: 2.5 }.swap();
// println!("({}, {})", float.x, float.y);
let float = Point { x: 1.5, y: 2.5 }.swap();
println!("({}, {})", float.x, float.y);
}Playgroundで開く型引数を持つ構造体にメソッドを定義するときは、impl<T> Point<T>と書きます。implの直後の<T>が「このブロックではTという型引数を使う」という宣言で、その後ろのPoint<T>が「Tを埋めたあらゆるPointに対して実装する」という対象です。
宣言を忘れてimpl Point<T>と書くと、コンパイラはTを「どこかで定義された具体的な型の名前」として探しにいき、見つからずにエラー(E0412: cannot find type 'T' in this scope)になります。<T>を2回書くのは冗長に見えますが、「宣言」と「使用」は別のことなので両方必要です。
問題のコードのimpl Point<i32>も、それ自体は正しいRustです。これは「Tがi32のときだけ使えるメソッド」を定義する書き方で、Point<f64>にはswapが存在しない状態でした。impl<T> Point<T>に変えると、Point<i32>にもPoint<f64>にも、これから使うどのPointにもswapが生えます。
メソッドの中身は変わっていません。self.xとself.yはどちらもT型の値で、それを入れ替えて新しいPoint { x: self.y, y: self.x }を作るだけです。第10章で学んだselfを受け取るメソッドが、所有権ごと値を受け取って新しい値を返す形です。Tの中身には一切触れていないので、Tが何の型であっても成り立ちます。
06 - ジェネリック列挙型
ジェネリクスと列挙型とOption型とResult型に関する問題です。
Maybeは「値があるかもしれない」を、Outcomeは「成功か失敗か」を表す自作の列挙型ですが、今は決まった型でしか使えません。mainとlookup_rankのコメントアウトされた部分も動くように、2つの列挙型を型引数を持つ形に書き換えて、期待する出力に合わせてください。
在庫: 3
氏名: 佐藤
合計: 300
失敗: 在庫切れ
ランク: ゴールド
未登録のID: 9enum Maybe {
Just(i32),
Nothing,
}
enum Outcome {
Success(i32),
Failure(String),
}
fn checkout(quantity: i32) -> Outcome {
if quantity == 0 {
Outcome::Failure(String::from("在庫切れ"))
} else {
Outcome::Success(quantity * 100)
}
}
// fn lookup_rank(id: u32) -> Outcome<String, u32> {
// if id == 1 {
// Outcome::Success(String::from("ゴールド"))
// } else {
// Outcome::Failure(id)
// }
// }
fn show_outcome(outcome: Outcome) {
match outcome {
Outcome::Success(total) => println!("合計: {total}"),
Outcome::Failure(reason) => println!("失敗: {reason}"),
}
}
fn main() {
let stock = Maybe::Just(3);
match stock {
Maybe::Just(count) => println!("在庫: {count}"),
Maybe::Nothing => println!("在庫: 不明"),
}
// let name = Maybe::Just("佐藤");
// match name {
// Maybe::Just(value) => println!("氏名: {value}"),
// Maybe::Nothing => println!("氏名: 未登録"),
// }
show_outcome(checkout(3));
show_outcome(checkout(0));
// match lookup_rank(1) {
// Outcome::Success(rank) => println!("ランク: {rank}"),
// Outcome::Failure(id) => println!("未登録のID: {id}"),
// }
// match lookup_rank(9) {
// Outcome::Success(rank) => println!("ランク: {rank}"),
// Outcome::Failure(id) => println!("未登録のID: {id}"),
// }
}Playgroundで開く解答例と解説
enum Maybe {
Just(i32),
enum Maybe<T> {
Just(T),
Nothing,
}
enum Outcome {
Success(i32),
Failure(String),
enum Outcome<T, E> {
Success(T),
Failure(E),
}
fn checkout(quantity: i32) -> Outcome {
fn checkout(quantity: i32) -> Outcome<i32, String> {
if quantity == 0 {
Outcome::Failure(String::from("在庫切れ"))
} else {
Outcome::Success(quantity * 100)
}
}
// fn lookup_rank(id: u32) -> Outcome<String, u32> {
// if id == 1 {
// Outcome::Success(String::from("ゴールド"))
// } else {
// Outcome::Failure(id)
// }
// }
fn lookup_rank(id: u32) -> Outcome<String, u32> {
if id == 1 {
Outcome::Success(String::from("ゴールド"))
} else {
Outcome::Failure(id)
}
}
fn show_outcome(outcome: Outcome) {
fn show_outcome(outcome: Outcome<i32, String>) {
match outcome {
Outcome::Success(total) => println!("合計: {total}"),
Outcome::Failure(reason) => println!("失敗: {reason}"),
}
}
fn main() {
let stock = Maybe::Just(3);
match stock {
Maybe::Just(count) => println!("在庫: {count}"),
Maybe::Nothing => println!("在庫: 不明"),
}
// let name = Maybe::Just("佐藤");
// match name {
// Maybe::Just(value) => println!("氏名: {value}"),
// Maybe::Nothing => println!("氏名: 未登録"),
// }
let name = Maybe::Just("佐藤");
match name {
Maybe::Just(value) => println!("氏名: {value}"),
Maybe::Nothing => println!("氏名: 未登録"),
}
show_outcome(checkout(3));
show_outcome(checkout(0));
// match lookup_rank(1) {
// Outcome::Success(rank) => println!("ランク: {rank}"),
// Outcome::Failure(id) => println!("未登録のID: {id}"),
// }
// match lookup_rank(9) {
// Outcome::Success(rank) => println!("ランク: {rank}"),
// Outcome::Failure(id) => println!("未登録のID: {id}"),
// }
match lookup_rank(1) {
Outcome::Success(rank) => println!("ランク: {rank}"),
Outcome::Failure(id) => println!("未登録のID: {id}"),
}
match lookup_rank(9) {
Outcome::Success(rank) => println!("ランク: {rank}"),
Outcome::Failure(id) => println!("未登録のID: {id}"),
}
}Playgroundで開く列挙型も、名前の直後に<T>を付ければ型引数を持てます。宣言したTは、第11章で学んだデータを持つバリアントの中身の型として使えます。Just(T)は「T型の値を1つ持つバリアント」、Nothingは型引数に関係なくデータを持たないバリアントです。
Maybe::Just(3)と書けばTはi32、Maybe::Just("佐藤")なら&strです。取り出し方は今までどおりmatchで、Just(value)のパターンで中の値が得られます。
Outcome<T, E>は型引数が2つで、成功時の値の型Tと失敗時の値の型Eを別々に決められます。checkoutはOutcome<i32, String>、lookup_rankはOutcome<String, u32>と、同じ列挙型から用途ごとに違う組み合わせの型を作っています。ジェネリックな列挙型を関数の引数や戻り値にするときは、Outcomeとだけ書くのではなく、Outcome<i32, String>のように型引数を埋めた具体的な型で書きます。Outcome単独は「型を作るための型」であって、まだ値の型ではないからです。
OptionとResultの正体
ここまで書いてみると、気付いた人も多いと思います。Maybe<T>は第12章のOption型、Outcome<T, E>はResult型と、名前以外は同じ形です。
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}第12章では「Tはどんな型でも入るという意味」とだけ説明しましたが、その正体はこの章で学んだ型引数です。Option<u32>・Option<String>・Result<u32, String>といった型を自然に使い分けられていたのは、ジェネリック列挙型の単相化がその都度、専用の列挙型を用意してくれていたからです。標準ライブラリのOptionとResultには、何か特別な仕組みがあるわけではありません。第11章の列挙型と、この章のジェネリクスを組み合わせただけのものです。