CMake 秘籍(五)(5)

简介: CMake 秘籍(五)

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 模块:

  1. 顶层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})
  1. 第二步是在 account 子目录下包含接口定义和实现源代码,我们将在下面详细介绍:
# interface and sources
add_subdirectory(account)
  1. 顶层的 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
  )
  1. 包含的 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}
  )
  1. 然后我们生成一个可移植的导出头文件:
include(GenerateExportHeader)
generate_export_header(account
  BASE_NAME account
  )
  1. 现在我们准备好了对 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_FILEACCOUNT_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_APIaccount_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/

相关文章
|
15天前
|
Linux C++ iOS开发
CMake 秘籍(四)(3)
CMake 秘籍(四)
7 0
|
15天前
|
编译器 Linux C++
CMake 秘籍(六)(2)
CMake 秘籍(六)
17 0
|
15天前
|
并行计算 编译器 Linux
CMake 秘籍(二)(4)
CMake 秘籍(二)
15 0
|
15天前
|
Linux 编译器 C++
CMake 秘籍(七)(2)
CMake 秘籍(七)
24 1
|
15天前
|
编译器 测试技术 开发工具
CMake 秘籍(八)(4)
CMake 秘籍(八)
15 0
|
15天前
|
编译器 Linux C++
CMake 秘籍(六)(5)
CMake 秘籍(六)
15 1
|
15天前
|
Linux API iOS开发
CMake 秘籍(六)(1)
CMake 秘籍(六)
16 1
|
15天前
|
编译器 Linux C++
CMake 秘籍(二)(1)
CMake 秘籍(二)
18 0
|
15天前
|
Linux C++ iOS开发
CMake 秘籍(四)(1)
CMake 秘籍(四)
11 0
|
15天前
|
XML 监控 Linux
CMake 秘籍(七)(4)
CMake 秘籍(七)
28 0

热门文章

最新文章