Perl の Time::Piece 利用上の注意点

こんにちは、アプリケーションエンジニアの id:nanto_vi です。この記事ははてなデベロッパーアドンベントカレンダー 2015 の 2 日目です。

Perl で日時を扱うモジュールのひとつに Time::Piece があります。コアモジュールなので手軽に使えますが、Perl - Time::Piece に関するとりとめのないコト - Qiita にもまとめられているように注意すべき点も多いです。ここでは、そのような注意点をいくつか挙げていきたいと思います。

なお、以下のコードは Time::Piece 1.31 で確認しています。

タイムゾーン情報を持たない

大前提として、Time::Piece のインスタンスはタイムゾーン情報を持っていません。持っているのはローカル時間か GMT かの区別のみです。その「ローカル時間」が実際にどのタイムゾーンなのかは、環境 (環境変数 TZ の値など) から判別して都度計算しています。

インスタンスの複製

Time::Piece のインスタンスを複製するには、new メソッドをインスタンスメソッドとして使います。

use Time::Piece;
my $t0 = Time::Piece->strptime('2015-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S');
my $t1 = $t0->new;
say $t0->datetime; # 2015-01-01T00:00:00
say $t1->datetime; # 2015-01-01T00:00:00
say $t0->tzoffset; # 0
say $t1->tzoffset; # 0

new をクラスメソッドとして使ってしまうと、元のインスタンスが GMT であっても、ローカル時間のインスタンスが生成されます。

use Time::Piece;
my $t0 = Time::Piece->strptime('2015-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S');
my $t1 = Time::Piece->new($t0);
say $t0->datetime; # 2015-01-01T00:00:00
say $t1->datetime; # 2015-01-01T00:00:00
say $t0->tzoffset; # 0
say $t1->tzoffset; # 32400

ローカル時間と GMT の変換

gmtime / localtime 関数の第 1 引数に Time::Piece のインスタンスを渡すと、そのインスタンスの日時の成分 (年月日時分秒の値) を保ったままの、新たな Time::Piece のインスタンスが生成されます (Time::Piece 1.16 以降)。

エポック秒 (協定世界時 1970 年 1 月 1 日 0 時 0 分 0 秒からの経過秒数) を保ったままで新たなインスタンスを生成したいときは、gmtime / localtime の第 1 引数にエポック秒を渡す必要があります。

use Time::Piece;
my $t0 = Time::Piece->strptime('2015-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S');
my $t1 = $t0->new;
my $t2 = $t0->new;
say localtime($t1)->datetime;        # 2015-01-01T00:00:00
say localtime($t2->epoch)->datetime; # 2015-01-01T09:00:00

gmtimeTime::Piece::gmtime は異なる

多くの Perl モジュールでは、インポートした関数とパッケージ名付きで呼び出す関数は同一です。例えば、use List::Util qw(sum); sum 1 .. 10; としようが、use List::Util; List::Util::sum 1 .. 10 としようが、挙動に変わりありません。

しかし、Time::Piece からインポートしてきた gmtime / localtime 関数は Time::Piece::gmtime / Time::Piece::localtime 関数と挙動が異なります。

インポートしてきた関数は Time::Piece->gmtime / Time::Piece->localtime 相当なので、第 1 引数に Time::Piece のインスタンスを渡したときにそれが結果に影響します。Time::Piece::gmtime / Time::Piece::localtime は第 1 引数に Time::Piece のインスタンスを渡しても無視します。

use Time::Piece;
my $t0 = Time::Piece->strptime('2015-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S');
my $t1 = $t0->new;
my $t2 = $t0->new;
say localtime($t1)->datetime;              # 2015-01-01T00:00:00
say Time::Piece::localtime($t2)->datetime; # 2015-12-02T12:34:56 (実行時の時刻)

ローカル時間での日時のパース

Time::Piece->strptime の返り値は常に GMT になります。strptime をインスタンスメソッドとして呼び出すと、元のインスタンスのローカル時間フラグが、新しく生成されるインスタンスに引き継がれます。

use Time::Piece;
my $g = gmtime;
say $g->strptime('2015-01-01', '%Y-%m-%d')->tzoffset; # 0
my $l = localtime;
say $l->strptime('2015-01-01', '%Y-%m-%d')->tzoffset; # 32400

strftime はバイト列を返す

strftime メソッドの引数に文字列を渡しても、その返り値はバイト列になります。また、引数に NUL 文字が含まれていた場合、それ以降は無視されます。

use utf8;
use Time::Piece;
use Encode;

my $t = Time::Piece->strptime('2015-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S');
my $b = $t->strftime("%Y年%m月%d日\0%H自%M分%S秒");

# バイト列なので Wide character in ... 警告が出ない
say $b; # 2015年01月01日

# UTF-8 で符号化されたバイト列なので 17 バイトになる
say length $b; # 17

# decode_utf8 で文字列に変換すると 11 文字になる。
say length decode_utf8($b); # 11

gmtime / localtime は破壊的

gmtime / localtime 関数の第 1 引数に Time::Piece のインスタンスを渡すと、そのインスタンスの内部的なエポック秒の値が変更されます。どう変更されるかは Time::Piece 1.29 以前と 1.30 以降で異なりますが、1.30 以降だと値の不整合が発生することもあります。

use Time::Piece 1.31;

my $t0 = Time::Piece->strptime('2015-01-01T00:00:00', '%Y-%m-%dT%H:%M:%S');
say $t0->epoch; # 1420070400

my $t1 = localtime($t0);

say $t0->datetime; # 2015-01-01T00:00:00
say $t0->tzoffset; # 0

# 2015-01-01-T00:00:00+00:00 のエポック秒は 1420070400 のはずだが、
# localtime 関数の引数に渡した後はエポック秒だけが
# 2015-01-01T00:00:00+09:00 のものになっている。
say $t0->epoch; # 1420038000

まとめ

このように、Time::Piece には多くの注意点があるため、利用する際には慎重に扱う必要があります。

また、国際化を前提とする (複数のタイムゾーンを扱う必要がある) ときはそもそも Time::Piece は向いていないので、DateTime などタイムゾーン操作に対応したモジュールを使ったほうがいいでしょう。

はてなでは日時を正確に扱えるデベロッパーを募集しています (※ 自身が日時に正確である必要はありません)。