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

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

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

Это вторая статья из цикла «Санпросвет о плавающей точке»:

  1. Компьютеры и числа

  2. Вывод чисел с плавающей точкой на экран <- вы тут

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

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

Проблема № 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. Newbilius
          21.09.2025 16:23

          А на что отсылка с красным степлером?


          1. atomlib
            21.09.2025 16:23

            «Офисное пространство» (1999).


            1. Newbilius
              21.09.2025 16:23

              Спасибо!


        1. miksoft
          21.09.2025 16:23

          Ни разу не видел хранения в целых копейках, хотя работаю с этим достаточно давно. Почти всегда это DECIMAL(X,Y), причем Y>=4. Изредка, когда нужна не сумма как таковая, а расчет статистического показателя бывают float/double.


          1. mSnus
            21.09.2025 16:23

            Аналогично, при этом всё время читаю статьи про то, как важно копейки хранить отдельным целым


            1. SIISII
              21.09.2025 16:23

              Особого смысла в их хранении отдельно нет. Проще хранить всю сумму не в рублях, а в копейках (или там в десятых/сотых копейки -- зависит от конкретных требований).


              1. nixtonixto
                21.09.2025 16:23

                Смысл появляется, когда вы конвертируете валюты. Там почти всегда на выходе получается число с множеством цифр после запятой. Округлять до 1/100 копейки? Тогда в масштабах страны на 100+ млн населения получится неплохой доход для того кто внедрит <правильный> алгоритм округления. Поэтому в банковской сфере используется плавучка.


                1. Zenitchik
                  21.09.2025 16:23

                  для того кто внедрит <правильный> алгоритм округления

                  Вот только законным является только один алгоритм округления.


          1. ponikrf
            21.09.2025 16:23

            Хранение в копейках как целым числом очень удобно. Начисление и тп можно хранить в тех же double, но после агрегации - уже опять в копейках.

            Это всегда гарантирует вам safe сравнение и базовые операции с числами.


          1. SystemOutPrintln
            21.09.2025 16:23

            Ни разу не видел хранения в целых копейках

            А я видел. Сумма хранится в базе в копейках в поле с типом int8. Довольно интересное решение.

            Соответственно, чтобы получить сумму в рублях, её надо поделить на 100


            1. Chupaka
              21.09.2025 16:23

              Int8 — это максимум 1,27 рубля, что ли? Или имелось в виду, что копейки хранятся в отдельном поле от рублей?..


              1. SystemOutPrintln
                21.09.2025 16:23

                Или имелось в виду, что копейки хранятся в отдельном поле от рублей?..

                Нет, вместе.

                Int8 — это максимум 1,27 рубля, что ли?

                Это int8 из постгресса, который псевдоним для bigint. Диапазон от -9223372036854775808 до +9223372036854775807, хватит с головой :)


                1. SIISII
                  21.09.2025 16:23

                  Всего-то 18,5 десятичных цифр... Маловато будет! :) (на мэйнфреймах -- до 31)


      1. pae174
        21.09.2025 16:23

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


      1. 0xMihalich
        21.09.2025 16:23

        у postgres даже есть специальный тип данных money где под капотом int8, у которого две правые цифры становятся копейками. вот почему этот тип не используют?) ну классный же для денег


        1. miksoft
          21.09.2025 16:23

          Даже официальные курсы валют обычно имеют 4 знака после запятой. Так что обычно хранится 4 или больше.


          1. Andy_U
            21.09.2025 16:23

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


          1. 0xMihalich
            21.09.2025 16:23

            ну для этих случаев есть Decimal в кликхаус и numeric в постгрес. и то и другое под капотом в интах значение хранит, но цифры после запятой можно задать



      1. SIISII
        21.09.2025 16:23

        Ещё от архитектуры процессора зависит. Мэйнфреймы IBM от самого своего рождения (Система 360, 1964 год) имеют поддержку двоично-десятичных чисел (до 31 десятичной цифры плюс знак), и на них "экономическая" информация естественным образом считается в десятичной системе. Вот на ПК (IA-32, AMD64 -- неважно) или, скажем, на ARM любой разновидности такие вещи приходится считать, используя целочисленную арифметику; плавающая запятая -- лишь для задач, где ошибки округления приемлемы (скажем, научные расчёты: там не обязательно, чтоб последняя циферка сходилась, достаточно лишь обеспечить необходимую точность).


    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. stan_volodarsky
            21.09.2025 16:23

            К сожалению, интервальная арифметика не всегда помогает. Например, в ней x - x != 0. И при сложных вычислениях интервалы быстро становятся гигантскими.


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

              Ну я об этом и написал. К сожалению, панацеи в области представления чисел ещё не придумали (и, скорее всего, не придумают).


      1. lgorSL
        21.09.2025 16:23

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

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


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

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


        1. sic
          21.09.2025 16:23

          Дело в том, что хорошие вычислительные методы, дают предсказуемую погрешность, зачастую значительно меньшую, чем погрешность отдельных операций в составе их алгоритмов. "остаточный член на n-ной итерации", такие вот штуки, и, конечно, кому это надо, они это считают. Смутно конечно помню это с курсов в универе. Но суть представляю: зачем на каждую операцию погрешность считать, тратя на это время, если про многие популярные методы вычислений эти погрешности уже аналитически рассчитаны.


        1. Zenitchik
          21.09.2025 16:23

          Физики у погрешности пишут только первую цифру и порядок.

          А в двоичной системе первая цифра всегда будет 1.


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

            0_{10} = 0_2 ?


            1. Zenitchik
              21.09.2025 16:23

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


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

                Эм. Покажите, пожалуйста, определение?


                1. Zenitchik
                  21.09.2025 16:23

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

                  Нулевую погрешность не рассматриваем как практически невероятную. А если уж так надо её задать, можно предусмотреть одно спецзначение.


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

                    Вы первый начали. Физики пишут в десятичной системе.


                    1. Zenitchik
                      21.09.2025 16:23

                      Но хранить-то данные придётся в двоичной. Вы внатуре не понимаете, о чём я говорю?


        1. rombell
          21.09.2025 16:23

          Физики у погрешности пишут только первую цифру и порядок

          Отнюдь. Гравитационная постоянная

          G = (6,67430 ±15) *10E−11 м3/с^2/кг рекомендованное, или

          G = (6,674184±78) *10E−11 м3/с^2/кг уточнённое в эксперименте

          Или оценка постоянной Хаббла

          74,03 ± 1,42 (км/с)/Мпк


    1. azTotMD
      21.09.2025 16:23

      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. haqreu Автор
                  21.09.2025 16:23

                  Кажется, я начал догадываться. А вы же самую первую ссылку в моей статье пропустили, да?


                  1. Andy_U
                    21.09.2025 16:23

                    И я пропустил. И ссылку, и ту саму статью. Меня еще в 9-м классе учили переводить числа из десятичного представления в двоичное и обратно. Угу, карандишиком на бумажке в клеточку. Ага, 1972 год, тяжелое детство, деревянные игрушки, двухтомник Лорана Шварца (чуть позже, правда). И поэтому заголовок данной статьи меня несколько изумил. Потому что, то, о чем вы пишете. оно в подкорке. Все, как отношение котов/кошек с ежами. Ну нет такого зверя, в упор не вижу. Ну как я могу, хозяин, уколоться, нет же никого тут, кроме нас с тобой.


    1. Zenitchik
      21.09.2025 16:23

      Миллениалы открыли её минимум 15 лет назад. Теперь, наверно, уже очередь зуммеров.


      1. Wesha
        21.09.2025 16:23

        Миллениалы открыли её минимум 15 лет назад. Теперь, наверно, уже очередь зуммеров.

        Зуммеры: «А мы чо? Мы не жужжим!»


        1. Zenitchik
          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. ris58h
        21.09.2025 16:23

        санпросвет

        Уже не первый раз встречаю это слово на Хабре за последнюю неделю в совершенно неподходящем контексте. О каком санитарно-эпидемиологическом просвещении речь? Похоже, стоит вам провести ликбез.


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

          Зачем же так буквально? Метонимии никто не отменял.

          Можно было вообще вот это использовать :)


          1. Wesha
            21.09.2025 16:23

  1. Politura
    21.09.2025 16:23

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


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

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


  1. AbitLogic
    21.09.2025 16:23

    Есть же крейт rust-decimal


  1. diakin
    21.09.2025 16:23

    какую бы десятичную строку мы ни вывели

    Вопрос - что такое "вывели"? И для чего это "вывели" требуется. Вывели на экран? Вывели сумму в документе? Этот "вывод" требуется для представления человеку. И если человек складывал 0.1+0.2 то выводиться должно 0.3, как бы оно там в двоичном преобразовании туда-сюда не выглядело. Сколько знаков после запятой в слагаемых, столько же должно "выводиться" в сумме. 0.11+0.22=0.33 3.14159+0.00001=3.14160 0.1+0.001=0.1001


    1. kipar
      21.09.2025 16:23

      Сколько знаков после запятой в слагаемых, столько же должно "выводиться" в сумме.

      А сколько их в слагаемых? Что если мы сложили 0.1+0.2 на сервере а потом результат передали на клиент?


      1. diakin
        21.09.2025 16:23

        А что должен человек увидеть на клиенте? Я говорю о том, что есть внутреннее машинное представление, а есть "вывод на экран" для человека.


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

      Секунду, а как должен выглядеть вывод на экран, например \sin 0? Не все действия сводятся к простым суммам. Да и с суммами быстро проблемы начнутся, не зря в финансовой математике нужны специальные ухищрения.


      1. diakin
        21.09.2025 16:23

        Ну, а чему равен синус 0? Sin0=0.


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

          Хорошо, давайте я с другого края зайду. Как выводить на экран значение \sin 1?


          1. diakin
            21.09.2025 16:23

            Да, сколько знаков после запятой выводить должен определять человек.


          1. sic
            21.09.2025 16:23

            Синусы как раз прекрасно сводятся к простым суммам (ряды Тейлора), но не понятно, как это вообще должно быть связано с отображением результата. В sin(1) ни конечной в десятичной записи рациональной дроби, ни даже периодической вы не увидите, поэтому, по сути вопрос в том, на каком знаке после запятой его округлить. А это примерно "как пользователю нужно". Меня бы 0.01745 бы устроило, кроме тех случаев, когда нужно дальше с этим что-то считать.


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

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


    1. BorisU
      21.09.2025 16:23

      Кому должно? преобразование в двоичное представление необратимо вносит ошибку для десятичных дробей.


      1. diakin
        21.09.2025 16:23

        И что? Сложите в калькуляторе Windows 0.1+0.001 - будет он на экране результат отображать с ошибкой? А если команда print выводит на экран черти что - то кто в этом виноват?


        1. stan_volodarsky
          21.09.2025 16:23

          Функция print печатает равные значения одинаковым образом, а разные - различным. И это мне сильно облегчает жизнь. Если вы хотите чтобы было по-другому, выразите свои предпочтения через форматную строку.


          1. diakin
            21.09.2025 16:23

            Да, вы правы.


    1. diakin
      21.09.2025 16:23

      зы.

      Сколько знаков после запятой в слагаемых, столько же должно "выводиться" в сумме. 0.11+0.22=0.33

      В общем случае "программа не знает" конечно сколько знаков после запятой было в слагаемых.

      a=0.1
      b=0.2
      c=a+b
      print c

      В этом случае print не знает, сколько знаков выводить.Программист должен это явно как-то указать.


  1. Refridgerator
    21.09.2025 16:23

    За статью спасибо, пишете хорошо, о существовании прямо нескольких и замороченных алгоритмов для такого лично я не подозревал. О существовании типов decimal и rational действительно знают достаточно малое людей только потому, что они не на слуху. Ещё меньше людей знают, что формат с плавающей точкой был придуман не для дробных чисел, а для инженеров и физиков, у которых есть как очень большие, так и очень маленькие фундаментальные константы.


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

      Спасибо на добром слове. Знаете, что меня удивляет*? Эти статьи о замороченных алгоритмах имеют крохи цитирований. Например, один из основоположников процитирован 34 раза..

      И это при том, что во всех рантаймах всех толковых языков есть один из этих алгоритмов...

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

      На самом деле, не удивляет, поскольку эту кухню я знаю слишком хорошо.


  1. artyom7777
    21.09.2025 16:23

    С помощью decimal.getcontext().prec = (число знаков после запятой) можно получить точность намного больше 64 знаков после запятой


    1. BorisU
      21.09.2025 16:23

      и соответствующие тормоза