Common Lispには、コンディションシステムという強力なエラーハンドリングの仕組みがあります。
しかし、この仕組みを「例外処理の一種」として単純に捉えてしまうと、思わぬ設計上の失敗を招くことがあります。
その典型例が、エラーハンドリングとログ出力を混同してしまうケースです。
多くの言語における例外処理は、「異常を検知したら、その場で処理を中断し、上位に投げる」という一方向の流れを前提としています。
そのため、ログ出力もこの流れに沿って、catchした箇所やエラーを投げた箇所で行うのが一般的です。
しかし、Common Lispのコンディションシステムは、この前提とは根本的に異なる設計思想を持っています。
コンディションが送出された時点ではスタックが巻き戻っておらず、ハンドラはコンディションが発生した場所の文脈にアクセスしたまま処理を選択できます。
つまり、「異常の通知」と「処理方針の決定」と「スタックの巻き戻し」が、明確に分離された独立の概念として設計されているのです。
この分離を理解せずに、ログ出力のタイミングやレイヤーを従来の例外処理の感覚で決めてしまうと、以下のような問題が起こりがちです。
- 同じエラーに対して複数箇所でログが重複して出力される
- リスタート可能なはずの処理なのに、ログの都合で早期にスタックを巻き戻してしまう
- デバッグ時に必要な文脈情報が、ログ出力のタイミングによって失われる
本記事では、こうした失敗例を具体的なコードとともに整理し、コンディションシステムの設計思想に沿った、より適切なログ設計の考え方を解説していきます。
Lispの「コンディションシステム」とは何か?例外処理との違いを整理

Common Lispにおけるコンディションシステムは、単なるエラー通知の仕組みではありません。
異常事態の「検知」「報告」「対処方針の決定」「制御の移譲」という一連の流れを、それぞれ独立した部品として設計できる、非常に柔軟なフレームワークです。
多くのプログラマーは、この仕組みをJavaやPythonの例外処理と同一視してしまいがちですが、両者の設計思想には根本的な違いがあります。
本章では、まずコンディションシステムを構成する基本要素を整理し、その上で例外処理との違いを明確にしていきます。
シグナル・ハンドラ・リスタートの基本概念
コンディションシステムは、大きく分けて三つの要素から構成されています。
- コンディション(condition):異常や特定の状況を表すオブジェクト。エラーに限らず、警告や単なる通知にも使われます
- ハンドラ(handler):シグナルされたコンディションを受け取り、対応方針を決定する仕組み
- リスタート(restart):処理を中断せずに、別の方法で続行するための選択肢
signalやerrorによってコンディションが送出されると、Lisp処理系はスタックを巻き戻すことなく、動的スコープ内で有効なハンドラを探索します。
ここが最大の特徴です。
ハンドラは、コンディションが発生した時点の実行文脈にアクセスしたまま、どのリスタートを選ぶかを決定できます。
つまり、異常が起きた場所の情報を保持したまま、その場で「どう対処するか」を柔軟に選べるのです。
例外処理とコンディションシステムの設計思想の違い
一般的な例外処理では、throwされた時点でスタックの巻き戻しが即座に始まり、catchまたはtry-catchブロックに到達するまで、元の実行文脈へは二度と戻れません。
これは「異常が起きたら処理を中断し、上位に委ねる」という一方向的な思想に基づいています。
一方、コンディションシステムでは、シグナルの送出とスタックの巻き戻しが分離されています。
以下の表は、両者の違いを整理したものです。
| 観点 | 例外処理(例:Java) | コンディションシステム(Lisp) |
|---|---|---|
| 制御の移動 | 即座にスタックを巻き戻す | ハンドラ実行後に巻き戻すか選択できる |
| 対処の選択肢 | catchするかしないかの二択に近い | リスタートにより複数の継続方法を選べる |
| 発生元の文脈 | 巻き戻し後は失われる | ハンドラ実行時点でも保持されている |
この違いを理解しないまま設計を進めると、次章以降で扱う「ログ出力とエラーハンドリングの混同」という典型的な失敗につながっていきます。
なぜエラーハンドリングとログ出力を混同してしまうのか

前章で整理した通り、コンディションシステムはシグナル・ハンドラ・リスタートという独立した概念によって構成されています。
しかし、この構造を正しく理解していても、実際の設計フェーズになると、エラーハンドリングとログ出力を無意識に一体化してしまうケースが少なくありません。
ここでは、その原因を二つの観点から掘り下げていきます。
例外処理の感覚がもたらす思い込み
多くのプログラマーは、Common Lispを学ぶ以前に、Java、Python、C++といった言語で例外処理の書き方を身につけています。
これらの言語では、tryブロックの中で処理を行い、catchブロックでエラーを捕捉し、その場でログを出力してからリカバリ処理を行う、というパターンが半ば定石化しています。
この経験が染み付いていると、Lispでコードを書く際にも、以下のような思い込みが自然に発生します。
- 「エラーを検知したら、その場でログを出すのが正しい」
- 「ハンドラは例外のcatch節と同じ役割を持つはずだ」
- 「一度ハンドラが動いた時点で、処理は必ず中断される」
しかし、これらはすべて例外処理の設計思想に引きずられた誤解です。
コンディションシステムでは、ハンドラが動作した後でも処理を継続できる可能性があり、ログを出すべきタイミングと、エラーとして扱うべきタイミングは、必ずしも一致しません。
この思い込みが、後述する設計上の失敗の温床になります。
ログ出力位置の選定ミスが起きる背景
もう一つの原因は、ログ出力という関心事の性質そのものにあります。
ログ出力は、本来「何が起きたかを記録する」という横断的関心事であり、エラーハンドリングという「どう対処するかを決める」関心事とは、役割が異なります。
ところが、次のような理由から、両者が同じ場所に書かれてしまいがちです。
| 要因 | 内容 |
|---|---|
| 実装コストの低さ | ハンドラ内に直接ログ処理を書く方が、その場で完結して手軽に見える |
| デバッグ時の利便性 | エラー発生箇所付近にログがあると、一見追いやすく感じる |
| 責務分離の設計不足 | ログ機構とエラー処理機構を分離する設計方針が明確でない |
こうした背景から、ログ出力のコードがハンドラの中に埋め込まれ、コンディションシステム本来の柔軟性を損なう実装が生まれてしまいます。
次章では、この混同が引き起こす具体的な失敗例をコードとともに見ていきます。
【失敗例】ハンドラ内でログを出力してしまう設計の問題点

前章で述べた思い込みが実装に反映されると、具体的にどのような不具合が発生するのでしょうか。
ここでは、ハンドラ内に直接ログ出力を書いてしまった場合に起こりがちな、二つの典型的な失敗例を見ていきます。
ログが複数回重複出力されるケース
コンディションシステムの大きな特徴の一つに、ハンドラが複数階層にわたって設定できるという点があります。
handler-bindはコンディションを再送出(re-signal)できるため、ある層で処理しきれなかったコンディションは、さらに上位のハンドラへと伝播していきます。
このとき、それぞれのハンドラ内でログ出力を行ってしまうと、同一のエラーに対して何度もログが記録されてしまいます。
(defun inner-process ()
(handler-bind
((error (lambda (c)
(format t "~&[INNER LOG] エラー発生: ~A~%" c))))
(signal-invalid-data)))
(defun outer-process ()
(handler-bind
((error (lambda (c)
(format t "~&[OUTER LOG] エラー発生: ~A~%" c))))
(inner-process)))
このコードでは、inner-process内のハンドラがログを出力した後もコンディションが処理されずに伝播し続けるため、outer-process側のハンドラでも同じエラーについてログが出力されます。
結果として、一つの異常に対して二重、三重のログが記録され、ログを読む側は「本当に複数回エラーが起きたのか、それとも同じエラーが重複しているだけなのか」を判断できなくなってしまいます。
スタックの巻き戻しタイミングとログの矛盾
さらに深刻なのは、ログ出力のタイミングと、スタックの巻き戻しタイミングとの間に矛盾が生じる点です。
コンディションシステムでは、ハンドラが呼び出された時点では、まだスタックは巻き戻されていません。
ここでリスタートを使って処理を継続する選択肢が残されているにもかかわらず、ハンドラ内で安易に「エラーとしてログを出力してしまう」設計にすると、次のような問題が起こります。
- 本来は正常に継続できたはずの処理が、ログの都合で「エラー扱い」として記録されてしまう
- リスタートによって処理が回復した後も、直前に出力された異常ログが残り続け、ログの整合性が取れなくなる
- 「エラーログが出ているのに、処理は正常終了している」という一見矛盾した状態が生まれ、監視システムの誤検知につながる
つまり、ハンドラ内でのログ出力は、「まだ結果が確定していない段階」で記録を行ってしまう行為に他なりません。
ログは本来、処理の結末が確定した時点、あるいは記録すべき責務を持つ層で出力されるべきものです。
この設計上のズレこそが、コンディションシステムを誤解した典型的な失敗の正体だと言えます。
【失敗例】restartを使わずにログのためだけにcatchしてしまう設計

もう一つよく見られる失敗が、ログを出力する目的だけのためにhandler-caseを使い、コンディションをその場で握りつぶしてしまう設計です。
一見すると簡潔で分かりやすいコードに見えますが、実際にはコンディションシステムが持つ最大の利点である「リスタート可能性」を自ら手放してしまっています。
リスタート可能性を潰してしまう実装例
handler-caseは、handler-bindと異なり、コンディションを捕捉した時点でスタックを巻き戻し、対応する節の中に制御を移してしまいます。
これは通常の例外処理のcatchに近い挙動であり、一度この節に入ってしまうと、元の実行文脈には二度と戻れません。
(defun load-config (path)
(handler-case
(parse-config-file path)
(file-error (c)
(format t "~&設定ファイルの読み込みに失敗しました: ~A~%" c)
nil)))
このコードでは、parse-config-fileの内部でどれほど有用なリスタート(例えばデフォルト値を使う、別のパスを試す、といった選択肢)が用意されていたとしても、handler-caseによってその情報はすべて捨てられてしまいます。
呼び出し側は「エラーが起きたのでログを出してnilを返す」という一つの結末しか選べず、本来であれば呼び出し元が状況に応じて選べたはずの回復手段が、設計上完全に封じられてしまうのです。
コンディションシステムを正しく活用するのであれば、ログ出力のためだけにhandler-caseを使うのではなく、handler-bindを用いてログを記録しつつ、必要に応じてinvoke-restartで処理を継続させる、という選択肢を残すべきです。
デバッグ情報が失われる仕組み
さらに問題なのは、この設計がデバッグ効率を大きく損なう点です。
Common Lispの処理系の多くは、コンディションが捕捉されずにトップレベルまで到達すると、デバッガに入り、以下のような情報をそのまま参照できます。
| 情報 | 内容 |
|---|---|
| スタックフレーム | エラー発生時点の呼び出し階層 |
| ローカル変数 | 各フレームにおける変数の値 |
| 利用可能なリスタート | その場で選択できる回復手段の一覧 |
ところが、handler-caseでコンディションを早々に捕捉してログだけを出力してしまうと、スタックはすでに巻き戻された後であるため、これらの情報にはアクセスできなくなります。
ログに残るのは、あらかじめformatで書き出した文字列だけであり、実際に何が起きていたのかを後から調査する手がかりが大きく失われてしまうのです。
このように、ログ出力を目的とした安易なhandler-caseの利用は、コードの見た目こそシンプルになるものの、コンディションシステム本来の柔軟性と情報量を犠牲にする、代償の大きい設計だと言えます。
コンディションシステムの正しい理解:シグナルとハンドラの分離設計

ここまで見てきた失敗例は、いずれも「シグナルの送出」と「スタックの巻き戻し」を同一視してしまったことに起因しています。
この誤解を解くためには、handler-caseとhandler-bindという二つの基本マクロの違いを、正確に理解しておく必要があります。
handler-caseとhandler-bindの違い
両者はどちらもコンディションを処理するためのマクロですが、動作原理はまったく異なります。
| 観点 | handler-case | handler-bind |
|---|---|---|
| スタックの巻き戻し | 捕捉と同時に即座に行う | 行わない(ハンドラ実行後も文脈が残る) |
| 処理後の挙動 | 対応する節の値を返して終了する | ハンドラ内で処理を継続するか選べる |
| リスタートの利用 | 実質的に不可能 | invoke-restartで自由に選択できる |
| 用途 | 最終的な回復処理を一箇所で完結させたいとき | ログ記録や条件分岐など、文脈を保ったまま対応したいとき |
たとえば、以下のようにhandler-bindを使うと、コンディションが発生した時点の文脈を保持したまま、記録だけを行い、その後で処理を継続させることができます。
(handler-bind
((warning (lambda (c)
(log-warning c)
(invoke-restart 'muffle-warning))))
(risky-computation))
ここでのポイントは、ハンドラ内でlog-warningを呼び出した後、muffle-warningというリスタートを明示的に起動している点です。
ログの記録と、処理継続の判断が、コード上ではっきりと分離されて表現されています。
この構造こそが、コンディションシステムが持つ本来の設計思想です。
コンディション送出時点の文脈を活かす設計
handler-bindが優れているのは、単にスタックを巻き戻さないという性質だけではありません。
ハンドラが実行される時点で、以下のような情報にすべてアクセスできる点にあります。
- コンディション発生時点のローカル変数や動的変数の値
- 呼び出し階層全体のスタックフレーム
- その時点で有効なリスタートの一覧
この文脈を活かすことで、ログには「単にエラーが起きた」という事実だけでなく、「どのような状況で、どのような選択肢があった中でどう対処したか」という情報まで含めることが可能になります。
つまり、コンディションシステムを正しく設計に落とし込むということは、シグナルの送出、文脈の保持、対処方針の決定、そして必要に応じたスタックの巻き戻しを、それぞれ独立した工程として扱うということです。
次章では、この理解をもとに、ログをどのレイヤーで出力すべきかという具体的な設計指針を検討していきます。
ログ出力はどのレイヤーで行うべきか?設計指針を考える

コンディションシステムの分離構造を理解すると、次に問うべきは「では、ログはどの層で出力すべきなのか」という具体的な設計判断です。
ここでは、シグナル発生源とハンドラ層それぞれの役割を整理しながら、実践的な指針を考えていきます。
シグナル発生源でログを出さない理由
まず前提として、コンディションを送出する関数自体には、ログ出力の責務を持たせないことを原則とすべきです。
理由は明確で、シグナルを送出する側は「何が起きたか」を知っていても、「それをどう扱うべきか」までは知らないからです。
たとえば、下位のライブラリ関数がファイル読み込みエラーをシグナルする場面を考えてみます。
呼び出し元によっては、それを致命的なエラーとして扱いたい場合もあれば、単に警告として無視し、デフォルト値で処理を継続したい場合もあります。
もしシグナル発生源で無条件にログを出力してしまうと、以下のような弊害が生じます。
- 呼び出し元がリスタートで正常に回復した場合でも、無関係な異常ログが残ってしまう
- ライブラリの利用者が意図しないログ出力ポリシーを強制されてしまう
- 同じライブラリが複数箇所から呼ばれるたびに、文脈の異なるログが同じ形式で出力され、判別が困難になる
つまり、シグナル発生源の役割は「何が起きたかを正確に伝えること」に限定し、それをどう記録し、どう対処するかは、呼び出し側に委ねるのが筋の通った設計です。
ハンドラ層とアプリケーション層の役割分担
では、ログ出力の責務はどこに置くべきなのでしょうか。
基本的な指針として、以下のような役割分担が有効です。
| 層 | 主な責務 | ログ出力の扱い |
|---|---|---|
| シグナル発生源 | コンディションの生成と送出 | 行わない |
| ハンドラ層 | コンディションの捕捉と対処方針の決定 | 必要な文脈情報を添えて記録する |
| アプリケーション層 | 業務ロジック全体の制御、最終的な結果の確定 | 処理結果が確定した後の要約ログを記録する |
ハンドラ層は、handler-bindによってコンディション発生時の文脈にアクセスできる立場にあるため、詳細なログを記録するのに最も適した層です。
一方で、個々のコンディションを逐一記録するのではなく、業務としての結果が確定した段階で要約的なログを出すのは、アプリケーション層の役割です。
この二層構造を意識することで、「何が起きたか」を詳細に残す技術的なログと、「処理としてどう終わったか」を示す業務的なログを、それぞれ適切な粒度で分離できるようになります。
次章では、この設計指針を踏まえた具体的な実装例を見ていきます。
実装で学ぶ:コンディションシステムに沿ったログ設計のベストプラクティス

ここまでの議論を踏まえて、実際にどのようなコードを書けば、コンディションシステムの設計思想に沿ったログ出力を実現できるのかを見ていきます。
ポイントは、リスタートを提供する側と、それを利用する側を明確に分け、ログはその橋渡し役として機能させることです。
restartを活かしたログ出力の実装例
まず、コンディションを送出する側では、restart-caseを用いて回復手段を提供します。
ここではログ出力を一切行いません。
(defun fetch-user-record (id)
(restart-case
(or (db-lookup id)
(error 'record-not-found :id id))
(use-default-record ()
:report "デフォルトのユーザー情報を使用する"
(make-default-user id))
(retry-lookup ()
:report "再度データベースを検索する"
(fetch-user-record id))))
次に、呼び出し側ではhandler-bindを使い、コンディション発生時点の文脈を保持したままログを記録し、その上でリスタートを選択します。
(handler-bind
((record-not-found
(lambda (c)
(log-event :level :warn
:message "ユーザー情報が見つかりません"
:context (condition-id c))
(invoke-restart 'use-default-record))))
(fetch-user-record 42))
この設計では、fetch-user-recordはログの存在すら知らず、呼び出し側だけがログ出力とリカバリ方針の両方を制御しています。
責務が明確に分離されており、ライブラリの再利用性も損なわれません。
ログレベルとコンディション型の対応付け
ログ出力を体系的に設計する上では、コンディションの型とログレベルをあらかじめ対応付けておくことも有効です。
これにより、ハンドラ側での判断が一貫し、ログの粒度がプロジェクト全体で揃います。
| コンディション型 | 想定される状況 | 対応するログレベル |
|---|---|---|
| serious-condition | 処理継続が困難な致命的な異常 | error |
| error(非致命的) | リスタートで回復可能な異常 | warn |
| warning | 想定内の軽微な異常や注意喚起 | info |
| simple-condition | 単なる状態通知 | debug |
このような対応表をチーム内で共有しておくと、個々の開発者がハンドラを実装する際に、ログレベルの選定で迷う場面が減ります。
また、コンディションの型階層をdefine-conditionで適切に設計しておくことも、この対応付けを機能させる前提条件になります。
型階層が曖昧なままだと、ログレベルの判断もまた曖昧になってしまうため、コンディション設計とログ設計は、常にセットで検討すべき課題だと言えます。
まとめ:コンディションシステムを正しく理解してエラーハンドリングとログ出力を設計しよう

ここまで、Common Lispのコンディションシステムを題材に、エラーハンドリングとログ出力を混同してしまう典型的な失敗例と、その対策について整理してきました。
改めて全体を振り返ると、問題の根本にあったのは、多くの言語で身につけた例外処理の感覚を、そのままコンディションシステムに当てはめてしまったことにあります。
例外処理では、異常を検知した時点でスタックが即座に巻き戻り、その場でログを出力して処理を終える、という一方向の流れが前提でした。
しかし、コンディションシステムは、シグナルの送出、ハンドラによる文脈の保持、対処方針の決定、そして必要に応じたスタックの巻き戻しを、それぞれ独立した工程として扱う設計思想の上に成り立っています。
この前提を見誤ると、以下のような問題が繰り返し発生することになります。
- ハンドラが複数階層にわたって設定されているために、同じ異常に対してログが重複して記録されてしまう
- ログ出力のためだけに
handler-caseを用いてしまい、リスタートによる回復可能性を自ら潰してしまう - スタックの巻き戻しタイミングとログ出力のタイミングがずれ、処理が正常終了しているにもかかわらず異常ログが残るという矛盾が生じる
- コンディション発生時点でしか得られない文脈情報が、ログ出力の設計不備によって失われてしまう
これらの失敗に共通しているのは、「異常の通知」と「対処の決定」と「記録」という、本来別々に扱うべき責務を、一箇所にまとめて処理しようとしてしまった点です。
対策として本記事で提示した指針は、大きく三つにまとめられます。
- シグナル発生源にログ出力の責務を持たせない:ライブラリや下位関数は、何が起きたかを正確にコンディションとして送出することに専念し、記録の判断は呼び出し側に委ねます
handler-bindを用いて文脈を保持したままログを記録する:コンディション発生時点のスタックフレームや変数の状態にアクセスできるうちに、必要な情報をログへ落とし込みます- リスタートを活かした回復可能な設計を維持する:ログを出力すること自体が、処理の中断を意味しないようにし、
invoke-restartによって処理を継続できる余地を常に残しておきます
さらに、コンディションの型とログレベルをあらかじめ対応付けておくことで、チーム開発においてもログの粒度を一貫させることができます。
これは、コンディションの型階層設計そのものが、ログ設計の質に直結することを意味しています。
コンディションシステムは、単なる「Lisp版の例外処理」ではなく、異常系の設計そのものを再考させてくれる強力な仕組みです。
エラーハンドリングとログ出力という、似て非なる二つの関心事を明確に切り分けて設計することが、堅牢で保守性の高いLispコードを書くための第一歩だと言えるでしょう。
ぜひ手元のコードを見直し、ハンドラの中に無意識に書いてしまっているログ出力がないか、確認してみてください。


コメント