Как следует отображать на экране результат деления 3.0 на 10.0 ? Сколько цифр следует вывести, если пользователь не указал точность?

Скорее всего, вы даже не знали, что вывод чисел с плавающей запятой — это сложная проблема, настолько сложная, что по ней написаны десятки научных статей, причём последний прорыв был относительно недавно, в 2016 году. На самом деле, это одна из самых сложных частей поддержки чисел с плавающей запятой в среде выполнения языка.

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

Три основные проблемы

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

Проблема № 1: нужное количество цифр

Обратите внимание, что я написал числа 3.0 и 10.0 в десятичной системе счисления (база 10), потому что это система, которую используют люди. Но большинство десятичных дробей не имеют точного двоичного представления:

0.3_{10} = 0.0100110011001100110011001100\dots_2.

Эта двоичная дробь бесконечна, паттерн 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 накопило три ошибки аппроксимации — по одной для каждого операнда и еще одну от сложения:

  1. 0.1 округляется до 0.1000000000000000055511151231257827021181583404541015625

  2. 0.2 округляется до 0.200000000000000011102230246251565404236316680908203125

  3. Их сумма вносит еще одну ошибку округления.

Теперь вопрос: как мы должны вывести x и y? Давайте посмотрим, как это делает Python:

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

  • Если система выводит слишком много цифр, лишние цифры могут быть просто «мусором» от двоичного округления.
    Даже если x равно 0.299999999999999988897769753748434595763683319091796875, нет необходимости печатать все 54 цифры после десятичной запятой. На самом деле мы хотели сохранить 0.3, поэтому 54 цифры являются фактически численным мусором. Простого 0.3 достаточно для определения значения x, даже если x не равно 0.3.

  • Если система выводит слишком мало цифр, результат будет неверным в более строгом смысле: обратное преобразование в двоичную систему может дать другое число с плавающей запятой.

    Например, самым коротким десятичным числом, которое однозначно идентифицирует y, является 0.30000000000000004. Вывод только 0.3 будет неправильным, потому что 0.3 при обратном преобразовании дает другое двоичное значение.

Таким образом, правило таково:
Нужно вывести самую короткую десятичную строку, которая точно преобразуется в обратном направлении.

Проблема № 2: скорость

Наивный способ найти кратчайший вывод — сгенерировать гораздо больше цифр, чем необходимо, а затем постепенно округлять и проверять, восстанавливается ли исходное число, пока не будет найдена кратчайшая строка, которая проходит весь цикл. Это работает, но крайне неэффективно.

Именно поэтому существуют специализированные алгоритмы:

  • Dragon4 (классический, точный, но сложный)

  • Grisu3, Errol3, Ryu (более новые, более быстрые, доказательно минимальные)

Эти алгоритмы являются одними из самых сложных частей языковых сред выполнения (C, Java, Python, JavaScript и т. д.). Кроме того, производительность тесно связана со следующей сложностью.

Проблема № 3: только правильные цифры

Концептуально, чтобы сгенерировать правильные десятичные цифры, мы хотим выразить число с плавающей запятой в виде рационального числа:

\frac{\text{целочисленный числитель}}{\text{степень двойки}}

Но число с плавающей запятой имеет огромный динамический диапазон. Наибольшее конечное число типа double превышает 10^{308}. Это число имеет 308 десятичных цифр, что намного превышает возможности даже 128-битного целого числа (максимум 10^{38}). Поэтому нам нужна высокоточная арифметика (большие числа), чтобы выполнить точное деление без переполнения.

Современные алгоритмы, такие как 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)

Это делит диапазон [0,16) на 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 чисел v_{e,m}, индексированных 8 значениямиe\in[-4\dots 3] и 16 значениями m\in[0\dots 15]. Вставим явно значения опорных точек e в строку 11 вышеприведенного фрагмента кода на Python.


Таким образом, мы можем записать общую формулу:

\scriptstyle v_{e,m} = \left\{\begin{array}{lll}0 + \frac{m}{2^{n_m}}\left(2^{e+1} - 0\right) & =\quad m \cdot 2^{e+1-n_m} \quad & \text{если}~e= -2^{n_e-1}\\ 2^e + \frac{m}{2^{n_m}}\left(2^{e+1} - 2^e\right) & = \quad (m+2^{n_m}) \cdot 2^{e-n_m}  & \text{в противном случае}\end{array}\right.

Это отражает различие между денормализованными числами (первый случай) и нормальными (второй случай).

Следовательно, мы можем переписать вышеуказанный фрагмент в следующей форме:

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:

  • мы увеличиваем e\leftarrow e+1, если число является денормализованным,

  • и добавляем 2^{n_m} к m, если число является нормальным m\leftarrow m+2^{n_m}.

Таким образом, мы получаем уникальную формулу для конечного числа, которое мы генерируем в строке 9. Фактически это означает, что, несмотря на то, что мы фактически храним n_m битов числа m в памяти, это (n_m+1)-битное число без знака. Дополнительный 5-й бит нашего Float7 не хранится явно, он «скрыт» и может быть восстановлен из значения e.

Это также означает, что мантисса m кодирует число с фиксированной запятой с 1 битом для целой части и 2^{n_m} битами для дробной части.
Следовательно, \frac m {2^{n_m}} \in [0, 2). Умножив его на 2^e, мы сдвигаем точку на e двоичных разрядов.

Вместо перечисления всех возможных чисел с плавающей запятой, давайте создадим класс 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-битный шаблон и разделяет его на экспоненту e и мантиссу m, восстанавливая скрытый бит. Оператор __float__() преобразует наш пользовательский класс в нативные числа с плавающей запятой Python. Наконец, код выводит 93е число с плавающей запятой:

3.625

На всякий случай проверим вычисления:

93_{10} = 101\ 1101_2

Конструктор получает битовые шаблоны 101 и 1101 для экспоненты и мантиссы. Знаковая интерпретация со смещением говорит нам, что 101 интерпретируется как 1_{10}:

e = \left\lfloor\frac{93}{16}\right\rfloor-4 = 1

Число является нормальным, поэтому мы должны восстановить скрытый бит 1 для мантиссы:

m = (93 \mod 16) + 16 = 29

Наконец, приведение типа __float__() дает нам 3.625, как и должно быть:

29 \cdot 2^{1-4} = 3.625

Сложение в столбик

Теперь перейдем к выводу на экран. Как мы видели, каждое отдельное число с плавающей запятой можно рассматривать как число с фиксированной запятой в соответствующем интервале. Поэтому можно найти общий знаменатель для всех чисел с плавающей запятой.


Оказывается, что наш Float7 может быть представлен в виде числа с фиксированной запятой, имеющего 4 бита в целой части и 7 бит в дробной части, что можно обозначить как формат Q4.7. Мы можем определить это, учитывая, что мантисса числа с плавающей запятой является числом с фиксированной запятой Q1.4. Максимальная экспонента числа с плавающей запятой равна 3, что эквивалентно сдвигу мантиссы влево на 3 позиции. Минимальная экспонента числа с плавающей запятой равна -3, что эквивалентно сдвигу мантиссы вправо на 3 позиции. Эти величины сдвига нашей мантиссы Q1.4 означают, что все числа с плавающей запятой могут поместиться в число с фиксированной запятой Q4.7, что составляет в общей сложности 11 бит.

Скрытый текст

Мы можем представить все значения float32 без знака, включая денормализованные, с помощью 277-битного формата с фиксированной запятой Q128.149. 277 бит — это огромное число, и чаще всего для его обработки используется длинная арифметика. Я хочу полностью избежать арифметических операций с такими большими числами.

Все, что нам нужно сделать, это создать это число, вставив мантиссу в нужное место, а затем преобразовать большое число с фиксированной запятой в десятичное.


Рассмотрим пример из предыдущего фрагмента кода на Python.

m = 29_{10} = 11101_2

Мантисса хранит число с фиксированной запятой Q1.4 1.1101_2. Экспонента e=1 говорит нам, что нам нужно сдвинуть точку основания вправо на 1 разряд, поэтому представленное число равно v = 11.101_2. Мы можем скопировать и вставить этот шаблон в число с фиксированной запятой Q4.7 0011.1010000.


Назовем битовый шаблон b = \{0,0,0,0,1,0,1,1,1,0,0\}, где b_i обозначает i-й бит. Значение v можно вычислить как сумму битового шаблона, умноженного на соответствующие степени двойки:

v = \sum_{i=0}^{10} b_i\ 2^{i-7}

Мы можем восстановить его десятичное представление, выполнив то, что мы так любили в начальной школе, — сложение столбцами:

     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

Да, v действительно равно 3,625_{10}!

Подводя итог, я хочу избежать арифметических операций с длинной арифметикой, явно вычислив (ещё на этапе написания программы!) десятичное представление степеней двойки, участвующих в нашем числе с фиксированной запятой 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)


  1. atomlib
    21.09.2025 16:23

    Кстати, про эту проблему очень часто не задумываются — в том числе там, где идут серьёзные вопросы (деньги). В сентябре 2022 года я сделал такой скриншот интерфейса «СберМегаМаркета». Понятно, что это ни что это не влияет, это просто отображение на фронтенде.


    1. haqreu Автор
      21.09.2025 16:23

      Любопытно, я думал, что деньги всегда считают только целочисленной арифметикой.


      1. atomlib
        21.09.2025 16:23

        Думаю, на бэке обрабатывают как целое число копеек, а при необходимости делят на сто. Очень надеюсь — иначе кто-нибудь опять сожжёт офисное здание из-за конфискованного красного степлера.

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


      1. pae174
        21.09.2025 16:23

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


    1. qiper
      21.09.2025 16:23

      Хуже, когда выводится округлённое значение, а при попытке перевода, пишет, что не хватает денег. Что об этом думают обычные люди - загадка


  1. aamonster
    21.09.2025 16:23

    Первое требование — безопасность при обратном преобразовании:

    Да ладно. Такое требование возникает относительно редко – только при сериализации, а не при выводе для пользователя.


    1. haqreu Автор
      21.09.2025 16:23

      Не только в сериализации. А вообще с первых фраз я упомянул, что пользователь может указать точность, которая ему нужна. И предположил, что по умолчанию нужна полная точность.


      1. aamonster
        21.09.2025 16:23

        Ну вот это предположение, как правило, неверно. Но задача да, остаётся сложной, и много ответвлений. Например, считаем процентные доли чего-то и выводим – получаем 80% +13% + 6% (ну или там с десятыми-сотыми – сколько знаков пользователю нужно). Всегда найдётся кто-то, кто их сложит и будет возмущаться.


        1. haqreu Автор
          21.09.2025 16:23

          Ну как - как правило неверно? Питон, например, по умолчанию выводит именно так...


  1. nerudo
    21.09.2025 16:23

    Может для float нужно хранить не только значение, но и погрешность (в том числе накопленную при вычислениях)? Это позволило бы сразу понимать, где значащие разряды, а где мусор.


    1. haqreu Автор
      21.09.2025 16:23

      А в какой форме хранить погрешность? Либо в длинной арифметике, но тогда уж сразу нужно в ней считать, либо во флоатах, и тогда нам нужна будет погрешность на погрешность :)


      1. nerudo
        21.09.2025 16:23

        Ну можно хранить не точное значение, а оценку сверху - порядок погрешности. Тогда хоть в int'е.


        1. haqreu Автор
          21.09.2025 16:23

          Вообще люди уже об этом думали, конечно же. Но это сильно небесплатно, и часто можно обойтись без таких ухищрений. Если не принимать нужных мер, то зачастую интервалы расходятся, таким образом не давая никакой полезной информации. А если принимать нужные меры, то нередко интервалы не нужны.


  1. Sirion
    21.09.2025 16:23

    Кто об этой проблеме не подозревал, зумеры-вайбкодеры? Статья интересная, заголовок ужасен.


    1. haqreu Автор
      21.09.2025 16:23

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

      А что научные статьи на эту тему продолжают выходить сегодня, тем более.


    1. qiper
      21.09.2025 16:23

      Как же без кликбейта. Хорошо что не "ТОП1 проблем, о которых не подозревают 99% программистов!"


  1. Wesha
    21.09.2025 16:23

    Вася, зырь, миллениалы открыли для себя экспоненциальную форму хранения чисел (и прилагающиеся к ней детские грабли)!


    1. haqreu Автор
      21.09.2025 16:23

      Я правильно понял, что что вы предлагаете перестать писать учебники?


      1. Wesha
        21.09.2025 16:23

        вы предлагаете перестать писать учебники?

        Я предлагаю начать их читать.

        (Прикиньте, я об этой проблеме знал ещё до того, как у меня появился компьютер. Из книжек.)


        1. haqreu Автор
          21.09.2025 16:23

          А писать-то надо, или нет? Переведёте свой первый комментарий с саркастического на понятный?


          1. Wesha
            21.09.2025 16:23

            Писать‑то надо, но не на уровне «если получается Н.Е.Х., надо камлать заклинание import Decimal», а на уровне «На Самом Деле™ в битовом поле хранится N бит мантиссы и M бит порядка, [через 10 страниц] и поэтому надо подключить правильную библиотеку».

            Но, конечно, миллениалы — не читатели...


            1. haqreu Автор
              21.09.2025 16:23

              Писать‑то надо, но не на уровне «если получается Н.Е.Х., надо камлать заклинание import Decimal», а на уровне «На Самом Деле™ в битовом поле хранится N бит мантиссы и M бит порядка, [через 10 страниц] и поэтому надо подключить правильную библиотеку».

              Но, конечно, миллениалы — не читатели.

              Если честно, я так и не понял, что именно вы имете против миллениалов, и кого вы имеете в виду.

              Мне кажется, что я вложил больше усилий в попытку вас понять, нежели вы в попытку мне донести свою мысль. Возможно, конечно, что я ошибаюсь, но у нас разговор как-то не складывается.


              1. Wesha
                21.09.2025 16:23

                Вы статьи по приведённым в моём самом первом комментариии ссылочкам прочитали? Нет? Ч.Т.Д.


                1. haqreu Автор
                  21.09.2025 16:23

                  Я внимательно читаю комментарии, и посмотрел статьи по вашим ссылкам сразу же (оказалось, что я их пристально изучал до того). Что же означает ваш комментарий, для меня осталось загадкой.


  1. rbuilder
    21.09.2025 16:23

    В отдельных ЯП встречается тип данных Rational Numerics, где числа хранятся в виде числителя и знаменателя, поэтому в них 0.1 + 0.2 == 0.3 без сюрпризов.


    1. haqreu Автор
      21.09.2025 16:23

      Да, но это цикл статей "санпросвет о плавающей точке".


      1. rbuilder
        21.09.2025 16:23

        Mille pardons за мое грубое вмешательство


  1. Politura
    21.09.2025 16:23

    По этой дороге можно зайти очень далеко: https://habr.com/ru/articles/883366/


    1. haqreu Автор
      21.09.2025 16:23

      Отличная статья! Но в этом учебнике я дальше плавающей точки не пойду, и так уже хватает головной боли :)