🤖

事前条件も事後条件もテストも全部 assert!() でいいの? まあ、いいんじゃないでしょうかという話

2022/09/24に公開

Rustでは実行時表明とテスト表明の双方を同じ仕組み (panic機構) を用いて行います。

Rustを書くにあたって、この部分に違和感を覚えた人もいるのではないかと思います (多数派ではないと思いますが)。本稿ではこの違和感について分析し、Rustではそれで問題ないと確認することを目指します。

※割とフワッとした話に終始します

Resultとpanic

Rustではエラー処理の方法としてResultとpanicの2種類の方法を提供しています。これは大まかに以下のように使い分けられます。

  • プログラムが想定しなければいけないエラー (ユーザーが誤った入力を与えた場合や入出力エラーなど) はResultを使う。
  • プログラムが想定外の状態に陥った場合 (意図しない配列の境界外参照など) はpanicを使う。

ライブラリの呼び出し境界でエラーの意図が切り替わる場合もあります。この場合は .unwrap()/.expect()catch_unwind() で両者のエラー形態を変換することで対応することになります。

この2種類の区別については色々な人が説明を書いているので参考にしてください。

ここではこのような区別の話とは反対に、panicがそれ以上細分化されていないことについて扱います。

assertとpanic

まず前提として、assertとpanicは同等です。というのも、 (ペイロード・スタックフレーム・到達性解析の違いを無視すれば) 以下のようにassertとpanicを相互に変換できるからです。

  • assert!(cond)if !cond { panic!(); } と書ける。
  • panic!()assert!(false) と書ける。

todo!() など少し厄介な意味をもつものもありますが、これらも広い意味では表明 (意図的に誤りを含む表明) とみなせます。

そこで、以降ではassertとpanicをあまり区別せずに扱います。

事前条件表明と事後条件表明

まずはテストのことは忘れて、実行時表明について論じます。人工的な例ですが、以下のような関数を考えます。

// 非負平方根の切り捨てを返す。
// 引数は非負でなければならない。
pub fn isqrt(n: i32) -> i32 {
    // 事前条件表明
    assert!(n >= 0);
    let r = (n as f64).sqrt() as i32;
    // 事後条件表明
    assert!(0 <= r && r * r <= n && n < (r + 1) * (r + 1));
    r
}

この関数内には実行時表明の典型的な例が2つ書かれています。ひとつは事前条件表明で、もうひとつは事後条件表明です。

この2つには以下の大きな違いがあります。

  • 事後条件表明は、isqrtに間違いがない限りはいつでも成功する。
    • ただし、上の例では実は事後条件表明自体にバグがあり、失敗する可能性があります。暇な人は考えてみましょう。
  • いっぽう、事前条件表明はそうではなく、isqrt自体の正しさに関係なく失敗する可能性がある。
    • 具体的には isqrt(-1) を与えればよい。[1]

そもそも考えてみると、事前条件と事後条件の関係は引数・戻り値の関係と同じで、ちょうど反対 (反変位置にある) です。こういった感覚に敏感な人であれば、本来反対の役割を持つはずのものが、アサーション(表明)するときは同じ仕組みを使ってよいというのを不思議に思うこともあるかもしれません。

コンポーネントツリーで考える

このような現象をきちんととらえるには、ソフトウェアが「小さい部品から、より大きい部品を組み立てる」という操作の再帰でできているとみなすのが適切です。

Rustの世界の中でみると、これはおおよそ関数の呼び出しスタックに対応するという見方もできます。

すると、ある特定のコード辺は、コンポーネントEの一部であると同時に、それを含むより大きなコンポーネントBの一部でもある。さらに、それはそれを含むコンポーネントAの一部でもある…… というように、どのコンポーネントの一部とみるか (コンポーネント境界) という視点が出てきます。

すると表明も、

  • 常に成功する表明か、
  • 失敗するかもしれない表明か、

という2択ではなく、

  • この表明はコンポーネントEとの関わりでは失敗するかもしれない
  • しかし、コンポーネントBとの関わりでは常に成功する

というように、コンポーネント境界との関係によって整理できることになります。

すると、先ほどの事前条件表明と事後条件表明の性質の違いは以下のように解釈することができます。

  • 事前条件表明は、それを利用する側を含めた広いコンポーネントにとっての表明である。
  • 事後条件表明は、その関数自身で完結する表明である。

#[track_caller]

#[track_caller] はパニック時のスタックトレースの表示を調節するための属性です。 (Rust 1.46.0以降で使えます)

これもまた、表明とコンポーネント境界の関係として整理することができます。

コードは複数のコンポーネント境界に所属しているため、もし表明が失敗した場合、その原因をどのコンポーネント境界の責任に帰するのが適切かという選択が発生します。

このとき、通常はスタックトレースの最も内側が最も疑わしい原因であると仮定してエラーメッセージが表示されます。しかし、これが明らかに正しくないケースもあります。

わかりやすい例が Option::unwrap です。

impl<T> Option<T> {
    fn unwrap(self) -> T {
        if let Some(value) = self {
	    value
	} else {
	    panic!();
	}
    }
}

この Option::unwrap はpanicする可能性がありますが、このpanicは Option::unwrap というメソッドが明確に意図しているものです。そのため、このpanicの責任はその呼び出し元側にあるとするほうが自然で、実際に問題の解決をするのにも便利です。

#[track_caller] はこのような場合に振る舞いを調整するために使えます。

ただし、本稿で分析したように、この問題は本来表明とコンポーネント境界の関係として定義されるべきであり、適切な責任をとれるスタックフレーム位置を表明ごとに指定するほうが本来的なはずです。たとえば最初に示した例では事前条件表明でのみ #[track_caller] を有効化するのが望ましいですが、これは現状ではできません。

テスト表明

テスト表明は、コンポーネントの動作の正当性をコンポーネント境界の外から検査するという意味で、実行時表明とは異なる構造を持っています。

とはいえ、実際にはテストコード自体の誤りに由来して表明が失敗することもあり、実態としてはコンポーネントそのものの不変条件を検査するかわりにテストコードを含んだ全体を検査するものだともみなせます。

このような観点からは、テスト表明はテストコード境界内で常に成功することが期待されるような実行時表明の一種とみなせます。

技術的な背景

より技術的な視点からは、表明の失敗 (= panic) を捕捉できるためにこの方法で適切にテストできる、という風にもとらえることができます。libtestではこれを以下の2種類の方法で行っています。

  • catch_unwind で捕捉する。 (panic=unwindの場合)
  • 実際のテストを別プロセスで起動し、panicしたときは標準入出力経由で情報を伝搬する。 (panic=abortの場合)

Resultとの関係

テスト関数がResultを返すようにもできますが、これは単にテストハーネス側で unwrap を呼びパニックに転換されるものとみなせます。

Resultを使うと、 unwrap よりもコードが綺麗になる場合があるため、実用上は有用です。

続行可能なテスト表明との関係

テストフレームワークによっては、テスト表明のチェックに失敗してもテストケースを続行する場合があります。 (Goの t.Errorf など)

これには以下のpros/consがあると考えられます。

  • 1つの失敗したテストケースから一度にたくさんの情報を得ることができる。
  • その一方で、テストケース内の処理が先行する処理に依存しているとき、コンパイルが通らなかったり、テスト実行全体に悪影響が生じる可能性がある。 (nullチェックなど)

とはいえ、Rustのlibtestがこのpros/consを検討した上で現在の設計になったのか、そうでないかはよくわかりません。単にpanicとの統合性が高いから今の設計になった可能性もあります。

一般論としては、ひとつのテストケースに並列的な表明をたくさん書くのはアンチパターンとされることが多いかと思います。並列的な表明が必要な場合はテストケースの分割を検討するのがよいでしょう。

まとめ

  • Rustでは実行時表明とテスト表明で共通の assert! マクロ (や、その亜種) を利用する。
  • 実行時表明でも、たとえば事前条件表明と事後条件表明では期待する性質が違うように見える。しかし、これはコンポーネント境界との相対的な関係で考えると統一的な視点で扱うことができる。
    • より大きなコンポーネントの一部という観点で見れば、事前条件表明も事後条件表明も中間表明の特別な場合にすぎない。
  • テスト表明も「実装の正当性を確認する」という目的を「テストコード全体が整合的に実行されることを確認する」という手段で実現していると考えることで正当化できる。
脚注
  1. なお、この例の場合は引数を u32 にすればこの条件チェックは必要なくなってしまう。これはわかりやすさのために簡単な例を持ち出しているからにすぎず、現実には型で対応しにくいケースが存在する。 ↩︎

Discussion