Создаем С++ Python расширения с помощью pybind11

Когда вы хотите перенести часть функциональности в C++, то наиболее удобный способ [кмк] является использование функциональности модуля distutils который позволяет организовывать рапростроняемые библиотечки.

Первым делом создадим python-виртуальную среду (я использую virtualenvwrapper)

mkvirtualenv pybind11_test

Установка pybind11

Тут все просто, установка прямо через pip:

pip install pybind11

Создаем структуру проекта

Создадим файлы:

|- library.h
|- library.cpp
|- main.cpp
|- setup.py
  • library.h и library.cpp - это файлы, в которых непосредственно будут лежать исходники нашей библиотеки.
  • main.cpp - это файл, который будет инклюдить library.hpp, а также содержать код pybind11(который будет содержать meta-код для байндингов).
  • setup.py - файл, который создаст распространяемую библиотеку, а также упростит процесс сборки библиотеки.

Компилируем библиотеку

Отредактируем наш setup.py

import pybind11
from distutils.core import setup, Extension

ext_modules = [
    Extension(
        'library', # название нашей либы
        ['library.cpp', 'main.cpp'], # файлики которые компилируем
        include_dirs=[pybind11.get_include()],  # не забываем добавить инклюды pybind11
        language='c++',
        extra_compile_args=['-std=c++11'],  # используем с++11
    ),
]

setup(
    name='library',
    version='0.0.1',
    author='user',
    author_email='user@user.ru',
    description='pybind11 extension',
    ext_modules=ext_modules,
    requires=['pybind11']  # не забываем указать зависимость от pybind11
)

Хотя у нас пока нет никакого кода, но мы уже можем собрать наше расширение, для этого запустим:

python setup.py build_ext -i
  • build_ext обозначает, что мы будем собирать расширения, то есть те самые Extension, которые описаные в атрибуте ext_modules команды setup (см. setup.py).
  • флаг -i значит, что мы хотим, чтобы наша библиотека (*.so файл) сгенерировалась на том же уровне, что и файл setup.py.

Если все было выполнено корректно, то рядом с setup.py появится файл вида:

library.cpython-36m-x86_64-linux-gnu.so

где library - это название библиотеки, а постфикс cpython-36m-x86_64-linux-gnu - описание конфигурации, на которой собиралось расширение.

Давайте теперь запустим python консоль

python

и выполним следующую команду:

import library

В ответ получим:

ImportError: dynamic module does not define module export function (PyInit_library)

Это значит, что библиотека была успешна считана, но необходимых функций для работы с интерпретатором в ней не нашлось. Давайте это дело поправим.

Откроем файл main.cpp и отредактируем его:

#include 

PYBIND11_MODULE(library, m) {
};
  • #include <pybind11/pybind11.h> - это мы подключили pybind.
  • PYBIND11_MODULE(library, m) - макрос, который поволяет нам определить python модуль. Важно: имя модуля (первый аргумент) должно совподать с именем, указаным в setup.py, иначе при импорте модуля у вас будет возникать ошибка.:
setup(
    name='library',
    # ...
)

Второй аргумент m - это, своего рода, "фабрика", с помощью которой можно расширять функциональность нашей библиотеки, добавлять функции, класы и т.д.

Итак, снова запустим сборку:

python setup.py build_ext -i

Снова откроем python консоль и выполним следующий код:

import library
print(library)

Если все верно то нас ожидает красота свидетельствующая о том, что модуль успешно подключился:


Добавляем функцию в библиотеку

Начнем с учебного примера из документации и добавим функцию, которая просто сложит два числа. Отредактируем файлы:

// file: library.cpp
#include "library.h"

int add(const int &a, const int &b) {
    return a + b;
}
// file: library.h
#pragma once

int add(const int &, const int &);
// file: main.h
#include 
#include "library.h" // добавили инклюд

namespace py = pybind11;

PYBIND11_MODULE(library, m) {
    m.def("add", &add); // добавили функцию в модуль
};

Содержимое в library(.h|.cpp) тривиально, взглянем на главное изменение в файле main.py. Мы добавили команду:

m.def("add", &add);
  • Первый аргумент - это имя функции каким оно будет доступно из python.
  • Второй аргумент - это адрес функции которая будет использоваться в качестве обработчика.

Проверим что это все работает, компилируем:

python setup.py build_ext -i

Запускаем python консоль и вводим:

import library
print(library.add(40, 2))

В ответ получим:

42

Тут вас должна накрыть волна радости =)

Давайте теперь сделаем чего-нибудь интереснее.

Работаем с list

Со списками предлагается два основных способа взаимодействия.

Первый - это использование типа py::list, который, по сути, является C++ оберткой, позволяющей осуществлять прямой и последовательный доступ к элементам списка, причем тип элементов будет py::object, а, следовательно, эффективно с ними работать не получится.

Для примера добавим функцию, которая возвращает произвольный элемент. Редактируем файлы:

// file: library.cpp
#include "library.h"
#include  // добавили

//...

py::object get_random(const py::list &items) {
    if (items.size()) {
        std::mt19937 rng;
        rng.seed(std::random_device()());
        std::uniform_int_distribution uint_dist(0, items.size() - 1);
        return items[uint_dist(rng)];
    } else {
        return py::none();
    };
}
// file: library.h

//...

py::object get_random(const py::list &);
// file: main.h
#include 
#include "library.h"

namespace py = pybind11;

PYBIND11_MODULE(library, m) {
    //...
    m.def("get_random", &get_random);
};

Скомпилируем и попробуем запустить:

import library
print(library.get_random([40, 'f', {'field': 'value'}]))
# >>> f
print(library.get_random([]))
# >>> None

Как видите, наша C++ функция прекрасно воспринимает разные типы объектов; к сожалению, сам тип py::list не очень интересен и несмотря на то, что он поддерживает методы begin и end, это не позволяет применять к нему функции из stl.

Куда более широкое применение дает использование классов std::vector или std::list, в которые pybind из коробки осуществляет мэппинг. Давайте сделаем функцию, которая найдет элемент в массиве и вернет его позицию или None, если объект не найден:

// file: library.cpp
//...

py::object find_index(const std::vector &items, const py::object &value) {
    auto ptr = std::find(items.begin(), items.end(), value);
    if (ptr != items.end()) {
        // так как мы хотим иметь возможность вернуть none,
        // то приходится проводить явное преобразование типов
        return py::cast(ptr - items.begin());
    } else {
        return py::none();
    }
}
// file: library.h
//...

pybind11::object find_index(const std::vector &, const py::object &);
// file: main.h
#include 
#include "library.h"

namespace py = pybind11;

PYBIND11_MODULE(library, m) {
    // ...
    m.def("find_index", &find_index);
};

Компилируем и запускаем:

import library
library.find_index([40, 'f', {'field': 'value'}], 'f')
# >>> 1

Надо понимать, что проверка объектов на равенство происходит по внутреннему указателю, поэтому даже если вы определили собственный объект и реализовали метод __eq__, то он использоваться не будет. Например:

import library

{'field': 'value'} == {'field': 'value'}
# >>> True

print(library.find_index([40, 'f', {'field': 'value'}], {'field': 'value'})
# >>> None

Передаем функцию в качестве аргумента

Но нам никто не мешает передать функцию в качестве параметра и использовать ее в качестве компаратора:

// file: library.cpp
#include "library.h"
#include 
#include  // добавили

// ...

py::object
find_index_if(
        const std::vector &items,
        const py::object &value,
        const std::function &eq_func
) {
    auto ptr = std::find_if(items.begin(), items.end(), [&value, &eq_func](const py::object &item) -> bool {
        return eq_func(value, item);
    });
    if (ptr != items.end()) {
        return py::cast(ptr - items.begin());
    } else {
        return py::none();
    }
}
// file: library.h
#pragma once

#include 
#include 
#include 
#include 
#include  // добавили

// ...

py::object find_index_if(
        const std::vector &items,
        const py::object &value,
        const std::function &eq_func
);
// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    m.def("find_index_if", &find_index_if);
}; 

Компилируем и запускаем:

import library

library.find_index_if(
    [40, 'f', {'field': 'value'}],
    {'field': 'value'},
    lambda x,y: x == y
)
# >>> 2

Красота.

Работаем с классами

Создавать классы с pybind тоже очень просто - для этого достаточно определить структуру (или класс). Возьмем пример из документации:

// file: library.cpp
// ...

struct Pet {
    Pet(const std::string &name) : name(name) { }
    void setName(const std::string &name_) { name = name_; }
    const std::string &getName() const { return name; }

    std::string name;
};

А дальше достаточно просто подключить в модуле:

// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    py::class_(m, "Pet")
            .def(py::init())
            .def("setName", &Pet::setName)
            .def("getName", &Pet::getName);
};

Вместо использования геттера и сеттера (которые просто являются привязкой C++ функции к python-интерфейсу), можно привязать поле напрямую, используя конструкцию def_readwrite (или def_readonly для доступа только для чтения):

// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    py::class_(m, "Pet")
            .def(py::init())
            .def_readwrite("name", &Pet::name);
};

Можно еще использовать def_property (или def_property_readonly), чтобы определить доступ к полю как к свойству, то есть с неявным вызывом геттера и сеттера. Делается это так:

// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    py::class_(m, "Pet")
            .def(py::init())
            .def_property("name", &Pet::getName, &Pet::setName)
};

Переопределение методов класса

Один из важных способов работы с классами - это наличие возможности переопределять те или иные методы, причем желательно из python-кода. Понятно, что в таком случае мы будем терять в производительности, зато будем выигрывать в гибкости.

Например, пусть у нас есть класс животного, которое может издавать звук (по умолчанию животное издает пустую строку):

// file: library.h
// ...

struct Animal {
    Animal() = default;;
    virtual std::string get_sound() { return  "";};
    virtual void make_sound() { py::print(this->get_sound()); };
};

Если бы нам просто надо было пробросить класс в python, то мы бы сделали как в параграфе и написали бы:

// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    py::class_(m, "Animal")
            .def(py::init<>())
            .def("get_sound", &Animal::get_sound)
            .def("make_sound", &Animal::make_sound);
};

Но проблема в том, что такой метод класса, нельзя переопределить, то есть если мы скомпилируем наш библиотеку и попытаемся вызвать следующий код:

from library import Animal

class Dog(Animal):
    def get_sound(self):
        return "гав-гав"
        
dog = Dog()
dog.make_sound()

в консоле мы увидим пустую строку:

>>>

Очевидно, это не то, что мы ожидали.

Итак, для этого надо создать класс-обертку поверх C++ класса Animal и именно эту обертку экспортировать:

// file: library.h
// ...

// класс обертка 
struct PyAnimal : public Animal {
    using Animal::Animal; // наследуем конструкторы

    /*
     * Функция обертка, необходимо создавать такую обертку для каждой функции которую планируем сделать переписываемой
     */
    std::string get_sound() override {
        PYBIND11_OVERLOAD(
                std::string,
                Animal,
                get_sound
        );
    }
};

Также необходимо исправить код для экспортирования класса. Для этого исправим:

// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    py::class_(m, "Animal")
            .def(py::init<>())
            .def("get_sound", &Animal::get_sound)
            .def("make_sound", &Animal::make_sound);
};

на:

// file: main.h
// ...

PYBIND11_MODULE(library, m) {
    // ...
    py::class_ animalClass(m, "Animal");
    animalClass.def(py::init<>())
               .def("get_sound", &Animal::get_sound)
               .def("make_sound", &Animal::make_sound);
};

Компилируем и запускаем:

from library import Animal

class Dog(Animal):
    def get_sound(self):
        return "гав-гав"
        
dog = Dog()
dog.make_sound()

В консоле красота:

>>> гав-гав

Кстати, мы не стали делать обертку для функции make_sound потому, что она не используется другими функциями внутри класса и поэтому не требует дополнительных действий. Но если ее переписать, то ничего страшно не произойдет.

Работаем с dict

Работа со словарями осуществляется через мэппинг в stl класс map. Рассмотрим пример функции, которая работает по аналогии с функцией get-словаря в питоне.

Добавим следующую функцию:

// file: library.h
// ...
#include // добавили

// ...

py::object get_value(
    std::map &data, // словарь
    const std::string &key, // ключ, по которому вытаскиваем значения
    const py::object &default_value // дефолтное значение, если ключа нет в словаре 
);

И ее реализацию:

// file: library.cpp
// ...

py::object
get_value(std::map &data, const std::string &key, const py::object &default_value) {
    auto it = data.find(key);
    if (it == data.end()) {
        return default_value;
    }
    return it->second;
}

Добавим мэппинг для python-класса

PYBIND11_MODULE(library, m) {
    // ...
    m.def(
        "get_value",
        &get_value,
        "",
        // чтобы была возможность не указывать последний аргумент
        // пришлось перечислить все аргументы
        py::arg("data"),
        py::arg("key"),
        // указываем значение по умолчанию 
        py::arg("default_value") = py::none() 
    );
};

Скомпилируем и запустим в python:

import library
library.get_value({"field1": "field1_value"}, "field1")
# >>> field1_value

library.get_value({"field1": "field1_value"}, "field2")
# >>>

library.get_value({"field1": "field1_value"}, "field2", 'default')
# >>> default

Все работает, как и ожидалось.

Естественно, надо понимать, что данную статью нельзя рассматривать как полноценный гид по работе с pybind. У данной библиотеки прекрасная документация https://pybind11.readthedocs.io/en/stable/index.html, где помимо того, что было упомянуто в данном посте, есть еще тьма полезной информации и масса хороших примеров.

На этом все, до новых встреч, друзья! 

  c++, pybind, python

  Смотреть все посты