http://rust-lang.github.io/rfcs/1522-conservative-impl-trait.html
- Fature Name: conservative_impl_trait
- Start Date: 2016-01-31
- RFC PR: rust-lang/rfcs#1522
- Rust Issue: rust-lang/rust#34511
(保守的な形で) abstract return types を追加する。 impl Trait
とも呼ばれている機能。
以下のように機能を制限することで、多くの機能拡張と互換性を持たせる。
- 自由に出現する関数 (free-standing functions) もしくは固有関数 (inherent functions) のみ
- 関数の返り値の部分 (return type position) のみ
abstract return types は、トレイトオブジェクトと同様にトレイトの裏に具体的な型を隠す。しかしトレイトオブジェクトと違って具体型で静的にディスパッチするようなコードを生成する。
placeholder syntax とともに使うと、abstract return types は以下のように書ける。
fn foo(n: u32) -> impl Iterator<Item=u32> {
(0..n).map(|x| x * 100)
}
// ^
// ^ return type Map<Range<u32>, Closure> を持っているかのように振る舞う。
// (Closure は |x| x * 100 というクロージャの型).
for x in foo(10) {
// x = 0, 100, 200, ...
}
impl Trait
に関する議論は、コアのアイデアを異なる方向に拡張するような異なる提案として、すでにたくさんある。
- 最初の提案: rust-lang/rfcs#105
- より深く考察したブログポスト: http://aturon.github.io/blog/2015/09/28/impl-trait/
- よりよくした最近の提案: rust-lang/rfcs#1305
このRFCは前方互換性 (将来の拡張との互換性) を持つような最小の変更となっている。しかしながら、上のブログポストの主要な問いを解決するものになっている。
この RFC は最初の RFC と近い精神を持つものであるため、まずそのモチベーションを繰り返す。
なぜこれをするのか?どんなユースケースがあるのか?どんな結果になるのか?
現在の Rust では、関数のシグネチャは以下のように書ける:
fn consume_iter_static<I: Iterator<u8>>(iter: I)
fn consume_iter_dynamic(iter: Box<Iterator<u8>>)
どちらのケースでも、関数は引数の実際の型には依存していない。この型は"抽象的"で、トレイト境界を満たすためだけに使われる。
- ジェネリクスを使っている
_static
版では、この関数を呼び出すときに静的に具体的な型になって、インライン化されて、パフォーマンスもいい。- 静的ディスパッチ
- トレイトオブジェクトを使っている
_dynamic
版では、引数の具体的な型は実行時に vtable を使って判明する。- 動的ディスパッチ
! トレイトオブジェクトというのを知らなかったけれども、trpl https://github.com/rust-lang-ja/the-rust-programming-language-ja/blob/master/1.6/ja/book/trait-objects.md によると
Rustは「トレイトオブジェクト」と呼ばれる機能によって動的ディスパッチを提供しています。トレイトオブジェクトは &Foo か Box の様に記述され、指定されたトレイトを実装する あらゆる 型の値を保持する通常の値です。ただし、その正確な型は実行時になって初めて判明します。 とのこと。
また、Rust では以下のように (トレイトオブジェクトを使って) 書けるが、
fn produce_iter_dynamic() -> Box<Iterator<u8>>
以下のようには書けない。
fn produce_iter_static() -> Iterator<u8>
現在の Rust では、abstract return types はトレイトオブジェクトによってしか記述することができず、しかしトレイトオブジェクトはパフォーマンスがよくない。
この RFC では、prodece_iter_static
のような特徴を持つ "unboxed abstract types" を提案する。ジェネリクスのように、unboxed abstract types は静的ディスパッチとデータレイアウトのインライン化を保証する。
以下は unboxed abstract types が解決 (もしくは軽減する) 問題である:
- unboxed なクロージャを返すこと。クロージャ構文は、Closure トレイトを実装する無名の型を生成する。生成された型の名前を書くことができないので、unboxed abstract types がないと、この構文を使って unboxed なクロージャを返すということはできない。
- API の内部実装の漏れ出し。API がトレイト境界のみを約束 (promise) したいときでも、関数はその内部実装が戻り値の型に現れうる。例えば、
Rev<Splits<'a, u8>>
という型の値を返す関数は、Iterator<u8>
しか約束したくないときでも、イテレータがどのように構築されたかがわかってしまう。プライベートなフィールドを持つ newtype もしくは struct (! どんなテクニックなのか調べてない) を使うとできるが、追加のコードが必要になる。unboxed abstract types はトレイト境界のみを約束したいときに簡単にする。 - 複雑な型。特定のイテレータを使うと複雑な型になる。例は以下。詳細を隠すために newtype を使うときでも、この型は書かないといけない。非常につらい。unboxed abstract types ならトレイト境界を書くだけでいい。
Chain<Map<'a, (int, u8), u16, Enumerate<Filter<'a, u8, vec::MoveItems<u8>>>>, SkipWhile<'a, u16, Map<'a, &u16, u16, slice::Items<u16>>>>
- ドキュメント。現在の Rust では、
Iterator
トレイトのドキュメントを読むのは難しい。多くのメソッドが新しいイテレータを返すが、その返り値はそれぞれで異なる型になっていて (Chain
,Zip
,Map
,Filter
, etc)、それぞれの型のドキュメントを潜っていかないとどんなイテレータかどうかわからない。(! わかる (初心者並の感想))
簡単に言うと、unboxed abstract types はトレイト境界以外を約束しないような関数を書けるようにし、そしてその関数の実装者が具体的な型を書く必要をなくす。
この RFC のはじめに説明したように、この RFC の目的は abstract types の比較的狭い範囲 (固有関数と自由に出現する関数の返り値の型を対象とする) の導入をすることである。"abstract types" というようなコアの疑問点を解決する必要があるが、他の拡張機能をうまくすすめる複雑さを避ける。(?)
自転車置き場の議論を始めよう: 提案する構文は impl Trait
を返り値の型の位置に置くこと、トレイトを組み合わせるときは impl Foo+Send+'a
というような形にすることである。
これは「Trait
というトレイトを実装している型」ということを表し、これまでの多くの議論や提案のなかで決まった形である。
最初の RFC では、この機能は実装されたら広く使われると思われたので短さをとって @Trait
という提案をしていたが、コミュニティの強い反発によってこの現在の形になった。
他にも、abstract Trait
や ~Trait
というような形も考えられたが、具体的な構文はこの RFC のブロッカーにはならないので、構文の変更が必要であれば以降の RFC で検討する。
この機能のコアとなる意味論は以下。
この後の節では、どうしてこういうデザインになっているかについて詳細に説明する。また、挙げられている制限の多くは将来おそらく解決される。簡単のため、「コアとなる意味論」(将来の拡張で変更されない点) と「最初の制限」(あとで解決される可能性が高い) にわけて説明する。
- ある関数が
impl Trait
を返すなら、その本体はTrait
を実装した任意の型の値を返しうる。しかし返す値はすべて同じ型になっている必要がある。 - 型システムとコンパイラでの懸念に関して。関数外での戻り値の型 (impl Trait の関数外での扱い) は全く新しい型ではなく、また、ある型のエイリアスでもない。むしろ、その意味論は関数内のジェネリック型のパラメータとよく似ている。しかしながら関数の入力ではなく出力であることによって小さな差が生じる。
- その型が指定されたトレイトを実装しているとわかる。
- その型が他のトレイトを実装しているかどうかは、OIBITs (auto traits としても知られる) (! opt in builtin traits http://rust-lang.github.io/rfcs/0019-opt-in-builtin-traits.html らしい) とデフォルトのトレイト (e.g.,
Sized
) を除いて、わからない。 - その型は実際の型と等しいとはみなされない。
- その型は impl ブロックの Self 型として現れることは許されない。
Send
やSync
といった OIBITs は abstract return type を抜けて外に漏れるので、必要に応じて現れる non-local な型検査によってコンパイルになんらかの計算 (計算量) が追加される。(! らしい。)- 戻り値の型は、関数の本体がパラメータ化されたすべてのジェネリックなパラメータに基づく識別情報を持っている。これは以下のように振る舞う型の等価性を意味する。
fn foo<T: Trait>(t: T) -> impl Trait {
t
}
fn bar() -> impl Trait {
123
}
fn equal_type<T>(a: T, b: T) {}
equal_type(bar(), bar()); // OK
equal_type(foo::<i32>(0), foo::<i32>(0)); // OK
equal_type(bar(), foo::<i32>(0)); // ERROR, `impl Trait {bar}` is not the same type as `impl Trait {foo<i32>}`
equal_type(foo::<bool>(false), foo::<i32>(0)); // ERROR, `impl Trait {foo<bool>}` is not the same type as `impl Trait {foo<i32>}`
- コンパイラのコード生成パスは、ジェネリックパラメータと同じように、abstract return type と実際の型を区別するような線をひかない。これは以下を意味する:
- 同じトレイトのコードがインスタンス化される、例えば、
-> impl Any
は実際の型のIDが返される。(! コンパイラわからん) - 特殊化 (specilalization, たぶんこれ http://rust-lang.github.io/rfcs/1210-impl-specialization.html) は実際の型に基づいて行われる。
- 同じトレイトのコードがインスタンス化される、例えば、
impl Trait
は自由に出現する関数か固有関数の返り値にしか使えない。トレイトの定義や返り値以外の場所には使えない。また、それ自体が正しい返り値の型の一部でない限り、クロージャートレイトや関数ポインタの返り値には現れない。- 実際、この機能はトレイトの定義の中や関数の入力の位置にも使いたいだろう。
- 返り値の型の部分に
impl Trait
を複数回使うのは問題ない。例えば-> (impl Foo, impl Bar)
- 関数が
impl Trait
を返すときに生成される型は、クロージャや function item のように効率的に名前をつけることができない。- abstract return types を構造体などに配置できるように、長期的にはこの制限を取り除きたいと思うだろう。これを行うにはいくつかの方法があるが、すべてはジェネリックな引数のすべてが与えられた関数の「出力の型 (output type)」に関係する。
- 関数本体からは自身の返り値の型は見えない。なので以下のようなコードは書けない
- この制限を取り除きたいかどうかは不明だが、書けるようになるべき。
fn sum_to(n: u32) -> impl Display {
if n == 0 {
0
} else {
n + sum_to(n - 1)
}
}
! Rust の実装、specialization、OIBITs、に関する知識が必要っぽくて難しかった 🙇
ここから適当 & 未読。
いままで返り値の型についてどんな意味論がいいのか議論してきたけど、実用性と原理の両面からこういう感じになってる。
特殊化透過性の原理: rust-lang/rfcs#1210 の RFC でジェネリクスの境界をどのように理解するかの原理を与えた。(! 同じ型に対して複数の impl を書けて、より特殊な条件の実装が選ばれるようにする、みたいな理解。トレイト境界がいろいろ書いてあると、特殊な実装を使うようになってくれるらしい)
impl Trait
みたいに戻り値の型についても同様で、トレイト境界を書いておけばそれで特殊化してくれる。
(なんやかんや書いてあって) impl Trait
を返すことは T: Trait
を受け取ることの対称になることを目指す。特殊透過性はこの対称性を保持する。(?)
特殊化透過性の実利:
より効率的な特殊なコードで抽象化を破ること。
impl Iterator
を返すときとかに重要。
(! OIBITs の境界が暗黙的についたりする理由について書かれているが、よくわかっていない)
OIBITs が abstract return types を通して漏れる。関数ローカルの型推論の結果がアイテムレベルのAPIに影響を与える (?) のでこれは議論の余地があるとみなされるかもしれないが、以下の理由から価値があると考えられている。
- 人間工学的な観点: トレイトオブジェクトはすでに
Send
やSync
を明示的につけなければいけないという問題を持っており、この問題は abstract return types にまで広げないほうが好ましい。実際、この機能の利用場面の多くでは、関数を最大限使えるようにしたかったら明示的に OIBITs の境界をつけなければいけない - このシチュエーションはプライベートなフィールドを持つ構造体ですでにあるため、変更はほとんどない。 - どちらの場合も、OIBITが実装されているかどうかにかかわらず、プライベートな実装への変更が変わる可能性がある。 - どちらの場合も、OIBITの実装の存在はドキュメントのツールを除いて現れない - どちらの場合も、OIBITの実装の存在は明示的にトレイト境界を追加するかテストケースでしか確かめることができない。 - (! どちらの場合も、ってどの場合)
実際、 ...
この機能に関連する最近の RFC では、より「しっかりと隠す」抽象化メカニズムが提案されている しかし、特殊化の議論ではどのような抽象を提供するかとどのように達成するかについての問題を中心に置いている。 それにある突出した点、Rust では、privacy はすでに隠すための主要なメカニズムになっている。("Privacy is the new parametricity") 実際、これは特殊化に対する不透明性がほしいならば、newtype のようなものを使うべきという意味である。
abstract return type はこのプロポーザルでは名前付けされない。これは struct
の中などにはおけないということを意味している。
これは本質的な制限である。この制限は、このRFCをシンプルに保つため、また、名前付けを許す正確な方法がまだ定まっていないためである。
可能性としては、typeof
という演算子を導入するか、abstract types に明示的に名前をつけるか、というものがある。
いままで abstract types を他の場所にも置けるようにするいろいろな提案があった。例えば、fn x(y: impl Trait)
を fn x<T: Trait>(y: T)
の shorthand として書けるようにする、など。
これらの場所におけるようにすることに関する意味論や UX についてはまだ明瞭でない (impl Trait
は関数の入力位置と出力位置ではまったく異なる) ので、この RFC からは除いた。
abstract types を返す関数は、自身の返り値の実際の型を知ることはできない。以下のコードはコンパイルできない。
fn sum_to(n: u32) -> impl Display {
if n == 0 {
0
} else {
n + sum_to(n - 1)
}
}
この制限は、関数本体が自身の異なるインスタンスについての情報をどのくらい知ることができるかわからないためである。
ジェネリックなパラメータが同じであれば再帰呼び出しを安全に許すこともできそう。ジェネリックなパラメータが異なっていても、異なるインスタンスのプライベートな関数本体の中にいるので問題ないかもしれない。
しかしライフライムパラメータや特殊化の兼ね合いによっては問題ないかわからなくなる。
再帰するようなローカルな関数を作れば大丈夫。
fn sum_to(n: u32) -> impl Display {
fn sum_to_(n: u32) -> u32 {
if n == 0 {
0
} else {
n + sum_to_(n - 1)
}
}
sum_to_(n)
}
impl Trait
は関数本体に関する型を定義するものなので、関数シグネチャが関係しない議論は意味がない。
すでにある impl Trait
の提案の正しい批評で、より複雑なシナリオには対応できないんじゃないかというものがある。
返り値の型が、型パラメータがそのトレイトを実装しているかによって実装するトレイトが変わるような場合である。
例えば、あるイテレータアダプタが Iterator
と DoubleEndedIterator
を、型パラメータがそれを実装しているかによって変わる場合
fn skip_one<I>(i: I) -> SkipOne<I> { ... }
struct SkipOne<I> { ... }
impl<I: Iterator> Iterator for SkipOne<I> { ... }
impl<I: DoubleEndedIterator> DoubleEndedIterator for SkipOne<I> { ... }
-> impl Iterator
を使うと、これはできなくなる。
今のところ、これを fixed-trait-set (?) のケースと矛盾するような方法で対処する提案はないので、このRFCではこの問題は扱わない。
memo
- トレイトのメソッドで使えると嬉しいよね。
- ジェネリックなトレイトメソッドと一緒に使うと、higher kinded types と等価になる。 - Rust に HKT を導入するのはいろいろ議論があるがまだ固まっていない。予期せず変な実装にはしたくないので入れたくない。
- HKT は型コンストラクタをジェネリックに扱えるようにするやつ。後々実際の型でインスタンス化される。例えば、HK な型 T (パラメータとして1つの型をとる) に対して、
T<u32>
やT<bool>
などが、T = Vec
だったりT = Box
だったりというようなものを気にせずに書けるようになる。
trait Foo {
fn bar<U>() -> impl Baz
}
T: Foo
となるような型に対して、T::bar::<u32>
や T::bar::<bool>
にインスタンス化することができ、そして u32
や bool
によってインスタンス化 bar
の戻り値型を通して任意の型を得ることができる。例えば上の例では T<u32>
と T<bool>
は Vec<u32>
や Box<bool>
になったりする。
この問題は、現在のトレイトメソッドでは起こらない。具体型だからである。
trait Foo {
fn bar<U>() -> X<U>
}
上のコードでは、bar
が戻り値の型 X
を選択する方法はない。このメソッドは U
によってインスタンス化されるが、 X
は Self
のインスタンスによって別のものになるからである。
関連型を返すこともできるが、その場合 bar
からジェネリクスの情報が失われてしまうだろう。
trait Foo {
type X;
fn bar<U>() -> Self::X // No way to apply U
}
結論: Rust の HKT の議論はまだ具体化されておらず、そして現在のコンパイラとの互換性の問題もよくわかっていないので、ここでは具体的な解決策について達することはまだできない。
加えて、abstract return types がをれ自身のものにするか関連型の構文糖衣にするか、それが他の関連するアイテムとどのように相互作用するかなど、異なるプロポーザルもあり、トレイトの中で使われることを禁じることは、最初は最善策であろう。
- ! 値が存在しない型も作れて問題?
! 意訳
この RFC はでかい。入れたら何か問題が起こったり非互換になるかも。でもずっと議論されてきたし将来の互換性のために気をつけてデザインされている。
あと Rust を覚えるときも T: Trait
, Box<Trait>
に加えて impl Trait
が増えたり、どれにするか選んだりしないといけなくなる。
! 未読
他にはどのようなデザインが考えられたのか?これを採用しなかった場合のインパクトはなにか?
モチベーションのとこのリンクをみること。
しかし、基本的には、この機能がないと、例えば関数本体にプライベートな型でパラメータ化された効率的に使える型を返すといったようなこと (例えばクロージャを含むようなイテレータアダプタなど) は、難しかったり不可能になります。
デザインのどの部分が未決定なのか?
OIBIT 透過性の部分の実装詳細がまだ明確ではない。(! 理由が続くが未読)