こんにちは、バックエンドエンジニアの藤井脩紀です。 今回の記事では日付計算に潜む罠についてお話しさせていただきます。
なお今回はGoでの事例をご紹介しますが、原理的には他の言語やライブラリでも同様の事象が発生する可能性があります点にご注意ください。
time.Time.AddDateの注意点
突然ですがここでクイズです。 来月が何月か知りたいので以下のようなコードを書きました。 このコードを実行したのは10月だとします。 結果は何になるでしょう?
// AddDateの引数は順に年、月、日でそれらの値を加算したtime.Timeを返してくれます fmt.Printf("%d月\n", time.Now().AddDate(0, 1, 0).Month())
「え、11月でしょ?」と思う方もいらっしゃるかもしれませんが正確には異なります。 焦らさずに書きますが答えは「11月か12月のどちらか」です。 これこそが日付計算に潜む罠「正規化(normalize)」です。
上記のクイズについてより正確な回答をするのであれば 「10月1〜30日なら11月、10月31日なら12月」となります。 これは11月31日が存在しないことが原因で、正規化が行われて11月30日+1日で12月1日となるからです。
このことについては公式のドキュメントでも以下の通り説明がなされています:
AddDate normalizes its result in the same way that Date does, so, for example, adding one month to October 31 yields December 1, the normalized form for November 31. https://pkg.go.dev/time#Time.AddDate
なお正規化自体はみなさん慣れ親しんだもののはずで以下のケースも同様に正規化と呼ばれるものです:
- 1月31日+1日は1月32日が存在しないため2月1日になる
- 2020年12月に+1ヶ月は13月が存在しないため2021年1月になる
この2つの例の正規化は、計算として考えると999+1が1000になるのと同じで 1桁目(日)があふれれば2桁目(月)、それもあふれれば3桁目(年)に繰り上げされるということなので直感に相反することはないでしょう。
よってここで問題として取り上げるのは、クイズの例の「10月31日+1ヶ月が12月1日になる」のように直感と相反するケースです。 この問題は月の日数が一定でないことに起因しており月末(29日〜31日)にしか発生しない問題のため、ここでは「月末正規化」と呼ぶことにします。
ちなみに加算を例としてあげましたが減算でも月末正規化は発生します。 例えば12月31日-1ヶ月は11月30日+1日で12月1日になります。このように減算では月が変わりすらしないケースもあります。
それからもう一つ注意点として、月の加減算を例に月末正規化が起こりうることを説明しましたが、年の加減算についても同様に注意が必要です。レアケースではありますが閏年の2月29日に対して年の加減算を行ったときに結果が閏年以外だと2月29日が存在しないため3月1日になってしまいます。
月末正規化への対策
正規化の説明は以上なので、ここからは月末正規化という罠に対してどのように向き合うのか考えていきます。
対策案1:日単位でのみ加減算を行う
ここまでで説明した通り、月末正規化は年または月の加減算によって起こる問題であり、年と月という単位が一定の長さではないことに起因します。 よって加減算においてはこのような不定の単位ではなく日という不変の単位を利用することで月末正規化を避けることができます。
1年や1ヶ月ではなく365日や30日を単位として加減算するという選択肢が取れないか考えてみましょう。
なお補足としてtimeパッケージの概要に下記のような説明があるため閏秒については考えないものとします:
The calendrical calculations always assume a Gregorian calendar, with no leap seconds. https://pkg.go.dev/time#pkg-overview
対策案2:月未満を切り捨てる
年と月のみが必要な場合であれば、日付を捨てて計算しましょう。 月末正規化は29日以降でなければ起こり得ないためです。(28日までなら何月にでもあるので)
以下は、time.TimeにNヶ月を足した年と月を求める関数です。
-t.Day()+1
日を足すことでYYYY年MM月1日に固定して計算しているため、
10月+1ヶ月は常に11月になると言ったように月末正規化を避けることができます。
func AddMonths(t time.Time, months int) (year int, month time.Month) { year, month, _ = t.AddDate(0, months, -t.Day()+1).Date() return }
対策案3:月末正規化を意識づけるラッパー関数を作る
月末正規化の扱いを引数で選択させることで強制的に意識させようという案です。 (ミラティブのバックエンドではこの方式を採用しています)
扱い方には以下の3通りなどが考えられます:
- 正規化
- 通常通り正規化を行う
- 例:10月31日+1ヶ月=12月1日
- 切り捨て
- 月の上限から溢れた分の日数を切り捨てる
- 例:10月31日+1ヶ月=11月30日(11月は30日までなので+1日分は切り捨て)
- 月の最終日なら維持
- 基本的には「切り捨て」と同じ。ただし、元の値が月の最終日なら結果も月の最終日にする
- 例:10月31日+1ヶ月=11月30日(11月は30日までなので+1日分は切り捨て)
- 例:11月30日+1ヶ月=12月31日(元が11月の最終日なので結果も12月の最終日となる)
(この3通りの扱いは筆者が考えたものではなく、Perlの DateTime->add のend_of_monthというオプションに倣ったものです)
これを実際にGoで実装したコードを以下に示します:
// 月末正規化の扱いを決めるenum的なもの type AddMode uint8 const ( // 正規化 NormalizeExcessDays AddMode = iota // 切り捨て TruncateExcessDays // 月の最終日なら維持 PreserveEndDayOfMonth ) func AddYears(t time.Time, years int, mode AddMode) time.Time { return addYearsAndMonths(t, years, 0, mode) } func AddMonths(t time.Time, months int, mode AddMode) time.Time { return addYearsAndMonths(t, 0, months, mode) } func AddDays(t time.Time, days int) time.Time { return t.AddDate(0, 0, days) } func addYearsAndMonths(t time.Time, years, months int, mode AddMode) time.Time { if mode == NormalizeExcessDays { return t.AddDate(years, months, 0) } day := t.Day() year, month, dayLimit := t.AddDate(years, months+1, -t.Day()).Date() if day > dayLimit || mode == PreserveEndDayOfMonth && t.Month() != t.AddDate(0, 0, 1).Month() { day = dayLimit } hour, min, sec := t.Clock() return time.Date(year, month, day, hour, min, sec, t.Nanosecond(), t.Location()) }
AddYears/Months/Daysに分けたのはAddDaysにはオプションが不要なためです。 それからAddDateのように3つの単位を同時に加算できるのは少々複雑すぎると感じたのもあります。
また少々複雑な処理のため説明だけでは分かりにくいかと思い Go Playground も用意したのでよければお試しください。(AddDaysはAddDateとほぼ同一のため省略しました)
またこちらのコードは GitHub にも保管しており、 ライセンスはUNLICENSEとしているので自己責任のもとご自由にご利用ください。
補足:正規化を利用して月の最終日を求める
上記の対策案3の実装の中に正規化を利用した処理があり、若干分かりずらいと思うので補足説明します。 該当するのはdayLimitという変数を求めているところで、この変数には結果となる月の最終日が入ります。 これは翌月の0日を求めることで-1日の正規化が発生し結果的に当月の最終日になるというテクニックになっています。
上記のコードだと年と月の加算も混じっているのでその部分を除外したコードも置いておきます:
func EndDayOfMonth(t time.Time) time.Time { return t.AddDate(0, 1, -t.Day()) }
まとめ
本記事の内容は以上です。ここまでで日付計算の罠について説明し、その対策案をいくつか挙げました。 最初に注意として書いたようにこの問題はGo固有の問題ではなく、他の言語でも意識する必要のある問題です。
その点に関しては3月31日+1ヶ月がどのようになるのかを言語別に複数調査した記事があり参考になったので載せておきます:
眺めてみると4月30日となるものも数多くあるようで、5月1日になるのがデファクトという訳ではないようです。 もちろんライブラリや関数などによって変わる可能性もありますし、タイムゾーンなど他にも罠になりうる要素は存在するので 日付計算を行うときは活用する実装の挙動をよく理解して慎重に行うようにしましょう。
この記事で一人でも罠を踏み抜く方が減れば幸いです。
さいごに
ミラティブではバックエンドエンジニアを含め複数のポジションで力を貸してくださる方々を募集しています!