使用 CMake 统一管理并编译 C++/Python/R 算法包

在数据分析领域,Python 和 R 都是比较常用的语言。这两种语言在使用上有很多的相似处,也有很多的不同。一方面,这两个语言对于代码的执行效率都远远不如静态语言(如C++),尤其是循环的效率、矩阵运算的效率等。另一方面,这两种语言使用起来都要更为方便,而且有许多其他的软件包可以使用,很容易就可以和其他算法一起使用,这点又是 C++ 这种静态语言不能比的。所以长久以来形成了“用 Python 和 R 调用 C++ 计算”的模式,以发挥两类语言各自的特点。Python 中可以使用 pybind 或者 Cython 调用 C++ 代码,而 R 可以用 Rcpp 调用 C++ 代码。

目前很多算法库都有 Python 和 R 版本,但是往往都是单独开发,甚至 Python 版本和 R 版本不是同一个作者。为了解决这个问题,笔者尝试了使用 CMake 将算法计算部分的 C++ 代码和调用部分的 Python 与 R 代码统一管理,使开发者可以同时提供两种语言的版本。

# 项目结构

本项目主要采用这样一种结构:

- `/` 根目录
  - `CMakeLists.txt` 主 CMake 配置文件
  - `include` C++ 头文件
  - `src` C++ 源文件
  - `test` C++ 单元测试
  - `python` Python 模块代码
    - `mypypackage` Python 代码,主要包含用于调用 C++ 的 Cython 代码
    - `test`
    - `CMakeLists.txt` Python 模块的 CMake 配置文件
    - `setup.py` 用于构建和发布的 scikit-build 脚本
  - `R` R 包代码
    - `data` 用于存放包提供的数据文件
    - `man` 用于存放其他文档
    - `R` 用于调用的 R 代码
    - `src` 用于调用库的 C++ 代码
    - `CMakeLists.txt` R 包的 CMake 配置文件
    - `DESCRIPTION.in` R 包 DESCRIPTION 模板,在 CMake 项目配置时自动填入版本号等信息
    - `NAMESPACE.in` R 包 NAMESPACE 模板,在 CMake 项目配置时自动填入版本号等信息

根目录中可以添加一些持续集成配置文件、文档源文件等其他文件。

总体上,该项目结构是一个 C++ 项目的格式,在开发时也是先开发 C++ 代码,在 C++ 代码的基础上再开发 Python 或 R 代码,甚至其他语言的代码。

# 设计思路

在这个包中,根目录中的 C++ 代码主要负责实现算法内核的部分,即与所有调用语言无关的东西。在这里面,不能使用 Python 中的 DataFrame 或者 R 中的任何类型,只能使用纯 C++ 支持的类型。也就是说,需要调用者在 C++ 程序中可以直接调用这个库。这个算法核心部分通过 `/test` 目录下的代码进行单元测试,只要测试通过就说明算法核心没有问题。

目录 `Python` 和 `R` 中的代码主要是提供这些语言对于调用 C++ 代码的支持。一般情况下,都是这样一个顺序:Python 或 R 函数调用中间件、中间件调用 C++ 库。所以这两个目录中就需要包含 Python 或 R 函数(简称包函数)以及中间件这两个部分。在 Python 中,中间件往往是用 Cython 或者 Pybind 编写的,为包函数提供了 Python 对象和 C++ 对象进行对接的能力;在 R 中,中间件往往是用 C++ 编写的,依靠 Rcpp 包提供的能力,将 R 语言对象转换为 C++ 对象,并调用 C++ 库函数。

# 具体实现

为了描述方便,我们将 `CMakeLists.txt` 文件统称为配置文件。

根配置文件的编写比较简单,主要就是设置一些变量,例如是否有 Python 模块的 `WITH_PYTHON` 等,然后添加一些目录。下面是一个示例

```cmake
# /CMakeLists.txt
cmake_minimum_required(VERSION 3.12.0)
project(myproject VERSION 0.1.0)

set(CMAKE_CXX_STANDARD 11)
set(CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)

option(WITH_R "Whether to build R extension" OFF)

set(TEST_DATA_DIR ${CMAKE_SOURCE_DIR}/test/data)

add_subdirectory(src)

include(CTest)
enable_testing()

add_subdirectory(test)

set(CPACK_PROJECT_NAME ${PROJECT_NAME})
set(CPACK_PROJECT_VERSION ${PROJECT_VERSION})
include(CPack)

if(WITH_R)
    add_subdirectory(R)
endif()
```

主要工作就是向根配置文件中,引入 C++ 配置文件和测试用例,以及当 `WITH_R=ON` 时引入 R 配置文件。

## C++ 配置文件

这部分的配置文件写法和一般 CMake 管理的 C++ 库没有什么区别,所做的就是查找一些库、构建库或可执行程序。可以无需考虑 Python 或 R 的部分。

## R 配置文件

R 配置文件相对比较简单一些。由于 R 有自己的包结构,如果要发布到 CRAN 中的话,就需要按照这种结构来提交。而且 R 包的安装是通过 `R CMD INSTALL` 命令进行安装的,不太适合用文件拷贝的方式进行安装。那么我们可以根据现有的代码结构,单独使用一个文件夹,程序化构造 R 包的结构,并调用 R 相关命令进行包的构建。于是我们可以充分利用 CMake 提供的文件操作命令以及 `add_custom_target()` 方法实现这一目的。

在 CMake 配置和生成阶段,我们可以使用以下命令来生成一个 R 包的标准结构。

```cmake
# /R/CMakeLists.txt
set(PROJECT_RBUILD_DIR ${CMAKE_BINARY_DIR}/${PROJECT_NAME})
make_directory(${PROJECT_RBUILD_DIR})
configure_file(DESCRIPTION.in ${PROJECT_RBUILD_DIR}/DESCRIPTION)
configure_file(NAMESPACE.in ${PROJECT_RBUILD_DIR}/NAMESPACE)
file(COPY cleanup DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY configure DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY configure.ac DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/R DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/src DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/man DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/R/data DESTINATION ${PROJECT_RBUILD_DIR})
file(COPY ${CMAKE_SOURCE_DIR}/include/header.h DESTINATION ${PROJECT_RBUILD_DIR}/src)
file(COPY ${CMAKE_SOURCE_DIR}/src/sources.cpp DESTINATION ${PROJECT_RBUILD_DIR}/src)
```

这样在 CMake 构建目录下,会出现一个 `${PROJECT_RBUILD_DIR}` 的目录,里面就是一个标准结构的 R 包。接下来,所有与 R 相关的操作,就都可以针对这个文件夹进行。下面是一个对 R 包进行编译、生成文档、打包的示例。

```cmake
# /R/CMakeLists.txt
add_custom_target(mypackage_rbuild
    VERBATIM
    WORKING_DIRECTORY ${PROJECT_RBUILD_DIR}/..
    COMMAND_EXPAND_LISTS
    COMMAND ${CMAKE_COMMAND} -E
        make_directory "${PROJECT_NAME}.library"
    COMMAND ${R_EXECUTABLE} CMD INSTALL --preclean --clean --library=${PROJECT_NAME}.library ${PROJECT_NAME}
    COMMAND ${R_EXECUTABLE} -e "roxygen2::roxygenize('${PROJECT_NAME}', load_code = 'source')"
    COMMAND ${R_EXECUTABLE} CMD build ${PROJECT_NAME}
)
```

基于这种思路,我们同样可以编写一个测试,就用来执行 `R CMD check` 命令。

```cmake
# /R/CMakeLists.txt
add_test(
    NAME Test_R_mypackage
    COMMAND ${R_EXECUTABLE} CMD check ${PROJECT_NAME}_${PROJECT_VERSION_R}.tar.gz --as-cran --no-manual
    WORKING_DIRECTORY ${PROJECT_RBUILD_DIR}/..
)
```

此外,如果使用 VSCode 进行开发,且使用 CMake 作为配置提供工具,使用这种方式会导致自动类型提示无法在 `RcppExports.cpp` 等 R 包所需的 C++ 文件中工作。解决方法也非常简单,就是写一个正常的 CMake 生成目标即可,但是将这个生成目标排除在 ALL 目标之外。

```cmake
# /R/CMakeLists.txt
find_package(R REQUIRED)
include_directories(${R_INCLUDE_DIRS} ${RCPP_ARMADILLO_INCLUDE_DIR} ${RCPP_INCLUDE_DIR})
include_directories(../include)
add_library(mypackage_rcpp_export SHARED src/RcppExports.cpp)
target_link_libraries(mypackage_rcpp_export mylib)
set_target_properties(mypackage_rcpp_export PROPERTIES EXCLUDE_FROM_ALL TRUE)
```

这样,我们就可以完全依靠 CMake 的指令操作所有的流程。例如

```bash
mkdir build && cd build
cmake .. -DWITH_R=ON
cmake --build . --config Release --target mypackage_rbuild
ctest -R Test_R_mypackage --output-on-failure
```

在持续集成中也可以采用这样的操作,避免因为 R 解释器以及操作系统的问题,造成很多不必要的麻烦。

## Python 配置文件

Python 的配置文件会相对来说更复杂一点,因为涉及到 Cython 语言的编译问题。好在 scikit-build 库已经提供了使用 CMake 编译 Cython 文件的方法,而且有打包的功能。那么我们可以直接使用 scikit-build 编译,也可以像 R 包一样,构建一个 scikit-build 所需要的结构,并将这个包作为提交 Pypi 的包。

### 使用 CMake 直接编译

根据 [scikit-build 的文档](https://scikit-build.readthedocs.io/en/latest/cmake-modules/Cython.htmlhttps://scikit-build.readthedocs.io/en/latest/cmake-modules/Cython.html),我们可以用这样的配置直接编译一个 Python 模块(pyd 文件)

```cmake
# /python/mypackage/CMakeLists.txt
add_cython_target(pymypackage.pyx CXX)
add_library(pymypackage MODULE ${pymypackage})
target_link_libraries(pymypackage mylib ${ARMADILLO_LIBRARIES} ${Python3_LIBRARIES} Python3::NumPy)
python_extension_module(pymypackage)
```

然后在 Python 模块代码的配置文件中引入即可

```cmake
# /python/CMakeLists.txt
include_directories(${MYLIB_INCLUDE_DIR})
add_subdirectory("mypypackage")
add_subdirectory("test")
```

编译好的 Python 模块就可以直接通过 `import` 关键字引入。同样我们可以写一些 install 脚本,这样就可以直接将编译好的包安装在本地。

### 使用 Scikit-Build 编译

与 R 包类似,构建 Pypi 包无非就是拷贝一些文件到一个目录,形成对应的结构。例如我们可以这样

```cmake
# /python/CMakeLists.txt
set(PYMYPACKAGE_SKBUILD_DIR ${CMAKE_BINARY_DIR}/pymypackage)
add_custom_target(pymypackage_skbuild
  VERBATIM
  COMMAND_EXPAND_LISTS
  COMMAND ${CMAKE_COMMAND} -E
    make_directory
    ${PYMYPACKAGE_SKBUILD_DIR}
  COMMAND ${CMAKE_COMMAND} -E
    copy_directory ${CMAKE_SOURCE_DIR}/python ${PYMYPACKAGE_SKBUILD_DIR}
  COMMAND ${CMAKE_COMMAND} -E
    copy_directory ${CMAKE_SOURCE_DIR}/cmake ${PYMYPACKAGE_SKBUILD_DIR}/cmake
  COMMAND ${CMAKE_COMMAND} -E
    copy_directory ${CMAKE_SOURCE_DIR}/include ${PYMYPACKAGE_SKBUILD_DIR}/include
  COMMAND ${CMAKE_COMMAND} -E
    copy_directory ${CMAKE_SOURCE_DIR}/src ${PYMYPACKAGE_SKBUILD_DIR}/src
)
```

包结构设置好了,下面就是使用 Scikit-Build 进行编译。虽然 Scikit-Build 最终还是通过 CMake 构建的,但是这种方式支持 python 命令编译安装。我们需要编写 `setup.py` 和 `pyproject.toml` 文件。

```python
# setup.py
from skbuild import setup
setup(
    name="pymypackage",
    version="0.2.0",
    author="myname",
    packages=["pymypackage"],
    install_requires=['cython']
)
```

```toml
[build-system]
requires = [
    "setuptools>=42",
    "wheel",
    "scikit-build>=0.12",
    "cmake>=3.18",
    "ninja",
    "cython",
    "numpy",
    "pandas",
    "geopandas"
]
build-backend = "setuptools.build_meta"
```

此时如果使用 `python setup.py` 的方式安装,到此为止只是告诉 Python 要使用 Scikit-Build 安装,以及如何使用这个工具安装。但是还没有告诉 Scikit-Build 怎么去安装。这一步还是通过写 CMake 配置文件进行实现的,通过这个配置文件就告诉 Scikit-Build 使用什么样的步骤构建并编译包。

```cmake
# /python/CMakeLists.txt
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
find_package(Armadillo REQUIRED)
if(MSVC)
  add_definitions(-D_CRT_SECURE_NO_WARNINGS)
endif(MSVC)
set(MYLIB_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include)
include_directories(${MYLIB_INCLUDE_DIR})
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src ${pygwmodel_BINARY_DIR}/mylib)
add_subdirectory("pygwmodel")
enable_testing()
add_subdirectory("test")
```

那么问题来了,这段 CMake 配置应该写在哪里呢?应该是 `/python/CMakeLists.txt` ,因为这个文件是我们 Pypi 包结构的根配置文件。但是构建这个包的 CMake 配置写在哪里呢?还是这个文件,因为 `/python` 目录是 Python 包代码的根目录。这样就产生了一个冲突,这个文件该如何包含两种配置?

为了解决这个问题,我们可以使用 Scikit-Build 提供的一个 CMake 宏 `SKBUILD` 。如果定义了这个宏,那就说明是 Scikit-Build 在使用这个配置文件;如果没有,那就说明不是 Scikit-Build 在使用。但是如果不是 Scikit-Build 在使用,我们依然也需要分成两种情况:直接用 CMake 编译和构建 Pypi 包。因此我们需要再定义一个 `USE_SKBUILD` 的宏,来区分这两种情况。综合起来,配置文件 `/python/CMakeLists.txt` 就需要写成下面这个形式

```cmake
# /python/CMakeLists.txt
if(SKBUILD)
  set(CMAKE_CXX_STANDARD 11)
  set(CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake)
  find_package(Armadillo REQUIRED)
  if(MSVC)
    add_definitions(-D_CRT_SECURE_NO_WARNINGS)
  endif(MSVC)
  set(MYLIB_INCLUDE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/include)
  include_directories(${MYLIB_INCLUDE_DIR})
  add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/src ${pygwmodel_BINARY_DIR}/mylib)
  add_subdirectory("pygwmodel")
  enable_testing()
  add_subdirectory("test")
elseif(USE_SKBUILD)
  set(PYMYPACKAGE_SKBUILD_DIR ${CMAKE_BINARY_DIR}/pymypackage)
  add_custom_target(pymypackage_skbuild
    VERBATIM
    COMMAND_EXPAND_LISTS
    COMMAND ${CMAKE_COMMAND} -E
      make_directory
      ${PYMYPACKAGE_SKBUILD_DIR}
    COMMAND ${CMAKE_COMMAND} -E
      copy_directory ${CMAKE_SOURCE_DIR}/python ${PYMYPACKAGE_SKBUILD_DIR}
    COMMAND ${CMAKE_COMMAND} -E
      copy_directory ${CMAKE_SOURCE_DIR}/cmake ${PYMYPACKAGE_SKBUILD_DIR}/cmake
    COMMAND ${CMAKE_COMMAND} -E
      copy_directory ${CMAKE_SOURCE_DIR}/include ${PYMYPACKAGE_SKBUILD_DIR}/include
    COMMAND ${CMAKE_COMMAND} -E
      copy_directory ${CMAKE_SOURCE_DIR}/src ${PYMYPACKAGE_SKBUILD_DIR}/src
  )
else()
  include_directories(${MYLIB_INCLUDE_DIR})
  add_subdirectory("mypypackage")
  add_subdirectory("test")
endif()
```

这样,就可以用这一个配置文件,实现三种不同的构建。如果只需要本地构建,配置 CMake 的时候带上 `-DWITH_PYTHON=ON` 参数;如果需要构建包结构,带上 `-DWITH_PYTHON=ON -DUSE_SKBUILD=ON` 参数,然后使用 Python 解释器运行 `setup.py` 脚本就可以构建了。

# 参考仓库

关于 Python 部分,可以参考仓库 [hpdell/libgwmodel](https://github.com/HPDell/libgwmodelhttps://github.com/HPDell/libgwmodel),该仓库是按照本文所描述的方式编写的。关于 R 部分,上述仓库中虽然有,但是方法比较陈旧了,与本文描述也有一定的出入。使用本文方法编写的仓库暂时还不适合开源,但会尽快开源。届时将补充在本文中。