RustのNever Typeの安定化
Stabilizing Rust's Never Type (lwn.net)
要約
Rust言語の「never type」(「!」で表される)が、2年以上の開発を経て安定版としてリリースされました。この型は、決して値を返さない関数や、値が決して発生しない状況を示すために使用されます。never typeは、コンパイラによるコード最適化を可能にし、型推論を簡素化するという実用的・哲学的な利点があります。今回の安定化には、過去のエディションとの互換性を保ちつつ、型推論のフォールバック(fallback)メカニズムの変更が含まれています。
全文翻訳
RustのNever Typeの安定化
関数の戻り値の型は、それが生成するデータの種類を示すものです。Rustの「never type」(感嘆符「!」で表される)は、決して返らない関数や値が決して発生しない場所を示すために言語が使用する型です。長らく、never typeはコンパイラ内部で使用されていましたが、不安定な機能と見なされていました。2年以上の作業を経て、8月24日にRustコンパイラのコントリビューターである「waffle」氏が、ついにこの型の安定化を実現しました。安定化に時間がかかった理由の一つは、以前のRustエディションに対する小さな破壊的変更を伴い、コンパイラメンテナーがそれが実際のコードに与える影響を最小限に抑える必要があったためです。
(注: Rustでは感嘆符はマクロの呼び出しを示すためにも使用されます。構文の構造上、never typeが有効な場所はマクロ呼び出しを置くのに有効な場所ではなく、その逆も同様です。)
なぜnever typeなのか?
Rustにnever typeが存在する理由は二つあり、一つは実用的、もう一つは哲学的です。実用的な理由は、より効率的なジェネリックコードを可能にするためです。例えば、標準ライブラリのFromStrトレイトを考えてみましょう。これは文字列からインスタンス化できる型に使用されます。
trait FromStr: Sized {
type Err;
fn from_str(s: &str) -> Result<Self, Self::Err>;
}
FromStr::from_str()は、変換された結果、またはカスタムエラー型を返します。例えば、「foo」を整数に変換しようとするとParseIntErrorが返されます。しかし、一部の型では、失敗しない変換が可能です。例えば、文字列をByteStringに変換することは常に可能です。そのFromStrの実装では、Errをnever typeに設定できます。そうすれば、コンパイラは返されるResultのエラーブランチが決して存在しないことを知り、それに触れたりチェックしたりするすべてのコードを最適化できます。
impl FromStr for ByteString {
type Err = !;
fn from_str(s: &str) -> Result<Self, !> { ... }
// 同じジェネリックインターフェースを維持しつつ、
// 次のようなコードと同等のコードを生成します:
// fn from_str(s: &str) -> Self { ... }
}
哲学的な理由は、正しい型推論に関係します。Rustでは、if文やwhileループのような構文は式であり、その結果を変数に代入できます。プログラマーが無限ループを記述した場合、コンパイラはその結果の型を推論する必要があります。これは実際のコードではあまり発生しないかもしれませんが、そのケースを均一に扱うために特別なルールを追加するよりも、型推論を単純化できることが判明しました。
特に、never typeはコードを単純化するための有用な特性を持っています。それは、他の任意の型に自動的に型変換(coerce)されます。これは奇妙に聞こえるかもしれませんが、never typeは値を決して生成しない計算の「結果」を表すため、安全です。したがって、コードがnever typeの値を持つと主張する場所であればどこでも、コンパイラはそのコードに到達できないことを知っているため、それを無視しても安全です。これは型システム駆動のデッドコード削除の一形態です。
これらの理由から、Rustプログラマーはnever typeを安定版の言語で使用できるようにしたいと考えていました。それを実現するには、特に厄介なエッジケースを解決する必要がありました。
Never fallback
never typeから他の型への変換の実装方法により、コンパイラが式の具体的な型を単純に推論できない状況に陥ることがあります。この例を考えてみましょう。これは匿名関数(||を使用、PythonやLISPのラムダのようなもの)を定義し、それが決して返らないようにし、その後、具体的なエラー型を期待する方法で呼び出します(?演算子を使用)。
let function_that_never_returns = || { loop {} };
function_that_never_returns()?;
この無限ループには!という型が与えられ、その後、関数が返すはずの型に暗黙的に変換されます。しかし、関数はローカルに定義されており、明示的な型が与えられていないため、コンパイラはその型が何であるかを判断するのに十分な情報を持っていません。この問題は、関数に明示的な戻り値の型を与えることで修正できます。
let function_that_never_returns = || -> Foo { loop {} };
しかし、このような注釈は関数が何も返せない場合にのみ必要とされるため、プログラマーに架空の型を割り当てるように要求するのは少し無意味です。そのため、コンパイラは特別なルールを含んでいます。すべての他の型推論が完了した後でも、まだ決定できない曖昧な型がある場合は、それを指定されたフォールバック型と見なします。2024年のRustのエディションより前は、そのフォールバック型は()(単一の値しか持たないユニット型)でした。2024年のエディションでは、フォールバック型は!自体に変更され、実質的に暗黙の変換が打ち消されました。コンパイラ内部では、never typeは依然として未知の型に変換され、その後フォールバックしますが、プログラマーの視点からは、never typeが型を意味のあるものにするために必要な場合にのみ暗黙的に変換されるのと同等の動作になります。
この動作の変更は、技術的には破壊的変更でした。一部のコードの型推論が変わる可能性があり、それがコンパイルエラーを引き起こす可能性があります。Rustのエディションシステムは、言語のフロントエンド設計における破壊的変更を、古いコードを壊したり、エコシステム全体を一度に更新したりすることなく可能にすることを目的としています。しかし、この場合、新しい動作を古いエディションにバックポートしたい理由がありました。
Never infallible
長年、標準ライブラリにはnever typeの不安定な性質を回避するためにInfallible型が存在していました。これはnever typeと同じ意味論的な目的を果たしていましたが、特別なコンパイラサポートはありませんでした。そのため、これを使用するコードは技術的には正しいものの、オプティマイザーがInfallibleへの参照を常に削除できるわけではなかったため、最適ではありませんでした(列挙型に余分なタグレイヤーがある、またはデッドコードが出力されるなど)。never typeが最終的に安定化されたら、Infallibleは!の型エイリアスになり、古いコードはすべて静かに効率化される予定でした。しかし、Infallibleの再定義が誤って破壊的変更になってしまったといういくつかの指摘がありました。!には暗黙の変換があるため、Infallibleの定義を変更すると、既存のコードが型チェックのために追加の型指定子を必要とする可能性があります。
幸いなことに、Infallibleの定義の変更とデフォルトのフォールバック型の変更は、どちらも破壊的変更ですが、ほぼ相殺されます。標準ライブラリのInfallible型を参照するコードは引き続き機能しますが、型推論が暗黙的にInfallibleを生成することが期待される場所でのみ、既存のコードが壊れるリスクがあります。Rustではほとんどの場合、暗黙の変換がないため、never typeがInfallibleに暗黙的に変換されていた場所で最も頻繁に発生します。Infallibleがnever typeの型エイリアスにされた場合、それらの場所ではnever typeのフォールバックが発生する可能性があり、それが推論される型を変更し、never fallback型が同時に更新されない場合にコンパイルエラーを引き起こす可能性があります。
両方の変更が同時に行われたため、Rustメンテナーは、既存のRustコードのほぼすべてが引き続きコンパイルされると信じていましたが、「ほぼすべて」という修飾子は、後方互換性のない変更を扱う際には安心できるものではありません。Rustコミュニティには、公開されているすべてのRustライブラリをダウンロードしてコンパイルし、コンパイラ変更によって壊れるコードを探すことができるcraterというソリューションがあります。
Never say never
Waffle氏は4月にcraterを実行し、変更によって3,300のクレートが影響を受けましたが、完全に壊れたのは7つだけで、残りはその後修正された古いバージョンのライブラリに依存していたため壊れていました。後者の場合、問題は理論的には数個のコアライブラリのバックポートされた修正をリリースすることで修正可能でした。これは偶然ではありません。Rustは2024年以降、新しい変更で壊れるような方法でnever type fallbackをトリガーするコードに対して常に警告を発していたため、ほとんどのライブラリは自発的に更新する十分な時間がありました。craterが観測した最も一般的な残りのエラーは、コンパイラが特定の戻り値の型を選択するのに十分な型情報なしでジェネリック関数を呼び出すコードです。この関数を考えてみましょう。
fn foo<T: Default>() -> Result<T, Error> { ... }
これは、呼び出し元が選択したDefaultトレイトを実装する必要がある型Tの値、またはエラーを返します。しかし、Tの値が指定されずに呼び出された場合、型フォールバックが発生する可能性があります。
// 型が指定されていません。
foo()?;
以前は、これによりコンパイラはTを()と仮定し、()はDefaultを実装しているため、コードはコンパイルされていました。この変更後(および2024年エディションでは)、コンパイラはTを!と仮定しますが、!はDefaultを実装していないため、コンパイルエラーが発生します。修正は、foo()が返す型を呼び出し時またはパターンマッチング代入で明示的に指定することです。
foo::<()>()?;
// または
() = foo()?;
複雑な変更ではありませんが、Rustメンテナーは3,300のクレートを壊すことを望んでいませんでした。Waffle氏は、一般的なライブラリのメンテナーと協力して、上記のような簡単な変更(多くのRustビルド環境が自動的に選択する新しいパッチバージョンをリリースするなど)をバックポートし、壊れた依存関係に依存するライブラリの数を減らすように依頼されました。いくつかのライブラリの著者はバックポートを行うことに前向きでしたが、古いバージョンはすでにサポート終了しているという理由で拒否した人もいました。それらのメンテナーは、ユーザーは古いバージョンのRustにとどまるか、ライブラリのサポートされているバージョンに更新できると指摘しました。それでも、成功したバックポートは、失敗したクレートの1,553を解決しました。
関連する問題の数件を修正して、壊れたクレートの数をさらに減らした後、Rustメンテナーは、壊れたコードがまだいくつかあるとしても、言語を単純化するために変更を行う価値があるという結論に至りました。そのため、Rust 1.100以降、never typeは安定し、Infallibleはnever typeの型エイリアスになります。これがコードを壊すユーザーにはいくつかの選択肢があります。
- Rustバージョン1.99にとどまる。
- 問題の修正を含むサポートされているバージョンに依存関係を更新する。
- 影響を受ける関数呼び出しの戻り値の型を明示的に指定するパッチを追加する。
一方では、これは破壊的変更であり、安定して動作していたコードが突然コンパイルできなくなる可能性があります。これはRustの後方互換性へのコミットメントの違反と見なされるかもしれません。他方では、問題は比較的まれであり、修正するための複数の簡単な方法があり、長年警告されてきた、そして常に言語の計画の一部でした。さらに、Rustメンテナーはコミュニティと直接協力して破壊性を特定および対処し、人気のあるライブラリの古いバージョンの修正さえもバックポートするのに協力しました。したがって、プロセス全体は、後方互換性へのRustのコミットメントの肯定と見なすこともできます。
将来的には、言語を学ぶ人々はnever typeを少しだけ特別でなくなるものとして見つけることを願っています。いずれにせよ、Rustのほとんどのユーザーは影響を受けないでしょうが、「never say never」(決して諦めない)です。