2024年1月31日に入社した人は、2024年2月29日の時点で、何か月勤めたことになるでしょうか。
この仕様では、元にしたツールと同じく「入社日に1か月を足して、その月に同じ日がなければ月末にする」と決めています。1月31日の1か月後は、2月29日(うるう年)です。だから答えは 1か月0日 です。
ところが、4言語とも 0か月29日 を返しました。
これまでの5回は、言語の違いで答えが割れたケースでした。最終回は、言語の不具合ではありません。「1か月」の決め方が、書き方ごとに違うことが原因です。
月末に入社した人で、どの書き方もずれた
Python・Go・JavaScript では、「年・月・日をそれぞれ引き算して、日がマイナスなら前の月の日数を借りる」書き方をしました。PHP では、標準の DateTime::diff を使いました。
| 入社日 → 基準日 | 正解 | 前の月の日数を借りる書き方(Python・Go・JS) | PHP DateTime::diff |
|---|---|---|---|
| 1/31 → 2/29(うるう年) | 1か月0日 | 0か月29日 | 0か月29日 |
| 2/29 → 翌年2/28 | 1年0か月0日 | 0年11か月30日 | 0年11か月30日 |
| 2/29 → 翌年3/1 | 1年0か月1日 | 1年0か月0日 | 1年0か月0日 |
| 3/31 → 4/30 | 1か月0日 | 0か月30日 | 0か月30日 |
| 8/31 → 翌年2/29 | 6か月0日 | 5か月29日 | 5か月29日 |
どの書き方も、「基準日の日付が、入社日の日付(31日)に届いたか」で月を数えます。2月や4月には31日がないので、月末まで勤めても1か月と数えません。
勤続年数や、有給休暇を付与する日の計算では、月末に入社した人の扱いが結果を左右します。
「1か月 −2日」 ― 日数がマイナスになった
2025年1月31日に入社して、2025年3月1日(平年)の時点では、正解は 1か月1日 です。前の月の日数を借りる書き方は、ここで 1か月 −2日 を返しました。
3月1日 − 1月31日 → 月 +2、日 −30
日がマイナスなので、月を1つ減らして、前の月(2月)の28日を足す → 月 +1、日 −2
2月は28日しかないので、30日ぶんを借りきれません。借りる月を「前の月」に決め打ちしていることが原因です。
PHP の DateTime::diff は、マイナスにはならず 0か月29日 を返しました。答えの形は自然ですが、この仕様の正解とは違います。
「◯か月足す」関数も、月末で繰り越す
「それなら、日付ライブラリで1か月足せばいい」と考えたくなります。ところが、Go の AddDate も PHP の modify('+1 month') も、2025年1月31日に1か月足すと 3月3日 を返します。
time.Date(2025, 1, 31, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0) // 2025-03-03
(new DateTime('2025-01-31'))->modify('+1 month'); // 2025-03-03
2月31日を作り、2月にない3日分を3月に繰り越すためです。これも1つの決め方ですが、「同じ日がなければ月末にする」決め方とは違います。
どう直したか ― 決め方をコードに書く
「入社日に k か月を足した日(同じ日がなければ月末)が、基準日を超えない、最大の k」を数えるように書き直しました。日付ライブラリの「◯か月足す」は使っていません。
1か月足した日を求める部分は、次の考え方です(Python の例)。
import calendar
def add_months(y, m, d, k):
"""y年m月d日に k か月足す。その月に同じ日がなければ月末にする"""
total = (y * 12 + (m - 1)) + k
ny, nm = divmod(total, 12)
nm += 1
last = calendar.monthrange(ny, nm)[1]
return ny, nm, min(d, last)
add_months(2024, 1, 31, 1) # (2024, 2, 29)
add_months(2025, 1, 31, 1) # (2025, 2, 28)
日数は、この日から基準日までを数えます。マイナスになることはありません。
直した後は、4言語とも17件中17件が正解と一致しました。
連載のまとめ
全6回で、同じ仕様を4言語で書いたときに答えが割れた、または、そろって間違えたケースを見てきました。
| 回 | 論点 | 何が起きたか |
|---|---|---|
| ① | 0.5 の丸め方 | Python は偶数の方へ、JavaScript は大きい方へ丸めた |
| ② | 2進小数の誤差 | 1,100 ÷ 1.1 が 999 になり、4言語がそろって間違えた |
| ③ | 負の数 | 余りの符号で、Go は止まり、JavaScript は NaN を返した |
| ④ | 大きな整数 | PHP は上限に張りつき、JavaScript は桁が化けた |
| ⑤ | 日付とタイムゾーン | JavaScript だけ、日本で動かすと日付が1日ずれた |
| ⑥ | 「1か月」の決め方 | 言語ではなく、決め方の違いで答えが割れた |
どの回にも共通しているのは、ふだんの値では正しく動き、境目の値でだけ間違えることです。0.5 ちょうど、マイナスの金額、2^53 を超える数、月末の日付。テストを書くときは、こうした境目の値を先に正解表に入れておくと、言語や書き方の違いを見つけられます。
検証の結果は CODE Polyglot で、毎朝自動で検証し直して公開しています。
この記事のケースの正解表・4言語の結果・直す前と後のコードの違いは、次のページで見られます。
- 勤続年数(◯年◯か月◯日)(c004〜c013)
Analyzegear


