编写Rcpp包02:与R交互
2022年4月29日
编程
一般而言,要用Rcpp写扩展包,通常是用于处理在R中处理比较慢的过程,或者调用一些在R中无法使用的能力,以提高程序运行的效率。既然这样,必然需要给C++程序传入一些参数,然后将计算结果返回给R。如果R和C++各玩各的,那就没有什么意思了。这篇文章将以斐波那契数列为切入点,介绍R与C++进行交互的方法。 我们主要实现以下三个斐波那契数列相关的函数: - 数列第n项的值 - 数列的前n项列表 - 数列前n项的(加权)和 完整的实现可参考仓库 [HPDell/tutorial-Rcpp/02-DemoRcpp2](https://github.com/HPDell/tutorial-Rcpp/tree/master/02-DemoRcpp2) ,这里将代码分解开进行介绍。 # 数列第n项的值 -- 标量类型的参数与返回值 我们在分析一个Rcpp包的时候,往往先从最外层的R函数开始,一层层阅读,以理解整个包的设计,这样有助于先建立一个总体的概念再去了解细节的实现。但是在写Rcpp包的时候,根据我的经验,最好还是先从最底层的C++函数开始编写,再层层封装。好处有以下几个: - 在具体实现C++函数的时候,比较容易确定所需的参数及其类型; - 通过实现C++函数,可以明确参数需要哪些约束条件,并选择适当的处理位置; - 可以通过一定方法在不编写R函数的时候对C++函数进行测试,甚至进行调试; - 将C++与R的设计部分解耦,有助于日后将C++模块单独抽取成为一个库。 因此本文也将按照这个顺序进行介绍。 ## 库函数 和上一篇一样,库函数是指没有写在 `RcppExports.cpp` 文件中的C++函数,是功能具体实现的函数。我们将自动生成的文件 `rcpp_hello_world.cpp` 重命名为 `fibonacci.cpp` 并删除 `rcpp_hello_world()` 函数,新建函数 `fibonacci_item()` ```cpp // [[Rcpp::export]] int fibonacci_item(size_t n) { int a0 = 0; int a1 = 1; int an = a1; switch (n) { case 0: an = 0; break; case 1: an = 1; break; default: for (size_t i = 2; i <= n; i++) { a0 = a1; a1 = an; an = a0 + a1; } break; } return an; } ``` 这也许不是一个最佳实现,但是可以体现C++编程的特点。注意第一行的注释,有这个注释存在,函数才能导出被Rcpp发现。这个函数中操作的都是C++基本类型,没有涉及Rcpp定义的类型。 ## C++接口函数 C++接口函数的主要工作是将R的数据类型转换为C++基本类型或Rcpp以及其他库所提供的类型。一般而言,我们使用一个 `SEXP` 类型作为R与C++交互的中间类型,即使用这个类型的对象承接来自R的数据,并将结果转换为这个类型的对象返回给R。 我们在 `RcppExports.cpp` 文件中定义库函数的接口 `_DemoRcpp_fibonacci_item()` ```cpp int fibonacci_item(size_t n); RcppExport SEXP _DemoRcpp_fibonacci_item(SEXP nSEXP) { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; Rcpp::traits::input_parameter<size_t>::type n(nSEXP); rcpp_result_gen = Rcpp::wrap(fibonacci_item(n)); return rcpp_result_gen; END_RCPP } ``` 在这个函数中,我们使用一个模板类 `Rcpp::traits::input_parameter<T>::type` 来指定将 `SEXP` 类型转换为何种具体的数据类型 `T` ,也就是模板参数。这个 `type` 类型其实是 `Rcpp::InputParameter<T>` 类型的别名,定义在 `struct Rcpp::traits::input_parameter` 结构体中,而查看 `Rcpp::InputParametert` 类型的定义,可以发现其构造函数就是接受一个 `SEXP` 类型的参数并将其保存在私有变量中,然后在这个类中提供了一个重载了的类型转换运算符 `T` : ```cpp inline operator T() { return as<T>(x) ; } ``` 这样就将变量在 `SEXP` 类型与 `T` 类型之间做显式或隐式类型转换了。在这个例子中,传入 `fibonacci_item()` 的参数需要一个 `size_t` 类型的参数,但是我们接受的参数 `nSEXP` 是一个 `SEXP` 类型的参数,但是我们创建了一个变量 `n` 作为中间变量,它可以隐式地转换为 `size_t` 类型,这样这个参数就可以当作 `size_t` 类型去使用了。 编写完了接口函数,我们将其添加到 `CallEntries` 中, ```cpp static const R_CallMethodDef CallEntries[] = { {"_DemoRcpp_fibonacci_item", (DL_FUNC) &_DemoRcpp_fibonacci_item, 1}, // ^ ^ ^ // R调用名称 函数名 参数个数 {NULL, NULL, 0} }; ``` 这样就完成了C++接口函数的编写。 ## R接口函数与包函数 我们在 `RcppExports.R` 中继续封装,添加下面这个函数 ```R .fibonacci_item <- function(n) { .Call(`_DemoRcpp_fibonacci_item`, n) } ``` 这里 `.Call()` 函数的第一个参数就是上面提到的 `CallEntries` 中记录的调用名称,后面直接跟上所有需要传入的参数即可。 在包函数中,我们只需要调用这个R接口函数 ```R fibonacci_item <- function(n) { .fibonacci_item(n) } ``` 编译我们的包,就可以使用这个函数了。 ```R library(DemoRcpp) fibonacci_item(10) # [1] 89 ``` ## 参数校验 可以发现,在C++函数中,参数 `n` 的类型 `size_t` 表示一个非负整数(无符号长整型)。但是在R函数中,参数 `n` 是任意类型。这是我们就需要对 `n` 进行参数校验,在不符合条件时停止调用。这就是参数校验过程。修改后我们的包函数就变成了这个样子。 ```R fibonacci_item <- function(n) { if (!(is.numeric(n) || is.integer(n))) { stop("Parameter n must be numeric or integer.") } else if (n < 0) { stop("Parameter n must be a positive number.") } .fibonacci_item(n) } ``` 但事实上,我们总共写了四个函数,理论上为了保证健壮性,每个函数都有可能需要校验。如果每个函数都写校验,不仅写起来非常麻烦,事实上也容易拖慢运行速度。因此其实可以省略一些,尽量将校验的层级提高。比如这个包里面,除了包函数以外,其他都是内部函数,不会被其他包或库调用,而且这些函数也只会在一个地方被调用,所以就在包函数里面做参数校验就可以了。但是如果接口函数在多个函数中被调用了,或者库函数有可能被其他库调用了,那么还是给这些接口函数与库函数添加参数校验比较好。但总体上而言,具体在什么位置进行参数校验,还是要具体情况具体分析,结合一定的开发经验,最终确定其位置。 # 向量类型的参数与返回值 之前的例子中,我们只介绍了C++原生类型的参数。但是在R中,向量是使用更为广泛的一种数据类型,那么这种类型如何在Rcpp中进行对接呢? ## 数列的前n项列表 -- 向量类型返回值 Rcpp提供了一系列的类型用于表示向量 - 浮点型 `NumericVector` - 整型 `IngeterVector` - 字符串型 `CharacterVector` - 布尔型 `LogicalVector` 详细用法可参考 [Data types](https://teuder.github.io/rcpp4everyone_en/070_data_types.html#vector-and-matrix) 中的介绍。 如果我们要返回一个向量,例如数列前 n 项的列表,就可以使用 `IntegerVector` 作为函数的返回值,例如 ```cpp // [[Rcpp::export]] IntegerVector fibonacci_seq(size_t n) { IntegerVector fib_seq(n); switch (n) { case 0: break; case 1: fib_seq[0] = 1; break; case 2: fib_seq[0] = 1; fib_seq[1] = 1; break; default: fib_seq[0] = 1; fib_seq[1] = 1; for (size_t i = 2; i < n; i++) { fib_seq[i] = fib_seq[i-1] + fib_seq[i-2]; } break; } return fib_seq; } ``` 接口函数和之前的没有什么太大区别,直接将 `IntegerVector` 包装成一个 `SEXP` 类型的返回值 ```cpp IntegerVector fibonacci_seq(size_t n); RcppExport SEXP _DemoRcpp_fibonacci_seq(SEXP nSEXP) { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; Rcpp::traits::input_parameter<size_t>::type n(nSEXP); rcpp_result_gen = Rcpp::wrap(fibonacci_seq(n)); return rcpp_result_gen; END_RCPP } ``` 这样我们就可以在C++中创建向量,返回给R。 ## 数列前n项的加权和 -- 向量类型的参数 如果要接受向量作为参数,也是一样的。例如我们写函数 `fibonacci_sum()` 计算前数列前n项的加权和,权重参数为 `weights` ,就可以这样实现 ```cpp // [[Rcpp::export]] double fibonacci_sum(size_t n, NumericVector weights) { IntegerVector fib_seq = fibonacci_seq(n); double sum = 0.0; for (size_t i = 0; i < n; i++) { sum += fib_seq[i] * weights[i]; } return sum; } ``` C++接口函数可以实现为 ```cpp double fibonacci_sum(size_t n, NumericVector weights); RcppExport SEXP _DemoRcpp_fibonacci_sum(SEXP nSEXP, SEXP weightsSEXP) { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; Rcpp::traits::input_parameter<size_t>::type n(nSEXP); Rcpp::traits::input_parameter<NumericVector>::type weights(weightsSEXP); rcpp_result_gen = Rcpp::wrap(fibonacci_sum(n, weights)); return rcpp_result_gen; END_RCPP } ``` 我们使用 `Rcpp::traits::input_parameter<NumericVector>::type` 类型的变量做中间变量转换。 ## 使用C++标准库中的类型 上述方法在编写库函数的时候,有时会比较麻烦,因为这样会将库函数与R进行绑定,能够使用的方法就限制在了Rcpp提供的方法中。如果我们可以使用C++标准库类型,例如 `vector<double>`,那么会有很多库可以协助我们进行处理。Rcpp也提供了两个转换的函数 `as()`和`wrap()` ,前者用于将Rcpp类型转换为标准库类型,后者将标准库类型转换为Rcpp类型。虽然使用的是专门的转换函数,而不是重载类型转换运算符,但如果参数是标准库类型,Rcpp依然会隐式地调用转换函数,而且也可以直接从 `SEXP` 类型转换。 我们将之前写的 `fibonacci_seq()` 和 `fibonacci_sum()` 函数进行改写,使之使用标准库类型声明函数,而不使用Rcpp类型。 ```cpp // [[Rcpp::export]] vector<int> fibonacci_seq_std(size_t n) { vector<int> fib_seq(n); switch (n) { case 0: break; case 1: fib_seq[0] = 1; break; case 2: fib_seq[0] = 1; fib_seq[1] = 1; break; default: fib_seq[0] = 1; fib_seq[1] = 1; for (size_t i = 2; i < n; i++) { fib_seq[i] = fib_seq[i-1] + fib_seq[i-2]; } break; } return fib_seq; } // [[Rcpp::export]] double fibonacci_sum_std(size_t n, vector<double> weights) { vector<int> fib_seq = fibonacci_seq_std(n); double sum = 0.0; for (size_t i = 0; i < n; i++) { sum += fib_seq[i] * weights[i]; } return sum; } ``` C++接口函数就分别改为了 ```cpp // fibonacci_seq std::vector<int> fibonacci_seq_std(size_t n); RcppExport SEXP _DemoRcpp_fibonacci_seq(SEXP nSEXP) { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; Rcpp::traits::input_parameter<size_t>::type n(nSEXP); rcpp_result_gen = Rcpp::wrap(fibonacci_seq_std(n)); return rcpp_result_gen; END_RCPP } // fibonacci_sum double fibonacci_sum_std(size_t n, std::vector<double> weights); RcppExport SEXP _DemoRcpp_fibonacci_sum(SEXP nSEXP, SEXP weightsSEXP) { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; Rcpp::traits::input_parameter<size_t>::type n(nSEXP); Rcpp::traits::input_parameter<std::vector<double> >::type weights(weightsSEXP); rcpp_result_gen = Rcpp::wrap(fibonacci_sum_std(n, weights)); return rcpp_result_gen; END_RCPP } ``` 这样我们所写地库函数就可以不再局限于Rcpp包,甚至完全脱离Rcpp依赖也是可能的。仓库 [HPDell/tutorial-Rcpp/02-DemoRcpp2s](https://github.com/HPDell/tutorial-Rcpp/tree/master/02-DemoRcpp2s) 中的 `fibonacci.cpp` 就是完全脱离了Rcpp的实现,并且将相关函数的声明移动到了头文件里,并在 `RcppExports.cpp` 文件中包含了这个头文件,以实现函数的导入。但是有一点需要注意,如果用这种方式,导入的头文件最好放在 `RcppExports.cpp` 的开头,至少要在 `Rcpp.h` 之前导入,否则会引发一些奇怪的问题。这一点并没有得到广泛验证,我也不知道是为什么,也许和Rcpp的实现有关。 # 参考 :octocat: 仓库 [HPDell/tutorial-Rcpp](https://github.com/HPDell/tutorial-Rcpp) 📖 文档 [Rcpp for everyone](https://teuder.github.io/rcpp4everyone_en/)
感谢您的阅读。本网站 MyZone 对本文保留所有权利。