koluku's blog

コードは毎日の欠片

🕗Perlの時間はモジュールで #perlwakate

2020/07/01 にオンラインで開催された 「Perl若手の会(https://connpass.com/event/179235/)」でのLT資料です。

speakerdeck.com

下記の文章はスライド内容を一部修正したものです。

引用の中は特にURLが無い限りはperldoc.jpからの引用です。

Perlでの基本実装

$^T変数、time関数

Perlには$^Tという変数が予め宣言されていて、

プログラムを実行開始した時刻を、紀元 (1970年の始め) からの秒数で 示したものです

と定義されています。この数字は紀元 (1970年の始め)からの秒数です。

$ perl -E 'say $^T'
1593654027

じゃあ紀元 (1970年の始め) って何なのかいうと、time関数に解説があります。

ほとんどのシステムでは紀元はUTC1970年1月1日00:00:00です;特徴的な例外としては、古いMacOSではローカルタイムゾーンの1904年1月1日00:00:00を紀元として使います。

UTC1970年1月1日00:00:00はいわゆるUNIX時間なので基準としてわかりやすいです。

本当に例外として古いMac(Power PCのMacintoshあたり?)のときだけUTC1904年1月1日00:00:00が開始になります。なぜ1904年なのかというと、うるう年の例外回避のための日付だからです。

そもそもうるう年とは、

  1. 西暦年が4で割り切れる年は(原則として)閏年。
  2. ただし、西暦年が100で割り切れる年は(原則として)平年。
  3. ただし、西暦年が400で割り切れる年は必ず閏年。

閏年 - Wikipedia

と定義されています。

なので1900年は100年に一度の例外でうるう年が発生せず、2000年は400年に一度の例外でうるう年が発生します。つまり1904年から始めると2096年まで4年に一度うるう年が存在するので、うるう年が発生しないという例外処理をしないで済みます。

$^T変数もtime関数もどちらもシステム時間を返すのでどちらを使っても同じです。多分time関数のほうが読みやすいのでそちらのほうが良いかと思います。

localtime関数, gmtime関数

time関数は現在時間を知ろうにもUNIX時間(秒数)で返されるので普通には扱えないという問題があります。

そこでtime関数から個別の時刻情報を取り出すことができるlocaltime関数があります。

time関数が返す時刻を、ローカルなタイムゾーンで測った時刻として、9要素の配列に変換します。

my @localtime =  localtime;
say $localtime[2];

# OR

my ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime;
say $hour;

帰ってくる配列の中身はindex 0から順にこうなっています。

  1. $sec … 秒
  2. $min … 分
  3. $hour … 時間
  4. $mday … 日
  5. $mon … 月
  6. $year … 年
  7. $wday … 曜日
  8. $yday … 1年の中で何日目
  9. $isdst … 夏時間かどうか

ただし、年の扱いがそのままのグレゴリオ暦で出力されません。2020年だと$yearは120で返ってきます。

$year は 1900 年からの年数を持ちます。

1900年は人間が読みやすいように下二桁で判断できる開始地点です。いわゆるPOSIX準拠ですね。なので1900を足してから扱います。

スカラーのときに扱うと日付情報含めて文字列で出力されます。

$ perl -E 'say scalar localtime'
Thu Jul  2 12:26:19 2020

POSIX::strftimeを併用して使うと任意の出力をすることができます。

use POSIX qw(strftime);
my $now_string = strftime "%a %b %e %H:%M:%S %Y", localtime

localtimeだとマシンの時間に依存するため日本だとGMT+9:00でグリニッジ標準時から9時間足された時間になります。

これがマシンの時間がわかっているのであれば単純に-9時間で計算すればいいのですが、サーバーに置いた場合に時間わからない場合があり、戻す時間がわからなくなります。

そこで最初からグリニッジ標準時で出力されるgmtime関数があります。こちらはlocaltimeと同じAPIで取り扱うことができます。

モジュールでの実装

Time::Piece

localtimeは便利ですが、使いたい形にするためには結局関数化する必要があります。日付や時刻情報などは出力の形が大体決まっており、その形にわざわざ書くのも面倒です。

そこでTime::Pieceの登場です。Time::Pieceはlocaltime関数とgmtime関数をTime::Pieceオブジェクトにして返してくれます。ちなみにTimepieceは時計という意味です。

分や日など単独で欲しい情報のメソッドはもちろん、日付やフォーマットの仕方などもメソッドで提供されています。

$t->ymd                 # 2000-02-29
$t->date                # $t->ymd と同じ
$t->mdy                 # 02-29-2000
$t->mdy("/")            # 02/29/2000
$t->dmy                 # 29-02-2000
$t->dmy(".")            # 29.02.2000
$t->datetime            # 2000-02-29T12:34:56 (ISO 8601)
$t->cdate               # Tue Feb 29 12:34:56 2000

$t->time_separator($s)  # デフォルトのセパレータを設定 (デフォルト ":")
$t->date_separator($s)  # デフォルトのセパレータを設定 (デフォルト "-")
$t->day_list(@days)     # デフォルトの曜日を設定
$t->mon_list(@days)     # デフォルトの月名を設定

Time::Pieceオブジェクトに対してTime::Secondsを加算・減算したり、Time::Pieceオブジェクト同士で差分をTime::Secondsで出したりすることができます。

use Time::Piece;
use Time::Seconds;

my $today = localtime;
my $tommorow = $today + ONE_DAY; # ONE_DAYはTime::Secondsで定義されている1日
say $tommorow->ymd; # Time::Pieceオブジェクト
# 2020-07-03

my $diff = $tommorow - $today;
say $diff->hours; # Time::Secondsオブジェクト
# 24

また、Time::Pieceオブジェクト同士で比較する事もできます。

use Time::Piece;
use Time::Seconds;

my $today = localtime;
my $tommorow = $today + ONE_DAY;

$tommorow += ONE_HOUR;
if ($tommorow - $today != 24 * ONE_HOUR) {
    say '差は24時間ではないです';
}

しかし、一つ問題があり

このモジュールは、perl の time() 経由で提供され、gmtime() と localtime() が対応している紀元秒システムを内部で使用しています。

そもそもtime関数で拾っているtime_t型は符号付きintで実装されているため231-1(2,147,483,647秒 = UTC2038年01月19日03:14:07)を超えると負になり、 正しく時刻を扱うことができなくなります。

そのため、回避策を取る必要があります。

64 ビット perl を使ってください。 またはこれらの選択肢を取れない場合は、過去と未来の年に対応している DateTime モジュールを使ってください

ほとんどの場合は64bitのPerlで解決しますが、古いPerlを使わないといけない場合などではDateTimeで対処する必要があります。

DateTimeでできるのであればTime::Pieceの優位性が無いと思われますが、Time::PieceはPerl 5.10以降でデフォルトで入っていることやDateTimeより軽いということもあり特に理由がなければTime::Pieceを使うのが良いと思います。

でもどうやって加算や比較をしてる?

ちょっと寄り道ですが、先程Time::Pieceでは加算・減算・比較ができると言いましたが、冷静に考えるとこれはちょっとおかしいです。というのも、Time::Pieceはオブジェクトなので数値や文字列のように直接比較することができません。

不思議だなぁということでmeta::cpanからBrowseでコードを見てるとTime::Seconndsにoverloadパッケージで演算子を囲っていることがわかります。

use overload
    'fallback' => 'undef',
    '0+' => \&seconds,
    '""' => \&seconds,
    '<=>' => \&compare,
    '+' => \&add,
    '-' => \&subtract,
    '-=' => \&subtract_from,
    '+=' => \&add_to,
    '=' => \&copy;
};

Seconds.pm - metacpan.org

overloadパッケージとはなんぞやとperldoc.jpを見てみると、

Perl の演算子のオーバーロードを行うパッケージ

とあります。つまり算術演算子や比較演算子を別の関数に上書きできるみたいです。

Time::Secondsでは例えば+算術演算子の場合は左右から値を取ってadd関数に入れ、加算したものをTime::Seconndsで返していることがわかります。

sub _get_ovlvals {
    my ($lhs, $rhs, $reverse) = @_;
    $lhs = $lhs->seconds;
 
    if (UNIVERSAL::isa($rhs, 'Time::Seconds')) {
        $rhs = $rhs->seconds;
    }
    elsif (ref($rhs)) {
        die "Can't use non Seconds object in operator overload";
    }
 
    if ($reverse) {
        return $rhs, $lhs;
    }
 
    return $lhs, $rhs;
}

# ~

sub add {
    my ($lhs, $rhs) = _get_ovlvals(@_);
    return Time::Seconds->new($lhs + $rhs);
}

Seconds.pm - metacpan.org

Time::Piece::MySQL

時間といえばMySQLでの扱いはなかなかに面倒です。

例えば、時刻の表記ではfrac(小数点以下)が追加されています。

また、DATETIMEとTIMESTAMPは同じフォーマットでもサポートされている時間の範囲が異なり、 DATETIMEは月・日に00を許容されます。そして、時には同じ値でも違う値で登録されることがあります。

そこで、Time::Pieceの拡張メソッドとしてTime::Piece::MySQLがあります。

Time::Piece->mysql_dateでTime::PieceオブジェクトをMySQLのDATE型に変換し、Time::Piece->from_mysql_date($datetime)でMySQLのDATE型からTime::Pieceオブジェクトに変換することができます。DATETIME・TIME・TIMESTAMPでも対応するメソッドを使えば変換することができます。

Time::HiRes

そういえば時間といえば面倒なものがありましたね。そうです、小数点以下の時間の扱いです。

time関数では秒数しか扱わないため、ミリ秒・マイクロ秒を扱うことができません。

Time::HiRes::gettimeofdayを使うとtime関数で返ってくる秒数情報に加えてマイクロ秒が配列で返ってきます。

use Time::Piece;
use Time::HiRes;

my ($time, $ms) = Time::HiRes::gettimeofday;
my $t = localtime($time);
say $t->datetime . ".$ms";
# 2020-07-02T19:31:40.946569