『プログラミング in OCaml』で関数型言語に入門
感想
関数型言語を学ぶため, 『プログラミング in OCaml』を読みました (出版社のページ). 関数型言語や関数型プログラミングといった言葉を何度か耳にして気になっていたので入門してみることにしました.
OCaml という(マイナー?)な言語を選んだのにはそれほどの理由はありません. C++を知っていれば Java や C# など他のオブジェクト指向言語もおおよそ似たようなものに感じられるように, 関数型言語を 1 つ学べば他の理解もスムーズになるだろうと思ったという程度です.
関数型言語ならではの考え方に触れたり,他言語に輸入されたであろう機能を見たりして楽しめました. 後半で詳しく書いていますが, 木のようなデータ構造を型で表現できる機能や再帰を使ったエレガントな書き方には感動しました. 基礎から説明してあるので, 関数型言語を学んだことのない方にもおすすめの本です.
印象に残ったこと
いくつかピックアップして印象的な点を振り返ります.
OCaml の歴史
ML というプラグラミング言語の処理系の 1 つらしいのですが, 祖先に当たる ML は元々コンピュータで数学の手切りを証明するためのシステムに起源を持つ言語のようです. この説明だけでも, ML やその派生言語が C 言語系(ALGOL 系?)の言語と出自が異なるということが感じられます.
関数型言語では「プログラムを実行する = 関数を実行してその解を得る」という捉え方です. プログラムは複数の関数の組み合わせであり, その関数を実行していくことがプログラムの目的ということです. これが関数型と言われる所以だと思います.
強力な型推論
型推論自体は多くのメジャーな静的型付け言語に備わっている機能だと思います. 本書では, OCaml は基本的に必要がなければ型は書かなくて良いというスタンスで, これは型推論が言語の中心的な機能として最初から考えられていたからこそのものだと思いました.
例えば C++ にも 型推論の auto はあります. これは C++11 から追加された機能で, 便利ですが乱用するのは良しとされない印象があります. あまりにも長い型名(iterator など)や冗長な型宣言を省略するというあくまでも補助的な機能のように感じます.
再帰
OCaml ではとにかく再帰が頻出です. リストは定義自体が再帰的なので再帰的処理が向いているのですが, 本書の 5 章でもリストを扱う練習が取り上げられています.
リストと同等のものは他の言語にもあると思います. 定義は以下のようにします.
let l = [4; 3; 5; 2; 1];;
リストに対する再帰的処理の例を示すため, リストの最大値を求める関数を考えましょう. 最大値は「先頭の値と残りのリストの最大値」というように再帰的に定義できます.
let rec max_in_list l = match l with
[] -> 0
| v :: rest -> max v (max_in_list rest);;
(* 引数が1つの場合は省略して次のように書くこともできます *)
let rec max_in_list = function
[] -> 0
| v :: rest -> max v (max_in_list rest);;
max_in_list はリストを引数にとる再帰関数です. 再帰関数は定義の先頭に rec を付けます. match 式はパターンマッチの構文で, OCaml の目玉機能の 1 つです. リストが空の場合の条件分岐, 先頭要素と残りの分離に使っています.
max_in_list は次のようにして使います. # から始まる行が REPL への入力で, その次の行がレスポンスです.
# let l = [4; 3; 5; 2; 1];;
val l : int list = [4; 3; 5; 2; 1]
# max_in_list l;;
- : int = 5
本書で紹介されるリストに対する処理の例では他に結合や反転などがあるのですが, どれも簡単な操作なのに再帰で書くとなると一瞬手が止まってしまいました. 普段ならこういった操作は for や while を使うので, 発想を変える必要があり頭の体操になりました. 新しいプログラム言語を学ぶときの楽しみの 1 つです.
ちなみに本書では再帰を使うためのモットーとして「how ではなく what を考えよ」(p.56)という言葉が紹介されています. これは何かを計算したいときその方法を考えるのではなく, 計算対象がどういう性質を持ったものなのかを考えるということです. 上記の max_in_list も, for で書く以下のような方法に比べて, 最大値というものの定義をよく表したものになっていると思います.
int mx = 0;
for (int v : l) if (v > mx) mx = v;
ヴァリアント
ヴァリアントは複数の型を持つ型だと思うのですが, 本書ではその仕組みと使いみちが多岐にわたるため一言で説明するのは難しいと書かれています. 具体例を見たほうが理解が早いと思います.
例として図形を扱うことを考えます. 図形には点, 円, 長方形など様々な種類があり, 1 つの型で全てを表現するのは難しそうです. そこで OCaml では次のようにします.
type figure =
Point
| Circle of int
| Rect of int * int
| Square of int * int
figure が新しく宣言された図形を表す型で, その中には点や円などの種類があるということが表現されています. 例として figure の面積を計算する関数 area は以下のようになります.
let area = function
Point -> 0
| Circle r -> 3 * r * r
| Rect (width, height) -> width * height
| Square width -> width * width;;
figure が 4 種類の図形を持つので,figure を扱う関数も 4 通りの場合分けが必要です. ちなみに, 簡単のため円周率は 3 としました.
オブジェクト指向ではポリモーフィズムやダックタイピングによって実現する処理ですね. ヴァリアントには更に強力な使い方があるので次で紹介します.
ヴァリアントによる木構造の表現
二分木を考えます. 二分木は以下のように再帰的に定義することができます.
- 空の木は二分木である (葉)
- 2 つの二分木をノードの子要素として付け加えたものは二分木である
これを表すヴァリアントは以下のようになります.
type 'a tree =
Leaf
| Node of 'a * 'a tree * 'a tree;;
'a は C++ で言うところのテンプレートの typename T です. int や char など, 様々な型を 'a として表しています.
Leaf は何も要素を持たず, Node は自身の値と左の子, 右の子を持ちます. このヴァリアントを使った二分木は以下のように定義することができます. 画像の木を表しています.
let tr = Node(4,
Node(2, Node(1, Leaf, Leaf), Node(3, Leaf, Leaf)),
Node(5, Leaf, Leaf));;
例として, 二分探索木から要素を検索する関数 find を考えてみましょう. 二分探索木とは 左の子 < 親 < 右の子 という大小関係になっている二分木のことです.
find 関数の挙動は次の通りです.
- ツリーが葉なら false
- ツリーがノードで, ノードの値が検索対象と等しいなら true
- ツリーがノードで, ノードの値が検索対象と異なるなら左の子, 右の子に対して検索
let rec find tr x =
match tr with
Leaf -> false
| Node (v, left, right) when v = x -> true
| Node (v, left, right) -> (find left x) || (find right x);;
木構造を型として表現できるという点が非常に新鮮で感銘を受けました. 二分探索のアルゴリズムもエレガントに表現されていると感じます.
これまでの例を通じて, ヴァリアントやパターンマッチの雰囲気が伝わったのではないかと思います (もちろん, 本書の中では詳しく説明されています).
他言語にある機能
関数型言語や OCaml ならではの特徴として紹介されながらもオブジェクト指向言語でも見られるような機能もありました.
- 高階関数: 関数を引数や返り値にできる機能 -> C++ の Lambda, C# の LINQ など
- レコード: 不偏のデータの組を表す型 -> C# の record, kotlin の data class など
- オプション型: 値を持つ, または持たないことを表す型 -> Rust の Option 型など
おそらくこういった便利な機能が他言語に輸入されたのだと思います. 将来的には関数型とオブジェクト指向型の境界がより曖昧になっていくのかもしれません.
結び
上記で触れた内容以外にも, カリー化, 式の評価戦略(call-by-value/name/need)など面白いトピックがありました. 関数型言語に入門するという目的は果たされたと満足しています.
ちなみに, OCaml の O はオブジェクトの O で, OCaml にもオブジェクト指向的な機能があります. しかし仕様がそれほど定まっていなかったり, 使われていないプログラムの方が多かったりして, メインの機能ではない印象を受けました.
関数型言語に触れるのが始めてだったので新鮮さを感じることは多かったのですが, 一方で, オブジェクト指向言語との決定的な違いについては未だによく分かっていません.
プログラミング言語は道具なので目的によって使い分けるのが良いと思っているのですが, 関数型言語はこういった処理に向いているというようなもののイメージが浮かびません. 例えばゲームはオブジェクトが相互作用するものなのでオブジェクト指向がぴったりだと思うのですが, 関数型言語はどうでしょう. なんとなくコンパイラなどの言語処理系に向いているような気がしたのですが, その理由を言語化できるほどには関数型言語への理解がまだないようです.
これはと思うような機能やコンセプトがあり, 可能性を感じたので今後も関数型言語を勉強したいと思います.