関数型言語入門
最近になって、関数型言語が注目されつつあります。Microsoft も Visual
Studio 2010 で F# という関数型言語を採用していますし、他に Haskel,
Scala, Scheme などが人気があるようです。実は大昔か
ら LISP という大御所的な関数型言語もあります(が、純粋な関数型
ではないという見方もあります)。それが、なぜ今になって?ということです
が、1つには計算機能力の圧倒的向上等により、実行速度が実用的になってき
たというのは大きいと思います。逆にいうと、昔は処理系が重くて学習には使
えても実用システムを組むのにはちょっと…ということでした。
しかし、他にも関数型の特徴で注目されているところはいろいろあるようです。
ともかく、手続き型の延長としてのオブジェクト指向プログラミングとは全く
違った世界があるということを知っておくのも大切だと思いますので、ここで
簡単に説明しておきます。
関数型言語とは何か?
関数型言語とは、ラムダ計算というプログラム意味論の概
念をプログラミング言語として具現化したものです。
ラムダ計算とは
λx.x+y
のような記述で、束縛変数を明示して式を記述し、その変形でプログラムの意
味を自動的(機械的)に計算する、ということなのですが、数学的にもかなり
高度ですので、ここでは詳細は略します。
すべての計算は関数の評価によって行われます。関数型言語は、広い
意味ではファーストクラスの関数オブジェクトを持つ言語であり、関
数型言語の多くは、
カリー化、遅延評価などの機能を備えています。
ここでいうファーストクラスオブジェクトは、オブジェクト指向での
オブジェクトとは違って、簡単にいうと、プログラム内で変数に代入したり値
を取り出したり、関数の引数、戻り値に利用できたり、実行時に生成したり組
み合わせたり表示したりできる、という意味です。
…といっても分かりにくいので、イメージでいうと;
- Java 等のオブジェクト指向言語の一部を含めた)手続き型をベースとし
た言語は、プログラムの手順を記述します。つまり、フォン・ノイマン型のコ
ンピュータがメモリにある命令を順番に実行していくのが透けてみえ
る(?けど、もっと人間に優しい)記述によりコンピュータを動作させるもの
と言えるでしょう。いわば How の記述です。
- 一方、関数型言語は、原則として関数の宣言、つまりコンピュータの動作
原理とかはさておいて、問題解決のために必要な情報の記述のみを行ない、具
体的な処理手順は処理系におまかせ、といったものです。いわば What の
記述 です。
ちなみにこのような What の記述によるプログラミングには、他に論理
型というのもありますが、詳細は略します。
- 関数型言語の基本的な思想では、変数は値を変えることがありませ
ん。関数型言語では、原則として関数を呼び出した戻り値を組み合わせて
すべてを完結する、ということです。たとえば、再帰の代表である階乗の計算
は elisp で書くと
(defun fact (n)
(cond ((< n 1) 1)
(t (* n (fact (- n 1))))))
となり、引数の n 以外の変数は必要としません。
実は Java でも同じように
int fact(int n) {
if (n < 1) return 1;
else return n * fact(n-1);
}
と書けるのですが、このような場合は再帰を使わず
int fact(int n) {
int ret = 1;
for (int i = n; i < 1; i--) ret *= i;
return ret;
}
のように書くほうが効率的とされます。ですが、これは思いっきり状態機械と
してのコンピュータの動作を意識しているわけで、関数型はそうじゃなくあく
までも関数式の記述だけに専念して具体的動作は意識しない、というわけです。
関数型言語が注目される理由
処理速度の向上以外に、なぜ今になって注目されてきているか、ということで
すが、1つは Intel core シリーズに代表される CPU のマルチコア化が進んで
きているのをうけて、並行処理を前提とするアプリケーションのための枠
組み が必要とされてきているから、と言えるかもしれません。どういう
ことかというと、機械語(アセンブリ言語)レベルで
TIA 1
AIA 2
のように上位メモリから順番に命令を読み込み実行(この場合はAレ
ジスタを操作)するように、手続き型では、
int a = 1;
i += 2; // i+=2 は i の値を2増やす、の意
と記述するわけで、ここには
処理の順序
が本質的に入っています。ですが、関数型は前述のように CPU の実行モ
デルとは全く違う記述をします。特に原則として変数等のメモリアクセスにた
いする記述をしないのがポイントです(後述)。
手続き記述の問題点
言い方をかえると、オブジェクト指向を含む手続き型プログラムは、常に今の
プログラムの状態をメモリ上の数値として管理しています。例えば
int sum = 0;
for (int i = 0; i < 10; i++) { // i++ は i の値を1増やす、の意
sum += data[i];
}
のような Java のコードの場合、繰り返し(順序)の管理と同時に取
り出す情報の位置管理の2つの意味で i という変数をつかっていま
す。このようなプログラムでの「変数」による状態の管理、つなわち
処理が順番に進むのにともなって値が変化していくので、どこまで計算がすす
んだか、という状況依存の情報が変数には入っている、ということは
並行処理をする上で問題がある場合がある、ということです。もう少しくだい
て言うと、上のループを展開して記述すると
int sum = 0;
int i = 0;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
sum += data[i];
i++;
となるわけですが、i の値は上から処理がすすむにつれて順番に増えていって
いる、ということは、おなじ「i++;」という記述でも実際の値はどの行まで処
理がすすんでいるかによって全く異なる、ということで、あたりまえ
といえばあたりまえなのですが、このような順序に依存してる記述は並列処理
をするときには応用しにくい(順番に処理してはじめて意味のある処理は、同
時並列には処理できない)ということなのです。
ちなみにこの場合は、パイプラインによる平行処理は出来るかもしれません
(詳細は略)。あと、ちなみに最近の Java だと
int sum = 0;
for (int e : data) {
sum += e;
}
のように書けるので(順序を管理していない)多少ましといえるかもしれません。
では、状態をプログラムで管理する問題はというとですが、以下のようなこと
が考えられます。
- まず、上でも少しふれたように、複数のプロセス(あるいはスレッド)で
この状態変数を参照したり書き換えたりする場合の排他制御が難しいというこ
とです。
なので、難しい場合はなくせばよい、というのがある意味達観で、歴史的に
GOTO をなくしてスパゲティコードをなくそうとしたり、グローバル変数をなく
してバグの混入の機会をへらす、というのと同じかもしれません。つまり、プ
ログラムで状態の管理を出来なくしてしまうことを試みるわけです。
- 関連して、排他制御をへたにするとスケーラビリティを阻害する (CPU の
クロックをあげたりコアを増やせばその分プログラムは早くなってくれなくな
る)というとです。
- さらに、1つめに関連するかもしれませんが、ソフトウェアの規模や処理
の複雑さが今のプログラミング環境で管理できる範囲を越えそう(なので新し
いパラダイムが求められている)、というムードがあるのかもしれません。
引数としての関数
上で関数を引数とできる、という説明をしましたが、それで何が便利になるか
といえば、汎用のテンプレートを用意しておいてそれを便利につかう
ことができるようになるからです。Java でも java.Util.Array の sort メソッ
ドなどで無理やり(?)やっているように、並び替えの対象(データ列)
と、大小関係を判断する関数を引き数として渡して並び替え処
理を実行させることができるわけです。
カリー化
上に関連して、関数型言語では関数を簡単に利用するための仕組みをもってい
ます。その代表的なものがカリー化です。カリー化とは、n 引数の関
数を、1 引数の関数の戻り値として 1 引数の関数を返すことを n 回くり
かえすことで作成することです。…といっても分かりにくいと思いますが、
これはラムダ計算と密接につながった複雑な機構です(詳細は少しだ
け後述します)。ともかく、他の手続き型言語、たとえばCで
関数ポインタを使って関数自体を処理の対象にするのは、それなりのプログラ
ム経験をもった技術者には便利な機能だったとしても、初心者には非常に理解
し辛いものなのですが、関数のカリー化は関数ポインタに比べると概念さえ理
解してしまえば非常に便利なものだと思います。
LISP
ここでは、関数型の代表例としての LISP を外観してもらうことで、関数型言
語に少しだけ慣れてもらおうと思います。逆に、LISPを語る上で、必ず、LISP
は関数型言語であるという説明が出てきます。では、関数型言語とはどのよう
なものかということで、上でも難しい説明がありましたが、Javaなどの関数型
以外の言語と比較するとわかりやすいと思います。Javaなどの言語では、処理
を行う関数(Javaではメソッドですが、便宜上、ここではメソッドも関数と表
記します)を定義し、その中で必要な変数を定義し、変数に結果を入れ出しし
ながら必要な結果を求めていくかと思います。これは、関数は変数の状態を変
えていく手続きと考えることができます。
それに対して、関数型言語の基本的な思想では前述のとおり変数は値を変える
ことがありません。関数型言語では、関数を呼び出した戻り値を組み合わせて
すべてを完結していきます。
ただ、実際に変数の値を変えられないのは不便なため、Common Lispなどの多く
のLISP系言語では、変数の値を変更する機能は持っています。ですが、プログ
ラミングする際の基本的な思想は変わらないです。
Javaなどの言語になれている人からすると変数の値は変えないと言うことはピ
ンとこないかもしれませんが、関数型言語とは副作用的に生じる変化
ではなく、関数の戻り値を使って動作するプログラミングを書いていく手法・
考え方だ、と当面は理解しておいてください。ちなみにLISPは、現在存在する、
いるいろな関数型言語のなかで最初のものですので、最初に学ぶ価値はあると
思います。
LISPクロージャ
ここまでで、LISPの概観を説明してきましたが、ここからはLISPの中身につい
て見ていきます。LISPの言語特徴には、「LISPは式と文を区別しない」や
「LISPのプログラムはすべてリストで出来ている」などいったものが
ありますが、それらの意味や特徴を説明するのは大変ですので、ここでは他の
言語と比べて典型的に関数型の特徴であるといえるLISPの「クロージャ」
の例を示して、LISPの長所を知ってもらえればと思います。
クロージャとは、関数自体を数値データなどと全く同様に式の戻り値とし
て扱える機能のことです。言葉ではわかりづらいですが、次から説明する
例を見てもらうと理解していただけると思います。
まず、クロージャを使わない簡単な例で「引数に1を足した値を返す」関数を、
LISPと比較のためにJavaで書いてみます(構文の説明については、省略しま
す。簡単な例なので、イメージだけをとらえてください)。
- Java
public int tasu(int x) { return x + 1;}
- LISP
(defun tasu(x) (+ x 1))
この例の場合にはどちらにしても大して違いはないかもしれません。では、次
に「引数に1を足す関数を返す」関数を考えてみましょう。先ほどの例と違うと
ころは、今度は、値を返すのではなく、関数を返すところです。どう
ですか?Javaの場合は、そのまま関数を返すことはできないので、関数のクラ
スをつくってあげないと実現できません。
- Java
// 1を足す関数クラスを返す操作
public function plusFunction() {
return new function();
}
// 1を足す関数クラスの定義
class function {
int plusOne(int x) {
return x + 1;
}
}
本来やりたいことは簡単なことなのですが、その割には記述がくどい、と言え
るかもしれません。では、LISPではどうかというと、LISPでは関数を数値など
のデータと同列に扱えるクロージャの機能があるため、関数を返す関数を以下
のように簡単に作れます。
- LISP
(defun plusOne()
;;1を足す関数を返す
#'(lambda(x)
(+ x 1)))
圧倒的にこちらのほうが簡単に記述できています。もちろん、このようなこと
はCでは関数ポインタをつかってもできます。しかし、LISPのクロージャがこれ
らと違うのは、CやJavaでは、あらかじめ静的に定義された関数やクラスの
実体 しか返さないのに対して、LISPではプログラムの中で動的に関
数を作って返す ことができるということです。LISPはこのクロージャの
機能があるため、プログラムを非常に柔軟に作れるようになっています。
別の例として、3倍する関数は LISP の場合、名前をつけるのは必須ではないので
(lambda (n) (* 3 n))
で無名関数が定義でき、この関数に対して複数のパラメータを一気に(ルー
プをまわさずに!)適応させたい場合
(mapcar (lambda (n) (* 3 n)) '(3 4 5 6 7)) ; elisp の場合
のようにすることで、 (9 12 15 18 21) というリストが一度に得られます。
http://www.sksk.info/lazy-lisp.html で動かすには
(map (* 3) (list 3 4 5 6 7))
とすればいいようです。
ちなみにこれは カリー化の例にもなっています。どうい
うことかというと、「*」(掛け算)は本来引数は2つなのですが、
「(* 3)」とすることで、2引数のうちの1つを3に固定して、1引数の(こ
の場合は「3倍する」という)関数になって、その引数として
3,4,5,6,7 のそれぞれを map (作用させる)ということをしている
わけです。
いずれにせよ、ここでは5つの要素に対してその処理の順序はまっ
たく問わない、ということが本質ですので、この処理系がマルチ CPU (スレッ
ド)に対応していれば、並行処理がそのまま素直に記述できていることに
なる わけです。
まとめ
ここまでで、関数型言語の例ということで、ほんの少しですがLISPについて述
べてきましたが、雰囲気だけでもつかんてもらえればと思います。LISPは古く
からありますが、古いからと言って使えない言語ではありません。むしろ、
LISPに代表される関数型言語の思想(パラダイム)やプログラミング手法など
は、現在の言語を理解し活用する上でも参考になる部分は多いと思います。実
際、クロージャは Python, JavaScript などではよく使われていますし、その
意味では関数型とのハイブリッド言語、と言えるかもしれません。機会があれ
ば、関数型言語について各自で掘り下げてみてください。
講義用スタイル
印刷用スタイル