0
Обложка: Исключения в C++: безопасность, спецификации, бенчмарки

Исключения в C++: безопасность, спецификации, бенчмарки

Продолжаем разговор об исключениях в C++.

  1. Гарантии безопасности
  2. Спецификации исключений в C++
  3. Стоит ли избегать исключений
  4. Как работают исключения
  5. Заключение
  6. Полезные ссылки
Георгий Осипов
Георгий Осипов
Один из авторов курса «Разработчик C++» в Яндекс Практикуме, разработчик в Лаборатории компьютерной графики и мультимедиа ВМК МГУ

В первой части мы разобрали, как создавать исключения и работать с ними, а также какими они бывают. Разобрали ключевые слова try, catch и throw, синтаксис выбрасывания и обработки исключений, а ещё особые случаи, связанные с исключениями.

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

  • гарантии безопасности;
  • спецификации исключений;
  • как исключения влияют на скорость выполнения;
  • как устроены исключения в C++ и как они работают.

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

Углубить и систематизировать знания C++ поможет курс «Разработчик C++» в Яндекс Практикуме. Для тех, кто знает C++, но желает изучить работу по сети, Docker, Linux и множество вспомогательных инструментов, есть курс «C++ для бэкенда».

Гарантии безопасности

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

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

Выделяют четыре уровня безопасности исключений:

  1. Гарантия отсутствия исключения. Самая сильная гарантия. Означает, что исключение возникнуть не может, а значит, ничего не сломает.
  2. Строгая гарантия безопасности. Исключение может возникнуть, но в этом случае всё будет возвращено к тому, как было до вызова соответствующей функции. Иными словами, операция не удалась, но можно сделать вид, как будто её и не было.
  3. Базовая гарантия безопасности. При возникновении ошибки мы не можем вернуть всё как было, но всё равно останемся в корректном состоянии. Все инварианты будут сохранены, все ненужные ресурсы освобождены, ничего не утечёт. С объектом, из-за которого возникло исключение, можно продолжать работать.
  4. Отсутствие безопасности. Не можем гарантировать ничего. Если исключение произошло, лучше поскорее завершить работу программы.

Рассмотрим пример. Напишем собственную операцию push_back для вставки в вектор.

Гарантия отсутствия безопасности

template<class T>
class Vector {
public:
    void push_back_no_guarantee(T elem) {
        T* old = mem;

        // Возможное исключение в new не опасное:
        // мы ещё ничего не успели испортить
        mem = new T[size + 1];
        size_t old_size = size++;

        // Используем алгоритм move для перемещения всех
        // элементов из старой памяти в новую.
        // Тут может возникнуть исключение.
        std::move(old, old + old_size, mem);
        mem[old_size] = std::move(elem);

        delete[] old;
    }

private:
    T* mem = nullptr;
    size_t size = 0;
};

Вызов new не опасен в отличие от перемещения элементов: мы ничего не знаем про конструктор перемещения неизвестного объекта T. Он может выбрасывать исключение.

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

В примере мы использовали new для простоты и наглядности. Реальный вектор должен выделять сырую память без инициализации.

Базовая гарантия безопасности

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

void push_back_basic_guarantee(T elem) {
    T* old = mem;
    mem = new T[size + 1];
    size_t old_size = size;
    size = 0;

    try {
        // Используем цикл, чтобы постоянно знать актуальный размер.
        for (size_t i = 0; i < old_size; ++i) {
            mem[i] = std::move(old[i]);
            size++;
        }
        mem[old_size] = std::move(elem);
        size++;
    }
    catch(...) {
        // Предотвратим утечку ресурсов в случае исключения
        delete[] old;
        throw;
    }

    delete[] old;
}

Уже лучше: размер вектора будет корректен, и мы не допустим утечки. Однако такая вставка может привести, например, к обнулению вектора, если исключение возникло в самом первом перемещении.

Строгая гарантия безопасности

Над строгой гарантией нужно потрудиться.

void push_back_strong_guarantee(T elem) {
    // Идём другим путём: аллоцируем память, но не будем
    // менять this->mem пока копирование не закончено.
    T* new_mem = new T[size + 1];

    try {
        for (size_t i = 0; i < size; ++i) {
            // Мы должны быть готовы всё вернуть как было, а значит, 
            // не можем перемещать элементы, чтобы не испортить 
            // старую память — придётся их копировать.
            new_mem[i] = mem[i];
        }
        // Последний элемент можно переместить:
        new_mem[size] = std::move(elem);
    }
    catch(...) {
        delete[] new_mem;
        throw;
    }

    // Выше мы вообще не меняли this.
    // Теперь, когда всё точно готово, сделаем это.
    T* old = mem;
    mem = new_mem;
    size++;

    // Считаем, что деструктор не выбрасывает.
    delete[] old;
}

У строгой гарантии есть неприятный эффект: нам пришлось отказаться от перемещений в пользу копирований. Таким образом, она отрицательно влияет на эффективность.

Можно сделать всё эффективно, если есть уверенность, что перемещение объектов типа T не выбрасывает исключений. Стандартный std::vector так и делает.

Гарантия отсутствия исключения

И наконец, достигнем вершины — напишем метод без исключений:

// Теперь метод возвращает bool:
// true показывает, что вставка удалась,
// false свидетельствует об ошибке.
bool push_back_no_exception(T elem) {
    T* new_mem;

    // Теперь нужно поймать возможный std::bad_alloc или
    // исключение в конструкторе
    try {
        new_mem = new T[size + 1];
    }
    catch(...) {
        return false;
    }

    try {
        for (size_t i = 0; i < size; ++i) {
            new_mem[i] = mem[i];
        }
        new_mem[size] = std::move(elem);
    }
    catch(...) {
        delete[] new_mem;
        return false;
    }

    T* old = mem;
    mem = new_mem;
    size++;
    
    // По-прежнему считаем, что деструктор не выбрасывает.
    delete[] old;

    return true;
}

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

Спецификации исключений в C++

Такой разный noexcept

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

// Функция извлечения корня будет возвращать пустое значение
// а не выбрасывать исключение. Чтобы показать, что она не выбрасывает
// мы пометили её как noexcept:
std::optional<double> SafeSqrt(double arg) noexcept {
    return arg >= 0 ? sqrt(arg) : std::nullopt;
}

Если же мы хотим явно сказать, что функция выбрасывает исключение, можно написать noexcept(false):

double UnsafeSqrt(double arg) noexcept(false) {
    return arg >= 0 
        ? sqrt(arg) 
        : throw std::invalid_argument("Попытка извлечь корень из отрицательного числа");
}

Но это лишнее, ведь выбрасывающими по умолчанию считаются все функции, кроме деструкторов. Зато можно поместить внутрь скобок содержательное выражение времени компиляции:

int f() noexcept { return 42; }
int g() { throw 42; }

constexpr bool f_is_noexcept = true;
constexpr bool g_is_noexcept = false;

// h является noexcept, только когда и f и g таковые:
int h() noexcept(f_is_noexcept && g_is_noexcept) {
    return f() + g();
}

Тут мы явно написали, какая функция noexcept, а какая — нет, хотя могли бы вычислить. noexcept допустимо использовать как операцию, определяющую, может ли выбрасывать содержимое скобок:

#include <iostream>

int f() noexcept { return 42; }
int g() { throw 42; }

constexpr bool f_is_noexcept = noexcept(f());
constexpr bool g_is_noexcept = noexcept(g());

int h() noexcept(f_is_noexcept && g_is_noexcept) {
    return f() + g();
}

int main() {
    std::cout << "Функция f может выбрасывать исключения? " << (noexcept(f()) ? "нет" : "да") << std::endl;
    std::cout << "Функция g может выбрасывать исключения? " << (noexcept(g()) ? "нет" : "да") << std::endl;
    std::cout << "Функция h может выбрасывать исключения? " << (noexcept(h()) ? "нет" : "да") << std::endl;
    
    std::cout << "Выражение f() + g() может выбрасывать исключения? " << (noexcept(f() + g()) ? "нет" : "да") << std::endl;
}

Вывод программы будет таким:

Функция f может выбрасывать исключения? нет
Функция g может выбрасывать исключения? да
Функция h может выбрасывать исключения? да
Выражение f() + g() может выбрасывать исключения? да

Заметьте, что мы пишем noexcept(g()), а не noexcept(g). Последнее выражение всегда false, поскольку само по себе выражение g ничего не делает, а значит, ничего не выбрасывает.

Если в функции, помеченной как noexcept, всё же возникло исключение, то оно приведёт к вызову std::terminate и завершению программы.

А нужен ли noexcept?

Вряд ли что-то может ответить на поставленный вопрос красноречивее бенчмарка. В этом бенчмарке, который мы провели в сервисе QuickBench, созданы три практически одинаковых класса:

F_except, у которого есть обычный конструктор перемещения;

  • F_noexcept, у которого конструктор перемещения помечен как noexcept;
  • F_noexcept2 — как F_noexcept, но с деструктором, помеченным как noexcept(false). Деструктор нужно явно помечать как noexcept(false), иначе он считается невыбрасывающим.

Объекты этих классов добавляются в std::vector. Его метод push_back обеспечивает строгую гарантию исключений. Как вы уже знаете, в этом случае перемещение вектора возможно только тогда, когда перемещение его элементов не выбрасывает исключений.


Добавление элемента в вектор в среднем в 1,6 раза быстрее, если конструктор перемещения элементов помечен как noexcept. Добавление замедляется, если деструктор элементов может выбрасывать

Отличие заметное, хотя не такое существенное из-за оптимизаций в std::vector. noexcept позволил выбрать более эффективный алгоритм, но только в том случае, когда он применялся и для конструктора перемещения, и для деструктора.

Волшебный default

Рассмотрим три класса:

class T1 {
public:
};

class T2 {
public:
    T2() {}
};

class T3 {
public:
    T4() = default;
};

Казалось бы, разницы между ними нет, но попробуем изучить конструкцию T*() на предмет выбрасывания исключений:

class T1 {
public:
};

class T2 {
public:
    T2() {}
};

class T3 {
public:
    T4() = default;
};

class T4 {
public:
    T4() noexcept {}
};

int main() {
    std::cout << std::boolalpha;
    std::cout << "T1() noexcept: " << noexcept(T1()) << std::endl;
    std::cout << "T2() noexcept: " << noexcept(T2()) << std::endl;
    std::cout << "T3() noexcept: " << noexcept(T3()) << std::endl;
    std::cout << "T4() noexcept: " << noexcept(T4()) << std::endl;
}

В этом примере мы также добавили четвёртый класс. Вывод программы такой:

T1() noexcept: true
T2() noexcept: false
T3() noexcept: true
T4() noexcept: true

Нас подвёл только один вариант, в котором конструктор объявлен как T2() {}. Такой конструктор будет считаться выбрасывающим, хотя на вид он аналогичен записи T2() = default.

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

Список исключений

В некоторых языках программирования c исключениями всё строже. Каждая функция снабжается списком исключений, которые она может выбрасывать. Была такая попытка и в C++ с самого момента его стандартизации:

#include <iostream>
#include <cmath>

class MyException{};

// Спецификация исключений для функции.
// Компилировать с флагом -std=c++03 или иным 
// в зависимости от компилятора
int f(int x) throw(MyException) {
    if (x < 0) 
        throw MyException();
        
    return sqrt(x);
}

int main() {
    f(15);
    f(-3);
}

У такого подхода есть преимущества:

  • компилятор может убедиться, что все исключения обрабатываются;
  • вы видите, что может, а что не может выбрасывать функция по её сигнатуре;
  • обработчик можно искать в compile-time.

Однако на деле не всё так радужно. Всплыл ряд недостатков:

  • Некоторые исключения, например, std::bad_alloc может выбрасывать почти любая функция. Везде писать throw(std::bad_alloc) утомительно.
  • Непонятно, как быть с указателями на функции и std::function. Если мы хотим заранее всё знать об исключениях, то спецификации исключений должны быть в типе функции. Тогда неясно, как их преобразовывать.
  • Можно сделать спецификации нестрогими, но тогда непонятно, что они дают.

С похожими недостатками сталкиваются и в других языках программирования. В C++ недостатки перевесили, и комитет по стандартизации решил от явных спецификаций отказаться, оставляя только noexcept. В Стандарте C++11 они объявлены устаревшими (deprecated), а позже вовсе исключены из языка.

Стоит ли избегать исключений

«Мы не используем исключения в C++», — cтайлгайд Google.

Не нужны или незаменимы

Исключения в C++ — удобный инструмент, не лишённый недостатков. Может быть, самый существенный из них — в том, что исключения прозрачны. Глядя на сигнатуру функции, нельзя понять, какие исключения она выбрасывает. Это можно понять, глядя в документацию. Но документация не проверяется автоматически, а значит, может ошибаться.

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

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

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

В некоторых местах без исключений трудно: например, это единственный способ прервать конструктор. Можно, конечно, ввести для объекта невалидное состояние, указывающее, что во время конструктора произошла ошибка. Но это будет усложнением класса.

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

std::optional<PersonCard> ParseJSON(json raw_json, int idx) {
    PersonCard result;
    try {
        result.first_name = raw_json["persons"][idx]["name"]["first"].as_string();
        result.last_name = raw_json["persons"][idx]["name"]["last"].as_string();
        result.phone = parse_phone(raw_json["persons"][idx]["phone"].as_string());
        result.address = raw_json["persons"][idx]["address"];
    }
    catch(json::error e) {
        std::cout << "Error in JSON at " << e.where() << ": " << e.what() << std::endl;
        return std::nulltopt;
    }
    return result;
}

Без исключений только первая строка блока try записывалась бы так:

if (!raw_json.is_object()) return std::nullopt;
if (!raw_json.has_key("persons")) return std::nullopt;
if (!raw_json["persons"].is_array()) return std::nullopt;
if (raw_json["persons"].length() >= idx) return std::nullopt;
if (!raw_json["persons"][idx].is_object()) return std::nullopt;
if (!raw_json["persons"][idx].has_key("name")) return std::nullopt;
if (!raw_json["persons"][idx]["name"].is_object()) return std::nullopt;
if (!raw_json["persons"][idx]["name"].has_key("first")) return std::nullopt;
if (!raw_json["persons"][idx]["name"]["first"].is_string()) return std::nullopt;

При этом мы опустили вывод диагностики о местоположении ошибки и её сути. Конечно, можно было придумать сложный прокси-объект, который ведёт себя как JSON, но на самом деле находится в ошибочном состоянии и не выполняет никаких действий, и тем самым сократить количество строк.

Но решайте сами, следовать ли стайлгайду Google в этой части. Ещё один аргумент в пользу исключений в C++ вы найдёте ниже.

Строгий запрет

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

Для GCC и clang это опция -fno-exceptions. Её использование не значит, что исключений не возникнет. В частности, никто не может избавить вас от std::bad_alloc и других исключений, выбрасываемых из библиотек. Однако чаще всего исключение будет приводить к вызову std::terminate.

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

Назад в будущее

У исключений есть альтернатива, известная ещё корифеям, — возврат флага или ошибки из функции. Если вы пишете процедуру, то проще всего вернуть флаг успеха:

bool DoSomeOperation() {
    if (!CanDoOperation()) 
        return false;
    DoUnchecked();
    return true;
}

Чтобы компилятор проверял, что вызывающая функция точно обрабатывает ошибку, добавим [[nodiscard]]:

[[nodiscard]] bool DoSomeOperation() …

Подобного сервиса — проверки, что ошибка обрабатывается, — нет даже у исключений. Если булевого флага недостаточно, функция может возвращать объект ошибки, содержащий информацию о её причинах. В этом случае [[nodiscard]] можно поставить прямо в класс:

struct [[nodiscard]] ErrorInfo {
    // Операция вернёт false, если ошибки не было.
    operator bool() const {
        return error_code != 0;
    }

    std::string what() const;
    int error_code = 0;
}

ErrorInfo DoOperation();

int main() {
    auto err = DoOperation();
    if (err) {
        std::cout << err.what() << std::endl;
        return EXIT_FAILURE;
    }
}

Когда функция должна сама по себе вернуть значение, такой способ не подходит. Решение есть — специальный тип std::expected, но он будет добавлен в язык только в 2023 году. Предполагается, что этот объект будет хранить либо возвращённое значение, либо код ошибки, когда операция не удалась.

Универсальный солдат

Если ждать до 2023-го не хочется, можно реализовать expected самостоятельно или использовать простую замену: передать функции объект ошибки по ссылке, предлагая записать в него информацию.

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

  • std::filesystem. Функции работы с файловой системой часто могут завершаться с ошибкой. Например, вы хотите переименовать файл, но его только что удалили. В этом и подобных случаях выбрасывается std::filesystem::filesystem_error, подкласс std::system_error, который в свою очередь расширяет подкласс std::runtime_error и std::exception.
    Но если вы указали дополнительный параметр типа std::error_code&, то исключение не будет выброшено — вместо этого ошибка запишется в переданный объект:

    std::error_code ec;
    std::filesystem::rename(old_path, new_path, ec);
    if (!ec) {
        std::cerr << "Ошибка переименования" << std::endl;
    }
  • Потоки ввода-вывода. Для них используется немного другой механизм. По умолчанию потоки не выбрасывают, и вы вынуждены проверять все операции на корректность.
    Если вызвать метод потока exceptions, то можно попросить поток сигнализировать при появлении флагов ошибок. Будет выбрасываться исключение std::ios_base::failure (также подвид std::system_error). У метода есть эффект, даже если он вызван уже после возникновения ошибки:

    #include <iostream> 
    #include <fstream> 
     
    int main()
    {
        std::ifstream f("abracadabra");
     
        try {
            f.exceptions(f.failbit);
        }
        catch (const std::ios_base::failure& e) {
            std::cout << "Ошибка потока: " << e.what() << '\n'
                      << "Код: " << e.code() << '\n';
        }
    }

    Передавать в метод exceptions флаг eofbit не рекомендуется. Достижение конца файла — штатная, а не исключительная ситуация. Последующие чтения всё равно установят флаг fail.

А что со скоростью?

Бытует мнение, что исключения — это всегда медленно. Ведь у всех этих конструкций try наверняка большой оверхед. Даже если try-блоков нет, компилятор всё равно должен быть в любой момент готов обработать исключение при вызове любой функции, не помеченной как noexcept.

Проверим, так ли это. Лучший способ — бенчмарк. Будем измерять две функции, выполняющие похожую работу:

  • void f()  — не помечена как noexcept. Рекурсивно вызывает себя. После 10 миллионов вызовов выбрасывает исключение.
  • bool g()  — помечена как noexcept. Рекурсивно вызывает себя. После 10 миллионов вызовов возвращает false, и цепочка вызовов прерывается.

Результат во многом зависит от компилятора, оптимизаций и наличия у функций атрибута noinline. Но в большинстве случаев версия с исключениями по крайней мере не хуже. Вот бенчмарк, где версия с исключениями работает в полтора раза быстрее. При использовании clang преимущество скромнее — всего 10%.

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

Сравним дизассемблер. Для наглядности добавим больше рекурсивных вызовов в каждую функцию. Так выглядит вариант с исключениями:

Иллюстрация: исключения c++

Каждый рекурсивный вызов превратился в одну инструкцию call, последний — в jmp

А вот начало функции с проверками. Не будем приводить код полностью:

Каждый рекурсивный вызов превратился в три инструкции — call, test и условный переход

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

Профессионалы советуют: везде, где можно, ставьте noexcept. Иначе производительность может сильно пострадать. Это причина, почему многие крупные компании вообще не используют исключения.

Как работают исключения

Исключения в компиляторах можно реализовать по-разному. Реализации стремятся к нулевому оверхеду, который формулируется так:

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

Современные компиляторы (а вернее, генераторы кода) в полной мере этому принципу не следуют, в чём можно убедиться, изучив ассемблерный код, который они выдают. Но возникающий оверхед минимальный, а где-то отсутствует вовсе. Подумаем, что обязан делать процессор для того, чтобы обеспечить возможность исключений.

Посадочные площадки

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

std::string s = "Имя гостя номер " + std::to_string(N) + " - " + guest_names.at(N);
std::cout << s << std::endl;

Они могут прерваться в девяти местах:

  • два раза — при конвертации const char* в std::string; возможное исключение bad_alloc;
  • три раза — при сложении строк; возможное исключение bad_alloc;
  • один раз — в std::to_string; возможное исключение bad_alloc;
  • один раз — в std::vector::at; возможное исключение std::out_of_range;
  • два раза — в std::ostream::operator <<; возможное исключение std::ios_base::failure. Даже если мы не устанавливали для потока флаг выброса исключений, компилятор всё равно должен быть готов к такому повороту событий — операция << не помечена как noexcept.

Для этих девяти мест компилятор должен предусмотреть пути отхода. А именно установить, какие временные объекты он должен удалить в каждом случае и что делать дальше. Для этого в бинарном коде программы создаётся посадочная площадка (landing pad). Процессор приземляется на ней только в случае исключения.

Вызов функции обычно превращается в процессорную инструкцию call. При этом в стек кладётся адрес места, куда нужно вернуться после инструкции ret, завершающей вызов. Если вызываемая функция помечена как noexcept, то этого достаточно. Если нет, процессору нужно знать, куда идти, если в процессе выполнения вызова произошло непойманное исключение. Он идёт на посадочную площадку.

Таким образом для возможности обработки исключений каждый вызов требует максимум одну дополнительную инструкцию — запоминание адреса экстренной посадочной площадки. Сущая мелочь.

Выбор обработчика

Мы так увлеклись оверхедом, что совсем забыли: исключения нужны, чтобы их ловить. А ловят их обработчики. Чтобы исключение нашло свой обработчик, при выполнении raise нужно сохранить информацию о типе. Причём эта информация будет обрабатываться во время выполнения программы (run-time).

В C++ для этого есть свой механизм — динамическая идентификация типа данных, или RTTI. Чтобы мы могли поймать исключение по базовому типу, в run-time должна быть доступна информация обо всех предках выброшенного исключения.

Таким образом, raise делает следующее:

  • сохраняет объект исключения;
  • сохраняет информацию о типе;
  • возможно, сохраняет что-то ещё (например, ссылку на деструктор объекта исключения);
  • если надо, запускает terminate;
  • если не надо, запускает раскрутку стека.

Раскрутка стека делает следующее:

  • приземляется на посадочную площадку;
  • удаляет временные и автоматические объекты;
  • проверяет все доступные обработчики на соответствие типа;
  • если надо, запускает terminate;
  • если не надо (и обработчик не найден), продолжает раскрутку стека.

Обработчик делает следующее:

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

И снова бенчмарк

Чтобы проверить, что информация о типе действительно обрабатывается в run-time, проведём ещё один любопытный бенчмарк. Создадим тип F очень простого вида:

struct F{};

А также шаблон template<int> struct G. Его особенность — в том, что у типа G<0> очень много потомков, 2048 штук. В функции f всё просто: будем бросать исключение типа F и затем ловить исключение типа F. В функции g будем бросать исключение типа G<0>, а ловить по типу одного из его многочисленных потомков — G<1111>.

Если наше предположение верно, то программе потребуется гораздо меньше времени для поиска обработчика исключения типа F. Напротив, чтобы убедиться, что обработчик типа G<1111> соответствует выброшенному исключению типа G<0>, придётся потрудиться.

Запустим бенчмарк и убедимся в своей правоте:

Иллюстрация: исключения cpp

Слева обработка исключения типа G<0>, справа — типа F

Поимка объекта по глубокому производному классу заняла в 32 раза больше времени. Таким образом мы убедились, что проверка исключения на соответствие типу действительно происходит в run-time.

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

Заключение

Мы проделали большую работу. Разобрались в тонкостях исключений в C++, гарантиях, спецификациях. Узнали, что, вопреки распространённому мнению, исключения — не всегда медленно.

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

Некоторые компании избегают исключений в своём коде. Но только вам решать, отказываться от них или нет. При умелом использовании исключения не принесут вреда, а польза может быть огромна. В одном можете быть уверены: после прочтения этой статьи умелое использование исключений — в ваших руках.

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