関数型言語とシンタックスノイズ
また飛行機内の暇つぶしエッセイ。
昔ベックマンがチャンネル9でHaskellやF#では、シンタックスを数学に近づけたい、なぜならそっちの方が数学的に考える時にノイズが少ないからだ、 みたいな事を言っていた気がする。 その事を思い出した、という話。
最近Folangでgoで書いたトランスパイラをセルフホストのために再実装している。 同じような実装をgoとfolangで書く事になるので、両者の特徴というか出来上がるコードの違いがなかなか興味深い。 基本的にはfolangの実装はF#で実装した場合とほとんど同じ感じとは思うので、以下はF#とgolangの話と思って読んでもらっておおむね良い。
セルフホストのためには再実装が必要なのだが、同じものを二回、しかも比較的最近実装したものをもう一回実装するのはかったるい。 だからとっとと終わらせたいし、デバッグとかもしたくないので、なるべくなら同じ実装にしたい。
けれどfolangにはいくつかの機能が無いのでそのままでは書けないし、書けたとしてもfolang的で無い書き方がちょくちょくあり、 そういうのはfolang的になるように変更している。 例えばトークナイザはstructのposを更新するのでは無く、新しいトークナイザを返すようにする、とか、 Discriminated Unionでよりちゃんと型のモデリングをするとか。
その中の結構大きな変更の一つに、相互再帰がうまく書けないので関数オブジェクトを渡すように変更する、というのがある。 相互再帰、F#ではandを使って書けるのだけれど、あまり推奨はされていない。 Folangでは関数定義のandはサポートしていない(型定義はしている。Discriminated Unionとレコードの相互参照は必須だろうから)。 という事でF#的な推奨方法に直す事になるのだが、それが関数オブジェクトを渡す、という事。
例えばExprのトランスパイルの関数をExprTransと呼ぶ事にする(コード上ではExprToGoと呼んでいるがこの名前はいまいちだったと思っている)。 そしてStmtのトランスパイルをStmtTransと呼ぶ事にする。
Folangは全部Exprという訳では無い上に、BlockというExprがStmtを持てるので、 ExprTransはStmtTransを必要とし、StmtTransはExprTransを必要とする。 こういうの、golangではそのまま書ける。
func ExprTrans(expr Expr) string {
...
StmtTrans(stmt)
...
}
func StmtTrans(stmt Stmt) string {
...
ExprTrans
...
}
golangなどの多くの言語は関数定義は使うよりあとでも平気になっている。 C言語などの言語でも、宣言を先に持ってくる、という方法で、少し原始的だが同じ事が出来る。
だが、F#ではこの手の仕組みが無い。 関数の呼び出しは定義よりあとじゃないといけないので、こういう事を上手く書けない。
そこでどうするか、というと、関数オブジェクトを渡す感じにするのが定石となる。 F#知らんがな、という人のためにgoで書くと以下のような感じ。
func ExprTrans(stmtTrans func(Stmt) string, expr Expr) string {
...
stmtTrans(stmt)
...
}
このようにstmtTransという関数オブジェクトを引数に追加してそれを使う。 そしてStmtTransの方ではこれを渡すようにすると、自己の再帰だけで済む。
func StmtTrans(stmt Stmt) string {
...
ExprTrans(StmtTrans, expr) // <- StmtTrans自身を渡す
...
}
わざわざ高階関数にするのは読みにくいし良い事無い気がする。実際golangではそうだ。 でもF#はそういうものだ。これは非常に良く出てくるので、関数オブジェクトだらけになって、それが実際にどれなのかは非常に分かりにくい。 F#系の言語が慣れないと読みにくいのもこれが原因の一つだろう。
関数オブジェクトがめちゃくちゃたくさん出てくるので、このシンタックスが簡便である、というのはかなり重要だ。 両者の定義を並べてみる。
stmtTrans func(Stmt) string
stmtTrans: Stmt->string
stmtTransの方が、認知的な負荷が(自分には)低い。 この差はちょっとした違いなのだけれど、たくさん増えてくるとこのちょっとの違いが大きくなる。
この認知的な負荷が低いおかげで、この手の作業を続けていくと、考え方に反転が起こる。 ExprTransを書く時に、StmtTransを関数オブジェクトとして渡すのでは無く、 Stmtからstringへの変換をするなにかを使ってExprTransを書く、 という風に、現在実装中の関数に必要なものを単に引数に並べる、というような感覚で書くようになるのだ。 ExprTransを実装する時に他の事は考えない。単にそういう道具がある、と考えるようになる。 こうなると関数オブジェクトばかりで実際にはなんの関数が渡されるのか、とかはあまり気にしなくなって、 コードが読みやすくなる。 この関数の実装をするのに必要なものを引数でぽこぽこ定義していって関数を作っていく感じは、 関数型言語に特有の感覚に思う。
これは他の言語のトップダウンでのコードの書き方に似ている。 必要なinterfaceなどをまず作って、それを使ったコードを書く。 次にそのinterfaceを実装する側を書く、という。 これが出来るのが初級と中級のプログラマを分ける一つだと個人的には思っているが、 中級のプログラマならまぁ誰でも出来るし日々やっている事でもあるだろう。 ただこのinterfaceなどをまず作ってそれを使うコードから書くのと比べると、少し違う部分もある。
まずこの、関数の引数の型は、この実装中の関数に専用のインターフェースとなっている。 実装をする時の都合で作られたものであり、この実装中の関数のためだけのものだ。 一方、インターフェースを先に作るスタイルだと、このインターフェースが現在実装中のものだけのものかは自明では無い。 いちいち関数を実装する都度専用のinterfaceを作るという訳にもいかないので、普通はその関数だけに限らず使われるなにかの概念を表したものになる。
現在実装中の関数のためだけのものだと、気軽に新しく作ったりやっぱりなくしたりも出来るし、 まさに実装中のものに必要な事だけを持ったインターフェースになる。Stmtからstringにするなにかであって、それ以外の情報が無い。 追加のオプションの引数やフラグの指定などがあるか、とかも考える必要は無い。 この必要な情報以外が無い、というのは、抽象化の定義を情報を落とす事だとするなら、抽象的である、とも言える。
2つ目に、このパラメータの型は、実装中の関数のすぐそばで作ったりなくしたり変更したり出来る、という事がある。これも重要だ。 interface定義は普通は関数定義の外、場合によっては別ファイルになったりする。 だから関数の実装を考える時とインターフェースを変更する時には少し距離がある。 けれど関数の引数はbodyの数行上であり、しかもその関数のためだけに自由に変えて良い所になっている。 試行錯誤も簡単に出来るし、この試行錯誤が本当に必要なのがなんなのかを考える良い手段にもなる。 interfaceに相当する側の試行錯誤とそれを使う側の試行錯誤がもっと近くで、お互いがお互いの洗練の作業をサポートしてくれる。 こうした相互の支え合いは、一段上の抽象を考える事になっている。
このような2つの理由、つまりその実装中の関数のための型である事と実装のコードのそばで編集出来る事、のために、インターフェースを先に定義してそれを使ったコードを書く、 という通常のトップダウンの書き方よりも、 必要な型についての試行錯誤がやりやすい。
この試行錯誤をする時にパラメータ側で考えたい中心となるのが、A->B
のAとBがなんなのか、
つまりドメインと像がなんなのか、という所になる。
シンタックスがそうなっていると、自然とこのAとB、そしてその対応について考える事が出来るようになる。
これは非常に関数的というか写像的な考えで、それを考えるのにコードを書いたりパラメータを足したり引いたり変えたりする、
というコーディングのアクティビティが有効となる。
この写像的に考えようとする時に、
その考えに近いF#などはシンタックスのノイズが低い、という気がするのだろう。
そもそもに、2つのシンタックスがあって、片方にノイズがあって片方にノイズが無い、というのは正しい表現では無い。 ノイズというのはたぶん、心の中にあるモデルとの距離のようなものであって、 つまりそれは心にあるモデルに依る。だからノイズはシンタックスだけで定義出来るものではなく、 シンタックスと心にあるモデルの2つが揃って初めて定義される概念なんだろう。
golangの方が関数というものの具体的な実体を元に考えるなら自然と言える。 スタックがあって引数で渡してなにか実行されてリターンされるようななにか、というのが見えてほしい人にとっては、 こういうシンタックスの方がノイズが少ないと言える。 golangは関数に限らず、この実装の見えやすさ、みたいなのが魅力でもある。 見た通りに動く、という。
一方でそうした事を表に考えるのであれば、関数の引数にむやみに関数オブジェクトを渡すのは、無駄な抽象化が増えて読みにくくなるだけ、と言える。 呼ぶ側もそれに合わせるのが面倒だし。 それよりは直接関数を呼び出す方がずっと良い。 それが相互再帰になっているかどうかなんて気にする必要も無く単に呼べば良いので、 わざわざ相互再帰を関数オブジェクトのパラメータにして切る必要なんてない。
だが必要な写像があった時に自身は何をするのか? 何をするかから見た時に必要な写像とは何なのか? というようなものの見方をしている時は、 よりそれに直接的なシンタックスの方がノイズが少ない、という事になる。 ノイズとは、考えている事との距離であって、 考えている事がどういう事かは問題とかやり方による。 どういう問題をどう解こうとしているかによって、同じ言語でもノイズが大きくも小さくもなる。
そしてこれは逆側の向きもある。 つまり、あるシンタックスは、それと近い心の中のありよう、メンタルモデルを導く。育むといっても良い。 ある言語を使って経験を積む事で、 ある考え方の経験を積む事が出来る。
考え方に習熟するのは、言語をちょっと触るだけではまったく足りない。 その言語の導く考え方でもって問題を考え、 その考えを元に試行錯誤を行おうとしてそのギャップからさらに考えを矯正されて、 その両者のギャップを何度も擦り合わせて馴染んできた頃にはじめてそうした考え方を習得したと言える状態になる。 そのためにはその言語の本を読んでちょっとコードを書いてみた、 くらいでは駄目で、 考え方とコードのすり合わせを何度も何度も行ったり来たりするような経験が必要になる。 少しずれた考え方だとコードの方にノイズが大きくなる事から、 考えの方が矯正されていく。 考えを矯正するのはそれなりに難しいので、それを矯正するための力、摩擦が必要だ。 このノイズにぶつかって考えの方が折れる為には、 それなりに難しい問題について、それなりに時間を掛けて取り組む必要がある。
こうした考え方は、ひとたび身につけたら、その言語を離れて使う事が出来る。 プログラマの使える道具箱に入った道具の一つとなる。 シンタックスのノイズが大きい言語ではその有効性は弱まるが、 大きな問題に挑む時には一つの道具だけでは不十分で、 いろんな道具を寄せ集めてなんとかこなす事になる。 だから使える道具はいっぱいある方がいい。 それは今使っている問題の今使っている言語に沿ったものだけでは無く、 それとは全然違う考えもいろいろある方が、 手強い問題をやっつけるとっかかりに出会える可能性は上がるし、 同じ倒すにしてもより良い倒し方で倒せる事も多くなる。
今回シンタックスに導かれて写像的に考えている自分に気づき、 そんな事を思った。