ElixirでFizzBuzz, 総和(再帰)をやってみた


はじめに

プログラミングElixirを読んでElixirの面白さを知ったので、誰かにも共有したいと思って記事を書きました。
Elixirの紹介, FizzBuzz, 総和(再帰)を通してちょっとでもElixirのことを知ってもらえたら嬉しいです。

Elixirとは?

  • 登場 2012年
  • 強い動的型付けの関数型言語
  • 並行処理に強い
  • Erlangの仮想環境 (BEAM)で動作
  • LISPのようなマクロによるメタプログラミング
  • Clojureのようなプロトコルによるポリモーフィズム
  • Rubyのような文法
  • PhoenixというWeb FWがある

箇条書きの特徴をみても、一体どんな言語なのかよく分かりませんね。
JavaやJavaScript, Pythonなどの言語を触ってきた私にとって、よく分からない概念が多くて知的好奇心をくすぐります。

FizzBuzz

ここからは実際にコードを書いていきます。
環境はpaizaを使いました。

fizzbuzz.exs
fizzbuzz = fn
  n when rem(n, 3) == 0 and rem(n, 5) == 0 -> "FizzBuzz"
  n when rem(n, 3) == 0 -> "Fizz"
  n when rem(n, 5) == 0 -> "Buzz"
  n -> n
end

1..15
|> Enum.map(fizzbuzz)
|> IO.inspect

シンプルでコンパクトにまとまりました。
以下、解説になります。

fizzbuzz関数

Elixirには無名関数と名前付き関数がありますが、ここでは無名関数を使いました。
基本的な構文は以下のようになります。

fn a, b -> a + b end

fizzbuzz関数では無名関数に加えて、ガード節と複数のボディを取り入れています。
Elixirでは一つの関数に対して複数のボディを定義することができ、ガード節(when 条件)やパターンマッチングを使うことで、引数ごとに分岐することができます。
FizzBuzz, Fizz, Buzz, それ以外で関数のボディを分けることで、一つ一つの関数が短くなりました。

パイプライン演算子

fizzbuzz関数の呼び出しではパイプライン演算子(|>)を使っています。

arg
|> functionA
|> functionB
|> functionC

引数をfunctionAに渡し、その結果をfunctionBに渡す、といったことを順番に行っていきます。
よくPythonなどでは以下のように関数の入れ子になって、読みづらいことがありますよね。

functionC(functionB(functionA(arg)))

Elixirでは関数を使って値から別の値へ加工するという関数型の考え方を、パイプライン演算子を使うことで実感することができます。

総和(再帰)

コードは以下のようになります。

sum.exs
defmodule Math do
    def sum([], accumulator), do: accumulator
    def sum([head | tail], accumulator), do: sum(tail, head + accumulator)
end

IO.puts Math.sum([1, 2, 3, 4, 5], 0)

パターンマッチング

今回はMathモジュールにsumという名前付き関数を定義しました。
その引数に着目すると、[]と[head | tail]があります。
この二つはパターンマッチングを使って分岐させています。

実はElixirには代入という概念がありません。
その代わりにパターンマッチングを使います。

# 変数xに1をマッチングする。
# 左辺が変数の場合は、両辺の値は必ずマッチングするようになる。(xに1が束縛される)
x = 1

# 1とxをマッチングする。
# xには1が束縛されているのでマッチングする。
1 = x

# 2とxをマッチングする。
# xには1が束縛されているのでマッチングせず、MatchErrorが発生する
2 = x

上記のようにパターンマッチングは左辺と右辺が等しいかを判定します。
変数束縛はパターンマッチングの過程で行われます。

パターンマッチングは関数の引数でも行われます。
パターンマッチングする引数が定義してある関数のボディが実行されます。

headとtail

elixirではリストを先頭とそれ以外に分けるためのcons演算子(|)があります。

# head = 1
# tail = [2, 3, 4, 5]
# [ 1 | [2, 3, 4, 5]]
[head | tail] = [1, 2, 3, 4, 5]

つまりsum関数では[]と[head | tail]を使って、リストが空の場合とそうではない場合のパターンマッチングを行っています。

末尾最適化

accumulatorという引数を定義しました。
この引数を使わなくても再帰は以下のように書けます。

defmodule Math do
    def sum([]), do: 0
    def sum([head | tail]), do: head + sum(tail)
end

IO.puts Math.sum([1, 2, 3, 4, 5])

ではなぜaccumulatorを使っているかというと末尾最適化のためです。
上記の書き方だと以下のように展開されます。

1 + sum([2, 3, 4, 5])
1 + 2 + sum([3, 4, 5])
1 + 2 + 3 + sum([4, 5])
1 + 2 + 3 + 4 + sum([5])
1 + 2 + 3 + 4 + 5 + sum([])
1 + 2 + 3 + 4 + 5 + 0

この場合ですと、呼び出し元の関数は呼び出し先の結果がわかるまで計算はできません。
最後の再帰をした後に、呼び出し元に値を返すような形になります。
しかし、accumulatorを使う場合は以下のようになります。

sum([2, 3, 4, 5] 0 + 1)
sum([3, 4, 5] 1 + 2)
sum([4, 5] 3 + 3)
sum([5] 6 + 4)
sum([] 10 + 5)
15

sum関数は呼び出し先の結果を待たなくても、次に渡しているので最後の再帰を呼び出した時には結果が求められています。つまりは呼び出し元の状態をスタックする必要がなくなり、メモリを食うことがなくなります。
これらの最適化を末尾最適化とよび、関数型言語ではサポートしているみたいです。

C言語などのforループを使ったアプローチと比較

int list[] = {1, 2, 3, 4, 5};
int sum = 0;
for (int i = 0; i < 5; i++) {
    sum = sum + list[i];
}

Elixirでは上記のような書き方ができません。
なぜならforは関数であり、変数は不変性を持つためだからです。

不変性

Elixirでは変数は不変性です。
例えば[1, 2, 3, 4, 5]となっているリストの1番目の要素を更新したい、といったことができません。なので、新たな配列を作る必要があります。
また関数に引数を渡した場合は、参照の中身はコピーされ、呼び出し元と呼び出し先では別物となります。
関数から呼び出し元の参照の中身を変えることはできません。

Elixirではforは関数になるので、sumの値を書き換えることはできないです。

参考

まとめ

簡単なfizzbuzzや総和(再帰)だけでもElixirの関数型言語としての側面が見られて、とても勉強になります。

最後にプログラミングElixirからお気に入りの言葉を引用して締めます。

この本は、考え方を変えることについての本だ。つまり人びとがプログラミングについて言うことのいくつかは、それがすべてではない、と受け入れることについての本だ。
-- 中略 --
コードを書くのにたった一つの方法なんてない。でもElixirはメインストリームとはだいぶ違うやり方をする。だからこれを学ぶことは、今よりも広い視点を与えてくれる。そうして、プログラミングについての新しい考え方へと、あなたの心を開くだろう。