"日期和时间是我们教给小孩子的东西。能有多难呢?"
许多程序员都犯过这个错误,而随之而来的经历往往对他们的健康和幸福产生不良影响。
任何用日期和时间做正经编程的人,至少都应该准备好理解并减轻潜在的问题。
本文档会尝试概述 R 如何处理日期和时间,但更多细节仍需查阅完整文档。
作为一门数据科学语言,日期和时间在 R 中格外重要。 大部分数据都以时间序列的形式出现:疾病暴发期间每日的患者人数、政府报告的月度就业数据、筑巢群落的鸟类数量、主要经济体的年度 GDP,等等,例子数不胜数。
这是否意味着 R 让日期和时间的处理变得简单了?
很遗憾,并不是!
Base R 中的日期和时间功能名声不佳,如今大部分已很少使用。
lubridate 包是一大改进,但要做好准备:它是一个庞大而复杂的库,为的是处理同样庞大而复杂的一系列问题。
lubridate 原本是独立的,如今已随 tidyverse 合集一同打包。
用 library(lubridate) 或 library(tidyverse) 加载它。
以下资源可以帮助你使用 lubridate:
date 就是一天,不包含时间信息。
在内部,日期存储为自 1970-01-01 以来的(带符号的)天数。
today()
#> [1] "2026-04-22"
datetime 结合了日期、时间和时区信息。
在内部,它存储为自 1970 年初起、以 UTC 时区计的秒数。
now()
#> [1] "2026-04-22 15:37:11 MST" # mid-afternoon in Arizona
也许出乎意料的是,R 对仅有时间的值没有内部表示。
date 和 datetime 值最常见的日期/时间需求大概是:
today())和/或日期时间(用 now())。书写日期和时间的方式有很多,而且往往带有文化差异。 像 “7/6/23” 这样全用数字的日期存在歧义,容易让人困惑,还曾在跨国公司里造成许多代价高昂的错误(作者说这话时可谓满是辛酸)。
国际标准(y-m-d)定义在 ISO 8601 中,有两大优势:
然而,数据科学的核心就是处理你能拿到的任何数据,抱怨别人怎么格式化往往毫无意义。
Base R 有一些转换函数,它们接收一个格式字符串(默认为 ISO 8601)。
as.Date("2026-04-22")
#> [1] "2026-04-22"
as.Date("4/22/26", format = "%m/%d/%y")
#> [1] "2026-04-22"
as.POSIXct("2026-04-22 15:30:09 MST")
#> [1] "2026-04-22 15:30:09 MST"
这些格式字符串类似已有数十年历史的 strptime() 函数,它被包含在_许多_语言里。
熟悉这套语法的程序员可以选择使用 lubridate 中的 fast_strptime() 函数。
更有帮助的是,lubridate 提供了各种各样的辅助函数,其名称直接表示字段的顺序。
一旦这些函数知道要查找哪些值、按什么顺序查找,解析就变得异常灵活。
mdy("4/22/26")
#> [1] "2026-04-22"
mdy("April 22nd 26")
#> [1] "2026-04-22"
mdy("Wednesday, April 22nd 2026")
#> [1] "2026-04-22"
mdy_hm("Wednesday, April 22nd 2026, 4:14 pm")
#> [1] "2026-04-22 16:14:00 UTC" # defaults to your OS timezone
当你的程序把日期/日期时间的各个组成部分拆成单独的数字时,可以用 make_date() 或 make_datetime() 把它们组装起来。
以下是可以提供的组成部分,展示了它们的顺序和默认值:
make_datetime(
year = 1970L,
month = 1L,
day = 1L,
hour = 0L,
min = 0L,
sec = 0,
tz = "UTC"
)
#> [1] "1970-01-01 UTC"
make_datetime(2026, 4, 22, 17)
#> [1] "2026-04-22 17:00:00 UTC"
当然,make_date() 只接收年、月、日这几个实参。
R 控制台会自动把日期/日期时间显示为字符串形式。 这通常很方便,但有时会误导人。
在内部,日期和日期时间是数字(外加一些属性),而不是字符串。 在需要字符串的地方返回日期(时间)会引发问题,所以我们需要知道怎么做转换。
一个简单的办法是使用 Base R 里的 as.character(),它会输出 ISO 8601 格式的字符串。
日期时间还有一个 format_ISO8601() 函数,它带有可舍入到指定精度的选项。
today() |> as.character() # a date
#> [1] "2026-04-23"
now() |> as.character() # a datetime
#> [1] "2026-04-23 09:07:09.779319"
now() |> format_ISO8601() # defaults to nearest second
#> [1] "2026-04-23T09:07:38"
很少有非程序员愿意看到 ISO 8601 格式的输出,因此处理带有文化差异的日期/时间格式至关重要。
Base R 提供了 format() 函数,它使用的格式字符串类似于许多其他语言里历史悠久的 strftime() 函数。
format(today(), "%A, %d %B %Y")
#> [1] "Thursday, 23 April 2026"
这种 Base R 的做法难看但可靠。 以下是一些比较常见的代码:
| 代码 | 结果 |
|---|---|
| %d | 月中的日(5、17、28 等) |
| %a | 星期几的缩写(Mon、Tue、Wed 等) |
| %A | 星期几全称(Monday、Tuesday 等) |
| %w | 星期几的数字(0-6,星期天为 0) |
| %u | 星期几的数字(1-7,星期一为 1) |
| %b | 月份的缩写(Jan、Feb 等) |
| %B | 月份全称(January、February 等) |
| %y | 两位年份(如 89) |
| %Y | 四位年份(如 1989) |
| %h | 小时(24 小时制) |
| %m | 分钟 |
| %s | 秒 |
| %z | 相对 UTC 的偏移 |
| %Z | 时区(字符串) |
Lubridate 力求用 stamp() 函数,以及更窄的 stamp_date() 和 stamp_time(),提供更人性化的替代方案。
它们接收一个模板字符串,作为期望输出的示例,并创建一个自定义函数来生成那种格式。
Lubridate 会尝试猜测格式,但常常遇到歧义并产生错误的结果。
要做好准备,提供格式字符串(如 "dmy")来消除歧义。
在 Exercism 上,指定 locale = "C" 也很有用,这样可以绕过系统区域设置,用英文处理(正如测试所期望的那样)。
区域设置很复杂!
sf <- stamp("Saturday, June 27, 2026 at 14:45", locale = "C")
#> Multiple formats matched: "%A, %B %d, %Y at %H:%M"(1), "Saturday, %Om %d, %Y at %H:%M"(1), "Saturday, %B %d, %Y at %H:%M"(1), "%A, %Om %d, %Y at %H:%M"(0)
#> Using: "%A, %B %d, %Y at %H:%M"
sf(make_datetime(2023, 3, 29, 15, 0, 0))
#> [1] "Wednesday, March 29, 2023 at 15:00"
Lubridate 提供了许多函数来获取或设置日期、日期时间的组成部分:既有 year() 和 second() 这类显而易见的,也有 quarter() 和 epiweek() 这类更专门的。
所有这些函数都可以用来获取值,大多数还可以用来设置值。
dt <- now()
dt
#> [1] "2026-04-23 15:47:11 MST"
# get
month(dt)
#> [1] 4
# set
month(dt) <- 11
dt
#> [1] "2026-11-23 15:47:11 MST"
对于 wday()(星期几)和 month(),默认输出是整数,但是:
labels = TRUE 会返回与当前区域设置对应的缩写字符串。abbr = FALSE 会返回全称。d <- today()
d
#> [1] "2026-04-23"
wday(d)
#> [1] 5
wday(d, label = TRUE)
#> [1] Thu
#> Levels: Sun < Mon < Tue < Wed < Thu < Fri < Sat
wday(d, label = TRUE, abbr = FALSE)
#> [1] Thursday
#> Levels: Sunday < Monday < Tuesday < Wednesday < Thursday < Friday < Saturday
这些标签是 categorical variables,将会是另一个概念的主题。
值被限制在列出的 Levels 中,这些也显示了排序顺序。
一周默认从星期天开始,但可以通过 week_start 实参更改(星期一用 1)。
dt <- make_datetime(2026, 4, 23)
# default behavior
wday(dt)
#> [1] 5
wday(dt, label = T)
#> [1] Thu
# set week_start to Monday
wday(dt, week_start = 1)
#> [1] 4
有几个实用函数返回 TRUE/FALSE 值,比如 leap_year() 和 dst()(夏令时是否生效?)。
各月长度不同,所以 days_in_month() 会给出整数值。
date 和 datetime 值都表示时间轴上的一个瞬间。
我们如何处理 “昨天的这个时间” 或 “从今天起一周后” 这样的概念?
熟悉其他科学计算语言的读者可能知道 Python 中的 datetime.timedelta 或 Julia 中的 Period 类型。
相比之下,R 表示时间跨度的方法不少于三种,各有各的优势和局限。 一如既往,选择最适合你情境的那一个。
各种跨度可以轻松互相转换,用 as.period()、as_duration() 和 as.interval()。
可以用 is.period() 等来查询跨度类型。
一个 period,比如 hours(3),可以把时钟时间增加 3 小时。
夏令时(DST)之类的不规则情况基本被忽略。
所有的 period 都是单位名称的复数形式:years、weeks 等。
d <- today()
d
#> [1] "2026-04-25"
d + weeks(2)
#> [1] "2026-05-09"
# dates are coerced to datetimes at midnight if necessary
d - hours(20)
#> [1] "2026-04-24 04:00:00 UTC"
hours(3)
#> [1] "3H 0M 0S"
一个 duration,比如 dhours(3),把物理时间增加 3 小时,会考虑到夏令时这类导致时钟时间偏离物理时间的不规则情况。
所有的 duration 都在对应的 period 前加一个 d 前缀:dyears、dweeks 等。
dhours(3)
#> [1] "10800s (~3 hours)"
一个 interval 是起始时间和结束时间的组合,可以用 interval() 函数或 %--% 中缀运算符创建。
# the next 2 lines were run about 33s apart, so now() is different
dt1 <- now()
dt2 <- now() + hours(1)
# interval(0 function)
ivl <- interval(dt1, dt2)
ivl
#> [1] 2026-04-24 16:24:54 MST--2026-04-24 17:25:27 MST
# infix operator
iv2 <- dt1 %--% dt2
iv2 == iv1
#> [1] TRUE
处理时区可能会很棘手。 下面的引文出自 Python 文档,但对 R 同样适用:
“世界各地调整时间的规则更多是政治性的而非理性的,而且经常变化,除了 UTC,没有哪一种标准适用于所有应用。”
关于时区,有几点一般性说明:
datetime 总是包含一个时区(tz);date 则不感知时区。now() 函数是个例外:它从系统时钟获取你的本地时间,包括 tz。
默认使用本地 tz,但也可以指定其他值,比如 now("UTC")。# timezone defaults to UTC
local <- ymd_hm("2026-04-25 10:32")
local
#> [1] "2026-04-25 10:32:00 UTC"
# timezone can be specified
AZ <- ymd_hm("2026-04-25 10:32", tz = "America/Phoenix")
AZ
#> [1] "2026-04-25 10:32:00 MST"
# same clocktime, different tz
local - AZ
#> Time difference of -7 hours
涉及时区的运算,用时长的比用时期的更可靠。
处理日期和日期时间,用 lubridate 通常比用 Base R 容易得多。
本概念尝试给出一个概览,但难免遗漏了很多细节。
冒着重复的风险说一句:文档是你的朋友,请务必使用它。
在这个练习中,你要为纽约一家美容院编写预约排期程序,这家美容院于 2012 年 9 月 15 日开业。
你有五个任务,它们都与预约日期有关。 时间采用 24 小时制(不使用 AM/PM)。 为了尽量简化时区问题,假设所有时间都使用 UTC。
实现schedule_appointment()函数,把预约日期的文本表示解析成对应的DateTime格式。
注意,输入的格式是美式的月/日/年顺序。
julia> schedule_appointment("07/25/2023 13:45:00")
2023-07-25T13:45:00
实现has_passed()函数,它接收一个预约日期,检查该预约是否在过去:
julia> has_passed(DateTime(1999, 12, 31, 9, 0, 0))
true
实现is_afternoon_appointment()函数,它接收一个预约日期,检查该预约是否在下午(>= 12:00 且 < 18:00):
julia> is_afternoon_appointment(DateTime(2023, 3, 29, 15, 0, 0))
true
实现day_of_week()函数,返回星期几的_数字_表示。
美容院周日不营业,所以周一是第 1 天,周六是第 6 天。
# a Friday
day_of_week(make_datetime(2026, 06, 05, 12, 30, 00))
#> [1] 5
当顾客需要改期时,预约会自动改到下周五的同一时间(因为周五是一周里最清闲的一天)。
实现reschedule()函数,它接收原始预约(以月/日/年顺序的文本字符串形式),返回重新安排后的预约。
# a Saturday
reschedule("06/13/2026 12:30:00")
#> [1] "2026-06-19 12:30:00 UTC"
如果原预约在周一到周四,就改到_本_周五。 如果原预约在周五或周六,就改到_下_周五。