Common Lispのassertと契約による設計

  • Common Lispのassertマクロは、条件が偽になるとエラーを発生させ、デバッガに処理を渡します。
  • 「契約による設計」の考え方で、事前条件・事後条件・不変条件をコード上に明示すると、バグの責任の所在を特定しやすくなります。
  • #+developmentのようなフィーチャーフラグを使うと、コストの高い検査を本番環境で無効にできます。

関連記事

1. assertマクロの基本の書き方

Common Lispのassertマクロは、「この条件が成り立たなければ処理を続けるべきでない」という箇所に置くものです。

Common Lisp の assert マクロ 基本の使い方 (defun divide (a b) (assert (not (zerop b))) (/ a b)) 1 条件が偽 → エラー発生 2 デバッガへ自動移行 3 条件式がそのままメッセージに check-type 型検査に特化した assert #+development 本番では検査をスキップ コンディションシステム 値を直して実行を再開できる 他言語にはない特徴
(defun divide (a b)
  (assert (not (zerop b)))
  (/ a b))Code language: Lisp (lisp)

条件が偽になるとsimple-errorが発生し、デバッガに飛びます1

(divide 10 0)
; => simple-error: The assertion (NOT (ZEROP B)) failed.Code language: Lisp (lisp)

エラーメッセージにはフォームそのものが含まれるので、何の条件が破れたかも一目でわかります。
「ゼロでないこと」「リストが空でないこと」「ある不変条件が保たれていること」といった意味的な制約が対象です。

テストフレームワークと似ていますが、結果を確かめるのではなく、前提や途中の整合性が取れているのか確かめるために使います。

1.1. 型検査ならcheck-type

型を検査するなら、check-typeもあります。

(check-type b number)
; => "B is not a NUMBER" と言ってくれるCode language: Lisp (lisp)

内部的にはassertを使っていますが、エラーメッセージが自動的に設定されます2

1.2. 本番環境では無効にするには?(フィーチャーフラグ)

Common Lispではassertは常に評価されます。
とくに、大量の繰り返し処理の内部に含めると遅くなってしまいます。

そこで、本番ではコストの高い検査を走らせたくない場合は、フィーチャーフラグを付けます。

#+development
(assert (expensive-invariant-check data))Code language: Lisp (lisp)

#+はリーダーマクロで、*features*というリストにシンボルが入っていなければ、そのフォームはコードとして存在しない扱いになります3

通常は、:developmentは、*features*には含まれていないので、次の式が実行されません。
開発環境でフラグを立てるには、起動スクリプトやasdfのシステム定義の先頭で次のように書きます4

(pushnew :development *features*)Code language: Lisp (lisp)

反対に、#-#+の否定です。
:productionがないときだけ有効にするといった書き方もできます。

#-production
(format t "debug: ~A~%" data)Code language: Lisp (lisp)

2. 契約による設計

assertの基本には、「Design by Contract(契約による設計)」という考え方があります。

契約による設計(Design by Contract) Bertrand Meyer(1986)提唱 → Eiffel言語に組み込まれた考え方 事前条件 precondition 呼び出し側が 守るべき条件 破れたら呼び出し側のバグ 事後条件 postcondition 関数が保証する 出力の条件 破れたら実装側のバグ 不変条件 invariant 状態が常に 満たすべき条件 中間地点に配置して検査 assert の位置がバグの責任の所在を示す 型システムが薄い Lisp では、インタフェース仕様として機能する

「Design by Contracct」は、Bertrand Meyerが1986年に提唱し、Eiffelという言語に組み込まれた考え方です5

ここでは、関数とその呼び出し側の関係を「契約」として捉えます。

契約は3つの要素で構成されます。

  • 事前条件(precondition)は呼び出し側が守るべき条件で、これが破れたら呼び出し側のバグです。
  • 事後条件(postcondition)は関数が保証する条件で、これが破れたら実装側のバグです。
  • 不変条件(invariant)はオブジェクトや状態が常に満たすべき条件を指します。

この枠組みでassertを置くと、コードが自分自身の責任範囲を明示できます。

(defun divide (a b)
  ;; 事前条件:呼び出し側の責任
  (assert (numberp a))
  (assert (not (zerop b)))
  (let ((result (/ a b)))
    ;; 事後条件:この関数の責任
    (assert (numberp result))
    result))Code language: Lisp (lisp)

事前条件が破れたときは「呼び出し方が間違っている」と判断でき、事後条件が破れたときは「この関数の実装に問題がある」と切り分けられます。
つまり、バグの責任の所在がassertの位置から読み取れるわけです。

Common Lispには契約を言語レベルで強制する仕組みはありませんが、assertを規律をもって配置することで同じ効果が得られます。
cl-contractsのようなライブラリを使うと、デコレータ的なスタイルで契約を宣言的に書くこともできます。

2.1. 事前条件として使うassert

関数の先頭に置くassertは、呼び出し側が守るべき前提を表現します。

(defun binary-search (vec target)
  (assert (vectorp vec))
  (assert (> (length vec) 0))
  ...)Code language: Lisp (lisp)

これは、ドキュメントとして読む人に条件を伝える意味もありますし、実際にコードを実行するときに検査にもなります。

型シグネチャを持たないLispでは、こうした明示的な検査がインタフェース仕様の代わりになります6

契約による設計の観点でassertを使うと、「誰が何を保証するか」がコード上に残ります。
とくに型システムが薄いLispでは、こうした明示的な検査が関数のインタフェース仕様として機能します。

2.2. 計算の中間地点に置くassert

関数内部のバグの発生箇所を絞り込むには、長い計算の途中にassertを挟むことも有効です。

(let ((result (step-one input)))
  (assert (valid-intermediate-p result) (result)
          "step-one produced invalid result: ~A" result)
  (step-two result))Code language: Lisp (lisp)

「どの段階まで正しかったか」がassertで区切られます。

2.3. リスタートできるエラー(コンディションシステム)

また、assertは、第2引数に変数リストを渡すと、REPLやSLIMEで「その変数を直して再試行する」リスタートが有効になります7

(assert (not (zerop b)) (b) "Divisor must not be zero, got ~A" b)Code language: Lisp (lisp)

他の言語では「例外を握りつぶして再実行」か「最初からやり直し」かしかないところを、コンディションシステムが実行コンテキストを保ったまま回復させます8
このデバッグ中に実行を止めずに値を修正して続けられるのは、Common Lispの面白い特徴です。

  1. assertはANSI Common Lisp標準に定義されているマクロです。仕様の詳細はCommon Lisp HyperSpec(CLHS)で参照できます。 – CLHS: Macro ASSERT
  2. CLHSによると、check-typeは内部的に(assert (typep place 'typespec) (place) 'type-error ...)と等価な動作をします。型違反時のメッセージは処理系が自動生成するため、assertより一貫性があります。 – CLHS: Macro CHECK-TYPE
  3. CLHSのSection 24.1.2では、フィーチャーとは「Lispイメージが持つ側面や属性」であり、*features*に含まれるシンボルで表現されると定義されています。シンボルは通常KEYWORDパッケージに置くことが推奨されています。 – CLHS: Variable FEATURES
  4. ASDFはAnother System Definition Facilityの略で、Common Lispのビルドシステムです。2001年にDaniel Barlowが開発を始め、現在は主要なすべてのCL処理系に同梱されています。(require "asdf")でロードできます。 – ASDF – Another System Definition Facility
  5. Meyerは1986年のOOPSLA(Object-Oriented Programming, Systems, Languages, and Applications)カンファレンスでEiffelを発表しました。Design by Contractという名称はEiffel Softwareが2003年に商標登録しています。 – Design by contract – Wikipedia
  6. CLHSのassertの項では、第2引数のplacesに指定した変数はエラー発生時にのみ評価され、ユーザーが値を変更してassertを再試行できるリスタートが自動的に利用可能になると定義されています。 – CLHS: Macro ASSERT
  7. SLIMEはEmacs用のCommon Lisp開発環境で、独自のデバッガSLDBを内蔵しています。コンディションが発生するとSLDBバッファが開き、利用可能なリスタートの一覧とバックトレースが表示されます。 – SLIME User Manual: Debugger
  8. Common Lispのコンディションシステムはスタックを巻き戻さずにエラー処理を行える点が特徴です。restart-bindhandler-bindと組み合わせることで、呼び出し元がリスタートを定義し、深いネストの中から制御を回復する構造が作れます。 – The Common Lisp Cookbook – Debugging