はじめに
Golang の time パッケージのAddDate()
について、
挙動を理解していないが為に重要なバグを生んでしまったので、まとめてみました。
結論
-
AddDate()
で月を加算すると加算前の月の日数(1月だったら31日)が加算される - つまり、2022年1月31日に
AddDate()
で月を1加算すると、2022年3月3日になる
実際に起こったこと
それは、今月の月と来月の月を返す関数getMonths()
を実装した際に起こりました。
まずは、実装したコードを見てください。
func getMonths() []int {
targetTime := time.Now()
calendars := make([]int, 2)
for i, _ := range calendars {
calendars[i] = int(targetTime.Month())
targetTime = targetTime.AddDate(0, 1, 0)
}
return calendars
}
このコードはバグります。
たとえば1月30日の場合、[1, 3]が返ってしまいます。
time.AddDateの挙動を見てみる
一旦、time.AddDateの実装部分を見てみましょう。
func (t Time) AddDate(years int, months int, days int) Time {
year, month, day := t.Date()
hour, min, sec := t.Clock()
return Date(year+years, month+Month(months), day+days, hour, min, sec, int(t.nsec()), t.Location())
}
上のコードでわかる通り、月の部分はmonth+Month(months)
となっており、
現在の月に引数の月(int型をMonthにキャストする)を足した値を返しています。
time.Date()では次のような実装になっています。
func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time {
if loc == nil {
panic("time: missing Location in call to Date")
}
// Normalize month, overflowing into year.
m := int(month) - 1
year, m = norm(year, m, 12)
month = Month(m) + 1
// Normalize nsec, sec, min, hour, overflowing into day.
sec, nsec = norm(sec, nsec, 1e9)
min, sec = norm(min, sec, 60)
hour, min = norm(hour, min, 60)
day, hour = norm(day, hour, 24)
// Compute days since the absolute epoch.
d := daysSinceEpoch(year)
// Add in days before this month.
d += uint64(daysBefore[month-1])
if isLeap(year) && month >= March {
d++ // February 29
}
// Add in days before today.
d += uint64(day - 1)
// Add in time elapsed today.
abs := d * secondsPerDay
abs += uint64(hour*secondsPerHour + min*secondsPerMinute + sec)
unix := int64(abs) + (absoluteToInternal + internalToUnix)
// Look for zone offset for expected time, so we can adjust to UTC.
// The lookup function expects UTC, so first we pass unix in the
// hope that it will not be too close to a zone transition,
// and then adjust if it is.
_, offset, start, end, _ := loc.lookup(unix)
if offset != 0 {
utc := unix - int64(offset)
// If utc is valid for the time zone we found, then we have the right offset.
// If not, we get the correct offset by looking up utc in the location.
if utc < start || utc >= end {
_, offset, _, _, _ = loc.lookup(utc)
}
unix -= int64(offset)
}
t := unixTime(unix, int32(nsec))
t.setLoc(loc)
return t
}
月を算出している部分は以下の部分で、
// Compute days since the absolute epoch.
d := daysSinceEpoch(year)
// Add in days before this month.
d += uint64(daysBefore[month-1])
if isLeap(year) && month >= March {
d++ // February 29
}
ここでは引数のyearまでの日数を取得し、
引数の月までの日数を加算しています。
daysBefore は以下のような値です。
var daysBefore = [...]int32{
0,
31,
31 + 28,
31 + 28 + 31,
31 + 28 + 31 + 30,
31 + 28 + 31 + 30 + 31,
31 + 28 + 31 + 30 + 31 + 30,
31 + 28 + 31 + 30 + 31 + 30 + 31,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 30,
31 + 28 + 31 + 30 + 31 + 30 + 31 + 31 + 30 + 31 + 30 + 31,
}
つまり、Date()の引数にyear=2022, month=3を渡した場合、
2022年1月1日までの総日数を取得し、
2022年1月1日〜2022年3月1日 までの日数(31+28)を加算しているわけです。
2022年1月31日でAddDate(0, 1, 0)
をすると
2022年1月31日でAddDate(0, 1, 0)
をすると、
内部で2022年1月1日までの総日数を取得し、
2022年1月1日から2022年2月1日までの総日数31日分が加算されます。
つまり、
2022年1月31日が2022年3月3日になります。
改めて今月の月と来月の月を返す関数を実装してみる
これらを踏まえてもう一度getMonths()
を実装してみると、
func getMonths() []int {
loc, _ := time.LoadLocation("Asia/Tokyo")
targetTime := time.Now()
months := make([]int, 2)
for i, _ := range months {
months[i] = int(targetTime.Month())
targetTime = time.Date(targetTime.Year(), targetTime.Month()+1, 1, 0, 0, 0, 0, loc)
}
return months
}
これで意図した関数になりました。
または、ややこしいことはしないで、
func getMonths() []int {
now := time.Now()
month := now.Month()
months := make([]int, 2)
for i, _ := range months {
months[i] = int(month)
if month == 12 {
month = 1
} else {
month++
}
}
return months
}
これでももちろん十分です。
まとめ
今回はAddTime()
で月を加算するとどうなるかまとめてみました。
今思えばそりゃそうだろって感じですが、雑魚なりに意外と深いところまで読めました。