编写Rcpp包01:入门
2022年4月18日
编程
我打算将这个做成一个“编写Rcpp包”系列,这里是第一篇:入门篇。本篇我们主要分析Rcpp包的结构,并编写一个简单的函数。 特别声明:本系列中所说的Rcpp包,不仅包含Rcpp扩展包,也会介绍RcppArmadillo扩展包。 # Rcpp包的结构 我们先通过以下步骤创建一个名为 ***DemoRcpp*** 的Rcpp包。 ```R Rcpp::Rcpp.package.skeleton("DemoRcpp") ``` 我们会得到拥有下面结构的 `DemoRcpp` 目录 ```plaintext DemoRcpp ├── DESCRIPTION ├── NAMESPACE ├── R │ └── RcppExports.R ├── Read-and-delete-me ├── man │ ├── DemoRcpp-package.Rd │ └── rcpp_hello_world.Rd └── src ├── RcppExports.cpp └── rcpp_hello_world.cpp ``` 这些文件和目录的含义分别是: - `DESCRIPTION` 是包的描述文件,里面存放了关于这个包的元数据,例如:包名、版本号、作者、维护者、依赖项、许可证等。是非常重要的包文件之一。 - `NAMESPACE` 类似于描述包的公共命名空间。这里面会标记载入什么动态库,从外部导入什么对象,以及这个库**导出什么对象**。也是非常重要的包文件之一。 - `R` 目录是存放包中R代码的目录。目录里面现存的 `RcppExports.R` 是自动创建的,里面主要存放调用C++代码的R接口函数。 - `src` 目录是存放C++代码的目录。目录里面现存的 `RcppExports.cpp` 是自动创建的,主要用于编写一些胶水函数,用于接收R的对象,并转化成C/C++库计算时所需要的对象。文件 `rcpp_hello_world.cpp` 是示例的C++文件,在这个文件中我们来编写我们的库函数。 - `man` 目录是存放文档的目录。这个目录里面的内容我们可以通过 ***roxygen2*** 这个包来生成,无需手动编写。 - `Read-and-delete-me` 顾名思义,就是读了之后删掉的文件。这个文件就告诉我们要进行下面的操作: - 在 `man` 目录中编写帮助文件 - 在 `NAMESPACE` 中编写导出对象,并添加需要的导入 - 在 `src` 目录中编写 C/C++/Fortran 代码 - 如果有已编译的代码,在 `NAMESPACE` 中添加额外的 `useDynLib()` 指令 - 运行 `R CMD build` 来构建包压缩文件 - 运行 `R CMD check` 来检查包文件 这就是一个 Rcpp 包的基本结构。通过这个结构我们已经可以初步了解到了这个R包应该怎么编写。下面我们介绍一下Rcpp包的工作方式,看看这个包到底是如何让R语言用上C++代码的。 # Rcpp包的工作方式 我们将包中所涉及到的函数分为以下几类:库函数,C++接口函数,R接口函数,包函数。这几个函数分别存放在了不同的文件中,形成了一个层级结构。我们先从最底层的库函数开始讲起。 ## 库函数 观察自动创建的 `rpp_hello_world.cpp` 文件中,写了这样一个C++函数 `rcpp_hello_world()` 。**为了方便区分,下面我们用 `cpp_hello_world()` 来代称**。 ```cpp // [[Rcpp::export]] List rcpp_hello_world() { CharacterVector x = CharacterVector::create( "foo", "bar" ) ; NumericVector y = NumericVector::create( 0.0, 1.0 ) ; List z = List::create( x, y ) ; return z ; } ``` 这个函数就是用Rcpp头文件中定义的类型创建了对象,并组合成了一个List对象,最终返回。注意代码前面的一行注释 `// [[Rcpp::export]]` ,这行注释起到了关键的作用,表示这个函数需要被导出,以被 `RcppExports.cpp` 中的函数链接。这里涉及到一点编译相关方面的知识。但是基本上我们把需要使用C++进行密集计算的代码放在这里即可。 > 简单地说,编译器在编译 .cpp 源文件的时候,通过编译器将每个 .cpp 文件都会被编译成为一个后缀是 .o 的目标文件,然后通过链接器将这些目标文件链接到一起,生成一个库文件 .a 或者可执行文件。通过这种方式,我们声明的每一个符号最终都能找到其定义。如果某个符号我们声明了却没有定义,链接的时候就会出现“未定义标识符”等错误。 ## C++接口函数 然后在 `RcppExports.cpp` 文件中,写了这样一个C++函数 `_DemoRcpp_rcpp_hello_world()` ```cpp // rcpp_hello_world List rcpp_hello_world(); RcppExport SEXP _DemoRcpp_rcpp_hello_world() { BEGIN_RCPP Rcpp::RObject rcpp_result_gen; Rcpp::RNGScope rcpp_rngScope_gen; rcpp_result_gen = Rcpp::wrap(rcpp_hello_world()); return rcpp_result_gen; END_RCPP } ``` 这个函数前面先写了 `cpp_hello_world()` 的声明,表示这个源文件要用到 `cpp_hello_world()` 这个函数。然后定义这个 `_DemoRcpp_rcpp_hello_world()` 函数,返回值类型是 `SEXP` ,这是一个通用的用于在R和C++之间传递参数和返回值的类型,任何在 `RcppExports.cpp` 文件中的函数,参数类型和返回值类型可以都是 `SEXP` 类型。然后函数的定义以宏 `BEGIN_RCPP` 开头,以宏 `END_RCPP` 结束。我们可以不用去管这两个宏到底干了什么,只需要知道在这两个宏之间写函数代码。我们可以看到个函数其实就是调用了一下 `cpp_hello_world()` 然后返回了。由于我们这个函数没有参数,所以不需要做参数转换。 ## R接口函数 接着,这个函数的名字出现在 `RcppExports.cpp` 文件中的这段代码 ```cpp static const R_CallMethodDef CallEntries[] = { {"_DemoRcpp_rcpp_hello_world", (DL_FUNC) &_DemoRcpp_rcpp_hello_world, 0}, {NULL, NULL, 0} }; RcppExport void R_init_DemoRcpp(DllInfo *dll) { R_registerRoutines(dll, NULL, CallEntries, NULL, NULL); R_useDynamicSymbols(dll, FALSE); } ``` 最后,又出现在了 `RcppExports.R` 文件中的R函数 `rcpp_hello_world` 里面 ```R rcpp_hello_world <- function() { .Call(`_DemoRcpp_rcpp_hello_world`) } ``` 这个函数 `rcpp_hello_world()` 就是我们用于调用C++库的R函数。这个函数里面也只是调用了 `.Call()` 函数。这个函数的第一个参数是 `RcppExports.cpp` 文件中定义的库函数的名字,也就是 `CallEntries` 中我们所声明的函数。如果这个库函数需要参数,可以在 `.Call()` 函数第一个参数后添加所需要的参数。 ## 包函数 其实R接口函数已经可以在 NAMESPACE 中导出了,也就是说编译并加载这个包应该就可以使用这个函数了。但是一般我们不这样做,一般会在这个函数外面再套一个R函数,用来处理复杂的R对象。如果这个函数是 `hello_world()` 那么我们可以这样写 ```R hello_world <- function() { rcpp_hello_world() } ``` 将这段代码放在目录 R 之下,命名为 `hello_world.R` ,这样我们的包含 `hello_world()` 函数的 R 包就已经写好了。 ## 调用流程 下面这个时序图总结了R包中各个函数的调用流程。 ```mermaid sequenceDiagram 外部函数->>包函数: R Call 包函数->>R接口: R Call R接口->>C接口: .Call() C接口->>库函数: C++ Call 库函数-->>C接口: C++ return C接口-->>R接口: C++ return R接口-->>包函数: R return 包函数-->>外部函数: R return ``` 需要注意的是,这几个函数都不是1对1的关系,一个包函数可以调用多个R接口来调用多个库函数。但是由于R语言默认是单线程的,所以调用都是同步的。这里就只画了1对1调用的情况。 # 函数的导出 我们之前提到过,R包里面函数的导出是在 NAMESPACE 文件中声明的。观察现在的这个文件,有这么一行代码 ```R exportPattern("^[[:alpha:]]+") ``` 这是用正则表达式的方式导出了一批函数,也就是说只要函数名与这个正则表达式可以正确匹配,函数就可以导出了。那么这个正则表达式的含义是什么? 这其实是一个PHP正则表达式,其中的 `[:alpha:]` 表示匹配所有字母和数字,也就相当于普通正则表达式中的 `[a-zA-Z0-9]` 的作用。符号 `^` 表示匹配开头,`+` 表示前面的符号要匹配至少一次。那么这个正则表达式的含义就是匹配以字母或数字开头、并且开头至少有一个字母或数字的函数。举几个例子,下面这些函数有的是可以匹配的,有的是不能匹配的。 ```R hello_world() # TRUE hello2() # TRUE 2hello() # TRUE _hello_world() # FALSE .hello.world() # FALSE ``` 所以,我们可以让包中需要用到、但不需要被用户用到的函数的名称以 `.` 或者 `_` 开始,表示为私有函数。需要导出为公有函数的话,就以字母或数字开头即可。 除了以上方法之外,我们还可以在 NAMESPACE 文件中使用 `export()` 函数,指定只导出某一个函数。例如 ```R export(hello_world) ``` 这样就只有这个函数被导出,其他函数没有导出。 # DESCRIPTION 文件 这个文件其实比较简单,就是一个关于这个包的元数据。里面的各种字段也非常好懂。这里就只简单介绍一下。 | 字段 | 作用 | 备注 | | ------------------ | ---------------- | -------------------------------------------------------------------------------------------------------- | | Package | 包名 | 一般只包含字母和数字,没有空格下划线等 | | Type | | 一般就是 Package | | Title | 包帮助文档的标题 | 最好不要以 A package 之类的开头,而且要使用标题大小写格式 | | Version | 版本号 | R中版本号的格式是 `[major].[minor]-[patch]` 的方式,例如 `2.2-8` 。 | | Date | 创建日期 | | | Author | 作者列表 | | | Maintainer | 维护者 | 需要用 `Your Name <your@email.com>` 的方式写出名称和邮箱<br />将用于 CRAN 与包开发者联系的方式 | | Description | 详细描述 | 可以多行,但是换行后要添加缩进 | | License | 许可 | | | Imports | 导入 | 包在运行时所需要的其他包<br />必须安装这些包之后,当前包才能工作<br />但在加载包的时候并不直接加载依赖包 | | Depends | 依赖 | 包在运行时所依赖的包<br />如果这个包被加载了,其依赖项也会一并加载<br />但这个方式现在已经不推荐了 | | LinkingTo | 关联包 | 添加在 LinkingTo 后面的包<br />其包中的头文件目录将添加到编译包所用的包含目录中 | | SystemRequirements | 系统要求 | 可以填写 `GNU make` 以表示需要 GNU make 进行包配置 | 其他元数据这里就不再赘述了,请查看 [R Packages](https://r-pkgs.org/index.html) 。 # 包的构建和编译 如果要直接安装,可以使用如下命令(命令行工作目录调至 DemoRcpp 目录的父目录) ```bash R CMD INSTALL DemoRcpp ``` 在向 CRAN 发布之前,我们需要使用如下命令构建一个压缩包 ```bash R CMD build DemoRcpp ``` 然后对包进行检查 ```bash R CMD check DemoRcpp_1.0.tar.gz ``` 检查通过后,再向 CRAN 提交包。 # 参考 :octocat: 仓库 [HPDell/tutorial-Rcpp](https://github.com/HPDell/tutorial-Rcpp) 📖 教程 [R Packages](https://r-pkgs.org/index.html) 📑 博客 [Rcpp 简明入门](https://cosx.org/2013/12/rcpp-introduction/)
感谢您的阅读。本网站 MyZone 对本文保留所有权利。