【仓颉语言入门 · 第二篇 第 2 课】

【仓颉语言入门 · 第二篇 第 2 课】时间与日期:管好每一秒(std.time)

上一课我们用正则从日志里"挖"出了时间字符串——但它到手里还只是一串文本。"2026-10-09 08:31:02" 比大小要靠字符串比较,加一小时要手工进位,想问"这两条日志隔了几秒"更是无从下手。这一课的 std.time 就是给"时刻"和"时长"安上真正的类型:DateTime 表达一个时刻,Duration 表达一段时长——构造、加减、比较、格式化、解析、时区换算、时间戳互转,全套齐活。最后我们把上一课日志里的时间字符串真正解析成时间对象,让日志巡检工具再进化一步。

本文所有代码、输出与报错文案均在仓颉 SDK 1.2.0 下逐行实测编译运行。


目录(第二篇导航)

第一篇《仓颉语言入门》共 30 课已完结(环境搭建 → 语法 → 函数 → 类型系统 → 工程化 → 并发 → 两场实战)。第二篇在更高的起点上继续,共 17 课、4 个模块:

模块 课次 内容
一、补遗篇 01~06 正则、时间日期、命令行参数、随机数、加密哈希、二进制处理
二、数据库篇 07~10 std.database.sql 入门与 CRUD、预编译与事务、手写 DAO、综合小实战
三、进阶硬核篇 11~14 反射、宏与 deriving、C 互操作 FFI、并发容器与对象池
四、实战项目篇 15~17 HTTP 服务端、多线程日志分析器、简易爬虫
  1. 正则表达式(std.regex)
  2. 时间日期处理(本文)
  3. 命令行参数解析(std.argopt)
  4. 随机数与概率(std.random)
  5. 加密与哈希(std.crypto)
  6. 二进制数据处理(std.binary)
  7. 数据库入门:std.database.sql 与 CRUD
  8. 预编译、事务与防注入
  9. 手写 DAO 数据访问层
  10. 数据库综合小实战
  11. 反射:在运行时打量类型
  12. 宏与 deriving:让编译器帮你写代码
  13. 与 C 互操作:FFI
  14. 并发容器与对象池
  15. 实战三:HTTP 服务端
  16. 实战四:多线程日志分析器
  17. 实战五:简易爬虫

一、为什么要专门一个时间库

先看三个"不用时间库"的惨案:

  • 比时间先后:字符串比较 "2026-10-09 08:31:02" < "2026-10-09 08:31:45" 碰巧能对,但一换成 2026-9-5(没补零)就全乱。
  • 加一段时间:08:45:00 加 30 分钟,秒、分、时、日、月、年逐级进位,还要管 2 月 28 天、闰年 366 天——手写必错。
  • 算两时刻差:23:50:00 到次日 00:10:00 隔了多久?跨天、跨月、跨年,手工计算是灾难。

std.time 把这三件事全包了:DateTime 会自己处理进位和闰年,Duration 表达"一段时长",两者相减就是间隔。和 std.regex 一样,它属于核心标准库,随 SDK 安装、开箱即用,不需要 stdx。

本课的所有示例都在下面这个最普通的工程里完成:

s2probe/            ← 工程根目录(cjpm init --name s2probe)
├── cjpm.toml
├── data/
│   └── app.log     ← 第十一节实战用的数据文件
└── src/
    └── main.cj     ← 所有代码都在这里

二、两个主角:DateTime 与 Duration

std.time 的核心就是两个类型,先认清分工:

  • DateTime:一个时刻。"2026 年 10 月 10 日 8 点 30 分 15 秒"是一个时刻。
  • Duration:一段时长。"90 秒"是一段时长,它不关心从几点到几点。

时刻 ± 时长 = 新时刻;时刻 − 时刻 = 时长。这两个类型配合,就能表达绝大多数时间计算。

先 import std.time.*。但注意:Duration 其实定义在 std.core(基础库,默认可见),就算不写任何 import,Duration.second 也能直接用——实测把 import std.time.* 删掉后 Duration 的代码照样编译通过。DateTime、TimeZone 这些才真正需要 import std.time.*。


三、DateTime:表达一个时刻

3.1 拿到"现在"

import std.time.*

main(): Int64 {
    println("UTC 时间:${DateTime.nowUTC()}")
    println("本地时间:${DateTime.now(timeZone: TimeZone.Local)}")
    0
}

在我这台 UTC+8 的机器上运行(你的输出会随运行时间变化):

UTC 时间:2026-10-10T14:35:13.6185812Z
本地时间:2026-10-10T22:35:13.6186243+08:00

nowUTC() 返回世界协调时(UTC,字符串以 Z 结尾);now(timeZone: TimeZone.Local) 返回本地时区的时间(+08:00 结尾)。now 必须带命名参数 timeZone:,直接写 DateTime.now() 会编译报错:

error: missing argument prefix 'timeZone:' for named parameter

3.2 默认格式:ISO 8601

上面输出的 2026-10-10T22:35:13.6186243+08:00 就是 DateTime 的默认打印格式(ISO 8601):日期 T 时间 时区。这是国际标准格式,排序友好、机器可读,但人看着累——人看的格式化第六节用 format 解决。

3.3 构造指定时刻:DateTime.of

要表达"2026 年 10 月 10 日 8 点 30 分 15 秒",用 DateTime.of:

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println(dt)
    0
}
2026-10-10T08:30:15+08:00

这里有个和以往 API 很不一样的规矩,本课第一坑:of 的参数全是命名参数,必须写 year:、month:、dayOfMonth:……一个都不能漏写前缀,按位置传会报:

error: missing argument prefix 'year:' or 'month:' or 'dayOfMonth:' or 'hour:' or 'minute:' or 'second:' or 'nanosecond:' or 'timeZone:' for named parameter

参数清单与默认值(实测:只写前 6 个参数完全合法):

参数 类型 说明 默认值
year: Int64 年 必填
month: Int64 或 Month 月,10 或 Month.October 都行 必填
dayOfMonth: Int64 日 必填
hour: / minute: / second: Int64 时分秒 必填
nanosecond: Int64 纳秒(10⁻⁹ 秒) 0
timeZone: TimeZone 时区 本地时区

不带 timeZone: 时构造出来就是本地时区的时刻(+08:00);要 UTC 就传 timeZone: TimeZone.UTC,还有个等价的快捷构造 DateTime.ofUTC(...),两者实测输出一致(都是 Z 结尾):

import std.time.*

main(): Int64 {
    let a = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15, timeZone: TimeZone.UTC)
    let b = DateTime.ofUTC(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("a=${a}")
    println("b=${b}")
    0
}
a=2026-10-10T08:30:15Z
b=2026-10-10T08:30:15Z

3.4 拆开看:字段属性

DateTime 的字段属性一 table 打尽(用 2026-10-10 08:30:15.123456789 +08:00 这个时刻实测):

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15, nanosecond: 123456789)
    println("year=${dt.year}")
    println("month=${dt.month}")
    println("dayOfMonth=${dt.dayOfMonth}")
    println("dayOfWeek=${dt.dayOfWeek}")
    println("dayOfYear=${dt.dayOfYear}")
    println("hour=${dt.hour} minute=${dt.minute} second=${dt.second} nanosecond=${dt.nanosecond}")
    println("zoneId=${dt.zoneId}")
    println("zoneOffset=${dt.zoneOffset}")
    0
}
year=2026
month=October
dayOfMonth=10
dayOfWeek=Saturday
dayOfYear=283
hour=8 minute=30 second=15 nanosecond=123456789
zoneId=UTC+08:00
zoneOffset=8h

三个要点:

  • month 返回 Month 枚举(打印英文全名 October)、dayOfWeek 返回 DayOfWeek 枚举(Saturday)、dayOfYear 是年内的第几天(2026-10-10 是第 283 天)。
  • zoneId 是时区 ID 字符串,zoneOffset 是相对 UTC 的偏移量——类型是 Duration,所以打印成 8h(Duration 的格式,下一节讲)。
  • 还有三个元组属性:isoWeek(年 + ISO 周数)、date(年、月、日)、time(时、分、秒)。元组不能直接塞进字符串插值,要先解构:
import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    let (y, m, d) = dt.date
    let (h, mi, s) = dt.time
    println("date=(${y}, ${m}, ${d})")
    println("time=(${h}, ${mi}, ${s})")
    let (iw, w) = dt.isoWeek
    println("isoWeek=第 ${iw} 年第 ${w} 周")
    0
}
date=(2026, October, 10)
time=(8, 30, 15)
isoWeek=第 2026 年第 41 周

小提示:直接写 "${dt.date}" 编译就过不了,报 the type 'Tuple<Int64, Enum-Month, Int64>' should implement interface 'ToString' in the 'core' package——元组没实现 ToString。解构(如上)或用下标 dt.date[0] 都行。

另外老资料里还能见到 monthValue 属性(返回 1~12 的数字),它已标记废弃,编译会有 warning,用 month 配合第十四节的 Month 枚举方法即可。


四、Duration:表达一段时长

4.1 常量与打印格式

Duration 提供了一组现成常量,打印格式是"数字 + 单位缩写":

main(): Int64 {
    println("Zero=${Duration.Zero}")
    println("nanosecond=${Duration.nanosecond}")
    println("microsecond=${Duration.microsecond}")
    println("millisecond=${Duration.millisecond}")
    println("second=${Duration.second}")
    println("minute=${Duration.minute}")
    println("hour=${Duration.hour}")
    println("day=${Duration.day}")
    0
}
Zero=0s
nanosecond=1ns
microsecond=1us
millisecond=1ms
second=1s
minute=1m
hour=1h
day=1d

单位从纳秒(ns)到天(d)共八档。注意这个 main 连 import 都没写——Duration 在 std.core,天生可见。

4.2 拼出任意时长:常量组合运算

Duration 的构造没有"指定秒数"的普通方法,标准手法是常量 + 运算组合:

main(): Int64 {
    let d1 = Duration.minute * 2 + Duration.second * 30
    println("d1=${d1}")
    let d2 = Duration.hour + Duration.millisecond * 500
    println("d2=${d2}")
    let d3 = Duration.day * 3
    println("d3=${d3}")
    0
}
d1=2m30s
d2=1h500ms
d3=3d

打印时自动挑合适的单位档位拼接:2m30s、1h500ms。负时长也支持(d = Duration.Zero - Duration.second 打印 -1s)——但注意一个实测的坑:Duration 不支持一元负号,写 -Duration.second 直接编译报错:

error: invalid unary operator '-' on type 'Struct-Duration'
  # note: you may want to implement 'operator func -()' for type 'Struct-Duration'

要表达负时长,用 Duration.Zero - x 或者乘 -1(Duration.second * -1,实测输出 -1s)。

4.3 四则运算

main(): Int64 {
    let a = Duration.minute + Duration.second * 30      // 加
    println("a=${a}")
    let b = Duration.minute - Duration.second * 90      // 减(出负数)
    println("b=${b}")
    let c = Duration.hour * 3                            // 乘整数
    println("c=${c}")
    let d = Duration.day / 8                             // 除整数
    println("d=${d}")
    let e = Duration.day / 2.5                           // 除小数
    println("e=${e}")
    0
}
a=1m30s
b=-30s
c=3h
d=3h
e=9h36m

乘除都行:乘 Int64、除 Int64 或 Float64。Duration.day / 2.5 = 9.6h,打印成 9h36m(打印按档位取整显示,数值本身不失精度)。

4.4 换单位与比较

一组 toXxx() 方法把时长换算成指定单位(向下截断成整数):

main(): Int64 {
    let d = Duration.hour * 25 + Duration.second * 61 + Duration.nanosecond * 500000000
    println("d=${d}")
    println("toDays=${d.toDays()}")
    println("toHours=${d.toHours()}")
    println("toMinutes=${d.toMinutes()}")
    println("toSeconds=${d.toSeconds()}")
    println("toMilliseconds=${d.toMilliseconds()}")
    println("toNanoseconds=${d.toNanoseconds()}")
    0
}
d=1d1h1m1s500ms
toDays=1
toHours=25
toMinutes=1501
toSeconds=90061
toMilliseconds=90061500
toNanoseconds=90061500000000

toSeconds 把 1 天 1 小时 1 分 1.5 秒折成 90061 秒(截掉半秒);toMilliseconds 折到毫秒级 90061500。比较运算和数学里一样:

main(): Int64 {
    println("相等:${Duration.minute == Duration.second * 60}")
    println("小于:${Duration.second < Duration.minute}")
    println("compare:${Duration.minute.compare(Duration.second * 60)}")
    println("绝对值:${(Duration.minute - Duration.second * 90).abs()}")
    0
}
相等:true
小于:true
compare:Ordering.EQ
绝对值:30s

compare 返回 Ordering 枚举(LT/EQ/GT,第 18 课的老朋友);abs() 取绝对值。


五、时间计算:加加减减才见真章

5.1 add 系列:按"日历单位"推进

DateTime 提供八个 addXxx 方法,按年/月/周/日/时/分/秒/纳秒推进:

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("加 3 天:${dt.addDays(3)}")
    println("加 2 月:${dt.addMonths(2)}")
    println("加 1 年:${dt.addYears(1)}")
    println("加 2 周:${dt.addWeeks(2)}")
    println("加 5 时:${dt.addHours(5)}")
    println("加 45 分:${dt.addMinutes(45)}")
    println("加 50 秒:${dt.addSeconds(50)}")
    println("加 1.5 秒(纳秒):${dt.addNanoseconds(1500000000)}")
    println("链式:${dt.addDays(1).addHours(2).addMinutes(3)}")
    0
}
加 3 天:2026-10-13T08:30:15+08:00
加 2 月:2026-12-10T08:30:15+08:00
加 1 年:2027-10-10T08:30:15+08:00
加 2 周:2026-10-24T08:30:15+08:00
加 5 时:2026-10-10T13:30:15+08:00
加 45 分:2026-10-10T09:15:15+08:00
加 50 秒:2026-10-10T08:31:05+08:00
加 1.5 秒(纳秒):2026-10-10T08:30:16.5+08:00
链式:2026-10-11T10:33:15+08:00

两个观察:

  • add 系列的参数是普通位置参数(addDays(3)),和 of 的命名参数风格不同——这不是笔误,是实测结论,两套 API 各有各的规矩。
  • addNanoseconds(1500000000) 就是 1.5 秒,打印成 08:30:16.5——默认格式自动省略纳秒尾零(123456789 纳秒打 .123456789,500000000 纳秒打 .5)。

add 系列默认接收负数就是回退:dt.addDays(-1) 等价"减一天"。

还有一个必须知道的月末陷阱:1 月 31 日加 1 个月是几号?2 月没有 31 号,实测它截断到当月最后一天:

import std.time.*

main(): Int64 {
    let jan31 = DateTime.of(year: 2026, month: 1, dayOfMonth: 31)
    println(jan31.addMonths(1))
    0
}
2026-02-28T00:00:00+08:00

2026 年不是闰年,2 月只有 28 天,结果落在 2-28。做"会员到期日""账单日"这类功能时,这个行为要想清楚是不是你要的。

5.2 与 Duration 混合运算

DateTime 和 Duration 可以直接加减——"时刻 + 时长 = 新时刻":

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("开会后 2 小时:${dt + Duration.hour * 2}")
    println("提前 90 分钟:${dt - Duration.minute * 90}")
    0
}
开会后 2 小时:2026-10-10T10:30:15+08:00
提前 90 分钟:2026-10-10T07:00:15+08:00

5.3 两时刻相减 = Duration;比大小直接用运算符

DateTime 减 DateTime 得到 Duration,比较运算符 <、>、== 全部可用:

import std.time.*

main(): Int64 {
    let a = DateTime.of(year: 2026, month: 10, dayOfMonth: 9, hour: 8, minute: 0, second: 0)
    let b = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("a 在 b 之前:${a < b}")
    println("间隔:${b - a}")
    println("间隔秒数:${(b - a).toSeconds()}")
    0
}
a 在 b 之前:true
间隔:1d30m15s
间隔秒数:88215

== 比的是"是不是同一瞬间",与时区无关:把一个时刻用第八节的 inUTC() 换到 UTC 再比,实测仍然相等(它们的 Unix 时间戳一致)。


六、格式化输出:format 与格式符全表

给"机器看的"是默认 ISO 格式,给"人看的"要用 format。它接收一个模式串,模式串里混合"格式符"和"普通字符":

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15, nanosecond: 123456789)
    println("${dt.format("yyyy-MM-dd HH:mm:ss")}")
    println("${dt.format("yyyy/MM/dd HH:mm:ss.SSS")}")
    println("${dt.format("hh:mm a")}")
    println("${dt.format("yyyy年MM月dd日 HH时mm分")}")
    0
}
2026-10-10 08:30:15
2026/10/10 08:30:15.123456789
08:30 AM
2026年10月10日 08时30分

-、/、空格、甚至中文,凡不是格式符的字符原样输出。格式符全表(对 2026-10-10 08:30:15.123456789 +08:00、星期六实测):

格式符 输出 说明
yyyy / y 2026 4 位年
yy 26 2 位年
M / MM 10 月(MM 补零到 2 位)
MMM Oct 英文月缩写
MMMM October 英文月全名
d / dd 10 日
H 8 时(24 小时制,不补零)
HH 08 时(24 小时制,补零)
h / hh 8 / 08 时(12 小时制)
m / mm 30 分
s / ss 15 秒
S 123 毫秒(固定 3 位)
SS 123456 微秒(固定 6 位)
SSS 123456789 纳秒(固定 9 位)
a AM 上午/下午
D / DD 283 年内第几天
w / ww 6 / 06 星期几(周日=0,周六=6)
www Sat 英文星期缩写
wwww Saturday 英文星期全名
O +08 时区偏移(小时)
OO +08:00 时区偏移(时:分)
OOO +08:00:00 时区偏移(时:分:秒)
OOOO +08:00 同 OO

四个实测的"反直觉"点,写进你的备忘录:

  1. E 不是星期格式符(Java 背景的同学最容易中招)。写 "E" 原样输出字母 E。要星期几用 w/www/wwww,或者拿 dayOfWeek 属性自己转中文(见 6.2)。
  2. S/SS/SSS 不是"不足补零的任意位小数",而是固定毫秒/微秒/纳秒。写 4 个 S 直接抛异常:The number of character "S" can not exceed 3.
  3. 同理 D 最多 2 个、w 最多 4 个,超了分别报 The number of character "D" can not exceed 2. 和 The number of character "w" can not exceed 4.
  4. X、x 也不是时区格式符,原样输出。

6.1 内置的四个预设

DateTimeFormat 有四个 RFC 标准格式的字符串常量(注意:它们本身就是 String,直接传给 format 即可):

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("RFC3339=${dt.format(DateTimeFormat.RFC3339)}")
    println("RFC1123=${dt.format(DateTimeFormat.RFC1123)}")
    println("RFC822=${dt.format(DateTimeFormat.RFC822)}")
    println("RFC850=${dt.format(DateTimeFormat.RFC850)}")
    0
}
RFC3339=2026-10-10T08:30:15+08:00
RFC1123=Sat, 10 Oct 2026 08:30:15 UTC+08:00
RFC822=06 10 Oct 26 08:30:15 UTC+08:00
RFC850=Saturday, 10-Oct-26 08:30:15 UTC+08:00

RFC3339 就是 HTTP 头、JSON 里最常见的那种时间格式。RFC822 输出开头的 06 是 ww(星期六=6),不是日期——这是 RFC822 模式串 ww dd MMM yy ... 的原样行为,别被它吓到。

6.2 实战套路:输出中文星期

格式符只出英文星期,要"星期六"三个字,自己用 dayOfWeek 映射:

import std.time.*

func weekCn(d: DayOfWeek): String {
    match (d) {
        case Sunday => "日"
        case Monday => "一"
        case Tuesday => "二"
        case Wednesday => "三"
        case Thursday => "四"
        case Friday => "五"
        case Saturday => "六"
    }
}

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("2026-10-10 是星期${weekCn(dt.dayOfWeek)}")
    0
}
2026-10-10 是星期六

七、解析:从字符串到 DateTime

格式化的逆过程。parse 有两种用法,各自的规矩都不少。

7.1 默认格式解析:必须带时区

DateTime.parse(字符串) 只认 ISO 8601 格式,而且时区部分必须写:

import std.time.*

func tryP(s: String): Unit {
    try {
        println("解析成功:${DateTime.parse(s)}")
    } catch (e: Exception) {
        println("解析失败:${e.message}")
    }
}

main(): Int64 {
    tryP("2026-10-10T08:30:15+08:00")
    tryP("2026-10-10T00:30:15Z")
    tryP("2026-10-10T08:30:15")
    tryP("2026-10-10 08:30:15")
    0
}
解析成功:2026-10-10T08:30:15+08:00
解析成功:2026-10-10T00:30:15Z
解析失败:Illegal zone offset value.
解析失败:Fail to parse at index 10: " " should be "T".
  • 带偏移 +08:00 或带 Z 都能解析;不带时区的 "2026-10-10T08:30:15" 抛 Illegal zone offset value.——这是很多人第一次用 parse 翻车的地方。
  • 日期和时间之间必须是 T,写空格报 Fail to parse at index 10: " " should be "T".(index 10 直接告诉你在第几个字符翻的车)。

那"不带时区"的串怎么解?用模式串。

7.2 模式串解析:自己声明格式

parse 的第二个重载接收模式串,格式符与第六节的表格完全一致:

import std.time.*

main(): Int64 {
    let dt = DateTime.parse("2026/10/10 08:30", "yyyy/MM/dd HH:mm")
    println(dt)
    println("格式化回去:${dt.format("yyyy年MM月dd日 HH时mm分")}")
    0
}
2026-10-10T08:30:00+08:00
格式化回去:2026年10月10日 08时30分

没写的字段补零值(时分秒补 0),时区默认本地。日志里最常见的 "2026-10-09 08:31:02"(中间是空格、无时区)就得走这条路:DateTime.parse(t, "yyyy-MM-dd HH:mm:ss")——第十一节实战就这么干。

解析失败抛的是 TimeParseException(Exception 的子类),try-catch 住即可;另一个实测文案给留个印象——串里第一个字符就不是数字时报:

Fail to parse at index 0: "垃" should be integer.

7.3 tryParse:不想抛异常就用它

tryParse 是 parse 的温和版:成功返回 Some(dt),失败返回 None,绝不抛异常:

import std.time.*

func show(s: String): Unit {
    match (DateTime.tryParse(s)) {
        case Some(dt) => println("[${s}] -> ${dt}")
        case None => println("[${s}] -> 不是合法时间")
    }
}

main(): Int64 {
    show("2026-10-10T08:30:15+08:00")
    show("2026-10-10T08:30:15")
    show("明天早上八点")
    0
}
[2026-10-10T08:30:15+08:00] -> 2026-10-10T08:30:15+08:00
[2026-10-10T08:30:15] -> 不是合法时间
[明天早上八点] -> 不是合法时间

注意第二条:tryParse 与 parse 的默认格式同样要求时区,不带时区照样失败。来源不可信的字符串(用户输入、外部文件)优先 tryParse;格式明确的(自家日志)直接 parse + 模式串。


八、时区:TimeZone

8.1 两个常量与三个转换

TimeZone.UTC 和 TimeZone.Local 覆盖绝大多数需求,DateTime 上有三个转换方法:

import std.time.*

main(): Int64 {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("原(本地):${dt}")
    println("转 UTC:${dt.inUTC()}")
    println("转本地:${dt.inLocal()}")
    let tz9 = TimeZone("UTC+9", Duration.hour * 9)
    println("转东 9 区:${dt.inTimeZone(tz9)}")
    0
}
原(本地):2026-10-10T08:30:15+08:00
转 UTC:2026-10-10T00:30:15Z
转本地:2026-10-10T08:30:15+08:00
转东 9 区:2026-10-10T09:30:15+09:00

看清语义:转换不改瞬间,只改显示。8:30(北京)和 0:30(UTC)是同一瞬间——这在 5.3 节验证过 == 相等。

8.2 自建时区:固定偏移构造

TimeZone("UTC+9", Duration.hour * 9) 的第一个参数是自定义 ID(随便起,打印 zoneId 时用),第二个参数是与 UTC 的偏移。Windows 上想表达"北京时间",这是标准写法——因为按名字加载时区的 TimeZone.load("Asia/Shanghai") 在 Windows 上实测直接抛异常:

No valid timezone file is found.

Windows 没有随 SDK 附带 IANA 时区数据库文件,load 无从加载。记住这个替代写法:

import std.time.*

main(): Int64 {
    let bj = TimeZone("UTC+8", Duration.hour * 8)
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    println("北京时间:${dt.inTimeZone(bj)}")
    println("时区 ID:${bj.id}")
    0
}
北京时间:2026-10-10T08:30:15+08:00
时区 ID:UTC+8

九、Unix 时间戳:机器间交换时间的通用语

Unix 时间戳 = 从 1970-01-01 00:00:00 UTC 起经过的秒数。数据库、接口、日志系统之间传时间,十有八九传的就是它。

import std.time.*

main(): Int64 {
    // 时刻 -> 时间戳
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 8, minute: 30, second: 15)
    let ts = dt.toUnixTimeStamp()
    println("时间戳(Duration):${ts}")
    println("时间戳(秒):${ts.toSeconds()}")

    // 时间戳 -> 时刻
    let back = DateTime.fromUnixTimeStamp(ts)
    println("转回来:${back}")
    println("往返一致:${back == dt}")

    // 起点
    println("纪元:${DateTime.UnixEpoch}")
    println("ofEpoch(0, 0):${DateTime.ofEpoch(second: 0, nanosecond: 0)}")
    0
}
时间戳(Duration):20736d30m15s
时间戳(秒):1791592215
转回来:2026-10-10T00:30:15Z
往返一致:true
纪元:1970-01-01T00:00:00Z
ofEpoch(0, 0):1970-01-01T00:00:00Z

要点:

  • toUnixTimeStamp() 返回 Duration(距纪元的时长),要整数秒就链一个 .toSeconds()。打印 20736d30m15s 是 Duration 的显示格式(20736 天 + 30 分 + 15 秒),别被它迷惑,数值是对的。
  • fromUnixTimeStamp(Duration) 逆向转换。注意转回来打印的是 UTC(Z)——时间戳本身不带时区,fromUnixTimeStamp 统一按 UTC 表达。
  • DateTime.ofEpoch(second:, nanosecond:)(命名参数)从秒 + 纳秒构造,负数也合法:ofEpoch(second: -86400, nanosecond: 0) 得到 1969-12-31T00:00:00Z,1970 年前的时刻同样能表达。
  • DateTime.UnixEpoch 就是纪元时刻本身。

十、MonoTime:给代码掐表

想知道"这段代码跑了多久",用 MonoTime——单调时钟,只进不退,不受系统改时间的影响,是测耗时的正确工具:

import std.time.*

main(): Int64 {
    let start = MonoTime.now()
    var sum = 0
    for (i in 0..1000000) {
        sum += i
    }
    let elapsed = MonoTime.now() - start
    println("sum=${sum}")
    println("耗时:${elapsed}")
    println("耗时(毫秒):${elapsed.toMilliseconds()}")
    0
}

我机器上的一次运行(你的数值会不同):

sum=499999500000
耗时:2ms429us200ns
耗时(毫秒):2

MonoTime.now() - MonoTime.now() 直接得到 Duration,配合第四节的 toXxx() 想看什么单位都行。<、> 比较也支持(时间越往后值越大)。

小提示:为什么不用 DateTime.now() 相减测耗时?因为系统时间可能被 NTP 校准、被手动调整,两次取值之间时钟"倒退"就会算出负数;MonoTime 的读数单调递增,专门为"测间隔"设计。


十一、CIDE 实操:日志巡检工具再进化

兑现上一课的预告:把日志里的时间字符串真正解析成时间对象。在 S2-01 的日志巡检工具上加三个新能力:

  1. 把每条日志的时间串 parse 成 DateTime;
  2. 打印每条日志距上一条的间隔秒数(时刻相减);
  3. 统计整批日志的时间跨度。

11.1 数据文件

沿用上一课的 data/app.log(六行服务日志):

2026-10-09 08:30:15 [INFO] auth - 用户 user01 从 192.168.1.23 登录
2026-10-09 08:31:02 [ERROR] db - 连接超时: 192.168.1.10:5432
2026-10-09 08:31:45 [WARN] auth - 密码重试过多: user07
2026-10-09 08:32:20 [INFO] api - 请求 /api/orders 耗时 42ms
2026-10-09 08:33:05 [ERROR] api - 调用支付网关失败: 10.0.0.8
2026-10-09 08:34:10 [INFO] auth - 用户 13812345678 修改了手机号 13998887766

11.2 完整代码

src/main.cj 全文:

package s2probe

import std.time.*
import std.regex.*
import std.fs.*
import std.unicode.*

main(): Int64 {
    // 1. 读入日志文本(Windows 文件是 \r\n 换行,逐行后 trim 掉行尾的 \r)
    let raw = String.fromUtf8(File.readFrom(Path("data/app.log")))
    let lines = raw.split("\n")
        .map({ l: String => l.trim() })
        .filter({ l: String => l != "" })
        .toArray()

    // 2. 逐行抽时间串,解析成 DateTime
    let lineRe = Regex("^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}) \\[(\\w+)\\] (\\w+) - (.+)$")
    var prev: Option<DateTime> = None
    var first: Option<DateTime> = None
    var last: Option<DateTime> = None

    for (line in lines) {
        match (lineRe.find(line, group: true)) {
            case Some(m) =>
                let tStr = m.matchString(1)
                let level = m.matchString(2)
                let dt = DateTime.parse(tStr, "yyyy-MM-dd HH:mm:ss")
                match (prev) {
                    case Some(p) =>
                        let gap = dt - p
                        println("${dt.format("HH:mm:ss")} [${level}] 距上条 ${gap.toSeconds()} 秒")
                    case None =>
                        println("${dt.format("HH:mm:ss")} [${level}] 第一条")
                }
                if (first.isNone()) { first = Some(dt) }
                last = Some(dt)
                prev = Some(dt)
            case None => println("格式不认识的行:${line}")
        }
    }

    // 3. 时间跨度
    match ((first, last)) {
        case (Some(f), Some(l)) =>
            println("时间跨度:${(l - f).toSeconds()} 秒")
        case _ => println("没有可解析的日志")
    }

    0
}

11.3 逐段看懂

读文件与拆行是第 22 课和 S2-01 的老三样:File.readFrom → String.fromUtf8 → 按 \n 拆行 → trim 去掉行尾 \r。链式 lambda 的参数类型标注 { l: String => ... } 不能省(S2-01 实战踩过的坑)。

正则 + parse 的接力是本课的核心:lineRe 的 1 号捕获组把 2026-10-09 08:31:02 原样取出,交给 DateTime.parse(tStr, "yyyy-MM-dd HH:mm:ss")。为什么必须用模式串? 回看 7.1:默认格式要求 T 分隔 + 必带时区,日志串两样都不满足,直接 parse 必抛 TimeParseException。

var prev: Option<DateTime> 记住上一条的时间——第一条没有"上条",用 Option 表达"可能还没有",正是第 9 课 Option 的本职。dt - p 时刻相减得 Duration,.toSeconds() 拿到整数秒。

元组模式匹配 match ((first, last)) 把两个 Option 一次性解构——case (Some(f), Some(l)) 同时命中"首尾都有值"才算出跨度。这比嵌套两层 match 简洁得多。

11.4 运行与验证

08:30:15 [INFO] 第一条
08:31:02 [ERROR] 距上条 47 秒
08:31:45 [WARN] 距上条 43 秒
08:32:20 [INFO] 距上条 35 秒
08:33:05 [ERROR] 距上条 45 秒
08:34:10 [INFO] 距上条 65 秒
时间跨度:235 秒

上一课我们从日志里"看见"了时间,这一课时间真正"可计算"了:间隔、跨度都是一行表达式的事。把这套能力记熟,第 16 课的多线程日志分析器里还会再用。


十二、常用 API 速查

DateTime(需 import std.time.*)

类别 API 说明
当前 nowUTC() / now(timeZone:) UTC 时刻 / 指定时区时刻(timeZone: 是命名参数)
构造 of(year:, month:, dayOfMonth:, hour:, minute:, second:[, nanosecond:][, timeZone:]) 全命名参数;month 收 Int64 或 Month;纳秒默认 0、时区默认本地
构造 ofUTC(year:, ...) / ofEpoch(second:, nanosecond:) UTC 版 / 时间戳版(支持负数)
时间戳 fromUnixTimeStamp(Duration) / toUnixTimeStamp(): Duration / UnixEpoch 互转与纪元
字段 year / month / dayOfMonth / dayOfWeek / dayOfYear / hour / minute / second / nanosecond / zoneId / zoneOffset 单值属性
字段 date / time / isoWeek 元组属性(不可直接插值,先解构)
推进 addYears / addMonths / addWeeks / addDays / addHours / addMinutes / addSeconds / addNanoseconds(n) 位置参数;负数即回退;月末截断
运算 dt + Duration、dt - Duration、dt1 - dt2、< > ==、compare 与 Duration 混算、差值、比较
转时区 inUTC() / inLocal() / inTimeZone(tz) 只改显示不改瞬间
格式化 format(模式串) 预设 DateTimeFormat.RFC3339 等也是 String,直接传
解析 parse(串) / parse(串, 模式串) / tryParse(串): Option<DateTime> 默认格式必须带时区;失败抛 TimeParseException

Duration(std.core,无需 import)

类别 API 说明
常量 Zero / nanosecond / microsecond / millisecond / second / minute / hour / day 打印 0s 1ns ... 1d
构造 常量组合:Duration.minute * 2 + Duration.second * 30 1.2.0 的 Duration.of 不可从用户代码调用,别用
运算 + -(两个 Duration)、* Int64、/ Int64、/ Float64 一元负号不支持;负值用 Zero - x 或 * -1
换算 toDays / toHours / toMinutes / toSeconds / toMilliseconds / toMicroseconds / toNanoseconds 向下截断成整数
比较 == < > <= >=、compare、abs()

TimeZone 与 MonoTime

API 说明
TimeZone.UTC / TimeZone.Local 两个常量
TimeZone(id, Duration) 固定偏移构造(Windows 表达"北京时间"的标准写法)
TimeZone.load(id) 按名加载;Windows 上实测抛 No valid timezone file is found.
MonoTime.now() 单调时钟取值;相减得 Duration,测耗时专用

十三、常见问题 FAQ

Q1:DateTime.of 一堆参数到底哪个能省?
只有 nanosecond:(默认 0)和 timeZone:(默认本地)可省,前六个(年月日时分秒)必填且必须带参数名。位置传参报 missing argument prefix 'year:' ...。

Q2:DateTime.now() 为什么不能无参调用?
报 missing argument prefix 'timeZone:' for named parameter。库的设计意图是逼你显式表态"要哪个时区的时间":要 UTC 用 nowUTC(),要本地用 now(timeZone: TimeZone.Local)。

Q3:Duration.of(...) 为什么编译不过?
1.2.0 里它不可从用户代码调用(no matching function declaration for function call 'of')。用常量组合代替:Duration.of(sec: 90, ns: 5) 的意图写成 Duration.minute + Duration.second * 30 + Duration.nanosecond * 5。

Q4:-Duration.second 报错,负时长怎么表达?
Duration 没实现一元负号(invalid unary operator '-')。用 Duration.Zero - Duration.second 或 Duration.second * -1。

Q5:parse("2026-10-10T08:30:15") 抛 Illegal zone offset value.?
默认格式必须带时区:+08:00 或 Z。不带时区的串用模式串解析:DateTime.parse("2026-10-10 08:30:15", "yyyy-MM-dd HH:mm:ss")。

Q6:格式符 "E" 怎么原样输出了?
E 不是本库的星期格式符(Java 老习惯别带过来)。星期用 w(数字,周日=0)、www(缩写)、wwww(全名),中文星期见 6.2 的映射写法。

Q7:TimeZone.load("Asia/Shanghai") 抛异常?
Windows 平台没有时区数据库文件,load 实测抛 No valid timezone file is found.。用固定偏移构造:TimeZone("UTC+8", Duration.hour * 8)。

Q8:"${dt.date}" 编译报错?
元组属性没实现 ToString(报 should implement interface 'ToString')。解构 let (y, m, d) = dt.date 后再拼,或用下标 dt.date[0]。


十四、课后练习

练习原则照旧:每题只考本课知识点,题面给全提示,先想清楚 API 再动手。前 4 题必做,第 5 题选做。

1.(必做)构造与格式化。 用 DateTime.of 构造"2026 年 10 月 1 日 9 点 0 分 0 秒",用 format 打印成 2026年10月01日 09:00:00。提示:模式串 yyyy年MM月dd日 HH:mm:ss(dd/HH 补零)。

import std.time.*

func ex1(): Unit {
    let dt = DateTime.of(year: 2026, month: 10, dayOfMonth: 1, hour: 9, minute: 0, second: 0)
    println(dt.format("yyyy年MM月dd日 HH:mm:ss"))
}

期望输出:

2026年10月01日 09:00:00

2.(必做)月末陷阱观察。 构造 2026-01-31,打印 addMonths(1) 与 addMonths(2) 的结果(println 默认格式即可),并打印 2 月 28 日是星期几。提示:dayOfWeek 返回 DayOfWeek 枚举,直接插值打印英文名。

import std.time.*

func ex2(): Unit {
    let d = DateTime.of(year: 2026, month: 1, dayOfMonth: 31)
    println(d.addMonths(1))
    println(d.addMonths(2))
    println(d.addMonths(1).dayOfWeek)
}

期望输出:

2026-02-28T00:00:00+08:00
2026-03-31T00:00:00+08:00
Saturday

3.(必做)模式串解析与时间戳。 用模式串解析 "2026/12/31 23:59:59",打印解析结果与它的 Unix 时间戳整数秒。提示:DateTime.parse(s, "yyyy/MM/dd HH:mm:ss");toUnixTimeStamp() 返回 Duration,链 .toSeconds()。

import std.time.*

func ex3(): Unit {
    let dt = DateTime.parse("2026/12/31 23:59:59", "yyyy/MM/dd HH:mm:ss")
    println(dt)
    println(dt.toUnixTimeStamp().toSeconds())
}

期望输出:

2026-12-31T23:59:59+08:00
1798732799

4.(必做)MonoTime 掐表。 用 MonoTime 测量"对 0..500000 求和"这段循环的耗时,打印毫秒数(toMilliseconds())。提示:let start = MonoTime.now(),循环 var sum = 0; for (i in 0..500000) { sum += i },MonoTime.now() - start 得 Duration。运行数值因机器而异,打印格式为纯数字即可。

import std.time.*

func ex4(): Unit {
    let start = MonoTime.now()
    var sum = 0
    for (i in 0..500000) {
        sum += i
    }
    let elapsed = MonoTime.now() - start
    println("sum=${sum}")
    println("耗时毫秒:${elapsed.toMilliseconds()}")
}

期望输出(耗时数值因机器而异):

sum=124999750000
耗时毫秒:1

5.(选做)电影散场时刻。 一部电影时长 2 小时 15 分 30 秒,20:45:00 开场。用 Duration 表达片长(常量组合),时刻加时长得到散场时刻,按 HH:mm:ss 格式化打印。提示:Duration.hour * 2 + Duration.minute * 15 + Duration.second * 30;dt + 片长。

import std.time.*

func ex5(): Unit {
    let begin = DateTime.of(year: 2026, month: 10, dayOfMonth: 10, hour: 20, minute: 45, second: 0)
    let movie = Duration.hour * 2 + Duration.minute * 15 + Duration.second * 30
    println("散场:${(begin + movie).format("HH:mm:ss")}")
}

期望输出:

散场:23:00:30

下节预告

我们的程序一直是"哑巴"——所有输入要么写死在代码里,要么靠改文件。真实世界的工具第一件事就是听懂命令行:tool.exe -i input.log -o out.txt --verbose 这种参数串怎么优雅地接住?下一课 命令行参数解析(std.argopt) 带你把"短选项、长选项、带值选项、位置参数"一次理清,顺手给本课的日志工具装上"指定日志路径"的命令行入口。


💬 遇到问题?扫码联系作者

跟着课程练习时,如果在 SDK 安装、环境变量配置、编译报错或调试上卡住,欢迎扫码加作者企业微信直接咨询(请备注"仓颉课程"):

离线环境下图片可能加载不出来,也可以在 CIDE 菜单 Help ▸ 联系作者 / Contact 中查看同一张二维码(应用内置兜底图,无需联网)。


📥 工具下载

本系列全程使用的仓颉 IDE —— CIDE(免费开源、社区版):

  • GitCode 仓库 / 安装包下载:https://gitcode.com/wp_upala/cide
  • 打开页面后进入 发行版(Releases),两种包任选其一:
    • 安装版:下载 CIDE-<版本>-x64-Setup.exe,双击安装,适合日常长期使用;
    • 免安装版(Portable):下载 CIDE-<版本>-x64-Portable.zip,解压到任意目录即用,不写注册表、不留安装痕迹,拷到 U 盘也能在别的电脑直接运行(包内附《使用说明.txt》)。适合先试用、或在受限电脑上学习本系列课程。
  • 仓颉 SDK 请前往仓颉编程语言官网下载:https://cangjie-lang.cn
posted @ 2026-10-11 00:35  upala  阅读(13)  评论(0)    收藏  举报