FRTKL

失意泰然、得意冷然

DateTime::diff()とCarbon::diffInMonths()のバグっぽい挙動と対策

この記事は コネヒト Advent Calendar 2020 12日目 の記事です。

目次

混乱しがちなPHPのDateTime::diff()、Carbon系のライブラリにあるdiffInMonths()、にまつわる問題について、それぞれ挙動になっている理由を示しながら紹介します!(もともと社内向けに公開していた記事を加筆・修正しています)

問題リスト

  1. UTC環境下でdiffInMonths()の結果がおかしい
  2. Asia/Tokyo環境下でdiff()の結果がおかしい

1. UTC環境下でdiffInMonths()の結果がおかしい

問題の挙動

  • 2019/02/01と2019/03/01のdiffは1ヶ月
  • 2019/01/31と2019/03/01のdiffは0ヶ月 ← !?
  • 2019/03/01と2019/01/31のdiffは1ヶ月 ← !? (0じゃないの...?)

再現コード

<?php

$testcases = [
    ['2019-02-01', '2019-03-01'],
    ['2019-01-31', '2019-03-01'],
    ['2019-03-01', '2019-01-31'],
    ['2019-01-31', '2019-03-02'],
    ['2019-01-31', '2019-03-03'],
];

foreach ($testcases as [$since, $until]) {
    $tz = new DateTimeZone('UTC');
    $since = new DateTimeImmutable($since, $tz);
    $until = new DateTimeImmutable($until, $tz);

    // Carbon::diffInMonths()を標準関数だけで表現 https://github.com/briannesbitt/Carbon/blob/ca197a5e31acb17e1ac6fe2708b65e367534ec1b/src/Carbon/Traits/Difference.php#L174-L179
    $diff = $since->diff($until, true);
    $diffInMonths = $diff->format('%r%y') * 12 + (int) $diff->format('%r%m');

    echo sprintf("%sから%sまでのdiffInMonths()は%d (%s)", $since->format('Y-m-d'), $until->format('Y-m-d'), $diffInMonths, $diff->format('%mヶ月 %d日 総日数 %a日')), PHP_EOL;
}
$ php diffInMonths.php
2019-02-01から2019-03-01までのdiffInMonths()1 (1ヶ月 0日 総日数 28)
2019-01-31から2019-03-01までのdiffInMonths()0 (0ヶ月 29日 総日数 29)
2019-03-01から2019-01-31までのdiffInMonths()1 (1ヶ月 1日 総日数 29)
2019-01-31から2019-03-02までのdiffInMonths()0 (0ヶ月 30日 総日数 30)
2019-01-31から2019-03-03までのdiffInMonths()1 (1ヶ月 0日 総日数 31)

原因

  • 正確には「UTC環境下でdiffInMonths()の結果がおかしい」ではなく「UTC環境下で3/1と3/2のdiffInMonths()の結果がおかしい」
    • ただし、比較対象となる日付の基準点によって結果が変わる
  • PHPの DateTime::diff() の仕様の不具合?

詳細

  • たとえば以下のそれぞれのケースにおいて期待する結果を示す(人が直感的に思う期待値)
ケース 期待値
5/30 から見た 4/30 1ヶ月前 (1ヶ月0日前 総日数 30日)
1/31 から見た 1/1 1ヶ月前ではない (0ヶ月30日前 総日数 30日)
  • 1月31日の30日前は1月1日だが、5月30日の30日前は4月30日
  • どちらも同じ「30日」の差があるが、基準点によって「1ヶ月前」と表現するかどうかが変わる
  • これを踏まえて、今回のケースを人が直感的に考えると以下になると思う
ケース 期待値
2/1 から見た 3/1 1ヶ月後 (1ヶ月0日後 総日数 28日)
1/31 から見た 3/1 1ヶ月後 (1ヶ月1日後 総日数 29日)
3/1 から見た 1/31 1ヶ月後 (1ヶ月1日前 総日数 29日)

※閏年ではない場合

  • これをPHPはどう解釈しているかというと再現コードの結果から以下であることが分かる
ケース 期待値
2/1 から見た 3/1 1ヶ月後 (1ヶ月0日後 総日数28日)
1/31 から見た 3/1 0ヶ月後 (0ヶ月29日後 総日数29日)
3/1 から見た 1/31 1ヶ月後 (1ヶ月1日後 総日数29日)

※閏年ではない場合

  • 0ヶ月後 (0ヶ月29日後 総日数29日) の算出方法
    • まず単純に2つの日付("1月"と"3月")を引き算して 2ヶ月後 (2ヶ月-30日後) となる code
    • その後平準化が行われる。日数が正しい値になるまでループで処理される。 code
      • このとき 3月が基準となる
      • PHPでは、日数がマイナスだった場合は 基準の前月の日数 でくりさげることを繰り返す code
      • 1回目のループ: 1ヶ月後 (1ヶ月-2日後) (-30に2月の28日を足して-2となる)
      • 2回目のループ: 0ヶ月後 (0ヶ月29日後) (-2に1月の31日を足して29となる)

解決策/対処

  • 現時点でスマートな解決策はなし...?
  • このような直感に反する挙動が起きるのは diffInMonths() の第2引数が「3/1」と「3/2」の場合のみ
  • 現実解としては以下
    • 3/1と3/2におかしくなるので分岐を入れる
      • ちなみに閏年では「3/2」でも正常に動作する
    • 影響を考えた上で軽微なら許容する
    • 比較時の基準点を常に「新しい日付」とする
      • (1/31)->diff(3/1); ではなく (3/1)->diff(1/31);

参考/関連

2. Asia/Tokyo環境下でdiff()の結果がおかしい

問題の挙動

  • 2019/01/01と2019-02-01のdiffは1ヶ月
  • 2019/01/01と2019-03-01のdiffも1ヶ月 ← !?

再現コード

<?php

$testcases = [
    ['2019-01-01', '2019-02-01'],
    ['2019-01-01', '2019-03-01'],
];

foreach ($testcases as [$since, $until]) {
    $tz = new DateTimeZone('Asia/Tokyo');
    $since = new DateTimeImmutable($since, $tz);
    $until = new DateTimeImmutable($until, $tz);

    $diff = $since->diff($until);

    echo sprintf("%sから%sまでのdiffは %s", $since->format('Y-m-d'), $until->format('Y-m-d'), $diff->format('%mヶ月 %d日 総日数 %a日')), PHP_EOL;
}
$ php diff.php
2019-01-01から2019-02-01までのdiffは 1ヶ月 0日 総日数 31日
2019-01-01から2019-03-01までのdiffは 1ヶ月 28日 総日数 59

原因

  • PHPのDateTime::diff()の仕様

詳細

  • PHPのDateTime::diff() は、UTC以外の2つの日付を比較するとき、両方の表記をUTCに修正してからdiffを算出する という仕様になっている code
    • たとえば、Asia/TokyoならUTCに直すと00:00は前日の15:00になる
  • これを踏まえて計算すると
    • 2019-01-01から2019-03-01 は、 2018-12-31から2019-02-28 となる
    • この期間のdiffを取ると 1ヶ月 28日 という結果が得られる

解決策/対処

  • 一度自分でUTCに変換してからdiff()を使うと正しい結果となる
    • 独自のdiff関数を使えばうまくいく
<?php

$testcases = [
    ['2019-01-01', '2019-02-01'],
    ['2019-01-01', '2019-03-01'],
    ['2019-01-01', '2019-04-01'],
];

foreach ($testcases as [$since, $until]) {
    $tz = new DateTimeZone('Asia/Tokyo');
    $since = new DateTimeImmutable($since, $tz);
    $until = new DateTimeImmutable($until, $tz);

    $utc_tz = new DateTimeZone('UTC');
    $since2 = new DateTimeImmutable($since->format('Y-m-d'), $utc_tz);
    $until2 = new DateTimeImmutable($until->format('Y-m-d'), $utc_tz);

    $diff = $since2->diff($until2);

    echo sprintf("%sから%sまでのdiffは %s", $since2->format('Y-m-d'), $until2->format('Y-m-d'), $diff->format('%mヶ月 %d日 総日数 %a日')), PHP_EOL;
}
$ php diff.php
2019-01-01から2019-02-01までのdiffは 1ヶ月 0日 総日数 31日
2019-01-01から2019-03-01までのdiffは 2ヶ月 0日 総日数 59日
2019-01-01から2019-04-01までのdiffは 3ヶ月 0日 総日数 90
  • ただし、サマータイムまで考慮した時・分・秒単位のdiffを出そうとすると上手くいかないので注意
    • 詳しくは↓のqiitaにある。

参考/関連

さいごに

知らないと罠にはまる問題なので完璧に理解せずとも知識として知っておきましょう! ライブラリや言語側でより直感的な結果が返るように改善されていくと良いですね。