Ми вже раніше говорили про механізми обробки помилок в мові С++, такі як cerr(), exit() і assert(). Однак ми не встигли поговорити про ще одну дуже важливу тему — “Винятки в мові С++”. Зараз ми це виправимо.
Коли коди повернення не справляються
При написанні повторно використовуваного коду виникає необхідність в обробці помилок. Одним з найбільш поширених способів обробки потенційних помилок є використання кодів повернення (або «кодів завершення»), які повертає оператор return. Наприклад:
1 2 3 4 5 6 7 8 9 10 11 |
int findFirstChar(const char* string, char ch) { // Перебираємо кожний символ рядка for (int index=0; index < strlen(string); ++index) // Якщо поточний символ співпадає зі значенням змінної ch, то повертаємо індекс цього символу if (string[index] == ch) return index; // Якщо збігу не виявлено, то повертаємо -1 return -1; } |
Ця функція повертає індекс першого символу переданого рядка, який збігається зі значенням змінної ch
. Якщо символ не знайдено, то функція повертає -1
в якості індикатора помилки.
Головною перевагою цього підходу є його простота. Однак є ряд недоліків, які можуть швидко проявитися в нетривіальних ситуаціях.
По-перше, значення, що повертаються, не завжди зрозумілі. Якщо функція повертає -1
, то чи означає це якусь специфічну помилку чи може це взагалі коректне значення? Часто важко зрозуміти, не маючи перед очима код самої функції.
По-друге, функції можуть повертати лише одне значення. А що, якщо нам потрібно буде повернути як результат виконання функції, так і код завершення? Наприклад:
1 2 3 4 |
double divide(int a, int b) { return static_cast<double>(a)/b; } |
Тут потрібен механізм обробки помилок, тому що, якщо користувач передасть 0
в якості параметра b
, відбудеться збій. Крім того, функція також повинна повернути і результат виконання операції static_cast<double>(a)/b
. Як же це зробити? Один з варіантів — повернення результату операції або коду завершення по посиланню, наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
#include <iostream> double divide(int a, int b, bool &success) { if (b == 0) { success = false; return 0.0; } success = true; return static_cast<double>(a)/b; } int main() { bool success; double result = divide(7, 4, success); // ми зараз передаємо значення типу bool, щоб знати наперед, чи буде операція успішною if (!success) // перевіряємо результат виконання операції перед фактичним використанням result std::cerr << "An error occurred" << std::endl; else std::cout << "The answer is " << result << '\n'; } |
По-третє, коли коду багато, то багато речей можуть піти не по плану, тому коди повернення потрібно постійно перевіряти. Розглянемо наступний фрагмент програми, в якому проводиться аналіз текстового файлу на наявність певних значень:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
std::ifstream setupIni("setup.ini"); // відкриваємо setup.ini для читання // Якщо файл не можна відкрити (наприклад, тому що він відсутній), то повертаємо помилку if (!setupIni) return ERROR_OPENING_FILE; // Якщо ж файл можна відкрити, то зчитуємо значення з цього файлу if (!readIntegerFromFile(setupIni, m_firstParameter)) // намагаємося знайти значення типу int в файлі return ERROR_READING_VALUE; // повертаємо помилку, якщо значення не знайдено if (!readDoubleFromFile(setupIni, m_secondParameter)) // намагаємося знайти значення типу double в файлі return ERROR_READING_VALUE; if (!readFloatFromFile(setupIni, m_thirdParameter)) // намагаємося знайти значення типу float в файлі return ERROR_READING_VALUE; |
Ми ще не розглядали роботу з файлами, тому не хвилюйтеся, якщо ви не розумієте, як і що тут працює — просто зверніть увагу на те, що для кожного виклику функції потрібна перевірка і повернення поточного стану назад в caller. Тепер уявіть, якби у нас було двадцять параметрів різних типів — нам би довелося виконувати перевірку і повернення ERROR_READING_VALUE
двадцять разів! Весь цей механізм обробки помилок тільки ускладнює розуміння (читання) того, що ж насправді повинна виконувати ця функція.
По-четверте, коди повернення не дуже добре поєднуються з конструкторами. Що станеться, якщо ми створимо об’єкт, а всередині конструктора відбудеться щось катастрофічне? Конструктори не можуть використовувати оператор return для повернення індикатора стану, а передача по посиланню може заподіяти масу незручностей, і її потрібно явно перевіряти. Крім того, навіть якщо ми це зробимо, об’єкт все одно створиться, і “лікувати” ми вже будемо наслідки (або обробляти, або видаляти).
Нарешті, при поверненні помилки назад в caller, сам caller може не завжди бути готовим обробити цю помилку. Якщо caller не хоче обробляти помилку, він або ігнорує її (що вже погано), або повертає помилку назад в функцію, від якої він її і отримав. Це не те що незручно, це може призвести до збою програми або до невизначених результатів.
Основна проблема з кодами повернення полягає в тому, що вони щільно пов’язані із загальним потоком виконання коду, а це, в свою чергу, обмежує наші можливості.
Винятки
Обробка винятків якраз і забезпечує механізм, який дозволяє відокремити обробку помилок або інших виняткових обставин від загального потоку виконання коду. Це надає більше свободи в конкретних ситуаціях, зменшуючи при цьому безлад, який створюють коди повернення.
На наступному уроці ми розглянемо принципи обробки винятків в мові C++.