5月31日の1ヶ月後は何月何日?答えは言語やライブラリによって異なるらしい
みなさんは「5月31日の1ヶ月後はいつになるか?」と聞かれると何と答えますか?単純に月に1を加算するだけだと「6月31日」となって存在しない日付を指してしまうので、何らかの調整が必要になってきます。
どうやらこの日付の扱いはプログラミング言語やライブラリによって異なるようです。
そこで以下のケースについて、Python, JavaScript, Go の標準ライブラリおよび主要なサードパーティライブラリがどのような答えを出すのか検証してみました。
検証するケース
- 基準日:5月31日
- 操作:1ヶ月後
- 論点:6月30日(月末合わせ)か、7月1日(オーバーフロー)か?
Python の挙動
Python の標準 datetime モジュールには「月単位の加算」を行う機能がありません(timedelta は日単位)。そのため、以下のライブラリを使って検証してみました。
検証コード
from datetime import date
from dateutil.relativedelta import relativedelta
import arrow
import pendulum
# dateutil
d1 = date(2024, 5, 31) + relativedelta(months=1)
print(f"dateutil: {d1}")
# Arrow
d2 = arrow.get("2024-05-31").shift(months=1)
print(f"Arrow: {d2.format('YYYY-MM-DD')}")
# Pendulum
d3 = pendulum.parse("2024-05-31").add(months=1)
print(f"Pendulum: {d3.to_date_string()}")
実行結果
dateutil: 2024-06-30
Arrow: 2024-06-30
Pendulum: 2024-06-30
Python の主要ライブラリはすべて「月末合わせ(Clamping)」を採用しているようです。
JavaScript の挙動
標準ライブラリの Date クラスと以下のサードパーティライブラリで検証してみました。
検証コード
import dayjs from "dayjs";
import { addMonths } from "date-fns";
// Date
const d1 = new Date("2024-05-31");
d1.setMonth(d1.getMonth() + 1);
console.log(`Date: ${d1.toLocaleDateString()}`);
// Day.js
const d2 = dayjs("2024-05-31").add(1, "month");
console.log(`Day.js: ${d2.format("YYYY-MM-DD")}`);
// date-fns
const d3 = addMonths(new Date("2024-05-31"), 1);
console.log(`date-fns: ${d3.toLocaleDateString()}`);
結果
Date: 2024/7/1
Day.js: 2024-06-30
date-fns: 2024/6/30
標準ライブラリの Date は7月1日になりました。
- 5月の31日目の翌月は、6月の31日目
- 6月は30日までしかないから、あふれて7月1日
というロジックのようです。一方で、サードパーティライブラリは6月30日に補正してくれました。
ここでさらに気になって調べてみたのですが、次世代の標準 API である Temporal だとどうなるのでしょうか?Polyfill を使って検証してみました。
検証コード
import { Temporal } from "@js-temporal/polyfill";
// Temporal
const d4 = Temporal.PlainDate.from("2024-05-31").add({ months: 1 });
console.log(`Temporal: ${d4.toString()}`);
結果
Temporal: 2024-06-30
Temporal も6月30日に補正してくれるようです。ここまで見ていると、「月末の1ヶ月後は月末」として扱うのが一般的なようですね。
また Temporal にはオーバーフロー時にエラーとするオプションも用意されているようです。overflow: "reject" を指定することで、例外を発生させることができました。
(デフォルトは overflow: "constrain")
// Temporal (overflow: reject)
const d5 = Temporal.PlainDate.from("2024-05-31").add({ months: 1, overflow: "reject" });
console.log(`Temporal: ${d5.toString()}`);
Go の挙動
Go でも標準パッケージの time とサードパーティライブラリの Carbon を使って検証してみました。
検証コードと結果
package main
import (
"fmt"
"time"
"github.com/dromara/carbon/v2"
)
func main() {
// time
d1 := time.Date(2024, 5, 31, 0, 0, 0, 0, time.UTC)
d1 = d1.AddDate(0, 1, 0)
fmt.Println("time: ", d1.Format("2006-01-02"))
// Carbon
d2 := carbon.Parse("2024-05-31").AddMonth()
fmt.Println("carbon:", d2.ToDateString())
}
結果
time: 2024-07-01
carbon: 2024-07-01
意外にも Go は7月1日となりました。この点については 公式ドキュメント にも記載があります。
func (Time) AddDate
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.
翻訳:AddDateは、Date関数と同じ方法で結果を正規化(normalize)します。したがって、例えば10月31日に1ヶ月を加えると、11月31日の正規化された形である12月1日になります。
Carbon にはオーバーフローをさせないバージョンの関数も用意されているようです。
// Carbon(オーバーフローさせない)
d3 := carbon.Parse("2024-05-31").AddMonthNoOverflow()
fmt.Println("carbon:", d3.ToDateString())
AddMonthNoOverflow() を使えば6月30日が返ってきました。
なぜ Go は日付をオーバーフローさせるのか?
比較的新しい言語である Go が、なぜ Python や JavaScript ライブラリのように「気の利いた月末合わせ」を標準にしなかったのでしょうか?
この挙動について AI に聞いてみたところ、ここには Go の設計思想と、C 言語からの系譜が関係しているようです。
AI の回答
可逆性という考えはなるほどなと思いました。オーバーフローさせることが一概にバグとは言えず、一定の合理性があるようです。
挙動のまとめ
各プログラミング言語・ライブラリの挙動をまとめると以下のようになります。
| 言語 | ライブラリ | 5月31日の1ヶ月後 |
|---|---|---|
| Python | dateutil | 6月30日 |
| Arrow | 6月30日 | |
| Pendulum | 6月30日 | |
| JavaScript | Date | 7月1日 |
| Day.js | 6月30日 | |
| date-fns | 6月30日 | |
| Temporal (overflow: constrain) | 6月30日 | |
| Temporal (overflow: reject) | エラー | |
| Go | time | 7月1日 |
| Carbon(AddMonth) | 7月1日 | |
| Carbon(AddMonthNoOverflow) | 6月30日 |
ちなみにうるう年に対する1年後(例:2024年2月29日の1年後)も同様の挙動をしているようですので、Go で1ヶ月後・1年後の演算を行う場合は注意が必要です。
民法における日付の扱い
では「ビジネス上の正解」はどちらなのでしょうか。日本の法律(民法)では、期間の計算方法が明確に定められているようです。
民法 第143条第2項(暦による期間の計算)
「……ただし、月又は年によって期間を定めた場合において、最後の月に応当する日がないときは、その月の末日に満了する。」
(e-GOV 法令検索 より抜粋)
法律上は「5月31日の1ヶ月後は6月30日」とするのが正解のようですね。
契約期間などの一般的な日本のビジネスロジックにおいて、JavaScript や Go の標準ライブラリを使っていると、「5月31日の1ヶ月後を7月1日」とするのは誤りとなります。
余談:夏時間はさらにやっかい
パリの夏時間において2時〜3時は存在せず、1時59分59秒の次は3時となります。では2時30分のように存在しない時間帯はどのように扱っているのでしょうか。
1時30分の1時間後は何時か?
これは3時30分として扱うのが正しいようです。
計算のロジック
- 1時30分から「30分」経過 → 2時00分(ここで時計が3時00分にワープ)
- 残り「30分」を経過させる → 3時00分から30分進んで 3時30分
1時30分から3時30分だと、数値的には2時間が経過しているように見えます。
しかし例えば「ケーキを焼くのに1時間かかる」といった場合に、人間の感覚としての1時間が経過していないとマズいため、3時30分が正解となるようです。この挙動は他の言語と日付の挙動が異なる Go でもそのようになっていました。
パリの午前10時の1日後は何時か?
さらに怖いのが、夏時間における「1日後」の扱いです。午前10時の1日後は翌日の午前10時のようにも感じますが、2時〜3時が存在しない夏時間においては、翌日の10時というのは23時間後になります。
これについては考え方が2つあるようです。
物理時間(Duration)で24時間足す場合
- 答え:翌日の11時
- 理由:夜中に1時間スキップしたため、24時間経過すると時計の針は25時間分進んでいるように見えるから。
- 用途:サーバーのセッション有効期限、物理的なタイマー処理
カレンダー上の1日(Period)を足す場合
- 答え:翌日の10時
- 理由:「明日も同じ時間に」という人間の感覚に合わせるため、物理時間は23時間しか経過していないが、時計の針を合わせる。
- 用途:目覚まし時計、定例会議のリマインダー
1日を24時間として扱うことを当たり前にしていると、非常に混乱します...
まとめ
日付・時間計算が非常にやっかいであることがよく分かりました。言語やライブラリを変更するだけでも挙動が変わる可能性があるということを念頭に入れておく必要があるんだなということが分かり勉強になりました。
開発で国際化対応をする際には、丁寧に実装・検証をするように肝に銘じておこうと思います。
Discussion