ホア論理、クイックソート、そして安全性(Safety)思考を通して、トニー・ホアの正しさに関する考え方が現実的な正確なコードを書く/レビューする技法にどう結びつくかを学びます。

人がプログラムを「正しい」と言うとき、多くは「何回か実行して出力が良さそうだった」という意味で使います。それは有益なシグナルですが、正しさそのものではありません。平たく言えば、正しさとはプログラムが仕様を満たすこと:許容されるすべての入力に対して要求される結果を出し、状態変化やタイミング、エラー処理に関するルールを守ることです。
しかし「仕様を満たす」は思ったより難しいのが現実です。
まず、仕様はしばしば曖昧です。要件が「リストをソートする」と言っても、それは安定ソートを意味するのか?重複値や空リスト、非数値要素はどう扱うのか?仕様が明示しない場合、人によって解釈が分かれます。
次に、エッジケースは稀ではなく、単にテストされにくいだけです。null 値、オーバーフロー、オフバイワンの境界、異常なユーザー操作列、外部障害が「見た目は動く」コードを本番で壊します。
さらに、要件は変わります。昨日の仕様に対して正しかったコードが、今日の仕様では間違っていることがあります。
トニー・ホアの大きな貢献は「すべてを常に証明しろ」という主張ではありませんでした。彼の示したのは、コードが何をすべきかをより正確に書き、それについて規律立てて推論できるようにする考え方です。
この記事では三本のつながる糸を辿ります:
ほとんどのチームがすべてを形式的に証明するわけではありませんが、部分的な「証明スタイル」の思考でもバグが見つかりやすくなり、レビューの精度が上がり、出荷前に挙動が明確になります。
トニー・ホアは、論文や講義室に留まらなかった稀有な計算機科学者の一人です。学界と産業界を行き来し、すべてのチームが直面する実践的な問い――特に利害が大きいときに「どうやってプログラムが想定どおり動くと分かるのか?」に取り組みました。
この記事は実際のコードベースで何度も現れるホアの考えを中心に扱います:
{P} C {Q} を使って振る舞いを記述する方法。ここでは数学的な厳密性に深く踏み込んだり、クイックソートの機械検証済みの完全な証明を試みたりはしません。狙いは概念を平易に保つこと:あなたの推論を明確にするのに十分な構造を提示し、コードレビューを大学院セミナーにしないことです。
ホアの考え方は、関数が依存する仮定、呼び出し側に保証すること、ループ中に保つべきこと、レビュー中に「ほぼ正しい」変更を見抜く方法といった日常的な判断に落とし込まれます。{P} C {Q} を明示しなくても、その形で考えるだけで API、テスト、議論の質が上がります。
ホアの見方は「いくつかの例で通った」より厳密です:正しさとは合意された約束を満たすことであり、少数の成功例で判断するものではありません。
バグは多くの場合、中間の仕様を省略して要件から直接実装に飛ぶときに生じます。
しばしば混同される二つの主張:
現実のシステムでは、終了しないことが「誤動作」と同じくらい有害なことがあります。
正しさの主張は普遍的ではなく、次のような仮定に依存します:
仮定を明示することで「自分のマシンで動いた」が他人にも意味を持つようになります。
関数 sortedCopy(xs) を考えます。
有用な仕様の例:「新しいリスト ys を返す。(1) ys は昇順にソートされている、(2) ys は xs と同じ要素(同じ個数)を含む、(3) xs は変更されない。」
ここで「正しい」とは、上の三点が述べられた仮定の下で満たされることを意味します。単に出力がざっと見てソートされているだけではありません。
ホア論理はコードについて、契約のような明快さで話す方法です:「もしある状態が前提を満たしていて、このコード片を実行すれば、ある保証が成立する」。
中心になる表記は ホア三重項 です:
{precondition} program {postcondition}
前提は 実行前に真でなければならない 事実を述べます。それは希望ではなく、コードが必要とする条件です。
例:ある関数がオーバーフロー検査なしで二つの数の平均を返す場合。
a + b が整数型に収まることavg = (a + b) / 2avg は a と b の数学的平均に等しい前提が満たされないと、結果の保証は成り立ちません。三重項はそれを明示させます。
結果は 実行後に真である べきことを述べます(前提が成り立っていることを条件に)。良い結果は具体的で検査可能です。「結果が有効」はなく、「ソートされている」「非負である」「特定のフィールドだけが変更される」といった形で書きます。
ホア論理は小さな文から複数ステップのコードまで拡張できます:
x = x + 1 の後に x についてどんな事実が成り立つかを考えます。目的は波かっこをコード中にばらまくことではなく、意図を読みやすくすることです:明確な前提、明確な結果、そしてレビューでの「動いたっぽい」議論を減らします。
ループ不変式 は、ループが始まる前、各反復後、ループ終了時に真である文です。単純な考え方ですが効果が大きく、反復の各段階で実際にチェックできる主張に置き換えます。
不変式がないとレビューは「リストを反復してだんだん直す」といった曖昧な説明になりがちです。不変式は「今この時点で何が既に正しいか」を明確にします。これが明確になるとオフバイワンや見落としは、不変式が破られる瞬間として分かりやすくなります。
日常の多くのコードは、いくつかの信頼できるテンプレートで扱えます。
インデックスを安全な範囲に保つ。
0 <= i <= nlow <= left <= right <= highこの不変式は範囲外アクセスを防ぎ、配列の議論を具体化します。
データを「済んだ領域」と「まだ」の領域に分ける。
a[0..i) のすべての要素は検査済み」result に移したすべての要素はフィルタ述語を満たす」これにより漠然とした進捗が「今何が済んでいるか」という契約に変わります。
ソートやマージ、パーティションで一般的。
a[0..i) はソートされている」a[0..i) の全要素は <= pivot、a[j..n) の全要素は >= pivot」配列全体がまだソートされていなくても、何が確定しているかが定まります。
正しさは単に正しいことだけでなく、ループが 終わる ことも含みます。簡単な主張方法は、各反復で縮む測度(バリアント)を名付けることです。
例:
n - i は毎回 1 ずつ減る」縮む測度が見つからなければ、それは無限ループの実際的リスクを示しています。
Quicksort の約束はシンプルです:配列の区間を与えられれば、その要素を非減少順に並べ替え、要素を失ったり新しく作ったりしないこと。アルゴリズムの高レベルは簡潔にまとめられます:
Quicksort は教示用に優れています:頭に収まる大きさでありながら、非形式的な推論が失敗する箇所を浮き彫りにします。ランダムテストで「動いた」ように見えても、特定の入力や境界で間違うことがあります。
典型的な問題は次の通りです:
ホア風に正しさを主張するには、通常証明を二つに分けます:
この分離により証明は扱いやすくなります:まずパーティションを正しくして、そこからソートの正しさを積み上げます。
Quicksort の高速さは一見小さなルーチン、partition に依存しています。partition が少しでも間違うと、Quicksort は誤ソート、無限再帰、あるいは境界外アクセスでクラッシュします。
ここでは古典的な ホアのパーティション方式(両端から内側へ進む二つのポインタ)を使います。
入力: 配列スライス A[lo..hi] と選ばれた pivot 値(多くの場合 A[lo])。
出力: インデックス p を返し、以下を満たすこと:
A[lo..p] の各要素は <= pivotA[p+1..hi] の各要素は >= pivotここで注意すべきは約束されないこと:pivot が必ず p に収まるとは限らず、pivot と等しい要素はどちらの側にも現れる可能性がある点です。それでも Quicksort は正しい分割を得れば十分です。
アルゴリズムが左から i、右から j の二つのインデックスを進めるとき、良い推論は「既に固まったもの」に注目します。実務的な不変式のセットは:
A[lo..i-1] の要素はすべて <= pivot(左側はクリーン)A[j+1..hi] の要素はすべて >= pivot(右側はクリーン)A[i..j] は 未分類(まだチェックされていない)A[i] > pivot と A[j] < pivot を見つけてスワップすれば、不変式は保たれ、未分類領域が縮みます。
i は右へ走り、パーティションは終了して妥当な p を返す必要がある。j は左へ走り、同様に終了しなければならない。< と <= を混ぜるとポインタが停滞する。ホアの方式は一貫したルールで進行を保証する。Lomuto、Hoare、3-way といった異なるパーティション方式がありますが、重要なのは一つを選び、その契約に対してコードを一貫してレビューすることです。
再帰は次の二つが明確に答えられると信頼しやすくなります:「いつ止まるか?」と「各ステップはなぜ有効か?」ホア風の思考は呼び出し前に何が真であるべきか、戻った後に何が真であるかを明示させます。
再帰関数は少なくとも一つの 基本ケース を持ち、そこでは再帰呼び出しを行わずに約束を満たさねばなりません。ソートでは典型的に「長さ 0 または 1 の配列は既にソート済みである」が基本ケースです。ここで「ソート済み」は明示的に:任意の i < j に対して a[i] <= a[j] が成り立つこと。等しい要素の元の順序を保持する性質は安定性と呼ばれ、Quicksort は設計しない限り通常は安定ではありません。
各再帰ステップは厳密に小さい入力に対して自己呼び出しを行うべきです。これは終了性の議論になります:サイズが小さくなり 0 未満にはならないなら、無限再帰は起き得ません。
縮小はスタック安全の観点でも重要です。正しいコードでも再帰深度が大きすぎればクラッシュします。Quicksort では偏った分割が深い再帰を生むので、最悪深さを考慮すべきです。
Quicksort の最悪計算量は分割が偏ると O(n^2) に悪化しますが、それは性能の問題であって正しさの欠如ではありません。推論の目標は、パーティションが要素を保存し pivot に従った分割を行うなら、部分範囲の再帰ソートにより全体が定義どおりにソートされる、ということです。
テストと証明スタイルの推論は同じ目標――信頼性――に向かいますが、アプローチが異なります。
テストは具体的な間違いを見つけるのが得意です。一方、証明スタイルの推論(特にホア風)は、仕様から出発して「前提が満たされるなら常に結果が成り立つか」を問います。これをうまくやると、境界外アクセスや不変式破壊、終了性問題といったバグのクラスをまとめて排除できます。
明確な仕様はテストの自動生成器になります。ポストコンディションが「出力はソートされ、入力の置換である」と書かれていれば自動的にテスト案が出ます:
仕様は「正しい」とは何かを教え、テストは現実がそれに合っているかを検査します。
プロパティベーステストは証明と事例テストの中間に位置します。手でいくつかの場合を選ぶ代わりに、性質を表明してツールに多くの入力を生成させます。
ソートに対して有効な二つの性質:
これらの性質は実行可能なポストコンディションです。
スケールする軽量な手順:
これを制度化するなら、PR テンプレートやコードレビューのチェックリストに「仕様+推論ノート+テスト」を入れると良いでしょう(参照: /blog/code-review-checklist)。
チャットベース生成ワークフロー(vibe-coding 等)を使う場合も同じ規律が必要です。例えば Koder.ai では、実装前に Planning Mode で前提/結果を固め、スナップショットやロールバックを使いながらプロパティベーステストを追加できます。ツールは実装を速めますが、速さが「脆弱さ」に変わらないようにするのは仕様です。
正しさは単に「正しい値を返す」だけではありません。安全性思考は別の問いを投げかけます:『許容できない結果は何か、それをどう防ぐか――コードが過負荷にさらされたり、誤使用されたり、一部が故障している場合でも?』』実務では、安全性は優先順位付きの正しさです:ある失敗は迷惑に留まり、別の失敗は金銭的損失、プライバシー侵害、身体的危害に繋がる可能性があります。
バグ はコードや設計の欠陥です。ハザード は受け入れがたい結果に至る状況です。一つのバグは文脈によって無害にも危険にもなり得ます。
例:写真ギャラリーのオフバイワンは画像のラベルを誤るかもしれませんが、投薬用量計算の同じミスは患者に危害を及ぼす可能性があります。安全性思考はコード挙動を結果に結びつけることを強制します。
重い形式手法を導入しなくても即効性のある安全対策が取れます:
これらの技術はホア式の推論と自然に合います:前提を明示し(どの入力が許容されるか)、結果に安全性の性質(絶対に起きてはならないこと)を含めます。
安全チェックにはコストがあります――CPU 時間、複雑さ、あるいは誤検出による拒否の発生頻度。
安全性思考は優雅さを証明するより、許容できない故障モードを防ぐことに重きを置きます。
コードレビューは正しさ思考の投資対効果が最も高く現れる場です。ホアの基本的な動き――「何が実行前に真であるべきか」「実行後に何が真になるか」を述べる――はレビュー時の問いにそのまま使えます。
変更を読むとき、主要な関数を小さな約束としてフレーム化してみてください:
簡単なレビューハビット:前提/結果を一文で言えないなら、コードは構造が曖昧か注釈が必要です。
リスクの高い関数には署名直上に小さな契約コメントを追加しましょう。具体的に:入力、出力、副作用、エラーを列挙します。
def withdraw(account, amount):
"""Contract:
Pre: amount is an integer > 0; account is active.
Post (success): returns new_balance; account.balance decreased by amount.
Post (failure): raises InsufficientFunds; account.balance unchanged.
"""
...
これらは形式的な証明ではありませんが、レビュアーがチェックすべき明確な基準を与えます。
パース/検証(不正入力経路、境界ケース)、並行処理(ロック、競合、冪等性、リトライ)、金銭/クォータ(丸め、二重課金、オーバーフロー)、権限(誰が何をできるか)を扱うコードをレビューするときは特に明示的にします。
変更がこれらに触れる場合は「前提は何で、どこで強制されているか?」と「失敗したときでもどんな保証を提供するか?」を必ず確認してください。
形式的推論を全コードベースに導入する必要はありません。やるべきは、普通のレビュー+テストだけでは足りない「差し迫った箇所」に工夫を注ぐことです。
すべてが依存する小さなクリティカルモジュール(認証、決済ルール、権限、セーフティインターロック)、あるいはオフバイワンの間違いが何ヶ月も隠れる厄介なアルゴリズム(パーサ、スケジューラ、キャッシュ/エヴィクション、パーティション型コード、境界に敏感な変換)に適しています。
実用的なルール:バグが「実害」「大きな金銭損失」「沈黙のデータ破壊」を引き起こす可能性があるなら、普通のレビュー+テスト以上が必要です。
軽量から重い手法まで選択肢があります。多くの場合は組み合わせが効果的です:
踏み込む深さは次を天秤にかけて決めます:
実務では「形式度」は段階的に増やせます:明確な契約と不変式から始め、自動化で整合性を保つのが現実的です。Koder.ai のようなツールを使うチームでは、React フロント、Go バックエンド、Postgres スキーマといった複数部分を短いループで生成しつつ、スナップショット/ロールバックで安定性を確保し、CI で契約と静的解析を回すといった運用ができます。
計画やレビューの段階で「もっと形式化すべきか?」を判断する簡単な門番として使ってください:
参考トピック:設計による契約(design-by-contract)、プロパティベーステスト、状態機械のモデル検査、言語向け静的解析、証明支援系の入門資料。
正確さとは、合意された仕様を満たすことを意味します。つまり許容されるすべての入力や関連するシステム状態に対して、要求された出力と副作用を生成し、エラー動作も仕様どおりに扱うことです。単に「試したら動いた」は、入力空間や境界条件を十分にチェックしていないことが多いです。
要件はビジネス上の目的(例:「一覧を表示用にソートする」)です。仕様はその要件を正確かつ検査可能にしたもの(例:「新しいリストを返し、昇順に並び、元の要素の多重集合が同じ」)です。実装は実際に書いたコードです。チームが要件から直接コードに飛ぶと、検査可能な「約束」を書き落としてバグが生まれます。
部分的正しさ:コードが戻った場合、その結果は正しい。 全体的正しさ:コードは必ず戻り、かつ結果は正しい(終了性も含む)。 実務では、終了しないことがユーザーが体感する不具合やリソース問題、安全上のリスクになる場面では、全体的正しさが重要になります。
ホア三重項 {P} C {Q} は契約のように読むことができます。
P(前提): C を実行する前に成り立っていなければならないことC : 実際のコード断片Q(結果): P が成り立っているという前提で C の実行後に成り立つこと記法自体をコードに書く必要はありませんが、「入力時にこれを仮定し、終了後にこれを保証する」という構造をレビューや設計に持ち込むのが実利的な利点です。
良い前提(precondition)はコードが実際に必要とする条件です(例:「インデックスが範囲内である」「要素が比較可能である」「ロックが保持されている」)。呼び出し側が前提を破る可能性があるなら、次のいずれかを行います:
前提を書かないと、後述の保証は単なる願望になります。
ループ不変式は、ループ開始前に真であり、各反復の後も真であり、ループ終了時にも真である文です。再利用しやすいテンプレート例:
0 <= i <= n)a[0..i) の要素はすべて検査済み」)a[0..i) はソートされている」)不変式を明示できない場合、それはループが多くの責務を抱えているか、境界が曖昧であるサインです。
通常は、各反復で減少する測度(バリアント)を名付けます。たとえば:
n - i が各反復で1ずつ減る減少する測度が見つからない場合、それは無限ループの実際的リスクを示しているかもしれません(特に重複値やポインタの停滞がある場合)。
Quicksort において、partition(分割)が中心です。partition が少しでも誤ると、
といった事態が起きます。だから partition の契約(左側は pivot 以下、右側は pivot 以上、要素は置き換えだけで新しい要素は作らない=置換になる)を明確にすることが重要です。
重複値と pivot に等しい要素の扱いが不適切だとよく失敗します。実務的な対処法:
重複が多いなら三分割(3-way)パーティションを検討すると、バグと再帰深度の両方が緩和されます。
テストは具体的な誤り(オフバイワン、境界ケース、回帰)を見つけます。推論(証明スタイル)は、ある事柄のクラス(境界外アクセスの不在、不変式の保持、終了性など)を排除できます。現実的なハイブリッドワークフロー:
ソートでは、高価値な二つのプロパティは「ソート済みであること(非減少順)」と「置換であること(入力と同じ要素を同じ個数含む)」です。