エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

9時間足すんだっけ引くんだっけ問題~あるいは、諸プログラミング言語はいかにタイムゾーンと向き合っているか

私は日付時刻の処理が大好きです。 タイムゾーンの問題でデータ抽出が9時間分漏れていたとか、朝9時の始業前のログが昨日付けになってしまっていたなんていう問題が起こると喜んじゃうタイプ。

そんな私にとって、各プログラミング言語が標準で持っている日付時刻型クラスにはそれぞれ思うところがあり、今日はちょっとその品評会をしてみたいと思います。
エムスリーエンジニアリンググループ、Unit1(製薬企業向けプラットフォームチーム)三浦(@yuba@reax.work) [記事一覧 ]がお送りいたします、エムスリー Advent Calendar 2023の2日目です。

至高の日付時刻型を持つ言語、BigQuery SQL

各言語の不満点をつらつら並べていくのかと思わせておいていきなりですが、至高の言語、ほぼ不満のない言語を紹介します。
BigQueryは、このテックブログでもひんぱんに言及されているGoogle提供のビッグデータ分析用データベースですね。この問い合わせに使うSQL言語が理想的な日付時刻型データ体系をそなえています。

理想的というからにはいかなる体系か、それは次の4つの型からなっています。

  • TIMESTAMP型
    瞬間を表現する型です。精度はマイクロ秒。以下すべて精度は同じ。
  • DATETIME型
    タイムゾーンの決まっていない日付時刻を表現する型です。
    タイムゾーンが決まっていないということは、特定の瞬間を表していません。ただ「2023-12-02 12:00:00」と言われても、それが東京時間なのかロンドン時間なのかによって別の瞬間を表してしまいますものね。
  • DATE型
    タイムゾーンの決まっていない日付を表現する型です。
    特定の瞬間範囲を表していない、と言えます。ただ「2023-12-02」と言われてもその開始・終了の瞬間は東京時間なのかロンドン(以下略)
  • TIME型
    タイムゾーンも日付も決まっていない時刻を表現する型です。

お気付きでしょうか、タイムゾーンを持っている型というのが一つもないのです。
国際的に使われるアプリケーション、それこそWebアプリなどを作るのにタイムゾーン情報って日付時刻型に必要じゃないんですか? はい、まったく必要じゃありませんというのが今回私のお伝えしたいことになります。
次の相関図を見てください。

タイムゾーンはどこに出てきますか? そう、データの一部ではなくて、データを変換するときに添えるものなのです。
「日付時刻2023-12-02 12:00:00は、東京時間(Asia/Tokyo)でのことなのならこれこれの瞬間のこと」
「この瞬間は、ニューヨーク時間で文字列化すると 『2023-12-01 22:00:00』」
などと言えるわけですよね。
瞬間に名前を付けるとき、名前から瞬間を求めるときに必ず必要になるものがタイムゾーンである、と整理できるわけで、それをその通り型体系に落とし込んだものがBigQuery SQLの日付時刻型だといえます。

この当たり前の型を使っていれば、まずタイムゾーン関連で処理を誤るということがありません。
時刻を記録するときには基本的にTIMESTAMP、それを「その日の0時」「月末の24時」なんていう処理をしたいときにはDATETIMEにいったん変換するのでこのときにどこのタイムゾーンでの話なのかを意識する、という風に正しい処理を強制してくれます。
もちろん、ユーザーに表示したりユーザー入力を解釈するときにもタイムゾーンを使って文字列と相互変換しますからどこのタイムゾーンでのユーザー対話なのか意識することになりますね。 当ブログの人気記事のひとつ、タイムゾーンを考慮した日時の扱いのベストプラクティスにもある通りの正しい処理が自然に書けてしまいます。

唯一の不満点を申し上げておきましょう。
これは便利さのためだとは思うのですが、各変換関数でタイムゾーンが省略可能で省略するとデフォルトタイムゾーンが使われるようになっていることです。
省略できてしまうために、「変換するにはタイムゾーンが必要、ゆえにどこのタイムゾーンでの話でしたっけこの要件は?」という意識を強制する働きが中途半端になってしまっていることです*1

不足はないが蛇足、Java 8

JavaはJava 8より前と以降で日付時刻型体系がガラッと変わりました。

まず、「より前」のJavaについてちょっとだけ言及しておくと、Date型がBigQueryのTIMESTAMP型に相当する瞬間型でした。実際にはlong数値をラップしただけの簡単なクラス。
実は瞬間を表す型というのをまず用意していた点は良いセンスでした。しかし、これを日付時刻に変換して処理するために使うCalendar型のインターフェースが非常に悪く、全体として使いづらい型体系となってしまっていました。
ついでに言うと、瞬間を表すのに「Date」というネーミングも微妙ではありましたよね。
こういった残念さゆえJoda-Timeというサードパーティーライブラリが広く使われていたわけですが*2、そのJoda-Time作者の主導するJSR-310というプロジェクトによりJava 8にDateTime APIと呼ばれる新しい日付時刻型体系が持ち込まれます。これは抜粋すると、以下のような構成の体系です。精度はナノ秒。

  • LocalDateTime型
    DATETIMEに相当します。タイムゾーン無指定の日付時刻。このような、特定の瞬間をささない日付時刻のことを不定時刻と呼ぶことにしましょう。瞬間を表しているなら絶対時刻という風に。BigQueryのTIMESTAMPは絶対時刻でDATETIMEは不定時刻です。
  • LocalDate型
    DATEに相当します。タイムゾーン無指定の日付。
  • OffsetDateTime型
    「+/-何時間」という形式でのタイムゾーンを持つ日付時刻です。日付時刻とタイムゾーンを組み合わせれば、特定の瞬間を表せていますね。つまりこれは絶対時刻クラスです。
  • ZonedDateTime型
    都市名という形式でのタイムゾーンを持つ日付時刻です。これも、特定の瞬間を表せていますから絶対時刻クラスですね。Offset型との違いは、夏時間情報を持てるので季節によってUTCとの時間差が違ってくる点。法改正にも追随できたり。

絶対時刻型と不定時刻型を備えている、この点ではBigQueryと同じ道具立てが揃っており同様の処理が書けます。
⋯しかし、余計なのです。
瞬間という値にタイムゾーンが必ず一緒に付いてきているのが余計。たとえば「今この瞬間」という瞬間は全地球上で普遍であって東京時間でもソウル時間でもないのに、「東京時間です」もしくは「+09:00です」というラベルも必ず貼らされるのです。

もちろん、必ずこのタイムゾーンというラベルが貼られていることで便利さはあります。絶対時刻を文字列化するなり「月末の24時」みたいな計算をするときにタイムゾーンを用意する必要がなくて便利。
しかし、この便利さは間違っていると私は考えます。
タイムゾーンがどこだか考えないといけない処理を書くときにタイムゾーンを要求されないことは、意図せず間違ったタイムゾーンで処理してしまうリスクにしかならないのだと。
そして、絶対時刻なのに年月日や時分秒も取り出せてしまいますから、そういうのを取り出して処理するにはタイムゾーンを特定して変換しないといけないことに思い至れません。

さて良いニュースですが実はJava 8(DateTime API)にも Instant 型という、タイムゾーンのない絶対時刻型が存在しています。これを使えばBigQueryと同様に正しいプログラミングを強制してもらえるのですが、この型の存在を認知しないままDateTime APIを使っているプログラマも多そうですね。OffsetDateTime、ZonedDateTimeというわかりやすい名前のクラスの存在のせいで多くのプログラマはこちらに引き寄せられてしまい、わざわざこの理解の難しい方を手に取ってしまいます。そして、タイムゾーンよくわからないと悩んでしまいます。
OffsetDateTime、ZonedDateTimeが蛇足だったのです。

日付時刻で画竜点睛を欠いたC#

C#とはつまり、 .NET Framework の標準ライブラリの日付時刻型ということですね。
一種類の型で表されています。

  • DateTime型
    100ナノ秒精度の日付時刻と、種別を持っています。
    種別とは「Utc」「Local」「Unspecified」の3種です。

これは、BigQueryやJavaの型体系を見てきたあとだと一目見てぎょっとしますね。
同じ型が、絶対時刻を表すこともあれば、不定時刻を表すこともあるというのです。「Utc」もしくは「Local」なら絶対時刻、「Unspecified」なら不定時刻。

そして現在時刻を取得するメソッド DateTime.NowDateTime.UtcNow を使うとこの絶対時刻タイプの値が取れるのですが、コンストラクタで DateTime(2023, 12, 2, 12, 0 ,0) と書くときのデフォルトはUnspecifiedなので不定時刻タイプ。プログラムパス中でも気を付けないと容易に混交してしまいます。型が同じですからね、静的にチェックできません。

さらに恐ろしい事実を。
大小判定(つまり前後判定)するときに、この種類が参照されず、日付時刻部分のみで比較が行われます。
その結果、Utc 2023-12-02 06:00Local(ここでは日本時間) 2023-12-02 09:00では、後者の方が遅い時刻だと判定されます。わざわざ絶対時刻にしている意味がない。

私は、実用性と美しさを稀に見るハイレベルさで両立させたC#という言語が大好きですが、こと日付時刻型体系に関してだけは改革の余地が大きいという評価です。

C#よりややまし、Python

Pythonの標準ライブラリdatetimeモジュールの構成は、C#と似ています。

  • datetime型
    マイクロ秒精度の日付時刻とタイムゾーンを持っています。タイムゾーンを持っていないこともあります。
  • date型
    DATEに相当します。タイムゾーン無指定の日付。

出た、同じ型なのに絶対時刻だったり不定時刻だったりするdatetime型。タイムゾーンを持っていない、不定時刻な場合の値をPython用語ではnaiveと呼び、タイムゾーンを持たせて絶対時刻になっているとawareと呼びますね。
タイムゾーンが「Utc」「Local」の2種類しかないC#に比べれば任意のタイムゾーンが持たせられる分自由度はちょっと高いです。

Pythonのdatetime、C#のDateTimeと同じ欠点があるわけですが、2点においてややましだと言えます。

  • Pythonは動的型言語であり、「同じ型なのに意味論が違う」ことの問題が相対的には小さい。
    (別々の型になっていたとしてもコード上相互に代入が自由なので正しく扱わないとどうせ混交してしまう)
  • 大小判定はタイムゾーンを考慮して瞬間としての前後をちゃんと判定してくれる(aware VS naiveだとちゃんと較べようがないってエラーに)

それはそれとして、Pythonのdatetime型は搭載メソッドが貧弱なのでサードパーティライブラリ(relativedeltaとか、pytzとか)を併用しないと書きたい処理もまともに書けないという困ったところがあり、これはどうにかならないものかといつも思っています。
(Pythonという処理系が環境構築の面で未完成さを抱えており、サードパーティーライブラリを導入すること自体がいろいろなつらみを伴う)

型は良い構成、なのに命名と処理関数で損しているPostgreSQL

冒頭でBigQueryこそ至高とご紹介しましたが、同じSQL処理系でPostgreSQLも良い型で構成されています。

  • timestamp with time zone型
    絶対時刻。タイムゾーン情報は持っていない
  • timestamp [without time zone]型
    不定時刻
  • date型
    タイムゾーンの決まっていない日付。
  • time [without time zone]型
    タイムゾーンも日付も決まっていない時刻。

まったく、BigQueryのTIMESTAMP型、DATETIME型、DATE型、TIME型に対応しています。つまり正しいプログラミングを強制できるはずの体系です。
しかし、二つの点で大きくこの利点を損なってしまっており、この体系の良さを実感できているプログラマはほとんどいません。

  • 型名がおかしい。これによりプログラマは型の選択を間違います。
    • 単にtimestampと書くと不定時刻型になってしまうが、やはりtimestampという言葉は瞬間を指すべきだろう。
    • タイムゾーン情報を持っていないのに with time zone は明らかに間違っている。
  • 処理関数、たとえば『時』『分』を取り出すEXTRACT関数などすべてが、絶対時刻を扱うときにもタイムゾーンを要求しない(常にデフォルトタイムゾーン*3であるものとして処理してしまう)。
    このため、プラグラマにとってwithwithoutの違いがよくわからず、変換の必要性を感じることができない。

型体系とはメソッドと一体であることがよくわかる実例となっているのがPostgreSQLなのです。

まとめ

  • データ保持は絶対時刻
  • ユーザー対話のときは目的のタイムゾーンにあわせて不定時刻と変換
  • 時分などを処理するときは目的のタイムゾーンにあわせて不定時刻と変換
  • 変換するときには「目的のタイムゾーン」とは何であるのかを要件として考える

簡単に言えばこれが日付時刻を扱うための大原則です。
型体系、つまり型の構成とその処理関数・メソッドをあわせたものは、適切に用意すれば良い原則をプログラマに強制することができます。

今回は日付時刻データにフォーカスしてお話いたしましたが、より一般的に、良い扱い方を使い手に強制できるインターフェースというのをモジュール設計においては常に意識したいものですね。

We are hiring

「9時間足せばいいんだっけ、引けばいいんだっけ? ⋯⋯よくわからない、両方試してそれっぽい数字出た方!」で悩むのに比べれば、絶対時刻or不定時刻で考えれば日付時刻処理はよりクリアカットになるの、ご得心いただけましたでしょうか!?
われわれエムスリーのエンジニアは決して、すごい計算力でややこしいことも計算しきって正しいプログラムを書ける超人とかってわけではありません。
そうではなく、シンプルに理解できて間違いにくい組み立てをいつも模索している、そういう人たちです。 コード書く前に設計で勝ち、設計する前に概念の組み立てで勝つ、そういう開発環境にちょっとでもご興味ありましたらこちらのページからどうぞ。応募を前提にしないカジュアル面談もやっています。

jobs.m3.com

*1:それゆえ、私はコードレビューの際には省略されているタイムゾーンの明記を促すようにしています。実行環境によってSQL式の意味が意図せず違ってしまうおそれがあるのも困ったことですしね

*2:Joda-Timeのせいで起こってしまった面白事件もあり、それについては18分59秒をめぐって日本標準時の歴史をひもとくことにという記事を書いたことがあります。

*3:ちなみにこのデフォルトタイムゾーンってのは曲者なんですよ、PostgreSQL 9.2 でタイムゾーンのデフォルト値が変わった話 - mallowlabsの備忘録 にあるようにPostgreSQLのマイナーバージョンアップで定義が変わってしまったりします。エムスリーでもこの問題にもろに巻き込まれて大混乱になったことがありました。