【问题标题】:Updates to data.table when passed as a function argument作为函数参数传递时对 data.table 的更新
【发布时间】:2021-04-22 00:32:10
【问题描述】:

当我将 data.table 作为参数传递给函数时,我可以在被调用函数中“通过引用”更新该表,并将结果应用于原始对象。 但是,如果我做一些需要“深拷贝”的事情(例如 rbindlist 添加行),则该副本仅存在于被调用的函数中。原始对象在调用框架中保持不变。

library(data.table)
l1 <- function(a1, action='update'){
  b <- l2(a1, action)
  print('l1')
  print(a1)
}
l2 <- function(a2, action){
  c <- l3(a2, action)
  print('l2')
  print(a2)
}
l3 <- function(a3, action){
  if (action == 'update') a3[, col2 := col + 1]
  if (action == 'append') a3 <- rbindlist(list(a3, data.table(col = c(21, 22))), fill=TRUE)
  if (action == 'forceupdate') assign('DT', 
                                      rbindlist(list(a3, data.table(col = c(21, 22))), fill=TRUE),
                                      envir = parent.frame(3))
  print('l3')
  print(a3)
  a3
}
DT <- data.table(col = c(1, 2, 3))
print(DT)
#>    col
#> 1:   1
#> 2:   2
#> 3:   3
l1(DT, 'update')
#> [1] "l3"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> [1] "l2"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> [1] "l1"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
print(DT)
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4

l1(DT, 'append')
#> [1] "l3"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> 4:  21   NA
#> 5:  22   NA
#> [1] "l2"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> [1] "l1"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
print(DT)
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4

l1(DT, 'forceupdate')
#> [1] "l3"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> [1] "l2"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> [1] "l1"
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
print(DT)
#>    col col2
#> 1:   1    2
#> 2:   2    3
#> 3:   3    4
#> 4:  21   NA
#> 5:  22   NA

reprex package (v1.0.0) 于 2021 年 4 月 22 日创建

在此示例中,有一个 3 级的函数调用堆栈。第一级的参数向下传递堆栈并在函数 l3 中更新。

使用 data.table 更新语法,l3 向对象添加一个新列,这导致原始对象被“就地”更改,结果在调用堆栈的每个级别中都可以看到。

但是,如果我使用 rbindlist 添加行,则会在 l3 的框架内进行复制,这不会影响原始对象或父调用中的任何视图。

如果我将更改分配回“原始框架”,那么它会在那里看到,但中间调用看不到更改。

有没有办法将这个“深拷贝”的结果反映到调用堆栈上?

如果assign 是要走的路,我会很感激一个例子,说明如何建立底层数据对象的名称和环境,以便无需硬编码即可进行此分配。

【问题讨论】:

  • 如果你总是想用deep copy,那么就用copy(dt)吧。
  • 谢谢和平。然而,这里的问题是深拷贝的影响以及结果表最终所在的框架/环境。如果复制是在本地完成的,则调用函数无法看到结果,如果将其分配回原始帧,则中间函数无法看到结果。我希望它的行为与引用更新一样。
  • 在我需要向 data.table 添加行的情况下,我也遇到了这个问题,所以如果出现聪明的答案,我很感兴趣。目前,在这种情况下,我有时会选择&lt;&lt;-,它会逐步向上搜索到父环境,直到它到达将创建某些东西的全局。它不完美,但可以满足您的需求。

标签: r data.table pass-by-reference


【解决方案1】:

(请不要在生产中随意使用它。:-)

我认为这个问题确实是Insert a row in a data.tableAdd a row by reference at the end of a data.table objectHow to delete a row by reference in data.table?的重复。已经多次说过通过引用添加行的能力“可以”完成,但不是微不足道的(而且还没有完成)。归根结底,独角兽data.table::insert 函数不存在(还)。

虽然我不喜欢使用 &lt;&lt;-assign 的前提,但这里有一个 *hack* 可以实现这一点......但有很多警告。

func <- function(.x) {
  nm <- deparse(substitute(.x))
  stopifnot(
    "'.x' must be a whole data.table" =
      nm == make.names(nm)
  )
  env <- parent.frame()
  while (!is.null(env) && !exists(nm, envir=env, inherits=FALSE)) {
    env <- parent.env(env)
  }
  stopifnot(!is.null(env))
  assign(nm, rbindlist(list(.x, .x[1,])), envir=env)
}

它正在按预期工作:

DT <- data.table(col1=1:3,col2=11:13)
DT
#     col1  col2
#    <int> <int>
# 1:     1    11
# 2:     2    12
# 3:     3    13
func(DT)
DT
#     col1  col2
#    <int> <int>
# 1:     1    11
# 2:     2    12
# 3:     3    13
# 4:     1    11

但是,如果我们传递一个过滤/子集的表,我们应该错误:

func(DT[1,])
# Error in func(DT[1, ]) : '.x' must be a whole data.table

为什么?请允许我演示一下。首先,我要修改func,以便我们找到原来的DT,而不仅仅是部分:

func <- function(.x) {
  nm <- deparse(substitute(.x))
  nm <- gsub("[[({].*", "", nm) # turns 'DT[1,]' --> 'DT'
  env <- parent.frame()
  # ... everything else here
}

DT <- data.table(col1=1:3,col2=11:13)
func(DT[1,])
DT
#     col1  col2
#    <int> <int>
# 1:     1    11
# 2:     1    11

DT <- data.table(col1=1:3,col2=11:13)
func(DT[,1])
DT
#     col1
#    <int>
# 1:     1
# 2:     2
# 3:     3
# 4:     1

func(copy(DT))
# Error in assign(nm, rbindlist(list(.x, .x[1, ])), envir = env) : 
#   cannot change value of locked binding for 'copy'

所有这三种情况在普通 R 代码中都是合理的,其中函数可能只需要数据的一个子集。并且所有三个都证明了尝试根据传递给函数的数据对原始对象进行足够的推断是危险的。我敢肯定有人可以在func 中添加一些逻辑,这样它就可以捕捉到其中一些条件,但这比我已经达到的 *hack* 水平更远了.

这个练习的目的是证明在调用者知道风险并且总是通过整体(不是子集/过滤器)data.table的理想情况下,这个函数将有效地添加行参考。但正如我们所知,它不是 by-ref,它只是将诡计和记忆膨胀隐藏在烟雾和镜子后面。并且在此过程中失去了一点data.table 的隐秘/惊人的效率。

(请不要在生产中随意使用它。:-)

【讨论】:

  • 非常有用的分析@r2evans。 data.table::insert 将解决问题,而您的 func 解决了我的问题的最后一部分。然而,此分配的进一步限制是它确实更新了基础表,但调用堆栈中的中间函数看不到这一点,它通过“引用”传递了表。
  • 没错,它有很多问题。我不建议这个答案是data.table-devs 应该考虑的首选或规范方法,它只是一个 **hack*。另一种方法是通过环境传递data.table 对象并在环境中进行所有更改/添加。这允许通过引用语义,但调用者和被调用者都需要更多的努力。
【解决方案2】:

虽然您不能通过引用 data.table 对象来添加行,但您可以预先分配额外的行以供将来通过引用进行更新。根据您的实际用例,这可能是一种值得考虑的方法。

在下面的示例中,我首先将 1000 行分配给 DB 表,即使起始 DT 只有 3 个值。然后我可以运行任意数量的“插入”,只要不超过原始分配。

## Create a "Data Base" table with an over-allocated number of rows
DB <- data.table(ID = seq_len(1e3),
                 InUse = FALSE,
                 col1 = as.integer(NA),
                 col2 = as.integer(NA), key = "ID")


## Starting Table DT
DT <- data.table(InUse = TRUE,
                 col1 = c(1, 2, 3),
                 col2 = as.integer(NA))

## Add an ID lookup key
DT[, ID := seq_len(.N)]


## Populate the "Data Base" from Starting DT
setkey(DT,ID)
DB[DT, c("InUse","col1","col2") := .(i.InUse,i.col1, i.col2)]


l3 <- function(x, action){
  if (action == 'update') x[, col2 := col1 + 1L]
  if (action == 'append') {
    ## Define rows to be appended
    NR <- data.table(InUse = TRUE,
                     col1 = sample.int(10L,sample.int(n = 3L, size = 1L)),
                     col2 = as.integer(NA))

    ## Allocate unused ID's for new rows
    NR[,ID := seq_len(.N) + x[InUse == TRUE,ID[.N]]]
    
    ## Allocate values for new rows by joining on ID
    x[NR, c("InUse","col1","col2") := .(i.InUse,i.col1, i.col2), on = .(ID)]
    
    }
  
}


set.seed(1L)

DB[InUse == TRUE]
#    ID InUse col1 col2
# 1:  1  TRUE    1   NA
# 2:  2  TRUE    2   NA
# 3:  3  TRUE    3   NA
l3(DB, "update")

DB[InUse == TRUE]
#    ID InUse col1 col2
# 1:  1  TRUE    1    2
# 2:  2  TRUE    2    3
# 3:  3  TRUE    3    4

l3(DB,"append")
l3(DB, "update")
DB[InUse == TRUE]
#    ID InUse col1 col2
# 1:  1  TRUE    1    2
# 2:  2  TRUE    2    3
# 3:  3  TRUE    3    4
# 4:  4  TRUE    4    5

l3(DB,"append")
l3(DB, "update")
DB[InUse == TRUE]
#    ID InUse col1 col2
# 1:  1  TRUE    1    2
# 2:  2  TRUE    2    3
# 3:  3  TRUE    3    4
# 4:  4  TRUE    4    5
# 5:  5  TRUE    1    2
# 6:  6  TRUE    2    3
# 7:  7  TRUE    5    6

这实际上是相当高效的,生成要添加的新行是该过程中最慢的部分。在下面的示例中,我将函数简化为仅“插入”的情况,使用 data.table::set 来提高性能,使用两个参数 - DB 用于“数据库”,表 new 用于插入行。

insert <- function(x, new){
  
  ## Allocate unused ID's for new rows
  set(new, j = "InUse", value = TRUE)
  set(new, j = "ID", value = seq_len(nrow(new)) + x[InUse == FALSE,min(ID)] - 1L)
  setkey(new,ID)
  
  ## Allocate values for new rows
  x[NR, c("InUse","col","col2") := .(i.InUse,i.col, i.col2), on = .(ID)]
  
}

## Create a "Data Base" table with an over-allocated number of rows
DB <- data.table(ID = seq_len(1e5),
                 InUse = FALSE,
                 col1 = as.integer(NA),
                 col2 = as.integer(NA), key = "ID")

NR <- data.table(col = 1L,
                 col2 = 2L)

microbenchmark(insert(DB,NR),times = 1000)
# Unit: milliseconds
#           expr      min       lq     mean   median       uq      max neval
# insert(DB, NR) 4.081703 4.231624 4.645903 4.278256 4.362353 60.53741  1000

有很多关于数据库“插入速度”的性能指标,它们都因要插入的数据的大小/形状而异,但 4.6 毫秒(每秒 217 次插入)对于我可以想象的很多用例来说似乎足够快(也许是一个闪亮的应用程序)

【讨论】:

  • 在可以确保预分配足够的控制良好的情况下非常有用。如果预分配即将被破坏,我已经看到按块“扩展”的代码,但这再次需要“深拷贝”。
猜你喜欢
  • 2022-01-02
  • 1970-01-01
  • 2015-12-04
  • 1970-01-01
  • 1970-01-01
  • 2019-11-29
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多