多读书多实践,勤思考善领悟

CMake 完整使用教程 之十 语言混合项目

本文于1064天之前发表,文中内容可能已经过时。

本章的主要内容如下:

  • 使用C/C++库构建Fortran项目
  • 使用Fortran库构建C/C++项目
  • 使用Cython构建C++和Python项目
  • 使用Boost.Python构建C++和Python项目
  • 使用pybind11构建C++和Python项目
  • 使用Python CFFI混合C,C++,Fortran和Python

有很多的库比较适合特定领域的任务。我们的库直接使用这些专业库,是一中快捷的方式,这样就可以使用来自其他专家组的多年经验进行开发。随着计算机体系结构和编译器的发展,编程语言也在不断发展。几年前,大多数科学软件都是用Fortran语言编写的,而现在,C/C++和解释语言Python正占据着语言中心舞台。将编译语言代码与解释语言的代码集成在一起,变得确实越来越普遍,这样做有以下好处:

  • 用户可以需要进行定制和扩展功能,以满足需求。
  • 可以将Python等语言的表达能力与编译语言的性能结合起来,后者在内存寻址方面效率接近于极致,达到两全其美的目的。

正如之前的示例中展示的那样,可以使用project命令通过LANGUAGES关键字设置项目中使用的语言。CMake支持许多(但不是所有)编译的编程语言。从CMake 3.5开始,各种风格的汇编(如ASM-ATT,ASM,ASM-MASM和ASM- NASM)、C、C++、Fortran、Java、RC (Windows资源编译器)和Swift都可以选择。CMake 3.8增加了对另外两种语言的支持:C#和CUDA(请参阅发布说明:https://cmake.org/cmake/help/v3.8/release/3.8.html#languages )。

本章中,我们将展示如何以一种可移植且跨平台的方式集成用不同编译(C/C++和Fortran)和解释语言(Python)编写的代码。我们将展示如何利用CMake和一些工具集成不同编程语言。

9.1 使用C/C++库构建Fortran项目

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-9/recipe-01 中找到,其中有两个示例:一个是Fortran与C的混例,另一个是Fortran和C++的混例。该示例在CMake 3.5版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

Fortran作为高性能计算语言有着悠久的历史。目前,许多线性代数库仍然使用Fortran语言编写,许多大型的数字处理包也保持与过去几十年的代码兼容。而Fortran提出了一个很自然的语法处理数值数组,它缺乏与操作系统交互,所以为了编程的通用性,需要一个互操作性层(使用C实现),才发布了Fortran 2003标准。本示例将展示如何用C系统库和自定义C代码来对接Fortran代码。

准备工作

第7章中,我们把项目结构列为一个树。每个子目录都有一个CMakeLists.txt文件,其中包含与该目录相关的指令。这使我们可以对子目录进行限制中,如这个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
├── CMakeLists.txt
└── src
├── bt-randomgen-example.f90
├── CMakeLists.txt
├── interfaces
│ ├── CMakeLists.txt
│ ├── interface_backtrace.f90
│ ├── interface_randomgen.f90
│ └── randomgen.c
└── utils
├── CMakeLists.txt
└── util_strings.f90

我们的例子中,src子目录中包括bt-randomgen-example.f90,会将源码编译成可执行文件。另外两个子目录interfaceutils包含更多的源代码,这些源代码将被编译成库。

interfaces子目录中的源代码展示了如何包装向后追踪的C系统库。例如,interface_backtrace.f90:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
module interface_backtrace

implicit none

interface
function backtrace(buffer, size) result(bt) bind(C, name="backtrace")
use, intrinsic :: iso_c_binding, only: c_int, c_ptr
type(c_ptr) :: buffer
integer(c_int), value :: size
integer(c_int) :: bt
end function

subroutine backtrace_symbols_fd(buffer, size, fd) bind(C, name="backtrace_symbols_fd")
use, intrinsic :: iso_c_binding, only: c_int, c_ptr
type(c_ptr) :: buffer
integer(c_int), value :: size, fd
end subroutine
end interface
end module

上面的例子演示了:

  • 内置iso_c_binding模块,确保Fortran和C类型和函数的互操作性。
  • interface声明,将函数在单独库中绑定到相应的符号上。
  • bind(C)属性,为声明的函数进行命名修饰。

这个子目录还包含两个源文件:

  • randomgen.c:这是一个C源文件,它对外公开了一个函数,使用C标准rand函数在一个区间内生成随机整数。
  • interface_randomgen.f90:它将C函数封装在Fortran可执行文件中使用。

具体实施

我们有4个CMakeLists.txt实例要查看——根目录下1个,子目录下3个。让我们从根目录的CMakeLists.txt开始:

  1. 声明一个Fortran和C的混合语言项目:

    1
    2
    cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
    project(recipe-01 LANGUAGES Fortran C)
  2. CMake将静态库和动态库保存在build目录下的lib目录中。可执行文件保存在bin目录下,Fortran编译模块文件保存在modules目录下:

    1
    2
    3
    4
    5
    set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
    set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/lib)
    set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/bin)
    set(CMAKE_Fortran_MODULE_DIRECTORY
    ${CMAKE_CURRENT_BINARY_DIR}/modules)
  3. 接下来,我们进入第一个子CMakeLists.txt,添加src子目录:

    1
    add_subdirectory(src)
  4. src/CMakeLists.txt文件添加了两个子目录:

    1
    2
    add_subdirectory(interfaces)
    add_subdirectory(utils)

interfaces子目录中,我们将执行以下操作:

  1. 包括FortranCInterface.cmak模块,并验证C和Fortran编译器可以正确地交互:

    1
    2
    include(FortranCInterface)
    FortranCInterface_VERIFY()
  2. 接下来,我们找到Backtrace系统库,因为我们想在Fortran代码中使用它:

    1
    find_package(Backtrace REQUIRED)
  3. 然后,创建一个共享库目标,其中包含Backtrace包装器、随机数生成器,以及Fortran包装器的源文件:

    1
    2
    3
    4
    5
    6
    7
    8
    add_library(bt-randomgen-wrap SHARED "")

    target_sources(bt-randomgen-wrap
    PRIVATE
    interface_backtrace.f90
    interface_randomgen.f90
    randomgen.c
    )
  4. 我们还为新生成的库目标设置了链接库。使用PUBLIC属性,以便连接到其他目标时,能正确地看到依赖关系:

    1
    2
    3
    4
    target_link_libraries(bt-randomgen-wrap
    PUBLIC
    ${Backtrace_LIBRARIES}
    )

utils子目录中,还有一个CMakeLists.txt,其只有一单行程序:我们创建一个新的库目标,子目录中的源文件将被编译到这个目标库中。并与这个目标没有依赖关系:

1
add_library(utils SHARED util_strings.f90)

回到src/CMakeLists.txt:

  1. 使用bt-randomgen-example.f90添加一个可执行目标:

    1
    add_executable(bt-randomgen-example bt-randomgen-example.f90)
  2. 最后,将在子CMakeLists.txt中生成的库目标,并链接到可执行目标:

    1
    2
    3
    4
    5
    target_link_libraries(bt-randomgen-example
    PRIVATE
    bt-randomgen-wrap
    utils
    )

工作原理

确定链接了正确库之后,需要保证程序能够正确调用函数。每个编译器在生成机器码时都会执行命名检查。不过,这种操作的约定不是通用的,而是与编译器相关的。FortranCInterface,我们已经在第3章第4节时,检查所选C编译器与Fortran编译器的兼容性。对于当前的目的,命名检查并不是一个真正的问题。Fortran 2003标准提供了可选name参数的函数和子例程定义了bind属性。如果提供了这个参数,编译器将使用程序员指定的名称为这些子例程和函数生成符号。例如,backtrace函数可以从C语言中暴露给Fortran,并保留其命名:

1
function backtrace(buffer, size) result(bt) bind(C, name="backtrace")

更多信息

interface/CMakeLists.txt中的CMake代码还表明,可以使用不同语言的源文件创建库。CMake能够做到以下几点:

  • 列出的源文件中获取目标文件,并识别要使用哪个编译器。
  • 选择适当的链接器,以便构建库(或可执行文件)。

CMake如何决定使用哪个编译器?在project命令时使用参数LANGUAGES指定,这样CMake会检查系统上给定语言编译器。当使用源文件列表添加目标时,CMake将根据文件扩展名选择适当地编译器。因此,以.c结尾的文件使用C编译器编译,而以.f90结尾的文件(如果需要预处理,可以使用.F90)将使用Fortran编译器编译。类似地,对于C++, .cpp.cxx扩展将触发C++编译器。我们只列出了C/C++和Fortran语言的一些可能的、有效的文件扩展名,但是CMake可以识别更多的扩展名。如果您的项目中的文件扩展名,由于某种原因不在可识别的扩展名之列,该怎么办?源文件属性可以用来告诉CMake在特定的源文件上使用哪个编译器,就像这样:

1
2
3
4
set_source_files_properties(my_source_file.axx
PROPERTIES
LANGUAGE CXX
)

那链接器呢?CMake如何确定目标的链接器语言?对于不混合编程语言的目标很简单:通过生成目标文件的编译器命令调用链接器即可。如果目标混合了多个语言,就像示例中一样,则根据在语言混合中,优先级最高的语言来选择链接器语言。比如,我们的示例中混合了Fortran和C,因此Fortran语言比C语言具有更高的优先级,因此使用Fortran用作链接器语言。当混合使用Fortran和C++时,后者具有更高的优先级,因此C++被用作链接器语言。就像编译器语言一样,我们可以通过目标相应的LINKER_LANGUAGE属性,强制CMake为我们的目标使用特定的链接器语言:

1
2
3
4
set_target_properties(my_target
PROPERTIES
LINKER_LANGUAGE Fortran
)

9.2 使用Fortran库构建C/C++项目

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-9/recipe-02 中找到,其中有一个示例:一个是C++、C和Fortran的混例。该示例在CMake 3.5版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

第3章第4节,展示了如何检测Fortran编写的BLAS和LAPACK线性代数库,以及如何在C++代码中使用它们。这里,将重新讨论这个方式,但这次的角度有所不同:较少地关注检测外部库,会更深入地讨论混合C++和Fortran的方面,以及名称混乱的问题。

准备工作

本示例中,我们将重用第3章第4节源代码。虽然,我们不会修改源码或头文件,但我们会按照第7章“结构化项目”中,讨论的建议修改项目树结构,并得到以下源代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
.
├── CMakeLists.txt
├── README.md
└── src
├── CMakeLists.txt
├── linear-algebra.cpp
└── math
├── CMakeLists.txt
├── CxxBLAS.cpp
├── CxxBLAS.hpp
├── CxxLAPACK.cpp
└── CxxLAPACK.hpp

这里,收集了BLAS和LAPACK的所有包装器,它们提供了src/math下的数学库了,主要程序为linear-algebra.cpp。因此,所有源都在src子目录下。我们还将CMake代码分割为三个CMakeLists.txt文件,现在来讨论这些文件。

具体实施

这个项目混合了C++(作为该示例的主程序语言)和C(封装Fortran子例程所需的语言)。在根目录下的CMakeLists.txt文件中,我们需要做以下操作:

  1. 声明一个混合语言项目,并选择C++标准:

    1
    2
    3
    4
    5
    6
    7
    cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

    project(recipe-02 LANGUAGES CXX C Fortran)

    set(CMAKE_CXX_STANDARD 11)
    set(CMAKE_CXX_EXTENSIONS OFF)
    set(CMAKE_CXX_STANDARD_REQUIRED ON)
  2. 使用GNUInstallDirs模块来设置CMake将静态和动态库,以及可执行文件保存的标准目录。我们还指示CMake将Fortran编译的模块文件放在modules目录下:

    1
    2
    3
    4
    5
    6
    7
    8
    include(GNUInstallDirs)
    set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY
    ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
    set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
    ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
    set(CMAKE_RUNTIME_OUTPUT_DIRECTORY
    ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR})
    set(CMAKE_Fortran_MODULE_DIRECTORY ${PROJECT_BINARY_DIR}/modules)
  3. 然后,进入下一个子目录:

    1
    add_subdirectory(src)

子文件src/CMakeLists.txt添加了另一个目录math,其中包含线性代数包装器。在src/math/CMakeLists.txt中,我们需要以下操作:

  1. 调用find_package来获取BLAS和LAPACK库的位置:

    1
    2
    find_package(BLAS REQUIRED)
    find_package(LAPACK REQUIRED)
  2. 包含FortranCInterface.cmake模块,并验证Fortran、C和C++编译器是否兼容:

    1
    2
    include(FortranCInterface)
    FortranCInterface_VERIFY(CXX)
  3. 我们还需要生成预处理器宏来处理BLAS和LAPACK子例程的名称问题。同样,FortranCInterface通过在当前构建目录中生成一个名为fc_mangl.h的头文件来提供协助:

    1
    2
    3
    4
    5
    FortranCInterface_HEADER(
    fc_mangle.h
    MACRO_NAMESPACE "FC_"
    SYMBOLS DSCAL DGESV
    )
  4. 接下来,添加了一个库,其中包含BLAS和LAPACK包装器的源代码。我们还指定要找到头文件和库的目录。注意PUBLIC属性,它允许其他依赖于math的目标正确地获得它们的依赖关系:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    add_library(math "")

    target_sources(math
    PRIVATE
    CxxBLAS.cpp
    CxxLAPACK.cpp
    )

    target_include_directories(math
    PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}
    ${CMAKE_CURRENT_BINARY_DIR}
    )
    target_link_libraries(math
    PUBLIC
    ${LAPACK_LIBRARIES}
    )

回到src/CMakeLists.txt,我们最终添加了一个可执行目标,并将其链接到BLAS/LAPACK包装器的数学库:

1
2
3
4
5
6
7
8
9
10
11
add_executable(linear-algebra "")

target_sources(linear-algebra
PRIVATE
linear-algebra.cpp
)

target_link_libraries(linear- algebra
PRIVATE
math
)

工作原理

使用find_package确定了要链接到的库。方法和之前一样,需要确保程序能够正确地调用它们定义的函数。第3章第4节中,我们面临的问题是编译器的名称符号混乱。我们使用FortranCInterface模块来检查所选的C和C++编译器与Fortran编译器的兼容性。我们还使用FortranCInterface_HEADER函数生成带有宏的头文件,以处理Fortran子例程的名称混乱。并通过以下代码实现:

1
2
3
4
5
FortranCInterface_HEADER(
fc_mangle.h
MACRO_NAMESPACE "FC_"
SYMBOLS DSCAL DGESV
)

这个命令将生成fc_mangl.h头文件,其中包含从Fortran编译器推断的名称混乱宏,并将其保存到当前二进制目录CMAKE_CURRENT_BINARY_DIR中。我们小心地将CMAKE_CURRENT_BINARY_DIR设置为数学目标的包含路径。生成的fc_mangle.h如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef FC_HEADER_INCLUDED
#define FC_HEADER_INCLUDED

/* Mangling for Fortran global symbols without underscores. */
#define FC_GLOBAL(name,NAME) name##_

/* Mangling for Fortran global symbols with underscores. */
#define FC_GLOBAL_(name,NAME) name##_

/* Mangling for Fortran module symbols without underscores. */
#define FC_MODULE(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name

/* Mangling for Fortran module symbols with underscores. */
#define FC_MODULE_(mod_name,name, mod_NAME,NAME) __##mod_name##_MOD_##name

/* Mangle some symbols automatically. */
#define DSCAL FC_GLOBAL(dscal, DSCAL)
#define DGESV FC_GLOBAL(dgesv, DGESV)
#endif

本例中的编译器使用下划线进行错误处理。由于Fortran不区分大小写,子例程可能以小写或大写出现,这就说明将这两种情况传递给宏的必要性。注意,CMake还将为隐藏在Fortran模块后面的符号生成宏。

NOTE:现在,BLAS和LAPACK的许多实现都在Fortran子例程附带了一个C的包装层。这些包装器已经标准化,分别称为CBLAS和LAPACKE。

由于已经将源组织成库目标和可执行目标,所以我们应该对目标的PUBLICINTERFACEPRIVATE可见性属性的使用进行评论。与源文件一样,包括目录、编译定义和选项,当与target_link_libraries一起使用时,这些属性的含义是相同的:

  • 使用PRIVATE属性,库将只链接到当前目标,而不链接到使用它的任何其他目标。
  • 使用INTERFACE属性,库将只链接到使用当前目标作为依赖项的目标。
  • 使用PUBLIC属性,库将被链接到当前目标,以及将其作为依赖项使用的任何其他目标。

9.3 使用Cython构建C++和Python项目

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-9/recipe-03 中找到,其中有一个C++示例。该示例在CMake 3.5版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

Cython是一个静态编译器,它允许为Python编写C扩展。Cython是一个非常强大的工具,使用Cython编程语言(基于Pyrex)。Cython的一个典型用例是加快Python代码的速度,它也可以用于通过Cython层使Python与C(++)接口对接。本示例中,我们将重点介绍后一种用例,并演示如何在CMake的帮助下使用Cython与C(++)和Python进行对接。

准备工作

我们将使用以下C++代码(account.cpp):

1
2
3
4
5
6
#include "account.hpp"
Account::Account() : balance(0.0) {}
Account::~Account() {}
void Account::deposit(const double amount) { balance += amount; }
void Account::withdraw(const double amount) { balance -= amount; }
double Account::get_balance() const { return balance; }

代码提供了以下接口(account.hpp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#pragma once

class Account {
public:
Account();
~Account();

void deposit(const double amount);
void withdraw(const double amount);
double get_balance() const;

private:
double balance;
};

使用这个示例代码,我们可以创建余额为零的银行帐户。可以在帐户上存款和取款,还可以使用get_balance()查询帐户余额。余额本身是Account类的私有成员。

我们的目标是能够直接从Python与这个C++类进行交互。换句话说,在Python方面,我们希望能够做到这一点:

1
2
3
4
5
6
account = Account()

account.deposit(100.0)
account.withdraw(50.0)

balance = account.get_balance()

为此,需要一个Cython接口文件(调用account.pyx):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# describe the c++ interface
cdef extern from "account.hpp":
cdef cppclass Account:
Account() except +
void deposit(double)
void withdraw(double)
double get_balance()

# describe the python interface
cdef class pyAccount:
cdef Account *thisptr
def __cinit__(self):
self.thisptr = new Account()
def __dealloc__(self):
del self.thisptr
def deposit(self, amount):
self.thisptr.deposit(amount)
def withdraw(self, amount):
self.thisptr.withdraw(amount)
def get_balance(self):
return self.thisptr.get_balance()

具体实施

如何生成Python接口:

  1. CMakeLists.txt定义CMake依赖项、项目名称和语言:
1
2
3
4
5
6
7
8
# define minimum cmake version
cmake_minimum_required(VERSION 3.5 FATAL_ERROR)
# project name and supported language
project(recipe-03 LANGUAGES CXX)
# require C++11
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
  1. Windows上,最好不要保留未定义的构建类型,这样我们就可以将该项目的构建类型与Python环境的构建类型相匹配。这里我们默认为Release类型:

    1
    2
    3
    if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
    endif()
  2. 在示例中,还需要Python解释器:

    1
    find_package(PythonInterp REQUIRED)
  3. 下面的CMake代码将构建Python模块:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    # directory cointaining UseCython.cmake and FindCython.cmake
    list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake-cython)

    # this defines cython_add_module
    include(UseCython)

    # tells UseCython to compile this file as a c++ file
    set_source_files_properties(account.pyx PROPERTIES CYTHON_IS_CXX TRUE)

    # create python module
    cython_add_module(account account.pyx account.cpp)

    # location of account.hpp
    target_include_directories(account
    PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}
    )
  4. 定义一个测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # turn on testing
    enable_testing()

    # define test
    add_test(
    NAME
    python_test
    COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py
    )
  5. python_test执行test.py,这里进行一些存款和取款操作,并验证余额:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    import os
    import sys
    sys.path.append(os.getenv('ACCOUNT_MODULE_PATH'))

    from account import pyAccount as Account

    account1 = Account()

    account1.deposit(100.0)
    account1.deposit(100.0)

    account2 = Account()

    account2.deposit(200.0)
    account2.deposit(200.0)

    account1.withdraw(50.0)

    assert account1.get_balance() == 150.0
    assert account2.get_balance() == 400.0
  6. 有了这个,我们就可以配置、构建和测试代码了:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    $ mkdir -p build
    $ cd build
    $ cmake ..
    $ cmake --build .
    $ ctest

    Start 1: python_test
    1/1 Test #1: python_test ...................... Passed 0.03 sec
    100% tests passed, 0 tests failed out of 1
    Total Test time (real) = 0.03 sec

工作原理

本示例中,使用一个相对简单的CMakeLists.txt文件对接了Python和C++,但是是通过使用FindCython.cmake进行的实现。UseCython.cmake模块,放置在cmake-cython下。这些模块包括使用以下代码:

1
2
3
4
5
# directory contains UseCython.cmake and FindCython.cmake
list(APPEND CMAKE_MODULE_PATH ${CMAKE_CURRENT_SOURCE_DIR}/cmake-cython)

# this defines cython_add_module
include(UseCython)

FindCython.cmake包含在UseCython.cmake中,并定义了${CYTHON_EXECUTABLE}变量。后一个模块定义了cython_add_modulecython_add_standalone_executable函数,它们分别用于创建Python模块和独立的可执行程序。这两个模块都可从 https://github.com/thewtex/cython-cmake-example/tree/master/cmake 下载。

这个示例中,使用cython_add_module创建一个Python模块库。注意,将使用非标准的CYTHON_IS_CXX源文件属性设置为TRUE,以便cython_add_module函数知道如何将pyx作为C++文件进行编译:

1
2
3
4
5
# tells UseCython to compile this file as a c++ file
set_source_files_properties(account.pyx PROPERTIES CYTHON_IS_CXX TRUE)

# create python module
cython_add_module(account account.pyx account.cpp)

Python模块在${CMAKE_CURRENT_BINARY_DIR}中创建,为了让Python的test.py脚本找到它,我们使用一个自定义环境变量传递相关的路径,该环境变量用于在test.py中设置path变量。请注意,如何将命令设置为调用CMake可执行文件本身,以便在执行Python脚本之前设置本地环境。这为我们提供了平台独立性,并避免了环境污染:

1
2
3
4
5
6
7
add_test(
NAME
python_test
COMMAND
${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py
)

我们来看看account.pyx文件,这是Python与C++之间的接口文件,并对C++接口进行描述:

1
2
3
4
5
6
7
# describe the c++ interface
cdef extern from "account.hpp":
cdef cppclass Account:
Account() except +
void deposit(double)
void withdraw(double)
double get_balance()

可以看到cinit构造函数、__dealloc__析构函数以及depositwithdraw方法是如何与对应的C++实现相匹配的。

总之,发现了一种机制,通过引入对Cython模块的依赖来耦合Python和C++。该模块可以通过pip安装到虚拟环境或Pipenv中,或者使用Anaconda来安装。

更多信息

C语言可以进行类似地耦合。如果希望利用构造函数和析构函数,我们可以在C接口之上封装一个C++层。

类型化Memoryview提供了有趣的功能,可以映射和访问由C/C++直接在Python中分配的内存,而不需要任何创建:http://cython.readthedocs.io/en/latest/src/userguide/memoryviews.html 。它们使得将NumPy数组直接映射为C++数组成为可能。

9.4 使用Boost.Python构建C++和Python项目

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-9/recipe-04 中找到,其中有一个C++示例。该示例在CMake 3.5版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

Boost库为C++代码提供了Python接口。本示例将展示如何在依赖于Boost的C++项目中使用CMake,之后将其作为Python模块发布。我们将重用前面的示例,并尝试用Cython示例中的C++实现(account.cpp)进行交互。

准备工作

保持account.cpp不变的同时,修改前一个示例中的接口文件(account.hpp):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#pragma once

#define BOOST_PYTHON_STATIC_LIB
#include <boost/python.hpp>

class Account
{
public:
Account();
~Account();
void deposit(const double amount);
void withdraw(const double amount);
double get_balance() const;

private:
double balance;
};

namespace py = boost::python;

BOOST_PYTHON_MODULE(account)
{
py::class_<Account>("Account")
.def("deposit", &Account::deposit)
.def("withdraw", &Account::withdraw)
.def("get_balance", &Account::get_balance);
}

具体实施

如何在C++项目中使用Boost.Python的步骤:

  1. 和之前一样,首先定义最低版本、项目名称、支持语言和默认构建类型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # define minimum cmake version
    cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

    # project name and supported language
    project(recipe-04 LANGUAGES CXX)

    # require C++11
    set(CMAKE_CXX_STANDARD 11)
    set(CMAKE_CXX_EXTENSIONS OFF)
    set(CMAKE_CXX_STANDARD_REQUIRED ON)

    # we default to Release build type
    if(NOT CMAKE_BUILD_TYPE)
    set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
    endif()
  2. 本示例中,依赖Python和Boost库,以及使用Python进行测试。Boost.Python组件依赖于Boost版本和Python版本,因此需要对这两个组件的名称进行检测:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    # for testing we will need the python interpreter
    find_package(PythonInterp REQUIRED)

    # we require python development headers
    find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

    # now search for the boost component
    # depending on the boost version it is called either python,
    # python2, python27, python3, python36, python37, ...

    list(
    APPEND _components
    python${PYTHON_VERSION_MAJOR}${PYTHON_VERSION_MINOR}
    python${PYTHON_VERSION_MAJOR}
    python
    )

    set(_boost_component_found "")

    foreach(_component IN ITEMS ${_components})
    find_package(Boost COMPONENTS ${_component})
    if(Boost_FOUND)
    set(_boost_component_found ${_component})
    break()
    endif()
    endforeach()

    if(_boost_component_found STREQUAL "")
    message(FATAL_ERROR "No matching Boost.Python component found")
    endif()
  3. 使用以下命令,定义Python模块及其依赖项:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    # create python module
    add_library(account
    MODULE
    account.cpp
    )

    target_link_libraries(account
    PUBLIC
    Boost::${_boost_component_found}
    ${PYTHON_LIBRARIES}
    )

    target_include_directories(account
    PRIVATE
    ${PYTHON_INCLUDE_DIRS}
    )

    # prevent cmake from creating a "lib" prefix
    set_target_properties(account
    PROPERTIES
    PREFIX ""
    )

    if(WIN32)
    # python will not import dll but expects pyd
    set_target_properties(account
    PROPERTIES
    SUFFIX ".pyd"
    )
    endif()
  4. 最后,定义了一个测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # turn on testing
    enable_testing()

    # define test
    add_test(
    NAME
    python_test
    COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test.py
    )
  5. 配置、编译和测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    $ mkdir -p build
    $ cd build
    $ cmake ..
    $ cmake --build .
    $ ctest

    Start 1: python_test
    1/1 Test #1: python_test ...................... Passed 0.10 sec
    100% tests passed, 0 tests failed out of 1
    Total Test time (real) = 0.11 sec

工作原理

现在,不依赖于Cython模块,而是依赖于在系统上的Boost库,以及Python的开发头文件和库。

Python的开发头文件和库的搜索方法如下:

1
2
find_package(PythonInterp REQUIRED)
find_package(PythonLibs ${PYTHON_VERSION_MAJOR}.${PYTHON_VERSION_MINOR} EXACT REQUIRED)

首先搜索解释器,然后搜索开发头和库。此外,对PythonLibs的搜索要求开发头文件和库的主版本和次版本,与解释器的完全相同。但是,命令组合不能保证找到完全匹配的版本。

定位Boost.Python时,我们试图定位的组件的名称既依赖于Boost版本,也依赖于我们的Python环境。根据Boost版本的不同,可以调用python、python2、python3、python27、python36、python37等等。我们从特定的名称搜索到更通用的名称,已经解决了这个问题,只有在没有找到匹配的名称时才会失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
list(
APPEND _components
python${PYTHON_VERSION_MAJOR}${PYTHON_VERSION_MINOR}
python${PYTHON_VERSION_MAJOR}
python
)

set(_boost_component_found "")

foreach(_component IN ITEMS ${_components})
find_package(Boost COMPONENTS ${_component})
if(Boost_FOUND)
set(_boost_component_found ${_component})
break()
endif()
endforeach()

if(_boost_component_found STREQUAL "")
message(FATAL_ERROR "No matching Boost.Python component found")
endif()

可以通过设置额外的CMake变量,来调整Boost库的使用方式。例如,CMake提供了以下选项:

  • Boost_USE_STATIC_LIBS:设置为ON之后,可以使用静态版本的Boost库。
  • Boost_USE_MULTITHREADED:设置为ON之后,可以切换成多线程版本。
  • Boost_USE_STATIC_RUNTIME:设置为ON之后,可以在C++运行时静态的连接不同版本的Boost库。

此示例的另一个特点是使用add_library的模块选项。我们已经从第1章第3节了解到,CMake接受以下选项作为add_library的第二个有效参数:

  • STATIC:创建静态库,也就是对象文件的存档,用于链接其他目标时使用,例如:可执行文件
  • SHARED:创建共享库,也就是可以动态链接并在运行时加载的库
  • OBJECT:创建对象库,也就是对象文件不需要将它们归档到静态库中,也不需要将它们链接到共享对象中

MODULE选项将生成一个插件库,也就是动态共享对象(DSO),没有动态链接到任何可执行文件,但是仍然可以在运行时加载。由于我们使用C++来扩展Python,所以Python解释器需要能够在运行时加载我们的库。使用MODULE选项进行add_library,可以避免系统在库名前添加前缀(例如:Unix系统上的lib)。后一项操作是通过设置适当的目标属性来执行的,如下所示:

1
2
3
4
set_target_properties(account
PROPERTIES
PREFIX ""
)

完成Python和C++接口的示例,需要向Python代码描述如何连接到C++层,并列出对Python可见的符号,我们也有可能重新命名这些符号。在上一个示例中,我们在另一个单独的account.pyx文件这样用过。当使用Boost.Python时,我们直接用C++代码描述接口,理想情况下接近期望的接口类或函数定义:

1
2
3
4
5
6
BOOST_PYTHON_MODULE(account) {
py::class_<Account>("Account")
.def("deposit", &Account::deposit)
.def("withdraw", &Account::withdraw)
.def("get_balance", &Account::get_balance);
}

BOOST_PYTHON_MODULE模板包含在<boost/python>中,负责创建Python接口。该模块将公开一个Account Python类,该类映射到C++类。这种情况下,我们不需要显式地声明构造函数和析构函数——编译器会有默认实现,并在创建Python对象时自动调用:

1
myaccount = Account()

当对象超出范围并被回收时,将调用析构函数。另外,观察BOOST_PYTHON_MODULE如何声明depositwithdrawget_balance函数,并将它们映射为相应的C++类方法。

这样,Python可以在PYTHONPATH中找到编译后的模块。这个示例中,我们实现了Python和C++层之间相对干净的分离。Python代码的功能不受限制,不需要类型注释或重写名称,并保持Python风格:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from account import Account

account1 = Account()

account1.deposit(100.0)
account1.deposit(100.0)

account2 = Account()

account2.deposit(200.0)
account2.deposit(200.0)

account1.withdraw(50.0)

assert account1.get_balance() == 150.0
assert account2.get_balance() == 400.0

更多信息

这个示例中,我们依赖于系统上安装的Boost,因此CMake代码会尝试检测相应的库。或者,可以将Boost源与项目一起提供,并将此依赖项,作为项目的一部分构建。Boost使用的是一种可移植的方式将Python与C(++)进行连接。然而,与编译器支持和C++标准相关的可移植性是有代价的,因为Boost.Python不是轻量级依赖项。在接下来的示例中,我们将讨论Boost.Python的轻量级替代方案。

9.5 使用pybind11构建C++和Python项目

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-9/recipe-05 中找到,其中有一个C++示例。该示例在CMake 3.11版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

前面的示例中,我们使用Boost.Python与C(C++)接口。本示例中,我们将尝试使用pybind11将Python与C++接口。其实现利用了C++11的特性,因此需要支持C++11的编译器。我们将演示在配置时如何获取pybind11依赖和构建我们的项目,包括一个使用FetchContent方法的Python接口,我们在第4章第3节和第8章第4节中有过讨论。在第11章第2节时,会通过PyPI发布一个用CMake/pybind11构建的C++/Python项目。届时将重新讨论这个例子,并展示如何打包它,使它可以用pip安装。

准备工作

我们将保持account.cpp不变,只修改account.cpp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#pragma once
#include <pybind11/pybind11.h>
class Account
{
public:
Account();
~Account();
void deposit(const double amount);
void withdraw(const double amount);
double get_balance() const;

private:
double balance;
};
namespace py = pybind11;
PYBIND11_MODULE(account, m)
{
py::class_<Account>(m, "Account")
.def(py::init())
.def("deposit", &Account::deposit)
.def("withdraw", &Account::withdraw)
.def("get_balance", &Account::get_balance);
}

按照pybind11文档的方式,通过CMake构建(https://pybind11.readthedocs.io/en/stable/compile )。并使用add_subdirectory将pybind11导入项目。但是,不会将pybind11源代码显式地放到项目目录中,而是演示如何在配置时使用FetchContent (https://cmake.org/cmake/help/v3.11/module/FetchContent.html )。

为了在下一个示例中更好地重用代码,我们还将把所有源代码放到子目录中,并使用下面的项目布局:

1
2
3
4
5
6
7
.
├── account
│ ├── account.cpp
│ ├── account.hpp
│ ├── CMakeLists.txt
│ └── test.py
└── CMakeLists.txt

具体实施

让我们详细分析一下这个项目中,各个CMakeLists.txt文件的内容:

  1. CMakeLists.txt文件:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # define minimum cmake version
    cmake_minimum_required(VERSION 3.11 FATAL_ERROR)

    # project name and supported language
    project(recipe-05 LANGUAGES CXX)

    # require C++11
    set(CMAKE_CXX_STANDARD 11)
    set(CMAKE_CXX_EXTENSIONS OFF)
    set(CMAKE_CXX_STANDARD_REQUIRED ON)
  2. 这个文件中,查询了用于测试的Python解释器:

    1
    find_package(PythonInterp REQUIRED)
  3. 然后,包含account子目录:

    1
    add_subdirectory(account)
  4. 定义单元测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    # turn on testing
    enable_testing()

    # define test
    add_test(
    NAME
    python_test
    COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=$<TARGET_FILE_DIR:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
    )
  5. account/CMakeLists.txt中,在配置时获取pybind11的源码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    include(FetchContent)

    FetchContent_Declare(
    pybind11_sources
    GIT_REPOSITORY https://github.com/pybind/pybind11.git
    GIT_TAG v2.2
    )

    FetchContent_GetProperties(pybind11_sources)

    if(NOT pybind11_sources_POPULATED)
    FetchContent_Populate(pybind11_sources)

    add_subdirectory(
    ${pybind11_sources_SOURCE_DIR}
    ${pybind11_sources_BINARY_DIR}
    )
    endif()
  6. 最后,定义Python模块。再次使用模块选项add_library。并将库目标的前缀和后缀属性设置为PYTHON_MODULE_PREFIXPYTHON_MODULE_EXTENSION,这两个值由pybind11适当地推断出来:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    add_library(account
    MODULE
    account.cpp
    )

    target_link_libraries(account
    PUBLIC
    pybind11::module
    )

    set_target_properties(account
    PROPERTIES
    PREFIX "${PYTHON_MODULE_PREFIX}"
    SUFFIX "${PYTHON_MODULE_EXTENSION}"
    )
  7. 进行测试:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    $ mkdir -p build
    $ cd build
    $ cmake ..
    $ cmake --build .
    $ ctest

    Start 1: python_test
    1/1 Test #1: python_test ...................... Passed 0.04 sec
    100% tests passed, 0 tests failed out of 1
    Total Test time (real) = 0.04 sec

工作原理

pybind11的功能和使用与Boost.Python非常类似。pybind11是一个更轻量级的依赖——不过需要编译器支持C++11。account.hpp中的接口定义与之前的示例非常类似:

1
2
3
4
5
6
7
8
9
10
11
#include <pybind11/pybind11.h>
// ...
namespace py = pybind11;
PYBIND11_MODULE(account, m)
{
py::class_<Account>(m, "Account")
.def(py::init())
.def("deposit", &Account::deposit)
.def("withdraw", &Account::withdraw)
.def("get_balance", &Account::get_balance);
}

同样,我们可以了解到Python方法是如何映射到C++函数的。解释PYBIND11_MODULE库是在导入的目标pybind11::module中定义,使用以下代码包括了这个模块:

1
2
3
4
add_subdirectory(
${pybind11_sources_SOURCE_DIR}
${pybind11_sources_BINARY_DIR}
)

与之前的示例有两个不同之处:

  • 不需要在系统上安装pybind11
  • ${pybind11_sources_SOURCE_DIR}子目录,包含pybind11的CMakelist.txt中,在我们开始构建项目时,这个目录并不存在

这个挑战的解决方案是用FetchContent,在配置时获取pybind11源代码和CMake模块,以便可以使用add_subdirectory引用。使用FetchContent模式,可以假设pybind11在构建树中可用,并允许构建和链接Python模块:

1
2
3
4
5
6
7
8
9
add_library(account
MODULE
account.cpp
)

target_link_libraries(account
PUBLIC
pybind11::module
)

使用下面的命令,确保Python模块库得到一个定义良好的前缀和后缀,并与Python环境兼容:

1
2
3
4
5
set_target_properties(account
PROPERTIES
PREFIX ${PYTHON_MODULE_PREFIX}
SUFFIX ${PYTHON_MODULE_EXTENSION}
)

CMakeLists.txt文件的其余部分,都在执行测试(与前一个示例使用相同的test.py)。

更多信息

我们可以将pybind11源代码包含在项目源代码存储库中,这将简化CMake结构,并消除在编译时对pybind11源代码进行网络访问的要求。或者,我们可以将pybind11源路径定义为一个Git子模块(https://git-scm.com/book/en/v2/Git-Tools-Submodules ),以应对pybind11源依赖项的更新。

在示例中,我们使用FetchContent解决了这个问题,它提供了一种非常紧凑的方法来引用CMake子项目,而不是显式地跟踪它的源代码。同样,我们也可以使用超级构建的方法来解决这个问题(参见第8章)。

要查看如何简单函数、定义文档注释、映射内存缓冲区等进阶阅读,请参考pybind11文档:https://pybind11.readthedocs.io

9.6 使用Python CFFI混合C,C++,Fortran和Python

NOTE:此示例代码可以在 https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-9/recipe-06 中找到,其中有一个C++示例和一个Fortran示例。该示例在CMake 3.11版(或更高版本)中是有效的,并且已经在GNU/Linux、macOS和Windows上进行过测试。

前面的三个示例中,我们使用Cython、Boost.Python和pybind11作为连接Python和C++的工具。之前的示例中,主要连接的是C++接口。然而,可能会遇到这样的情况:将Python与Fortran或其他语言进行接口。

本示例中,我们将使用Python C的外部函数接口(CFFI,参见https://cffi.readthedocs.io)。由于C是通用语言,大多数编程语言(包括Fortran)都能够与C接口进行通信,所以Python。由于c是通用语言,大多数编程语言(包括fortran)都能够与c接口进行通信,所以python/) CFFI是将Python与大量语言结合在一起的工具。Python CFFI的特性是,生成简单且非侵入性的C接口,这意味着它既不限制语言特性中的Python层,也不会对C层以下的代码有任何限制。

本示例中,将使用前面示例的银行帐户示例,通过C接口将Python CFFI应用于Python和C++。我们的目标是实现一个上下文感知的接口。接口中,我们可以实例化几个银行帐户,每个帐户都带有其内部状态。我们将通过讨论如何使用Python CFFI来连接Python和Fortran来结束本教程。

第11章第3节中,通过PyPI分发一个用CMake/CFFI构建的C/Fortran/Python项目,届时我们将重新讨论这个例子,并展示如何打包它,使它可以用pip安装。

准备工作

我们从C++实现和接口开始,把它们放在名为account/implementation的子目录中。实现文件(cpp_implementation.cpp)类似于之前的示例,但是包含有断言,因为我们将对象的状态保持在一个不透明的句柄中,所以必须确保对象在访问时已经创建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "cpp_implementation.hpp"

#include <cassert>

Account::Account()
{
balance = 0.0;
is_initialized = true;
}
Account::~Account()
{
assert(is_initialized);
is_initialized = false;
}
void Account::deposit(const double amount)
{
assert(is_initialized);
balance += amount;
}
void Account::withdraw(const double amount)
{
assert(is_initialized);
balance -= amount;
}
double Account::get_balance() const
{
assert(is_initialized);
return balance;
}

接口文件(cpp_implementation.hpp)包含如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#pragma once

class Account
{
public:
Account();
~Account();
void deposit(const double amount);
void withdraw(const double amount);
double get_balance() const;

private:
double balance;
bool is_initialized;
};

此外,我们隔离了C-C++接口(c_cpp_interface.cpp)。这将是我们与Python CFFI连接的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include "account.h"
#include "cpp_implementation.hpp"

#define AS_TYPE(Type, Obj) reinterpret_cast<Type *>(Obj)
#define AS_CTYPE(Type, Obj) reinterpret_cast<const Type *>(Obj)

account_context_t *account_new()
{
return AS_TYPE(account_context_t, new Account());
}
void account_free(account_context_t *context) { delete AS_TYPE(Account, context); }
void account_deposit(account_context_t *context, const double amount)
{
return AS_TYPE(Account, context)->deposit(amount);
}
void account_withdraw(account_context_t *context, const double amount)
{
return AS_TYPE(Account, context)->withdraw(amount);
}
double account_get_balance(const account_context_t *context)
{
return AS_CTYPE(Account, context)->get_balance();
}

account目录下,我们声明了C接口(account.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#ifndef ACCOUNT_API
#include "account_export.h"
#define ACCOUNT_API ACCOUNT_EXPORT
#endif
#ifdef __cplusplus
extern "C"
{
#endif
struct account_context;
typedef struct account_context account_context_t;
ACCOUNT_API
account_context_t *account_new();
ACCOUNT_API
void account_free(account_context_t *context);
ACCOUNT_API
void account_deposit(account_context_t *context, const double amount);
ACCOUNT_API
void account_withdraw(account_context_t *context, const double amount);
ACCOUNT_API
double account_get_balance(const account_context_t *context);
#ifdef __cplusplus
}
#endif
#endif /* ACCOUNT_H_INCLUDED */

我们还描述了Python接口,将在稍后对此进行讨论(__init_ _.py):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
from subprocess import check_output
from cffi import FFI
import os
import sys
from configparser import ConfigParser
from pathlib import Path

def get_lib_handle(definitions, header_file, library_file):
ffi = FFI()
command = ['cc', '-E'] + definitions + [header_file]
interface = check_output(command).decode('utf-8')
# remove possible \r characters on windows which
# would confuse cdef
_interface = [l.strip('\r') for l in interface.split('\n')]
ffi.cdef('\n'.join(_interface))
lib = ffi.dlopen(library_file)
return lib

# this interface requires the header file and library file
# and these can be either provided by interface_file_names.cfg
# in the same path as this file
# or if this is not found then using environment variables
_this_path = Path(os.path.dirname(os.path.realpath(__file__)))
_cfg_file = _this_path / 'interface_file_names.cfg'
if _cfg_file.exists():
config = ConfigParser()
config.read(_cfg_file)
header_file_name = config.get('configuration', 'header_file_name')
_header_file = _this_path / 'include' / header_file_name
_header_file = str(_header_file)
library_file_name = config.get('configuration', 'library_file_name')
_library_file = _this_path / 'lib' / library_file_name
_library_file = str(_library_file)
else:
_header_file = os.getenv('ACCOUNT_HEADER_FILE')
assert _header_file is not None
_library_file = os.getenv('ACCOUNT_LIBRARY_FILE')
assert _library_file is not None

_lib = get_lib_handle(definitions=['-DACCOUNT_API=', '-DACCOUNT_NOINCLUDE'],
header_file=_header_file,
library_file=_library_file)
# we change names to obtain a more pythonic API
new = _lib.account_new
free = _lib.account_free
deposit = _lib.account_deposit
withdraw = _lib.account_withdraw
get_balance = _lib.account_get_balance

__all__ = [
'__version__',
'new',
'free',
'deposit',
'withdraw',
'get_balance',
]

我们看到,这个接口的大部分工作是通用的和可重用的,实际的接口相当薄。

项目的布局为:

1
2
3
4
5
6
7
8
9
10
11
.
├── account
│ ├── account.h
│ ├── CMakeLists.txt
│ ├── implementation
│ │ ├── c_cpp_interface.cpp
│ │ ├── cpp_implementation.cpp
│ │ └── cpp_implementation.hpp
│ ├── __init__.py
│ └── test.py
└── CMakeLists.txt

具体实施

现在使用CMake来组合这些文件,形成一个Python模块:

  1. CMakeLists.txt文件包含一个头文件。此外,根据GNU标准,设置编译库的位置:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    # define minimum cmake version
    cmake_minimum_required(VERSION 3.5 FATAL_ERROR)

    # project name and supported language
    project(recipe-06 LANGUAGES CXX)

    # require C++11
    set(CMAKE_CXX_STANDARD 11)
    set(CMAKE_CXX_EXTENSIONS OFF)
    set(CMAKE_CXX_STANDARD_REQUIRED ON)

    # specify where to place libraries
    include(GNUInstallDirs)
    set(CMAKE_LIBRARY_OUTPUT_DIRECTORY
    ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR})
  2. 第二步,是在account子目录下包含接口和实现的定义:

    1
    2
    # interface and sources
    add_subdirectory(account)
  3. CMakeLists.txt文件以测试定义(需要Python解释器)结束:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    # turn on testing
    enable_testing()

    # require python
    find_package(PythonInterp REQUIRED)

    # define test
    add_test(
    NAME
    python_test
    COMMAND
    ${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
    ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
    ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
    ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
    )
  4. account/CMakeLists.txt中定义了动态库目标:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    add_library(account
    SHARED
    plementation/c_cpp_interface.cpp
    implementation/cpp_implementation.cpp
    )

    target_include_directories(account
    PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}
    ${CMAKE_CURRENT_BINARY_DIR}
    )
  5. 导出一个可移植的头文件:

    1
    2
    3
    4
    include(GenerateExportHeader)
    generate_export_header(account
    BASE_NAME account
    )
  6. 使用Python-C接口进行对接:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ mkdir -p build
    $ cd build
    $ cmake ..
    $ cmake --build .
    $ ctest

    Start 1: python_test
    1/1 Test #1: python_test ...................... Passed 0.14 sec
    100% tests passed, 0 tests failed out of 1

工作原理

虽然,之前的示例要求我们显式地声明Python-C接口,并将Python名称映射到C(++)符号,但Python CFFI从C头文件(示例中是account.h)推断出这种映射。我们只需要向Python CFFI层提供描述C接口的头文件和包含符号的动态库。在主CMakeLists.txt文件中使用了环境变量集来实现这一点,这些环境变量可以在__init__.py中找到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# ...
def get_lib_handle(definitions, header_file, library_file):
ffi = FFI()
command = ['cc', '-E'] + definitions + [header_file]
interface = check_output(command).decode('utf-8')

# remove possible \r characters on windows which
# would confuse cdef
_interface = [l.strip('\r') for l in interface.split('\n')]

ffi.cdef('\n'.join(_interface))
lib = ffi.dlopen(library_file)
return lib

# ...

_this_path = Path(os.path.dirname(os.path.realpath(__file__)))
_cfg_file = _this_path / 'interface_file_names.cfg'
if _cfg_file.exists():
# we will discuss this section in chapter 11, recipe 3
else:
_header_file = os.getenv('ACCOUNT_HEADER_FILE')
assert _header_file is not None
_library_file = os.getenv('ACCOUNT_LIBRARY_FILE')
assert _library_file is not None
_lib = get_lib_handle(definitions=['-DACCOUNT_API=', '-DACCOUNT_NOINCLUDE'],
header_file=_header_file,
library_file=_library_file)
# ...

get_lib_handle函数打开头文件(使用ffi.cdef)并解析加载库(使用ffi.dlopen)。并返回库对象。前面的文件是通用的,可以在不进行修改的情况下重用,用于与Python和C或使用Python CFFI的其他语言进行接口的其他项目。

_lib库对象可以直接导出,这里有一个额外的步骤,使Python接口在使用时,感觉更像Python:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# we change names to obtain a more pythonic API
new = _lib.account_new
free = _lib.account_free
deposit = _lib.account_deposit
withdraw = _lib.account_withdraw
get_balance = _lib.account_get_balance

__all__ = [
'__version__',
'new',
'free',
'deposit',
'withdraw',
'get_balance',
]

有了这个变化,可以将例子写成下面的方式:

1
2
3
import account
account1 = account.new()
account.deposit(account1, 100.0)

另一种选择则不那么直观:

1
2
3
from account import lib
account1 = lib.account_new()
lib.account_deposit(account1, 100.0)

需要注意的是,如何使用API来实例化和跟踪上下文:

1
2
3
4
5
6
account1 = account.new()
account.deposit(account1, 10.0)

account2 = account.new()
account.withdraw(account1, 5.0)
account.deposit(account2, 5.0)

为了导入account的Python模块,需要提供ACCOUNT_HEADER_FILEACCOUNT_LIBRARY_FILE环境变量,就像测试中那样:

1
2
3
4
5
6
7
8
9
add_test(
NAME
python_test
COMMAND
${CMAKE_COMMAND} -E env ACCOUNT_MODULE_PATH=${CMAKE_CURRENT_SOURCE_DIR}
ACCOUNT_HEADER_FILE=${CMAKE_CURRENT_SOURCE_DIR}/account/account.h
ACCOUNT_LIBRARY_FILE=$<TARGET_FILE:account>
${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/account/test.py
)

第11章中,将讨论如何创建一个可以用pip安装的Python包,其中头文件和库文件将安装在定义良好的位置,这样就不必定义任何使用Python模块的环境变量。

讨论了Python方面的接口之后,现在看下C的接口。account.h内容为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct account_context;
typedef struct account_context account_context_t;

ACCOUNT_API
account_context_t *account_new();

ACCOUNT_API
void account_free(account_context_t *context);

ACCOUNT_API
void account_deposit(account_context_t *context, const double amount);

ACCOUNT_API
void account_withdraw(account_context_t *context, const double amount);

ACCOUNT_API
double account_get_balance(const account_context_t *context);

黑盒句柄account_context会保存对象的状态。ACCOUNT_API定义在account_export.h中,由account/interface/CMakeLists.txt生成:

1
2
3
4
include(GenerateExportHeader)
generate_export_header(account
BASE_NAME account
)

account_export.h头文件定义了接口函数的可见性,并确保这是以一种可移植的方式完成的,实现可以在cpp_implementation.cpp中找到。它包含is_initialized布尔变量,可以检查这个布尔值确保API函数按照预期的顺序调用:上下文在创建之前或释放之后都不应该被访问。

更多信息

设计Python-C接口时,必须仔细考虑在哪一端分配数组:数组可以在Python端分配并传递给C(++)实现,也可以在返回指针的C(++)实现上分配。后一种方法适用于缓冲区大小事先未知的情况。但返回到分配给C(++)端的数组指针可能会有问题,因为这可能导致Python垃圾收集导致内存泄漏,而Python垃圾收集不会“查看”分配给它的数组。我们建议设计C API,使数组可以在外部分配并传递给C实现。然后,可以在__init__.py中分配这些数组,如下例所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from cffi import FFI
import numpy as np

_ffi = FFI()

def return_array(context, array_len):
# create numpy array
array_np = np.zeros(array_len, dtype=np.float64)

# cast a pointer to its data
array_p = _ffi.cast("double *", array_np.ctypes.data)

# pass the pointer
_lib.mylib_myfunction(context, array_len, array_p)

# return the array as a list
return array_np.tolist()

return_array函数返回一个Python列表。因为在Python端完成了所有的分配工作,所以不必担心内存泄漏,可以将清理工作留给垃圾收集。

对于Fortran示例,读者可以参考以下Git库:https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter09/recipe06/Fortran-example 。与C++实现的主要区别在于,account库是由Fortran 90源文件编译而成的,我们在account/CMakeLists.txt中使用了Fortran 90源文件:

1
2
3
4
add_library(account
SHARED
implementation/fortran_implementation.f90
)

上下文保存在用户定义的类型中:

1
2
3
4
5
type :: account
private
real(c_double) :: balance
logical :: is_initialized = .false.
end type

Fortran实现可以使用iso_c_binding模块解析account.h中定义的符号和方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
module account_implementation

use, intrinsic :: iso_c_binding, only: c_double, c_ptr

implicit none

private

public account_new
public account_free
public account_deposit
public account_withdraw
public account_get_balance

type :: account
private
real(c_double) :: balance
logical :: is_initialized = .false.
end type

contains

type(c_ptr) function account_new() bind (c)
use, intrinsic :: iso_c_binding, only: c_loc
type(account), pointer :: f_context
type(c_ptr) :: context

allocate(f_context)
context = c_loc(f_context)
account_new = context
f_context%balance = 0.0d0
f_context%is_initialized = .true.
end function

subroutine account_free(context) bind (c)
use, intrinsic :: iso_c_binding, only: c_f_pointer
type(c_ptr), value :: context
type(account), pointer :: f_context

call c_f_pointer(context, f_context)
call check_valid_context(f_context)
f_context%balance = 0.0d0
f_context%is_initialized = .false.
deallocate(f_context)
end subroutine

subroutine check_valid_context(f_context)
type(account), pointer, intent(in) :: f_context
if (.not. associated(f_context)) then
print *, 'ERROR: context is not associated'
stop 1
end if
if (.not. f_context%is_initialized) then
print *, 'ERROR: context is not initialized'
stop 1
end if
end subroutine

subroutine account_withdraw(context, amount) bind (c)
use, intrinsic :: iso_c_binding, only: c_f_pointer
type(c_ptr), value :: context
real(c_double), value :: amount
type(account), pointer :: f_context

call c_f_pointer(context, f_context)
call check_valid_context(f_context)
f_context%balance = f_context%balance - amount
end subroutine

subroutine account_deposit(context, amount) bind (c)
use, intrinsic :: iso_c_binding, only: c_f_pointer
type(c_ptr), value :: context
real(c_double), value :: amount
type(account), pointer :: f_context

call c_f_pointer(context, f_context)
call check_valid_context(f_context)
f_context%balance = f_context%balance + amount
end subroutine

real(c_double) function account_get_balance(context) bind (c)
use, intrinsic :: iso_c_binding, only: c_f_pointer
type(c_ptr), value, intent(in) :: context
type(account), pointer :: f_context

call c_f_pointer(context, f_context)
call check_valid_context(f_context)
account_get_balance = f_context%balance
end function
end module

这个示例和解决方案的灵感来自Armin Ronacher的帖子“Beautiful Native Libraries”: http://lucumr.pocoo.org/2013/8/18/beautiful-native-libraries/