Javaでもう一度学び直すオブジェクト指向プログラミングを関数型プログラミングで考え直す 〜第1章〜

7 min read読了の目安(約6800字

この記事は、Software Design 2021年3月号に掲載されているJavaでもう一度学び直すオブジェクト指向プログラミングと言う記事を関数型プログラミング言語Elmで考えたらどうなるだろう?と考えてみた記事です。

何故このような記事を書いたかと言うと、かつて幾度と無くオブジェクト指向プログラミングを学ぶことで素晴らしい体験ができますよと言う甘言に誘われて学んでは、上手くいかず挫折をしてきました。そこで関数型プログラミング言語を学んでいくことで徐々にシンプルに物事を考える思考を身につけた結果、必ずしもオブジェクト指向プログラミング手法を学ぶことだけが上達の道ではないと学んだからです。

もちろん関数型プログラミングが唯一の正解とは思っていません。今ではオブジェクト指向プログラミングも関数型プログラミングも両方を使い仕事に活かしています。どちらのプログラミング手法も奥が深く、難しかったり煩雑な問題を解決するためには高度な技を身に付ける必要があります。しかし簡単な問題はシンプルでわかりやすい問題に置き換えてしまった方が、書き手・読み手両方にとって幸せなのではないか?と言う信念は持ち続けています。

Softwareデザインの記事に書かれていた例題に関しては、シンプルな問題に置き換え、メリットを多く享受した方が良いのではないかと考え、関数型プログラミングのアプローチで紹介しようと思った次第です。今回はその第1弾ということで第1章の例題に挑んでみようと思います。

今回の例題の動くサンプルです。また、テストコードが大事になってくるため、テストコードを含めた実装を見たい方はこちらのリポジトリを参照してください。

Software Designに載っている、オブジェクト指向プログラミングの主張

Software Designでは、じゃんけんのプログラムを例にオブジェクト指向のプログラミングの利点を主張しています。

じゃんけんプログラムでは、戦略をインターフェースとして以下のようなものを定義し、

interface JankenStrategy {
  Hand selectHand();
}

戦略を切り替えてプログラムを構築しています。

JankenStrategy strategy;

if(strategy == 1) {
  strategy = new AlwaysStrategy(Hand.Paa);
} else if(strategy == 2) {
  strategy = new AlwaysStrategy(Hand.Guu);
} else if
...

Hand cpuHand = strategy.selectHand();

これはポリモーフィズムを実現し、CPUの手を出す実装を切り替えることがOOPのメリットであることを主張しています。

また、最後の手を保存するクラスを作り、それを継承することで最後の手を参照するロジックを使い回し再利用することができると更に主張を加えています。

(※ コード例や書いてある記述をたくさん載せてしまうのを避けてかなり説明を省略しています。次の関数型プログラミングを用いた説明は詳細にします。更に詳しく知りたい方は、Software Designを購入してみてください。)

関数型プログラミングで考え直してみる

それでは関数型プログラミングで「戦略」について考えてみましょう。戦略とはCPUの手を出すためのもの、つまり (Strategy -> Hand) な関数と考えられます。更に戦略には、前にプレイヤーが出した手を使うものがあります。状態を扱う?IOかな?と難しく考えることもできますが、もっとシンプルに考えましょう。先ほどの関数に前の手を引数に追加してあげるだけでいいのです({ lastHand: Hand, strategy: Strategy) -> Hand) 。CPUの手を出すだけでは簡単すぎるため、自分の出した手を加えて、更に勝敗を出すような関数を定義してみました。戦略の定義を増やした場合、judgeHandWithStrategyの戦略の分岐を同じように増やさなければコンパイルエラーになるため、interfaceを実装してポリモーフィズムをすることと得られる効果は変わりません。

type Hand
    = Guu
    | Choki
    | Paa


type Strategy
    = AlwaysHand Hand
    | SameHand
    | LastHand
    | LastWinHand

type alias JudgeHandMaterail =
    { yourHand : Hand
    , lastHand : Hand
    , strategy : Strategy
    }


judgeHandWithStrategy : JudgeHandMaterail -> JudgeResult
judgeHandWithStrategy { lastHand, strategy, yourHand } =
    judgeHand yourHand
        (case strategy of
            AlwaysHand hand ->
                hand

            SameHand ->
                yourHand

            LastHand ->
                lastHand

            LastWinHand ->
                Maybe.withDefault Guu <|
                    List.head <|
                        List.filter (\cpuHand -> judgeHand cpuHand lastHand == Win)
                            [ Guu, Choki, Paa ]
        )

勝敗の定義は以下です。また、プレイヤーの手とCPUの手から勝敗を導き出すのは、戦略に関わらず同じなので関数として切り出して定義しています。

type JudgeResult
    = Win
    | Lose
    | Draw
    
    
judgeHand : Hand -> Hand -> JudgeResult
judgeHand yourHand cpuHand =
    case ( yourHand, cpuHand ) of
        ( Guu, Choki ) ->
            Win

        ( Guu, Paa ) ->
            Lose

        ( Choki, Paa ) ->
            Win

        ( Choki, Guu ) ->
            Lose

        ( Paa, Guu ) ->
            Win

        ( Paa, Choki ) ->
            Lose

        _ ->
            Draw

クラスやインターフェースを用いず単純な関数として定義して得られるメリットとして、実装した関数に対して、単なる引数の組み合わせでインスタンスやモックを用意しなくてもテストコードが書けてしまうと言うことです。

judgeHandWithStrategyTest : Test
judgeHandWithStrategyTest =
    describe "#judgeHandWithStrategy"
        [ describe "「常にチョキを出す戦略の時」グーを出せば"
            [ test "勝つ" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Guu, strategy = AlwaysHand Choki }
                        |> Expect.equal Win
            ]
        , describe "「常にチョキを出す戦略の時」、パーを出せば"
            [ test "負ける" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Paa, strategy = AlwaysHand Choki }
                        |> Expect.equal Lose
            ]
        , describe "「常に自分と同じを出す戦略の時」、グーを出せば"
            [ test "引き分けになる" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Guu, strategy = SameHand }
                        |> Expect.equal Draw
            ]
        , describe "「常に自分と同じを出す戦略の時」、チョキを出せば"
            [ test "引き分けになる" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Choki, strategy = SameHand }
                        |> Expect.equal Draw
            ]
        , describe "「常に自分と同じを出す戦略の時」、パーを出せば"
            [ test "引き分けになる" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Paa, strategy = SameHand }
                        |> Expect.equal Draw
            ]
        , describe "「最後に出した手を出す戦略の時」、最後の手がグーであるならば、パーを出せば"
            [ test "勝つ" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Paa, strategy = LastHand }
                        |> Expect.equal Win
            ]
        , describe "「最後に出した手を出す戦略の時」、最後の手がチョキであるならば、パーを出せば"
            [ test "負ける" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Choki, yourHand = Paa, strategy = LastHand }
                        |> Expect.equal Lose
            ]
        , describe "「最後に出した手に勝つ手を出す戦略の時」、最後の手がチョキであるならば、パーを出せば"
            [ test "勝つ" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Choki, yourHand = Paa, strategy = LastWinHand }
                        |> Expect.equal Win
            ]
        , describe "「最後に出した手に勝つ手を出す戦略の時」、最後の手がグーであるならば、パーを出せば"
            [ test "引き分けになる" <|
                \_ ->
                    judgeHandWithStrategy { lastHand = Guu, yourHand = Paa, strategy = LastWinHand }
                        |> Expect.equal Draw
            ]
        ]

まとめ

オブジェクト指向プログラミングでは良い設計手法を身につければ、拡張性に優れ、再利用性に優れたコードを記述することができる。これ自体には嘘はないと思います。しかし、オブジェクト指向を採用したプログラミング言語はいくつも存在し、クラスベース・プロトタイプベース・メッセージパッシングなど流派が多く存在します。そのため、学んだノウハウが楽に活かせるかと言うと、そう言う時ばかりではないでしょう。

今回の記事で示したElmのコードは、関数型プログラミングの初歩中の初歩、単なる関数を定義しただけに過ぎません(レコードのようなデータ構造や代数的データ型は関数型プログラミングの武器と呼べるかわかりませんが、大抵の関数型プログラミング言語には標準装備されています)。そのためJavaですら容易にコードを再現することができます。Javaでのアプローチのデメリットは触れられていませんが他にも存在します。クラスに副作用を利用するロジックが忍ばせてあった場合、継承により副作用が蔓延することになります。Elmのような純粋関数型プログラミング言語ではそのようなことは起こり得ません。また、継承を選ぶべきかコンポジションを選ぶべきか、などの選択肢で迷うようなことが起こり得ません。シンプルで出来ることの幅が少ないですが、シンプルが故に選択で迷う時間が発生しないと言うことは実はとても素晴らしいことです。

また、第1章のまとめでは、オブジェクト指向は良いフレームワークやライブラリを使ったり・作ったりするために学ぶべきだと言う記述がありました。関数型プログラミング言語でも何ら問題もありません。特定のインターフェースを持つ関数を要求すればフレームワークとなり得ます(ElmのThe Elm Architectureがまさにそうです)し、型が明示されるライブラリも同様です。

何度も述べていますが、シンプルは武器です。難しい手法を学ぶだけが上達への道ではありません。難しい問題をなるべくシンプルな問題へ変換して、楽に解決していきましょう。