Как следует отображать на экране результат деления 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.10000000000000000555111512312578270211815834045410156250.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Вообще люди уже об этом думали, конечно же. Но это сильно небесплатно, и часто можно обойтись без таких ухищрений. Если не принимать нужных мер, то зачастую интервалы расходятся, таким образом не давая никакой полезной информации. А если принимать нужные меры, то нередко интервалы не нужны.

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

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

lgorSL
21.09.2025 16:23А в какой форме хранить погрешность? Либо в длинной арифметике, но тогда уж сразу нужно в ней считать, либо во флоатах, и тогда нам нужна будет погрешность на погрешность :)
Физики у погрешности пишут только первую цифру и порядок. Обычно цель - оценить порядок бедствия и понять, в каком месте теряется точность.
Для этой цели вполне хорошо подходит пара float, где первый float это значение и второй это погрешность.
Формулы для погрешности сложения/умножения/синусов/косинусов и сходны с теми, что получаются при вычислении производных, там ничего сложного нет.
Из минусов только то, что арифметических операций будет больше.
haqreu Автор
21.09.2025 16:23Ну всё зависит от того, что и как вы считаете. У меня вычисления представляют собой длиннейшие цепочки, и чисто одной парой флоатов не обойтись.

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

Zenitchik
21.09.2025 16:23Физики у погрешности пишут только первую цифру и порядок.
А в двоичной системе первая цифра всегда будет 1.

haqreu Автор
21.09.2025 16:23?

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

haqreu Автор
21.09.2025 16:23Эм. Покажите, пожалуйста, определение?

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

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 (км/с)/Мпк

azTotMD
21.09.2025 16:23
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Я внимательно читаю комментарии, и посмотрел статьи по вашим ссылкам сразу же (оказалось, что я их пристально изучал до того). Что же означает ваш комментарий, для меня осталось загадкой.

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

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

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

haqreu Автор
21.09.2025 16:23Да, но это цикл статей "санпросвет о плавающей точке".

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

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

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

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

kipar
21.09.2025 16:23Сколько знаков после запятой в слагаемых, столько же должно "выводиться" в сумме.
А сколько их в слагаемых? Что если мы сложили 0.1+0.2 на сервере а потом результат передали на клиент?

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

haqreu Автор
21.09.2025 16:23Секунду, а как должен выглядеть вывод на экран, например
? Не все действия сводятся к простым суммам. Да и с суммами быстро проблемы начнутся, не зря в финансовой математике нужны специальные ухищрения.

diakin
21.09.2025 16:23Ну, а чему равен синус 0?
.

haqreu Автор
21.09.2025 16:23Хорошо, давайте я с другого края зайду. Как выводить на экран значение
?

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

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

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

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

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

diakin
21.09.2025 16:23зы.
Сколько знаков после запятой в слагаемых, столько же должно "выводиться" в сумме. 0.11+0.22=0.33
В общем случае "программа не знает" конечно сколько знаков после запятой было в слагаемых.
a=0.1 b=0.2 c=a+b print cВ этом случае print не знает, сколько знаков выводить.Программист должен это явно как-то указать.

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

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

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

atomlib
Кстати, про эту проблему очень часто не задумываются — в том числе там, где идут серьёзные вопросы (деньги). В сентябре 2022 года я сделал такой скриншот интерфейса «СберМегаМаркета». Понятно, что это ни что это не влияет, это просто отображение на фронтенде.
haqreu Автор
Любопытно, я думал, что деньги всегда считают только целочисленной арифметикой.
atomlib
Думаю, на бэке обрабатывают как целое число копеек, а при необходимости делят на сто. Очень надеюсь — иначе кто-нибудь опять сожжёт офисное здание из-за конфискованного красного степлера.
Насколько предполагаю, это они на фронте не получают с бэка изначальную цену без скидок, а просто сложили реальную заплаченную сумму со всеми скидками и вывели как изначальную цену. При этом складывали наивно — как числа с плавающей запятой.
Newbilius
А на что отсылка с красным степлером?
atomlib
«Офисное пространство» (1999).
Newbilius
Спасибо!
miksoft
Ни разу не видел хранения в целых копейках, хотя работаю с этим достаточно давно. Почти всегда это DECIMAL(X,Y), причем Y>=4. Изредка, когда нужна не сумма как таковая, а расчет статистического показателя бывают float/double.
mSnus
Аналогично, при этом всё время читаю статьи про то, как важно копейки хранить отдельным целым
SIISII
Особого смысла в их хранении отдельно нет. Проще хранить всю сумму не в рублях, а в копейках (или там в десятых/сотых копейки -- зависит от конкретных требований).
nixtonixto
Смысл появляется, когда вы конвертируете валюты. Там почти всегда на выходе получается число с множеством цифр после запятой. Округлять до 1/100 копейки? Тогда в масштабах страны на 100+ млн населения получится неплохой доход для того кто внедрит <правильный> алгоритм округления. Поэтому в банковской сфере используется плавучка.
Zenitchik
Вот только законным является только один алгоритм округления.
ponikrf
Хранение в копейках как целым числом очень удобно. Начисление и тп можно хранить в тех же double, но после агрегации - уже опять в копейках.
Это всегда гарантирует вам safe сравнение и базовые операции с числами.
SystemOutPrintln
А я видел. Сумма хранится в базе в копейках в поле с типом int8. Довольно интересное решение.
Соответственно, чтобы получить сумму в рублях, её надо поделить на 100
Chupaka
Int8 — это максимум 1,27 рубля, что ли? Или имелось в виду, что копейки хранятся в отдельном поле от рублей?..
SystemOutPrintln
Нет, вместе.
Это int8 из постгресса, который псевдоним для bigint. Диапазон от -9223372036854775808 до +9223372036854775807, хватит с головой :)
SIISII
Всего-то 18,5 десятичных цифр... Маловато будет! :) (на мэйнфреймах -- до 31)
pae174
Подобных пробем в вычислениях на самом деле много и только часть из них решается хранением финансовых данных в виде целочисленного количества копеек. Вообще этим всем занимается так называемая вычислительная математика, которую преподают в нормальных вузах.
0xMihalich
у postgres даже есть специальный тип данных money где под капотом int8, у которого две правые цифры становятся копейками. вот почему этот тип не используют?) ну классный же для денег
miksoft
Даже официальные курсы валют обычно имеют 4 знака после запятой. Так что обычно хранится 4 или больше.
Andy_U
Курс, это совсем не количество денег. Т.е. после умножения/деления числа рублей и копеек на курс результат округляется до центов. Понятия не имею, по какому финансовому правилу.
0xMihalich
ну для этих случаев есть Decimal в кликхаус и numeric в постгрес. и то и другое под капотом в интах значение хранит, но цифры после запятой можно задать
Beholder
https://wiki.postgresql.org/wiki/Don't_Do_This#Don.27t_use_money
SIISII
Ещё от архитектуры процессора зависит. Мэйнфреймы IBM от самого своего рождения (Система 360, 1964 год) имеют поддержку двоично-десятичных чисел (до 31 десятичной цифры плюс знак), и на них "экономическая" информация естественным образом считается в десятичной системе. Вот на ПК (IA-32, AMD64 -- неважно) или, скажем, на ARM любой разновидности такие вещи приходится считать, используя целочисленную арифметику; плавающая запятая -- лишь для задач, где ошибки округления приемлемы (скажем, научные расчёты: там не обязательно, чтоб последняя циферка сходилась, достаточно лишь обеспечить необходимую точность).
qiper
Хуже, когда выводится округлённое значение, а при попытке перевода, пишет, что не хватает денег. Что об этом думают обычные люди - загадка