Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
9
Help us understand the problem. What is going on with this article?
@Hiraku

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

More than 1 year has passed since last update.

DateTime::diffの挙動がよくわからない - Qiita

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

DateTime::diffとは

PHP: DateTime::diff - Manualとは、PHPの組み込み日時型である、DateTimeDateTimeImmutable に実装されている、2つの時刻の間隔を算出するメソッドである。戻り値は DateInterval という、時間(期間)を表す型で返ってくる。

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

<?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
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 も、そのような挙動が期待されている。

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

ややこしいことに、 DateTimeDateTimeImmutable は、それ自体がタイムゾーンを内包するように設計されている。 $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
// 日付だけはズレないけど、間違っている関数
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時間を超えることはないし、問題なく計算できるはず。

// 日付までの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の
プロパティ名
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にも同じ問題があるのかもしれない。(よく知らない)

9
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Hiraku
PHP, Go界隈をうろうろしています。最近はgRPCと戦ってる。 特に明示していなければ、記事中のソースコード片は `CC-0 1.0` とします。出典表示無しで自由にコピペして頂いて構いません。 ただ、記事自体をコピペされるのは嫌なので、ソースコード部分以外の文章は通常通り全ての著作権を私が保持するものとします。 引用を超える範囲のコピペは止めて下さい。
mercari
フリマアプリ「メルカリ」を、グローバルで開発しています。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
9
Help us understand the problem. What is going on with this article?