Как следует отображать на экране результат деления 3.0
на 10.0
? Сколько цифр следует вывести, если пользователь не указал точность?
Скорее всего, вы даже не знали, что вывод чисел с плавающей запятой — это сложная проблема, настолько сложная, что по ней написаны десятки научных статей, причём последний прорыв был относительно недавно, в 2016 году. На самом деле, это одна из самых сложных частей поддержки чисел с плавающей запятой в среде выполнения языка.
Давайте продолжим разговор о самой неоптимизированной в мире библиотеке эмуляции плавающей точки при помощи целочисленной арифметики. Первую статью читать тут.
Три основные проблемы
Спойлер: в моей реализации я намеренно обойду самые сложные части,
но давайте посмотрим, что делает эту задачу такой сложной.
Проблема № 1: нужное количество цифр
Обратите внимание, что я написал числа 3.0
и 10.0
в десятичной системе счисления (база 10), потому что это система, которую используют люди. Но большинство десятичных дробей не имеют точного двоичного представления:
Эта двоичная дробь бесконечна, паттерн 0011
повторяется бесконечно.
Поэтому, когда мы пишем x = 0.3
, переменная x
на самом деле хранит ближайшее представимое число с плавающей запятой.
Например, в Python плавающая запятая имеет двойную точность (64 бита). Таким образом, x
на самом деле хранит 0.299999999999999988897769753748434595763683319091796875
.
Вы можете проверить это сами:
from decimal import Decimal
x = 0.3
print(Decimal(x))
Если мы создадим вторую переменную y = 0.1 + 0.2
, то сохраненное значение будет: 0.3000000000000000444089209850062616169452667236328125
.
from decimal import Decimal
y = 0.1 + 0.2
print(Decimal(y))
Неудивительно, что x
и y
различаются: y
накопило три ошибки аппроксимации — по одной для каждого операнда и еще одну от сложения:
0.1
округляется до0.1000000000000000055511151231257827021181583404541015625
0.2
округляется до0.200000000000000011102230246251565404236316680908203125
Их сумма вносит еще одну ошибку округления.
Теперь вопрос: как мы должны вывести x
и y
? Давайте посмотрим, как это делает Python:

Первое требование — безопасность при обратном преобразовании:
какую бы десятичную строку мы ни вывели, при обратном преобразовании должно быть восстановлено точно то же двоичное число с плавающей запятой.
Если система выводит слишком много цифр, лишние цифры могут быть просто «мусором» от двоичного округления.
Даже еслиx
равно0.299999999999999988897769753748434595763683319091796875
, нет необходимости печатать все 54 цифры после десятичной запятой. На самом деле мы хотели сохранить0.3
, поэтому 54 цифры являются фактически численным мусором. Простого0.3
достаточно для определения значенияx
, даже еслиx
не равно.
-
Если система выводит слишком мало цифр, результат будет неверным в более строгом смысле: обратное преобразование в двоичную систему может дать другое число с плавающей запятой.
Например, самым коротким десятичным числом, которое однозначно идентифицирует
y
, является0.30000000000000004
. Вывод только0.3
будет неправильным, потому что0.3
при обратном преобразовании дает другое двоичное значение.
Таким образом, правило таково:
Нужно вывести самую короткую десятичную строку, которая точно преобразуется в обратном направлении.
Проблема № 2: скорость
Наивный способ найти кратчайший вывод — сгенерировать гораздо больше цифр, чем необходимо, а затем постепенно округлять и проверять, восстанавливается ли исходное число, пока не будет найдена кратчайшая строка, которая проходит весь цикл. Это работает, но крайне неэффективно.
Именно поэтому существуют специализированные алгоритмы:
Dragon4 (классический, точный, но сложный)
Grisu3, Errol3, Ryu (более новые, более быстрые, доказательно минимальные)
Эти алгоритмы являются одними из самых сложных частей языковых сред выполнения (C, Java, Python, JavaScript и т. д.). Кроме того, производительность тесно связана со следующей сложностью.
Проблема № 3: только правильные цифры
Концептуально, чтобы сгенерировать правильные десятичные цифры, мы хотим выразить число с плавающей запятой в виде рационального числа:
Но число с плавающей запятой имеет огромный динамический диапазон. Наибольшее конечное число типа double
превышает . Это число имеет 308 десятичных цифр, что намного превышает возможности даже 128-битного целого числа (максимум
). Поэтому нам нужна высокоточная арифметика (большие числа), чтобы выполнить точное деление без переполнения.
Современные алгоритмы, такие как Ryu и Grisu, хитро избегают большинства операций с большими числами: они быстро генерируют цифры с помощью 64/128-битной арифметики целых чисел и прибегают к большим числам только в редких случаях (проверка границ, сложные пограничные случаи). Это обеспечивает как правильность (безопасность при обратном проходе), так и скорость.
Печать точного значения
Тем не менее, для моих нужд мне не важны самые короткие строки или скорость. Вместо этого я хочу печатать точное сохраненное значение числа с плавающей запятой. Почему? Потому что я пишу библиотеку эмуляции плавающей точки, и возможность видеть точные значения значительно упрощает отладку.
Обратите внимание, что даже если большинство десятичных дробей не могут быть точно представлены в двоичной системе, каждая конечная двоичная дробь имеет конечное десятичное разложение. Таким образом, вывод точного значения всегда возможен.
Более десяти лет назад Брюс Доусон опубликовал реализацию на C++, которая выводит точное сохраненное значение float
. Его метод использовал только арифметику int32_t
для одинарной точности, но полагался на частичную реализацию длинной арифметики для десятичного преобразования (путем повторяющегося деления на 10).
Технически это прекрасно, но я хочу что-то более простое и прозрачное.
Класс Float7
Мне нужен простой, короткий и надежный код. Я не хочу отлаживать вычисления деления/остатка для длинной арифметики. Если вам нравится простой код, давайте вернемся к нашему игрушечному 7-битному беззнаковому числу с плавающей запятой, которое мы создали в предыдущей главе:
n_e = 3
n_m = 7-n_e
anchors = [ 0 ]
for e in range(-2**(n_e-1)+1, 2**(n_e-1)+1): # prepare 2**n_e intervals
anchors.append(2**e)
numbers = []
for i in range(len(anchors)-1): # for each interval
for m in range(2**n_m): # populate with 2**n_m numbers
v = anchors[i] + m/2**n_m * (anchors[i+1]-anchors[i])
numbers.append(v)
print(numbers)
Это делит диапазон на 8 интервалов, каждый из которых заполнен 16 равномерно распределенными числами. Всего у нас есть 128 значений:
[0.0, 0.0078125, 0.015625, 0.0234375, 0.03125, 0.0390625, 0.046875, 0.0546875, 0.0625, 0.0703125, 0.078125, 0.0859375, 0.09375, 0.1015625, 0.109375, 0.1171875, 0.125, 0.1328125, 0.140625, 0.1484375, 0.15625, 0.1640625, 0.171875, 0.1796875, 0.1875, 0.1953125, 0.203125, 0.2109375, 0.21875, 0.2265625, 0.234375, 0.2421875, 0.25, 0.265625, 0.28125, 0.296875, 0.3125, 0.328125, 0.34375, 0.359375, 0.375, 0.390625, 0.40625, 0.421875, 0.4375, 0.453125, 0.46875, 0.484375, 0.5, 0.53125, 0.5625, 0.59375, 0.625, 0.65625, 0.6875, 0.71875, 0.75, 0.78125, 0.8125, 0.84375, 0.875, 0.90625, 0.9375, 0.96875, 1.0, 1.0625, 1.125, 1.1875, 1.25, 1.3125, 1.375, 1.4375, 1.5, 1.5625, 1.625, 1.6875, 1.75, 1.8125, 1.875, 1.9375, 2.0, 2.125, 2.25, 2.375, 2.5, 2.625, 2.75, 2.875, 3.0, 3.125, 3.25, 3.375, 3.5, 3.625, 3.75, 3.875, 4.0, 4.25, 4.5, 4.75, 5.0, 5.25, 5.5, 5.75, 6.0, 6.25, 6.5, 6.75, 7.0, 7.25, 7.5, 7.75, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 13.5, 14.0, 14.5, 15.0, 15.5]
У нас есть 128 чисел , индексированных 8 значениями
и 16 значениями
. Вставим явно значения опорных точек
в строку 11 вышеприведенного фрагмента кода на Python.
Таким образом, мы можем записать общую формулу:
Это отражает различие между денормализованными числами (первый случай) и нормальными (второй случай).
Следовательно, мы можем переписать вышеуказанный фрагмент в следующей форме:
numbers = []
for e in range(-2**(n_e-1), 2**(n_e-1)):
for m in range(2**n_m):
if e == -2**(n_e-1):
e += 1 # subnormal number
else:
m += 2**n_m # normal number
v = m * 2**(e-n_m)
numbers.append(v)
print(numbers)
Обратите внимание на строки 5 и 7:
мы увеличиваем
, если число является денормализованным,
и добавляем
к
, если число является нормальным
.
Таким образом, мы получаем уникальную формулу для конечного числа, которое мы генерируем в строке 9. Фактически это означает, что, несмотря на то, что мы фактически храним битов числа
в памяти, это
-битное число без знака. Дополнительный 5-й бит нашего
Float7
не хранится явно, он «скрыт» и может быть восстановлен из значения e
.
Это также означает, что мантисса кодирует число с фиксированной запятой с 1 битом для целой части и
битами для дробной части.
Следовательно, . Умножив его на
, мы сдвигаем точку на
двоичных разрядов.
Вместо перечисления всех возможных чисел с плавающей запятой, давайте создадим класс Float7
для представления одного значения:
class Float7:
def __init__(self, uint7):
self.e = uint7 // 16 - 4
self.m = uint7 % 16
if self.e == -4:
self.e += 1 # if subnormal, increment the exponent, the hidden bit = 0
else:
self.m += 16 # if normal, recover the hidden bit = 1
def __float__(self):
return self.m * 2**(self.e-4)
print(float(Float7(93)))
Конструктор (строка 2) принимает 7-битный шаблон и разделяет его на экспоненту и мантиссу
, восстанавливая скрытый бит. Оператор
__float__()
преобразует наш пользовательский класс в нативные числа с плавающей запятой Python. Наконец, код выводит 93е число с плавающей запятой:
3.625
На всякий случай проверим вычисления:
Конструктор получает битовые шаблоны 101
и 1101
для экспоненты и мантиссы. Знаковая интерпретация со смещением говорит нам, что 101
интерпретируется как :
Число является нормальным, поэтому мы должны восстановить скрытый бит 1 для мантиссы:
Наконец, приведение типа __float__()
дает нам 3.625
, как и должно быть:
Сложение в столбик
Теперь перейдем к выводу на экран. Как мы видели, каждое отдельное число с плавающей запятой можно рассматривать как число с фиксированной запятой в соответствующем интервале. Поэтому можно найти общий знаменатель для всех чисел с плавающей запятой.
Оказывается, что наш Float7
может быть представлен в виде числа с фиксированной запятой, имеющего 4 бита в целой части и 7 бит в дробной части, что можно обозначить как формат Q4.7. Мы можем определить это, учитывая, что мантисса числа с плавающей запятой является числом с фиксированной запятой Q1.4. Максимальная экспонента числа с плавающей запятой равна 3, что эквивалентно сдвигу мантиссы влево на 3 позиции. Минимальная экспонента числа с плавающей запятой равна -3, что эквивалентно сдвигу мантиссы вправо на 3 позиции. Эти величины сдвига нашей мантиссы Q1.4 означают, что все числа с плавающей запятой могут поместиться в число с фиксированной запятой Q4.7, что составляет в общей сложности 11 бит.
Скрытый текст
Мы можем представить все значения float32
без знака, включая денормализованные, с помощью 277-битного формата с фиксированной запятой Q128.149. 277 бит — это огромное число, и чаще всего для его обработки используется длинная арифметика. Я хочу полностью избежать арифметических операций с такими большими числами.
Все, что нам нужно сделать, это создать это число, вставив мантиссу в нужное место, а затем преобразовать большое число с фиксированной запятой в десятичное.
Рассмотрим пример из предыдущего фрагмента кода на Python.
Мантисса хранит число с фиксированной запятой Q1.4 . Экспонента
говорит нам, что нам нужно сдвинуть точку основания вправо на 1 разряд, поэтому представленное число равно
. Мы можем скопировать и вставить этот шаблон в число с фиксированной запятой Q4.7
0011.1010000
.
Назовем битовый шаблон , где
обозначает i-й бит. Значение
можно вычислить как сумму битового шаблона, умноженного на соответствующие степени двойки:
Мы можем восстановить его десятичное представление, выполнив то, что мы так любили в начальной школе, — сложение столбцами:
0,0078125 * 0
+ 0,0156250 * 0
+ 0,0312500 * 0
+ 0,0625000 * 0
+ 0,1250000 * 1
+ 0,2500000 * 0
+ 0,5000000 * 1
+ 1,0000000 * 1
+ 2,0000000 * 1
+ 4,0000000 * 0
8,0000000 * 0
= ---------
3,6250000
Да, действительно равно
!
Подводя итог, я хочу избежать арифметических операций с длинной арифметикой, явно вычислив (ещё на этапе написания программы!) десятичное представление степеней двойки, участвующих в нашем числе с фиксированной запятой Q4.7. Ну а простое сложение в столбик решает задачу.
Вот полный код:
class Float7:
def __init__(self, uint7):
self.e = uint7 // 16 - 4
self.m = uint7 % 16
if self.e == -4:
self.e += 1 # if subnormal, increment the exponent, the hidden bit = 0
else:
self.m += 16 # if normal, recover the hidden bit = 1
def __float__(self):
return self.m * 2**(self.e-4)
def __str__(self):
return str(Q4_7(self.e + 3, self.m))
class Q4_7: # 11-bit number with 4 bits in the integer part and 7 in the fraction
digits = [
[5,0,0,0,0,0,0,0,0,0,0], # constant array, 11 powers of 2 in base 10
[2,5,0,0,0,0,0,0,0,0,0],
[1,2,5,0,0,0,0,0,0,0,0],
[8,6,2,5,0,0,0,0,0,0,0],
[7,5,1,2,5,0,0,0,0,0,0],
[0,1,3,6,2,5,0,0,0,0,0],
[0,0,0,0,1,2,5,0,0,0,0], # the decimal dot is here
[0,0,0,0,0,0,0,1,2,4,8],
[0,0,0,0,0,0,0,0,0,0,0] # zero padding to avoid extra logic in line 40
]
def __init__(self, offset, uint5):
self.number = [ 0 ]*11 # 11-bit number, binary expansion of uint5 * 2**offset
while uint5 > 0:
self.number[offset] = uint5 % 2
uint5 //= 2
offset += 1
def __str__(self):
string = ''
carry = 0
for position in range(9): # loop from the least significant digit to the most significant
total = sum(bit * dgt for bit, dgt in zip(self.number, self.digits[position])) # sum of 11 digits
digit = str((total + carry) % 10) # current digit to output
carry = (total + carry)//10
string = digit + string
if position==6: # decimal dot position
string = '.' + string
return string
print(Float7(93))
В строке 14 битовый шаблон вставляется в нужное место 11-разрядного числа с фиксированной запятой Q4_7
. Затем в строках 36-46 функция преобразования строк __str__()
просто суммирует столбцы над 11 степенями двойки, хранящимися в массиве digits
. Все очень просто!
Вот результат:
03.6250000
Удаляем ведущие и конечные нули, и все готово.
Вывод
Мы увидели, что вывод чисел с плавающей запятой — это удивительно сложная проблема. В то время как все более-менее нормальные языки используют сложные алгоритмы (Ryu, Grisu, Dragon4), для отладки реализаций soft-float мы можем пойти на хитрость: представлять значения в виде фиксированной запятой, заранее вычислить десятичные разложения и использовать простое сложение в столбик.
В следующий раз мы перейдем к сложению двух чисел с плавающей запятой. Оставайтесь с на связи!
Комментарии (0)
aamonster
21.09.2025 16:23Первое требование — безопасность при обратном преобразовании:
Да ладно. Такое требование возникает относительно редко – только при сериализации, а не при выводе для пользователя.
haqreu Автор
21.09.2025 16:23Не только в сериализации. А вообще с первых фраз я упомянул, что пользователь может указать точность, которая ему нужна. И предположил, что по умолчанию нужна полная точность.
aamonster
21.09.2025 16:23Ну вот это предположение, как правило, неверно. Но задача да, остаётся сложной, и много ответвлений. Например, считаем процентные доли чего-то и выводим – получаем 80% +13% + 6% (ну или там с десятыми-сотыми – сколько знаков пользователю нужно). Всегда найдётся кто-то, кто их сложит и будет возмущаться.
haqreu Автор
21.09.2025 16:23Ну как - как правило неверно? Питон, например, по умолчанию выводит именно так...
nerudo
21.09.2025 16:23Может для float нужно хранить не только значение, но и погрешность (в том числе накопленную при вычислениях)? Это позволило бы сразу понимать, где значащие разряды, а где мусор.
haqreu Автор
21.09.2025 16:23А в какой форме хранить погрешность? Либо в длинной арифметике, но тогда уж сразу нужно в ней считать, либо во флоатах, и тогда нам нужна будет погрешность на погрешность :)
nerudo
21.09.2025 16:23Ну можно хранить не точное значение, а оценку сверху - порядок погрешности. Тогда хоть в int'е.
haqreu Автор
21.09.2025 16:23Вообще люди уже об этом думали, конечно же. Но это сильно небесплатно, и часто можно обойтись без таких ухищрений. Если не принимать нужных мер, то зачастую интервалы расходятся, таким образом не давая никакой полезной информации. А если принимать нужные меры, то нередко интервалы не нужны.
Sirion
21.09.2025 16:23Кто об этой проблеме не подозревал, зумеры-вайбкодеры? Статья интересная, заголовок ужасен.
haqreu Автор
21.09.2025 16:23Предложите новый заголовок? Лично я не зумер, но сам никогда не задумывался, пока не понадобилось свою библиотеку написать, что вывод хотя бы просто целой части флоата уже требует длиной арифметики.
А что научные статьи на эту тему продолжают выходить сегодня, тем более.
qiper
21.09.2025 16:23Как же без кликбейта. Хорошо что не "ТОП1 проблем, о которых не подозревают 99% программистов!"
Wesha
21.09.2025 16:23Вася, зырь, миллениалы открыли для себя экспоненциальную форму хранения чисел (и прилагающиеся к ней детские грабли)!
haqreu Автор
21.09.2025 16:23Я правильно понял, что что вы предлагаете перестать писать учебники?
Wesha
21.09.2025 16:23вы предлагаете перестать писать учебники?
Я предлагаю начать их читать.
(Прикиньте, я об этой проблеме знал ещё до того, как у меня появился компьютер. Из книжек.)
haqreu Автор
21.09.2025 16:23А писать-то надо, или нет? Переведёте свой первый комментарий с саркастического на понятный?
Wesha
21.09.2025 16:23Писать‑то надо, но не на уровне «если получается Н.Е.Х., надо камлать заклинание
import Decimal
», а на уровне «На Самом Деле™ в битовом поле хранится N бит мантиссы и M бит порядка, [через 10 страниц] и поэтому надо подключить правильную библиотеку».Но, конечно, миллениалы — не читатели...
haqreu Автор
21.09.2025 16:23Писать‑то надо, но не на уровне «если получается Н.Е.Х., надо камлать заклинание
import Decimal
», а на уровне «На Самом Деле™ в битовом поле хранится N бит мантиссы и M бит порядка, [через 10 страниц] и поэтому надо подключить правильную библиотеку».Но, конечно, миллениалы — не читатели.
Если честно, я так и не понял, что именно вы имете против миллениалов, и кого вы имеете в виду.
Мне кажется, что я вложил больше усилий в попытку вас понять, нежели вы в попытку мне донести свою мысль. Возможно, конечно, что я ошибаюсь, но у нас разговор как-то не складывается.
Wesha
21.09.2025 16:23Вы статьи по приведённым в моём самом первом комментариии ссылочкам прочитали? Нет? Ч.Т.Д.
haqreu Автор
21.09.2025 16:23Я внимательно читаю комментарии, и посмотрел статьи по вашим ссылкам сразу же (оказалось, что я их пристально изучал до того). Что же означает ваш комментарий, для меня осталось загадкой.
rbuilder
21.09.2025 16:23В отдельных ЯП встречается тип данных Rational Numerics, где числа хранятся в виде числителя и знаменателя, поэтому в них 0.1 + 0.2 == 0.3 без сюрпризов.
Politura
21.09.2025 16:23По этой дороге можно зайти очень далеко: https://habr.com/ru/articles/883366/
haqreu Автор
21.09.2025 16:23Отличная статья! Но в этом учебнике я дальше плавающей точки не пойду, и так уже хватает головной боли :)
atomlib
Кстати, про эту проблему очень часто не задумываются — в том числе там, где идут серьёзные вопросы (деньги). В сентябре 2022 года я сделал такой скриншот интерфейса «СберМегаМаркета». Понятно, что это ни что это не влияет, это просто отображение на фронтенде.
haqreu Автор
Любопытно, я думал, что деньги всегда считают только целочисленной арифметикой.
atomlib
Думаю, на бэке обрабатывают как целое число копеек, а при необходимости делят на сто. Очень надеюсь — иначе кто-нибудь опять сожжёт офисное здание из-за конфискованного красного степлера.
Насколько предполагаю, это они на фронте не получают с бэка изначальную цену без скидок, а просто сложили реальную заплаченную сумму со всеми скидками и вывели как изначальную цену. При этом складывали наивно — как числа с плавающей запятой.
pae174
Подобных пробем в вычислениях на самом деле много и только часть из них решается хранением финансовых данных в виде целочисленного количества копеек. Вообще этим всем занимается так называемая вычислительная математика, которую преподают в нормальных вузах.
qiper
Хуже, когда выводится округлённое значение, а при попытке перевода, пишет, что не хватает денег. Что об этом думают обычные люди - загадка