[Rustのはじめ方] Part22: ジェネリクスの定義と使用

Rust

ジェネリクスとは? 🤔

プログラミングをしていると、異なる型に対して同じような処理をしたい場面がよくあります。例えば、数値のリストから最大値を見つける、2つの値を比較するなどです。このような場合に、型ごとに同じロジックの関数をたくさん書くのは大変ですよね。そこで登場するのがジェネリクス (Generics) です!

ジェネリクスは、具体的な型を指定せずに、様々な型に対応できる関数やデータ構造(構造体や列挙型など)を定義するための仕組みです。これにより、コードの重複を減らし、より柔軟で再利用性の高いコードを書くことができます。TypeScriptやJavaなどの言語にも同様の機能がありますね。

Rustでは、ジェネリクスを使ってもパフォーマンスが低下する心配はありません。これは、コンパイル時に単相化 (Monomorphization) という処理が行われるためです。コンパイラが、ジェネリクスが使われている箇所を具体的な型に置き換えたコードを生成してくれるので、実行時には型を指定して書いたコードと同じくらい効率的に動作します。まさに「ゼロコスト抽象化」です!✨

関数におけるジェネリクス

関数でジェネリクスを使うには、関数名の後に <T> のように型パラメータを宣言します。T は慣習的によく使われる名前ですが、他の名前(例: U, V など)でも構いません。宣言した型パラメータを、引数の型や戻り値の型として使用できます。

例として、どんな型でも受け取ってそのまま返す単純な関数を見てみましょう。

fn return_as_is<T>(item: T) -> T {
    println!("受け取った値をそのまま返します!");
    item
}

fn main() {
    let number = return_as_is(100);
    let message = return_as_is("こんにちは!");

    println!("数値: {}", number); // 数値: 100
    println!("文字列: {}", message); // 文字列: こんにちは!
}

この return_as_is 関数は、i32 型でも &str 型でも、他のどんな型でも受け取ることができます。<T> で型パラメータ T を宣言し、引数 item の型と戻り値の型を T としています。

ただし、ジェネリクスを使う関数内で、その型に対して特定の操作(例えば比較や加算)を行いたい場合は、その操作が可能であることをコンパイラに伝える必要があります。これにはトレイト境界を使いますが、詳細は次の記事「トレイトとトレイト境界」で解説します。

構造体におけるジェネリクス

構造体でもジェネリクスを使って、様々な型の値を保持できるように定義できます。構造体名の後に <T> のように型パラメータを宣言し、フィールドの型として使用します。

例として、x座標とy座標を持つ Point 構造体を考えてみましょう。座標の型は整数かもしれないし、浮動小数点数かもしれません。

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer_point = Point { x: 5, y: 10 }; // T は i32 になる
    let float_point = Point { x: 1.0, y: 4.0 }; // T は f64 になる

    println!("整数座標: ({}, {})", integer_point.x, integer_point.y);
    println!("浮動小数点座標: ({}, {})", float_point.x, float_point.y);

    // これはエラーになります! x と y は同じ型である必要があるため
    // let mixed_point = Point { x: 5, y: 4.0 };
}

この Point<T> 構造体では、xy は同じ型 T である必要があります。もし異なる型のフィールドを許容したい場合は、複数の型パラメータを使います。

struct PointDifferent<T, U> {
    x: T,
    y: U,
}

fn main() {
    let point1 = PointDifferent { x: 5, y: 4.0 }; // T は i32, U は f64
    let point2 = PointDifferent { x: "Hello", y: 'c' }; // T は &str, U は char

    println!("混合座標1: ({}, {})", point1.x, point1.y);
    println!("混合座標2: ({}, {})", point2.x, point2.y);
}

このように、<T, U> と宣言することで、x フィールドは型 Ty フィールドは型 U となり、異なる型を持つことができます。

列挙型におけるジェネリクス

Rust標準ライブラリでおなじみの Option<T>Result<T, E> も、ジェネリクスを使った列挙型です。

  • Option<T>: 値が存在するかもしれないし、存在しないかもしれない状況を表します。Some(T) は値 T が存在する場合、None は値が存在しない場合です。T がジェネリックな型パラメータです。
  • Result<T, E>: 処理が成功したか、失敗したかを表します。Ok(T) は成功して値 T を持つ場合、Err(E) は失敗してエラー値 E を持つ場合です。TE がジェネリックな型パラメータです。
// Option<T> の定義(簡略版)
enum Option<T> {
    Some(T),
    None,
}

// Result<T, E> の定義(簡略版)
enum Result<T, E> {
    Ok(T),
    Err(E),
}

fn main() {
    let some_number: Option<i32> = Some(5);
    let no_number: Option<i32> = None;

    let success: Result<String, String> = Ok(String::from("成功しました!"));
    let failure: Result<String, String> = Err(String::from("エラーが発生しました..."));
}

このように、列挙型でもジェネリクスを使うことで、様々な型の値やエラーを柔軟に扱うことができます。

メソッド定義におけるジェネリクス

ジェネリックな構造体や列挙型にメソッドを実装する場合も、impl キーワードの後に型パラメータを宣言する必要があります。

struct Point<T> {
    x: T,
    y: T,
}

// Point<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()); // p.x = 5
}

ここで注意が必要なのは、impl<T><T> は「この impl ブロックがジェネリックであること」を宣言し、Point<T><T> は「どの型に対する実装か」を指定している点です。

また、特定の型に対してのみメソッドを実装することも可能です。例えば、Point<f32>Tf32 の場合)にだけ、原点からの距離を計算するメソッドを追加できます。

# 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()
    }
}

fn main() {
    let p_f32 = Point { x: 3.0_f32, y: 4.0_f32 };
    println!("Distance: {}", p_f32.distance_from_origin()); // Distance: 5

    let p_i32 = Point { x: 3, y: 4 };
    // これはエラーになります! Point<i32> にはこのメソッドは実装されていません
    // println!("Distance: {}", p_i32.distance_from_origin());
}

さらに、メソッド定義自体に、構造体の型パラメータとは別のジェネリックな型パラメータを追加することもできます。

struct Point<T, U> {
    x: T,
    y: U,
}

impl<T, U> Point<T, U> {
    // メソッド自身もジェネリックな型パラメータ V, W を持つ
    fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
        Point {
            x: self.x, // self の x (型 T)
            y: other.y, // other の y (型 W)
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 }; // T=i32, U=f64
    let p2 = Point { x: "Hello", y: 'c' }; // V=&str, W=char

    // p1 の x (i32) と p2 の y (char) を持つ新しい Point を作成
    let p3 = p1.mixup(p2); // 戻り値は Point<i32, char>

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y); // p3.x = 5, p3.y = c
}

この mixup メソッドでは、TU は構造体 Point に関連付けられた型パラメータですが、VWmixup メソッド自身に固有の型パラメータです。これにより、異なる型の Point を組み合わせて新しい Point を作るような、より複雑な操作も可能になります。

ジェネリクスのメリット ✨

ジェネリクスを使うことには、以下のような大きなメリットがあります。

  • コードの再利用性向上: 同じロジックを異なる型に対して使い回せるため、コードの重複を大幅に削減できます。
  • 型安全性: コンパイル時に型チェックが行われるため、実行時エラーのリスクを減らしつつ、柔軟なコードを書くことができます。
  • パフォーマンス: 前述の単相化により、ジェネリクスを使っても実行時のパフォーマンス低下がありません。
  • 表現力の向上: より抽象的で汎用的なライブラリやデータ構造を設計できます。

ジェネリクスは、Rustの強力な型システムの中核をなす機能の一つです。最初は少し難しく感じるかもしれませんが、使いこなせるようになると、より効率的で堅牢なコードを書くための強力な武器になります 💪

次のステップでは、ジェネリクスと密接に関連する「トレイト」について学んでいきましょう!

コメント

タイトルとURLをコピーしました