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

    Всем доброго {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
    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 6

      +2
      Разве нельзя .cpp файл сохранить в utf-8 в Visual Studio?
        0
        Увы, это не так просто, как например в Eclipse CDT или CodeBlocks, там просто указываешь UTF-8 в workspace. Мало того, на логику того что в коде именно строки файловой системы по умолчанию уже может быть заложено много кода. В частности во взаимодействии с БД или веб-клиентом. Если просто перекодировать код большого проекта в UTF-8 и закоммитить, то:
        1) получишь тонну проблем в массе стыковых мест, которые не так легко отловить, т.к. код изначально подразумевал переход от локализованной части к интернациональной;
        2) к тебе придут все разработчики на MS Visual C++, если ты работаешь в команде больше одного человека, и спросят, с какого у них всё теперь так отображается в виде кракозябр и не работает.

        Максимально безболезненно перенести все стыковые проблемы с перекодировкой на Boost.Python.
        Если код на C++ пишется с нуля, разумеется лучше будет писать его сразу весь в UTF-8 (без BOM).
        Но! Транспорт уменьшится по трафику вдвое, если перекодировать строку в cp1251 перед отправкой. Вспомните на досуге ваши проблемы с укороченными СМС кириллицей. Это всё благодаря транспортному уровню в UTF-8, кириллически символ идёт за два байта и не помещается в один стандартный пакет отправки.
        +2
        По-моему, все с ног на голову. Не надо больше работать с кодированным текстом, не только в python, но и в C, а уж тем более в базах! Об этом и как раз революция Гвидо намекает. Вы отстали от жизни, базы уже давно распухли на строках и позволяют хранить нормальный тест на любом языке.

        UCS — это бинарное представление строк в памяти и далеко не только в Windows. Кодировки используются только для записи в файлы, (и я бы рекомендовал забыть обо всех, кроме utf-8). При чем тут у вас кодировка файловой системы по отношению к строке «Это Я!» тоже непонятно, на имя файла это совсем не смахивает.

        Ненавистный MS предложил это в лохматом 1996 ввиде Unicode API в WinNT 4, программистский мир дозрел только сейчас.
          –2
          Эм, это как мне не работать с кодированным текстом в C++? Стандартная строка на языке C++ подразумевает char*=byte*, то есть базовый как бы намекает один символ=один байт, что ещё на заре C++ было далеко не всегда так. Уже используя UTF-8 мы получаем для Windows массу символов в 2 и 3 байта.
          Я понимаю, что есть UCS-2 (обрезок UTF-16) которым Microsoft заткнула большинство дыр с Юникодом. Но подавляющее большинство библиотек на С++ работают с char* и std::string, причём не только сторонних, но и стандартных. Большинство функций уже дублировано через wchar_t* и std::wstring, но не все даже стандартные, даже в WinAPI.
          Ну и в конце концов вы правда хотите работать на C++ с wide-strings?
          Кроме того, с «широкими строками» в Windows мы получаем три основные проблемы.
          1. Нет поддержки суррогатных пар из UTF-16, это дословно означает, что Юникода в Windows нет. Вы рано или поздно огребёте от наших китайских или индийских друзей. То что до сих пор не огребли, значит что либо применение вашего кода локализовано, либо эти ребята тоже пользуются Windows и их алфавиты сильно порезаны применением «основного».
          2. Ваше API будет подразумевать постоянный переход от char* к wchar_t*. Как ни крути а большинство использует однобайтовые строки, нужно поддержать сигнатуру стандартных строк на C++.
          3. Ваши строки в виде Wide-string сильно распухнут по сравнению с UTF-8 (наиболее компактным представлением Юникода), просто потому что ваш код изобилует пунктуацией, служебными символами и латиницей примерно наполовину минимум. Это очень сильно скажется на траффике и объемах данных на жёстком диске.
          Исходя из вышесказанного проще использовать однобайтовую кодировку UTF-8.
            0
            Я не понял, вы предлагаете не работать с wchar*, а работать с char* на utf-8? И что для этого есть стандартные функции?
              –1
              Стандартные функции работают с UTF-8 в виде std::string или char* просто замечательно, за исключением operator[], поскольку он ссылается именно на элемент данных, а не на символ. Если Вы скажете, что wchar_t и std::wstring лучший выбор, я пожалуй не соглашусь, потому как с UTF-16 также operator[] не обязательно вернёт ссылку на символ. Если в тексте UTF-16 встречается суррогатная пара, то об индексации по символам можно забыть.
              Если говорить только об UCS-2, то это настолько малая часть Юникода, по сути вы отказываетесь от 0x10FF0000 символов, то есть около 285 миллионов, в пользу 65,5 тысяч. В UCS-2 не входят даже все основные алфавиты, но кириллица там конечно же есть.
              Можете обманывать себя, но по сути использование UCS-2 для кириллицы не сильно отличается от использования кодовой страницы. Проблем с другой кодовой страницей Вы конечно не получите, но интернационализации кодировки также не будет. Первый же выход крупного проекта ударит по вам незнакомыми доселе проблемами с хинди и, возможно, каким-нибудь кантонским, для избежания которых и существует общепринятый Юникод.
              Используйте пожалуйста UTF-16, но также помните, что operator[] от i-го элемента не обязательно ссылается на i-й символ.

        Only users with full accounts can post comments. Log in, please.