轨道
/
R
R
/
练习
/
预约美容
预约美容

预约美容

学习练习

简介

"日期和时间是我们教给小孩子的东西。能有多难呢?"

许多程序员都犯过这个错误,而随之而来的经历往往对他们的健康和幸福产生不良影响。

任何用日期和时间做正经编程的人,至少都应该准备好理解并减轻潜在的问题。

本文档会尝试概述 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 则不感知时区。
  • 日期时间构造函数通常默认为 UTC。
  • 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。

1. 解析全数字格式的预约日期

实现schedule_appointment()函数,把预约日期的文本表示解析成对应的DateTime格式。

注意,输入的格式是美式的月/日/年顺序。

julia> schedule_appointment("07/25/2023 13:45:00")
2023-07-25T13:45:00

2. 检查预约是否已经过去

实现has_passed()函数,它接收一个预约日期,检查该预约是否在过去:

julia> has_passed(DateTime(1999, 12, 31, 9, 0, 0))
true

3. 检查预约是否在下午

实现is_afternoon_appointment()函数,它接收一个预约日期,检查该预约是否在下午(>= 12:00 且 < 18:00):

julia> is_afternoon_appointment(DateTime(2023, 3, 29, 15, 0, 0))
true

4. 检查预约是星期几

实现day_of_week()函数,返回星期几的_数字_表示。 美容院周日不营业,所以周一是第 1 天,周六是第 6 天。

# a Friday
day_of_week(make_datetime(2026, 06, 05, 12, 30, 00))
#> [1] 5

5. 重新安排预约

当顾客需要改期时,预约会自动改到下周五的同一时间(因为周五是一周里最清闲的一天)。

实现reschedule()函数,它接收原始预约(以月/日/年顺序的文本字符串形式),返回重新安排后的预约。

# a Saturday
reschedule("06/13/2026 12:30:00")
#> [1] "2026-06-19 12:30:00 UTC"

如果原预约在周一到周四,就改到_本_周五。 如果原预约在周五或周六,就改到_下_周五。

通过 GitHub 编辑 链接将在新窗口或新标签页中打开
R Exercism

准备好开始 预约美容 了吗?

注册 Exercism,借助 21 个概念111 个练习 和真人导师指导,学习并掌握 R,全部免费。