编写Rcpp包01:入门

我打算将这个做成一个“编写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/)