假定我有一个用 C++ 创建一个的动态库,该如何在 Python 下调用它。
- ctypes?
- cffi?
- pybind11?
- sip?
- shiboken?
- ...
每一个东西都有复杂的细节,我们用最简单的例子,简单过一下,不发散。
为保证例子在 Windows、Linux 和 MacOS 下都能工作,
C++部分统一使用了cmake,并借助 GitHub 的 CI 功能进行自动化测试。本文涉及到的完整源码均位于 github仓库。
准备工作
先准备一下目录结构:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 | project/
├── cpp/
│   ├── CMakeLists.txt
│   ├── lib1/
│   │   ├── lib1.cpp
│   │   ├── lib1.h
│   │   ├── CMakeLists.txt
│   ├── lib2/
├── bindings/
│   ├── lib2_pythonapi/
│   ├── lib2_pybind11/
│   ├── lib2_sip/
│   ├── lib2_shiboken/
├── pythontests/
│   ├── test_lib1_ctypes.py
│   ├── test_lib1_cffi.py
│   ├── test_lib2_pythonapi.py
│   ├── test_lib2_pybind11.py
│   ├── test_lib2_sip.py
│   ├── test_lib2_shiboken.py
├── .github/
│   ├── workflows/
│       ├── ci.yml
 | 
为了简单起见,通过配置cpp/CMakeLists.txt,将所有动态库统一放置到构建目录下的bin或lib目录下:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14 | cmake_minimum_required(VERSION 3.22)
project(CppLibs)
if (WIN32)
    set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) # For .dll
    set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR}/bin)
    set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # For .lib
else()
    set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # For .so/.dylib
    set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) # For .a
endif()
add_subdirectory(lib1)
#add_subdirectory(lib2)
 | 
c++ 动态库(C接口)
动态库内容,就定一个简单的加法函数吧:
|  | #include "lib1.h"
int add(int a, int b) {
    return a + b;
}
 | 
头文件cpp\lib1\lib1.h:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15 | #pragma once
#ifdef _WIN32
    #ifdef BUILD_LIB1_DLL
        #define LIB1_DLL_EXPORT __declspec(dllexport)
    #else
        #define LIB1_DLL_EXPORT __declspec(dllimport)
    #endif
#else
    #define LIB1_DLL_EXPORT
#endif
extern "C" {
    LIB1_DLL_EXPORT int add(int a, int b);
}
 | 
工程文件cpp/lib1/CMakeLists.txt:
|  | add_library(lib1 SHARED lib1.cpp)
if (WIN32)
    target_compile_definitions(lib1 PRIVATE BUILD_LIB1_DLL)
endif()
 | 
c++ 动态库(C++接口)
lib2的定义和前面lib1基本完全一样,只是没有用 extern "C",头文件如下
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13 | #pragma once
#ifdef _WIN32
    #ifdef BUILD_LIB2_DLL
        #define LIB2_DLL_EXPORT __declspec(dllexport)
    #else
        #define LIB2_DLL_EXPORT __declspec(dllimport)
    #endif
#else
    #define LIB2_DLL_EXPORT
#endif
LIB2_DLL_EXPORT int add(int a, int b);
 | 
直接使用动态库
如果动态库导出的是C接口(比如lib1),可以直接使用动态库。
对于lib2,由于C++有名字改编的问题,尽管只要我们知道改编后的名字,也是可以这么用的。但不同C++编译器名字改编规则也不同,偶尔应急用一下还行,折腾下去意义不大。
通过 ctypes
对于C接口的lib1,可以直接使用Pythoni标准库中的ctypes。
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22 | import ctypes
# Load the shared library
try:
    lib1 = ctypes.CDLL(lib_full_path)
    print(f"Successfully loaded library from {lib_full_path}")
except OSError as e:
    print(f"Failed to load library: {e}")
    sys.exit(1)
# Specify the function signature
lib1.add.argtypes = [ctypes.c_int, ctypes.c_int]
lib1.add.restype = ctypes.c_int
# Test the function
def test_lib1_add():
    result = lib1.add(1, 1)
    print(f"lib1.add(1,1) = {result}")
    assert result == 2
if __name__ == "__main__":
    test_lib1_add()
 | 
本身ctypes使用还算好,不过不同平台下名字不一样,还是需要注意的:
|  | # Determine the shared library name
lib_base_name = "lib1"
if sys.platform == "win32":
    lib_path = f"bin/{lib_base_name}.dll"
elif sys.platform == "darwin":
    lib_path = f"lib/lib{lib_base_name}.dylib"
else:
    lib_path = f"lib/lib{lib_base_name}.so"
lib_full_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "../build", lib_path))
 | 
通过 cffi
对C接口的lib1,使用cffi也简单,test_lib1_cffi.py:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20 | from cffi import FFI
# Create an FFI instance
ffi = FFI()
# Define the C function signature
ffi.cdef("""
         int add(int a, int b);
""")
lib1 = ffi.dlopen(lib_full_path)
# Test the function
def test_lib1_add():
    result = lib1.add(1, 1)
    print(f"lib1.add(1,1) = {result}")
    assert result == 2
if __name__ == "__main__":
    test_lib1_add()
 | 
通过创建绑定使用
这个方式就比较多了,可以直接根据Python的扩展要求手撸出来一个(针对简单例子)。更多还是借助其他工具方便
直接使用Python API
最直接的方式,但写起来也最繁琐
测试用例
都封装成模块了,用例简单:
|  | sys.path.insert(0, mycommon.get_module_path())
import lib2_pythonapi
# Test the function
def test_lib2_add():
    result = lib2_pythonapi.add(1, 1)
    print(f"lib2_pythonapi.add(1,1) = {result}")
    assert result == 2
if __name__ == "__main__":
    test_lib2_add()
 | 
创建绑定
直接定义一个模块lib2_pythonapi:
|  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 | #define PY_SSIZE_T_CLEAN
#include <Python.h>
#include "../../cpp/lib2/lib2.h"
static PyObject* py_add(PyObject* self, PyObject* args) {
    int a, b;
    if (!PyArg_ParseTuple(args, "ii", &a, &b)) {
        return NULL;
    }
    int result = add(a, b);  // call functions from lib2
    return PyLong_FromLong(result);
}
static PyMethodDef ModuleMethods[] = {
    {"add", py_add, METH_VARARGS, "Add two numbers"},
    {NULL, NULL, 0, NULL}
};
static struct PyModuleDef moduledef = {
    PyModuleDef_HEAD_INIT,
    "lib2_pythonapi",
    "A Python module that links to lib2.dll/liblib2.so",
    -1,
    ModuleMethods
};
PyMODINIT_FUNC PyInit_lib2_pythonapi(void) {
    return PyModule_Create(&moduledef);
}
 | 
工程文件:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12 | find_package(Python3 REQUIRED COMPONENTS Interpreter Development)
add_library(lib2_pythonapi MODULE lib2_pythonapi.cpp)
target_link_libraries(lib2_pythonapi PRIVATE lib2 Python3::Python)
if (WIN32)
    set_target_properties(lib2_pythonapi PROPERTIES SUFFIX ".pyd") # Windows generate .pyd
endif()
set_target_properties(lib2_pythonapi PROPERTIES
    PREFIX ""  # Remove prefix
)
 | 
使用 pybind11
先看看测试用例,由于是将lib2封装成了lib2_pybind11,可以直接导入:
|  | sys.path.insert(0, mycommon.get_module_path())
import lib2_pybind11
# Test the function
def test_lib2_add():
    result = lib2_pybind11.add(1, 1)
    print(f"lib2_pybind11.add(1,1) = {result}")
    assert result == 2
if __name__ == "__main__":
    test_lib2_add()
 | 
pybind11 安装
pybind11 是受 Boost.Python 启发创建的,但没有Boost那么笨重。
pybind11仅有头文件构成,可以将其包含在项目中,或者通过pip安装
安装后需要配置环境变量让cmake能够能找到它,它的路径在:
|  | python -m pybind11 --cmakedir
 | 
创建 pybind11 绑定
源文件:
|  | #include <pybind11/pybind11.h>
#include "../../cpp/lib2/lib2.h"
PYBIND11_MODULE(lib2_pybind11, m) {
    m.doc() = "A simple example";
    m.def("add", &add, "A function that adds two numbers");
}
 | 
工程文件
|  | set(PYBIND11_FINDPYTHON ON)
find_package(pybind11 REQUIRED)
pybind11_add_module(lib2_pybind11 lib2_pybind11.cpp)
target_link_libraries(lib2_pybind11 PRIVATE lib2 pybind11::module)
 | 
使用 SIP
SIP 是 PyQt使用的绑定工具,开发始于1998年。不用于PyQt采用GPL和商业双授权,SIP采用BSD协议,无需担心协议问题。
在 SIP 6.x 中,传统的 sip 命令被替换为 sip-build、sip-module 等一组命令集合。
安装
这是一个重量级的东西。不过这东西20多年了,可参考的文档实在太少。可能学习能力退化了,感觉比其10年前的老版本难用!
创建绑定
准备一个.sip文件,取名lib_sip.sip:
|  | %Module(name=lib2_sip, language="C++")
%ModuleHeaderCode
#include "lib2.h"
%End
int add(int a, int b);
 | 
创建一个工程文件pyproject.toml:
|  1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12 | [build-system]
requires = ["sip >=6, <7"]
build-backend = "sipbuild.api"
[project]
name = "lib2_sip"
[tool.sip.bindings.lib2_sip]
headers = ["../../cpp/lib2/lib2.h"]
include-dirs = ["../../cpp/lib2"]
libraries = ["lib2"]
library-dirs = ["../../build/lib", "../../build/lib/Release"]
 | 
直接执行
如果有疑难杂症,记得加上选项 --verbose以便于排查。
即可生成绑定模块:
- Windows下: build\lib2_sip\build\lib.win-amd64-cpython-312\lib2_sip.cp312-win_amd64.pyd
- Ubuntu下:build/lib.linux-x86_64-cpython-312/lib2_sip.cpython-312-x86_64-linux-gnu.so
- MacOS下:build/lib.macosx-10.13-universal2-cpython-312/lib2_sip.cpython-312-darwin.so
单元测试
测试代码本身简单,不过找到这个模块,需要操作一下。
|  | import lib2_sip
# Test the function
def test_lib2_add():
    result = lib2_sip.add(1, 1)
    print(f"lib2_sip.add(1,1) = {result}")
    assert result == 2
if __name__ == "__main__":
    test_lib2_add()
 | 
使用 Shiboken
Shiboken是PySide使用的为C++Qt库创建绑定的工具。开发始于2008年的Nokia,2016年复活,当前版本是其复活后的Shiboken6。
PySide项目最开始阶段使用的是 Boost.Python,因为性能问题,才决定自行开发Shiboken。
注意,可执行程序Shiboken位于软件包 shiboken6-generator 中,软件包shiboken6 只是其运行库。
安装
|  | pip install shiboken6  shiboken6-generator
 | 
另外,这个东西还依赖libclang,(shiboken6需要libclang10。)需要手动安装。环境变量 CLANG_INSTALL_DIR 也需要指向它。
准备绑定
它需要一个类型系统文件,取名 lib2_shiboken.xml:
|  | <?xml version="1.0" encoding="UTF-8"?>
<typesystem package="lib2_shiboken">
    <primitive-type name="int"/>
    <function signature="add(int, int)" />
</typesystem>
 | 
此时,执行shiboken6即可生成所需的cpp文件:
|  | shiboken6 ..\..\cpp\lib2\lib2.h .\lib2_shiboken.xml
 | 
生成文件位于.\out\lib2_shiboken\lib2_shiboken_module_wrapper.cpp
由于 Shiboken没有提供CMake支持,如果不跨平台的话,直接编译这个文件可能是最简单的。不然,它仓库中最简单的例子samplebinding也足以劝退大多数人。
构建
准备工程文件CMakeLists.txt:
|  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 | set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Find Python3 interpreter and development components
find_package(Python3 COMPONENTS Interpreter Development REQUIRED)
# Debug: Print Python3 executable path
message("Python3_EXECUTABLE: ${Python3_EXECUTABLE}")
# Attempt to retrieve the Shiboken6 installation path dynamically
execute_process(
    COMMAND python -c "import shiboken6; print(shiboken6.__path__[0])"
    OUTPUT_VARIABLE SHIBOKEN6_PATH
    ERROR_VARIABLE SHIBOKEN6_ERROR
    OUTPUT_STRIP_TRAILING_WHITESPACE
)
# Debug: Print the result of execute_process
message("SHIBOKEN6_PATH: ${SHIBOKEN6_PATH}")
message("SHIBOKEN6_ERROR: ${SHIBOKEN6_ERROR}")
# Set paths for Shiboken6 tool, headers, and library
set(shiboken_include_dir ${SHIBOKEN6_PATH}/../shiboken6_generator/include)
message("shiboken include path: " ${shiboken_include_dir})
# Configure platform-specific library settings
if(WIN32)
    set(shiboken_library ${SHIBOKEN6_PATH}/shiboken6.abi3.lib)
    set(module_suffix ".pyd")
elseif(APPLE)
    set(shiboken_library ${SHIBOKEN6_PATH}/libshiboken6.abi3.dylib)
    set(module_suffix ".so")
else()
    set(shiboken_library ${SHIBOKEN6_PATH}/libshiboken6.abi3.so)
    set(module_suffix ".so")
endif()
# Specify the C++ header and typesystem file for the binding
set(wrapped_header ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/lib2/lib2.h)
set(typesystem_file ${CMAKE_CURRENT_SOURCE_DIR}/lib2_shiboken.xml)
# Use Shiboken6 to generate the binding source code
add_custom_command(
    OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/lib2_shiboken/lib2_shiboken_module_wrapper.cpp
    COMMAND shiboken6
    --output-directory=${CMAKE_CURRENT_BINARY_DIR}
    --include-paths=${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/lib2
    --typesystem-paths=${shiboken_include_dir}
    ${wrapped_header}
    ${typesystem_file}
    DEPENDS lib2 ${wrapped_header} ${typesystem_file}
    WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
# Create the Python module
add_library(lib2_shiboken MODULE
    ${CMAKE_CURRENT_BINARY_DIR}/lib2_shiboken/lib2_shiboken_module_wrapper.cpp
)
# Link the Shiboken6 runtime library and include the headers
target_include_directories(lib2_shiboken PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../cpp/lib2 ${shiboken_include_dir})
target_link_libraries(lib2_shiboken PRIVATE
    lib2
    ${shiboken_library}
    Python3::Python
)
# Set the module properties
set_target_properties(lib2_shiboken PROPERTIES
    PREFIX "" # Remove default 'lib' prefix
    SUFFIX ${module_suffix} # Use platform-specific suffix
)
 | 
参考
- https://docs.python.org/3/library/ctypes.html
- https://docs.python.org/3/extending/extending.html
- https://cffi.readthedocs.io/en/stable/
- https://pybind11.readthedocs.io/en/latest/
- https://pypi.org/project/sip/
- https://doc.qt.io/qtforpython-6/shiboken6/gettingstarted.html
- https://www.kdab.com/creating-python-bindings-for-qt-libraries/
- https://github.com/qtproject/pyside-pyside-setup