Chienomi

コンピュータとプログラムの「日時」を理解する

プログラミング::beginners

プログラミングを説明していて、dateの概念が理解されないことが多い、と最近特に感じる。 確かに、dateはかなり難しいものではあるが、説明しても伝わらないというのは少し意外ではある。

dateの難しさは、dateが内包する概念が多いこと、10進数でないこと、進行が一定でないこと、桁上がりが一定でないことあたりにある。 が、基礎概念をちゃんと理解していれば、どうすれば良いかという観点はシンプルな手順で算出することが可能で、少なくとも理解してなお難しい類のものではない。

そこで、今回はコンピュータの日時の理解を深めるため、イチから紐解いていこうと思う。

universal time

コンピュータにおける日時を理解する上で最も重要なのが、universal timeを理解することである。

まず、日本時間の15:00と、中央ヨーロッパ時間の7:00は同じ時刻であるということを理解する必要がある。 ちゃんと書くと

2024年  7月 16日 火曜日 15:00:00 JST
2024年  7月 16日 火曜日 07:00:00 CEST

この2つの時刻は全く同じ時を表している。

世界にはそれぞれタイムゾーンがあり、これが時差となる。 そこにいる人々にとっては現地の時刻が基準となるが、それは「朝7時を迎える実際のタイミングが違う」のであって、ある瞬間を切り取ると朝であったり夜であったりする。 よって、日本の昼はヨーロッパの朝になる、というわけだ。

時差のない扱いをすることも不可能ではない話なのだが、そうすると日付が変わるタイミングが場所によって昼だったり夜だったりすることになる。 時間の感覚と活動時間の認識を合わせるために現地時刻が必要になってくる。

だが現地時刻だけに注目していると、違うタイムゾーンを持つ地域との間で「同じ時刻」を共有するのが難しくなる。

時刻にタイムゾーンを付与すれば、2つのタイムゾーン間の時差から算出することは可能だが、そうするとふたつの問題がある。ひとつは、時刻にタイムゾーンの情報が不可欠になり、タイムゾーンのない時刻が与えられると実際の時刻が不明になること。もうひとつは、基準として使うタイムゾーンが不定であることだ。

この問題に対応するのがUTC(協定世界時)である。 タイムゾーンをまたぐ時刻を使う場合は、UTCを使うことで共通の了解が得られるようにしたものである。

UTCに関する豆知識だが、UTCはもちろん協定世界時の頭文字なのだが、英語ではcoodinated universal time、フランス語ではtemps universal coordonné、イタリア語ではtempo coordinato universaleという感じで各国で頭文字が異なるため、その辺の諍いが起きないように調整されたもので、UTCが何の頭文字である、と説明するのは難しい。

UTCはGMT(グリニッジ標準時)を元にしているが、概念的にはUTCはタイムゾーンから分離されたuniversalなものであるのに対して、GMTはイギリスの現地時刻だから両者は異なるものである。 が、時間自体は同じなので、単なる別名扱いされている場合もある。 それでも、(GMTが標準時扱いされてきた過去があるにしても)「イギリスの時刻を標準にします!」と言うよりはだいぶ穏健なのである。

さて、UTCがあるおかげで、タイムゾーンのない時刻はUTCを表していると考えられるようになった。 これは文字列で、「エンコーディングの指定がなければUTF-8とみなす」と同じような話だ。 この決まりを導入するだけで、時刻に関する交換が実にスムーズになる。 UTCが浸透する前はタイムゾーンのないローカルタイムを保存したり交換したりしていたが、現代ではタイムゾーンがないのならばUTCとみなすのが普通で、タイムゾーンのないローカルタイムを使うのは腐ったプログラムであると考えて良い。

universal timeは暗黙のUTCであり、「タイムゾーンのないlocal time」とは異なる。 見た目からこの違いは分からず、規格や約束事てあるが、この区別は非常に重要である。

Unix時刻は1970-01-01 00:00:00 UTC(エポック時刻)からの経過秒を表すタイムゾーンのない整数値であるが、経過秒数が地域によって変わることはないため、universal timeである。

JavaScript

JavaScriptのDateはuniversal

JavaScriptのDateはuniversal timeであり、タイムゾーン情報がなく、UTCになる。

new Date()は様々な形式の日時を与えることができるが、これはlocal timeとして解釈される。 このため、local timeを入力してuniversal timeが得られる仕組みになっている。

> date = new Date("2024-07-16 15:00:00")
2024-07-16T06:00:00.000Z

厄介なことに、柔軟なコンストラクターを持っているのはnew Date()のみであり、Date.parse()はISO8601形式でTZを与え、Date.UTC()は年からの整数値を与える。

JavaScriptはmonthが0 origin1になっているもので、かなり扱いにくい。 これもあって、Date.UTC()に時刻を与えようとすると扱いが面倒。

serializeとdeserialize

ただJavaScriptでdumpされた時刻を復元するのは難しくない。 まず、Date()にはUnixエポックからのミリ秒(universal time)を与えることができ、これはDate.prototype.getTime()で得られる値に等しい。 また、DateオブジェクトをJSON.stringify()によってJSON化するとタイムゾーンオフセットのないUTC時刻文字列が得られ、これをDate()に渡すことでも復元できる。

ちなみに、JavaScriptの整数日時はすべてミリ秒というクレイジー仕様。 浮動小数点数にすればUnix時間と互換性も保てたし、そもそもJavaScriptの数値型(Number)は浮動小数点数しかないから整数にするメリットもないのに、謎の決断をしてしまっている。

JavaScriptのDateオブジェクトは純粋なuniversal timeであり、タイムゾーン情報を持っていない。 DateのメソッドはUTCを扱うものとlocal timeを扱うものがあるが、すべてにおいて両方のメソッドが用意されているわけではない。 指定のタイムゾーンを取り扱えるのはtoLocale*()メソッドだけであり、任意のタイムゾーンの時刻を得ることは難しい。一応、環境変数$TZを参照するようになってはいるが2、サーバーなどプロセス単位での改変が難しい環境では使えない。 場合によってはuniversal timeであるDateオブジェクトを、あたかもタイムゾーンのないローカルタイムであるかのように扱い、getUTCDate()のようなメソッドを使う必要がある。

前後の日付を求める

ただし、これを助ける要素として、JavaScriptは前後の日時を求めるのが簡単であるという特徴がある。 例えば、dateを7ヶ月後にセットしたい場合

date.setMonth(date.getMonth() + 7)

が成立し、桁が溢れた場合はちゃんと年を進めてくれる。 またこれは負の値でも成立する。 これは他の言語と比べかなり利便性が高いところだが、破壊的変更を伴うメソッドであるのがちょっとクセのあるところ。

2024年7月の頭を求めたい場合は

date = new Date(2024, 6, 1)

でよく、2024年7月の末を求めたい場合は

date = new Date(new Date(2024, 7, 1).getTime() - 1)

で求めることができる。 JavaScriptのDateはミリ秒精度なので、これでlocal timeで2024-07-31 23:59:59.999になる。

他のタイムゾーンの日時を得る

さて、オフセットを求める方法だが、ローカルタイムゾーンがAsia/Tokyo (UTC+9)で、Europe/Berlin (UTC+1)の7月頭を求めたいとする。

まず前提として各タイムゾーンのオフセットを知っておく必要がある。 そして、現在のタイムゾーンのオフセットからターゲットのオフセットを引いた値を足すとそのタイムゾーンで指定した時刻になるuniversal timeが得られる。

date = new Date(new Date(2024, 7, 1).getTime() + ((offset["Asia/Tokyo"] - offset["Europe/Berlin"]) * 60 * 60 * 1000))

ただ、その時刻の文字列表現を指定のタイムゾーンで得る方法がtoLocal*()しかない。 なのでこの方法は、あくまでuniversal timeのまま取り扱いたい場合向きの方法となる。

与えられたuniversal timeが指定のタイムゾーンで何時何分なのかを得たい場合、universal timeにオフセットを足して、getUTC*()メソッドを使う。

date = universal + (offset["Europe/Berlin"] * 60 * 60 * 1000)
date.getDate()

ただ、このオブジェクトはuniversal timeではなく「タイムゾーンのないlocal time」であるため、この値を保存してはいけない。それは混乱のもとになる。

Ruby

Rubyはタイムゾーンつき日時

RubyのTimeはかつてはタイムゾーンのない時刻であり、タイムゾーンを扱う必要がある場合はdatetimeライブラリを使うようになっていた。 現在はTimeがタイムゾーンも扱うようになり、それに伴ってdatetimeはdeplicatedとなっているが、datetimeの全機能が導入されているわけではない。

RubyのTimeはタイムゾーンつきナノ秒精度のlocal timeである。内部的にはナノ秒精度のUnix時刻とタイムゾーンを持っている。 暗黙のタイムゾーンで作られた場合はホストのタイムゾーンを持つ。

irb(main):001> Time.now.zone
=> "JST"

タイムオフセットは指定できるが、タイムゾーンの指定はできない。 このため、ローカルではないタイムゾーンのTimeオブジェクトはタイムゾーン情報を持っていない。

irb(main):005> Time.now(in: "+01:00").zone
=> nil

オフセットは得られる。

irb(main):007> Time.now(in: "+01:00").utc_offset
=> 3600

タイムゾーンつきlocal timeで交換

Rubyの場合、交換時はTime型を表現可能なYAML3あるいはMarshalを使うことが多いため、交換時の表現を気にすることは少ないが、基本的にはタイムゾーンつきlocal timeとして出力することでタイムゾーンを保持しようとする。

jsonライブラリはJSON.dumpではタイムゾーンつきlocaltime文字列として出力するが、JSON.loadTimeオブジェクトに復元することはしない。

異なるタイムゾーンの日時を得る

Time#getlocalで指定したオフセットのTimeオブジェクトを生成できる。

ここでも指定できるのはオフセットだけなので、タイムゾーンは失われる。

前後の日時を得るのは難しい

Rubyでは時間の加減は秒単位に限られ、「1ヶ月後」のような日時を求めるのは結構難しい。 1月32日や2月30日のような範囲外の日付を設定すると、単純にエラーになってしまう。

Activeライブラリを使う場合は関連のメソッドが追加されるが、日時のために使う気にはなれないだろう。

Perl

プリミティブは最悪。5.10からはライブラリで

Perlは古い言語なので、universal timeという概念もなければ、そもそも日時型というものが存在していない。

標準のlocaltime()およびgmtime()関数は9要素のリストを返す、というこれまたなかなか凶悪なものになっている。 ちなみに、順序は秒、分、時、日、月、年、曜日、年の通算日、夏時間かどうか、である。 月と曜日と年の通算日は0 origin。

localtime(), gmtime()をスカラーコンテキストで評価すると日付表現の文字列を返すが、これはロケールに関係なくアメリカ式の英語の日付を返す。

こんなあまりにも古い文明を感じさせるものであり、人々がlocal timeで取り扱うという悪習を身に着けた一因でもあるPerlだが、5.10以降は標準モジュールとしてTime::Pieceが追加され、オブジェクト指向で扱えるようになった。

Time::Piecesも結局local time

しかしTime::Pieceは結局localtime()を便利に扱うためのモジュールに過ぎないため、扱うのはlocal timeである。

ただ、localtime()との大きな違いとしてタイムゾーンを保存するようになったため、universal timeに変換可能な時刻を出力できるようになった。

異なるタイムゾーンのlocal timeを得ることはできない。 このような目的にはDateTimeモジュールが使われることが多いようだ。

Elixir

ElixirはTime, Date, NativeDateTime, DateTimeという4つの時間関連モジュールがあり、NativeDateTimeDateTimeが日時型となっている。

NativeDateTimeはuniversal timeで、タイムゾーンは扱えない。 つまり、常にUTCに見せかけたlocal timeである。

DateTimeはタイムゾーンを含んでいるが、タイムゾーンのサポートは外部にゆだねているため、デフォルトではUTCしか扱えない。

外部ライブラリであるtzdataパッケージを使うとDateTimeはタイムゾーンを扱うことができる。 これはタイムオフセットではなくタイムゾーンであり、これによってElixirはとびきりパワフルな日時のサポートを与える。

jst_dt = DateTime.from_native!(~N[2024-07-14 15:00:00], "Asia/Tokyo")
{:ok, est_dt} = DateTime.shift_zone(jst_dt, "America/Detroit")

YAML

YAMLにはtimetsamp型がある。

これを見れば分かるように、オフセットを表現できるが、タイムゾーンは使えない。

タイムオフセットとタイムゾーンの違い

タイムゾーンは基準となる時計の種別で、タイムオフセットはUTCからどれだけ離れているかを示すものである。

日本だけを見れば、タイムゾーンJSTとタイムオフセット+09:00は同じものに思えるかもしれない。 だが、+09:00のタイムゾーンは標準時だけでもPWT, EIT, KST, TLT, WIT, TAKTの6つがある。

「別にどれかわからなくても支障はない」と思うかもしれない。 だが、実はそうはいかない。ULASTとCHOSTは+09:00夏時間(DST)なのだ。

つまり、+09:00という表現だけでは、夏時間があるのかないのか、そしてあるとしたらいつ切り替わるのか、ということが分からない。

プログラムであまりちゃんとタイムゾーンを扱わない理由は、取り扱いの困難さにある。

夏時間は年によって異なるし、タイムゾーンによっても異なる。 このため、自動的に夏時間を反映するためには、夏時間の情報を随時更新で追加していかなくてはいけない。 タイムゾーンは数多あるため、プログラミング言語処理系で保守するのはかなり厳しい。

システム側はこの保守作業をしている場合が多い。 WindowsはWindows Updateで夏時間の情報の更新が降ってくるし、Linuxではzoneinfoファイルに書かれており、サマータイムが来るときはこれが更新される。 ちなみに、Asia/Tokyoも1948年から1951年までの間のサマータイムJDTが記載されている。 zoneinfoファイルには他にもうるう秒の情報も入っている。

プログラミング言語処理系側ではシステムから取ってくれば、今のタイムゾーンが標準時なのか夏時間なのかというのは分かる。だから、「今のタイムゾーン」を設定するのは難しくない。

しかし、これが日の加減を可能にすると難しい。 プログラミング言語処理系は夏時間が切り替わるタイミングを知らない。 tzinfoのようなシステムのデータをルックアップして処理することは可能だと思うが、基本的に標準でそこまでやっている言語は私が知る限りない。

そこまでケアするライブラリは稀にあるが、こうしたものはシステムが持っているタイムゾーン関連情報を参照するようにななっている。 システムに近いライブラリはここらへんをサポートするようになっていたりする。

基本概念のおさらい

  • UTCは現地の時間によらず地球上のある瞬間を表すことができるuniversalなもの
  • タイムゾーンがUTCであるuniversal timeから、任意のタイムゾーン/タイムオフセットの時間を算出できる
  • タイムゾーンのついたlocal timeから、universal timeを逆算することができる
  • タイムゾーンのないlocal timeは、そのデータ自体から真の日時を特定することができない
  • local timeを表現するために、universal timeに時間を加減したuniversal timeを生成するのは悪しき行いである
  • 現代的な言語環境であればuniversal timeを扱えるものなので、UTCのlocal timeを作ろうとするのは悪しき行いである
  • universal timeで保存・交換すると、その日時を生成した環境のタイムゾーンが何であったかは分からなくなる
  • JavaScriptはuniversal timeで交換し、受け手のローカルのタイムゾーンで扱う方式
  • Rubyはタイムゾーンつきlocal timeで扱い、生成元のタイムゾーンを残す方式
  • タイムゾーンの最大の厄介要素は夏時間
  • 夏時間の反映は非常に難しい
  • タイムオフセットからタイムゾーンを特定することはできず、タイムオフセットつきlocal timeの情報量はuniversal timeと同等になっている
  • 生成元のローカルではないタイムゾーンを扱える言語処理系はかなり珍しく、タイムオフセットを扱うことがほとんど