変更はできる限りさせない〜ソフトウェア設計のきほん〜

この記事ではソフトウェア設計において

  • 変更を許容するとどうなるか??
  • 変更を許容しない方法と不変の効果

についてまとめました。


動画にもまとめたので是非ご覧ください


変更を許容するとどうなるか??

まずは変更可能なコードを見ていきましょう!


❌インスタンス生成後に変更可能なクラス
class Campaign(
    var campaignStartDate: LocalDate?, // varは変更可能でvalは変更不可能
    var campaignEndDate: LocalDate?,
    var pointRate: Double?
) {

    fun calculateCampaignPoint(purchaseAmount: Int): Int {
        if (!isValidCampaign()) return 0
        return floor(pointRate!! * purchaseAmount).toInt() // ここではpointRateが必ず入る想定

    }

    private fun isValidCampaign(): Boolean {
        val today = LocalDate.now()
        return !today.isBefore(campaignStartDate) && !today.isAfter(campaignEndDate)
    }
}
使う側はインスタンス生成後に変更可能
   
        @Test
        fun `生成後のインスタンスを変更する`() {
            val campaign = Campaign(
                campaignStartDate = null,
                campaignEndDate = null,
                pointRate = null
            )

            // このタイミングではまだCampaignが設定されておらず使えない状態

            campaign.campaignStartDate = LocalDate.of(2023, 1, 1)
            campaign.campaignEndDate = LocalDate.of(2023, 12, 31)
            campaign.pointRate = 0.1

           // このタイミングではCampaign設定済みで使える状態

            val actual = campaign.calculateCampaignPoint(1000)


            Assertions.assertEquals(100, actual)

        }


CampaignクラスのcalculateCampaignPointメソッドを利用するには、

事前にインスタンス変数が設定されている必要があり、

使う側はインスタンスの状態を常に気にしながら

calculateCampaignPointメソッドを利用する必要があります。



今回の例だとインスタンス直後に変更しているのでまだ良いのですが、

これが間にデータベースからデータ取得して、

外部API連携してデータ取得して、

他のメソッドにCampaignを渡して一部初期セットアップしてもらって

いろんな処理が終わってようやくセットアップ完了する。


という感じだとどうでしょう??

インスタンス生成後に変更
   
        @Test
        fun `生成後のインスタンスを変更する`() {
            val campaign = Campaign(
                campaignStartDate = null,
                campaignEndDate = null,
                pointRate = null
            )

           // DBからデータ取得
            val campaignSetting = campaignSettingRepository.find()            
            campaignSetting.setUp(campaign)

            // 外部API連携
            val pointRate = campaignService.getPointRate()
            campaign.pointRate = pointRate

           // このタイミングではCampaign設定済みで使える状態

            val actual = campaign.calculateCampaignPoint(1000)


            Assertions.assertEquals(100, actual)

        }


ややこしくなってきましたね。

どのタイミングで変更されて、

どのタイミングでCampaignの設定が全て終わり、

calculateCampaignPointメソッドが使える状態になるのか

理解するには少し辛くなってきます。



では、さらにCampaignインスタンス生成から

calculateCampaignPointメソッド実行までの間に

処理が入って、

campaignが変更される可能性があるとしたらどうでしょう??



想像したくもないですが、、、

変更を許容すると、

どこでいつ変更されるか分からない。

という危険に常に注意を払わなければならなくなり大変です。


おまけ

変更を許容するとマルチスレッドで問題になる可能性もあります。


例えば、

スレッドAがセットアップを完了させて利用する直前、

別のスレッドBがセットアップを更新してしまい、

スレッドAが利用する時には予想外の設定で動いてしまうなど

が考えられます。


変更を許容しない方法と不変の効果

ここからは先ほどのサンプルコードを、

変更を許容しないように修正しながら、

不変の効果を見ていきましょう!


⭕️インスタンス生成後に変更不可能なクラス
class Campaign private constructor(
    private val campaignStartDate: LocalDate,
    private val campaignEndDate: LocalDate,
    private val pointRate: Double
) {
    companion object {
        fun create(
            campaignStartDate: LocalDate,
            campaignEndDate: LocalDate,
            pointRate: Double
        ): Campaign {
            if (!campaignStartDate.isBefore(campaignEndDate)) throw IllegalArgumentException("キャンペーンの開始日は終了日より前である必要があります")

            return Campaign(
                campaignStartDate = campaignStartDate,
                campaignEndDate = campaignEndDate,
                pointRate = pointRate
            )

        }
    }

    fun calculateCampaignPoint(purchaseAmount: Int): Int {
        if (!isValidCampaign()) return 0
        return floor(pointRate!! * purchaseAmount).toInt() // ここではpointRateが必ず入る想定

    }

    private fun isValidCampaign(): Boolean {
        val today = LocalDate.now()
        return !today.isBefore(campaignStartDate) && !today.isAfter(campaignEndDate)
    }
}
使う側はインスタンス生成後に変更不可能
        @Test
        fun `有効なキャンペーンの付与ポイントが算出できる`() {
            val campaign = Campaign.create(
                campaignStartDate = LocalDate.of(2023, 1, 1),
                campaignEndDate = LocalDate.of(2023, 12, 31),
                pointRate = 0.1
            )

           // コンパイルエラー
           //campaign.pointRate = pointRate

            val actual = campaign.calculateCampaignPoint(1000)
            Assertions.assertEquals(100, actual)
        }


Campaignクラスのインスタンス変数は

全て変更できないようにvalにしました。

(ついでに、createメソッド経由でしかインスタンス生成できなくし、

キャンペーン期間の不正な設定をガードしてみました。)



これによって、

使う側はインスタンス生成時にセットアップを行い、

それ以外は一切変数を変更できなくなりました。


これによって、

Campaignインスタンスがいつどこで変更されるか??

という危険に注意を払う必要がなくなりました。



もうcalculateCampaignPointメソッドは

どのタイミングで使えるのか??悩む必要はなく、

インスタンスが生成できたら(= セットアップ正常に完了)

それ以降いつでもメソッドは実行可能な状態です。



このように、

使う側がCampaignの内部の状態を使う側が気にすることなく、

シンプルに使えるようになれば、

Campaignを誤用する可能性も無くせて、

バグを埋め込む可能性も減るでしょう。

今回のテーマについては以上です。



初めてソフトウェア設計を行う方向けに

記事を作ったのでそちらも是非ご覧ください。

初めてソフトウェア設計をする方に向けて|新人プログラマー時代の自分に伝えたいこと


参考文献

第4章 不変の活用ー安定動作を構築するー

良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方


Chapter7 誤用しにくいコードを書く

Good Code, Bad Code ~持続可能な開発のためのソフトウェアエンジニア的思考

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください