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 явно, а иногда (в зависимости от настроек форматирования) вывод может выглядеть не так заметно. В любом случае форматирование — это только внешний вид. Проверять корректность нужно по значениям, а не по тому, «симпатично ли напечаталось» в консоли.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ