久しぶりに C++ (as better C) で真面目なプログラムを書いていて引っかかったので備忘録。 「拡張なんだから標準関数の挙動に影響するわけねえだろ」という常識人は読む必要はない。
要旨
- time_t の表現は環境依存
- サポートしている時刻は UTC とプロセスグローバルなシステム時刻 (local time) のみで、任意のタイムゾーン間の時刻変換を行う標準的な方法はない
- BSD / GNU libc は tm 構造体にタイムゾーン情報を含むが、tm -> time_t の変換 (
timegm
/mktime
) においてその情報は無視される
事前知識
C 標準ライブラリにおいて時刻の操作に関係するものは time.h (C++ では ctime) ヘッダに定義されている。ここで時刻を表現するデータ型は2つある: time_t と tm である。time_t が第一義的な型であり、それを人間が扱い易いように分解した副次的な構造体が tm という関係になっている。なので標準ライブラリには現在時刻を time_t として取得する関数 (time_t time(time_t *)
) が先ずあり、そこから time_t と tm を相互に変換する関数が定義されている。
ここで time_t の定義は処理系依存である。C / C++ 標準はそれが算術型であることを求めているのみで (C11 からは実数型に厳格化された)、その実体は任意である。POSIX においては UNIX epoch (1970-01-01T00:00:00Z) からのうるう秒を除いた経過秒数であることが保証されており Linux や BSD の子孫も同様だが、この事実に依存するのは移植性のある方法ではない。
一方で tm は構造体であり、最低限必要なデータメンバが規定されている:
int tm_year
: 1900 年からの年数int tm_mon
: 月 (0-based; 即ち [0, 11])int tm_mday
: 月初からの日数 (1-based)int tm_hour
: 時 (Military clock; 即ち [0, 23])int tm_min
: 分int tm_sec
: 秒 (うるう秒を含み得るので [0, 60])int tm_wday
: 直近の日曜日からの日数int tm_yday
: 年初からの日数int tm_isdst
: サマータイム中か (1) 否か (0)
tm_isdst
以外タイムゾーンに関係する項目がないのが分かる。
また BSD / GNU libc は以下のデータメンバも含む:
char *tm_zone
: 考慮したタイムゾーンの名前long gmtoff
: 考慮したタイムゾーンのUTC からのオフセット秒数
tm_zone
が指しているのはライブラリ内部で管理している領域なので、const はついていないものの書き換えたりするべきではない。
タイムゾーン
time_t は扱うシステムのタイムゾーンに依らない表現だが、それを変換した tm はカレンダー日時なので当然影響を受ける。 そのため C 標準ライブラリが提供する time_t -> tm の変換は常に UTC に基いて計算する gmtime
と、プロセスのタイムゾーン設定を考慮した localtime
の2種が提供されている。 逆の操作 (tm -> time_t) は local time に対応する mktime
だけが標準化されている。BSD / GNU libc には名前の対称性から timegm
/ timelocal
(mktime
の同義語) が存在する。
今「プロセスのタイムゾーン設定」と書いたとおり、タイムゾーン設定はプロセスグローバルである。またその設定方法 (POSIX であれば TZ
環境変数を設定して tzset
関数で変更を反映する) は環境依存なので、C 標準ライブラリには任意のタイムゾーン間の時刻変換をする可搬な方法はないものと思って良い。
gmtime
で時刻を得た場合、time_isdst = 0
/ tm_zone = "GMT"
/ tm_gmtoff = 0
になる。 localtime
の場合は環境によるが、例えば TZ=Asia/Tokyo
(JST) な環境なら time_isdst = 0
/ tm_zone = "JST"
/ tm_gmtoff = 32400
になるだろう。
tm から time_t への逆変換
さて得られたカレンダー日時を再び time_t 表現に戻す操作を考える。この場合に使える変換は先述のように timegm
/ mktime
(timelocal
; 対称性から以後こちらを使う) である。
tm 構造体は便利のために tm_wday
/ tm_yday
データメンバを持つが、これらは tM_year
/ tm_mon
/ tm_mday
から定まるので単に無視される。整合性はチェックされない。
time_t から tm への変換時には tm の各データメンバは自明な値域内の値を持ったが、逆変換に渡す tm 構造体はそれらを逸脱する値を持っても良い: tm_mon = 8
/ tm_mday = 40
(9月40日) なら tm_mon = 9
/ tm_mday = 10
(10月10日) に正規化されるし、tm_year = 100
/ tm_mon = -1
(2000年-1月) なら tm_year = 99
/ tm_mon = 11
(1999年12月) になる。
ややこしいのは tm_isdst
の扱いで、まず timegm
で変換する場合単に無視される。サマータイムは有り得ないからだ。timelocal
の場合、tm_isdst >= 1
/ tm_isdst = 0
はそれぞれサマータイム中か否かを示しそれを計算に含む。tm_isdst < 0
のときは環境変数で設定されたタイムゾーンから指定の日付がサマータイム中か否かを自動判定する。
ところで BSD / GNU 拡張の tm_zone
/ tm_gmtoff
だが、実はこれらも常に無視される。例え tm_zone = "JST"
/ tm_gmtoff = 32400
になっていようが TZ=Europe/Berlin
な環境で timelocal
を呼べば CET (UTC+0100) タイムゾーンにおける日時として計算される。
#include <cstdio>
#include <cstdlib>
#include <ctime>
using namespace std;
int main() {
time_t now = time(nullptr);
tm calendar = *localtime(&now);
// Resets local time zone to CET.
setenv("TZ", "Europe/Berlin", 1);
tzset();
// Restores |time_t|, treating the given |calendar| as a CET datetime.
time_t restored_in_cet = timelocal(&calendar);
// Shows difference between CET and original time zone, in seconds.
printf("%lf\n", difftime(restored_in_cet, now));
return 0;
}
これをコンパイルして実行すると以下の結果を得られる:
% clang++ --std=c++11 -o timelocal timelocal.cc
% TZ=Asia/Tokyo ./timelocal
28800.000000
考えてみればこれは当たり前で、mktime
が標準ライブラリ関数である以上、拡張データメンバを参照して挙動が変わると仕様を逸脱してしまうのだった。おしまい。
コメント
コメントを投稿