Исключения, гарантии безопасности исключений, RAII


Как можно возвращать ошибку? Можно это делать с помощью error-кодов (так делали в C):

// int — код ошибки.
int do_something() {
	FILE* file = fopen("1.txt");
	if (!file)
		return FILE_NOT_FOUND_CODE; // Какая-то константа.
	int err = file_interact(file);  // Некоторая функция, которая может завершиться с ошибкой.
	if (err)
		return err;
	// Ещё 100 функций, после каждой из которых проверяем наличие ошибки и возвращаем её, если есть.
	return 0;
}

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

Исключения в C++ пишутся так:

try {
	// Делаем что-то, что может завершиться с ошибкой.
	// ...
	// ...
	// Завершиться с ошибкой --- это вот так:
	throw something;
	// ...
	// ...
} catch (error_type const& e) { // Что делать, если ошибка.
	// Обрабатываем ошибку.
} catch (another_error_type const& e) {
	// Обрабатываем другую ошибку.
}

Что происходит при бросании исключения:

  • Создаётся копия объекта, переданного в throw. Копия будет существовать, пока исключение не будет обработано.
  • У всех проинициализированных локальных переменных вызываются деструкторы (если эти деструкторы тоже кидают исключение — происходит std::terminate).
  • Прерывается исполнение программы.
  • Выполняется раскрутка стека, пока исключение не будет обработано (поймано), вызываются деструкторы в правильном порядке. Каким конкретно образом — смотри далее.

Обработано — это когда исключение происходит в блоке try, и тогда, в зависимости от типа исключения, происходит переход в соответствующий блок catch. Если такового нет, то try-catch блок игнорируется, и исключение летит дальше вверх по стеку. Ещё есть особый блок catch, который выглядит как catch (...).

Исключения и наследование.

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

struct base {
	virtual std::string msg() const {
		return "base";
	}
}
struct derived : base {
	std::string msg() const override {
		return "derived";
	}
}
int main() {
	try {
		throw derived();
	} catch (base const& e) {
		std::cout << e.msg() << std::endl;
	}
}

Вообще кидать вы можете всё что угодно — любой копируемый тип. Но обычно кидают std::exception или его наследников.

Исключения и виртуальное наследование.

catch работает тогда, когда можно однозначно определить подобъект. В частности такой код:

struct A {};
struct B : A {};
struct C : A {};
struct D : B, C {};

int main() {
	try {
		throw D();
	} catch (A const&) {
		std::cout << "Caught A.\n".
	}
}

не поймает исключение, если B и C отнаследованы не виртуально.

catch (...) и throw;.

Ещё есть специальный тип catch'а — catch (...), который ловит вообще всё что угодно. Но, как в известном анекдоте, есть один нюанс: catch (...) ловит необязательно исключения C++. В Windows, например, исключения C++ — это частный случай исключений, встроенных в Windows. И эти встроенные исключения также ловятся при помощи catch (...), что вы хотите явно не всегда. Системные исключения вообще не надо ловить, если взять POSIX'ивое исключение abi::__forced_unwind и проглотить его, то может быть всё очень плохо. Ещё более интересный момент есть в GCC. Там исключения поддерживаются не только плюсовые, а и другие разные, которые работают не так. Куча языков реализуют исключения так, что catch может либо прервать, либо продолжить работу. В C++ такого нет, но ABI такое поддерживает. И такое уж точно вы не хотите ловить.

Но вообще catch (...) имеет идиоматическое использование:

catch (...) {
	// Что-то почистим.
	throw;
}

throw; просто берёт то, что поймал и кидает его же. Без копирования. Поэтому подобный синтаксис даже в обычных catch бывает полезен, он лучше, чем

catch (const type& e) {
	throw e;
}

потому что во втором варианте есть лишнее копирование или даже slicing.

Best practices и worst practices.

Совет — всегда ловите исключение по константной ссылке. Если Вы, конечно, не хотите исключение менять, тогда просто по ссылке.

Ещё не кидайте указатель на исключение (то есть не пишите throw new error_type(...);). Если вы так сделаете, ловить вам надо будет catch (error_type*), и тогда надо не забыть сделать delete. И понятно, что нельзя делать catch (...), потому что тут вы никак не сделаете delete.

Также не надо ловить копию. Потому что когда вы вместо catch (const error_type& e) вы пишете catch (error_type e), тоже происходит копирование/slicing.

Цена исключений.

Если открыть что-то в интернете, вам скажут, что исключения — это бесконечно дорого. Чтобы говорить об этом серьёзно, то сначала надо понять, какую операцию мы измеряем по времени.

Например, как работает try? Есть две стратегии реализации.

  1. Поддерживать связный список на стеке, по которому в случае чего мы и идём.
    Проблема тут в том, что каждый вход в try (даже если исключения не происходит) жрёт время и память. А исключения у вас должны происходить редко. Поэтому сейчас эту реализацию не используют.
  2. Zero-cost исключения. Компилятор для каждой инструкции записывает, куда идти в случае исключения.
    Тогда вход в блок try не генерирует абсолютно никакой код. И тут у нас при throw никакого оверхеда от исключений почти нет. Почему «почти»? Потому что в общем случае код может быть не совсем одним и тем же. Если мы делаем какие-то операции с памятью, например, мы должны видеть все эффекты до throw и не видеть ни одного после. Поэтому компилятор может меньше переупорядочивать код в таком случае.

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

А что происходит, когда исключение всё-таки брошено?

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

Как раскручивается стэк? Используется unwinder — раскручиватель стека (можно в любой момент остановить программу и спросить у него: если я сейчас сделаю return, то где я окажусь?).

Так вот, unwinder работает медленно.

Причины:

  • Lock contention
    Unwinder работает так: он смотрит специальные таблички, где написано, как раскручивать стек. И ему нужно смотреть в разные .so (shared-object) файлы (потому что функции, которые друг друга вызывают, могут быть в разных файлах). А эти файлы могут динамически загружаться-выгружаться. И разным потокам в многопоточной программе нужен доступ к списку загруженных .so. И они все обращаются к этому списку. В общем, проблема в том, что если у нас в нескольких потоках летят исключения, то это плохо параллелится и возникают зависимости между потоками.

  • Ограниченная память
    Сложность разработки unwinder'а в том, что он почти не может выделять память, потому что любое выделение может вызвать исключение. То есть он не может использовать почти никаких структур данных. А ещё, информация про то, как расручивать стэк, которая хранится в файле, лежит не в том виде, которым удобно пользоваться, а в том, который занимает мало места.

  • Плюсовая поддержка исключений основана на языконезависимой поддержке (Itanium ABI).
    A Itanium ABI поддерживает такую фичу как resumable исключения. Как это работает? У нас вылетело исключение, мы его поймали, а потом можем продолжить с той точки, откуда его выкинули. C++ такой фичи не имеет, но в реализации это всё равно есть. Это тоже лишняя сложность — на самом деле стэк раскручивается дважды: в первый раз для того, чтобы найти catch, в который мы попали, и узнать, resume'ящийся он или нет, а во второй раз для того, чтобы повызывать деструкторы.

noexcept. std::terminate.

Что будет, если кинуть исключение из деструктора при обработке исключения? То есть мы получили исключение, вызываем деструкторы, и получаем исключение там.

По стандарту, это ошибка в программе: считается, что в ситуации, когда вы получили 2 исключения, происходит что-то невообразимо ужасное, и в программе вызывается std::terminate — экстренное завершение программы. Более простой способ получить std::terminate — выкинуть исключение в main.

Кстати, поскольку деструкторы не должны бросать исключение, такие функции как free явно в языке помечены как неспособные бросать исключения. Чтобы пометить так функцию, есть модификатор noexcept Бросание исключений из noexcept таких функций также приводит к std::terminate. Ещё помимо noexcept можно указать noexcept(/* булевое выражение */). Это нужно, если вы шаблонный код пишете, и у вас функция может в зависимости от шаблона либо быть noexcept, либо нет.

И вот что важно знать — деструкторы по умолчанию noexcept. Если вам нужно это исправить — noexcept(false). Тогда при обычном бросании исключения в деструкторе всё будет хорошо, а при двойном исключении — всё ещё std::terminate.

Ошибки и аллокация памяти.

operator new и operator delete - работают как malloc и free (выделяют сырую память), но бросают исключения (std::bad_alloc), а не возвращают nullptr.

Несмотря на то, что operator new отличается от malloc только тем, что исключение кидает, вам всё равно не позволяют выделять память при помощи operator new, а освобождать при помощи free (или наоборот).

RAII.

Давайте зададим себе такой вопрос: вот есть у нас C-шный код, где мы открываем несколько файлов. Как он выглядит? Ну, вот так:

	res = 0;
	FILE* f1 = fopen("1.txt", "r");
	if (f1 == NULL)
		return -1;
	FILE* f2 = fopen("2.txt", "r");
	if (f2 == NULL) {
		res = -1;
		goto close_f1;
	}
	FILE* f3 = fopen("3.txt", "r");
	if (f3 == NULL) {
		res = -1;
		goto close_f2_and_f1;
	}

	// ...
	if (error_while_reading) {
		res = -1;
		goto close_all;
	}
	// ...

close_all:
	fclose(f3);
close_f2_and_f1:
	fclose(f2);
close_f1:
	fclose(f1);
	return res;

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

	int* p = new int(1);
	int* q = new int(2);
	// ...
	delete p;
	delete q;

Тут есть фундаментальная проблема, что в случае, если new int(2) кинет исключение, мы не освободим p. Хм-м-м, ну, у нас же была та же проблема с файлами в C, а std::istream её решил. Каким образом? Наличием деструктора, где освобождается ресурс. Значит надо сделать класс, который будет работать как указатель, но иметь деструктор, освобождающий память.

Когда класс при создании выделяет какой-то ресурс, а при удалении — освобождает, говорят, что он удовлетворяет идиоме RAII (resource acquisition is initialization). И в стандартной библиотеке всё по этой идиоме и работает.

Хотя вообще данная идиома не очень хорошо названа, потому что выделять-то мы не обязаны в конструкторе, мы можем вызвать std::istream::open, чтобы не при конструкторе файл открыть, а потом. Но в деструкторе мы всё равно закроем файл, если открывали.

std::unique_ptr.

Выше мы анонсировали класс, который похож на указатель, но работает по RAII. И в стандартной библиотеке есть несколько таких, в зависимости от того, что нужно делать при копировании.

Его мы почти полностью можем и сами написать, так что давайте это сделаем:

template <class T> // Сюда смотреть пока рано.
class unique_ptr {
private:
	T* ptr;

public:
	unique_ptr() : ptr(nullptr) {}
	unique_ptr(T* ptr) : ptr(ptr) {}
	unique_ptr(const unique_ptr&) = delete;
	unique_ptr& operator=(const unique_ptr&) & = delete;

	~unique_ptr() {
		delete ptr;
	}

	T& operator*() {
		return *ptr;
	}
	T* operator->() {
		return ptr;
	}
	T& get() {
		return *ptr;
	}

	void reset(T* new_ptr = nullptr) {
		delete ptr;
		ptr = new_ptr;
	}
	T* release() {
		T* result = ptr;
		ptr = nullptr;
		return result
	}
};

Ещё должна быть внешняя функция make_unique, которая выделяет новый unique_ptr, но её мы как раз реализовать не можем, нам variadic-шаблонов не хватает.

Вообще функции типа release использовать опасно, потому что они превращают RAII'шную обёртку над указателем в сырой указатель, и в таком случае вы должны сами следить, чтобы в момент исключения он не оказался сырым.

RAII вокруг объекта, кидающего исключение при закрытии.

Давайте теперь рассмотрим POSIX, в котором мы файлы открываем.

struct file_descriptor {
public:
	file_descriptor(const char* filename)
		: fd(open(filename, O_RDONLY)) {}

	~file_descriptor() {
		close(fd);
	}

private:
	int fd;
};

В чём тут проблема? В том, что на man написано, что close имеет возвращаемое значение, в нём может произойти ошибка. Это связано с тем, что close выполняет две операции — дозаписывает данные до конца, чтобы закрыть файл и возвращает файловый дескриптор операционной системе. Поэтому в идеальном мире мы бы разбили его на две — какой-нибудь flush, который дозаписывает данные и именно что close. И примерно такого поведения мы и хотим, а кидать исключение в случае провала close не хотим, потому что двойное исключение.

Поэтому давайте напишем всё вот так:

struct file_descriptor {
public:
	file_descriptor(const char* filename)
		: fd(open(filename, O_RDONLY)) {}

	void flush() {
		int result = close(fd);
		if (result != 0)
			throw std::runtime_exception("File closing failed.");
		fd = -1;
	}

	~file_descriptor() {
		if (fd != -1)
			close(fd);
	}

private:
	int fd;
};

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

Гарантии исключений.

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

struct string {
	// ...
	string& operator=(const string&) & {
		if (this == &other)
			return;

		operator delete(data);

		size = other.size;
		capacity = other.capacity;
		data = (char*)operator new(size + 1);

		memcpy(data, other.data, size + 1);

		return *this;
	}
};

Итак, правильный ответ:
Если в operator new произойдёт исключение, строка останется в некорректном состоянии. Причём настолько некорректном, что на ней даже деструктор вызвать нельзя: будет double-free.

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

  1. Nothrow — гарантируется, что исключение из данного метода выброшено быть не может.
  2. Strong — в случае исключения состояние объекта будет сброшено на то, которое было до вызова метода, откуда было брошено исключение.
  3. Basic — в случае исключения объект останется в корректном, но неопределённом состоянии. Т.е. все инварианты останутся верными и не произойдёт утечка ресурсов, но никакое конкретное состояние не гарантируется.
  4. No guarantee — не exception-safe, ошибка в проектировании программы.

Давайте немного пофантазируем на тему std::vector. Какие его методы каким гарантиям удовлетворяют? Понятно, какие-нибудь size, capacity, empty или begin и end не бросают исключений, с чего бы им. А вот что можно сказать про pop_back? Ну, вроде как кидать там нечего, но ведь деструктор элемента может теоретически и бросать... А вот нет — все стандартные контейнеры накладывают некоторые условия на принимаемые ими типы. И абсолютно каждый требует nothrow-деструктора. Хорошо, то есть pop_back — это nothrow. А что будет, если мы удалим элемент из пустого вектора? А undefined behaviour. То есть мы можем спокойно вернуть *nullptr и ничего не бросать в таком случае. Поэтому если нам хочется, мы вполне можем написать vector, который имеет noexcept-pop_back.

Про push_back, например, ясно, что она может спокойно давать строгие гарантии. А какие функции дают базовые? insert и erase. Вообще их можно сделать так, чтобы гарантии были строгими, но они базовые, чтобы можно было их выполнять через swap'ы. Обычно swap'ы не бросают исключение, но вообще могут делать это. Поэтому тут мы по сути получаем, что гарантия методов контейнера зависит от того, какой тип ему дали: есть swap не бросает, то гарантии у нас строгие, а иначе — базовые.

Swap-trick.

Хорошая идея — писать оператор присваивания через swap. Это даёт нам строгие гарантии, и не заставляет думать:

my_string& my_string::operator=(my_string copy) {
//        Внимание!            ^^ копия ^^
     std::swap(*this, copy);
     return *this;
}

Здесь при передаче вызовется конструктор копирования строки, а потом мы сделаем swap.

Но это не единственное, чему можно легко дать строгие гарантии, если у нас не бросающий swap. Пусть у нас есть std::vector, у которого есть erase, дающий базовую гарантию. Но следите за руками:

void erase_strong(vector& v, iterator where) {
	vector copy = v;
	copy.erase(where);
	copy.swap(v);
}

И лёгким движением руки базовая гарантия превращается в строгую.