Исключения

Материал из SEWiki
Перейти к: навигация, поиск

Обработка ошибок

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

Обработка кода возврата

Из каждой функции возвращаем код, в котором содержится дополнительная информация о результате работы функции. Программист функции задает семантику этого кода. Одним из его значений должен быть "успех". Результат функции возвращается через параметр, передаваемый по ссылке. Такой подход увеличивает колличество кода. Нужно проверять код возврата. В зависимости от проекта, этот код может понадобиться проверять каждый раз после вызова функции. Также пользователь функции должен знать семантику кода возврата. Для кода возврата можно не создавать отдельную переменную в сигнатуре функции. Можно возвращать значение, которое не входит в множество допустимых значений. Например, результат функции всегда положительный, но мы возвратим отрицательное число, если что-то пошло не так. Тогда вызов будет короче, но мы совместим ответственности - результат будет отвечает и за ошибки. Если множество значений функции в перспективе может расшириться, то этот способ не подходит.

Глобальная переменная errno

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

SEH, Signals

Платформенно зависимые исключения и сигналы от ОС. Например деление на 0, извлечение квадратного корня из отрицательного числа, разъименование нулевого указателя, порча стека и т.п. Обычно говорят о том, что дальнейшее выполнение программы не имеет смысла, потому часто явно не обрабатываются в программах. 24/7/365 сервисы обычно требуют обработки таких исключений. Это отдельная большая тема.

Assert

Это утверждения, логические условия, истинность которых проверяется либо во время выполнения, либо во время компиляции (static assert). Если статическое утверждение во время компиляции ложно, то возникает ошибка компиляции. Если во время вполнения утверждение ложно, то в стандартный поток ошибок выводится текст этого утверждения и программа завершается. Assert'ы отключаются, если при компиляции определен символ NDEBUG.

Пример:

#include <cassert>
assert(1 + 1 == 2 && "working with field which charcteristic > 2");

Строка с комментарием к assert'у "working with ..." - это ненулевой char const *, потому он всегда True и не влияет на результат логического И.

Исключения

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

Работа с исключениями состоит из следующих этапов:

  • Выполнено условие, при котором стоит сообщить внешнему коду о произошедшей ошибке.
  • Бросается исключение.
  • Исключение всплывает по стеку вызовов программы до первой точки, в которой это исключение может быть обработано.
  • В обработчике исключения можно выполнить все действия для восстановления программы и продолжить ее выполнение или если это не возможно в данном месте кода, бросить новое исключение дальше по стеку.
  • Если исключение не было обработано и всплыло выше функции main, то вызывается обработчик unexpected, который по умолчанию завершает выполнение программы.

Некоторые моменты:

  • Если исключение поймано (обработано), то вызовутся деструкторы всех объектов, которые лежали на стеке от места бросания до места обработки.
  • Если мы выделим память в куче, а затем кинем исключение, то выделенная память не будет освобождена (деструктор указателя ничего не делает).
  • Реальный механизм работы исключений и его накладные расходы зависит от разрядности процессора и ОС.

Рассмотрим базовый синтаксис работы с исключениям ив C++:

	try
	{
		if (a == 0)
			throw 0;
		int res = 10/a;
	}
	catch(int ex)
	{
		if (ex == 0)
			std::cerr << "division by zero" << std::endl;
	}
	catch(float ex)
	{
		if (ex >= 0)
			std::cerr << "float" << std::endl;
	}


  • Оператор try {} показывает границы блока кода, в котором мы хотим ловить исключения. Исключения будут ловиться не только в самом блоке,

но и ниже по стеку вызовов - во всех функциях, вызванных из блока try.

  • Оператор catch(...){} определяет код-обработчик исключения, который выполняется в случае если оно было кинуто в try. Аргумент ... означает что будут ловиться все исключения.

Если мы хотим перехватить исключение какого-то конкретного типа, то в аргументе catch объявляют переменную этого типа. Для классов и структур используется константная ссылка.

  • Оператор throw принимает экземпляр типа, который мы хотим кинуть.

Давайте поймем почему код, который бросает примитивные типы плох? В обработчике catch нам приходится проверять какое целое значение было кинуто, чтобы понять что с ним делать. Мы можем определить отдельный класс для каждого исключения и бросать уже объекты этих классов. Это избавит нас от необходимости проверять еще какую-то информацию, неявно передаваемую в кидаемом экземпляре примитивного типа. Для удобства в стандартной библиотеки stl уже есть класс исключений std::exception, объявленный в файле <exception>. Для поддержания единообразности кода на C++, следует наследовать свои классы исключений от него. Также у std::exception есть виртуальный метод what(), который удобно использовать для возвращения информации об исключении в удобном для прочитывания человеком формате. Также стандартная библиотека определяет некоторые другие классы, например std::logic_error, std::runtime_error, std::bad_alloc.

Нужно помнить, что при выборе блока catch, который соответствует кинутому исключению, приведение типов не происходит. Одновременно может быть активно только одно исключение, поэтому если во время всплытия исключения по стеку до того как исключение найдет свой обработчик, кинется еще одно исключение, программа полностью закончит свое выполнение. Теперь давайте вспомним какой код выподняется во время всплытия исключения - происходят вызовы всех деструкторов. Если хотябы один деструктор кинет исключение, то программа полностью завершится. Поэтому деструкторы не должны кидать исключений. Теперь представим себе что будет, если кинуть исключение в конструкторе - до завершения конструктора объект не считается созданным, потому в случае падения исключения в конструкторе, деструктор создаваемого объекта не будет вызван. То есть если мы уже выделили какой-то ресурс в конструкторе, а потом в нем возникло исключение, то ресурс не будут освобожден. Поэтому надо писать конструкторы так, чтобы при возникновении в нем исключения, можно было освободить занятые ресурсы и сообщить об исключении дальше. Напомним также, что при нормальной работе программы оператор delete не кидает исключений, а оператор new может. Напишем класс, который освободит выделенную память если произошло исключение в конструкторе. Здесь же используется специальный синтаксис для перехвата исключений в списке инициализации.

struct A
{
	int* p1;
	int* p2;

	A(size_t p1_sz, size_t p2_sz) 
		try
		:	p1(new int[p1_sz])
		,	p2(0)
	{
		try
		{
			p2 = new int[p2_sz];
		}
		catch(...)
		{
			delete[] p1; //only p1 allocated
			throw;
		}
	}
	catch(...)
	{
		//no memory allocated
		throw;
	}

	~A()
	{
		delete[] p1;
		delete[] p2;
	}
};

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

	if (a + b == c + d)
	{
		return a * b;
	}

Подсказка: учтите что вызываются операторы приведения типов, +, *, ==.

Гарантии, которые может предоставлять код по отношению к исключениям

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

  • Гарантия отсутствия исключений. Такая гарантия специфицирует, что либо ошибок нет, либо все ошибки обрабатываются и объект выполняет все требуемые функции.
  • Строгая гарантия исключений (транзакционность). Объект после обработки ошибки возвращается в состояние перед вызовом метода, в котором она возникла. Если мы выполняем ввод\вывод, то гарантировать строгую гарантию невозможно без очень сложных мероприятий.
  • Базовая гарантия. После обработки исключения оказываемся в любом другом согласованном состоянии.

Напишем неполный класс массива, который реализует строгую гарантию исключений при копировании через конструктор.

template<typename T>
struct Array
{
	Array(size_t size)
		:	size_(size)
		,	data_(new T[size])
	{}

	Array(Array const & src)
	{
		T* old_data = data_;
		size_t old_size = size_;
		try
		{
			data_ = new T[src.size_];
			size_ = src.size_;
			for(size_t i = 0; i < size_; ++i)
				data_[i] = src.data_[i];
		}
		catch(...)
		{
			delete [] data_;
			data_ = old_data;
			size_ = old_size;
		}
	}

private:
	T* data_;
	size_t size_;
};