Skip to content
Назад к блогу
Руководства

Точность чисел с плавающей запятой: почему 0.1 + 0.2 ≠ 0.3

Почему точность чисел с плавающей запятой ломает 0.1 + 0.2, как округляет IEEE 754 и как безопасно сравнивать float — бесплатный онлайн-конвертер.

12 мин чтения

Точность чисел с плавающей запятой: почему 0.1 + 0.2 ≠ 0.3

0.1 + 0.2 возвращает 0.30000000000000004, потому что ни 0.1, ни 0.2 не существуют внутри двоичного числа с плавающей запятой. Double хранит только значения вида m × 2ⁿ, а одна десятая в основании 2 — бесконечная периодическая дробь, поэтому оборудование подставляет вместо нее ближайшего представимого соседа. Double, стоящий за 0.1, равен ровно 0.1000000000000000055511151231257827021181583404541015625. За 0.2 стоит 0.200000000000000011102230246251565404236316680908203125. Если сложить эти два сохраненных значения, точная сумма окажется между двумя представимыми double. IEEE 754 округляет до ближайшего из них, а тот лежит на волосок выше 0.3.

Вот и весь ответ. Точность чисел с плавающей запятой конечна, и каждая операция округляет результат заново.

Это не причуда JavaScript и не дефект процессора. Тот же результат появляется в Python, Java, C, Go, Rust, Swift и в колонках FLOAT языка SQL, потому что все они работают поверх IEEE 754. Вставьте любое значение в бесплатный конвертер IEEE 754, чтобы увидеть его биты и точное сохраненное значение, цифра в цифру.

Ошибка появляется не случайно, язык ее иногда прячет, == при сравнении float не годится, а там, где точность стоит денег, нужен другой числовой тип. Об этом — дальше.

Точность чисел с плавающей запятой за 60 секунд

Почти все сюрпризы объясняются тремя фактами:

  1. Двоичная плавающая запятая представляет только числа вида m × 2ⁿ, то есть суммы степеней двойки.
  2. Десятичные дроби вроде 0.1, 0.2 и 0.3 к этому виду не сводятся, поэтому округляются уже на входе.
  3. Каждая арифметическая операция снова округляет свой результат до ближайшего представимого значения.
ДесятичноеПредставимо точно?Почему
0.5Да2⁻¹
0.25Да2⁻²
0.75Да2⁻¹ + 2⁻²
2.5Да2 + 2⁻¹
100ДаЛюбое целое меньше 2⁵³
0.1Нет1/10 — в знаменателе есть множитель 5
0.2Нет1/5 — та же беда
0.3Нет3/10 — та же беда

Практическое правило: сократите дробь, и если знаменатель оказался чистой степенью двойки, значение точное. Любой уцелевший множитель 5 означает, что значение хранится приближенно.

Почему 0.1 нельзя сохранить точно

Биты после двоичной запятой несут веса 1/2, 1/4, 1/8, 1/16, 1/32 и так далее. Ровно 0.1 из них не собирается. Получается 1/16 = 0.0625, плюс 1/32 = 0.09375, плюс 1/256 = 0.09765625: с каждым шагом ближе, но в точку ни одна сумма не попадает. В двоичной записи одна десятая выглядит как 0.0001100110011…, где 0011 повторяется бесконечно.

У десятичной системы та же проблема, только с другими дробями. Запись 1/3 в основании 10 дает 0.333…, и никакая конечная строка цифр в нее не попадает. Багом десятичной системы это никто не называет. Основание 2 просто проводит границу в другом месте, и 1/10 оказывается не с той стороны. Раздел документации Python Floating Point Arithmetic: Issues and Limitations разбирает то же самое разложение подробнее.

Двоичная система — это всего лишь основание 2, и той же позиционной арифметикой живут hex и восьмеричная запись. Конвертер систем счисления показывает, как значение переходит между основаниями, а наше руководство по системам счисления подробно разбирает целочисленную сторону. Проблемы с основанием 2 начинаются именно на дробях.

Из чего состоит double

64-битный double делится на три поля: 1 бит знака, 11 бит экспоненты и 52 бита мантиссы. У мантиссы есть неявная ведущая единица, которая нигде не хранится, поэтому значащая часть насчитывает 53 бита, то есть около 15.95 десятичных цифр точности чисел с плавающей запятой. Экспонента хранится со смещением 1023.

Попросите у языка больше цифр, чем он печатает по умолчанию, и приближение вылезет наружу:

>>> 0.1
0.1
>>> f"{0.1:.20f}"
'0.10000000000000000555'
>>> from decimal import Decimal
>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')
(0.1).toFixed(20);      // '0.10000000000000000555'
(0.1).toPrecision(20);  // '0.10000000000000000555'

Decimal(0.1) показывает правду: он конвертирует уже имеющийся double без повторного округления и печатает все 55 цифр. Панель точности в конвертере IEEE 754 печатает то же разложение для любого введенного числа, а рядом показывает знаковую ошибку округления.

Точная арифметика за числом 0.30000000000000004

Если сложить два сохраненных значения точно, без округления, получится:

0.1 →  0.1000000000000000055511151231257827021181583404541015625
0.2 →  0.200000000000000011102230246251565404236316680908203125
sum →  0.3000000000000000166533453693773481063544750213623046875

Сама эта сумма представимым double не является. Она лежит между двумя соседями на двоичной сетке:

below:  0.299999999999999988897769753748434595763683319091796875   (prints as 0.3)
above:  0.3000000000000000444089209850062616169452667236328125     (prints as 0.30000000000000004)

Зазоры до соседей одинаковы: точная сумма отстоит на 2⁻⁵⁵2.776e-17 выше нижнего соседа и на те же 2⁻⁵⁵ ниже верхнего. Она лежит ровно посередине: идеальная ничья.

Округлению до ближайшего здесь не за что зацепиться, поэтому IEEE 754 применяет запасное правило, round-half-to-even: из двух кандидатов берется тот, у которого последний бит мантиссы равен 0. Значащая часть нижнего соседа — 5404319552844595, число нечетное. У верхнего — 5404319552844596, четное. Побеждает четное значение, и получается битовая комбинация 0x3FD3333333333334, на один ULP выше того double, в который отображается литерал 0.3; печатается это как 0.30000000000000004.

Почему именно четный сосед? Если бы половины всегда округлялись вверх, длинные суммы уводило бы в одну сторону. Чередование в пользу четного соседа удерживает этот дрейф около нуля на многих операциях. Это то же «банковское округление», которым пользуются бухгалтеры, только закрепленное в кремнии. Из-за него самая известная ошибка округления с плавающей запятой и заканчивается на ...04.

Сравните с суммой, которой округление вообще не требуется:

0.5 + 0.25 === 0.75;   // true
0.1 + 0.2 === 0.3;     // false

0.5, 0.25 и 0.75 — это 2⁻¹, 2⁻² и 2⁻¹ + 2⁻². Все три точны, их сумма точна, и равенство ведет себя так, как подсказывает школьная арифметика. Панель соседей в конвертере показывает предыдущее и следующее представимые значения вокруг любого введенного числа вместе с зазором в ULP. Сетку вокруг 0.3 можно проверить самостоятельно.

Почему язык иногда прячет ошибку

Путаницу создает вывод: 0.1 печатается как 0.1, а 0.1 + 0.2 печатается семнадцатью цифрами. Формат хранения один и тот же, а на экране числа выглядят совершенно по-разному.

Современные среды выполнения используют форматирование shortest round-trip: они печатают кратчайшую десятичную строку, которая при обратном разборе дает тот же самый double. Строка "0.1" и так однозначно отображается обратно в значение, сохраненное для 0.1, поэтому ее и видно. Сумма 0.1 + 0.2другой double, не тот, в который отображается "0.3", поэтому форматировщик вынужден добавлять цифры, пока строка не станет однозначной, а на это уходят все семнадцать. Начало этой линии положила работа Дэвида Гэя 1990 года о корректном округлении; Grisu, а следом Ryu сделали ее достаточно быстрой для любой стандартной библиотеки. Язык не врет. Он показывает кратчайшую метку, которая однозначно определяет значение.

Поведение по языкам

ЯзыкТип float по умолчаниюПечатает 0.1 + 0.2Точный десятичный тип
JavaScriptnumber (только double)0.30000000000000004Встроенного нет — целые центы или библиотека
Pythonfloat (double)0.30000000000000004decimal.Decimal, fractions.Fraction
Javadouble0.30000000000000004BigDecimal (создавать из строки)
C#double0.30000000000000004decimal — 128 бит, основание 10
Gofloat640.30000000000000004 (через переменные)math/big.Rat
Rustf640.30000000000000004крейт rust_decimal
SQLFLOAT / DOUBLE PRECISION0.30000000000000004NUMERIC / DECIMAL

Две строки в этой таблице требуют оговорки.

Go сворачивает константы с произвольной точностью. Литеральное выражение компилятор вычисляет точно и только потом приводит к типу, поэтому константа 0.1 + 0.2 становится ровно 0.3 еще до того, как превратится в float64:

package main

import "fmt"

func main() {
	fmt.Println(0.1 + 0.2) // 0.3   — constant folded exactly, then converted
	a, b := 0.1, 0.2
	fmt.Println(a + b)      // 0.30000000000000004
	fmt.Println(a+b == 0.3) // false
}

BigDecimal в Java наследует ошибку, если передать ему double. Конструктор добросовестно преобразует те биты, которые ему вручили:

System.out.println(new BigDecimal(0.1));
// 0.1000000000000000055511151231257827021181583404541015625

System.out.println(new BigDecimal("0.1").add(new BigDecimal("0.2")));
// 0.3

SQL разделяется по типу колонки, а не по диалекту:

-- PostgreSQL: a bare decimal literal is NUMERIC, which is exact
SELECT 0.1 + 0.2 = 0.3;                          -- t

-- Cast to binary floating point and equality fails
SELECT 0.1::float8 + 0.2::float8 = 0.3::float8;  -- f

-- SQLite: REAL is a double, and the printed value hides it
SELECT 0.1 + 0.2;        -- 0.3
SELECT 0.1 + 0.2 = 0.3;  -- 0  (false)

В паре из SQLite противоречие видно лучше всего: напечатанный результат говорит 0.3, сравнение говорит обратное, и правы оба.

Сравнение float: почему == подводит и почему Number.EPSILON — не допуск

0.1 + 0.2 === 0.3 дает false, потому что слева стоит другой double. Обычный совет — сравнивать с допуском, но самая растиражированная его версия неверна:

Math.abs(a - b) < Number.EPSILON;   // ⚠️ not a general-purpose comparison

Number.EPSILON равен 2.220446049250313e-16, то есть 2⁻⁵². В MDN его определяют как разницу между 1 и наименьшим double, который больше 1. Это шаг сетки в районе 1.0, а не универсальный бюджет ошибки. Шаг между соседними float удваивается на каждой степени двойки, поэтому постоянный порог неверен везде, кроме той окрестности, в которой его измерили.

Рядом с 1.0 он случайно работает:

Math.abs((0.1 + 0.2) - 0.3);                  // 5.551115123125783e-17
Math.abs((0.1 + 0.2) - 0.3) < Number.EPSILON; // true

Увеличьте то же вычисление в миллиард раз, и проверка перестанет работать:

const a = (0.1 + 0.2) * 1e9;
const b = 0.3 * 1e9;

a === b;                          // false
Math.abs(a - b);                  // 5.960464477539063e-8
Math.abs(a - b) < Number.EPSILON; // false — yet a and b are adjacent doubles

Поскольку 1e9 попадает в интервал [2²⁹, 2³⁰), ULP там равен 2²⁹⁻⁵² = 2⁻²³ ≈ 1.19e-7, то есть расстояние между соседними double примерно в 500 миллионов раз больше, чем Number.EPSILON. Любая настоящая ошибка округления такого масштаба перекрывает порог, и проверка всегда возвращает false. А внизу, около 1e-20, та же константа чересчур щедра и объявляет равными очевидно разные значения.

Абсолютный допуск, относительный допуск и расстояние в ULP

  • Абсолютный допуск|a − b| <= atol. Подходит, когда порядок величины известен заранее, и это единственное, что работает при сравнении с нулем: относительный допуск возле 0 всегда равен 0.
  • Относительный допуск|a − b| <= rtol × max(|a|, |b|). Масштабируется вместе с данными по всем порядкам, но вырождается около нуля.
  • Расстояние в ULP — сколько представимых значений помещается между двумя битовыми комбинациями. Точнее не бывает, но читать такой код тяжело.

Соедините первые два, и получится форма, которую использует любая серьезная численная библиотека:

function nearlyEqual(a, b, rtol = 1e-9, atol = 1e-12) {
  if (a === b) return true;            // handles Infinity === Infinity
  const diff = Math.abs(a - b);
  return diff <= Math.max(rtol * Math.max(Math.abs(a), Math.abs(b)), atol);
}

nearlyEqual(0.1 + 0.2, 0.3);                  // true
nearlyEqual((0.1 + 0.2) * 1e9, 0.3 * 1e9);    // true
nearlyEqual(0, 1e-15);                        // true
nearlyEqual(1, 1.0001);                       // false

В Python это лежит в стандартной библиотеке, а allclose из NumPy использует ту же формулу:

import math
math.isclose(0.1 + 0.2, 0.3, rel_tol=1e-9, abs_tol=1e-12)  # True
math.isclose(1e9, 1e9 + 1e-7, rel_tol=1e-9)                # True

Для строгого варианта нужно отобразить биты в монотонный целочисленный порядок и вычесть:

const view = new DataView(new ArrayBuffer(8));

function toOrdinal(x) {
  view.setFloat64(0, x);
  const i = view.getBigInt64(0);
  return i >= 0n ? i : -(1n << 63n) - i;   // make negatives order correctly
}

function ulpDistance(a, b) {
  const d = toOrdinal(a) - toOrdinal(b);
  return d < 0n ? -d : d;
}

ulpDistance(0.1 + 0.2, 0.3);                // 1n
ulpDistance((0.1 + 0.2) * 1e9, 0.3 * 1e9);  // 1n
ulpDistance(-0, 0);                         // 0n

Кстати, точное == не всегда ошибка. Оно уместно для целочисленных double ниже 2⁵³, для констант, которые были присвоены и ни разу не участвовали в вычислениях, и для проверки, равно ли значение ровно нулю.

Когда точность стоит денег: выбор числового типа

Деньги и двоичные float — плохая пара, и не потому, что отдельная ошибка велика. Ошибки систематичны и воспроизводимы, а заметит их первым, скорее всего, аудитор:

19.99 * 100;              // 1998.9999999999998
Math.round(19.99 * 100);  // 1999

(1.005).toFixed(2);       // '1.00'  — 1.005 is stored as 1.00499999999999989...

Вторая строка ежегодно ловит целые команды. toFixed округлил правильно. Значение, которое ему передали, уже было меньше 1.005.

Целые числа в минорных единицах

Храните центы, а не доллары. 1999 означает $19.99, вся арифметика идет на целых числах, а делить приходится только при выводе на экран.

const priceCents = 1999;             // $19.99
const subtotal = priceCents * 3;     // 5997 — exact

const totalCents = 1000;             // $10.00 split three ways
const share = Math.floor(totalCents / 3);   // 333
const remainder = totalCents - share * 3;   // 1 cent to allocate

Две оговорки. Выше Number.MAX_SAFE_INTEGER (9007199254740991, то есть 2⁵³ − 1) целые числа в JavaScript перестают быть точными, поэтому нужен BigInt. И целые числа не решают за вас политику округления: деление, проценты и разбиение налога по-прежнему требуют явного правила, куда отправится оставшийся цент.

Десятичные типы

Десятичный тип хранит цифры в основании 10, поэтому десятичные дроби ложатся точно. Создавать его нужно из строки и никогда из float, иначе двоичная ошибка достанется вам еще до начала работы:

from decimal import Decimal

Decimal("0.1") + Decimal("0.2") == Decimal("0.3")   # True
Decimal(0.1)                                        # 0.1000000000000000055511151231257827021181583404541015625
Console.WriteLine(0.1m + 0.2m);          // 0.3
Console.WriteLine(0.1m + 0.2m == 0.3m);  // True

В Java есть BigDecimal, в C# — родной 128-битный decimal, в SQL — NUMERIC(12,2). Все они чинят десятичные дроби. Ни один не чинит 1/3, потому что десятичный тип — это по-прежнему формат с плавающей запятой, просто основание переехало с 2 на 10.

Матрица выбора

СценарийРекомендуемый типПочему
Деньги, биллинг, налогиЦелые минорные единицы или decimalТочная десятичная арифметика, проверяемое округление
Наука, физикаdouble (FP64)15–16 цифр хватает с запасом; лучшая поддержка библиотек
Геометрия, графикаfloat или double + допускИ так приближенно; сравнение через epsilon
Обучение MLbfloat16Диапазон FP32 при вдвое меньшей памяти
Инференс ML, хранениеFP16Больше бит мантиссы, когда значения нормализованы
Счетчики, ID, ключи64-битное целое или BigIntИм никогда не было места во float

Накопление ошибок и катастрофическая потеря значимости

Одна ошибка округления — это 1e-17, и она безобидна. Десять тысяч таких — уже тикет в поддержку:

let total = 0;
for (let i = 0; i < 10000; i++) total += 0.1;

total;         // 1000.0000000001588
total - 1000;  // 1.588205122970976e-10

Дрейф растет примерно пропорционально числу операций. В цикле такого размера он незаметен; в ночной агрегации по миллионам строк — вполне заметен.

Катастрофическая потеря значимости

Хуже ведет себя вычитание. Когда вычитаются два почти равных числа, совпадающие старшие цифры взаимно уничтожаются, и остается в основном то, что успела накопить ошибка. Абсолютная ошибка при этом не растет, а относительная взрывается: результат стал крошечным, а ошибка осталась прежнего размера.

Учебная однопроходная формула дисперсии, E[x²] − (E[x])², попадает в эту яму сразу:

const data = [1e9, 1e9 + 1, 1e9 + 2];
const mean = data.reduce((s, x) => s + x, 0) / data.length;

// One-pass: E[x²] − (E[x])²
data.reduce((s, x) => s + x * x, 0) / data.length - mean * mean;  // 0

// Two-pass: centre the data first
data.reduce((s, x) => s + (x - mean) ** 2, 0) / data.length;      // 0.6666666666666666

Истинная дисперсия генеральной совокупности равна 2/3. Однопроходная формула возвращает ровно ноль. Это не погрешность, а полностью неверный ответ: оба операнда были порядка 1e18, а разница между ними живет ниже последнего сохраненного бита. Онлайн-алгоритм Уэлфорда обходит это, обновляя среднее и сумму квадратов отклонений инкрементально.

У формулы корней квадратного уравнения та же слабость при b² ≫ 4ac, и лечится она так же:

const a = 1, b = 1e8, c = 1;
const disc = Math.sqrt(b * b - 4 * a * c);

(-b + disc) / (2 * a);   // -7.450580596923828e-9  — about 25% wrong
(2 * c) / (-b - disc);   // -1e-8                  — correct

Обе строки вычисляют один и тот же корень. Первая вычитает два почти равных числа; вторая перестраивает алгебру так, что вычитать не приходится. Работа Голдберга What Every Computer Scientist Should Know About Floating-Point Arithmetic до сих пор остается эталонным разбором потери значимости и границ ошибки.

Суммирование по Кэхэну

Компенсированное суммирование Кэхэна запоминает младшие биты, которые отбрасывает каждое сложение, и возвращает их на следующей итерации:

function kahanSum(values) {
  let sum = 0;
  let compensation = 0;
  for (const value of values) {
    const y = value - compensation;
    const t = sum + y;
    compensation = (t - sum) - y;   // the bits that fell off
    sum = t;
  }
  return sum;
}

kahanSum(new Array(10000).fill(0.1));   // 1000  — exactly

В Python это достается бесплатно. math.fsum([0.1] * 10_000) возвращает 1000.0, а начиная с CPython 3.12 встроенная sum() тоже применяет компенсацию Ноймайера, поэтому sum([0.1] * 10_000) точна, тогда как явный цикл с += по-прежнему уплывает к 1000.0000000001588.

Компенсация нужна для длинных сумм, финансовых агрегатов и численного интегрирования. Для горстки значений или если вы уже перешли на точный тип, она лишняя.

Специальные значения, которые ломают ожидания

IEEE 754 резервирует битовые комбинации под значения, которые не подчиняются привычным правилам:

NaN === NaN;          // false — mandated by the standard
Number.isNaN(NaN);    // true
[NaN].indexOf(NaN);   // -1   (uses ===)
[NaN].includes(NaN);  // true (uses SameValueZero)

-0 === 0;             // true
Object.is(-0, 0);     // false
1 / -0;               // -Infinity

1e308 * 10;           // Infinity — overflow never throws
Infinity - Infinity;  // NaN
Number.MIN_VALUE;     // 5e-324 — the smallest subnormal double

NaN не равен ничему, включая самого себя. Так задумано: провалившееся вычисление не должно выдать себя за корректный результат. Проверять его нужно через Number.isNaN() или math.isnan() в Python.

Переполнение ведет себя тише и опаснее: оно возвращает ±Infinity и продолжает работу, растекаясь дальше по цепочке, пока кто-нибудь не заметит график, полный пустот. Отрицательный ноль — отдельная битовая комбинация, которая при этом равна +0 по ===; он появляется, когда отрицательное значение уходит в антипереполнение, или из -1 * 0, и раскрывают его только деление или Object.is.

Субнормальные числа заполняют промежуток между нулем и наименьшим нормальным числом. Когда поле экспоненты полностью нулевое, неявная ведущая единица отбрасывается, а мантисса плавно тает к нулю. Поэтому разность двух неравных float никогда не округляется ровно в ноль. Плата — падающая точность и, на части оборудования, резкий провал производительности. Чипы специальных значений в конвертере IEEE 754 загружают ±0, ±Infinity, NaN и наименьшее субнормальное число, так что каждую битовую комбинацию можно рассмотреть напрямую.

float, double, FP16 и bfloat16

Один стандарт, разные бюджеты на диапазон и на точность чисел с плавающей запятой:

ФорматВсего битЭкспонентаМантиссаПримерно десятичных цифрМакс. конечноеТипичное применение
binary16 (FP16)16510~3.365504Инференс на GPU, компактное хранение
bfloat161687~2.4~3.39 × 10³⁸Обучение ML
binary32 (float)32823~7.2~3.40 × 10³⁸Графика, сенсоры, GPU
binary64 (double)641152~15.9~1.80 × 10³⁰⁸По умолчанию во всем остальном

Биты экспоненты покупают диапазон, биты мантиссы — точность. FP16 тратит бюджет на точность и упирается в потолок 65504, а это достаточно близко к реальным значениям градиентов, чтобы переполнение до Infinity стало рутинной опасностью. bfloat16 идет на обратный размен: сохраняет восемь бит экспоненты от FP32 и живет с семью битами мантиссы, поэтому значения, которые переполнили бы FP16, проходят спокойно, а обучению редко нужен loss scaling.

По умолчанию берите double. К более узкому формату спускайтесь только после того, как измерите узкое место по памяти или пропускной способности, а цену такого сужения увидите, если введете одно и то же значение в каждом формате в конвертере.

Практические правила работы с точностью чисел с плавающей запятой

  • Никогда не применяйте == к вычисленным float. Берите комбинированный абсолютно-относительный допуск, подобранный под ваши данные.
  • Не считайте Number.EPSILON порогом: он описывает сетку в районе 1.0 и больше ничего.
  • Держите деньги в целых минорных единицах или в десятичном типе и всегда создавайте decimal из строк.
  • Компенсируйте длинные суммы алгоритмом Кэхэна, Ноймайера или через math.fsum.
  • Перестраивайте формулы, вычитающие почти равные величины, вместо того чтобы подкручивать вокруг них допуски.
  • Форматируйте для людей явно: toFixed, f-строка, printf. Не отдавайте пользователям стандартный repr.
  • Передавайте числа через границы сервисов строками или целыми, потому что путь туда и обратно через double в JSON проходит молча и с потерями.
  • Для денежных колонок выбирайте NUMERIC, а не FLOAT. Исправлять после запуска придется миграцией и сверкой.
  • Думайте в битах, когда значение выглядит невозможным. На этой же привычке держится наше руководство по побитовым операциям, и она превращает загадки плавающей запятой в арифметику, которую можно проверить.

Часто задаваемые вопросы

Сломана ли математика с плавающей запятой?

Математика с плавающей запятой не сломана — она в точности следует IEEE 754. Стандарт представляет вещественные числа конечным количеством двоичных разрядов, а у десятичных дробей вроде 0.1 точной двоичной формы нет, поэтому сохраняется ближайшее представимое значение. Сломано здесь ожидание, что каждая десятичная дробь поместится.

Почему Python печатает 0.1 как 0.1, а 0.1 + 0.2 как 0.30000000000000004?

repr в Python использует форматирование shortest round-trip: печатается кратчайшая десятичная строка, которая при обратном разборе дает тот же самый double. Строка "0.1" уже однозначно задает свой double. Сумма 0.1 + 0.2 — не тот double, в который отображается "0.3", поэтому форматировщику приходится выдать все семнадцать цифр, чтобы не осталось неоднозначности.

Почему не стоит сравнивать два float через Number.EPSILON?

Number.EPSILON (около 2.22e-16) — это зазор между 1 и следующим double, а не универсальный бюджет ошибки. Шаг между соседними float удваивается на каждой степени двойки, поэтому возле 1e9 соседние double отстоят друг от друга примерно на 1.19e-7. Любая настоящая ошибка округления там превышает EPSILON, и сравнение всегда дает false. Берите относительный или комбинированный допуск.

Можно ли считать деньги во float, если округлить в конце?

Округление в конце деньги во float не спасает. Ошибки накапливаются на сложениях, умножениях и многошаговых распределениях, а сам момент округления меняет итог. 19.99 * 100 уже дает 1998.9999999999998. Аудиту нужна воспроизводимая точная арифметика: целые центы, decimal, BigDecimal или NUMERIC в SQL.

Какие десятичные числа хранятся в double точно?

Только те, что выражаются как m / 2ⁿ для целых m и n в пределах точности формата. Сюда попадают 0.5, 0.25, 0.125, 0.75 и 2.5, а также каждое целое до 2⁵³. Сначала сократите дробь: любой оставшийся множитель 5 в знаменателе — как у 0.1, 0.2, 0.3 или 0.7 — означает, что значение хранится приближенно.

Почему 0.1 + 0.2 === 0.3 дает false, а 0.5 + 0.25 === 0.75 — true?

Потому что 0.5, 0.25 и 0.75 — это 2⁻¹, 2⁻² и 2⁻¹ + 2⁻², все три представимы точно, и их сумма не требует округления. А 0.1, 0.2 и 0.3 хранятся приближенно, и округление суммы первых двух попадает на один ULP выше того double, в который отображается 0.3.

Это происходит во всех языках программирования?

Одинаково ведет себя любой язык, построенный на двоичной плавающей запятой IEEE 754: JavaScript, Python, Java, C, C++, Go, Rust, Swift и FLOAT в SQL. Различаются точность печати по умолчанию и наличие точного десятичного типа из коробки — как decimal в C# или модуль decimal в Python.

Теги: floating-point ieee-754 javascript python numbers

Похожие статьи

Все статьи