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 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 ) 类似于之前的示例,但是包含有断言,因为我们将对象的状态保持在一个不透明的句柄中,所以必须确保对象在访问时已经创建:
#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 ):
#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 plementation/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:
# 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
)第 11 章中,将讨论如何创建一个可以用 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 中,由 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 垃圾收集导致内存泄漏,而 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 示例,读者可以参考以下 Git 库: https://github.com/dev-cafe/cmake-cookbook/tree/v1.0/chapter09/recipe06/Fortran-example 。与 C++实现的主要区别在于,account 库是由 Fortran 90 源文件编译而成的,我们在 account/CMakeLists.txt 中使用了 Fortran 90 源文件:
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”: http://lucumr.pocoo.org/2013/8/18/beautiful-native-libraries/
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论