DateTime::diffの年月とTimezoneのバグっぽく見える挙動

[DateTime::diffの挙動がよくわからない - Qiita](https://qiita.com/smzk/items/0b8c47dbdb283832a94b)

↑の記事で知ったのだけど、UTC以外のほとんどのTimezoneだとDateTime::diffは奇妙な挙動をする。
面白かったので動作を調べていたまとめを書く。


## DateTime::diffとは

[PHP: DateTime::diff - Manual](https://www.php.net/manual/ja/datetime.diff.php)とは、PHPの組み込み日時型である、`DateTime` と `DateTimeImmutable` に実装されている、2つの時刻の間隔を算出するメソッドである。戻り値は `DateInterval` という、時間(期間)を表す型で返ってくる。

よくあるのは誕生日から満年齢を計算するために使ったりするユースケースだろうか。

```php
<?php
$utc = new DateTimeZone('UTC');
$birthday = new DateTime('1990-01-01', $utc);
$today = new DateTime('2020-01-01', $utc);

$diff = $birthday->diff($today);

echo $diff->format('%R %y年 %mヶ月 %d日 %h時間 %i分 %s.%f秒'), PHP_EOL;
// + 30年 0ヶ月 0日 0時間 0分 0.0秒
```

`{過去}->diff({未来})` という順番で書くことに注意。これでプラスの経過時間が取れる。
過去と未来を逆にするとマイナスの経過時間が取れる。

## DateTime::diffの奇妙な挙動

この日本語の記事を読んでいる人はおそらくAsia/Tokyo (+9:00)のタイムゾーンでプログラムを書いていることだろう。
ほとんどのタイムゾーンでは、DateTime::diffを使って期間を計算すると、奇妙な挙動に悩まされるはずだ。
例としては以下のような感じ。

```php
<?php
const FORMAT = '%R %y年 %mヶ月 %d日 (総日数 %a日) %h時間 %i分 %s.%f秒';
$timezones = [
    new DateTimeZone('UTC'),
    new DateTimeZone('Asia/Tokyo'),
    new DateTimeZone('Europe/London'),
    new DateTimeZone('America/Los_Angeles'),
];
$testcases = [
    // 全部2ヶ月に見えるテストケース
    ['2010-01-01', '2010-03-01'],
    ['2010-02-01', '2010-04-01'],
    ['2010-03-01', '2010-05-01'],
    ['2010-01-31 23:59:59', '2010-03-31 23:59:59'],
    ['2010-02-28 23:59:59', '2010-04-28 23:59:59'],
    ['2010-03-31 23:59:59', '2010-05-31 23:59:59'],
];

foreach ($timezones as $tz) {
    foreach ($testcases as [$since, $until]) {
        $since = new DateTime($since, $tz);
        $until = new DateTime($until, $tz);

        $diff = $since->diff($until);
        echo sprintf("%-20s ", $tz->getName()), $diff->format(FORMAT), PHP_EOL;
    }
}
```

↓結果

```
UTC                  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
UTC                  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
UTC                  + 0年 2ヶ月 0日 (総日数 61日) 0時間 0分 0.0秒
UTC                  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
UTC                  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
UTC                  + 0年 2ヶ月 0日 (総日数 61日) 0時間 0分 0.0秒
Asia/Tokyo           + 0年 1ヶ月 28日 (総日数 59日) 0時間 0分 0.0秒
Asia/Tokyo           + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
Asia/Tokyo           + 0年 2ヶ月 2日 (総日数 61日) 0時間 0分 0.0秒
Asia/Tokyo           + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
Asia/Tokyo           + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
Asia/Tokyo           + 0年 2ヶ月 0日 (総日数 61日) 0時間 0分 0.0秒
Europe/London        + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
Europe/London        + 0年 1ヶ月 31日 (総日数 59日) 0時間 0分 0.0秒
Europe/London        + 0年 1ヶ月 30日 (総日数 61日) 0時間 0分 0.0秒
Europe/London        + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
Europe/London        + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
Europe/London        + 0年 2ヶ月 0日 (総日数 61日) 0時間 0分 0.0秒
America/Los_Angeles  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
America/Los_Angeles  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
America/Los_Angeles  + 0年 2ヶ月 0日 (総日数 61日) 0時間 0分 0.0秒
America/Los_Angeles  + 0年 2ヶ月 0日 (総日数 59日) 0時間 0分 0.0秒
America/Los_Angeles  + 0年 1ヶ月 28日 (総日数 59日) 0時間 0分 0.0秒
America/Los_Angeles  + 0年 2ヶ月 0日 (総日数 61日) 0時間 0分 0.0秒
```

UTCでは、全部2ヶ月ピッタリのテストケースである。しかし、他のタイムゾーンだと、何故か `1ヶ月28日` や `2ヶ月2日` と判定されている箇所がある。
この挙動は本記事執筆時点のPHP最新版の7.4でも再現する。

## ○○ヶ月ってどうやって判定するべきだと思う？

ここで、読者と認識を合わせておきたい。
1月31日の30日前は1月1日だけど、5月30日の30日前は4月30日だ。

| ケース | 期待値 |
|:-:|:-:|
| 5/30 から見た 4/30 | **1ヶ月前 (1ヶ月0日前)**  |
| 1/31 から見た 1/1  | **1ヶ月前ではない (0ヶ月30日前)**  |


一般的に、こういう風に解釈するのではなかろうか。 `DateTime::diff` も、そのような挙動が期待されている。

つまり、 **何ヶ月経ったか** あるいは **何年経ったか** は、計測の基準点によって微妙に変わるのである。
これが日数であれば基準点によらず確実なことが言えるのだが…。

ややこしいことに、 `DateTime` や `DateTimeImmutable` は、それ自体がタイムゾーンを内包するように設計されている。 `$since` と `$until` が同じtimezoneとは限らないのだ。そこで、PHPで採用されている https://github.com/derickr/timelib のアルゴリズムでは、 **両方の表記をUTCに修正してから、diffを算出する** という風になっている。

Asia/Tokyoなら、UTCに直すと00:00は前日の15:00。 `2010-01-01 ~ 2010-03-01` は、 `2009-12-31 ~ 2010-02-28` という計算にすり替わり、`1ヶ月 28日` という結果が得られたわけだ。

### タイムゾーンによる挙動差

* UTCより東の国々、+のタイムゾーンだと、UTCにすると過去の日付になる。つまり月の最初あたりでdiffを取ったときに、この挙動が観測できる。
* UTCより西の国々、-のタイムゾーンだと、UTCにすると未来の日付になる。つまり月の最後あたりでdiffを取ったときに、この挙動が観測できる。

冒頭で `UTC以外のほとんどのTimezoneだと` と書いたが、問題が一切起きないのはUTCと同じオフセットで、なおかつDST(サマータイム)を採用していないタイムゾーンだけだ。ほとんどのタイムゾーンでは多かれ少なかれこの挙動に巻き込まれる。

## サマータイムの話

ここまでの話を見て、なるほどUTC扱いにして計算すればいいのか！みたいに **思ってはいけない**。

```php
<?php
// 日付だけはズレないけど、間違っている関数
function date_diff2(DateTimeInterface $since, DateTimeInterface $until) {
    $utc = new DateTimeZone('UTC');
    $since = new DateTime($since->format('Y-m-d H:i:s'), $utc);
    $until = new DateTime($until->format('Y-m-d H:i:s'), $utc);
    return $since->diff($until);
}
```

この関数は、DST(Daylight Saving Time)、いわゆるサマータイムが導入されているタイムゾーンだと正しく動作しない。DSTありのタイムゾーンだと、日付によってオフセットが異なるため、UTCに単純に引き直しただけだと情報が失われてしまうのだ。

例えば `America/Los_Angeles` だと、 2020-03-08 の `01:00:00 - 03:00:00` をdiffると、

* DateTime::diffなら `1時間`
* ↑の、date_diff2なら `2時間`

と計算される。02:00にサマータイムに入り`02:00 ~ 03:00` の期間は存在しないため、この場合は `1時間` が正しい。 ~~サマータイム滅んでくれないかなー~~


## 個人的に欲しかったもの

というわけで、`DateTime::diff` は割と「正しい挙動」をどう設定するかが難しい。バグかどうかではなく、「こうしたい」という意見が必要だ。現状のtimelibも、いちおう仕様通り動いているとも言える。( php.net にもう少し注釈があってもいいとは思うが)

とはいえ、今の仕様も求めるものじゃないと思う。
色々改善策を考えたのだが…、DSTが鬼門で、期待する答えを得るためにはかなり頑張らなくてはならなそうだった。

結局、年〜ミリ秒に至るまで全てのdiffをまとめて計算する関数を作ってしまったのが全ての元凶で、

* 日付のdiffを求める関数
* 日数と時間のdiffを求める関数

の2つに分ければ難しく考えなくとも問題は起きなかったのでは、と思うようになった。

日付までのdiffだったら、UTCとして解釈させて、今までどおりDateTime::diffを取ればいいだけだ。同一タイムゾーンであれば、DSTの影響が24時間を超えることはないし、問題なく計算できるはず。

```php
// 日付までのdiffを取る関数。この結果の年月日は直感と一致する
function date_diff2(DateTimeInterface $since, DateTimeInterface $until, bool $absolute = FALSE): DateInterval
{
    if ($since->getTimezone()->getName() !== $until->getTimezone()->getName()) {
        throw new InvalidArgumentException('cannot handle different timezones');
    }
    $utc = new DateTimeZone('UTC');
    $since = new DateTimeImmutable($since->format('Y-m-d'), $utc);
    $until = new DateTimeImmutable($until->format('Y-m-d'), $utc);
    return $since->diff($until, $absolute);
}
```

今までのDateTime::diffで素直に計算される結果は、年/月/日は信用ならないものとして扱う。
代わりに days(日数)があるが、こちらまでは信用ができる。

| 項目 | DateIntervalの<br>プロパティ名 | formatでの表記 | 信用できる？|
|:-:|:-:|:-:|:-:|
| 符号 | invert | %R | ○ |
| 年 | y | %y | ✗ |
| 月 | m | %m | ✗ |
| 日 | d | %d | ✗ |
| 日数 | days | %a | ○  |
| 時間 | h | %h | ○ |
| 分 | i | %i | ○ |
| 秒 | s | %s | ○ |
| マイクロ秒 | f | %f | ○ |


この2つの関数を使い分けるのが良さそうだ。

## まとめ

* DateTime::diff による年月日の差分は、直感と反する結果が返ってくる
* UTCとして解釈させると直感と一致する結果が得られる
* しかし日付より詳細なdiffを計算しようとするとDSTのせいでうまくいかない
* 日付と時刻は難しい
* DST滅んでほしい

## 余談: 年齢計算

満年齢を計算するだけであれば、 `YYYYmmdd` をintとして解釈させた上で、 `(int)((対象日のYYYYmmdd - 誕生日のYYYYmmdd) / 10000)` とするやり方もよく知られている。
まあ、これでも問題ない。

## 余談その2: timelibの影響範囲

timelibのreadmeによると、PHPの他にMongoDBでも採用されているとある。
なので、MongoDBにも同じ問題があるのかもしれない。(よく知らない)