追記
こんな記事を読むより、まともな関数型プログラミング言語を使ってまともに関数型プログラミングを学ぶほうが、関数型プログラミングについてよほど正確な理解を得られます。少しでも関数型プログラミングに興味のある人は、まずは真面目なHaskellの教科書やすごいH本を読んだり、やさしいHaskell入門を読んだりしながら、実際に関数型プログラミングのコードを書いてみることをお勧めします。
繰り返しますが、この記事はあんまり読む必要はないです。関数型プログラミングを理解するには実際に自分でコードを書いてみるのが一番です。関数型プログラミングあるいは関数型プログラミング言語を理解するもっとも確実な方法は、Haskellをあなたのマシンにインストールして何かまとまったアプリケーションを書いてみることです。Haskellでアプリケーションが書けたら、自分は関数型プログラミングを理解しているし使えると胸を張っていいと思います。Qiita最長記録でも狙っているのかというこんなクソ長い記事を読む暇があったら、HaskellをインストールしてHelloWorldを書いてみたほうがよっぽど有意義に時間を過ごせます。長い文章が苦手な人にはまったく向いていませんし、「実際にコードを書く前に、まず概要や関数型プログラミングの考え方を掴んでから」というのもあまり役に立つとは思えません。それでも「関数型プログラミング」あるいは「関数型プログラミング言語」について何か掴んでおきたい、あるいは実際にある程度Haskellを使ってみたがしっくりこないというのなら、この記事がなんらかの役に立つかもしれません。
はじめに
「関数型プログラミング」および「関数型プログラミング言語」の概念は30年以上前からあるにも関わらず、長い間世間の主流とは外れたところにありました。それが近年になってようやく関数型プログラミングのスタイルが見直されてきているようで、さまざまな言語にラムダ式が採用されたり、ECMAScript5 でArray.prototype.map
やArray.prototype.reduce
が導入されたり、Java8でjava.util.stream.Stream
が導入されたりという形で現れてきています。その一方で、関数型というパラダイムはあまりに誤解されてきています。
この記事は、関数型言語の誤解や疑問に答えるという形で関数型言語の真実の姿を説明し、関数型言語の周辺にはびこる誤解を一掃するべく書かれています。以前書いてお蔵入りになったものを大幅に再編集したテキストなので、少々話題が重複していたり説明不足の用語があるかもしれませんが、ご容赦ください。
なお、この記事を書くにあたってはこのQiita内の記事はもちろん、関数型言語について紹介するブログ記事やそれに対する各種SNSでの反応などを漁って記事のテーマを蒐集しています。この記事に対して「こんな誤解する奴いねーだろ」というようなコメントももらったことがあるのですが、この記事は架空の疑問や誤解をもとに書かれているわけではなくて、各項目は私が実際に見かけた実在の疑問や誤解で、具体的にこのURLのこの人のこの文章についての回答というのが実はちゃんと存在します。でも、この記事はそうした人にマサカリを投げるために書かれているわけではないので、当該のURLを張ることは意図的にしていません。むしろ特定ができないようにわずかに表現を変えているくらいです。
この記事の後半でも言っていますが、道具はその機能さえ優れていればいいというものではなく、その道具が広く使われているかどうかというのも道具の利便性を左右する重要な要素になります。だから関数型プログラミング言語についてより多くの人に知ってもらいたいと筆者は思っていますし、そのためにまずは『関数型言語』って何?どんなもの?あの記事でああ言っているけどホント?という疑問に答えるために、この記事は書かれています。
関数型言語、関数型プログラミングというカテゴリについて
「関数型言語かどうかは、その言語の言語仕様が〇〇かどうかで一発で見分けられる」
ひとことで「関数型プログラミング言語」といってもその言語仕様はあまりに多岐にわたり、ひとつやふたつの言語仕様上の特徴で区別したり説明したりするのは困難です。関数型言語と呼ばれる言語にはHaskellやOCamlのように複雑な機能を豊富に備えている言語がある一方で、Lisp/Schemeのような極端に構文や機能がシンプルなもの、Lazy Kのように「すべてが関数」という風変わりなものもあります。これらの言語から最大公約数的な言語機能を探そうとすると、関数がファーストクラスであることが関数型言語の特徴、というようなことになってしまいかねません。これだと、C++やJavaScriptのような関数型言語とはあまり呼ばれない言語まで関数型言語に含まれることになってしまいますし、最近では関数型言語の言語機能を取り込もうという従来型の言語も多くありますから、その境界はさらにあいまいになってきています。
『明示的状態/暗黙的状態』というものを関数型プログラミングかどうかの基準にあげる人も見かけましたが、私はそれは関係ないと思いますし、Cのstaticな変数やオブジェクト指向のthis
など、大抵の言語では関数が引数以外の状態にアクセスする手段が用意されているのと同様に、関数型言語でもクロージャを通して外側のスコープにアクセスできるのが普通です。参照透明かどうかやデータ型が不変かどうかだけでも判断できません。参照透明でなかったり可変なデータ型を持っていても関数型言語と呼ばれている言語はたくさんあります。(「参照透明性」とは何か、ということに関しては後ほど説明があります)
そうとはいっても、このカテゴリには何の統一性もないというわけではなく、これらの言語を俯瞰していると根底には共通した理念があるように感じます。「関数型」の名が表す通り、関数型言語では常に表現の中核として関数の概念が用いられています。いわゆるマルチパラダイムと呼ばれる言語が様々な言語機能を貪欲に取り込んでいくのに対して、関数型言語は関数の機能を強化する概念は取り入れますが、関数と関わりの薄い言語機能を取り込むことは好みません。関数型言語は関数の概念を以って言語全体をすっきりと整理し、一貫性を乱すような余計な機能は排除し、可能な限りのシンプルさを保とうとしているように感じます。いろいろなものを関数で表現しようというスタイルの究極型がLazy Kのような言語であり、使いやすさとシンプルさのバランスを取ったものがHaskellのような言語だといえます。
「関数型プログラミングの定義とは○○だ」
関数型プログラミングがいまいち飲み込めず悶々としている人が「定義を教えてくれ」とせがむ気持ちはよくわかります。何かの概念を理解しようとするときに、まずその定義を知ろうとするのはとても殊勝な心がけです。定義を求める人はたぶん数学が得意な人なんだと思いますが、数学なら関数が微分可能か、超越関数かどうか、集合が有限集合かどうか、整列可能かどうかは、誰かに定義をせがめば教えてくれるでしょう。それは数学のように定義がはっきりしている事柄ではうまくいきますが、そうでない事柄ではうまくいきません。音楽の「プログレッシブロック」を聞いてみると「これが……ロッ……ク……?」と頭が疑問符でいっぱいになったり、書店の「ライトノベル」のコーナーがこの世すべての清濁を飲み込んだような混沌に満ちているように、明確な定義のない漠然とした分類というのもよくあるのです。「関数型プログラミング」もその類です。
関数型プログラミングの定義を誰かにせがむと、是非とも要請に答えなければと考えて、苦し紛れに色々な定義を示してくれる親身な人もいると思います。しかし「関数型プログラミングの定義」などという存在しないものを誰かにねだらないほうがいいと思います。また、苦し紛れに示した「関数型プログラミングの定義」が違和感に満ち溢れていたとしても、それは不可能に挑戦した結果当然のように産み落とされた失敗作なのでどうか勘弁してあげてください。もし何かうまく違和感の少ない「定義」を見つけ出したとしても、それは定義とはとても呼べないような漠然とした定義だから違和感が少ないだけで、明確な定義に成功しているわけではありません。
他のパターンとして、関数型プログラミングとは膨大な概念が漠然と集まったものであるのにもかかわらず、ごく一部の要素だけに注目して関数型プログラミングあるいは関数型プログラミング言語を定義付けようとするものもあります。たとえば、「関数型プログラミングは、map
やreduce
などの高階関数を使うプログラミングだ」とか、「関数型プログラミングとは純粋な関数によるプログラミングを支援するものだ」とかで、そのような歯切れのいい定義や説明は『わかりやすい』と受け取られることが多いらしいです。確かにそうやって関数型プログラミングをすっきりと説明できたらどんなに説明が楽だろうかと筆者も思うのですが、そういう歯切れのいい説明は関数型プログラミングをあまりに一面的にしか捉えていません。map
やreduce
を使ったくらいで関数型プログラミングと呼べるなら、map
やreduce
が導入されたJavaScriptがあればHaskell/OCaml/F#/Scalaのような関数型プログラミング言語は不要かというと、もちろんそんなことはありません。関数型プログラミングにはmap
/reduce
以外にも便利なものがたくさんあります。JavaScriptでも純粋な関数は定義できるので純粋関数型プログラミング言語は不要なのかというと、もちろんそんなことはありません。関数型プログラミングを歯切れよく説明することなんてできません。「ロックとはエレキギターとドラムを中心とした少数編成による音楽様式で……」なんてなんて百科事典の冒頭みたいな定義を読んで「なるほど!自分はロックを理解した!」なんていう人がいたら、どう考えてもにわかロック野郎です。「関数型プログラミングとは純粋な関数によるプログラミングを支援……」なんていう説明を読んだだけで「なるほど!自分は関数型プログラミングを理解した!」なんていう人も同じです。
ロックを理解したければ、ロックの定義を尋ねるのではなく、とにかくロックをいろいろ聴いてみるのがいいでしょう。ライトノベルを理解したければ、ライトノベルの定義を尋ねるのではなく、ライトノベルを何冊も読んでみるといいでしょう。関数型プログラミングを理解したければ、関数型プログラミングの定義を尋ねるのではなく、実際に何らかの関数型プログラミングを使ってコードを書いてみるのがよいと思います。定義がどうこう分類がどうこうなんて考えるのは時間の無駄だし、歪んだ認識を増やすだけです。いいから自分でコードを書いてみるのがいいと思います。
「関数型言語とは何でも関数で表すプログラミング言語だ」
先の節で筆者はそのように説明したのですが、『いろんなものを関数で表すというのは、関数型言語に限らず、他のプログラミング言語でも同じなんじゃないか』という疑問もあるようです。例えば、Python2では標準出力するprint
は文であり、関数ではありませんでしたが、それではやはり一貫性に欠けるということでPython3からprint文からprint
という関数に言語仕様が変更されるという経緯がありました。このように、関数で表せるものは関数で表したほうが仕様が一貫しており簡単だという考えかたは様々な言語で共有されており、必ずしも関数型特有だとは言えません。
しかし、関数型が一線を画しているのは、その「なんでも関数」というコンセプトが従来の言語よりはるかに徹底しているということです。この考え方の背景にはラムダ計算という計算モデルがあります。簡単にいえば、ラムダ計算は、
- 識別子
- 無名関数
- 関数適用
というたった3つの要素だけで式が表現される計算モデルです。実際、BNFではラムダ計算はたった3つの生成規則で表現されます。そして、このラムダ計算は、たったこれだけの要素でありながら、ありとあらゆる計算が可能であることが証明されているのです。つまり、プログラムも関数だけあればありとあらゆるすべての計算をこなせるのです。驚くべきことですが、ここでいう「すべて」は誇張ではなく、文字通りすべてです。「データ」と「操作」は全然別のものだと思うかもしれませんが、ラムダ計算では、データすらただの関数に過ぎません。ラムダ計算では0
もtrue
も関数ですし、リストのようなデータ構造さえ関数なのです。つまり、「関数にできるものは関数にしよう」という原則に忠実になると、本当に何もかも関数になってしまいます。それはprint文がprint関数になるというどころの話ではなく、0
のようなデータも関数、forループも関数、if文も関数、データのコンストラクタも関数、何もかもが関数になります。
しかしながら、現実的には本当にすべて関数にしてしまうとプログラムのソースコードは何がなんだかわからないものになってしまいます。これは「すべて関数」というコンセプトを本当にプログラミング言語として実現したLazyKなどの言語のソースコードを眺めればすぐわかります(厳密にはLazyKはSKIコンビネータ計算というまた別の計算モデルに基づいていますが、「すべてが関数」というのはラムダ計算とも共通しています)。とてもじゃありませんが、これを読むのも書くのも不可能に近いです。しかも、現在のコンピュータではそのような計算モデルは効率が悪いという理由もあります。
そのため、実用的な関数型言語では、いろいろなものを関数として表現するという一貫性を追求しながらも、読み書きのしやすさも考慮に入れて設計されています。ここでは、JavaScriptでは関数でないのに、Haskellでは関数であるというものの例を挙げてみましょう。
- JavaScriptのプロパティアクセスでは、オブジェクト
person
のname
プロパティの参照は、ドット演算子を使ってperson.name
というような構文になります。Haskellでは直積型のデータperson
からフィールドname
の値を取り出すには、フォールドラベルによって自動的に定義された関数name
を使い、name person
という式になります。この式ではname
という関数を式person
に適用しています。JavaScriptの.name
というプロパティアクセスは関数適用ではありませんが、Haskellのname
は関数であり、フィールドへの参照は関数適用そのものです。
- JavaScriptのオブジェクト作成は、new演算子を使って
new Person("John Doe")
というような式になりますが、HaskellではPerson "John Doe"
というような式になります。このときHaskellのPerson
はデータコンストラクタという特殊な関数で、Person "John Doe"
という式はただの関数適用に過ぎません。JavaScriptではコンストラクタの呼び出しと関数呼び出しはまったく異なり、new Person("John Doe")
という式はnew Person
という関数を"John Doe"
に適用しているわけではありません。
-
JavaScriptではfor文がありますが、Haskellにはそのような制御文は存在せず、それに相当するものがあるとしたら
map
やfoldl
あるいはモナドと組み合わせて使うtraverse
、forM
といった関数です。JavaScriptのfor文はもちろん関数ではありませんが、Haskellのこれらの関数はユーザが一から自分で定義することすら可能な、本当にただの関数です。 -
Haskellでも演算子は関数ではありません。しかしHaskellの演算子は括弧で囲むだけで関数に変換することができます。たとえば、数の加算演算子
+
は括弧で囲んで(+)
にすると、これは関数として扱うことができ、1 + 2
という演算子適用の式は(+) 1 2
という関数適用の式に変換することができます。記述性のために関数でない要素である演算子を導入するという妥協をしながらも、演算子と関数の間に直接的で自明な変換を設けることで、「なんでも関数」というコンセプトは可能な限り維持されています。 -
JavaScriptで、ある式の結果を変数に代入する
var input = getLine()
というようなコードは変数を宣言する文です。Haskellでもinput <- getLine
というような文を書くことがありますが、これは実はgetLine >>= (\input -> ... )
というような式のシンタックスシュガーで、実はgetLine
という式と(\input -> ...)
という関数を演算子>>=
に適用するという式です。<-
という記号は演算子ではなく構文であり、input
という変数を宣言して代入しているというより、実はinput
はただの無名関数リテラルの引数だったりします。 -
関数型言語は例外処理も単に関数で行うことを好みます。JavaScriptには例外処理専用の構文としてthrowステートメントやtry-catchステートメントを使いますが、Haskellでは関数は単に返り値として正常値かエラー値を返すのが通常ですし、IOエラーなどのネイティブな例外も
catch
という単なる関数で捕まえます。例外処理専用の構文というものはありません。
このように、「いろいろなものが関数」というコンセプトはHaskellのような関数型では相当に徹底されています。この意味で、筆者はHaskellのような関数型言語の言語仕様は比較的単純だと思っています(ただし、単純なのは言語仕様であって、ライブラリではその単純なモノを組み合わせてとんでもなく複雑なモノを創りだしてしまうので、その意味では関数型言語は複雑だと思います)。もちろん、ラムダ計算ほど「すべてが関数」というわけではなくて、0
やTrue
などのデータは関数ではありませんし、case式やlet式のような関数でない式もたくさんあります。
一方で、関数型言語の最右翼のひとつであるHaskellも案外野暮ったい部分も残していて、if式は関数として定義することも可能なのに、なぜかHaskellでは式のままになっています。これはどうも読みやすさを考慮したということらしいのですが、筆者は読みやすさとしては正直どちらも大差ないと思うし、もういっそifも関数にしちゃえば一貫性あるのにとも思います(別に思うだけじゃなくて自分で定義して使ってもいいのですが、もちろんコーディングスタイルとして標準的でないのでおすすめはできません)。そもそも、Haskellではcase式とパターンマッチングが強力なせいでif式の出番はあまり多くありません。
- 関数型言語はいろいろなものを関数として表現しますが、その徹底ぶりは従来の言語とは一線を画しています
- でもHaskellもif式を残していたりと、案外鬼になりきれない部分が残っているようです
「関数型プログラミングとは遅延リストやmap
、reduce
のような高階関数を用いたプログラミングスタイルである」
それは関数型プログラミングのほんの一部でしかありません。最近ではオブジェクト指向言語やマルチパラダイム言語と呼ばれるような言語でも、関数型のスタイルを取り入れたとされる言語機能が採用されることがしばしば見られます。例を挙げれば、
- ラムダ式(無名関数)
-
map
、reduce
のような高階関数 - 遅延リスト/遅延ストリーム
などが採用されるケースがよくあるようです。確かにこれらは関数型のスタイルから取り入れられたといっていいでしょうが、オブジェクト指向言語などの言語からこれらの機能を使うだけでは、とても関数型プログラミングを味わったとは言いがたいものがあります。先に述べたとおり、関数型言語にはコード全体を一貫して関数で表現しようという理念が感じられますが、いわゆるマルチパラダイムの言語は言語仕様の簡潔さや一貫性とは程遠く、またこれらの言語仕様やライブラリは取り入れやすいものだけを取り入れてみたというだけで、関数型プログラミング言語の理念とは一線を画すものだといえます。そういったマルチパラダイムの言語は、新たな概念を導入してプログラミング手法の幅を増やすことには熱心でも、不要なものを削って一貫性や単純さを追求しようという姿勢には消極的な気がします。
また、map
のような高階関数は言語やライブラリ全体が高階関数プログラミングのスタイルによって設計されているときに最大の威力を発揮します。既存の言語に持ち込んだところで、つぎはぎだらけになったり、for文などと入り混じるような一貫性のないコードになりがちです。たとえば、JavaScriptには関数型のスタイルを取り入れたつもりだと思われるArray.prototype.reduce
という関数があります。このreduce
はHaskellなどの関数foldl1
と似たような機能をもっていますが、実はJavaScriptのreduce
はまともな関数型プログラミングを知っている人なら絶対にしないような明らかな設計ミスを犯しており、Haskellのfoldl1
とは別物になってしまっています。というのも、例えば配列xs
の最大値を求めようとするときに、Haskellではfoldl1 max xs
と書くのに対し、JavaScriptではxs.reduce(Math.max)
のようには書けず、xs.reduce((x,y)=>Math.max(x,y))
というようにいったん関数リテラルで包んでからreduce
に渡さなければならないからです。
var xs = [2, 45, 9, -4, 22];
console.log(xs.reduce(Math.max)); // NaN
console.log(xs.reduce((x,y)=>Math.max(x,y))); // 45
これがなぜなのかはreduceの仕様を読めばわかるのですが、reduce
は引数で渡された関数に、配列の要素だけでなくそのインデックスと配列全体の参照まで加えた4つの引数を与えて呼び返します。xs.reduce(Math.max)
と書くと、max
がそれらの余計な引数まで受け取ってしまうため、NaN
が返ってきてしまうのです。ここから察するに、どうもこの関数を設計した人はreduce
を単なるfor文やforeach文の代替くらいにしか考えていないように思われます。しかも恐ろしいことにunderscore.jsもLo-Dashも同様の間違いを犯している(もしくは間違いを正すことなく継承している)のですから、高階関数プログラミングに対する誤解の根の深さは生半可ではありません。
関数型のスタイルを中途半端に取り入れて関数型プログラミングを実現していると標榜するのは、カリフォルニア巻きを食べて寿司を理解したと言っているようなものです。それぞれの文化に合わせてアレンジして取り入れることは悪くありませんが、そういった自称「関数型プログラミング」は須らく本来の関数型プログラミングのコードとは異なるつぎはぎだらけのコードになっています。「関数型」を理解したいなら、従来型の言語から関数型っぽい機能を使うだけではなく、関数型プログラミング言語を使って「本物」の関数型プログラミングにどっぷり浸かるのがいいでしょう。
「関数型プログラミングとは、まずデータ型の定義を行い、そこに関数の定義を加えていくスタイルのことである」
それだとC言語やPascalのような素朴なプログラミング言語も「関数型プログラミング」を行っているということになり、我々がイメージするところの「関数型プログラミング」とはだいぶ広い概念になってしまいそうです。「関数型プログラミングとは何か」をひとことで言い表すのはとても難しいのですが、従来の言語よりも関数が活用される範囲を拡張し、さまざまな処理を関数の定義と適用で表現することで簡潔で一貫したコーディングを目指すスタイルである、と言い表すのが関数型のイメージに近いのではないでしょうか。
とはいえ、現代ではあまりにオブジェクト指向が席巻しており、データと操作をクラスとしてまとめて定義していくスタイルがさも当然のものと思われています。それとは違うという意味で「(オブジェクト指向とは異なり)関数型ではデータと関数が独立して存在している」と説明すること自体は間違いではありません。
「関数型プログラミングを扱うには圏論の知識が不可欠」
そんなことはまったくありません。圏論由来の概念をまともに扱えるのは関数型プログラミング言語のなかでもごく一部の言語、具体的にはせいぜいHaskell/Purescript、あるいはScalaといった限られた言語だけですし、HaskellやPureScriptでさえ別に圏論を知らなければコーディングできないなどということはまったくありません。興味があるとか教養として知りたいというのなら止めませんが、覚えておいた方がいい知識の優先順位としてはかなり後ろのほうなんじゃないでしょうか。
プログラミングの技術をその背景まで楽しみたいというのなら圏論について学ぶのも良さそうですが、単純に便利なプログラミングの道具として関数型プログラミングを使いたいなら、圏論の知識を学ぶ必要はないと思います。筆者もべつに圏論なんで概要や僅かな応用しか知りませんが、関数型プログラミングをするのに何ら差し支えありませんし、もちろんモナドや関手といった概念を扱うのにも何ら問題ありません。圏論由来だかなんだか知りませんが、プログラミングにおいてはただの関数とデータの組み合わせに過ぎません。筆者の場合、圏論のような数学的概念を通じて関数型プログラミングを知るというより、関数型プログラミングを通じて数学を知るということのほうが多いです。趣味として学ぶぶんには数学は結構面白いので、興味のあるひとはいろいろ調べてみると面白いとは思います。
「関数型プログラミングを習得するには、まず『関数型プログラミングの考えかた』を学び、それから高階関数やモナドといった個別の手法を学ぶと良い」
まったく逆だと思います。『考えかた』などという漠然としたイメージが何かの役に立つとは到底思えません。個々の手法を習得したあとで、関数型プログラミング全体としての方向性がおぼろげに見えてくるんだと思います。
JavaScriptなどの既存のプログラミング言語で関数型プログラミングに取り組もうとしても、関数型プログラミングをするというのはどういうことなのか見えていない段階では何も書けないと思います。関数型でない言語で関数型プログラミングを模倣しようとしても面倒なだけでメリットに乏しいので、とりあえずHaskellやOCaml、Scalaといった関数型プログラミング言語に取り組むのをおすすめします。
「関数型プログラミングに乗り換えたが、実際にプログラムを書こうとすると手が止まる」というのなら、単純にいきなり大きなことに取り組みすぎではないでしょうか。大きな課題に取り組んで難しいのなら、小さな課題から取り組んでいくしかないと思います。HalloWorldから始めて、mapやfoldといった高階関数に親しみ、代数的データがのような便利な機能を知り、pureやidといった抽象的な語彙も身につけて、それでだんだんと『関数型プログラミングの考えかた』が見えてくるんだと思います。『関数型プログラミングの考えかた』を身に付ければ関数型プログラミングができるようになるというのは、一見それっぽいですが順番が逆です。
「モナドは難しい」
そのとおりで、モナドは難しいです。なにしろモナドはとても抽象的です。抽象的なものを学ぶにはコツが必要で、誤ったアプローチで学ぼうとするとめちゃくちゃ難しく感じるでしょう。モナドそのものが難しいというより、抽象的な概念を学ぶことに難しさがあります。数学が得意な人はこの抽象的概念の学びかたを身につけているのですが、それが身についていない人も多いはずです。
抽象的な概念の典型的なダメな学びかたは『定義とにらめっこする』『いろんな啓蒙記事を数多く読み漁る』『自分のすでに知っている概念に例えようとする』などです。一般のモナドの定義なんてあとで学べばいいです。モナドの啓蒙記事を読みあさるのは止めましょう。何か一冊、まともな関数型プログラミングの入門書を読めばいいです。モナドは他のどんなものにも例えることはできません。無理に例えようとすると誤解を招くだけです。
抽象的な概念を学ぶコツは、『具体例をたくさん学ぶことと』『実際に手を動かして何度も確認し、体に覚えさせること』です。もしモナドを学ぼうとしてみて挫折した経験のある人がいたら、モナドの具体例はいくつ挙げられるでしょうか。ここで3つや4つくらい挙げられないのであれば、それはもうモナドとの取り組みかたが根本的に間違っていると思います。また、モナドを理解しないままとにかくコードを書いて、わけのわからないコンパイルエラーメッセージとにらめっこするという練習はしたでしょうか。モナドを使えるようになるには、計算ドリルを解いて解き方を体に覚えさせるように、練習が必要です。鉄棒で、逆上がりができるようになってから逆上がりを練習するのではなく、逆上がりができずに何度も飛び跳ねて失敗して、そのうちようやく逆上がりができるようになるんです。コードを書いて山ほどのコンパイルエラーを出して練習せずにモナドは習得できません。
「関数型プログラミング言語とは、副作用を避けるプログラミングを支援し奨励するプログラミング言語だ」
関数型プログラミング言語の分類はあいまいなので、その場ではそういう分類であるという前提で話を続けたいのなら、それで構わないと思います。副作用はプログラムを複雑にする一因であり、可能な限り避けるべきであるのは間違いありません。
ただしその場合、それでいうところの『関数型プログラミング言語』が指すのは、Miranda/Clean/Haskell/Lazy K/Elm/PureScriptといった言語くらいでしょう。Lispはもちろん、MLもScalaもF#も関数型プログラミング言語ではないことになります。なぜなら、「副作用を避けるプログラミング」というのものが現実的に可能なのは『純粋関数型プログラミング言語』だけで、純粋関数型でない言語では関数の純粋性を確かめる現実的な方法がないからです。それに対して、純粋関数型プログラミング言語では、副作用は「副作用なしでプログラミングすることをサポートしたり奨励」されるどころか副作用は自動的にすべて排除され、副作用のないプログラミングが現実的に、しかも簡単に可能です。一般的に理解されている関数型プログラミング言語の分類や定義はともかく、もしあなたがプログラミング言語に求めるものが『副作用の排除』であるなら、あなたが求めている『関数型プログラミング言語』は決してML/Scala/F#ではなく、Haskell/Elm/PureScriptです。
「関数型プログラミング言語とは、副作用を避けるプログラミングを支援し奨励するプログラミング言語だ」というのは「真の関数型プログラミング言語だと言えるのは、純粋関数型プログラミング言語だけだ」と言っているも同然で、かなり過激な立場のようにも思えます。関数の純粋性は関数型プログラミングという膨大なパラダイムの一部だとしか私は思っていませんし、関数の純粋性だけに注目して関数型プログラミングあるいは関数型プログラミング言語を定義することが妥当とまでは思えません。MLやScalaには副作用のある操作がたくさんあり、そこまで副作用の排除を大きく掲げているとは思えませんし、普通はMLもScalaも関数型プログラミングが可能な関数型プログラミング言語に分類される以上、純粋性だけをことさら取り上げて関数型プログラミングを説明することが広く認められているとはいいがたいです。関数型プログラミングには純粋性以外にも便利な概念や機能がたくさんあります。純粋性だけに注目し、その他の機能を無視してしまうのは、あまりにもったいないことだと思います。
ただ、私が本当に便利だと感じたりおすすめしたいと思っているのは、関数型プログラミング言語全体というより純粋関数型プログラミング言語です。私も「純粋関数型こそが真の関数型だ!」と言い切ってみたいですが、いろんな方面から怒られそうなのでそう声高に主張するのは控えています。でも関数型プログラミングを関数の純粋性で特徴づけようとする人は案外いるらしく、それは純粋関数型プログラミング言語を推奨したい私にとっても嬉しい話ではあります。その考え方が広く認められているだろうとは私には思えませんが、妥当かどうかはともかくそういう話になれば私には都合がいいので、いいぞもっとやれ!と言いたいところです。「副作用は悪だ」「関数型プログラミング言語とは、副作用を避けるプログラミングを支援し奨励するプログラミング言語だ」という主張に賛同するなら、あなたにぴったりなのはHaskell/Elm/PureScriptです!Haskell/Elm/PureScriptを使いましょう!
関数型言語の機能について
「関数型プログラミングのメリットは、ループで変数定義しなくて良いことだ」など
確かにそれもメリットかも知れませんが、「関数型プログラミングのメリットは何か」と聞かれた時に挙げる項目としてはあまりに瑣末だと思います。for
文をmap
関数やforEach
関数で置き換えたくらいで、それが関数型プログラミングの真髄だみたいに言い出す人は結構多いですが、それは関数型プログラミングという巨大なパラダイムのほんの一部でしかありません。for
文で超困っているなんてことはないでしょうし、それをメリットとして挙げられると、そんなどうでもいいことを改良するのが関数型プログラミングなのか、という印象になってしまいそうです。
「関数型プログラミングのメリット」をひとことで言い表すのはとても難しいです。そのため、具体的に挙げろと言われると、そういった瑣末でもわかりやすい点から挙げてしまうのかもしれません。間違ってはいませんが、適切な要約だとは言いがたいものがあります。
「○○○○という機能は関数型プログラミングとはいえない」
「関数型プログラミングを学んでみよう」と思っている人に、例えば「型クラスというものがあるよ」というと、「型クラスのある関数型プログラミング言語なんてごく一部だ。それは関数型プログラミングとはいえない」と待ったをかける人がいます。確かに型クラスは決して関数型プログラミング言語に共通する特徴とは言いがたいのですが、「関数型プログラミングを学んでみよう」という人の元々の目的を考えると、まず「ソフトウェア開発を楽にしたい」という目的があって、その手段として「関数型プログラミングという分野を探すと、まだ自分の知らない便利な機能や概念があるかもしれない」と考えて関数型プログラミングに辿り着いたのであって、そこに「型クラスが関数型プログラミングらしい機能かどうか」は関係がありません。重要なのは、型クラスが便利かどうかということです。
それに、関数型プログラミングに定義が存在しない以上、ある機能が関数型プログラミングらしいかどうかという議論は不毛です。たとえそれがほんの一部の言語にしか存在しないような機能だとしてもです。ただ漠然と区分けしたものを、その分類が正しいとか正しくないとか厳密に議論しても意味がありません。
それが関数型プログラミングらしいかどうかにかかわらず、便利なら使いましょう。不便なら使うのを止めましょう。それが便利なのか不便なのかよくわからなかったら、それが関数型プログラミングらしいかどうかで使うかどうか判断するのではなく、とりあえず使ってみて便利かどうか判断しましょう。便利かどうか判断するために使ってみるという余裕がないのであれば、とりあえずはこれまでの方法を続けて、いずれ時間が空いた時に試すのがいいと思います。
「関数型言語は副作用が中心になる処理は書きにくい、あるいは副作用は扱えない」
ぜんぜん違います。関数型言語で副作用が中心になる処理が書きにくいということはありませんし、言語にもよりますが、むしろ手続き型より短く書けます。このあたりの誤解はとても深いらしく、「副作用を含む処理が書きにくい」どころか「副作用が書けないのでゲームソフトが作れない」などと誤解している人さえ見かけましたが、まったくの誤解です。「純粋な関数プログラミングは副作用がないので、入出力や状態変化は出来ず、つまりメモリをいじって遊んでいるだけだよね」なんてのもよくある誤解です。当たり前ですが、普通のプログラミング言語と同様にキー入力やマウス入力なども扱えますし、ネットワークアクセスやファイル入出力もできます。ある程度プログラミングの経験が豊富な人ほど「純粋」「副作用がない」というキーワードから自分の知識の範囲の中で想像をふくらませてしまい、妙な誤解に迷い込むことがあるようです。
なお、ここでいう「関数型言語」は「参照透明な言語」「純粋関数型言語」と呼ばれる関数型言語にまつわる問題であって、そうでない関数型言語は別に問題なく手続き型と同じように副作用を記述できます。「副作用を記述しにくい」と思われている関数型プログラミング言語は、関数型プログラミング言語のなかでもさらに限られた、ごく一部の言語についての話です。 関数型言語としてよく知られるLispやOCamlは参照透明ではないので、従来の言語と遜色なく普通に書くことができます。
そして、関数型言語の一部には「副作用を書きにくい」と思われている言語は確かに存在します。ここでは「副作用を直接書くことが出来ない『純粋関数型言語』」の代表格であるHaskellがどのように副作用を記述するかについて説明していきます。
たとえば、標準入力から入力された文字列を、入力されるたびにすべて大文字にして出力するような処理を繰り返すプログラムを考えてみましょう。標準入力を読むのも標準出力に書きだすのもバリバリの副作用ですよね。副作用を直接記述できないとされている純粋関数型言語の筆頭格であるHaskellでは、このプログラムを次のように書くことができます。
import Data.Char
main = interact $ map toUpper
モジュールのインポートやmain =
という処理の本体でない部分を除けば、interact $ map toUpper
という実にたったの4トークンです1。ここまで短く書ける言語はなかなかないでしょう。副作用が書きにくいどころか、副作用を表現するときにも極端に簡潔なコードにこだわるのが関数型言語にしばしば見られる特徴です。これはHaskellの話ですが、副作用を含む処理をどう簡潔に書くか最初から想定しているため、このinteract
という関数が標準ライブラリに用意されています。「標準入力から文字列を入力して、加工して標準出力、それを繰り返す」という一連の構造がinteract
という関数一個に集約されているのです。もちろんそのような関数を定義することは他の言語でも可能ですが、それが標準ライブラリに入っているような言語はそう多くないでしょう。
「それはちょっとずるい、たまたまちょうどいい機能が標準ライブラリにあったから短く書けただけなんじゃないか?」と思う人もいるかもしれないので、疑り深い人のためにもう少しC/C++のような手続き型に似たコードも書いてみましょう。さきほどのプログラムは次のようにも書けます。
import Data.Char
import Control.Monad
import Control.Applicative
main = forM_ [0..] (\i -> do{
text <- getLine;
text <- pure (map toUpper text);
putStrLn text;
})
-
forM_
というのがfor文やforeach文に相当しています -
i
がループカウンタで、[0..]
のリストに入っている値が順番に渡されます -
getLine
で標準入力を読み取り、<-
でtext
に代入 -
map toUpper text
でtext
を大文字に変換し、<-
でtext
に再び代入(のつもり) -
putStrLn text
でtext
を出力
関数型言語では馬鹿馬鹿しいほど手続き的なコードですが、筆者にはこれが副作用を書きにくいようにはまったく見えません。Haskellでは変数は再代入不可能と聞いていたかもしれませんが、少なくとも上のコードではtext
に再代入しているように見えます(見えるだけですが。単なる隠蔽でない「本当のヒープ書き換え」も可能ですが、説明が増えるのでここでは省略します)。
当初はHaskellの「本当のヒープ書き換え」を説明すると長くなるので省略しようかと思いましたが、この記事はすでにクッソ長いですし、これ以上長くなっても大して変わらないと思いますので、ついでに説明してしまうことにします。整数型のヒープ領域を確保してその領域を初期化するようなコードをC++とHaskellで書くと、例えば次のような感じになります。
int *ref = new int(42);
ref <- newIORef 42
HaskellではnewIORef
という関数があり、この関数は引数で与えられた値で初期化されたヒープ領域への参照を返します。型推論があるとか、new演算子がないとか、関数適用に括弧が要らない、行末のセミコロンが要らないなどの言語仕様のお陰で、コードの字面の上でも既にHaskellのほうが簡潔です。また、この領域から値を取り出すには、次のようにします。
int value = *ref;
value <- readIORef ref
C/C++では*
演算子でポインタから値を読みますが、HaskellではreadIORef
という関数を呼びます。コードの見かけの上では、C++とHaskellの違いは演算子を使うか関数を使うかという違いくらいしかありません。また、この領域に書き込むのは次のような感じになります。
*ref = 100;
writeIORef ref 100
これも、C/C++では演算子、Haskellでは関数を使うというくらいの違いしかありません。また、これらの関数とは別に、本当にCのmalloc
/free
のようなような低レベルのメモリ操作関数も提供されています。あまり頻繁に使うような関数ではありませんが、筆者はOpenGLを叩くのにHaskellのmalloc
を使ったことがあります。Haskellでヒープ領域を確保して書き換えるという副作用を引き起こすのは、(少なくともコードの見かけの上では)別に面倒でも複雑でもないのです。
なお、Haskellは副作用のない「純粋関数型言語」「参照透明な言語」と言われていたわりに、writeIORef ref 100
やreadIORef ref
は思いっきり「副作用のあるコード」に見えるかもしれません。でもこの式は副作用のない「参照透明」な式なのに、このコードは副作用のあるプログラムを表しているのです。このあたりの、副作用あるの?ないの?という疑問は、おそらくどんなに表面的な説明やたとえ話をしても解消するのは難しいでしょう。理解するには例の「モナド」の仕組みを地道に学んでみるしかありません。
このように、Haskellは副作用を扱いにくいと誤解されることがありますが、Haskellでも手続き的な思考と手順をそのままコードに書き写すことができます。Cのような手続き型言語を知っていれば上のコードは簡単に読み解くことができるはずです。このような副作用のあるコードでは純粋関数型言語の旨味が発揮しにくくなるので、もし明示的な副作用のある操作を避けられるなら避けたほうがいいですが、Haskellでもそのような操作が必要とならどんどん使っても構いません。
上のコードに理解し難い要素があるとすれば、pure
という関数でしょう。Haskellでは副作用のある関数によって得られた値と、副作用のない関数によって得られた値とを区別します。副作用のない関数で得られた値を、副作用のある関数から得られた値のように変換するのがpure
という関数の役目です。値を区別するといっても難しいことではありませんし、他の言語でもこういう区別はいくらでもあります。たとえば、
-
10
という直接の値と、[10]
というリストに入った値は区別されるし、[]
で囲めばリストに入った値に変換できます - Rubyでは安全でない操作から得られた値は安全な値と区別され、
untaint
で汚染されていない値に変換できます - JavaScriptで非同期に得られたPromiseと直接の値は区別されますが、
resolve
で通常の値を非同期に得られたPromiseに変換できます - JavaでStreamに入った値と直接の値は区別されますが、
IntStream.of
で通常の値をストリームに入れることができます
こういった計算に共通点を見つけ出して抽象化したのがファンクタやアプリカティブ、モナドのような概念で、モナドの具体的な事例はPromiseなどとしてすでに理解され広く使われています。Promiseの概念が理解できるなら、モナドを理解するのは決して難しいことではありません。 JavaScriptのPromiseは理解できるけどモナドは難しくてわからん!というのは、「野球はわかる。サッカーもわかる。テニスもわかる。でも『球技』という概念は抽象的でさっぱりわからん!」と言っているようなものです。 抽象的で掴みどころが無いかもしれませんが、Promiseが理解できるなら、あせらずゆっくり理解していけば難しくはないはずです。
なぜ副作用がある処理を短く書けるのかというのもちゃんと明快な理由があって、Haskellのような言語では副作用のある処理の表現そのものを第一級のデータとして操作できるからです。それゆえforM_
やforever
、interact
のような副作用を制御する関数が豊富に存在し、自由に組み合わせて利用できるのです。
このように、純粋関数型言語で副作用のある処理を書くのは決して難しいわけでも面倒なわけでもありません。「参照透明な言語では副作用を直接記述できない」というような説明をされることがよくあり、その説明自体は間違いではないのですが、おそらくこの「直接書けない」というのを「書きにくい」ということだと誤解したものか、Haskellでは数学から生まれたモナドという怪物を飼いならさなくてはならないという脅迫めいた解説をされて、モナドの正体を知らないまま恐れおののいているだけなのではないかと思います。
- 純粋関数型言語では副作用のある式を書くことができません
- でも純粋関数型言語では副作用のあるプログラムをC/C++と同じくらい簡単に書けます
- 「式に副作用がないのに、副作用のあるプログラムを書ける」というのは一見矛盾しているように見えますが、それを実際に可能にしてしまったのが、純粋関数型言語という不思議なカテゴリの言語です
- モナドは醜悪な怪物などではなく、Promiseみたいな可愛いふわふわとした感じの何かです
「関数型言語は副作用が不得意なので、関数型のスタイルを使いつつも、副作用が多用される部分にはオブジェクト指向のスタイルを用いるのがよい」
先に述べたように、誤解です。十分な機能を持つ関数型言語なら副作用を記述するのも容易で、従来の手続き型言語やオブジェクト指向のスタイルは必要ありません。関数型言語はそれのみでプログラムを完結できますし、完結できるように設計されています。もっとも、関数型言語やそれ以外の言語との使い分けが不要だというわけではないし、いつでも関数型言語さえあれば十分というわけではありません。関数型言語で速度的な性能が要求を達成できない場合に、C++に処理を任せるという使い分けをすることは適切です。
「『純粋』関数型言語はプログラムに大域的な状態を持つことができない」
いわゆる「グローバル変数」がないのはそのとおりです。グローバル変数どころか、いわゆる「ローカル変数」すらありません。純粋関数型言語には、再代入可能な変数というものがないのです。ただし、それはプログラム全体で共有できる変更可能な状態というものがない、というわけではありません。
関数型プログラミング言語のなかでも特に「純粋関数型プログラミング言語」に分類される言語は「すべての式が純粋」という性質を持ち、すべてのデータ型が不変だったり、通常の言語のような代入文がなかったりします。そのために大域的な状態を持てないとしばしば勘違いされますが、ぜんぜん違います。普通に状態を扱います。純粋な式のみで状態変化を表現するのです。「一部で純粋性を破壊することで大域的な状態を持っているのだ」というのも違います。すべて完全に純粋なまま、状態変化を扱う方法が知られているのです。
このあたりはコロンブスの卵的な巧妙な方法で実現しています。その方法はこの記事の生半可な説明を読んだくらいではとうてい思いつくことではありませんので、地道にReader
モナドやIO
モナドについて学んでみてください。一切再代入をしない、副作用のある式はいっさい使わずに副作用を扱うとんでもないトリックを理解したとき、私は本当に感心しました。
「関数型言語を使うとほとんどのバグがなくなる」
いや……それは幾らなんでも無理でしょう……。HaskellやOCaml、Scalaなどの静的型付けの関数型プログラミング言語は、データのダウンキャストやnullになりうるポインタへのアクセスのような、実行時エラーの危険のある操作を型システムを利用して意図的に排除して安全性を確保しており、バグが少ない傾向にはあります。しかし、ほとんどなくなるというほどではありませんし、ましてやバグがゼロになるなどということはもちろんありません。また、動的な型付けの関数型言語もありますから、関数型プログラミング言語一般の傾向とは言いがたいと思います。
もしかしたら信じられない人もいるかもしれませんが、本当にこういう誤解を見かけたので、この節を設けてあります。関数型プログラミング言語は、もちろんそんな銀の弾丸ではありません。
「純粋関数型プログラミング言語では副作用を扱えない、あるいはとても効率が悪いので、ゲームは作れない」
そんなこと言われても、筆者は普通に純粋関数型プログラミング言語を使って3Dのゲームを作っていますけど……?
「純粋関数型プログラミング言語ではコンストラクタも純粋である」
これはそのとおりです。不思議なことに、純粋関数型プログラミング言語ではコンストラクタさえ純粋です。でもよく考えてみると、コンストラクタだって失敗する可能性はあるのではないでしょうか。たとえば、円を表すデータ型Circle
があったとしましょう。
data Shape = Circle { center :: Position, radius :: Number }
データ型Circle
は、位置をPosition
型、半径をNumber
型で持ちます。このようなデータ型の構造を定義すると、自動的に次のようなデータコンストラクタが使えるようになります。
Circle :: Position -> Number -> Shape
ここで、半径を表すプロパティradius
は必ず正の値をとらなくてはなりませんが、このデータコンストラクタの振る舞いをカスタマイズすることは一切できません。もし半径に負の引数が与えられたとしたら、不正な状態を持つShape
のデータが作成されてしまいますが、コンストラクタだけではそれを防ぐことができないのです。Javaのような言語では、コンストラクタの引数に負の値が渡ってきたら例外を投げるのが普通かもしれませんが、Haskellのような言語ではデータコンストラクタで例外を投げることは一切できません。
しかし、プログラムではなるべく不正な状態のデータを作るべきではありませんし、不正な状態のデータが作成されそうになったらなるべく早くそれを阻止するべきでしょう。じゃあ堅牢なデータ型を作るにはどうすればいいかというと、データを構築するための別の関数を定義し、もともとのデータコンストラクタはモジュール内でプライベートにします。
module Graphics (createCircle) where
data Shape = Circle Position Number
createCircle :: Position -> Number -> Maybe Shape
createCircle position radius | 0 < radius = Just (Circle position radius)
| otherwise = Nothing
このcreateCircle
のような関数をスマートコンストラクタなどと読んだりします。createCircle
はただの関数なので、Maybe
で失敗と成功を表すもよし、max 0.0 radius
にして値を丸めるもよし、throw
で例外を投げるもよしで、好みに合わせて自由に例外処理の方法を選ぶことができます。これで、モジュールの外部から不正なShape
データを作成することは一切できません。C++だとコンストラクタで例外を投げるとデストラクタが呼ばれないのでメモリが漏れるというような落とし穴がありますが、純粋関数型プログラミングでのデータコンストラクタは極めて単純明快で、それ故に予想外の振る舞いや変な落とし穴などはありません。
「純粋関数型プログラミング言語で作用を表現するにはモナドが必要である」
そんなことはありません。実際に、Elmや最初期のHaskellでは、作用を扱うためにIO
モナドではない別の方法を使っています。ただ、IO
モナドを使えば、純粋関数型プログラミング言語でありながら『普通の』プログラミング言語とさほど変わらない見た目で作用を記述でき、便利でわかりやすいので、HaskellやPureScriptのような言語ではIO
モナドによる作用の表現を採用しています。一方で、モナドをまともに扱おうとすると『型クラス』のような高度な型システムやdo
記法のような構文糖が必要になり、学習コストが増すため、Elmのようにモナドに頼らない方法で作用を扱おうとする言語もあります。
関数型言語と性能
「関数型言語で書かれたコードは並列化しやすい」
いろいろ誤解が混ざっています。並列化云々の話は関数型一般の性質ではなくて、参照透明な言語、純粋関数型言語についての話です。また、遅延評価と混同しているケースもよくあるようですが、遅延評価とは直接は関係ありません。誤解のないように正確に言い直すと、「参照透明な言語は並列化可能なポイントを簡単に見分けることができる」ということです。これを説明するには、まず「参照透明性」とは何かという話から始めなくてはなりません。
関数型言語では関数に参照透明性という性質を付加されることがあります。参照透明性もまたある種の制約なのですが、参照透明な関数とは、
- 同じ入力に対しては同じ出力を返す
- 関数の呼び出しの前後で、その関数が引き起こした観測できる変化(副作用)がない
というような性質をもつ関数のことです。JavaScriptで言えば、(x,y)=>x+y
やMath.max
は参照透明な関数といえます。一方で、Date.now
やMath.random
は呼び出すごとに異なる値を返す可能性があるので参照透明ではありません。console.log
は返り値はいつもundefined
ですが、呼び出したあとでコンソールに明らかな変化が生じるので参照透明でない関数だといえます。「副作用のない関数」と「参照透明な関数」は厳密には異なります。Date.now
はその呼び出しによって世界の時間が進んでいるわけではないので、Date.now
自体には副作用はありませんが、この関数は呼び出すタイミングで値が異なってしまうので参照透明だとはいえません。でも、参照透明性というのは従来の手続き型言語には登場しない概念で馴染みがないひとも多いでしょうし、わかりにくければ参照透明な関数とは副作用のない関数であると考えてしまっても、さしあたっては問題ありません。
Haskellでは、このconsole.log
のような参照透明でない関数はいっさい存在しません(というのは厳密には間違いで、unsafePerformIO
という抜け穴のような関数が存在しており、これは一部の偉大な魔術師のみにだけ使用が許されたHaskell最大の禁忌となっています)。参照透明な関数しか許さない言語は、純粋関数型言語と呼ばれています。
「制約を加える」というとなんだか関数の表現力が低下してしまいそうですが、実は参照透明な関数にはさまざまな便利な性質があり、そのひとつに並列に処理が可能なのかどうか一発で見分けがつくという性質があります。それゆえ、並列処理するようにコンパイラが最適化することが簡単になるということです。
このことについて、「関数型言語で書かれたコードは並列化しやすいというけれど、関数f
とf
の結果に依存する関数g
は並列化できんだろ」という疑問が示されたのを見たことがありますが、関数型言語が主張しているのはそういうことではないのです。まずもう一度確認すると、並列化しやすいと言っているのは参照透明な言語のことです。先に述べたとおり、「関数型言語」とは特定の言語仕様を持った言語のことではありませんから、関数型言語だからといって参照透明だとは限りません。あくまで参照透明な言語についての話です。さて、次のJavaScriptっぽい言語のコードにおいて、一般のf
とg
について、f
とg
の実行を並列化することはできません。
console.log(g(f()));
しかし、f
とg
の具体的な中身がわかれば並列化できる可能性はあります。たとえば、それぞれ独立しているが、完了するのに1分かかる関数superLargeHeavyCalculationF
とsuperLargeHeavyCalculationG
があり、f
とg
が次のような関数であったとしましょう。この処理を素直に実行すると合わせて2分かかるでしょう。
function f(){
return superLargeHeavyCalculationF();
}
function g(x){
var y = superLargeHeavyCalculationG();
return x + y;
}
このとき、superLargeHeavyCalculationG
の計算にsuperLargeHeavyCalculationF
の結果が必要なわけではありませんから、superLargeHeavyCalculationF
とsuperLargeHeavyCalculationG
を並列に実行し、両者の計算を待ってx + y
を計算するように変更すれば、処理は1分で完了します。そういう意味で、f
とg
の中身がわかれば並列化することができます。でもこれはコンパイラの最適化の問題であって、参照透明性とははまた別のお話です。
また、f
とg
の中身が次のようになっている場合はどうでしょうか。
function f(){
return superLargeHeavyCalculationF();
}
function g(x){
var y = superLargeHeavyCalculationG();
return y;
}
この場合、g
は渡されたf()
の結果x
をまったく使用していません。この場合はsuperLargeHeavyCalculationF
とsuperLargeHeavyCalculationG
を並列処理する必要どころか、そもそもsuperLargeHeavyCalculationF
を実行する必要すらありません。遅延評価というものがある言語では具体的な値が必要になる時までsuperLargeHeavyCalculationF
の計算が遅延されるので、superLargeHeavyCalculationF
の呼び出しは行われず、処理は1分で終わります。コンパイラの最適化がすごいからsuperLargeHeavyCalculationF
の呼び出しが回避されるのではなくて、superLargeHeavyCalculationF
が呼び出されないのが通常の動作です。これが遅延評価の力なのですが、これも参照透明性とはまた別のお話です。
そして、一般の f
やg
については、これらの関数の中身がわからない以上はどうしようもありません。f
とg
が参照透明かどうかにかかわらずこの場合は並列化は不可能で、「参照透明な言語は並列化しやすい」と主張する人も、このようなコードを並列化できると主張しているわけではありません。問題は次のようなタイプのコードです。
var t = f();
var s = g();
console.log(t + s);
このJavaScriptっぽいコードにおいて、g()
はf()
の結果に依存していないように見えますが、f
とg
が参照透明でないかもしれないならg()
がf()
に依存していないとは言い切れないので、f()
とg()
を並列に実行することはできません。しかし、**もしf
やg
が参照透明な関数なら、f
やg
がどんな関数であるかにかかわらず並列に実行することができます。**これが参照透明性の力なのです。また、次のようなコードであるとき、f
とg
が参照透明であれば、f
とg
がどんな関数であるかにかかわらず、var t = f();
の式をプログラムから取り除いても構いません。
var t = f();
var s = g();
console.log(s);
参照透明な言語なら、f
やg
が非常に複雑でも、f
やg
がどんな関数なのかまったくわからなくても、並列できる処理を探しだして並列化したり、不要な処理を見つけ出して取り除いたりできるのです。見方を変えると、参照透明な言語ではコードに処理ごとの依存関係が陽に現れると考えてもいいでしょう。g()
はf()
の結果であるt
を受け取ってはいません。関数すべてが参照透明な言語なら、このコードを見るだけでg()
はf()
の結果に依存していないと判断してよく、したがって並列化したり不要だと判断できれば除去することすら可能なのです。
「いくら並列化できるといっても、g
がf
の結果に依存しないように、慎重にアルゴリズムを選択しなければならないのではならないのではないか?」という疑問もあるようです。確かに、先ほどのコードを書こうとしてうっかり間違えて次のようなコードを書くと、あっさりと並列化不可能なコードになってしまいます。
var t = f();
var s = g(t); // !?
console.log(t + s);
でも、関数を呼び出すときに引数を間違えないというのはコーディングではあたりまえで、普段以上の特別な慎重さが必要だとは思えません。g
はt
を必要としないのですから、べつに慎重に書かなくても自然に書けば自然に並列化可能なコードになります。それに、静的型付けの言語であれば、型チェックのお陰でg
にt
をうっかり渡すことすら難しいでしょう。普通に正しく書けば並列化できるはずなのに、自分がうっかり間違えて並列化できないように書いてしまったコードが並列化できないからといって、「なんでも並列化できるっていったじゃん!嘘じゃねえか!」みたいに言われても困ってしまいます。間違って依存関係が書かれたコードまで並列化できるとは誰も主張してないです。静的型付けの言語を使えばそもそもそんな心配もほとんどなくなりますが。
他にも、純粋関数型プログラミング言語のデータ型はすべて不変なので、データが複数のスレッドから参照されても安全だというメリットもあります。もっとも、これは通常の言語でもデータ型を不変にすれば同じようなことが出来ないわけではありません。ただ、純粋な言語では特別な工夫を凝らさなくてもすべてのデータがスレッド間で簡単に共有できるようになるというわけです。
もっとも、並列処理に向いたアルゴリズムと並列処理に向かないアルゴリズムというのはあるわけで、並列処理に向かないアルゴリズムが参照透明な言語で書くと勝手に並列処理に向いたアルゴリズムになるわけではありません。参照透明な言語なら並列処理できるコードと並列処理できないコードが簡単に区別できる、というように理解するのがよいのではないかと思います。
なお、並列化しやすくなると言っても、本当にそのコードを並列化するかどうかは処理系次第です。あまりにも小さいコード片では処理をフォークしたり結果を統合するコストのほうが大きくなってしまい、並列化のメリットがなくなってしまいます。参照透明な言語で書けばなんでも並列化できるわけではなく、現実的にはどこを並列化するかは明示的な指示が必要になると思います。そうだとしても参照透明な言語は並列化可能なポイントを一発で見分けることができるので、コード中に手作業で並列処理することを明示するとしても、参照透明でない言語よりずっと簡単になります。実用的には、並列化しやすくなるということより、コーディング上で処理ごとの依存関係をコードから視認しやすくなるというメリットのほうが大きいのではないかと思います。
- 参照透明な言語では、並列処理可能なポイントや、省略可能なポイントを簡単に見分けることができます
- 参照透明な言語で書くと、並列処理に向かないアルゴリズムが勝手に並列処理に向いたアルゴリズムになるわけではありません
- 参照透明な言語でも本当にコンパイラが並列処理しはじめるわけではありません
「関数型言語は遅い」
ベンチマークはひとつの側面を計測したものに過ぎないので過信は禁物ですが、実体験や印象で語るよりはずっと信頼に足るでしょう。Computer Language Benchmarks Gameで、多体問題に関するプログラムのベンチマークからメジャーな言語の成績を抜粋してみます。
以下の表は各言語(というか実行環境)の高速な順に並んでおり、一番左の数値はもっとも高速な言語の実行の何倍の時間がかかったかを表しています。このベンチマークではIntelのFortranがもっとも高速で、g++がその約1.1倍の時間で実行を完了しているということです。
× | Program Source Code | CPU secs | Elapsed secs | Memory KB | Code Bytes | ≈ CPU Load |
---|---|---|---|---|---|---|
1.0 | Fortran Intel #5 | 8.57 | 8.57 | 260 | 1659 | 0% 0% 0% 100% |
1.1 | C++ g++ #8 | 9.08 | 9.08 | 336 | 1544 | 0% 0% 1% 100% |
1.2 | C gcc #4 | 9.91 | 9.92 | 336 | 1490 | 0% 1% 1% 100% |
2.6 | C# Mono #3 | 22.04 | 22.04 | 19,412 | 1305 | 0% 1% 1% 100% |
2.8 | ●Scala | 23.84 | 23.85 | 25,076 | 1358 | 1% 1% 0% 100% |
2.9 | Java #2 | 24.50 | 24.52 | 19,636 | 1424 | 1% 0% 1% 100% |
3.0 | ●F# Mono | 25.66 | 25.67 | 27,168 | 1259 | 0% 1% 1% 100% |
3.0 | ●Haskell GHC #2 | 26.07 | 26.08 | 2,212 | 1874 | 1% 0% 0% 100% |
4.5 | ●Lisp SBCL #2 | 38.24 | 38.25 | 8,008 | 1398 | 1% 0% 0% 100% |
4.9 | JavaScript V8 #2 | 42.02 | 42.03 | 7,576 | 1527 | 1% 1% 0% 100% |
5.8 | ●OCaml | 49.91 | 49.93 | 596 | 1239 | 0% 0% 0% 100% |
25 | ●Erlang HiPE #3 | 217.95 | 218.02 | 10,460 | 1399 | 1% 0% 0% 100% |
46 | Ruby JRuby #2 | 6 min | 6 min | 713,580 | 1137 | 0% 1% 1% 100% |
90 | PHP #3 | 12 min | 12 min | 2,588 | 1082 | 0% 0% 0% 100% |
117 | Python 3 | 16 min | 16 min | 4,252 | 1181 | 1% 0% 0% 100% |
133 | Perl #2 | 18 min | 18 min | 1,992 | 1401 | 0% 0% 0% 100% |
関数型言語とみなされることのある言語には●をつけています。ただし、Scalaは関数型のパラダイムを含むマルチパラダイムであり、Erlangを関数型とするかはかなり微妙なセンです(訂正)Erlangは関数型言語とみなしていいみたいです。ごめんねアーラン……。(追記)なお、なんでCRubyじゃなくてJRubyなの?というのはCRubyが遅すぎて計測不能だったからです。詳しくは上のリンクをどうぞ。
このベンチマークの結果から推測するに、
- FORTRAN/C/C++あたりが最速
- C#/F#/Java/Scala/HaskellがC/C++の3倍程度
- Lisp/JavaScript/OCamlあたりが5倍程度
- Ruby/PHP/Pythonが数十倍から100倍程度
とおおまかに考えられそうです。関数型言語のプログラムの実行は基本的に高価でC/C++のような言語には原理上叶いませんが、HaskellやOCamlに関してはJava/C#のようなポピュラーな言語と同等か少し遅い程度ですし、Ruby/Python/PHPの性能ははるかに上回りますから、汎用の言語として使って問題ない速度だと思います。Haskellに関してはデータ型が変更不可能にもかかわらず、メモリの消費量がC#/Java/JavaScript/Ruby/Pythonなどを圧倒していることも注目に値します。 (訂正)今ソースコード覗いたらpokeElemOff
しまくりでした。pokeElemOff
は生のポインタで生のメモリ領域に書き込む身も蓋もない関数で、これでデータ型が変更不可能と主張するのはかなりズルいので訂正します。 OCamlのメモリ消費量に至ってはC/C++に迫る勢いです。
遅延評価がある言語では何気なく書いたコードが異様に速いということがしばしばあります。遅延評価だと妙に早い例としてよく知られているのは竹内関数で、定義をそのまま書き写すような愚直なコードでも計算量が抑えられるケースが遅延評価にはたまにあります。一方で関数型言語には、性能のポテンシャルというよりパフォーマンスチューニングの難しさという問題があります。とくに遅延評価のある言語は式の評価の順序がコードの順序とまったく一致しないので、評価の過程を追ってパフォーマンス低下の原因を探るのがとても難しくなります。
そのため、「チューニングしなくてもそこそこ速いが、チューニングするのは恐ろしく難しい」というのが筆者の印象です。もしかしたら、筆者が知らないだけで遅延評価の関数型言語のチューニングをやすやすと遂げる優秀な開発者も数多くいるのかもしれません。
余談ですが、このベンチマークだと動的型付けのスクリプト言語であるにもかかわらずJavaScriptが驚異的に早いですね。やはりJavaScriptは末恐ろしい言語です。JavaScriptは、というかV8が、かもしれませんが。
- 効率の面において、関数型言語はC++にはまったく歯が立ちません。C/C++は最速です
- C++には叶いませんが、JavaやC#、JavaScriptなどのメジャーな言語と同程度か、それより少し遅いくらいのパフォーマンスが出ます
- Ruby/Python/PHPなどのスクリプト言語をはるかに上回ります。RubyやPythonな人から「関数型言語は遅い」などと言われる筋合いはありません。RubyやPythonも実用的なのですから、問題は用途です
- 筆者は遅延評価のある言語のパフォーマンスチューニングがさっぱりできません
「不変のデータ型はメモリを消費しすぎるし、遅すぎて実用にならない」
先に述べた通り、C/C++には及ばずともJavaやC#に並ぶくらいの速度は出ますし、Python/Ruby/PHPなどのスクリプト言語の類は圧倒しています。メモリの消費量も問題なさそうです。データ型が不変だとオブジェクトを使いまわさず新たなオブジェクトを作りまくるのでメモリ消費が激しいというイメージがありますが、それはまったく杞憂に過ぎないことがわかります(訂正)回避方法はいくらでもあることがわかります。速度が変わらないのなら目標の性能が達成できているなら不変のデータ型の安全性とわかりやすさを取るべきです。
「関数型言語は数値計算に強い」
どうみても弱いです。現代のコンピュータでは計算が高価になりがちな抽象度の高い表現を使っているので、どうやっても低レベルな表現に向いたC/C++/Fortranよりも遅いからです。ガチな数値計算ではどうしても効率が問題になりますから、C/C++やアセンブリ言語のような低レベルな言語、あるいはCUDAのようなハードウェアの性能を最大限引き出すための特殊な環境こそが最適と言わざるをえません。もちろん、いくら弱いといってもPythonやRubyやPHPなどの非常に遅い言語よりははるかに早いですから、効率を度外視してRubyやPythonを使うことを考えるなら関数型言語も当然選択肢に入るでしょう。
「Haskellのような関数型言語はチューニングが難しい」
先に述べたとおり、筆者はHaskellのような言語のパフォーマンスチューニングはよくわからないのですが、関数型の言語は比較的高レベルな記述が多く、本質的にチューニングが難しい部分はあるのではないかと思っています。ですからパフォーマンスを追い求める必要があるのなら、C++やGo、Rustのような言語を選んだほうがずっと楽だと思いますし、もしRubyやPythonのような遅い言語が選択肢に入るような場面であれば、HaskellやOcamlのような言語がパフォーマンスを理由に選択肢から外れることはないでしょう。
オブジェクト指向言語との関係性について
「関数型プログラミングはスタイルに過ぎないから、HaskellやOcamlから得たノウハウをJavaやC#で活かすとよい」
残念ながら、JavaやC#で関数型プログラミングをまともに行うのは非常に難しいです。関数型プログラミングの一部はそのような言語でも再現可能ですが、最初から関数型として設計されていないプログラミング言語では、関数型プログラミングを使いやすくするための言語仕様もライブラリもあまりに不足しています。おそらく関数型プログラミング本来のポテンシャルの1割も発揮できないでしょう。関数型プログラミング言語で培った知識をそうでない言語で活かせるなど、あまり期待しないほうがいいと思います。
関数型プログラミング言語では、null
に相当するものとしてMaybe
型がよく使われます。Maybe
型はnull
と違って条件分岐を強制することができ、返り値がない場合も安全に処理することができます。Javaでお馴染みのNullPointerException
のようなものは、それが起きないことをコンパイル時に保証できるのです。Java8にもMaybe
と同様の機能を持ったOptional
型が導入されましたが、もちろんnull
がすべてOptional
に置き換わったわけでもなんでもなく、依然として危険なnull
は使い続けなくてはなりません。Optional
を使おうとしても、null
とOptional
を互いに変換する手間が増えます。そして、言語仕様は複雑さを増すばかりです。このような基礎的なデータ型はあとから導入しても手遅れなのです。
- 関数の純粋性 → これを本当に実施できるのは、HaskellやElm/PureScriptといったいわゆる『純粋関数型』と言われる言語だけです。『純粋関数型』では関数が自動的に純粋になりますから、任意の関数が純粋であることを保証できます。それ以外の言語では、もちろん純粋な関数とそうでない関数があり、純粋な関数を定義することはできますが、その関数が純粋になっていることを確認する簡単な方法はありません。つまり『純粋な関数によるプログラミング』は現実的には実施できません。
- 不変データ型や不変な変数 → HaskellやPureScriptのような言語ではデータや変数はすべて不変であり、それらの言語に慣れればデータは不変でも構わないことがわかるでしょうが、将来的にそれ以外の言語でデータや変数がデフォルトで不変になるなどという言語仕様の改良があるとは思えません。デフォルトでデータを不変にできるのは、最初からデータが不変な言語だけです。
- モナド → モナドはあらゆる『作用』の根幹を抽象するもので、後付けでモナドを用意してもほとんど意味はありません。後付けでモナドを導入しても、モナドを使わない既存の方法と、モナドを使う方法が混在して複雑になるだけです。なにもわかりやすくなどなりません。
-
Optionalのようなデータ型 →
Optional
型は型安全かつ便利にnull
を代替できます。もしnull
を一切廃止してOptional
に切り替えることができれば便利ですが、もちろん既存のコードの互換性を捨てることはできないので無理です。JavaにOptional
型を導入しても、既存のAPIがOptional
を使うように統一されるわけではありません。関数型プログラミングを行うには、言語ごとを乗り換えるのが現実的な唯一の方法です。参考: Optionalは最も優れた型である -
map
/reduce
のような高階関数 → 既存の言語に無理なく導入することができ、for文などに対する優れた代替手段になります! 良かった! 関数型プログラミングを学んで得たものすべてが、既存の言語を使うときに役に立たないというわけではありません! 関数型プログラミングという巨大なパラダイムのごく一部に過ぎませんが、ほんの一部なら関数型プログラミングで得た知識がそれ以外の言語でも役に立ちます。まあ、その場合はfor文とreduce
関数の両方を学ぶ必要がありますが。ちなみにHaskellならfor文やforeach文、for-of文のような個別の構文を学ぶ必要はないのでもっと簡単です。
既存の言語にしがみつくのはやめて、関数型プログラミング言語を使いましょう。それができないなら、関数型プログラミングを学んでも覚えることが増えて大変なだけで、あんまり意味はないと思います。
「関数型言語もいずれオブジェクト指向などのスタイルを取り入れたマルチパラダイムな言語になる」
関数型言語にとって、「マルチパラダイム」というのはまったくのナンセンスです。先ほど言ったとおり、関数型言語は関数というたったひとつの武器だけをひたすら研ぎ澄まして問題に立ち向かおうとする言語であり、それ以外の武器はかえって邪魔になります。OCamlなんかはCamlという言語にオブジェクト指向的な機能を付け加えたObjective Camlが改名されたものだそうですが、OCamlが積極的にオブジェクト指向で設計されているようには見えないです。Haskellに至ってはオブジェクト指向を取り入れる動きはほとんど皆無ですし、そうする必要がないです。これは筆者がHaskellやPureScriptに触れた経験からの結論でもありますし、O'Haskellといってオブジェクト指向の概念を取り入れたHaskellが提案されたこともあったようですが、現在O'Haskellが使われているという話は聞いたことがありません。最近では関数型以外の言語が関数型のスタイルを取り入れる動きがしばしば見られる一方で、関数型のほうから例えばオブジェクト指向を取り入れる動きはあまり積極的ではないようです。
しかし近年ではやはりScalaがオブジェクト指向と関数型の融合を目指したものとして台等しています。ただ、"Better Java"として使っているだけであくまでオブジェクト指向プログラミングの延長として考えているユーザと、Scalazのように関数型プログラミングを急進的に取り入れている勢力("Scalaz people"?)とで、少なからず意見の分断が起きているようです。もちろんどちらのスタイルでもソフトウェアはちゃんと作れます。つまり機能が完全に重複しているんだと思います。
Common Lisp Object Systemのような試みがないわけではありませんが、たぶんですがあまり使われていません(筆者はよく知りません)【(訂正)ここを書く前に適当に覗いたalexandriaとかUSOCKETのようなライブラリがたまたまオブジェクト指向してなかっただけで、cl-gtk2やHunchentootなんかはちゃんとオブジェクト指向してました。ちゃんと調べずに適当なこと書いてしまい申し訳ありません。訂正します。】(追記)筆者は関数型言語を「関数型言語は関数というたったひとつの武器だけをひたすら研ぎ澄まして問題に立ち向かおうとする言語」というように表現しましたが、Common Lispはその説明に当てはまらない言語であり、Common Lispに関して筆者のこの説明は失敗しています。こういった言語もあるので**「関数型言語とは何か」をひとことで言い表すのは本当に難しい**のですが、他に何か関数型言語を言い表す説明を思いついたらぜひとも筆者にも教えて下さい。
「関数型言語とオブジェクト指向は相性が悪い」
オブジェクト指向言語が関数型言語のエッセンスを取り入れることは問題なく、現にmap
/reduce
のような関数やunderscore.jsやLo Dashのようなリストライブラリだったり、lazy.jsのような遅延リストライブラリだったりといった形でオブジェクト指向にも関数型のスタイルが取り入れられるようになってきています。それに対し、関数型言語にオブジェクト指向の要素が持ち込まれることはほとんどありません。はっきりいって、関数型言語にとってオブジェクト指向言語はまったくの無用の長物で、むしろ言語仕様が無為に複雑化したりするような問題すら生じます。関数型言語とオブジェクト指向言語の両者はまったく別々の発想から出発したといえますし、もちろんお互いを否定するために考案されたわけではありません。しかし、関数型のパラダイムはオブジェクト指向を拒絶するという意味で、関数型プログラミングとオブジェクト指向は相容れない存在であると考えざるをえません。
問題をはっきりさせるために、具体的にオブジェクト指向が関数型プログラミングを阻害するという具体的な例を挙げてみましょう。たとえば、配列xs
の要素をmap
を利用して出力したいとします。このとき、直感的にはxs.map(console.log)
ように書けそうですが、実際にはそのコードは正常に動きません。
xs.map(console.log.bind(console)); // OK
xs.map(x=>console.log(x)); // OK
xs.map(console.log); // Illegal Invocation!
xs.map(console.log)
では、console.log
には暗黙の引数であるthis
にconsole
が渡されないからです。これを解決するにはFunction.prototype.bind
のような機能を使うか、関数リテラルで包んでconsole.log
を呼びなおす必要があります。これはオブジェクト指向が関数にthisという暗黙の変数を導入してしまったせいで起きたトラブルで、もしconsole.log
が、というか、JavaScriptが関数型プログラミングのコンセプトで設計されていたら、こんな残念なことにはならなかったでしょう。JavaScriptのオブジェクト指向システムは高階関数プログラミングに耐えられるようには設計されていないのです。
(追記)確かにこれはJavaScript固有の問題ではありますから、オブジェクト指向一般の問題例としてはあまり適切ではなさそうですね。ほとんどすべてのオブジェクト指向言語に共通する問題もあるのですが、そっちを取り上げるべきかどうか検討しておきます。 もっといい説明がありました。JavaScriptのmap/parseInt問題の解説 こちらで説明されているとおりJavaScriptの関数には、高階関数を多用することが前提のHaskellのような言語とは明らかに方針の異なった設計がまま見受けられます。こういったコーディングは不可能ではないものの、それが自然に可能であるようには言語が設計されていない、ということです。
関数型言語でオブジェクト指向の機能が使えないことは何ら問題ないのです。こう言っても信じられないかもしれませんが、クロージャ付きの第一級関数はあまりに強力なので、関数型言語ではオブジェクト指向の機能はまったく必要になりません。無いよりはあったほうが便利、ということは、関数型というパラダイムに限っては当てはまらないのです。
基本的に、オブジェクト指向言語がreduce
やmap
のような関数型言語らしい要素を導入してもオブジェクト指向のコンセプトが破壊されることはなく、得るものはあっても失うものはまずありません。それゆえ、オブジェクト指向から見れば関数型プログラミングはオブジェクト指向と共存可能なものに見えるのかもしれません。しかし関数型プログラミングにとって、オブジェクト指向の要素は必要ないどころか関数の概念で統一された関数型プログラミングの一貫性と簡潔さをぶち壊してしまうものなのです。オブジェクト指向言語と関数型言語の対立とは、正確には関数型言語陣営がほぼ一方的にオブジェクト指向を疎んじているだけだったりします筆者がほぼ一方的にオブジェクト指向を疎んじているだけだったりします。
オブジェクト指向言語が関数型プログラミングのエッセンスを取り込むことはある程度可能ではありますが、オブジェクト指向の土台の上で実現可能な「関数型プログラミング」というものはつまるところ関数型プログラミングの出来損ないに過ぎず、真の関数型プログラミングを体験したことのある人間筆者個人にとっては決して満足いくものではありません。そして関数型プログラミング筆者個人にとって腹立たしいのは、オブジェクト指向プログラミングがあまりに蔓延しており、さまざまなものがオブジェクト指向に基いて設計されているせいで、それらの機能を逐一関数でラップしなければならなかったり、関数型プログラミングの普及がなかなか進まななかったりすることです。HTMLのDOMに然り、.NET Frameworkのクラスライブラリに然り。関数型言語のユーザ筆者個人にとって、オブジェクト指向はプログラミングの世界を歪めている元凶そのものなのです。
もちろんオブジェクト指向の存在が完全に否定されるというわけではありません。現代ではオブジェクト指向を使う理由があまりなくなってきているとしても、かつてはオブジェクト指向が必要だった時代があったのは確かだし、関数型言語が向いていない分野というものも確実に存在するのです。その意味では、現代ではC++がオブジェクト指向を適切な目的で採用しているほとんど唯一といっていい言語だと思います。ひとことで言ってしまえば、関数型プログラミングの実行コストは高価になりがちなので唯一無二の最速最軽量言語であるC/C++のコンセプトとは相容れず、C++が実行効率に優れるオブジェクト指向を採用するのは合理的な選択だということです。C++とその活躍の場は今後もずっと失われることはないだろうし、それゆえオブジェクト指向もまた消えたりはしないのです。このあたりのことについて詳しく説明するには、オブジェクト指向とは何だったのかということについて最初から説明しなければならず、たぶんこの記事の長さがこの3倍になってしまうのでここでは割愛します。
(**なお、ここで大量の訂正が入っている通り、これは筆者個人の見解に過ぎません。その他の関数型プログラミング言語のユーザは、オブジェクト指向を否定しているなんてことはまったくありません。**関数型言語のユーザが関数型言語を好んで使いながら何故関数型言語の優位性を認めないのかはよくわかりませんが(実はよくわかりますが)、関数型言語のユーザはオブジェクト指向を否定などしていないし、オブジェクト指向の否定を吐露しているのは筆者個人です。)
- C++が必要な領域は存在します。C++は多分永久に不滅です
- C++が必要でない大半の領域において、関数型言語はもっとも単純で扱いやすい解決策です
「関数型は論理や推論などを表すのに向いていて、オブジェクト指向は現実の現象を表すのに向いている」
少なくとも筆者は特にそのような向き不向きは感じませんでした。どうも、オブジェクト指向の一部の界隈に広まっている「オブジェクト指向では現実の物体をクラスとして表現する」というような誤った風説が、このような誤解を引き起こしているように見えます。このあたりはオブジェクト指向の話になるのでここでは深入りは避けますが、このような関数型言語とオブジェクト指向言語に使い分けがあるというような言説には注意したほうがいいと思います。他の節で述べている通り、オブジェクト指向の言語の多くが汎用の言語として設計されているのと同じように、関数型言語の多くもまた汎用の言語として設計されており、必ずしも何らかの分野に特化しているというわけではありません。
「関数型言語もオブジェクト指向言語も、状況と目的によって使い分けるのが大切だ」
オブジェクト指向プログラミングと関数型プログラミングの対立について、「オブジェクト指向も関数型も、目的に合わせて使い分ければいいじゃん」というコメントをしばしば目にします。それ自体はそのとおりでまったくもって正しく、おそらくどの立場の人もほぼ全員がすぐに同意するとは思うのですが、「使いわけよう」で全会一致したところでそれ以上何か進歩があるとは思えません。問題は「使いわけよう」ではなく、「 どの場面でどれを使うべきか 」であって、こういう具体的な問題提起を「使いわけよう」という具体性のない結論で受け流してしまうのは、あまり有益な態度とはいえません。我々がどうしても検討して答えを導かなければならないのは、オブジェクト指向言語が最も有用なのはどの場面で、関数型プログラミング言語が最も有用なのはどの場面か、そしてそれはなぜなのか、ということです。もちろん、関数型言語のユーザとオブジェクト指向のユーザが本当にリアルファイトを始めたら、誰かがそういう毒にも薬にもならない言葉でひとまず場を収めるのが日本人的な和の心というやつでしょうけれど。
主要なオブジェクト指向言語や、主要な関数型言語は、いずれも汎用の言語として設計されています。それはつまり、アプローチは異なれど、道具の目的はおもいっきり重複しているということです。それは、オブジェクト指向言語でも関数型言語でも、どちらを使っても同じ機能のソフトウェアを書くことができるのからも明らかです。オブジェクト指向言語でないと作れないソフトウェアや、関数型言語でないと作れないソフトウェアはありません。
たとえば、スクリュードライバーとハンマーはまったく目的の重ならない道具です。両方ともが必要であり、両方共を用意するにはコストもかかるし持ち歩きでかさばるとしても、両方をちゃんと用意して使い分ける必要があります。
その一方で蒸気機関車と電車は、両者の操作方法や性能はまったく異なりますが、目的の重なる道具です。どちらかひとつがあればもうひとつは不要です。両方を用意することは、余計なコストを産みます。そして、オブジェクト指向言語と関数型言語もまた目的の重なる道具なのです。ほとんどの場合、これらを同時に用意して使い分ける意味ははありません。
『何事も使い分けが大切だ』などという聞き心地のいい言葉に騙されないようにしましょう。多くの道具を使いこなすにはそれだけ多くのコストが掛かりますから、使う道具はなるべく少ないほうがいいです。目的の達成に必要な道具だけを慎重に抜き出し、余計な道具は手放しましょう。
筆者はこの記事で『大半の目的は関数型言語で達成できる』というように主張していますが、道具を目的に合わせて選択し使い分けたら、結果的に関数型言語でほとんどの用が済んでしまったということです。決して関数型言語に妙なこだわりがあるとか、関数型言語が向かない分野に無理やり使ってみたというわけではありません。現代で蒸気機関車がほとんど走っていないのは、電車と蒸気機関車の使い分けを怠ったとか、誰かが電車に変なこだわりを見せた結果ではなく、使い分けたら電車のほうが便利な場面が圧倒的に多かっただけであるのと同じです。
ちなみに、筆者が仕事で書いているコードの半分は、いわゆる『純粋関数型プログラミング言語』で書かれたものです。残りの半分がなぜ純粋関数型言語で書けていないかというと、ウェブブラウザというプラットフォームがJavaScriptという純粋でない言語が基盤となっているために、それらをつなぎ合わせるために仕方なくJavaScript/TypeScriptを使っているためと、世界でもっとも普及したウェブサイトの基盤であるWordPressを使うためにPHPで書いているからです。純粋関数型言語の機能そのものに不都合があって適用範囲が狭まってしまったわけではなく、純粋でない仕組みを基盤としてソフトウェア開発業界全体が構築されているためです。
- それぞれの言語がどの場面でどう有用なのかはちゃんと比較できます。『使いわけよう』で比較を避け結論をごまかすのではなく、比較できるものは比較し、どの目的でどれを使うべきか明らかにしましょう
「オブジェクト指向と関数型の住み分けは可能。関数は関数だしオブジェクトはオブジェクトでいい」
それはこれまで世界を思うままに支配してきたオブジェクト指向側の勝者の余裕であって、これまで虐げられてきた関数型の側から見れば、オブジェクト指向によって歪められてきたこの世界を正すには悪の元凶であるオブジェクト指向を粛清するほかないのです。これほどオブジェクト指向が蔓延してしまったら、もはや生ぬるい方法では革命を達成することなどできません。関数型言語が描く理想の世界を実現するには、(C++を除いて)オブジェクト指向を根絶やしにするしかありません。というのが関数型言語の ユーザ 一部過激派組織がしばしば抱く妄想です。(訂正)まるで関数型言語ユーザ全員が過激派テロリストだと言っているように受け取られるおそれがあるようなので訂正します。 (訂正ここまで) (更に訂正) この辺りはいわゆる「宗教論争」のような馬鹿げた議論のことであって、筆者もこの部分はあんまりまじめに書いてないのですが、「関数型のユーザは攻撃的だ」というように受け取る人がいるようです。関数型のユーザの中にも、そういう対立の構図を嫌がる人がいるので、関数型のユーザはオブジェクト指向に批判的だと思わないようにしてください。 (/更に訂正)
「関数型とオブジェクト指向は対立しているのではなくて、関数型が一方的にオブジェクト指向を攻撃している」
本当です。オブジェクト指向は関数型のパラダイムを取り入れるなど寛容な態度を見せていますが、関数型にしてみればオブジェクト指向は存在自体が間違っているのです。なぜならオブジェクト指向は不必要であるわりに複雑で、「オブジェクトが互いにメッセージを交換しながら協調して処理を行う」「動物クラスを継承する犬クラスと猫クラス、というようにクラスで階層構造を作る」というイメージのしやすさから人々にプログラミングをしやすいという幻想を抱かせ、「オブジェクト指向らしい設計」という何の具体性もない方針を強制し無為に時間を浪費させてきた邪教であり、その盲信から人々を解き放つべくオブジェクト指向という信仰を否定し攻撃しており、要するに、お願いですからHTMLのDOMとかJavaScriptのAPIをオブジェクト指向で設計するのやめてもらえませんかね、と言ってるのがオブジェクト指向を批判する関数型言語ユーザなのです。というイメージです。(追記)この辺でどうして突然ポエム始まっちゃったの?と思うかもしれませんが、まじめに語ると本気の怨嗟が漏れてしまいそうなので冗談めかして大げさにファンタジーしています。なんだこのネタを説明させられてる感じは!?
(訂正)嘘です。関数型のユーザは温厚で寛容な人たちばかりで他のパラダイムを否定することなんてありませんし、オブジェクト指向のことも愛しています。オブジェクト指向に憧れと羨望すら抱いています。関数型とオブジェクト指向に対立など存在しません。筆者はオブジェクト指向による一極支配体制を打破すべくオブジェクト指向と関数型の間に争いの火種を蒔くプログラミングパラダイム的テロリストなので、筆者ははオブジェクト指向を攻撃しているという虚構の対立を演出しますが、実際には何の争いも起きていません。筆者は関数型のユーザではなくただのテロリストですし、関数型のユーザは攻撃的だなどと思わないようにしてください。 (/訂正)
「オブジェクト指向と関数型はぜんぜん使う分野が違う」
そもそも、ひとえに「オブジェクト指向」といっても、C++が求められているような分野とJava/C#/Ruby/Python/JavaScriptが求められているような分野は全然別です。このうちC++の分野に関数型言語が進出するのはまず無理ですが、Java/C#/Ruby/Python/JavaScriptが使われているような分野には、関数型言語もまた適しています。この意味で、オブジェクト指向言語と関数型言語のカバーする分野がぜんぜん異なるということはありません。
逆に言えば、関数型言語では細かいチューニングをコンピュータに指示することは不可能です。したがって、性能を追い求めたいときにはC/C++を使うしかありえず、C/C++のような言語と関数型言語の住み分けは確かに存在します。
「関数型でもオブジェクト指向でも、適切なコードが書けるならどちらでもいい」
そうですね。たとえば、「ドライバーでもハンマーでも、正しくネジを締められるならどちらの道具でもいい」というのは、命題としては妥当だと思います。間違ったことは言っていません。ただ問題は、「正しくネジを締められるなら」という部分の条件が決して簡単には成立しないということです。そりゃあドライバーでもハンマーでももし目的を遂げられるならどっちでもいいでしょう。でも論点はそんなところではなく、ハンマーでは明らかにドライバーよりネジを締めにくいということです。
「関数型でもオブジェクト指向でも、適切なコードが書けるならどちらでもいい」のは確かですが、関数型もオブジェクト指向も異なる性質を持った道具であり、その目的の完遂のしやすさが明らかに異なります。「どちらの道具のほうが目的を遂げやすいか」が問題なのに、「目的を遂げられるなら」という前提から議論を出発しても、あまり意味のある結論は出ないと思います。「関数型でもオブジェクト指向でも、適切なコードが書けるならどちらでもいい」という主張は、どちらの道具を使うべきかの議論で結論を出すために何かの役に立つんでしょうか?関数型ユーザとオブジェクト指向ユーザが喧嘩をしていたら、その仲裁をする役には立つかもしれませんが。こういう寛容?多様性を重んじる?自由を尊重する?みたいな中庸の立場を主張するひとは結構多く、それは間違ったことは言っていませんが、何も言っていないのと同じだと思います。
「どんなプログラミング言語も大差ない」という立場の人もいます。「銀の弾丸はない」のは確かですし、よほど変な言語でない限り何十倍も効率が異なるわけではないと思いますが、それでも誰だって少しでもいい道具が欲しいはずです。既存の言語を改良するべく日夜議論と改良が続けられていますが、「どんなプログラミング言語も大差ない」というのはそうした努力を否定するも同然です。それに、たとえばJavaScriptにasync/awaitが導入されて嬉しいか嬉しくないかといえば誰だって嬉しいはずです。もし、async/awaitが導入されて言語がちょっと変わるのは嬉しいが、関数型とオブジェクト指向では大差ないしどっちでもいい、なんていう人がいたら、ただのダブルスタンダードでしょう。自分が便利さを理解しているものは「便利な改良」、自分が便利さを理解していないものは「大差ないしどっちでもいい」と自分の知識の中だけで結論を下しているだけだと思います。
それに、「どんなプログラミング言語も大差ないしどっちでもいい」っていう人に「どっちでもいいなら、じゃあHaskellで書いてね」といったら、その人はきっと「いやだからどっちでもいいならJavaScriptを使ってもいいでしょ!」みたいな感じで拒絶するんじゃないでしょうか。たぶんその人は本当は「今使っている言語をそのまま使い続けたいんだけどそれでいいよね?」って言いたいだけで、そこで下手に「どっちでもいいから」なんて言うのは、自滅行為では……。
その他
「関数型のパラダイムは人間の思考プロセスに馴染まない」
誤解だと言ってしまっていいでしょう。単にソフトウェアエンジニアという人種がコンピュータに合わせて副作用のある計算モデルに慣れすぎているだけで、それ以外の人たちは関数型で使われているようなものと同じような計算モデルで思考しています。
関数型言語のような副作用のない計算モデルと、副作用のある計算モデルでは、どちらか一方で書けるプログラムは他方でも書けることがわかっており、計算能力に違いがないことは証明されています。計算能力に差がないのなら使いやすい方を選べば良いので、では人間の思考に馴染みやすいのはどちらなのかということが問題になります。これについては主観的な部分も大きいので一概には言えませんが、歴史的にみれば副作用のない計算モデルのほうがはるかに古くから存在しており、有史以来コンピュータが発明されるまで、計算モデルの中心は副作用のないモデルのほうだったのは間違いありません。理由はおそらく、異なる値を取りうるものは異なる式で表したほうが混同しにくいからという単純なものでしょう。プログラミングを習ったことのある人なら、きっと一度は次のような式に戸惑ったことがあるかと思います。
x = x + 1
これを見て、「**x
とx+1
が同じってどういうこと?」**と混乱しない人のほうが稀でしょう。算数や数学ではこのような副作用を前提にした表記はしないからです。$x$ で表されるある値と $x+1$ で表される値があり、たとえこれをある性質 $x$ の時間的変化だと捉えるとしても、数学ではこの両者を混同しないように次のように $x_0$ や $x_1$ という違う記号を使って次のように書くのが普通です。
x_1 = x_0 + 1
これまで親しんできた記法と異なるのだから、x = x + 1
という表現に戸惑うのは当然です。「これは等式ではなく代入なのだから、=
という記号を使うことが問題なのだ。←
というような異なる記号を使ってx ← x + 1
というように書けばよい」という考え方もあるでしょうが、記号を変えたところで、x ← x + 1
の前後で同じx
という記号が異なる値を示し、混乱をまねくことにはかわりありません。人間がx ← x + 1
という式を理解するには、この式の前後でx
の解釈を切り替えなければなりません。文脈によって記号の解釈が変わりうるのは副作用のない計算モデルでも同じですが、副作用のないモデルならまとまった量の式のシーケンスをひとつの文脈で解釈すればいいのに対し、副作用のある計算モデルでは1行毎にことなる文脈になりうるので、1行毎に記号の解釈を改めなければならないのです。x_0
とx_1
が異なる値を取りうるなら、なるべく異なる記号で表現するのが簡単で自然な方法です。
別の言い方をすれば、このような式の振る舞いを理解するには、この式とは別に、計算途中のx
の状態を別の場所にメモするか、頭のなかで記憶しておかないといけないということです。別の場所にメモするくらいなら、最初から式の中で明示しておいたほうがよほど便利です。
コンピュータが発明され、計算機であるコンピュータに計算を指示するための記法を決めようとするとき、これまで人間が使ってきた記号と同じ記号を使いたくなるのは当然の発想でしょう。数値は1, 2, ...
、加算の記号は+
、と当然のように数学と同じ記号が採用されました。数値や演算子は数学のものとだいたい似たような記号が採用されたものの、コンピュータが発明された当初は、数学で使われてきたものと似たような x_1 = x_0 + 1
という記法は使い物になりませんでした(過去形)。なぜなら、コンピュータのメモリはとても限られており、必要ならともかく後ほど参照しないならx_0
のために確保しておくほどメモリに余裕がなかったからです。したがって、x_0
とx_1
は同時には参照されないことを明示し、貴重なメモリをx_0
とx_1
で使い回し、x_0
で使っている領域をx_1
で上書きして節約することをコンピュータに指示するために、x = x + 1
という式が使われたのです。
(追記)このあたりはかなりわかりづらいようですので補足します。ここでいう「思考」というのは、仕事をなんとなくぼんやりと表現するために使う方法として副作用のない計算モデルが使われてきたということではなく、仕事を曖昧さなく正確に表現するために使われてきた方法が、副作用のない計算モデルだったということです。
たとえば、 $0$ から $n$ までの自然数の合計 $S(n)$ を求めるという「仕事」を考えます。この仕事を表すぼんやりとした手順なら、実にいろいろな表現が使われてきたでしょう。たとえば、「S(n)とは、1を足して、2を足して……と繰り返していき、nまで足しあわせたもの」とか「1+2+....+nみたいな感じ。nが5なら1+2+3+4+5とか、そういうアレ」というようなものです。人間相手ならそれでも伝わるし、数学的に厳密に検討するのでもない限り、それで十分です。でもここで検討しているのは、そういう曖昧な表現で構わない時に人類がどのような方法を使ってきたかではなく、曖昧さなく正確に仕事を表現するために人類が使ってきた方法はどのようなものだったかということです。コンピュータが発明される以前から、数学の世界を中心に仕事の手順を曖昧さなく正確に表現する必要に迫られることがありました。そして、近年になってコンピュータが登場したことで、プログラミングのために仕事を曖昧さなく表す方法が欲しいという需要が新たに生まれました。このときに、本来ならコンピュータに合わせて今まで使ってきた方法を変える必要はなく、数学で使ってきた手法をそのまま使えばいいのです。
数学を自然と思える人でないと副作用のない計算モデルを自然だとは思えないだろう、という指摘はもっともです。しかし数学の式とは問題をはっきりと正確に捉えるときの人間の思考過程そのものであり、数学で使われる式は思考の過程をあいまいさなく表現できる人間にとって最もわかりやすい方法だから、それが使われてきたわけです。数学でこの仕事を表すとすると、
\begin{equation}
S(n) =
\left\{
\begin{array}{ll}
0 & (n = 0) \\
n + S(n - 1) & (otherwise) \\
\end{array}
\right.
\end{equation}
というようになるでしょう。もちろん、手続き型言語のように
int S(int n){
int s = 0;
for(int i = 0; i <= n; i++){
s += i;
}
return s;
}
というような表現も、仕事を曖昧さなくはっきり表現する方法です。しかし、実際には何百年もの間前者のほうばかり使われてきており、コンピュータが登場した時に後者の表現が都合が良かったので、後者のような表現も使われるようになったようです。筆者が知る限り、後者の副作用を元にした記法が数学で古くから使われていたという事実は聞いたことがありません。(/追記)(追記)数学でこの手の記法が使われている例として、項書換え系があります。項書換え系の発祥がいつ頃といえるのかはよくわかりませんでしたが、この手の話が出てきたのが、チャーチ・ロッサー性が1936年、句構造文法が1957年、L-Systemが1968年だそうです。「古く」というあいまいな言い方をしてしまいましたが、数学でこの手の記法を扱いだした時期としては多分そのあたりなんじゃないでしょうか。もっとも、ラムダ計算や形式文法の性質を調べたりするのにこのような項書換えの記法を使ったということであって、単に数を扱うときには普通に等式を使って計算を表すのが普通だったはずです。我々がプログラミングをするときも、殆どの場合は計算で値を求めるのが目的であって、計算の性質そのものを調べるのが目的ではないでしょう。先ほど例として出した${\rm S}(n)$を求めるという例も単なる数値計算であって、この計算を表すのに真っ先に項書換え系の記法を使うことを思いつく人は少ないと思います。もちろん、等式や再帰は苦手だ、せっかくだから俺は${\rm S}(n)$を表すのに項書換え系を使うぜ、という人がいても、別にそれが間違っているということではまったくありません。(/追記)
x_1 = x_0 + 1
のような式を自動的に解析し、x_0
があとで参照されないいことを突き止め、自動的にx = x + 1
というような式に書き換えるようなことも可能です。しかし、かつてコンピュータはそのような解析をする余裕すらなかったし、コンパイラもシンプルな機能しか実現していなかったため、プログラマは自力で使いまわせる領域を判断し、x_0
とx_1
を同じx
という式で表すので両者を混同しないように気をつけながら、x = x + 1
という式を書かなければならなかったのです。すべては、コンピュータのリソースが限られていたことが原因です。
現代のコンピュータの環境は非常に恵まれており、起動直後から一つのOSの上で数千ものプロセスが走り、ブラウザの上でマウスのボタンを一回クリックするだけで数百メガバイトの動画が読み込まれる時代です。コンパイラの最適化機能も極めて高度で、コンピュータはプログラマが書いた間抜けなコードを忠実に実行なんてしません。ガーベジコレクタも標準で組み込まれて、プログラマは参照を切るだけで勝手にメモリが回収されます。このような環境で、メモリ領域を使いまわすことをプログラマがコンピュータにわざわざ指示するなんて、あまりにしみったれた習慣としかいいようがありません。
もっとも、現代では本格的に数学を学ぶ以前からプログラミングに親しんだことのあるひとも結構いるはずです。筆者も小学1年生の時には父親がパソコンでプログラミングを試しているのを脇から覗きこんでいたことがあります。もしかしたら副作用のある計算モデルのほうに先に馴染んでいる人もいて、その人にとっては関数型のパラダイムより手続き型のパラダイムのほうが親しみやすいのかもしれません。関数型のパラダイムは自分の思考と馴染みにくいと感じる人がいても不思議ではありません。
- 副作用のあるモデルがプログラミングに採用された理由は2つ。ひとつめはかつてコンピュータの性能が不足していたため。ふたつめはコンパイラの機能が不足していたため
- 関数型言語でしばしば採用される副作用のない計算モデルは、異なる値を取りうる式は異なる式で表すというわかりやすさを重視したモデル
- 現実世界に状態や副作用があることはともかく、数学が発達していなかった何千年もの昔に実際に人間が採用したのは副作用のない計算モデルだったというのは事実。なぜなら、副作用のある体系では式の評価を進めていくあいだに『途中経過』の状態が式に明示的に現れず暗黙に存在するが、副作用のない体系では否が応でも途中経過を式で明示的に表現しなくてはならないので、それはそれで人間には扱いやすかったから
「関数型言語のプログラムはアルゴリズムを表現しない」
まったくの誤解です。関数型言語でももちろん「アルゴリズム」は存在します。我々が問題の「アルゴリズム」を研究する目的は、主に問題解決の明確な方策を与えることや、その方策の性能を調べることですが、関数型言語にももちろんバブルソートとクイックソートが別々の計算量を持つ別のアルゴリズムとして存在しており、計算量が異なる以上、区別するべき複数の異なるアルゴリズムが存在しており、アルゴリズムが存在しないということはありません。ただし、関数型のスタイルを多用すると、for文やif文などのいかにも手続き的な記述や条件分岐などがどんどん消え去ります。その意味で、「計算の手順」のような時間軸に従った操作がコードから読み取りにくくなるように感じるのは確かです。
「宣言型プログラミング言語」という概念と絡めて、関数型言語を「ある処理を行う手続きを記述するのではなく、達成すべき目的を記述する言語」というようにみなす向きもあるようですが、どういうものが「手続き的」でどういうものが「宣言的」なのかはどうもはっきりしません。どんなプログラミング言語でも手続き的な部分も宣言的な部分も持っているし、こういう主観的な判断でコードを分類するのはかなり無理があります。「宣言型プログラミング」を参照透明性の概念をもって定義する向きもあるようですが、参照透明性のほうが概念としてずっと明快なので、「宣言的プログラミング」のようなものについて言及するときは、この参照透明性による分類で説明したほうがいいでしょう。
「理論上は優れているとしても、現実の作業では関数型言語は使いものにならない」
-半分ウソで半分本当です。筆者も現実の作業で関数型言語を採用することにあまり気が進まないのは確かで、といってもそれは「関数型言語はコーディングしにくいから使い物にならない」のではなく、ひとえに関数型言語のユーザがとても少ないのが原因です。ユーザが少ないことは様々な問題を引き起こします。
- ライブラリやフレームワークの選択肢が狭い
- 開発者が集まらない
- 書籍にしろWebにしろ情報が少ない
- トラブルを周囲の人に相談してもわからないと言われる
- HTMLのDOMや.NET Frameworkのような基盤となるAPIがなぜかオブジェクト指向で設計されていて、関数型言語から叩きづらい
- 保守を他人に任せられないor任せたら放棄される
- というか言語自体も選択肢が少ない
- 人気がないのが原因でさらに人気がなくなる
- 関数型を触っているとマニアックな変人だと思われる
プログラミング言語の人気度のランキングとして比較的よく知られているTIOBE Indexだと、最も人気のある関数型言語はF#で19位、このテキストでさんざん説明に使われているHaskellでさえランクインぎりぎりの49位という悲惨な結果になっています。こんな状況で関数型言語を採用するのは、道無き道をナタを振るって進むようなものです。昔の人がいうところの「長いものには巻かれろ」というやつも大切なことです。
だがちょっと待ってほしい。昔の人はこうも言いました――「ナンバーワンよりオンリーワン」。舗装された道路をのうのうと歩いて行くなんて子供でもできることで、真に賞賛されるべきは、茂みを掻き分け岩を踏み砕き道無き道を走り抜けて道を作った、その最初のひとりでしょう。たとえどんなにマイナーな言語であっても、それが優れた言語であると考えるならそれを使いこなしてこそ、優秀な開発者と言えるでしょう。まあ私は明日からもJavaScriptを使いますが。
- 「みんなが使っているから」という理由でオブジェクト指向言語を採用するのは理にかなっています
- 「オブジェクト指向言語が現存する言語でもっとも書きやすいから」という理由でオブジェクト指向言語を採用するのは理にかなっていません
- あっ、筆者はJavaScript好きですよ。みんな使ってるからです。自分が使う言語を選ぶにあたって、「みんなが使ってる」という事実は言語仕様の少々の瑕疵すらひっくり返す最重要な要素です。
「最近、関数型プログラミング(関数型プログラミング言語)が流行っている」
たまに近年「関数型プログラミング」や「関数型プログラミング言語」が流行っているかのように言われることがありますが、筆者はあまり流行っているという実感は持っていません。以前よりは「関数型」という単語を見かけることが多くなった気はしますが、現実としてTIOBE IndexやGithubで新規に作られたリポジトリの数のような指標で実際に上位を占めているのは関数型プログラミング言語ではないからです。
「流行っている」のは単に「人気がある」のとは違って、「以前はさほど注目されていなかったが、近年になって急に注目されるようになった」というニュアンスもあります。人気があると言えるかはともかく、注目度の上昇率の大きさという意味では、既に確固たる地位を築いておりもはや注目度に変化の少ないC/C++/Java/C#のような言語よりは、関数型プログラミング言語は「注目度が変化しつつある」「流行っている」と言えるかもしれません。ただ、その意味でも、Perlの影に隠れたマイナー言語だったのにRuby on Railsで突然メジャーに踊りでたRubyや、かつてはWebページの下らない装飾に使われていた程度だったのにGoogleマップなどの実用的なアプリケーションで使われるだけでなくサーバサイドまで侵食し始めたJavaScriptに比べれば、以前よりは多少知られるようになったというだけのHaskellやOCamlなんて「大して注目されていない」と言っていいレベルだと思います。
「関数型プログラミング言語」や「関数型プログラミング」が流行っているというより、「高階関数などの関数型プログラミングの手法を、関数型プログラミング言語でない言語に取り入れること」が流行っているのかもしれません。高階関数のライブラリを整備するくらいなら言語に大きな変更が加わるという程でもないし、先進的な言語だという印象を与えてプログラミング言語に「箔」をつけるのに、「関数型プログラミング」という曖昧で胡散臭い単語は持ってこいです。これもまあ関数型プログラミングが流行っていると言えなくもないですが、先に述べたとおり、関数型プログラミング言語でない言語に取り入れられつつある手法は、数ある関数型プログラミングの手法の一部に過ぎません。筆者はそれ以外の直和型、高階型、型クラス、関手/モナドなどの関数型プログラミングの手法もとても有用であると思っていますが、そのような手法を既存の言語に無理なく導入するのはなかなか難しいようです。
ラムダ式やmap
関数といった極めて基本的な道具だけを導入しただけなのに、「Javaでも関数型プログラミングができる!」などと宣伝されることがあります。しかし関数型プログラミングをするにはラムダ式やmap
なんて最低限とすら言えないようなあまりに心もとない装備です。テニスをやったことがない人がテニスラケットだけを買ってきて「これで私も今日からテニスプレイヤーだ!」みたいに言ってるようなものです。ボールもウエアもシューズも揃って、初めてまともにテニスと呼べるようなことができます。自分にテニスプレイヤーという肩書を付けたいだけでラケットだけを買ってきて自室の壁に飾って眺めているだけの人と、本気でテニスをやるためにラケットとシューズとウエアとボールを揃え、動くのに邪魔な衣装も脱いで本気でテニスをプレイしている人には、ずいぶんな温度差があります。
- C/C++はもはや侵すことのできない聖域です。DやRust食い込めるんでしょうか
- JavaScriptやRubyは誕生当初は苦戦したものの、現在では大流行してトレンドの最先端を突っ切っています
- Lispは他に類のない単純さと柔軟さを備えた極めて特殊な言語です。「プログラムとは何なのか」を考えさせる、現代でも学ぶべき価値があるものです。
- Java/C#/PHPはC/C++に絶望した人類が待ち望んだ英雄であり、登場と同時に当然のごとく大流行しました。
- HaskellやOCamlは「望まれた言語」ではありません。JavaやC#を使っている人の期待や想像を更に上回るような柔軟性と堅牢性を備えたプログラミングを実現するものだからです。それ故に有用性が理解されにくく、RubyやJavaScriptに比べれば不遇の時代が未だに続いています
- Scala/F#って新参のくせになんかHaskell/OCaml以上に流行りそうな雰囲気ない?「新進気鋭」という熟語が似合う言語だと思います
- 誰か関数プログラミングを流行らせてくれませんか
「『関数型プログラミング』より『関数プログラミング』のほうが正しい」
どっちでもいいよそんなこと! Functional Programmingの訳語で、"Function"ではなくて"Functional"なのでその違いを考慮に入れて「関数」ではなく「関数型」と訳すことが多いと思いますが、すごくどうでもいいです。私は「関数型プログラミング」のほうが慣れているので特に理由がなければ「関数型プログラミング」を使いますが、「関数プログラミング」と表記してるひとを見かけてもそんな仔細な違いに噛み付いたりすることはないです。というか、そんな揚げ足取りにもなっていない下らない指摘をして話の腰を折ったりしたら、空気読めない奴だと思われるので、頼まれてもやりたくありません。
「『関数型言語』は、正しくは『関数型プログラミング言語』では?」
どっちでもいいよそんなこと! この記事で筆者は「関数型言語」という表現をしばしば使いますが、正式には「関数型プログラミング言語」です。ただし、正式に書くとかなり字面が長くなるし、「関数型」だけでは「関数型プログラミング言語」なのか「関数型プログラミング」なのか区別がつかないので、間をとって「関数型言語」と略記しています。「関数型プログラミング言語」を「関数型言語」と省略することに強い違和感を覚える人もいるようなのですが、単に筆者がキーボードを叩くのが面倒くさがっているだけで、意味は同じなので略記くらい許してください。
さいごに
何度もいいましたが、関数型プログラミングや関数型プログラミング言語が何たるかを理解するには、やはり実際に関数型言語を使ってみるのが一番です。特に、関数型言語ではオブジェクト指向が必要ないということを実感するには、どうしても実際に自分で書いてみるしかありません。百聞は一見にしかずとはよく言ったもので、なぜオブジェクト指向が必要ないのかということをくどくど語ってもいいですが、そんなことは話を聞くだけでは決して納得などできないのです。関数型のスタイルを取り入れているというライブラリを従来の言語から使うだけでは、関数型のほんの表面をなぞったにすぎません。筆者のおすすめはやはりHaskellで、関数型の理念をまっとうしつつも使いやすさも忘れない、バランスの良い言語仕様とライブラリを持っています。
参考文献
関数型プログラミングの重要性について述べられた有名なテキストで、実に30年も前の論文です。ちょっと難解ではありますが、筆者のこのテキストであまり触れられていない遅延評価の重要性について実例を以って述べられていますから、遅延評価について詳しく知りたいひとについてもお勧めです。この論文の最後の一文を引用すれば、
関数型言語は遅延評価であるべきだと信じるものもいれば、遅延評価であるべきではないと信じるものもいる。遅延リストを用意するだけで妥協するものもいる。この場合、(たとえば、[AS86]のSCHEMEのように)特殊構文を用いてこれを構成する。本論文では遅延評価が非常に重要で、第二級の市民権に格下げできないという更なる証拠を用意した。それは関数プログラマの持つおそらくもっとも強力な糊であろう。このような極めて重要なツールへのアクセスを妨げてはならない。
とのことだそうです。つまり、最近では関数型プログラミングを取り入れたとして遅延リスト(遅延ストリーム)をライブラリとして実装してみたという喧伝がよくありますが、そんなライブラリで実現されるようなものは真の遅延評価、真の関数型プログラミングとはとても言えない、というのがこの論文の主張のひとつです。筆者は遅延評価が非常に苦手で、この論文でいうところの「遅延評価であるべきではないと信じるもの」のひとりのようです。もしかしたらHaskellも遅延評価いらなかったんじゃないかと思うくらいなので、遅延評価については筆者よりこの論文に説明を求めたほうがいいでしょう。
- また、@osiire さんにコメント欄にて参考文献を挙げて頂きました!有益な文献ばかりだと筆者も思うので、さらに詳しい説明を求める方はぜひとも御覧ください。
謝辞
このテキストの修正部分には各種SNSでのツッコミが反映されています。ありがとうございます。具体的に引用したりお名前を挙げるのは差し控えさせて頂きますが、この記事の訂正に貢献していただいた以下の正しくて鋭いツッコミ、一日以内という素早い反応に対して、筆者がメモ的に記録しておくとともに、感謝の意を述べさせていただきます。筆者は以下の様な正しく鋭いツッコミを歓迎します。
- Common Lispでオブジェクト指向使われてるだろ
- Haskellのベンチマークでは領域を再利用してるだろ
- Erlangは関数型言語だろ
- なんでCRubyじゃないん?
- 数学を自然と思える人じゃないと関数型の計算モデルは自然に思えんだろ
また、以下のツッコミは有益なので、次回以降の記事を書くときに参考にさせて頂きます。
- 長い
- オブジェクト指向設計を関数型で再設計するくらいの詳しい解説がほしい
- 長すぎる
- 悪いのはオブジェクト指向じゃない。オブジェクト指向の使い方を間違ってる奴が悪いのだ
- あとでよむ(読まない)
- 中盤に毒みたいなポエム的なものが交じるのやめろ
- 見出しに掲げてそれを否定、というスタイルは読みづらい
- コードは簡潔なのに説明が長すぎる
- なぜ関数型が普及しないのかが書いてない
-
『処理の本体だけで比較するならどの言語もいっしょじゃないの?』と思ったひともいるみたいです。そのとおりです。本文中で触れている通り、
interact
相当の関数が存在すればですが。大半のプログラミング言語ではそのような関数が存在していないのは、本文中で『もちろんそのような関数を定義することは他の言語でも可能ですが、それが標準ライブラリに入っているような言語はそう多くないでしょう』言っているとおりですし、だからそれを先読みして『「たまたまちょうどいい機能が標準ライブラリにあったから短く書けただけなんじゃないか?」と思う人もいるかもしれないので』と、更に詳しい説明を書いています。その疑問を先読みして筆者は予め回答を書いた、疑問の先読みとその答えを事前に用意したという万全の準備を整えておいたというのに、それでもあとからその疑問を疑問として提出するということに、ウェブという媒体での情報伝達の限界を感じました。 ↩