読者です 読者をやめる 読者になる 読者になる

Julia の日付・時刻の標準ライブラリ `Base.Datas` における日付演算

JuliaLang Advent Calendar 2015 の 21 日目の記事です。

この間のJuliaTokyo#5 で話題に出たので、Base.Dates 全般の紹介をしようかと思ったのですが、日付演算が(えぐくて)面白くて文章量が多くなりすぎたので、絞りました。

概要

Julia の標準ライブラリ Base.Dates は、日付や時刻、時間を表す型や演算、暦の取得を司るものです。

時刻と時間の算術

時間に時間を足し引きして新しい時間に作ったり、 時刻に時間を足し引きして新しい時刻を作ることができます。

julia> using Base.Dates

# 時間+時間=時間
julia> Hour(1) + Minute(30)
1 hour, 30 minutes

# 現在時刻
julia> nw = now()
2015-12-21T01:15:17

julia> nw + Hour(1)
2015-12-21T02:15:17

julia> nw - Minute(30)
2015-12-21T00:45:17

# 今日の日付
julia> td = today()
2015-12-21

# 明日
julia> td + Day(1)
2015-12-22

# 去年
julia> td - Year(1)
2014-12-21

# 来月
julia> td + Month(1)
2016-01-21

はい、便利ですね。

さて、1日以下と、月と年との間は、単位換算が確定しているので曖昧さなく算術可能ですが、月と日の間の算術は曖昧さを含みます。 次の16 個の日付計算で得られる結果は、それぞれ何月何日でしょう?読み進める前に予想をしてみてください。

# 2015-01-30 の
julia> d = Date(Year(2015), Month(1), Day(30))

# (1) 1日後
julia> d + Day(1)

# (2) 2日後
julia> d + Day(2)

# (3) 1ヶ月後
julia> d + Month(1)

# (4) 2ヶ月後
julia> d + Month(2)

# (5) 1ヶ月後(3)の1ヶ月後
julia> (d + Month(1)) + Month(1)

# (6) 1日後(1)の1ヶ月後
julia> (d + Day(1)) + Month(1)

# (7) 1ヶ月後(3) の1日後
julia> (d + Month(1)) + Day(1)

# (8) (1ヶ月と1日)後
julia> d + (Month(1) + Day(1))

# (9)
julia> d + Month(1) + Day(1)

# (10)
julia> d + Day(1) + Month(1)

# (11) 
julia> Month(1) + d + Day(1)

# (12) 
julia> Day(1) + d + Month(1)

# (13) 
julia> Day(1) + Month(1) + d

# (14) 
julia> d + Month(1) + Month(1)

# (15) 
julia> d + Month(1) - Month(1)

# (16) 
julia> d - Month(1) + Month(1)

予想はしてみましたか?それでは答え合わせと解説です。

最初の2つは簡単ですね。

# (1)
julia> d + Day(1)
2015-01-31
# (2)
julia> d + Day(2)
2015-02-01

2015-01-30 の1日後と2日後は曖昧さなく、それぞれ2015-01-31 と2015-02-01 です。

それでは2015-01-30 の1ヶ月後、2ヶ月後、「1ヶ月後」の1ヶ月後は?

# (3)
julia> d + Month(1)
2015-02-28

# (4)
julia> d + Month(2)
2015-03-30

# (5)
julia> (d + Month(1)) + Month(1)
2015-03-28

ご覧のとおりの結果です。予想はあたったでしょうか?

実はJulia は

  1. 年・月・日の順番に足し引きをする
  2. 各段階ごとに繰り上げや繰り下げを行う
  3. 月部分の計算が終わった段階で、月の日数が減って日部分がはみ出た場合、端数を切り捨てて丸める

というアルゴリズムで日付の計算を行います。

(3) では、2015-01-30 + 0000-01-00 = 2015-02-30 となり、この月はもちろん28 日までしかないため、丸められて2015-02-28 となります。 一方で(4) では、2015-01-30 + 0000-02-00 = 2015-03-30 となり、正当な日付となるため、そのまま答えになります。 最後に(5) では、(3) の結果 = 2015-02-28 に0000-01-00 を足すので、2015-03-28 となります。

このアルゴリズムが頭に入れば、(6)-(8) を解くことができます。 一旦予想しなおしてから、先に進みましょう。

# (6) 1日後(1)の1ヶ月後
julia> (d + Day(1)) + Month(1)

# (7) 1ヶ月後(3) の1日後
julia> (d + Month(1)) + Day(1)

# (8) (1ヶ月と1日)後
julia> d + (Month(1) + Day(1))

それでは答え合わせ。

# (6)
julia> (d + Day(1)) + Month(1)
2015-02-28

# (7)
julia> (d + Month(1)) + Day(1)
2015-03-01

# (8)
julia> d + (Month(1) + Day(1))
2015-03-01

(6) は最初の和で 2015-01-31 となって、次の和で2015-02-31 => 2015-02-28 となります。 一方(7) は最初の和で 2015-02-30 => 2015-02-28 となって、次の和で2015-02-29 => 2015-03-01 となります。 (8) では、2015-01-30 + 0000-01-01 となるのですが、年月日の順番に足し算が行われます。年は変わらず、月の足し算で 2015-02-31 => 2015-02-28 となり、最後に日が足されて 2015-02-29 => 2015-03-01 となります。

さて、カッコを外して項の順番をシャッフルしてみるとどうなるでしょう。 まずは(9), (10) から。

# (9)
julia> d + Month(1) + Day(1)
2015-03-01

# (10)
julia> d + Day(1) + Month(1)
2015-03-01

Julia の二項演算子のうち+*では、単独の演算子で3つ以上の項を繋いだ時に、それらの項すべてを一度に引数として取るようなメソッドが呼び出されます。 実際にどこで定義されたメソッドが呼び出されるかは@which マクロを使うと確かめることができて、例えば

julia> @which 1 + 2
+(x::Int64, y::Int64) at int.jl:8

julia> @which 1 + 2 + 3
+(a, b, c, xs...) at operators.jl:103

julia> @which d + Month(1) + Day(1)
+(a::Base.Dates.TimeType, b::Base.Dates.Period, c::Base.Dates.Period) at dates/periods.jl:227

となります。今回のケースでは、 (+)(a::TimeType,b::Period,c::Period) = (+)(a,b+c) と定義されており、(8) に帰着します。

一方(11)-(13) はどうなるか。まず何が呼び出されるのかをチェックしてみると、すべて同じ定義

julia> @which Month(1) + d + Day(1)
+(a, b, c, xs...) at operators.jl:103

となります*1。 これは何かというと、どんな型の値でも foldl 関数と二項演算子+(x,y) とを使って左から順番に畳み込んでいく定義になっています。 つまり、 (11) はMonth(1) + d + Day(1) == (Month(1) + d) + Day(1) となり、(7) に帰着します。 同様に(12) は(6) に、(13) は(8) に帰着します。

# (11) 
julia> Month(1) + d + Day(1)
2015-03-01

# (12) 
julia> Day(1) + d + Month(1)
2015-02-28

# (13) 
julia> Day(1) + Month(1) + d
2015-03-01

最後の3問です。今までの知識があれば、もうほんの少しの調査・考察で解けるはずです。

# (14) 
julia> d + Month(1) + Month(1)

# (15) 
julia> d + Month(1) - Month(1)

# (16) 
julia> d - Month(1) + Month(1)

(14) はもう大丈夫ですね。日付に足す前に後ろの期間どうしの足し算が実行されるので、答えは 2015-01-30 + 0000-02-00 = 2015-03-30 です。 一方(15), (16) では、演算子が混ざっているので、普通に二項演算子として扱われて、左から結合していきます。 (15) では (d + Month(1)) - Month(1) => 2015-02-28 - 0000-01-00 => 2015-01-28に、 (16) では (d - Month(1)) + Month(1) => 2014-12-30 + 0000-01-00 => 2015-01-30になります。

# (14) 
julia> d + Month(1) + Month(1)
2015-03-30

# (15) 
julia> d + Month(1) - Month(1)
2015-01-28

# (16) 
julia> d - Month(1) + Month(1)
2015-01-30

まとめ

  • Julia の日付演算は
    1. 年・月・日の順番に足し引きをする
    2. 各段階ごとに繰り上げや繰り下げを行う
    3. 月部分の計算が終わった段階で、月の日数が減って日部分がはみ出た場合、端数を切り捨てて丸める
  • 月が絡むと、特に月末が絡むと難しい
    • Julia 公式ドキュメントいわくJavascriptPHP では、1ヶ月を一律31日および30日として計算しているらしい
    • 自分で日数に直すのも手
  • 二項演算子+*では引数の数が3つ以上になることがある
    • 引数の数(=項の数)が変わるとメソッドも変わるため、演算を変えることも可能
      • もちろんあまり変なことはしないほうが使用者のため
  • @which マクロを使うとコードリーディングが楽になる

参考文献(というかネタ元)

*1:正確には、引数の型が異なるためにすべて違うメソッドになりますし、その結果別途にJIT コンパイルが行われます。しかし、定義自体は同じなので、@which の結果は同じとなります。