dplyr与 data.table的组合会悄悄破坏表格结构
请看下列代码:
require(dplyr)
require(data.table)
dt_original <- data.table(
colA_char = c("A1", "A2", "A3"),
colB_char = c("B1", "B2", "B3"),
colC_num = c(10L, 20L, 30L)
)
dt_new <-
dt_original %>%
filter(colA_char!='') %>%
setcolorder('colC_num')
它会产生符合预期的 dt_new,但除此之外它还会破坏原始表的结构:
> dt_original
colC_num colA_char colB_char
<char> <char> <int>
1: A1 B1 10
2: A2 B2 20
3: A3 B3 30
你会看到所有列名相对于它们的内容被错位了!
是的,我理解把dplyr和 data.table混用是个不好的做法,而且我也可以在管道中直接加上 copy() —— 但这种悄无声息的破坏行为——难道不是一个bug吗?至少不应该给用户发出警告吗?
附注:版本信息: R版本4.5.2 (2025-10-31 ucrt) dplyr 1.2.0 data.table 1.18.2.1
解决方案
基本上,这是R 的拷贝-就地修改(copy-on-modify)机制与 data.table 的就地按引用修改之间的冲突。正如 文档 所述:
在
data.table的说法中,所有set*函数都按引用修改它们的输入。
这个问题在于,当 dplyr::filter() 处理你的数据时,它会创建一个新对象,即数据的拷贝。然而,这个新表仍然共享对原始表的列名的引用,而这些列名并没有发生改变。当你使用 setcolorder() 时,结果会让两张表的列名顺序都改变,但实际上只改变了一张表的列顺序。
一个可操作的示例
让我们把 dt_original 的一个拷贝赋给 dt_new。
dt_new <- dt_original
现在,我们来看看使用 dplyr::filter() 是否会拷贝数据和名称。我们可以在没有条件的情况下使用 filter() 来返回所有行。
在 data.table 上使用 filter()
对一个 data.table 的拷贝会保持相同的内存地址。但在使用 filter() 之后,它会获得一个新的地址。
# Do they have the same memory address? Yes
address(dt_new) == address(dt_original)
# [1] TRUE
# But with `filter()`? No
address(filter(dt_new)) == address(dt_original)
# [1] FALSE
列
dt_new 的列也保持与 dt_original 的列相同的内存地址,但在使用 filter() 之后会得到一个新的地址。
# What about a column?
address(dt_new$colA_char) == address(dt_original$colA_char)
# [1] TRUE
# But with `filter()` No
address(filter(dt_new)$colA_char) == address(dt_original$colA_char)
# [1] FALSE
名称
然而,同样的规则并不适用于列名:
# Do the names have the same memory address? Yes
address(names(dt_original)) == address(names(dt_new))
# [1] TRUE
# What about after `filter()`? Still Yes!
address(names(dt_original)) == address(names(filter(dt_new)))
# [1] TRUE
因此在使用 filter() 之后,两个表共享同一个名称向量。也许这是设计使然——按任意条件筛选行不会改变列名。无论如何,这就是问题的根源。
底层到底发生了什么?
data.table 在C 语言中就地修改了那份共享的 names 向量,具体实现位于 setcolorder():
SEXP setcolorder(SEXP x, SEXP o)
{
SEXP names = getAttrib(x, R_NamesSymbol);
const int ncol=LENGTH(x);
if (isNull(names)) error(_("dt passed to setcolorder has no names"));
if (ncol != LENGTH(names))
internal_error(__func__, "dt passed to setcolorder has %d columns but %d names", ncol, LENGTH(names)); // # nocov
SEXP tt = PROTECT(allocVector(VECSXP, 2));
// change the order of column names
SET_VECTOR_ELT(tt, 0, names); // this line is the culprit
// change the order of columns (the data)
// this also mutates in place - but a different place
SET_VECTOR_ELT(tt, 1, x);
reorder(tt, o);
UNPROTECT(1);
return R_NilValue;
}
由于原始数据集仍然指向同一个名称向量,因此表头会被移动。但数据本身的位置不同,所以数据保持不动。
如何避免
最好的做法是在这里不要使用 dplyr::filter()。data.table 有自己专门的子集语法:
dt_new <-
dt_original[colA_char!=''] |>
setcolorder("colC_num")
如果你必须使用 dplyr,你已经知道可以使用 copy()。如果你不想那样做,避免使用 set* 函数(以及同样会就地修改的 :=)。你也可以用其他方式重新排序列:
dt_new <-
dt_original |>
filter(colA_char!='') |>
_[, .(colC_num, colA_char, colB_char)]
以上方法都不会修改 dt_original。
这是一个bug吗?
我能理解为什么会让人觉得是。
不过,我认为这其实是混用不同生态系统的后果。data.table 假设你在使用一个 set* 函数,你要么在处理一个纯净的 data.table 对象,要么你已经显式地使用了 copy() 来保护你的内存引用。由于标准的R 与 dplyr 在很大程度上依赖共享引用(浅拷贝),在一个 dplyr 流水线的末端进行就地修改的 data.table 往往会带来静默的副作用。