9 December 2012

Дружим Python 3 с MS Visual C++. Строим мост в Boost.Python с автоматической перекодировкой

PythonProgrammingC++
Всем доброго {daytime}!

Сегодня пришла пора рассказать вам о фундаментальной проблеме перекодировки при взаимодействии проекта собранного на MS Visual C++ на платформе Windows и наиболее приятной скриптовой обвязки для языка C++, благодаря библиотеке Boost.Python, собственно написанной для языка Python.

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

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

В языке Python, начиная с версии 3.0 принято решение считать строками только сам текст. Не важно как сам текст закодирован, а закодирован он в Юникоде, само понятие строки навсегда оторвано от его кодировки. То есть нет никакой возможности понять какое число соответствует символу в строке иначе, как перекодировать его в массив байт, указав кодировку.
"Привет!".encode('cp1251')

Пример выше показывает, что сама строка «Привет!» останется таковой как вы её набрали, вне зависимости от того, смотрят на неё в России, США или в Китае, на Windows, Linux или MacOS, она останется строкой «Привет!». Декодировав её в массив байт методом строки str.encode( encoding ) мы всегда получим одно и то же значение элементов массива байт, вне зависимости от того в какой точке земного шара мы находимся и какой платформой мы пользуемся. И это замечательно!
Однако вернёмся на Землю. Есть такая ОС Windows…

Вся проблема заключается в замечательной среде разработки MS Visual Studio. А более всего она замечательна тем, что все строки в C++ в ней гарантированно в кодировке кодовой страницы Windows. То есть для России все строки всегда будут в 'cp1251'. И всё бы ничего, но для вывода на веб-страницу, сохранения в XML, вывода в интернациональную БД и прочего данная кодировка не подходит. Предлагаемый Microsoft вариант со строками вида L«Привет» приемлем чуть более, но мы ведь знаем как замечательно в C++ работается с такими строками. Кроме того мы будем исходить из того, что к нам попал проект уже с кучей строк в виде cp1251. Гигабайты кода, работающие с std::string и char* и работающие с ними прекрасно: быстро и качественно.

Если вы идёте со стороны Python в C++, просто помните, что строки Python отлично конвертируются в char* используя внутреннюю память Python, поскольку все строки в Python 3.x как минимум в UTF-8 уже хранятся и за ними зорко следят GC и счётчик ссылок. Поэтому опять же: не надо этого UCS-2 от Microsoft выдаваемого за Юникод, используйте обычные строки. Ну и кроме того, помните, что локальная для России БД вашей компании не скажет вам спасибо за удвоенный размер данных, при переходе с WIN-1251 на UTF-8, поскольку наверняка под завязку набита кириллицей.
В общем проблема обозначена.

Теперь решение.

У вас уже наверняка есть последняя версия Python 3.x (в настоящий момент это — Python 3.3), если ещё нет, ставьте последний отсюда: www.python.org/download/releases
Также у вас наверняка стоит MS Visual Studio (в настоящий момент последняя — это VS2012, но всё нижесказанное будет верно и для предыдущей версии VS2010).
Для связки ваших классов на C++ с Python потребуется библиотека Boost.Python. Поставляется в составе уже почти стандартной библиотеки Boost: www.boost.org (в настоящий момент последней версией является 1.52, но проверено и верно вплоть до 1.44).
К сожалению, в отличие от всего остального, Boost.Python нужно собрать. Если он у вас ещё не собран вместе с остальными библиотеками собрать только Boost.Python можно следующей командой Boost.Build (в более старых версиях через bjam):
b2 --with-python --build-type=complete
Если вы выкачали Python 3.x для x64, то нужно указать ещё и address-model=64.
Более подробно в документации Boost.Build.
В результате в {BoostDir}\stage\lib\ у вас должна появится куча библиотек вида boost-python*. Они нам уже вот-вот понадобятся!..

Итак собственно воспроизводим проблему. Пишем простой класс:
    class MY_EXPORT Search
    {
    public:
        static string That( const string& name );
    };

С вот такой реализацией его единственного метода:
    string Search::That( const string& name )
    {
        if( name == "Это Я!" )
            return "Я";
        else
            throw runtime_error( "Я ничего не нашёл!" );
    }

В реальности всё значительно сложнее: у вас скорее всего запись из БД с полями кириллицей, да и сами значения тоже кириллицей, и всё в кодировке Windows-1251. Но нам чтобы отладится хватит этого тестового примера. Здесь есть конвертация строк туда и обратно из С++ и даже передача исключений в Python.

Используя Boost.Python обернём нашу маленькую библиотеку:
BOOST_PYTHON_MODULE( my )
{
    class_<Search>( "Search" )
        .def( "That", &Search::That )
        .staticmethod( "That" )
    ;
}

Не забываем про зависимости от Boost и исходной библиотеки в настройках проекта!
Полученную библиотеку переименовываем в my.pyd (да-да, просто меняем расширение).

Пробуем поработать с ней из Python. Можно прямо из консоли, если нет под рукой IDE вроде Eclipse+PyDev, просто импортируем и используем в две строки:
import my
my.Search.That( "Это Я!" )

Не забываем, что это всё-таки .dll и ей наверняка требуется .dll исходной библиотеки с классом Search, кроме того новой библиотеке-обёртке потребуется .dll Boost.Python соответствующей сборки из {BoostDir}\stage\lib\, например для MS VS2012 и Boost 1.52 для сборки Debug (Multi-thread DLL) это boost_python-vc110-mt-gd-1_52.dll.
Если непонятно чего не хватает вашей .dll посмотрите её зависимости с помощью того же Dependency Walker: www.dependencywalker.com — просто откройте depends.exe вашу .dll с библиотекой-обёрткой.
Итак вам удалось импортировать библиотеку my и выполнить my.Search.That( "Это Я!" )

Если всё хорошо, вы увидите пришедшее исключение из С++ с пустым текстом. То есть мало того, что мы не попали в нужную ветку if, так ещё и текст исключения перекодировался не так, как мы его отправили!

Если вы присоединитесь к процессу Python через "Attach to process" из MS Visual Studio, то увидите что в Search::That( const string& name ) приходит name в UTF-8. Boost.Python не знает о том в какой кодировке отдавать строку, поэтому отдаёт по умолчанию в UTF-8.
Само собой наш код в Visual Studio полностью ориентирован на Windows-1251, поэтому понять что «Р­С‚Рѕ РЇ!» на самом деле «Это Я!» он также не может. Получаем разговор слепого с глухим. По той же причине не видно текста исключения пришедшего из C++ в Python.

Ну что же, будем исправлять.

Первое что приходит в голову: унаследовать/завернуть исходный класс в другой, который умеет перекодировать.
Ага, теперь посмотрим на остальные классы, сиротливо шаркающих ножкой в ожидании своей очереди. Вы готовы потратить полжизни? Даже если это не так, первые же замеры производительности покажут насколько вы не правы оборачивая потомков. Ну и в конце у вас будут адовые проблемы при попытке достать обёрнутые классы обратно в объекты C++. Они у вас будут, поверьте! Мы строим мост по которому будем ходить в обе стороны, и обёртки классов должны напрямую ссылаться на методы и свойства нужного класса. Смотри в сторону extract<T&>(obj) из boost::python на стороне C++.

Анализируем всё, что делается в Boost.Python когда строка путешествует между C++ и Python. Видим несколько замечательных мест в которых используются функции PyUnicode_AsString и PyUnicode_FromString. Немного зная родное для Python API для чистого Си (если не знаем, то читая документацию) понимаем, что это и есть корень всех зол. Boost.Python отлично различает Python 2 и 3 версий, но понять самостоятельно что строку юникода нужно преобразовать в строку закодированную кодовой страницей файловой системы он не может, однако предоставляет для этого альтернативные функции, которые предлагается использовать самостоятельно:

PyUnicode_DecodeFSDefault — перекодирует строку в кодировке файловой системы (в нашем случае это как раз Windows-1251) и возвращает уже готовый объект строки, отлично подходит вместо PyUnicode_FromString в {BoostDir}\libs\python\src\ в файлах str.cpp и converter\builtin_converters.cpp.

PyUnicode_DecodeFSDefaultAndSize – то же самое, но с указанием размера строки. Подходит в качестве замены аналогичной PyUnicode_FromStringAndSize в тех же файлах.

PyUnicode_EncodeFSDefault — наоборот принимает объект строки из питона и перекодирует, возвращает результат в виде массива байт (объекта PyBytes), из массива байт уже после этого можно вытянуть обычную сишную строку функцией PyBytes_AsString. Требуется для обратного преобразования вместо функции PyUnicode_AsUTF8String, а в паре
PyBytes_AsString( PyUnicode_EncodeFSDefault(obj) ) заменяют макрос _PyUnicode_AsString( obj ) делающий фактически то же, но без переконвертации.

Итак, мы вооружены до зубов, знаем врага в лицо, осталось только его найти и обезвредить!

Нам нужны файлы использующие PyUnicode_* в коде {BoostDir}\libs\python\src\ и заголовочных файлах внутри {BoostDir}\boost\python\, кроме того, открою сразу тайну, нам потребуется ещё поправить исключения в файле error.cpp.

В общем список следующий:
builtin_converters.cpp — правим преобразования строк из Python в С++ и обратно
builtin_converters.hpp — надо поправить макрос преобразования в заголовочном файле
str.cpp — правим обёртку в C++ над классом Python str (обычная строка питона в C++).
errors.cpp – правим передачу текста исключения из C++ в Python

Изменений немного, они точечные, все перечислены ниже, в архиве приложенном к статье лежат патчи и отчёты об изменениях, как правило все изменения не превышают одной строки кода, чаще даже одной инструкции вызова, суммарно их ровно 13 на 4 файла. Вы ведь не суеверны, нет?..

После всех правок собираем только Boost.Python уже упомянутой выше командой:
b2 --with-python --build-type=complete
(Добавьте обязательно address-model=64 если сборка для x64, т. е. и ваш проект, и Python 3.x установленный на вашей машине собраны под 64-разрядную архитектуру адресации.)

После того как Boost.Python собран, соберите заново свой проект с обновлённой библиотекой, обновились не только .lib и .dll, но и один заголовочный файл.
Не забудьте подменить старый и унылые .dll на свежесобранные. Вы ведь наверняка не забудете их скопировать, так ведь?!

Момент истины!

import my
res = my.Search.That( 'Это Я!' )
print( res )

Всё тот же код теперь возвращает то, что и ожидалось: строку 'Я'.
Вполне себе кириллица, очень даже Юникод, если Python 3 считает этот объект строкой!
Теперь проверим как там придёт наше исключение:
import my
res = my.Search.That( 'Это Я!' )
print( res )
try:
    my.Search.That( 'Это кто-то другой!' )
except RuntimeError as e:
    print( e )

Наше исключение приходит замечательно, с нужным текстом, в виде RuntimeError — стандартного исключения Python.
Бонусом мы получили то, что на стороне C++ создавая объекты boost::python::str мы их сразу конвертируем в Юникод, что очень поможет нам когда мы на стороне C++ захотим какой-нибудь аттрибут объекта Python названного кириллицей:
object my = import( "my" );
object func = my.attr( str("Функция") )
int res = extract<int>( func( x * x ) );

Теперь в MS Visual C++ не будет никаких проблем с таким кодом. Всё отлично выцепится, позовётся и вернёт всё что надо.
Ну и раз уж речь зашла о вызове из C++ кода на Python, стоит упомянуть о том, как ловить оттуда исключения.
Все исключения из Python на уровне C++ будут ловиться типом error_already_set& всё из того же boost::python. Выцепить текст, тип и стэк исключения не представляется сложным и подробно описано вот здесь: wiki.python.org/moin/boost.python/EmbeddingPython — раздел Extracting Python Exceptions. В подавляющем большинстве случаев ничего большего нежели забрать текст исключения и не понадобится, если конечно вы не придумали своей специфической логики исключений. Но в этом случае вам лучше написать свой транслятор исключений, а это уже совсем другая история…

Итого

Мы подружили родной код MS Visual C++ с обычным кодом Python с помощью небольшого патча Boost.Python, фактически не меняя код, просто подменив в нескольких местах вызов одних функций API питона на другие, выполняющие дополнительную перекодировку. Поскольку всё сделано через API самого Python, он сам позаботится о памяти выделяемой для объектов, никаких std::string и прочих ужасов обращения к Heap через замечательные мьютексы, которые Microsoft так старательно заложила в механизм new своей стандартной библиотеки. Нет! Ничего такого! Всё за нас сделает Python, нам лишь надо было ему немного помочь.
Простые смертные всё так же могут писать код в Visual Studio не задумываясь о кодировках. Возможно даже и не зная о них. В принципе узкому специалисту в области той же транспортной части (протоколы, пакеты данных и т. п.) знать об этом не так уж и обязательно.
Особо пытливые могут замерить потери на перекодировке, они разумеется есть. По моим замерам, они настолько незначительные, что однажды переписав код очень медленной генерации веб-страницы с C++ на один join+format в Python ускорил его почти на 10%. Это с учётом перекодировки с вышеприведёнными правками. Соответственно можете представить незначительность подобных потерь, если в коде на C++ просто собиралась достаточно большая строка (даже с предварительным reserve).
По стабильности, уже полгода минимум как оболочка построенная на данных изменениях благополучно крутится на рабочих сайтах (правда версии Boost намного старше текущей). На сегодняшний день всё перекодируется стабильно, нареканий не вызывает, и не вызывало.

Обещанный архив с изменениями

Здесь собраны отчёты и патчи по изменениям в файлах библиотеки Boost.Python:
www.2shared.com/file/NFvkxMzL/habr_py3_cxx.html

Также прилагается бонусом маленький архив с тестовым проектом (собран под x64):
www.2shared.com/file/FRboyHQv/pywrap.html

Ссылки на полезное

Ссылка на документацию Python 3. Раздел Си-API перекодировки из кодовой страницы файловой системы и обратно:
docs.python.org/3/c-api/unicode.html?highlight=pyunicode#file-system-encoding

Ссылка на документацию Boost.Python:
www.boost.org/doc/libs/1_52_0/libs/python/doc
Tags:с++python3pythonboostboost.pythonboost::pythonwindowsmicrosoftvisual c++visual studiovisual studio 2012c++11python 3.3
Hubs: Python Programming C++
+6
12.6k 61
Comments 6
Top of the last 24 hours