CMake 秘籍(五)(4)https://developer.aliyun.com/article/1524581
还有更多
我们可以将 pybind11 源代码作为项目源代码仓库的一部分,这将简化 CMake 结构并消除在编译时需要网络访问 pybind11 源代码的要求。或者,我们可以将 pybind11 源路径定义为 Git 子模块(git-scm.com/book/en/v2/Git-Tools-Submodules),以简化更新 pybind11 源依赖关系。
在本例中,我们使用FetchContent解决了这个问题,它提供了一种非常紧凑的方法来引用 CMake 子项目,而无需显式跟踪其源代码。此外,我们还可以使用所谓的超级构建方法来解决这个问题(参见第八章,The Superbuild Pattern)。
另请参阅
若想了解如何暴露简单函数、定义文档字符串、映射内存缓冲区以及获取更多阅读材料,请参考 pybind11 文档:pybind11.readthedocs.io。
使用 Python CFFI 混合 C、C++、Fortran 和 Python
本配方的代码可在github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-06找到,并包含 C++和 Fortran 示例。这些配方适用于 CMake 版本 3.5(及更高版本)。这两个版本的配方已在 GNU/Linux、macOS 和 Windows 上进行了测试。
在前三个菜谱中,我们讨论了 Cython、Boost.Python 和 pybind11 作为连接 Python 和 C++的工具,提供了一种现代且清晰的方法。在前面的菜谱中,主要接口是 C++接口。然而,我们可能会遇到没有 C++接口可供连接的情况,这时我们可能希望将 Python 与 Fortran 或其他语言连接起来。
在本菜谱中,我们将展示一种使用 Python C Foreign Function Interface(CFFI;另见cffi.readthedocs.io)的替代方法来连接 Python。由于 C 是编程语言的通用语,大多数编程语言(包括 Fortran)都能够与 C 接口通信,Python CFFI 是一种将 Python 与大量语言连接的工具。Python CFFI 的一个非常好的特点是,生成的接口是薄的且不侵入的,这意味着它既不限制 Python 层的语言特性,也不对 C 层以下的代码施加任何限制,除了需要一个 C 接口。
在本菜谱中,我们将应用 Python CFFI 通过 C 接口将 Python 和 C++连接起来,使用在前述菜谱中介绍的银行账户示例。我们的目标是实现一个上下文感知的接口,可以实例化多个银行账户,每个账户都携带其内部状态。我们将通过本菜谱结束时对如何使用 Python CFFI 将 Python 与 Fortran 连接进行评论。在第十一章,打包项目,菜谱 3,通过 CMake/CFFI 构建的 C/Fortran/Python 项目通过 PyPI 分发,我们将重新审视这个示例,并展示如何打包它,使其可以通过 pip 安装。
准备工作
我们将需要几个文件来完成这个菜谱。让我们从 C++实现和接口开始。我们将把这些文件放在一个名为account/implementation的子目录中。实现文件(cpp_implementation.cpp)与之前的菜谱类似,但包含了额外的assert语句,因为我们将在一个不透明的句柄中保持对象的状态,并且我们必须确保在尝试访问它之前创建了对象:
#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)包含以下内容:
#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 连接的接口:
#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):
/* CFFI would issue warning with pragma once */ #ifndef ACCOUNT_H_INCLUDED #define ACCOUNT_H_INCLUDED #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):
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', ]
这是一堆文件,但是,正如我们将看到的,大部分接口工作是通用的和可重用的,实际的接口相当薄。总之,这是我们项目的布局:
. ├── account │ ├── account.h │ ├── CMakeLists.txt │ ├── implementation │ │ ├── c_cpp_interface.cpp │ │ ├── cpp_implementation.cpp │ │ └── cpp_implementation.hpp │ ├── __init__.py │ └── test.py └── CMakeLists.txt
如何操作
现在让我们使用 CMake 将这些文件组合成一个 Python 模块:
- 顶层
CMakeLists.txt文件包含一个熟悉的标题。此外,我们还根据 GNU 标准设置了编译库的位置:
# 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})
- 第二步是在
account子目录下包含接口定义和实现源代码,我们将在下面详细介绍:
# interface and sources add_subdirectory(account)
- 顶层的
CMakeLists.txt文件以定义测试(需要 Python 解释器)结束:
# 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 )
- 包含的
account/CMakeLists.txt定义了共享库:
add_library(account SHARED implementation/c_cpp_interface.cpp implementation/cpp_implementation.cpp ) target_include_directories(account PRIVATE ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR} )
- 然后我们生成一个可移植的导出头文件:
include(GenerateExportHeader) generate_export_header(account BASE_NAME account )
- 现在我们准备好了对 Python—C 接口进行测试:
$ 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 中查询了这些环境变量:
# ... 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 接口感觉更 pythonic:
# 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', ]
有了这个改动,我们可以这样写:
import account account1 = account.new() account.deposit(account1, 100.0)
另一种方法则不那么直观:
from account import lib account1 = lib.account_new() lib.account_deposit(account1, 100.0)
请注意,我们能够使用上下文感知的 API 实例化和跟踪隔离的上下文:
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_FILE 和 ACCOUNT_LIBRARY_FILE 环境变量,就像我们为测试所做的那样:
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 )
在 第十一章《打包项目》中,我们将讨论如何创建一个可以使用 pip 安装的 Python 包,其中头文件和库文件将安装在定义良好的位置,这样我们就不必定义任何环境变量来使用 Python 模块。
讨论了接口的 Python 方面之后,现在让我们考虑接口的 C 方面。account.h 的本质是这一部分:
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 中定义,该文件由 CMake 在 account/interface/CMakeLists.txt 中生成:
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 的垃圾回收不会“看到”已分配的数组。我们建议设计 C API,使得数组可以在外部分配并传递给 C 实现。然后,这些数组可以在__init__.py内部分配,如本例所示:
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 示例,我们建议读者参考以下配方仓库:github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter-09/recipe-06/fortran-example。与 C++实现的主要区别在于,账户库是由 Fortran 90 源文件编译而成,我们在account/CMakeLists.txt中对此进行了考虑:
add_library(account SHARED implementation/fortran_implementation.f90 )
上下文保存在用户定义的类型中:
type :: account private real(c_double) :: balance logical :: is_initialized = .false. end type
Fortran 实现能够通过使用iso_c_binding模块解析未更改的account.h中定义的符号和方法:
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”,lucumr.pocoo.org/2013/8/18/beautiful-native-libraries/。