При написанні програм виникнення помилок майже неминуче. Помилки в мові C++ діляться на дві категорії: синтаксичні та семантичні.
Синтаксичні помилки
Синтаксична помилка виникає при порушенні правил граматики мови C++. Наприклад:
якщо 7 не дорівнює 8, то пишемо "not equal";
Хоча цей стейтмент нам (людям) зрозумілий, комп’ютер не зможе його коректно обробити. Відповідно до правил граматики мови C++, коректно буде:
1 2 |
if (7 != 8) std::cout << "not equal"; |
Синтаксичні помилки майже завжди компілятор ловить і їх зазвичай легко виправити. Тому про них не варто сильно турбуватися.
Семантичні помилки
Семантична (або “смислова”) помилка виникає, коли код синтаксично правильний, але виконує не те, що потрібно програмісту. Наприклад:
1 2 |
for (int count=0; count <= 4; ++count) std::cout << count << " "; |
Можливо, програміст хотів, щоб вивелося 0 1 2 3
, але насправді виведеться 0 1 2 3 4
.
Семантичні помилки компілятор не ловить і вони можуть мати різний вплив: деякі можуть взагалі не відображатися, що призведе до неправильних результатів, до пошкодження даних або взагалі до збою програми. Тому про семантичні помилки турбуватися вже доведеться.
Вони можуть виникати декількома способами. Однією з найбільш поширених семантичних помилок є логічна помилка. Логічна помилка виникає, коли програміст неправильно програмує логіку виконання коду. Наприклад, вищенаведений фрагмент коду має логічну помилку. Ось ще один приклад:
1 2 |
if (x >= 4) std::cout << "x is greater than 4"; |
Що відбудеться, якщо x
дорівнюватиме 4
? Умова виконається як true
, а програма виведе x is greater than 4
. Логічні помилки іноді буває досить-таки важко виявити.
Іншою поширеною семантичною помилкою є помилкове припущення. Помилкове припущення виникає, коли програміст припускає, що щось буде істинним або хибним, а виявляється навпаки. Наприклад:
1 2 3 4 5 6 7 |
std::string hello = "Hello, world!"; std::cout << "Enter an index: "; int index; std::cin >> index; std::cout << "Letter #" << index << " is " << hello[index] << std::endl; |
Помітили потенційну проблему тут? Передбачається, що користувач введе значення між 0
і довжиною рядка Hello, world!
. Якщо ж користувач введе від’ємне число або число, яке більше довжини зазначеного рядка, то index
виявиться за межами діапазону масиву. В цьому випадку, оскільки ми просто виводимо значення по індексу, результатом буде вивід сміття на екран (за умови, що користувач введе число поза діапазону). Але в інших випадках помилкове припущення може призвести і до модифікації значень змінних, і до збою в програмі.
Безпечне програмування — це методика розробки програм, яка включає аналіз областей, де можуть бути допущені помилкові припущення, і написання коду, який виявляє і обробляє будь-який випадок такого порушення, щоб звести до мінімуму ризик виникнення збою або пошкодження програми.
Визначення помилкових припущень
Виявляється, ми можемо знайти майже всі припущення, які необхідно перевірити в одному з наступних трьох місць:
При виклику функції, коли caller може передати некоректні або семантично безглузді аргументи.
При поверненні значень функцією, коли значення, що повертаються, можуть бути індикаторами виконання (сталася помилка чи ні).
При обробці даних вводу (або від користувача, або з файлу), коли ці дані можуть бути не того типу, що потрібно.
Тому, дотримуючись безпечного програмування, потрібно слідувати наступним трьом правилам:
У верхній частині кожної функції переконайтеся, що всі параметри мають відповідні значення.
Після повернення функцією значення, перевірте значення, що повертається (якщо воно є), і будь-які інші механізми повідомлення про помилки на предмет того, чи відбулася помилка.
Перевіряйте дані вводу на відповідність очікуваному типу даних і його діапазону.
Розглянемо приклади проблем:
Проблема №1: При виклику функції caller може передати некоректні або семантично безглузді аргументи:
1 2 3 4 |
void printString(const char *cstring) { std::cout << cstring; } |
Чи можете ви визначити потенційну проблему тут? Справа в тому, що caller може передати нульовий вказівник замість допустимого рядка C-style. Якщо це станеться, то в програмі відбудеться збій. Ось як правильно (з перевіркою параметру функції на те, чи не є він нульовим):
1 2 3 4 5 6 |
void printString(const char *cstring) { // Виводимо cstring при умові, що він не нульовий if (cstring) std::cout << cstring; } |
Проблема №2: Значення, що повертається, може вказувати на виниклу помилку:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <iostream> #include <string> int main() { std::string hello = "Hello, world!"; std::cout << "Enter a letter: "; char ch; std::cin >> ch; int index = hello.find(ch); std::cout << ch << " was found at index " << index << '\n'; return 0; } |
Чи можете ви визначити потенційну проблему тут? Користувач може ввести символ, який не знаходиться у рядку hello
. Якщо це станеться, то функція find() поверне індекс -1
, який і виведеться. Правильно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
#include <iostream> #include <string> int main() { std::string hello = "Hello, world!"; std::cout << "Enter a letter: "; char ch; std::cin >> ch; int index = hello.find(ch); if (index != -1) // обробляємо випадок, коли функція find() не знайшла символ в рядку hello std::cout << ch << " was found at index " << index << '\n'; else std::cout << ch << " wasn't found" << '\n'; return 0; } |
Проблема №3: При обробці даних вводу (або від користувача, або з файлу), ці дані можуть бути не того типу і діапазону, що потрібно. Розберемо програму з попереднього прикладу, код якої дозволяє проілюструвати ситуацію з обробкою вводу:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <iostream> #include <string> int main() { std::string hello = "Hello, world!"; std::cout << "Enter an index: "; int index; std::cin >> index; std::cout << "Letter #" << index << " is " << hello [index] << std::endl; return 0; } |
Ось як правильно (з перевіркою користувацького вводу):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include <iostream> #include <string> int main() { std::string hello = "Hello, world!"; int index; do { std::cout << "Enter an index: "; std::cin >> index; // Обробляємо випадок, коли користувач ввів нецілочисельне значення if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(32767, '\n'); index = -1; // переконуємося, що index має неприпустиме значення, щоб цикл продовжувався continue; // цей continue може здатися тут зайвим, але він явно вказує на готовність припинити виконання цієї ітерації циклу } } while (index < 0 || index >= hello.size()); // обробляємо випадок, коли користувач ввів значення поза діапазону std::cout << "Letter #" << index << " is " << hello [index] << std::endl; return 0; } |
Зверніть увагу, тут перевірка дворівнева:
По-перше, ми повинні переконатися, що користувач ввів значення того типу даних, який ми використовуємо.
По-друге, це значення повинно знаходитися в діапазоні масиву.
Обробка помилкових припущень
Тепер, коли ви знаєте, де зазвичай виникають помилкові припущення, давайте поговоримо про способи, які дозволяють їх уникнути. Одного універсального способу виправлення всіх помилок немає, все залежить від характеру проблеми.
Але все ж є декілька способів обробки помилкових припущень:
Спосіб №1: Пропустіть код, який напряму залежить від правильності припущення:
1 2 3 4 5 6 |
void printString(const char *cstring) { // Виводимо cstring тільки при умові, що він не є нульовим if (cstring) std::cout << cstring; } |
У прикладі, наведеному вище, якщо cstring
виявиться NULL
, то ми нічого не будемо виводити. Ми пропустили той код, який безпосередньо залежить від значення cstring
і який з ним працює (в коді ми просто виводимо цей cstring
). Це може бути хорошим варіантом, якщо пропущений стейтмент не є критичним і не впливає на логіку програми. Основний недолік при цьому полягає в тому, що caller або користувач не має можливості визначити, що щось пішло не так.
Спосіб №2: Повертайте код помилки з функції назад в caller і дозволяйте caller-у опрацювати цю помилку:
1 2 3 4 5 6 7 8 |
int getArrayValue(const std::array &array, int index) { // Використовуємо умову if для виявлення помилкового припущення if (index < 0 || index >= array.size()) return -1; // повертаємо код помилки назад в caller return array[index]; } |
Тут функція поверне -1
, якщо caller передасть некоректний index
. Повернення енумератора в якості коду помилки буде кращим варіантом.
Спосіб №3: Якщо потрібно негайно завершити програму, то використовуйте функцію exit(), яка знаходиться в заголовку cstdlib, для повернення коду помилки назад в операційну систему:
1 2 3 4 5 6 7 8 9 10 |
#include <cstdlib> // для exit() int getArrayValue(const std::array &array, int index) { // Використовуємо умову if для виявлення помилкового припущення if (index < 0 || index >= array.size()) exit(2); // завершуємо програму і повертаємо код помилки (2) назад в операційну систему return array[index]; } |
Якщо caller передасть некоректний index
, то програма негайно завершить своє виконання і передасть код помилки 2
назад в операційну систему.
Спосіб №4: Якщо користувач ввів дані не того типу, що потрібно — попросіть користувача ввести дані ще раз:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
#include <iostream> #include <string> int main() { std::string hello = "Hello, world!"; int index; do { std::cout << "Enter an index: "; std::cin >> index; // Обробляємо випадок, коли користувач ввів нецілочисельне значення if (std::cin.fail()) { std::cin.clear(); std::cin.ignore(32767, '\n'); index = -1; // переконуємося, що index має неприпустиме значення, щоб цикл продовжувався continue; // цей continue може здатися тут зайвим, але він явно вказує на готовність припинити виконання цієї ітерації циклу } } while (index < 0 || index >= hello.size()); // обробляємо випадок, коли користувач ввів значення поза діапазону std::cout << "Letter #" << index << " is " << hello [index] << std::endl; return 0; } |
Спосіб №5: Використовуйте cerr.
cerr — це об’єкт виводу (як і cout), який знаходиться в заголовку iostream і виводить повідомлення про помилки в консоль (як і cout), але тільки ці повідомлення можна ще і перенаправити в окремий файл з помилками. Тобто основна відмінність cerr від cout полягає в тому, що cerr цілеспрямовано використовується для виводу повідомлень про помилки, тоді як cout для виводу всього іншого. Наприклад:
1 2 3 4 5 6 7 8 |
void printString(const char *cstring) { // Виводимо cstring при умові, що він не є нульовим if (cstring) std::cout << cstring; else std::cerr << "function printString() received a null parameter"; } |
У прикладі, наведеному вище, ми не тільки пропускаємо код, який безпосередньо залежить від правильності припущення, але також реєструємо помилку, щоб користувач міг пізніше визначити, чому програма виконується не так, як потрібно.
Спосіб №6: Якщо ви працюєте в якомусь графічному середовищі, то поширеною практикою є вивід спливаючого вікна з кодом помилки, а потім негайне завершення програми. Те, як це зробити, залежить від конкретного середовища розробки.