Курс для початківців з C++: Обробка помилок

pic

Якщо ви не підготувалися, будьте готові до провалу.

Аудиторія

Ця стаття спрямована на тих, хто хоче зрозуміти обробку помилок у C++. Для того щоб повною мірою зрозуміти матеріал, вам необхідно мати базові знання мови та загальне уявлення про типи помилок, які виникають при написанні коду.

Також буде корисно, якщо ви бачили, як хоча б одна інша мова обробляє помилки. І нарешті, я рекомендую ознайомитися з моїми попередніми статтями з C++, що зібрані в списку тут (будь ласка).

Аргумент

Ми визначаємо помилку як щось, що заважає програмі виконуватися згідно з наміченим планом. Існує багато причин, чому це може статися:

  • Логічні помилки: Наприклад, спроба розпарсити ціле число з рядка "not-an-integer".
  • Помилки під час виконання: Наприклад, отримання відповіді 500 на HTTP-запит.

У сучасному C++ для вказівки таких обставин використовуються виключення.

Сподіваюся, що ви вже знайомі з синтаксисом try/catch, і це не стане для вас великим шоком.

Мотивація використання виключень така сама, як і в будь-якій іншій мові. Головним чином, ми змушуємо програму працювати з помилковим станом — його неможливо ігнорувати. Крім того, ми можемо розділити код, що кидає виключення, від коду, який його обробляє, причому все, що знаходиться між ними, залишається неуважним.

Однак є кілька моментів, які можуть вас здивувати. По-перше, відсутність синтаксису throws в кінці визначення функції. В деяких мовах (наприклад, Java) потрібно вказати, які перевірені виключення кидає функція. У нашому прикладі це може бути виглядати так:

void throw_exception() throws runtime_error {

C++ колись робив те саме. Однак виключення перевіряються під час виконання, що означає, що ми не можемо гарантувати їх правильну обробку на етапі компіляції. У випадку, коли функція кидає виключення, не вказане в списку, програма зупиняється.

Крім того, перевірка виключень вимагала більшої кількості коду, який генерував компілятор, що заважало оптимізаціям і не працювало з узагальненнями.

Зараз є лише два варіанти: або функція кидає виключення, або не кидає. Перше є значенням за замовчуванням (тому не потрібно нічого писати в кінці визначення функції), а для другого можна використовувати noexcept.

Проте, якщо виключення залишиться необробленим у функції noexcept, це призведе до завершення всієї програми, тому будьте обережні! Якщо ви хочете дізнатися всю історію, можна прочитати її тут.

Іншим здивуванням обробки виключень у C++ є відсутність блоку finally. Це зумовлено парадигмою "Придбання ресурсів — ініціалізація" (RAII).

По суті, RAII означає, що будь-який ресурс, який потрібно отримати, повинен бути прив'язаний до життєвого циклу об'єкта. Це включає відкриття файлів, потоки, вказівники тощо. Наприклад:

// Тривалість життя ресурсу прив'язана до об'єкта. Коли об'єкт виходить  
// із області видимості, ми звільняємо ресурс.  
class Object {  
public:  
 Object(ResourceHandle* resource_handle) : _resource_handle(resource_handle) {};  
 ~Object() {  
 delete _resource_handle;   
 }  
private:  
 ResourceHandle* _resource_handle;  
};

У контексті виключень це означає, що нам не потрібен блок finally. Ресурси повинні звільнятися автоматично, коли стек розгортається.

Розгортання стека

Уявімо, що у нас є блок try/catch. Усередині тіла блоку try ми викликаємо функцію, яка викликає функцію, яка створює об'єкт і так далі, поки в середині ланцюга не буде кинуто виключення, яке відповідає блоку catch.

Програма тоді піднімається по ланцюгу викликів, поки не досягне блоку catch (якщо на шляху немає інших блоків).
As it does this it destroys all of the automatic variables in the scope, in reverse order of construction, until it reaches the catch. This is stack unwinding (a great example is here).

Це автоматично підкреслює важливість правильного управління ресурсами об'єктами. Якщо цього не зробити, ресурси не обов'язково будуть звільнені правильно після виникнення виключення.

Перевірки

Варто звернути увагу на використання assert. Це інструмент для налагодження, який використовується для зупинки програми і виведення відповідної інформації, якщо щось пішло не так. Наприклад:

auto positive_number = generate_positive_number();  
assert(positive_number > 0);

Перевірки відрізняються від виключень тим, що вони використовуються лише для того, щоб зробити проблеми в логіці програми більш очевидними під час налагодження.

Функціональна обробка помилок

Виключення можуть бути певною мірою змішаним благом. З одного боку, ми можемо кинути виключення, а потім перехопити його через n рівнів ланцюга викликів, при цьому жоден з проміжних рівнів нічого про це не знає.

Однак через відсутність детальних специфікацій для виключень (лише noexcept), складно знати, які виключення може кинути функція, щоб правильно з ними працювати. Ми можемо використати загальну конструкцію catch(..) високо в ланцюгу викликів, але це не є оптимальним варіантом.

Натомість ми можемо скористатися деякими функціональними концепціями для обробки помилок. Почнемо з опціональних значень.

// Тут у нас є функція для парсингу  
// цілого числа з рядка. Ми бачимо, що під 
// капотом зазвичай викидається виключення. Однак,  
// ми можемо замінити це на повернення порожнього  
// опціонала  
optional parse_int(string s) {  
 try {  
 return stoi(s);  
 } catch(const invalid_argument &e) {  
 return {};  
 }  
}  

// Тут ми викликаємо функцію парсингу і  
// перевіряємо, чи опціонал містить значення. Якщо містить,  
// то парсинг був успішним, в іншому випадку  
// ми бачимо, що він не вдалося.  
void optional_error_handling() {  

 auto int_optional = parse_int("Error!");  
 if(!int_optional.has_value()) {  
 cout << "Всім паніка!" << endl;  
 }  

}

Опціонали не призначені для заміни виключень, вони призначені для ситуацій, коли немає нічого валідного, що ми могли б повернути (зазвичай ми б повернули null), і вони допомагають уникнути помилок з нульовими вказівниками.

У наведеному вище прикладі одна з можливих API для парсингу int з string могла б повертати null, якщо рядок невалідний. Ми замінюємо це на опціонал, змушуючи споживачів API працювати з помилковим випадком.

Однак, у разі невдачі, ми не маємо інформації про те, чому функція не вдалася, а лише порожній опціонал.

Альтернативою може бути використання variant. Це дозволяє нам виразити як успішний, так і неуспішний випадок.

// Ця методика повертає int, якщо ми успішно  
// його парсимо, в іншому випадку вона повертає рядок  
// з помилкою  
variant variant_parse_int(string s) {  
 try {  
 return stoi(s);  
 } catch(const invalid_argument &e) {  
 return "Невірний ввід";  
 }  
}  

void variant_exception() {  

 // Тут ми намагаємось парсити int і перевіряємо,  
 // який тип був повернений. Якщо це тип помилки (тобто  
 // рядок), ми обробляємо це.  
 auto int_variant = variant_parse_int("Error!");  
 if(holds_alternative<int>(int_variant)) {  
 cout << "Всім паніка!" << endl;  
 cout << "Значення варіанту: " << get<int>(int_variant) << endl;  
 }  

}

Ми неявно припускаємо, що int означає успіх, а string — невдачу. Чи не було б чудово, якби це можна було зробити явним?

На щастя, ми маємоexpected, введений у C++23.

// Ця версія парсингу цілих чисел може повернути два  
// значення: успішне значення або помилку.  

expected parse_int(string s) {  
 try {  
 return stoi(s);  
 } catch(const invalid_argument &e) {  
 return unexpected("Невірний ввід");  
 }  
}  

// Тут ми перевіряємо, чи є значення  
// (успіх) або помилка (невдача).   
// Зверніть увагу, це відрізняється від variant, оскільки  
// ми маємо явні випадки успіху та помилки, нам не потрібно  
// здогадуватися, який з них є який.  
int main() {  

 auto n = parse_int("Ой-ой!");  
 if(!n.has_value()) {  
 cout << n.error() << endl;  
 }  

 return 0;  
}

Це більш явна, орієнтована на обробку помилок версія variant, яку ми потребували! Інша перевага цього підходу полягає в можливості комбінувати його з іншими частинами функціонального програмування, що сприяє чистоті коду. Я продемонстрував це за допомогою transform нижче.

auto n = parse_int("1");  

// Тут ми можемо викликати функції на будь-яких валідних результатах  
// в 'expected'. Якщо їх немає, ці функції  
// будуть пропущені.  
auto m = n.transform([](auto a){return a * a;})  
 .transform([](auto a){return a + a;});  

// Це виведе 2  
if(m.has_value()) {  
 cout << m.value() << endl;  
}

Для подальшого читання про функціональну обробку помилок, я рекомендую блог Microsoft тут.

Висновок

На завершення, ми обговорили кілька різних способів обробки помилок у C++. Через виключення, функціональним підходом та іншими методами!

Перекладено з: A Crash Course in C++: Error Handling

Leave a Reply

Your email address will not be published. Required fields are marked *