Daily bit(e) C++. Обработка ошибок

Добавлено 6 августа 2023 в 00:29

Daily bit(e) C++ #6. Обработка ошибок в C++ (с нововведениями C++23)

C++ для обработки ошибок предлагает несколько инструментов. В этой статье мы рассмотрим каждый из них и обсудим их преимущества и недостатки, поскольку каждый подход к обработке ошибок подходит для разных вариантов использования.

Daily bit(e) C++. Обработка ошибок

Исключения

Исключения – это наиболее фундаментальный подход к обработке ошибок в C++. Это то, что подавляющее большинство стандартных библиотек использует для сообщения об ошибках, и он также рекомендуется и для пользователей. Тем не менее, это также одна из самых противоречивых функций C++, и некоторые крупные кодовые базы (особенно Google) полностью отключили исключения.

Если мы хотим обрабатывать исключения, мы окружаем код, который потенциально может вызвать исключение, блоком try-catch. В этом примере мы выбрасываем стандартное исключение std::runtime_error и перехватываем ее базовым классом std::exception.

#include <stdexcept>
#include <iostream>

void error_maker() 
{
    throw std::runtime_error("This is a runtime error.");
}

int main() 
{
    try 
    {
        error_maker();
    } 
    catch(std::exception& e) 
    {
        std::cout << "Failed: " << e.what() << "\n";
        // печатает: "Failed: This is a runtime error."
    }
}

Открыть пример на Compiler Explorer.

Это демонстрирует один недостаток исключений: громоздкий характер кода обработки ошибок.

Основная причина, по которой рекомендуются исключения, заключается в том, что во многих ситуациях нет необходимости в какой-либо обработке ошибок.

Сильная и слабая гарантии исключений

Прежде чем мы это продемонстрируем, нам нужно сначала поговорить о гарантиях исключений. При написании любого кода на C++ у нас есть, по сути, четыре варианта относительно исключений:

  • мы можем полностью игнорировать исключения (нет гарантий исключений);
  • мы можем гарантировать, что наш код не генерирует исключений (noexcept);
  • мы можем гарантировать, что если наш код выкинет исключение, он не нарушит никаких инвариантов (слабая гарантия исключений);
  • мы можем гарантировать, что если наш код выкинет исключение, состояние не изменится (сильная гарантия исключений).

Сильная гарантия исключений фактически транзакционная. Либо операция прошла успешно, либо ничего не изменилось. Слабая гарантия исключений допускает частичные изменения (которые не нарушают инварианты).

Давайте продемонстрируем использование тестового типа, который кидает исключение в своем копирующем конструкторе при 5-ом создании копии.

#include <stdexcept>
#include <vector>
#include <set>

struct TroubleMaker 
{
  TroubleMaker(int v) : v(v) {}
  TroubleMaker(const TroubleMaker& other) : v(other.v) 
  {
    --counter;
    if (counter == 0) throw std::runtime_error("making trouble!");
  }
  int v;
  friend auto operator<=>(
    const TroubleMaker&, const TroubleMaker&) = default;
  static int counter;
};

int TroubleMaker::counter = 1000;

std::vector<TroubleMaker> src{
  TroubleMaker{1},TroubleMaker{2},TroubleMaker{3},
  TroubleMaker{4},TroubleMaker{5},TroubleMaker{6},
  TroubleMaker{7},TroubleMaker{8},TroubleMaker{9},
};


TroubleMaker::counter = 5;
std::vector<TroubleMaker> data;
try 
{
  // Это выкинет исключение,
  // вставка 5-го элемента завершится неудачей
  data.insert(data.begin(),src.begin(),src.end());
} 
catch(...) 
{}

// Сильная гарантия исключений - в data не будет
// выполнено никаких изменений
// data.size() == 0


TroubleMaker::counter = 5;
std::set<TroubleMaker> weak;
try 
{
  // Это выкинет исключение,
  // вставка 5-го элемента завершится неудачей
  weak.insert(src.begin(),src.end()); // std::set::insert
} 
catch(...) 
{}

// Слабая гарантия исключений, первые четыре элемента 
// будут вставлены, все инварианты std::set сохраняются
// weak.size() == 4

Открыть пример на Compiler Explorer.

Самая сильная гарантия – это когда фрагмент кода не кидает исключений.

#include <iostream>

struct A 
{
    // эта функция обещает не кидать исключений
    void something() noexcept;
};

struct B 
{
    // эта функция может выкинуть какое-нибудь исключение
    void something();
};

// концепты C++20 могут ограничивать для noexcept
template <typename T>
requires requires (T t) 
{
    {t.something() } noexcept;
}
void our_function(const T&) 
{
    std::cout << "Only noexcept can enter.\n";
}

template <typename T>
void our_function(const T&) 
{
    std::cout << "I can handle anything.\n";
}

int main() 
{
    // вызов варианта nonexcept
    our_function(A{});
    // вызов варианта, обрабатывающего всё
    our_function(B{});
}

Открыть пример на Compiler Explorer.

Однако, как это обычно бывает в C++, код, в котором объявлено, что он не будет выкидывать исключения, всё же может их выкидывать. Однако, обратите внимание, что это приведет к завершению программы.

void uh_oh() noexcept { throw 1; }
int main() 
{
    uh_oh();
}
/* Вывод:
terminate called after throwing an instance of 'int'
*/

Открыть пример на Compiler Explorer.

RAII + исключения

Как и было обещано, пришло время обсудить главное преимущество исключений. Если бы мы правильно написали транзакционную операцию обработки ошибок, используя стиль C, это могло бы выглядеть примерно так:

void some_transactional_operation() noexcept 
{
    int ret = acquire_resource();
    if (ret)
        return;
    ret = construct_request();
    if (ret) 
    {
        release_resource();
        return;
    }
    ret = make_request();
    if (ret) 
    {
        free_request();
        release_resource();
        return;
    }
    ret = construct_response();
    if (ret) 
    {
        rollback_request();
        free_request();
        release_resource();
        return;
    }
    ret = send_response();
    if (ret) 
    {
        free_response();
        rollback_request();
        free_request();
        release_resource();
        return;
    }
    free_response();
    free_request();
    release_resource();
}

Открыть пример на Compiler Explorer.

Это трудно читается, и здесь легко допустить ошибку. В C++ мы, конечно, не стали бы писать такой код; вместо этого мы напишем что-то вроде этого:

void some_transactional_operation() noexcept 
{
    ResourceHandle resource;
    if (!resource.acquire())
        return;
    std::unique_ptr<Request> request = construct_request();
    if (request == nullptr)
        return; // resource освобождается в деструкторе ResourceHandle
    int id = make_request(std::move(request)); // передача владения
    if (id == 0)
        return;
    std::unique_ptr<Response> response = construct_response();
    if (response == nullptr) 
    {
        rollback_request(id);
        return;
    }
    int ret = send_response(std::move(response)); // передача владения
    if (ret) 
    {
        rollback_request(id);
        return;
    }
}

Открыть пример на Compiler Explorer.

Это намного лучше. Мы делегируем очистку ресурсов деструкторам и передаем владение там, где это необходимо. Тем не менее, вы можете заметить здесь появляющуюся закономерность.

if (error) return;

И это именно то, что исключения могут сделать для нас из коробки. Итак, наконец, вот версия с использованием исключений:

void some_transactional_operation() {
    // Если acquire завершается неудачей,
    // теперь он выбрасывает исключение,
    // поскольку мы его не поймаем, мы выйдем из области видимости.
    ResourceHandle resource = ResourceHandle::acquire();
    // Выделение памяти естественно кидает исключение,
    // так как мы не ловим, мы выходим из области видимости,
    // вызывая деструктор ResourceHandle..
    std::unique_ptr<Request> request = construct_request();
    // Новый дескриптор запроса, который будет выполнять
    // откат при уничтожении.
    RequestHandle id = make_request(std::move(request));
    // То же, что и раньше, но теперь еще и откатываем запрос.
    std::unique_ptr<Response> response = construct_response();
    // То же самое
    send_response(std::move(response));
    // Наконец, освобождаем обработчик, чтобы он не откатился.
    id.release();
}

Открыть пример на Compiler Explorer.

Это очень чисто. У нас нет ветвлений в нашем коде, и все операции полагаются на RAII для очистки. Но, разумеется, наша функция, которая раньше была noexcept, теперь будет выкидывать исключение в случае ошибки.

Это подводит нас к последнему пункту об исключениях, который является их основным недостатком.

Когда исключения становятся громоздкими

Идеальный вариант использования исключений – когда мы обрабатываем крупную транзакционную операцию, которая завершается успешно или неудачно. Возьмите HTTP-запрос в качестве примера. Это позволяет нам иметь только один большой блок try{}catch(){} на внешнем интерфейсе, который обрабатывает все ошибки.

Конечно, это создает проблему. Что, если мы заботимся о разных типах ошибок? При таком подходе исключение может исходить откуда угодно: из нашего кода, из сторонней библиотеки или из стандартной библиотеки.

Это часто означает, что нам нужно иметь больше блоков try{}catch(){}, потенциально переводящих ошибки. И иногда это может быть довольно плохо:

void our_code() 
{
    bool should_retry = true;
    while (should_retry && !timeout()) 
    {
        try 
        {
            some_code();
        } 
        catch (TerminalError& e) 
        {
            // обрабатываем ошибку
            should_retry = false;
        } 
        catch (TransientError& e) 
        {
            // обрабатываем ошибку
            continue;
        }
    }
}

Открыть пример на Compiler Explorer.

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

К сожалению, есть вторая проблема. В то время как удачный путь для исключений, как правило, быстрее, чем при использовании кодов ошибок (поскольку мы избегаем повторных проверок условия if(err)). Путь ошибки для исключений, как правило, относительно медленный, и рассмотрение его как обычного случая может привести к проблемам с производительностью.

Итак, давайте рассмотрим альтернативы.

Коды ошибок

Я пропущу здесь подход в стиле C, поскольку мы его видели, но давайте посмотрим на переведенную версию предыдущего примера на использование кодов ошибок:

void our_code() 
{
    int err = 0;
    while ((err = some_code()) == TransientError && !timeout());
    if (err != 0) 
    {
        // окончательная обработка
        // timeout или TerminalError
    }
}

Открыть пример на Compiler Explorer.

std::error_code

Первым инструментом в стандартной библиотеке для обработки кодов ошибок (без использования int) является std::error_code.

Проще говоря, std::error_code объединяет непрозрачный код ошибки с «объяснением» std::error_category. Стандарт уже предоставляет несколько категорий ошибок, в частности, std::system_category, которая обеспечивает сопоставление errno с std::strerror.

#include <system_error>
#include <iostream>

int main() 
{
    // симулируем EINTR:
    auto err = std::error_code(int(EINTR), std::system_category());
    if (err)
        std::cout << "We have an error: " << err.message() << "\n";
        // печатает: "We have an error: Interrupted system call"
}

Открыть пример на Compiler Explorer.

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

#include <system_error>
#include <string>
#include <iostream>

// пользовательское перечисление ошибок
enum class MyErr 
{
    ITS_FINE = 0,
    SOME_ERROR = 1,
    SOME_OTHER_ERROR = 2,
};

// пользовательская категория, отображающая MyErr->std::string
struct MyCategory : std::error_category 
{
    const char* name() const noexcept override 
    {
        return "MyCategory";
    }
    std::string message( int condition ) const override 
    {
        using namespace std::string_literals;
        switch(condition) 
        {
            case 0: return "everything is fine"s;
            case 1: return "we got some error"s;
            case 2: return "something else happened"s;
        }
        return ""s;
    }
};

// Регистрируем наше перечисление как код ошибки,
// чтобы мы могли создать error_code из него
template<> struct std::is_error_code_enum<MyErr> 
    : public std::true_type{};

// Говорим компилятору, что MyErr идет в паре с MyCategory
std::error_code make_error_code(MyErr e) {
    return std::error_code(static_cast<int>(e), MyCategory());
}

// И теперь мы можем использовать его:
std::error_code my_function() noexcept {
    return MyErr::SOME_ERROR;
}

int main() 
{
    if (auto err = my_function(); err) 
    {
        std::cout << err.message() << "\n";
        // печатает: "we got some error"
    }
}

Открыть пример на Compiler Explorer.

Типовое использование std::error_code – либо вместо возвращаемого кода ошибки, либо в качестве дополнительного аргумента, потенциально позволяющего различать версии API с генерацией исключений и без них (смотрите <filesystem>).

#include <system_error>

// вариант с выбрасыванием исключений
void something(int arg) 
{
    throw std::runtime_error("error");
}
// вариант noexcept
void something(int arg, std::error_code& err) noexcept 
{
    err = make_error_code(std::errc::interrupted);
}

Открыть пример на Compiler Explorer.

std::expected (С++ 23)

Использование std::error_code может быть неудобным, когда нам нужно вернуть какой-то другой тип, так как тогда нам пришлось бы возвращать его в качестве выходного аргумента (как показано в предыдущем примере).

Именно тогда приходит на помощь последнее дополнение к арсеналу обработки ошибок в C++. std::expected – это либо ожидаемый возвращаемый тип, либо неожиданный тип (ошибка). Этот тип предоставляет интерфейс, аналогичный std::optional, и ориентирован на ожидаемый путь.

#include <iostream>
#include <string>
#include <expected>
using namespace std::string_literals;

std::expected<std::string,std::error_code> function(bool fail) 
{
    if (fail)
        return std::unexpected(make_error_code(std::errc::no_message));
    return "This is a string"s;
}

int main() 
{
    // интерфейс на основе запросов
    std::cout << function(true).value_or("default string"s) << "\n";
    // печатает: "default string"

    // интерфейс в стиле указателей
    if (auto res = function(false); res) // то же самое, что res.has_value()
    {
        std::cout << *res << "\n"; // то же самое, что res.value()    
    }
    // печатает: "This is a string"

    if (auto res = function(true); !res.has_value()) 
    {
        // метод error() для доступа к не ожидаемой части
        std::cerr << res.error().message() << "\n";
    }
}

Открыть пример на Compiler Explorer.

std::expected вместе с std::optional также получил монадический интерфейс.

#include <expected>
#include <string>
#include <iostream>

std::expected<int,std::string> count_up(int v) 
{
    if (v >= 3) 
    {
        return std::unexpected("that's to much");
    }
    return v+1;
}

std::expected<int,std::string> deep_thought(std::string err) 
{
    std::clog << "Failed to count up : " << err << "\n";
    return 42;
}

int main() 
{
    auto v = count_up(1)
        .and_then(count_up);
    // *v == 3

    auto w = count_up(1) // 1->2
        .and_then(count_up) // 2->3
        .and_then(count_up) // 3->ошибка
        .and_then(count_up) // пропускается
        .or_else(deep_thought); // ошибка->42
    // *w == 42
}

Открыть пример на Compiler Explorer.

Заключение

Хотя это и не стоит отдельного раздела, следует отметить, что std::abort() – это хороший способ обработки ошибок терминала, особенно когда продолжение может представлять опасность повреждения данных.

В итоге:

  • Выбирайте исключения, когда вы можете сохранить код свободным от обработки ошибок.
  • Выбирайте коды ошибок, когда: ошибки случаются часто, или требуется тщательная обработка ошибок, или предпочтителен/необходим интерфейс без исключений.
  • Выбирайте std::expected, если хотите выбрать коды ошибок и можете использовать C++23.

Теги

C++ / CppC++23Daily bit(e) C++Exception / Исключениеstd::error_categorystd::error_codestd::expectedSTL / Standard Template Library / Стандартная библиотека шаблоновГарантии безопасности исключенийОбработка ошибокПрограммирование

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.