hnwの日記

PHPで1599年以前の曜日計算がおかしい


(2010-04-24 17:40)内容を補足する意味で「グレゴリオ暦が採用された時期とプログラミング言語の対応」「ユリウス暦を扱いたい場合」を追記しました。


PHPの日付まわりの処理にバグを見つけました。1599年以前の日付の75%程度の曜日を誤判定するバグがPHP 5.3.2までの全バージョンに存在します。


たとえば1599年12月31日は我々の現在使っているグレゴリオ暦で金曜日なのですが、PHPは土曜日と判定します。

<?php
date_default_timezone_set('Asia/Tokyo');
$datetime = new DateTime;
$datetime->setTime(0,0,0);

// string(31) "Sat, 31 Dec 1599 00:00:00 +0900"
$datetime->setDate(1599,12,31);
var_dump($datetime->format(DateTime::RFC2822));

// string(31) "Sat, 01 Jan 1600 00:00:00 +0900"
$datetime->setDate(1600,1,1);
var_dump($datetime->format(DateTime::RFC2822));


DataTimeクラスを使った場合だけでなく、64bit環境でdate関数を利用しても同じことが起こります。以前の記事「Unknown曜日」と非常に似た話題ですね。


原因となっている処理は、PHPのCソースコード中、ext/date/lib/dow.cにあります。

static timelib_sll century_value(timelib_sll j)
{
        timelib_sll i = j - 17;
        timelib_sll c = (4 - i * 2 + (i + 1) / 4) % 7;

        return c < 0 ? c + 7 : c;
}

static timelib_sll timelib_day_of_week_ex(timelib_sll y, timelib_sll m, timelib_sll d, int iso)
{
        timelib_sll c1, y1, m1, dow;

        /* Only valid for Gregorian calendar, commented out as we don't handle
         * julian calendar. We just return the 'wrong' day of week to be
         * consistent.
        if (y < 1753) {
                return -1;
        } */
        c1 = century_value(y / 100);
        y1 = (y % 100);
        m1 = timelib_is_leap(y) ? m_table_leap[m] : m_table_common[m];
        dow = (c1 + y1 + m1 + (y1 / 4) + d) % 7;
        if (iso) {
                if (dow == 0) {
                        dow = 7;
                }
        }
        return dow;
}


timelib_day_of_week_exというのが、年月日から曜日を整数で返す関数です。これはツェラーの公式の変形だと思うのですが、どう変形したのか僕には追い切れていません。ただ、公式ではガウス記号で表現されている部分がintへのキャストで実現されているせいで、負の数が登場すると不正な値を返してしまうようです。


DateTimeクラスも64bit unix timeも無かった頃の実装でしょうから、これほど過去の日付を扱う前提ではないということでしょう。quick hackで直りましたが、もう少し真面目なパッチを作って本家に送りつけたいと思っています。


ちなみに、1600年以降の曜日については問題ありません。また、グレゴリオ暦が採用された時期を考えると、そこまで昔の曜日を知る必要性があるとも思えません。そんなわけで、このバグによる実アプリケーションへの影響はほとんど無いと思います。

このバグを現行バージョンのPHPで回避する方法

誰も困らないとは思うのですが、このバグをPHPレベルで回避する方法を紹介します。方法は単純で、曜日を求めたい日付の年号が1600未満だった場合に、年に400の倍数を足して1600以上にすれば正しい曜日が取得できます。

<?php
// 0(日曜)から6(土曜)を返す
function get_weekday($year, $month, $day) {
  $year = 2000 + ($year % 400);
  date_default_timezone_set('Asia/Tokyo');
  $datetime = new DateTime;
  $datetime->setDate($year,$month,$day);
  return $datetime->format('w');
}


どういうことかというと、グレゴリオ暦の400年間の日数は7で割り切れるので、ある日付の曜日は400年後・800年後…の同じ日と同じ曜日になるのです。

365*400+97=146097=20871*7


97というのは400年間のうち閏年になる回数です。400年という、7で割り切れない年数で曜日がループすることは面白い知識だと思ったので紹介しました。

グレゴリオ暦が採用された時期とプログラミング言語の対応

実際にグレゴリオ歴が使われていた時期についても紹介します。Wikipediaの「グレゴリオ暦」から抜粋すると、たとえば次の日付で曜日がグレゴリオ歴が採用されています。

  • 1582 年10月15日 : イタリア
  • 1582 年12月20日 : フランス
  • 1752 年9月14日 : イギリス帝国(後のアメリカ合衆国など当時の植民地すべて)


それまではユリウス暦が採用されていたのを、上記の日付でいきなりグレゴリオ暦に移行したため、その月のカレンダーを作るとキモい状態になったようです。「cal 9 1752」すると不思議なカレンダーが出てくるよ、なんてことがcalコマンドのmanpageにも書かれています。

$ cal 9 1752
   September 1752
Su Mo Tu We Th Fr Sa
       1  2 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30



$


また、FreeBSDのncalコマンドを使うと、各国の不思議カレンダーを見ることができます。

$ ncal -s IT 10 1582
    October 1582
Mo  1 18 25
Tu  2 19 26
We  3 20 27
Th  4 21 28
Fr 15 22 29
Sa 16 23 30
Su 17 24 31
$ ncal -s FR 12 1582
    December 1582
Mo     3 20 27
Tu     4 21 28
We     5 22 29
Th     6 23 30
Fr     7 24 31
Sa  1  8 25
Su  2  9 26
$ ncal -s US 9 1752
    September 1752
Mo    18 25
Tu  1 19 26
We  2 20 27
Th 14 21 28
Fr 15 22 29
Sa 16 23 30
Su 17 24
$


これらは面白い知識ではあるのですが、ロケールによって曜日や月の日数を変えたいようなアプリケーションがどれだけあるのかは疑問です。むしろ、プログラミング言語の関数の仕様として考えると、「1752年9月3日は不正な日付である」といった不思議な仕様を採用するとアプリケーションのバグを誘発しかねません。ですから、日付・曜日については「過去についてもグレゴリオ暦を拡張して適用する」という仕様にするのが現実的ではないでしょうか。実際、RubyのTimeオブジェクトなどはそのようになっています(64bit環境でないと確認できませんが)。


上で引用したPHPソースコード中にも、1752年以前の曜日は返せないようにするコードがコメントアウトされています。ユリウス暦を意識しようかと思ったけど非現実的だからやめたよ、という状況なのでしょう。

ユリウス暦を扱いたい場合

PHPは「カレンダー関数」というのを標準装備していて、ユリウス暦ユダヤ暦を簡単に扱えるようです。僕は一生使わないと思いますが。