Rust での never type とセミコロン、型推論のルール変遷

この記事は、 Rust Internal Advent Calendar 2017 の7日目の記事である。

疑問

日常における前提

最後の式がセミコロンで終わっているようなブロックは、全体としては unit 型 (()) となる。

          fn main() {
    let _: () = { 42; };
}
        
42; はセミコロンで終わる式なので、ブロックの型は unit (()) となる (playground)

絶対に実行が返ってこない、また値が作られないことを示す ! 型 (never type) [0]は、いかなる型へも暗黙に型強制[1]できる。

          #[allow(unreachable_code)]
fn main() {
    let _: i32 = panic!("Hello");
    let _: Option<()> = ::std::process::exit(0);
}
        
panic!std::process::exit は呼び出し元に絶対に戻らないため、任意の型に(暗黙に)型強制される (playground)

問題のコード

          #![allow(unreachable_code)]

// コンパイルエラー。
fn actually_return() -> ! {
    // 型エラー (expected `!`, found `{integer}`)。
    // `1` (整数型)は `!` に型強制できない。
    1

    // [予想1] `!` でない型の値を `!` に型強制することはできない。
}

// コンパイル可能。
fn dont_return() -> ! {
    panic!();
    // (ここから到達不可能)

    // 何事もなかったかのようにコンパイルが通る。
    1

    // 何故コンパイルが通るの?
    // `1` が `!` として扱われているのだろうか?
    // これは `actually_return()` における予想1と異なる挙動である。
    // [予想2] 到達不可能な箇所のコードでは型チェックが省略される。
}

// コンパイルエラー。
fn dont_return_wrong_type() -> i32 {
    panic!();
    // (ここから到達不可能)

    // 型エラー (expected `i32`, found `&str`)。
    "should be type error"

    // これは `dont_return()` における予想2とは異なる挙動である。
    // [疑問1] `!` を返す(というか返らない)はずの `dont_return()` で
    //         `1` になる式を最後に置けたのは何故?
}

// コンパイル可能。
fn semicolon_bang() {
    // 何事もなかったかのようにコンパイルが通る。
    let _: &str = { panic!(); };

    // 前提として、 `expr;` は `()` 型となる。
    // そのため、 `{ expr; }` もまた `()` 型となるはずだが、
    // `{ panic!(); }` は `()` でなく `&str` として扱われている。
    // ということは、 `{ panic!(); }` の値は `!` 型として扱われているのだろうか?
    // [疑問2] `{ panic!(); }` が `()` だけでなく任意の型として扱える(おそらく `!`
    //         として推論されている?)のは、どのような規則によるものかなのか。
}

fn main() {
}
        
疑問を詰め込んだコード (playground, gist)

問題の概要

前述のとおり:

  • (1) 最後の式がセミコロンで終わるブロックは、 ()
  • (2) ! は任意の型に型強制できる

一方、先のコードから読みとれる(推測できる)のは:

  • (3) 普通の型は自由に ! に型強制できるわけではない (→ actually_return)
  • (4) 到達不可能な場所では、どんな型の値でも ! に型強制できるようだ (→ dont_return)
    • (4.a) 最後の式が ! になっている?
    • (4.b) それとも特別に変換が許されるだけ?
    • (4.c) そもそも型チェックが省略されている?
  • (5) 到達不可能な場所でも無条件に任意の型への型強制が許されるわけではなさそうだ (→ dont_return_wrong_type)
    • → (4.a) と (4.c) は違うっぽい
  • (6) 末尾に到達不可能であるようなブロックを、セミコロンで終わる式で終わらせると、ブロックは任意の型へ型強制できるらしい (→ semicolon_bang)
    • 最後の式が () でなく ! を返しているように見える
    • →(4.a) は違うはず……あれ?

といったわけで、挙動を観察していてもどうにもはっきりしたルールがわからないので、 RFC か Issue を漁って調べようということになった。

最終的な疑問は、コードのコメントにあるとおり、以下の2点である。

  • 疑問1: ! が期待されている場所で 1 を返す(はずの)ブロックを書けたのは何故か?
  • 疑問2: { panic!(); } は、何故 () 型の値でなく ! 型であるかのように振る舞うのか?

簡単な解答

疑問1: `!` が期待されている場所で `1` を返す(はずの)ブロックを書けたのは何故か

答え: PR #40224 により、 ! が要求されている場所で diverge する式があった場合、その式の結果を ! へ型強制することが許されるようになったため。

すなわち、 { panic!(); 1 } は diverge する式であるから、この式(ブロック)は ! への型強制が許される。 よって、型チェックが通る。

疑問2: `{ panic!(); }` は、何故 `()` 型の値でなく `!` 型であるかのように振る舞うのか

答え: PR #40224 により、 diverge する文を含むブロックがあり、末尾の式(セミコロンで終わらない、戻り値となる式)が存在しない場合、ブロックの型は ! となるということになったため。

おまけ

          fn main() {
    // コンパイルエラー。
    let _: i32 = {
        panic!();
        ()
    };

    // コンパイル可能。
    let _: i32 = {
        panic!();
        ()
    } as !;
}
        
明示的に ! にキャストすると……

これも PR #40224 で導入されたルールで説明できる。
{ panic!(); () } は:

  • diverge する式であるから、 ! への型強制が許される。
  • しかし diverge する文を含むブロックであっても、末尾の式を持っているから、 ! の値としては扱われない。

ゆえに:

  • as ! がないまま i32 へ型強制しようとすると、ブロックの型は () となるから、 i32 への型強制は失敗し、型エラーとなる。
  • as !! へ型強制すると、これは成功し、 { panic!(); () } as ! の型は !とされる。 そこから更に i32 へと型強制が起き、そして成功する。

用語

diverge

値を期待している場所に、実行が戻ってこないこと。 典型的には、実行が停止したり、途中でブロックや式などを脱出するような場合など。 例として、以下のようなものがある。

  • let x = return; などは、途中で return してしまい x に実際に何らかの値が代入されることは絶対にないため、 diverge するという。

  • 3 + panic!() などは、途中で panic してしまい、加算は絶対に実行されないため、 diverge するという。

  • { ::std::process::exit(0); true } は途中でプログラムが終了してしまい、ブロックの値が true として評価されることは絶対にないため、 diverge するという。

時系列 ( まで)

RFC 1216: 到達不可能であることを示す `!` 、(ユーザにも使えるような)型として扱いたいよね

もともと variant を持たない enum は実行時の値を持てない型として使えていたのだから、 ! もそれと同等に(単なる型のひとつとして)扱えた方が嬉しくない?という提案が RFC 1216 である。 ! を単なる型として扱えるようになると、ユーザが直接に利用することができるというメリットもある (たとえば、必ず Ok(_) を返すような関数が Result<_, !> を返す、とユーザが記述できるようになる[2]など)。

この RFC の議論は Promote `!` to a type. #1216 で行われており、実装状況は tracking issue である issue #35121 で追跡されている。

この RFC では bang_type feature となっているが、その後改名され、 時点では never_type feature として管理されている。

Issue #39297: diverge する式の型がデフォで `()` に推論されたのが、 `?` 演算子で使われる式の型推論にまで影響するんだけど

issue 当時の ?try! の糖衣構文であり、 try! の実装が(実際はもう少し複雑だが) match expr { Ok(v) => v, Err(e) => return Err(e.into()) } のような実装となっていた。 そして、 diverge する式の型はデフォルトの () として見做されていた。

この issue では、 try! の内部で return していることで発生した、 () と見做された式の型(つまり ()) が、 ? の外に漏れ出して <_ as Deserialize> の部分の _() だと推論されてしまった、と主張している。

問題の概要

          match <_ as Deserialize>::deserialize() {
    Ok(v) => v,
    Err(e) => return Err(e.into()),
}
        

まず <_ as Deserialize>::deserialize() の戻り値は Result<Self, String> として宣言されている。 しかし、(何かの理由で) impl の実装を選択する際に Self に対する推論は行われないことになっている。 よって、 <_T0 as Deserialize>::deserialize() (ただし _T0, _T1, ... は推論されていない型) は、 _T0 が確定されないまま Result<_T0, String> を返すものとして推論される。

          match <_T0 as Deserialize>::deserialize() /* Result<_T0, String> */ {
    Ok(v) => v, /* _T1 */
    Err(e) => return Err(e.into()), /* _T2 */
} /* _T3 */
        

さて、 Ok(v)v の型が _T1 であるから、同じ match のもう一方の arm である Err(e) => return Err(e.into()) の型を調べ、それを match 式全体の型として使うことになる。 しかし、この match arm の式は return の結果を返しているから常に diverge する(すなわち、決して値を返さない)。 すると、この当時の実装では そのような式の型のデフォルトは () であり、結果 Err(e) の場合に返される式の型は () であるとされる。

          match <_T0 as Deserialize>::deserialize() /* Result<_T0, String> */ {
    Ok(v) => v, /* _T1 */
    Err(e) => return Err(e.into()), /* () */
} /* _T3 */
        

当然 _T1() (_T2 だった部分)は同じ型であると判断されるから、 v_T1 すなわち () である。

          match <_T0 as Deserialize>::deserialize() /* Result<_T0, String> */ {
    Ok(v) => v, /* () */
    Err(e) => return Err(e.into()), /* () */
} /* _T3 */
        

Result<_T0, String>Ok(v): Result<(), _> が同じ型であるから、_T0() であると推論される。 こうして、 deserialize()Result<(), String> を返すとされ、 <_ as Deserialize><() as Deserialize> と見做される。

          match <() as Deserialize>::deserialize() /* Result<(), String> */ {
    Ok(v) => v, /* () */
    Err(e) => return Err(e.into()), /* () */
} /* () */
        

かくして以下のようなエラーが出る。

          error[E0277]: the trait bound `(): Deserialize` is not satisfied
  --> src/main.rs:12:13
   |
12 |     let _ = <_ as Deserialize>::deserialize()?;
   |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Deserialize` is not implemented for `()`
   |
   = note: required by `Deserialize::deserialize`

error: aborting due to previous error

        

問題の核

問題は、以下の2つの点である。

  1. diverge する式の型が () として推論されること
  2. Self への推論が弱いこと

2つめの問題は、 @nikomatsakis 氏が行っている trait system の改善によって解決するであろうとされている。

1つめの問題については、 never_type feature (RFC 1216 で提案されたもの) によって、 diverge する式の型が ! として推論されるようになるが、それには別の問題がある。 もし上記コードにおいて ()Deserialize を実装していた場合、コンパイルは通っているから、 diverge する式の型のデフォルトを () から ! に変更してしまうと、 regression が発生するかもしれない。

この issue を立てた @nikomatsakis 氏は、 ? 演算子を早い段階で脱糖するのをやめて、 diverge する式の型情報が ? の外にまで洩れないようにすべきではないか、と述べている。 また、 trait system の改善では互換性破壊は起きないだろうとも述べている。

第2の選択

次のコメントで、 @canndrew 氏が実際に挙動が変化する regression になる例を提示した

そして、このような never_type feature の導入により起きうる regression の回避のために Add warning for () to ! switch #39009 で警告を追加したと述べた。 さらに、 !() へと推論することができるから、今まで (() であったがゆえに)通っていたコンパイルが (! になることで)通らなくなる問題は発生しないだろう、とも述べている。

そもそも diverge する式の型推論からおかしい

@nikomatsakis 氏は、デフォルトの型云々以前に、 diverge する式の型推論周辺がそもそも妙なことになっていると @eddyb 氏と話していたと言い、コード片を提示した。

let x = if true { 22 } else { return; 'a' }; の型チェックが(この issue の時点では)通るし、 diverging arm の型が何であるか考えるべきでないよね。
(原文: "then we ought not to be considering the type of diverging arm at all.")

でも let x: i32 と型注釈をつけるとコンパイル通らなくなるし、これはバグと考えるべきだよね。

@nikomatsakis, https://github.com/rust-lang/rust/issues/39297#issuecomment-276809637, 意訳

それまでの実装では、例の else 節のように必ず途中で脱出するブロックでは、ブロックそれ自体は値を返さないから、通常はブロックが返す型について型チェックは行われない。 しかし、ブロックに特定の型が期待されていた場合、本当にブロックがその型を返すか、ブロック末尾の式 (tail expression) について型チェックを行う実装になっていた[3]。 (おそらく、それこそ let x: i32 = { return; 'a' }; のようなおかしなコードを弾くためのものだったのだろう。)

しかし @nikomatsakis 氏は上の例で、右辺の式が同じなのに型を明示しない場合はコンパイルが通り、 let x: i32 のように型を明示すると今度はコンパイルが通らなくなるのはおかしいと主張した。 よって、一貫性のある型チェックの方法に修正したい、という話になる。

@eddyb 氏が、これらの行を削除すればどっちもコンパイル通るようになることに気付いたよ

@nikomatsakis, https://github.com/rust-lang/rust/issues/39297#issuecomment-276810343, 意訳

(diverging arm の型を考えず完全にスルーしなくとも、)この場合の else ブロックは ! 型であるとするべきじゃない?そうすれば !22 の型に型強制されることができるので型チェックも通るし

@canndrew, https://github.com/rust-lang/rust/issues/39297#issuecomment-277237337, 意訳

Pull request #39485: unrearchable なコード、絶対実行されないなら型チェックも要らなくない?

ここで、 @canndrew 氏が(削除すると問題がなくなるという行を削除する) pull request Ignore expected type in diverging blocks #39485 を出した。 これにより、「到達しえない位置での一部の型チェックを行わないことでコンパイルを通す」という戦略が使われるようになった。

具体的には、この pull request 以前は、常に diverge するブロックでは「(そのブロックに対して)何らかの ! 以外の型が期待されていれば、末尾の式 (tail expression) の型を、ブロックに期待される型へと型強制する。型強制が成功したら、ブロック全体は ! として扱う」というアルゴリズムだった。 これが pull request によって、常に diverge するブロックでは「(そのブロックに対して)どのような型が期待されていようと、末尾の式の型の確認は行わず、ブロック全体を ! として扱う」というように変更された。

regression

この変更では、期待される型の情報が一部で(具体的には、 diverging な部分式を含むようなブロックの最終的な型の推論で)使われなくなったため、今までは推論できていた部分の型が推論できなくなるという regression が発生する。 (具体的には https://github.com/rust-lang/rust/pull/39485/files で修正されているテスト群のようなコードで、型注釈が追加されている部分や、後述する regression などで提示されるコードなどである。)

これについて、 return される返り値型のみについて型推論を行うこともできるが、そうすると結局 dead code の型を考えることになり、この pull request で直そうとしていた問題を再導入することになってしまうため、 @nikomatsakis 氏は「必要ないやろ」と言った

また、解決として @canndrew 氏は「推論できなかった部分をデフォで ! とすることで、これらの regression を起こさないようにすることはできるよね(できるけどやりたいとは言ってないよ)」と言ったが、 @nikomatsakis 氏が「 dead code 部分から型の情報が(手前へ)上がってくると、 (issue #39297 で提示され、)この pull request で解決しようとしていた問題を再度引き起こすことになるから良くない」と言った

結局、 regression で直接壊れる crate がとても少なく、型注釈を足すだけで regression は簡単に解消できるため、この pull request はそのまま merge された。

issue #39808: mac-0.1.0 crate で Rust の regression を発見したんだけど

この regression は、前述の pull request #39485 により発生したものではない
たぶん commit rustc_typeck: correctly track "always-diverges" and "has-type-errors". ・ rust-lang/rust@6b3cc0b での src/librustc_typeck/check/mod.rscheck_then_else() への変更あたりが引き起こしたのだと思う(詳細は確認していない(正確には、確認したけどわからなかった))。

とはいえ、 diverging type の扱いに関するバグであるには違いない。

この regression に対応するため、 mac crate ではこんな妙な型注釈が必要になった

issue #39984: 最近 nightly で型推論の regression があったんだけど

comment https://github.com/rust-lang/rust/issues/39984#issuecomment-285863991 で丁寧な解説がされている:

        pub fn encode() -> Result<(), ()> {
    try!(unimplemented!());
    Ok(())
}
      

このコードは過去にはコンパイルが通っていたが、 pull request #39485 で到達できない部分の型合わせを行わないようになってしまった結果、 Ok(()) の型が Result<(), ()> から推論されなくなった、ということだろうか。

issue #40073: unreachable code での型推論が失敗するようになったんだけど

これも、 return で diverge した後の X::BX<()> へと推論できない例で、 pull request #39485 による予期されていた regression である。

pull request #40224: diverging types 周辺の型推論の戦略を変えるよ

この pull request での変更は以下のとおり:

  • 直接 ! 型になる場合:
    • ブロックに diverge する文 (statement) があり、かつ末尾の式 (tail expression) がない場合 ({ return; } など)、そのブロックの型も ! となる。
    • ! 型になるどのような式も、 diverge するものと見做される。
  • ! への型強制:
    • diverge する式が生成したどのような型の値についても、それが ! へと型強制されることを許す。 (たとえば fn foo() -> ! { panic!(); 32 } のような例では、関数の本体は diverge するから、それが生成する値 32! へ型強制され、結果型チェックが通る。)
  • ! からの型強制:
    • ! からどのような型への型強制も、常に許される。
  • diverge するからといって、それ以降の dead code の型が無視されることはない

(以下、 pull request よく読んでないし、修正後の提案に対する話かどうかさえわからないので、断片的な情報)

通常 { expr; }{ expr; () } と同等に扱われるが、この変更の後は、 diverge する式やブロックに対してこれは適用されない。 これは @canndrew 氏が質問し@nikomatsakis 氏が回答している

@nikomatsakis 氏は https://github.com/rust-lang/rust/pull/40224#issuecomment-285806743 で、解決できなかった型に対して ! のフォールバックが使われる場合の原理と問題点を説明している。

pull request #40636: pull request #39485 で発生した regression を避けるため revert するよ

問題の解決は pull request #40224 で行うため、過去の変更 pull request #39485 を revert しようという pull request 。

(pull request #40224 より前に) merge された。

これからのこと

  • issue #35121: tracking issue (RFC 1216): ! を普通の型にしたい
    • issue #40800: divergence の意味論をちゃんと決めて型強制がうまいこといくようにしたい
    • issue #40801: 決定できなかった型をなんでもデフォで ! にフォールバックさせるのはよくない

時系列2 ( から まで)

issue #40800: divergence の意味論をちゃんと決めて型強制がうまいこといくようにしたい

pull request #40224: diverging types 周辺の型推論の戦略を変えるよ にて、 diverge する式で ! への型強制を許すルールを追加したが、どうも実装当時と微妙にフラグの意味が違ってたりとか、ルールがイケてるかどうか微妙なところがあったりするので、はっきりさせたいよねという話。

結局、 ! への型強制を許すルールってどう有用なのかはっきりしないし、完全になくしてよくない?と @nikomatsakis 氏と @arielb1 氏は考えていたようで、 PR #45880 で到達不可能なコードで ! への型強制を許すルールは削除されることになった(というか、削除された)。

issue #40801: 解決できなかった型をデフォで `!` に推論するの、やめた方がよくない?

フォールバックのルールを変えると非互換な変更になってしまうというのと、提案されたルールが十分良さげというわけでもなさそうという、2つの理由からクローズされた。

(よく読んでない。)

pull request #45880: 到達不可能な場所での `!` への型強制は許さないことにするわ

タイトルそのまま。

issue 46325: `coerce_never` compatibility lint の tracking issue

結局、 PR #45880 で、 diverge する式(ブロック)で ! への型強制を常に許すという仕様が削除された。 ! だけに特例で妙な型強制を許すのは一貫性がないから、とのこと。

もしこの変更に由来するエラーに遭遇したら、ブロック末尾に生の式 (trailing expression) を置かず、必ずセミコロンで終わらせれば良いとのこと。

        fn example_fixes() -> ! {
    Some(panic!()); // now with more semicolons
    // no trailing expression, this can return any type!
}
      

そういったわけで、今では PR #40224 で実装されたルールは少々変更されている。

参考