プログラム上で時間を扱う際に気をつけること

この記事はただの集団 Advent Calendar 2018の22日目の記事です。昨日は wararaki (Kyohei Watarai) · GitHub さんの 玉子焼きとだし巻き玉子を分類してみた - Qiita でした。

adventar.org

結論

結論から先に示しておきます。

  • 日付・時刻を扱う際は、目的に合わせて使いたいクラスを適切に選択する必要がある。
  • 日付・時刻に関わらず、クラスの選択や設計・実装は目的に沿って行う必要がある。

背景 - 時間を扱う際に発生する問題

あと3日でクリスマスですね。(記事の投稿日は2018年12月22日09:00)

こういった会話は日常的によく行われていると思います。では、これをプログラム上のコードで表現してみましょう。

(※サンプルコードは全てScalaのREPLで記述します。)

scala> import java.time.{Duration, LocalDateTime}
import java.time.{Duration, LocalDateTime}

scala> val today = LocalDateTime.of(2018, 12, 22, 9, 0, 0)
today: java.time.LocalDateTime = 2018-12-22T09:00

scala> val christmasDay = today.plusDays(3)
christmasDay: java.time.LocalDateTime = 2018-12-25T09:00

一見良さそうに見えます。でもちょっと待ってください。

アプリケーションを開発する上では、コードは厳密に表現していかないと思わぬバグを生みます。

クリスマス という日が始まるのは 12月25日の00時 からではないでしょうか? コード上ではクリスマスはすでに始まっており、既に9時間も経過しています。
これでは、コードでクリスマスと言う日を正しく表現したことにはならない可能性があります。

では、それを考慮し あと3日あと2日と15時間 と表現するべきではないでしょうか?会話の文章を変えてみましょう。

後2日と15時間経過するとクリスマスと呼ばれる日が始まりますね。
(記事の投稿日は2018年12月22日09:00)

これをコードで表現してみます。

scala> import java.time.{Duration, LocalDateTime}
import java.time.{Duration, LocalDateTime}

scala> val today = LocalDateTime.of(2018, 12, 22, 9, 0, 0)
today: java.time.LocalDateTime = 2018-12-22T09:00

scala> val christmasDay = today.plusDays(2).plusHours(15)
christmasDay: java.time.LocalDateTime = 2018-12-25T00:00

コードは確かに正確になりました。

しかし、今度は別の問題が発生します。日常会話で 後2日と15時間でクリスマス とか普通は言いません。ならば、コードの方に問題があるのではないでしょうか?

このあたりの勘所が強い方達は最初のコードの時点でお気づきになられたと思いますが、LocalDateTime -> LocalDate にしてみます。

scala> import java.time.{Duration, LocalDate}
import java.time.{Duration, LocalDate}

scala> val today = LocalDate.of(2018, 12, 22)
today: java.time.LocalDate = 2018-12-22

scala> val christmasDay = today.plusDays(3)
christmasDay: java.time.LocalDate = 2018-12-25

良さそうですね。つまり、最初の文章は正確には

今日(2018-12-22)から後3日経過(+3 day)するとクリスマスと呼ばれる日(2018-12-25)になる

と表現していました。コードでも無理なく表現できています。

しかし、往々にして後ろに余計な情報 2018年12月22日09:00 などがあるとそちらに目がくらんで時間も扱うクラスでコードを書いてしまったり、書かれたコードを見たことがあるのではないでしょうか?

また、 良さそう と書いたのはここにタイムゾーンという情報が増えると、 日本ではクリスマスだがアメリカはクリスマスではない 、といった状況が発生しプログラムで予期せぬバグを生むこともあるからです。(※LocalDate,LocalDateTimeではTimezoneを扱えません)

ということで、今回はプログラミングをする際、時間を扱う時にどういった観点が必要か、どういった点に気をつけなければいけないかを考えていきたいと思います。

時間とは

時間は、出来事や変化を認識するための基礎的な概念として日常的に使われています。 しかし、「時間」という言葉の意味を正しく使わず、なんとなく使っていることが多いのではないでしょうか。

というのも、そもそもの話 時間 という言葉が曖昧すぎます。 あとn時間 のように範囲を表現しているときもあれば 今の時間は? のようにとある1点を表現しているときもあります。

Wikipedia先生にはこうありました。

1. 時刻。つまり、時の流れの中の一点のこと。
2. ある時刻と別のある時刻の間(時 - 間)。およびその長さ。
3. 空間と共に、認識のまたは物体界の成立のための最も基本的で基礎的な形式をなすものであり、いっさいの出来事がそこで生起する枠のように考えられているもの

時間 - Wikipedia

この記事中では、時間の詳しい定義や理論、うるう秒などについては取り扱いはしません。また、時間の流れは常に一定であり地球の自転や天体などの時間に対する影響は全て存在せず、基準の単位を1秒とし1日は86400秒であるとします。実行処理系や環境変数によるタイムゾーンなどプログラム外にある情報も取り扱いません。(※あくまでプログラム上で時間を扱う際に気をつけたいだけなので)

この記事中の「時間」の扱い方

まずは、 時間 に関係する用語と意味を記事内で統一します。

用語 意味
時点 時の流れの1点
時間軸 時点が一定の速さで連続で流れた際の集合
継続時間・期間 一定の時点・時期から他の一定の時点・時期までの時間の継続
時刻 特定の時点における人間にとって意味のある単位での表現
時刻系 時刻を表現する基準
時の流れを人間にとって意味のある単位に当てはめて数えるように体系づけたもの。
グレゴリオ暦 世界各国で用いられている太陽暦に基づいた暦法。単位の基準に秒を用いる
ISO-8601 グレゴリオ暦に基づいた日付と時間の表記に関する国際規格
UTC 協定世界時

これで準備は整いました。

例えば誰かが 「今」 と言った場合、 とは 発言が行われた時点 を指し、その 時点 の表現方法は 時刻系 で決まり 時刻 として表現される、といった使い方になります。

時間をコードで扱ってみる

時点を扱う

時間をプログラムで扱う時 now を変数名として使った経験のある方は多いのではないでしょうか? では、 now はどのクラスで扱うべきでしょうか。

これを決める際に先程の用語の意味に戻って考えています。

now は大抵の場合アプリケーションが実行している最中の特定の 時点 を指しています。 DBにInsertする時点ログを出力する時点 、etc...

とするならば、 時点 を表現するクラスやデータを利用するのが望ましいでしょう。これは、TimeAPIでは Instant (Java Platform SE 8) に該当します。 こちらのクラス説明には

時系列の時点。

と記述されているのでおそらく適切でしょう。また UnixTime1970-01-01T00:00:00.000Z からのミリ秒で表現されているので情報としてInstantよりは精度が低いものの、こちらもおそらく適切でしょう。

日時を扱う

時刻は先の定義により、 特定の時点における人間にとって意味のある単位での表現 となります。時刻は時刻系によって決まりますが、例えばグリニッジ標準時(GMT)協定世界時(UTC)は時刻系の1つです。日本においては日本標準時という地方時が時刻系として一般的に使われています。

時刻を表記する際には暦が用いられ、TimeAPIでは ISO-8601暦体系 が主に使われています。 日付と時間の両方を表現したいのであれば、ISO-8601で表記され時刻系の情報を扱えるクラスが望ましいでしょう。

ZonedDateTime (Java Platform SE 8) もしくはOffsetDateTime (Java Platform SE 8) が該当します。 使い分けは、単純にUTCからの時差だけを考慮する場合は OffsetDateTimeサマータイム含めてその地方時のルールを考慮する場合は ZonedDateTime を利用することになります。

サンプルコードとしては以下のとおりです。

scala> import java.time.{ZoneId,ZonedDateTime,OffsetDateTime}
import java.time.{ZoneId, ZonedDateTime}

scala> val now = ZonedDateTime.of(2018, 12, 22, 9, 0, 0, 0, ZoneId.of("Asia/Tokyo"))
now: java.time.ZonedDateTime = 2018-12-22T09:00+09:00[Asia/Tokyo]

scala> val christmas = now.plusDays(2).plusHours(15)
christmas: java.time.ZonedDateTime = 2018-12-25T00:00+09:00[Asia/Tokyo]

scala> val now = OffsetDateTime.of(2018, 12, 22, 9, 0, 0, 0, ZoneOffset.ofHours(9))
now: java.time.OffsetDateTime = 2018-12-22T09:00+09:00

scala> val christmas = now.plusHours(63)
christmas: java.time.OffsetDateTime = 2018-12-25T00:00+09:00

時間と時差の情報だけを扱った OffsetTime や、タイムゾーンの時差情報を扱った ZoneOffset地方標準時の特定する情報である ZoneId もあります。

また、作っているアプリケーションが対象としている地方時が1つだけだったり、地方時を考慮する必要がない場合は LocalDateTime (Java Platform SE 8) が望ましいでしょう。

LocalDateTime を使う場合、時点を示す情報を含んでいないので InstantUnixTime との相互変換の際は時刻系の考慮をする必要があります(クラスの説明にも記述してあります)

scala> import java.time.{LocalDateTime}
import java.time.LocalDateTime

scala> val now = LocalDateTime.of(2018,12,22,9,0,0)
now: java.time.LocalDateTime = 2018-12-22T09:00

scala> val christmas = now.plusDays(2).plusHours(15)
christmas: java.time.LocalDateTime = 2018-12-25T00:00

scala> val nowOnUtc = ZonedDateTime.of(now, ZoneId.of("UTC"))
nowOnUtc: java.time.ZonedDateTime = 2018-12-22T09:00Z[UTC]

サンプルコードでLocalDateTimeをZonedDateTimeに変換する際には、時刻系としてUTCを利用しています。 また、日付だけを扱った LocalDate や時間だけを扱った LocalTime もあります。

継続時間・期間を表す

継続時間と言ってもピンと来ない人が多いと思いますが、例えば同日の3時30分から9時20分までの間隔のことを指します。簡単に言うと時間の長さです。 これは名前そのもののクラスがTimeAPIに存在します。 Duration (Java Platform SE 8)Period (Java Platform SE 8) です。

Duration は時刻と時刻の差を時間で示します。 Period は時刻と時刻の差を日付で示します。使い方は以下のコードのようになります。

scala> import java.time.{LocalDateTime,LocalDate,Duration,Period}
import java.time.{LocalDateTime, LocalDate, Duration, Period}

scala> val now = LocalDateTime.of(2018,12,22,9,0,0)
now: java.time.LocalDateTime = 2018-12-22T09:00

scala> val christmas = now.plusDays(2).plusHours(15)
christmas: java.time.LocalDateTime = 2018-12-25T00:00

scala> val between = Duration.between(now, christmas)
between: java.time.Duration = PT63H

scala> between.toHours
res16: Long = 63

scala> val today = LocalDate.of(2018,12,22)
today: java.time.LocalDate = 2018-12-22

scala> val christmasDay = today.plusDays(3)
christmasDay: java.time.LocalDate = 2018-12-25

scala> val b = Period.between(christmasDay, today)
b: java.time.Period = P-3D

scala> b.getDays
res20: Int = -3

scala> b.toTotalMonths
res21: Long = 0

結果にもありますが、start > end の場合、負の値になる場合もあります。ドキュメントに記述もあります。

Duration モデルは有向デュレーションなので、デュレーションは負も可能です。 Period 期間は、方向性のある時間の量としてモデル化されます。つまり、期間の個々の部分は負になることがあります。

色々書いているけど、つまり何が言いたいのか

何気なくLocalDateTimeやLocalDateを使うのはやめましょう。だからといって、全てをUTCで扱えばいいものでもありません。 何を表現したいのか プログラムで扱っている 時間 とは何を指しているのか、を正しく把握してコードを書きましょう。

時間を題材にしましたが、これは数字や数値といったものにも当てはまります。

数字は 0-9までで表現された文字列 であり、数値は 数字を使って示された順序や量=数 を指します。

例えば現実に遭遇した話でいうと、

会員番号はIntegerで扱われているが、JavaScriptで parseInt を使ってエラーが出るかどうかチェックしてした。 仕様変更で表示や入力では頭に10桁になるまで0埋めされることになった。 Validationは変更されずにparaseIntで行った結果、8進数として扱わてしまい違う会員番号になった。

があります。幸いリリース前に気づいているので事故は発生していませんし今のブラウザでは parseInt は常に10進数として扱うので困ることはないでしょう。

しかし、そもそもの問題として会員番号は数値か数字でいうと順序や量を示すものではないのでそもそも 数字 としてStringで扱うべきでした。

プログラムは思ったとおりには動きません。書いたとおりに動きます。予期せぬバグや不要なトラブルを回避するためには、仕様や設計の段階で暗黙的に前提が置かれているものや、目的や用途を厳密に考えることは必要なのではないでしょうか。

ちなみに、初音ミクに当てはめると

(※以下の日時は全てJSTとして取り扱います。よってタイムゾーンは考慮しません。)

誕生日(発売日)は 2007年08年31日

クリスマス(2018-12-25)時点で生誕から 3939 日 と 39 * 5日 経過

来年のさっぽろ雪まつりでライブが開催されますが、最初の開催日時の開演日時は2019-02-09T14:00:00で

投稿日時(2018-12-22T09:00:00)から393 * 3時間 + 2時間後

scala> import java.time.{LocalDateTime,Duration,Period}
import java.time.{LocalDateTime, Duration, Period}

scala> val birthDay = LocalDate.of(2007,8,31)
birthDay: java.time.LocalDate = 2007-08-31

scala> val christmasDay = birthDay.plusDays(3939).plusDays(39 * 5)
christmasDay: java.time.LocalDate = 2018-12-25

scala> val firstLiveDateTime = LocalDateTime.of(2019,2,9,14,0,0)
firstLiveDateTime: java.time.LocalDateTime = 2019-02-09T14:00

scala> val publishDateTime = LocalDateTime.of(2018,12,22,9,0,0)
publishDateTime: java.time.LocalDateTime = 2018-12-22T09:00

scala> val duration = Duration.between(publishDateTime, firstLiveDateTime)
duration: java.time.Duration = PT1181H

scala> val duration = Duration.between(publishDateTime, firstLiveDateTime)
duration: java.time.Duration = PT1181H

scala> duration.toHours == 393 * 3 + 2 
res39: Boolean = true

となります。