PHP
datetime
Carbon
Chronos

「1月31日の一ヶ月後はいつですか」にCarbon や Chronos はどう答えるか

日時を扱う処理を書くとき、月をまたぐ部分はうっかりすると不具合の原因になりがち。

たとえば現在の日付が31日であるタイミングでこういうことをやると

\DateTime::createFromFormat('Y-m', '2018-02')

「年」は2018で「月」は2だけど「日」が現在日時の30になるため、月をこえてしまって結果は3月3日になるという悲劇が起きます。

さて今回は日時を生成するときじゃなくて「一ヶ月後」などのお話。
このへん、PHP の日時ライブラリ Carbon と Chronos はどう扱っているのか確認してみます。

Carbon の場合

動作

1月31日の一ヶ月後を求めようとして

\Carbon\Carbon::create(2018, 1, 31)->addMonths(1)

とすると、

2018-03-03

になります。

ただし先にこれをやっておくと

Carbon::useMonthsOverflow(false);

結果はこうなる。

2018-02-28

実装

Carbon の addMonths() ではこういう判定をしてます。

public function addMonths($value)
{
    if (static::shouldOverflowMonths()) {
        return $this->addMonthsWithOverflow($value);
    }

    return $this->addMonthsNoOverflow($value);
}

static::shouldOverflowMonths() が見ているのはこれで

public static function shouldOverflowMonths()
{
    return static::$monthsOverflow;
}

さきほどの Carbon::useMonthsOverflow(false); によって $monthsOverflow がセットされます。

public static function useMonthsOverflow($monthsOverflow = true)
{
    static::$monthsOverflow = $monthsOverflow;
}

実際に月を足し引きしてるのはこのふたつ。

public function addMonthsWithOverflow($value)
{
    return $this->modify((int) $value.' month');
}

public function addMonthsNoOverflow($value)
{
    $day = $this->day;

    $this->modify((int) $value.' month');

    if ($day !== $this->day) {
        $this->modify('last day of previous month');
    }

    return $this;
}

$monthsOverflow のデフォルト値は true になってるからそのままだと月をこえて計算されるけど、これを false にすることによって

一ヶ月後を計算して day が変わるようならそれは月を越えたサインだから、前月の末日に戻す

という処理になります。

Chronos の場合

動作

\Cake\Chronos\Chronos::create(2018, 1, 31)->addMonths(1)

は最初から

2018-02-28

になります。

実装

これは Chronos の実装がこうなっているためで

public function addMonths($value)
{
    $day = $this->day;
    $date = $this->modify((int)$value . ' month');

    if ($date->day !== $day) {
        return $date->modify('last day of previous month');
    }

    return $date;
}

もう月を越えたら無条件に前月の末日に巻き戻してる。

Carbon のデフォルト処理のように月を越えてもそのまま使いたい場合は、明示的に

\Cake\Chronos\Chronos::create(2018, 1, 31)->addMonthsWithOverflow(1)

とすることになるようです。

「一ヶ月後」という処理をするときには Chronos のデフォルト動作の方が求められていることが多いような気もするけど、「Chronos は Carbon の immutable 版」とだけ覚えてしまうとこのへんの違いで思わぬ問題が起きるかも。テストだいじ。