てきとうなさいと べぇたばん

PHPでstrtotime("+1 month")という挙動について調べる

strtotime

strtotime("+1 month", strtotime("2017-01-30")); // 2017-03-02

このstrtotimeは、2017年3月2日を指す。これは一体何故か。

挙動の確認

grepしよう

$ grep -r 'PHP_FUNCTION(strtotime' php-src/
php-src/ext/date/php_date.c:PHP_FUNCTION(strtotime)
php-src/ext/date/php_date.h:PHP_FUNCTION(strtotime);

ext/date/php_date.cの1466行目 に存在する。

PHP_FUNCTION(strtotime)

タイムゾーンの取得とかがあったあと、肝心のパースを始める。ext/date/lib/parse_date.cの24142行目。

timelib_time* timelib_strtotime(char *s, size_t len, struct timelib_error_container **errors, const timelib_tzdb *tzdb, timelib_tz_get_wrapper tz_get_wrapper)

いろいろな初期化が始まったあと、パースを実際に行うscan関数が実行される。scan関数そのものは、ext/date/lib/parse_date.cにあるが、re2cでのパースになるため、ext/date/lib/parse_date.reを見たほうがわかりやすい。

scan関数を見ていくと、いろいろな定義が書かれているのがわかるけれども、今回調べたいのは"+1 month"である。これの条件に当てはまるのは"relativetext"

reltextunit = 'ms' | 'µs' | (('msec'|'millisecond'|'µsec'|'microsecond'|'usec'|'sec'|'second'|'min'|'minute'|'hour'|'day'|'fortnight'|'forthnight'|'month'|'year') 's'?) | 'weeks' | daytext;

relnumber = ([+-]*[ \t]*[0-9]+);
relative = relnumber space? (reltextunit | 'week' );
relativetext = (reltextnumber|reltexttext) space reltextunit;

relativetextは1645行目に書いてあった。

relativetext
{
    timelib_sll i;
    int         behavior = 0;
    DEBUG_OUTPUT("relativetext");
    TIMELIB_INIT;
    TIMELIB_HAVE_RELATIVE();

    while(*ptr) {
        i = timelib_get_relative_text((char **) &ptr, &behavior);
        timelib_eat_spaces((char **) &ptr);
        timelib_set_relative((char **) &ptr, i, behavior, s);
    }
    TIMELIB_DEINIT;
    return TIMELIB_RELATIVE;
}

この中の"timelib_set_relative"関数が肝心の日付パースになる。ext/date/lib/parse_date.cの664行目にある。

case TIMELIB_MONTH:    s->time->relative.m += amount * relunit->multiplier; break;

このように、定数TIMELIB_MONTHにマッチして、その分相対時刻amountヶ月分増やす。今回は"+1 month"なので、1ヶ月増やすことになる。

ext/date/php_date.cの1492行目にtimelib_update_ts関数がある。

timelib_update_ts(t, tzi);

この関数で、相対時刻になっている部分を補正する。ext/date/lib/tm2unixtime.cの464行目。

void timelib_update_ts(timelib_time* time, timelib_tzinfo* tzi)

469行目にdo_adjust_relative関数を呼び出している。。

void do_adjust_relative(time);

246行目、timelib_do_normalize関数が呼び出され、ここで相対時刻を補正する。

 timelib_do_normalize(time);

ここまでの処理で、1月30日の+1 monthとはどうなっているのかというと、単純に「2月30日」とされている。それを、timelib_do_normalize関数によって補正されることで「3月2日」となっていく。

do {} while (do_range_limit_days(&time->y, &time->m, &time->d));

do_range_limit_days関数を繰り返しループすることによって、その年月日を補正していく。補正の仕方を見ていこう。

  • ext/date/lib/tm2unixtime.cの119行目に定義がある
  • 145行目にその月の日数(1月なら31日、2月ならうるう年なら29日、他28日etc)というのがdays_last_month定義されている
  • 変数d(日にち)が0日になっていたり、不正な日付になっていた場合、days_last_monthを足したり引いたりして補正している
    • 今回の"+1 month"の場合は後者の「不正な日付」にあたるので、2月30日となっており、days_last_monthは28、30 - 28で2となり、月が繰り上がった結果、3月2日となる

そして、繰り上がったり、繰り下がったりした場合には1を返すことでdo-while文のループを続けるか、続けないかを選択させる。これをすることによって、例えば"+90 days"となっていた場合に、1月90日となっていて、ループを繰り返して4月22日が求められる。

ただし、あまりにも大きい数字の場合、そんなことしてるとハングアップするので、400年(DAYS_PER_LYEAR_PERIOD)とかそういう区切りがある。

$ time php -r 'var_dump(date("Y-m-d", strtotime("+1837417347164719431 days")));'

こういうのやっても特段遅くなかった。

かくにん

do_adjust_relative関数に対してデバッグしてみた。調べたのは1月31日なので、strtotime("+1 month")は3月3日を指す

(gdb) b do_adjust_relative
Breakpoint 1 at 0x464935: file /home/tekitoh/docker/php-src/ext/date/lib/tm2unixtime.c, line 219.
(gdb) run -r 'strtotime("+1 month");'
Starting program: /home/tekitoh/docker/php-src/sapi/cli/php -r 'strtotime("+1 month");'
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".

Breakpoint 1, do_adjust_relative (time=0x7ffff605db40) at /home/tekitoh/docker/php-src/ext/date/lib/tm2unixtime.c:219
warning: Source file is more recent than executable.
219        if (time->relative.have_weekday_relative) {
(gdb) l
214        do_range_limit(1, 13, 12, &time->m, &time->y);
215    }
216
217    static void do_adjust_relative(timelib_time* time)
218    {
219        if (time->relative.have_weekday_relative) {
220            do_adjust_for_weekday(time);
221        }
222        timelib_do_normalize(time);
223
(gdb) n
222        timelib_do_normalize(time);
(gdb) l
217    static void do_adjust_relative(timelib_time* time)
218    {
219        if (time->relative.have_weekday_relative) {
220            do_adjust_for_weekday(time);
221        }
222        timelib_do_normalize(time);
223
224        if (time->have_relative) {
225            time->f += time->relative.f;
226
(gdb) n
224        if (time->have_relative) {
(gdb) n
225            time->f += time->relative.f;
(gdb) n
227            time->s += time->relative.s;
(gdb) n
228            time->i += time->relative.i;
(gdb) n
229            time->h += time->relative.h;
(gdb) n
231            time->d += time->relative.d;
(gdb) n
232            time->m += time->relative.m;
(gdb) p time->d
$1 = 31
(gdb) p time->m
$2 = 1
(gdb) n
233            time->y += time->relative.y;
(gdb) p time->m
$3 = 2
(gdb)

time構造体がたしかにtime->have_relativeが1のときにtime構造体が操作され、1月31日から2月31日になって、do_range_limit_days関数によって、以下のように

do_range_limit_days (y=0x7ffff605db40, m=0x7ffff605db48, d=0x7ffff605db50) at /home/tekitoh/docker/php-src/ext/date/lib/tm2unixtime.c:127
127        if (*d >= DAYS_PER_LYEAR_PERIOD || *d <= -DAYS_PER_LYEAR_PERIOD) {
(gdb) n
132        do_range_limit(1, 13, 12, m, y);
(gdb) n
134        leapyear = timelib_is_leap(*y);
(gdb) n
135        days_this_month = leapyear ? days_in_month_leap[*m] : days_in_month[*m];
(gdb) n
136        last_month = (*m) - 1;
(gdb) n
138        if (last_month < 1) {
(gdb) n
142            last_year = (*y);
(gdb) n
144        leapyear = timelib_is_leap(last_year);
(gdb) n
145        days_last_month = leapyear ? days_in_month_leap[last_month] : days_in_month[last_month];
(gdb) n
147        if (*d <= 0) {
(gdb) n
152        if (*d > days_this_month) {
(gdb) n
153            *d -= days_this_month;
(gdb) n
154            (*m)++;
(gdb) p *y
$1 = 2017
(gdb) p *m
$2 = 2
(gdb) p *d
$3 = 3
(gdb) n
155            return 1;
(gdb) s
158    }
(gdb) s
do_range_limit_days (y=0x7ffff605db40, m=0x7ffff605db48, d=0x7ffff605db50) at /home/tekitoh/docker/php-src/ext/date/lib/tm2unixtime.c:127
127        if (*d >= DAYS_PER_LYEAR_PERIOD || *d <= -DAYS_PER_LYEAR_PERIOD) {
(gdb) p *y
$4 = 2017
(gdb) p *m
$5 = 3
(gdb) p *d
$6 = 3
(gdb)

3月3日に変換されている。

まとめ

strtotime("+1 month")のみに絞って調べた。

  • 2017年1月31日現在でこの処理を行うと、2017年3月3日になる
  • strtotimeの+1 monthは、相対時間として以下のようなプロセスを経る
    • 2017年1月31日から2017年2月31日と「月」を1すすめる
    • 2017年2月31日が正しいのか補正する
    • 2月31日は存在せず補正する必要があるため、その月(ここでは2月)の日数を減算(31 - 28)、月を1すすめる
    • もう一度同じ処理を繰り返し補正する必要があるかチェックして、必要なければ補正終了

strtotimeは引数のパースにre2cを使っている上に非常に機能が豊富な関数のため、例えば同じ時刻を指しているとしても、処理の内容がそのまますべて同じとは限らない。

strtotime("+1 month", strtotime("2017-01-31"));
strtotime("+31 days", strtotime("2017-01-31"));

コレは両者とも2017年3月3日を指すけれども、strtotimeは同じ処理をしていない。後者は31日進めて、進めた後で補正をかけることになるはず。

便利な関数である上に、非常に奥の深い関数である。