安定化間近!Rustのimpl Traitを今こそ理解する

概要: 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を使う場合は、以下の手順で試すことができます。

  1. Rust Playgroundの右上にある "Nightly" を選択する。
  2. コードの先頭に #![feature(conservative_impl_trait, universal_impl_trait)] を挿入する。
  3. 以下にあるような例を書いて試す。

手元のRustで試す場合は、以下の手順が必要です。

  1. rustup install nightly でnightlyをインストールする。
  2. 以下のどちらかの方法でnightlyを有効にする。
    • cargo +nightly build のように、コマンドに毎回 +nightly をつけることで、一時的にnightlyを有効にできます。
    • 特定のディレクトリで rustup override set nightly を実行することで、そのディレクトリ内で半恒久的にnightlyを有効にできます。
  3. コードの先頭に #![feature(conservative_impl_trait, universal_impl_trait)] を挿入する。
  4. 以下にあるような例を書いて試す。

例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の場合、以下のようにBoxparser!を使わずに部品化することは以前から(場合によっては)可能でしたが、コンビネーターの構造が関数のシグネチャに反映されてしまうという問題がありました。

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のインターフェースとは少し違います。(DefaultEqなどがその例です。)

しかし、トレイトから型を作る構文が2つあります。それが dyn Traitimpl Trait です。(dyn Traitdyn は省略可能) これらはどちらも、具体的な型を隠蔽して、実装しているトレイトにだけ注目するときに使いますが、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 Traitdyn 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_traituniversal_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 Traitimpl Trait について、より詳しく比較しながら説明していきます。

書ける場所

dyn Trait は普通の型なので、型の出現する場所ならどこでも使えます。 (ただし、 Sized でないために限定される)

impl Trait は、今回安定化される範囲内では、以下の位置に出現できます。

  • 関数(通常の関数、固有実装のメソッド、トレイトのメソッド、トレイト実装のメソッド) の引数と境界の中。 (#![feature(universal_impl_trait)])
  • 関数のうち、「通常の関数」と「固有実装のメソッド」の戻り値。 (#![feature(conservative_impl_trait)])

ただし、丸括弧記法 (Fn(T) -> UTU の位置) には出現できません。また、 impl Trait の中に impl Trait をネストさせることもできません。

括弧の位置

dyn Traitimpl 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 Traitimpl Trait はどちらも新規構文で、特に構文を分ける必要はないため、この2つの間の差異はなさそうです。

トレイト境界とライフタイム境界

dyn Traitimpl 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 のライフタイムは大まかにいうと次のように推論されます。

  1. 明示されているときはそれが使われる。
  2. そうでないとき、 Trait に適切な Self: 'a 境界があればそれが使われる。例えば、 Box<dyn Any>Box<dyn Any + 'static> である。 (RFC 0192)
  3. そうでないとき、 dyn Trait が構文上参照で囲まれていればそれが使われる。例えば、 &'a dyn Fn()&'a (dyn Fn() + 'a) である。 (RFC 0599)
  4. そうでないとき、関数内の場合は匿名のライフタイムが割り当てられ、関数外のときは 'static が採用される。例えば、 Box<Fn()> を返す関数の戻り値は Box<Fn() + 'static> である。 (RFC 1156)

ライフタイムに関する仮定

dyn Traitimpl 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 Traitimpl Trait も、基本的にその境界に書かれているトレイトのみを仮定できます。しかしたとえば以下のような例外があります。

  • dyn Trait: !Sized である。 impl Trait: Sized である。
  • dyn Trait: Drop である。 impl Trait: !Drop である。
  • dyn Trait は、境界に書かれていない限り、auto trait (SendSyncなど)を自動で導出することはない。一方、 impl Trait は、もとの型のauto traitを継承する。

まとめ

以下の内容を説明しました。

  • impl Trait が安定化されると何が嬉しいのか?→クロージャなど特殊な型を使うコードが効率的・簡潔に書けるようになる。それがstableバージョンのコンパイラで使えるようになる
  • impl Trait の試しかた
  • dyn Traitimpl Trait の違い: どちらも型の素性を隠して「特定のトレイトを実装している」という風に抽象化するが、 dyn Trait は動的、 impl Trait は静的な抽象化をするため、使える場面に違いがある。
  • より詳しい挙動の説明