1. 为什么要比较时间并计算“已经过去了多久”
当我们第一次看到 time.Time 时,可能会觉得它“只是又一种可以打印出来的类型”。但几乎任何真实程序最终都会遇到这样的问题:我们是否赶上了截止时间?某个操作花了多久?什么时候该给用户显示提醒?一条记录是过期了还是还新鲜?在这些时刻,你需要的不是日期格式的美观,而是算术和比较。
要理解的是,在 Go 里,标准库开发者努力把 API 设计得尽量不容易出错。所以,他们没有让你手动用 > 和 < 比较时间,而是提供了 Before/After/Equal 方法。也没有要求你手写 time.Now().Sub(start),而是提供了更易读的 time.Since(start)。这不只是“语法糖”,而是让代码更清晰、更安全的一种方式——对一周后的你是这样,对一年后打开你文件的同事也是这样(如果全是毫秒却没有任何注释,他大概会默默流泪)。
2. Add 和 Sub:“时刻 ± 持续时间” 与 “时刻 − 时刻”
先从最基础的时间算术开始。Go 里有一条非常简单的规则,像乘法表一样值得记住:
- Time + Duration = Time (把时刻向前/向后平移)
- Time - Time = Duration (得到它们之间相隔多久)
- Duration + Duration = Duration (把区间相加)
不能直接把两个 time.Time 相加——这其实是好事:这样就更不容易误写出毫无意义的表达式。
添加持续时间:t.Add(d)
每个小标题之后,我都会稍微“铺垫”一下主题。这里的铺垫很简单:很多时候你知道“什么时候开始”,也知道“应该持续多久”,然后你想得到“什么时候结束”。
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
end := start.Add(45 * time.Minute)
fmt.Println("start:", start) // start: 2026-01-16 10:00:00 +0000 UTC
fmt.Println("end: ", end) // end: 2026-01-16 10:45:00 +0000 UTC
}
注意两点。第一,Add 不会修改变量 start,而是返回一个新的 time.Time。第二,45 * time.Minute 看起来就像“人类可读”的持续时间——你不需要去记忆它等于多少秒。
两个时刻的差:end.Sub(start)
接下来是对称的情况:通常我们有开始和结束,然后想要持续时间。这是 Sub 的“理想”场景。
package main
import (
"fmt"
"time"
)
func main() {
start := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 16, 10, 45, 0, 0, time.UTC)
elapsed := end.Sub(start)
fmt.Println("elapsed:", elapsed) // elapsed: 45m0s
}
如果你哪天在代码里看到这种“手写”计算:(endUnix - startUnix) / 60,那就要小心了:旁边通常藏着一个等着发作的 bug。Sub 并不是对所有时间场景都完美无缺(时间有日历层面的细节),但对于“两个时刻之间相隔多久”这种需求,它正合适。
3. time.Since(start) 和 time.Until(deadline)
这里的铺垫是:很多时候我们甚至不需要把“第二个时刻”保存成变量。我们只想知道从开始到“现在”过去了多久,或者从“现在”到截止时间还剩多久。当然,你可以每次都写 time.Now().Sub(start) ——但 Go 提供了更像英文的函数名,而这正是少数几个“英文在代码里真的能帮上忙”的场景。
测量“花了多久”:time.Since(start)
time.Since(start) 本质上就是 time.Now().Sub(start),只是意图一眼就能看懂:since start。
package main
import (
"fmt"
"time"
)
func main() {
start := time.Now()
time.Sleep(30 * time.Millisecond)
fmt.Println("since:", time.Since(start)) // example: since: 30.1ms
}
一个很重要的实践建议:time.Now() 最好只调用一次,把结果保存到 start,之后都基于它来计算。如果你“差不多同时”调用两次 time.Now(),你仍然会得到两个不同的值,而这有时会在意想不到的地方破坏逻辑,尤其是当比较接近“临界点”时。
距离截止时间还剩多少:time.Until(deadline)
如果 Since 表示“已经过去”,那 Until 就表示“还剩多少”。
package main
import (
"fmt"
"time"
)
func main() {
deadline := time.Now().Add(200 * time.Millisecond)
fmt.Println("left:", time.Until(deadline) > 0) // left: true (in a typical run)
}
time.Until(deadline) 可能是负数。这不是错误:它只是诚实地回答“截止时间已经过去了”。所以如果你想判断“是否赶上了”,通常会和零比较(> 0 或 <= 0),或者通过 Before/After 去比较时刻(下面会讲)。
4. 时间比较:Before、After、Equal
时间比较是新手最容易想“自己造轮子”的地方:去比较字符串、单独比较“小时”和“分钟”,或者干脆把一切转成数字,像石器时代一样生活。其实 time.Time 是一个功能丰富的类型,它提供的方法比任何“手写”技巧都更能准确表达意图。
检查顺序:t1.Before(t2) 和 t1.After(t2)
想象一下你在写“开始早于结束”的判断。在正常情况下,这应该只是一行,而不是一个带有“如果年份相等”的 20 行小算法。
package main
import (
"fmt"
"time"
)
func main() {
t1 := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
t2 := t1.Add(1 * time.Hour)
fmt.Println(t1.Before(t2)) // true
fmt.Println(t2.After(t1)) // true
}
代码读起来几乎像英语:t1 before t2。这就是目标。
相等:Equal,以及为什么 == 不一定是你想要的
time.Time 有 Equal 方法,当你比较的是“时间轴上的同一个时刻”时,应该优先用它。为什么不直接用 t1 == t2 呢?因为 time.Time 有内部表示上的细节(包括与单调时钟部分有关的内容),按“结构”比较和按“时刻”比较,行为可能并不完全相同。
在你还是新手的时候,一个很好的规则就是:比较时刻请使用 Before/After/Equal。这样意图更明确,也更少依赖内部细节。
package main
import (
"fmt"
"time"
)
func main() {
t := time.Date(2026, 1, 16, 10, 0, 0, 0, time.UTC)
fmt.Println(t.Equal(t)) // true
}
5. UTC vs Local:同一个时刻,多种显示方式
这里通常就是人们开始怀疑时间是阴谋的时刻了。说实话,他们也不完全错。
铺垫如下:程序里有一个“事件”——比如一个在 10:00 UTC 的会议。这是时间轴上的同一个时刻。但如果你在另一个时区,这个时刻看起来就会是不同的本地时间。也就是说,时刻没有变,变化的只是“我们如何展示它”。
UTC()、Local() 和 In(loc) 说的是表示方式
新手最常见的错误就是以为 t.UTC() 会“转换时间”并“移动事件”。不是这样。它只会为同一个时刻更换位置(表示方式)。
我们来看一个固定时区的例子(这样更简单,也更适合教学)。
package main
import (
"fmt"
"time"
)
func main() {
utc := time.Date(2026, 1, 16, 12, 0, 0, 0, time.UTC)
loc := time.FixedZone("UTC+3", 3*60*60)
inLoc := utc.In(loc)
fmt.Println("utc: ", utc) // utc: 2026-01-16 12:00:00 +0000 UTC
fmt.Println("loc: ", inLoc) // loc: 2026-01-16 15:00:00 +0300 UTC+3
fmt.Println("equal:", utc.Equal(inLoc)) // equal: true
}
最后一行最重要。Equal 说的是:是的,这是同一个时刻。只是一个时区里显示成“12:00”,另一个时区里显示成“15:00”。就像现实里:同一场线上会议,有人说“我早上 9 点参加”,有人说“我晚上 6 点参加”。
为什么程序喜欢 UTC
说到 UTC,不必太夸张地讲:UTC 很适合当作“通用尺子”。如果你把时间存成 UTC,那么比较起来更容易,惊喜更少,也更不依赖运行环境(服务器在一个地方,用户在另一个地方,测试又在第三个地方)。而本地时区通常放在边界上使用——当你需要把内容展示给人看,并让他感觉“这就是我习惯的时间”。
不过,你并不一定非得“永远都用 UTC”。最重要的是理解:时区关系到显示,而比较和算术是基于时刻进行的。
6. 一个小型时间跟踪器:测量与截止时间
为了不让主题停留在抽象层面,我们给教学应用增加一点小功能:带计划的持续时间测量。程序做的事情如下:先开始,然后设定“计划为 N 分钟”,过一会儿检查是否超时,并输出清晰的结果。
这还不是一个完整产品(世界现在还没准备好),但它是一个很好的教学积木:这里有 Add、Since、时间比较,以及清晰的输出。
数据模型:Session
先从一个简单的结构体开始。我们已经会使用结构体了,所以这里直接用它,而不是零散的变量。
package main
import "time"
type Session struct {
StartedAt time.Time
Planned time.Duration
}
StartedAt 是 time.Time,Planned 是 time.Duration。这两个字段不可能被随手搞混:类型不同,编译器不会放过。
会话截止时间:Deadline()
现在添加一个返回截止时刻的方法。这里直接使用“时刻 + 持续时间 = 时刻”这个规则。
package main
import "time"
type Session struct {
StartedAt time.Time
Planned time.Duration
}
func (s Session) Deadline() time.Time {
return s.StartedAt.Add(s.Planned)
}
这个方法很小,但非常重要:它把“我们如何计算截止时间”封装在一个地方。如果以后你想把计划时间四舍五入,或者考虑中断时间,你只需要改这里,而不是改遍整个项目。
检查“是否超时”:IsOverdue(now)
铺垫:如果把判断逻辑放进一个名字明确的小函数或方法里,比较通常会更容易读。这样主代码看起来就像一段文字。
package main
import "time"
func (s Session) IsOverdue(now time.Time) bool {
return now.After(s.Deadline())
}
我们刻意把 now 作为参数传进来,而不是在内部调用 time.Now()。这样代码更可预测,而且将来更容易测试:你可以传入任意一个“当前时刻”。
迷你 main:开始、执行、检查
现在把所有内容拼成一个简短程序。对,time.Sleep 这里只是用来模拟“时间过去了”——作为演示,这完全没问题。
package main
import (
"fmt"
"time"
)
type Session struct {
StartedAt time.Time
Planned time.Duration
}
func (s Session) Deadline() time.Time { return s.StartedAt.Add(s.Planned) }
func (s Session) IsOverdue(now time.Time) bool {
return now.After(s.Deadline())
}
func main() {
s := Session{StartedAt: time.Now(), Planned: 50 * time.Millisecond}
time.Sleep(80 * time.Millisecond)
fmt.Println("elapsed:", time.Since(s.StartedAt)) // example: elapsed: 80.2ms
fmt.Println("overdue:", s.IsOverdue(time.Now())) // overdue: true
}
这里能同时看到几件事:我们用 time.Since 来计算“过去了多久”,Add 被封装在 Deadline 里,比较则通过 After 完成,读起来非常自然。这已经是时间跟踪器的“骨架”了,后面你还可以继续加上输出格式化和参数输入。
一个小图:这些操作之间是怎么关联的
有时把类型之间的关系“看见”会更有帮助。我们用一个简单的图来固定这个概念:
flowchart LR
A[time.Time: start] -- Add(time.Duration) --> B[time.Time: deadline]
C[time.Time: now] -- Sub(start) --> D[time.Duration: elapsed]
C -- After(deadline) --> E[bool: overdue?]
在应用程序里通常就是这样工作的:有开始时间,有截止时间(start + planned),有“当前时刻”,有“过去了多久”(now - start),还有一个逻辑判断“是否超时?”。
7. 常见错误
错误 1:把持续时间写成 time.Now().Sub(time.Now())。
有时人们想把它“压成一行”,但这其实是逻辑错误:两个 time.Now() 调用得到的是两个不同的时刻,差值可能非常小、非常噪声化,甚至根本不能反映你真正想测量的内容。正确做法是先保存 start := time.Now(),只调用一次,做完事情后再用 time.Since(start) 或 time.Now().Sub(start)。
错误 2:把“时刻”和“持续时间”混为一谈,试图随意相加或比较。
如果你产生了“我想把两个时间相加”的冲动,先停一下问问自己:你要的是时刻,还是持续时间?在正确的模型里,只有持续时间可以相加;时刻只能加上持续时间:t.Add(d)。
错误 3:通过字符串或手动比较小时/分钟来比较时间。
这是后果最严重的错误之一。字符串依赖格式、本地化和时区,而手动比较很容易在跨日、跨月、跨年时出错。在 Go 中,比较时刻有 Before/After/Equal,默认就应该使用它们,因为它们表达的是意图,并且能避免“自制逻辑”的坑。
错误 4:以为 t.UTC() 会“把事件在时间上往前/往后挪”。
UTC() 和 In(loc) 改变的是显示方式,不是时刻本身。如果你把 12:00 的 UTC 转成 UTC+3,你会看到 15:00,但那仍然是同一个时刻。所以请问自己一句:我是在为人调整显示,还是在真正修改截止时间/日程?大多数情况下,你要的是前者。
错误 5:没有考虑到 time.Until(deadline) 可能是负数。
如果你写的是“剩余时间小于一分钟就提醒”,记住“剩余”可能变成“负 3 小时”。这不是 Until 的 bug,而是它诚实的回答。正确的处理方式是显式检查符号,或者比较时刻(now.After(deadline)),这样代码在截止时间之后也能保持可预测。
GO TO FULL VERSION