Perl Hackers Hub

第66回モジュールによる時間の多様な取り扱い(1)

本連載では第一線のPerlハッカーが回替わりで執筆していきます。今回のハッカーはコルクことkolukuさんで、テーマは「Perlの時間モジュール」です。

業務でコードを書くうえで時間は重要な要素です。日付の管理や特定の形式への変換、ミリ秒まで取り扱いたい場合など、さまざまなケースがあります。Perlには、これらの時間の取り扱いを便利にするモジュールがあります。本稿では、Perlの時間モジュールについて、標準モジュールと拡張モジュールの両方の側面から追っていきます。

本稿は、執筆時点2021年1月の最新版であるPerl 5.32.0を用いました。本稿のサンプルコードは、WEB+DB PRESS Vol.121のサポートサイトから入手できます。

Perlにおける時間の基本

本節では、Perlが時間をどのように管理しているのか、そして時間をどのように扱うのかを解説します。

UNIX時間による時間管理

まずは、Perlが時間をどのように管理しているのかを見ていきます。

Perlではtime関数で現在時間を取得できます。

say time; # 1609426800

このときに得られる時間はUNIX時間です。UNIX時間とは、UTCCoordinated Universal Time協定世界時)での1970年1月1日00:00:00を紀元に、閏秒うるうびょうの存在を無視した紀元からの経過時間を秒数で示したものです。閏秒とは、地球の自転から決まる世界時と、原子時計によって決まる原子時の時刻の差が0.9秒を超えないように、協定世界時に追加や削除される秒のことです。

UNIX時間は、組込み変数の$^Tでも取得できます。

say $^T; # 1609426800

2100年以降での不具合の可能性

UNIX時間は、例外的にClassic Mac OSなど一部の古いシステムでは、UTC1904年1月1日00:00:00が紀元になることがあります。1904年を紀元としている理由は、閏年うるうどしの例外条件を可能な限り回避できる最も古い年だからです。

閏年は、平年より1日多い366日です。閏年は、次の❶から強い優先度で定義されています。

  • ❶ 西暦が400で割り切れる年は閏年
  • ❷ 西暦が100で割り切れる年は平年
  • ❸ 西暦が4で割り切れる年は閏年

そのため、1900年は100年に一度の例外で4で割り切れる年にもかかわらず閏年が発生せず、2000年は400年に一度の例外で100で割り切れる年にもかかわらず閏年が発生します。そして、次の閏年が発生しない年は、100で割り切れ400で割り切れない2100年です。よって、1904年からUNIX時間を始めると、2096年までは4年に一度必ず閏年が存在します。そのため、2100年までは閏年が発生しない場合の例外処理が不要になるという利点があります。

ですが、裏を返せば2100年以降で例外処理を行わないままだと日付などの表示でズレが生じ、それ以降はソフトウェアが正常に動かなくなる割り切った設計でもあります。

2038年問題による不具合の可能性

time関数で取得しているUNIX時間は、標準Cライブラリのtime_t型から取得しています。time_t型は、現在普及しているシステムでは符号付き64ビットintで実装されていますが、古いシステムでは符号付き32ビットintで実装されていることがあります。

これにより、UNIX時間の2の31乗-1秒、つまりUTC2038年1月19日03:14:07を超えると値がオーバーフローして、UTC1901年12月13日20:45:52まで時間が戻り、時刻を正しく扱えなくなります。

時刻の表現

ここまで、PerlがUNIX時間を基準にして時間を管理していることがわかりました。次は、Perlが時間をどのように扱うのかを見ていきます。

time関数はUNIX時間で返されるため、このままでは西暦何年の何時何分何秒なのかを把握できません。そのため、UNIX時間を扱いやすい形で表現する必要があります。localtime関数を用いると、現在時間をさまざまな形で取り出せます。

localtime関数は、time関数が返す時刻を、マシンのタイムゾーンの時刻として秒、分、時、日、月、年、曜日、その年の何日目、夏時間かどうかの9要素の配列に変換します。インデックス番号4の月は0から11までの1ずれた月を持ち、インデックス番号5の年は1900年からの年数を持ちます。

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

localtime関数をスカラコンテキストのときに扱うと、日付情報を含めて文字列で出力されます。

say scalar localtime; # Fri Jan 1 00:00:00 2021

また、localtime関数は引数にUNIX時間を与えることで、その時間の値を返します。このとき渡したUNIX時間はtime関数の値を上書きせず、あとに続くlocaltime関数はもとの時間を返します。

# Fri Jan 1 00:00:00 2021
say scalar localtime(1609426800);

タイムゾーンの変換

前述したようにlocaltime関数は自身のマシンのタイムゾーンの時刻になるため、日本ではUTCに+09:00を足した時刻になります。

ほかのタイムゾーンの時刻に変換するには、自身のマシンの時刻が日本のものだとわかっているのであれば、単純に9時間を減算してUTCに戻したあとに求めるタイムゾーンの時間を加算することで行えます。しかし、海外のサーバなど自身のマシンのタイムゾーンが不明な場合は、UTCに戻すことができません。

そこで、最初からUTCで出力されるgmtime関数があります。この関数は、localtime関数と同じAPIで扱えます。gmtime関数をリストコンテキストのときに扱うと、夏時間は無効になります。

# 2021-08-01 12:00:00に実行
my ($sec, $min, $hour, $mday, $mon, $year, $wday, $yday,
$isdst) = gmtime;
say $isdst; # 常に0

指定した秒数の実行停止

指定した秒数の間実行を停止するには、sleep関数を使います。

say scalar localtime; # Fri Jan 1 00:00:00 2021
sleep 10; # 10秒間、プログラムを実行停止
say scalar localtime; # Fri Jan 1 00:00:10 2021

時間の標準モジュール

localtime関数やgmtime関数で時間をある程度は自由に表現できるものの、時間の加算や減算などの演算が煩雑であることや、ミリ秒の取り扱いができないなどの問題があります。

本節では、時間演算を便利に行う標準モジュールと、ミリ秒まで時間を扱う標準モジュールを見ていきます。

Time::Piece ─⁠─高速な時間演算モジュール

localtime関数は時間を任意の単位で取り出せるため加工するのに便利ですが、使いたい形にするためには関数化する必要があります。日付や時刻情報はISO8601などで出力フォーマットが決められていることが多く、その形に毎回変換するのは煩雑です。

Time::Pieceモジュール(執筆時点のバージョンは1.3401)は、localtime関数で得られていた分や日など単体の時間はもちろん、日付フォーマットの設定を行うメソッドを持っています。localtime関数とgmtime関数をオーバーライドして、Time::Pieceオブジェクトとして結果が返却されます。

use Time::Piece;
my $t = localtime;
# $t->ymd      2021-01-01 # 年月日
# $t->mdy      01-01-2021 # 月日年
# $t->mdy("/") 01/01/2021
# $t->dmy      01-01-2021 # 日月年
# $t->datetime 2021-01-01T00:00:00 (ISO 8601)
# $t->cdate    Fri Jan 01 00:00:00 2021

Time::PieceモジュールではUNIX時間から任意の時間を得るのではなく、文字列からTime::Pieceオブジェクトに変換できます。

use Time::Piece;
my $parse_time = Time::Piece->strptime('2021-01-01T00:00
:00', '%Y-%m-%dT%H:%M:%S');
say $parse_time->ymd; # 2021-01-01

localtime関数では、時間の加算や減算はUNIX時間を直接渡して出力を得ていました。

my $t = time;
# 1週間分の秒数を減らす
my $one_week_ago = localtime($t - 60 * 60 * 24 * 7);
say $one_week_ago;

それに対してTime::Pieceモジュールでは、Time::Pieceオブジェクトに対してTime::Secondsオブジェクトを加算や減算できます。このとき得られるのはTime::Pieceオブジェクトです。

use Time::Piece;
use Time::Seconds;
my $now = localtime;
# ONE_DAYはTime::Secondsで定義されている1日の定数
my $tomorrow = $now + ONE_DAY;
say $tomorrow->ymd; # 2021-01-02

また、Time::Pieceオブジェクトどうしを減算することで時間の差を得られます。このとき得られるのはTime::Secondsオブジェクトです。

use Time::Piece;
use Time::Seconds;
my $now = localtime;
my $tomorrow = $now + ONE_DAY;
my $diff = $now - $tomorrow;
say $diff->days; # -1

さらに、Time::Pieceオブジェクトどうしで比較できます。

use Time::Piece;
use Time::Seconds;
my $now = localtime;
my $next_year = $now + ONE_YEAR;
if ($now < $next_year) {
    say '$next_yearのほうが未来です';
}

Time::Secondsモジュールの定数の罠

Time::Secondsモジュールでは、1年の定数と1ヵ月の定数は次の内容で定義されています。

  • ONE_YEAR:365.24225日
  • ONE_MONTH:ONE_YEAR / 12

ONE_YEARは365日と定義されていません。これは、閏年を考慮して400年の平均の値となっているためです。その影響を受けて、ONE_MONTHは30日よりも10時間ほど多い値になっています。

閏年を考慮せず常に30日として扱いたい場合はONE_FINANCIAL_MONTHを、閏年として扱いたい場合はLEAP_YEARを、閏年以外として扱いたい場合はNON_LEAP_YEARを使います。

Time::HiRes ─⁠─ミリ秒まで時間を扱うモジュール

UNIX時間は秒数を単位にしているため、小数点以下の時間、つまりミリ秒やマイクロ秒をそのままでは扱えません。

Time::HiResモジュール(執筆時点のバージョンは1.9764)は、マイクロ秒まで扱うことができ、精度が求められる場合に有用です。現在時間をマイクロ秒までほしい場合には、このモジュールのgettimeofday関数を使うと、UNIX時間と小数点以下の秒数が配列で返ってきます。

use Time::HiRes qw(gettimeofday);
my ($now, $micro) = gettimeofday;
say scalar gettimeofday; # 1609426800.76897

マイクロ秒までの時間の差は、tv_interval関数にgettimeofday関数で得られた時間を渡すことで得られます。第二引数を省略した場合は現在時間を参照します。

use Time::HiRes qw(gettimeofday tv_interval);

my $t0 = [gettimeofday];
sleep 1;
my $diff = tv_interval($t0);
say scalar $diff; # 1.001211

sleep関数では秒数で実行を停止しましたが、Time::HiResモジュールではusleep関数にマイクロ秒を渡すことで浮動小数点の秒数でプログラムの実行を停止できます。

use Time::HiRes qw(usleep);
usleep 2_550_000; # 2.55秒停止

<続きの(2)こちら。>

WEB+DB PRESS

本誌最新号をチェック!
WEB+DB PRESS Vol.130

2022年8月24日発売
B5判/168ページ
定価1,628円
(本体1,480円+税10%)
ISBN978-4-297-13000-8

  • 特集1
    イミュータブルデータモデルで始める
    実践データモデリング

    業務の複雑さをシンプルに表現!
  • 特集2
    いまはじめるFlutter
    iOS/Android両対応アプリを開発してみよう
  • 特集3
    作って学ぶWeb3
    ブロックチェーン、スマートコントラクト、NFT

おすすめ記事

記事・ニュース一覧