🔥

[PHP/Carbon]subMonth、subRealMonth、subMonthNoOverflowの結果が月末のとき異なる

2021/04/06に公開

月末に発生するエラー

3月末になって、突然PHPUnitを動かしているCIがエラーになった。原因究明に苦労したが、最終的にたどり着いた結論は、PHPの日付ライブラリCarbonのsubMonth、subRealMonth、subMonthNoOverflowの挙動の違いによるものだった。

Carbonのバージョン

  • 2.43.0

subMonthの動作確認【3月末】

PHPUnitを使い、日付を2001年の3月末と仮定した上でテストコードを書いて動作確認を行った。

    /**
     * When subtract one month from the date at the end of March, you get 28 days before, 30 days before, and exactly one month before, respectively.
     */
    public function test_3月末の日付から1ヶ月引くとそれぞれ28日前、30日前、正確な1月前になる()
    {
        Carbon::setTestNow(Carbon::create(2001, 3, 31, 10));
        self::assertEquals(
            Carbon::create(2001, 3, 3, 10),
            Carbon::now()->subMonth(),
        );
        self::assertEquals(
            Carbon::now()->subDays(30),
            Carbon::now()->subRealMonth(),
        );
        self::assertEquals(
            Carbon::create(2001, 2, 28, 10),
            Carbon::now()->subMonthNoOverflow(),
        );
    }

subMonthの結果は2月の末尾である28日から、3日後の3月3日を指し示す。これはなぜか?それは3月が31日までであるから、subMonthはその結果として2月31日だと判断するからである。しかし2月31日は実質3月3日である。そのため、最終的な結果としては3月3日となる

subRealMonthは常に30日前を返す。

最後に、subMonthNoOverflowは、先程のように2月31日という計算結果になった後、その月の日数からはみ出る(overflow)ことのないように切り捨てを行う。このメソッドによる結果が、もっとも直感的である。

以上より、subMonthとsubRealMonthは月を引いたにもかかわらず、結果の日時の月は3月のままになってしまうことが分かる。

このように、同じように見えるメソッドでも計算結果が異なる。例えば、「前月の15日」を扱う必要のある要件を実装する場合、これらのメソッドのどれを使うかによって、ロジックの実装方法が異なることに注意が必要である。

subMonthの動作確認【うるう年3月末】

続いて、うるう年についても動作確認を行った。

    /**
     * When subtract one month from the end of March in a leap year, you get 29 days before, 30 days before, and exactly one month before, respectively.
     */
    public function test_うるう年の3月末から1ヶ月引くとそれぞれ29日前、30日前、正確な1月前になる()
    {
        Carbon::setTestNow(Carbon::create(2000, 3, 31, 10));
        self::assertEquals(
            Carbon::create(2001, 3, 2, 10),
            Carbon::now()->subMonth(),
        );
        self::assertEquals(
            Carbon::now()->subDays(30),
            Carbon::now()->subRealMonth(),
        );
        self::assertEquals(
            Carbon::create(2000, 2, 29, 10),
            Carbon::now()->subMonthNoOverflow(),
        );
    }

今度のsubMonthの結果は3月2日となった。先程の3月3日とは異なる結果である。
これはなぜか?それはうるう年のため、2月が29日まであるからである。2月29日まで存在するため、2月31日は実質3月2日となる。

subRealMonthはまたも30日前を示した。

最後に、subMonthNoOverflowは相変わらず直感的な結果を示す。


ここまでの結論として、基本的にはsubMonthNoOverflowを使うことで直感的な結果を得られるため、subMonthNoOverflowを使うべきだといえる。

毎回必ず30日前を示すsubRealMonthは、一定日数ごとに課金するようなサービスで利用するのだろうか。

addMonthの動作確認【1月末/うるう年1月末】

仮説を確認するため、addMonth/addRealMonth/addMonthNoOverflowでも同様に確認を行った。今回は1月末の日付を使っての実験である。

    /**
     * If you add one month from the end of January, it will be 31 days, 30 days, and one month from now, respectively.
     */
    public function test_1月末から1ヶ月足すとそれぞれ31日後、30日後、1ヶ月後になる()
    {
        Carbon::setTestNow(Carbon::create(2001, 1, 31, 10));
        self::assertEquals(
            Carbon::create(2001, 3, 3, 10),
            Carbon::now()->addMonth(),
        );
        self::assertEquals(
            Carbon::now()->addDays(30),
            Carbon::now()->addRealMonth(),
        );
        self::assertEquals(
            Carbon::create(2001, 2, 28, 10),
            Carbon::now()->addMonthNoOverflow(),
        );
    }

    /**
     * If you add one month from the end of January in a leap year, it will be 31 days later, 30 days later, and one month later, respectively.
     */
    public function test_うるう年の1月末から1ヶ月足すとそれぞれ31日後、30日後、1ヶ月後になる()
    {
        Carbon::setTestNow(Carbon::create(2000, 1, 31, 10));
        self::assertEquals(
            Carbon::create(2000, 3, 2, 10),
            Carbon::now()->addMonth(),
        );
        self::assertEquals(
            Carbon::now()->addDays(30),
            Carbon::now()->addRealMonth(),
        );
        self::assertEquals(
            Carbon::create(2000, 2, 29, 10),
            Carbon::now()->addMonthNoOverflow(),
        );
    }

先程解説した内容と同一になるため割愛するが、addMonthでも同じロジックで動いていることが分かる。1月31日にaddMonthすると2月31日になるため、最終的な日付は3月2日または3日となる。

前月の15日の日時を求める場合

最後に文中に述べた、前月の15日の日時を求めたい場合について改めて解説する。

    /**
     * When the requirement is to calculate 0:00:00 on the 15th of the previous month, care should be taken to execute startOfMonth() first, or subMonthNoOverflow() should be used.
     */
    public function test_前月の1500分を算出するという要件の時は先にstartOfMonthを実行するように気をつけるか、subMonthNoOverflowを使うべき()
    {
        Carbon::setTestNow(Carbon::create(2001, 3, 31));
        self::assertEquals(
            Carbon::create(2001, 2, 15),
            Carbon::now()->startOfMonth()->subMonth()->addDays(14),
        );

        // 先にsubMonthすると日付が当月になってしまう
        self::assertEquals(
            Carbon::create(2001, 3, 15),
            Carbon::now()->subMonth()->startOfMonth()->addDays(14),
        );

        self::assertEquals(
            Carbon::create(2001, 2, 15),
            Carbon::now()->subMonthNoOverflow()->startOfMonth()->addDays(14),
        );

        self::assertEquals(
            Carbon::create(2001, 2, 15),
            Carbon::now()->startOfMonth()->subMonthNoOverflow()->addDays(14),
        );
    }

startOfMonthを実行してからsubMonthを使うことで、奇妙な挙動によるバグを回避することができるが、subMonthNoOverflowを使えばそこの順序に依存せずに望んだ結果を得ることができる。


結論として、subMonthやaddMonthは、月を足したり引いたりする処理に見えて実際は前後の月の日数の差によって意図しない結果になる場合がある。そのため、基本的にはsubMonthNoOverflowまたはaddMonthNoOverflowを利用し、場合によっては他のメソッドを使うように考えるほうがバグが発生しにくいといえる。

Discussion