💡

[超入門] FizzBuzzで考える関数型プログラミング学習を純粋関数型言語でやる理由

2021/08/01に公開

先日、関数型プログラミングはまずは純粋関数型言語を用いて、考え方から理解しよう
と言う記事を書かせていただきました。納得の声をたくさん頂きましたが、それでは純粋関数型プログラミング言語を闇雲に初めて勘所がわかった!とはすぐにはならないと思い、順調にステップアップするための記事を書こうと思いました。また、考え方が違うだけで関数型プログラミング言語を学んだり使用する理由は特にないとの意見をいただいたので、改めて実例と共に関数型プログラミング言語を確認していただければなと思います。

今回の記事ではFizzBuzzとElmを利用して解説をしていきたいと思います。FizzBuzzは使い古された例ですが、それ故に誰でもロジックや結果が理解できる、考え方の違いを比べるために十分活用できるため使用させていただきました。Elmはオンラインの実行環境で、誰でも実行・編集が可能であり、構文がシンプルで実用的なものづくりにも発展できるため使用させていただいております。

冒頭の最後にお願い事ですが、この記事を読んだり、実際に関数型プログラミング言語を使い始めて、わからないところなどがあれば是非コメントやTwitterにて教えてください。さらに発展した記事や本の執筆の参考にしたいと思っています。

手続き型の考えを利用したFizzBuzz

実行例

例えば皆さんが、JavaScript(TypeScript)で以下のようなコードでFizzBuzzを書いたとしましょう。

for(let i = 0; i < 100; i++) {
	const x = i + 1;
	
	if(x % 15 == 0) {
		console.log("FizzBuzz");
	} else if(x % 3 == 0) {
		console.log("Fizz");
	} else if(x % 5 == 0) {
		console.log("Buzz");
	} else {
		console.log("" + x);
	}
}

おそらく、コードを上から下に向かって読み込み、以下のようなステップ(スナップショット)を頭の中でシミュレーションしながら、コードを書いたり実行したりすることでしょう。

さらに開発の現場では、デバッガを用いて、今どこまで進んでいて、どんなメモリの状態かを確認しながら行うのが手続き的なスタイルとなっていきます。

関数型の考えを利用したFizzBuzz

実行例

初見、ウッとなるかもしれませんが、現段階ではサラッと読み流す程度で構いません。後できちんと解説して行くので安心してください。

main =
    ul [] <|
        List.map
            (\x ->
                li []
                    [ text
                        (if modBy 15 x == 0 then
                            "FizzBuzz"

                         else if modBy 3 x == 0 then
                            "Fizz"

                         else if modBy 5 x == 0 then
                            "Buzz"

                         else
                            String.fromInt x
                        )
                    ]
            )
        <|
            List.range 1 100

関数型プログラミングでの考え方は、関数(ルール)と値を組み合わせて、目的とする値に変換して行くパズルのような感覚で考えていきます。

ルールその1 数字は、FizzBuzzのルールに従って、文字列に変換される。

-- Int -> String
1 -> "1"
2 -> "2"
3 -> "Fizz"
4 -> "4"
5 -> "Buzz"

コードとして抜粋すると以下の部分です。

(if modBy 15 x == 0 then
	"FizzBuzz"

 else if modBy 3 x == 0 then
        "Fizz"

 else if modBy 5 x == 0 then
        "Buzz"

 else
        String.fromInt x
)

ルールその2 文字列は、li要素として変換される

-- String -> Html msg
"文字列" -> li [] [ text "文字列" ]
-- これと同義です。 <li>文字列</li>

コードとして抜粋すると以下の部分です。

(\x ->
    li [] [ text (ルール1 x) ]
)

ルールをリストに適用する

このルールが大きく手続き方と異なるポイントになります。手続き方がカウンタ変数iを更新して行ってステップごとに実行していったことに対して、Elmでは定義したルール(関数)をより大きな値リストに適用していきます。イメージとしては以下のような感覚です。

ルール1を1-100までの数字のリストに対して適用する

-- List Int -> List String
[ 1, 2, 3, 4, 5, ..., 100 ] -> [ "1", "2", "Fizz", "4", "Buzz", ..., "Buzz" ]

ルール2をルール1を適用したリストに対して適用する

-- List String -> List (Html Msg)
[ "1", "2", "Fizz", "4", "Buzz", ..., "Buzz" ] -> [ li [] [text "1"], li [] [ text "2" ], li [] [ text "Fizz" ], ..., li [] [ text "Buzz" ] ] 
-- 最終的には、 <ul><li>1</li><li>2</li><li>Fizz</li></ul> のようになります。

コードとしては以下になりますが、ルール1とルール2は一度に適用されています。

List.map
   (\x ->
       li []
           [ text
               (if modBy 15 x == 0 then
                   "FizzBuzz"

                else if modBy 3 x == 0 then
                   "Fizz"

                else if modBy 5 x == 0 then
                   "Buzz"

                else
                   String.fromInt x
               )
           ]
     )
     <|
     List.range 1 100

手続き型の考え方のメリット

まずは、手続き方の利点について考えていきましょう。成果物が小さく、とにかく実行しながら完成をいち早く目指し、その後はメンテナンスをしないスクリプトなどには向いていると考えます。また、ハードウェアを使うIOTや電子工作の場合には、とにかく外部から入ってくる入力の値を見て解析しながらプログラムを完成させる、また、リソースが限られておりメモリやCPUの効率をなるべく考えながらプログラミングしたいときには手続き型で常にメモリとコードが近しい関係になっているコーディングが好ましいと考えます。

手続き型の考え方のデメリット

より大きなコードに発展させていきたい、変更が多い、このようなプログラムを組むときには注意が必要です。

例えば、最初のコードの例でconsole.logの繰り返しが多い、リファクタリングをしたいな・・・と考えるとします。

for(let i = 0; i < 100; i++) {
	const x = i + 1;
	
	if(x % 15 == 0) {
		console.log("FizzBuzz");
	} else if(x % 3 == 0) {
		console.log("Fizz");
	} else if(x % 5 == 0) {
		console.log("Buzz");
	} else {
		console.log("" + x);
	}
}

そこで以下のように書き換えた場合には、書き換えであり、リファクタしたとは言い難いと言えます。このようなプログラムのため、リファクタしたと言ってしまいそうな内容ですが、この前後のコードが正しいと判断するのは、実行結果を確認した人間になります。

for(let i = 0; i < 100; i++) {
	const x = i + 1;
	let result: string;
	
	if(x % 15 == 0) {
		result = "FizzBuzz";
	} else if(x % 3 == 0) {
		result = "Fizz";
	} else if(x % 5 == 0) {
		result = "Buzz";
	} else {
		result = "" + x;
	}
	
	console.log(result);
}

また、テストを書けば解決はしますが、テストできる状態にするまでにリファクタではなく、何ステップか書きかを要します。

function fizzBuzz(x: number): void {
   if(x % 15 == 0) {
	console.log("FizzBuzz");
   } else if(x % 3 == 0) {
	console.log("Fizz");
   } else if(x % 5 == 0) {
	console.log("Buzz");
   } else {
        console.log("" + x);
   }
}

for(let i = 0; i < 100; i++) {
	const x = i + 1;
	fizzbuzz(x);
}

このステップで初めて、fizzbuzzに関するテストを記述することができます。もちろん実際のプロダクトでの書き換えは、さらに難しく、リファクタの本が何冊も出るくらい高度な技術を要されます。

function fizzBuzz(x: number): string {
   let result: string;
   
   if(x % 15 == 0) {
	result = "FizzBuzz";
   } else if(x % 3 == 0) {
	result = "Fizz";
   } else if(x % 5 == 0) {
	result = "Buzz";
   } else {
        result = "" + x;
   }
   
   return result;
}

for(let i = 0; i < 100; i++) {
	const x = i + 1;
	console.log(fizzbuzz(x));
}

関数型の考え方のデメリット

先にデメリットについて触れますが、これは手続き型のメリットの反対になります。関数型スタイルで書く場合、ルール(関数)を定義して組み合わせて行くスタイルになるため、そのメモリ効率が良いか悪いかは、コードで制御するのではなく、コードがどのようにコンパイルされるかどのように実行されるかに比重が大きく寄ってしまいます。さらに実行しながらコードを書き換えて行く、ハードウェアプログラミングには向いていないと考えられるでしょう。

関数型の考え方のメリット

値を変換するルールについて記述して行く言語なので、常に関数は値を返します。そのため、いつでも後付けでテストが可能です。(もちろんテストファーストに書くことにも特化しているため、後で解説します)

以下が全体像でしたが、FizzBuzzに変換するルールを切り出すのは、単に切り取りをすれば済みます。

main =
    ul [] <|
        List.map
            (\x ->
                li []
                    [ text
                        (if modBy 15 x == 0 then
                            "FizzBuzz"

                         else if modBy 3 x == 0 then
                            "Fizz"

                         else if modBy 5 x == 0 then
                            "Buzz"

                         else
                            String.fromInt x
                        )
                    ]
            )
        <|
            List.range 1 100

ここでは、少しマニアックになってしまうので省略しますが、elm-testでは、Htmlと言うデータに対するテストを書くことができます。本当にリスキーなリファクタをする場合には、安全に舵を切る選択肢が用意されていると言うのは心強いです。

main =
    ul [] <|
        List.map (\str -> li [] [ text fizzbuzz x ]) <|
                List.range 1 100


fizzbuzz : Int -> String
fizzbuzz x =
    if modBy 15 x == 0 then
        "FizzBuzz"

    else if modBy 3 x == 0 then
        "Fizz"

    else if modBy 5 x == 0 then
        "Buzz"

    else
        String.fromInt x

また、TDDでテストファーストに開発を行いたい場合には、fizzBuzzのテストを書いたり、指定した範囲の数値に対する関数を用意してしまえば、そのテストを書くことができます。

fizzbuzzRangeTest : Test
fizzbuzzRangeTest =
    describe "fizzbuzzRange test"
        [ describe "1-15までの数字は"
            [ test "1 2 Fizz 4 Buzz Fizz " <|
                \_ ->
                    fizzbuzzRange 1 15
                        |> Expect.equal [ "1", "2", "Fizz", "4", "Buzz", "Fizz", "7", "8", "Fizz", "Buzz", "11", "Fizz", "13", "14", "FizzBuzz" ]
            ]
        ]
fizzbuzz : Int -> String
fizzbuzz x =
    if modBy 15 x == 0 then
        "FizzBuzz"

    else if modBy 3 x == 0 then
        "Fizz"

    else if modBy 5 x == 0 then
        "Buzz"

    else
        String.fromInt x


fizzbuzzRange : Int -> Int -> List String
fizzbuzzRange lo hi =
    List.range lo hi |> List.map fizzbuzz

関数型プログラミングを学ぶ際に純粋関数型言語を使うべき理由

さて、ここまで手続き型と関数型プログラミングの考え方を比較していきました。これは前回の記事の主題の部分ですが、例えば今回、手続き型プログラミングの代表としてTypeScriptを利用しました。ですが、関数型プログラミングは考え方に過ぎないので、もちろんTypeScriptで関数型プログラミングをすることは可能ですし、テスタビリティを考えると、その考え方を取り入れるのはごく自然です。ですが、学ぶ時は騙されたと思って是非、純粋関数型プログラミング言語を利用してください。

何故かと言うと、例えばFizzBuzzの文字列を作るためにワンライナーでTypeScriptでも関数型プログラミングができる!と主張されるかもしれません。ですが、List.range 1 100と書いていた部分が、[...Array(100).keys()].map(i => i + 1)このように無理やりテクニックによって、たまたま生成できているに過ぎません。

console.log([...Array(100).keys()].map(i => i + 1).map(x => fizzbuzz(x)).join("\n"));

rangeだけではなく、このような、ルール1とルール2をmapを2回に分けて適用しているコードをルール1とルール2を合わせてルール3を作ってしまう。つまり、関数合成を利用してリファクタすることもできます。

 ul [] <|
        List.map (\str -> li [] [ text str ]) <|
        List.map fizzbuzz <|
                List.range 1 100

ルール1 >> ルール2 これが、関数合成の例になります。

ul [] <|
        List.map (fizzbuzz >> (\str -> li [] [ text str ])
        <| List.range 1 100

TypeScriptでは、rangeと言う関数を別途定義したり、関数型プログラミングをするためのライブラリを導入して〜と言うふうに工夫することができるかもしれませんが、そこにリソースを割き過ぎてしまうと、その先にあるテストの工夫の仕方、関数の良い分け方、抽象化の手法などを学ぶための時間に割り当てられなくなってしまいます。それであれば、range関数は、再帰を利用して生成しているのか〜。と模範解答を見れる状態の方が有利と考えます。

range : Int -> Int -> List Int
range lo hi =
  rangeHelp lo hi []


rangeHelp : Int -> Int -> List Int -> List Int
rangeHelp lo hi list =
  if lo <= hi then
    rangeHelp lo (hi - 1) (cons hi list)

  else
    list

まとめ

前回の記事でも書きましたが、関数型プログラミングを難しい・障壁だと感じるのは当然です。考え方が違うので脳の使い方が異なります。しかし、その先に学んだ上で生かせる知識を獲得するために、純粋関数型プログラミングを利用した学びに首を突っ込んでみてはどうでしょうか! 冒頭で書いた通り、理解が難しいところはいくらでも記事や本を執筆する気持ちなので、是非どんなところで躓いたか教えてください!一緒に学んでいきましょう!

Discussion