查看导致段错误的 cpp 代码

Review cpp code causing segfault

我在 R 函数中有一些 运行 的 cpp 代码,调用了大约 80k 次。它的测试套件全面且通过。它在前 60k 次被调用时似乎 运行 没问题,然后在中间的某个地方,我遇到了段错误。

*** Error in `/usr/lib/R/bin/exec/R': malloc(): memory corruption: 0x00000000047150f0 ***
======= Backtrace: =========
/lib/x86_64-linux-gnu/libc.so.6(+0x77725)[0x7f684462e725]
/lib/x86_64-linux-gnu/libc.so.6(+0x819be)[0x7f68446389be]
/lib/x86_64-linux-gnu/libc.so.6(__libc_malloc+0x54)[0x7f684463a5a4]
/usr/lib/R/lib/libR.so(Rf_allocVector3+0x70d)[0x7f6844cd617d]
... # more

这是我的一些代码示例,您能看出其中有什么问题吗?

Return a LogicalVector(例如 TRUE/FALSE 向量),其中前导 NA 标记为 TRUE

#include <Rcpp.h>

using namespace Rcpp;

// [[Rcpp::export]]
LogicalVector leading_na(IntegerVector x) {
  int n = x.size();
  LogicalVector leading_na(n);

  int i = 0;
  while(x[i] == NA_INTEGER) {
    leading_na[i] = TRUE;
    i++;
  }
  return leading_na;
}

Return 一个 LogicalVector(例如 TRUE/FALSE 向量),其中尾随 NA 标记为 TRUE

// [[Rcpp::export]]
LogicalVector trailing_na(IntegerVector x) {
  LogicalVector trailing_na = leading_na(rev(x));
  return rev(trailing_na);
}

从 zoo 包中复制 na.locf(x, na.rm = TRUE) 的功能:

// [[Rcpp::export]]
IntegerVector na_locf(IntegerVector x) {
  int n = x.size();
  LogicalVector lna = leading_na(x);

  for(int i = 0; i<n; i++) {
    if((i > 0) & (x[i] == NA_INTEGER) & (lna[i] != TRUE)) {
        x[i] = x[i-1];
      }
  }
  return x;
}

Return向量中有数字的最后一个位置:

// [[Rcpp::export]]
int max_x_pos(IntegerVector x) {
  IntegerVector y = rev(x);
  int n = x.size();
  LogicalVector leading_na(n);

  int i = 0;
  while(y[i] == NA_INTEGER) {
    i++;
  }

  return n-i;
}

为了解决主要问题,您会收到看似随机的段错误,因为您的代码包含未定义的行为——特别是数组边界违规。由于您之前指出您是 C++ 的新手,因此值得您至少通读讨论此特定错误的第一个答案 to this question。对于从其他语言转向 C 或 C++ 的人来说,UB 可能是一个难以理解的概念,主要是因为它并不总是以错误的形式出现。行为是字面意思未定义,所以没有办法知道结果会是什么,你也不应该期望跨平台的行为是一致的、编译器,甚至编译器版本。

我会用你的leading_na函数来演示,但是max_x_pos函数也有同样的问题:

// [[Rcpp::export]]
Rcpp::LogicalVector leading_na(Rcpp::IntegerVector x) {
    int n = x.size();
    Rcpp::LogicalVector leading_na(n);

    int i = 0;
    while (x[i] == NA_INTEGER) {
        // ^^^^  
        Rcpp::Rcout << i << "\n";

        leading_na[i] = TRUE;
        i++;
    }

    return leading_na;
} 

由于没有任何强制约束 i < n,当 x 仅包含 NA 元素时,代码继续计算 x[n](以及可能的后续索引), 这是未定义的。然而,对于较小的向量,这在我的机器上运行得很好(我最终设法让它在较大的输入下崩溃),这正是与 UB 相关的错误很难识别的原因:

leading_na(rep(NA, 5))
# 0
# 1
# 2
# 3
# 4
# [1] TRUE TRUE TRUE TRUE TRUE 

然而,当我们将operator[]替换为at()成员函数时,它执行相同的元素访问,但在运行时also does bounds checking,错误很明显:

// [[Rcpp::export]]
Rcpp::LogicalVector leading_na2(Rcpp::IntegerVector x) {
    int n = x.size();
    Rcpp::LogicalVector leading_na(n);

    int i = 0;
    while (x.at(i) == NA_INTEGER) {
        Rcpp::Rcout << i << "\n";

        leading_na[i] = TRUE;
        i++;
    }

    return leading_na;
}

然后是

leading_na2(rep(NA, 5))
# 0
# 1
# 2
# 3
# 4
# Error: index out of bounds 

请注意 at 提供的额外边界检查 会带来轻微的性能成本,因为此检查发生在运行时,因此即使它可能是在开发阶段使用 at 而不是 operator[] 是个好主意,一旦您的代码经过全面测试,假设需要更好的性能,恢复到 operator[] 可能是个好主意。


至于解决方案,第一个是保留 while 循环并简单地添加对 i 值的检查:

while (i < n && x[i] == NA_INTEGER) {
    leading_na[i] = TRUE;
    i++;
} 

注意我写的是i < n && x[i] == NA_INTEGER而不是x[i] == NA_INTEGER && i < n。由于 && 执行短路评估,当 i < n 在第一个版本中评估为 false 时,表达式 x[i] == NA_INTEGERnot 评估-- 这很好,因为正如我们所见,这是未定义的行为。

另一种选择是使用 for 循环,这往往会更好地 "reminding" 我们检查我们的边界,根据我的经验,至少:

for (int i = 0; i < n && x[i] == NA_INTEGER; i++) {
    leading_na[i] = TRUE;
}

在这种情况下,选择使用 while 循环还是 for 循环并不重要,只要您选择的内容正确即可。

另一个(或两个)选项是使用迭代器而不是索引,在这种情况下,您可以使用 while 循环或 for 循环:

// [[Rcpp::export]]
Rcpp::LogicalVector leading_na5(Rcpp::IntegerVector x) {
    int n = x.size();
    Rcpp::LogicalVector leading_na(n);

    Rcpp::IntegerVector::const_iterator it_x = x.begin();
    Rcpp::LogicalVector::iterator first = leading_na.begin(),
        last = leading_na.end();

/*
    while (first != last && *it_x++ == NA_INTEGER) {
        *first++ = TRUE;
    }
*/

    for ( ; first != last && *it_x == NA_INTEGER; ++first, ++it_x) {
        *first = TRUE;
    }

    return leading_na;
} 

虽然迭代器是非常有用的设备,但我不确定在这种特殊情况下它们是否比手动索引有任何好处,因此我建议使用前两种方法中的一种。


与段错误无关,您的代码还有其他一些方面值得解决。

  1. 在R中,&&||分别执行原子逻辑与和原子逻辑或,而&|执行矢量化逻辑与和矢量化逻辑或, 分别。在 C++ 中,&&|| 的行为与它们在 R 中的行为相同,但 &| 是(原子的)按位 AND 和(原子)按位 或,分别。偶然地,使用 & 与在上面的函数中使用 && 具有相同的效果,但您需要修复它,因为您的意图是使用逻辑运算,而不是按位运算。
  2. 这更特定于 Rcpp / R 的 C API,但尽管使用 x[i] == NA_INTEGER 确实如此,但实际上,测试 x[i] 是否为 NA,并非所有类型像这样。 IIRC,针对 NA_REAL 进行任何平等测试总是错误的,即使是 NA_REAL == NA_REAL;对于非整数算术类型(数字和复数 (REALSXP / CPLXSXP)),您很可能还想检查值是否为 NaN。 Rcpp 提供了几种不同的方法来执行此操作,具体取决于对象类型。对于任何存储类型的向量,Rcpp::is_na(x) 将 return 与 x 大小相同的逻辑向量。对于原子值,我通常使用 Rcpp::traits::is_na<SEXPTYPE>(x[i]) -- REALSXP 表示 doubleINTSXP 表示 intCPLXSXP 表示 Rcomplex,等等。但是,我认为您可以等效地使用向量的相应静态成员函数,例如Rcpp::NumericVector::is_na(x[i]),等等,在这种情况下你不需要记住各种SEXPTYPE
  3. 严格来说,C++ 或 C 中没有 TRUEFALSE;这些(大概)是 R 的 API 提供的便利类型定义,所以请注意它们不存在于 R 的后端之外。当然,您可以在 Rcpp 代码中随意使用它们,因为它们的行为显然符合预期,但大多数人即使在使用 Rcpp 时也会坚持标准 truefalse
  4. 有点吹毛求疵,但是您的 leading_na 函数声明了一个局部变量,也命名为 leading_na,这有点令人困惑,或者至少是非正统的。
  5. 在处理对象大小时,考虑使用 std::size_t(标准 C++)或 R_xlen_t(R API 特定),例如在这个表达式中:int n = x.size();。这些是 unsigned 整数类型,应该足够大以存储任何对象的长度,其中 intsigned 整数类型这可能足够也可能不足够(通常是)。 99.9% 的情况下,最糟糕的情况是在使用 int 而不是 for (int i = 0; i < x.size(); i++) { // whatever } 等表达式中的其他两个时,您会收到一些额外的编译器警告(不是错误)。在极少数情况下,可能会产生更严重的影响,例如有符号整数溢出(这也是未定义的行为),因此请注意这种可能性很小的情况。

这个答案有点变成了代码审查/肥皂盒咆哮,但希望你能从中找到一些有用的信息。