CodeGym /课程 /Go SELF /time.Since, Add/Sub, 时间比较, UTC vs local

time.Since, Add/Sub, 时间比较, UTC vs local

Go SELF
第 34 级, 课程 1
可用

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)),这样代码在截止时间之后也能保持可预测。

1
任务
Go SELF, 第 34 级, 课程 1
已锁定
航班时间调整
航班时间调整
1
任务
Go SELF, 第 34 级, 课程 1
已锁定
行程报告
行程报告
1
任务
Go SELF, 第 34 级, 课程 1
已锁定
加载时长测量
加载时长测量
1
任务
Go SELF, 第 34 级, 课程 1
已锁定
同一事件,不同时区
同一事件,不同时区
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION