Сравнение PyBind11 vs ctypes

В принципе, можно вызывать C++ из Python двумя способами: при помощи библиотеки PyBind11 для C++, которая готовит модуль Python, либо при помощи пакета cytpes для Python, который предоставляет доступ к скомпилированной разделяемой библиотеке. Работая с PyBind11, не составляет труда совместно использовать множество типов данных, в то время как ctypes — это гораздо более низкоуровневое решение в стиле C.

Взявшись за описанный здесь проект, я хотел рассчитывать на производительность и переносимость C++, но так, чтобы не жертвовать интерактивностью интерпретируемых языков, которая удобна для экспресс-исследования и отладки.

К счастью, вызывать C++ из Python не так сложно, как может показаться на первый взгляд. Таким образом, можно в какой-то степени позаимствовать интерактивность Python при разработке кода C++.

Вот для чего я хотел использовать Python в данном конкретном случае:

  • Передавать в C++ некоторые проблемные параметры 

  • Вызывать код C++ для выполнения ресурсозатратных процедур

  • Извлекать окончательные результаты, а также, в отладочных целях — некоторые промежуточные вычисления.

  • Исследовать результаты в интерактивном режиме, строить на их основе графики и отчёты.

При использовании ctypes возникает такая проблема: для совместного использования множественных типов данных требуется немало низкоуровневых обходных манёвров. Например,  ctypes не поддерживает таких элементарных вещей, как комплексные числа, а PyBind11 обеспечивает полное взаимодействие Numpy с Eigen, и на это требуется минимум кода.

Правда, я обнаружил и небольшую проблему с PyBind11. Оказывается, что после перекомпиляции кода C++ и при попытке перезагрузить сгенерированный PyBind модуль Python ничего не происходит. Был только один действующий способ перезагрузить скомпилированный модуль — перезапустить сеанс Python. В любом случае, всё это несложно, Python запускается почти сразу. Вероятно, этот шаг можно автоматизировать на уровне IDE.

Итак, нас интересует, как выжать максимум из PyBind11.

Совместное использование класса C++ при работе с PyBind11

Официальная документация у PyBind11 просто отличная, и я без проблем приступил к работе, опираясь на неё. Но заранее хочу поделиться отличным руководством по быстрому запуску этой библиотеки, а ещё расскажу, как собираюсь этой библиотекой пользоваться.

Библиотека Pybind11 содержит только заголовочный файл, и получить её не составляет труда:

pip install pybind11

Нет необходимости структурировать весь ваш код на C++ как класс. Pybind11 сильно упростит вам жизнь, если у вас есть класс, который можно совместно использовать сразу в C++ и Python. (Кстати, я предпочитаю использовать vector, а не struct, причём, в порученных мне проектах стараюсь обойтись минимальным количеством классов).

Но в данном случае я пришёл к выводу, что, применив паттерн проектирования Фасад, можно одновременно и обеспечить очень простое взаимодействие между Python и C++, и сделать приятный API.

Таким образом, у меня получился простой класс. В сущности, он содержит:

  • Конструктор, читающий параметры задачи

  • Функцию run(), выполняющую вычисление

  • Несколько массивов Eigen, используемых в качестве публичных переменных для хранения результатов

Вот мой минимальный пример:

// mylib.h
#include <Eigen/Dense>
#include <cmath>

using Eigen::Matrix, Eigen::Dynamic;
typedef Matrix<std::complex<double>, Eigen::Dynamic, Eigen::Dynamic> myMatrix;

class MyClass {

    int N;
    double a;
    double b;

public:

    Eigen::VectorXd v_data;
    Eigen::VectorXd v_gamma;

    MyClass(){}
    MyClass( double a_in, double b_in, int N_in) 
    {
        N = N_in;
        a = a_in;
        b = b_in;
    }

    void run() 
    { 
        v_data = Eigen::VectorXd::LinSpaced(N, a, b); 

        auto gammafunc = [](double it) { return std::tgamma(it); };
        v_gamma = v_data.unaryExpr(gammafunc);
    }
};

Для совместного использования этого класса потребуется добавить немного кода на C++. Предпочитаю сделать это в отдельном файле, в котором будет всё, что необходимо для создания обёртки на Python.

// pywrap.cpp
#include <pybind11/pybind11.h>
#include <pybind11/eigen.h>
#include "mylib.h"

namespace py = pybind11;
constexpr auto byref = py::return_value_policy::reference_internal;

PYBIND11_MODULE(MyLib, m) {
    m.doc() = "optional module docstring";

    py::class_<MyClass>(m, "MyClass")
    .def(py::init<double, double, int>())  
    .def("run", &MyClass::run, py::call_guard<py::gil_scoped_release>())
    .def_readonly("v_data", &MyClass::v_data, byref)
    .def_readonly("v_gamma", &MyClass::v_gamma, byref)
    ;
}

Что здесь хотелось бы отметить:

  • Сигнатура конструктора класса указывается при помощи .def(py::init<int, double, double>())

  • Для функции run() потребуется снять глобальную блокировку интерпретатора (GIL), которая не позволяет нашей функции использовать по несколько потоков.

Наконец, этот код можно скомпилировать на основе следующего файла CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)

project(MyLib)
set(CMAKE_CXX_STANDARD 20)
set(PYBIND11_PYTHON_VERSION 3.6)
set(CMAKE_CXX_FLAGS "-Wall -Wextra -fPIC")

find_package(pybind11 REQUIRED)
find_package(Eigen3 REQUIRED)

pybind11_add_module(${PROJECT_NAME} pywrap.cpp)

target_compile_definitions(${PROJECT_NAME} PRIVATE VERSION_INFO=${EXAMPLE_VERSION_INFO})
target_include_directories(${PROJECT_NAME} PRIVATE ${PYBIND11_INCLUDE_DIRS})
target_link_libraries(${PROJECT_NAME} PRIVATE Eigen3::Eigen)

Всё готово к работе. Если вы работаете с VS Code, то, сконфигурировав расширение CMake, просто нажмите F7 — и ваша библиотека C++ скомпилируется.

Вызов библиотеки C++ из Python

Здесь всё совсем просто и должно работать прямо из коробки. Но поток задач интерактивный, и в нём есть несколько шагов, поддающихся оптимизации. Эти оптимизации реализовать несколько сложнее, но дело того стоит.

Например, если вы выполняете вашу среду Python, и скомпилированная вами библиотека поступает в каталог build, можно сделать так:

import sys
sys.path.append("build/")
from MyLib import MyClass

import matplotlib.pyplot as plt

Simulation = MyClass(-4,4,1000)
Simulation.run()

plt.plot(Simulation.v_data, Simulation.v_gamma, \
"--", linewidth = 3, color=(1,0,0,0.6),label="Function Value")
plt.ylim(-10,10)
plt.xlabel("x")
plt.ylabel("($f(x) = \gamma(x)$)")
plt.title("(Gamma Function: $\gamma(z) = \int_0^\infty x^{z-1} e^{-x} dx$)",fontsize = 18);
plt.show()

Обратите внимание, что Eigen-векторы были автоматически преобразованы в массивы Python.

Модифицировав myLib.hpp, остаётся добавить в файл pywrap.cpp всего по одной строке кода на каждую новую функцию или переменную, которые вы хотите предоставлять.

К сожалению, полностью интерактивный поток задач таким образом построить не удастся. Когда вы перекомпилируете ваш код C++ после изменений, на стороне Python ничего не произойдёт. Даже если вы попытаетесь перезагрузить модуль Python при помощи importtools:

import importlib
importlib.reload(MyLib

— ничего не произойдёт. Дело в том, что скомпилированный код перезагрузке в Python не поддаётся.

Таким образом, при работе с PyBind11 вам придётся перезапускать сеанс Python после каждой перекомпиляции кода C++ — на этапе разработки меня это довольно раздражает. Тем не менее, это вполне приемлемо, так как Python запускается почти сразу, и весь процесс, пожалуй, можно автоматизировать при помощи горячих клавиш IDE или других инструментов.

Резюме

Вот вы и узнали, как без труда вызывать библиотеку C++ из Python.

В частности, такой двухэтапный процесс помогает наладить процесс разработки, отличающийся высокой интерактивностью. Пусть он и построен по принципу «отредактировать-скомпилировать-запустить», в конце этой цепочки мы добавили интерпретатор, поэтому теперь наш поток задач приобретает вид «отредактировать-скомпилировать-запустить-исследовать».

Полагаю, в будущем надо как-то избавиться от необходимости (вручную) перезапускать сеанс Python после перекомпиляции кода C++. Надеюсь, эта проблема как-то решается на уровне VSCode. До сих пор лучшее, что можно сделать для этого в VSCode — принудительно завершить сеанс Python, а затем выполнить код Python командой Shift+Enter, которая создаст новый сеанс, если в настоящий момент открытых сеансов нет.

Напоминаю: весь код этого примера можно скачать в данном репозитории.

Комментарии (6)


  1. MAaxim91
    11.07.2025 11:27

    По поводу перезапуска питона после перекомпиляции, думаю проблема кроется в импортирте. Возможно, если использовать importlib (importlib.reload(импортированныйпакет)), может помочь.


  1. Shizzza
    11.07.2025 11:27

    при помощи пакета cytpes

    Прямо в самом начале очепятка. А так - спасибо за текст


  1. edo1h
    11.07.2025 11:27

    На всякий случай напомню, что рекомендациям не использовать C/C++ в новых проектах уже несколько лет


  1. Sdima1357
    11.07.2025 11:27

    Есть ещё Python.h из python-dev и линкуется без pybind11, напрямую https://docs.python.org/3/extending/extending.html


  1. Vilos
    11.07.2025 11:27

    Эх чего только люди не придумают что бы не пользоваться "православным" С++....Ну коли хочешь ты C++ - пользуйся C++ на кой тебе этот "Уж" сдался? C++ самодостаточный язык и делать гибриды типа Python+C сильно сомнительный сценарий.


    1. Vilos
      11.07.2025 11:27

      извиняюсь не заметил, что это перевод...к автору статьи претензий нет. А что за бульон в голове у автора оригинала...не знаю.