Pull to refresh

Объединяя C++ и Python. Тонкости Boost.Python. Часть первая

Reading time 10 min
Views 146K
Boost.Python во всех отношениях замечательная библиотека, выполняющая своё предназначение на 5+, хотите ли вы сделать модуль на С++ для Python либо хотите построить скриптовую обвязку на Python для нативного приложения написанного на С++.
Самое сложное в Boost.Python — это обилие тонкостей, поскольку и C++ и Python — два языка изобилующие возможностями, и потому на стыке их приходится учитывать все нюансы: передать объект по ссылке или по значению, отдать в Python копию объекта или существующий класс, преобразовать во внутренний тип Python или в обёртку написанного на C++, как передать конструктор объекта, перегрузить операторы, навесить несуществующие в C++, но нужные в Python методы.
Не обещаю, что в своих примерах опишу все тонкости взаимодействия этих фундаментальных языков, но постараюсь сразу охватить как можно больше частоиспользуемых примеров, чтобы вы не лазили за каждой мелочью в документацию, а увидели все необходимые основы здесь, или хотя бы получили о них базовое представление.

Оглавление



Введение


Исходим из того, что у вас уже установлен удобный инструментарий для сборки динамически-линкуемой библиотеки на C++, а также установлен интерпретатор Python.
Также понадобится скачать библиотеку Boost, после чего собрать её, следуя инструкции для своей ОС Windows или Linux.
В двух словах в Windows все действия сводятся к двум строкам в командной строке. Распакуйте скачанный архив Boost в любое место на диске, перейдите туда в командной строке и наберите последовательно две команды:
bootstrap
b2 --build-type=complete stage

Для сборки x64 нужно добавить аргумент address-model=64
Если у вас уже есть библиотека Boost, но вы не устанавливали Python, либо вы скачали и установили свежий интерпретатор Python и хотите собрать только Boost.Python, это делается дополнительным ключом --with-python
То есть вся строка для сборки только Boost.Python с 64-разрядной адресацией выглядит так:
b2 --build-type=complete address-model=64 --with-python stage

Стоит заметить, что x64 сборку следует заказывать, если у вас установлен Python x64. Также и модули для него нужно будет собирать с 64-разрядной адресацией.
Ключ --with-python серьёзно сэкономит вам время, если вам из библиотеки Boost кроме функционала Boost.Python ничего не нужно.
Если у вас установлено несколько интерпретаторов, крайне рекомендую прочитать подробную документацию по сборке Boost.Python
После сборки у вас появятся в папке Boost\stage\lib собранные библиотеки Boost.Python, они нам очень скоро понадобятся.

Настраиваем проект на C++


Создаём проект для создания динамически-линкуемой библиотеки на C++, предлагаю назвать его example.
После создания проекта, требуется указать дополнительные INCLUDE каталоги Python\include и корень Boost, а также каталоги для поиска библиотек Python\libs и Boost\stage\lib
Под Windows также следует в настройках Post-build events задать переименование $(TargetPath) в модуль с расширением example.pyd в корне проекта.
Также возможно стоит скопировать собранные библиотеки Boost.Python в каталог с собираемым модулем.
Подключение модуля после запуска интерпретатора в том же каталоге сведётся к одной команде:
from example import *

Не забываем также про сборку под x64 если вы собираете для 64-разрядного Python.

Обычный класс с простыми полями


Итак, давайте заведём нашем новом проекте сразу три файла:
some.h
some.cpp
wrap.cpp

В файлах some.h и some.cpp опишем некий замечательный класс Some, который обернём для Python в модуле example в файле wrap.cpp — для этого в файле wrap.cpp следует подключить <boost/python.hpp> и использовать макрос BOOST_PYTHON_MODULE( example ) {… }, также для лаконичности будет совсем не лишним использовать using namespace boost::python. В целом наш будущий модуль будет выглядеть вот так:
#include <boost/python.hpp>
...

using namespace boost::python;
...

BOOST_PYTHON_MODULE( example )
{
    ...
}
...


В файле some.h нам следует наваять объявление нашего чудо-класса. Для объяснения большинства базовых механизмов нам достаточно всего два поля:
private:
    int mID;
    string mName;

Допустим класс содержит описание чего-то, что имеет имя и целочисленный идентификатор. Как ни странно этот несложный класс вызовет кучу сложностей, благодаря в основном стандартному классу string, перегрузкам методов, константной ссылке и статическому свойству NOT_AN_IDENTIFIER, которое мы конечно же тоже введём:
public:
    static const int NOT_AN_IDENTIFIER = -1;

Разумеется эта константа нужна как идентификатор для объекта созданного конструктором по умолчанию, опишем также и другой конструктор, задающий оба поля:
    Some();
    Some( int some_id, string const& name );

В файле some.cpp опишем реализацию данных конструкторов, в дальнейшем реализацию описывать я не буду, но давайте конструкторы напишем вместе:
Some::Some()
    : mID(NOT_AN_IDENTIFIER)
{
}

Some::Some( int some_id, string const& name )
    : mID(some_id), mName(name)
{
}

Одновременно с появлением класса Some будет появляться обёртка класса для Python в файле wrap.cpp:
BOOST_PYTHON_MODULE( example )
{
    class_<Some>( "Some" )
        .def( init<int,string>( args( "some_id", "name" ) ) )
    ;
}

Здесь используется бессовестный обман зрения и шаблон boost::python::class_, который создаёт описание класса для Python в указанном модуле с помощью Python C-API, жутко сложного и непонятно при описании методов, а потому полностью скрытого за объявлением простого метода def() на каждой строчке.
Конструктор по умолчанию и конструктор копирования создаются для объекта по умолчанию, если не указано обратное, но мы этого ещё коснёмся чуть ниже.
Уже сейчас можно собрать модуль, импортировать его из интерпретатора Python и даже создать экземпляр класса, но ни прочитать его свойства, ни вызывать методы мы у него пока не можем, пока они физически отсутствуют.
Давайте это исправим, создадим «богатейшее» API нашего чудо класса. Вот полный код нашего заголовочного файла some.h:
#pragma once

#include <string>

using std::string;

class Some
{
public:
    static const int NOT_AN_IDENTIFIER = -1;

    Some();
    Some( int some_id, string const& name );

    int ID() const;
    string const& Name() const;

    void ResetID();
    void ResetID( int some_id );

    void ChangeName( string const& name );

    void SomeChanges( int some_id, string const& name );

private:
    int mID;
    string mName;
};


Раз реализация методов получилась также довольно короткой, давайте приведу и код some.cpp:
#include "some.h"

Some::Some()
    : mID(NOT_AN_IDENTIFIER)
{
}

Some::Some( int some_id, string const& name )
    : mID(some_id), mName(name)
{
}

int Some::ID() const
{ 
    return mID;
}

string const& Some::Name() const
{
    return mName;
}

void Some::ResetID()
{
    mID = NOT_AN_IDENTIFIER;
}

void Some::ResetID( int some_id )
{
    mID = some_id;
}

void Some::ChangeName( string const& name )
{
    mName = name;
}

void Some::SomeChanges( int some_id, string const& name )
{
    mID = some_id;
    mName = name;
}

Что ж, самое время описать обёртку в файле wrap.cpp:
Первый метод Some::ID() оборачивается без каких-либо проблем:
        .def( "ID", &Some::ID )

Зато второй с результатом в виде константной ссылки на строку уже показывает, что всё не так просто:
        .def( "Name", &Some::Name, return_value_policy<copy_const_reference>() )

Как видим, можно указать как Python должен интерпретировать возвращаемое значение, если метод в C++ возвращает указатель или ссылку. Дело в том, что зверский Garbage Collector (GC) очень любит удалять всё бесхозное, поэтому просто так объявить метод возвращающий указатель или ссылку, вам никто не даст, всё печально закончится на этапе компиляции, поскольку GC должен знать что ему делать с возвращаемым значением, для разработчика будет весьма печально, если он начнёт удалять содержимое объекта в C++. Всего есть несколько вариантов return_value_policy для разных случаев, самые важные из них следующие:
  • copy_non_const_reference — создаёт новый объект в Python, который содержит неконстантную ссылку на объект в C++, не требует обёртки для класса из C++ (пример: string не имеет обёртки, только конвертер в питоновский str)
  • copy_const_reference — создаёт новый объект в Python, который содержит константную ссылку на объект в C++, не требует обёртки для класса из C++ (пример: тот же string)
  • manage_new_object — создаёт новый объект в Python, используя обёртку класса из C++, по завершении содержимое удаляется
  • reference_existing_object — создаёт новый объект в Python, используя обёртку класса из C++, по завершении содержимое остаётся

Понимание того, как работает тот или иной return_value_policy в деталях приходит со временем, эксперементируйте, пробуйте, читайте документацию и набивайте руку. Для стандартного string ссылка в зависимости от константности при возвращении почти всегда copy_const_reference либо copy_non_const_reference, просто запомните, т.к. string по значению преобразуется на уровне Python в объект встроенного класса str, а по ссылке нужно явно указывать return_value_policy.

Метод Some::ResetID я намерено перегрузил, чтобы усложнить задачу с передачей указателя на метод в .def():
        .def( "ResetID", static_cast< void (Some::*)() >( &Some::ResetID ) )
        .def( "ResetID", static_cast< void (Some::*)(int) >( &Some::ResetID ), args( "some_id" ) )


Как видите, можно указать, с каким именем в Python будет создан аргумент метода. Как известно имя аргумента в Python куда важнее чем в C++. Рекомендую указывать имена аргументов для каждой обёртки метода, принимающего параметры:
        .def( "ChangeName", &Some::ChangeName, args( "name" ) )
        .def( "SomeChanges", &Some::SomeChanges, args( "some_id", "name" ) )


Осталось описать статическим свойством константу NOT_AN_IDENTIFIER:
        .add_static_property( "NOT_AN_IDENTIFIER", make_getter( &Some::NOT_AN_IDENTIFIER ) )

Здесь используется специальная функция boost::python::make_getter, которая по свойству класса генерирует get-функцию.
Вот так примерно выглядит наша обёртка:
#include <boost/python.hpp>
#include "some.h"

using namespace boost::python;

BOOST_PYTHON_MODULE( example )
{
    class_<Some>( "Some" )
        .def( init<int,string>( args( "some_id", "name" ) ) )
        .def( "ID", &Some::ID )
        .def( "Name", &Some::Name, return_value_policy<copy_const_reference>() )
        .def( "ResetID", static_cast< void (Some::*)() >( &Some::ResetID ) )
        .def( "ResetID", static_cast< void (Some::*)(int) >( &Some::ResetID ), args( "some_id" ) )
        .def( "ChangeName", &Some::ChangeName, args( "name" ) )
        .def( "SomeChanges", &Some::SomeChanges, args( "some_id", "name" ) )
        .add_static_property( "NOT_AN_IDENTIFIER", make_getter( &Some::NOT_AN_IDENTIFIER ) )
    ;
}

Если написать несложный тестовый скрипт вроде этого (Python 3.x):
from example import *
s = Some() 
print( "s = Some(); ID: {ID}, Name: {Name}".format(ID=s.ID(),Name=s.Name()) )
s = Some(123,'asd')
print( "s = Some(123,'asd'); ID: {ID}, Name: {Name}".format(ID=s.ID(),Name=s.Name()) )
s.ResetID(234); print("s.ResetID(234); ID:",s.ID())
s.ResetID(); print("s.ResetID(); ID:",s.ID())
s.ChangeName('qwe'); print("s.ChangeName('qwe'); Name:'%s'" % s.Name())
s.SomeChanges(345,'zxc')
print( "s.SomeChanges(345,'zxc'); ID: {ID}, Name: {Name}".format(ID=s.ID(),Name=s.Name()) )

Мы увидим вывод:
s = Some(); ID: -1, Name: ''
s = Some(123,'asd'); ID: 123, Name: 'asd'
s.ResetID(234); ID: 234
s.ResetID(); ID: -1
s.ChangeName('qwe'); Name:'qwe'
s.SomeChanges(345,'zxc'); ID: 345, Name: 'zxc'


Питонизируем обёртку класса


Итак, класс со всеми методами обёрнут, но счастья не наступило. При попытке из командной строки Python выполнив Some(123,'asd') мы не увидим описания полей и вообще объекта, поскольку мы не обзавелись методом __repr__, так же как и преобразование к строке, тот же print( Some(123,'asd') ) будет ужасно неинформативен, так как мы не обзавелись методом __str__. Очевидно также, что работать со свойствами через методы на C++ на Python не имеет смысла, это в C++ мы не имеем возможности заводить property, в Python их можно и нужно завести. Однако как же мы навесим методы на готовый класс C++ предназначенные для Python?
Очень просто: вспоминаем, что в Python методы не отличаются от функций, принимающих первым параметром ссылку на self — экземпляр класса. Заводим в C++ такие функции прямо во wrap.cpp и описываем их как методы в обёртке:
...
string Some_Str( Some const& );
string Some_Repr( Some const& );
...
BOOST_PYTHON_MODULE( example )
{
    class_<Some>( "Some" )
        ...
        .def( "__str__", Some_Str )
        .def( "__repr__", Some_Repr )
...

Сами функции можно описать например вот так:
string Some_Str( Some const& some )
{
    stringstream output;
    output << "{ ID: " << some.ID() << ", Name: '" << some.Name() << "' }";
    return output.str();
}

string Some_Repr( Some const& some )
{
    return "Some: " + Some_Str( some );
}


Со свойствами идентификатора и имени ещё проще, так как методы set и get для них уже описаны в классе:
        .add_property( "some_id", &Some::ID, static_cast< void (Some::*)(int) >( &Some::ResetID ) )
        .add_property( "name", make_function( static_cast< string const& (Some::*)() const >( &Some::Name ), return_value_policy<copy_const_reference>() ), &Some::ChangeName )


При описании свойств однако было два тонких момента:
1. Для set-метода свойства some_id было явное приведение к типу метода, принимающего int, т.к. есть ещё одна перегрузка метода.
2. Для get-метода свойства name была использована конструкция boost::python::make_function, которая позволила повесить return_value_policy на результат метода возвращающего константную ссылку на string.

Выполняем print( Some(123,'asd') ) и просто Some(123,'asd') из командной строки после from example import * и видим что подозрительно похожее на встроенный питоновский dict: { ID: 123, Name: 'asd' }
Почему бы не завести свойство инициализирующее экземпляр Some от стандартного dict и обратно?
Заведём ещё пару питонистических функций и заведём свойство as_dict:
...
dict Some_ToDict( Some const& );
void Some_FromDict( Some&, dict const& );
...
BOOST_PYTHON_MODULE( example )
{
    class_<Some>( "Some" )
        ...
        .add_property( "as_dict", Some_ToDict, Some_FromDict )
    ;
    ...
}
...
dict Some_ToDict( Some const& some )
{
    dict res;
    res["ID"] = some.ID();
    res["Name"] = some.Name();
    return res;
}

void Some_FromDict( Some& some, dict const& src )
{
    src.has_key( "ID" ) ? some.ResetID( extract<int>( src["ID"] ) ) : some.ResetID();
    some.ChangeName( src.has_key( "Name" ) ? extract<string>( src["Name"] ) : string() );
}

Здесь использован класс boost::python::dict, для доступа на уровне C++ к стандартному dict Python.
Также есть классы для доступа к str, list, tuple, называются они соответственно. Ведут себя классы в C++ так же как и в Python в плане операторов, вот только возвращают по большей части boost::python::object, из которого требуется ещё извлечь значение через функцию boost::python::extract.

В заключение первой части


В первой части был рассмотрен вполне стандартный класс с конструктором по умолчанию и дефолтным конструктором копирования. Несмотря на некоторые тонкости с работой со строками, и перегрузкой методов, класс вполне стандартный.
Работать с Boost.Python довольно просто, обёртка любой функции сводится обычно к одной строке, которая выглядит как аналогичное объявление метода в Python.
В следующей части мы научимся оборачивать классы, которые создаются не так тревиально, создадим класс на основе структуры, обернём enum, познакомимся на практике с другим важным return_value_policy<reference_existing_object>.
В третьей части рассмотрим конвертеры типов в стандартные типы Python напрямую без обёртки на примере массива байт. Научимся пробрасывать исключения определённого типа из C++ в Python и обратно.
Тема довольно обширная.

Ссылка на проект


Проект первой части для Windows выложен здесь.
Проект MSVS v11 настроен на сборку с Python 3.3 x64. Собранные .dll Boost.Python соответствующей версии прилагаются.
Но ничего не мешает собрать файлы some.h, some.cpp, wrap.cpp любым другим сборочным аппаратом с привязкой к любой другой версии Python.

Полезные ссылки


Документация Boost.Python
Политики возвращаемых по ссылке значений в Boost.Python
Начало работы с Boost для Windows
Начало работы с Boost для *nix
Тонкости сборки Boost.Python
Tags:
Hubs:
+64
Comments 8
Comments Comments 8

Articles