プログラミングと、制約と自由と、単純と高機能
プログラミング::essential
まえがき
この記事は、この記事がかけることが私のいわば「皆伝」に相当するものである。1
私のすべてが詰まっている、というわけではないが、この記事を書くに至るまでには極意の体得が必要となることは確かであり、記事そのものほど容易な話ではない。
この記事をとりあえず理解する上で、そのような次元に至る必要はない。 だが、この記事の真価を悟るには長い道のりが必要だろう。
序
多くの場合、「制約と自由」、「単純と高機能」は相反する要素であり、バランスの問題とされる。 しかし、少なくともプログラミングにおいてはそのようなことはなく、それぞれが直交した概念である。
例えば、「非常に複雑でありながら単純な機能しか提供しないインターフェイス」などというのは容易にありうる。
制約によって生まれる自由
それどころか、制約があるからこそ自由が生まれる、というのがプログラミングである。
例を挙げよう。
Unixシェルのパイプは1対のものであり、読み戻しはできない。 常に一方通行である。
これは、シェルスクリプトを書けば誰もが直面する現実だ。2対のpipeを使った読み戻しをしたいケースなど山ほどある。
だが、これがlesserな設計かというと、そんなことは全くない。 実際、この制約を前提としているからこそ、優れたコマンドラインユーティリティが数多く生まれた。 Unix哲学の威力は、この制約によって生まれたのだ。
JavaScriptはシングルスレッドで動作する。少なくともそのように見える。 これは明示的な制約であり、このために時間のかかるコールバック関数があると全体の動作が止まってしまう。 この問題に歯ぎしりしたプログラマは少なくないだろう。
だが、実際にJavaScriptがマルチスレッドでコールバックを呼び出すようになっていたとしたら、現在ほどの自由はない。 「シングルスレッド」という制約が撤廃されることで、書けないコード、使えない機能が大量に生まれる。結果的に、その問題を回避するための手段を追加で必要とするため、自由はより狭まる。場合によっては、撤廃された制限のためにその制限と同等の制限を追加する(例えばMutex)という事態すらありえる。
単純は正義
これはプログラムに限らないが、物事に対して認識・判断する上で 普遍性というのはとても大事な要素である。
基本的には次のようなものだ
- ひとつの法則、ルールによってすべてが説明できることが最も望ましい
- そのルールは適用される範囲が自明であるべきである
- そのルールはそのルールが適用されない界に対してゆるやかに接続可能であるべきである
このことは、いくつかの誤解を招きやすい。
まずこれは「説明されるものであり、ルールが先にあるのではなく、ルールは発見されるものである」ということを理解できているかどうかというところにかかる。 だから、「○○は××である」みたいな言い方をすればよいというものではなく、そこに例外が存在したらその時点でアウトなのだ。
なおここで、「例外は悪」という考え方も重要だ。 そもそもの話として、例外を無限に追加していけば場当たり的になんでも適用できるように見える。 だが、それは結局何も説明できていないのと同じだ。
もうひとつは、これがカプセル化と同じレベルで理解されてしまうことだ。 例えば
case flag
when "A"
return func_a()
when "B"
return func_b()
when "C"
return func_c()
end
というのが醜い、というのはまぁだいたい理解してもらえるだろう。 これは「全てのケースを例外として、ケースごとに個別に記述する」という方法をとっているからだ。
これをカプセル化の問題、つまり抽象化によって解決すべきと理解するならば次のような話になる。
[flag] baselib
あるいは次のようにもできる。
self.method(:"func_#{flag}").call
これは、実は
[flag] baselib
にできているならOKの可能性も高いのだが、これが
[flag].call baselib
とかいう話になると、個別のケースがあることを個別に手書きすることは避けられているが、 個別のケースそのものを手書きしていることは変わらないとかいう話になる。
抽象化しているということは、インターフェイス部分は単純化できている。 その部分だけをみれば統一的なルールが存在するからだ。 しかし、もう一段踏み込んだことを言うならば、コードジェネレーションなどを用いて宣言的に表現できるのであれば、より統一的である、ということは理解しやすいだろう。
単純さと高機能(強力さ)が両立する、というよりも、むしろ本質的にこれらは両立させねばならないことは、Unixシェルがわかりやすいだろう。 Unixシェルは統一的な書式を持ち、しようとしていることが何であるかを問わず同一の書式によってあらわすことができる(コマンド個別のオプションや引数の与え方は別として)。Unixシェルから起動されるコマンドは果てなく多様であることを考えれば非常に高機能なもののインターフェイスであると考えられるのだが、その高機能を利用するためのルールは非常にシンプルで、かつ統一的である。 さらに、そのインターフェイスを利用可能であることが、そのままシェルスクリプトという形で発展的に利用することも可能であるという点も美しい。
Hacker語として、「小さい」は最大の賛辞だといっていい。 もっとも、これは「適切に小さい」とか、「小さくて美しい」とかであることを忘れてはいけないのだが。
制約による単純さが成功につながったPureBuilder Simply
PureBuilder Simplyは”PureBuilder”の名を関するようになってから3つ目のソフトウェアである。 それ以前からあるACCSを含めると7つ目になる。
比較的短期間で作り直されていることからわかるように、旧PureBuilderはあまりうまくいっているとはいいがたかった。 この問題は、「ひとつの機能をひとつのコードで表現していた」ことにある。
一見あたり前のように思えるのだが、私はこれをやった時点で「筋悪なコードである」と感じてしまう。 そのコードが何をしようとしているか、何を求めているかということを完全に理解しきれていない、全体像が見えていない、という気持ちになる。
なにより、そのようなことをすると、コードは時とともに無軌道に膨れ上がるのだ。
PureBuilderが下地としていたPureDocに関しては積極的にコードジェネレーションを活用していた。 PureDocは「宣言的なコード」をかなり意識しており、DSLを提供するコード自体がDSL的に書かれていた。 これは、そのプログラムの本質が定義され、その本質を以てその具体的な値を宣言しているものであるから望ましいのだが、その設計自体が最適とは言えず、これに対して機能をぼこぼこと付け足すPureBuilderがなんとも不格好だったのだ。
PureBuilder Simplyが成功したのは、なんといってもPandocを下地にしているというのが大きい。
もちろん、これは生成部分でソフトウェア的にPandocを使っているという面もある。 だが、PureBuilder NGもKramdownによる生成をサポートしており、生成を外部ソフトウェアに依存するということそのものが決定的な違いになったわけではない。 だから、本質的にPureBuilder SimplyはPandocに依存しているわけではなく、(その労力を無視するならば)Pandocが担っているレイヤーを自力で実装することも可能である。
PureBuilder Simplyが成功したのは、Pandocを使うことによって、Pandocのテンプレート機能が使われるようになったことだ。
Pandocのテンプレート機能は非常にシンプルなものである。
備わっているのは、単純なif
によるキーに対する有効な値が存在するかどうかのチェックと、配列値に対するイテレータだけである(Pandoc
2.8で連想配列へのアクセスも可能になった)。あとは文字列としての変数展開だ。
PureBuilder2はテンプレートシステムとしてeRubyを採用していたから、テンプレート機能は大幅パワーダウンしたことになる。そう考えたからこそ、PureBuilder
Simplyは初期段階からPandocテンプレートにeRubyを組み合わせる機能を提供している。
だが、実際はこの単純なテンプレートという制約を前提にしたことで非常にすっきりした設計になったのだ。
PureBuilderによって構築されるコンテンツは提供時は静的に提供されることを前提にしているから生成時に決めてしまう必要がある。 Pandocテンプレートはあまり強力ではないが、事前にわかっている情報であればドキュメント自体に書いてしまえばよい。Pandocにはメタデータをドキュメント中に記述する方法が(ドキュメントフォーマットとして規定されている通りに)サポートされている。 だが、動的に生成されるべき値もあるし、ドキュメント中に書かれたメタデータから計算的に算出できるものもある。従来はこうしたものはテンプレート内で可能だったが、Pandocテンプレートを採用したことでそのようなことは不可能になった。 これが追加された「制約」だが、この制約に対応するために汎用性のあるメタデータをシステム側で追加するという方式になった。これは、例えばソースファイル名、出力ファイル名、タイトルやパスをエンコードしたもの、生成時刻などだ。 さらに、PureBuilder Simply 1.9ではより汎用性のある手段として、オプショナルにユーザーがメタデータを生成直前に更新する手段が追加された。
Pandocテンプレートという制約によっていびつなことをしているように見えるかもしれないが、実際は「Pandocによって生成される瞬間はこの状態でなくてはいけないし、方法はこれしかない」という形を守ることになり、それが可能なシンプルさが保たれることになった。要は、複雑さが際限なく散乱することを避けられているのだ。 これが、結果的に”keep simplicity”という考え方を背骨として通すことになり、PureBuilder Simply自体が合理的でシンプルな構造を保つことができるようになった。 さらに、一定のルールと考え方に基づいて処理するようになったことで自由度は大きく上がった。テンプレート内に好きなロジックを書くことができたPureBuilder2よりもずっとだ。
機能でなく概念で考える
プログラムを機能で考えたら、1つの機能を1つのコードで書くのは至極当たり前の話である。 これは、機能をコードに翻訳するという行為であるから、機能の数よりコードの数が少なくなりようがない。
もっと全体を俯瞰すればそれらのコードをまとめるという発想が生まれてくるかもしれない。 「これとこれは同じようなコードだからひとつにしよう」というような考え方だ。だが、これはすでに機能に基づいて発想されたコードをまとめるという変更を加えているに過ぎない。だから、すでに美しいコードになりようがなくなっている。
美しい小さなコードを書くためには、そのプログラムがなにをしようとしているのかということを概念として適切に定義できなくてはならない。 問題をいかに定義したかでその後の流れは大きく変わる。ここで失敗したら、もう美しいコードは生まれないのだ。
こうした点からいえば、Erinaの実装は実に悔しいものである。 あれは仮説に基づいて書かれているから、仮説ごとに全く異なるコードがあり、実際の値としてケース別にコードが存在する。途中からはAIによるコード生成に切り替わったことで書くべきコードが無限に増えるということは避けられたが、ディスクを食いつぶすモンスターが生まれてしまった。
これについて多くの面で、私が数学的素養がないということが響いている。 ずらっと値を並べたときに(もちろん、これは相関のあるものもないものも含まれる)、数学が得意なら一定の法則を見出して「これは個別に考えなくてもこの式で定義できる」ということが分かるのではないか……と考えてしまうのだ。
考えたことに基づいてコードを書いてしまうのは危険だ。 先が見えなくてそうせざるを得ない場合もあるが、可能な限り「理を式で示す」という感覚はなくさないようにしたい。
これは、まんま簡単な数式に当てはめれば、本質は
def val(n)
* (n - 1)
n end
で示すことができるようなものを、その本質はなんであるかと考えずに個別に書いているがために、
def val(n)
case n
when 1
0
when 2
2
when 3
6
when 4
12
when 5
20
end
end
とか書くようなものだ。 それが愚かしいとわかるのであれば、どんなことであれ可能な限り普遍性のある定義と表現を考えるべきなのだ。
「定義する」ことが常に最善なわけでもない
念の為にいっておくが、これはあくまで美しい設計をするための話である。 つまり、「小さく、早く、バグが少ない、保守しやすいコードを書く」ことに主眼を置いている。
私は、これが尊いと考えているのだが、この考え方が全ての善だとすると、言語的にもそのように書きやすいRubyやPythonで書くのが常に最善ということになってしまう。
だが、計算量的に良いコードを書こうとするとこれが良いとは限らなくなってくる。 計算量的に速いコードは人間側から見て理解しやすい概念や定義ではなく、コンピュータの「振る舞い」に着目したコードを書く必要があることもある。 それは意味的には一発で書けるような内容に対してわざわざ手書きするということもある。 同じ計算で導出可能だが与えられた値によってどちらが計算量的に有利かが変わる場合にふたつのアルゴリズムを実装し、値を検証してどちらのアルゴリズムで計算するかを切り替えるというようなことも考えられる。これは、意味的な定義においては無駄なようだが、現実の計算のためには必要である。
計算量を気にするが一方で明瞭に定義として書きたい人は(速い)関数型言語が好まれる、というのはあるだろう。私はそういう書き方ができないので私の理解の及ぶ範囲で述べているに過ぎないが。
また、競技プログラミングにおいては、ある程度の難易度になると、単純に問題の定義をコードに落とした「定義によるコード」という素朴な実装では通らないのが普通である。 それでは問題としてあまり成立しないからだ(比較的簡単な問題としては問題通りに実装できるか問う意味で成立するのだが)。 それと同様に、計算量的な問題が発生する場合も問題の定義に従って書き起こすだけでは解決できない可能性が高い。現実の実装における問題の定義はアルゴリズム実装に主眼があるわけではないので、そうした実装面はabstructに考えて設計すれば「定義によるコードの綺麗さ」と両立できるのだが、競技プログラミングだとまさにそれそのものが問題として出題されるため、問題の定義をそのままコードにすると失敗するわけだ。そう、TLE地獄である。
だが、特定の意図や目的のために最適な書き方をすることはいずれにせよ必要だ。 もちろん、場合によってはコードの美しさも計算量もスマートさも保守性も何もかも無視して一番早く浮かぶ、もっとも手っ取り早い方法で書くということが最適なことだってあるのだ。
概念の影響
プログラミング言語として現用されているものとしては古い部類に入るPerlだが、もともとPerlはSed/Awkの影響が強いこともあり、行指向テキスト向けの言語であった。
Perlそのものを知らない、あるいはPerlはwebアプリケーションのための言語だと思っているような人も多いかもしれないから、少し説明すると、基本的にPerlは「毛深くしたAwkであり、Sedである」というふうに言われることの多かった行指向テキスト処理言語で、これはPerlの名前にも反映されている。
一方、その文法はシェルスクリプト的なものに似せてあるもので、後述するeq
演算子もtest
コマンド([
コマンドと言ったほうが通りはいい)の-eq
演算子の影響があるし、$
ではじまるスカラー変数もシェルの影響を感じさせる。
このことから、Perlは、驚くかもしれないが、スカラー値としては基本的には文字列, 数値, リファレンスしか存在しない。true/falseもない言語だ。
そのため、スカラー値は文字列または数値のどちらかとして扱うことができる(というよりも、どちらとしても扱うことができる)。 そして、どちらとして扱われるかはコンテキストによる。(これもまたシェルスクリプトっぽい)
そのため、スカラーコンテキストでは文字列コンテキストであるか、数値コンテキストであるかということを判断できる状態である必要がある(値そのものを操作する場合は)。
Perlでは「文字列コンテキストと数値コンテキストに違う見た目を与える」という考え方を持っている。 そのため、
$var == 0
は数値コンテキストの等価性比較であり、
$var eq 0
は文字列コンテキストの等価性比較である。
Perlの指向としては、変数の値が文字列であるか数値であるかということを考える必要がないようにする、ということだ。つまり、文字列とか数値とかいうものをふわっとした概念にしている。 これは、SedやAwkと同じレベルで考えると非常にわかりやすく、要望としても明確なものである。 例えば、タブ区切りフィールドをもつテキストファイルの先頭カラムの値を合計したい場合に
my $sum = 0;
while(<>) {
my @i = split(/\t/);
$sum += @i[0]; #ここが重要
}print $sum, "\n";
もたいなことが書ける。Rubyだと同じように書こうとすると
= 0
sum ARGF.each do |line|
= line.split("\t")
i += i[0].to_i # to_iで明示的に整数値にしている
sum end
puts sum
と書く必要がある。
というわけで、考えていることは理解できるだろう。 だが、これは明らかな失敗である。
まずもって、数値として扱いたい、あるいは文字列として扱いたいということが明確な場合に、int(i)
とかi.to_i
とか書くのは別に大したことではないので、そこまで嬉しくないという問題がある。
いや、Awkでよく書くようなレベルだと絶対そんなことしたくないと思うし、Perlは結構そういう使い方をするのでPerlの考え方としてはおかしくないしメリットもあるのだが、プログラミング言語として普通に考えるとメリットはだいぶ小さい。
それよりは、「等価性」という同じ概念に==
とeq
というふたつの異なる演算子を持ち込まれるほうがやりづらいし、すっきりと入ってこない。
さらにいえば、test
コマンドだと==
が文字列比較、-eq
が数値比較なのに、Perlは==
が数値比較でeq
が文字列比較になっており、シェルスクリプトと親和性のある構文を持ち込んでいながらシェルスクリプトを知っていればこそ混乱するという仕様だ。
他の言語でよくあるのが、Cの考え方を動的言語に持ち込んで、数値0
は偽なのに文字列"0"
は真みたいなことがあるが、それがあると値が文字列か数値かを強く意識する必要があるため、Perlはどちらも偽になるようなっている。
だが、困ったことに数値0.0
は偽なのに文字列"0.0"
は真である。これで困ることは実際には少ないが。
先のスクリプトはRubyでは次のように1行で書くことができる。
puts ARGF.each.sum(0) {|i| i.split("\t")[0].to_i }
読みやすいかどうかは置いておくとして、Perlが特別な方法でより楽に書けるようにした典型的な処理であるにもかかわらず、書きやすさでいえばRubyのほうがPerlよりもずっと書きやすい。
これは、考え方の違いであり、概念をどのように定義し、どのような概念を持ち込んだかというところの違いである。 PerlやPHPのような、歴史の中でその立ち位置を大きく変えてしまった言語は適切な概念の定義が根本的に変わってしまって難儀することになりやすいが、統一的で整理された概念を導入することがどれほど効果的か分かるだろうか。
書かないで済むことは良いことだ
これは単なる理想を求める話ではないので、現実的なこととして、コンポーネントが「自分が書くことなく高い品質で提供しつづけられるもの」というのは特別な評価が必要である。
もちろん、機能的な面や性能的な面で自分の理想には叶わないかもしれないし、自分が描いたグランドデザインにぴたりとははまらないかもしれない。
だが、それでも優れた機能を発揮し、目的を達成でき、なおかつその廃止に怯えなくて良いのであれば、ライブラリであれ、ソフトウェアであれ、積極的に採用すべきである。
もちろん、自分で書いていないのだから、自分の理想とは異なるだろう。 だが、それを「制約である」と考えればその制約故にさらなる発展が望めるだろう。
この考え方は私としてもかなり新しいほうだ。だが、これはかなり強力であることが分かっている。 やはり、「1行む書かないで済む」というのは大きいのだ。
例えば、私は自分で使うためのものでは絶対にやらないことだが、商業的には結構な頻度でデータをCSVで出している。 これはオリジナルデータをCSVにしているということではなく、データ自体はJSONやYAMLで出力しているものでもCSVオプションがあったり、ログ形式をCSVにしていたりする。
これは、CSVで出力することでExcelで扱えるようにするというのが大きい。 わざわざ専用のビューワを作ったりする必要もないし、使い方を説明する必要もない。 私はまともに使えないけど、Excelが使える人は多いし、私の顧客だとより多い。
もちろん、CSVで表現可能なデータは非常に限定的である。しかも、Excelでどう解釈されるかということまで考えるとより制約は厳しい。 だが、その制約を前提にしてしまえばより良い設計というのも考えられる。
そもそも、制約をhackするというのは楽しいものだ。だから制約があるということが単純にマイナスになることではなく、望ましくない制約があったとしてもそれを積極的に活用することでより良いものが生まれる可能性もある。
制約と発展
コンピュータにまつわるものの発展の歴史を見ると、「制約があったからこそ発展した」という側面を持つものがかなり多い。
もちろん、理想的に考えれば制約はないほうがより良いものになる。少なくとも除去することが可能なのであれば制約は歓迎せざるものではあるのだ。
だが、現実にはよく出来ていたからこそ発展せず後発のものに取ってかわられたものもあれば、制約があったからこそ活発に発展を遂げたものもある。
これには要因はたくさんあるが、制約があるからこそ、その点を前提に考えることで問題が整理され、創意工夫が活かされるというようなものもある。
プログラミング言語の設計においても制約を導入するのは重要なことになっている。 もちろん、制約がなくなんでも可能なほうが機能としては優れることにはなるのだが、トータルで考えたときにそれによって良い結果にならないというケースがあるのだ。 例えばRubyではMatzは「マクロを導入しない」「複数のブロックを渡せるようにしない」などの制約を与えていることが述べられている。
この制約と機能の話で欠かせないのがXMLの話である。
XMLはSGMLの後継規格である。 XMLは厳格になったと表現されることもあるが、同時に単純になったとも言える。
SGMLの場合、終了タグは省略することが可能である。 これは、SGMLは別途スキームを必須としており、このスキームの中である要素が何の要素を内包できるかということが定義されているからだ。 HTMLはHTMLスキームを適用したSGMLであり、例えばP要素はP要素を内包することができないので、
<P>最初の段落
<P>2番目の段落
<P>3番目の段落
と書くことができる。内包することができない要素が来ている時点で、その手前で要素は終了していることは自明だからだ。
だが、これはいくつかの問題がある。一番は、「必ずスキーム(DTD)を用意しなければならない」ということだ。 また、「省略可能かどうかを覚えるのが大変」という問題もある。
XMLの場合、スキームはオプショナルであり、スキームがなくてもXML文書として解析することが可能である。 そのために、終了タグは省略できなくなった。だから必ず
p>最初の段落</p>
<p>2番目の段落</p>
<p>3番目の段落</p> <
のように書く必要がある。 そのほかにも、属性値を必ずダブルクォートで囲むという制約もある。
このようにSGMLとXMLを比較すれば、確かにSGMLで許されていた書き方がXMLで許されなくなっているので「厳格になった」という言い方もできる。 だが、実際には例えば
- 開始タグから終了タグが囲まれているのがその要素である
- 属性の書き方は
属性名="属性値"
である
のようにシンプルで明確で、より少ないルールになっており、「シンプルなルールになった」というふうに言うことができる。
記事の主旨に従って言い換えれば、XMLはわざわざより新しい規格で、より制約を持ったものを導入したのだ。 だが、SGMLを活用したもので現在普及しているのがHTMLくらいしかないのに対して、XMLが非常に広く使われていることを考えれば、より制約のきついXMLがより好ましいものであったと認められるだろう。 制約を導入することがメリットとなり、またそのメリットが発展に寄与したわけである。
なお、現在HTMLにはSGMLベースのHTMLとXMLベースのXHTMLが存在している。 HTMLはHTMLスキームに基づいており、専用のパーサーが必要である。 XHTMLはXMLのHTMLスキームを利用しており、HTMLスキームを適用しなかったとしてもXMLとして解釈できる。 HTMLはXHTMLに以降する、という流れだったはずなのだが、HTML5が基本がSGMLベースでXHTMLで書いてもいいよというスタンスをとったことで最近はXMLパーサーで解釈できるXHTMLはないがしろにされがちである。 (個人的には解析が楽になるからXHTMLで書いてほしいし、ChienomiはXHTMLで書かれている。PandocがXHTML5で出力してくれるからだ)
結びに
「制約によって自由を与え、単純ながら高機能である」ということはソフトウェアデザインにおいてごく普通に存在する状態である。 そして、それを実現したとき、そのソフトウェアは「とてもうまくいった」ものになる。
どのような設計が成功するのか、ということは事前に予測することは不可能であり、故に難しい。 だが、それに近づく方法は確実に存在する。
それは、成功する設計を目指すことである。
そしてそのためには、本質はなにで、それがどのように表現されるべきなのか、どのように定義するのがもっとも本質的かつ汎用性があるのか、本当に必要なものはなにか、その定義に合致しないものはどのように置かれるべきなのか、そういうことを熟慮の上取り組むのである。
自分にとって有利なように解釈を拡大したり、自分の領域を拡大しようとしたりすれば、たちまち崩壊するだろう。
「プログラムを書くにあたり、我々は常に謙虚でなければならない」
「書くべきことを少なくし、それでもなお書くことは簡潔かつ明瞭にする」ということは、私の頭には常にある。
無邪気なコーディングも悪いものではないが、ソフトウェアの哲学に挑むこともまた、必要なことではないだろうか。