[Rust] ジェネリックの型と境界を理解する基礎知識

はじめに

プログラミング言語Rustにおいて、ジェネリックは非常に重要な概念であり、その理解は開発における効率性やコードの可読性を大きく向上させます。ジェネリックを用いることで、異なるデータ型に対して同一の処理ロジックを適用することが可能となり、再利用性や柔軟性を持つプログラムを簡単に実現できます。本記事では、Rustにおけるジェネリックの基礎をまとめようと思います。

Rustにおけるジェネリック型とジェネリクス境界の基礎

Rustのジェネリック型とその活用法

Rustは強力な型システムを持つプログラミング言語であり、特にジェネリック型はその重要な一部を成しています。ジェネリック型を使用することで、コードの再利用性を高め、柔軟性を持たせることができます。

例えば下記のコードを考えます。形が異なるだけで内容が完全に同じになっています。

fn largest_i32(list: &[i32]) -> i32 {
    let mut largest = list[0];
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn largest_char(list: &[char]) -> char {
    let mut largest = list[0];
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];
    let result = largest_i32(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];
    let result = largest_char(&char_list);
    println!("The largest char is {}", result);
}

このようなケースで型を引数とすることができます。文字はなんでも使えますが、Tがよく使われます。
書き直したものが下記となります。コンパイラーはこれを利用して適切な型を決定し、型安全性を保証しつつ、コードのDRY (Don’t Repeat Yourself) 原則にも貢献します。

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];
    let result = largest(&number_list);
    println!("The largest number is {}", result);

    let char_list = vec!['y', 'm', 'a', 'q'];
    let result = largest(&char_list);
    println!("The largest char is {}", result);
}
実行してみると

任意の型Tに対して動作保証されていないのでエラーが出る。

こちらのコードを実行すると上記のようにエラーが出力されます。Tがなりうる全ての型に対して動作する必要があるからです。Rustでは、ジェネリック型に関して時に制約を与える必要があります。この制約を通じて、特定のトレイトを実装することを求めることができ、これによりジェネリック型がどのように利用されるか詳しく制御することができます。

上記のエラは次の節で修正します。

Rustにおけるジェネリクス境界の役割と注意点

ジェネリクス境界は、Rustにおけるジェネリック型の制限を定義するために利用されます。この境界により、ジェネリック型Tが特定のトレイトを実装することを求めることで、その型に特化した操作が可能になります。ジェネリクス境界を上手に使うことで、型の有効性や正当性をより厳密に制御することができます。また、境界を設けることで、コンパイル時に不要なエラーを未然に防ぐことができ、より安全なコードの記述が可能になります。しかし、境界を誤って設定すると、かえってコードの柔軟性を失う場合もあるので、適切な使い方を心がける必要があります。このような型制約は、時に必要な計算資源を抑制し、コードのパフォーマンスを向上させる鍵となります。

境界を追加して前節のエラーを改修します。

とあるので、Tのトレイと境界にPartialOrdを追加してみます。

fn largest<T: PartialOrd> (list: &[T]) -> T 

ついに解決か?と思いますが、次のコンパイルで別のエラーが出てきます。
i32charのようなサイズが既知の型については、Copyトレイが実装されています。T型は任意の型を想定しており、Copyトレイとが含まれない可能性があるので、下記のエラーが出ています。
要するにCopy境界を追加すれば直ります。

fn largest<T: PartialOrd + Copy> (list: &[T]) -> T 

これで、コンパイルが通ります。

Rust impl Traitとwhere節での使い方

Rustの”impl Trait”と”where”節は、ジェネリクスを使用したプログラミングにおいて大変有用な機能です。これらの機能は、関数の引数や返り値の型をより簡潔に記述するための手段を提供します。”impl Trait”を使用することで、関数の返り値や引数に特定のトレイトを実装している型を指定し、より柔軟な型システムを実現できます。この記法は、トレイトを利用した多態性を容易にします。また、”where”節は、関数や構造体の定義において、ジェネリクス型に対して詳細な境界を設定する方法として利用され、コードの可読性を高める効果があります。これにより、ジェネリック型に関するコードが明快になるため、複雑な型制約を扱う際には大きな助けとなります。Rustの強力な型システムを最大限に活用するために、これらの構文を熟知することが望まれます。

以下のような例を考えています。具体的には、山カッコのなかにジェネリックな型引数の宣言を置き、型引数の後ろにコロンを挟んでトレイ境界を置いている例です。この形は、糖衣構文(syntax sugar)
と呼ばれるもので冗長なものです。

pub fn hogehoge<T: Test>(item: &T)

これをimpl Traitを用いると簡潔に書くことができる。

pub fn hogehoge(item: &impl Test)

また、次のように引数が二つあるときはどうなるでしょうか?

//1
pub fn hogehoge1(item1: &impl Test, item2: &impl Test)
//2
pub fn hogehoge1<T: Test>(item1: &T, item2: &T)

1のケース:Testトレイとを実装しているものであれば型が異なることは許容します。より複雑な状態を表現することができます。
2のケース:ジェネリック型で統一させられているため、item1item2の型が同一である必要がある制約を与えています。

これまでは、少数のトレイトの例だけでした。しかし、たくさんのトレイト境界を使うこともあります。このケースの欠点は、引数にたくさんのトレイト境界が含まれるため可読性が低下します。それを防ぐためにwhere句があります。

fn example<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
// where句を使うと下記

fn example<T, U>(t: &T, u: &U) -> i32 
    where T: Display + Clone,
          U: Clone + Debug
{

書き換えた後の見通しがよくなっていると思います。
関数名、引数リスト、戻り値が近いからですかね?

さいごに

他の言語でもジェネリックはありますが、使いこなせていないので使いこなせるようになるぞ!