ほんわかレス推奨です! この掲示板では、しばしば激しい論争が起きそうな気がします。 コメントを書くときはスレをよく読み、ほんわかレスに努めてください。 |
関数型プログラミングとは、プログラミングスタイルもしくはプログラミングパラダイムである。参照透過型プログラミングのことかもしれない。
目次
-
概要
- 歴史 [ 黎明期 / 再登場 / 最近の動向 ]
- オブジェクト指向との論争
- 特徴
- まとめ
- 考察 [ 良さが説明しにくい理由 / 概念がわかりにくい理由]
- 関連項目
概要
関数型プログラミングとは、関数を数値などと同じように関数に受け渡しすることによって「上手に」プログラミングを行うスタイルである。文字だけを見れば関数型言語を使用してプログラミングすることともいえそうだが、主な目的は「状態」に依存することにより生じるプログラミングミスを参照透過性で防止することにあり、関数型言語の使用や関数型を引数にした高階関数の活用などは参照透過性を容易に実現するための手段もしくは副次的な結果に過ぎない(とまで言うとやや言い過ぎになる)。また、最近はマルチパラダイムを採用するプログラミング言語も増えているため、関数型言語に分類されない言語でも関数型プログラミングを行うことが可能になってきている。
そのため、プログラミング言語とプログラミングスタイルもしくはプログラミングパラダイムは分けて考えたほうがいいのではないかということで、関数型言語以外に関数型プログラミングという用語を使用することがある。
歴史
黎明期
大きく注目されるようになったのは2010年代に入ってからではないかと思われるが、その歴史は非常に古く、世界で2番目に古いプログラミング言語とされるLispにまでさかのぼる。しかしながら、Lispの構造は当時のコンピューターで実行するには重すぎたため、そこそこの可読性を備えつつ高いパフォーマンスを発揮するC言語などに押されて忘れ去られてしまった。
再登場
その後しばらくは表舞台からは姿を消してひっそっりと受け継がれてきたが、やっと時代が追いついてきた。
その要因として一つ目は作成するプログラムの高機能化に伴う開発の複雑化が挙げられる。開発の現場で人為ミスが多発し、無視できないほど生産性が損なわれるようになってきた。従って人為ミスを減らす環境に対する需要が開発言語を変更することまで視野に入ってくるほど高まってきたのである。
二つ目はコンピューター性能の向上である。かつて何十万円出しても買えなかったようなコンピューターが、今やその数百倍以上の性能のものが数万円で手に入るようになった。これによりプログラムを実行するPCの負荷が増えても、プログラムを開発する人間の負担が減った方がよいと考える土壌が形成されるようになってきたのである。
三つ目にはCPUのマルチコア化があるといわれている。マルチコアの性能を引き出すには並列処理が欠かせないが、後述のように関数型プログラミングの参照透過性と並列処理は相性がいい。
最近の動向
関数型プログラミングができる言語としては最古参のLisp以外に、純粋関数型言語のHaskell、Java仮想マシンで動作するScala,Clojure、MicrosoftがOCamlからお得意のパクりで作ったF#、Appleがこれもお家芸の旧環境切り捨てをして打ち出したSwiftなどが続々と参入してきている。実はJavascriptも初期から関数型プログラミングが可能であった(元はブラウザでLispしようとしたのが開発動機だったとか)。さらにはオブジェクト指向言語の代表格ともされるJavaですら2014年からは関数型プログラミングを言語仕様に取り入れるようになった。
オブジェクト指向との論争
前述のようにオブジェクト指向よりも歴史が古いともいえるわけだが、注目されるようになったのはオブジェクト指向よりも後であるため、オブジェクト指向の次に来るプログラミングパラダイムとみる人が多い。
それ故に関数型プログラミングに心酔する人の中にはオブジェクト指向を批判・否定する人も少なくなく、オブジェクト指向を使っている人との間に宗教戦争や最強議論に近い感情的対立がある人もいるようである。
共存できないのは人間であって、プログラミングパラダイム自体はオブジェクト指向と関数型プログラミングが共存可能であるとみる人も多い。チューリング完全である以上、できるできないの差ではなく、やりやすいかやりにくいかだけの違いなのではなかろうか。
[目次へ]
特徴
何をもって関数型プログラミングとするかが問題なのだが、歴史が古いと言いながらも、注目されてからの歴史はまだまだ浅い。そのため細かい点ではコンセンサスがなく言った者勝ちみたいなところ(この記事にもあてはまる)がなくもない。
以下に、主だったものを列挙する。
高階関数
関数を引数にしたり戻り値にしたりできる関数を高階関数という。これが使えることが関数型プログラミングを行うための最低条件であることは、おそらく共通認識とみて問題ない。
実際に関数プログラミングが可能な言語に高階関数として実装される定番には以下のものがある。それ以外のものは言語仕様上需要がある場合を除きあまり取り上げられない。
map(写像)
関数とリストを引数にとり、リストの各要素に関数を適用した戻り値を要素とするリストを返す。
filter(フィルター)
真偽値を返す関数とリストを引数にとり、関数を適用すると真になる要素のみからなるリストを返す。
fold, inject, reduce(重畳関数)
初期値と、リストと、「引数を2つとる関数」を引数に取る。言語により関数名の違いが大きい。
- 初期値とリストの1番目の要素に関数を適用する。
- 上記の戻り値とリストの2番目の要素に関数を適用する。
- 上記の戻り値とリストの3番目の要素に関数を適用する。
- これをリストの最後の要素まで繰り返し、最後に関数を適用した結果を戻り値として返す。
畳み込みと呼ぶこともあるが、日本語では畳み込みは数学の違う計算を指すことがあるもよう。
クロージャ
関数の中に、関数の外部の値への参照が閉じ込められている関数のこと。外部の値が変わるとそれに合わせて適切な挙動ができるということがメリットとして挙げられることがあるが、数学的関数の定義からは外れているようにも思える。
カリー化
複数の引数をとる関数を、引数を一つずつとっていく関数にすること。Haskellでは関数は標準でカリー化されているので言語仕様上重要だが、それ以外の言語ではあまりお目にかからない。
[目次へ]
関数(数学)
プログラミングで関数といえば、計算する以外にも入出力をこなしたりと様々な機能を持っているFunctionを指す。しかし関数型プログラミングにおける関数は、数学的な関数をいう。数学的な関数は計算のみを行い周囲に何の変化ももたらさない。また、数学的関数においては、環境によらず同じ引数からは常に同じ戻り値が得られる。
参照透過
イミュータブルという表現も用いられる。プログラム中で変数は常に同じもの(値)を指しているということ。プログラム内のどこからでも、値を参照すると初期設定値が透けて見える(途中で値が変更されていると、初期値を知ることはできない)という意味。
変数の値が変わる環境では、プログラムの文面上ではその変数がどういう状態にあるか(たとえばその変数に初期化処理がされているかどうか)がわからず、それが人為的ミスの元凶であるという考え方があるのではないかと思われる。
副作用禁止(再代入不可)
参照透過ということは、ものごとの状態は変更されず、値は一度代入されてしまったら、再度代入して変更することは出来ないということである(それゆえ代入という表現を嫌い、束縛という用語を用いる言語もある)。
このことを突き詰めると結果の画面表示ですら状態変更に該当し認められないことになってしまうため、一部の批判的な人は、この点を「外に出ようとしないひきこもり」にたとえたりすることがあるが、実際の関数型プログラミングは何らかの方法で妥協している。ただしその際でも、副作用のある部分と参照透過性のある部分の明示的分離が推奨されている。
モジュール化・抽象化
「状態」があると「状態」がいつ変更されるのかを常に意識しなければならないが、参照透過性により「状態」に依存したプログラミングから脱却できる。依存がなくなるということは、関数と計算対象の独立性が高くなるということであり、プログラムのモジュール化(独立した部品化)が促進されるらしい。
さらに計算対象からの独立性が高くなると、計算対象の種類によらないプログラムのフロー(繰り返しなど)を高階関数で表せるようになり、高度な抽象化を実現することができるようになるようだ。
抽象化の問題点
プログラミングパターンを高階関数として抽象化して簡潔なプログラムを書くことができる反面、プログラミングパターンの数だけ高階関数が存在することになるので、パターンの組み合わせも考えると、各種高階関数を学習するコストが幾何級数的に増加するおそれがある。
たとえば、上で述べた重畳関数は(i = i + 1を使うタイプの) for ループを高階関数 fold の適用に置き換えることができるが、リストの後ろから順番に計算したくなった場合、for ループであれば初期値をリストの先頭から末尾に変更し、 i = i + 1 を i = i - 1 にするだけであり、そこに新たな知識や定義は必要ないのに対し、高階関数fold は後ろ(右)から順に計算する高階関数foldr を別途定義するか、リストを逆順にするreverseのような関数を別途定義しなければならない。
ポインタ問題からの解放
C言語の時代からポインタがわかりにくいと言われている理由は多数あるが、その理由の一つに「ポインタと実体の乖離」がある。同じメモリ空間を参照する別のポインタからの操作により、気づかないうちにポインタの参照先が別の内容になっているということが起こりうるのである。
それ以上に多いのが、無効なポインタを有効な値に変更し忘れるという、いわゆる「ぬるぽ」にまつわる問題である。
参照透過性が保証されていればこれらの問題は解決する。一度書き込まれた内容は変更されることがないのだから、参照先が別の内容になっているとか、後で有効な値に変更しなければならないということ自体が起こり得ないからである。
遅延評価
実際にその値が必要になるまで計算しないで放置しておくこと。反対語は正格評価。f(g) = h (f, g, hは関数)とした場合、h(x)は計算できても h そのものについては計算のしようがないことがほとんどなので、ある意味仕方のない場合もある。
これが許される背景としては上記の参照透過性が欠かせない。後で計算しようとした時に引数の値が別の処理で変更されていたら結果もまた別のものになるからである。
計算しようとすると条件によってはエラーが発生する値も、エラーが発生する状況では必要とされない場合、計算式に含めることができる。この性質を用いて無限リスト(要素数が無限個のリスト)を(容易に)定義できるのが特長。
必要のない計算をしないのでパフォーマンスが向上するという話もあるが、インタラクティブなアプリケーションの場合は空いた時間に計算してしまったほうがよいような気がしないでもない。
[目次へ]
並列処理
並列処理プログラミングの最大の問題として、複数のスレッドが同時に同じ変数に値を書き込むと誤動作するということがある。また、これを回避するためにスレッドが変数にアクセスする順番待ちの制度が用意されることがあるが、順番を次にまわさないスレッドがでるとプログラムがフリーズする。
前述のように、関数型プログラミングではイミュータブルな値しか使わないので複数のスレッドが同時に値を書き込むことがない、というか値を書き込むのは初期化する一回限りである。したがって、上記のような誤動作やフリーズ(デッドロック)の心配をせずにマルチコアCPU向けに処理を並列化できる。
というのが建前だが、実際には副作用についてなんらかの妥協が行われているので、並列化する部分にその妥協が紛れ込んでいた場合には当てはまらないかもしれない。また、並列化された処理同士がお互いの処理結果に依存しないようアルゴリズムを設計しないといけないという点は、関数型プログラミングでも変わらない。
ループ処理(再帰)
関数型プログラミングのスタイルとしてよく例に挙げられるのが、forループの代わりに再帰を用いるということがある。なぜこのようなまどろっこしいことをするかというと、典型的なforループでは i = i + 1 のような処理が含まれるので、これが再代入禁止の原則に反するというのが主な動機である。なお、i = i + 1 が入るようなループについては連続する整数からなるリストに上述のmapやfoldなどの高階関数を適用することでも実装できる。
forループが「同様な処理」を「ある状態において(for)繰り返す」という、処理と条件判断・繰り返しがはっきり分かれた構造をしているのに対し、再帰によるループは「ある処理を行う(ただし、ある処理には繰り返し条件を判断して次の同様な処理を呼ぶことが含まれる)」というように、目的の処理と条件判断・繰り返し部分がつながった構造になっている。自然言語と同じで慣れの問題かもしれないが、違和感を感じる人がいるとしたら、for文ではループの存在が for, while などのキーワードで明示されているのに対し、再帰では書式自体は通常の関数呼び出し同じであるといった構造的な違いが原因かもしれない。
誤解のないように述べておくと、再帰自体はループ専用の概念というわけではなく、2分木構造の実現など様々な使用法がある。
末尾再帰最適化問題
再帰の中でもforループの代わりの再帰では、ループの末尾に再帰呼出しがくる。再帰呼出しは関数の呼び出しだが、関数の実行が終わったら戻ってくる。現実の問題としてたいていの処理系は、関数を呼び出すと戻ってくる場所に関する情報をメモリに保存している。
繰り返し数が膨大になると、この戻ってくるための情報がメモリを圧迫しオーバーフローを引き起こす。
関数型言語の一部では、この問題を避けるために末尾再帰に限り、呼び出しで戻る場所を最後に戻ってくるポイントだけ記憶するという最適化がコンパイラや実行系によって行われるようになっている。これを末尾再帰最適化(Tail Call Optimization)という。
末尾再帰最適化機能がない言語でもトリッキーな方法でオーバーフローを回避することが可能らしいが、そもそも末尾再帰最適化がない言語では for ループが使えるはずなので、無理に再帰を用いることはないのかもしれない。
無名再帰問題
再帰を使うには処理内容を記述した関数を再帰呼び出しするために、その処理内容を記述した関数自身を指す名前が必要になる。しかし、非関数型プログラミングでforループを使用する頻度を考えれば想像がつくように、繰り返し処理のたびに処理に名前を付けていたのではすぐに名前空間での衝突が起こり、付ける名前がなくなってしまう。
これを回避するには無名関数(ラムダ式のように、関数に名前を付けずに関数の処理内容のみを記述する関数定義方式)の中から(予約語などで)その関数自身を呼ぶことができればよいのだが、これができるかどうかは言語仕様に強く依存する。
[目次へ]
定理証明
プログラムに「絶対に」バグがないことを確認するのは通常であれば不可能に近い。しかし数学の関数であれば、数学の定理として証明してしまえば、バグがないことが保証されるのである。人為的ミスの削減の究極の形といえ、関数型プログラミングなら可能になるのではといわれている。実現していないこともないが、一般的な用途での実用には程遠い段階である。
分岐
宣言型プログラミング, FRP(Functional Reactive Programming)
関数型プログラミングでは参照透過性が原則になるため、GUIなど時々刻々と変化するものは相対的に扱いづらくなっている。そこで出てきたのがFunctional Reactive Programmingなどに代表される宣言型プログラミングという考え方である。
宣言型プログラミングでは、変化する物事の中でも変化しないもの、すなわち物事の間の関係性に着目した。たとえば b = a / 2 という関係を宣言しておけば、 a が変化すれば、自動的に b が更新されるといった具合である。GUIでいうならば、ある欄に記入された数値の座標に点が表示されると宣言すれば、数値が変更されるたびに点が移動するというような状態である。
副作用を扱うための制約が大きい関数型プログラミングで大きなご利益があるといえるが、関数型プログラミングでない言語でも実装されており、関数型プログラミングの専売特許というわけではない。
まとめ
- 参照透過性により変数の状態に依存した人為ミスや並列処理に伴う人為ミスが減る。遅延評価が可能になるというおまけもついてくる。
- 高階関数を用いると便利なことがある。
- 関数型の言語やプログラミングスタイルは、数学的関数を通じて参照透過性の利用と副作用の分離を促す(強制までされることはまれ)ことにより、人為ミスを減らし、生産性を高めようとしている。
- 大事なのは言語やプログラミングスタイルよりも、人為ミスを減らせるかどうかなのではなかろうか?
[目次へ]
考察
関数型プログラミングの良さが説明しにくい理由
関数型プログラミングのメリットを人為的ミスの減少に求めるなら、よくみかける「関数型言語ではこういうことができます」という成功体験論よりも、「他のスタイルでやってしまうミスが、関数型プログラミングでは注意しなくても起こりません」のようなネガキャン失敗談的教訓の方が説明としてはわかりやすいのかもしれないが、失敗のパターンは無数にあり得るため、そういった観点からの体系だった説明はあまり見かけない。
また、人為ミスというのは単純な例では起こりにくい。従って把握するのが困難なほど複雑な事例を示さないと関数型プログラミングの良さを示すことはできないのかもしれない。しかし把握するのが困難な事例というのは、説明に用いるのに適さないという自己矛盾を抱えているため、こういった観点からの説明もみかけることはない。
関数型プログラミングという概念がわかりにくい理由
まとめで述べたように、関数型プログラミングのメリットというのは単一のものではなく、複数のメリットからなる。しかもその中で大きな割合を占めているのは参照透過性によるものであり、関数型プログラミングという名称から連想される高階関数によるメリットは2番手に過ぎない。定理証明? なにそれおいしいの?
また、それぞれのメリット自体も性質が異なったものである。参照透過性のメリットは「凡ミス防止」という「マイナスをゼロにする」「だれにでもあてはまる」性質であるのに対し、高階関数は「理解力に応じて高度で抽象的な操作ができるようになる」という「ゼロをプラスにする」「使い手を選ぶ」性質である。前述の表現を踏襲するなら定理証明は「プラス(ユニットテストによる検証)を無限大(完璧)にする」性質とも言えるが、「実用上は使える人がいない」のでそこまで考える必要はない。
さらに、関数型プログラミング・参照透過性・高階関数というものは一対一に対応するものではない。まず、参照透過性は高階関数がなくても実装可能であり、高階関数は目的というよりは参照透過性を保ったまま便利にプログラミングするための手段と見ることもできる。そして、参照透過性は関数型プログラミングの重要な要素であるが、非純粋関数型言語の例をみればわかるように、関数を引数にとる高階関数には副作用のない関数を渡すという関数型プログラミングの建前に反して、副作用のある関数を渡せるよう言語仕様を定めることは可能である。このように関数型プログラミング・参照透過性・高階関数というのはお互いに重なりあいつつも、どちらかが一方的に他方に含まれるという関係でもないのである。
重なりあうところがありつつも性質が異なる複数の概念を一つの名前で呼んでいる上に、名前通りに考えた意味とは違う意味が第一義に来るため、人によって指しているものが異なり混乱があるのかもしれない。
もちろん異論は認める。
[目次へ]
関連商品
関数型プログラミングに関するニコニコ市場の商品を紹介してください。
関連項目
- 関数型言語
- 関数 / 高階関数 / 重畳関数 / クロージャ / カリー化
- イミュータブル / 参照透過 / 副作用
- 再帰
- モナド
- ひきこもり
- 縛りプレイ
- 定理証明
- プログラミングパラダイム
- プログラミング関連用語の一覧
[目次へ]
- 2
- 0pt