Rustジェネリクス入門
プログラミングにおいてよくある要求のひとつは、同じ機能の関数で異なる型のデータを処理することです。ジェネリクス(総称型)をサポートしていないプログラミング言語では、通常はそれぞれの型ごとに別々の関数を書く必要があります。しかし、ジェネリクスがあれば、開発者にとって便利なだけでなく、冗長なコードを減らし、言語自体の表現力も大きく向上します。つまり、1 つの関数で異なる型のデータを処理する多くの関数を置き換えることができます。
たとえば、ジェネリクスを使わずに、引数が u8
、i8
、u16
、i16
、u32
、i32
……などの型を許容する double
関数を定義するには、次のように書く必要があります:
fn double_u8(i: u8) -> u8 { i + i }
fn double_i8(i: i8) -> i8 { i + i }
fn double_u16(i: u16) -> u16 { i + i }
fn double_i16(i: i16) -> i16 { i + i }
fn double_u32(i: u32) -> u32 { i + i }
fn double_i32(i: i32) -> i32 { i + i }
fn double_u64(i: u64) -> u64 { i + i }
fn double_i64(i: i64) -> i64 { i + i }
fn main(){
println!("{}", double_u8(3_u8));
println!("{}", double_i16(3_i16));
}
上記では double
関数が大量に定義されていますが、関数のロジック部分は完全に一致しており、異なるのは型だけです。
ジェネリクスを使えば、こうした型によるコードの冗長さを解決できます。ジェネリクスを使った場合は以下のようになります:
use std::ops::Add;
fn double<T>(i: T) -> T
where T: Add<Output=T> + Clone + Copy {
i + i
}
fn main(){
println!("{}", double(3_i16));
println!("{}", double(3_i32));
}
上記の T
はジェネリクス(変数 x
と同様の意味)であり、さまざまな可能性のあるデータ型を表すために使われています。
関数定義におけるジェネリクスの使用
ジェネリクスを使って関数を定義する場合、本来は関数シグネチャで引数や戻り値の型を指定しますが、それをジェネリクスに置き換えることで、より柔軟なコードが書けるようになり、呼び出し元に対しても多様な機能を提供できるようになります。また、重複したコードを書く必要もなくなります。
Rust では、ジェネリクス引数の名前は自由に付けられますが、慣例として T
(type の頭文字)が最もよく使われます。
ジェネリクス引数を使うには、使用前に必ず宣言が必要です:
fn largest<T>(list: &[T]) -> T {...}
ジェネリクス版の関数を定義する際、関数名と引数リストの間にある山括弧(<>
)の中で型引数 T
を宣言します。たとえば largest<T>
では、まず T
をジェネリクス型として宣言し、それから list: &[T]
や戻り値 T
を指定しています。
引数部分 list: &[T]
は、list
の型がジェネリクス型のスライス &[T]
であることを示しています。
戻り値部分 -> T
は、関数の戻り値の型がジェネリクス型 T
であることを意味しています。
したがって、この関数定義の意味は次の通りです:ジェネリクス引数 T
を持つ関数であり、引数 list
は要素が T
型のスライス、そして戻り値も T
型となります。
要約すると、ジェネリック関数において、関数名の後の <T>
は関数スコープ内におけるジェネリクス型 T
の定義を表します。この T
は関数シグネチャおよび関数本体内でのみ使用できます。これは変数をスコープ内で定義し、そのスコープ内でのみ使用できるのと同じ考え方です。そして、ジェネリクスとは本質的に「様々なデータ型を表す変数」なのです。
したがって、この関数シグネチャが表す意味は「あるデータ型の引数を受け取り、その同じデータ型の戻り値を返す。ただしそのデータ型は任意の型でよい」ということです。
構造体におけるジェネリクスの使用
構造体のフィールド型にもジェネリクスを用いて定義することができます。たとえば以下のように書きます:
struct Point<T> {
x: T,
y: T,
}
注意すべき点として、ジェネリクス型 T
を使う前に Point<T>
のように明示的に宣言する必要があります。そのうえで構造体のフィールド型に T
を使用し、具体的な型の代わりとします。また、ここでは x
と y
は同じ型である必要があります。
x
と y
に異なる型を持たせたい場合は、異なるジェネリクス型を使います:
struct Point<T, U> {
x: T,
y: U,
}
fn main() {
let p = Point { x: 1, y: 1.1 };
}
列挙型におけるジェネリクスの使用
列挙型でもジェネリクスを使用することができ、Rust で最も一般的なジェネリック列挙型には Option<T>
と Result<T, E>
があります:
enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}
Option
と Result
はどちらも関数の戻り値によく使われます。Option
は値の有無を表し、Result
は値の正当性(成功か失敗か)に注目します。
関数が正常に動作した場合、Result
は Ok(T)
を返します。ここで T
は関数の実際の戻り値の型です。関数が異常終了した場合には Err(E)
を返し、E
はエラーの型です。
メソッドにおけるジェネリクスの使用
メソッドにもジェネリクスを使用することができます。ジェネリクス型を使用する前には、やはり事前に宣言が必要です:impl<T>
のように書くことで、Point<T>
において T
がジェネリクス型であることを Rust に伝えることができます。
struct Point<T> {
x: T,
y: T,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
ここで注意すべきは、メソッドの定義における Point<T>
はジェネリクスの宣言ではなく、完全な構造体型です。というのも、定義されている構造体が Point<T>
であり、Point
ではないからです。
また、構造体で使用しているジェネリクス型に加えて、その構造体のメソッド内で追加のジェネリクス型を定義することも可能です。これはジェネリック関数と同様の考え方です:
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,
}
}
}
この例では、T, U
は構造体 Point
に定義されているジェネリクス型であり、V, W
はメソッドに定義されたジェネリクス型です。両者は衝突せず、それぞれ独立したジェネリクスとみなされます。言い換えると、前者は構造体のジェネリクス、後者は関数(メソッド)のジェネリクスです。
また、特定のジェネリクス型に制限(制約)を加えて、その型にだけメソッドを実装することも可能です。たとえば、Point<T>
に対して、T
に特定のトレイト(Trait)を実装している場合にのみ有効なメソッドを定義することができます。つまり、特定の型にだけそのメソッドが存在し、他の Point<T>
型にはそのメソッドは存在しません。
このように、特定のジェネリクス型に対して特別なメソッドを提供でき、他の型にはそれを提供しないようにできます。
ジェネリクスへの制約(トレイト境界)
ジェネリクスへの制約は、「トレイト境界(Trait Bound)」とも呼ばれ、その構文には 2 つの方法があります:
- ジェネリクス型
T
を定義する際に、T: Trait_Name
のような形式で制限を加える - 戻り値の後、中括弧
{}
の前にwhere
キーワードを使って制限を加える
簡単に言うと、ジェネリクスに制約を加える目的は 2 つあります:
- 関数の内部で特定のトレイトの機能を必要とする場合
- ジェネリクス型
T
が表すデータ型をある程度限定したい場合(制約をしなければ、T
はあらゆる型を意味しうる)
const ジェネリクス
これまで紹介してきたジェネリクスは「型に対する抽象化」であり、異なる型を抽象化するためのものでした。
しかし、同じ型であっても長さの異なる配列は異なる型として扱われます。たとえば [i32; 2]
と [i32; 3]
は異なる配列型です。この問題を解決するには、配列スライス(参照)とジェネリクスを使う方法があります:
fn display_array<T: std::fmt::Debug>(arr: &[T]) {
println!("{:?}", arr);
}
ただし、この方法は参照を使うのが困難、または使えない場面には適していません。そこで使えるのが const
ジェネリクスです。これは値に対するジェネリクスであり、たとえば配列の長さを扱う場合に便利です:
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
println!("{:?}", arr);
}
このコードでは、型 [T; N]
の配列を受け取っています。T
は型に基づくジェネリクス引数、N
は値に基づくジェネリクス引数で、ここでは配列の長さを表します。
N
は const
ジェネリクスであり、構文 const N: usize
により、N
が usize
型の定数ジェネリクスであることを示しています。
const
ジェネリクスが登場する以前、Rust は複雑な行列演算にはあまり適していませんでしたが、これにより大きく状況が変わりました。
補足:たとえば、メモリの少ないプラットフォームで動作するコードを書く場合、関数の引数が使用するメモリサイズを制限したいことがあります。そのような場面でも、const
ジェネリクス表現が役立ちます。
ジェネリクスのパフォーマンス
Rust におけるジェネリクスは「ゼロコスト抽象(zero-cost abstraction)」です。そのため、ジェネリクスを使用する際にパフォーマンス面で心配する必要はまったくありません。一方で、犠牲となるのはコンパイル速度と最終的な生成ファイルのサイズです。なぜなら、Rust はコンパイル時にジェネリクスに対応する複数の型に対して、それぞれのコードを生成するからです。
Rust はジェネリクスのコードに対して、コンパイル時に「単態化(monomorphization)」を行うことで、高い効率性を保証しています。単態化とは、コンパイル時に使用される具体的な型で汎用コードを埋め込み、型に特化したコードへと変換するプロセスです。
これは、私たちがジェネリック関数を定義する流れとは逆であり、コンパイラはすべてのジェネリックコードの呼び出し箇所を見つけ、それぞれの具体的な型に基づいたコードを生成します。そのため、ジェネリクスを使用しても実行時のオーバーヘッドは発生しません。単態化のプロセスこそが、Rust におけるジェネリクスが非常に高効率である理由です。
Rust のコンパイラ(rustc)はコードをコンパイルする際、すべてのジェネリクスを、それが表す具体的なデータ型へと置き換えます。これはちょうど、変数名がコンパイル時にそのメモリアドレスへと置き換えられるのと同じような処理です。
このようにコンパイル時にジェネリクス型が具体型に置き換えられるため、コードは膨張(code bloat)します。つまり、1 つの関数が、0 個・1 個・あるいは複数の具体的な型の関数へと展開される可能性があります。この膨張により、コンパイルされた実行ファイルのサイズが大きくなることもあります。
しかし、多くの場合、このようなコードの膨張は大きな問題にはなりません。
もう一方で、コンパイル時にすでにジェネリクスが具体的なデータ型に置き換えられているため、プログラムの実行中には、それぞれの型に対応する関数を直接呼び出すだけでよく、ジェネリクスが何の型を表しているのかを実行時に判定する必要はありません。そのため、Rust におけるジェネリクスは実行時コストがゼロなのです。
まとめ
Rust では、関数シグネチャや構造体などの定義においてジェネリクスを使用することができ、それによって様々な異なる具体的なデータ型に対応できるようになります。関数、構造体、列挙型、メソッドにジェネリクスを使うことで、コードの柔軟性が高まり、呼び出し元に多様な機能を提供しつつ、重複コードも避けることができます。
ジェネリクスの型パラメータは山括弧(<>
)とキャメルケースの名称 <A, B, ...>
を使って指定します。
Rust はコンパイル時にジェネリックコードの単態化(monomorphization)を行い、効率性を保証しています。単態化とは、コンパイル時に具体的な型で汎用コードを展開するプロセスです。このプロセスにより、ジェネリックコードが膨張することがありますが、それでも Rust におけるジェネリクスは実行時のオーバーヘッドがゼロであるという強力な特徴を持っています。
私たちはLeapcell、Rustプロジェクトのホスティングの最適解です。
Leapcellは、Webホスティング、非同期タスク、Redis向けの次世代サーバーレスプラットフォームです:
複数言語サポート
- Node.js、Python、Go、Rustで開発できます。
無制限のプロジェクトデプロイ
- 使用量に応じて料金を支払い、リクエストがなければ料金は発生しません。
比類のないコスト効率
- 使用量に応じた支払い、アイドル時間は課金されません。
- 例: $25で6.94Mリクエスト、平均応答時間60ms。
洗練された開発者体験
- 直感的なUIで簡単に設定できます。
- 完全自動化されたCI/CDパイプラインとGitOps統合。
- 実行可能なインサイトのためのリアルタイムのメトリクスとログ。
簡単なスケーラビリティと高パフォーマンス
- 高い同時実行性を容易に処理するためのオートスケーリング。
- ゼロ運用オーバーヘッド — 構築に集中できます。
Xでフォローする:@LeapcellHQ
Discussion