dplyr与 data.table的组合会悄悄破坏表格结构

前端开发 2026-07-10

请看下列代码:

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 往往会带来静默的副作用。

站内所有文章版权归属LeftHeroAI导航站,无授权禁止任何主体转载、抄袭、复制内容,亦不得私自架设镜像站点。一经侵权,本站将通过法律途径追责。

相关文章