🐛

命題論理から考えるデバッグ手法

に公開

はじめに

デバッグはプログラミングの技術として非常に重要であるにもかかわらず、体系的に教える手法がほとんどないことで知られています。そこでこの記事ではプログラムのデバッグを数学的なアプローチを用いて説明することを試みました。数学の諸概念を扱うのでそれらの前提知識があると読みやすいですが、もしそれらの概念に詳しくなかったとしても、前提知識についてはなるべく説明するように心掛けています。

命題論理とは

命題論理をデバッグに応用する前に、命題論理そのものの復習をしておきます。既に理解しているという方は プログラミングに応用する まで読み飛ばしてもらって構いません。

命題とは

命題の定義は場所によって様々ありますが、一般的な定義として 「真か偽かが明確に定まる文」 のことを命題と呼ぶことにします。例えば次は命題の例です。

  • 5 < 3 である (偽)
  • 東京は首都である (真)
  • (x = 2 のもとで) x > 3 である (偽)

逆に次のような文は命題ではありません。

  • x > 3 である (xの値が与えられないと真偽が定まらない)
  • 京都は首都ですか? (真偽を与えられない)

論理記号の導入

先ほど考えた例は全て原子命題と呼ばれる 「これ以上分解できない命題」 でしたが、命題同士を組み合わせて別の命題を作るということが考えられます。よく使われる論理記号として次の5つがあります。

  • A∧B (AかつB、AND、蓮言)
  • A∨B (AまたはB、OR、選言)
  • ¬A (Aではない、NOT、否定)
  • A→B (AならばB、含意)
  • A↔B (Aの場合かつこの場合に限りB、同値)

一般的に次の言い換えが行えます。

  • A→B¬A∨B と同じ
  • A↔B(A→B)∧(B→A) と同じ
    • すなわち (¬A∨B)∧(¬B∨A) と同じ
    • すなわち (A∧B)∨(¬A∧¬B) と同じ

つまり ∧, ∨, ¬ を用いて言い換えることができるので、実は を特別扱う必要はないのですが、分かりやすさのために導入します。

それぞれの論理記号の意味はそれぞれのプログラミングの世界での意味とほぼ同じですが、真理値表 によってその意味を確認しておきましょう。真理値表とは、例えば A, B からなる命題 P があったとき、A, B の真偽のパターン (4パターン) によって P の真偽がどう変わるのかを書いた表のことです。

それぞれの論理記号について真理値を考えてみると、次のようになります。(読みやすさのために真を1、偽を0として表現しています)

A B A∧B A∨B A→B A↔B
1 1 1 1 1 1
1 0 0 1 0 0
0 1 0 1 1 0
0 0 0 0 1 1
A ¬A
1 0
0 1

必要条件・十分条件

命題 P, Q において P → Q という命題が真であるとき、次の概念を考えることができます。

  • Q が成り立つことは P が成り立つための 必要条件
    • Q が成り立つことは P が成り立つために必要
  • P が成り立つことは Q が成り立つための 十分条件
    • P が成り立たたなくても、十分 Q は成り立つ

直感的には P の方が Q よりも強い命題 (成り立つことが厳しい命題) であると言えます。

集合との対応

実は命題は集合と対応付けることができます。例えば x > 3 という条件Pxの値を変えながら考えてみましょう。考えるとき、これは次のような条件を考えることと等価になります。

x∈A \space ただし \space A = \{x | x > 3\}

※ここで x∈A は要素xが集合Aに含まれるという意味、\{x | x > 3\}x > 3 という値を満たす x 全体の集合という意味です

これは単に同じことを複雑に言い換えているだけに感じるかもしれません。しかし論理記号を考えると集合を考えることの面白さが出てきます。

x < 5 という条件Qを同様に x∈B \space ただし \space B = \{x | x < 5\} と考えたとき、P∧Q はどのように表せるでしょうか?

この例だと単純に 3 < x < 5 と求められてしまいますが、集合で考えると x∈(A∩B) と表すことができます。(ここで A∩BA, B の積集合を表す)

同様に P∨Qx∈(A∪B) (和集合) と、¬Px∈\bar{A} (補集合) と表せます。

ここで P→QP↔Q を考えてみます。それぞれ ∧, ∨, ¬ に書き換えることを用いると次のような対応関係が得られます。

  • P→Q = x∈(\bar{A}∪B)
  • P↔Q = x∈((A∩B)∪(\bar{A}∩\bar{B}))

これだけだと単に複雑な結果に思えるかもしれませんが、これらが常に成り立つような P, Q の条件 (すなわち A, B の条件) を考えると、次のような条件が得られます。

  • P→Q が常に成り立つ条件: A⊂B
    • ∵(\bar{A}∪B) = U ⇔ B⊃\overline{\bar{A}} ⇔ A⊂B
  • P↔Q が常に成り立つ条件: A=B
    • ∵((A∩B)∪(\bar{A}∩\bar{B})) = U ⇔ A∩B⊃\overline{\bar{A}∩\bar{B}} ⇔ A∩B⊃A∪B ⇔ A = B

※ここで A⊂BAB に含まれる (A の要素が全て B の要素である) ことを指します

これは非常に面白い結果で、論理的に (真理値表的に) 見れば複雑だった含意・同値が集合関係では非常にシンプルに表現できることが分かりました。この関係はのちの議論で非常に重要になります。

プログラミングに応用する

長い長い下準備が終わったところで、実際にプログラミングの世界で命題論理をどう役立てていくか考えてみましょう。

プログラミングの世界と命題の世界を繋げる

例として次のプログラムを題材にします。main() 関数が一定間隔で呼ばれている状況で、rightKey()leftKey() はそれぞれ右矢印キーと左矢印キーが押されているかどうかを検知する関数だとします。

let x = 0;
function main() {
    console.log('Hello, main!');

    if (rightKey()) {
        console.log('Hello, rightKey!');
        x += 1;
    }
    if (leftKey()) {
        console.log('Hello, leftKey!');
        x -= 1;
    }

    ctx.clearRect(0, 0, WIDTH, HEIGHT);
    ctx.fillRect(x, 10, 30, 30);
}

ここで「このプログラムの内部状態」全体の集合を考えることができます。これを便宜上 Sと呼ぶことにしましょう。しかしプログラムの内部状態は一般に書き下すことが非常に難しいので、ある内部状態 s の下で起きうる事象全体の集合を \mathit{Possible}(s) と表すことにします。逆にある事象 P が起きうる内部状態全体の集合を \mathit{Internal}(P) とします。ここで P = \mathit{Possible}(s)s = \mathit{Internal}(P) は同値であり、逆写像の関係にあることが分かります。

さらに内部状態の全体集合 S に対応した起きうる事象の全体集合 U = \{\mathit{Possible}(s) | s∈S\} を考えることができます。これには例えば次のような要素が含まれています。

  • console.log('Hello, main!'); が実行されて Hello, main! がコンソールに表示される
  • x += 1; が実行されて x が1足される
  • if文の時点で rightKey()true を返す

さらにこれらの要素、すなわちこのプログラムにおいて起きうることは適切な前提条件を与えれば命題とみなすことができます。これはそのプログラムがある行を実行するかしないか、ある時点での変数の値が○○かどうか、など真偽を一意に定めることができるためです。

命題論理を活用してデバッグする

命題とみなすことができるということは、論理関係を考えることもできれば、対応する集合を考えることもできます。これらの考え方を用いてデバッグを考えていきましょう。

デバッグのサイクル

このプログラムは右矢印キーが押されている間は右に動くことが期待されているわけですが、何らかの原因で 右矢印キーを押しても右に動かない という状況が生まれたとします。これは開発者の意図と反した挙動であり、いわゆるバグです。このバグの根本的な原因を追究していきましょう。

右矢印キーを押しても右に動かない、という状況はプログラムにおいて発生している事象であり、起きうる事象の全体集合 Uに含まれます。便宜上その要素を P と呼ぶことにしましょう。以下 P の真偽といった言葉が出てきますが、これらは全てこのプログラムをある条件で実行したという前提条件の下で考えているとみなしてください。

ここで原因を追究するという行為は常に Q→P が真である要素 Q∈U を探すということに一致します。(その理由はこれからの議論で明らかにしていきます) この Q仮説 と呼ぶことにします。仮説 Q の真偽を確認すると、そのどちらかによって原因を絞ることができます。このプロセスを (仮説の) 検証 と呼ぶことにします。

  • Q が真のとき: 実質的な原因が Q にあると見ることができます。Q を直すのが自明ではない場合、さらに Q について原因を深掘っていくことになるでしょう。(つまり Q の原因となる仮説を探し、検証していく)
  • Q が偽のとき: P∧¬Q という条件に強めることができます。さらに P∧¬Q の原因となる仮説を探し、検証していくことになるでしょう。

つまりバグという事象が起きた時、そのバグが起きる原因となる可能性のある現象 (=仮説) を考え、それが正しいかを検証することでより狭い範囲に原因を絞る、というプロセスを繰り返していくことになります。最終的に自明に直せる事象 (例えばtypoしていた、引数の順序を間違えていた、など) まで辿り着けば無事バグを直せたということになります。

集合で考える

また、このプロセスは集合で考えると分かりやすくなります。はじめにバグの事象として Q が観測された時、バグの原因は P を起こす全ての内部状態にあると考えられます。

Qが起きうる内部状態Internal(Q)⊂Pが起きうる内部状態Internal(P)⊂内部状態の全体集合Sのうち、Internal(P)のどこかにバグの原因がある

しかし仮説 Q を立てたのち、Q が真であることが分かれば、考えるべき内部状態は \mathit{Internal}(Q) のみで良くなります。

Internal(Q)のどこかにバグの原因がある

逆に Q が偽であることが分かれば、考えるべき内部状態は \mathit{Internal}(P)∩\overline{\mathit{Internal}(Q)} で良くなります。
Internal(P)∩Internal(Q)でないところにバグの原因がある

つまりどちらの場合にしろ、考えるべき内部状態が減っていることが分かります。これは本質的な原因に迫っていることを表しています。この考え方を用いると、良いデバッグの道筋とは何かが自ずと見えてきます。つまり、なるべく考えられる内部状態を減らす方向に向かうデバッグこそが、良いデバッグであると言えます。

具体例で考える

抽象的な議論では分かりづらいと思うので、具体的な例を考えてみましょう。先の議論においての Q はこのプログラムにおいて様々考えられますが、例えば次のように定義してみましょう。

  • 仮説 Q = x += 1; が実行されていない

右に動かないという事象をより具体的なプログラミング的な言葉で言い換えたような形です。プログラム全体から鑑みるに Q→P は成り立っているので、Q として適当でしょう。

x += 1; が実行されたらどうかを直接確認することは難しいですが、x += 1; の実行と同値な条件[1]である console.log('Hello, rightKey!'); を確認することは容易です。実際のデバッグでは、検証のプロセスでこのような同値の条件を確認するテクニックを使うことが多いです。

ここでは実行されていないことが確認された、すなわち仮説 Q は正しかったとします。すると次はデバッグのサイクルに当てはめて、次はなぜ x += 1; が実行されないのかという原因を追究していくことになります。これに対しての仮説 R を次のように立ててみます。

  • 仮説 R = rightKey() の返り値が false (falsy な値) になっている

これも R→Q が成り立っていることは明らかなので、仮説として妥当でしょう。その上でこの仮説を検証していきます。この仮説も直接検証するのは難しいので、同値な条件を定義したいところですが、このプログラムだと難しいので、次のようにプログラムを書き換えることにします。

  let x = 0;
  function main() {
      console.log('Hello, main!');
   
+     const target = rightKey();
+     console.log('target = ', target);
+     if (target) {
-     if (rightKey()) {
          console.log('Hello, rightKey!');
          x += 1;
      }
      if (leftKey()) {
          console.log('Hello, leftKey!');
          x -= 1;
      }
  
      ctx.clearRect(0, 0, WIDTH, HEIGHT);
      ctx.fillRect(x, 10, 30, 30);
  }

このように書き換えても通常プログラムの動作は変わらず、追加でif文に渡される rightKey() の値を知ることができます。ここでは rightKey()undefined が返ってきていた、すなわち仮説 R は正しかったとします。すると次はなぜ rightKey()undefined を返すのかを内部実装やドキュメントなどを参照して探っていくことになるでしょう。

これ以上の説明しませんが、命題論理を活用したデバッグの手法は分かって頂けたのではないかと思います。特段変なことをしているわけではなく、単に仮説の検証を繰り返して原因を絞っていくという一般的なプロセスを、より形式的に記述したような形です。

これまでの命題論理が一切分からなかったとしても、このプロセス自体はデバッグの一般的な手法として有用なのではないかと思います。ぜひ役立ててみてください。

おわりに

この考え方が実際のデバッグに役立つかは正直分かっていませんが、1つの考え方として理解しておくと面白いのかなと思います。加えて、実は数学的なアプローチはこういう場面でも活用できるということが示せたら、それも一つ良かったのかなと思っています。

もし間違いとかがあれば優しくコメントとかで教えてもらえると嬉しいです。お願いします。

脚注
  1. 少なくともJavaScriptにおいては、例外が起きない限り隣接する2つの処理のどちらかだけが実行されないということは基本的に起きないので、任意の前提条件において x += 1; が実行されることと console.log('Hello, rightKey!'); が実行されることは同値な命題であることが分かります。 ↩︎

GitHubで編集を提案

Discussion