Go time包AddDate使用解惑實例詳解

我們經常會使用 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() 對月份+1,即變成瞭11-31,換算成對應的天數、最終換算成對應的納秒數存儲在 Time 對象中;
  • 輸出時,Format()將輸出標準的日期,Time 中的納秒會轉為 12-01,而不是 11-31,因為這天並不存在;

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

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 month是 11-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使用解惑實例詳解的詳細內容,更多關於Go time包AddDate的資料請關註WalkonNet其它相關文章!

推薦閱讀: