Почему 0.1 + 0.2 != 0.3 в C: тип double и числа с плавающей точкой

Почему 0.1 + 0.2 != 0.3 в C: тип double и числа с плавающей точкой Полезное

Число с плавающей точкой в C — это приближенное представление вещественного числа по стандарту IEEE 754: знак, мантисса и порядок (экспонента), упакованные в фиксированное число бит. Ключевое слово тут — «приближенное»: далеко не каждая десятичная дробь укладывается в двоичную мантиссу точно, поэтому 0.1 + 0.2 в C не равно 0.3. Ниже разберем три типа (float, double, long double), почему возникает погрешность, как правильно сравнивать вещественные числа через эпсилон, что делать со спецзначениями inf и nan и какие спецификаторы printf использовать.

Статья узкая: она про числа с плавающей точкой, а не про обзор всех типов данных C. Целые типы и общая типизация — отдельная тема.

Три вещественных типа: float, double, long double

В C есть ровно три типа с плавающей точкой. Различаются они шириной и, как следствие, точностью и диапазоном.

  • float — одинарная точность IEEE 754, обычно 4 байта, около 7 значащих десятичных цифр.
  • double — двойная точность IEEE 754, обычно 8 байт, около 15-16 значащих цифр. Это тип по умолчанию для дробных литералов (3.14 — это double, а 3.14f — float).
  • long double — расширенная точность. Ширина зависит от платформы: на x86/x86-64 с GCC это обычно 80-битный формат x87 (хранится в 12 или 16 байтах), а на ARM (в том числе Apple Silicon) и в MSVC long double часто совпадает с double.

Не полагайтесь на конкретное число байт: печатайте его сами через sizeof. Значащие цифры для типа дают макросы FLT_DIG, DBL_DIG, LDBL_DIG из <float.h>.

Свойство float double long double
Формат IEEE 754 single double extended (зависит от платформы)
Размер, типично 4 байта 8 байт 8, 12 или 16 байт
Бит мантиссы 24 53 64 (формат x87) или как double
Значащих цифр ~7 (FLT_DIG=6) ~15-16 (DBL_DIG=15) до ~18-19 на x86
Диапазон модуля ~1.2e-38 .. 3.4e38 ~2.2e-308 .. 1.8e308 больше на x86
Литерал / суффикс 1.0f 1.0 1.0L
printf %f (см. ниже) %f %Lf

Практический выбор простой: по умолчанию берите double. float — когда критична память (большие массивы, GPU-данные), ценой точности. long double — редкие расчеты, где нужны лишние цифры, и с оглядкой на то, что переносимости у него нет.

Как устроено IEEE 754 и откуда берется погрешность

Число хранится в нормированной двоичной форме: знак * мантисса * 2^порядок, где мантисса — дробь вида 1.xxxx в двоичной системе. Бит мантиссы конечное число (24 у float, 53 у double), поэтому точно представимы только те дроби, которые раскладываются в сумму степеней двойки в пределах этой длины.

Число 0.5 = 2^-1 представимо точно. А вот 0.1 в двоичной системе — бесконечная периодическая дробь (как 1/3 в десятичной). Ее приходится обрезать по длине мантиссы, и в память ложится ближайшее представимое значение, чуть-чуть отличающееся от 0.1. Отсюда и накопление ошибки при сложении.

Почему 0.1 + 0.2 != 0.3

Покажем это тройкой: наивная проверка, ее фактический вывод, затем корректное сравнение.

Неверный подход — сравнивать дробные результаты оператором ==:

#include <stdio.h>

int main(void) {
    double a = 0.1, b = 0.2, c = 0.3;
    printf("a + b = %.17f\n", a + b);
    printf("c     = %.17f\n", c);
    if (a + b == c)
        printf("ravno\n");
    else
        printf("NE ravno\n");
    return 0;
}

Фактический вывод (clang, macOS; на GCC/Linux то же самое):

a + b = 0.30000000000000004
c     = 0.29999999999999999
NE ravno

Видно, что и 0.1 + 0.2, и сам литерал 0.3 хранятся с крошечной погрешностью, и эти погрешности не совпали. Оператор == проверяет точное равенство значений, а два хранимых приближения различаются, поэтому дает NE ravno. Это не баг компилятора, а следствие IEEE 754.

Правильный подход — сравнивать не на точное равенство, а на близость: модуль разности меньше малого порога (эпсилон).

#include <stdio.h>
#include <math.h>

int main(void) {
    double a = 0.1, b = 0.2, c = 0.3;
    double eps = 1e-9;
    if (fabs((a + b) - c) < eps)
        printf("schitaem ravnymi\n");
    else
        printf("ne ravny\n");
    return 0;
}

Вывод:

schitaem ravnymi

fabs из <math.h> дает модуль разности. Порог 1e-9 подходит для чисел порядка единицы. Важная граница: фиксированный эпсилон плох для очень больших или очень малых величин — у чисел порядка 1e9 шаг представления сам больше 1e-9, и такое сравнение вырождается. Для широкого диапазона порог масштабируют относительно самих чисел (относительная погрешность), но универсального «правильного» эпсилона нет — его выбирают под задачу.

Спецификаторы printf: %f, %e, %g

Для вывода вещественных чисел в printf есть несколько спецификаторов. Возьмем double x = 12345.6789:

#include <stdio.h>

int main(void) {
    double x = 12345.6789;
    printf("%%f  -> %f\n", x);
    printf("%%e  -> %e\n", x);
    printf("%%g  -> %g\n", x);
    printf("%%.2f -> %.2f\n", x);
    return 0;
}

Вывод:

%f  -> 12345.678900
%e  -> 1.234568e+04
%g  -> 12345.7
%.2f -> 12345.68

Коротко о выборе: %f — обычная запись с фиксированной точкой (по умолчанию 6 знаков после точки), %e — научная форма с экспонентой, %g — компактная (сама выбирает между %f и %e и убирает лишние нули). Точность задается через %.Nf.

Две частые ловушки. Первая: для double в printf спецификатор именно %f (не %lf), а вот в scanf для double нужен %lf. Вторая: у long double свой спецификатор %Lf. Сам float в printf тоже печатается через %f, потому что при передаче в вариативную функцию он автоматически расширяется до double.

Спецзначения: inf, nan и переполнение

IEEE 754 определяет не только конечные числа. Деление на ноль в плавающей точке не роняет программу, а дает бесконечность или «не число».

#include <stdio.h>
#include <math.h>

int main(void) {
    double big = 1e308;
    double inf = big * 10.0;   /* perepolnenie -> beskonechnost */
    double nan = inf - inf;    /* neopredelennost -> nan */

    printf("inf = %f\n", inf);
    printf("nan = %f\n", nan);
    printf("nan == nan -> %d\n", nan == nan);
    printf("isnan(nan) -> %d\n", isnan(nan));
    printf("isinf(inf) -> %d\n", isinf(inf));
    return 0;
}

Вывод:

inf = inf
nan = nan
nan == nan -> 0
isnan(nan) -> 1
isinf(inf) -> 1

Ключевой момент: nan не равен ничему, включая самого себя, поэтому nan == nan дает 0 (ложь). Проверять на «не число» надо функцией isnan из <math.h>, на бесконечность — isinf. Переполнение (big * 10.0) уводит результат в inf, а не обнуляет его, как это бывает у целочисленного переполнения.

Выводы

  • Числа с плавающей точкой в C (float, double, long double) хранят вещественные значения приближенно по IEEE 754; точное представление получают только двоичные дроби вида суммы степеней двойки.
  • 0.1 + 0.2 != 0.3 — не ошибка, а следствие конечной мантиссы; печать с %.17f показывает расхождение в последних цифрах.
  • Дробные числа нельзя сравнивать через ==; сравнивайте по близости fabs(a - b) < eps, подбирая эпсилон под масштаб чисел.
  • По умолчанию используйте double; float — ради памяти, long double — редко и без гарантий переносимости (на ARM он может совпадать с double).
  • printf использует %f/%e/%g для double и %Lf для long double; nan не равен себе, проверяйте isnan/isinf.

Где применяется / связь с практикой

Плавающая точка всплывает почти в любой программе с расчетами: финансовые суммы, графика, физика, статистика, метрики. Ошибка сравнения double через == — классический баг новичка: тест «сумма сошлась» проходит на бумаге и падает в коде. Понимание IEEE 754, эпсилон-сравнения и спецзначений отделяет рабочий расчет от плавающего по значениям бага.

Освойте тему на практике

Разобраться с типами, представлением чисел и подводными камнями C на практике помогает курс «C++ Developer. Basic»: C и C++ делят одну модель типов и арифметику плавающей точки. Посмотреть формат занятий и темы вживую можно на открытых уроках Otus — там же можно задать вопрос преподавателю.

FAQ

Чем double отличается от float на практике?
double шире (обычно 8 байт против 4) и точнее (около 15-16 значащих цифр против 7). Для большинства расчетов берут double; float выбирают, когда важен объем памяти при больших массивах данных.

Можно ли хранить деньги в double?
Нежелательно: из-за двоичного представления суммы вроде 0.1 не точны, и на больших объемах копятся расхождения. Для денег используют целые числа в минимальных единицах (копейки, центы) или специальные десятичные типы/библиотеки.

Какой эпсилон брать для сравнения?
Универсального значения нет. Для чисел порядка единицы часто берут 1e-9, но для очень больших или малых величин порог масштабируют относительно самих чисел; в <float.h> есть DBL_EPSILON как отправная точка.

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)