1. Вступ
Коли ви вперше бачите double, рука сама тягнеться написати if (a == b). Це природно: мозок звик, що «дорівнює» — це просто «дорівнює». Проблема в тому, що для дійсних чисел == перевіряє не «математичну рівність», а збіг конкретних значень, які можна зберегти в памʼяті. А числа на кшталт 0.1 у двійковій системі часто зберігаються як найближче значення, а не як «ідеальні 0.1». Тому в реальних обчисленнях == легко стає генератором випадкових сюрпризів.
Найвідоміший приклад:
#include <iostream>
int main() {
double a = 0.1 + 0.2;
double b = 0.3;
std::cout << (a == b) << '\n'; // зазвичай 0
}
Чому «зазвичай»? Тому що точний результат залежить від деталей подання й виведення, але загальна ідея незмінна: не варто розраховувати на точну рівність результатів обчислень із дробами.
Варто зауважити одну тонкість: якщо ви порівнюєте значення, які самі задали однаковими літералами (наприклад, double x = 1.5; double y = 1.5;), == цілком може бути доречним. Проблеми починаються там, де є арифметика, цикли, формули, перетворення, уведення користувача тощо — тобто майже всюди, де програма справді робить щось корисне.
2. Epsilon-порівняння: що означає abs(a - b) < eps
Щоб не боротися з похибкою, ми вводимо просту «інженерну» ідею: числа вважаються рівними, якщо вони достатньо близькі. Цю «достатню близькість» ми задаємо числом eps (epsilon). У цей момент математика трохи зітхає, зате інженерія радісно потирає руки: тепер ми порівнюємо не ідеал, а допуск — як на кресленнях деталей.
Базова формула виглядає так:
\[ |a - b| < eps \]
У коді це виглядає так (поки без <cmath>, бо наступна лекція саме про нього):
#include <iostream>
int main() {
double a = 0.1 + 0.2;
double b = 0.3;
double diff = a - b;
if (diff < 0.0) diff = -diff; // власний abs
const double eps = 1e-12;
std::cout << (diff < eps) << '\n'; // зазвичай 1
}
Тут дуже важливо правильно розуміти сенс eps: ми не доводимо «математичну рівність», а ухвалюємо практичне рішення: «у межах точності задачі це одне й те саме». У задачах про гроші, координати, відсотки, фізику чи геометрію це майже завжди саме те, що потрібно.
І так, це той самий момент, коли програміст дорослішає: перестає запитувати «чому не дорівнює» і починає запитувати «наскільки близько».
3. Як вибрати eps: домовленість, а не «магічне число»
Вибираючи eps, новачки зазвичай впадають у дві крайнощі: або ставлять 0.0000000000000000000001 (щоб уже напевно!), або беруть 0.1 (бо «яка різниця»). Насправді eps — це частина умов вашої задачі. Він залежить від того, у яких одиницях ви рахуєте, які у вас масштаби чисел і яка точність узагалі має сенс для цих даних.
Уявіть, що ви вимірюєте відстань між містами в кілометрах. Там eps = 1e-12 — точність без практичного сенсу: так не працює ані введення, ані карта, ані GPS. А якщо ви виконуєте обчислення у фізиці з малими величинами, навпаки, eps = 1e-3 може виявитися надто грубим.
Зручно тримати в голові таку табличку. Це не закон, а радше орієнтир для новачка:
| Контекст задачі (приблизно) | Розумний порядок eps |
|---|---|
| «шкільна математика», прості обчислення на double | 1e-12 … 1e-9 |
| координати/геометрія у «звичайних» одиницях | 1e-9 … 1e-6 |
| гроші/ціни (якщо вже ви рахуєте в double, що доволі спірно) | 1e-6 … 1e-2 (залежить від валюти/округлення) |
| значення з шумом (вимірювання, датчики) | залежить від рівня шуму в даних |
Ключовий принцип: eps має бути співмірним зі змістом задачі. Якщо ви виводите число з точністю до 2 знаків після коми, то eps = 1e-12 виглядає як спроба виміряти температуру чайника мікроскопом.
Дещо пізніше, уже на більш просунутому рівні, зʼявиться поняття відносної похибки, коли eps залежить від масштабу чисел. Але сьогодні наша мета — навчитися базової стійкості без перевантаження теорією.
4. Функції для стійких порівнянь
Порівняння через epsilon зручно винести в окрему функцію, бо інакше код швидко перетворюється на набір змінних diff і випадкових констант. Почнемо розвивати наш сьогоднішній мініпроєкт: консольний «Triangle Inspector», який зчитує сторони трикутника й визначає його тип. Сьогодні нам потрібна перша цеглинка — функція порівняння.
Майже рівні: almostEqual і abs власноруч
Спочатку зробимо власний abs для double (лише на базових операторах):
double absDouble(double x) {
if (x < 0.0) return -x;
return x;
}
Тепер — «майже дорівнює»:
bool almostEqual(double a, double b, double eps) {
return absDouble(a - b) < eps;
}
Мініперевірка в main:
#include <iostream>
int main() {
const double eps = 1e-12;
double a = 0.1 + 0.2;
std::cout << almostEqual(a, 0.3, eps) << '\n'; // 1 (true)
}
Зверніть увагу: eps ми передаємо параметром, а не ховаємо всередині функції. Це зручно, бо різні частини програми можуть по-різному розуміти «достатню точність».
Невелика примітка на майбутнє: у стандартній бібліотеці є std::abs, і вона має багато перевантажень для різних типів. Але сьогодні ми свідомо пишемо «ручний» варіант, щоб не залежати від наступної лекції про <cmath>.
Порівняння на межі: менше/більше із «зазором»
Після того як ви прийняли ідею «майже дорівнює», виникає інше питання: а як бути з умовами на кшталт x < limit? Адже поблизу межі може виникнути ситуація: «має бути рівно limit», але вийшло limit + 1e-16, і програма раптом переходить у «більше», хоча за змістом це «те саме». Саме тому, коли порівняння відбувається біля порога, ми часто залишаємо «зазор» через epsilon.
Є простий практичний набір правил:
- «строго менше» робимо як x < limit - eps
- «строго більше» робимо як x > limit + eps
- «приблизно дорівнює» — як abs(x - limit) < eps
Це можна оформити у вигляді функцій, щоб if читалися майже як звичайний текст:
bool definitelyLess(double a, double b, double eps) {
return a < b - eps;
}
bool definitelyGreater(double a, double b, double eps) {
return a > b + eps;
}
І короткий приклад:
#include <iostream>
int main() {
const double eps = 1e-12;
double x = 1.0;
double limit = 1.0;
std::cout << definitelyLess(x, limit, eps) << '\n'; // 0
}
Такий підхід особливо корисний у розгалуженнях, де на межі змінюється поведінка алгоритму. Інакше ви отримаєте ефект «тремтіння» на порозі: то одна гілка, то інша — просто через мікроскопічний хвіст похибки.
5. Практичний приклад: визначаємо прямокутний трикутник
Тепер зберемо все в нашому мініпроєкті «Triangle Inspector». Формулювання просте: трикутник прямокутний, якщо виконується теорема Піфагора. Але якщо сторони мають тип double, перевірка через == може підвести, особливо якщо сторони отримані з обчислень (або введені з десятковою крапкою, або надійшли з попередніх формул).
Нам потрібно перевірити:
\[ a^2 + b^2 \approx c^2 \]
Спочатку зробимо невелику «нормалізацію»: знайдемо найбільшу сторону й назвемо її c. Без сортування й алгоритмів — лише парою if, бо ми поки що на базовому C++.
void sort3(double& a, double& b, double& c) {
if (a > b) { double t = a; a = b; b = t; }
if (b > c) { double t = b; b = c; c = t; }
if (a > b) { double t = a; a = b; b = t; }
}
Перевірка прямокутності через epsilon:
bool isRightTriangle(double a, double b, double c, double eps) {
sort3(a, b, c);
return almostEqual(a * a + b * b, c * c, eps);
}
А тепер — main, який зчитує сторони й виводить результат:
#include <iostream>
int main() {
double a = 0.0, b = 0.0, c = 0.0;
std::cin >> a >> b >> c;
const double eps = 1e-9;
std::cout << isRightTriangle(a, b, c, eps) << '\n'; // 1 для 3 4 5
}
Якщо ввести 3 4 5, ви очікуєте 1 (true). Якщо ввести щось на кшталт 1 1 1, очікуєте 0. І головне: якщо ви отримаєте числа з якихось обчислень, наприклад із масштабування, нормалізації або ділення, цей код поводитиметься передбачувано, а не за принципом «сьогодні так, завтра ні».
Чому eps тут 1e-9, а не 1e-12? Тому що ми працюємо з квадратами, і похибка тут може візуально «зрости». Це не строгий розрахунок помилки, а практичний вибір, який часто виявляється цілком доречним для геометрії з введенням користувача.
6. Що робити з NaN і чому раптом усе false
Після лекції про NaN важливо розуміти: epsilon-порівняння не є «анти-NaN-закляттям». Якщо десь в обчисленнях у вас зʼявився NaN, то вираз a - b теж стане NaN, а далі absDouble(NaN) залишиться NaN, і порівняння виду NaN < eps дасть false. Збоку це схоже на «нічого не працює», але насправді така поведінка чесна: NaN не можна порівнювати як звичайне число.
На базовому рівні можна провести дуже просту перевірку: число x є NaN, якщо x != x. Будь-яке нормальне число дорівнює самому собі, а NaN — ні.
Наприклад, так можна захистити наші функції, не ускладнюючи дизайн:
bool isNaN(double x) {
return x != x;
}
А перед важливими перевірками робити ранній вихід:
bool safeAlmostEqual(double a, double b, double eps) {
if (isNaN(a) || isNaN(b)) return false;
return almostEqual(a, b, eps);
}
Це не «повний захист від усього» (у нас ще є Infinity, та й узагалі — домени функцій), але така перевірка вже робить поведінку програми зрозумілішою: якщо вхідні дані погані, ми не вдаємо, що все нормально.
У наступній лекції, коли ми підключимо <cmath>, зʼявляться більш стандартні інструменти для подібних перевірок, але поки що тримаємося в межах базового матеріалу.
7. Типові помилки під час epsilon-порівняння
Помилка № 1: замінити == на epsilon один раз — і вважати, що проблему розвʼязано назавжди.
Іноді роблять abs(a - b) < 1e-12 в одному місці, а в іншому залишають if (x == 0.0) або if (y == limit). У результаті програма й далі «тремтить» на межах, просто тепер трохи рідше. Якщо ви вже прийняли ідею похибки, усі порівняння поруч з обчисленнями варто робити послідовно.
Помилка № 2: вибрати eps без звʼязку зі змістом задачі.
Часто буває так: хтось побачив в інтернеті 1e-9 і тепер ставить 1e-9 всюди — для відсотків, відстаней, цін і геометрії. eps — це частина вимог до точності. Якщо ви виводите значення з двома знаками після коми, допуск у 1e-12 не робить програму розумнішою — він робить її лише самовпевненішою.
Помилка № 3: забути про модуль різниці й порівнювати просто a - b < eps.
Якщо a менше за b, різниця відʼємна, і умова майже завжди буде істинною (наприклад, -100 < 1e-12 — так). Тому в epsilon-порівнянні завжди потрібен модуль: |a - b|, хай ручний, хай бібліотечний.
Помилка № 4: використовувати epsilon лише для «дорівнює», але не враховувати межі в x < limit.
Навіть якщо ви порівнюєте a і b через epsilon, порогові умови теж можуть ламатися на межі. У реальних задачах x < limit - eps і x > limit + eps часто дають значно стійкішу логіку, ніж «чесне» x < limit.
Помилка № 5: не розуміти, що NaN перетворює порівняння на «усе false».
Якщо десь зʼявився NaN, то і abs(a - b) < eps, і a < b, і a >= b можуть раптом почати повертати false, ламаючи розгалуження. Це не містика і не «компʼютер зламався», а специфіка NaN. Корисно хоча б уміти швидко перевіряти x != x і розуміти: «ага, ось чому умова не спрацювала».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ