Rustで型推論を助けるトリック

Rust業界に詳しくないので既に広く知られているものかもしれないが、昨晩突如として以下のようなトリックを思いついた。

struct X<T> {/* fields omitted */}
impl X<f32> {
    pub fn f32(self) -> Self {self}
}
impl X<f64> {
    pub fn f64(self) -> Self {self}
}

let x = X::new(/* ... */).f64()
//                       ^^^^^^
// 他の関数呼び出しなどからは無理でも、これのおかげでT = f64と推論できる

いや、明示的に型指定しろよ、それかデフォルト型パラメータを使えよ、と思う方が大半であろうが、以下のようなケースで便利になるのではないかと思う。というか困っていたから思いついた。

まず、何らかの物体の三次元座標が入ったファイルがあり、それを読み込みたいと思っているとする。

A     1.00 1.00 1.00
B     2.00 3.00 1.00
...

これを読み込む際、例えばデータ量が超絶多いとか、可視化できればそれでいいから精度は低くていいとか、あるいはその両方といった理由でf64ではなくf32を使いたいケースというのはあるだろう。そういう場合、まあcfgでビルド時に決めてしまってもいいのだが、ジェネリクスを使って解決することもできる。

struct Particle<T> {
    name: std::string::String,
    pos : [T; 3],
}

で、これを読み込みたいとき、例えばちょっとLazyにしたかったりするだろう。そういうときはIteratorimplするのがよさそうだ。

struct Reader<R> { /* fields omitted */ }

impl<R: std::io::Read> std::iter::Iterator for Reader<R> {
    type Item = Particle<T>;
    //                  ^^^
    // この型パラメータTはどうするのか?
    fn next() -> Option<Particle<T>> {
        // ...
    }
}

すると、IteratorItemに型パラメータが必要になる。だが、Readerは今Particleの型パラメータTを知らない。なのでTを決めようがない。

仕方がないので、Readerにダミーのパラメータを持たせることになるだろう。

struct Reader<T, R> {
    _marker: std::marker::PhantomData<fn() -> T>,
    // ...
}

impl<T, R: std::io::Read> std::iter::Iterator for Reader<T, R> {
    type Item = Particle<T>;
    fn next() -> Option<Particle<T>> {
        // ...
    }
}

さて、このReadernewするとき、どうすることになるだろうか。

理想は、R: std::io::Readの方は型推論が何とかしてくれて、T: f32 | f64の方だけユーザーが明示的に指定できるというものだろう。

let reader = Reader::<f32>::new(
        std::fs::File::open("example.dat").unwrap()
    );

だがこれは通らない。C++template関数は決まっていない型パラメータだけを明示的に指定することを許すが、Rustは許さない。二つパラメータがあるなら二つ書く必要がある。

まあそれでも、「これは型推論しろ」という意思をアンダースコアで表現できるのでまだマシだが。

let reader = Reader::<f32, _>::new(
        std::fs::File::open("example.dat").unwrap()
    );

これでいいのでは? いや、考える必要があるのはユーザーのことだ。新規ユーザーがこのサンプルコードを見たら、「おや、このライブラリは柔軟性のために書きやすさと学習コストを犠牲にしているようだぞ? こんな単純なサンプルですら型推論が助けにならないなら、多分この後ずっと、どの型が何個ジェネリクスパラメータを取るか毎回調べないといけなくなるんだろうな、はーめんどくせ。なんのために型推論があると思ってるんだ。Rustやめちまえ」と思うだろう。

このユーザーは私の心の声でもある。昔書いたライブラリの構造体のジェネリクスパラメータの数なんか覚えてねえよ。コンパイラの仕事だろ。コンパイラが数えろ。

で、最初は「仕方ない、たいてい必要になるのはファイルを開いて読み込むことだから、それをするユーティリティ関数でも書いて、そこでf32f64を選べるようにするか」と考えた。

pub fn open<T, P>(path: P) -> Result<Reader<T>>
where
    P: std::convert::AsRef<std::path::Path>
{
    // ...
}

ああーっ、また型パラメータ増やしてやがる! お前はいつもそうだ。誰もお前を愛さない。

さて、そうなると、やはりこの関数を呼ぶときにも型パラメータの数を意識しないといけない。

let reader = crate::reader::open::<f32, _>(
        "example.dat"
    ).unwrap();

何も簡単になってないじゃないか!

というわけで、冒頭のトリックが有用になったりするわけだ。

impl<R> Reader<f32, R> {
    pub fn f32(self) -> Self {self}
}
impl<R> Reader<f64, R> {
    pub fn f64(self) -> Self {self}
}

これを使うとrustc型推論を補助することができる。

let reader = crate::reader::open("example.dat").unwrap().f32();

f32()を呼べるのはReader<f32, R>だけだから、T = f32に決定する。そしてopenができるのはReader<T, std::fs::File>だけだから、R = std::fs::Fileに決まる。というわけで、明示的に型パラメータを一切指定せずに、全ての型パラメータを決めることができるようになった。

別の行に書いても多分大丈夫だと思う。

if let Ok(reader) = crate::reader::open("example.dat") {
    let reader = reader.f32();
}

これちょっと格好良くないか? ちょっとビルダーパターンっぽい気もする。動的に型変えられるようになってる感があって面白い。

実際は、動的に変わっているのではなくて、rustcが型不定のまま1行めを解釈したあとreaderに対してf32()が呼ばれているのを見てそこで1行めのreaderReader<f32, File>だったことを知るのだ。なので実際には遡って型パラメータが決定されていっている。

これで型パラメータに怯えることはなくなった。型パラメータの数をいちいち数える日々から開放された。

安心して今夜も眠れるだろう。