概要: impl Trait
が安定化間近である。これはトレイトオブジェクトと似た用途を持つが、静的ディスパッチされSized
のまま使えるため効率的である。
impl Trait
が安定化間近
Rustでは新規の機能はまずnightlyバージョンに「不安定機能 (unstable feature)」として取り入れられます。そこでの実験を経て、プログラミング言語Rustに半恒久的に導入してもよいと合意されたものだけが「安定化 (stabilize)」され、betaやstableバージョンのコンパイラでも使用できるようになります。
さて、現在 「impl Trait
」と呼ばれる機能の安定化のめどがたったというアナウンスがありました。この機能は2016年夏ごろに実装され、長い間待ち望まれてきた目玉機能のひとつでしたが、ここにきてようやっと、という感じです。そこで、 impl Trait
について今一度このブログで解説してみたいと思います。
impl Trait
が使えると何が嬉しいのか
impl Trait
は、戻り値型を隠蔽する(トレイトオブジェクトに代わる)手段を提供します。特に、クロージャやイテレータ、パーサコンビネータなど、型が煩雑になりがちなものを返したいときに有効です。 impl Trait
を使うことで、
- クロージャのようにトレイトオブジェクトにより隠蔽するしかなかったケースでは、より効率的なコードを書ける可能性があります。
- イテレータやパーサコンビネータのように隠蔽せず型を書き下しているケースでは、煩雑な型を明示しなくてよくなる可能性があります。
impl Trait
を今すぐ試すには
Rust Playgroundを使う場合は、以下の手順で試すことができます。
- Rust Playgroundの右上にある "Nightly" を選択する。
- コードの先頭に
#![feature(conservative_impl_trait, universal_impl_trait)]
を挿入する。 - 以下にあるような例を書いて試す。
手元のRustで試す場合は、以下の手順が必要です。
rustup install nightly
でnightlyをインストールする。- 以下のどちらかの方法でnightlyを有効にする。
- コードの先頭に
#![feature(conservative_impl_trait, universal_impl_trait)]
を挿入する。 - 以下にあるような例を書いて試す。
例1: クロージャを返す
与えられたクロージャを二回適用する別のクロージャを返すプログラムは、トレイトオブジェクトを使って例えば以下のように書けます。(Box<dyn Trait>
は Box<Trait>
の新しい記法です)
#![feature(dyn_trait)] pub fn twice<'a, T: 'a>(f: Box<dyn Fn(T) -> T + 'a>) -> Box<dyn Fn(T) -> T + 'a> { Box::new(move |x| f(f(x))) } // もう少し使いやすいバージョン pub fn twice<'a, T: 'a, F: Fn(T) -> T + 'a>(f: F) -> Box<dyn Fn(T) -> T + 'a> { Box::new(move |x| f(f(x))) }
これは、 impl Trait
を使うと以下のように書けます。
#![feature(conservative_impl_trait, universal_impl_trait)] pub fn twice<T>(f: impl Fn(T) -> T) -> impl Fn(T) -> T { move |x| f(f(x)) }
これで無駄な Box
とおさらばすることができます。
例2: イテレータを返す
イテレータを返す関数も、以下のように簡単に書くことができます。
#![feature(conservative_impl_trait, universal_impl_trait)] // 奇数を列挙するイテレータ fn odds() -> impl Iterator<Item=i32> { (0..).map(|x| x * 2 + 1) } fn main() { println!("{:?}", odds().take(10).collect::<Vec<_>>()) }
例3: パーサーコンビネータを返す
以前の記事でも紹介したパーサーコンビネーターライブラリcombineの場合、以下のようにBox
やparser!
を使わずに部品化することは以前から(場合によっては)可能でしたが、コンビネーターの構造が関数のシグネチャに反映されてしまうという問題がありました。
extern crate combine; use combine::{Parser, Stream, many1}; use combine::char::{letter, spaces, Letter, Spaces}; use combine::combinator::{Many1, Skip}; fn word<I: Stream<Item = char>>() -> Skip<Many1<String, Letter<I>>, Spaces<I>> { many1(letter()).skip(spaces()) } fn main() { println!("{:?}", word().parse("foo bar baz")); println!("{:?}", word().parse("012 foo bar baz")); }
これは以下のように impl Trait
を使うとすっきり抽象化することができます。
#![feature(conservative_impl_trait, universal_impl_trait)] extern crate combine; use combine::{Parser, Stream, many1}; use combine::char::{letter, spaces}; pub fn word<I: Stream<Item = char>>() -> impl Parser<Input = I, Output = String> { many1(letter()).skip(spaces()) } fn main() { println!("{:?}", word().parse("foo bar baz")); println!("{:?}", word().parse("012 foo bar baz")); }
impl Trait
とは何か
以下、 impl Trait
について詳しく説明していきます
型とトレイトは本来別物
Rustのトレイトは、C++のコンセプトやHaskellの型クラスに近いものです。型が値を分類するのに対し、トレイトは型自体を分類します。この点でJavaのインターフェースとは少し違います。(Default
やEq
などがその例です。)
しかし、トレイトから型を作る構文が2つあります。それが dyn Trait
と impl Trait
です。(dyn Trait
の dyn
は省略可能) これらはどちらも、具体的な型を隠蔽して、実装しているトレイトにだけ注目するときに使いますが、dyn Trait
は動的に、impl Trait
は静的に解決されるという違いがあります。
dyn Trait
の仕組みとデメリット
まずは見慣れた dyn Trait
から説明します。 dyn Trait
なんて見たことない、と思われるかもしれませんがそれもそのはず、この構文はRFC2113で変更されたばかりでまだ安定化されていません。Box<Trait>
とか、トレイトオブジェクトといえば通じると思います。型とトレイトは本来別物なのに、構文からそれが見えないことが混乱のもとになっていたため、トレイトオブジェクトであることを明示するために dyn
が導入されました。そのため、本記事では dyn Trait
構文を一貫して使うことにします。
dyn Trait
の仕組みは、仮想関数テーブルを用いた動的ディスパッチです。 Box<T>
を Box<dyn Trait>
に変換するとき、元の型は忘れられてしまいますが、かわりにこのポインタがfatポインタになります。つまり、 T
自体へのポインタに加えて T
の仮想関数テーブルへのポインタを保持するようになります。x86-64環境なら、 Box<T>
は8byteなのに対して、 Box<dyn Trait>
は16byteです。データ本体に仮想関数テーブルへのポインタを置くC++とは異なり、Rustではこのようにfatポインタ内仮想関数テーブルへのポインタを置きます。
この仕組みのため、dyn Trait
にはいくつかのデメリットが存在します。まず、使えるトレイトが限定されます。 dyn Trait
ではfatポインタから元の型由来の情報を復元するため、&self
引数が1個もなかったり、逆に複数ある場合には呼び出せなくなってしまいます。またfatポインタを使う都合上、self
のムーブ渡しはできません。これらの条件をオブジェクト安全性といいます。
また、必ずポインタ経由になるのと、間接コール命令になるため、実行効率が悪くなる可能性があります。
impl Trait
の仕組みとデメリット
dyn Trait
が dyn Trait
という1つの型であったのに対して、 impl Trait
は実はそういう型があるわけではありません。これらは匿名の型を表すためのシンタックスシュガーで、 impl Trait
と書くたびに別の型に翻訳されます。そのため、構文上の使える場所が限られます。
実は、この impl Trait
は、場所によって2通りに翻訳されます。
引数で使われた場合 (RFC 1591)
引数位置の impl Trait
は、匿名の型引数に翻訳されます。頻出例は以下のようにコールバック関数を使う場合です。例えば、
#![feature(conservative_impl_trait, universal_impl_trait)] // コールバックに42を渡すだけの関数 fn give_42_to_callback(callback: impl Fn(i32)) { callback(42) }
という関数があった場合、これは以下のように翻訳されます。
// コールバックに42を渡すだけの関数 fn give_42_to_callback<F: Fn(i32)>(callback: F) { callback(42) }
このように、 impl Trait
が引数で使われた場合は、 Trait
を実装する匿名の型引数に置き換えられます。したがって、引数位置の impl Trait
は単なるシンタックスシュガーですが、これは戻り値位置の impl Trait
を理解する上でも重要な点を含んでいます。つまり、
- 引数位置の
impl Trait
の型は、呼び出し側によって静的に決定される
ということです。
戻り値で使われた場合 (RFC 1522)
上に書いたことを戻り値で置き換えたものがそのまま成り立ちます。つまり、
- 戻り値位置の
impl Trait
の型は、呼び出された側によって静的に決定される
ということです。この「呼び出された側によって決まる型」は存在型といいますが、このための構文はまだRustには実装されていません。ここでは、将来実装されるであろうRFC2071から記法を借用することとすると、
#![feature(conservative_impl_trait, universal_impl_trait)] // 42を返すクロージャを返す fn defer_42() -> (impl Fn() -> i32) { || 42 }
は、以下のように匿名の存在型に置き換えられると考えることができます。
#![feature(???)] // 42を返すクロージャを返す fn defer_42() -> Anon1 { || 42 } existential type Anon1: Fn() -> i32;
Fn() -> i32
を実装する特定の型だが、その中身が何なのかは明かされないのがポイントです。このexistential typeはnewtypeパターンとも似ていますが、クロージャのような特殊な型も含められることと、手動で Fn() -> i32
を実装しなくてもよいところが特徴です。
デメリット
されこの impl Trait
ですが、まず使える場所が限られるのが一つ目のデメリットです。いくつか拡張案がありますが、今回安定化される conservative_impl_trait
と universal_impl_trait
では、関数/メソッドでしか使うことができません。また、トレイトメソッドの戻り値には使用できません。
もう一つのデメリットとして、あくまで元の型を型システム上隠蔽しているだけなので、動的に内容を切り替えることはできません。例えば、以下のように条件に応じて異なる型のイテレータを返すコードは、 impl Trait
では実現できません。
#![feature(conservative_impl_trait, universal_impl_trait)] use std::iter; // nの倍数を列挙 (コンパイルエラー) fn multiples_of(n: i32) -> impl Iterator<Item=i32> { if n == 0 { //~ERROR if and else have incompatible types iter::once(0) } else { (0..).map(move |m| n * m) } }
ifを動かすなどして型を揃えるか、あきらめて dyn Trait
を使うのが正解です。
#![feature(dyn_trait)] use std::iter; // nの倍数を列挙 fn multiples_of(n: i32) -> Box<dyn Iterator<Item=i32>> { if n == 0 { Box::new(iter::once(0)) } else { Box::new((0..).map(move |m| n * m)) } }
より詳しい比較
ここから先は、 dyn Trait
と impl Trait
について、より詳しく比較しながら説明していきます。
書ける場所
dyn Trait
は普通の型なので、型の出現する場所ならどこでも使えます。 (ただし、 Sized
でないために限定される)
impl Trait
は、今回安定化される範囲内では、以下の位置に出現できます。
- 関数(通常の関数、固有実装のメソッド、トレイトのメソッド、トレイト実装のメソッド) の引数と境界の中。 (
#![feature(universal_impl_trait)]
) - 関数のうち、「通常の関数」と「固有実装のメソッド」の戻り値。 (
#![feature(conservative_impl_trait)]
)
ただし、丸括弧記法 (Fn(T) -> U
の T
と U
の位置) には出現できません。また、 impl Trait
の中に impl Trait
をネストさせることもできません。
括弧の位置
dyn Trait
と impl Trait
の括弧の位置について、mini-RFC 2250 で議論中です。 &(x + y)
や &(Trait + Send)
との一貫性を保ちつつ、使いやすく間違いにくい構文が望まれていますが、残念ながら万能な方法はなさそうです。細かい論点があって整理するのが大変ですが、結論としては以下のような妥協点で落ち着きそうです。
+
は&
より弱い。つまり、+
を使うときは&(dyn Error + Send)
のように括弧を入れる必要がある。- 同様に、
fn foo() -> impl Fn() -> (dyn Error + Send)
のようにFn() ->
の直後で+
を使う場合も括弧が必要。 - 上との一貫性を保つため、
fn foo() -> (impl Error +Send)
の位置にも(+
を使う場合は)括弧が必要。
いずれにせよ、 dyn Trait
と impl Trait
はどちらも新規構文で、特に構文を分ける必要はないため、この2つの間の差異はなさそうです。
トレイト境界とライフタイム境界
dyn Trait
と impl Trait
では、書ける境界の種類が異なります。
dyn Trait
に書けるもの- ちょうど1個の主トレイト。object-safeでなければならない。必ず最初に書く。
- 0個以上の追加トレイト。auto traitでなければならない。
- 高々1個のライフタイム。0個の場合は省略されたとみなされる(推論方法は後述)。
impl Trait
に書けるもの- 1個以上のトレイト。順番に意味はないが、最初はトレイトでなければならない。
- 0個以上のライフタイム。
トレイトがobject-safeであるとは、以下の条件を満たしていることをいいます。
- 直接的または間接的な出力が全て埋められている (e.g.
dyn Iterator<Item=char>
はOK,dyn Iterator
はダメ) - 直接的または間接的に
Self: Sized
を含意していない。 (e.g.dyn Default
はダメ) - 祖先トレイトを含む全てのメソッドがobject-safeである。メソッドがobject-safeであるとは、そのメソッドが
Self: Sized
を含意しているか、または以下の条件を満たしていることをいう。- 型パラメーターを持たない。
- 第一引数が
&self
,&mut self
,self: Box<Self>
のいずれかである。 - 他に
Self
が出現しない。 (Self::Item
とかはOK)
auto trait は、名前通り auto trait
で宣言されているトレイトで、 Send
, Sync
, UnwindSafe
, RefUnwindSafe
などがそれに当たります。
dyn Trait
のライフタイムは大まかにいうと次のように推論されます。
- 明示されているときはそれが使われる。
- そうでないとき、
Trait
に適切なSelf: 'a
境界があればそれが使われる。例えば、Box<dyn Any>
はBox<dyn Any + 'static>
である。 (RFC 0192) - そうでないとき、
dyn Trait
が構文上参照で囲まれていればそれが使われる。例えば、&'a dyn Fn()
は&'a (dyn Fn() + 'a)
である。 (RFC 0599) - そうでないとき、関数内の場合は匿名のライフタイムが割り当てられ、関数外のときは
'static
が採用される。例えば、Box<Fn()>
を返す関数の戻り値はBox<Fn() + 'static>
である。 (RFC 1156)
ライフタイムに関する仮定
dyn Trait
と impl Trait
では、ライフタイムに対する仮定は大きく異なります。
dyn Trait
の値は全く未知の型に由来する可能性があり、ライフタイムについては書かれている境界からしか推測できません。ライフタイムを省略した場合に上記のように推論されるのはそのためです。(0個=どのような生存期間も仮定できない、となってしまうため)impl Trait
は「関数の型引数」と「関数のライフタイム引数のうち、impl Trait
内に構文的に出現するもの」でパラメーター化されたnewtypeに過ぎないため、これらのパラメーターが生きていれば生きていることがわかります。
後者はわかりにくいので補足します。例えば、
fn foo<T, U>() -> impl Fn() { .. }
というシグネチャの場合、 impl Fn()
にはライフタイム境界がついていません(dyn Trait
と異なり、推論されているわけでもありません)。しかし、 dyn Trait
とは異なり、この場合は impl Fn(): 'a
となる十分条件が残されています。それは、 T: 'a, U: 'a
となることです。(impl Fn()
の中身は Anon1<T, U>
のような型であるため)
同一性
上述のように、 dyn Trait
は構文的に同じなら全て同じ型なのに対し、 impl Trait
は出現ごとに全く異なる型になります。つまり、
fn foo1() -> Box<dyn Trait> { .. } fn foo2() -> Box<dyn Trait> { .. } fn foo3() -> impl Trait { .. } fn foo4() -> impl Trait { .. }
に対して、 if true { foo1() } else { foo2() }
は通りますが、 if true { foo3() } else { foo4() }
はコンパイルエラーになります。
impl Trait
の実際の型は位置だけではなくて、その関数のジェネリクス引数にも依存します。具体的には以下のジェネリクス引数に依存します。
- その関数の全ての型引数。
- その関数のライフタイム引数のうち、
impl Trait
の境界部分に構文的に出現するもの。
以下の例を参照してください。
#![feature(conservative_impl_trait, universal_impl_trait)] use std::fmt; // T に依存したものは常に返せる fn foo1<T: fmt::Debug>(x: T) -> impl fmt::Debug { x } // 'a に依存したものは返せない fn foo2<'a>(x: &'a i32) -> impl fmt::Debug { x // ERROR } // 'a は明示的に含まれるため、 'a に依存したものも返せる fn foo3<'a>(x: &'a i32) -> (impl fmt::Debug + 'a) { x }
トレイトの透過性
dyn Trait
も impl Trait
も、基本的にその境界に書かれているトレイトのみを仮定できます。しかしたとえば以下のような例外があります。
dyn Trait: !Sized
である。impl Trait: Sized
である。dyn Trait: Drop
である。impl Trait: !Drop
である。dyn Trait
は、境界に書かれていない限り、auto trait (Send
やSync
など)を自動で導出することはない。一方、impl Trait
は、もとの型のauto traitを継承する。
まとめ
以下の内容を説明しました。