RustでOptionやResultの配列ができてしまったときの一般的なテク4つ
Vec<Result<_>>
ではなく Result<Vec<_>>
を得る
collect()
関数を使うと、 Vec<Result<_>>
を得ることもできるし、 Result<Vec<_>>
を得ることもできる。変換先の型を明示することで区別する。
fn main() { // 全てSomeならSome(配列)を返し、どれかがNoneなら全体もNoneになる assert_eq!([Some(1), Some(2)].iter().cloned().collect::<Option<Vec<_>>>(), Some(vec![1, 2])); assert_eq!([None, Some(2)].iter().cloned().collect::<Option<Vec<_>>>(), None); }
これができるのは以下の理由による。
FromIterator
は多対多である。つまり、ひとつの変換元に対して複数の変換先を定義できるようになっている。Option
とResult
は以下のFromIterator
を定義している。
impl<A, E, V: FromIterator<A>> FromIterator<Result<A, E>> for Result<V, E> { .. } impl<A, V: FromIterator<A>> FromIterator<Option<A>> for Option<V> { .. }
Some
/Ok
なものだけ抜き出す
flat_map
を使う。
fn main() { // 25を引き、引けなかったものは取り除く let v = [30u8, 40, 17, 80].iter() .flat_map(|&x| x.checked_sub(25u8)) .collect::<Vec<_>>(); assert_eq!(&v, &[5, 15, 55]); }
Result
と Option
はそれ自体が IntoIterator
である。(Ok
/Some
なら1要素、それ以外なら0要素として振る舞う。) そのため flat_map
が使える。
和や積を求める
Result
限定で Option
にはない。 Result<>
型のイテレーターに対して直接 sum
/product
を使うことができる。
fn main() { // 配列内の文字列をパースして、全て成功したらOk(和)、どれかが失敗したらErr let s = ["10", "20", "30"].iter() .map(|&s| s.parse::<i32>()) .sum::<Result<i32, _>>(); assert_eq!(s, Ok(60)); let s = ["10", "2o", "30"].iter() .map(|&s| s.parse::<i32>()) .sum::<Result<i32, _>>(); assert!(s.is_err()); }
これは Result
が以下のような Sum
/Product
の実装を持っているからである。
impl<T, U, E> Sum<Result<U, E>> for Result<T, E> where T: Sum<U> { .. } impl<T, U, E> Product<Result<U, E>> for Result<T, E> where T: Product<U> { .. }
失敗したらデフォルト値を入れる
単に要素ごとに unwrap_or
系関数を呼べばよい。
fn main() { // パースして失敗したら0にする let v = ["10", "2o", "30"].iter() .map(|&s| s.parse::<i32>()) .map(|r| r.unwrap_or(0)) .collect::<Vec<_>>(); assert_eq!(&v, &[10, 0, 30]); }
Rust パターンマッチの網羅性
Rustのパターンマッチは網羅性が検査され、網羅的でない場合はコンパイルエラーになる。網羅性は以下のように検証される。
型の分類
パターンマッチの網羅性をするときには、全ての型がADTのように扱われる。つまり、有限個の引数をとるコンストラクタが有限個あり、そのいずれかにより生成されていると仮定して、網羅性が判定される。
constructor_arity
あたりを読むとわかるが、例えば、
- 通常のADTは、そのままその意味でADTとみなされる。
- ただし、空なADTであっても、空であることが今いるモジュールからわからない場合は、余分なコンストラクタを持っているとみなされる。
bool
はtrue
とfalse
の2つのコンストラクタからなるとみなされる。u8
やi32
などはオープンな型とみなされ、全ての定数を網羅しても網羅的とはみなされない。これを修正する提案がある。- 参照は単一のコンストラクタを持つものとみなされる。
- 固定長配列は単一のコンストラクタを持つものとみなされる。
- スライスは長さごとに別々のコンストラクタを持つものとみなされる。ただし、計算の都合上、ある長さより長いものはまとめて扱われる。
パターンは以下のどれかに分類される。
- ワイルドカードに相当するパターン (
_
や識別子への束縛) - 単一のコンストラクタに対応するパターン (
&p
や(p1, p2)
など) - 複数のコンストラクタに対応するパターン (スライスパターンの一部)
複数のコンストラクタに対応するパターンはあまり多くないので、簡単のため以下ではなかったことにする。
アルゴリズム
Rustのパターンマッチの網羅性は is_useful
関数に帰着される。 is_useful
関数は以下の問題を解く。
入力:
- パターンからなる n×m行列 M
- パターンからなる 1×mベクトル v
出力:
- m個の値の組で、Mにマッチせずvにマッチするものがあるか否か
この関数は、マッチの各腕の到達可能性にも使われるし、網羅性検証にも使われる。到達可能性ならば
- m = 1
- M = ある腕より前にある腕のうち、ガードのないもの
- v = 該当の腕
とし、網羅性検証ならば
- m = 1
- M = 全ての腕のうち、ガードのないもの
- v = ワイルドカード
とおけばよい。
is_useful
は以下のように解かれる。
- m = 0, n = 0 のとき、true
- m = 0, n > 0 のとき、false
- m > 0 のときは、最初の列に注目して処理をする。
- v0がコンストラクタならば、これに基づいて以下の処理をする。
- Mの各行を、v0のコンストラクタにより特殊化する。v0のコンストラクタのarityをkとすると、新しい行列は(m-1+k)列になる。行数はn以下になる。
- v自身も同様に特殊化する。
- 特殊化したMとvに対して、
is_useful
を実行する。
- v0がワイルドカード相当ならば、
- v0の型から考えられるコンストラクタと、Mの左端の列に出現するコンストラクタを比較する。
- 網羅されていないコンストラクタがある場合は、true
- 全てのコンストラクタが網羅されている場合は、v0の全てのコンストラクタについて、特殊化した
is_useful
を試す。
空な型への対応
空(uninhabited)な型とは、値を作ることができないことがわかる型である。
空な型は #![feature(never_type)]
の有無により挙動が異なる。
#![feature(never_type)]
がない場合
原則として、型は非空なものとして扱われる。ただし、マッチの腕が0個の場合は、以下のいずれかの場合に網羅的とみなされる。
- マッチ対象の型が
!
である。 - マッチ対象の型が列挙型で、その列挙型がバリアントをひとつも持っていない。
例えば、以下の例では #![feature(never_type)]
を外すとコンパイルが通らない。
#![feature(never_type)] enum Empty {} fn f(x: (Empty,)) { match x {}; } fn main() {}
#![feature(never_type)] enum Empty {} fn f(x: Option<Empty>) { match x { None => {}, } } fn main() { }
#![feature(never_type)]
がある場合
#![feature(never_type)]
がある場合、空な型は以下のいずれかである。
!
- 空なフィールドを持つタプルや構造体
- 空なフィールドからなる共用体
- 全てのバリアントが空なフィールドを持つような列挙型
- 空な要素型を持つ要素数1以上の配列
- 空な型への参照
例外として、フィールドや型がmatchのあるモジュールから不可視の場合は、その構造体は非空と仮定される。
まとめ
- パターンマッチの網羅性は、およそ期待された通りにチェックされる。ただし、以下の例外がある。
- 整数型の網羅性は適切にチェックされない。
- 空な型のパターンマッチングは現在のところかなり保守的に検査される。ただし、
#![feature(never_type)]
を使うと、より意味的に正しい方法で検査されることになる。
Rustのmatchにおけるカンマの省略
Rustの match
の腕はカンマで区切られるが、これが省略できる場合が2つある。
- 末尾のカンマ
=> { .. }
の直後のカンマ
fn main() { match Some(1) { Some(x) => { } // => { .. } の直後にはカンマは省略可能 None => () // 末尾のカンマは省略可能 } }
この2の規則のために、matchの腕では以前の記事で説明したのと同じ打ち切り規則が適用されている。すなわち、
if (else)
,if let (else)
,match
,{}
,while
,while let
,loop
,for in
のいずれかの式である。.
(メソッド呼び出し) や?
(try構文) が後続しない。
が満たされるとき、その位置でパースが打ち切られる。
そのため以下のようなソースは正しくパースされる。
fn main() { match Some(1) { Some(x) => {10}.to_string(), None => "".to_string(), }; }
なお、上の条件にあるように、 {}
以外にも if
式などでパースが打ち切られることがあるが、式文のパースとは異なり、これらの式でコンマが省略された場合はパースエラーになる。これが意図した挙動かどうかはよくわからない。
fn main() { match Some(1) { Some(x) => if true { 10 } else { 20 } + 30 None => 40 }; }
Rustの構造体/列挙型フィールドの並べ替え
現在の安定版では無効化されているが、Rustのbeta/nightlyには構造体/列挙型フィールドの自動並べ替えが実装されている。この動作を説明する。
例
以下のようなプログラムを書くと、現在の安定版では6が表示され、beta/nightlyでは4が表示される。
use std::mem::size_of; struct A(u8, u16, u8); fn main() { println!("{}", size_of::<A>()); // 6 or 4 }
これはアラインメントと関係がある。通常 u8
は1バイト、 u16
は2バイトの境界に揃えられている必要がある。そのためこの構造体/列挙型のフィールドを宣言順に並べると、
u8
, 1バイトパディング、u16
,u8
, 1バイトパディング
となる。 (配列として並べることもあるため、最後にもパディングが必要である。)
一方、これを並び替えると、
u16
,u8
,u8
となり、アラインメントの制約を満たしつつよりコンパクトに構造体を表現することができる。
構造体/列挙型フィールドの並べ替えが発生する条件
並べ替え処理は rustc::ty::layout
に書いてあるのでここを読むとわかる。
#[repr(C)]
,#[repr(packed)]
,#[repr(simd)]
,#[repr(linear)]
のいずれでもない。- 以下のいずれかである。
構造体/列挙型フィールドの最適性条件
まず、フィールドのアラインメントが小→大→小のときのみ無駄が発生する、という性質を把握しておくとよい。例えば上の例でも u8
→u16
→u8
の順になっている。対偶をとると以下のことが言える。
フィールドのアラインメントの前半が降順、後半が昇順に並んでいるとき、最小バイト数(フィールドのサイズの和を、アラインメントの倍数になるように切り上げた値)を達成する。
証明: まずは、全体が降順に並んでいる場合を考える。このときはパディングは構造体の末尾にしか存在しない。最後のパディングはちょうど、構造体全体が構造体のアラインメントの倍数になるように切り上げる最小限のサイズになるから、最小バイト数が達成される。
次に、一般に全体が降順→昇順の場合に、全体が降順の場合と同じサイズが達成されることを、フィールド数に関する帰納法で示す。まずフィールド数が0のときは明らかである。1つ以上のフィールドがあるとき、最初のフィールドと最後のフィールドのうち少なくとも一方が、アラインメント最大のフィールドである。
- アラインメント最大のフィールド + 残り+パディング のとき: 「残り」の開始位置は「残り」の要求するアラインメントに沿っているため、この部分単体で考えたときの配置と一致する。帰納法の仮定よりこの部分を降順に並べ替えても同じサイズとなる。このとき全体でみても降順である。
- 残り+パディング + アラインメント最大のフィールド のとき: これを アラインメント最大のフィールド + 残り+パディング と入れ替えてもアラインメントの制約に反しない。これにより上に帰着される。
構造体/列挙型フィールドの並べ替え順
さて、現在のbeta/nightly Rustでは先ほどの条件を満たすとき、以下のようにフィールドが並べ替えられる。
- univariantなデータ型では、「最後の要素が
!Sized
になり得るかどうか」により異なる動作をする。- 最後の要素も必ず
Sized
である場合は、全ての要素が降順に並べ替えられる。 - 最後の要素が
!Sized
になりえる場合は、最後以外の全ての要素が降順に並べ替えられる。
- 最後の要素も必ず
- 判別子をもつ列挙型では、判別子を除く全ての要素が昇順に並べ替えられる。
このように、並び替えてはまずいケースが考慮されている。例えば、以下のように動作する。
macro_rules! offset_of { ($x:expr, $field: tt) => ( (&$x.$field as *const _ as usize - &$x as *const _ as usize) ) } struct A<X>(i8, i8, X); struct B<X: ?Sized>(i8, i8, X); fn main() { let x = A(0, 0, 0i32); let y = B(0, 0, 0i32); let z = B(0, 0, 0i16); // 4, 5, 0 println!("{}, {}, {}", offset_of!(x, 0), offset_of!(x, 1), offset_of!(x, 2)); // 0, 1, 4 println!("{}, {}, {}", offset_of!(y, 0), offset_of!(y, 1), offset_of!(y, 2)); // 0, 1, 2 println!("{}, {}, {}", offset_of!(z, 0), offset_of!(z, 1), offset_of!(z, 2)); }
このように、同じような構造体でも、 X: Sized
の有無によって配置が異なることがあり得る。
理由はもちろん、型強制やキャストによりこれを別の型に読み替える可能性があるからである。例えば上の &y
や &z
は、 &B<Debug>
という型に変換することができる。このとき最後以外の要素は一定の位置にあってほしいから、可変長である最後の要素が途中に来るのは好ましくない。
なお、 y
と z
で最後の要素の位置は異なるから、この要素に統一的にアクセスするのは簡単ではない。vtableの中にサイズとアラインメントの情報があり、これを使うことでこの要素にうまくアクセスするようになっている。
列挙型ではフィールドは降順ではなく昇順に並べ替えられる。これは、構造体とは反対に、列挙型では先頭にある判別子を動かせないからである。そこで判別子以外を昇順にすることで必ず降順→昇順となり、同じく最適性が保証される。
まとめ
- Rustの構造体/列挙型のメンバは動作や互換性に影響のない範囲内で並べ替えられることがある。
- 現在のbeta/nightlyで採用されているアルゴリズムは最適である。アラインメントを守る配列のうちパディングが最も少なくする配置のうちの1つが得られる。
Rustの配置構文とbox構文
概要: Rustの不安定機能である配置構文とbox構文の仕組みを説明する。
配置構文の動機
Rustの値渡しはデフォルトでムーブであり、コピーコンストラクターのような重い処理が勝手に実行されることはないから、多くの場面では値渡しのコストはそれほど高くない。それでも、大きな構造体を受け渡すと memmove
のコストが高くつく場合がある。
とりわけ、データ構造に値を追加する場面では、無駄なムーブが発生している可能性が高い。これを最適化するために、ライブラリのインターフェースに工夫を加えるのが、Rustの配置構文である。C++の emplace_back
と似ていると考えてよいだろう。
配置構文の使い方
配置構文の具体的な構文は、以下の2種類が提案されており、今のところは確定していない。
in PLACE { EXPR }
構文PLACE <- EXPR
構文
PLACE
の部分は、配置構文のための専用の関数を使う。例えば Vec
の末尾に値を配置するには以下のようにする。
let mut v = vec![1, 2, 3]; v.place_back() <- 4; // v.push(4); とほぼ同義
配置構文は値をもつ。上の場合は末尾の要素への &mut
参照が返される。
let mut v = vec![1, 2, 3]; *(v.place_back() <- 4) = 5;
配置構文を使うと、次のような巨大なデータの追加でスタックオーバーフローが回避される可能性がある。
#![feature(collection_placement)] #![feature(placement_in_syntax)] fn main() { let mut vec = vec![]; vec.place_back() <- [0; 16*1024*1024]; // vec.push([0; 16*1024*1024]); println!("foo\n"); }
配置できるデータ構造
#![feature(collection_placement)] #![feature(placement_in_syntax)] use std::collections::{VecDeque, LinkedList, BinaryHeap, HashMap}; fn main() { // Vec の末尾 let mut vec = vec![]; vec.place_back() <- 1; // VecDeque の先頭と末尾 let mut list = VecDeque::new(); list.place_front() <- 1; list.place_back() <- 2; // LinkedList の先頭と末尾 let mut list = LinkedList::new(); list.front_place() <- 1; list.back_place() <- 2; // BinaryHeapへの追加 let mut h = BinaryHeap::new(); &mut h <- 1; // HashMapへの追加 let mut h = HashMap::new(); h.entry("foo") <- 3; }
配置構文の仕組み
配置構文は、次の2つの値の組み合わせで実現される。
これを使って、配置構文は以下の処理をする。
Placer::make_place
により、領域を確保する。- これが完了した時点で、配置先のメモリは書き込み可能になっていなければならない。
make_place
は領域不足などの理由で失敗してもよい。
- これが完了した時点で、配置先のメモリは書き込み可能になっていなければならない。
Place::pointer
により、配置先を確認し、ここに出力するようにEXPR
を実行する。- もし
EXPR
が失敗したら、InPlace::finalize
が呼ばれないままInPlace
がdropされる。このタイミングで、必要に応じて領域の巻き戻しを行う。
- もし
InPlace::finalize
により配置の完了を通知する。
例えば、 Vec
の場合、「領域の確保」は十分な容量を確保するだけの操作になる。この場合、 EXPR
に失敗しても不整合な状態にはなっていないから、 PlaceBack
は drop
を実装する必要はない。かわりに、成功時には finalize
でサイズを1増やすことになる。
一方、ツリーマップのようにノードごとに malloc
で要素を確保するデータ構造では、要素の追加に失敗したら巻き戻し処理が必要になるかもしれない。その場合は finalize
よりも drop
のほうに重要なコードが集中することになるだろう。
PLACE <- EXPR
はおよそ以下のように脱糖される。ただし、 EXPR
は unsafe
で囲まれていないかのように扱われる。
{ let p = PLACE; let mut place = ::std::ops::Placer::make_place(p); let raw_place = ::std::ops::Place::pointer(&mut place); unsafe { ::std::intrinsics::move_val_init(raw_place, EXPR); ::std::ops::InPlace::finalize(place) } }
Box
に対する配置
HEAP
を使うと、 Box::new
を配置構文で行うことができる。
#![feature(placement_in_syntax)] #![feature(box_heap)] use std::boxed::HEAP; fn main() { let x: Box<_> = HEAP <- 1; }
box構文
現在の box x
は単に Box::new(x)
の構文糖衣である。しかし、もともと box
が配置のための構文として考えられていたこともあり、これを一般の配置newとして使うことが提案されている。
これによると、 box EXPR
は以下のように脱糖される。ただし、 EXPR
は unsafe
で囲まれていないかのように扱われる。
{ let mut place = ::std::ops::BoxPlace::make_place(); let raw_place = ::std::ops::Place::pointer(&mut place); unsafe { ::std::intrinsics::move_val_init(raw_place, EXPR); ::std::ops::Boxed::finalize(place) } }
これは PLACE <- EXPR
とよく似ているが、 PLACE
がなく BoxPlace
トレイトによりシングルトンとして生成されているという違いがある。
なお、現在はこれは実装されていない。型推論まわりの問題があるからである。
Rustの型推論の概略
概要: Rustの型推論の大枠を説明する。
なお、筆者もRustの型推論の動作を詳細に把握しているわけではない。
短くまとめると
- Rustの型推論はHindley-Milner型推論ベースである。したがって後方の式や文の内容から手前の型が推論されることもある。しかし以下に挙げるようなRust独自の特徴をもつ。
- 型推論は原則として関数定義の中で閉じており、関数の引数型や戻り値型は推論されない。これは、関数を抽象化の境界とするための意図的なものである。この意味で「局所的」というのであれば間違いではない。
- ただし、let多相を含む型推論を避ける意図もあるかもしれない。
- 関数呼び出しや一部の演算子などは、その部分の制約を立てる段階で、型の一部分が判明している必要がある。この動作は推論順序の影響を受ける。
- トレイトによりオーバーロード可能になっている関数や演算子は、射影型を使っている場合、ボトムアップにしか推論されない(式全体の型からオペランドが推論されない)。
- 型強制は、その時点で型のミスマッチが判明している場合にのみ発生するので、推論順序の影響を受ける。
- その他、整数型と浮動小数点数型が特別扱いされていたり、生存期間に関して部分型付けをもつための特別処理があるなどの違いがある。
Phase3の大まかな流れ
型検査・型推論はPhase3の一環として行われる。Phase3の流れは以下の通りである。
- 言語アイテム (
Sized
などの処理系が特殊扱いするアイテム) の収集 - 生存期間の名前解決
- ソースコード中のリージョンの列挙
break
/continue
とループの対応関係の検査static
の定義に再帰・相互再帰がないかの検査- 不安定機能の検査
- 型推論・型検査
static
/const
の安全性検査- 可視性検査
- イントリンシックの事前条件検査
unsafe
検査match
検査- 生存検査
- 右辺値の
Sized
検査 - MIR検査
- 借用検査
- 到達可能性検査
- デッドコード検査
- 未使用機能検査
- リント検査
つまり、型推論の段階では、収集したリージョンをもとにリージョンの上界と下界の推定も行う。借用同士がコンフリクトしているかどうかは後の検査で行う。
Hindley-Milner型推論
Rustの型推論はHindley-Milner型推論である。このことはrustc::infer
のREADMEで言及されている。
This is loosely based on standard HM-type inference, but …
このREADMEでは、部分型付けについて重点的に補足されている。本記事では部分型付けの詳細には立ち入らないことにする。なお、Rustの部分型付けはリージョンの大小によって発生するものである。
Hindley-Milner型推論についてはすでに多くの解説があるが、ここで大まかに説明する。この型推論は、「変数を立てる」「方程式を立てる」「方程式を解く」という流れで型を決定していくものである。例えば、
let x = Default::default(); let y = 1; let mut z; z = x; z = y;
というコードを考える。大雑把に言うと、この場合は3つの変数が生成される。つまり、 x
の型, y
の型, z
の型である。これを以下 ?X
, ?Y
, ?Z
と呼ぶことにする。
この例では単純な代入しかないため、方程式は以下のように立つ。
?Y = i32
?Z = ?X
?Z = ?Y
一般には Vec<_>
のように型コンストラクタが関わってくる場合もある。いずれにせよ、このような方程式は一般に最汎単一化子アルゴリズムと呼ばれる方法で解くことができる。
最汎単一化子アルゴリズムは次の3つのうちのいずれかの結果を出す。
- 唯一解がある場合: 型推論は成功となる。
- 変数が残ってしまった場合: 情報が足りず、型推論は失敗となる。ただし、言語によってはデフォルト値を適用する場合がある。
- 解なし: 型エラーにより型検査は失敗となる。
上の例では ?X = ?Y = ?Z = i32
が唯一解となる。
Hindley-Milner型推論は、体系によっては全く型注釈なして推論できるが、Rustの場合はこれはあまり当てはまらない。一方、Hindley-Milner型推論のもう一つの特徴として、式の出現順にかかわらず制約を収集して柔軟性の高い推論を行えることが挙げられるが、これはRustの場合はそれなりに当てはまる。
例えば以下のプログラムでは、タプル x
の要素型は最初の文からはわからないが、後続の文から推論されている。
fn main() { let mut x : (_, _) = Default::default(); x.0 = "foo".to_string(); x.1 = vec![2, 2, 4]; println!("{:?}", x); }
インクリメンタルな型推論
Hindley-Milner型推論では、制約を集める途中でインクリメンタルにそれを解くこともできる。Rustの場合、Hindley-Milner型推論の通常の等式制約は、インクリメンタルに解くようになっている。
Rustの場合は部分型付けがあるため、等式ではなく不等号制約も扱いたい場合がある。また型だけではなく生存期間や型+ミュータビリティーなどにも不等号制約を解かせたいことから、これを rustc::ty::relate
として一般化している。
最汎単一化子アルゴリズムは型の木構造にそって同値判定をする。この一般的なルーチンはsuper_relate_tys
に実装されている。変数の処理は別の場所にあり、関係の種別(等号か不等号か)により実装が分けられている。例えば rustc::infer::equate
の tys
関数 を見ると変数の処理がわかる。
制約の収集順序
理想的な(例えばSTLCに対する)Hindley-Milner型推論では、制約の収集順序は型推論の動作に影響を与えない。というのも、制約の収集のために、その制約を解いた結果自体が必要となることがないからである。
しかし、Rustの場合はそうはいかない。型がある程度わかっていないと、次の制約集めがそもそもできない場面があるからである。
Rustコンパイラでいうと、 rustc_typeck::check
などで structurally_resolved_type
メソッドを呼んでいる部分がそれに当たる。例えば以下のような場面がそれに当たる。
f()
の被呼び出し側f
x.some_method()
,x.some_field
,x.0
におけるレシーバーx
- 理由: 名前解決に必要なため。また、自動参照外しのため。
*x
,x[i]
のオペランドx
- 理由: 自動参照外しのため。
!x
,-x
のオペランドx
- 理由: 基本型のヒューリスティックスのため。 (
-x
は整数型に対しては同じ整数型が推論される)
- 理由: 基本型のヒューリスティックスのため。 (
以下がその実例である。
fn main() { { let mut x = Default::default(); // if let Some(x) = x { x(); } // Error x = Some(main); if let Some(x) = x { x(); } } { let mut x = Default::default(); // x.clone(); // Error x = 1; x.clone(); } { let mut x = Default::default(); // x.0; // Error x = (1, 2); x.0; } { let mut x = Default::default(); // *x; // Error x = Box::new(0); *x; } { let mut x = Default::default(); // -x; // Error x = 0; -x; } { let mut x = Default::default(); // x[1]; // Error x = [0, 1, 2]; x[1]; } }
型強制
Rustの型強制は、式の型と必要な型が異なるときに、自動的に変換を挟むものである。そのため、その時点で型がどれくらい判明しているかに応じて、挙動が変わる可能性がある。
例えば、以下のように、推論順序によってコンパイルできたりできなかったりする可能性がある。
fn main() { { let mut y = Default::default(); y = Box::new(Box::new([1, 2, 3]) as Box<[_]>); y = Box::new(Box::new([1, 2, 3])); let z : Box<Box<[_]>> = y; } // Error /*{ let mut y = Default::default(); y = Box::new(Box::new([1, 2, 3])); y = Box::new(Box::new([1, 2, 3]) as Box<[_]>); let z : Box<Box<[_]>> = y; }*/ }
型強制サイトは check_expr_coercable_to_type
や demand_coerce
を呼んでいる箇所であり、以下の位置にある。
static
/const
の右辺→左辺型- 関数・クロージャの最後の式→戻り値型
return
の式→戻り値型let
の右辺→左辺型 (左辺にref
がない場合のみ)- 代入の右辺→左辺型
[x; n]
の要素→期待されている要素型- タプルの要素→期待されている要素型
x || y
,x && y
の右辺→bool
- それ以外の二項演算の右辺→ 左辺のメソッド解決により期待される型
x[i]
のi
→Index::Output
型- 関数・メソッド呼び出しの引数→仮引数型
- 構造体リテラルのフィールド値→フィールド型
- ブロックの末尾→期待されているブロックの型
トレイト実装の選択
Rustの型推論を妨げるもう1つの壁が、トレイト実装の選択である。例えば以下の式を考える。
lhs + rhs
これはおおよそ ::std::ops::Add::add(lhs, rhs)
の糖衣構文である(正確には型強制が含まれるので少し異なる)から、その戻り値は <Lhs as ::std::ops::Add<Rhs>>::Output
である。
このような射影型は、他の型コンストラクタ (&X
, Box<X>
, [X; n]
など) と異なり、単射でも排反的でもない。そのため、Hindley-Milner型推論の観点からは、これは型コンストラクタというよりも特殊な型変数のように見える(一般には単一化ができない)。
射影型を決定するには、このトレイト制約 すなわち Lhs: ::std::ops::Add<Rhs>
を解くしかない。これをRustでは選択と呼んでいる。このトレイト制約に対する実装を選ぶのが目的だからである。
上に挙げた、制約集めのために型が判明している必要がある場合(structurally_resolved_type
)は、等式制約だけではなく、トレイト制約もできる限り選択される。
それ以外の場合は、トレイト制約の解決は後回しにされる。例えば以下の例では、 10.add(20)
の型は 10: i32
が確定するまでわからないが問題ない。しかし、 10.add(20).add(30)
の場合は 10.add(20)
の型をその場で判定する必要があるからエラーになる。
use std::ops::Add; fn main() { println!("{}", 10.add(20)); // println!("{}", 10.add(20).add(30)); // Error }
制約集めの順序と期待型の伝搬
制約集めは基本的に実行順 (上から下、左から右、中から外) に行われる。例えば、以下のような推論順序依存性がある。
fn main() { let mut x = Default::default(); // (x.clone(), {x = 1}); // Error ({x = 1}, x.clone()); }
ただし、そのままではトップダウンな推論ができないために、主に型強制が思ったとおりに発生せず不便なことがある。例えば以下の例を考える。
fn main() { let x : Box<[_]> = Box::new([1, 2, 3]); // ^^^^^^^^^^^^^^^^^^^ coercion site let y : Box<Box<[_]>> = Box::new(Box::new([1, 2, 3])); // ^^^^^^^^^^^^^^^^^^^ coercion site }
この例はどちらも正しくコンパイルされる。
ここで、 x
の型強制は let
の制約集めのときに発生しているということで説明がつくが、 y
の型強制は let
ではなく、外側の Box::new
の引数部分で発生している。
この型強制が発生するためには、 Box::new
の制約集めの段階で、この部分に要求されている型が判明している必要がある。そのために、式の制約集めをするときは、どのような型が期待されているかという補助的な情報をトップダウンに伝搬するようになっている。この期待型の情報は以下の列挙型で表現されている。
pub enum Expectation<'tcx> { NoExpectation, ExpectHasType(Ty<'tcx>), ExpectCastableToType(Ty<'tcx>), ExpectRvalueLikeUnsized(Ty<'tcx>), }
つまり、以下の4種類のいずれかの期待型情報が与えられる。
- 特に情報はない。
- 型
T
か、その部分型が要求されている。 (例:&'a str
に対して&'b str
) - 型
T
に明示的にキャストできる型が要求されている。 (例:u8
に対してu32
) - 可変長型
T
(スライスまたはトレイトオブジェクト)自身か、それに読み替えられる型(配列または、トレイトを実装した型)が要求されている。
例えば、上の y
の例では、
Box::new(Box::new([1, 2, 3]))
にはBox<Box<[_]>>
のExpectHasType
期待型情報が与えられている。- 関数呼び出しの処理により、
Box::new([1, 2, 3])
には、Box<[_]>
のExpectHasType
期待型情報が与えられている。 - 関数呼び出しの処理により、
[1, 2, 3]
には、[_]
のExpectRvalueLikeUnsized
期待型情報が与えられている。 1
,2
,3
には_
のExpectHasType
期待型情報が与えられている。[1, 2, 3]
の型は[{integer}; 3]
で確定する。[1, 2, 3]
は[_]
に強制できないためBox::new([1, 2, 3])
はBox<[{integer}; 3]>
となる。Box<[1, 2, 3]>
はBox<[_]>
に強制できるためBox::new(Box::new([1, 2, 3]))
はBox<Box<[{integer}]>>
となる。
整数・浮動小数点数型の推論
- 接尾辞で型が明示されているときは、それが採用される。 (例:
100i8
) - 整数リテラルに整数型が期待されている場合、浮動小数点数リテラルに浮動小数点数型が期待されている場合は、それが採用される。
- 整数リテラルに以下のような型が期待されている場合は (例:
32 as char
)char
→u8
- 生ポインタ、関数ポインタ、関数定義 →
usize
- それ以外の場合は、整数/浮動小数点数限定の特殊な型変数が生成される。 (
{integer}
,{float}
と表示される)
これらの型変数に代入されなかった場合は、 i32
と f64
が採用される。
(C言語とは異なり)リテラルの中身は型に影響を与えない。 1000000000000
と書いてもデフォルトでは i32
に推論される。オーバーフローするときは警告される。
抽象化境界としての関数定義
ここまで見てきたように、Rustの型推論はHindley-Milnerベースでありながら、いくつかの理由から推論順序の影響を受けたり、型注釈が必要なことがあった。
これとは別に、RustがHindley-Milnerベースであるという印象を受けない理由がもう1つある。それは、OCamlなどとは異なり、Rustが関数の引数・戻り値の推論をしないという点である。(なお、クロージャーの引数・戻り値の推論は行う。)
ただしこれは、Rustが関数を抽象化境界とみなしているという意図的な理由がある。(The BookのLifetime Elisionの項目を参照)
これは推測だが、これとは別に、多相な関数の垣根を越えた型推論を避けることで複雑性を低減する意図もあるかもしれない。なおRustのクロージャーは単相である。
まとめ
- Rustの型推論はHindley-Milner型推論ベースである。したがって後方の式や文の内容から手前の型が推論されることもある。しかし以下に挙げるようなRust独自の特徴をもつ。
- 型推論は原則として関数定義の中で閉じており、関数の引数型や戻り値型は推論されない。これは、関数を抽象化の境界とするための意図的なものである。この意味で「局所的」というのであれば間違いではない。
- ただし、let多相を含む型推論を避ける意図もあるかもしれない。
- 関数呼び出しや一部の演算子などは、その部分の制約を立てる段階で、型の一部分が判明している必要がある。この動作は推論順序の影響を受ける。
- トレイトによりオーバーロード可能になっている関数や演算子は、射影型を使っている場合、ボトムアップにしか推論されない(式全体の型からオペランドが推論されない)。
- 型強制は、その時点で型のミスマッチが判明している場合にのみ発生するので、推論順序の影響を受ける。
- その他、整数型と浮動小数点数型が特別扱いされていたり、生存期間に関して部分型付けをもつための特別処理があるなどの違いがある。
Rustのtry-catch構文
Rustのnightlyに新しく入ったtry-catch関連構文を紹介する。
do catch
によるcatch構文
#![feature(catch_expr)] use std::fs::File; use std::io::{self, BufReader, Read}; fn main() { do catch { let f = File::open("foo.txt")?; let mut f = BufReader::new(f); let mut buf = String::new(); f.read_to_string(&mut buf)?; println!("{}", buf); Ok(()) }.unwrap_or_else(|err: io::Error| { eprintln!("An error occured: {}", err); }) }
catchはRust RFC 0243のtry-catch構文の一部として提案されていた。tryにあたるクエスチョンマークはRust1.13.0で安定化されたが、catchはまだ実装されていなかった。
catch構文は、これまた最近実装された値つき break
(Rust RFC 1624) の亜種を用いて脱糖される。すなわち、
do catch { do catch { ... e1? } e2? } e3?
のようになっている場合、これは
'catch1: { 'catch2: { ... match e2 { ... break 'catch2 err } } match e2 { ... break 'catch1 err } } match e3 { ... return err }
のようなHIRに脱糖される。ただし正確にはこのような構文は存在しない。ASTの構文上、ラベルをとることができるのは loop
, for
, while
のいずれかである。また、実際には 'catch1
のようなラベルが付与されるのではなく、NodeIdで直接break先が識別される。
なお、 do
は現時点では予約キーワードであり、 catch
は弱キーワード(文脈依存キーワード)となる。 catch
単体ではレコード構造体の初期化構文と紛らわしいため、 do
をつけることでそれを回避している。
?
の一般化
?
は現在の安定版では std::result::Result<T, E>
の処理だけができるようになっている。以前のnightlyではこれを std::ops::Carrier
というトレイトで一般化する実装が入っていたが、これは std::ops::Try
という別のトレイトに置き換えられることになった。(Rust RFC 1859)
それぞれのトレイトの定義は以下の通りである。
pub trait Carrier { type Success; type Error; fn from_success(_: Self::Success) -> Self; fn from_error(_: Self::Error) -> Self; fn translate<T>(self) -> T where T: Carrier<Success=Self::Success, Error=Self::Error>; } pub trait Try { type Ok; type Error; fn into_result(self) -> Result<Self::Ok, Self::Error>; fn from_error(v: Self::Error) -> Self; fn from_ok(v: Self::Ok) -> Self; }
名前のほかに異なる点として、 Carrier::translate
に存在していた多相性がなくなって、よりシンプルになっている。
これにより、 e?
は以下のように脱糖されることになる。
match ::std::ops::Try::into_result(e) { ::std::result::Result::Err(err) => #[allow(unreachable_code)] // do catch {} の内側の場合 break 'innermost_catch ::std::ops::Try::from_error(::std::convert::From::from(err)), // do catch {} の内側ではない場合 return ::std::ops::Try::from_error(::std::convert::From::from(err)), ::std::result::Result::Ok(val) => #[allow(unreachable_code)] val }
残念ながら現在の実装では Result
のみが Try
を実装している。