Механізм винятків: try, catch, throw та розкручування стеку
Від кодів помилок до винятків: еволюція обробки помилок
У попередніх лекціях ми працювали з функціями, що повертають значення або модифікують об'єкти. Проте реальні програми стикаються з виняткими ситуаціями (exceptional situations) — помилками, що не є частиною нормального потоку виконання: відсутність файлу, переповнення буфера, ділення на нуль, недостатньо пам'яті. Традиційний підхід до обробки помилок у мові C базується на кодах повернення:
#include <iostream>
#include <cmath>
// Традиційний C-стилевий підхід: код помилки через повернення
int safeDivide(int a, int b, double& result)
{
if (b == 0)
return -1; // Код помилки: ділення на нуль
result = static_cast<double>(a) / b;
return 0; // Успіх
}
int safeSquareRoot(double value, double& result)
{
if (value < 0)
return -1; // Код помилки: від'ємне значення
result = std::sqrt(value);
return 0; // Успіх
}
int main()
{
double result;
// Потрібно перевіряти кожен виклик
if (safeDivide(10, 0, result) != 0)
{
std::cout << "Помилка: ділення на нуль\n";
return 1;
}
if (safeSquareRoot(-5, result) != 0)
{
std::cout << "Помилка: від'ємне значення під коренем\n";
return 1;
}
std::cout << "Результат: " << result << "\n";
return 0;
}
Проблеми підходу через коди помилок:
- Примусова перевірка: потрібно вручну перевіряти кожен виклик функції — забуте
ifпризведе до ігнорування помилки - Захаращений код: логіка обробки помилок змішана з бізнес-логікою, знижуючи читабельність
- Обмежений тип повернення: функція не може одночасно повертати результат і код помилки (потрібні out-параметри)
- Відсутність контексту: код
-1не містить опису помилки — потрібна додаткова документація - Неможливість пропуску рівнів: якщо функція
A()викликаєB(), аB()—C(), помилка зC()має пройти черезB(), навіть якщоB()не може її обробити
Механізм винятків (exception handling) у C++ розв'язує ці проблеми, дозволяючи відокремити код обробки помилок від основної логіки та автоматично передавати помилки вгору по стеку викликів до того місця, де вони можуть бути оброблені.
🎯 Мета лекції
- Опанувати тріаду
try-catch-throw: синтаксис викидання та перехоплення винятків - Зрозуміти механізм розкручування стеку (stack unwinding) та його зв'язок із деструкторами
- Навчитися створювати та використовувати множинні catch-блоки для різних типів винятків
- Дослідити порядок перехоплення: від найспецифічніших до загальних типів
- Розібрати типові помилки: проковтування винятків, витоки ресурсів, невизначена поведінка
🔑 Ключові терміни
- Виняток (exception): об'єкт, що сигналізує про виняткову ситуацію та автоматично передається вгору по стеку викликів
- Викидання винятку (throwing an exception): оператор
throw, що створює виняток і перериває нормальний потік виконання - Перехоплення винятку (catching an exception): блок
catch, що обробляє виняток конкретного типу - Розкручування стеку (stack unwinding): процес автоматичного виклику деструкторів локальних об'єктів при пошуку відповідного catch-блоку
- Блок захисту (try block): область коду, у якій відстежуються винятки, що можуть бути викинуті
- Необроблений виняток (unhandled exception): виняток, для якого не знайдено відповідного catch-блоку, призводить до виклику
std::terminate()
Синтаксис try, catch, throw: базова структура
Викидання винятку: оператор throw
Оператор throw створює об'єкт-виняток та перериває виконання поточної функції, передаючи управління найближчому відповідному catch-блоку:
#include <iostream>
double safeDivide(int a, int b)
{
if (b == 0)
throw "Помилка: ділення на нуль"; // Викидаємо виняток типу const char*
return static_cast<double>(a) / b;
}
int main()
{
try
{
double result = safeDivide(10, 0);
std::cout << "Результат: " << result << "\n"; // Не виконається
}
catch (const char* errorMessage)
{
std::cout << "Перехоплено виняток: " << errorMessage << "\n";
}
std::cout << "Програма продовжує виконання\n";
return 0;
}
Як це працює:
- Виклик
safeDivide(10, 0): функція перевіряє умовуb == 0→ істина - Оператор
throw: створює об'єктconst char*з повідомленням та перериває виконання функції - Пошук catch-блоку: runtime шукає найближчий
catch, що може прийнятиconst char* - Перехоплення: блок
catch (const char* errorMessage)отримує управління - Обробка: виводить повідомлення про помилку
- Продовження: виконання продовжується після блоку
catch
Блок захисту: try та перехоплення catch
Блок try позначає область коду, у якій можуть виникнути винятки. Одразу після нього має йти один або кілька блоків catch:
try
{
// Код, що може викинути виняток
}
catch (тип_винятку параметр)
{
// Обробка винятку цього типу
}
Важливі правила:
catch-блок обов'язковий —tryбезcatchє синтаксичною помилкоюcatchперехоплює винятки конкретного типу або базового класу- Якщо тип викинутого винятку не збігається з жодним
catch, виняток передається вгору по стеку викликів
Типи винятків: від примітивів до об'єктів
C++ дозволяє викидати будь-який тип як виняток: примітивні типи (int, double), рядки (const char*, std::string), власні класи. Проте найкраща практика — використовувати об'єкти класів для передачі детальної інформації:
#include <iostream>
#include <string>
// Власний клас винятку
class DivisionByZeroError
{
private:
int m_numerator;
public:
DivisionByZeroError(int numerator) : m_numerator(numerator) {}
std::string getMessage() const
{
return "Ділення " + std::to_string(m_numerator) + " на нуль неможливе";
}
};
double safeDivide(int a, int b)
{
if (b == 0)
throw DivisionByZeroError(a); // Викидаємо об'єкт класу
return static_cast<double>(a) / b;
}
int main()
{
try
{
std::cout << "Результат: " << safeDivide(10, 2) << "\n";
std::cout << "Результат: " << safeDivide(15, 0) << "\n"; // Викине виняток
std::cout << "Ця строка не виконається\n";
}
catch (const DivisionByZeroError& ex)
{
std::cout << "Перехоплено виняток: " << ex.getMessage() << "\n";
}
std::cout << "Програма продовжує роботу\n";
return 0;
}
Переваги використання класів для винятків:
- Деталізована інформація: можна зберігати контекст помилки (значення змінних, час, місце виникнення)
- Ієрархія винятків: можна створювати спадкові класи для категоризації помилок
- Типобезпека: компілятор перевіряє відповідність типів у
catch - Методи для обробки: виняток може мати методи для діагностики або відновлення
std::exception (розглянемо у наступній лекції). Викидання примітивних типів (throw 42;, throw "error";) вважається поганою практикою через втрату контексту.Множинні catch-блоки: обробка різних типів помилок
Один try-блок може мати кілька catch-блоків для обробки різних типів винятків:
#include <iostream>
#include <string>
class DivisionByZeroError {};
class NegativeValueError {};
double complexCalculation(int a, int b)
{
if (b == 0)
throw DivisionByZeroError();
double result = static_cast<double>(a) / b;
if (result < 0)
throw NegativeValueError();
return result;
}
int main()
{
try
{
double result = complexCalculation(-10, 2); // Викине NegativeValueError
std::cout << "Результат: " << result << "\n";
}
catch (const DivisionByZeroError&)
{
std::cout << "Помилка: спроба ділення на нуль\n";
}
catch (const NegativeValueError&)
{
std::cout << "Помилка: результат від'ємний\n";
}
catch (...) // Універсальний catch для всіх інших типів
{
std::cout << "Невідома помилка\n";
}
return 0;
}
Порядок catch-блоків:
- Компілятор перевіряє
catch-блоки зверху вниз у порядку оголошення - Виконується перший підходящий
catch - Блок
catch (...)(три крапки) перехоплює будь-який тип винятку — має бути останнім
catch (...) перед специфічними блоками, він перехопить всі винятки, і решта блоків стануть недосяжними:try { /* ... */ }
catch (...) // ❌ ПОГАНО: перехопить ВСЕ
{
std::cout << "Невідома помилка\n";
}
catch (const DivisionByZeroError&) // ⚠️ Ніколи не виконається!
{
std::cout << "Ділення на нуль\n";
}
Передавання винятків вгору по стеку викликів
Якщо функція не має відповідного catch-блоку, виняток автоматично передається викликачеві:
#include <iostream>
void level3()
{
std::cout << "level3(): викидаємо виняток\n";
throw 42; // Немає try-catch — виняток йде до level2()
}
void level2()
{
std::cout << "level2(): викликаємо level3()\n";
level3(); // Немає try-catch — виняток йде до level1()
std::cout << "level2(): ця строка не виконається\n";
}
void level1()
{
std::cout << "level1(): викликаємо level2()\n";
try
{
level2();
}
catch (int error)
{
std::cout << "level1(): перехоплено виняток з кодом " << error << "\n";
}
std::cout << "level1(): продовжуємо роботу\n";
}
int main()
{
level1();
std::cout << "main(): програма завершена\n";
return 0;
}
Візуалізація стеку викликів:
main()
→ level1()
→ try { level2() }
→ level3()
→ throw 42 ← Виняток викинуто тут
← Повернення до level2() пропущено
← Повернення до level1() пропущено
← catch (int) перехоплює виняток
Ключове спостереження: Функції level2() та level3() не потребують знати про обробку помилок. Вони просто викидають виняток, і він автоматично доходить до того рівня, де є відповідний catch. Це дозволяє відокремити логіку від обробки помилок.
Розкручування стеку: автоматичне звільнення ресурсів
Одна з найпотужніших властивостей механізму винятків — розкручування стеку (stack unwinding). Коли виняток передається вгору по стеку викликів, C++ runtime автоматично викликає деструктори всіх локальних об'єктів у зворотному порядку їхнього створення.
Механізм розкручування: від винятку до catch
Розглянемо докладний приклад з об'єктами, що виводять повідомлення у конструкторі та деструкторі:
#include <iostream>
class Resource
{
private:
std::string m_name;
public:
Resource(const std::string& name) : m_name(name)
{
std::cout << " [Конструктор] " << m_name << " створено\n";
}
~Resource()
{
std::cout << " [Деструктор] " << m_name << " знищено\n";
}
};
void functionC()
{
Resource r3("Resource C");
std::cout << "functionC(): викидаємо виняток\n";
throw std::runtime_error("Помилка у functionC");
std::cout << "functionC(): ця строка не виконається\n";
}
void functionB()
{
Resource r2("Resource B");
std::cout << "functionB(): викликаємо functionC()\n";
functionC();
std::cout << "functionB(): ця строка не виконається\n";
}
void functionA()
{
Resource r1("Resource A");
std::cout << "functionA(): викликаємо functionB()\n";
try
{
functionB();
}
catch (const std::exception& ex)
{
std::cout << "functionA(): перехоплено виняток: " << ex.what() << "\n";
}
std::cout << "functionA(): продовжуємо роботу\n";
}
int main()
{
std::cout << "=== Початок виконання ===\n";
functionA();
std::cout << "=== Завершення виконання ===\n";
return 0;
}
Покроковий аналіз розкручування:
functionC()викидає виняток → runtime перериває виконання функції- Автоматичний виклик
~Resource()дляr3→ ресурс C коректно звільнено - Повернення до
functionB()→ немає catch-блоку, продовжуємо розкручування - Автоматичний виклик
~Resource()дляr2→ ресурс B коректно звільнено - Повернення до
functionA()→ знайденоcatch (const std::exception&) - Виняток перехоплено → розкручування зупинено, виконання продовжується після catch
- Нормальне завершення
functionA()→ виклик~Resource()дляr1
Порівняння: RAII vs ручне управління ресурсами
Розглянемо, як механізм винятків взаємодіє з різними підходами до управління ресурсами:
void processFile()
{
int* data = new int[1000]; // Виділення пам'яті
// Якщо тут виникне виняток, delete не виконається
riskyOperation(); // Може викинути виняток
delete[] data; // ❌ Ця строка може не виконатись
}
#include <memory>
void processFile()
{
std::unique_ptr<int[]> data(new int[1000]); // RAII-обгортка
// Навіть якщо виникне виняток, деструктор unique_ptr
// автоматично викличе delete[] під час розкручування стеку
riskyOperation();
// delete[] не потрібен — виконається автоматично
}
Правило безпеки: Ніколи не викликайте new без відповідного RAII-класу (std::unique_ptr, std::shared_ptr, власний клас із деструктором). Інакше виняток призведе до витоку пам'яті.
Обмеження: що НЕ очищається під час розкручування
Розкручування стеку викликає деструктори лише для локальних об'єктів. Наступні ресурси не звільняються автоматично:
- Пам'ять, виділена через
new(без RAII-обгортки) → потрібенdeleteуcatch - Відкриті файли через C-стилі
FILE*→ потрібенfclose()уcatch - Глобальні або статичні ресурси → деструктори не викликаються до завершення програми
- Ресурси, отримані через C API (сокети, дескриптори) → потрібне ручне звільнення
#include <iostream>
void unsafeFunction()
{
int* data = new int[1000]; // ❌ Небезпечно без RAII
try
{
riskyOperation(); // Може викинути виняток
delete[] data; // Виконається лише якщо немає винятку
}
catch (...)
{
delete[] data; // ⚠️ Потрібно дублювати звільнення
throw; // Перекидаємо виняток далі
}
}
catch дублює код та схильне до помилок. Завжди використовуйте RAII-обгортки (std::unique_ptr, std::fstream, std::lock_guard) для автоматичного управління ресурсами.Перекидання винятків: throw; без аргументів
Іноді catch-блок потребує частково обробити виняток (наприклад, залогувати помилку), але не може повністю його вирішити. У таких випадках виняток можна перекинути далі вгору по стеку за допомогою throw; без аргументів:
#include <iostream>
void lowLevelFunction()
{
std::cout << "lowLevelFunction(): викидаємо виняток\n";
throw std::runtime_error("Критична помилка на низькому рівні");
}
void middleLevelFunction()
{
try
{
lowLevelFunction();
}
catch (const std::exception& ex)
{
std::cout << "middleLevelFunction(): частково обробляємо виняток\n";
std::cout << " Логування: " << ex.what() << "\n";
// Перекидаємо той самий виняток далі
throw; // ← Без аргументів: перекидається оригінальний виняток
}
}
void highLevelFunction()
{
try
{
middleLevelFunction();
}
catch (const std::exception& ex)
{
std::cout << "highLevelFunction(): остаточна обробка\n";
std::cout << " Повідомлення користувачеві: операція не вдалася\n";
}
}
int main()
{
highLevelFunction();
return 0;
}
Різниця між throw; та throw ex;:
| Оператор | Поведінка | Використання |
|---|---|---|
throw; | Перекидає оригінальний виняток зі збереженням типу | ✅ Рекомендовано: зберігає повну інформацію |
throw ex; | Створює копію об'єкта ex (може зрізати тип) | ⚠️ Небезпечно: може втратити інформацію |
Проблема зрізки об'єкта при throw ex;:
#include <iostream>
class BaseException
{
public:
virtual void printType() const { std::cout << "BaseException\n"; }
};
class DerivedException : public BaseException
{
public:
void printType() const override { std::cout << "DerivedException\n"; }
};
void demonstrateSlicing()
{
try
{
throw DerivedException(); // Викидаємо похідний тип
}
catch (const BaseException& ex)
{
ex.printType(); // Виведе "DerivedException" (правильно)
// ❌ ПОГАНО: throw ex створить копію BaseException (зрізка!)
// throw ex;
// ✅ ДОБРЕ: throw без аргументів зберігає оригінальний тип
throw;
}
}
int main()
{
try
{
demonstrateSlicing();
}
catch (const DerivedException&)
{
std::cout << "Перехоплено DerivedException\n";
}
catch (const BaseException&)
{
std::cout << "Перехоплено BaseException (зрізка сталась!)\n";
}
return 0;
}
throw; без аргументів для перекидання винятків. Це гарантує збереження оригінального типу, vtable, та всієї інформації про виняток.Необроблені винятки: std::terminate() та аварійне завершення
Якщо виняток не перехоплено жодним catch-блоком, C++ runtime викликає функцію std::terminate(), яка за замовчуванням викликає std::abort() — аварійне завершення програми без виклику деструкторів.
#include <iostream>
class Resource
{
public:
Resource() { std::cout << "[Конструктор] Resource створено\n"; }
~Resource() { std::cout << "[Деструктор] Resource знищено\n"; }
};
void throwUnhandledException()
{
Resource res;
throw std::runtime_error("Необроблений виняток");
}
int main()
{
std::cout << "=== Початок програми ===\n";
// ❌ Немає try-catch для перехоплення
throwUnhandledException();
std::cout << "=== Ця строка не виконається ===\n";
return 0;
}
Що відбувається:
throwUnhandledException()викидає виняток- Runtime шукає відповідний
catch→ не знаходить - Досягнуто
main()→ немаєcatchуmain() - Досягнуто кореня стеку → викликається
std::terminate() std::terminate()викликаєstd::abort()→ аварійне завершення- Деструктори НЕ викликаються → ресурси не звільнено
- Витоку пам'яті (незвільнені
new) - Незакритих файлів (дані залишаються у буфері)
- Незахищених критичних секцій (м'ютекси залишаються захопленими)
- Втрати даних (незбережені зміни у БД)
try-catch у main() для перехоплення неочікуваних винятків.Захист від необроблених винятків: кореневий catch
Безпечна практика — обгорнути весь код main() у try-catch із універсальним перехопленням:
#include <iostream>
#include <exception>
void riskyOperation()
{
throw std::runtime_error("Несподівана помилка");
}
int main()
{
try
{
std::cout << "=== Початок програми ===\n";
riskyOperation();
std::cout << "=== Завершення програми ===\n";
}
catch (const std::exception& ex)
{
std::cerr << "ФАТАЛЬНА ПОМИЛКА: " << ex.what() << "\n";
return 1; // Код помилки для ОС
}
catch (...)
{
std::cerr << "НЕВІДОМА ФАТАЛЬНА ПОМИЛКА\n";
return 2;
}
return 0; // Успішне завершення
}
Переваги кореневого catch:
- ✅ Програма завершується контрольовано з кодом помилки
- ✅ Деструктори локальних об'єктів у
main()викликаються - ✅ Можна залогувати помилку перед завершенням
- ✅ Можна спробувати зберегти критичні дані
Типові помилки та антипатерни при роботі з винятками
Помилка 1: Проковтування винятків (Silent Failure)
Найнебезпечніша практика — перехоплювати виняток і нічого не робити:
try
{
riskyOperation();
}
catch (...)
{
// Порожній catch — виняток проковтнуто
// Програма продовжує роботу так, ніби нічого не сталось
}
try
{
riskyOperation();
}
catch (const std::exception& ex)
{
// Мінімум — залогувати помилку
std::cerr << "ПОПЕРЕДЖЕННЯ: " << ex.what() << "\n";
// Або перекинути далі, якщо не можемо обробити
throw;
}
Чому це небезпечно: Програма продовжує виконання у невизначеному стані. Наприклад, якщо riskyOperation() мала записати дані у файл, але викинула виняток, програма може думати, що дані збережено, хоча насправді файл порожній.
Помилка 2: Перехоплення за значенням замість за посиланням
Перехоплення винятку за значенням призводить до зайвого копіювання та можливої зрізки об'єкта:
try
{
throw DerivedException();
}
catch (BaseException ex) // ❌ За значенням — копіювання!
{
// ex — це копія, можлива зрізка типу
}
try
{
throw DerivedException();
}
catch (const BaseException& ex) // ✅ За const-посиланням
{
// Ефективно, без копіювання, зберігає оригінальний тип
}
Правило: Завжди перехоплюйте винятки за const-посиланням (const T&), крім випадків, коли потрібна модифікація об'єкта винятку.
Помилка 3: Викидання винятків у деструкторах
Викидання винятку з деструктора — одна з найнебезпечніших помилок у C++:
#include <iostream>
class DangerousResource
{
public:
~DangerousResource()
{
std::cout << "Деструктор: викидаємо виняток\n";
throw std::runtime_error("Помилка у деструкторі"); // ❌ НЕБЕЗПЕЧНО!
}
};
int main()
{
try
{
DangerousResource res;
throw std::runtime_error("Перший виняток");
}
catch (const std::exception& ex)
{
std::cout << "Перехоплено: " << ex.what() << "\n";
}
return 0;
}
Що станеться:
- Викидається
"Перший виняток"уtry-блоці - Розкручування стеку викликає
~DangerousResource() - Деструктор викидає
"Помилка у деструкторі"→ два активні винятки одночасно - C++ не може обробити два винятки одночасно → викликається
std::terminate() - Програма аварійно завершується без перехоплення жодного винятку
try-catch і залогуйте помилку, але не перекидайте далі:~SafeResource()
{
try
{
riskyCleanup();
}
catch (const std::exception& ex)
{
std::cerr << "Помилка звільнення ресурсу: " << ex.what() << "\n";
// НЕ throw; — проковтуємо виняток
}
}
noexcept, що гарантує відсутність винятків на рівні компілятора.Помилка 4: Забуті ресурси через ранній вихід
Викидання винятку до виділення ресурсу призводить до того, що ресурс ніколи не буде звільнений:
void processData()
{
int* data = new int[1000];
validateInput(); // ❌ Якщо викине виняток, delete не виконається
// Обробка data...
delete[] data; // Ця строка може не виконатись
}
Розв'язання через RAII:
void processData()
{
std::unique_ptr<int[]> data(new int[1000]); // ✅ RAII-обгортка
validateInput(); // Навіть при винятку деструктор звільнить пам'ять
// Обробка data...
// delete не потрібен
}
Практичне завдання: система валідації з винятками
Реалізуйте клас Validator, що перевіряє введені дані та викидає спеціалізовані винятки при порушенні обмежень:
- Базовий клас винятку
ValidationErrorз полемmessage - Похідні класи:
EmptyValueError— порожнє значенняOutOfRangeError— значення поза діапазоном (зберігаєmin,max,actual)InvalidFormatError— невалідний формат
- Клас
Validatorз методами:validateNotEmpty(const std::string& value)— перевіряє, що рядок не порожнійvalidateRange(int value, int min, int max)— перевіряє діапазонvalidateEmail(const std::string& email)— перевіряє наявність@
Приклад використання:
int main()
{
Validator validator;
try
{
validator.validateNotEmpty(""); // Викине EmptyValueError
}
catch (const ValidationError& ex)
{
std::cout << "Помилка: " << ex.getMessage() << "\n";
}
try
{
validator.validateRange(150, 0, 100); // Викине OutOfRangeError
}
catch (const OutOfRangeError& ex)
{
std::cout << "Діапазон: [" << ex.getMin() << ", " << ex.getMax()
<< "], отримано: " << ex.getActual() << "\n";
}
return 0;
}
Очікуваний вивід:
Помилка: Значення не може бути порожнім
Діапазон: [0, 100], отримано: 150
#include <iostream>
#include <string>
// ═════════════════════════════════════════════════════════════
// БАЗОВИЙ КЛАС ВИНЯТКУ
// ═════════════════════════════════════════════════════════════
class ValidationError
{
protected:
std::string m_message;
public:
ValidationError(const std::string& message) : m_message(message) {}
virtual ~ValidationError() = default;
virtual std::string getMessage() const { return m_message; }
};
// ═════════════════════════════════════════════════════════════
// ПОХІДНІ КЛАСИ ВИНЯТКІВ
// ═════════════════════════════════════════════════════════════
class EmptyValueError : public ValidationError
{
public:
EmptyValueError()
: ValidationError("Значення не може бути порожнім") {}
};
class OutOfRangeError : public ValidationError
{
private:
int m_min;
int m_max;
int m_actual;
public:
OutOfRangeError(int min, int max, int actual)
: ValidationError("Значення поза діапазоном"),
m_min(min), m_max(max), m_actual(actual) {}
int getMin() const { return m_min; }
int getMax() const { return m_max; }
int getActual() const { return m_actual; }
std::string getMessage() const override
{
return "Значення " + std::to_string(m_actual) +
" поза діапазоном [" + std::to_string(m_min) +
", " + std::to_string(m_max) + "]";
}
};
class InvalidFormatError : public ValidationError
{
private:
std::string m_field;
public:
InvalidFormatError(const std::string& field)
: ValidationError("Невалідний формат поля: " + field),
m_field(field) {}
std::string getField() const { return m_field; }
};
// ═════════════════════════════════════════════════════════════
// КЛАС ВАЛІДАТОРА
// ═════════════════════════════════════════════════════════════
class Validator
{
public:
void validateNotEmpty(const std::string& value) const
{
if (value.empty())
throw EmptyValueError();
}
void validateRange(int value, int min, int max) const
{
if (value < min || value > max)
throw OutOfRangeError(min, max, value);
}
void validateEmail(const std::string& email) const
{
if (email.find('@') == std::string::npos)
throw InvalidFormatError("email");
}
};
// ═════════════════════════════════════════════════════════════
// ДЕМОНСТРАЦІЯ РОБОТИ
// ═════════════════════════════════════════════════════════════
int main()
{
Validator validator;
std::cout << "=== Тест 1: Порожнє значення ===\n";
try
{
validator.validateNotEmpty("");
std::cout << "Валідація пройшла успішно\n";
}
catch (const ValidationError& ex)
{
std::cout << "Помилка: " << ex.getMessage() << "\n";
}
std::cout << "\n=== Тест 2: Значення поза діапазоном ===\n";
try
{
validator.validateRange(150, 0, 100);
std::cout << "Валідація пройшла успішно\n";
}
catch (const OutOfRangeError& ex)
{
std::cout << "Діапазон: [" << ex.getMin() << ", " << ex.getMax()
<< "], отримано: " << ex.getActual() << "\n";
}
catch (const ValidationError& ex)
{
std::cout << "Помилка: " << ex.getMessage() << "\n";
}
std::cout << "\n=== Тест 3: Невалідний email ===\n";
try
{
validator.validateEmail("invalid.email.com");
std::cout << "Валідація пройшла успішно\n";
}
catch (const InvalidFormatError& ex)
{
std::cout << "Поле '" << ex.getField() << "': " << ex.getMessage() << "\n";
}
catch (const ValidationError& ex)
{
std::cout << "Помилка: " << ex.getMessage() << "\n";
}
std::cout << "\n=== Тест 4: Валідні дані ===\n";
try
{
validator.validateNotEmpty("Hello");
validator.validateRange(50, 0, 100);
validator.validateEmail("user@example.com");
std::cout << "Всі перевірки пройшли успішно!\n";
}
catch (const ValidationError& ex)
{
std::cout << "Помилка: " << ex.getMessage() << "\n";
}
return 0;
}
Архітектурні особливості розв'язку:
- Ієрархія винятків: базовий
ValidationErrorдозволяє перехоплювати всі помилки валідації однимcatch - Віртуальний
getMessage(): похідні класи можуть перевизначати формат повідомлення - Додаткові дані у винятках:
OutOfRangeErrorзберігаєmin,max,actualдля детальної діагностики - Порядок catch-блоків: спочатку специфічні (
OutOfRangeError), потім загальні (ValidationError) - Віртуальний деструктор: базовий клас має
virtual ~ValidationError()для коректного видалення похідних об'єктів
Питання для самоперевірки
Основні недоліки кодів повернення:
- Примусова перевірка: розробник може забути перевірити код помилки → помилка ігнорується мовчки
- Захаращений код: перевірки
if (error)змішуються з бізнес-логікою, знижуючи читабельність - Обмеження типу повернення: функція не може одночасно повертати результат і код помилки (потрібні out-параметри або структури)
- Відсутність контексту: код
-1не містить опису помилки - Неможливість пропуску рівнів: помилка має проходити через всі проміжні функції, навіть якщо вони не можуть її обробити
Переваги винятків:
- Відокремлення логіки обробки помилок від основного коду
- Автоматичне передавання вгору по стеку до місця, де помилка може бути оброблена
- Багата інформація про помилку через об'єкти класів
- Автоматичне звільнення ресурсів через розкручування стеку та RAII
Розкручування стеку (stack unwinding) — це процес автоматичного виклику деструкторів локальних об'єктів при передаванні винятку вгору по стеку викликів.
Алгоритм:
- Виняток викидається у функції
C() - Runtime шукає відповідний
catchуC()→ не знаходить - Викликає деструктори всіх локальних об'єктів у
C()у зворотному порядку створення - Повертається до функції
B()→ шукаєcatch→ не знаходить - Викликає деструктори всіх локальних об'єктів у
B() - Продовжує до тих пір, поки не знайде
catchабо не досягнеmain()
Критична важливість:
- Гарантує автоматичне звільнення ресурсів через деструктори (пам'ять, файли, м'ютекси)
- Забезпечує безпеку винятків (exception safety) у поєднанні з RAII
- Дозволяє писати чистий код без дублювання логіки звільнення у кожному catch-блоці
throw; (без аргументів):
- Перекидає оригінальний виняток зі збереженням повного типу
- Зберігає vtable для віртуальних методів
- Не створює копію об'єкта
throw ex; (з аргументом):
- Створює копію об'єкта
ex - Може призвести до зрізки об'єкта (object slicing), якщо
exмає базовий тип, а оригінальний виняток був похідним - Втрачається інформація про реальний тип винятку
Приклад зрізки:
try {
throw DerivedException(); // Оригінальний тип: DerivedException
}
catch (const BaseException& ex) {
ex.printType(); // Виведе "DerivedException"
throw ex; // ❌ Створює копію BaseException (зрізка!)
// throw; // ✅ Перекидає DerivedException (правильно)
}
Золоте правило: Завжди використовуйте throw; для перекидання винятків.
Проблема двох активних винятків:
Якщо виняток викидається з деструктора під час розкручування стеку (коли вже існує активний виняток), C++ опиняється у ситуації двох одночасно активних винятків. Стандарт C++ не дозволяє це — runtime викликає std::terminate().
Сценарій:
- Функція викидає виняток A
- Розкручування стеку викликає деструктор об'єкта
- Деструктор викидає виняток B
- Два винятки одночасно: A (який ще не перехоплено) + B (щойно викинутий)
- Runtime викликає
std::terminate()→ аварійне завершення
Рішення:
~SafeResource()
{
try {
riskyCleanup();
}
catch (...) {
// Логування помилки, але НЕ throw
std::cerr << "Помилка у деструкторі\n";
}
}
Правило: Деструктор ніколи не повинен дозволяти винятку вийти назовні. У наступних лекціях розглянемо специфікатор noexcept, що гарантує це на рівні компілятора.
Резюме та перспективи подальшого вивчення
У цій лекції ми опанували фундаментальні механізми обробки винятків у C++ — інструменти, що дозволяють відокремити логіку обробки помилок від основного коду та забезпечити автоматичне звільнення ресурсів. Ми розібрали:
Синтаксис та механізми
- Тріада
try-catch-throwдля викидання та перехоплення винятків - Типи винятків: від примітивів до об'єктів класів
- Множинні catch-блоки для різних типів помилок
- Порядок перехоплення: специфічні → загальні
Розкручування стеку
- Автоматичний виклик деструкторів локальних об'єктів
- Взаємодія з ідіомою RAII для безпеки винятків
- Передавання винятків вгору по стеку викликів
- Обмеження: що не звільняється автоматично
Перекидання винятків
throw;без аргументів — зберігає оригінальний типthrow ex;— створює копію та може призвести до зрізки- Часткова обробка з передаванням далі (логування)
Небезпеки та антипатерни
- Необроблені винятки та
std::terminate() - Проковтування винятків (silent failure)
- Перехоплення за значенням замість за посиланням
- Викидання винятків з деструкторів
Порівняння підходів до обробки помилок
| Характеристика | Коди повернення | Винятки |
|---|---|---|
| Відокремлення логіки | ❌ Змішано з бізнес-логікою | ✅ Відокремлено у catch-блоках |
| Примусова перевірка | ❌ Можна забути перевірити | ✅ Неможливо ігнорувати (terminate) |
| Контекст помилки | ❌ Обмежений (лише код) | ✅ Багатий (об'єкти класів) |
| Пропуск рівнів | ❌ Має проходити через всі функції | ✅ Автоматично до відповідного catch |
| Звільнення ресурсів | ❌ Ручне у кожній функції | ✅ Автоматичне через розкручування |
| Продуктивність | ✅ Жодних витрат | ⚠️ Витрати при викиданні винятку |
Зв'язок із майбутніми темами
Базові механізми винятків є фундаментом для більш просунутих тем:
- Стаття 86 — Ієрархія std::exception: стандартні класи винятків,
what(), власні похідні класи - Стаття 87 — Гарантії безпеки винятків: базова, строга, no-throw гарантії,
noexcept-специфікатор - Стаття 88 — Функціональний try-блок: обробка винятків у конструкторах та списках ініціалізації
- Move-семантика (статті 89–92): взаємодія
noexceptіз переміщенням, сильні гарантії безпеки - STL та винятки: контейнери та алгоритми, що надають гарантії безпеки винятків
Практичні рекомендації
- Завжди перехоплюйте за const-посиланням (
const T&), крім випадків модифікації - Викидайте об'єкти класів, а не примітивні типи — для багатого контексту
- Використовуйте RAII для всіх ресурсів — гарантія звільнення при винятках
- Ніколи не викидайте з деструкторів — призводить до
std::terminate() - Майте кореневий try-catch у main() — запобігання необробленим винятком
- Не проковтуйте винятки — як мінімум логуйте або перекидайте далі
- Порядок catch: специфічні → загальні — уникайте недосяжних блоків
Ключовий висновок: Механізм винятків C++ дозволяє будувати відмовостійкі (fault-tolerant) системи, що коректно обробляють помилки без захаращування основної логіки. Поєднання винятків із RAII гарантує автоматичне звільнення ресурсів навіть при найскладніших сценаріях розгалуженого потоку виконання. Розуміння розкручування стеку та правил перехоплення — фундамент для написання безпечного та підтримуваного коду у сучасному C++.
Часткова спеціалізація шаблонів класів
Узагальнені спеціалізації для категорій типів: покажчики, посилання, константні типи, контейнери. Синтаксис template<typename U> для часткової спеціалізації. Пріоритети вибору: часткова спеціалізація > первинний шаблон. Метапрограмування через рекурсивні спеціалізації та type traits.
Ієрархія std::exception та власні класи винятків
Ознайомлення зі стандартною ієрархією класів винятків C++, проєктування власних класів винятків предметної області, правила черговості обробників catch, універсальний перехоплювач catch(...) та поведінка програми при неспійманих винятках