1. Навіщо знати про NaN і Infinity
Коли ви лише починаєте працювати з double, може здаватися, що це «звичайні числа, тільки з крапкою». Але арифметика дійсних чисел має одну особливість: деякі обчислення не завершують програму аварійно, а дають спеціальні значення. І програма далі працює так, ніби нічого не сталося. Це зручно для наукових розрахунків, але дуже небезпечно для прикладного коду, де ви очікуєте простого правила: «якщо сталася помилка — зупинися».
Три головні «особливі гості» сьогодні:
- Infinity (додатна нескінченність)
- -Infinity (відʼємна нескінченність)
- NaN (Not a Number, «не число»)
Їх називають «тихими», бо вони легко просочуються в розрахунки: ви записуєте формулу, виводите результат, а там раптом nan або inf. І це ще пів біди. Гірше, коли NaN потрапляє в if, а розгалуження починають поводитися так, ніби програма втратила звʼязок із реальністю.
2. Infinity і -Infinity: звідки беруться
Простіше кажучи, Infinity зʼявляється там, де ви намагаєтеся отримати «занадто велике число» або ділите на нуль у дійсній арифметиці. У математиці на нуль ділити не можна, а компʼютер у режимі double часто відповідає: «Гаразд, нехай буде нескінченність» — і йде далі. Саме це і є типовим джерелом «тихих» проблем: помилка вже сталася, але програма не зупинилася.
Найпростіший приклад — ділення double на нуль. Одразу важливе уточнення: цілочисельне ділення на нуль — це окрема історія, і зазвичай вона закінчується погано. Зараз ми говоримо саме про double.
#include <iostream>
int main() {
double a = 1.0;
double b = 0.0;
double x = a / b;
std::cout << x << '\n'; // inf (часто виводиться як "inf")
}
Можна отримати й відʼємну нескінченність:
#include <iostream>
int main() {
double a = -1.0;
double b = 0.0;
std::cout << (a / b) << '\n'; // -inf
}
Є й інший сценарій: деякі обчислення просто «вибухають» за масштабом. Наприклад, дуже великі значення після багатьох множень можуть вийти за межі діапазону представлення double. Тоді замість «величезного числа» ви отримаєте inf. Така поведінка залежить від платформи й налаштувань, але саму ідею варто памʼятати: у double є межа, і «занадто велике» перетворюється на нескінченність.
3. NaN: звідки береться «не число»
NaN — це випадок, коли результат не просто «дуже великий», а узагалі не має сенсу як дійсне число в межах звичайної арифметики. Уявіть калькулятор, який замість відповіді пише: «Я розгубився». Це і є NaN.
Найтиповіше джерело NaN — операції на кшталт 0.0 / 0.0 або «невизначені» дії з нескінченностями, наприклад inf - inf. Ви можете навіть не помітити, як до цього дійшли: спочатку обчислили знаменник, він став нулем через похибку або логіку, потім поділили — і ось уже в даних зʼявився NaN.
#include <iostream>
int main() {
double zero = 0.0;
double x = zero / zero;
std::cout << x << '\n'; // nan (часто виводиться як "nan")
}
Ще один класичний приклад:
#include <iostream>
#include <limits>
int main() {
double inf = std::numeric_limits<double>::infinity();
double x = inf - inf;
std::cout << x << '\n'; // nan (часто)
}
Зверніть увагу: тут ми використали std::numeric_limits<double>::infinity() — це стандартний спосіб отримати «справжню» нескінченність. А сам факт, що стандартна бібліотека окремо розглядає випадки NaN і inf навіть на рівні форматування, добре показує: це не «екзотика», а звичайна частина моделі дійсних чисел.
4. Чому NaN небезпечний: ламає порівняння і if
Ось тут починається найцікавіше — і трохи підступне. Infinity ще можна сприймати як «дуже-дуже велике число», хоча це теж не зовсім число. А NaN — це значення, яке поводиться так, ніби воно усім незадоволене і ні з чим не погоджується.
Майже будь-яке порівняння з NaN повертає false.
Навіть такі варіанти:
- NaN == NaN -> false
- NaN < 0.0 -> false
- NaN > 0.0 -> false
У результаті умова if (x < 0) раптом не спрацьовує, хоча вам здається, що x явно «поганий». Причина проста: x — не число, а отже порівняння не має сенсу.
Давайте подивимося, як це виглядає в коді.
#include <iostream>
#include <limits>
int main() {
double x = std::numeric_limits<double>::quiet_NaN();
std::cout << (x == x) << '\n'; // 0 (false)
std::cout << (x != x) << '\n'; // 1 (true)
}
Так, це схоже на збій у матриці, але насправді це спеціальна властивість NaN. Саме вона дає нам простий «детектор NaN» на базовому рівні: якщо x != x, то x — NaN.
Щоб закріпити матеріал, ось невелика таблиця:
| Вираз | Результат |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Саме тому це називають «тихим збоєм логіки»: програма не падає, але умова раптом перестає працювати так, як ви очікуєте.
5. Як отримати NaN і Infinity через numeric_limits
У реальній програмі ви зазвичай не хочете створювати NaN і Infinity. Ви хочете їх виявити й ухвалити рішення: вивести помилку, зупинити розрахунок або попросити користувача ввести інші дані.
Але для діагностики корисно вміти отримувати ці значення коректно — щоб тестувати програму й розуміти її поведінку.
Для цього є заголовок <limits> і шаблон std::numeric_limits<T>.
#include <iostream>
#include <limits>
int main() {
double inf = std::numeric_limits<double>::infinity();
double nan = std::numeric_limits<double>::quiet_NaN();
std::cout << inf << '\n'; // inf
std::cout << nan << '\n'; // nan
}
Чому це краще, ніж просто «зробити 1.0/0.0»? Тому що numeric_limits — це явний і читабельний спосіб: ви одразу бачите в коді намір. А 1.0/0.0 виглядає як помилка, і ви в майбутньому або ваш викладач почнете нервово чухати потилицю.
6. Як NaN/Infinity розповзаються по обчисленнях
У NaN є неприємна суперздібність: він рідко залишається на одному місці. Зазвичай він перетворює на NaN все, до чого дотягнеться. Умовно кажучи, якщо у формулі бере участь NaN, то результат найчастіше теж стане NaN. А через кілька кроків ви вже не розумієте, де саме він зʼявився.
Це зручно показати «ланцюжком»:
flowchart TD
A["Помилка або особливий випадок: ділення на 0"] --> B["Отримали NaN/Infinity"]
B --> C["Виконали ще кілька обчислень"]
C --> D["NaN/Infinity потрапили до нових змінних"]
D --> E["if/цикл/умова починає поводитися дивно"]
E --> F["Баг виглядає так, ніби 'логіка зламалася сама'"]
Мініприклад «зараження»:
#include <iostream>
#include <limits>
int main() {
double nan = std::numeric_limits<double>::quiet_NaN();
double a = nan + 10.0;
double b = a * 2.0;
std::cout << a << '\n'; // nan
std::cout << b << '\n'; // nan
}
Infinity теж «розповзається», але поводиться передбачуваніше: inf + 1 залишається inf, inf * 2 теж залишається inf, і лише деякі комбінації, наприклад inf - inf, дають NaN.
7. Захист у консольному застосунку
Уявімо, що на цьому етапі курсу ми пишемо простий консольний застосунок — «мінікалькулятор команд», який зчитує команду й два числа. Раніше ми могли рахувати на int, а тепер починаємо переходити до double.
Нехай команди такі: add, sub, mul, div. Саме команда div тут найнебезпечніша: якщо знаменник 0, можна отримати inf або nan і тихо «поїхати далі».
Розгляньмо мінімальний приклад: обчислюємо result, а потім перевіряємо, чи не є він «особливим». Поки що без більш просунутих функцій на кшталт std::isnan — їх можна знайти в <cmath>, але ми тримаємося ближче до базової моделі. Використаємо те, що вже знаємо з лекції: порівняння з нескінченністю та трюк x != x.
#include <iostream>
#include <limits>
#include <string>
int main() {
std::string op;
double a = 0.0, b = 0.0;
std::cin >> op >> a >> b;
double result = (op == "div") ? (a / b) : 0.0;
const double inf = std::numeric_limits<double>::infinity();
if (result != result)
std::cout << "Error: NaN\n";
else if (result == inf || result == -inf)
std::cout << "Error: Infinity\n";
else
std::cout << result << '\n';
}
Ось що тут важливо.
Спочатку ми отримали inf через numeric_limits, щоб не писати «магічні» обчислення. Потім перевірили result != result — це виявляє NaN. Далі порівняли результат із inf та -inf, щоб виявити нескінченність. Якщо все нормально — друкуємо результат.
Так, це ще не ідеальна архітектура: логіка вибору операції поки що захована в одному рядку. Але на поточному рівні курсу нам важливіше побачити сам принцип: після обчислень із double іноді варто перевіряти результат.
Демонстрація «тихої» помилки без перевірок
Щоб краще відчути проблему, порівняймо це з наївним варіантом: «порахували й одразу використовуємо в умові».
#include <iostream>
int main() {
double x = 0.0 / 0.0; // nan
if (x < 0.0) {
std::cout << "negative\n";
} else {
std::cout << "not negative\n"; // not negative (хоча x узагалі не число)
}
}
Якщо ви очікували, що «погане значення» потрапить у гілку negative або викличе помилку, — ні. Воно просто робить порівняння беззмістовним, тож умова дає false.
8. Корисні нюанси
Infinity у логіці: не просто «дуже велике»
Легко потрапити в пастку й сприймати inf як «ну, це майже 1e9999». Але inf має поведінку, яка ламає «інтуїтивні» перевірки діапазонів.
Наприклад, ви можете поставити обмеження: «якщо результат більший за 1e6, то це занадто багато». З inf це справді спрацює: inf > 1e6 дасть true. І здається, що все гаразд. Але якщо далі почати робити зворотні операції, можна отримати зовсім не те, чого ви очікуєте.
Як inf перетворюється на 0.0 і чому це небезпечно
Іноді люди намагаються «нормалізувати» число, обчислюючи 1.0 / x. Якщо x раптом дорівнює inf, то 1.0 / inf перетворюється на 0.0. Із «катастрофічно великого» значення ви раптом отримуєте «акуратний нуль». Потім він бере участь у діленні, а далі по ланцюжку вже зʼявляється NaN. Такі ланцюжки неприємні саме тим, що кожен крок виглядає цілком законно.
Мініприклад:
#include <iostream>
#include <limits>
int main() {
double inf = std::numeric_limits<double>::infinity();
double x = 1.0 / inf;
std::cout << x << '\n'; // 0
}
Нуль зʼявився «чесно», але якщо ваша логіка не очікувала нулів або ділення на нуль, далі буде весело.
9. Типові помилки під час роботи з NaN і Infinity
Помилка № 1: намагатися перевірити NaN через x == std::numeric_limits<double>::quiet_NaN().
Це виглядає логічно: «порівняю з NaN — і все зрозумію». Але NaN спеціально влаштований так, що NaN == NaN дає false. Тому така перевірка ніколи не спрацює. На базовому рівні простіше запамʼятати трюк x != x, а в більш «бібліотечному» стилі, до якого ми ще прийдемо, використовують спеціальні функції перевірки.
Помилка № 2: думати, що ділення на нуль завжди аварійно зупиняє програму.
Для цілих чисел усе справді часто закінчується погано, а от у double ви можете отримати inf або nan, і програма продовжить виконуватися. Це небезпечно тим, що помилка не «кричить», а тихо псує дані.
Помилка № 3: використовувати NaN в умовах як звичайне число.
Люди пишуть if (x < 0.0), if (x >= limit), if (x == 0.0) — і очікують, що «погане значення» кудись потрапить. Але NaN майже завжди змушує порівняння повертати false. Тому розгалуження починають спрацьовувати «за замовчуванням», і створюється відчуття, ніби if зламаний, хоча насправді зламане саме значення.
Помилка № 4: не перевіряти результати обчислень на «особливі значення» там, де є ризик.
Якщо у формулі є ділення, добування кореня, логарифм або обчислення на межі допустимого діапазону, то перевірка результату або вхідних даних стає частиною нормальної гігієни коду. Особливо в навчальних мініпрограмах це допомагає раніше виявляти помилки: краще чесно вивести Error: Infinity, ніж отримати nan і потім шукати його походження по всій програмі.
Помилка № 5: плутати «гарний вивід» із «коректними даними».
Іноді std::cout друкує nan/inf явно, а іноді, залежно від налаштувань форматування, вивід може бути менш помітним. У будь-якому разі форматування — це лише зовнішній вигляд. Перевіряти коректність потрібно за значеннями, а не за тим, наскільки «симпатично» це надрукувалося в консолі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ