Elm で関数型言語[超]入門 (実は時々 Elm でやってました)

こんにちは菊池です。久しぶりの登場です。

僕は関数型言語ファンなんですが、仕事で使う機会は (ほとんど) ありません。
今の世の中は手続き型言語からのオブジェクト指向言語が大勢を占めておりこの世の春です。僕は「これ関数型言語で書いたら楽そうだなー」と思いつつも、諸般の事情により実戦投入は憚れることが多い状況です。じっと耐え忍んで過ごしています。

そしてまた静的型付けが好きです。実行前に型チェックを済ませて、実行時にはほぼ落ちることのないプログラム。最高じゃないですか。
とても時間のかかる処理を実行していて、終わりが近づいてきたなーって頃に、些細なコードのミスで nil やら NULL やらで落ちたりすると「おまえもっと早く教えてくれや」と言いたくもなりますが、そういう経験も格段に減る気がします。

そんな思いも人知れず、静かに日々を過ごしておりましたが、僕はあるとき気付いてしまったのです。

「自分だけで完結する案件ならば、投入しちゃってもいいのでは?」

時々ですが短期的な WEB のプロモーション案件を担当することがあります。

  • 開発担当者は自分だけ
  • 期間限定プロモーション(期間後はメンテ不要)
  • WEB のフロントエンドで完結

こういう案件ならば Elm でも大丈夫じゃないか?と思って、一度試しに使ってみたら上手いこと行ったので、それから時々やってます。やってますというか、使ってます。

Elm のいいところ

Elm は関数型言語で、コンパイルすると JavaScript に変換されます。WEB のフロントエンドの開発に特化していて、専用のフレームワークが標準装備されてます。

あくまで個人的な感想ですが、

  • 見た目が Haskell に似た静的型付けな関数型言語
  • 言語仕様がシンプル
  • コンパイルが早い

関数型言語は慣れるにしたがって、最初に型の定義をしっかりしておくと、そこから実装が自然と導かれていくように感じます。

リファクタしたくなってコードを変更すると、コンパイラが変更の必要な場所を教えてくれます。エラーメッセージがわかりやすいので、コンパイラの導きに沿ってコードを修正していくと、リファクタもサクサク進みます。

そして、コンパイルが通ると実行時に落ちることはほとんどないので、大きな安心感があります。

それと、文法が好みなんですよね。

※以上、あくまで個人の感想です

普段使っている言語との違い

ということで、みんな使ってみよう!

と言いたいところですが、いきなり関数型言語に飛び込んだ人たちが苦しんでいる風景を見たことがあるので、言語の雰囲気を感じられるような簡単なサンプルを書いてみました。

サンプルを見る前に、まずはこちらで基本的な文法をざーっと眺めて見てください。大した量はありません。大丈夫です。さくっと眺めちゃってください。

https://elm-lang.org/docs/syntax

さくっと見ました?ざっくり型と関数がわかれば大丈夫。

ちなみに、Elm の値は全てイミュータブルなので、以下の基本の for ループでよく見る i のような変数の中身を変更するようなコードは書けません。

sum = 0
xs = [1, 2, 3, 4, 5]
for (i = 0; i < xs.length; i++) {
    sum = sum + xs[i]
}
return sum

ちょっと最初は窮屈に感じるかも。

関数入門

では最初に、足し算する関数 add を書いてみましょう。

add : Int -> Int -> Int
add a b = a + b

ここではちゃんと関数の型定義も書くようにします。

一行目が関数の型定義で add 関数は Int 型と Int 型を受け取って Int 型を返す関数を表しています。
二行目が関数の中身で、引数の a と b を足し算したものが add になります。

簡単ですね。

ちなみにこの型定義は Int 型を受け取って Int -> Int の関数を返す関数としても読むことが出来ます。 Int -> (Int -> Int) のカッコが省略されているということです。

REPL で試しに動かしてみましょう。

$ elm repl
> add : Int -> Int -> Int
| add a b = a + b
|   
<function> : Int -> Int -> Int

> add 1 2
3 : Int -- 「値 : 型」が出力されています

-- add 関数に引数を一つだけ渡した場合
> add1 = add 1
<function> : Int -> Int -- Int -> Int の関数になっています

-- add1 は引数に 1 を足す関数になります
> add1 2
3 : Int

リスト操作関数を自分で実装してみよう

これから紹介する関数は言語に標準装備されてるので、実際には自分で書かなくても大丈夫です。言語の雰囲気を味わってもらいたいので、あえて自分で書いたコードを紹介しています。

まずは、リストについて簡単に紹介します。他の言語と同じように、リストは [値, ...] で書けます。また、関数型言語でよくある記法で 値::.. としても書けます。 がリストの終わりを表しています。

REPL で書いてみると

$ elm repl
> [1,2,3] -- [] で括るよく見る書き方
[1,2,3] : List number

> 1::2::3::[] -- :: で繋いで [] で終わる書き方
[1,2,3] : List number

> 1::2::3::[] == [1,2,3] -- どちらも同じ
True

僕はこれをみたときに、リンクリストを思い出しました。

さて、リストを書けるようになったところで、リストの最初の要素を返す関数 head を書いてみましょう。List を受け取って最初の要素を Maybe で返す関数です。リストが空だった場合は Nothing、要素があった場合は要素を Just で包んで返します。

オプショナル型が導入された言語も増えたので、Maybe に違和感が無い人も増えたんじゃないでしょうか。

書くとこんな感じ

head : List a -> Maybe a
head list = case list of
  [] -> Nothing
  (x::xs) -> Just x

REPL で実行してみると

$ elm repl
> head : List a -> Maybe a
| head list = case list of
|   [] -> Nothing
|   (x::xs) -> Just x
|   
<function> : List a -> Maybe a

> head [1,2,3]
Just 1 : Maybe number -- リストの先頭要素が Just に包まれて返ります

> head []
Nothing : Maybe a -- リストが空なので Nothing が返ります

関数の中身を簡単に説明すると、

[] の条件でリストが空だったら Nothing を返します。

引数の List を (x::xs) のところで、先頭の要素と2番目以降の要素のリストに分解しています。先頭の要素が x に入って、2番目以降の要素のリストが xs に入るので、先頭の要素 x を Just で包んで返しています。

大丈夫そうですね。

続いて、リストの最後の要素を返す関数 last を書いてみましょう。関数の型は head と一緒ですね。

last : List a -> Maybe a
last list = case list of
  [] -> Nothing
  (x::[]) -> Just x
  (x::xs) -> last xs -- 再帰呼び出し

の条件でリストが空だったら Nothing を返してます。

(x::) の条件で、x が最後の要素だったら Just で包んで返しています。

(x::xs) の条件は x が最後の要素でなかったら、自分自身の last 関数を2番目以降の要素が入ったリスト xs を引数にして再帰呼び出ししています。

長いリストの場合、3番目の条件のところを繰り返すことでリストがだんだん短くなって、残りひとつになったら2番目の条件にマッチして終了します。

REPL で実行してみると

$ elm repl
> last : List a -> Maybe a
| last list = case list of
|     [] -> Nothing
|     (x::[]) -> Just x
|     (x::xs) -> last xs
|   
<function> : List a -> Maybe a

> last [1,2,3]
Just 3 : Maybe number

> last []
Nothing : Maybe a

んー、大丈夫そう?

続きまして、リストの n 番目の要素を返す関数 nth を書いてみましょう。

nth : Int -> List a -> Maybe a
nth n list = case list of
  [] -> Nothing
  (x::xs) -> case n of
    0 -> Just x
    _ -> nth (n - 1) xs -- 再帰呼び出し

[] の条件で引数の List が空だったら Nothing を返しています。

List に要素がある場合で、引数のインデックスが 0 の場合は先頭の要素を Just で包んで返します。それ以外の場合は、ここでも自分自身を再帰呼び出ししています。

指定したインデックスの n が小さくなりつつ、リストが短くなりつつ、n がゼロになったら先頭要素を返して終了。となります。

だんだん、きつくなってきた人もいるかも。

REPL で実行してみると

$ elm repl
> nth : Int -> List a -> Maybe a
| nth n list = case list of
|     [] -> Nothing
|     (x::xs) -> case n of
|       0 -> Just x
|       _ -> nth (n - 1) xs
|   
<function> : Int -> List a -> Maybe a

> nth 1 ['a', 'b', 'c']
Just 'b' : Maybe Char -- リストの 1 番目の要素が Just に包まれて返ります

> nth 100 ['a', 'b', 'c']
Nothing : Maybe Char -- 引数の 100 が範囲外なので Nothing が返ります

> nth -1 ['a', 'b', 'c']
Nothing : Maybe Char -- 引数の -1 が範囲外なので Nothing が返ります

こんな風にループは再起呼び出しか、ループを抽象化した高階関数を使って実装します。最初はこの辺が頭に馴染むまでちょっと時間がかかるかも。

慣れないと再起呼び出しがスッと浮かんでこないんですよね。こんな簡単なことをスラスラと実装できないなんて... 体がムズムズする!!!と入門した頃の僕は感じていました。

しかしやがて慣れてくると、手順を実装するというよりは、定義を実装している感覚に...

※以上、あくまで個人の感想です

以下参考まで

map 関数

map : (a -> b) -> List a -> List b
map f list = case list of
  [] -> []
  (x::xs) -> (f x) :: (map f xs) -- 再帰呼び出し

fold 関数

fold : (b -> a -> b) -> b -> List a -> b
fold f acc list = case list of
  [] -> acc
  (x::xs) -> fold f (f acc x) xs -- 再帰呼び出し

sum 関数再帰版

sum : List Int -> Int
sum list = sum1 0 list

sum1 : Int -> List Int -> List
sum1 acc list = case list of
  [] -> acc
  (x::xs) -> sum1 (acc + x) xs -- 再帰呼び出し

sum 関数高階関数版(上で実装した fold を使うと、すごくシンプル)

sum list = fold (+) 0 list

ちなみに、再帰呼び出しばかりだと、長いリストの時にスタックオーバーフローするんじゃないの!?と思った人もいるかも知れませんが、末尾再帰最適化という手法がありまして、関数型言語ではコンパイラが再帰呼び出しを単純なループに変換してくれるので、大丈夫と思ってもらってほとんど大丈夫。

https://ja.wikipedia.org/wiki/%E6%9C%AB%E5%B0%BE%E5%86%8D%E5%B8%B0#%E6%9C%AB%E5%B0%BE%E5%91%BC%E5%87%BA%E3%81%97%E6%9C%80%E9%81%A9%E5%8C%96

大丈夫慣れます

何年か前に社内の勉強会で Haskell をやったことがあります。ノートパソコンを持って会議室に集まり、 みんなで Haskell 99 だったかな?の問題集を実装する勉強会でした。

勉強会が始まって少し経つと、会議室の中は唸り声で満たされました。

「うーーー、か、書けない。こんな短いコードを...なんで」

僕も含めてみなさん職業でプログラムを書いているベテラン揃いでしたが、最初はちょっと大変でした。でもやってるうちに慣れます。そのうち脳の扉が開きます。

Elm をとっかかりとして Haskell へ行くのもいいと思います(勧誘)

初めて使った時に思ったんですが Elm は言語仕様がとてもシンプルなので、関数型言語に入門するのには良さそうな印象をもちました。

実際に使い始めるとついて回る Maybe 型ですが、Maybe.andThen や Maybe.withDefault といった関数を |> で繋いで書いてみてください。僕は、ここであーなるほどーってなりました。簡単だし便利じゃないか。見た目も良いし。

やがて、もうちょっといろいろやってみたいな、って思ったら Haskell に行くともっと楽しめると思います。そこで、きっと新しい概念や気付きを得ることができるでしょう。

※ 以上、あくまで個人の感想です