プログラムを数学の言葉で理解すること
ふとしたきっかけから「圏論の歩き方」という本を読んでいる。圏論そのものはとても高度で強力な数学的概念なんだけど、その強力さゆえに応用範囲がとても広い。この本はそれらをほとんど目にもとまらぬ速さで駆け足に紹介しており、それゆえ、これ一冊で圏論を理解できるようなものでは到底なく、その名が示す通りに圏論のいわば「ガイドブック」になっている。なもんだから、読む側のこっちとしても、次の旅先をどこにしようかなとガイドブックを流し読みするくらいの気楽さでパラパラと読んでいる。
さて、その中の「プログラム意味論と圏論」という章で、ある処理を行うプログラムを、原始的なプログラミング言語(機械語)で、専門的に言えば「手続き的」に、記したものと、同等の処理を関数型プログラミング言語で表したものとを比較している箇所にこんな記述があってページをめくる手が止まった。
(後者のプログラムの方は)数学の関数や写像を定義するのに近い感覚で書かれているため、人間にも理解しやすいものになっています。
著者はおそらく軽い気持ちで「人間にも理解しやすい」と書かれたのだろうとは思うのだけど、実のところ世の中けっこう、前者の方が理解しやすいと考える人も少なくない。これは学生相手にアルゴリズムを教えていると、ときおり実感する。数式で示されるよりも、「まず A をして、次に B が C なら D して、初めに戻る」といった具合に手順が逐一書かれている方が何をやっているのか分かりやすい、と言われたりする。こんなことを書いている僕自身、長らくはその立場だったので、そうした感覚はよく分かる。
数式で書いてある方が分かりやすい、と思えるようになってきたのは、僕の場合おそらくは、LISP や Ruby のように記述密度の高い言語でプログラムを書く割合が増えてきた頃からだと思う。「記述密度の高い」というのは、要は短かく書くことができるということだ。短かく書けるということは、余計なことに手をわずらわされずに素早く書けるということであり、またそれを後で読むときにも素早く目を通して理解することができることを意味する。
プログラムの記述密度
記述密度の高さはどこから生まれるか。思うにそれは大まかに言って
- 記号法が整備されており、複雑な操作を短かい記号の組み合わせで記述できる
- それにより、本質的に不要な要素を記述から排除できる
の二つの要因がある。
「記号法の整備」の方は、例えば「1と2を足した数は3に等しい」という記述は、「1+2=3」と短く書き表せる、といったような話。ただ、これらの記号に読む側の頭が馴染んでいないと、その内容はまったくのちんぷんかんぷんであり、これがプログラムを数式で表すことに対する拒否反応の一つの要因であろう。
もう一つの要因については、例をいくつか示しながらゆっくりと説明しよう。
不要な要素をプログラムから排除する
二つの変数、x と y があるとする。この二つの変数の中身を交換したいとき、
x := y (変数 x に変数 y の値を代入する。以下同様) y := x
と書いてしまうと、一行目を実行した段階で x と y の中身は同じになってしまい、失敗する。そのため、原始的なプログラミング言語では、一時的に第三の変数(ここでは tmp とする)を用意して、このように書く必要がある。
tmp := y y := x x := tmp
これで目的の通りに二つの変数の中身を入れ替えられる。しかし、そのために元の目的とは本質的には無関係な変数 tmp という、余計なものの侵入を許してしまった。この変数は別に tmp という名前である必要はなく、temp でも hogehoge でもなんでもいいのだが、なにかを置かないことには処理を書けないので、とりあえずの名前を与えざるを得なかったわけだ。そのため処理の記述はわずかに冗長なものになってしまった。
読む側にとっても、この tmp の登場は混乱の素だ。tmp という名前に意味はあるのかないのか、後でこの変数は使うことになるのか、それとももう使わないのか。後で同名の変数が登場した時にそれとの関連はあるのかないのか。なんでもいい、一時的なものでしかないというのは書いた側の事情であって、読む側にそれが共有されているとは限らない。
これが、例えばこのように書けたとする。
swap x,y
これは「swap 変数,変数」という新しい記法を導入して、先のプログラムにあったような具体的な手順をそっくり置き換えている。swap の導入は先に挙げた「記号法の整備」に該当するが、これによって変数 tmp という、本質的には余計なものを招きこまずに済ませることができた。覚えてないといけない事は増えてしまったけど、この「余計なものを入れない」事が、その処理内容の本質を素早く頭の中に入れていく上ではとても重要だ。
いまの例だと、tmp が減って swap が増えて、で差し引きゼロっぽいので、もうひとつ例を示そう。複数の要素が集まった集合 S を考える。この S に含まれるすべての要素に対して、ある処理を適用したいとする。適用する順番は問われないし、適用されるのは要素ごとにまったく同じ処理だとしよう。
ここで、昔ながらのプログラミング言語では、集合を配列として表すのが一般的だ。配列はその中身の要素に番号を振って管理するので、要素の長さが array.length で得られるとすると、
for i = 0 to i < array.length do_something(array[i]) end
といったように書ける。
さて、上記のプログラムにも、本来やりたかった処理とは関係のない「余計なもの」が含まれている。変数 i だ。もともと、集合 S の中身は順番に関係なく詰め込まれていて、どのような順で処理を進めても良かった。ところが、配列の要素を i を使って参照することにしたために、0 から順番に処理を進めていくという、要求にない手続きをとることになってしまっている。プログラムを読む側も、「i って 0 からでいいんだっけ?」とか「array.length と比較してるけど、array.length - 1 じゃなくていいのかな?」などと迷ってしまうかもしれない。実際、よくあるセキュリティ上の欠陥はこういったところでのミスに起因しているのだ。
それではどのような記述が好ましいのか。いまどきのプログラミング言語だと、こうした場面でこのように書くことができる。
for x in array do_something(x) end
これは変数 x を呼び込んでしまってはいるが、0 だの array.length だのといった余計な要素を排除することはできた。さらにはこんな風に書ける言語もあるだろう。
apply do_something array
ここまで来れば、元々やりたいと思っていた処理を過不足なく記すことができている。
抽象化を支える背景知識
これらの例に見たようにプログラムは、本質的に不要な要素を削ぎ落とすことによって、元の概念に忠実で、かつ簡潔に記述することができるようになる。これが記述密度の高さにつながり、素早い把握を助けることになるわけだ。そうした読み書きに習熟していくと、プログラム、というか、「自分のやりたい事」を抽象的に理解する度合いが一段階深くなる。
とはいえ、簡潔な記述を読み解くためには、その裏に隠れている「暗黙の了解」を事前に知っていなければならない。最後の例で言えば、apply という命令があることを知ってなければならないし、その機能が、二番目に書かれた関数を三番目に書かれた集合の各要素に適用するものであるということもそうだ。"do_something" というのはその名前で示される関数それ自身を apply に伝えることを目的として記されているのであって、その関数の実行結果を使うのではない、ということもわかってないといけないし、三番目に記されるべきは集合である、ということも知っておく必要がある。
簡潔な記述は、その記述を成立させるための膨大な背景知識が必要であり、だからこそそれら知識を記述せずに済ませることができるのである。
ということは、その背景知識を身につけない限りは、記述密度の高いプログラムの読み書きに支障をきたすことになる。そのため、背景知識に乏しい人から見れば、記述密度の濃い、抽象度の高い記述はわかりにくく映るわけだ。
抽象度の低いプログラムを読み解くための背景知識
ところが一方で、抽象度の低い記述の方も、それはそれで背景知識が必要となってくる、ということの問題点が、わりに過小評価されているように思う。
例えば swap の例を再度とりあげると、
tmp := y y := x x := tmp
というプログラムから「変数 x と y の中身を交換する」という意味を読み取るには、隠れている暗黙の了解、すなわち「tmp という変数は文脈上たいして重要ではなく、今後使用されることもない」ということを知らないといけない。そうでなければ、このプログラムは「変数 y に x の中身を、x と tmp に y の中身を、それぞれ代入する」という解釈もできてしまうからだ。tmp はしょせん tmp であり、後で使わないどうでもいいものである、といった暗黙の了解は、実はそれなりのプログラミング経験を積まねば体得できない知識なのだろう。だから、「この tmp を別の場所で上書きして使ってるけどいいんですか。」といったような質問が学生から寄せられるのも、むべなるかな、である。
これら「○○は関係ない」「××は不要」といった、不必要性を説くタイプの知識は、「○○が関係する」「××が必要」といった必要性を説くものに比べると、その存在は見落とされやすい。それゆえ、熟練者から見ると初学者がどうしてこういうところでつまずくのかがわからなかったりする。
結局どちらを教えるべきなのか
僕の本業である大学教員という立場から、プログラミング教育についてはずっと悩み続けている。 もしこれが、数学の基礎もまだ学んでいないような、小中学生などを相手に教えるのであれば数学側からのアプローチを選ぶべくもないから、原始的なものに近い方の言語を使いつつ、別の方法—たとえば Viscuit のような—に分がないかを考えることになるだろう。もしかしたら、これも Viscuit の原田さんからの影響だが、表計算ソフトの使い方から入るかもしれない。
しかし、教える相手が大学生のように数学の基礎をそれなりに学んでいることが期待できるのであれば、数学の立場からのアプローチには合理性がある。初期段階で必要となる数学的な知識なんてのは実に簡単なもので、数学的帰納法や簡単な論理が理解できれば十分だ。いま僕が担当している講義でもできる限り数学の立場からのアプローチを取り入れるようにしている。
実際、ちょっと数学の心得のある学生が結構すんなりとプログラミングを理解できるようになるのも、これまたよく観測される現象なのだ。「ループを書くの気持ち悪いので再帰で書いていいですか」なんて言い出すのもいたりする。
もちろん、現行のコンピュータ特有の泥臭さについてはなにがしかの形で経験を積んでもらうことは必須であるにしても、なにもかもを一から泥臭く経験しなくてもいいはずだ。いまさら行番号 BASIC を子供や学生に叩き込むような愚を犯すことはあるまい。数学は、コンピュータに比べれば遥かに長い時間をかけてもまれてきた概念である。数学という巨人の肩にのぼって、そこからコンピュータをあやつることのメリットは大きい。もとより、コンピュータ自体が、数学者達が築きあげてきた「計算」に関する知識の上に作られたものである。とはいえ、その数学的観念をいかにして物理的実体として組み上げていくか、その過程は想像以上に泥臭い。そのあたりの歴史については、「チューリングの大聖堂」に詳しいのでぜひお読みいただきたい。
泥の中の宝石
しかしながら一方で、プログラミング言語の抽象性については、これまでの議論をまったくひっくり返して考えていくこともできる。そもそもが、どんなプログラムも最後は「0」と「1」の羅列でしかなく、その記号性はほとんど究極といってもよい。コンピュータにとっては 1 も 2 も 3 も、ただ 01 の組み合わせで記述できる概念であり、それらの間に大差はない。数だけでなく演算も同様で、足し算だろうが掛け算だろうが、ただちょっと手順が異なるだけの記号処理でしかない。コンピュータの立場から見れば、そこに意味の違いはないのである。「1たす1」も「2かける3」もない。01 であらわされた値と 01 であらわされた値とを、これまた 01 であらわされた処理手順に従って処理して、最後に 01 の羅列を出力するだけだ。それら 01 の羅列に意味を与えるのは、実は人間の仕事なのである。
ここに、コンピュータの妖しい魅力がある。
必要とあらば配列境界をぶっちぎって値を書き換えたり、プログラムが計算結果をもとに自分で自分を書き換えたりするような、意味をものともしない記号処理を平気で行わせられるのも、コンピュータの醍醐味の一つである。†1
プログラミングを学ぶということは、いっけん美しく見える記述の内に見える野暮ったさを嗅ぎとり、ときに泥臭い記述の中に眠る一粒の宝石を見逃さずに掬いとるための、訓練であると言えるような気が、僕にはするのだ。詩と同じく、プログラムにも、清濁がある。
詩有輕重清濁大小緩急
祇園南海「詩學逢原」
2015.12.12
- †1
- そうした醍醐味を教えるための教材として、6502 snake (destructive edition) を作ったのでご笑覧いただきたい。