Число с плавающей точкой в 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) и в MSVClong 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 как отправная точка.



