関数型プログラミングとは結局何なのか


この記事はドワンゴ Advent Calendar 2014の14日目です。
ちなみに前日は@erukitiさんでした。
他の方は「こんなもの作ってみた!」系の記事が多いのですが、技術系の話題であれば特に縛りはないようなので、今回はひたすら文章をつらつらと綴っていきたいと思います。

ここ数年、「マルチコア時代の主流は関数型だ」とか「Javaはもう古い! 時代は関数型!」といった記事をよく見かけるようになった気がします。
大学でOCamlを学んできた自分としては嬉しい限りなのですが、なんだか関数型という言葉がバズワード的な使われ方をしている気がして、まるで「現在起こっている全ての問題を解決する銀の矢だ!」といわんばかりの雰囲気を感じるのが気になっています。
最近うちの部署でもにわかに「関数型っぽく書こう」みたいな機運が高まってることもあるので、この機に関数型プログラミングとはなにか、どのような特徴があって何に向いているのか、みたいなことをまとめてみたいと思います。

命令型プログラミングと宣言型プログラミング

そもそも、関数型プログラミングは宣言型プログラミングの一種です。
宣言型プログラミングとは「問題の性質を記述する」ことでプログラミングするパラダイムで、「問題を解く手順を記述する」ことでプログラミングする命令型プログラミングと対になっています。

宣言的なものの馴染みのある例として、SQLクエリが挙げられます。

SELECT SUM(count) FROM sales WHERE product_id = 1

上記のクエリでは、求めたいものが「product_idが1であるものをsalesから探してきて、そのcountの合計を返してください」と明示されています。(ちなみに、売上票の集計といったシチュエーションを想像して書きました)
一方で、データストアを順に探すのか、それとも分割統治して求めるのか、といった「どのように求めるか」は記述されていません。
データの求め方は、クエリを解釈した処理系がクエリに合致するものを取ってこれるように判断します。

SQLクエリに合わせて、命令型でも同様の物を求めるプログラムをC言語っぽく書いてみます。

int sum_sales(sales *sales, int sales_length) {
  int sum = 0;
  for(int i=0; i < sales_length; i++) {
    if (sales[i].product_id == 1) {
      sum += sales[i].count;
    }
  }
  return sum;
}

多分こんな雰囲気になるんじゃないでしょうか。
「全部の要素を順番に見ていって、product_idが1のものがあったらsumに足し合わせろ」という求め方の手順を記述しています。
この時、iやsumを代入命令によって書き換えているのが命令的な部分です。

関数型プログラミング

それでは宣言型プログラミングの一種である関数型プログラミングではどのようになるか…の前に、そもそも関数型プログラミングとは何であるか定義しましょう。
関数型プログラミングでは、問題を「副作用のない関数」として記述します。
…というとよくわからないと思うので、先ほどと同様、売上からproduct_idが1のものだけを選んでcountを集計する処理を書いてみましょう。

let rec sum_sales sales = match sales with
  | [] -> 0
  | sale::rest ->
    if sale.product_id = 1 then
      sale.count + sum_sales rest
    else
      sum_sales rest

OCamlっぽく書いたので見慣れないと分かりづらいかもしれませんが、これは以下の様な定義になっています。

  • salesが空なら、合計は0である
  • salesから取り出した要素のproduct_idが1なら、合計はその要素のcountと残り全体の合計とを足しあわせたものである
  • そうでないなら、合計は残り全体の合計である

ぱっと見は命令型と同じようにみえるか、あるいは余計ややこしくなったように感じるかもしれませんが、ここでのポイントは「sumやiといった状態が現れていない」という点です。
状態として「今何番目の要素を処理しているか」や「現在の合計がいくつか」を保持する代わりに、最初の要素と残りの要素に分割して、残りの要素は自分自身を再帰的に使うことで定義しています。
こう書くと「再帰=関数型」みたいに読めてしまうかもしれませんが、あくまでも「副作用のない関数」という点が重要です。
今回取り上げた再帰や、関数型の特徴として取り上げられることの多い高階関数などは、「関数型プログラミングを実現するための道具立て」であって関数型プログラミングの本質ではないと自分は考えています。
(もっとも、命令型言語と同等の表現力を持つために、ほぼ必須ではあります。この辺は今回深く触れないので「原始再帰関数」「ラムダ計算」辺りでググってください)

言語とパラダイム

ところで、ここまで一貫して「命令型プログラミング」「関数型プログラミング」とだけ記述して、「命令型プログラミング言語」などと記述することや、具体的に「○○言語は命令的な言語である」と記述するのを避けてきました。
ここで改めて~言語、という単語を導入しましょう。

  • 命令型プログラミング言語:命令型プログラミングの考えに基づき設計された言語
  • 関数型プログラミング言語:関数型プログラミングの考えに基づき設計された言語

それぞれ、長いので「命令型言語」「関数型言語」と略します。
そのまんまじゃん、と言われそうですが、それぞれのパラダイムの「考えに基づき設計された」だけであって、命令型言語で書く=命令型プログラミング、ではないということに注意が必要です。
もちろん命令型言語で書いたほうが命令型プログラミングはしやすいのですが、関数型言語でも命令型プログラミングをすることは可能ですし、逆に命令型言語で関数型プログラミングをすることもできます。
例えば、命令型言語であるC言語で、以下のようなコードを考えてみます。

int sum_sales(sales *sales, int sales_length, int idx) {
  if (idx >= sales_length) return 0;
  if (sales[idx].product_id == 1) {
    return sales[idx].count + sum_sales(sales, sales_length, idx + 1);
  } else {
    return sum_sales(sales, sales_length, idx + 1);
  }
}

この場合は、命令的な状態書き換えがなく、関数型プログラミングの考え方に近い実装であると思います。
一方、関数型言語でも、例えばパフォーマンスのために命令的な記述を交えることがあります。
つまるところ、「命令型プログラミング」「関数型プログラミング」とは書き方のスタイルの問題であって、言語はそのサポートに過ぎない、というわけです。

そういったわけで、もし「関数型言語で書けばそれは関数型プログラミングなんだ!」と思っている人がいたら、そうではなく「関数型プログラミングをどこで活かせるか」を考えてもらえると嬉しいです。

関数型プログラミングのメリット

さて、関数型プログラミングがなんぞや、の部分を分析しましたが、それで結局、関数型プログラミングで書くとなにが良くなるんでしょうか。
「副作用のない関数」として問題を記述する、という特性についてだけ考えれば、メリットは以下のような点になりそうです。

  • 宣言的な記述のため、簡潔に書けることが多く関数の仕様を理解しやすい
  • 副作用がないため、形式的な証明がしやすい
  • 関数の評価結果がコンテキストに依存しないため、テストがしやすい
  • コンテキストに影響を与えないため、関数を組み合わせて利用しても相互に影響しない
  • 関数が相互に影響しないため、並列・並行処理が書きやすい(?)

並列・並行性に関しては、メリットとして取り沙汰されることが多いものの、関数型の利点かというと微妙なところがある気はします。
この手の話題になると必ずErlangの話が出てきますが、それはErlang自体が並列処理を行うためのマイクロプロセスとメッセージパッシングの機構を持っているためであって、関数型の恩恵というのは語弊があるでしょう。
むしろ、メッセージパッシングはオブジェクト指向から派生した概念なので、(Alan Kayのいう意味での)オブジェクト指向の恩恵といえるかもしれません。
Erlangがオブジェクト指向的であるというのは開発者のJoe ArmstrongもRalph Johnsonのインタビューの中で認めているところです。
一方、純粋関数型のHaskellでは並行・並列処理で一冊本が出るレベルなので、「関数型を採用すれば並列処理が簡単になる」というのはやや誇張表現と言わざるを得ないでしょう。
">まぁそれでもJavaのThreadクラスを用いた並列処理に比べるとマシなのかもしれませんが… Javaでは命令的でない並列処理の実装(Future, Streamなど)の話も混じってきてしまうため、例えとしてあまり適切ではありませんでした。古典的な「複数のスレッドが共有リソースを書き換えながら動作するようなプログラミングスタイルと比べて」と解釈していただければと思います。

一方、関数型とセットで取り沙汰されることの多い高階関数のメリットは、「関数自体の表現力が上がる」の一点だと思います。
とはいえ関数自体の表現力が上がるのは非常にわかりやすいメリットです。
また命令型のパラダイムと相反するわけではないため、高階関数の機能だけを独立して導入している言語も多く見られます。
例えばrubyのブロックを扱うメソッドやC#のLINQなどは実質的に高階関数になっています。
(もっとも、オブジェクト指向でも処理のまとまりを渡すブロックという仕組みがSmalltalkにあるため、そちらから影響を受けている部分もあるでしょう)

他に強力な静的型検査システム、型推論による型注釈の省略、などが挙げられることもありますが、この辺は関数型言語の中でも一部の言語(主にOCamlとHaskell)に特有のもので、関数型プログラミングのメリットかというと微妙な気がするため今回は外します。

関数型プログラミングのデメリット

デメリットは、一部のメリットの裏返しが多いです。

  • 宣言的な記述のため、知識がないと実際にどう動くのかを追いづらく、パフォーマンスチューニングが難しい
  • 状況に応じて別のことをさせる、という処理はやや書き難い
  • コンパイルが重くなりがち
  • IOなど、本質的に副作用のある処理の記述が難しい

とはいえ、そもそも命令型がコンピュータがわかりやすいものであるのに対して、宣言型はより人間の考え方に近いもののはずなので、個人的には(少なくとも命令型と比べての)デメリットは少ないのではないかと思います。
またIOなどに関しては、無理に関数型プログラミングにこだわらず、そこだけ命令的に記述する、というのが最も簡単な解決策でしょう。
個人的には、本質的なドメインロジックを副作用を排して書き、IOに関わる部分は多少の副作用を許容する、というのがバランスが良い気がします。

関数型プログラミングと関係ないトピック

メリットのところにも書きましたが、強力な静的型検査システム、型推論による型注釈の省略などは関数型プログラミング自体とは関連性が薄い気がします。
そもそもErlang、Lispなどは動的型付き言語なので、「関数型プログラミングだから」という話ではなく、静的型付き言語だから、というべきでしょう。

また、これは割と重要だと思うのですが、「どのような単位で処理を切り分けるのか」について、関数型プログラミングというパラダイムは特段示唆を与えていません。
オブジェクト指向と関数型を比べて「関数型では処理を適切な単位に切り分ける基準がない」「設計論が確立されていない」みたいな論調を見たことがありますが、これについてはそもそも関数型プログラミングという枠で議論するべきではないでしょう。
例えば(オブジェクト指向設計レイヤーでの)クラスと同レイヤーで使われるだろう機能として、OCamlにはファンクターの機能が、Haskellには型クラスの機能があります。
これらは言語特有の機能のため、個別に使われ方や設計論を分析したうえで比較する必要があるはずです。
オブジェクト指向という言葉がモジュール分割的な使われ方も兼ねているため、この辺は大変分かりづらい状況になっています。
(今回その辺も絡めて記事にしたかったのですが、時間的にも文字数的にもボリュームが多すぎるため断念しました…)

まとめ

アドベントカレンダーなので日付が変わる頃には投稿したかったのですが、既に1時をがっつり回ってしまっています…
というわけで、今回「関数型プログラミングとはなんぞや」というのを自分なりにまとめてみました。
非常に長文になりましたが、これで少しでも関数型プログラミングに対する理解が広まればと思います。

ドワンゴアドベントカレンダー、明日15日は@Kinoppyさんです!
お楽しみに!


2 Comments

  1. 年が明けてからのコメントになってしまいましたが…

    関係あること、ないことがはっきりしていて分かりやすい内容でした。
    あと、ひとつ気になるところがありました。

    > 例えば、命令型言語であるC言語で、以下のようなコードを考えてみます。
    > function sum_sales(sales, sales_length, idx) {
    > if(sale_length >= idx) return 0

    もしかして
    ・C言語ではなくJavaScript
    ・sale_length → sales_length
    ・>= → <=
    ではないでしょうか。

    • kokuyouwind

      2015年1月28日 at 11:01 PM

      コメントありがとうございます。
      ご指摘の通り、Cの文法をかなり忘れていた上に動作確認を行わなかったため、Cの例が色々と間違っておりました。
      修正しgcc -std=c99にて動作確認を行いました、ご指摘ありがとうございました。

コメントを残す

Your email address will not be published.