Путешествие исключений между C++ и Python или «Туда и обратно»

в 13:37, , рубрики: boost.python, c++, c++11, embedded, exception, python, python3, translation, wrapper, гибрид, исключение, модуль, Программирование, скрипт, метки: , , , , , , , , , , , ,

В этой главе сказа про дружбу C++ и Python будет на удивление мало использования Boost.Python. Передача исключений туда и обратно является по сути слабым местом данной библиотеки. Будем обходиться родным API языка Python, а где это возможно использовать Boost.Python.
Тем не менее Boost.Python создаёт окружение, в котором исключения из C++ попадают в Python в виде стандартного RuntimeError, а обратно из Python генерируется исключение C++ типа error_already_set, что означает «тебе что-то прилетело, сходи сам почитай что там». И вот здесь нам как раз будет не лишним использовать C-API языка Python, чтобы вычитать необходимую информацию об исключении и преобразовать в соответствующий класс сообразно логике приложения.
К чему такие сложности? — Дело в том, что в Python, в отличие от C++, кроме текста исключения и его типа приходит ещё и traceback — стек до места возникновения исключения. Давайте немного расширим стандартный std::exception дополнительным параметром для этого stacktrace, а заодно напишем конвертер исключений туда и обратно из классов C++ в классы исключений Python.

В предыдущих сериях

1. Boost.Python. Введение. Обёртки C++ в Python.
2. Boost.Python. Обёртка специфичных классов C++.
3. Boost.Python. Создание конвертеров типов между C++ и Python.

Введение

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

Конечно в большинстве случаев вам будет достаточно стандартного механизма Boost.Python преобразования исключения из C++ в Python в виде RuntimeException с текстом пришедшим из exception::what(). На стороне С++ нужно отлавливать исключение типа error_already_set и использовать родное API языка Python, но зато можно вычитать не только тип и текст исключения, но и traceback — фактически историю исключения.
Но обо всём по порядку.

Путешествие маленького исключения из C++ в Python

Итак, вы написали модуль на C++ с использованием Boost.Python, подключили его в коде на Python через обычный import и используете одну из обёрток функций или методов. Допустим в коде на C++ кидается самое обычное исключение через столь же обычный throw. В коде на Python вы получите RuntimeException с текстом, полученным из exception::what() если это исключение порождено от std::exception.
Если вас устраивает то, что вы кроме текста исключения ничего не получите, то можно даже больше ничего и не делать. Однако если вам нужно будет отловить исключение строго определённого класса ошибок, то нужно будет немного поработать.

На самом деле Boost.Python предоставляет возможность зарегистрировать свою трансляцию исключения покидающего родные пенаты модуля, написанного на C++. Всё что нужно сделать: в функции объявления модуля вызвать шаблонную функцию boost::python::register_exception_translator<T,F>(F), где T — тип исключения в C++, а F — функция, принимающая ссылку на исключение данного типа и каким-то образом исполняющая свой долг по передаче исключения нужного типа во внешний код уже в Python. В общем виде примерно так:

class error : public exception { ... };
...

void translate_error( error const& );
...

BOOST_PYTHON_MODULE( ... )
{
    ...
    register_exception_translator<error>( translate_error );
}

...
void translate_error( error const& e )
{
    PyErr_SetString( PyExc_Exception, e.what() );
}

Здесь мы использовали стандартное исключение типа Exception встроенное в Python, но можете использовать абсолютно любое исключение: стандартное, внешнее подключенное через import с получением PyObject* через object::ptr() или даже своё собственное созданное прямо тут же на месте через PyErr_NewException.

Давайте для полноты ощущений добавим ещё пару классов, которые будут отдаваться как аналог ZeroDivisionError и ValueError и для полного счастья унаследуем их от нашего error, назовём их соответственно zero_division_error и value_error:

class error : public exception
{
public:
    error();
    error( string const& message );
    error( string const& message, string const& details );

    virtual const char* what() const;

    virtual string const& get_message() const;
    virtual string const& get_details() const;

    virtual const char* type() const;

private:
    string m_message;
    string m_details;
};

class value_error : public error
{
public:
    value_error();
    value_error( string const& message );
    value_error( string const& message, string const& details );

    virtual const char* type() const;
};

class zero_division_error : public error
{
public:
    zero_division_error();
    zero_division_error( string const& message );
    zero_division_error( string const& message, string const& details );

    virtual const char* type() const;
};

Поле m_details нам потребуется на обратном пути из Python в C++, для сохранения traceback например. А метод type() понадобится для отладки чуть позже. Простая и понятная иерархия.

Зарегистрируем для наших исключений функции-трансляторы в Python:

void translate_error( error const& );
void translate_value_error( value_error const& );
void translate_zero_division_error( zero_division_error const& );
...
BOOST_PYTHON_MODULE( ... )
{
    ...
    register_exception_translator<error>( translate_error );
    register_exception_translator<value_error>( translate_value_error );
    register_exception_translator<zero_division_error>( translate_zero_division_error );
}
...
void translate_error( error const& e )
{
    PyErr_SetString( PyExc_Exception, e.what() );
}

void translate_value_error( value_error const& e )
{
    PyErr_SetString( PyExc_ValueError, e.what() );
}

void translate_zero_division_error( zero_division_error const& e )
{
    PyErr_SetString( PyExc_ZeroDivisionError, e.what() );
}

Отлично, осталось только завести на стороне C++ тестовые функции, которые и будут кидаться этими исключениями:

double divide( double a, double b )
{
    if( abs( b ) < numeric_limits<double>::epsilon() )
        throw zero_division_error();
    return a / b;
}

double to_num( const char* val )
{
    double res;
    if( !val || !sscanf( val, "%LG", &res ) )
        throw value_error();
    return res;
}

void test( bool val )
{
    if( !val )
        throw error( "Test failure.", "test" );
}

Почему бы и нет, эти функции не хуже любых других и кидают ровно то, что нам надо.
Оборачиваем их:

...
BOOST_PYTHON_MODULE( python_module )
{
    register_exception_translator<error>( translate_error );
    register_exception_translator<value_error>( translate_value_error );
    register_exception_translator<zero_division_error>( translate_zero_division_error );

    def( "divide",  divide, args( "a", "b" ) );
    def( "to_num",  to_num, args( "val" ) );
    def( "test",    test,   args( "val" ) );
}
...

Ну что ж, собираем наш модуль, выполняем import python_module, вызываем наши функции с нужными параметрами, получаем нужные исключения (скрипт на Python 3.x):

import python_module as pm

try:
    res = pm.divide( 1, 0 )
except ZeroDivisionError:
    print( "ZeroDivisionError - OK" )
except Exception as e:
    print( "Expected ZeroDivisionError, but exception of type '{t}' with text: '{e}'".format(t=type(e),e=e) )
else:
    print( "Expected ZeroDivisionError, but no exception raised! Result: {r}".format(r=res) )

try:
    res = pm.to_num( 'qwe' )
except ValueError:
    print( "ValueError - OK" )
except Exception as e:
    print( "Expected ValueError, but exception of type '{t}' with text: '{e}'".format(t=type(e),e=e) )
else:
    print( "Expected ValueError, but no exception raised! Result: {r}".format(r=res) )

try:
    res = pm.test( False )
except Exception as e:
    if type(e) is Exception:
        print( "Exception - OK" )
    else:
        print( "Exception of type '{t}', expected type 'Exception', message: '{e}'".format(t=type(e),e=e) )
else:
    print( "Expected Exception, but no exception raised! Result: {r}".format(r=res) )

Вывод скрипта:
ZeroDivisionError — OK
ValueError — OK
Exception — OK
Пока всё хорошо. Поехали в обратную сторону.

Приключения нашего исключения по пути из Python в C++

Давайте выделим в отдельный проект типы исключений и тестовые функции и будем собирать из них отдельную динамически подключаемую библиотеку error_types. Модуль для Python будем собирать отдельно в проекте python_module.
А теперь заведём приложение на C++ где будем ловить исключения из Python, назовём его catch_exceptions.
Всё что нужно, это подключить наш модуль через import(«python_module»), потом получить доступ к функциям модуля через attr(«divide»), attr(«to_num»), attr(«test»). Будем вызывать их, они породят исключения на уровне C++ кода, пройдут в интерпретатор Python и пробросятся дальше в приложение на C++, вызвав исключение error_already_set — исключение библиотеки Boost.Python заготовленное как раз для таких случаев.

Сам по себе объект типа error_already_set, важно просто поймать исключение. В общем случае обработка такого исключения выглядит так:

catch( error_already_set const& )
{
    PyObject *exc, *val, *tb;
    PyErr_Fetch( &exc, &val, &tb );
    PyErr_NormalizeException( &exc, &val, &tb );

    handle<> hexc(exc), hval( allow_null( val ) ), htb( allow_null( tb ) );

    throw error( extract<string>( !hval ? str( hexc ) : str( hval ) ) );
}

Так мы получим исключение всегда одного и того же типа, но хотя бы сможем извлечь текст исключения. Но нам ведь приходит тип исключения в переменную exc, сам объект исключения в переменной val и даже объект со стеком произошедшего исключения в переменной tb. Давайте преобразуем произошедшее исключение в zero_division_error и value_error, если пришло ZeroDivisionError или ValueError соответственно.

Стоп! Не всем всё понятно, что это за две функции, почему всё PyObject*, откуда исключения в C-API если в Си их нет, давайте подробнее.
Да, в чистом Си нет исключений, зато в Python они есть и его API предоставляет возможность вытянуть информацию о произошедшем исключении. В Python C-API все значения и типы, да вообще почти всё, представляется в виде PyObject*, поэтому исключение E типа T — это пара значений типа PyObject*, добавим к этому ещё и PyObject* для traceback — сохранённого стека, где произошло исключение.
Вытянуть информацию о произошедшем исключении можно функцией PyErr_Fetch, после чего информацию об исключении можно нормализовать (если не хотите работать в внутренним представлением в виде tuple) функцией PyErr_NormalizeException.
После вызова пары этих функций мы заполним три значения типа PyObject*, соответственно: класс исключения, экземпляр (объект) исключения и стек (traceback) сохранённый в момент генерации исключения.

Далее куда удобнее работать с Boost.Python, оборачиваем PyObject* в boost::python::handle<>, который совместим с любым объектом библиотеки Boost.Python, нам как раз нужен boost::python::str. После преобразования к аналогу строки питона в Boost.Python мы можем вытянуть стандартную родную строку std::string языка C++. При желании можно вытянуть и обычный const char*.

С первыми двумя параметрами всё понятно, они прекрасно приводятся к строке, а вот с traceback ещё придётся преобразовать к читабельному виду. Проще всего это сделать при помощи модуля traceback, передав наши три параметра в функцию format_exception. Функция traceback.format_exception( exc, val, tb ) вернёт нам массив строк в виде стандартного list языка Python, который замечательно джойнится в одну большую толстую строку.
На стороне C++, используя Boost.Python, это будет выглядеть примерно так:

    ...
    format_exception = import( "traceback" ).attr( "format_exception" );
    return extract<string>( str( "" ).join( format_exception( exc, val, tb ) ) );
}

Можно сделать вспомогательную функцию для генерации строки из исключения. Проблема однако в том, что вызов import() в функции каждый раз будет приводить к недешёвому вызову, поэтому объект получаемый из import( «traceback» ).attr( «format_exception» ) лучше всего сохранить результат функции в отдельный object, также нам понадобится сохранить результат import(«python_module»). Учитывая то, что это потребуется сделать где-то между Py_Initialize() и Py_Finalize(), то ничего лучше полей синглтона для хранения таких переменных в голову не приходит.

Работа с Python API через синглтон

Итак, давайте заведём синглтон, это усложнит приложение, но несколько упростит код и позволит корректно инициализировать работу с интерпретатором, сохранить все вспомогательные объекты и корректно всё завершить:

class python_interpreter
{
public:
    static double divide( double, double );
    static double to_num( string const& );
    static void   test( bool );

    static string format_error( handle<> const&, handle<> const&, handle<> const& );

private:
    object m_python_module;
    object m_format_exception;

    python_interpreter();
    ~python_interpreter();

    static python_interpreter& instance();

    object& python_module();
    string  format_error( object const&, object const&, object const& );
};

Конструктор будет инициализировать работу с интерпретатором, а деструктор зачищать сохранённые поля и де-инициализировать работу с интерпретатором, методы python_module и format_error импортируют соответствующие модули лишь однажды:

python_interpreter::python_interpreter()
{
    Py_Initialize();
}

python_interpreter::~python_interpreter()
{
    m_python_module = object();
    m_format_exception = object();

    Py_Finalize();
}

double python_interpreter::divide( double a, double b )
{
    return extract<double>( instance().python_module().attr("divide")( a, b ) );
}

double python_interpreter::to_num( string const& val )
{
    return extract<double>( instance().python_module().attr("to_num")( val ) );
}

void python_interpreter::test( bool val )
{
    instance().python_module().attr("test")( val );
}

string python_interpreter::format_error( handle<> const& exc, handle<> const& msg, handle<> const& tb )
{
    return instance().format_error( object(exc), object(msg), object(tb) );
}

python_interpreter& python_interpreter::instance()
{
    static python_interpreter single;
    return single;
}

object& python_interpreter::python_module()
{
    if( m_python_module.is_none() )
        m_python_module = import( "python_module" );
    return m_python_module;
}

string python_interpreter::format_error( object const& exc, object const& val, object const& tb )
{
    if( m_format_exception.is_none() )
        m_format_exception = import( "traceback" ).attr( "format_exception" );
    return extract<string>( str( "" ).join( m_format_exception( exc, val, tb ) ) );
}

Итого мы получили готовый механизм, применимый для любого приложения на C++, использующего Python как мощный вспомогательный функционал с кучей библиотек.
Пора проверить наш механизм исключений!

Проверка механизма трансляции исключений из Python в C++

Заведём вспомогательную функцию:

void rethrow_python_exception()
{
    PyObject *exc, *val, *tb;
    PyErr_Fetch( &exc, &val, &tb );
    PyErr_NormalizeException( &exc, &val, &tb );

    handle<> hexc(exc), hval( allow_null( val ) ), htb( allow_null( tb ) );

    string message, details;
    message = extract<string>( !hval ? str( hexc ) : str( hval ) );
    details = !tb ? extract<string>( str( hexc ) ) : python_interpreter::format_error( hexc, hval, htb );

    if( PyObject_IsSubclass( exc, PyExc_ZeroDivisionError ) )
        throw zero_division_error( message, details );
    else if( PyObject_IsSubclass( exc, PyExc_ValueError ) )
        throw value_error( message, details );
    else
        throw error( message, details );
}

Тогда механизм обработки исключений сведётся к следующей схеме для каждого тестируемого метода как например для divide:

        try
        {
            try
            {
                python_interpreter::divide( 1, 0 );
            }
            catch( error_already_set const& )
            {
                rethrow_python_exception();
            }
        }
        catch( error const& e )
        {
            output_error( e );
        }

Здесь output_error простейшая функция, которая выводит информацию об исключении, например вот так:

void output_error( error const& e )
{
    cerr << "nError type: " << e.type() << "nMessage: " << e.get_message() << "nDetails: " << e.get_details() << endl;
}

Здесь как раз нам пригодился виртуальный метод type() который мы завели в базовом классе error.

Заводим подобные секции для to_num и для test, а ещё проверим что придёт если просто выполнить в Python строку «1 / 0» через exec:

        try
        {
            try
            {
                exec( "1 / 0" );
            }
            catch( error_already_set const& )
            {
                rethrow_python_exception();
            }
        }
        catch( error const& e )
        {
            output_error( e );
        }

Запускаем…
Вывод должен быть примерно таким:

Error type: zero_division_error
Message: Division by zero!
Details: <class 'ZeroDivisionError'>

Error type: value_error
Message: Inappropriate value!
Details: <class 'ValueError'>

Error type: error
Message: Test failure.
Details: <class 'Exception'>

Error type: zero_division_error
Message: division by zero
Details: Traceback (most recent call last):
File "", line 1, in ZeroDivisionError: division by zero

Эпилог

Итого: мы получили механизм взаимнооднозначного преобразования исключений из Python в C++ и обратно.
Минус заметен сразу же — это разные абсолютно не связанные между собой сущности. Это связано с тем, что С++ класс не может быть унаследован от класса из Python, равно как и наоборот. Есть вариант с «инкапсуляцией» нужного класса исключения в обёртке класса C++ для Python, но это всё так же будут разные классы, которые просто преобразуются в соответствующие каждый в своём языке.
Если у вас сложная иерархия исключений в C++, наиболее простой способ, завести себе аналог в Python в отдельном .py модуле, потому как создавать через PyErr_NewException, а затем где-то хранить довольно накладно и не добавит читаемости коду.
Не знаю как вы, а я с нетерпением жду, когда Boost.Python обзаведётся приличным транслятором исключений, или хотя бы аналогом boost::python::bases для наследования обёртки от класса Python. В целом Boost.Python отличная библиотека, но данный аспект прибавляет геммороя при разборе исключений из Python на стороне C++. Трансляция в Python через регистрацию функции-транслятора register_exception_translator<E,F>(F) выглядит вполне удачной и позволяет преобразовать исключение типа A в C++, в совершенно другой класс B на стороне Python, но хотя бы автоматически.
В принципе не обязательно реагировать на error_already_set именно так как описано выше, можете сами выбрать себе рецептуру поведения вашего приложения, используя Python API для обработки исключений из Python.

Ссылка на проект находится здесь (~223 KB). Проект MSVS v11 настроен на сборку с Boost 1.52 и Python 3.3 x64.

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

PyWiki — Извлечение исключения на стороне C++
Обработка исключений средствами Python C-API
Регистрация транслятора исключений из C++ в Python

Автор: Qualab

Источник



https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js