Rust入門

【Rust入門】ジェネリックな関数や型の基本を分かりやすく解説

【Rust入門】ジェネリックな関数や型の基本を分かりやすく解説
naoki-hn

Rust でジェネリックな関数や型(構造体・列挙体)を扱う方法を初心者にも分かりやすく解説します。

ジェネリック

ジェネリックとは

ジェネリック(generic)とは、型をパラメータとして受け取ることで、様々な異なる型に対応できるようにする仕組みのことです。

例えば、i32 型専用の関数を作成した場合、i64 型や f64 型に対しては、別の関数を定義しなければなりません。しかし、ジェネリックを使うことで「型に依存しない」関数にすることが可能です。Rust では、ジェネリックを関数、構造体、列挙体などで使用することができます。

この記事では、ジェネリックな関数や型の基本について分かりやすく説明します。なお、公式ドキュメントなどではジェネリクス(generics)という複数形で表現されることも多いですが、この記事では「ジェネリックな」という表現に統一して紹介します。

ジェネリックな関数

Rust は型安全性が高く、コンパイル時に厳密な型チェックを行うプログラミング言語です。もし、関数を書く場合に「fn func(x: i32) -> i32」という型注釈をつけた場合には、i32 型以外の型を渡すことはできません。Rust には多くの数値型があるので、関数を書くときにどの型を選ぶのか、関数名をどうするかといった煩わしさに取り組まなくてはいけなくなります。

以降で紹介するジェネリックな関数を使うことで、この問題を解決することができます。

ジェネリックな関数の定義と使い方

以下の例を使用してジェネリックな関数の定義と使い方を説明します。

// 基本的なジェネリック関数
fn identity<T>(x: T) -> T {
    x
}

// 複数の型パラメータを持つ関数
fn make_pair<T, U>(x: T, y: U) -> (T, U){
    (x, y)
}


fn main() {
    let a: i32 = 10;
    let b: i64 = 50;
    let c: f64 = 100.5;

    // identity 関数の使用
    println!("{}", identity(a));
    println!("{}", identity(b));
    println!("{}", identity(c));

    // make_pair 関数の使用
    println!("{:?}", make_pair(a, b));
    println!("{:?}", make_pair(b, c));
    println!("{:?}", make_pair(c, c));
}
【実行結果】
10
50
100.5
(10, 50)
(50, 100.5)
(100.5, 100.5)

上記は、受け取った引数をそのまま返却する identity 関数と受け取った 2 つの引数をペアのタプルにして返却する make_pair 関数です。

identity 関数の直後に <> で囲んだ T という文字があります。この <T> は、関数定義の型パラメータ(type parameter)と言います。これは、identity 関数が T という型パラメータで表現されるジェネリック関数であることを意味しています。

Tx の型注釈と戻り値の型注釈に使用されています。これにより、xi32 が渡されたときは、戻り値も i32 型、xf64 の場合は、戻り値も f64 というように、同じ型パラメータの部分には同一の型が入ることを示します。実際に、main 関数の identity 呼び出しでは、i32i64f64 の変数を渡していますが、Rust コンパイラが型を推論することで適切に処理ができています。

複数の型パラメータが必要となるような場合には、make_pair 関数での <T, U> のように複数の型パラメータを指定します。TU の部分は任意の文字でよいですが、慣習として T, U, V, …といった型パラメータがよく使用されます。

この時、make_pair 関数の戻り値を (y, x) とすると、TU に同じ型の変数を渡す場合には問題になりませんが、型が異なる場合に不一致が発生してしまうので注意が必要です。(y, x) を返却するようにしたい場合には、戻り値の型パラメータを (U, T) とするのが適切です。

明示的な型指定での呼び出し

Rust は型推論が強力であるため、関数に渡される変数などから型を適切に推論してくれます。型を明示的に指定して呼び出したい場合は「関数名::<型名>()」という形で明示的に呼び出すことも可能です。

fn main() {
    let a: i32 = 10;
    let b: i64 = 50;
    let c: f64 = 100.5;

    // identity 関数の使用
    println!("{}", identity::<i32>(a));
    println!("{}", identity::<i64>(b));
    println!("{}", identity::<f64>(c));

    // make_pair 関数の使用
    println!("{:?}", make_pair::<i32, i64>(a, b));
    println!("{:?}", make_pair::<i64, f64>(b, c));
    println!("{:?}", make_pair::<f64, f64>(c, c));
}

基本的には型推論に任せればよいでしょう。意図的に型を指定したい場合に、明示的に型を指定する方法があるということだけ覚えておいてもらえればと思います。

ジェネリックな構造体

ジェネリックな構造体の定義と使い方

型パラメータは、以下のようにジェネリックな構造体の宣言にも使用することができます。

// ジェネリックな構造体
struct SomeNum<T, U> {
    c: char,
    num1: T,
    num2: U,
    num3: U,
}

fn main() {
    let some_number = SomeNum{
        c:'a', num1: 10, num2: 20.5, num3: 30.5
    };
    println!("{}", some_number.c);
    println!("{}", some_number.num1);
    println!("{}", some_number.num2);
    println!("{}", some_number.num3);

    let some_number1 = SomeNum{
        c: 'b', num1: 10.5, num2: 20, num3: 30
    };
    println!("{}", some_number1.c);
    println!("{}", some_number1.num1);
    println!("{}", some_number1.num2);
    println!("{}", some_number1.num3);
}
【実行結果】
a
10
20.5
30.5
b
10.5
20
30

上記の構造体は、char型のc と、TU という型パラメータの要素を持つ構造体となっています。num1T 型、num2num3U 型の要素になっています。この構造体を使用する場合には、num2num3 は、同じ型でないといけないことに注意してください。

構造体の基本については以下を参考にしてください。

【Rust入門】構造体の使い方を分かりやすく解説

ジェネリックな列挙体

ジェネリックな列挙体の定義と使い方

型パラメータは、以下のようにジェネリックな列挙体の宣言にも使用することができます。列挙体の代表例である Option<T> 型や Result<T, E> 型についても、型パラメータの TE を持つジェネリックな列挙体となっています。

以下では、Result<T, E> に似た独自の MyResult<S, F> 列挙体を定義しています。

// ジェネリックな列挙体
enum MyResult<S, F> {
    Success(S),    // 成功時の値
    Failure(F, String),  // 失敗時の値とエラー文字列
    Uncertainty,
}

fn main() {
    let mut result = MyResult::Success::<i32, u16>(10);
    if let MyResult::Success(code) = result {
        println!("Success {}", code);
    };

    result = MyResult::Failure(1, "Error1".to_string());
    if let MyResult::Failure(code, error) = result {
        println!("Failure {} {}", code, error);
    }

    result = MyResult::Uncertainty;
    if let MyResult::Uncertainty = result {
        println!("Uncertain");
    }

    // result の S は i32型で使用しているので以下はエラーとなる
    // result = MyResult::Success(10.0);

    // 異なる型での利用
    let result = MyResult::Success::<f64, u8>(10.5);
    if let MyResult::Success(code) = result {
        println!("Success {}", code);
    };
}
【実行結果】
Success 10
Failure 1 Error1
Uncertain
Success 10.5

列挙体を使用する際には「MyResult::Success::<i32, u16>(10)」のように SF が何の型であるかを指定して使用します。この時、型パラメータ F については、以降のコードで使用されれば型推論で型を特定できますが、基本的には使用するタイミングで明示して定義するのが良いでしょう。以下のように型注釈をつけても構いません。

    let mut result: MyResult<i32, u16> = MyResult::Success(10);

なお、コメントアウトしている「result = MyResult::Success(10.0);」は、既に resultSi32 型であるのに対して、浮動小数点数を指定しているためエラーとなります。必要であれば、上記例のようにシャドーイングで再定義するか、別の変数で使用する必要があります。

列挙体の基本については以下を参考にしてください。

【Rust入門】列挙型の基本を分かりやすく解説

トレイト境界を利用した型の制約

ジェネリックな関数は便利ですが、任意の型でも受け入れることができてしまいます。そのため、関数内で想定していない型が渡されると、コンパイルエラーとなります。

Rust では「トレイト」という仕組みを使って型の振る舞いを定義できます。ジェネリックな関数では、型パラメータがどのトレイトを実装しているかを指定することで制約を加えることができ、これを「トレイト境界」と呼びます。

use std::fmt::Display;

enum MyResult<S, F> {
    Success(S),    // Result
    Failure(F, String),  // FailureCode と エラー文字列
    Uncertainty,
}

// トレイト境界を指定したチェック関数
fn check_result<S, F> (result: &MyResult<S, F>) 
where S: Display, F: Display
{
    match result {
        MyResult::Success(code) => println!("Success: {}", code),
        MyResult::Failure(code, error) => println!("Failure: {} detail: {}", code, error),
        MyResult::Uncertainty => println!("Uncertain Result")
    }
}

fn main() {
    let mut result = MyResult::Success::<i32, u16>(10);
    check_result(&result);

    result = MyResult::Failure(1, "Error1".to_string());
    check_result(&result);
    
    result = MyResult::Uncertainty;
    check_result(&result);
}
【実行結果】
Success: 10
Failure: 1 detail: Error1
Uncertain Result

上記の check_result 関数では、結果の形式を match で判定して println で出力していますが、その中で {} を指定しています。{} に値を埋め込むには、その型が Display トレイトを実装している必要があります。

SF という型パラメータが Display トレイトを実装していることを where で指定しています。「fn check_result<S: Display, F: Display>() 」と指定することも可能です。 なお、複数のトレイトを実装している必要がある場合は「Display + Debug」のように「+」でつなげていきます。

このようにすることでジェネリックの汎用性を使いつつ、型の制約を入れることで安全な処理を設計することが可能となります。

トレイトの基本については以下を参考にしてください。

【Rust入門】トレイトの基本を分かりやすく解説

ジェネリックとゼロコスト抽象化

Rust のジェネリックは ゼロコスト抽象化(zero-cost abstraction) を実現しています。これは「抽象化を使っても、コンパイル後の実行時コストは手書きのコードと変わらない」という考え方です。プログラミング言語によっては、実行時にどの型かを判別して振る舞いを切り替えるためにパフォーマンスに影響が出る場合があります。

Rust のジェネリックは「モノモーフィゼーション(Monomorphization)」という仕組みで処理されます。これはコンパイル時に、ジェネリック関数や型が実際に使われる具体的な型に展開して最適化されたコードを生成する仕組みです。

例えば、上記でも紹介した identity 関数を考えます。

// 基本的なジェネリック関数
fn identity<T>(x: T) -> T {
    x
}

fn main() {
    // identity 関数の使用
    let a = identity(10);
    let b = identity(20.5);
}

コンパイル後には、identity<i32>identity<f64> という型ごとの別々の関数が具体的に生成されます。そのため、実行時に型を判別して振る舞いを切り替える余分な処理が発生せず、最初から i32 専用関数と f64 専用関数を書いたかのようなパフォーマンスが得られます。

この仕組みにより Rust は抽象化と高パフォーマンスを両立させています。「分かりやすく安全なコードを書きつつ、速度も妥協しない」という Rust の大きな強みが、ジェネリックのゼロコスト抽象化により支えられています。

まとめ

この記事では、Rust におけるジェネリックの基本について解説しました。

  • ジェネリックな関数を使うと、型に依存しない柔軟な関数を定義できます。
  • ジェネリックな構造体や列挙体 により、さまざまなデータ型を扱える再利用性の高い型を設計できます。
  • Rust のジェネリックは、モノモーフィゼーションによりコンパイル時に具体的な型に展開されるゼロコスト抽象化を実現しており、抽象化しても実行時のオーバーヘッドは発生しない高速な処理を実現できます。

ジェネリックを使うことで、安全で読みやすく、高いパフォーマンスを両立できるのが Rust の大きな特徴です。

ABOUT ME
ホッシー
ホッシー
システムエンジニア
はじめまして。当サイトをご覧いただきありがとうございます。
私は製造業のメーカーで、DX推進や業務システムの設計・開発・導入を担当しているシステムエンジニアです。これまでに転職も経験しており、以前は大手電機メーカーでシステム開発に携わっていました。

これまでの業務を通じてさまざまなプログラミング言語や技術に触れてきましたが、その中でもRustの設計思想に惹かれ、この言語についてもっと深く学びたい、そしてその魅力を発信していきたいと思い、このサイトを立ち上げました。

自身の学びを整理しつつ、同じようにRustに興味を持つ方のお役に立てるような情報を発信していければと思っています。どうぞよろしくお願いいたします。

※キャラクターデザイン:ゼイルン様
記事URLをコピーしました