令人困惑的 Go time.AddDate

語言: CN / TW / HK

我們經常會使用 Go time 包 AddDate() ,對日期進行計算。而它得到的結果,可能會往往超出我們的“預期”。(為什麼預期要打引號,因為我們的預期可能是模糊、偏差的)。

引例

假設,今天是10月31日,是10月的最後一天,我們想通過 AddDate() 計算下個月的最後一天。

today := time.Date(2022, 10, 31, 0, 0, 0, 0, time.Local)
nextDay := today.AddDate(0, 1, 0)
fmt.Println(nextDay.Format("20060102"))

// 輸出:20221201

結果輸出: 20221201 ,而非我們預期的下個月最後一天11月30日。

Go Time 包中是這麼處理的:

AddDate()
Format()

只要是涉及到大小月的最後一天都會出現這個問題。

today := time.Date(2022, 3, 31, 0, 0, 0, 0, time.Local)
d := today.AddDate(0, -1, 0)
fmt.Println(d.Format("20060102"))
// 20220303

today := time.Date(2022, 3, 31, 0, 0, 0, 0, time.Local)
d := today.AddDate(0, 1, 0)
fmt.Println(d.Format("20060102"))
// 20220501

today := time.Date(2022, 10, 31, 0, 0, 0, 0, time.Local)
d := today.AddDate(0, -1, 0)
fmt.Println(d.Format("20060102"))
// 20221001

today := time.Date(2022, 10, 31, 0, 0, 0, 0, time.Local)
d := today.AddDate(0, 1, 0)
fmt.Println(d.Format("20060102"))
// 20221201

原始碼分析

看一下 Go Time 包具體原始碼,仍以開頭 10-31 + 1 month 的例子為用例。

AddDate() ,首先對 month+1 ,然後呼叫 Date() 處理。

// time/time.go

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())
}

Date() 中此時傳入的引數是

  • year 2020
  • month 11
  • day 31
  • hour、min、sec、nsec 為執行時的時分秒納秒

d 計算的是絕對紀元到今天之前的天數:

**d = 今年之前的天數 + 年初到當月之前的天數 + 月初到當天之前的天數;**

最終,將 d 轉換成納秒 + 當天經過的納秒 儲存在 Time 物件中。

// time/time.go

func Date(year int, month Month, day, hour, min, sec, nsec int, loc *Location) Time {
    ……

    // 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)

    ……
    return t
}

對 Date() 輸入 2022-11-31 和輸入 2022-12-01 ,將得到同樣的 d(天數)。兩者底層儲存的時候都是一樣的資料,Format() 時將 2022-11-31 的Time 格式化成 2022-12-01 也就不例外了,輸出當然要顯示讓人看得懂的常規標準日期嘛。

// 2022-11-31
d = 2022年之前的天數 + 1月到10月的總天數 + 30天

// 2022-12-01
d = 2022年之前的天數 + 1月到11月的總天數 + 0天
  = 2022年之前的天數 + 1月到10月的總天數 + 30天 + 0天

你甚至可以往 Date() 輸入非標準日期 2022-11-35 ,它和標準日期 2022-12-05 ,將得到同樣的 d (天數)。

“非標準日期”和“標準日期”就像天平的兩邊,雖然形式不一樣,但他們實際的質量(d 天數)是一樣的。記住這句話,後面有用。

預期偏差

我們弄清楚了原理,但仍然不能接受這個結果。這樣的結果是 Go 的 bug 嗎?還是 Go Time 包偷懶了?

然而並不是,恰恰是我們的“預期”出現了問題。

正常來說,我們預期 10-30 + 1 month11-30 日,這很合理。那我們為什麼還期待 10-31 + 1 month 也是 11-30 日?僅僅因為 10-31 是當前月的最後一天,我們也期待 +1 month 後是下個月的最後一天嗎?

10-30 和 10-31 兩個日期相差一天,進行同樣的 +1 month 操作後,就變成為了同一天。這就像 1 + 10 = 2 + 10 一樣的結果,這顯然不合理。

Go 目前的處理結果是正確的,並且他在 AddDate() 註釋中也註明了會處理“溢位”的情況。況且,不止 Go 語言這麼處理,PHP 也是這麼處理的,見鳥哥文章 令人困惑的strtotime - 風雪之隅

怎麼解決

道理我都懂,但我就是想獲取上/下一個月的最後一天怎麼辦?

利用前面原始碼分析階段,提到的“天平原理”,就能拿到我們想要的結果。

today := time.Date(2022, 10, 31, 0, 0, 0, 0, time.Local)
d := today.Day()

// 上個月最後一天
// 10-00 日 等於 9-30 日
day1 := today.AddDate(0, 0, -d)
fmt.Println(day1.Format("20060102"))

// 下個月最後一天
// 12-00 日 等於 11-30 日
day2 := today.AddDate(0, 2, -d)
fmt.Println(day2.Format("20060102"))

// 20220930
// 20221130

結語

最初,發現這個問題是看鳥哥文章,當時認為那是 PHP 的“坑”,並沒有深入思考過。如今,在 Go 語言再次遇到這個問題,重新思考,發現日期函式本應該就那麼設計,是我們對日期函式理解不夠,產生了錯誤的“預期”。

文章來自 令人困惑的 Go time.AddDate