理解何时data.table是另一个data.table的引用(而不是副本)

228

我对data.table的按引用传递属性有一些困惑。一些操作似乎会“破坏”引用,我想确切地了解正在发生的事情。

从另一个data.table创建data.table(通过<-),然后通过:=更新新表时,原始表也会被改变。根据预期,如下所示:

?data.table::copystackoverflow: pass-by-reference-the-operator-in-the-data-table-package

以下是一个示例:

library(data.table)

DT <- data.table(a=c(1,2), b=c(11,12))
print(DT)
#      a  b
# [1,] 1 11
# [2,] 2 12

newDT <- DT        # reference, not copy
newDT[1, a := 100] # modify new DT

print(DT)          # DT is modified too.
#        a  b
# [1,] 100 11
# [2,]   2 12

然而,如果我在上述<-赋值和:=的行之间插入一个非:=的修改,DT就不再被修改:

DT = data.table(a=c(1,2), b=c(11,12))
newDT <- DT        
newDT$b[2] <- 200  # new operation
newDT[1, a := 100]

print(DT)
#      a  b
# [1,] 1 11
# [2,] 2 12

看起来 newDT$b[2] <- 200 这行代码会"破坏"引用。我猜这可能会触发某种复制,但我想完全了解R是如何处理这些操作的,以确保不会在我的代码中引入潜在的错误。

如果有人能向我解释一下,我会非常感激。


5
我刚刚发现了这个“特性”,它让人感到恐惧。在互联网上,广泛提倡在R语言中使用<-而不是=进行基本赋值(例如,Google的https://google.github.io/styleguide/Rguide.xml#assignment)。但这意味着,使用data.table操作将无法像操作数据框一样运作,因此它远非数据框的即插即用替代品。 - cmo
2个回答

153

是的,在R中使用<-(或=->)进行子赋值会复制整个对象。您可以使用tracemem(DT).Internal(inspect(DT))跟踪它,如下所示。 data.table的特性:=set()将引用分配给它们传递的任何对象。因此,如果该对象以前已被复制(通过子赋值<-或显式copy(DT)),则修改引用的是副本。

DT <- data.table(a = c(1, 2), b = c(11, 12)) 
newDT <- DT 

.Internal(inspect(DT))
# @0000000003B7E2A0 19 VECSXP g0c7 [OBJ,NAM(2),ATT] (len=2, tl=100)
#   @00000000040C2288 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 1,2
#   @00000000040C2250 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 11,12
# ATTRIB:  # ..snip..

.Internal(inspect(newDT))   # precisely the same object at this point
# @0000000003B7E2A0 19 VECSXP g0c7 [OBJ,NAM(2),ATT] (len=2, tl=100)
#   @00000000040C2288 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 1,2
#   @00000000040C2250 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 11,12
# ATTRIB:  # ..snip..

tracemem(newDT)
# [1] "<0x0000000003b7e2a0"

newDT$b[2] <- 200
# tracemem[0000000003B7E2A0 -> 00000000040ED948]: 
# tracemem[00000000040ED948 -> 00000000040ED830]: .Call copy $<-.data.table $<- 

.Internal(inspect(DT))
# @0000000003B7E2A0 19 VECSXP g0c7 [OBJ,NAM(2),TR,ATT] (len=2, tl=100)
#   @00000000040C2288 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 1,2
#   @00000000040C2250 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 11,12
# ATTRIB:  # ..snip..

.Internal(inspect(newDT))
# @0000000003D97A58 19 VECSXP g0c7 [OBJ,NAM(2),ATT] (len=2, tl=100)
#   @00000000040ED7F8 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 1,2
#   @00000000040ED8D8 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 11,200
# ATTRIB:  # ..snip..

注意即使没有更改a,它的副本仍然被复制了(不同的十六进制值表示向量的新副本)。甚至整个b都被复制,而不是只改变需要更改的元素。这对于大型数据来说非常重要,这也是为什么data.table引入了:=set()的原因。

现在,有了我们复制的newDT,我们可以进行引用修改:

newDT
#      a   b
# [1,] 1  11
# [2,] 2 200

newDT[2, b := 400]
#      a   b        # See FAQ 2.21 for why this prints newDT
# [1,] 1  11
# [2,] 2 400

.Internal(inspect(newDT))
# @0000000003D97A58 19 VECSXP g0c7 [OBJ,NAM(2),ATT] (len=2, tl=100)
#   @00000000040ED7F8 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 1,2
#   @00000000040ED8D8 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 11,400
# ATTRIB:  # ..snip ..

请注意,所有三个十六进制值(列点的向量和每个列)保持不变。因此,它是通过引用真正修改的,没有任何副本。

或者,我们可以通过引用修改原始的DT

DT[2, b := 600]
#      a   b
# [1,] 1  11
# [2,] 2 600

.Internal(inspect(DT))
# @0000000003B7E2A0 19 VECSXP g0c7 [OBJ,NAM(2),ATT] (len=2, tl=100)
#   @00000000040C2288 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 1,2
#   @00000000040C2250 14 REALSXP g0c2 [NAM(2)] (len=2, tl=0) 11,600
#   ATTRIB:  # ..snip..

这些十六进制值与我们在上面看到的DT原始值相同。输入example(copy)以获取更多使用tracememdata.frame比较的示例。

顺便说一下,如果您执行tracemem(DT),然后执行DT[2,b:=600],您将看到一个副本被报告。这是print方法所做的前10行的副本。当用invisible()包装或在函数或脚本中调用时,print方法不会被调用。

所有这些内容也适用于函数内部;即:=set()不会在写入时复制,即使在函数内部也是如此。如果您需要修改本地副本,则在函数开始时调用x=copy(x)。但请记住,data.table适用于大型数据(以及小型数据更快的编程优势)。我们故意不想复制大型对象(从未)。因此,我们不需要考虑通常的3倍工作记忆因子经验法则。我们试图只需要与一列一样大的工作内存(即工作记忆因子为1/ncol而不是3)。


3
这种行为何时是可取的? - colin
有趣的是,复制整个对象的行为不会发生在data.frame对象中。在复制的data.frame中,只有通过->赋值直接更改的向量才会更改内存位置。未更改的向量保持原始data.frame向量的内存位置。这里描述的data.table的行为是1.12.2版本的当前行为。 - lmo

120

简单概述

<-data.table 中的使用方式类似于基本数据类型;也就是说,在使用 <- 进行子赋值(例如更改列名或更改元素,如 DT[i,j]<-v)之前,不会进行复制操作。然后,它会像基本数据类型一样复制整个对象。这被称为写时复制。我认为更好地称之为“在子赋值时复制”!但是,当您使用特殊的 := 操作符或由 data.table 提供的 set* 函数时,则不会进行复制。如果您有大量数据,可能需要使用它们。即使在函数内部,:=set* 也不会复制 data.table

给定此示例数据 :

DT <- data.table(a=c(1,2), b=c(11,12))

下面的代码仅仅是将另一个名称DT2绑定到当前绑定到名称DT的同一数据对象上:

DT2 <- DT

这个操作不会复制任何数据,也不会在基础对象中进行复制。它只是标记数据对象,以便R知道两个不同的名称(DT2DT)指向同一个对象。因此,如果之后对它们中的任何一个进行子分配,R将需要复制对象。

这对于data.table也是完美的。:=不是用来做这件事的。因此,以下是一个故意的错误,因为:=不仅仅是绑定对象名称:

DT2 := DT    # not what := is for, not defined, gives a nice error

:= 是通过引用进行子分配(subassigning)的操作符。但你不会像在基础语言中那样使用它:

DT[3,"foo"] := newvalue    # not like this

您可以像这样使用它:

DT[3,foo:=newvalue]    # like this

那改变了按引用传递的 DT。假设你通过引用向数据对象添加了一列new,则不需要执行以下操作:

DT <- DT[,new:=1L]
因为 RHS 已经通过引用更改了 DT,所以额外的 DT <- 会误解 := 的作用。您可以在那里编写它,但这是多余的。DT 通过 := 按引用更改,即使在函数内部:
f <- function(X){
    X[,new2:=2L]
    return("something else")
}
f(DT)   # will change DT

DT2 <- DT
f(DT)   # will change both DT and DT2 (they're the same data object)

data.table 是用于大型数据集的。如果你在内存中有一个 20GB 的 data.table,那么你需要一种处理它的方式。这是 data.table 的一个非常刻意的设计决策。

当然可以进行拷贝。您只需要告诉 data.table,您确定要拷贝您的 20GB 数据集,使用 copy() 函数即可:

DT3 <- copy(DT)   # rather than DT3 <- DT
DT3[,new3:=3L]     # now, this just changes DT3 because it's a copy, not DT too.
为避免复制,请勿使用基本类型的分配或更新操作:
DT$new4 <- 1L                 # will make a copy so use :=
attr(DT,"sorted") <- "a"      # will make a copy use setattr() 

如果您想确保通过引用进行更新,请使用.Internal(inspect(x))并查看组成部分的内存地址值(请参见Matthew Dowle的回答)。

j中编写:=可以让您按组通过引用进行子赋值。您可以按组通过引用添加新列。这就是为什么在[...]内部以这种方式执行:=的原因:

DT[, newcol:=mean(x), by=group]

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接