Дробные числа: IEEE 754
Урок разбирает формат IEEE 754 — стандарт хранения чисел с плавающей точкой и причину знаменитых ошибок округления.
Число с плавающей точкой — это запись вида «знак × мантисса × 2^экспонента», аналог научной записи, но в двоичной системе. Точка «плавает»: её позиция задаётся экспонентой.
Зачем плавающая точка
Целыми числами не выразить ни 3,14, ни массу электрона, ни расстояние до звезды одним типом. Нужен формат с огромным диапазоном и приемлемой точностью. Решение — хранить число как в научной нотации: 6,022 × 10^23. В двоичном варианте основание 2, и стандарт IEEE 754 фиксирует, сколько бит отдать на знак, экспоненту и дробную часть.
Устройство 32-битного float (single)
┌─┬──────────┬───────────────────────────┐ │S│ E (8 бит)│ M (23 бита) │ └─┴──────────┴───────────────────────────┘ 31 30 23 22 0 S - знак (0 = +, 1 = -) E - экспонента со смещением (bias = 127) M - мантисса (дробная часть, к ней неявно прибавляется 1.) Значение = (-1)^S * 1.M * 2^(E - 127)
Хитрость «неявной единицы»: нормализованная мантисса всегда начинается с 1., поэтому эту единицу не хранят — экономия одного бита точности. Экспонента хранится со смещением 127, чтобы и положительные, и отрицательные степени кодировались неотрицательным числом (удобно сравнивать).
Как работает под капотом: разбираем число руками
Возьмём число 6,5. В двоичном это 110.1 (4 + 2 + 0,5). Нормализуем: 1.101 × 2^2. Значит S=0, мантисса = 101 (дальше нули), экспонента = 2 + 127 = 129. Проверим стандартным модулем struct (он входит в stdlib):
import struct
def float_bits(x):
# упаковываем float в 4 байта, читаем как 32-битное целое
[bits] = struct.unpack(">I", struct.pack(">f", x))
b = format(bits, "032b")
return b[0], b[1:9], b[9:]
for x in (6.5, -6.5, 0.5, 1.0):
s, e, m = float_bits(x)
exp = int(e, 2) - 127
print(f"{x:>5} | знак={s} | E={e}({exp:+d}) | M={m[:8]}...")Вывод:
6.5 | знак=0 | E=10000001(+2) | M=10100000... -6.5 | знак=1 | E=10000001(+2) | M=10100000... 0.5 | знак=0 | E=01111110(-1) | M=00000000... 1.0 | знак=0 | E=01111111(+0) | M=00000000...
Почему 0.1 + 0.2 != 0.3
Число 0,1 в двоичной системе — бесконечная периодическая дробь (как 1/3 в десятичной). Его приходится округлять до 23 (или 52) бит. Сумма двух округлённых приближений даёт крошечную погрешность. Это не баг Python, а фундаментальное свойство двоичной плавающей точки.
a = 0.1 + 0.2
print("0.1 + 0.2 =", a)
print("Равно ли 0.3? ", a == 0.3)
print("Разница: ", a - 0.3)
print("Вывод с 17 знаками:", format(a, ".17f"))Вывод:
0.1 + 0.2 = 0.30000000000000004 Равно ли 0.3? False Разница: 5.551115123125783e-17 Вывод с 17 знаками: 0.30000000000000004
Особые значения
| Значение | Как закодировано |
| ±0 | экспонента и мантисса = все нули |
| ±бесконечность | экспонента все единицы, мантисса = 0 |
| NaN (не число) | экспонента все единицы, мантисса != 0 |
| денормализованные | экспонента все нули, мантисса != 0 (очень малые числа) |
Аналогия: научная запись и «плавающая» точка
Чтобы прочувствовать, зачем точка «плавает», вспомните, как физик записывает и массу электрона (9,1 × 10⁻³¹ кг), и расстояние до Солнца (1,5 × 10¹¹ м) одним и тем же способом. Он не хранит длинную вереницу нулей — он отдельно держит значащие цифры (мантиссу) и порядок (экспоненту). IEEE 754 — это та же научная запись, только в двоичной системе и с фиксированным числом бит на каждую часть. Экспонента «двигает» двоичную точку по мантиссе влево или вправо, отсюда и название «плавающая точка»: в отличие от фиксированной точки, где разрядов до и после запятой поровну, здесь точка скользит, отдавая разрядность туда, где она сейчас нужнее.
Эта аналогия объясняет и главный компромисс формата. У научной записи фиксированное число значащих цифр: записав 6 цифр мантиссы, вы одинаково «грубо» представляете и число 1,23456, и 123456000000. То же у float — число значащих бит постоянно, поэтому большие числа представляются «реже», с большими промежутками между соседними представимыми значениями. Точность не абсолютна, а относительна величине, и это фундаментальное, а не случайное свойство.
Глубже: машинный эпсилон и неравномерная сетка чисел
Важнейшее следствие плавающей точки — представимые числа расположены на числовой оси неравномерно: густо около нуля и всё реже по мере роста. Между 1 и 2 у 32-битного float умещается 2²³ ≈ 8 миллионов значений, а между 2 и 4 — столько же, но на вдвое более длинном отрезке, то есть вдвое реже. Минимальный относительный «шаг» называют машинным эпсилоном. Отсюда два практических следствия: складывать число очень разного масштаба опасно (маленькое слагаемое может полностью потеряться — это «поглощение»), а проверять накопленную сумму на точное равенство бессмысленно. Понимание неравномерной сетки превращает загадочные ошибки округления в предсказуемое поведение.
Именно поэтому числовые алгоритмы — от решения уравнений до обучения нейросетей — проектируют с оглядкой на порядок операций: суммировать лучше от меньших значений к большим, избегать вычитания близких чисел (это «катастрофическая потеря значимости»), а сравнения вести с допуском, соразмерным эпсилону. Это целая дисциплина — вычислительная математика, выросшая из устройства тех самых 32 и 64 бит.
Историческая справка: зачем понадобился единый стандарт
До 1985 года каждый производитель реализовывал плавающую точку по-своему: разная разрядность, разные правила округления, разное поведение при делении на ноль. Программа, дававшая верный результат на одной машине, на другой могла молча выдать чушь — кошмар для переносимости. Стандарт IEEE 754, в разработке которого ключевую роль сыграл математик Уильям Кахан (за что получил премию Тьюринга), навёл порядок: зафиксировал форматы single и double, правила округления, а также особые значения — бесконечности и NaN. Благодаря этому ваш расчёт сегодня даёт идентичный бит-в-бит результат хоть на телефоне, хоть на сервере. NaN при этом задуман не как ошибка-катастрофа, а как «тихий сигнал»: операция вроде 0/0 возвращает NaN, который дальше распространяется по вычислению, позволяя обнаружить проблему в конце, а не ронять всю программу посередине.
Частые ошибки
- Сравнивать float через ==. Из-за округления сравнивайте с допуском:
abs(a - b) < 1e-9. - Хранить деньги во float. Копейки теряются на округлении; используйте целые (центы) или
decimal. - Думать, что double «точный». double (64 бита) точнее, но проблема та же — просто меньше заметна.
Итог
- IEEE 754: знак + экспонента (со смещением) + мантисса (с неявной единицей).
- Диапазон огромен, но точность ограничена — отсюда ошибки округления.
- 0.1+0.2 != 0.3 — следствие двоичного представления, а не ошибка языка.