ジェネリクスと単一化

このページの目次

ジェネリクスにより、1つのアルゴリズムが制約を満たす一連の型に適用可能になります。制約は双方に役立ちます:呼び出し側は要件に合致する型を提供する必要があり、関数本体はその要件が約束する能力のみを使用できます。コンパイラは、ある呼び出しでたまたま String が渡されたからといって、宣言されていない文字列専用の操作を許可しません。

定義からのチェックからインスタンス化へ

ジェネリック定義のチェック関数本体は宣言された型制約にのみ依存できる1実際に必要なインスタンスを収集例: choose::<i32> と choose::<String>2具体的な型ごとに生成・最適化インライン展開、定数畳み込み、コードの削除や結合が可能3ジェネリック定義から最終マシンコードへインスタンス化モデルは、最終バイナリに数えられる独立した関数シンボル群が残ることを保証するものではありません。
fn choose<T: Ord>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

fn main() {
    assert_eq!(choose(3, 8), 8);
    assert_eq!(choose(String::from("a"), String::from("z")), "z");
}

このアルゴリズムは全順序比較を必要とするため、Ord が要求されます。PartialOrd に変更すると、比較不可能な値(例:浮動小数点の NaN)の処理戦略を明確にする必要があります。a > b が false であることを一律に「a は b 以下」と解釈することはできません。

rustc は必要に応じて具体的な型のジェネリックインスタンスを収集し、コード生成に進みます。ライフタイムパラメータは主に静的チェックのために機能し、借用の長さごとに別のマシンコードが生成されるわけではありません。単一化

生成プロセスにおけるインスタンスと最終シンボルは1対1で対応しません:使用されていないコードは存在しないこともあり、インライン展開後に関数本体が消滅することもあります。同じ実装は結合される可能性があります。3つの呼び出しがあるからといって、バイナリ内に必ず3つの独立した関数があるとは限りません。

制約は現在の呼び出しから逆算される暗黙の特殊化ではない

fn f<T>(value: T) を定義する際、T は既知の制約を満たす任意の型を表します。たとえ現在のすべての呼び出しで String が渡されたとしても、関数本体は String 固有のメソッドを直接呼び出すことはできません。AsRef<str> などの真に要求される制約を宣言するか、パラメータを直接 &str にする必要があります。これにより、呼び出し側はシグネチャのみで適用範囲を理解できます。

同様に、T: Add は対応する加法実装が存在することを示すだけで、結果が必ずしも T になるわけではありません。アルゴリズムが T に閉じていることを必要とする場合、関連する出力制約 Add<Output = T> を記述すべきです。ジェネリック制約は、アルゴリズムが必要とする操作や型関係に由来すべきであり、コンパイルエラーに出会うたびに Clone、Copy、Default をすべて追加すべきではありません。Trait bounds

同じアルゴリズムでも、所有権の選択によってインターフェースが変わる

上記の choose は2つの T を値で受け取り、勝者(より大きい方)を返します。もう一方の値は解放されます。この関数は Clone 制約を持っていません。値をコピーする必要があるわけではないからです。呼び出し側が既存のオブジェクトを比較してそれらを保持したい場合は、借用バージョンを提供できます。

fn choose_ref<'a, T: Ord + ?Sized>(a: &'a T, b: &'a T) -> &'a T {
    if a >= b { a } else { b }
}
fn main() {
    let first = String::from("alpha");
    let second = String::from("omega");
    assert_eq!(choose_ref(&first, &second), "omega");
    assert_eq!(first, "alpha");
    assert_eq!(second, "omega");
    // str は DST(サイズ不定型)であり、関数はそれを借用しているだけ。?Sized は暗黙の Sized 制約を緩和する。
    assert_eq!(choose_ref::<str>("a", "b"), "b");
}

ここで 'a は、返される参照が入力の借用期間を超えないことを制約しており、2つの文字列オブジェクトの寿命を延ばすものではありません。また、2つのオブジェクトが生成から破棄まで同じライフタイムを持つ必要もありません。呼び出し側では、両方が有効なより短い範囲で再借用できます。

?Sized は T が動的サイズでなければならないことを要求するのではなく、暗黙の Sized を満たさないことを許可するものです。関数は依然として参照を通じて T にアクセスし、参照自体は既知のサイズを持つ値です。これにより、任意の未知サイズの T が直接値としてローカル変数として処理できるようになるわけではありません。

インターフェース引き渡すもの適用対象
fn choose<T: Ord>(a: T, b: T) -> T2つの値の所有権選択後にもう一方の入力を必要としない場合
fn choose_ref(... &T ...) -> &T入力への共有借用コピーせず、2つの元の値を保持する場合
fn choose_cloned<T: Ord + Clone>(...) -> T借用し、選択された値をコピー独立した返り値や、Clone が定義する共有所有権が明確に必要である場合

実際のニーズが単に名前を出力することであるのに、T を返すために Clone を追加し、大きなバッファをコピーする場合、ジェネリックシグネチャは不要な作業を隠蔽している可能性があります。まず返り値の所有権を決定し、その後でトレイト制約を選択します。

静的ディスパッチと「ゼロコスト」の実際の意味

具体的な T は、コンパイラにレイアウトとメソッド実装の情報を提供し、インライン展開や最適化を容易にします。これが必ずインライン展開されることを保証するものではなく、アルゴリズム、メモリ割り当て、境界チェックにコストがかからないことを意味するものでもありません。オーバーヘッドは、同等のセマンティクスを持つ具体的な実装と比較すべきです。

ジェネリクスはトレイトオブジェクトを扱うこともできます:T: Trait + ?Sized は T = dyn Trait を取ることができ、この場合、内部メソッドは依然として動的ディスパッチを行う可能性があります。静的ジェネリクスと動的オブジェクトは組み合わせ可能であり、排他的な言語パターンではありません。

PartialOrd の欠落は明示的な処理が必要

浮動小数点には NaN が含まれるため、偏序しかありません。比較の結果が None の場合、アルゴリズムは拒否するか、スキップするか、明確な全順序を採用する必要があります。これらの戦略は、Ord を PartialOrd に変更しただけでは自動的に得られるものではありません。

use std::cmp::Ordering;
fn choose_partial<T: PartialOrd>(a: T, b: T) -> Option<T> {
    match a.partial_cmp(&b)? {
        Ordering::Less => Some(b),
        Ordering::Equal | Ordering::Greater => Some(a),
    }
}
fn main() {
    assert_eq!(choose_partial(1.5, 2.0), Some(2.0));
    assert_eq!(choose_partial(f64::NAN, 2.0), None);
}

このバージョンでは、比較不可能な場合に2つの入力を捨てて None を返します。呼び出し側がそれらを保持したい場合は、エラー分岐に元の値を持たせるべきです。トレイトは「どのような操作が可能か」を表しますが、関数は操作の結果がビジネスロジックにどのように影響するかを明確にする必要があります。

ジェネリック伝播の範囲を制御する

もし大きな処理の一部がすでに T に依存していないなら、それを具体的な型の関数に移動できます。以下の関数は異なる AsRef<str> 入力を受け取りますが、スキャンロジックは &str だけを必要とします。

fn count_words(input: impl AsRef<str>) -> usize {
    count_words_inner(input.as_ref())
}

fn count_words_inner(input: &str) -> usize {
    input.split_whitespace().count()
}

fn main() {
    assert_eq!(count_words("one two"), 2);
    assert_eq!(count_words(String::from("three")), 1);
}
&str型ごとの薄い入口String型ごとの薄い入口その他の AsRef<str>型ごとの薄い入口count_words_inner(&str)後のアルゴリズムはエントリ型の T に依存しない型依存の収束:複数のエントリポイント、共通のデータ表現これはソースコードレベルでの型の境界です。最終的なインライン展開、関数の結合、およびマシンコードの数は依然としてコンパイラによって決定されます。

この構造は型の依存関係の伝播を減らしますが、実際にマシンコードが減少するかどうかは、インライン展開と最適化の影響を受けます。ジェネリクスを排除するために各要素に対して事前に String を割り当てると、より大きなランタイムコストをもたらす可能性があります。借用や既存の共通表現を優先して探すべきです。

共通表現は元のセマンティクスを保持すべき

複数の型をまず &str に変換することで、文字列にのみ依存するロジックを共有できます。しかし、入力がパス、生バイト、または構造化オブジェクトである場合、無理に String に変換すると、割り当てが発生したり、UTF-8 以外の情報が失われたり、本来表現できたエラーが失われたりする可能性があります。境界の収束は、アルゴリズムが実際に共通表現だけを必要とする場所で発生させるべきです。

クロスクレート(他クレート間)のジェネリクスも、実際のインスタンスに応じてコード生成に参加する必要があります。依存ライブラリが一度コンパイルされれば、すべてのダウンストリームインスタンスを処理する必要がないと単純に考えてはいけません。一方、異なるライフタイムパラメータは通常、個別にマシンコードを複製することはありません。到達していない型の組み合わせを機械的に生成物に含めるべきではありません。

ジェネリックの伝播はどこで止まるか

比較するのは型情報が伝播する範囲です。アルゴリズムが &str だけを必要とするなら、薄いジェネリック入口で借用し、共通の走査関数を呼べます。ブロック数は機械語の複製数ではなく、インライン化、除去、統合が結果を変えます。

図を準備しています
ジェネリックの伝播はどこで止まるか

共通の走査処理を &str にすると、薄いアダプタだけが T に依存します。

関連型制約を使って入力項目を表現し、コンテナを固定しない

アルゴリズムが u32 の反復だけを必要とする場合、Vec<u32> の受け取りを強制する必要はありません。IntoIterator<Item = u32> は、配列、Vec、または u32 を値で生成する他のソースを許可します。借用スライスのデフォルトの反復項目は &u32 であり、この場合、明示的に .copied() を使用して、「要素のコピーを行うかどうか」を呼び出し側で決定できるようにします。

fn total<I>(input: I) -> u64
where
    I: IntoIterator<Item = u32>,
{
    input.into_iter().map(u64::from).sum()
}
fn main() {
    assert_eq!(total([1, 2, 3]), 6);
    assert_eq!(total(vec![4, 5]), 9);
    let values = [6, 7];
    assert_eq!(total(values.iter().copied()), 13);
    assert_eq!(values, [6, 7]);
}

ここで u32 から u64 への変換では個々の数値は失われませんが、無限または十分に長い入力では合計がオーバーフローする可能性があります。合計が u64 を超える可能性がある場合は、チェック付きの累積とエラー返却が必要です。入力コンテナを汎用化することは、数値の安全性の範囲を拡大するものではありません。

const generics:定数も型パラメータである

struct Buffer<T, const N: usize> {
    data: [T; N],
}

impl<T, const N: usize> Buffer<T, N> {
    fn as_slice(&self) -> &[T] { &self.data }
}

fn main() {
    let small = Buffer { data: [1u8, 2, 3, 4] };
    let empty = Buffer::<u8, 0> { data: [] };
    assert_eq!(small.as_slice(), &[1, 2, 3, 4]);
    assert!(empty.as_slice().is_empty());
}

Buffer<u8, 4> と Buffer<u8, 8> は異なる型です。N は構築時に自由に変わるフィールドではありません。実行時に長さが必要な場合は、Vec やスライスの方が適していることが多く、すべての動的なニーズを型パラメータに無理に押し込むべきではありません。

安定した const generics がサポートするパラメータの種類と式には境界があります。配列の長さで N を直接使用することと N + 1 を使用することは同等ではなく、任意のジェネリック定数式が安定して使用できると一概には言えません。ジェネリックパラメータのルール

型パラメータ内の長さと実行時長さ

Buffer<u8, 4> の N は、インスタンスの型が決定された時点で固定されるため、コンパイラはレイアウトを知ることができます。ユーザー入力から得られた len を直接 Buffer<u8, len> と書くことはできません。少数の固定サイズのみをサポートする場合、実行時に分岐して異なる静的インスタンスを選択できます。サイズが任意に変化する場合は、Vec、スライス、または固定の最大バッファに有効な長さを追加するのが自然です。

const パラメータはすべての算術演算が有効であることを自動的に証明するものではありません。N=0 は合法である場合もあれば、あるアルゴリズムの要件に違反する場合もあります。インデックス N-1、N×M のオーバーフローや、スタック上の大きな配列のリソースオーバーヘッドは、依然として明示的に処理する必要があります。数字を型に移動したからといって、ビジネスロジックの条件がすべてコンパイル時の証明になるわけではありません。

impl Trait パラメータは呼び出し側が選択する

パラメータ位置の impl Trait は匿名ジェネリックパラメータを表現します。例えば、上記の impl AsRef<str> がそれです。2つの独立した impl Trait パラメータは、具体的な型が同じであることを要求しません。同じ T を持つ2つのパラメータとして記述した場合、それらは同じ型に統一されることを要求します。

返り値位置の impl Trait は、関数の実装によって隠蔽された具体的な型を選択します。それは、実行するたびに任意の実装型を返せることを意味しません。詳細は 関連型と GAT を参照してください。

2つの匿名パラメータは「同じ型」を表現しない

use std::fmt::Display;
fn show_pair(left: impl Display, right: impl Display) -> String {
    format!("{left}:{right}")
}
fn same_pair<T: Display>(left: T, right: T) -> String {
    format!("{left}:{right}")
}
fn main() {
    assert_eq!(show_pair(7u32, "ready"), "7:ready");
    assert_eq!(same_pair(7u32, 8u32), "7:8");
}

show_pair の2つの実装型は異なる場合があります。same_pair は、その呼び出しにおいて同じ T に統一されることを要求します。パラメータ間や返り値の型の関係を記述する必要がある場合、T を明示的に命名する方が明確なことが多いです。パラメータ位置の impl Trait は dyn Trait ではなく、型名を省略したからといって自動的に動的ディスパッチになるわけではありません。impl Trait

特殊化と一貫性

安定版の Rust では、より具体的な実装で既に重複している一般的な実装を任意に上書きすることはできません。specialization はまだ実験的な機能であり、min_specialization を安定したインターフェースの約束として扱うべきではありません。Unstable Book

安定したソリューションでは、新しい型、明示的な戦略パラメータ、または異なるエントリポイントを使用して、実装の範囲が重複しないようにします。これらのソリューションを選択するのは、インターフェースを明確にするためであり、コンパイラの特殊化がもたらすすべての効果が自動的に得られることを保証するものではありません。パフォーマンスに関心がある場合は、ジェネリックパラメータの数だけを数えるのではなく、コンパイル時間、生成物のサイズ、実際のホットパスを同時に観察してください。

コストをどのように観察するか:ソースコードからマシンコードの数を推定しない

コスト観察指標混入しやすい変数
コンパイル時間同一設定でのクリーンビルドとインクリメンタルビルドを比較キャッシュ、並列度、依存バージョン
生成物のサイズ実行コードセクション、デバッグ情報、総ファイルサイズを個別に記録strip、LTO、パニック戦略、リンカ
実行時間同一データ、セマンティクス、ワークロード割り当て、I/O、ホット/コールドキャッシュ、分岐比率
ホットパスのディスパッチアセンブリ/IR と実際の性能サンプリングの併用インライン展開と仮想化解除後の結果

rustc --edition=2024 -O --emit=llvm-ir example.rs を使用して、独立した例の最適化された IR を確認できますが、1回のシンボル検索の数を言語の保証として記述すべきではありません。インスタンス化の数はコンパイラの作業量の一部分ですが、最終的なコードはインライン展開、削除、または結合される可能性があります。

大規模なループを非ジェネリック関数に分割してビルドが速くなったが、ホットパスが遅くなった場合、元々型情報にもたらされていた最適化が失われたかどうかを確認すべきです。逆に、動的呼び出しを増やすことでコードが小さくなり、命令キャッシュの改善につながる可能性もあります。静的または動的な境界の選択には、インターフェースの表現、コンパイルコスト、実際の負荷を同時に考慮する必要があり、「単一化」を絶対的な勝利のラベルとして扱うべきではありません。