包中 C++ 的使用



CRAN 和 BIOC 存储库中约有 20% 的包包含一些本机代码,其中一半以上包含一些 C++ 代码。鉴于 R API 和运行时是为 C(或 Fortran)设计的,并且不能在没有大量工作和限制的情况下可靠地从 C++ 中使用,因此这个数字相当高。为了避免此类代码中出现讨厌的错误,需要深入了解 R 内部,并且在遵循限制时,无论如何都无法从 C++ 中使用太多内容。本文介绍了一些这些技术问题并提供了一些建议。

建议总结如下:不要使用 C++ 与 R 进行接口。如果你需要在本地代码中实现一些计算,请使用 C(或可能是 Fortran),而不是 C++,或者完全避免与 R 运行时进行交互(例如,.C.Fortran 接口很好,事实上,许多外部库都是用 C++ 编写的)。

我写这篇文字主要是基于我帮助包作者获取其 C++ 代码的 rchk(PROTECT 错误查找工具)报告的经验,但相信它们是误报。当我阅读他们代码的引用行时,我经常得出结论,它们确实是误报(与现在相当罕见的 C 不同),但我也会看到在这些行或非常接近的行上使用 C++ 与 R API 时存在一些问题。不幸的是,这些问题非常普遍,可能导致崩溃和其他难以找到的错误。

RAII

RAII(资源获取即初始化)是一种特性/惯用法,有时被认为是 C++ 相对于 C 的核心创新。它允许轻松地在 C 堆栈上分配内存,并在堆栈展开时安全地释放它,无论是沿着正常返回还是 C++ 异常。明智地使用它可以实现优雅而快速的范围内存管理。事实上,还有更多内容,但其他内容也可以在 C 中获得,即使可能以不太优雅的方式。

不幸的是,RAII 不适用于 C 运行时为异常处理提供的 setjmp/longjmp 函数。在发生远跳转的情况下,不会执行静态分配的局部变量的析构函数。这是 C/C++ 运行时的特性,也是 C++ 异常和 setjmp/longjmp 的性能目标和实现之间的不兼容性的结果。通常,C++ 异常被设计为在不发生异常时具有最小的开销,因为它们用于实现错误路径。但是,远跳转在发生时必须非常快,因为它们用于语言解释器中解释语言的控制流;即使不发生这些跳转,支付一些性能开销也是有意义的。不过,事实上,即使会造成一些性能开销,远跳转也不能运行析构函数,这令人沮丧。

R 内部使用 setjmp/longjmp 来实现解释循环和返回语句的控制流(有时,但并非总是,字节码编译器允许消除长跳转),但也用于错误处理。R 错误,例如调用 error() 的结果或从 R 堆分配时分配失败,会导致长跳转。如果从 C++ 调用,长跳转将不会运行析构函数。

因此,这意味着不能依赖于在 C++ 中实现的包中运行析构函数。堆栈上的内存仍将被释放(长跳转将执行此操作),但使用 new 运算符分配的内存,例如在静态分配对象的构造函数中,并在该对象的析构函数中使用 delete 取消分配,将不会被释放,从而导致内存泄漏。这是一个常见的错误。

R 在进行长跳转之前恢复保护堆栈深度,因此,如果 C++ 析构函数包含例如 UNPROTECT(1) 调用以恢复保护堆栈深度,则无关紧要,因为它不会被执行,因为 R 将自动执行此操作。不幸的是,这是唯一可以在析构函数中安全执行的操作,但一个常见的错误是编写析构函数来执行更多操作。

包装 R API 调用

无法轻易猜测哪些 R API 函数可能会长跳转,而且这可能会在 R 版本之间发生变化,恕不另行通知。在 C 中编程时,这不是问题,长跳转将导致标准 R 错误处理。在 C++ 中编程时,如果要使用析构函数(而且,没有析构函数的 C++ 可能相当无用),唯一的选项是使用将长跳转转换为 C++ 异常的代码包装所有 R API 调用,或者将运行一些清理代码。可以使用 R_UnwindProtect 等方式进行此转换,但这远非易事;请参阅 编写 R 扩展 6.12,但需要一些冗长的编码/样板。Rcpp 目前使用此 API。

如果 R 长跳转转换为 C++ 异常,那么当代码从 C++ 返回到 C(R 运行时)时,这些异常也需要转换回长跳转。

函数返回时的 PROTECT 错误

即使我们将长跳转转换为 C++ 异常并返回,不幸的是,析构函数还有另一个问题。根据惯例返回 SEXP 的函数返回时不保护它,而由调用者保护它。但是,如果在该函数退出时运行的任何析构函数进行分配,则 R GC 可能会运行,并且它可能会在返回值之前销毁该值。不幸的是,在这样的析构函数中,我们无法访问保存该对象的变量,因此无法保护它。因此,应该避免在析构函数中从 R 堆分配,但鉴于几乎任何 R API 函数都可以分配,这很难:只需不要从析构函数调用任何 R API 函数即可。

我们在 NAM 包中发现了这样的错误(由使用 ASAN 的 CRAN 检查检测到,但需要一些时间来分析):一个 Rcpp 函数在析构函数中使用了 Rcpp RNGScope 对象,该对象恢复了随机数生成器的状态。不幸的是,这意味着它必须调用 R API(PutRNGstate),该 API 会分配,因此可能会运行 GC,进而销毁该函数要返回的值。事实上,调试这些事情远非易事,在这种情况下,我们很幸运 ASAN 捕获到了它。

当一个函数的返回值传递给另一个函数时,类似的错误很容易发生在各种运算符和复制构造函数中。如果其中一些调用是隐式的,那么调用者很容易忘记保护它。

内存泄漏和异步去初始化

在用纯 C 编写的包中也可能发生动态分配的内存泄漏,但我经常在与 R 交互的 C++ 代码中看到它们:使用 new 分配的内存,使用 delete 释放,其间调用 R API,通常甚至显式调用 error,并且不尝试从长跳转中恢复(如果长跳转转换为 C++ 异常,则必须处理它们)。如果发生错误,则此内存将永久泄漏。使用 C 时,可以使用自动释放的 R_alloc,并且也可以在长跳转时释放(请参阅 编写 R 扩展 6.1.1)。

这可以通过使用带有析构函数的静态分配对象(以防我们已将转换后的长跳转转换为异常)或使用带有终结函数的 R 对象来解决。可以在 R 堆上创建这样的虚拟 R 对象,保护它,使用 delete 为它提供一个终结函数,并在函数结束时取消保护它,如果这是释放应该发生(或可能首先发生)的地方。

通过这种方式,可以获得类似于析构函数的东西,它最终将运行(例如,R 关闭除外),但不会与范围结束同步,因此不是 RAII。此惯用语可用于代替 C++ 析构函数,例如,当长跳转转换未就位时,但它也会添加一些样板代码。从终结函数回调到 R 时必须小心,因为 R 实际上不是可重入的(请参阅 编写 R 扩展 5.13),但不必像在析构函数中那样小心,因为正如我所提到的,不应调用任何可能分配的函数。

自动取消保护

如果 R 是使用 C++ 接口在 C++ 中实现的,它可能具有一些形式的自动取消保护:对象将在超出范围时自动取消保护(使用 RAII),这将避免某些类型的保护不平衡错误。无法在标准 C 中获得此功能。

以 C++ 实现的包有时会采用某种形式的自动取消保护,但我不会将包从 C 切换到 C++ 仅仅是为了获得自动取消保护,我认为使用标准 API 有利于更好的维护和工具支持。使用 rchk 工具可以非常容易地找到保护不平衡错误,现在定期运行该工具来检查 CRAN 包并在容器中提供该工具,并且它们并不像其他保护错误那样常见(通常会忘记保护)。该工具通常还可以找到此类更严重的保护错误,但很少在使用非标准 API 时找到(自动取消保护会混淆该工具)。

此外,之前的限制适用。自动取消保护不能简单地使用 R_PreserveObject/R_ReleaseObject,因为长跳转绕过了析构函数,因此不会释放对象(除非阻止/转换了长跳转)。自动取消保护不应出于我之前描述的原因使用 UNPROTECT_PTR按值取消保护)。原则上,自动取消保护可以执行类似于 UNPROTECT(n) 的操作,但确实需要注意,C++ 对象不是动态分配的,或者 n 对于分配的所有对象都是相同的,否则析构函数可能会按错误的顺序运行并导致保护错误或内存泄漏。如果将长跳转转换为异常并返回,则使用 R_PreserveObject/R_ReleaseObject 的解决方案似乎最安全,但它也需要大量工作来进行转换。

摘要

当我开始使用 rchk(PROTECT 错误查找工具)时,我首先想使用纯 C 与 LLVM 进行接口。即使存在 C 接口,我也很快遇到了问题,因为它记录不佳、相当笨拙且使用不多。LLVM 是用 C++ 编写的,使用它的预期和支持方式实际上是通过其 C++ 接口。幸运的是,我在一开始就切换到了 C++,并完全用 C++ 编写了该工具。

要从本机代码与 R 进行接口,正确的接口是 C。除了避免我在这里描述的问题之外,它还是 R Core 记录、支持和维护的接口语言,与必须遵循的各种限制和底层规则一起描述,都在一个地方。使用 C 接口使代码比任何外部包装器接口更容易审查和调试。在 C 接口之上使用复杂 C++ 代码需要将事情追溯到原始 C 接口,并考虑限制(例如析构器做什么,以及如何以及何时修改对象等,这些东西比在原始接口中更难找出)。

对于那些需要使用 C++ 的人来说,例如与外部库进行接口,其中唯一有意义的接口是 C++,最好的选择是从 C++ 代码以任何方式避免与 R 进行接口(例如通过 .C 接口扩展 R,如果通过 .Call,则使用 C 层进行彻底隔离)。此类 C++ 代码将在 C 堆(不是 R 堆,但也许是允许 R 语义修改的现有对象的指针除外)上操作对象,并且绝不会以任何方式调用 R。

已经使用 C++ 的软件包最好由其作者仔细审查和修复。当 C++ 的使用非常有限且易于避免时,也许最好的选择就是这样做,否则可以使用我在这里描述的一些技巧。请注意,使用 Rcpp 不会让软件包作者免于思考这些问题:事实上,使用 Rcpp 仍然可以直接调用 R API,但即使避免了这种情况,也可以通过不正确地使用现有对象(例如 RNGScope 示例)、通过引入自己的对象(从析构器分配 R API 调用)的复杂析构器或在不考虑异常的情况下动态分配内存来引入 PROTECT 错误。