『関数型プログラミングに目覚めた!』のレビュー(Day-1)

  • 120
    いいね
  • 534
    コメント
この記事は最終更新日から1年以上が経過しています。

Day2のレビューDay3のレビューに続きます。

なぜこれを書いているか

私は社会科学分野を専門にしていますが、教育的理由を含む様々な理由から学部1年生から2年生向けの基礎教育で関数プログラミングの基礎を教えることを考えています(計算機科学の専門家ではありませんからできることは限られていますが)。私自身の能力的限界から使用する言語はHaskellかOCamlになると思いますが、そもそもの関数プログラミングについて1年生に教える際に適切な教材に関心を持っています。つい最近(2015年4月)刊行された岡部健『関数型プログラミングに目覚めた! IQ145の女子高生の先輩から受けた特訓5日間』も、そうした関心を以って購読しました。

最初に結論を述べてしまえば、私は本書を学生に読ませようとは思いませんし、率直に言って「どうにも困った本だなあ」と思います。しかし、否定的評価にせよその理由を記録しておくことは、一定の社会的効用を有しうるのではないかと思ってこれを書いています。また、上述のような私個人の関心から、レビューにあたって特に最初の3章について、かなり細かく感想と評価を書いていくことにします。また、その際には本書に関してインターネット上で提示されている疑問点を検討することにします(煩雑になることを避けるために敢えて引用はしません)。

「0〜9までの数をすべて足すコードを書け」について

このテーマはDay3の終わりまでほぼ200ページ(つまり本書の半分)を占めています。いわゆる命令型のコード1

imperative_sum.js
var s = 0;
for (var n = 0; n < 10; n++)
{
  s = s + n;
}

console.log(s);

と関数型のコード2

functional_sum.js
var plus = function(a, b)
{
  return a + b;
};

var s = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
  .reduce(plus);

console.log(s);

が対比されています。

[0,1,2,...,9]はダサいか?

本書に対する感想として幾つか見かけたものに、「関数型コードの[0,1,2,...,9]という配列リテラルベタ書きの方が命令型コードよりダサいではないか!」というものがありました(0〜999まで足せと言われたらどうするつもりなのか!)。しかし、まさに0〜999まで足すにはどうしたらいいのだろう、という問いを本書の登場人物自身が問い(p. 45)、配列リテラルではなくrange関数を使って配列[0..999]を生成するコード例が示されます(p. 109)3。ですので、何度も繰り返されるコード例[0,1,2,3,4,5,6,7,8,9].reduce(plus)のダサさは本書にとって本質的なものではありません。

ただ、容易に予想できる反応でもあるので、最初に「配列として与えられたデータ[13, 5, -6, 34, 9, -17, 8, 21, 7]の和を求めるコードを書け」というような例にしておけばよかったのに、とは思います。

(追記:と思ったのですが、プロフェッショナルな関数プログラマからは下記のような評価になるようです。

素人としては深入りを避けたいと思いますが、そうだとすればそもそもJavaScript選んじゃったのが関数プログラミングへの無理解を象徴的に示すものなのだということになるような気がします。)

フロー(チャート)に対する批判

最初に引用した命令型コードに対応するフローチャートが掲出され(pp. 9, 25, 52, 269)、そのフローチャートを見てそれが0〜9までの数を足せ、という問題の正しい解になっていることを確信できる人間は殆どいない、という主張がなされています(p. 9)。

そのことに異論はありません。が、for文を使った元の命令型コードそのものであれば、少なからぬプログラマはそれが正しい解だと思うのではないでしょうか。(JIS規格に従った)フローチャートにはfor文にそのまま対応する記法があるにも拘らず、敢えてそれを使用せず、よりprimitiveな記法でフローチャートを書けば、わかりにくくなるのはある意味では当然です(そもそもfor文は一定の抽象化・構造化を与えるものなのですから!)4。これは命令型に慣れた人からは不適切な藁人形論法に見えるでしょう。

(あと、そもそもの問題としてJavaScriptで「フローは不要」なんて不可能事そのものなんではという疑問はさておき。5

「問題の論理」?

p. 12で初出のこの「問題の論理」というタームはその後に繰り返し出てきます。が、その意味するところは良くわかりません。既出の関数型コードは「単純に問題の論理そのものをコードへまる写しているだけ」&「そしてそれがクールなコードを書く唯一の方法だ」ということなのですが、それは「0〜9までの数をすべて足せ」という要求をそのままコードで表現すればいいのだ(そこにはループや途中の計算結果の受け渡しなんか出てこない)&そうしたコードのみがクールなんだ、ということのようです。

しかし、たとえば「与えられた配列sをソートせよ」という問題を与えられたらどうするんでしょうか。s.sort(isGreater)と書け、ということなんでしょうか。肝腎のそのsort()はどうやって書けばいいのかは、およそ「問題の論理」からは出てこないように思います。或いは更に「与えられた配列を時間量$O(n\ \mathrm{log}\ n)$でソートせよ」という「問題の論理そのもの」をどう「コードへまる写し」すればクイックソートやマージソートなどの各種のソートが得られるのでしょうか? どうやら「クールなコード」を書こうとする限り、ソートを行うコードを書くことはできないようです6。とすると、「神の目」はどうやら役立たずだということになるでしょう。

「関数という論理操作」?

次に問題になるのは、p. 15の「関数という論理操作」です。相変わらず「論理操作」という語が何を意味するのかはよくわかりません。

     炊飯器
米と水 >-----> 炊飯器(米と水) = 炊きたてのご飯

の「炊飯器(米と水) = 炊きたてのご飯」という関係が「論理操作の関係」なんだそうです。「関数というものは作用して変化させる、という論理操作」なんだとか (p. 19)。正直なところ私はもう既について行きかねているのですが7、しかし、重大な問題がここには潜んでいるように思います。

まず、炊飯器が米と水をご飯に変化させるのは「因果的作用」です。更に、米と水に炊飯器を因果的に作用させると、米と水はご飯に変化し、もとの米と水はなくなってしまいます。他に例として挙げられているのが家の改造(Before After)であるところからして、著者のいう「作用して変化させる」が因果的な作用による物理的変化のそれであるということは一貫しており、そこでは、outputが得られた時にはinputは消滅しています。

しかしながら、ということは、これが関数プログラミングでいう「関数」に対応するものではないことは明らかです。x = 1; y = foo(x);xfooを作用させてoutputであるyを得ていますが、xfooによってもちろん消滅したり変化したりはしません(もし変化してしまうならばそのfooは副作用を持っており、関数プログラミングが基本的構成要素とするところの意味での「関数」にはなりません)。

このことは、著者の「関数」のイメージが、関数プログラミングでの「関数」と食い違っていることを示しています。関数プログラミングでいう「関数」は数学的意味での関数、つまり、「写像 $f: A \rightarrow B$」であって、あくまでも始域 $A$ の元と終域 $B$ の元との 対応関係 のことであり、そこには(始域の元が終域の元に「なる」というような)「変化」などというものはありません。

「『まとまり』は美しい単一の論理構造」?

さて最後に「論理構造」です。相変わらずよくわからないのですが:

[0,1,2,3,4,5,6,7,8,9] // 0から9までの数というのは『まとまり』なの。[……]『まとまり』というのは、まとまっているのだから極めて単純で美しい論理構造なの。『まとまり』という単一の論理構造は、これ以上分割する余地はない。(p. 28)

「まとまっているのだから極めて単純で美しい論理構造なの。」の「〜のだから」がもうさっぱりわからないのですが、そこを措くとしても、果たしてこの「論理構造」というのは何のことなんでしょうか。0〜9の『まとまり』と一口にいっても、集合${0,1,2,3,4,5,6,7,8,9}$とn-組$〈1,2,3,4,5,6,7,8,9〉$は違うわけだし、木構造でまとまってたりしたらどうするのでしょうか。これらの違いを考えるにそれぞれ「極めて単純で美しい」というには偉いこと「構造」が入っているように思うわけです。

そもそも「分割する余地はない」のなら、分割できませんよね。ということはリストや木を始めとした再帰的データ構造はここでいう「論理構造」には入らないということになりましょう。そうすると、『まとまり』とは単純に集合のことなんでしょうか。それだって部分集合とか考えたら「分割」できますけど(部分集合は「論理的」じゃなかったりするんでしょうか)。

なにより、[0,1,2,3,4,5,6,7,8,9]みたいなデータに対してなにかする際に(たぶん「論理操作」というてやつですね)それを「分割する余地はない」とか言われたら構造に対する再帰も使えないわけで、関数プログラミングで肝腎のreduce関数を定義することすらできません。お説に従えば、Haskellでお馴染みのパターンマッチによる構造分解による:

r f i (x:xs) = r f (f i x) xs

みたいな関数プログラミングのイディオムみたいなものも曰く「ダサい」コードとなってしまうのであって、およそ関数プログラミングをどうやって遂行したものか途方に暮れるしかありません。

まとめ

Day1に出てくる「問題の論理」「論理操作」「論理構造」といったキーターム(らしきもの)の内実はさっぱりわかりません。特にどれも「論理」という語を含んでいる辺りが問題で、いったいこの3つに何か共通する要素としての「論理」なる何事かがあるのでしょうか?

そのような「論理」なるものの説明が与えられないままに、「問題の論理」「論理操作」「論理構造」といった表現を並べられても、関数プログラミングを関数プログラミングよりも不明瞭な何事かで説明しようという無益な試みになるしかない、というのがDay1についての率直な感想です。

また著者の主張をそのまま信ずるならば、現実に行われている関数プログラミングの多くが「ダサい」ものだという事になりますが、翻って、著者が「クールなコード」と認めるようなものでどれほどの関数プログラミングができるかは極めて疑わしいということになるでしょう。

続くかどうか

本書で最もreadableなDay1ですら、改めて読み返したところひどく疲れてしまいました。Day2について、すぐに続きを書くことは難しいでしょう。

続きを書きました

追記(2015/05/30):デスクトップアプリケーション??

著者はブログで@camloebaさんや@esumiiさんにデスクトップアプリケーションをOCamlで作成するように求めているようです。著者が挙げている「クリックカウンター」や「お絵かき」の類を作成するのに特別なexpertiseは不要で、単に使用するGUIライブラリ・グラフィックスライブラリのドキュメントが読めれば充分なので、その趣旨は私にはまったく判然としません(当該の記事で著者がOCamlで「お絵かきロジック」を実装してみせよと要求する一方で自身ではJavaScriptによるそれを公表していない点も気になります)。もちろん、OCamlであれHaskellであれ破壊的代入の類の副作用を使用せずに書くのもなんら困難ではありません。

(追記:コメント欄にてyataketa9056さんに、既存のGUIライブラリを前提にスケールするコードを書こうとするとOCamlでは困難であろうという指摘を頂いています。利点はともかくHaskellと同じようにモナドを利用したライブラリを作成するなら純粋な関数型でのプログラミングも可能になる(けれどもOCamlプログラミングとしてはわざわざそのようなことをしても嬉しくはない)ということになります。とはいえ原理的に不可能でないのなら(そして不可能でないので)、純粋な関数プログラミングでGUIアプリケーションを作成するのにFRPがなんら必要でない、という趣旨には充分です。またこの主張自体はHaskellでのコードによっても充分に示されています。)

クリックカウンターはHaskellでOpenGLに対するラッパーであるGlossライブラリを使うならばこうなります(手元の環境でインストールしやすかったのでこれを使いました):

ClickCounter.png

ClickCounter.hs
import Graphics.Gloss.Interface.Pure.Game

main = play disp colour freq ini draw eTrans tTrans
 where
  disp = (InWindow "Click Counter" (225, 150) (40, 40))
  colour = white
  freq = 100
  ini = 0
  draw =  Translate (-75) (-50) . Text . show
  tTrans _ n = n 
  eTrans (EventKey (MouseButton LeftButton) Up _ _ ) n = n + 1
  eTrans _ n = n

お絵かきアプリも難しいところはなにもなく、ドラッグ中かどうかのフラグをマウスボタンの上下で切り替えて管理し、フラグを状態遷移関数(状態とイベントをとって状態を返す純粋な関数)の引数として渡していくだけです。

DrawingCanvas.png

DrawingCanvas.hs
import Graphics.Gloss.Interface.Pure.Game
import Data.Monoid (mconcat)

main = play disp colour freq ini draw eTrans tTrans
 where
  disp = (InWindow "Drawing Canvas" (400, 400) (40, 40))
  colour = white
  freq = 100
  ini = (False, [], [])
  draw (_, path, paths) = mconcat $ map line $ path:paths
  tTrans _ w = w
  eTrans (EventKey (MouseButton LeftButton) Down _ _ ) (_, path, paths) = (True, path, paths)
  eTrans (EventKey (MouseButton LeftButton) Up _ _ ) (_, path, paths) = (False, [], path:paths)
  eTrans (EventMotion pos) (True, path, paths) = (True, pos:path, paths)
  eTrans _ w = w

いずれにせよ、状態機械の数学的構成では状態遷移が状態と入力から状態への関数として表現されるというだけのことではあり(状態渡し)、状態遷移が複雑になれば状態遷移を純粋な関数として表現する作業も複雑になり困難になるので(そしてそれはしばしば綺麗な関数合成では上手くいかないようなものになることが多いので)、結局のところ少なくとも現状のFRPも(イベントのシグナルから状態のシグナルを構成する際に状態遷移をそうした関数で表現する必要が出てくるので)本質的には銀の弾丸にはならないと言わなければなりません(関数プログラミングで書けるということの恩恵はもちろんあるとしても)。
 

ちなみに、大した意味はありませんが、敢えてFRPライブラリ(Reactive-Banana)を使うとこうなります:

ReactiveBananaDrawingCanvas.hs
import Data.Monoid (mconcat)
import Reactive.Banana (accumB)
import Graphics.Gloss.Interface.Pure.Game
import Graphics.Gloss.Interface.FRP.ReactiveBanana (playBanana)

main = playBanana disp colour freq gen
 where
  disp = (InWindow "Drawing Canvas" (400, 400) (40, 40))
  colour = white
  freq = 100
  gen deltaTime = return . fmap draw . accumB (False,[],[]) . fmap eTrans
  draw (_, path, paths) = mconcat $ map line $ path:paths
  eTrans (EventKey (MouseButton LeftButton) Down _ _ ) (_, path, paths) = (True, path, paths)
  eTrans (EventKey (MouseButton LeftButton) Up _ _ ) (_, path, paths) = (False, [], path:paths)
  eTrans (EventMotion pos) (True, path, paths) = (True, pos:path, paths)
  eTrans _ world = world

FRPライブラリを使ってもコードが先のものと殆ど変わらないことに注意してください。要するにこれらのコードの本質は状態遷移を純粋な関数(ここではeTrans)で表現するところにあり、イベントストリームやシグナル・ビヘイビアといったFRP特有の要素を用いるか否かにはないということが示されています。
 
 
 


  1. (pp. 7, 52, 86, 133, 141) なお以下のコード例の引用についても同様ですが、本書のコードはMITライセンスです(©2015 Ken Okabe)。なお、(読みにくい気がしますが)波括弧の位置はママです。 

  2. (pp. 8, 11, 12, 14, 27, 29, 43, 46, 54, 86, 108-9, 152-3) 

  3. なんで64頁も間が空くような構成にしたのか、という気はしないでもありません。 

  4. なお、まったくの未経験者にとっては命令型コードの方がわかりにくいと思いますし、関数型コードのほうが自然であると思います。だからこそ学生に関数型言語でのプログラミング入門を提供しようかと思っているわけですから。 

  5. もっとも単純な例を挙げれば、JavaScriptでコードを並べる順番や位置を迂闊に変えるとマズいことが色々と起きますよね(たとえば前方参照だのシャドウイングだの)。そしてそれはJavaScriptが実行される際の制御フローそのものから生じている問題です。 

  6. 言うまでもないことですが、各種のソートアルゴリズムは純粋に関数型コードで書くことができますし、それぞれのソートアルゴリズムの実行効率もピンからキリまで異なったものになります。ということは、つまるところ関数プログラミングはどうやら「問題の論理そのものを書き写す」ということには存しないということになります。 

  7. 「論理操作」という語を敢えて私が理解しようとすれば、「$p$と$p \wedge q$から$q$を推論する」とか「$p \rightarrow q$から$\neg q \rightarrow \neg p$を推論する」とかといった論理的に妥当な推論の遂行、ということになるのですが、明らかにそういう意味では使われていません。