😸

「Go To Statement Considered Harmful」を読んだ

に公開

最近、ふとしたきっかけで構造化プログラミングとは何かを調べていたところ、エドガー・ダイクストラが書いた「Go To Statement Considered Harmful」という論文(というより編集者への寄稿?)を見つけました。

詳しく読んでみると、僕が知っていた構造化プログラミング=制御構文を利用したプログラミングと、ダイクストラが論文に書いていた主旨はかなり乖離していて、個人的には論文の内容の方により興味が湧きました。

論文自体の発行は1968年ですが、その趣旨は現在でも通用するような普遍的な内容で、プログラミングと向き合う姿勢を考え直すキッカケになると思います。

そこで、この記事ではダイクストラが論文で何を言いたかったのかを簡潔かつ理解し易い形にまとめてみたいと思います。(原文は結構難しいと感じました)

ちなみに参照した原文は以下のURLになります。

https://www.informatik.uni-bremen.de/agbkb/lehre/programmiersprachen/artikel/EWD-goto-harmful.PDF

翻訳箇所は引用にしていますが、"かなり"意訳です。細かい違いについてはご容赦ください。

また、セクション分けは原文でされているわけでは無く、筆者が記事を作るにあたって作った構成であることにご留意ください。

冒頭の内容

まずダイクストラは、

プログラム中のGoTo文の密度が高くなるに応じて、プログラマの能力が低くなることを観察によって知った

と書いています。

これは、一部のプログラマの能力の低さを揶揄しているわけではなく、プログラムの品質がプログラマの能力の発揮に影響することを意味しています。

またダイクストラは、

高水準なプログラミング言語(おそらく機械語以外のすべて)からGoTo文が廃止されるべきだと確信するに至った

と、少々過激にも思える発言をしています。(ただ、論文の最後の方には少し違ったことが書かれていました)

そして、

当時はそれほど重要な発見だとは思っていなかったが、勧められたので公開することにした

ということを書いています。まさか10年間に続く論争に繋がるとは思っていなかったでしょうね。

では肝心の内容についてみていきます。

2つの要点

ダイクストラは次に、2つ要点を述べています。

プログラムとプロセス

まず1つ目は、

プログラマの活動は正しいプログラムを書いた時に終わるにもかかわらず、プログラムの制御下で実行されるプロセスこそが活動の真の主題である。
というのも、この"プロセス"こそが期待通りの効果を発揮しなければいけないのであり、この"プロセス"こそがその動的な挙動の中で期待した通りの仕様を満たさなければならないのだから。
だが、ひとたびプログラムが作成されれば、それに対応するプロセスの"生成"は機械に委ねられてしまうのである。

もうこの時点で難解です。しかし、要点はこういうことです。

ここで云うプログラムとは、要はソースコードのことです。例えば、

a()
b()
c()

というソースコードです。

そしてプロセスとは、

  • aを実行
  • bを実行
  • cを実行

というように、実際に機械によって順次実行される処理のことを指しています。

もう少し読み進めると、なぜこの2つを分けて考えるかをより深く理解できるので、まずは進めます。

人間の知的能力について

2つ目の要点としてダイクストラが述べているのは、

我々の知的能力は静的な関係性を理解するのにより適していて、時間と共に進化するようなプロセスをイメージする能力は比較的乏しい。
これを理由として、我々は(テキスト空間に拡がる)静的なプログラムと(時間に拡がる)動的なプロセスの間のギャップを埋めることに最大限努力すべきである。

これを具体的に書くと、

a()

:label1

b()

goto label1

のようなコードがあった時に、実際のプロセスは時間と共に以下のように展開されるわけです。

  • aを実行
  • bを実行
  • bを実行
  • bを実行
  • ...

この規模のプログラムであれば、実際に実行されるプロセスをイメージすることは容易です。

しかし、より大規模で複雑なプログラムになった時、実際に時間の中で動的に展開されていくプロセスを正しくイメージすることは難しいでしょう。

だからこそ、プログラム(ソースコード)とプロセスの乖離を埋めるべきだと言っています。

個人的には、「読み解く量が増えると認知負荷が大きい」ということだと思っています。

例えば、

a()

while (true) {
    b()
}

という風に書かれていれば、とにかくb()が無限に実行され続けるのだと一目で理解できますが、goto文を使う場合は

  1. label1b()の前にあって
  2. b()が実行されて
  3. label1に飛んで、
  4. b()が実行されて
  5. label1に飛んで、
  6. ...あ、これ無限ループか

と、無限ループであることに気付くまでコードをトレースし続ける必要があります。

GoTo文を読み慣れていて、なおかつ先の例のようにコードが小さければ気づくまでにさほど時間はかからないでしょう。

しかし巨大なスパゲッティコードを追うことになれば、無限ループの存在に気付くことは容易ではありません。

すると、読む側だけでなく、書いている本人ですら「正しいプログラムを書いている」という確信を得ることが難しいプログラムになってしまい、ダイクストラが最初に挙げた要点の中で述べた

プログラマの活動は正しいプログラムを書いた時に終わる

を達成することも困難になってしまいます。

これらのことから、

「実際のプロセスの挙動を理解するために読むべきコードの量は少ない方が良い」(方針①)

という方針が得られると僕は考えます。

これは現在でも通用する考え方であり、凝集度やローカリティの考え方に対応しています。

読むべき量を少なくするための努力として、

「正しく動いていると確信できる小さいコード片を組み合わせて、さらに別のコード片を組み立てるべきだ」(方針②)

という方針も帰結できると思います。

これもまた、現在のプログラミングにおいて通用する考え方であり、モジュールやコンポーネント化の考え方に対応します。

ダイクストラ自身がそう言っているわけではありませんが、方向性としては合っていると思います。

ただし、プロセスの挙動の理解に必要なコード片の種類が多すぎれば、学習曲線が非常に急になってしまうことがトレードオフとして考えられることも注意したいところです。

方針②を目指してコードを複雑に階層化しすぎると、方針①を違反してしまうということです。

少々脱線してしまいましたが、続きを解説していきます。

プログラムとプロセスの乖離を考えるための例題

次にダイクストラは、例題として一つの問いを立てます。

どうすればプロセスの進捗を特定できるかを考えてみましょう。
(この問いを具体的に考えてもらって大丈夫です。例えば、プロセスを時間的に連続したアクションと考え、そのプロセスが任意のアクションの後で停止した場合に、プロセスをその時点から再開するためにどのようなデータを保持する必要があるでしょうか。といった具合に。)
もしプログラムが例えば、いくつかの代入文の純粋な結合であるなら、2つの連続したアクションの記述の間の時点を指し示せば十分です。
(GoTo文を使わなければ、前述した3語(連続した アクションの 記述)のテキスト空間の(プログラム的な)表現である「(連続したアクション)の記述」という解釈と、時間上の(プロセス的な)表現である「連続した(アクションの記述)」という解釈という構文的なあいまいさを許容できます。)
この適切な位置を指し示す地点を"textual index"(原文上の位置)と呼ぶことにしましょう。

かなり難解な表現ですが、具体的に説明するとかなり単純です。

例えば、以下のようなコードがあったとします。

let a = 1

let b = a

let c = a + b

let d = c * 2

これがプロセスとして実行されると、

  1. aに1を代入
  2. bにaを代入
  3. cにa+bを代入
  4. dにc*2を代入

と進むわけです。

このプロセスを3.で止めた場合、コード上(原文上)では5行目と対応します。よって、textual indexは「5」です。

このようにプログラムとプロセスが対応できるわけですが、GoTo文が使われるとこれが崩れます。例えば、

let a = 1

:label1

let b = a

goto label1

これがプロセスとして実行されると、

  1. aに1を代入
  2. bにaを代入
  3. bにaを代入
  4. bにaを代入
  5. bにaを代入
  6. ...

となるわけです。

これを3.で停止した時、対応するtextual index=ソース上の行数は「5」です。

しかし、4.で停止した場合も、対応するtextual indexは「5」です。

これでは「ソース上では5行目まで進んでいます」と述べた時、実際のプロセスの進捗は3.かもしれないし、4.かもしれないのです。

これがダイクストラの言う「静的なプログラムと動的なプロセスの乖離」です。

GoTo文を多用して乖離が大きくなりすぎると、プログラムによって生成されるプロセスを理解できなくなるということですね。

制御構文やプロシージャの導入をした場合

次に、制御構文などを導入した時に、どのようにプロセスの進捗を特定されるかを説明しています。

条件句(if B then A)、代替句(if B then A1 else A2)、選択句(case[i] of (A1, A2, ..., An) あるいはJ.McCarthyによって紹介された条件文(B1 → E1, B2 → E2, ..., Bn → En)を導入した時、
プロセスの進捗が単一のtextual indexによって特定可能である事実は変わりません。
しかし、プロシージャ(手続き)を持ち込んだ途端、単一のtextual indexでは十分ではありません。
(中略)
プロシージャを含める場合、textual indexのシーケンスによってプロセスの進捗を特定できます。
シーケンスの長さは動的なプロシージャの呼び出し階層の深さと同じです。
繰り返し句を考えてみましょう。(while B repeat A あるいは repeat A until B のような)
論理的には、これらは余計なものだと考えられています。
なぜなら、繰り返しは再帰を使えば表現できるからです。
しかし、現実的な理由により、私は繰り返しを排除したくありません。
一つの理由は、現在の有限な機械リソース上でも十分実装可能であること。
もう一つの理由は、induction(帰納的推論)の能力によって繰り返し句によって生成されるプロセスを知的に理解できるからです。
繰り返し句が導入されると、textual indexではプロセスの進捗を追うのに十分ではなくなります。
しかし、繰り返し句に入るたびに、いわゆる「dynamic index」と呼ばれる、現在の繰り返しに対応する順序数を自動的に数え上げる数を利用できます。
繰り返し句が入れ子で適用されても(プロシージャの呼び出しのように)、textualあるいはdynamic indexのシーケンスでプロセスの進捗を特定できます。
主眼となる点は、これらのindexはプログラマの制御外にあるということです。
プログラムとして書かれ、かつプロセスの動的な進行によって、プログラマが望むか望まざるかに関わらず生成されるためです。
indexはプロセスの進捗を表す独立座標を提供します。
なぜそのような独立座標が必要なのでしょうか。
その理由は、これは逐次的なプロセスであることに由来すると思われますが、その変数の値をプロセスの進捗のみと関連付けて解釈できるからです。
例えばもし、変数nを用いて誰もいない部屋に入っていく人の数を数えようとするなら、誰かが部屋に入っていくのを見たら必ずnを増やせばいいでしょう。
しかし、誰かが入っていくのを観測した後、nを増やすまでの間、nの数は実際に部屋にいる人の数マイナス1になっているのです!
(以下略)

ちょっと長くなりましたが、順番に解説していきます。

まずif文に関してですが、今まで通り単一のtextual indexを保持するだけでプロセスの進捗を特定できます。

if A then {
  B()
} else {
  C()
}

としたとき、プロセスはAが真の場合

  1. Aの評価
  2. B()

となり、textual indexは「2」で一意に対応します。

次にプロシージャの呼び出しを考えてみましょう。

以下のコードがあり、

fn A() {
    B()
}

fn B() {
    D()

    C()
}

fn C() {
    ...
}

A()

A → B → C、と進んでいる時点でのプロセスの進捗はどう特定できるでしょう。

まず、Aはプログラム全体では15行目で呼び出していて、BはAの中で1行目、CはBの中で3行目に呼び出しているわけなので、15・1・3というtextual indexのシーケンスでプロセスの進捗を特定できます。

これは今でいうコールスタックですね。

次にfor文などの繰り返しに言及しています。

繰り返し句を使うと単純にtextual indexではダメです。

for i in arr {
    print(arr[i])
}

というような文の場合、プロセス的に何回実行されていても、対応するコード上の行数は一緒です。

これでは一意に特定できないため、dynamic index(動的インデックス)であるiを用いる必要があります。

forが入れ子になった場合、

for i in arr {
    for j in arr[i] {
        print(arr[i][j])
    }
}

となるわけですが、プロセス上の進捗は(i, j)となり、これが独立座標というわけです。

そしてダイクストラは、iとjはプログラマの意志によらず提供されるべきであり、自らその値を足していくことは避けるべきだとしています。

例えば、

let i = 0
while (arr[i]) {
    print(arr[i])

    // ループ1週目の場合、i=0
    i++;
    // ループ1週目の場合、i=1
}

としてしまうと、実際にはループの一週目にも関わらずiが0だったり1だったりするので、正しくプログラムの進捗に対応できないことを指摘しています。

GoTo文でループカウントをしようとすると、以下のようにカウントを変数として持つ必要が出てくるため、問題となるということです。

let i = 0

:loop

print(arr[i])

i++

goto loop

i++の後にインデックスアクセスを行うコードを意図せずに書いてしまうかもしれないリスクを抱えるよりは、for文などで自動的に提供される"正確にプログラムの進捗に対応する"インデックスを使った方がよいという主張です。

末尾の内容

主だった内容はここまでです。

今時、構造化プログラミングとして語られる内容は単なる例題でしかなく、本質的な主張は全く別にあることが分かります。

では、末尾の方の内容を簡単に訳します。

GoTo文の利用が望ましくないという主張は、特に新しいものではありません。
何に書いてあったかを正確には覚えていませんが、緊急終了を除いてGoTo文の使用を制限するべきという内容を読んだことがあります。
(中略)
Giuseppe JacopiniはGoTo文の不要性を論理的に証明したようです。
任意のフローチャートを多かれ少なかれ機械的にジャンプ(GoTo文等)を使わないフローチャートに置き換えるエクササイズがあるようですが、推奨はできません。
結果として得られるフローチャートが、元のフローチャートよりも分かりやすいという保証は無いからです。

謝辞というほどのものが書けるほど参照元を覚えているわけでも無いようで、この論文が随筆的なモノであったことが分かります。

ただ最後の文は印象的で、機械的に新しいツールに置き換えたところで本質的な品質が上がるとは限らないことが述べられています。

これは「GoTo文さえ使わなければいいコードが書けるはず」というように考えている人への戒めとも取れます。

終わりに

こうしてちゃんと読んでみると、「順次実行と条件分岐と繰り返しを使ってプログラムを構成するべき」なんて書いてないことが分かります。(なんならプロシージャーの話だって出ていますし)

あくまでGoTo文だけでは認知負荷が高くなりすぎるような論理構造の表現を、新しい句の導入によって難易度を下げる試みについて解説されているだけです。

そして、あくまでGoToの文によって書かれたプログラムを改善するためのツールとして制御構造やプロシージャが挙げられただけで、一つの例題に過ぎないであろうことを強調しておきたいです。

個人的には、具体的なGoTo文周りのことよりも、この記事で「2つの要点」としてまとめた内容(人間の知的能力やプログラムとプロセスの乖離に関する内容)が重要であるように感じました。

また繰り返しになりますが、1968年の論文でも現在に通ずる考え方のベースが語られていることに注目したいです。

この時代の論文は、プログラミング業界にとってもはや古典ですが、古典を読んでこそ気づかされる内容があるように思います。

また、近い時代の論文などで興味があったら、記事にしてみようと思います。

ご精読ありがとうございました。

Discussion