strtotimeとtimestampの素敵な関係

基本的には関数型言語推しですが、業務では諸事情でPHPを使っています。
色々と罠の多い言語であることは知っていたんですが、先日「これはあんまりだろ…」という挙動を踏み抜きました。
軽くググった限りでは同様の事例紹介が見当たらなかったのと、あまりにも衝撃的だったため記事として残しておきます。

前置き:MySQL DATETIMEとstrtotime

PHPerな諸氏におかれましては、strtotimeが色々と頑張りすぎるせいでハマったことは1度や2度ではないのではないでしょう。
例えばMySQLのDATETIMEで未定義を表す 0000-00-00 00:00:00を32bit環境でstrtotimeに渡すとfalseになります。
ドキュメントを見ると

返り値 ¶
成功時はタイムスタンプ、そうでなければ FALSE を返します。 PHP 5.1.0 以前ではこの関数は失敗時に -1 を返します。

と書かれており、「そうか、日付として不正だから解釈に失敗してfalseを返したんだな。0月とか存在しないし」と思ってしまいそうになるんですが、同じコードを64bit環境で実行すると以下のようになります。

$ php -r 'var_dump(strtotime("0000-00-00 00:00:00"));'
int(-62170016400)

謎の数値が出てきました。
これが一体いつを表しているのか、さらにdate関数を掛けて確認してみます。

$ php -r 'var_dump(date("Y-m-d H:i:s", strtotime("0000-00-00 00:00:00")));'
string(20) "-0001-11-30 00:00:00"

つまり、PHPはMySQLのDATETIMEの未定義値を「マイナス1年11月30日の0時0分0秒」とみなしていることがわかります。
実はPHPでは0月が「前年の12月」を、0日が「前月の最終日」を、それぞれ表すと解釈されるので、0年の1ヶ月前の更に1日前で-1年11月30日という日付が導かれるんですね。
32bit環境でfalseが返っていたのは、日付として不正だからでも何でもなく、単に「表現できる整数値の範囲を超えていたから」という理由だったわけです。

この挙動の違いは公式ドキュメントのコメント欄にさんざん書かれているので知っている人も多いと思います。
一方、今回踏んだ挙動は発生条件がかなり限られていることと、そもそも普通はそんなことしないだろう、という辺りであまり知られていないだろう挙動です。
あ、ここまでが前振りです。長くてすみません。

タイムスタンプとstrtotime

さて、突然ですが、APIが日時を受け取るとき、タイムスタンプにするか日付の文字列にするか迷いますよね?
迷いますよね? 迷ってください。迷え。

で、同じ日時を表すものなので、パラメタにどちらを渡しても動作したら素敵ですよね。
そんなわけで、ある変数に「タイムスタンプの整数値」か「日付を表す文字列」のいずれかが入っているとしましょう。
これを深く考えずにstrtotimeにかけると、タイムスタンプをstrtotimeに渡すという稀有な状況が生まれます。型? 知らん。

例えば、この記事を書いている 2015-06-19 00:00:00 を表すタイムスタンプ、1434639600を渡してみます。

$ php -r 'var_dump(strtotime(1434639600));'
bool(false)

あー、やっぱり整数値は日付として扱えないんですね(棒読み)。
では、ちょうど1日前の 2015-06-18 00:00:00 を表すタイムスタンプ、1434553200を渡してみます。

$ php -r 'var_dump(strtotime(1434553200));'
int(38829735295)

またしても謎の数値が出てきました。
さっきと同様、dateを掛けて日付に戻してみましょう。

$ php -r 'var_dump(date("Y-m-d H:i:s", strtotime(1434553200)));'
string(19) "3200-06-19 14:34:55"

お分かりいただけたでしょうか。
元になったタイムスタンプと並べてみます。

  • 1434553200
  • 3200-06-19 14:34:55

分かりやすく、色を付けてみます。

  • 1434553200
  • 3200-06-19 14:34:55

これで一目瞭然ですね。
タイムスタンプの上位桁から「時分秒(各2桁)」「年(4桁)」と解釈した日付になっています。
色分けされていない月と日はどこから来たのかというと、これはコードを実行した月日(この記事を書いている6/19)になっています。

1434639600の時には、「63秒」の部分が不正な秒数なのでfalseになっていたわけですね。
また今回の実験環境は64bit環境なので比較的再現しやすいですが、32bit環境ではMySQL DATETIMEのものと同様「32bit符号付き整数で表現可能な範囲」という縛りも付くうえ、後述の別条件も絡むため、非常に再現しにくい挙動となっています。

挙動を読み解く

どうしてこんなことになってしまうのか、公式ドキュメントのサポートする日付と時刻の書式を読み解いていきましょう。

まず最初に、日付と時刻の両方を指定するための複合的な書式に目を通します。
しかしながら、ここに書かれている内容には「日付が先行し、なおかつ区切り文字を要しない書式」は存在しません。
この時点では、上記の挙動は不可解に思えます。

次に、日付の書式に目を移します。
すると、「年 (年だけの指定)」という書式があることに気が付きます。

年 (年だけの指定) YY “1978”, “2008”

さらに、次のノートを見てみましょう。

注意:
書式「年 (年だけの指定)」は、時刻の指定が先に出現している場合のみ機能します。 そうでない場合、指定された4桁は HH MM とみなされます。

お分かりいただけたでしょうか。
つまるところ、複合的な書式には明記されていないものの、時刻が先に指定されている後に4桁の数値が続く場合、それは年だけの指定として解釈されるわけです。
複合的な書式に書いておいてくれよ、なんて言ってはいけません。ドキュメントがあるだけでもありがたいんです。
(実際、PHPのドキュメントは総じてよく出来ています。このへんが初心者に人気の理由かもしれません)

最後に、時刻の書式に目を移しましょう。
すると「時、分、秒(コロンなし)」という露骨な書式が存在します。

時、分、秒(コロンなし) ‘t’? HH MM II “040837”, “T191919”

これですべてのピースが揃いました。
タイムスタンプ、というより10桁の数値は、「『時、分、秒(コロンなし)』の時刻書式に続き、『年 (年だけの指定)』が続くような、日時の指定」として解釈されていたわけです。
読み解いてみると、確かに仕様に沿った解釈になっているなーと納得できるようなできないような。

再現条件

さて、詳細な再現条件に移りましょう。
色々実験していると分かるのですが、一見上記の解釈で正しい日付になっていそうなパターンでもfalseになる場合があります。

$ php -r 'var_dump(strtotime(1437361400));'
bool(false)
$ php -r 'var_dump(strtotime(1437371400));'
int(-17972821343)

上の例は1400/MM/DD 14:37:36と解釈されても良さそうですが、falseになっています。
一方、下の例は1400/MM/DD 14:37:37と期待通りに解釈されています。
たった1秒の違いなのに、いったい何が違うんでしょうか。

ここで、先ほど全く役に立たなかった複合的な書式を見てみましょう。
すると、以下のシンボルと書式が存在することに気が付きます。

doy “00”[1-9] | “0”[1-9][0-9] | [1-2][0-9][0-9] | “3”[0-5][0-9] | “36”[0-6] “001”, “012”, “180”, “350”, “366”
PostgreSQL: 年、日番号 YY “.”? doy “2008.197”, “2008197”

これはPostreSQLの年と日番号(doy, day of year)の組み合わせによる書式です。
ここで「36[0-6]」はdoyとして解釈できるが、「37X」はdoyとして解釈できない、という所が重要です。

先ほど解釈に失敗した1437361400は、秒の部分が36で後に続く数値も1であることから、PosgreSQL書式、すなわち「1437年の361日」としてパースされてしまうのです。
しかしながら続く数値が解釈できずエラーになり、最終的にfalseが返っています。
一方、解釈に成功した1437371400では、秒の部分が37のためどう頑張ってもdoyとしては解釈できないことから、先述の書式として解釈に成功します。

このことから、実は再現条件が結構厳しいことがわかります。

  1. 10桁のタイムスタンプである
  2. 1-2桁目は時として妥当である(10-24)
  3. 3-4桁目は分として妥当である(00-60)
  4. 5-6桁目は秒として妥当である(00-60
  5. 5-7桁目はdoyとして解釈できない(“00″[1-9] | “0”[1-9][0-9] | [1-2][0-9][0-9] | “3”[0-5][0-9] | “36”[0-6]に該当しない)
  6. 32bit環境の場合、タイムスタンプが32bit符号付き整数で表現可能である(少なくとも7-10桁目が1901-2038の間である必要がある)

現在時刻を渡すようなプログラムの場合、32bit環境では相当にレアであり、64bit環境でもなかなか再現しないのが分かるのではないかと思います。

まとめ

とりあえず、タイムスタンプと時刻文字列を一緒くたに扱うのはやめましょう…
ちなみにこれと似たような例で「date関数にタイムスタンプではなく時刻文字列を渡した結果、タイムスタンプ2015の1970/01/01 09:33:35がやたら出てくる」ってパターンもあります。
まぁレガシーなPHPコードを保守していない限りこんなバグを踏むことはそうそうないと思いますが、なにかの参考になれば幸いです。

パーフェクトPHP
パーフェクトPHP 小川雄大 柄沢聡太郎 橋口誠

技術評論社 2014-10-31
売り上げランキング : 5035