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 и понять: «ага, вот почему условие не сработало».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ