パターンと網羅性の検査
Rust では、パターンマッチングと束縛に、いくつかの非常に役立つ性質があります。 コンパイラは、束縛が行われるときにそれが反駁不能であることと、match アームが 網羅的であることを検査します。
パターンの有用性
有用性検査が答える中心的な問いは次のものです。 「この match 式において、その分岐は冗長か?」。 より正確には、すでに見たパターンのリストが与えられたときに、 ある新しいパターンが何らかの新しい値にマッチする可能性があるかを 計算することに帰着します。
たとえば、次の match 式では、
各パターンが、その上にあるパターンにマッチしなかった何かに
マッチする可能性があるかを順に尋ねます。
ここでは、4 番目のパターンが 1 番目のパターンと冗長であることがわかります。
その分岐には「到達不能」の警告が出ます。
3 番目のパターンが有用かどうかは、
Foo に Bar 以外のバリアントがあるかどうかに依存します。
最後に、ワイルドカードパターン(_)が
その match 内のすべてのパターンのリストに対して有用かどうかを尋ねることで、
match 全体が網羅的かどうかを尋ねることができます。
ここでは _ が有用である((false, None) を捕捉する)ことがわかります。
したがって、この式には「網羅的でない match」エラーが出ます。
#![allow(unused)]
fn main() {
// x: (bool, Option<Foo>)
match x {
(true, _) => {} // 1
(false, Some(Foo::Bar)) => {} // 2
(false, Some(_)) => {} // 3
(true, None) => {} // 4
}
}
したがって、有用性は 2 つの目的で使われます。 到達不能コードの検出(これはユーザーにとって有用です)と、 match が網羅的であることの保証(これは健全性にとって重要です。 なぜなら match 式は値を返すことができるためです)。
どこで行われるか
この検査は、パターンを書ける場所ならどこでも行われます。match 式、if let、let else、
通常の let、関数引数です。
#![allow(unused)]
fn main() {
// `match`
// 有用性は到達不能な分岐を検出し、網羅的でない match を禁止できます。
match foo() {
Ok(x) => x,
Err(_) => panic!(),
}
// `if let`
// 有用性は到達不能な分岐を検出できます。
if let Some(x) = foo() {
// ...
}
// `while let`
// 有用性は無限ループとデッドループを検出できます。
while let Some(x) = it.next() {
// ...
}
// 分解 `let`
// 有用性は網羅的でないパターンを禁止できます。
let Foo::Bar(x, y) = foo();
// 関数引数の分解
// 有用性は網羅的でないパターンを禁止できます。
fn foo(Foo { x, y }: Foo) {
// ...
}
}
アルゴリズム
網羅性検査は、MIR の構築前に check_match で実行されます。
これは rustc_pattern_analysis クレートに実装されており、
アルゴリズムの中核は usefulness モジュールにあります。
そのファイルには、アルゴリズムの詳細な説明が含まれています。
重要な概念
コンストラクターとフィールド
値 Pair(Some(0), true) において、Pair はその値のコンストラクターと呼ばれ、Some(0) と
true はそのフィールドです。マッチ可能なすべての値は、この方法で分解できます。
コンストラクターの例には、Some、None、(,)(2 タプルコンストラクター)、
Foo {..}(構造体 Foo のコンストラクター)、および 2(数値 2 のコンストラクター)があります。
各コンストラクターは固定数のフィールドを取ります。これはそのアリティと呼ばれます。Pair と (,) の
アリティは 2、Some のアリティは 1、None と 42 のアリティは 0 です。各型には既知の
コンストラクター集合があります。一部の型には多くのコンストラクター(u64 など)があり、さらには
無限個のコンストラクター(&str や &[T] など)を持つものもあります。
パターンも似ています。Pair(Some(_), _) はコンストラクター Pair と 2 つのフィールドを持ちます。
違いは、いくつかのパターン専用コンストラクターが追加されることです。具体的には、ワイルドカード _、
変数束縛、0..=10 のような整数範囲、[_, .., _] のような可変長スライスです。
or パターンは別に扱います。
さて、値 v がパターン p にマッチするかを検査するには、v のコンストラクターが p の
コンストラクターにマッチするかを検査し、必要であればそれらのフィールドを再帰的に比較します。
代表的な例をいくつか示します。
matches!(v, _) := truematches!((v0, v1), (p0, p1)) := matches!(v0, p0) && matches!(v1, p1)matches!(Foo { a: v0, b: v1 }, Foo { a: p0, b: p1 }) := matches!(v0, p0) && matches!(v1, p1)matches!(Ok(v0), Ok(p0)) := matches!(v0, p0)matches!(Ok(v0), Err(p0)) := false(互換性のないバリアント)matches!(v, 1..=100) := matches!(v, 1) || ... || matches!(v, 100)matches!([v0], [p0, .., p1]) := false(互換性のない長さ)matches!([v0, v1, v2], [p0, .., p1]) := matches!(v0, p0) && matches!(v2, p1)
この概念は、パターン解析において絶対的に中心となるものです。constructor モジュールは、
コンストラクターを抽出、列挙、操作するための関数を提供します。これは十分に有用な概念であるため、
その変種はコンパイラの他の場所にも見られます。たとえば、match 式の MIR lowering や
いくつかの clippy lint などです。
コンストラクターのグループ化と分割
パターン専用コンストラクター(_、範囲、可変長スライス)は、それぞれ通常のコンストラクターの集合を表します。
たとえば、_: Option<T> は集合 {None, Some} を表し、[_, .., _] は
アリティ >= 2 のスライスコンストラクターの無限集合 {[,], [,,], [,,,], …} を表します。
これらのコンストラクターを管理するために、可能な限りグループ化したまま保持します。たとえば次のようにします。
#![allow(unused)]
fn main() {
match (0, false) {
(0 ..=100, true) => {}
(50..=150, false) => {}
(0 ..=200, _) => {}
}
}
この例では、0、1、..、49 はすべて同じアームにマッチするため、1 つのグループとして扱うことができます。
実際、この match で考慮する必要がある範囲は 0..50、50..=100、
101..=150、151..=200、201.. だけです。同様に次の場合です。
#![allow(unused)]
fn main() {
enum Direction { North, South, East, West }
let wind = (Direction::North, 0u8);
match wind {
(Direction::North, 50..) => {}
(_, _) => {}
}
}
ここでは、North ではないすべてのコンストラクターを 1 つのグループとして扱えるため、扱うべきケースは
North と、それ以外のすべてのもの、の 2 つだけになります。
これは「コンストラクター分割」と呼ばれ、網羅性検査を妥当な時間で実行するために不可欠です。
空の型が存在する場合の有用性と到達可能性
これはおそらく網羅性において最も微妙な側面です。完全に正確に言えば、match は 値に対して動作するのではなく、場所に対して動作します。特定の unsafe な状況では、場所が その型に対して有効なデータを含まないことがあり得ます。これは空の型に対して微妙な結果をもたらします。 次を見てください。
#![allow(unused)]
fn main() {
enum Void {}
let x: u8 = 0;
let ptr: *const Void = &x as *const u8 as *const Void;
unsafe {
match *ptr {
_ => println!("Reachable!"),
}
}
}
この例では、ptr は無効なデータを持つ場所を指している有効なポインターです。_ パターンは
場所 *ptr の内容を見ないため、このコードは問題なく、アームが選択されます。言い換えると、
検査している場所の型が Void であるにもかかわらず、到達可能なアームがあります。一方で、
そのアームに束縛がある場合は次のようになります。
#![allow(unused)]
fn main() {
#[derive(Copy, Clone)]
enum Void {}
let x: u8 = 0;
let ptr: *const Void = &x as *const u8 as *const Void;
unsafe {
match *ptr {
_a => println!("Unreachable!"),
}
}
}
ここでは、その束縛が *ptr という場所から型 Void の値をロードします。この例では、データが有効ではないため、
これは UB を引き起こします。一般の場合では、これは *ptr にあるデータの妥当性を表明します。
いずれにせよ、このアームが選択されることは決してありません。
最後に、空の match match *ptr {} について考えてみましょう。これが網羅的であると考えるなら、
*ptr に無効なデータがあることは無効です。言い換えると、空の match は意味論的には
_a => ... match と同等です。明示性の観点から、アームがあるケースを好むため、
ユーザーに _a アームを削除するようには伝えません。言い換えると、_a アームは
到達不能ではあるものの、冗長ではありません。これが、lint が “unreachable” と言っているにもかかわらず、
到達不能なアームではなく冗長なアームに対して lint を行う理由です。
これらの考慮事項は、特定の場所、すなわち UB なしに非有効なデータを含み得る場所にのみ影響します。 それらは、ポインタのデリファレンス、参照のデリファレンス、union フィールドアクセスです。網羅性チェック中に、 与えられた場所が有効なデータを含むことが既知かどうかを追跡します。
以上を踏まえても、現在の網羅性チェックの実装は上記の考慮事項に従っていません。stable では、
空型はほとんどの場合、非空として扱われます。
exhaustive_patterns 機能は逆方向に誤っています。unsafe な状況で到達可能になり得るアームの省略を
許可してしまいます。never_patterns 実験的機能は、これを修正し、
パターン内の空型の正しい振る舞いを許可することを目指しています。