1+1=10

记记笔记,放松一下...

如何在Python中使用C++库

假定我有一个用 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接口)

动态库内容,就定一个简单的加法函数吧:

1
2
3
4
5
#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

1
2
3
4
5
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使用还算好,不过不同平台下名字不一样,还是需要注意的:

1
2
3
4
5
6
7
8
9
# 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

最直接的方式,但写起来也最繁琐

测试用例

都封装成模块了,用例简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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,可以直接导入:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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安装

1
pip install pybind11

安装后需要配置环境变量让cmake能够能找到它,它的路径在:

1
python -m pybind11 --cmakedir

创建 pybind11 绑定

源文件:

1
2
3
4
5
6
7
#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");
}

工程文件

1
2
3
4
5
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 等一组命令集合。

安装

1
pip install sip

这是一个重量级的东西。不过这东西20多年了,可参考的文档实在太少。可能学习能力退化了,感觉比其10年前的老版本难用!

创建绑定

准备一个.sip文件,取名lib_sip.sip

1
2
3
4
5
6
7
%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"]

直接执行

1
sip-build

如果有疑难杂症,记得加上选项 --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

单元测试

测试代码本身简单,不过找到这个模块,需要操作一下。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 只是其运行库。

安装

1
pip install shiboken6  shiboken6-generator

另外,这个东西还依赖libclang,(shiboken6需要libclang10。)需要手动安装。环境变量 CLANG_INSTALL_DIR 也需要指向它。

准备绑定

它需要一个类型系统文件,取名 lib2_shiboken.xml

1
2
3
4
5
<?xml version="1.0" encoding="UTF-8"?>
<typesystem package="lib2_shiboken">
    <primitive-type name="int"/>
    <function signature="add(int, int)" />
</typesystem>

此时,执行shiboken6即可生成所需的cpp文件:

1
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

Python c++