🐈

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

2021/03/27に公開

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

第5章のテーマは、継承よりコンポジションを採用せよ。というテーマなのですが、Elmや一般的な純粋関数型言語には継承は存在せず、コンポジション(や型クラス)を利用しているため、1~4章までのコードがまさにそれを守り続けているコードなため語るようなことがありません。そのため、この記事では前半に簡単に改めてコンポジションに当たるようなコードの説明。そして、後半にオブジェクト指向にはない関数型プログラミング言語の魅力を少しだけ触れてみようかと思います。

コンポジション

Elmではプリミティブな型(最初から組み込まれている汎用的な型という意味です)として、ListSetなどのコレクション型があります。これらは任意の型が扱えてしまえますし、豊富な関数群を使えることが利点ですが、アプリケーション内のコードでは型や機能を制限することで、意図をハッキリさせたい場合にコンポジションを利用します。これはオブジェクト指向の考えと全く同じになります。

以下の例では、SkillSetと言うカスタム型がList Skillを持ち隠蔽することで、コンポジションを表現しています。SkillSetはhasSkillと言うSkillを持っているかどうかだけを判別する機能のみを持ち合わせており、意図が発揮しています。

module SkillSet exposing (SkillSet, hasSkill, skill, skillSet)


type Skill
    = Skill String


skill : String -> Skill
skill =
    Skill


type SkillSet
    = SkillSet (List Skill)


skillSet : List Skill -> SkillSet
skillSet skillList =
    SkillSet skillList


hasSkill : Skill -> SkillSet -> Bool
hasSkill skl (SkillSet list) =
    List.member skl list

User型もSkillSetを持つ型で、createUserと言うファクトリ関数からのみデータが生成されます。

module User exposing (User, createUser, skillSet)

import Set
import SkillSet exposing (SkillSet, skill)


type User
    = User
        { name : String
        , skillSet : SkillSet
        }


skillSet : User -> SkillSet
skillSet (User v) =
    v.skillSet


createUser : String -> List String -> User
createUser name skillList =
    User
        { name = name
        , skillSet =
            SkillSet.skillSet <| List.map skill <| Set.toList <| Set.fromList skillList
        }

以下がテストコードです。UserSKillSetを利用したコードからは、SkillSetカスタム型が持つList Skillを意識することなく、User型のデータを生成して目的のSkillを持っているかどうかを非常に簡素ですが、抽象的で読みやすいコードが記述できていることがわかります。データを右辺の引数に渡していく右パイプ(|>)を利用することで、オブジェクト指向プログラミングのように記述ができることがわかります。

module UserTest exposing (..)

import Expect
import SkillSet exposing (..)
import Test exposing (..)
import User exposing (..)


hasSkillTest : Test
hasSkillTest =
    describe "#hasSkillTest"
        [ describe "ElmのスキルをABは持っている"
            [ test "Elmを持っているは正しい" <|
                \_ ->
                    let
                        ab =
                            createUser "ABAB" [ "Elm", "Elm", "Scala", "Java" ]
                    in
                    ab
                        |> User.skillSet
                        |> hasSkill (skill "Elm")
                        |> Expect.true "Expected has Elm"
            ]
        ]

以上で、コンポジションを用いたElmのコードの説明終わります。

コレクションライブラリ

これまでは、ElmでJavaの持つオブジェクト指向の機能をどのように再現をするか?と言う視点でで紹介をしてきました。それでは、Javaが真似できないElmの世界の一つとして、コレクションの紹介をしていきたいと思います。

ListはJavaのコレクションを扱うための伝統的なインターフェースです。また、Streamパッケージはコレクションを関数型的に扱う方法としてJava8から導入され始めた機能群です。この二つの機能とElmのListモジュールとの比較をしていきますが、結論として多くの場合、安全面と柔軟性、記述のしやすさにおいてElmのListモジュールの方が優れていると言えると思います。

まず、Listから先頭の要素を取得することを考えてみましょう。最初の行では1を取得できますが、次の行のコードは空のListのため、実行時例外を吐いてしまいます。これを防ぐには、事前に人間がListの長さを測ったり、try-catchを用いた冗長なコードを記述する必要があります。

List.of(1, 2, 3).get(0) // == 1
List.of().get(0)        // throw new IndexOutOfBoundsException()

それに対してElmの先頭を取得するhead関数は、Maybe型と言うあるかないかを表す型で値がラップされているため、事前にコンパイラが計算可能な値かどうかをチェックしてくれます。もし、中の値を取り出したければ、デフォルト値を与えるMaybe.withDefault関数を使うなどして、安全に値を取り出します。1行で済むため冗長なコードにもなり得ません。Maybeのまま値を加工するなどの柔軟な操作も行えます。

List.head [1, 2, 3] -- == Just 1
List.head []        -- == Nothing
List.head [] |> Maybe.withDefault -1 -- == -1

JavaはStreamを利用して関数型プログラミングスタイルでコードを記述することができますが、まだまだ機能としては不十分と言えます。例えば、indexを利用しようとしたコード場合、Streamの値単体では上手く表現することができません。先ほどのように.getと言う実行時例外を起こすようなコードを使ってしまい危険かつ冗長なコードです。

var list = List.of("a", "b", "c", "d", "e");
var intStream = IntStream.range(0, list.length);

intStream.map(i -> list.get(i) + "-" + "i"); // Stream("a-0", "b-1", "c-2", "d-3", "e-4")  

List.indexedMapを使えば、indexを利用したコードはすぐ書くことができます。

["a", "b", "c", "d", "e"] |> List.indexedMap (\i str -> str ++ String.fromInt i) -- ["a-0", "b-1", "c-2", "d-3", "e-4"]

ただ、機能が豊富であると言うわけではありません。例えば任意の型のリストにindexをつけたペアの形List ( Int, a )を作るzipWithIndexは単にList.indexedMapTuple.pair2つの関数を組み合わせるだけでできてしまうのです。

zipWithIndex : List a -> List ( Int, a )
zipWithIndex =
    List.indexedMap Tuple.pair
    
    
zipWithIndex ["a", "b", "c", "d", "e"] -- == [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e")]

より複雑な関数を簡単な関数の組み合わせで作る例を見てみましょう。次のnumbersは、filterMap関数を用いて、文字列のリストから整数に変換できたもののみをリストに残すと言う関数です。String.toIntは次のシグネチャを持っています(String -> Maybe Int)。filterMap自身もfoldrと言うListモジュールに用意された汎用的な畳み込み関数とmaybeConsと言う数行からなるヘルパー関数を組み合わせただけで実現されます。プログラミング言語において、コードを組み合わせるのは基本中の基本のテクニックですが、関数型プログラミング言語は数学の考えから来るコレクションの加工手法と関数を組み合わせるための技術によって、より安全・より柔軟に・より簡潔に行うことができるのです。

numbers : List Int
numbers =
  filterMap String.toInt ["3", "hi", "12", "4th", "May"]

-- numbers == [3, 12]

filterMap : (a -> Maybe b) -> List a -> List b
filterMap f xs =
  foldr (maybeCons f) [] xs
  
  
maybeCons : (a -> Maybe b) -> a -> List b -> List b
maybeCons f mx xs =
  case f mx of
    Just x ->
      cons x xs

    Nothing ->
      xs

関数を組み合わせる技術

コレクションだけではありません。関数を抽象的に巧みに組み合わせる、それだけで本当にいろんなものが、簡単に記述することができてしまいます。詳しく説明してしまうとそれだけで1本の記事ができてしまうので、雰囲気だけでも関数型プログラミング言語のコードをお楽しみください。

複数の乱数を組み合わせて、ゲームの敵のデータを作る例です。

import Random

type alias Enemy
  { health : Float
  , rotation : Float
  , x : Float
  , y : Float
  }

enemy : Random.Generator Enemy
enemy =
  Random.map2
    (\x y -> Enemy 100 0 x y)
    (Random.float 0 100)
    (Random.float 0 100)

Jsonのデコーダの部品を組み合わせて、大きなデコーダを作る例です。

import Json.Decode as Decode exposing (Decoder, int, string)
import Json.Decode.Pipeline exposing (required)

type alias User =
    { id : Int
    , name : String
    , email : String
    }

userDecoder : Decoder User
userDecoder =
    Decode.succeed User
        |> required "id" int
        |> required "name" string
        |> required "email" string

result : Result String User
result =
    Decode.decodeString
        userDecoder
        """
      {"id": 123, "email": "sam@example.com", "name": "Sam"}
    """


-- Ok { id = 123, name = "Sam", email = "sam@example.com" }

言語内DSL(Domain Specific Language)として、パーサを作る例です。これも小さい部品を組み合わせることで一つのパーサを構築しています。これは、(1.5, 2.0)のような座標をレコードにパースする例です。

import Parser exposing (Parser, (|.), (|=), succeed, symbol, float, spaces)

type alias Point =
  { x : Float
  , y : Float
  }

point : Parser Point
point =
  succeed Point
    |. symbol "("
    |. spaces
    |= float
    |. spaces
    |. symbol ","
    |. spaces
    |= float
    |. spaces
    |. symbol ")"
    
    
Parser.run point "(1.5, 2.0)" -- Ok { x = 1.5 y = 2.0 }

まとめ

これでオブジェクト指向プログラミングを関数型プログラミングで考えてみる最後の記事になります。どちらの考えを持った言語が優れている、劣っているなどと言うことはありません。作りたいプロダクトがパフォーマンスを求める、安全性を求める、複雑なアルゴリズムを表現したいなど、用件によって適した言語や考え方がマッチしていくものだと思います。その時に、オブジェクト指向の選択肢しかないことで生産性を落としてしまう、安全性に配慮しきれていないコードが量産されてしまう。または、関数型を用いることでパフォーマンスの低下や逆に複雑さを招いてしまうような考えの偏りによって様々なエンジニアの方が機会損失してしまうことを恐れて今回のような記事を執筆させていただきました。選択肢が多いことは知識を広げ、ソフトウェアの品質や可能性を広げることができます。是非、オブジェクト指向以外のパラダイムがあるのかと言うことを知っていただいて、エンジニア人生に活かしてください。

最後になりますが、今回記事を書くにあたり利用させていただいた関数型プログラミングElmは、関数型プログラミングを始めるに当たって非常に最適な言語となっています。なぜなら、複雑であるGUIプログラミングを書くための環境がクラウド上にあり、それの道標となり関数型プログラミングのいろはを学ぶためのガイドがなんとどちらも無料で提供されているのです。また、関数型の考えが容易に習得できるように構文はとても少ないです。(記事でもいくつかの構文の組み合わせで多くのOOPの機能を再現できていました) 是非、明日、いや今から関数型プログラミングを初めてはいかがでしょうか?ここまでご覧くださりありがとうございました!!

Discussion