- 数据分析
【免费下载链接】dplyr
dplyr: A grammar of data manipulation
导读
cross_join()是 dplyr 中实现笛卡尔积(cartesian product)的联接函数,它会将x的每一行与y的每一行两两匹配。本文以 dplyr 仓库的快照测试文件 tests/testthat/_snaps/join-cross.md 为切入点,深入剖析cross_join()在遇到重复列名时抛出的Input columns in \x` must be unique.错误:先还原快照测试的完整触发场景,再追踪错误从join_cols()→check_duplicate_vars()的源码调用链,最后结合参数语义与边界测试,给出可复现、可避坑的实战方案。读完本文,你将掌握cross_join()的输入校验规则、suffix冲突消解机制,以及用expect_snapshot()` 为 R 包编写错误快照测试的方法。
一、问题还原:快照测试中的那一条错误
1.1 快照测试是什么
在 testthat 3.x 中,expect_snapshot(error = TRUE)会把被测试代码的输出、条件(错误/警告/消息)等原样固化到tests/testthat/_snaps/目录下的 Markdown 文件中。当测试再次运行时,testthat 会将实际输出与快照逐字比对,一旦错误信息措辞变化,测试就会失败,从而防止错误提示在版本迭代中被无意破坏。
tests/testthat/_snaps/join-cross.md 正是cross_join()错误行为的快照存档,其内容如下:
# cross join checks for duplicate names Code cross_join(df1, df2) Condition Error in `cross_join()`: ! Input columns in `x` must be unique. x Problem with `a`.逐段解读:
# cross join checks for duplicate names:这是对应test_that()的测试描述,说明该快照专门验证"重复列名"这一边界场景;Code块记录的是被求值的表达式cross_join(df1, df2);Condition块记录随后触发的条件:Error in \cross_join()`表示错误来自cross_join()调用,!为主错误信息Input columns in `x` must be unique.,x Problem with `a`.是附带的问题定位——列a在x` 中重复了。
1.2 触发快照的测试用例
快照由 tests/testthat/test-join-cross.R 中名为 "cross join checks for duplicate names" 的测试产生:
test_that("cross join checks for duplicate names", { df1 <- tibble(a = 1, b = 2, a = 3, .name_repair = "minimal") df2 <- tibble(a = 2, c = 3) expect_snapshot(error = TRUE, { cross_join(df1, df2) }) })这里的关键是df1的构造方式:tibble(a = 1, b = 2, a = 3, .name_repair = "minimal")。tibble 默认的"unique"列名修复策略会把第二个a自动改名为a...3,从而掩盖重复列名问题;而显式指定.name_repair = "minimal"后,tibble 不做任何修复,直接保留两个同名的a列。于是cross_join(df1, df2)在运行到输入校验时便抛出了快照中的错误。
注意快照中的错误只点名了x侧的a。这是因为 join_cols() 先校验x后校验y,x校验失败时立即中止,不会再继续检查y。
二、源码溯源:这条错误从哪里来
2.1 调用链全景
cross_join()对数据框的核心实现位于 R/join-cross.R。与按键匹配的其他联接(如left_join())不同,cross_join()使用一个空的 join-by 规格,即"无条件匹配":
cross_join.data.frame <- function(x, y, ..., copy = FALSE, suffix = c(".x", ".y")) { check_dots_empty0(...) y <- auto_copy(x, y, copy = copy) x_names <- tbl_vars(x) y_names <- tbl_vars(y) # Empty join by with no keys by <- new_join_by() # Particular value isn't too important, as there are no keys to keep/drop keep <- FALSE vars <- join_cols( x_names = x_names, y_names = y_names, by = by, suffix = suffix, keep = keep ) ... }随后join_cols()(定义于 R/join-cols.R)是列名规划的核心:它决定输出中哪些列保留原名、哪些列需要加后缀。而校验发生在join_cols()的第一件事——任何列名规划之前:
check_duplicate_vars(x_names, "x", error_call = error_call) check_duplicate_vars(y_names, "y", error_call = error_call)也就是说,x与y的列名各自必须唯一是join_cols()的硬性前置条件,后续add_suffixes()做后缀消解时直接依赖这一前提(R/join-cols.R#L167-L168 的注释明确写道:"join_cols()checks thatxandyare individually unique, which plays into assumptions made here")。
2.2check_duplicate_vars():错误信息的真正制造者
快照中Input columns in \x` must be unique.` 这句消息由 check_duplicate_vars() 生成:
check_duplicate_vars <- function(vars, input, ..., error_call = caller_env()) { check_dots_empty0(...) dup <- duplicated(vars) if (any(dup)) { bullets <- c( glue("Input columns in `{input}` must be unique."), x = glue("Problem with {err_vars(vars[dup])}.") ) abort(bullets, call = error_call) } }其工作原理:
- 用
duplicated(vars)找出重复出现的列名(返回逻辑向量),只要存在重复即判定失败; - 主消息
Input columns in \{input}` must be unique.中的{input}会被替换为调用方传入的"x"或"y",这解释了为什么快照里出现的是x`; - 副消息
Problem with {err_vars(...)}.通过 err_vars() 格式化:对字符向量加反引号并用"and/逗号"拼接,因此多个重复列会呈现为Problem with `a` and `b`.或Problem with `a`, `b`, and `c`.的形式。
需要留意的是,duplicated()只标记第二次及以后出现的元素,所以err_vars()列出的总是"重复的那些",而不是"全部同名列"。
2.3 为什么cross_join()严格要求列名唯一
从输出构建逻辑看,cross_join()的产出是x与y的全部列拼接:
x_out <- set_names(x_in, names(vars$x$out)) y_out <- set_names(y_in, names(vars$y$out)) x_out <- vec_rep_each(x_out, times = y_size) y_out <- vec_rep(y_out, times = x_size) x_out[names(y_out)] <- y_out dplyr_reconstruct(x_out, x)其中vec_rep_each()把x的每一行重复nrow(y)次,vec_rep()把y整体重复nrow(x)次,最后把y的列并入x。若x内部已存在同名列,set_names()与随后的列合并都无法可靠地对应每一列,输出的列名体系将彻底失真。因此"输入列名唯一"是join_cols()面向所有联接函数(而不仅是cross_join())的统一约束;cross_join()由于没有键列,无法像其他联接那样通过"保留键名/丢弃键名"来绕开冲突,校验因此直接生效。
三、重复列名与suffix:冲突消解的边界
很多读者会误以为suffix = c(".x", ".y")是"处理所有重复列"的开关。实际上它只解决跨表冲突,即x与y之间的同名列,而同一张表内部的重复列是直接报错的。
3.1 跨表同名:由suffix消解
tests/testthat/test-join-cross.R 的 "cross join appliessuffix" 测试验证了这一点:
test_that("cross join applies `suffix`", { df1 <- tibble(x = 1, y = 2) df2 <- tibble(x = 2, z = 3) expect_named(cross_join(df1, df2), c("x.x", "y", "x.y", "z")) expect_named( cross_join(df1, df2, suffix = c("", "_y")), c("x", "y", "x_y", "z") ) })- 默认
suffix = c(".x", ".y")时,两个表共有的列x分别变为x.x(来自x)与x.y(来自y); - 指定
suffix = c("", "_y")时,x侧列名保持不变,y侧加_y后缀,得到x、y、x_y、z。
其底层实现是 add_suffixes():
add_suffixes <- function(x, y, suffix) { if (identical(suffix, "")) { return(x) } x <- c(y, x) # Never marks the "first" duplicate (i.e. never anything in `y`) dup <- duplicated(x) while (any(dup)) { x[dup] <- paste0(x[dup], suffix) dup <- duplicated(x) } loc <- seq2(length(y) + 1L, length(x)) x <- x[loc] x }该函数保证y侧列名(作为基准)永不添加后缀,只对x侧与y重名的列反复追加后缀,直到互不冲突。while循环意味着它甚至能处理"加完后缀后又与别的列撞名"的极端情况。
3.2 参数合法性校验
除了重复列名,suffix与copy也各有前置校验:
- standardise_join_suffix() 要求
suffix必须是长度为 2 的字符向量,且不允许NA; copy = TRUE时,auto_copy() 会把y复制到x的数据源中,允许跨数据源联接,但这是一个潜在昂贵的操作,官方文档明确要求用户显式选择(见 man/cross_join.Rd);...必须为空,check_dots_empty0(...)会拦截任何未命名的多余参数。
3.3 官方文档中的输出契约
根据 cross_join() 的 roxygen 文档 与 man/cross_join.Rd,cross_join()的输出保证:
- 行数为
nrow(x) * nrow(y); - 输出列包含
x、y的全部列,跨表列名冲突由suffix消解; - 尽可能保持
x的行序与列序。
这进一步说明:跨表冲突有明确契约可依,表内重复列则无此契约——这正是check_duplicate_vars()存在的意义。
四、边界行为与测试证据
4.1 0 行输入的笛卡尔积
test-join-cross.R 验证了空输入的行为:只要x或y一方为 0 行,结果就是 0 行(因为nrow(x) * nrow(y) = 0),且列结构依然保留:
df1 <- tibble(x = 1:2) df2 <- tibble(y = integer()) cross_join(df1, df2) # tibble(x = integer(), y = integer())4.2 0 列但多于 0 行的 tibble
test-join-cross.R 展示了"无列有行"的特殊输入:两个 3 行 0 列的 tibble 做笛卡尔积得到 9 行 0 列;0 列表与 2 行 1 列表的结果等价于vec_rep(df2, times = 3)。这说明cross_join()的行数逻辑对零列输入同样成立,且不会因为列缺失而报错。
4.3 快照测试的维护方式
当错误措辞需要修改时,开发者可以运行testthat::snapshot_accept()(或testthat::test_dir(update = TRUE))让 testthat 接受新输出并更新_snaps/join-cross.md。这意味着该快照文件是"错误信息的活文档"——读者想了解当前版本cross_join()对重复列名的确切报错文本,直接查看 tests/testthat/_snaps/join-cross.md 即可,无需实际运行 R。
五、实战:如何复现与规避
5.1 最小复现
在安装了 dplyr 的 R 环境中运行:
library(dplyr) df1 <- tibble(a = 1, b = 2, a = 3, .name_repair = "minimal") df2 <- tibble(a = 2, c = 3) cross_join(df1, df2)预期输出(与快照一致):
Error in `cross_join()`: ! Input columns in `x` must be unique. x Problem with `a`.5.2 规避方案
- 修复列名:使用
tibble(..., .name_repair = "unique")(默认行为)或df %>% rename_with(~ make.unique(.x))消除表内重复; - 手工改名:
df1 <- df1 %>% set_names(c("a", "b", "a2")),让每个列名唯一后再联接; - 使用最小修复名称构造列:如果确实需要两个内容不同的列,应从一开始就取不同名称(如
a、a2),而不是依赖"minimal"保留重名。
5.3 何时会出现表内重名
正常情况下,tibble()、data.frame()的默认构造都会自动修复列名,表内重名只会在以下场景出现:
- 显式传入
.name_repair = "minimal"(如测试用例所示); - 直接拼接向量列表后用
new_tibble()等底层构造器创建对象; - 读取某些不规范的 CSV 或数据库表结构。
六、总结
cross_join()要求x、y的列名各自唯一,这是所有 dplyr 联接函数的统一前置校验,错误由 check_duplicate_vars() 抛出;- 快照文件 tests/testthat/_snaps/join-cross.md 与测试 tests/testthat/test-join-cross.R#L53-L59 共同锁定了
Input columns in \x` must be unique.` 这一错误契约; suffix只负责消解x、y之间的跨表同名冲突(由 add_suffixes() 实现),不能绕过表内重复列名检查;- 撰写或维护 R 包时,
expect_snapshot(error = TRUE)+_snaps/*.md是一种低成本、高可读性的错误信息回归保护机制,值得在涉及输入校验的代码路径上普遍采用。
- 数据分析
【免费下载链接】dplyr
dplyr: A grammar of data manipulation
相关推荐
dplyr grouped_df 校验机制与分组元数据:从快照测试解读 validate_grouped_df() 的完整错误契约
dplyr grouped_df 校验机制与分组元数据:从快照测试解读 validate_grouped_df 的完整错误契约 dplyr 的分组数据框( gr
数据分析ggplot2 点形状字符串解析与校验:从快照测试到 `translate_shape_string()` 源码级解读
ggplot2 点形状字符串解析与校验:从快照测试到 translate_shape_string 源码级解读 ggplot2 允许用户用人类可读的名称(如 "
数据可视化Roc 语言 List.sublist 嵌套列表切片:从 REPL 快照测试到源码实现的完整解析
Roc 语言 List.sublist 嵌套列表切片:从 REPL 快照测试到源码实现的完整解析 本文以 Roc 语言仓库中的 REPL 快照测试文件 list
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考