PHPでstrtotime("+1 month")という挙動について調べる
TOP > てきとうにこらむ > ゲーム作りとプログラミング日記 > 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日進めて、進めた後で補正をかけることになるはず。
便利な関数である上に、非常に奥の深い関数である。