AddinBox(Title_Logo)

( 2011/3/15 掲載  ,  2011/3/24 一部修正 )
[ 旧dionサーバー時のURL & アーカイブリンク : http://www.h3.dion.ne.jp/~sakatsu/Perl/Notes_Perl_timegm_timelocal.htm ]

〜 Perl : [ timegm / timelocal ] で「年」引数の仕様の正しい理解と使い方について 〜

[ 祝日ロジック( Perl )へ戻る ]

(補)  〜 この問題で影響を受ける範囲 〜 [ 2011/3/24 追記 ]
      「1900年代の日付([$year-1900]=0〜99 になる)を使う事は絶対に無い」という場合には、この問題に
      抵触しませんので、[$year-1900] の使い方を続けても全く支障ありません(2000年代以降の日付ならば
      [$year-1900]の値が3桁になりますので、timegm/timelocalに[1900年からのオフセット]として解釈 して
      貰えます)。
      「カレンダーツール・日付に関する関数」等の開発に際して、1999年以前もカバーするという仕様を検
      討する場合には、この問題に注意を払ってください。引数をエポック秒で受け取る仕様の場合、エポッ
      ク秒の算出では(そのツール/関数の利用者サイドで)、「timegm/timelocalへ4桁西暦年を渡す」ように
      しないと正しい期待通りの値が得られない為、利用者サイドへの注意喚起が必要となります。
     

T. 要 旨

timegm/timelocal について、多くの解説書・解説HPが紹介している 「1900を引く ($year-1900)
という使い方(70 イコール 1970年 等という風に固定されているといった認識)は、timegm/time
local 本来の仕様からすれば誤解を含んだ使い方です。Perl が世に出て以降、現在まで「偶々、
その方法でも上手くいっていた
」というだけです。

これからは、timegm/timelocal の仕様に則った使い方をするように改める必要があります。

U. 概 要

日付(日時)をエポック秒へ変換するには Time::Localモジュールの timegm/timelocal を使用します
が、多くの解説書/解説HPにおいては、timegm/timelocal を呼び出す際に引数として指定する『年』
のデータを
    ・ 「1900を引いて渡す」(または、サンプルコードが $year-1900 )。
    ・ 「1900を引いても、引かなくても、どちらでも良い。どちらでも計算可」。
という何れかで解説しています。

調べたところ、「1900を引く」というのは、
    現時点(2011年時点、および、2019年まで)では、その方法で
    「Perlが扱える下限の日付:1970/1/1」以降の全ての日付を
    【偶々】カバーできている。

というだけの事でした。

今後、
    「1900を引く」という指定方法では、ユーザーが意図する日付を、
    正しく timegm/timelocal へ渡せなくなる。
という時期が訪れようとしています。

現に、2011年時点で
    ・ 1970年以前(マイナスのエポック秒)を扱えるように
      拡張されている Perl5.10 以前のシステム
    ・ 2038年以降 および 1970年以前(マイナスのエポック秒)を
      正式にサポートした Perl5.12
において、「1900を引く」という指定方法では「1900〜1961年」の日付として解釈して貰えなくなって
います。

1900を引いた [0〜61] という値を、2011年時点で timegm/timelocal は [2000〜2061年]
と解釈
します。


これは、timegm/timelocal のバグではありません

仕様に則って正しく解釈した結果です。

単に、我々、ユーザーが timegm/timelocal の仕様をしっかりと理解せずに使い続けてきたというのが
事の真相です。

2011年現在では、[1900〜1961年] の範囲がそのように処理されますが、1970年以降の日付は未だ
大丈夫です。しかし、今後、2020年,2030年…へと進むにつれて、
    「年」から 1900 を引く ( $year-1900 )
という指定方法のままでは期待通りの結果を得る事ができなくなります(注:この問題の影響を受ける
のは1900年代の日付です。2000年以降の日付には影響しません
)。

例: 1970年の意味で [1970-1900 = 70] を timegm/timelocal に指定した場合
        [ 2011年 ] 時点 : 期待通りに 1970年 として扱われます。
        [ 2021年 ] 時点 : 意に反して 2070年 として扱われます

何故、こういう結果になるのかについては、後述の「V. 詳細」で解説しています。

これからは、timegm/timelocal を使うに当たって、以下のプログラミング作法に変えていくべきです。
基本的に [1900年からのオフセット ( $year-1900 ) ] は使用すべきではありません(1899年以前
の日付を扱う特殊なプログラム以外は)。

    timegm/timelocal へは「年」の値として
        ・ 1900年 以降 : 4桁の西暦年 (ex. 1960, 2011)
        ・ 1899年 以前 : 1900年からのOffset (マイナス, ex. 1899 → -1, 1800 → -100)
    を渡すようにします。

  my $adjust_year = $year;      # year は4桁西暦年
  $adjust_year -= 1900 if ($adjust_year < 1900); #1899 以前の[年]は一切扱わないと断言できれば不要
  $epoch = timelocal (…… , $adjust_year);

V. 詳 細

1. [$year-1900] の使用方法が何故駄目なのか、その事を説明する前に、[$year-1900] でプログ
    ラミングした下記サンプルを試してみてください。
          [ Perl_timegm_Test1._cgi.txt ]
                AddinBox サイト には CGI ( Perl ) の動作環境がありませんので、テキストファイル( txt ) にしてあります。。
                各自の環境にダウンロードしてお試しください。尚、Perl 5.10 以前のシステム(マイナスのエポック秒(1970/1/1
                より前の日付)が扱えるように機能拡張されているシステムは除く)では、問題となる期間のデータ(1900〜1961
                年)を試す事が出来ませんので、スクリプト末尾に コメント として入れてある実行結果サンプルをご覧ください。

    サンプルコードの末尾にも実行結果をコメントとして載せてありますが、2011年時点では [1900〜
    1961年] の範囲で [+100年] という不可解な結果が出ています(1900/1/1〜1900/2/28では更
    に「1日」のズレが出ていますが、それは[閏年ではない1900年]のところを[閏年である2000年]とし
    て処理している為です)。


2. 先ずは、timegm/timelocal の仕様を整理しつつ、[$year-1900] の使用方法では上記テスト結果
    になるという事の理由を解説します。

(1) 「1900〜1961の範囲で[+100年]になる挙動」は timegm/timelocal のバグではありません。仕様
    (年の取り扱いルール)に則った挙動です。


(2) gmtime/localtime の返却値(年)が[1900年からのOffset値]である為、その逆関数である timegm/
    timelocal を呼び出す際にも、
          年の条件(値)を考慮する事なく、固定的に[1900年からのOffset値 ($year-1900)]を渡す。
    としているプログラム作法に問題があります。

    多くの解説書/解説HPにおいては、timegm/timelocal を呼び出す際に引数として指定する『年』
    のデータを
          ・ 「1900を引いて渡す」(または、サンプルコードが $year-1900)
          ・ 「1900を引いても、引かなくても、どちらでも良い。どちらでも計算可」
    という何れかで解説しています。


(3) timegm/timelocal における「年」の取り扱いに関する完全な仕様は下記で確認できます。

      ・ [Time::Local]モジュール [Perl\lib\Time\Local.pm]のソース末尾コメント。
      ・ http://search.cpan.org/dist/Time-Local/lib/Time/Local.pm

    私(AddinBox)が検証に使った ActivePerl5.12.3 には [v1.1901_01] がパッケージされていました。

    2011/3 現在の最新バージョンは CPANサイトにある [v1.2000] です。他に[v1.0700]という2003年
    頃の版も見てみましたが、コードの整形やチェック強化などの変更はありますが、「年の取り扱いル
    ール」そのものは当初から変わっていないことが確認できました。


(4) 「年の取り扱いルール」
    timegm/timelocal に渡される「年」のデータは、その値の範囲によってtimegm/timelocal 内部で下
    記のように扱われます。

    (a) year > 999  …… ( 2900年以降の日付では[$year-1900]が この範囲に入ります )
            1900年からのOffset ではなく、4桁の西暦年と解釈します。
            timegm/timelocal側でOffset に変換して処理しています。

            ex. '1964'=AD 1964 , '1964'AD 3864(1964+1900)
                 [2900-1900]=1000 --> AD 1000 , AD 2900

    (b) 100 <= year <= 999  ……  ( 2000〜2899年の日付は[$year-1900]が この範囲に入るので問題ありません )
            1900年からのOffset と解釈します。

            ex. '112'=AD 2012 ( 112+1900 )

    (c) 0 <= year <= 99  …… ( 1900年代の日付では[$year-1900]が この範囲となる為、問題を引き起こします )
 
  マシン日付の 【今年】 を基点にした前後50年間の [西暦 下2桁] と解釈します。
  つまり、新年を迎える度に条件が変わります

            timegm/timelocal側で下記変換を施した上でOffset に変換して処理しています。

            今年 2011年の場合 → [1962〜2011〜2061]
                (i) 62〜99=AD 19xx (+1900)
                (ii) 0〜61=AD 20xx (+2000)
                ex. '62'=AD 1962 , '80'=AD 1980 , '0'=AD 2000 , '61'=AD 2061

            2021年になると → [1972〜2021〜2071]
                (i) 72〜99=AD 19xx (+1900)
                (ii) 0〜71=AD 20xx (+2000)
                ex. '62'=AD 2062 , '80'=AD 1980 , '0'=AD 2000 , '61'=AD 1961

            2061年になると → [2012〜2061〜2111]
                (i) 12〜99=AD 20xx (+2000)
                (ii) 0〜11=AD 21xx (+2100)
                ex. '62'=AD 2062 , '80'=AD 2080 , '0'=AD 2100 , '61'=AD 2061

    (d) year < 0  …… ( 1899年以前の日付は[$year-1900]が この範囲に入るので問題ありません )
            1900年からの Offset (過去)と解釈します。

            ex. '-1'=AD 1899(-1+1900) , '-100'=AD 1800(-100+1900)


(5) 固定的に [1900年からのOffset 値] で渡すと、上記の仕様により、timegm/timelocal は以下
    のように解釈します。

  [今年が 2011年 の場合]
  Y=2039 Offset=139 (b) 139+1900 = AD 2039
  Y=2038 Offset=138 (b) 138+1900 = AD 2038
  Y=2011 Offset=111 (b) 111+1900 = AD 2011
  Y=2000 Offset=100 (b) 100+1900 = AD 2000
  Y=1999 Offset= 99 (c-i)  99+1900 = AD 1999
  Y=1962 Offset= 62 (c-i)  62+1900 = AD 1962
  Y=1961 Offset= 61 (c-ii)  61+2000 = AD 2061 (*)
  Y=1900 Offset=  0 (c-ii)   0+2000 = AD 2000 (*)
  Y=1899 Offset= -1 (d)  -1+1900 = AD 1899
  Y= 100 Offset=-1800 (d) -1800+1900 = AD 100

    これが[ Perl_timegm_Test1.cgi ] に現れた不可解な結果の理由です。


(6) マイナスのエポック秒(1970/1/1 0:0:0 よりも前の日時)はPerl5.12 で正式にサポートされま
    した(2038年問題も同時に対応されています)。

    尚、非公式ではありますが、Perl5.10以前でもシステムによっては、マイナスのエポック秒を取り
    扱えたようです(ディストリビューターによる機能拡張/ユーザー自身による機能拡張モジュール
    の組み込み 等に依る)。

    今後、1970年以前も普通に扱えるようになった事が知れ渡ると、「固定的に1900年からのOffset
    値で渡す」という従来の作法のままでは、前記(5) の 「( 4-c ) 仕様に触れた予期しない結果」が
    出てきて慌てる人が増えてくると予想されます。

    注意して貰いたいのは、これが何も「1970年以前の日付」に限った事ではないという事です。

    今後、時が進んで行けば、(4-c) の例にあるとおり、「1900年代のつもりで渡した日付が、全て2000
    年代の日付になっている」という時が訪れます。更に「年が開ける」度に条件が変わりますので、12
    月中に行なったテストでは問題なかったのに、1月になった途端に計算結果がおかしくなるという事
    態も起こり得ます(注:この問題の影響を受けるのは1900年代の日付です。2000年以降の日付に
    は影響しません
)。


(7) ( 4-c ) 仕様 に触れないように、今後は新たなプログラミング作法として以下のようにするべきで
    しょう(これはPerl5.10以前でも有効です)。

    timegm/timelocal へは「年」の値として
        ・ 1900年 以降 : 4桁の西暦年 (ex. 1960, 2011)
        ・ 1899年 以前 : 1900年からの Offset (マイナス, ex. 1899 → -1, 1800 → -100)
    を渡すようにします。

  my $adjust_year = $year;      # year は4桁西暦年
  $adjust_year -= 1900 if ($adjust_year < 1900); #1899 以前の[年]は一切扱わないと断言できれば不要
  $epoch = timelocal (…… , $adjust_year);

    4桁の西暦年を渡すように修正したテストコードを試してみてください。全ての「年」で期待通りの
    結果が得られています。
          [ Perl_timegm_Test2_cgi.txt ]
                AddinBox サイト には CGI ( Perl ) の動作環境がありませんので、テキストファイル( txt ) にしてあります。
                各自の環境にダウンロードしてお試しください。尚、Perl 5.10 以前のシステム(マイナスのエポック秒(1970/1/1
                より前の日付)が扱えるように機能拡張されているシステムは除く)では、問題となる期間のデータ(1900〜1961
                年)を試す事が出来ませんので、スクリプト末尾に コメント として入れてある実行結果サンプルをご覧ください。


(8) 一方で、gmtime/localtime から得られる[年]の値は[1900年からのOffset] で固定されています。
    その為、gmtime/localtime から得た[年]を
          ・その値のまま
          ・もしくは、そのオフセット値に前年[-1]/翌年[+1]等の何らかの加工をした値
    でtimegm/timelocal へ渡すのは、(4-c)仕様に触れる可能性があり危険です。

    gmtime/localtime から得た[年]は、
          ・先ずは[+1900]で4桁西暦年に戻し
          ・その後で「4桁西暦年そのまま or 4桁西暦年で何らかの加工をする」
    という段階を踏み、4桁西暦年としてtimegm/timelocal へ渡すべきです。

  my ($year,$month,$day,$hour,$min,$sec) = (localtime($epoch))[5,4,3,2,1,0];
  $year += 1900;    #4桁西暦年に戻す

  my $year2 = $year + 5; #ex. 5年後
  my $year2 -= 1900 if ($year2 < 1900);  #1899以前の[年]は一切扱わないと断言できれば不要

  my $epoch2 = timelocal($sec,$min,$hour,$day,$month,$year2);


(9) 参考:Perl\lib\Time\Local.pm [v1.2000] ソースコード解説

    以下の注釈は、今年が2011年の場合です。

  my $ThisYear = ( localtime() )[5];              # → 2011-1900=111
  my $Breakpoint = ( $ThisYear + 50 ) % 100;      # → 61
  my $NextCentury = $ThisYear - $ThisYear % 100;  # → 100
  $NextCentury += 100 if $Breakpoint < 50;        # → 100
  my $Century = $NextCentury - 100;               # → 0

      ( 中 略 )

  sub timegm {
    my ( $sec, $min, $hour, $mday, $month, $year ) = @_;
    if ( $year >= 1000 ) {
        $year -= 1900;
        # 999超は 4桁西暦年。1900を引いてOffset値にする。
    }
    elsif ( $year < 100 and $year >= 0 ) {
        $year += ( $year > $Breakpoint ) ? $Century : $NextCentury;
        # 0-99はBreakPoint(61)の前後で足す値を変えてOffset値にする。
        # 62-99 : Century(0)を足す → 19xx
        # 0-61 : NextCentury(100)を足す → 20xx
    }
        # 他(100-999 , マイナス値)
        # 渡された値はOffset値と解釈して、そのまま使用する。


[ Home へ戻る ] [ 祝日ロジック( Perl ) へ戻る ]


角田 桂一 Mail:addinbox@h4.dion.ne.jp CopyRight(C) 2011 Allrights Reserved.