27

この記事は最終更新日から1年以上が経過しています。

@statiolake

Rust の関連型はジェネリクスではだめなのか?

これは関連型に対する自分の理解をまとめたものです。高度なトレイト - The Rust Programming Languageの関連型の項を補足する形でもあります。これは変だ、またはもっと正確な認識がある、という場合はぜひぜひ教えていただけると嬉しいです。コードは少し古いですが rustc 1.34.0-nightly (097c04cf4 2019-02-24) により確認しています。

トレイトにおけるジェネリクス・関連型とは

Rust のトレイトは、トレイトにおいて型を抽象化するためにジェネリクスや関連型を用いることができます。まずはこれらについて説明します。まだ本題でないので、知っている方は読み飛ばしていただいて大丈夫です。

ジェネリクスの例

例えば、標準ライブラリの std::convert::From トレイトはある型を別の型の値から生成できることを表すトレイトですが、概ね次のように定義されています。

pub trait From<T> {
    fn from(from: T) -> Self;
}

この定義の <T> となっているところがジェネリクスで、 T という名前の型変数を宣言しています。

このトレイトを使うと、たとえば型 u8 の値を型 i32 に変換できることを表すには impl From<u8> for i32 とできます。また型 U に対して U: From<T> という制約が与えられた場合、型 U は型 T から変換できることを表します。

関連型の例

例えば、標準ライブラリの std::iter::Iterator トレイトは next() メソッドによってある型の値を順々に取り出せることを表すトレイトですが、デフォルト実装されるメソッドやアノテーションなどを除くと、核になるのは次の部分です。

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
}

この定義の type Item; となっているところが関連型で、 Item という名前の型を宣言しています。

このトレイトを使うと、たとえば型 CharIter が型 char の値を生成するイテレータであることを表すには

impl Iterator for CharIter {
    type Item = char;
    // ...
}

とします。また型 U に対して U: Iterator<Item = T> という制約が与えられた場合、型 U は型 T の値を生成するイテレータであることを表します。

本題 : 関連型はジェネリクスではだめなのか?

先の std::iter::Iterator の例を見ていると、関連型を使わなくても

pub trait Iterator<Item> {
    fn next(&mut self) -> Option<Item>;
}

のように、ジェネリクスを使って Item を実装すればいい気がしますが、どうなのでしょうか?

結論から言うと、 この例では、 そのようにトレイトを定義しても使えないことはないです。ただしジェネリクスと関連型は意味が違うので、いくつかの面倒と問題を引き起こします。ざっくり言うと次のような違いです。

※ これでない例で、そもそも 関連型が必要になる例 というのもあります1。ただし今回はそこには踏み込みません。

  • ジェネリクスは「トレイト」と「型変数がとりえる型」が 1 対多
  • 関連型は「トレイト」と「関連型がとりえる型」が 1 対 1

ジェネリクスでは、 Iterator<u8>Iterator<char> は別のトレイトなので、両方を同じ型 T に対して実装することもできてしまいます。すると T に実装されている Iterator<Item>Itemu8char になります。これが 1 対 です。一方、関連型では、トレイトとしてはあくまで Iterator というトレイトなので、これは同じ型 T に対しては一回しか実装できません。その一回の実装の中で Item に型を結び付けるため、やはり Item は一つの型でしかありえません。これが 1 対 1 です。

従って、関連型を使うべき場合とは「ある型に対して別の型が一意に定まる場合」といえます。Iterator などはまさにそうです。型 Vec<u8> をイテレートすると型 u8 の要素が出てくるのが自然でしょう。普通 Stringi32 は出てきません。 String のようにバイト列 [u8] か文字列 [char] か曖昧なコンテナは String::bytes()String::chars() を一旦経由しないとイテレータに変換できないようになっています。いずれにせよ、ある Iterator が存在したとき、そのイテレータが生成できる型が複数あるというのは不自然ですよね。

逆にジェネリクスを使うべき場合とは「ある型に対して別の型がいくつでも有り得る場合」と言えます。From などはまさにそうです。「型 i64 に失敗なく変換できる型」は決して一つとは限らず、 i32u32u8 や... といくらでも考えられます。 From の定義に関連型を使ってしまうと「変換元になる型は何か一つしかない」ことを示すことになります。

では、一意に定まる場合にジェネリクスを使うと何が問題になるかを見ていきましょう。

説明に使うトレイト

「コンテナ型の値から先頭の要素をとりだすことができる」ことを表すトレイト、 GetFirst というものを考えることにします。コンテナ型というのはどのような型の要素を持っているかわかりませんから、ジェネリクスか関連型を使って要素の型を抽象化する必要があります。この場合「コンテナ型の要素の型」というのは Vec<u8> なら u8String なら char(i32, i32) なら i32 というふうにコンテナ型に対応して一通りに定まりますから、ここでは関連型を使うのが正しいです。しかしあえてジェネリクスにしてみましょう。

なお、 Stringchar のコンテナとも u8 のコンテナとも言えて曖昧ではありますが、用途に合わせてコンテナ型に対する解釈を定めれば一通りになります。文字の列ともバイトの列とも言えるかもしれませんが、文字ともバイトとも言えるものの列ではないですよね。

trait GetFirst<Item> {
    fn first(&self) -> Option<Item>;
}

問題 1 : 型推論が効かなくなるとメソッド記法が使えなくなる

ではこのトレイトを仮に StringVec<u8>(i32, i32) に実装してみましょう。

// String を char のコンテナと見て一文字目を返す。
// 返す型は char
impl GetFirst<char> for String {
    fn first(&self) -> Option<char> {
        self.chars().next()
    }
}

// Vec<u8> は言わずもがな u8 のコンテナで、一つ目の要素を返す。
// 返す型は u8
impl GetFirst<u8> for Vec<u8> {
    fn first(&self) -> Option<u8> {
        self.iter().cloned().next()
    }
}

// (i32, i32) を二要素の i32 のコンテナと見て左側を返す。
// 返す型は i32
impl GetFirst<i32> for (i32, i32) {
    fn first(&self) -> Option<i32> {
        Some(self.0)
    }
}

使ってみます:

fn main() {
    let s = String::from("hello");
    println!("{:?}", s.first()); // => Some('h')

    let v = vec![0u8, 1u8, 2u8];
    println!("{:?}", v.first()); // => Some(0)

    let t = (1i32, 2i32);
    println!("{:?}", t.first()); // => Some(1)
}

問題なく実行できますね。しかし、ここにうっかり次のような実装を追加してしまうと問題が見えます。

// String をバイト列のコンテナと見て一バイト目を返す。
// 返す型は u8
impl GetFirst<u8> for String {
    fn first(&self) -> Option<u8> {
        self.bytes().next()
    }
}

うっかりこのような実装を追加してしまうと、 使用箇所で エラーになってしまいます。

error[E0282]: type annotations needed
  --> junk01-19-09.rs:39:24
   |
39 |     println!("{:?}", s.first()); // => Some('h')
   |                        ^^^^^ cannot infer type for `Item`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0282`.

コンパイラが言っていることは至極当然で、 String には GetFirst<char>GetFirst<u8> の両方が実装されているため、型変数 Item の型を推論できないと言っています。これらを呼びわけるためには戻り値型から推論させるか、メソッド記法を諦めて書き下すしかありません。

println!("{:?}", GetFirst::<char>::first(&s)); // => Some('h')
println!("{:?}", GetFirst::<u8>::first(&s)); // => Some(104)

先ほど s.first() だけで正しくコンパイルできたのは、型 String に対して GetFirstGetItem<char> しか実装されていなかったために、自動的に Item = char であると 型推論されたから なのです。あくまでも推論に依存しているので、このように複数の候補がある場合など推論できない状況に陥るとエラーになってしまいます。

今回の例では、コンテナが「持つ」とされる要素の型は本来ただ一つであるはずなのに Stringchar を持つとも u8 を持つともしてしまったためにエラーが起きました。問題はこれが 使用時にエラーになった ということで、型に両方を実装しただけではエラーになりません。それはジェネリクスを使うのが適正なことだってあるから当然です。しかし今回のような場合はそれでは困るので、型引数 Item が異なる GetFirst<Item> を同じ型に対して実装しないように「気をつける」ことでしか対応できません。

また、 GetFirst<Item> がライブラリで定義されているトレイトだったりすると、その用途を誤解したライブラリのユーザーが、同じ型に複数の GetFirst<Item> を実装した上でそれをライブラリとして公開するかもしれません。そうなると本来 GetFirst<Item> が実現したかった意図と実際の使われ方がかけはなれていくこともありえます。

もし Item が関連型になっていれば、この Item の定義は GetFirst を何か型に実装するときになされることとなります。 GetFirst はジェネリクスではありませんから真に単一のトレイトです。すると GetFirstString に実装できる機会は一度だけですから、 Item も必ず一通りにしかなりません。この型は型により一意に定まるものだという意図も明確にできます。

問題 2 : 他のジェネリクスで制約を設定したいときに型変数が増える

これは実質面倒なだけではありますが、 GetFirst を実装した型を関数で受け取りたいときに余計な型変数が増えます。それはもちろんそうで、ジェネリクスのシステム的には GetFirst<u8>GetFirst<char> の両方があるかもしれないので、どちらに対しても呼び出せるよう、ジェネリクスにする必要があります。

fn get_first_of<Item, U: GetFirst<Item>>(x: &U) -> Option<Item> {
    x.first()
}

もし Item が関連型になっていれば、 U: GetFirst のみですみます。 UGetFirst を実装した時点で Item の定義がなされており、必ず一つに定まっているからです。ジェネリクスはトレイトごと (GetFirst<u8>GetFirst<char> は別のトレイトです) に一つの型を、関連型はそのトレイトを実装する型に一つの型を結び付けるものだと考えてもいいかもしれません。

この程度ならば面倒なくらいですむかもしれませんが、これが増えてくると厄介です。意味のない例ですが、例えば次のようにしなければなりません。

use std::ops::Add;

trait Foo<T, U>
where
    T: Copy + Eq + Ord,
    U: Copy + Eq + Ord + Add<T, Output = U>,
{
    fn foo(x: T, y: U) -> U;
}

struct X;

impl Foo<i32, i32> for X {
    fn foo(_: i32, _: i32) -> i32 {
        42
    }
}

fn bar<T, U, V>(_: V)
where
    T: Copy + Eq + Ord,
    U: Copy + Eq + Ord + Add<T, Output = U>,
    V: Foo<T, U>,
{
}

fn main() {
    bar(X);
}

特に bar() に注目してください。このように、とくにジェネリクスの型変数に制約がある場合などは関数ごとに一々制約ごと書き直さなくてはなりません。ジェネリクスを使うべき場合はもちろん制約が必要となるので仕方がないですが、関連型を使うべき場合、つまり型に対して型変数が一通りに定まる場合は、トレイトの実装時に型が定まっているわけなので、関数を呼び出すごとに確認しなくても、そこできちんと制約に則っていることが分かれば本来それでよいはずです。実際、関連型を使えば

use std::ops::Add;

trait Foo {
    type T: Copy + Eq + Ord;
    type U: Copy + Eq + Ord + Add<Self::T, Output = Self::U>;

    fn foo(x: Self::T, y: Self::U) -> Self::U;
}

struct X;

impl Foo for X {
    type T = i32;
    type U = i32;

    fn foo(_: i32, _: i32) -> i32 {
        42
    }
}

fn bar<V: Foo>(_: V) {}

fn main() {
    bar(X);
}

とかけます。特に bar() は非常にすっきりしたと思います。

参考


  1. Rust の関連型の使いどころ | κeen の Happy Hacκing Blog も参考になるかと思われます。 

ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
statiolake

コメント

この記事にコメントはありません。
あなたもコメントしてみませんか :)
ユーザー登録
すでにアカウントを持っている方はログイン
記事投稿イベント開催中
Azure IoTに関する記事を投稿しよう!
~
Qiita 10周年記念イベント - 10年後のために今勉強しておきたい技術
~