常见的 PROTECT 错误



此文章介绍了包中存在的常见 PROTECT 错误,基于对 ~100 个剩余的 CRAN 包的手动检查,其中包含来自 rchk 的报告。

背景

任何与 R 交互的 C/C++ 代码(在 R 本身和包中)都需要告知垃圾收集器 R 堆上的哪些对象可从局部变量访问。指向此类对象的指针保留在指针保护堆栈或珍贵列表或多集中,但最常见的是带有 PROTECT/UNPROTECT 宏的指针保护堆栈。未保护稍后访问的对象是一个常见错误,这可能导致结果不正确或崩溃,此类错误通常很难找到,因为它们可能由碰巧在 GC 运行时发生变化的无关紧要的更改触发。

帮助查找 PROTECT 错误的工具之一是 rchk,它会定期为 CRAN 包运行,如果发现任何潜在问题,报告将显示在 CRAN 包检查结果的“其他问题”下。有关指针保护 API 的更多信息,请参见 编写 R 扩展,有关如何使用 API 的更多建议,请参见 此处Gctorture 是 R 中可用于发现 PROTECT 错误的运行时工具。

运行 rchk

可以在 Linux 上本机安装 rchk(已在 Debian、Ubuntu、Fedora 上测试),这是我的使用方法,对于任何使用 C 编程的人来说,安装过程应该足够容易。还有一个 vagrant 脚本,可将 rchk 自动安装到以 Ubuntu 为 guest 系统的 virtualbox 中(这适用于包括 Linux、Windows 和 macOS 在内的 host 系统)。最近还出现了带有 rchk 的预构建 singularity 容器(guest 系统为 Ubuntu)。有关详细信息,请参见 rchk 文档。事情可以像

singularity pull --name rchk.img shub://kalibera/rchk:def
singularity run rchk.img jpeg

一样简单,以检查 jpeg 包的当前 CRAN 版本。实际上可以提供 tarball。安装需要它们的 R 包的系统依赖项需要一个 singularity 覆盖(此处已涵盖)。

未保护新对象

令人惊讶的是,经常会看到一系列调用,特别是调用 coerceVector() 或对其进行包装的某些宏,而没有必要的保护

SEXP MM = coerceVector(_MM, INTSXP);
SEXP NN = coerceVector(_NN, INTSXP);

此处对 coerceVector() 的第二次调用可能会运行 GC,并可能会破坏 MM,即对 coerceVector() 的前一次调用的结果。在第二次调用之前确实需要保护 MM 以防止这种情况发生。

应该保守地假设所有函数都会分配,并且所有返回 SEXP 的函数实际上都会返回一个需要保护的新鲜 SEXP。原因是这可能会改变,可以在以前不存在的代码路径中引入对象的分配或复制。此外,分配可能存在于人们想不到的函数中(例如,从框架中读取变量,它可以运行活动绑定)。

rchk 仍然努力找出哪些函数会分配,并且在将未受保护的变量暴露给不会分配的函数时不会报告错误,只要该工具能够看到。此问题的报告会说类似于“在调用分配函数 Rf_coerceVector 时未保护变量 MM”。

使用未受保护参数的分配参数表达式

调用者始终负责确保传递给函数的所有参数都已受到保护。从历史上看,一些核心 R API 函数是被调用者保护,它们保护自己的参数并在整个调用期间保持它们的保护。最好不要依赖此属性,但通常会这样做,并且 rchk 尝试检测被调用者保护的函数并且不报告错误。

不依赖此属性的明显原因是因为它可能会改变。还有一个细微的细节,即参数是否受到保护直到函数结束,或者仅受到保护直到函数需要它的最后一刻,并且混淆这两个可能会引入错误。一个不太明显的原因是人们可以轻松地引入另一个错误

lang3(R_BracketSymbol, lang2(R_ClassSymbol, R_NilValue), ScalarReal(cur_class_i + 1)

在此示例中,使用两个分配参数调用被调用者保护的函数。lang3 确实会保护其参数,但这无济于事:在对 lang2() 的分配调用期间,对 ScalarInteger() 的调用的未受保护结果可能会被破坏,远在 lang3() 被调用之前。这是一个非常常见的问题。

另一种变体

setAttrib( ans, install("class"), mkString2( "srcref", 6 ) );

此处由 mkString2() 分配但未受保护的对象可能会被对 install() 的调用破坏。请注意,install() 将符号放置在垃圾收集器可以找到它们的符号表中,因此不必对它们进行保护,但当符号表中尚未找到符号时,该函数仍会分配。原则上,在这种情况下,一些常见符号(如“class”)将位于符号表中,因为 class() 是 API 的一部分,但人们永远不应该依赖它。

不太明显的变体

setAttrib( ans, install("srcfile"), srcfile );

如果srcfile未受保护,这仍然是错误的。它会被install()的调用所破坏。请注意,C 中函数参数的求值顺序是未定义的,因此不应该编写仅在反向顺序中正确的代码。

rchk通常将这些错误报告为“对(两个或更多个未受保护的参数)的可疑调用”,这是来自maacheck工具(rchk的一部分)。特别是这些报告应该被认真对待,因为它们很少出错(尽管如此,该工具有时可能会保守地假设一些复杂函数在分配时实际上没有分配)。

过早取消保护

几个包过早取消了对象的保护。我不确定是如何发生的,但也许在函数结束时取消所有对象的保护不太容易出错,除非有真正的危险,即内存会被阻止太长时间而无法重新使用。

PROTECT(destVector = allocVector(REALSXP,ssize));
for (i = 0; i < ssize; i++){
   REAL(destVector)[i] = working_space[shift+i];
}
UNPROTECT(1);
PROTECT(f = allocVector(INTSXP,fNPeaks));

在此示例中,destVector在填充之前已得到适当保护,但随后取消保护,然后调用allocVector(),稍后(此处未显示)再次读取destVector

一个不太明显但常见的示例是在从函数返回之前调用假定不会分配的函数

PROTECT(myint = NEW_INTEGER(len));
p_myint = INTEGER_POINTER(myint);
for(int i=0;i<n;i++) p_myint[i] = sigma_0[i];
UNPROTECT(2);
PutRNGstate();
return myint;

函数PutRNGstate()分配。我知道我一直在重复自己,但包编写者不应该对任何函数假设它不会分配。这些事情可能非常令人惊讶,而且它们可能会改变,并且会超出它们的控制。

向普通函数传递未受保护的参数

参数必须由调用者保护。一个常见的错误是向函数传递未受保护的参数,然后在使用参数之前破坏参数

PROTECT( ret = NEW_OBJECT(MAKE_CLASS( TIME_CLASS_NAME )));

此处,由MAKE_CLASS()分配的NEW_OBJECT()参数可以在读取之前被NEW_OBJECT()破坏。它必须受到保护。

Rf_eval(Rf_lang3(symbols::new_env, Rf_ScalarLogical(TRUE), parent), R_BaseEnv);

此处,由Rf_lang3()分配的参数可以在使用之前被Rf_eval()破坏,在传递给Rf_eval()之前必须对其进行保护。

这些规则也适用于包中定义的函数:采用多个 SEXP 参数的函数应该能够假定这些参数受到保护。

返回分支上的保护不平衡

每个函数都应保持指针保护平衡:当函数正常退出(不通过长跳转)时,指针保护堆栈大小应与函数被调用时相同(并且保护堆栈甚至在内容方面也应相同)。

  SEXP sBC = PROTECT(allocVector(REALSXP, rank==0 ? n : 0));
  if (rank == 0) {
    if (REAL(sBC) == NULL) {
        REprintf("Rank %d: error!\n", rank);
        return NULL;
    }
  }

在此示例中,在以 return NULL 结尾的返回路径上,函数在指针保护堆栈上保留了(至少)一个额外的指针(在此示例中,分支实际上已失效,因为 REAL(sBC) 永远不会为 NULL,但它仍然说明了问题)。

忘记在调用 return 的路径上取消保护是一个常见错误。rchk 报告函数中的指针保护不平衡(“可能存在保护堆栈不平衡”),但需要手动查找其原因(查找 return 语句并检查周围的 UNPROTECT 通常有效)。

总结

编写 C 代码,特别是针对 R 的 C 代码,需要承担责任。其中一项责任是确保 R 对象受到适当的 PROTECT(直接在 R 中编程时,无需担心)。

rchk 结果已在 CRAN 上发布近两年,CRAN 团队一直在不懈地提醒软件包维护者检查其报告。该工具无法找到所有 PROTECT 错误,特别是它已调整为报告更少的误报。结果,现在 CRAN 软件包的几乎所有剩余报告都是真正的错误,其中大多数都可以轻松修复(困难的部分是找到它们的位置,但这项工作已经完成)。

剩余的许多误报都是无论如何都应该修复的问题(例如,该工具假定 getAttrib(x, R_NamesSymbol) 返回一个新对象,即使 x 是 R 当前版本将通过 x 间接保护某个对象的类型)。

本文基于对 CRAN 软件包的所有剩余 rchk 报告的手动检查而撰写(并且已向软件包维护者报告了明显的错误),希望这可以使软件包的数量减少,其中 rchk 可以检测到 PROTECT 错误,并保持较低水平。