Обработка исключительных ситуаций (Exceptions).

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

Пример на основе функции fopen():


FILE *f=...
f=fopen("...","r"); // - пытаемся открыть файл на чтение.
При вызове fopen() файл мог не открыться по многим причинам, например:
Можно проверить корректность переменной f следующим образом:

if(f==0) {
	... //что-то сделать.
}
На самом деле fopen() записывает в глобальную переменную errno так называемый код ошибки. В этом случае при обработке ошибки с помощью if(f==0) нужно писать так:

if(f==0) {
	switch(errno) {
		...
	}
}

    Поскольку переменная errno является глобальной, то существует ряд проблем с этим связанных:
  1. К одной и той же переменной обращается много разных функций.
  2. В случае многопоточности эта переменная может быть изменена другим потоком.
  3. Если происходит большое количество действий, требующих обработку ошибок, то после каждого нужно проверить значение errno.

Способы получения информации об ошибке в С.

Обе эти техники были продемонстрированы на примере функции fopen().

    Другие примеры.

  1. XML_Parser (библиотека expat)
    Функция XML_Parse() возвращает код ошибки (XML_ERROR_NONE, ...).
    
     if(XML_Parse(...) != XML_ERROR_NONE) {
     	...
     }
     
    Даже авторы библиотеки expat в своих примерах проверяют лишь наличие ошибки, а не её тип. Зачастую проверка не делается вовсе.
  2. sscanf
    функция sscanf() вернёт количество успешно прочитанных символов.
  3. atoi
    atoi("42") вернёт это число или 0, если произошла ошибка. В этом случае мы не сможем отличить ошибку от простого значения 0.

Ситуации, при которых полезно знать об ошибке и как-то ее обрабатывать.

Рассмотрим сложную функцию,возвращающую некоторый результат.
Например, функцию, которая решает уравнение и возвращает его корень:


double solve_equation(...) {
	//действия с параметрами
	x = a / b; 
}
В момент деления a на b могло произойти деление на 0, поэтому вставим проверку:

double solve_equation(...) {
	//действия с параметрами
	if(b==0) {
		...
	}
	x = a / b;
	
}
Непонятно, что нужно вернуть в случае b == 0, и более того, если деление происходит много раз, то эту проверку придется проводить во всех случаях.
Если бы деление можно было заменить на корректно работающую в выше описанной ситуации функцию divide(a,b), это было бы приемлемым вариантом. Однако это не избавило бы нас от проблемы с возвращаемым значением. Эта ситуация является ощутимой проблемой для программиста.

Идея решения обработки ошибок в С++.

Как было описано в предыдущем примере, заменим стандартный оператор "/" на функцию divide(a,b), чтобы избежать деления на 0:

double divide(a, b) {	
	if(b==0) {
		 "Случилась исключительная ситуация!"
		 // В этом месте необходимо сообщить, что случилось что-то непредвиденное, иными словами "бросить" exception.
	}
	return a/b;
}
В случае, когда кидаем Exception, происходит аварийный выход, т.е. дальше программа не выполняется.
Note: Т.к. divide вызвана в функции solve_equation, то та тоже прекратит свою работу и так по иерархии вызовов вплоть до функции main
Для аварийного выхода из программы так же существует функция exit(), однако когда мы "бросаем" exeption, в дальнейшем его можно будет "поймать".
Пример:
main

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

В качестве исключения можно передать всё что угодно, любую информацию. На самом деле, с помощью Exception'ов мы решили еще одну проблему:
если функция divide() совершила аварийную отсановку, мы можем узнать об ошибке и обработать её в main, т.е. на 2 или несколько уровней выше (по иерархии вызовов).
Возможность обработки ошибок в любом месте программы является очень важным свойством, т.к. разные куски кода зачастую пишут разные люди и в разное время. Например, функцию для решения уравнений и графический интерфейс для работы с ней как правило пишет не один и тот же программист. Если при решении уравнения случилась ошибка,то автор может не знать, что с ней делать. Тогда в этом месте достаточно "кинуть" Exception, а его обработкой и сообщением пользователю об ошибке будет заниматься человек, использующий эту функцию.


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

Использование Exception'ов является "сильнодействующим средством" для больших программ, поэтому не следует применять их без особой необходимости.

Как оформлять работу с Exception'ами в коде.

Рассмотрим функцию divide с использованием исключений:

double divide(a, b) {
	if(b==0) {
		throw std::string("Случилось страшное!");
	}
	return a/b;
}
После throw выполняется развёртка стека, т.е. последовательный выход из всех функций.
Пусть в функции solve_equation вначале был создан некий объект:

double solve_equation(...) {
	Complex c;
	Array array;
	...
	return x;
	// здесь был бы вызван деструктор
}
В момент, когда мы "бросаем" exception, происходит выход из функции и для всех объектов, созданых на стеке, будет вызван деструктор. Работа функции завершится корректно.
Пример кода, который ловит исключения:

 main
 try {
 	solve_equation() // пытаемся выполнить кусочек кода, если происходит исключение, то ловим его
 } 
 catch( //что ловить// std::string &ex) {
 	...//что-то сделать с "пойманной" строчкой
}
 
Comment 1:У одного try может быть несколько обработок catch.
Comment 2:О том, что случится, если исключение произойдёт в деструкторе, будет рассказано в следующей лекции.

Проблемы, которые возникают из-за exception'ов.

Код, который мы привели выше, пока достаточно плох, т.к. бросить можно всё, что угодно.
    Например:
  • строчку
  • число
  • свой объект и т.д.

  • Если программист захочет использовать несколько библиотек, то он должен ловить все виды exception'ов и знать какие типы приходят и от кого.
    Что касается бросания строк, то здесь возникает еще одна проблема. А именно: если бросили строчку, а мы ловили int, то исключение не будет поймано и полетит дальше. А если ловили std::string, а брошено было "Случилось страшное!" – тоже не будет поймано, т.к. это уже const char *.
    Во избежание таких ситуации существует специальный класс в стандартной библиотеке: класс std::exception. Рекомендуется использовать только его.
    В std::exception находится один виртуальный метод:
    
     virtual const char *what(); // отвечает на вопрос "что летит?"
     
    От класса std::exception можно отнаследоваться и переопределить этот метод. В случае необходимости можно добавить и свои методы.
    Итак, если мы знаем, что ловить, то ловим, а если нет, то воспользовавшись полиморфизмом, будем ловить всё, что является std::exception.
    
     catch(std::exception &e) {
     	// что-то сделать.
     }
     
    Если в этом месте программы мы не знаем как правильео обработать данную ошибку или не смогли жто сделать, то можно перебросить exception дальше:
    
     catch(std::exception &e) {
     	// пытаемся обработать ошибку.
    	throw e; // если не получилось обработать, то бросить дальше "rethrow". 
     }
    Существует более короткая запись "переброски" exception'а:
    
     catch(std::exception &e) {
    	throw;
     }
    Именно такая запись испальзуется в следующем случае:
    При работе с exception'ами есть способ ловить всё, что летит, независимо от типа: catch(...){//что-то сделать//}, но в этом случае у нас нет переменной, с которой можно работать, тогда можно бросить exception дальше без непосредственного обращения к нему.
    
     catch(...) {
     	//что-то сделать.
    	throw;
     }
    Comment 1: В случае если после try написано несколько catch, выполняться всегда будет первый подходящий.

    Виды исключений.

    У класса std::exception есть несколько стандартных наследников.

    Рассмотрим примеры использования подклассов исключений.

    1. bad_alloc
      Пусть была попытка создать объект, но на него не хватило памяти.
      
      Object *o = new Object[1000000000];
      
      В этом случае new бросит bad_alloc.
    2. runtime_error - ошибка, которая произошла в неожиданно; что-то, что не должно происходить.
      Например, ошибку деления на ноль логично сделать ноаследником runtime_error.
      Comments: Обычно runtime_error никто не ловит, но они являются полезной информацией для разработчика. bad_alloc по своей сути является runtime_error.
    3. logic_error - как правило, более предвиденная ошибка
    4. Предположим, что пользователь использует нашу библиотеку, но документацию, конечно, не читал, а функции вызывает как попало, тогда иногда может случаться логическая ошибка: вызов с неправильными параметрами (invalid_argument). То, о чём мы предупреждали, но "Вы" всё равно нарвались.Такие ошибки принято ловить и как-то обрабатывать.
      Пример:
      
       sort(int from, int to) {  //from < to// 
       	if(from >= to) {
       		throw logic_error(...);
       	}
       	...
       }
       

    Использование exception'ов является "сильнодействующим средством". Поэтому не надо использовать их там, где можно обойтись без них.
    Например в случае, рассмотренном ниже, лучше было бы использовать простой "break":
    
    try {
    	for(int i = 0; i < 100; ++i ) {
    		if(a[i] == 0) {
    			throw std::exception();
    		}
    		...
    	} 
    }
    catch(std::exception &...) {
    }
    

    Выводы и замечания.

    Итак, exception'ы нужны для того, чтобы пробросить сообщение об ошибке через несколько уровней.
    Использование исключений не сказывается на быстродействии программы.
    Мы обсудили еще одну технику, часто используемую в С++, но это не значит, что в одной программе нужно использовать все виды техник, которые вам известны.
    По словам "знающих людей", возможно, лучше и вовсе обойтись без exception'ов, в этом случае необходимо указать ключ "-fno-exceptions" (не бросать exception'ы ) при компиляции вашей программы (для клмпилятора gcc).
    Замечание: в случае ошибки оператор new в этом случае вернёт 0, а не бросит std::bad_alloc.

    Типичные ошибки в коде.

    Загадочный код.

    1. Вспомним пример про парсер XML c помощью библиотеки expat. Для хранения результатов работы парсера лучше было создать структуру с 4 полями с понятными названиями, чем создавать "непонятный" массив int data[4], т.к.при использовании массива не ясно что именно хранится в каждом элементе.
    2. Задание про вывод на экран комплексных чисел.
      std::ifstream("file.txt", std::ios::in);
      Зачем указывать второй параметр, если вызов функции без него (std::ifstream("file.txt");) облегчит понимание и не изменит работу программы?.. Хорошая программа та, которую легко понять и исправить, а не та, которая правильно, но непонятно как работает!!!
    3. Задание про stl.
      std::map< std::string, std::string> Map;
      Map.insert(std::pair< std::string, std::string>("first", "second"));
      Нужно: Map.insert( std::make_pair("first", "second"));
    4. Задача пройти по списку и удалить некоторые элементы.
      
      for(std::list< x>::iterator it = l.begin(); it != l.end(); ++it) {
      	if(test(*it)) {
      		it = l.erase(it);  
      	}
      }
      При удалении элемента следующий будет пропущен.Чтобы избежать этого, после удаления можно написать --it;, но это усложнит понимание кода и будет некорректно работать при удалении первого элемента.
      Правильное решение - убрать из цикла ++it, и написать следующее:
      for(std::list< x>::iterator it = l.begin(); it != l.end(); ) {
      	if(test(*it)) {
      		it = l.erase(it);  
      	}
      	else {
      	++it;
      	}
      }

    Warnings.

    Предупреждения, выдаваемые компилятором с ключом -Wall нужно читать, иначе это может привести к некорректной работе программы.
    Пример: возврат ссылки на временный объект (reference to temporary).
    
    const std::string &foo() {
    	std::string s;
    	return s; // s - временный объект
    }
    Вернуть ссылку на объект, находящийся на стеке можно, но если стек не был изменен. Иначе правильная работа программы не гарантирована.
    Comment: Нужно стремиться к тому, чтобы при компиляции предупреждения не выдавались совсем.
    Но, иногда компилятор выдаёт предупреждение о том, что действительно имелось в виду. В этом случае от предупреждений всё равно следует избавиться, иначе среди большого количества "ненужных" предупреждений мы можем не увидеть "нужного". Как это можно сделать?
    1. Исправить код.
      foo(int x, int z)
      Пусть один параметр не используется, а нужен только для того, чтобы перекрыть какую-либо другую функцию. Тогда можно опустить его имя в списке параметров:
      foo(int x, int)
    2. Есть множество ключей для компилятора, позволяющие убрать те или иные предупреждения.
      -Wall -Wno -...

    Пишите оптимальный код!

    Любую программу можно улучшить, но делать это можно бесконечно долго и часто это ни к чему не приводит. Не нужно преждевременно оптимизировать код, т.е. сразу применять сложные алгоритмы, но и не надо заведомо ухудшать код . Пример:
    1. Задача о выводе на экран комплексного числа.
      
      if(im < 0) {
      	os<< abs(im); //зачем вызывать abs от заведомо отрицательного числа?
      }
      
      Более простой вариант(нет вызова целой функции!):
      
      if(m<0) {
      	os<< -im;
      }
      Вывод: Вывод на экран, конечно, работает гороздо дольше, чем вызов функции abs, но зачем, если хороший код написать проще?
    2. Вычисление одной и той же величины внутри цикла.
      
      for(i = 0; i < length(); ++i) {
      	...
      }
      Здесь в цикле каждый раз вычисляется одно и то же значение функции length(), а значит увеличивается время работы. Более оптимальный код:
      
      int l=lenght();
      for(i = 0; i < l; ++i) {
      	...
      }