ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

dplyr `join_by()` 连接规范 DSL 深度解析:等值、不等式、滚动与重叠连接的完整指南

dplyr `join_by()` 连接规范 DSL 深度解析:等值、不等式、滚动与重叠连接的完整指南
  • 数据分析

【免费下载链接】dplyr

dplyr: A grammar of data manipulation

项目地址:https://gitcode.com/gh_mirrors/dp/dplyr
点击查看免费下载

join_by()是 dplyr 提供的一门用于描述表连接方式的"迷你领域特定语言"(domain specific language):它以表达式的方式声明左右两张表如何匹配,既支持最常见的等值连接(==),也支持不等式连接(>=、>、<=、<)、滚动连接(closest())与重叠连接(between()、within()、overlaps()),并可直接作为任意*_join()函数的by参数使用。读完本文,你将掌握join_by()的全部语法要素、列引用规则、底层解析与执行流水线,并理解其打印输出与错误消息的完整行为契约(以仓库中的快照测试为证),能够在销售时间序列、基因组区段等真实场景中正确构造复杂连接。

一、join_by()是什么:连接规范的语法核心

在 dplyr 中,left_join()、right_join()、inner_join()、full_join()、semi_join()、anti_join()等连接函数都通过by参数来决定"拿哪些列、按什么规则去匹配"。早期by只能接受字符向量或命名向量,仅支持等值匹配;dplyr 在连接机制全面重构时(见 NEWS.md 中关于 joins overhaul 的条目,灵感来自 data.table 的 join 语法,涉及 issue #5914、#5661、#5413、#2240)引入了join_by(),把by升级为可编程的规范对象。

从源码看,join_by()的核心实现位于 R/join-by.R:

join_by <- function(...) { exprs <- enquos(..., .named = NULL) exprs <- map(exprs, quo_squash) ... for (i in seq_len(n)) { bys[[i]] <- parse_join_by_expr(exprs[[i]], i, error_call = error_call) } new_join_by( exprs = exprs, condition = condition, filter = filter, x = x, y = y ) }

它做了三件关键的事:

  1. 捕获原始表达式而不求值:使用enquos()捕获...,再用quo_squash()将 quosure 递归压平为纯表达式,从而既能在用户环境中编程使用,又不依赖运行时求值;
  2. 逐个解析:每个表达式交给parse_join_by_expr()分类处理(等值、不等式、closest()、between()、within()、overlaps());
  3. 产出规范对象:new_join_by()构造一个 class 为dplyr_join_by的列表,包含exprs(原始表达式)、condition(每个匹配条件对应的运算符)、filter(是否滚动及滚动方向)、x与y(两侧列名)四组平行向量。参见 R/join-by.R。

join_by()支持的连接类型(?join_by,见 man/join_by.Rd)包括:

类型语法要素典型用途
等值连接 Equality joins==,或直接写单列名最常见,按键相等匹配
不等式连接 Inequality joins>=、>、<=、<时间序列、基因组学
滚动连接 Rolling joinsclosest(expr)无精确匹配时滚动取最近值
重叠连接 Overlap joinsbetween()、within()、overlaps()区段落在区间内/包含/相交
交叉连接 Cross joinscross_join()全组合,由独立函数实现

其中等值、不等式、滚动、重叠连接由join_by()表达式直接描述,交叉连接通过cross_join()单独实现——这一点也体现在快照测试中:join_by()不带任何表达式时会直接报错,并提示改用cross_join()(见 tests/testthat/_snaps/join-by.md 首条快照)。

二、等值连接:==与单列名简写

等值连接要求左右两表在若干对列上取值相等。用join_by()表达等值连接有两种方式:

# 显式:x 表的 a 列 与 y 表的 b 列相等 join_by(a == b) # 简写:左右两侧列名相同时,只写一个名字即可 # join_by(x) 等价于 join_by(x == x) join_by(x)

多列同时匹配时,在...中并列多个表达式即可,例如join_by(a == b, c == d)表示x$a匹配y$b且x$c匹配y$d;同名列可直接简写为join_by(a, c)。这一简写在源码中由parse_join_by_expr()处理:当表达式是单个符号或字符串时,自动改写为expr(!!expr == !!expr)(见 R/join-by.R)。

测试 tests/testthat/test-join-by.R 验证了等值条件的内部结构:

by <- join_by(x == y, a == b) # by$x -> c("x", "a") # by$y -> c("y", "b") # by$condition-> c("==", "==") # by$filter -> c("none", "none")

列名既可以不带引号写成符号,也可以写成字符串(join_by("a", "b" == "c")),两者解析结果完全一致。

三、不等式连接:>=、>、<=、<

不等式连接按非等值条件匹配,常用于"某事件发生在另一个事件之前/之后"这类时间序列问题,以及基因组学的区间查询。用法与等值连接类似,把==换成不等式即可:

# 对每一行 sale_date,找出所有早于该销售的 promo_date join_by(id, sale_date >= promo_date)

join_by()按位置约定表归属:条件左侧的列默认来自左表x,右侧的列默认来自右表y。所以sale_date >= promo_date意为x$sale_date >= y$promo_date。

⚠️ 需要注意:不等式连接会把x中的一行匹配到y中潜在的大量行,产生行数膨胀。官方文档(man/join_by.Rd)明确提醒:"Be extra careful when constructing inequality join specifications!"。同时,连接重构后keep默认值变为NULL:对等值条件等价于keep = FALSE,对不等式条件等价于keep = TRUE(保留两侧键),详见 NEWS.md。此外,keep = FALSE在存在非等值条件时是不允许的,join_cols()会直接报错(见 R/join-cols.R)。

四、滚动连接:closest()

滚动连接是不等式连接的变体:它同样基于不等式,但把结果限制为"最近的匹配",在精确匹配不存在时向前/向后滚动。用法是把一个不等式整体包进closest():

# 对每个 x 值,在 y 中找到"小于等于该 x 值"的最近值 join_by(closest(x >= y))

关于closest()有三条关键语义:

  1. 内部不等式类型:closest()的参数必须是一个使用>=、>、<=、<之一的不等式,不允许==(等值无"滚动"意义,源码在parse_join_by_closest()中专门拦截closest(a == b),见 R/join-by.R);
  2. 主表固定为x:无论不等式怎么书写,closest()总是以左表x为主表、在右表y中寻找最近匹配。例如closest(y$a >= x$b)恒被解释为closest(x$b <= y$a);
  3. 滚动方向由运算符决定:>=/>表示向前滚动取最大值,<=/<表示向后滚动取最小值。这在内部结构上体现为filter字段:测试 test-join-by.R 断言closest(a >= b)的filter为"max",closest(a <= b)的filter为"min",对应源码 R/join-by.R 中的switch映射。

closest()也可以与其他条件并列组合,例如同时要求滚动匹配与一个下界不等式(来自官方文档示例):

sales <- mutate(sales, sale_date_lower = sale_date - 1) by <- join_by(id, closest(sale_date >= promo_date), sale_date_lower <= promo_date) full_join(sales, promos, by)

五、重叠连接:between()、within()、overlaps()

重叠连接是不等式连接的特例:涉及左表的一到两列"区间"与右表两列定义的区间之间的包含/相交关系。join_by()识别三个 helper,它们在底层都会展开为更简单的不等式组合。

5.1between(x, y_lower, y_upper, ..., bounds = "[]")

对左表的每个值x,找出所有落在右表区间[y_lower, y_upper]内的行。默认等价于x >= y_lower, x <= y_upper。

bounds参数控制上下界的开闭(是否包含端点),取值与展开结果对应如下(源码parse_join_by_between()中的switch,见 R/join-by.R,测试见 test-join-by.R):

bounds展开的不等式含义
"[]"(默认)x >= y_lower, x <= y_upper闭区间,两端都包含
"[)"x >= y_lower, x < y_upper左闭右开
"(]"x > y_lower, x <= y_upper左开右闭
"()"x > y_lower, x < y_upper开区间,两端都不包含

5.2within(x_lower, x_upper, y_lower, y_upper)

对左表的每个区间[x_lower, x_upper],找出所有完全包含它的右表区间[y_lower, y_upper]。恒等价于x_lower >= y_lower, x_upper <= y_upper;within()没有bounds参数,因为不管区间开闭,展开结果的不等式都相同(见 R/join-by.R)。

5.3overlaps(x_lower, x_upper, y_lower, y_upper, ..., bounds = "[]")

对左表的每个区间,找出所有以任意方式与之相交的右表区间。默认等价于x_lower <= y_upper, x_upper >= y_lower。

overlaps()的bounds语义与between()略有不同:"[]"使用<=与>=,其余三种("[)"、"(]"、"()")都使用<与>,且生成的不等式完全相同。源码在parse_join_by_overlaps()中用一个closed布尔量统一处理(见 R/join-by.R),并在注释中说明这样设计是为了与between(bounds =)的参数名保持一致、便于记忆(issue #6504)。

这三个 helper 都要求区间"良构且非空":当按"[]"处理时需满足x_lower <= x_upper,否则需满足x_lower < x_upper(见 man/join_by.Rd)。

六、列引用规则:x$与y$显式指定表归属

默认情况下,join_by()按"左侧列来自x、右侧列来自y"的约定解析列名。但有时把条件两边的列写反更清晰(例如希望左表引用右表的列),这时可以用x$或y$前缀显式指定归属:

# 等价于默认写法:x 的 a 列 与 y 的 b 列相等 join_by(a == b) # 显式引用:x 的 a 列 与 y 的 b 列相等(语义相同,写法更明确) join_by(x$a == y$b) # 把"右表列"写在左边:y 的 a 列 与 x 的 b 列相等 join_by(y$a == x$b)

显式引用会触发条件翻转:当左侧是y$列而右侧是x$列时,join_by()会把x/y列归位并反转运算符。源码中维护了一张翻转查找表(见 R/join-by.R):

lookup <- c("==" = "==", ">=" = "<=", ">" = "<", "<=" = ">=", "<" = ">")

测试断言:join_by(y$a == x$a, y$a >= x$a, y$a > x$a, y$a <= x$a, y$a < x$a)的条件依次被归一化为c("==", "<=", "<", ">=", ">")(见 test-join-by.R)。closest()、between()、within()、overlaps()内同样支持x$/y$引用,且这些 helper 也接受命名参数写法(如between(a, y_upper = b, y_lower = c)、overlaps(y_lower = c, y_upper = d, x_lower = a, x_upper = b))。

显式引用必须在"同一表达式内引用不同表":二元表达式的左右两侧、between()的上下界对、within()/overlaps()的左右两侧上下界,都不能来自同一张表,否则会在解析阶段报错(详见第八节)。

七、打印输出与内部结构:快照测试确立的行为契约

dplyr_join_by对象有专属的打印方法(见 R/join-by.R):以Join By:为标题,每个表达式一行、以-前缀列出。快照测试 tests/testthat/_snaps/join-by.md 固定了这些输出,例如:

join_by(a, b) # Join By: # - a # - b join_by(a == a, b >= c) # Join By: # - a == a # - b >= c join_by(a == a, closest(b >= c), closest(d < e)) # Join By: # - a == a # - closest(b >= c) # - closest(d < e)

两点值得注意:

  • 字符串列名保留引号打印:join_by("a", "b")打印为- "a"、- "b",join_by(a == a, b >= "c")打印为- b >= "c",与符号写法在输出上有区分;
  • 命名空间前缀原样保留:join_by(dplyr::between(x, left, right))打印为- dplyr::between(x, left, right),即dplyr::前缀会在打印时保留(该能力在 #6838 引入,见 NEWS.md),但其内部展开的condition、filter、x、y与不带前缀的写法完全一致(测试allows for namespaced helpers验证了这一点)。

八、错误消息体系:几乎覆盖所有非法输入

join_by()的解析器对非法输入非常敏感,快照文件集中记录了全部错误契约。按类别梳理如下(每类均可在源码中找到对应抛出点):

8.1 顶层调用错误

输入错误消息要点源码位置
join_by()(空)Must supply at least one expression.并提示改用cross_join()R/join-by.R
join_by(a = b)Can't name join expressions.提示是否误用=而非==R/join-by.R
join_by(NULL)Expressions can't be empty.(第 1 个表达式为空)R/join-by.R
join_by(1)Each element of ... must be a single column name or a join by expression.R/join-by.R
join_by(foo(x > y))、join_by(x == y, x^y)、join_by(1())Expressions must use one of: ==, >=, >, <=, <, closest(), between(), overlaps(), or within().stop_invalid_top_expression(),见 R/join-by.R
join_by(x + 1 == y)、join_by(x == y + 1)Expressions can't contain computed columns,只能按名字或x$col/y$col引用列parse_join_by_name(),见 R/join-by.R

最后一条对应官方文档的明确指引:join_by()不允许计算列,例如join_by(sales_date - 40 >= promo_date)是非法的——必须先mutate()生成新列再引用(见 man/join_by.Rd 与 NEWS.md)。

8.2$引用错误

输入错误消息要点
join_by(x$a)Can't use $ when specifying a single column name.
join_by(z$a == y$b)、join_by(x$a == z$b)The left-hand side of a $ expression must be either x$ or y$.
join_by((x + 1)$y == b)The left-hand side of a $ expression must be a symbol or string.
join_by($(x) > y)(缺失 name 参数)Expressions using $ can't contain missing arguments.

8.3 同一表引用错误

输入错误消息要点
join_by(x$a == x$b)、join_by(y$a == b)、join_by(closest(x$a >= x$b))The left and right-hand sides of a binary expression must reference different tables.
join_by(between(x$a, x$a, x$b))等Expressions containing between()/within()/overlaps() can't all reference the same table.
join_by(between(a, x$a, y$b))Expressions containing between() must reference the same table for the lower and upper bounds.
join_by(within(x$a, y$b, y$a, y$b))... must reference the same table for the left-hand side lower and upper bounds.
join_by(overlaps(x$a, x$b, y$a, x$b))... must reference the same table for the right-hand side lower and upper bounds.

8.4 缺失参数错误

这类错误对"被包装的join_by()"尤其有用(快照中的nicely catches required missing arguments when wrapped案例):当fn <- function(x, y) join_by({{x}} == {{y}})只传入一个参数时,能精确报出缺失的是哪一侧的哪个参数:

Error: ! Expressions using `==` can't contain missing arguments. x Argument `y` is missing.

同样的机制覆盖每个 helper:between(x)报Argument y_lower is missing,within(x)报Argument x_upper is missing,overlaps(x)报Argument x_upper is missing,closest()报Argument expr is missing。实现上由check_missing_arg()统一处理(见 R/join-by.R)。

8.5closest()专属错误

  • join_by(closest(a >= b, 1)):多余参数 →unused argument (1)(closest()只接受一个参数);
  • join_by(closest(a == b)):The expression used in closest() can't use ==.;
  • join_by(closest(x))、join_by(closest(1)):The first argument of closest() must be an expression.;
  • join_by(closest(x + y)):The expression used in closest() must use one of: >=, >, <=, or <.(见stop_join_by_closest_invalid_expression(),R/join-by.R)。

8.6bounds与多余...错误

between()与overlaps()的bounds必须由check_bounds()校验(R/join-by.R),取值范围仅"[]"、"[)"、"(]"、"()"四种:

join_by(between(x, lower, upper, bounds = 1)) # Error: # ! `bounds` must be a string or character vector. join_by(between(x, lower, upper, bounds = "a")) # Error: # ! `bounds` must be one of "[]", "[)", "(]", or "()", not "a".

两个 helper 的...是为未来扩展预留的,当前必须为空(check_join_by_dots_empty(),R/join-by.R):

join_by(between(x, lower, upper, foo = 1)) # Error: # ! `...` must be empty. # i Non-empty dots were detected inside `between()`.

8.7 命名空间与by类型错误

  • join_by(dplyrr::between(x, left, right)):Expressions can only be namespace prefixed with dplyr::.(只允许dplyr::前缀,见stop_invalid_namespaced_expression(),R/join-by.R);
  • as_join_by(FALSE):by must be a (named) character vector, list, join_by() result, or NULL, not FALSE.(as_join_by.default(),R/join-by.R)。

九、编程友好性:quosure、{{ }}拥抱与命名空间写法

尽管join_by()基于"纯表达式、不评估",但它专门支持整洁求值(tidy evaluation)的编程手法。源码注释说明(R/join-by.R):先enquos()捕获 quosure,再quo_squash()递归压平,从而允许{{ }}拥抱注入。测试 test-join-by.R 验证了多种场景:

fn <- function(x) { join_by({{ x }} == y) } fn("foo") # -> join_by("foo" == y) fn(a) # 表达式替换而非 quosure 求值,仍是 join_by(a == y) fn(!!a) # 可用 !! 内联求值,得到 join_by("foo" == y) # 也支持在 between() 中拥抱注入 fn <- function(x, top) { join_by(between({{ x }}, lower, {{ top }})) } fn(x, y) # -> join_by(between(x, lower, y))

因此可以放心地把join_by()封装进自定义函数、用...透传,并通过{{ }}注入列名或整个表达式。从 #6838 起,helper 还可以写成dplyr::between(...)、dplyr::within(...)、dplyr::overlaps(...)、dplyr::closest(...)的命名空间形式,便于在调用环境中避免名称冲突。

十、by参数的其他形态与join_by_common()

join_by()结果只是by的合法形态之一。从as_join_by()泛型(R/join-by.R)可以看到by支持四种输入并被统一归一化为dplyr_join_by对象:

by形态说明
join_by()结果直接透传
字符向量未命名元素按"两表同名"处理(by = c("a", "b")),命名元素按"x名 =y名"处理(by = c("x_a" = "y_a"))
list(含x、y两个字符向量)编程式指定两侧列名
NULL自然连接:自动取两表公共列,并打印Joining with by = join_by(...)消息

NULL时由join_by_common()(R/join-by.R)自动求两表列名交集并构造等值规范,快照记录了其消息输出:

by <- join_by_common(c("x", "y"), c("x", "y")) # Message # Joining with `by = join_by(x, y)`

对于需要反引号包裹的特殊列名(如_x、foo bar),消息会自动加反引号:Joining with by = join_by(_x, foo bar)。若两表没有任何公共列,则报错并要求显式提供by或使用cross_join()(快照末尾的join_by_common()错误案例)。

十一、连接执行流水线:从规范到行匹配

一条join_by()规范从构造到真正执行匹配,会依次经过以下环节(结合源码调用链):

  1. 构造:join_by()解析表达式,产出dplyr_join_by(exprs/condition/filter/x/y);
  2. 归一化:*_join()内部把任意形态的by交给as_join_by()(NULL时先走join_by_common()),统一为dplyr_join_by;
  3. 列定位:join_cols()(R/join-cols.R)校验列存在性、唯一性,根据condition区分等值键与非等值键,决定输出列与keep/后缀处理;非等值条件下keep = FALSE被禁止;
  4. 行匹配:join_rows()(R/join-rows.R)把x、y键列与condition、filter一起交给vctrs::vec_locate_matches(),由底层 C 实现完成匹配、滚动过滤与multiple/unmatched/relationship策略处理;
  5. 重组输出:按匹配到的行索引从两表取数并拼接。

从源码注释可见,condition与filter正是驱动底层匹配的两组参数:condition取"=="、">="、">"、"<="、"<"之一,filter取"none"(普通匹配)、"max"(向前滚动取最大)、"min"(向后滚动取最小)。compute_join_relationship()还根据condition决定是否检查 many-to-many 关系:只要存在任何非等值条件,就不做 many-to-many 检查,因为不等式与重叠连接天然容易产生多对多(见 R/join-rows.R)——这也是官方文档提醒"构造不等式连接要格外小心"的底层原因。

十二、实战示例:滚动连接与重叠连接

12.1 销售与促销:从等值到滚动

官方文档(man/join_by.Rd)用销售/促销数据完整演示了三层递进:

sales <- tibble( id = c(1L, 1L, 1L, 2L, 2L), sale_date = as.Date(c("2018-12-31", "2019-01-02", "2019-01-05", "2019-01-04", "2019-01-01")) ) promos <- tibble( id = c(1L, 1L, 2L), promo_date = as.Date(c("2019-01-01", "2019-01-05", "2019-01-02")) ) # 第 1 层:等值连接——id 匹配 id,销售日期匹配促销日期 by <- join_by(id, sale_date == promo_date) left_join(sales, promos, by) # 第 2 层:不等式连接——每个销售找所有早于它的促销 by <- join_by(id, sale_date >= promo_date) left_join(sales, promos, by) # 第 3 层:滚动连接——每个销售只找"最近一次"早于它的促销 by <- join_by(id, closest(sale_date >= promo_date)) left_join(sales, promos, by) # 若不允许精确匹配日期的促销(同日不算),改用 `>` by <- join_by(id, closest(sale_date > promo_date)) left_join(sales, promos, by)

注意第四步:由于促销日期2019-01-05与销售日期同一天,改用>后它不再被视为"最近匹配"。这正是closest()中>=与>差异的直接体现。

12.2 基因组区段:between()、within()、overlaps()与bounds

用区段数据演示三种重叠语义的区别:

segments <- tibble( segment_id = 1:4, chromosome = c("chr1", "chr2", "chr2", "chr1"), start = c(140, 210, 380, 230), end = c(150, 240, 415, 280) ) reference <- tibble( reference_id = 1:4, chromosome = c("chr1", "chr1", "chr2", "chr2"), start = c(100, 200, 300, 415), end = c(150, 250, 399, 450) ) # 点的落入:segment 的 start 是否落在 reference 的 [start, end] 内 by <- join_by(chromosome, between(start, start, end)) full_join(segments, reference, by) # 完整包含:segment 区间完全落在 reference 区间内 by <- join_by(chromosome, within(x$start, x$end, y$start, y$end)) inner_join(segments, reference, by) # 任意相交:segment 区间与 reference 区间有任何重叠即匹配 by <- join_by(chromosome, overlaps(x$start, x$end, y$start, y$end)) full_join(segments, reference, by) # 右开区间 [):415 的端值不再与 415 的起始值重叠 by <- join_by(chromosome, overlaps(x$start, x$end, y$start, y$end, bounds = "[)")) full_join(segments, reference, by)

这里特别展示了x$/y$显式引用的价值:在within()与overlaps()中显式写出x$start、y$start等,可以让表归属一目了然(官方文档称之为"makes your intentions clearer");bounds = "[)"则模拟了常见的半开区间(右端不包含)的生物学区间约定。

十三、注意事项与最佳实践

  1. 不要使用计算列:join_by()只接受"列名 + 运算符 + 列名"的结构,join_by(sales_date - 40 >= promo_date)会报Expressions can't contain computed columns。正确做法是先用mutate()生成sales_date_lower这类列再参与连接(见 man/join_by.Rd 与 NEWS.md);
  2. 警惕不等式连接的行数膨胀:一个x行可能匹配y的大量行,构造条件前先想清楚基数关系;非等值条件下keep = FALSE不可用,且不会触发 many-to-many 警告,需要自行把握;
  3. closest()恒以x为主表:滚动语义始终是"在y中找x的最近匹配",需要换主表时把对应的表放在x位置;
  4. bounds只接受四种取值:"[]"、"[)"、"(]"、"()",并假设区间良构(闭区间要求lower <= upper,开区间要求lower < upper);
  5. 生产中显式提供by:省略by会触发自动公共列检测并打印消息,显式by = join_by(...)是官方推荐的规范写法(见 R/join.R 中by参数文档);
  6. 复用与封装:join_by()支持{{ }}拥抱与...透传,适合封装进自定义分析函数;helper 可加dplyr::前缀避免名称冲突(#6838)。

十四、延伸阅读

  • R/join-by.R:join_by()完整实现,包括所有解析函数与错误抛出点
  • man/join_by.Rd:?join_by官方文档与全部示例
  • tests/testthat/_snaps/join-by.md:打印输出与错误消息的行为契约快照
  • tests/testthat/test-join-by.R:结构断言、条件展开与编程用法测试
  • R/join-rows.R 与 R/join-cols.R:行匹配与列组织的底层执行
  • R/join-common-by.R:by = NULL时自动公共列检测
  • NEWS.md:join_by()引入与连接重构的历史背景(#5914、#5661、#5413、#2240、#6838 等)
  • 数据分析

【免费下载链接】dplyr

dplyr: A grammar of data manipulation

项目地址:https://gitcode.com/gh_mirrors/dp/dplyr
点击查看免费下载

相关推荐

上一篇:微信聊天记录永久保存终极指南:3步打造你的个人数字记忆库
下一篇:压测数据秒变可视化大屏:wrk+Grafana打造性能监控仪表盘

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进