OpenWork Tech Blog

社員クチコミサービスを運営しているオープンワークエンジニアによるテックブログです。

TCAが取り入れた関数型プログラミング的発想

ネイティブアプリエンジニアの入江です。 前回TCAについてのだいぶサラッとした記事を投稿したのですが、今回は少し焦点を絞った観点の投稿です。

TCAと関数型プログラミング

タイトル入れておきながら恐縮ですが、関数型プログラミングとは!的テーマでの深掘りはしないでおきます。 もっと関数型プログラミングらしくするには・・みたいな話はどんどん深掘りできてしまい、今回の記事の目的とずれてしまいそうです。(と言いつつ、筆者が、関数型プログラミングの学習から目を背けたいだけなのであります!!) ・・とはいえ、関数型プログラミングの良いところを取り入れたのものがTCAの思想にあるので、これについてはしっかり理解しておく必要があります。 本記事は、TCAが取り込んだ関数型プログラミングの利点と、それによって何が嬉しいのかを説明したものになります。

暗黙的副作用はダークマター

※プログラミングにおける副作用についての定義も様々なので、副作用とは何かという話はしません。あくまで「iOSアプリ開発におけるTCAでは、何を副作用と見做すべきか、そしてどうすれば副作用をうまく扱えるか」という実用的な観点で書いています。

いわゆる関数型プログラミングと呼ばれるものの代表的な特徴の1つに、「副作用を排除する」というものがあるらしいです。 この副作用を排除するというのが、まさにPoint-Freeが目指すTCAの重要な目標です。 全て英語なのでちょっと難しいですが、Point-Freeが副作用をいかに嫌っているかがわかる記事がこちらです。 https://www.pointfree.co/episodes/ep2-side-effects

要は、シグネチャに書かれているもの以外のなんらかの処理が走れば、それは全て副作用、ということです。

具体的にはこんな感じ。

副作用なし
    func add10(value: Int) -> Int {
        return value + 10
    }
副作用あり
    func add10(value: Int) -> Int {
        let newValue = value + 10
        print(newValue)
        return newValue
    }

シグネチャによれば、Int型の引数を与えて実行すればInt型の値が返すということ以外は何も書かれていません。 なのに、「副作用あり」の方では、途中print文の出力が行われています。これは関数の外部に向けてシグネチャに書かれたこと以外の処理が行われているため、副作用がある処理といえます。

この副作用はダークマターです。この辺はPoint-Freeの記事ではなく、あくまで自分の経験と考えに基づいた考えですが、なぜダークマターなのか理由があります。

まず1つ目に、副作用が乱立することで、シグネチャを信用できなくなります。 この関数を超厳密に命名するなら、add10andPrintResultだと思いませんか?add10をする関数だと思っていたのに、実態としては+10した上で、+10したResultがprintされている・・・ 「だったらadd10andPrintResultじゃないか!!!」・・・と、豆腐メンタル他責思考傾向のある筆者がこれが原因でバグを産んだら思ってしまいます(冗談です)。 そしてこのようなバグが増えていくと、再発防止として「命名規則を厳しくしよう」みたいな変な流れにもなりかねません。しかし実際に関数の中身を全て命名で表現することは現実的ではありません。

そして2つ目に、網羅的にテストができないという点です。func add10の期待値と結果をAssertionすれば、+10された値が返ってくるかどうかというテストはできます。 しかし、print(newValue)されたことはどうやってテストすれば良いのでしょうか。

主にこの2つが暗黙的副作用が及ぼす悪影響です。

副作用がprint文では重みがないので、ちょっと誇張した例を出してみます。

例:ポイントカードのアプリ。商品購入時に+1加算されて、加算された後のポイントをDBに保存しておく。次回加算するときはDBに保存された直近の値を使う。

    func add1Point() -> Int {
        let newPoint = DB.currentPoint + 1
        DB.write(newPoint, forKey: currentPoint)
        return newPoint
    }

おそらくこんな感じの関数になります。しかし、何かのミスでこのDB.writeが書かれていませんでした。ユーザーにどんな影響が出るでしょうか?

買っても買っても、ユーザーのポイントは永遠に1のままです。とんだ大やらかしになってしまいます。 しかもこういう大事な処理に限って案外普通に忘れたりします。実際にそういうシーンを何回も経験してきました。 なぜ書き漏らしが起きてしまったのでしょうか。 ミスは絶対に起きます。ですが命名だけでは副作用を表現しきれません。 暗黙的な副作用によるバグを防止する方法は色々ありますが、TCAでどのように対応できるか説明します。

TCAではどう対処しているか

副作用を完全に排除することは難しいですよね。1つのActionが実行された時に常に実行したい副作用的な処理があるというケースはよくあります。 TCAでは、reducerの戻り値にActionを強制することで、副作用をより安全に扱えるようにしいています。どういうことか説明します。

先ほどの、1ポイント追加すると同時にDBを更新するような処理のReducerを書いてみます。

    @ReducerBuilder<State, Action>
    var core: some ReducerProtocol<State, Action> {
        Reduce { state, action in

            switch action {
            case .addOne:
                {addOneする処理がここに入る}
                return .task {
                    .writeDB()
                }
            case .writeDB:
                {現在のポイントをDBに書き込む}
                return .none
            }
        }
    }

このReduceというのは、イニシャライザにTaskResult<Action>型の戻り値がついていて、reducerで処理を実行すると必ずTaskResult<Action>型の戻り値が吐き出されます。どんな処理がトリガーであっても必ずTaskResult<Action>型が返ってくるということで、実装者はActionの中で他に処理がないかを強制的に意識させられることになります。 これを利用すると、副作用にすべき処理を別のActionに切り出し、TaskResult<Action>型で返すことで、戻り値としてその副作用が実行されることを保証できます。逆に、副作用がない時はreturn .noneすることで、副作用がないことを保証できます。 さらに、このTaskResult<Action>型のActionをawaitしておくことで、テスト上で副作用として呼ばれるべきActionをスケジューリングし、完了を検知できます。 上記の例で言えば、addOne単体の処理を保証しつつ、addOneが戻り値としてwriteDBを吐き出したかどうかテストを書けるということです。 また、厳密にはenumのcaseなので関数シグネチャではないですが、addOnewriteDBも、嘘のない(=こっそりaddOne以外のことをやってない)純粋なシグネチャとして信頼できる表現となっています。

完璧に純粋な関数とは言い切れないですが、reducerは常にActionを返すという点、副作用を戻り値で表現することで暗黙的な副作用を排除できている点から、関数型プログラミングの優れた性質を活かせていると思います。

関数を便利に使う工夫

最後のおまけ程度に・・ iOSチームでは、関数をより便利に活用する工夫も行なっています。 Swiftは関数リテラルが使えます。 関数を値にできるとどんなメリットがあるかというと、関数を抽象化することができます。 関数を抽象化するというのはどういうことか説明します。

    func add10(value: Int) -> Int {
        return value + 10
    }

この関数は、シグネチャと具体処理が一体となった関数です。

これを抽象化すると、

    var add10: (_ value: Int) -> Int
    add10 = { value in value + 10 }

こうなります。関数型の変数です。 どちらの方法でも結果は同じ値を取得できますが、後者の抽象化された変数は宣言時はIntの入力でIntを出力することまでしか知らず、+10するという処理はクロージャにより関数リテラルが代入されることで初めてわかったことです。 これが関数の抽象化です。

これができると何が嬉しいのかというと、関数単位で処理をDIできることです。 Swiftの場合、関数はStruct, Enum, Classなどの中で定義されるため、具体処理をDIしたい場合は例えばStructをprotocolで抽象化し、コンストラクタでInjectionするなどの方法が必要です。しかし、大抵の場合はProtocolには抽象化したい対象以外の関数やら変数のインターフェースが存在していて、特定の関数だけをDIすることができません。 そこで、関数を値にして使うことで、Structなどの単位でDIせずとも、関数ごとに必要な具体処理をDIできます。 実際にOpenWorkアプリのAPIClientではこの手法を取り入れながらリファクタしています。使いたいAPIの関数だけMockすればいいので、柔軟なテストを作ることができます。

まとめ

  • TCAは関数型プログラミングの利点を取り入れた思想が根底にある
  • 必要シーンに応じて関数型プログラミングの優れた性質を活用し、事故防止に努めたい

最後に

筆者もまだまだ勉強不足ですが、少しずつ理解の幅を広げていきたい所存でございます。もしOpenWorkにご興味あれば求人もぜひご確認ください。 ここまで読んでいただきありがとうございました。 www.openwork.co.jp