
ジェネリック型とトレイト。コードの抽象化【Rustを分かりたい】 #22
8000行ではなく8000文字です。10行は10行ですhttps://t.co/VEEimkYMGR
— Uliboooo(うりぼう) (@Uliboooo) January 1, 2025
いつかやろうと思っていたジェネリック型についてなんとなく理解できたので、記事にします。テーマは"抽象化"です。
ちなみにIt mediaの記事が分かりやすかったのでよければ読んでみてください。
*ログインしないと全文見られませんがそれだけの価値はあると思います。(回し者ではないです)
また、個人的におすすめな学習法なのですが、👆や👇のドキュメント含め、ピンとくるまでいろんな具体例や説明を見るのが深く考えず手っ取り早く身につくなと感じています。
注意
この記事(今シリーズ)は初心者がRustをかじりながら、備忘録のような形で投稿していく予定です。そのため、今シリーズ全体を通して信憑性は非常に低いです。また専門の方などから見れば、無茶苦茶なこと、おかしなことをしているかもしれませんがご容赦ください。
また、あくまで私がわかりやすいという点でまとめているので、ご容赦ください。
前回について
👆にて
トレイトに関する詳細は来週、というか次回の予定です。すいません。
と言いましたが、流石にこの2要素を別の記事に分けるのは見づらいだろうとというのと、前回の記事があまり好みでないのでこちらにまとめます。
また記事の主体は概念の説明とし、言語的な実装方法は最後にまとめてあります。
ジェネリック型とは
rustにおけるジェネリック型とは、関数などの引数の型指定において型の制限を緩める機能です。
ジェネリック型を利用しない場合に関数の引数を設定した場合、以下のようになります。これはVec<i32>をすべて合算した値をi32で返す関数です。
fn sum(values: Vec<i32>) -> Result<i32, String> {
// 結果用の値
let mut result = 0;
// ベクタが空の場合にアクセスすると落ちるので確認
if values.is_empty() {
return Err("empty".to_string());
}
// forでベクタを足してく
for i in values {
result += i;
}
// 結果を返す
Ok(result)
}
しかしRustにはi32以外にも整数型があります。しかしRust暗黙的な型変換を行わないので、i32を引数に指定した関数にi16などは渡せません。試しにこの関数にi16型のベクタを渡そうとするとエラーになります
fn main() {
let i16_vec: Vec<i16> = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let error_sum = sum(i16_vec);
}
cargo build
Compiling generic_trait_for_note v0.1.0 (/Users/yuki/Develop/Rust/generic_trait_for_note)
error[E0308]: mismatched types
--> src/main.rs:27:25
|
27 | let error_sum = sum(i16_vec);
| --- ^^^^^^^ expected `Vec<i32>`, found `Vec<i16>`
| |
| arguments to this function are incorrect
|
= note: expected struct `Vec<i32>`
found struct `Vec<i16>`
note: function defined here
--> src/main.rs:1:4
|
1 | fn sum(values: Vec<i32>) -> Result<i32, String> {
| ^^^ ----------------
👆ではi32を期待したがi16が見つかったとされている。渡す前に型変換でもいいのでしょうが、ベクタの型変換は面倒ですし常に型変換ができるとは限りません。
そこで、sum関数が受け入れる引数の型をi32と絞るのではなく、「整数」くらいに緩めてみます。この時に必要になるのがジェネリック型、あるいは「T」です。
T
先ほどの引数の制限を緩めたものが👇です。型の指定には本来i32など、具体的な型を明記しますがここではTとします。Tとはあらゆる型を示す感じです。(感じというのは私の理解が怪しいからです)また、Tはその関数内では同じ型を示します。
fn sum_integer<T>(values: Vec<T>) -> Result<T, String> {
// 結果用の値
let mut result: T;
// ベクタが空の場合にアクセスすると落ちるので確認
if values.is_empty() {
return Err("empty".to_string());
}
// forでベクタを足してく
for i in values {
result += i;
}
// 結果を返す
Ok(result)
}
しかし、これをそのままは実行できません。というかコンパイルが通りません。解決は次で。
cargo build
Compiling generic_trait_for_note v0.1.0 (/Users/yuki/Develop/Rust/generic_trait_for_note)
error[E0368]: binary assignment operation `+=` cannot be applied to type `T`
--> src/main.rs:25:9
|
25 | result += i;
| ------^^^^^
| |
| cannot use `+=` on type `T`
|
help: consider restricting type parameter `T`
|
16 | fn sum_integer<T: std::ops::AddAssign>(values: Vec<T>) -> Result<T, String> {
| +++++++++++++++++++++
また、TだけでなくQなども使用できるので👇のような感じに実装もできます。
fn foo<T, Q> (one: T, two: Q) {
ちなみに2つtips。
1つ目。関数名の右にも<T>とある理由。
関数の本体で引数を使用するとき、コンパイラがその名前の意味を把握できるようにシグニチャでその引数名を宣言しなければなりません。 同様に、型引数名を関数シグニチャで使用する際には、使用する前に型引数名を宣言しなければなりません。 ジェネリックなlargest関数を定義するために、型名宣言を山カッコ(<>)内、関数名と引数リストの間に配置してください。 こんな感じに:
fn largest<T>(list: &[T]) -> T {
この定義は以下のように解読します: 関数largestは、なんらかの型Tに関してジェネリックであると。 この関数にはlistという引数が1つあり、これは型Tの値のスライスです。 largest関数は同じT型の値を返します。
2つ目。Tの正体、というか由来
typeのTだそう。
慣習では、 Rustの引数名は短く(しばしばたった1文字になります)、Rustの型の命名規則がキャメルケースだからです。 "type"の省略形なので、Tが多くのRustプログラマの既定の選択なのです。
Tだけでは制限が緩すぎる
先ほどの<T>を追加しただけのコードはコンパイルが通りません。これはTを用いた関数内の処理で、そのTに適用できない可能性のある処理を含む場合(ほとんどの場合起きると思う。そのままT型を返すとかならともかく)にエラーが出るようになっているために発生します。
具体的には先ほどのコードで結果を足していく時点で+=演算子を使用しています。これは数には適用可能ですが、文字(charとか)には適用不可です。しかしTは特に指定しない場合、いかなる型も受け付けます。この状況をそのままコンパイルしてしまうと実行時にsum_integer("hello")とかしてpanic!する可能性があります。(*integerとかいう関数に文字突っ込むなと言われればそうですが…)
Tに制限をかける(トレイト境界)
これでは不便なのでTに制限をかけます。緩めすぎた制限を再び締め付けます。rustコンパイラのエラーは非常に丁寧なため解決策がありますね
16 | fn sum_integer<T: std::ops::AddAssign>(values: Vec<T>) -> Result<T, String> {
| +++++++++++++++++++++
: std::ops::AddAssignをつけてはどうかと、提案されています。詳細は後述しますが機能を表すようなもので、: std::ops::AddAssignは加算演算子を持っているかを表します。
これをつけることで「+演算子が使用できる型」という制限をTにかけることができます。これでTには+演算子が実装されている型しか入らなくなりました。また、resultを初期化する過程でデフォルトの値が必要になったためそれも追加しています。ちなみにi32のデフォルトは0です。👇より
// 👇足し算ができて、 👇デフォルトの値がある型
fn sum_integer<T: std::ops::AddAssign + Default>(values: Vec<T>) -> Result<T, String> {
// 結果用の値
let mut result = T::default(); // 👈ここで初期化するためにDefaultトレイトが必要
// ベクタが空の場合にアクセスすると落ちるので確認
if values.is_empty() {
return Err("empty".to_string());
}
// forでベクタを足してく
for i in values {
result += i;
}
// 結果を返す
Ok(result)
}
トレイトに関して一切話がないので意味がわかりませんね…次で説明します。
トレイト(Trait)とは
トレイトとは抽象化された機能の宣言です。(と思っています)
OPPのクラスの話です。曖昧なくせに書いちゃったので飛ばしてください。
Rustにはクラスという概念は無く、継承もありません。正直オブジェクト指向(以下OPP(object-oriented programming))をちゃんとやったことのないので、曖昧な説明ですがOPPにおいて、人間クラスを定義した後に勇者クラスを定義する際に、勇者は人間属なんだから人間クラスと同じ部分は記述する必要がない、👉「人間+勇者の要素」という書き方ができます。これを勇者クラスは人間クラスを継承したと言うそうです。これは人間という要素を抽象化しています。
Rustにも抽象化機能はあり、似たような操作(メソッド*1)を事前に定義しておくことができます。この定義したものがトレイトです。そのトレイトを型に実装するかどうか、実装するならどのような処理を行うかは型の開発者に委ねられます。
*1: 型特有の関数。身近で言えば.to_stringなど
例えばDisplayトレイトを実装している型であればその型の値をprintln!()するときに容易に表示できます。Vecはこれを実装していないため、:?でデバッグ用の表示をしないといけません。
またトレイトは自作することもできます。以下に例を示します。(微妙かも)
trait marry {
fn maeey(self, pertner: human);
}
struct human {
name: String,
age: u32,
partner_name: Option<String>,
}
impl human {
fn set_pertner(mut self, pertner_name: String) -> Self {
self.partner_name = Some(pertner_name);
self
}
}
impl marry for human {
fn maeey(self, pertner: human) {
self.set_pertner(pertner.name);
}
}
impl std::fmt::Display for human {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(f, "Name: {}, Age: {}, Partner: {:?}", self.name, self.age, self.partner_name)
}
}
実用的な話をすると呼び出し時の所有権とかで面倒そうですが、本題とは違うので無視。
今回humanのみですが、例えばここにmonsterを追加した際に結婚させる処理を書くならimpl marry for monsterとして結構の処理を書けば、それらを呼び出す際に固有の名前ではなくmonster型の値.marry(相手)という書き方ができます。
またhuman構造体はDisplay も実装しているためprintln!("{} ", human型の値)とすると一気に表示できます。
なぜジェネリック型の制限でトレイトを使うのか
ここで先ほどのジェネリック型にトレイトを書くことで、制限がかけられる理由がわかります。
これらのすでに用意された(抽象化された)機能を事前に指定することで、さまざまな型に対応しつつ、処理できない型は弾くということができます。
これ書いてて思いましたが、型の実装時にトレイトに間違ったメソッド書いたら結構迷惑なことできますね…displayトレイトって書いてあるのにデバッグ向けの出しちゃったり、極端な話、足し算しちゃったりとか…
これは私のイメージに近いですが、説明の足しになれば…
精一杯の例え
トレイトはポケモンの技(私はポケモン未プレイ)
未プレイですがポケモンは技を覚える、程度のことは知っています。rustにおいても型はトレイトという名の技を覚えます。型の実装者によって。
そして関数という名のジムに挑戦するには(おそらく99%そんなルールはポケモンにないと思いますが…)特定の技を覚えなければなりません。
これがトレイトが技で、ジェネリック型はジムに挑戦する際に持っている必要のある技で、ジムに指定された技がトレイト境界です。
*2: ポケモンの技を決めているのがどことか知りません。テキトウ言ってます。申し訳ございません。
実装方法(≒ドキュメント内のコード)
これ以降のコードは以下より。コメントは私が追記した場合あり。
関数へのジェネリック型
fn fn largest<T>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list.iter() {
if item > largest {
largest = item;
}
}
largest
}
トレイト境界を実装
+で複数の境界を設定可能。割とコンパイラが提案してくれることもあり。
構造体などへの実装時も同じように境界を実装できます。
// 👇比較. 👇コピー
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut largest = list[0];
for &item in list {
if item > largest {
largest = item;
}
}
largest
}
構造体への実装
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 };
}
enumへの実装
enum Option<T> {
Some(T),
None,
}
複数のジェネリック型も
enum Result<T, E> {
Ok(T),
Err(E),
}
メソッド実装時にジェネリック型
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());
}
トレイトの実装
trait Summary {
fn summarize(&self) -> String;
}
型にトレイトを実装
この辺はドキュメント見た方が良さげ。
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
他にもトレイトを引数したりとできるようですが、脳がパンクしそうだったので一旦やめました。いつか追記か別記事で出す予定。