Плаваюча (рухома) крапка, частина 7: десяткова рухома крапка

Читайте також:
— Плаваюча (рухома) крапка. Частини 1-2: ввідна, перші граблі. Там же каталог всіх частин.
— Плаваюча (рухома) крапка. Частина 3: Сучасний стандарт і все навколо — основи.
— Плаваюча (рухома) крапка. Частина 4: Сучасний стандарт — тонкі деталі і проблеми
— Плаваюча (рухома) крапка. Частина 5: Текстовий експорт-імпорт
— Плаваюча (рухома) крапка. Частина 6: Порівняння

Основи і фіксована крапка

Розповідаючи про десятковий варіант рухомої крапки (коми), треба почати з деякого вступу системи «disclaimer», або «що не так і чому з ним треба інакше».

Як вже згадувалось, а зараз ще раз перекажемо відкрито, використання основи 2 і основи 10 це різни домени, тобто «світи», які мало перетинаються. Ну, звісно, якщо використовувати кожний за призначенням і оптимально. Чи можна рахувати те, що треба в десятковій арифметиці, в двійковій? Ну, до першого критичного баґа — можна.

Маленький приклад на такі баґи з дискусії, відтворений в Python на вбудованому float, який є IEEE754 binary64:

>>> (1.68+1.71+1.715+1.965)/4
1.7674999999999998
>>> (1.965+1.715+1.71+1.68)/4
1.7675

Далі в Excel викликається mround() до 0.005 від суми значень клітин, і результати змінюються залежно від порядку сортування рядків.

>>> round((1.68+1.71+1.715+1.965)/4/0.005)
353
>>> round((1.68+1.71+1.715+1.965)/4/0.005)/200
1.765
>>> round((1.965+1.715+1.71+1.68)/4/0.005)
354
>>> round((1.965+1.715+1.71+1.68)/4/0.005)/200
1.77

Відповідно, кінцевий результат mround() буде 1.765 або 1.77. Змінили порядок доданків — отримали інший результат. Якщо це були фізичні обчислення, то маленьке відхілення не суттєве (ну і такий фінальний round() там нетиповий), якщо гроші — пишіть приїхало, результат не є дозволеним.

(І, до речі,

>>> round((1.68+1.71+1.715+1.965)/4/0.005)*0.005
1.7650000000000001

Помножити на 0.005 і розділити на 200 — різні операції.)

Чому Excel, який весь орієнтований саме на гроші, користується двійковою плавучкою? окреме тяжке питання до Microsoft... Я питав LLM, він нічого корисного не каже, крім посилань на леґасі і «кому треба, той в курсі і давно сам закотує сонце вручну».

Або, вже не в цьому прикладі, перевірка на «x ⩽ 100» не спрацювала тому, що x опинилось 100.0000000002. Можуть бути і більш тонкі ефекти, і відслідковувати їх, в довгому ланцюжку подій, буде дуже складно, а доводити коректність — тим паче (на якому етапі такі мікро-похибки дозволені, і в якій кількості, а на якому — ні?) Тому, щоб не мати подібних проблем, треба всі такі розрахунки вести в десятковій системі.

Недоліки? Розрахунок при основі 10, по-перше, дорожче у виконанні (іноді — значно дорожче); по-друге, більше страждає від похибок молодших разрядів. При основі 2, дещо спрощуючи, ця похибка обмежена 1 бітом. При основі 16 — 4 бітами. При 10 — фактично, теж 4 (бо log2(10) ≈ 3.32...) Втрачати ще 3 біти на щось непотрібне зараз це не найкраща ідея, тому в розрахунках на максимум точности (матфізика і схожі) схиляються до основи 2. Але... повернімось: але коли нам потрібні результати, які точні саме при виконанні з основою 10, варіантів нема — щонайменше, самі розрахунки треба виконувати саме так. І коли ми не можемо дозволити собі похибку — ми вимушені робити так, щоб поява похибки вважалась помилкою розрахунку. Для цільового домену основи 10 похибка це майже завжди ненормальне, і це «по-друге» перестає впливати.

Все це змінює підходи до розрахунків, і є два головних підходи, які використовуються по одному або змішуються.

Одразу же згадаємо «General Decimal Arithmetic Specification» (GDAS) від IBM, остання відома версія від 2009. Воно може бути порівняно з IEEE754 бути типовим прикладом того, як не апаратна, а проґрамна реалізація, перетинаючись з ними там, де інакше не можна:), бо здоровий глузд і відчуття сенсу дають тверді рішення. Найкращі зразки десяткової арифметики з плаваючою комою базуються на ній. У той же час ця специфікація дуже загальна і не називає жодного конкретного слова для імен типів, функцій, констант, і самих значень констант. В анґломовній вікіпедії є стаття Decimal floating point, проте дуже базова: вимоги щодо реалізацій не описані, GDAS не згадана.

Потрібне для корисної реалізації тут треба розбирати з двох боків — властивості типів і операцій. Почнемо з типів.

Представлення грошової суми завжди вимагає якоїсь мінімальної величини, в якій і рахується сума. Для нас це копійки (або шаґи, якщо переіменують, або гривні, бо інфляція), для доларів це центи, для фунтів це пенні. Для комісій на біржі це можуть бути десятитисячні частки центу. Все одно спільне — фіксована одиниця. Тоді зручно представляти таку суму як ціле число у цих мінімальних одиницях. 1 копійка — 1. 1 гривня — 100. 123 гривні 98 копійок — 12398.

Але таки головне — що у нас конкретне дане має конкретний тип: ціле або з фіксованою крапкою — і позиція цеї крапки дійсно визначена типом. І діапазон значень визначається знову в кількости цифр — як до крапки, як і після — бо так зрозуміло не проґрамістам (з чого це 21 міліон і яке діло всім іншим до того, чому дорівнює 2↑31?), а під вимоги цільових спеціалістів (вважаємо, бухґалтерів). Зазвичай указують його в форматі, наприклад, «DECIMAL(n,m)», де n — скільки цифр всього, m — після крапки. Класичний SQLʼевський DECIMAL це саме воно... ось і дійшли до практики. (І не тільки SQL; такі ж фіксовані типи — норма в COBOL, PL/1...)

Тут я систематично використовував описові терміни, типу «цифр всього», «цифр після крапки». З ними теж є проблеми визначення. Якщо ми подивимось в стандарт SQL, то там є терміни precision — скільки всього цифр, і scale — після крапки. Якщо подивимось в визначення форматів printf в C, то там precision — кількість цифр після крапки в форматах a/e/f, і всього — g і цілочисельних (а width визначає повну довжину виводу, включаючи крапку, мінус і поле порядку). GDAS теж використовує precision як загальну кількість цифр. Тому в десятковому контексті будемо називати по варіанту SQL і GDAS (precision — всього, scale — після крапки), навіть при тому, что scale тут може бути не ідеально зрозумілим.

Тепер про операції у випадку фіксованої крапки.

Додавання і віднімання не змінюють тип (головне, одиницю дробности), якщо типи однакові, але можуть привести до переповнення. Не згадаю сучасних прикладів, але в Civilization 1 (ще під MS-DOS), якщо ви набирали 32768 золотих, воно перетворювалось в —32768 (мої вітання 16-бітним змінним). Якщо у вас 32-бітний стандартний int, ви не представите в ньому більше ніж 21 міліон 474 тисячі 836 гривень і 47 копійок. Якщо 64-бітний, то сума майже необмежена, ну якщо ви не розраховуєте темпи росту держборгу США;) Але контроль за переповненням все одно потрібний. (Взагалі це інша тема; згадаймо лише, що stdckdint.h (контрольовані на переповнення операції) зʼявився лише в C23 і багато де ще відсутний, а до C++ ще не дійшов; GCC 5.1, перший публічний з власними розширеннями для цього, це квітень 2015 року; ручні еквіваленти існують вже десятиліттями, а на undefined behavior стерло собі зуби вже друге покоління проґрамістів; іноді хочеться взяти автомата і примусити всіх перейти, наприклад, на Ada. Ну, не в цій статті.)

Вимоги підвищуються при інших операціях, і тут вже починаються елементи того, що ми бачили в алґоритмах рухомої крапки. Звісно, множити гроші на гроші не можна. Але можна на ціле число, на проценти (відсотки), на промілле... а тепер: ось у вас 123 гривні 45 копійок і від них треба взяти 17%. 123.45×0.17 = 20.9865, сумма не є цілою в копійках, округлюємо до 20.99? В фінансах норма — half-away, поки що все нормально? Але: ми вже округлювали з представлення з більшою точністю! Ось вже і вийшли за той тип, що був спочатку... І тут починається.

По-перше, нам треба дозволити результату мати не ту точність (виражену в цифрах після крапки), що у арґументів. Домножуємо два числа по 2 цифри, отримуємо 4 цифри після крапки. (І це ще тільки множення.) Тепер результат, який знову має бути гривнями-і-копійками, округлюємо до копійок. Ну добре, точний результат умістився в свою змінну, похибки тут немає, крім врахованої в цільовому округленні.

Тобто, на відміну від звичного нам по попередніх прикладах і по всьому звичному досвіду, результат операції множення має бути іншого типу, ніж арґументи. Це значне ускладнення (і приводить до підходу, який описаний далі, з плаваючою крапкою, але з упором на інші вимоги). (І не тільки множення, звісно, але воно на практиці найчастіше зустрічає такі проблеми.)

А ще є операції конверсії типу, при яких міняється точність. І вони теж мають бути якось явно задані.

Саме цей підхід закладений у основу найстарішої мови з орієнтацією на такі розрахунки, яка зараз є в дії — саме, COBOL. Коли ви задаєте:

DATA DIVISION.
01 SPEED        PICTURE 99V99.
01 TIME         PICTURE 99V99.
01 DISTANCE     PICTURE 9999V9999.
CODE DIVISION.
MULTIPLY SPEED BY TIME GIVING DISTANCE.

(Або, більш сучасна форма останнього рядка: «COMPUTE DISTANCE = SPEED * TIME.»)

...у SPEED і TIME в даному випадку по 4 цифри всього і 2 після крапки (precision=4, scale=2), у DISTANCE — 8 всього і 4 після крапки. Тип цільової змінної задає, що тут робити; у цьому прикладі у DISTANCE 4 цифри після крапки, тому округлення не потрібне; якщо було б менше (наприклад, 9999V99 — дві цифри після крапки), воно б відбулось, завмовчки — усіченням (direct toward zero), або, якщо додати ROUNDED, то воно виконає half-away. Щоб це коректно працювало, ми мусимо задавати тип для кожного проміжного результата; там це робиться саме через специфікацію типу змінної цими дивними PICTURE. Підгонку під конкретну точність результата виконує компілятор, і саме це те, що в ньому зручно.

Ця проблема показана на множенні. На більш складних операціях — як ділення — все ще заплутаніше. Ось ми спробуємо поділити 1.0 на 3; отримуємо 0.3333... де зупинитись і, головне, чому саме там? Складання точности вже не працює. Ми вимушені або вводити якісь штучні правила, або просто знати вимоги домена... В прикладі вище це разом і штучні фіксовані (на етапі компіляції) правила, і такі, що відповідають вимогам домена.

Де нема таких можливостей, починається дивне. Або ми все робимо в цілих, і потім для отримання потрібної кількості цифр після крапки коректуємо арґументи результати згідно тому, як треба (десь розділити на степінь 10, десь домножити на степінь 10), все вручну. Або ми сподіваємось на сумарну точність і виконуємо в ній, коректуючи результати в потрібних точках, про це далі (бо це і є плаваюча крапка у десятковому варіанті).

Фіксована крапка тут, здається, більш прямий метод. Але він має, по описаному, суттєвий недолік: за відсутности засобу, який би давав контроль точности (представлення числа, скільки цифр після крапки) автоматично, проґраміст вимушений підкладати милиці цілими гронами.

Звісно, не один COBOL такий; наприклад, для C++ можна пойти двома шляхами. Означивши параметризований тип FP<n>, де n — кількість цифр після крапки, можна створити функцію типа multiply(T, A, B), де всі три параметри — типу FP з деякою точністю; тоді функція рахуватиме метод обчислення і округлення сама на основі цих параметрів точности. Або можна зробити автоматичне визначення, як

template <int P1, int P2>
auto operator*(FP<P1> a, FP<P2> b) -> FP<P1+P2>
{
    … рахуємо результат, знаючи значення точности …
}

Але в обох випадках ця точність має бути специфікована на етапі компіляції: компілятор вже має знати, що у вас гривня з 100 копійок, або євро з 100 євроцентів... якщо це змінюється, треба перекомпіляцію. Десь це підходить, десь — ні. Якщо ні, то задання повинно бути дінамічним на рівні власне виконання коду (і точність, тобто скільки часток у туґрику, йені чи що там буде, задана десь у конфіґурації); це типове для якоїсь платформи стилю SAP чи 1С. При динамічному завданні треба зберігати разом з самим числом кількість цифр після крапки в обʼєкті числа, тобто ми... фактично знову переходимо до рухомої крапки! Коло замкнулось...

Плаваюча (рухома) крапка для основи 10

База і софтверні реалізації

Фіксована крапка — не єдиний метод. Другий — зробити дійсно плаваючу (рухому) крапку, але з виконанням всіх операцій в десятковій формі. І, як показано аж одним абзацем вище, є умови, при яких ми маємо використовувати її навіть проти головного принципу, який намагались показати всею попередньою розповіддю: похибка дозволена і нормальна; ні, тут вона не нормальна і не дозволена. (Так, це є фундаментальна така милиця, на якій стоїть весь підхід.)

І тут головне питання — як уникнути неконтрольованих похибок. Забігаючи дещо вперед: нам для цих реалізацій критично важливо або не допускати похибок, або надійно їх детектувати (і на практиці обидва підходи, в ідеалі, підтримані і виконуються одночасно); це робиться або негайною реакцією на inexact error, або розрахунками з такою кількістю цифр, щоб не було неочікуваних округлень. І тут змагаються різні варіанти і стилі реалізації.

Перший варіант — це просто тримати запас точности і щоб всі операції вкладались в нього. Це дійсно буде працювати для більшости операцій, крім невеличкого кола особливих. Які це операції? Їх дійсно мало в цьому домені. Наприклад, замість ділення на 3 буде призначено взяття 30, 33, або 34 відсотки — ну так тут прийнято. Вкрай мало задач, де саме ділять на три, на пʼять різних частин... Ще рідше — квадратний корінь. Далі уже я не можу уявити собі, для чого потрібно було б брати, наприклад, арксінус від частки грошей. (Хоча, Python в decimal для чогось же має exp() і ln()? І вони ставлять той Inexact.) Скоріш за все, така задача буде переоформлена і розрахунки підуть вже серйозніші і в двійковій арифметиці. Частина засобів, що далі згадуються, відповідають цьому варіанту.

А другий — це вимагати від засобу детекта втрати точности і ґенерації помилки на неї (а далі, підіймається флаґ чи ґенерується виняток — вже менш важливе питання, завжди можна і перевірити).

Перелічимо найбільш популярні і придатні. Всі вони, в залежности від засобів самої мови, підтримують максимально прямо стандартні операції (десь a+b, десь a.add(b), a.plus(b), etc.), розширений набір функцій, де треба (рідко де треба щось серйозніше квадратного кореня); імпорт-експорт виконується частіше власними засобами типу, ніж стандартними функціями (що дещо ускладнює проґрамування, але не критично). Значення NaN, Infinity у більшости не підтримуються, вихід за межі підтримуваних порядків викликає виняток, але діапазон порядків достатній, щоб про це не думати:) Винятки з цих правил будуть вказані явно.

В частині подальших реалізацій є суттєвий момент: в задання точности входять хвостові нулі. Тобто, наприклад, «1», «1.00» і «1.0000» це різні представлення одного і того ж числа. Кількість нулів опосередковано передає точність представлення, і разом з тим впливає на таку ж декларовану точність результатів. Множина представлень одного і того ж числа, але з різною декларованою таким чином точністю, зветься когортою («cohort»). Вага найменшої цифри з представлених, включаючи нулі, зветься квантом («quantum»). Наприклад, для «1e6» квант рівен 1000000, для «1» — 1, для «1.0000» — 0.0001.

Сумарна точність обмежує довжину числа включаючи ці нулі. Операції виконуються так, щоб зберігати ці хвостові нулі, доки є можливість. Операції визначені так, що є так званий бажаний квант (preferred quantum; краще варіанту, ніж «бажаний», поки не бачу) для результата, якщо він вміщується без втрати точности; інакше трапляється округлення (якщо дозволено).

І ще — в цих прикладах я максимально уникатиму проблеми локалізації (в частині 5, про імпорт-експорт, для неї була окрема глава). Автоматично вмикає локаль у найпростіше закодованих реалізаціях хіба що .NET.

Python Decimal

Python Decimal з набору стандартних батарейок:

>>> from decimal import *
>>> Decimal('1.1000')
Decimal('1.1000')
>>> print(Decimal('1.1000'))
1.1000
>>> Decimal('1.0') + Decimal('2')
Decimal('3.0')
>>> Decimal('1.00') + Decimal('2')
Decimal('3.00')
>>> Decimal('1') + Decimal('0.000')
Decimal('1.000')
>>> Decimal('1') + Decimal('0.000') == Decimal('1')
True
>>> print(Decimal('1.20') * Decimal('2.30'))
2.7600

Модуль decimal в Python має реґульовану обʼєктом контекста максимальну точність, завмовчки 28 цифр. Також реґулюється режим округлення одною з пропонованих констант. Є ті ж 5 базових режимів IEEE754, і ще декілька, включаючи RPSP (у нього зветься ROUND_05UP).

>>> from decimal import *
>>> getcontext().prec = 6 ## задали точність, не міняли округлення
>>> Decimal('999999') + Decimal('4')
Decimal('1.00000E+6')
>>> getcontext().rounding = ROUND_UP
>>> Decimal('999999') + Decimal('4')
Decimal('1.00001E+6')
>>> getcontext().traps[Inexact] = True
>>> Decimal('999999') + Decimal('4') ## має дати виняток
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.Inexact: [<class 'decimal.Inexact'>] ## <-- а ось і виняток

Примітимо, що ініціалізувати обʼєкт числа Decimal краще з текстової форми, бо якщо це робити з двійкового float:

>>> Decimal(0.1)
Decimal('0.1000000000000000055511151231257827021181583404541015625')

Це, як показувалось раніше, найбільш точне текстове представлення числа з типу float (і довжина при такому створенні із float не обмежена контекстом!) Але воно для того типу розрахунків, що нам тут потрібні, неадекватне; нам потрібна була найкоротша текстова форма:

>>> Decimal(0.1) + 0
Decimal('0.1000000000000000055511151231') ## йой, вже скоротилось, але все одно зайві цифри
>>> Decimal(str(0.1))
Decimal('0.1')

Напряму округлення до заданої дробової точности (після крапки) не присутнє в публічних методах, але воно робиться через стандартну функцію round() і її обробник __round__ у класа Decimal; явно непрямий підхід для тих, хто звик шукати методи з явними іменами в самому класі, вимагає запамʼятати окремо (і те, що режим десь побоку впливає на стандарту функцію):

>>> getcontext().rounding = ROUND_DOWN
>>> round(Decimal('123.456'), 2)
Decimal('123.45')
>>> getcontext().rounding = ROUND_UP
>>> round(Decimal('123.456'), 2)
Decimal('123.46')

Модуль decimal декларовано відповідає General Decimal Arithmetic Specification, згаданій вище. Зокрема, що нам найважливіше для цеї розповіді, він дозволяє трекати Inexact error, і навіть перехоплювати її у момент її виникнення. Найбільш суттєва проблема, як і у всьому з Python, це швидкість.

Java

Java має BigDecimal в стандартній поставці. Точність даних довільна, фактично реґулюється тим, які дані на вході операцій і який контекст заданий. Контекст складається з точности (precision) і округлення. Якщо зовсім не задавати контекст з округленням, то операції, що дають результат скінченної довжини, виконуються точно для будь-якої довжини результату.

        BigDecimal d1 = new BigDecimal("999999");
        BigDecimal d2 = new BigDecimal("4");
        BigDecimal d3 = d1.add(d2);
        // Взагалі, toString() необовʼязково, println робить його сам. Але покажемо і з ним.
        System.out.println(d3.toString());

Результат: 1000003; продовжуємо в тій же функції:

        // без контексту був би виняток
        BigDecimal d4 = d3.divide(new BigDecimal("3"), MathContext.DECIMAL64);
        System.out.println(d4);
        MathContext ctx_64_up = new MathContext(
                MathContext.DECIMAL64.getPrecision(),
                RoundingMode.UP);
        d4 = d3.divide(new BigDecimal("3"), ctx_64_up);
        System.out.println(d4.toString());

Результат:

333334.3333333333
333334.3333333334

(16 цифр — задано контекстом DECIMAL64; чому 16 — це точність 64-бітного десяткового IEEE754, буде далі в секції про IEEE);

        BigDecimal d5 = d4.round(MathContext.DECIMAL32);
        System.out.println("To decimal32:", d5);
        d5 = d4.round(new MathContext(10, RoundingMode.HALF_EVEN));
        System.out.println("To 10 digits:", d5);

Результат:

To decimal32: 333334.3
To 10 digits: 333334.3333

Задання точности для операцій в контексті робиться як повна довжина в цифрах (precision); коли треба округлити до заданої кількости цифр після крапки, зветься setScale() (знову, якщо немає RoundingMode, то ґенеруєтся виняток у випадку неможливости отримати те ж саме число). З цим можна працювати навіть при відсутности явної помилки Inexact. Спостерігаємо за фіксованою точністю, яка зберігає хвостові нулі:

        BigDecimal d11 = new BigDecimal("1.20");
        System.out.println("Scales:");
        System.out.println(d11.setScale(5));
        System.out.println(d11.setScale(1));

        BigDecimal d12 = new BigDecimal("2.30");
        System.out.println("Product:", d11.multiply(d12));

Результат:

Scales:
1.20000
1.2
Product: 2.7600

Неможливість представити у випадку скінченної послідовности цифр (що у нашому прикладі коїться при діленні на 3) викликає негайну ґенерацію винятка ArithmeticException. Для типового використання цього, здається, досить — якщо є правильні формування і передача контексту, де він потрібний.

А ось що може бути не так — якщо ми викликали операцію зі скороченням точности згідно контексту, ми вже в цьому інтерфейсі не узнаємо, було це скорочення реально чи точности вистачило; за цим треба слідкувати окремо — наприклад, перевіряти довжину результату у ключових точках обчислення. У будь-якому випадку, це краще, ніж повна відсутніть засобів контролю.

(Гадаю, прикладів достатньо, тут все прямолінійно.)

.NET

.NET Decimal з базової поставки дає ґарантовані 28 цифр (в середині, там 96 біт, максимальне число приблизно 7.9e28, тобто іноді до 29 цифр); проблема користувача — слідкувати, щоб потрібна точність на будь-якому етапі обчислень не вийшла за цю межу. Фактичний діапазон десяткового порядку — від —28 до +29; всередині це виражено так, що при right units view (мантиса — ціле число) порядок приймає значення від —28 до 0. Я не зміг знайти, тут 28 цифр і замовчування на 28 цифр в пітонівському Decimal це випадковий збіг чи навмисне рішення.

Літерали пишуться в C# з суффіксом ’m’: 6.022e23m. Операції стандартні: чотири арифметичні, конверсії типів і представлень (імпорт-експорт), округлення. Є дивні методи, як IsOddInteger(), які явно були зроблені для якихось вузьких застосувань.

На проміжних етапах, якщо треба, викликається явний Round(). Режим округлення задається тільки в явних операціях округлення, як ця Round() параметром типу MidpointRounding, що дає 5 режимів зі списку IEEE754; і тут треба розуміти, що midpoint — це (зараз) не точно посередини, а взагалі значення, для якого AZ ≠ TZ при вибраній точності, і назви режимів нерівні (AwayFromZero це half-away, а ToZero це direct toward zero). Всі інші операції виконуються з нереґульованим half-to-even, про що, як і слід було чекати, у документації ані слова. Засобів детектувати неточний результат одної операції (inexact error) нема, можна тільки полагатись на коректність проґрамування для неперехода через межу точности.

Як на мене, це дуже дивний дизайн. В ньому незрозуміло майже все, починаючи з головного: чому нерівна точність? Це впливає на якість результатів, якщо вони доходять до максимальної точности. Чому обмежений діапазон порядків? Виглядає суто штучно і под незрозумілі цілі. Гарного у ньому тільки те, що він швидкий, бо внутрішня реалізація добре оптимізована.

Тести-зразки проводились на Ubuntu через dotnet-sdk 8.0.407. Проблеми локалізації усунуті заданням, де це могло впливати, локалі C в оточенні при запуску, інакше в коді аж рябитиме в очах від згадування InvariantInfo на кожній операції парсінгу чи форматування.

Найпростіше:

using System.Globalization;
decimal d1 = 1.2m;
decimal d2 = Decimal.Parse("2.3");
// Якби чітко фіксували незалежність від локалі:
//- decimal d2 = Decimal.Parse("2.3", NumberFormatInfo.InvariantInfo);
decimal d3 = d1 * d2;
Console.WriteLine("d3 = {0}", d3);

Запускаємо:

d3 = 2.76

Неконтрольоване округлення:

Console.WriteLine("{0}", 1.0m / 3);
Console.WriteLine("{0}", 2.0m / 3);
decimal d31 = 1.0m;
decimal d32 = 1.5e-28m;
Console.WriteLine("{0}", d31 + d32);

Результат:

0.3333333333333333333333333333
0.6666666666666666666666666667
1.0000000000000000000000000002

Явне округлення: (precision тут — після крапки, хм, документація не уточнює)

decimal d41 = 3.141592653589793m;
Console.WriteLine("{0}", Decimal.Round(d41, 6, MidpointRounding.ToZero));
Console.WriteLine("{0}", Decimal.Round(d41, 6, MidpointRounding.AwayFromZero));

Результат:

3.141592
3.141593

Я не знаю, які ще приклади наводити. Воно дуже просте, прямолінійне, і... специфічне, цензурно кажучи, засоби дуже обмежені. Користувачам щастить те, що вихід за 28 цифр дуже рідкий, а проблеми некоректного округлення на стандартних операціях фінансового домену не виникають, або треба дуже щось хитре закрутити, щоб натрапити на них.

За межами вбудованого decimal, пошук показав деякий ExtendedNumerics.BigDecimal (сирець), який має задану ґлобально точність і тільки два напрямки округлення — toward zero (усічення) і half-away. Ну, корекцією точности можна врятувати від більшости проблем.

Інші рецепти, знайдені на межах Інтернету, головним чином пропонують взяти стандартний BigInteger і заврапити його самому з ручним урахуванням кількости цифр після крапки. В суммі це дає враження, що саме в домені Windows менше задач рахувати гроші, ніж за його межами, що, порівняно з типовим застосуванням продукції Microsoft, якось надто протирічно. 🤷🏼

JavaScript

JavaScript: зовсім не мій домен, тому тільки те, що швидко наґуґлив. Три бібліотеки з мінімальною різницею в стилі знаходяться під одним юзером: bignumber.js, decimal.js, big.js. Всі вони мають специфічні схожі риси: 1) require або import видає тільки конструктор; 2) конструктор можна параметризувати точністю і режимом округлення у випадку, коли операція дає нескінченно довгий результат (ділення, квадратний корінь і т.д.); 3) обʼєкт числа створюється за допомогою таким чином налаштованого конструктора.

В bignumber.js, big.js операції з точним результатом, як додавання, віднімання, множення, обчислюються точно (саме як з Java BigDecimal); в decimal.js вже є округлення таких операцій до заданої в конструкторі точности (ґлобальний параметр).

Приклад застосування (nodejs):

> const BigNumber = require('bignumber.js')
undefined
> x1 = BigNumber("3.14")
BigNumber { s: 1, e: 0, c: [ 3, 14000000000000 ] }
> x2 = x1.times(x1)
BigNumber { s: 1, e: 0, c: [ 9, 85960000000000 ] }
> x2.toString()
'9.8596'
> x2.decimalPlaces(2, 0) // режими округлення задаються числом з BigNumber.ROUND_xxxx
BigNumber { s: 1, e: 0, c: [ 9, 86000000000000 ] }
> x2.decimalPlaces(2, 0).toString()
'9.86'
> x2.div(3)
BigNumber { s: 1, e: 0, c: [ 3, 28653333333333, 33333300000000 ] }
> x2.div(3).toString()
'3.28653333333333333333'
> x2.div(3).decimalPlaces(4, BigNumber.ROUND_HALF_EVEN).toString()
'3.2865'
> x2.div(3).decimalPlaces(4, BigNumber.ROUND_UP).toString()
'3.2866'
> BigNumber.config({DECIMAL_PLACES: 50})
... skipped big structure ...
> x2.div(3).toString()
'3.28653333333333333333333333333333333333333333333333'

decimal.js дуже схожий, але 1) точність і округлення у конструкторі задає і обмеження таких операцій, як додавання і множення, 2) точність в конверсіях задається у цифрах всього, а не після крапки (ну і дефолтна конверсія в текст для зневадника інша, одразу в фінальне значення).

> const Decimal = require('decimal.js');
undefined
> x1 = Decimal("3.14")
3.14
> x2 = x1.times(x1)
9.8596
> x2.toSignificantDigits(3, Decimal.ROUND_DOWN)
9.85
> x2.toSignificantDigits(3, Decimal.ROUND_UP)
9.86
> Decimal.precision = 50
50
> x2.div(3)
3.2865333333333333333333333333333333333333333333333
> x2.div(3).toSignificantDigits(6, Decimal.ROUND_UP)
3.28654
> x2.div(3).toSignificantDigits(6, Decimal.ROUND_DOWN)
3.28653

big.js схожа на дві попередні, але ще більше скорочена за функціональністю, і знову має суттєво інший інтерфейс. Але за відношенням до точности операцій вона ближче до bignumber.js, ніж до decimal.js: для додавання, віднімання, множення точність не обмежується.

Довжина значення для неточних операцій (ділення, квадратний корінь...), точних (decimal.js), і режим округлення для значення такої операції задається у всіх параметром конструктора.

Всі три не уміють в трекінґ факту округлення (Inexact error). Decimal.js в цьому сенсі дещо гірше, бо округлення може виникнути і на неточних операціях. Працювати з цим (в decimal.js) можна тільки при заданні достатнього запасу точности, у цифрах. І, разом з Java, не підтримують RPSP, тобто, це розраховано на однокрокове округлення до потрібної точности у будь-яких операціях (хм...)

LLM підказує існування TC39 decimal proposal, який з несуттєвими спрощеннями повторює 128-бітне десяткове число з IEEE754. Це дійсно дало б непогану базу. З дивного — використання терміну «halfExpand» замість «halfAway».

IEEE754: що зробили там

Нарешті, згадаємо сам IEEE754 і його десяткову частину. IEEE754 (тут і далі — версій 2008 і 2019) вводить свої типи, в яких позиція крапки повністю рухома, але точність обмежена кількістю десяткових цифр. Для 32, 64, і 128 біт повного представлення — відповідно, 7, 16, і 34 цифри. Вимоги до виконання операцій такі ж, як у випадку двійкової арифметики, включаючи округлення і роботу з помилками. Про це буде далі. До нього є декілька софтових реалізацій, як Boost.Decimal від cppalliance, libdecnumber для C, libmpdec для C. Апаратно десяткова плаваюча крапка реалізована... знов тільки у IBM, в лініях Z (нащадок S/360, S/370...) і P (POWER-процесори)! «Це ж-ж-ж-ж не просто так», казав Віні-Пух.

(Взагалі, десяткова арифметика це місце, де IBM більше всього, тим паче, порівняно з двійковою, де вони ніколи не намагались бути взірцем і вести за собою. Це не дивно, зважаючи на те, що вони орієнтуються перш за все саме на «бізнес»-застосування, які неминуче включають в себе десяткові розрахунки. IEEE754 був прийнятий в лінію SystemZ (вона ж просто IBM Z офіційно) десь біля 2000-го року, коли всі зрозуміли, що без цього їх компʼютери не будуть нікому потрібні. До десяткової арифметики за IEEE754, в SystemZ була власна старого стилю з фіксованими розмірами даних до 31 цифри.)

IEEE754 специфікація десяткової арифметики зʼявилась з версії 2008 року. Що в ній? Знову, формати, операції і інше. Якщо ж подивитись в історичному плані і на деталі, то це стандарт IBM і тільки IBM. Решта, у найкращому випадку, відшліфувала незначні деталі.

Формати: є специфікації для 32, 64, 128 біт і довільне розширення після 128 біт порціями по 32 біти.

|Бітів формату|Дес.цифр|Мін.норм.порядок|Макс.порядок|
+-------------+--------+----------------+------------+
|      32     |    7   |       -95      |      96    |
|      64     |   16   |      -383      |     384    |
|     128     |   34   |     -6143      |    6144    |
+-------------+--------+----------------+------------+

(Про визначення порядку тут дивитись двома абзацами нижче.)

Для 32-бітного формату визначене тільки транспортне представлення, але не операції. Пояснень не бачив, але думаю, що очевидно, що 7 цифр мало для реальних розрахунків (ну де буде обмеження суммами менше 100 тисяч гривень чи доларів там, де треба автоматизацію фінансів?) Це збігається з тим, що в SystemZ машинних команд для операцій у 32 бітах нема, є тільки конверсії з/до 64- і 128-бітного.

Диапазон порядку спочатку ширше, ніж у двійкових типів відповідного розміру: наприклад, для 64 біт це від —383 до +384 (проти приблизно —308 до +308 для двійкового) в left units view, для обох субнормальні не враховуємо. Тут є деякий трюк: стандарт говорить про зсув порядку 398, але це в right units view; right units view тут не принциповий, але інакше було б треба додавати на кожному кроці окреме пояснення, типу «мантиса представлена це ціле число, що показує її мільонні долі»; щоб минути таких складнощів на кожному кроці, змінили стиль. Якщо перерахувати на left units view, зсув буде рівним 383 (наприклад, 1.234567890123456e33 = 1234567890123456e18, зсунутий порядок дорівнює 416 = 383 + 33 = 398 + 18), як раз за тими же принципами, що для двійкових (див. частину 3): зворотнє до мінімального нормального має бути скінченним, в іншому порядок має бути максимально симетричним відносно нуля. Не знаю, кому потрібен настільки широкий діапазон, але специфіка представлення мантиси така, що додавати в неї ще бітів хоча б на одну цифру вкрай незручно (про це далі). Ну, якщо деякі країни будуть уперто нарощувати свій борг, то, може, такі числа і будуть потрібні. Потім діапазон порядків починає рости повільніше, ніж у двійкових, і перестає задовільняти правилу, сформульованому в IEEE854 про не менше ніж розширення в 8 разів (див. частину 3) — але це для десяткової арифметики, може, і не критично?

Набір базових операцій з числами з основою 10 принципово такий же, як для основи 2, з поправкою на одну специфічну операцію, яка зветься quantize(). Про неї дещо далі. А ось «рекомендовані» операції — там можуть бути виключені всі специфічні, як триґонометрія, степені, лоґарифми — бо їх і складніше, і вкрай рідко потрібно реалізовувати.

З мантисою (вона ж significand в IEEE754, вона ж coefficient в GDAS, вона ж Ґоша вона ж Абліґація...) хитріше. Почнемо ось з чого. Коли у нас ґарантовано тільки одне значення цілої частини мантиси, як у нормалізованого числа у випадку двійкової арифметики, коли вона дорівнює 1, її дуже просто не передавати — тому і в двійковому IEEE754 вона прихована, і декілька реалізацій ще до IEEE754 її приховували. А коли вона може бути від 1 до 9, так приховати вже не можна. Значить, її треба передавати явно. А якщо треба передавати явно, то і вимога, що найстарша цифра не дорівнює 0 (примусова нормалізація) втрачає сенс. Тому для десяткових форматів нема примусової нормалізації. Майже будь-яке число може бути нормалізовано; якщо вже не можна цього зробити через обмеження діапазону порядку, таке число зветься субнормальним. Це той же механізм, що у випадку основи 2, але в більш явному вигляді.

І тут ми отримуємо згадані вже когорти: можливість мати декілька представлень одного значення (що принципово виключено в двійковому варіанті). Наприклад, у 32-бітного формата 7 значущих цифр, і для числа 1230 представлення 1230000×10↑-3, 0123000×10↑-2, 0012300×10↑-1, 0001230×10↑0, 0000123×10↑1 всі — одна когорта. І як з іншими реалізаціями, є квант числа (відповідно від 0.001 до 10) і правила задання бажаного кванта для результатів операцій.

Виконання операцій базується на тих же принципах, що з основою 2, але тут є одна велика... ну, хай буде специфіка;) і одна маленька. Почнемо з маленької. Необовʼязкова нормалізованість додає до алґоритмів обчислень один крок. Наприклад, ви складаєте 123000 і 45.6, але 123000 представлено як 000123e3 (а 45.6 як 000456e-1). Перш ніж зсувати доданок з меншим порядком вправо, намагаються зсунути той, що з більшим порядком, максимально вліво, скільки вистачає місця в акумуляторі мантиси (але зупиняються, коли досягнено порядок іншого доданка); тому при 6 цифрах (як у нас в тих прикладах) в першому акумуляторі стане 123000e0, і тільки після цього вираховуватимемо зсув для другого доданка. Якщо так не зробити, втратимо точність результата.

А тепер — велика специфіка: насправді в IEEE754 тут два субстандарти, вибір між якими за реалізатором: в одному зовнішньому (транспортному) форматі представлення мантиси двійкове, в іншому десяткове (так зване densely packed decimal (DPD)). Відповідно, і виконувати операції краще з відповідними механізмами.

Для обох субстандартів поле порядку (насправді, зветься «combination field», бо в нього втиснули найстаршу цифру мантиси) задає, аналоґічно двійковому варіанту, нормальні числа (тут нема окремого поняття субнормальних на цьому рівні, це просто ті, що вже не можуть бути нормалізовані навіть з мінімальним значенням порядку), нескінченність і не-число. Робота з цими особливими значеннями така ж, як у двійковому варіанті. Формат представлення свій, специфічний. Це виглядає так:

1) Якщо два старших біти combination field не 11, то воно містить зсунутий порядок (biased exponent) і 3 біти старшої частини мантиси.

2) Якщо два старших біти combination field — 11, а два наступних — не 11, то воно містить: ці 11, зсунутий порядок і 1 біт старшої частини мантиси, до якого треба додати 8 (тобто там значення читається як 8 або 9).

3) Якщо чотири старщиї біти combination field — 1111, то це або Infinity, або NaN (дивимось на наступний біт).

Можна дійсно вважати красивим трюком. Він дозволяє зекономити 1 біт на представленні, за рахунок звуження діапазона порядку, але той діапазон і так достатньо широкий (ширше, ніж у двійковому випадку).

В двійковому представлені мантиса це двійкове число (для 32-бітного формату — 24-бітне, щоб умістило все від 0 до 9999999 включно, зберігається в двох варіантах в 23 бітах). Наведу один приклад.

Візьмемо число 9876. Когорта можливих представлень: 9876×10↑0; 98760×10↑-1; 987600×10↑-2; 9876000×10↑-3. Зсунутий порядок займає 8 бітів у кожному випадку, але позиція залежить від мантиси. Значення представлене як sign:combination:significand.

9876*10↑0:    0:|01100101|000:00000010011010010100
98760*10-1:   0:|01100100|000:00011000000111001000
987600*10-2:  0:|01100011|000:11110001000111010000
9876000*10-3: 0:11|01100010|1:01101011001000100000

Розшифровуємо:

9876×10↑0: 9876 < 8388608, тому перший варіант формату (combination не починається з 11); зсунутий порядок дорівнює 101; significand — двійкове предствлення для 9876.

98760×10↑-1: 98760 < 8388608, тому перший варіант формату (combination не починається з 11); зсунутий порядок дорівнює 100; significand — двійкове предствлення для 98760.

987600×10↑-2: аналоґічно попереднім.

9876000×10↑-3: 9876000 ⩾ 8388608, тому другий варіант формату (combination починається з 11); зсунутий порядок дорівнює 97; 9876000 представлене двійково як 1001|01101011001000100000, я через ’|’ відділив останні 20 бітів, які передаються в significand; старша частина (до ’|’) — 1001 двійкове, 9 десяткове, тому в останній біт combination кладеться 1.

Звісно, власне додавання, віднімання, множення, ділення з двйковою мантисою проводиться на тому ж хардвері, що і двійкові операції, які завжди є. А ось зсуви, необхідні для узгодження порядку чисел і фінальної корекції, перетворюються в множення або ділення на 10, 100, і так далі, пошук старшої ненулевої цифри — на порівняння з набором констант... Проґрамно це робиться (на сучасних потужностях) легко, набір констант можна дати вже готовий, і навіть для ділення зарані підготувати їх під швидке зворотнє множення. Апаратно — теж, декілька десятків тисяч вентилів на ППЗ для таких цілей не ціна. Тому незалежні реалізації, як libdecnumber, всі зроблені на двійковому представленні.

Десяткове представлення значно хитріше, ніж двійкове. Не наводиму вже приклад, бо представлення порядку схоже, а про представлення мантиси занадто несуттєвого для загальної лінії розповіді. Наївний розробник зробив би, що представлення одної десяткової цифри займає чотири біти (розпаковані формати саме так, по більшости, і зроблені). В IEEE754, десяткове представленя мантиси (densely packed decimal — DPD) базується на трюку, названому Chen-Ho encoding. Три десяткові цифри пакуються в 10 двійкових (місця вистачає: 1000 ⩽ 1024), але не по порядку, а хитрим чином, який дозволяє апаратне кодування і декодування порівняно невеличкою структурою електронної двійкової лоґіки без ділення і множення. Любителям embedded і хитрих низкорівневих рішень це буде цікаво.

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

Здавалось б, зараз (ну, після 2000-го) вигідніше робити тільки варіант з двійковою мантисою? Вбудована реалізація GCC так і робить. Але ні, апаратна реалізація IBM таки на десятковій мантисі! Ну, бібліотеки є, перетворення форматів можна робити і не так вже дорого...

Округлення додає режим half-away, який має бути підтриманий у всіх реалізаціях, але замовчування все одно half-to-even. Набір помилок, випадків ґенерації і методів їх детекту той же самий, що і у двійковому варіанті. (Це добре, бо, як показано вище, нам конче потрібен Inexact для контролю неочікуваних похибок, ну і інші помилки... сумніви тільки в Underflow, бо він без Inexact не буває.)

Все інше несуттєво відрізняється від того, що ми вже знаємо про двійкову арифметику.

Головне питання на цьому етапі — чи потрібне комусь закладатись на використання саме IEEE реалізації десяткової арифметики? У мене враження, що якщо ви не примушені до цього чимось зовнішнім і не працюєте на техніці IBM (рядів Z чи P), то твердо ні, бо власні засоби таких мов, як Java, в усьому іншому світі пасують краще. (Ну крім питання менеджменту памʼяти, тут треба вже оцінювати під конкретну задачу.) Але при щільній роботі з десятковою арифметикою апаратна підтримка була б у великій нагоді і замінила б більшість цих засобів.

Підтримка десяткової арифметики IEEE (з двійковою мантисою) присутня як розширення для GCC, але тільки для коду на C, не для C++. Не знаю причини такого обмеження, але можна зробити свій врапер.

Перетворення для сортування

В частині 3 обговорювалось перетворення чисел в IEEE754 для сортування. Аналогічне для десяткового представлення можна зробити, але значно складніше і не так красиво. Щонайменше, ми маємо:

1) Представлення за Chen-Ho економне, але порушує впорядкованість в окремих 10-бітних ґрупах. Тому, або переходимо на двійкове представленя мантиси, або треба замінити значення ґруп на такі, що для представлених чисел від 0 до 999 бітові представлення теж монотонно зростають. (І знову про перевагу двійкового представлення.)

2) Числа вимагають примусової нормалізації, щоб додати можливість впорядкування у тому ж стилі, що з двійковим. Для мантиси 0 зсунутий порядок має дорівнювати нулю.

3) Combination field треба твердо розділити на власне порядок і старшу частини мантиси. Це вимагає розширення представлення на три біти. Так як більшість такої підготовки для сортування вимагає цілого числа байтів, це означає розширення на 1 байт.

Для не-IEEE форматів, щонайменше, пункт (2) має таку ж силу.

Кванти і квантування

Вище згадувалось, що деякі (адванснуті) реалізації розрізняють представлення на зразок 1, 1.0, 1.00, і поняття кванта згідно ньому. Квант, щонайменше в IEEE754 і GDAS, визначений як вага найменшої представленої цифри, навіть якщо це нуль. Іноді зручно говорити про його десятковий лоґарифм, але IEEE754 завжди говорить про «preferred exponent». Я тут перекладаю preferred як «бажаний».

Операції виконуються згідно правилам вибору цього бажаного кванта і відповідно бажаного порядку. Для складання, віднімання бажаний порядок — мінімум з двох арґументів, для множення — сумма, для ділення — різниця. Тому 1 + 2 = 3, 1.0 + 2 = 3.0, 1.00 + 2 = 3.00, 2.00 * 3.0 = 6.000, і так далі. Якщо результат вже не вміщується в доступну кількість цифр, він скорочується і, якщо при цьому втрачаються не тільки нулі, то і округлюється. Покажемо знову на Python Decimal, де є повна підтримка. Флаґ Rounded ще не згадувався: він фіксує будь-яку втрату кінцевих цифр, навіть якщо це були нулі і значення не змінилось, крім довжини.

>>> getcontext().prec = 6

>>> getcontext().flags.update({Inexact: False, Rounded: False})
>>> Decimal("2.000") * Decimal("3.00")
Decimal('6.00000')
>>> print(getcontext().flags[Inexact], getcontext().flags[Rounded])
False False

>>> getcontext().flags.update({Inexact: False, Rounded: False})
>>> Decimal("2.000") * Decimal("3.000")
Decimal('6.00000') ## памʼятаємо, prec == 6
>>> print(getcontext().flags[Inexact], getcontext().flags[Rounded])
False True

>>> getcontext().flags.update({Inexact: False, Rounded: False})
>>> Decimal("2.456") * Decimal("3.123")
Decimal('7.67009')
>>> print(getcontext().flags[Inexact], getcontext().flags[Rounded])
True True

Тут другий приклад — реальної втрати точности ще нема, але довжину хвостових нулів довелось скоротити, про це піднятий флаґ Rounded. Третій — вже настільки результат не лізе в 6 цифр, що трапилось реальне округлення, і вже піднятий флаґ Inexact.

Є операція зміни цеї декларованої точности, вона зветься quantize() (є в GDAS і реалізаціях за ним, і в IEEE754), яка з числа створює число з тим же реальним значенням, але іншим квантом. Наприклад:

>>> getcontext().flags.update({Inexact: False, Rounded: False})
>>> Decimal("1.23").quantize(Decimal("0.0001"))
Decimal('1.2300')
>>> print(getcontext().flags[Inexact], getcontext().flags[Rounded])
False False

>>> getcontext().flags.update({Inexact: False, Rounded: False})
>>> Decimal("1.20").quantize(Decimal("0.1"))
Decimal('1.2')
>>> print(getcontext().flags[Inexact], getcontext().flags[Rounded])
False True

>>> getcontext().flags.update({Inexact: False, Rounded: False})
>>> Decimal("1.23").quantize(Decimal("0.1"))
Decimal('1.2')
>>> print(getcontext().flags[Inexact], getcontext().flags[Rounded])
True True

>>> Decimal("1.23").quantize(Decimal("1e-30"))
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
decimal.InvalidOperation: [<class 'decimal.InvalidOperation'><class 'decimal.invalidoperation'="">]

InvalidOperation виникає, якщо виникає переповнення при представленні в такій точності; якщо ж втрачаються цифри в хвості, то це Rounded і/або Inexact.

Як «технопорн» цікаво, що quantize() приймає як зразок вже число з потрібним квантом, а не просто значення порядку. Автори GDAS стверджують, що це дешевше в реалізації. Обʼєкт числа з необхідним квантом можна підготувати зарані і використовувати його повторно.

Корисність цього механізму для більшости з застосувань дійсно мінімальна, але він дозволяє детектувати проблемні розрахунки раніше, ніж трапиться більш суттєва втрата точности. Але для внутрішньої реалізації він дозволяє економити на зсувах і нормалізаціях, це могло бути одним з арґументів на його користь.

Висновки

«Тут своя атмосфера», порівняно з двійковою арифметикою. Контроль за точністю вимагає недопуску будь-яких похибок, крім місць, де вони навмисно дозволені. Засіб реалізації має дозволяти контролювати появу похибки, інакше він суттєво неповноцінний; але можна обійти, при якісному контролі, через завищену точність. Уважність — наше все. Вибір і коректна реалізація заданого правилами (законом, постановами) округлення є критичними.

Після цього, використовувати обчислення, операції в десятковій плаваючій крапці просто, потрібне вже є для більшости мов і середовищ.

Вимога максимальної ефективности дає противорічні напрямки руху. Занадто легко подумати, що перехід на двійкову арифметику спростить життя, за рахунок готової реалізації. На прикладі MS Excel ми бачимо, що це так не працює, але випадки порівняно рідкі і це дає виробнику змогу зекономити (ну і, здається, тут вже накопичилось леґасі?) Якісна, повністю десяткова, реалізація в своїй лоґіці дорожча, але в критичних випадках без неї не обійтись.

Що найбільш дратує — це нерозуміння реалізаторами і користувачами цінности RPSP. Втілити цей режим не складніше, ніж інші, а потенційна користь більша, ніж думається спочатку.

PS[2026-02-28]: зі свіжої дискусії тут:

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

чомусь я впевнений, що проблема саме була в недоцільному застосуванні двійкової арифметики:\

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось4
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter