Плаваюча (рухома) крапка, частина 5: Текстовий експорт-імпорт

💡 Усі статті, обговорення, новини про Java — в одному місці. Приєднуйтесь до Java спільноти!

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

(Нагадую, що «ввід-вивід» підказує звʼязок з зовнішніми пристроями чи памʼяттю, а це не обовʼязково тут; тому за назвою «імпорт-експорт» тут перевага.)

>>> print(f"{math.pi=}")
math.pi=3.141592653589793
>>> print(math.cos(float("3.14159265358979")))
-1.0

База і сучасні засоби

Людина, якщо вона не супер-хакер, не буде систематично працювати з внутрішніми представленнями чисел, як ґрупа байтів. Треба підтримувати зовнішні представлення, всі ці звичні 6.02×10↑23 і тому подібні, може, з поправкою на можливості запису в один рядок. Тобто тут приходимо до вже знайомого всім формату типа 6.02e23. Десяткового, примітимо. Повторюсь, але це треба постійно памʼятати: ми думаємо в десятковій системі, а не двійковій чи шістнадцятковій.

В чому тут проблеми? Їх декілька, і кожна серйозна по-своєму. Але спочатку про принципи.

Ще з дуже старих часів (ранній Fortran чи ще раніше) проґрамісти виробили три головних режими текстового експорту чисел рухомої крапки (вони були просто очевидні, але вони стали бути стандартизовані):

1. Експоненційний («exponential»): число записане в явному вигляді з мантисою (нормалізованою) і порядком. Типовий запис зараз виглядає як «6.022e23», до знаку «e» «E» іде мантиса, після нього — порядок. Це значить, що в голові треба замінити «e» («E») на «домножити на 10 в степеню, що після нього».

Які варіації? Їх декілька, з більшістю ви не зіткнетесь, бо вони або в старих засобах, або у чомусь ще більш дивному:

1) Уточнення типу в джерельному коді: В C, C++, багато де пишеться «6.022e23f» (суфікс типу після значення); але в Fortran замінюють букву: «6.022D23»; нуль в подвійній точності треба писати як «0D0», «0.0D0». При імпорті і експорті це зазвичай не вказується, тип указаний контекстом. (Хочу коротке слово для «implied».)

2) Зовнішні формати ранніх часів дозволяли не писати «e», якщо порядок завжди зі знаком: «6.022+23». Не думаю, що зараз ви таке зустрінете. Була така економія, угу.

3) Колись основним варіантом для виводу був fraction view (0.1 ⩽ M < 1): «0.6022e24». Зараз головним є left units view: 1 ⩽ M < 10; «6.022e23».

4) Ще є «інженерний» формат (специфічний для застосувань, як електротехніка), при якому порядок має бути кратним 3, а в мантисі від 1 до 3 знаків цілої частини (1 ⩽ M < 1000): «602.2e21». Підтримується в Fortran... і в .NET.

5) Наскільки дозволені форми «.1», «.1e2», «1.e2» (до або після крапки нічого нема?) Зараз, зазвичай, не дозволяють в коді («.1» — точно; «1.», «1.e2» дозволено, наприклад, в Python), через конфлікт синтаксису (тому можна писати, наприклад, «(1.).real», але не можна «1..real»), але можуть дозволити у джерелі проґрамного імпорту.

6) Що буде при переповненні дозволеної ширини поля експорту? Виведеться більше знаків, уріжуть точність, чи виведуть просто рядок якихось зірок? Більшість сучасних засобів порушує ліміт ширини і виводить те, що є.

2. Формат з фіксованою крапкою («fixed»). Угу, не «рухома». Фіксована (чи напряму точно задана) кількість цифр після крапки, порядок не пишеться. Експорт такого типу має сенс у деяких випадках, навіть якщо розрахунки йшли в рухомій крапці. Поки не ліземо у подробиці, але памʼятаємо, що якщо число не в діапазоні для коректної передачі значення, то або зламаємо форматування, або втратимо деталі.

>>> f"{math.pi:.7f}"
'3.1415927'

3. Змішаний («загальний», general) формат, коли буде експоненційний чи фіксований, вибирається залежно від значення, що експортується. Стандарт C сформульований так, що перехід до експоненційної форми робиться для чисел менше ніж 0.0001, або коли порядок не менше ніж точність (тоді вже однаково щось губиться при виводі). Очевидно, такі були критерії читовности у авторів специфікації... і це теж росте з 1950-х, плюс-мінус одна цифра.

>>> f"{123456:g}"
'123456'
>>> f"{1234567:g}"
'1.23457e+06'
>>> f"{6.022e23:g}"
'6.022e+23'
>>> f"{6.022e23:.30g}"
'602200000000000027262976'

Якщо формат (режим) задається буквою, то це відповідно E(e), F(f), або G(g). Проте в Rust, наприклад, «g» не пишеться (точність можна задати, букву формату — ні).

Це все було при експорті. При імпорті більшість сучасних засобів сприймає одинаково і фіксований, і експоненційний формати.

Отже, до проблем і тонких деталей.

Специфіка і проблеми експорту

Перша проблема — таке перетворення дороге, іноді — дуже дороге. Не маю на увазі власне перетворення числа розміром до якогось int64 в десяткове чи назад — це зараз можна вважати дешевим (якщо ви не на AVR чи PIC якомусь). Але є і другий рівень. Якщо число дуже велике чи, навпаки, дуже мале (в обох випадках — за модулем), точне перетворення вимагає операцій на повну довжину значення у бітах, відлічуючи від дробової крапки в потрібний бік. Наприклад, виконуючи імпорт запису «1e300», код-імпортер має вирахувати, чому дорівнює 10↑300 в двійковому вигляді (а це 996 бітів!) і вже це значення (рядок бітів) округляти до потрібної точности. А якщо значення на вході виглядає на зразок «8.98846567431158e+307», то треба ще домножити мантису на порядок, і зробити це з точністю, достатній для точного результату (обчислення, щонайменше, в 1024 бітах...) (У цьому прикладі число, яке в двійковому дорівнює точно 2↑1023. На ньому легко перевіряти точність такого алґоритму;)) Аналоґічно для відʼємних порядків, как з «1e-300». Ця проблема — необхідність обчислень дуже довгих чисел, в сотні і тисячі бітів — згадувалась вже в розділі про операції, про нормалізацію арґументу для sin(), cos(), інших періодичних функцій, тут маємо фактично те ж саме. Для цього використовується арифметика довільної точности (arbitrary-precision arithmetic); існують тисячі реалізацій різної гнучкости і ефективности. Код експорту-імпорту може використовувати готову чи робити свою, навіть на простих масивах...

(А ще було, що на деяких специфічних значеннях реалізація зависала при імпорті; трапилось, щонайменше, з Java і PHP. Тобто крім багато-багато бітів, треба ще й забезпечити стабільність лоґіки конверсії.)

В ранні часи розвитку цих засобів вважалось нормальним мати деяку систематичну похибку самого процесу такого експорту-імпорту; в IEEE754-1985 сказано:

Conversions shall be correctly rounded as specified in Section 4 for operands lying within the ranges specified in Table 3. Otherwise, for rounding to nearest, the error in the converted result shall not exceed by more than 0.47 units in the destination’s least significant digit the error that is incurred by the rounding specifications of Section 4, provided that exponent over/underflow does not occur.

Таблиця 3 обмежує порядок числа — 13 для одинарної і 27 для подвійної точности. І мало, і не мало. І ці порядки, і дивна константа 0.47 виходять з відомих прикладів тодішніх алґоритмів у випадку не‑використання багатозначної аріфметики.

А ось версія 2008 року вже вимагає повної точности і при імпорті, і при експорті. Що змінилось за ці 23 роки? А тут пройшов найбурхлівіший період розвитку апаратних можливостей — коли всі наслідки з закону Мура ще працювали, і зріст будь-якого смачного показника в 2 рази за рік-півтора сприймався як належне. За одні і ті ж гроші в 2008-му порівняно з 1985-м можна було отримати показники, кращі, щонайменше, в тисячу разів, а в чомусь і в десятки тисяч. Ну і з середини 1990-х у всіх процесорах «загального» і «серверного» призначення зʼявився FPU вже незворотньо (для x86 це почалось з Pentium). Тепер можна дозволити і додати код для точного обміну, і виконувати довгі складні операції. Мабуть. Якщо у вас не embedded рівня молодших ARM/32 або ще нижче, коли треба слідкувати за тим, щоб не було неочікуваних витрат.

!! І тому мені дивно, що, наприклад, у JSON вимагають тільки десяткового представлення. Воно навіть у випадку цілих може бути задорого для обмежених ресурсів, а у плаваючих — і поготів. Чому не дозволити шістнадцяткову форму (див. нижче)?

Але вже в 1985-му вимагали монотонности конверсії (тобто якщо представлення A >= B, то воно не може прочитатись у числа внутрішнього представлення a < b, і те ж саме навпак для експорту), і це правильно.

Друга проблема — що перетворення нерівне в плані відповідности точности двійкового і десяткового представлення. Це вже згадувалось, але повторимо більш детально.

Якщо у нас є L біт двійкового представлення (включаючи, як в стандарті, приховану одиницю), то вони можуть зберігати до floor((L-1)*log10(2)) десяткових цифр (min10d в таблиці в частині 3). Тут все зрозуміло з ходу, крім −1; ми «втрачаємо» один біт на похибку округлення. Для двох базових типів IEEE754 це відповідно L=24 і 53, і результати — 6 і 15. Детальне обґрунтування з доказом вже не розписуватиму, дивіться книги. Але приклад наводився: 8.589973e9 — мінімальне більше 1, яке для одинарної точности не зберігає найменшу цифру (подвійна конверсія дає 8.589974e9).

Таке ж двійкове представлення для точного зберігання в зовнішньому десятковому вигляді вимагає ceil(L*log10(2))+1 десяткових цифр (max10d в таблиці в частині 3). І знову це +1 було на похибку округлення. Для тих же типів маємо 9 і 17. Приклад для подвійної точности, щоб не можна було скоротити до 16 цифр, я вираховував, модіфікувавши число π: 3.1415926535897936.

Очевидно, різницю min10d і max10d для одного формату не можна зробити менше ніж 2: значення лоґарифму log10(2) ірраціональне, тому між ceil() і floor() вже мінімум 1, а фінальне +1 для другого дає вже 2. У випадку double там різниця 2; для single і quad (128 біт) навіть 3.

(C++ експортує ці значення як std::numeric_limits<T>::digits10 і std::numeric_limits<T>::max_digits10 для всіх таких T. C дає, відповідно, FLT_DIG, DBL_DIG, LDBL_DIG і FLT_DECIMAL_DIG, DBL_DECIMAL_DIG, LDBL_DECIMAL_DIG. Обидва стилі іменування сумнівні, але C++ дає дещо кращий.)

Тому, скільки цифр треба просити для представлення числа? Для одинарної точности — 6 чи 9? Для подвійної — 15 чи 17? Для яких цілей представлення?

І тут зʼявляється дивне леґасі. «Старі» «класичні» формати експорту, які в сучасних засобах звуться стилем printf, мають замовчування незалежно від точности самого даного — 6 цифр після крапки для %f і %e, і 6 всього, якщо по %g вибирається форма без порядку. Це нормально для одинарної точности. Але не для подвійної, бо з нею треба кожного разу вказувати потрібну точність. (Неочікуваний результат тут буде з MySQL, про це буде в відповідній частині.)

Які альтернативи? Згадаймо знову мем про інтернет для роботів. Мають рацію, результат 0.1*3 в подвійній точності не дорівнює 0.3, і може бути виведений як 0.30000000000000004. Але що зі «справжнім» 0.3?

Подальші роботи зручніше, головним чином, виконувати у Python, бо всі батарейки в комплекті, тільки треба імпортувати (твердження import я не писатиму в прикладах). Підготуємо самі числа:

>>> vf1 = 0.3
>>> vf2 = 0.1 * 3

і спитаємо, як вони представлені всередині:

>>> binascii.hexlify(struct.pack('>d', vf1))
b'3fd3333333333333'
>>> binascii.hexlify(struct.pack('>d', vf2))
b'3fd3333333333334'

Якщо передати це в вигляді дробу, то це значення, відповідно, 5404319552844595/18014398509481984 і 5404319552844596/18014398509481984 (знаменник дорівнює 2↑54). Бачимо різницю на 1 найменший розряд, unit of least precision (ULP) (памʼятаєте про послідовність представлень, якщо інтерпретувати як цілі?) — значення суто сусідні.

Спитаємо не те, що Python виконує по repr(), а експорт завмовчки у стилі printf.

>>> '%g %g' % (vf1, vf2,)
'0.3 0.3'

Ой, а де ж наша різниця? А нема. Точність 6 цифр завмовчки, обидва результати округлюються до 0.300000, і видаляються хвостові нулі. Ок, поступово підвищуємо точність, пропускаю нецікаві значення:

>>> "%.16g %.16g" % (vf1, vf2,)
'0.3 0.3'
>>> "%.17g %.17g" % (vf1, vf2,)
'0.29999999999999999 0.30000000000000004'

Привіт. Друге «слово» у останньому рядку ми знаємо, а перше це що? А це і є дійсне значення, яке найближче до нескінченно точного представлення 0.3, але з замовленою точністю (17 значущих цифр).

Як показано раніше, 17 цифр достатньо, щоб передати у десятковому вигляді будь-яке значення IEEE754 подвійної точности (по-простому, double в більшости мов, що походять за стилем від C). Хоча є і більш цікаві пограничні приклади:

>>> "%.17g %.17g" % (1.01, 1.02)
'1.01 1.02'
>>> "%.18g %.18g" % (1.01, 1.02)
'1.01000000000000001 1.02000000000000002'

тобто точність передана (достатня для зворотнього читання), але подробиці ще не увімкнулись. Як їх всіх виявити? Ми можемо підвищувати точність, поки не дійдемо до такої, при якої результат перестає мінятись:

>>> "%.54g %.54g" % (vf1, vf2,)
'0.299999999999999988897769753748434595763683319091796875
 0.3000000000000000444089209850062616169452667236328125'

(Проміжні значення точности показують різні стадії округлення. Не наводжу їх, кожен може повторити.)

Або те ж саме з %f:

>>> "%.16f %.16f" % (vf1, vf2,)
'0.3000000000000000 0.3000000000000000'
>>> "%.17f %.17f" % (vf1, vf2,)
'0.29999999999999999 0.30000000000000004'
>>> "%.60f %.60f" % (vf1, vf2,)
'0.299999999999999988897769753748434595763683319091796875000000
 0.300000000000000044408920985006261616945266723632812500000000'

(тільки хвостові нулі не скорочуються, на відміну від %g. Вистачило б 54, але я взяв 60, щоб показати ці нулі.)

це жахіття — це і є повне представлення дійсного значення обох змінних у двійковому форматі; всередині це дріб з величезним знаменником, що є степенем двійки. Інші форми — як та ж з %.17g, яка перша дійсно розрізняє всі значення — скорочені (з округленням).

Результати тут співпадають на Linux (Ubuntu 22.04) і Windows (VS2022, toolset 143). Стандарт диктує деталі.

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

А що ж з друком зовсім завмовчки у Python (тобто ми навіть формат не задаємо)?

>>> f"{vf1} {vf2}"
'0.3 0.30000000000000004'

О. А це і є те, на що натякав мем. Якщо значення можна вивести у короткій формі, яка при зворотньому імпорті буде сприйнята як те ж число, це буде зроблено (вибирається найкоротша форма). В Python це викликається через str() або repr() у обʼєкту типу float, до яких прицеплений алґоритм короткого виводу. У Microsoft сказано про таке як «the shortest round-trippable».

В JavaScript це теж дефолтний варіант виводу, специфікований в стандарті ECMA-262 (стандарт ECMAScript, тобто JavaScript без фірмового ™️) як ToString() для типа number, і викликається, наприклад, через `${varname}`, чи через console.log(), чи через промпт NodeJS... (насправді мені здається, що мем був зроблений жабаскриптерами, а не змієлюбами; там навіть звичайний printf складніше отримати, ніж такий експорт).

Самі ці алґоритми це відносно нове (ну, порівняно з самою реалізацією імпорта-експорта у текстовому вигляді). Їх почали серйозно розробляти десь з 1980-х (вже перша редакція IEEE754 була видана, а реалізації в залізі давно існували) і фінально дороблені до практичної форми вже біля 2000 року. Вони поступово підвищують точність зовнішнього десяткового представлення, поки не дойдуть до такого найкоротшого, що при зворотній конверсії дає те ж саме число у внутрішньому представленню. Чи не першим була работа Guy Steele + Jon White в 1990 р., і David Gay в тому ж році; деякі из сучасних і підтримуваних — Ryu printf; Dragonbox; і так далі. Ще посилань є в ECMA-262 (текст доступний вільно, хвала ECMA!) В Python, JavaScript, Java, багатьох інших, як бачимо, ці можливості вже вбудовані. В інших мовах треба шукати і додавати бібліотеки (чому я і наводжу посилання і імена для пошуку, ну крім того, що за таке вважаю належним дати особливу шану). Хто хоче, може подивитись код, але попереджаю — базово зрозуміти їх це вже подвиг...

Який недолік цих алґоритмів? Порог порядку, за яким підіймається дорога у використанні багатозначна (arbitrary-precision, чи просто «довга») арифметика, значно нижчий (може бути і нулем). Звісно, реальні затрати залежать від значення, воно не підіймає по 10000 біт на кожну конверсію; але і малі обʼєми можуть бути непридатні для обмежених ресурсів (читай, embedded).

Java, раптово, нам показує зовсім дивну картину. Беремо число π і поступово підвищуватимемо точність виводу:

ToString: 3.141592653589793
p=6 : 3.14159
p=7 : 3.141593
p=8 : 3.1415927
p=9 : 3.14159265
p=10: 3.141592654
p=11: 3.1415926536
p=12: 3.14159265359
p=13: 3.141592653590
p=14: 3.1415926535898
p=15: 3.14159265358979
p=16: 3.141592653589793
p=17: 3.1415926535897930
p=18: 3.14159265358979300
p=19: 3.141592653589793000
p=20: 3.1415926535897930000

це з такого тесту:

public class t03 {
    public static void main(String[] _args) {
        double pi = 3.1415926535897932;
        System.out.printf("ToString: %s\n", Double.toString(pi));
        int p;
        for (p = 6; p <= 20; ++p) {
            String fmt = String.format("p=%%-2d: %%.%dg\n", p);
            System.out.printf(fmt, p, pi);
        }
    }
}

Або схожий за духом результат зі звичними нам 0.3 і 0.1*3:

ToString: 0.3 0.30000000000000004
p=6:  0.300000 0.300000
p=16: 0.3000000000000000 0.3000000000000000
p=17: 0.30000000000000000 0.30000000000000004
p=54: 0.300000000000000000000000000000000000000000000000000000 0.300000000000000040000000000000000000000000000000000000

це замість:

p=54: 0.299999999999999988897769753748434595763683319091796875
 0.3000000000000000444089209850062616169452667236328125

(Для цих чисел достатньо точности 54 цифр для %g, а навкруги 0.1 треба 56 для повного представлення. Для 0.01 вже буде треба 58. Максимум для 0.0001, вимагає 63. Це ціна за «вільність» лоґіки %g.)

Це зовсім не те, що ми бачимо у раніше розглянутих прикладах (C, C++, Python). З одного боку, при недостатній точності експорту значення округлюється (half-away! дивний вибір). З іншого боку, при значеннях точности вище доступного (найкоротше представлення, яке назад перетворюється в те ж число) далі воно не показує можливі цифри, а просто додає нулі, навіть при %g!

Є стисла, але ясна розповідь про цю манеру, з обґрунтованою критикою з прикладом некоректного округлення при виводі: «The Java specification requires a troublesome double rounding in this situation», друге округлення дає інше значення, ніж вірне (при чому відрізняється на 45e6 дабловськіх ULP!)

Як на це дивитись? Якщо запросити достатню точність, то результат є прийнятним: зворотній імпорт дає те ж число. Але специфіку представлення з нього вже не вирахувати... Нерівне рішення, мʼяко кажучи: або б давали короткий варіант без нулів в кінці, або б писали чесно скільки запитано цифр, але не так.

Тобто, краще всього в Java використовувати прямий toString() (або %s в форматах, він сам викликає toString()), для найкоротшого представлення, або явно задавати максимальну точність згідно типу даних і не чекати більше, ніж вона дає. Скорочення точности має бути фатальним для значення через подвійне округлення.

Використання half-away для округлення проміжних значень натякає, що це була спроба усидіти на двох стільцях одночасно — двійковому і десятковому. Здається, не дуже вдалось. Здається, що автори Java самі швидко зрозуміли, що накоїли, але... леґасі в IT воно таке, що відійти від поспішних некоректних рішень можна далеко не завжди...

Дотнет показує більш реальні засоби контролю. Явні форматні специфікатори дають потрібну точність зі всіма реальними цифрами, але їх треба писати, наприклад, як «{0:G17}» (це синтаксично не відповідає підходам ні Fortran, ні C; MS не могла не зробити по-своєму, несумісно). Але «G» без конкретного значення точности дає те, що у Python у repr() і Java у toString(), тобто найкоротшу форму. Також для цього працює «R» (від «round-trip»), хоч він і явно не рекомендований саме для плаваючих, хоча в документації про специфікатор G сказано, що це «Smallest round-trippable number of digits to represent the number». Ну, тут без бардаку не обійшлося, за визначенням.

Що ще? Це ми спробували вбудовану бібліотеку (і те — тільки експорт; про імпорт буде нижче). А ще є засоби подивитись на стан проґрами, що виконується, ззовні. Перш за все — дебаґери, вони ж зневадники.
Найпростіший тест показує, що CLion і Visual Studio показують наші два зразкових числа як 0.29999999999999999 і 0.30000000000000004. Добре, що точність не скорочена до дефолтної за %g. Але чи краще виводити 0.29999999999999999, ніж 0.3? Сумнівно. PyCharm виводить 0.3 і 0.30000000000000004. Це лоґічно, бо узгоджено з repr() самого Python. Те ж саме у інтерпретаторі NodeJS. Idea теж показує 0.3 і 0.30000000000000004, — узгоджено з Java toString(), але не з System.out.printf.

Звісно, це все було при округленні завмовчки (half-to-even, FE_TONEAREST в стандарті C); Java, дотнет, Python ви ще й так просто не примусите вибирати інший режим; якщо буде засіб, він буде врапером навкруги чогось сішного. Для C, C++ це можливо штатними функціями. Якщо вимагати, наприклад, округлення до +∞ (FE_UPWARD), результати будуть інші — бо форматний експорт реаґує на режим. Додамо ще два числа:

#include <stdio.h>
#include <fenv.h>
#include <math.h>
int main()
{
#pragma STDC FENV_ACCESS ON
  double vf1 = 0.3;
  double vf2 = 0.1 * 3;
  double vfm = nextafter(vf1, 0.0);
  double vfp = nextafter(vf2, 10.0);
  fesetround(FE_UPWARD); // тут можемо міняти режим
  printf("auto: %g %g %g %g\n", vfm, vf1, vf2, vfp);
  printf("16: %.16g %.16g %.16g %.16g\n", vfm, vf1, vf2, vfp);
  printf("17: %.17g %.17g %.17g %.17g\n", vfm, vf1, vf2, vfp);
  printf("54: %.54g %.54g %.54g %.54g\n", vfm, vf1, vf2, vfp);
}

Результат:

auto: 0.3 0.3 0.300001 0.300001
16: 0.3 0.3 0.3000000000000001 0.3000000000000001
17: 0.29999999999999994 0.29999999999999999 0.30000000000000005 0.3000000000000001
54: 0.29999999999999993338661852249060757458209991455078125
 0.299999999999999988897769753748434595763683319091796875
 0.3000000000000000444089209850062616169452667236328125
 0.300000000000000099920072216264088638126850128173828125

Останній рядок співпадає з результатами з half-to-even, бо округлювати вже нічого; інші — ні. Проте бачимо різницю... у даному конкретному випадку. Якщо це було б не 0.3 і 0.1×3, а 0.1×3 і наступне число, різниці б знову не було аж до досягнення першої повної точности (17 цифр):

Ну і тепер до нуля (FE_TOWARDZERO), решта в попередньому коді не змінена:

auto: 0.299999 0.299999 0.3 0.3
16: 0.2999999999999999 0.2999999999999999 0.3 0.3
17: 0.29999999999999993 0.29999999999999998 0.30000000000000004 0.30000000000000009
54: 0.29999999999999993338661852249060757458209991455078125
 0.299999999999999988897769753748434595763683319091796875
 0.3000000000000000444089209850062616169452667236328125
 0.300000000000000099920072216264088638126850128173828125

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

Який висновок? Цей метод виводу робить тільки те, що він робить — показує результат з заданою точністю... і його можна, відповідно, сприймати тільки з поправкою на точність. Згадаємо проблему порівняння чисел з похибкою...

Числа, що однаково виводяться, можуть бути різними. Треба мати на увазі скорочення і округлення при виводі.

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

А тепер пабабабааам... дивимось, що ж написано у стандарті IEEE754 про експорт в текстове десяткове представлення. Не цитуватиму казенну мову напряму, а суть така:

1) Взяти кількість цифр, які необхідні для точного представлення у зовнішньому вигляді числа з найширшим форматом з підтримуваних. (Нехай це буде класичний double, тоді це 17 цифр.)

2) Додати 3 до попереднього (чому 3? тому що ґладіолус. ось таку вирішили дозволити похибку. але вона важлива тільки для «направлених» режимів округлення, а не half-щось).

Це мінімум для числа H, а взагалі рекомендується зробити його необмеженим. У нашому прикладі H не може бути менше 20. Тепер: запити на експорт у представлення для деякого h ⩽ H мають бути коректно округлені. Запити на експорт з точністю h > H мають бути доповнені хвостовими нулями.

Видно, що:

1) Класичний сішний printf майже задовільняє цьому. Насправді, «%.53e» це воно, H=53 (хм...). Інші варіанти не дуже відповідають: %g при будь-якій точності не додає хвостові нулі. (Але чому ми маємо слідкувати, яку точність ставити в форматах?)

2) Варіант printf в Java бреше, спочатку вираховувавши найкоротше представлення, а потім або округлюючи, або додаючи нулі. Незрозуміло, навіщо. Як сказано вище, не можна дозволяти йому округлювати ще раз.

3) Варіант найкоротшої форми не відповідає... але від нього і не вимагали? У нього інші методи, хоча і та ж сама загальна ціль — точне зовнішнє представлення.

Норма ж стандарту відповідає конкретним вимогам — точності і зворотності — але не зручності роботи людини з такими представленнями.

Дешево і точно: шістнадцятковий формат

Типова інерція в IT від відкриття до перших значних наслідків — біля 10 років. Відкриття методів ґенерації найкоротшого представлення, які вимагають багатозначної арифметики, і розвитку апаратних засобів привело до вимоги мати метод імпорту-експорту, який не був би настільки дорогим, і в C99, нарешті (скільки десятиліть чекали?), зʼявився специфікатор конверсії «%a» для шістнадцяткового представлення. На відліч від десяткових представлень, це максимально дешеве: пошук старшого розряду (тільки у субнормальних), зсув аж до 4 біт і друк за таблицею символів. (Навіть вимога писати порядок в десятковій формі це дешево, порядок не буває надто великим.) Зворотній імпорт теж «дешевше не буває» (ну крім напряму двійкової форми, BE чи LE).

Для Python його аналоґом є метод float.hex():

>>> print(vf1.hex(), vf2.hex())
0x1.3333333333333p-2 0x1.3333333333334p-2

(Дивно, що не додали напряму «%a», «%x» в %-оператор та/або «{:a}», «{:x}» в format(), але це за межами нашої розмови.)

Зворотньо, треба викликати float.from_hex(). Тобто на зараз проґраміст має спочатку визначити (наприклад, перевіривши на початкове `0x` чи `-0x` регексом), який формат дійсно застосований, і виконати відповідний виклик. Чому не можна, щоб float() сприймала на вхід і шістнадцятковий формат? Мабуть, треба скаржитись...

Аналоґічні засоби поступово зʼявляються у всіх мовах, що хоч якось розвиваються.

І я все ще здивований, що цей формат не зʼявився раніше. Знаючи те, що ми знаємо зараз, його мали винайти ще тоді, коли перші засоби текстового вводу-виводу зʼявлялись у кінці 1950-х. Тут непахане поле роботи для історика технолоґій і наукової думки: чому деякі прості речі вимагають так багато часу і зусиль для своєї появи... А ще — чому б його не дозволити саме в міжпроґраммному обміні (починаючи з JSON), помітно скоротивши витрати на конверсію представлення?

Локалі, або культури

Як на самому початку першої частини, це у нас крапка чи кома? Для формату, який призначений для взаємодії проґрам, хоч і в текстовому вигляді, буде крапка. Для людини, якщо ми встановлюємо, що це Україна чи більша частина Європи — кома. І це не все: будуть ще розділові знаки тисяч («123,456.789» це те ж саме, що «123456.789», але зручніше для читання; для Європи, в середньому, імовірніше буде «123 456,789», хоча для da_DK буде вже «123.456,789» (саме так! порівняно з США, крапка і кома обмінялись ролями). В представленні для проґрамістів крапка між цілою і дробовою частинами, здається, безваріантно. А ось допоміжний сепаратор може бути підкресленням (_) у більшості мов, де він дозволений, але апостроф (’) в C++.

Не вдаватимемось більш детально, бо для кожної мови і платформи тут свої підходи і проблеми, як ґарантувати конкретну культуру (чи незалежність від неї) у якому виді виклику. Тільки згадаємо Turkey test (для України має особливе, не стороннє значення: кримська мова тут ідентична турецькій по всіх згаданих параметрах). Щонайменше для мови проґрамування потрібно мати розділені, або ж чітко параметризовані, методи імпорту-експорту з урахуванням локалі/культури чи без нього (текстовий формат для взаємодії проґрам). На щастя, шістнадцятковий формат не має локалізованих форм:) Але, щоб в проґрамі, в якій активована локаль, не отримати, наприклад, «123 456,789» в JSON, треба постаратися, а спочатку ще й розуміти, що проблема існує. (Для CSV, здається, так і не зробили єдиний стандарт портабельного формату?)

І навпаки, імпорт

Маючи десяткову форму, треба перетворити її у відповідне внутрішнє двійкове представлення. Це навіть більше «первинна» задача, ніж експорт — як для людини. Хоча для компʼютера, навпаки, вторинна — йому простіше не мати з таким справи, все передаючи двійково.

Тут засоби різних мов ще більш різноманітні, ніж при експорті. Десь можна читати scanf чи аналогом по одному значенню, а десь треба самому прийняти повний рядок, розділити на частини і перекласти кожну окремо. Введення з вже відділенного рядка через parseFloat(), float(), from_chars() має, по більшості, спільні принципи і риси з експортом, але і деяку свою специфіку.

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

У типовому варіанті імпорт однаково приймає два базових формати — з фіксованою крапкою і експоненційний — однаково, незалежно від того, який вказаний функції перетворення. Також неважливо, число було записано в якому вигляді: fraction view (0.6022e24), left units view (6.022e23), інженерному (602.2e21), right units view (6022e20, якщо більше нема цифр), поки число не урізано через брак місця. Шістнадцятковий, проте, чомусь ще не всюди дозволений у тих же умовах: наприклад, Python вимагає явний float.from_hex(), щоб розібрати його; для JavaScript штатного способу ще нема(?), потрібна якась бібліотека (розібрати на частини тексту, проконвертувати шістнадцяткову форму як ціле, зібрати число і відмасштабувати... дешево і не дуже складно, але чому ми маємо писати таке для базових вимог?)

Критично важно вибрати тип, який підтримує необхідну точність. Якщо реалізація за IEEE754 і вам потрібно прийняти значення із 7 або більше десяткових цифр, ви не можете прийняти його у float і потім використовувати в розрахунках як double:

  float vf;
  double vd;
  sscanf(argv[1], "%f", &vf);
  sscanf(argv[1], "%lf", &vd);
  printf("Result: %.17g %.17g\n", vf, vd);

Запускаємо з π на вході:

Result: 3.1415927410125732 3.1415926535897931

Очевидно, що перше значення — якась єресь.

Що залишається на роздуми? Не ліземо тут в реалізації алґоритмів імпорту, це далеко за межами. Тільки вимоги і наслідки.

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

При цьому, в загальному випадку, не буде точного співпадіння значень, що точне з тексту, і що буде представлено в двійковій формі (приклади для 0.1, 0.3 і так далі — могли комусь вже набридти); тому вмикається уся машина округлення: усічення хвоста значення, вибір між TZ і AZ. Вона може мати свої особливости: я вище вже наводив випадок, як така реалізація зависала на деяких значеннях. На щастя 99.99+% присутніх, їм можна просто користуватись готовими бібліотеками.

Наслідки, які нам важливі:

1. Експорт з достатньою точністю (щоб передати всі деталі двійкового значення) і зворотній імпорт повинні дати початкове значення. Згадувалось, 9 і 17 цифр повного значення відповідно для одинарної і подвійної точности. І це вже при безваріантному half-to-even, інакше нема ґарантії, що відновиться те ж значення; а якщо використати те ж направлене (direct) округлення, то воно застосується двічі і це ще надійніше спотворить результат. Направлені округлення для імпорту це вже зовсім нетипове.

2. І зворотньо, імпорт з достатньою точністю і зворотній експорт при дефолтному half-to-even повинні дати початкове значення — і тут, повторюємо, ґарантовані 6 і 15 цифр відповідно для одинарної і подвійної точности.

3. Формат експорту при максимальній потрібній точності (як «%.17g» для double) і максимально скорочений — повинні давати при зворотньому імпорті одне і те ж двійкове значення.

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

В стандарті сказано так:

Conversion from a character sequence of more than H significant digits or larger in exponent range than the destination binary format first shall be correctly rounded to H digits according to the applicable rounding direction and shall signal exceptions as though narrowing from a wider format and then the resulting character sequence of H digits shall be converted with correct rounding according to the applicable rounding direction.

Це ще треба розкурити. Підкреслю: спочатку вхідний текст скорочується до H десяткових цифр з округленням згідно вибраного режима, але ще не перетворюється — це робиться ще повністю в десятковому представленні. (Що таке H, було в розділі про експорт.) Потім це значення з H цифр перетворюється у двійкове і уже в такому форматі округлюється до точности вибраного формата. (Норма про скорочення з округленням виглядає надто дорого. Але на практиці це можна зробити простим автоматом.)

Я не розкопав обґрунтування цього додавання 3 до H порівняно з довжиною представлення для найбільшого підтриманого формату. Схоже, що воно важливе тільки при направленому режимі округлення, і спирається на якісь непозначені роботи; 3 цифри дають похибку до 1/1000 при імпорті з таким округленням. Зважаючи, що направлені режими використовуються майже тільки для інтервального оцінювання, нас це мало цікавить.

Один приклад на імпорт з «виправленням» значення. Згідно Python і не тільки, повне текстове представлення точного значення π у double дорівнює 3.141592653589793. Тільки Найпильніше Око помітить, що тут, хм, тільки 16 цифр. Раніше ми казали, що максимум що треба — 17. З повного значення, що дорівнює 3.14159265358979323846264338327950288419716939937510..., бачимо, що могли б ще додати двійку. Що ж, перевіримо: імпорт і потім знову експорт в найкоротше представлення.

>>> math.pi
3.141592653589793
>>> f"{math.pi:.17g}"
'3.1415926535897931'
>>> float('3.1415926535897928')
3.1415926535897927
>>> float('3.1415926535897929')
3.141592653589793
>>> float('3.1415926535897931')
3.141592653589793
>>> float('3.1415926535897932')
3.141592653589793
>>> float('3.1415926535897933')
3.141592653589793
>>> float('3.1415926535897934')
3.1415926535897936

Що ж, саме для цього числа спрощення запису було вірне.

Тут міг бути ще приклад на ефекти імпорту при недефолтному напряму округлення, C, стандартний scanf, fesetround(). Але підкату нема і я його скоротив. Якщо треба — додамо в коментарій, але це майже банально. Результат з ним, що мені не вдалось знайти приклад, коли б цей імпорт порушував стандарт. Що ж, вважатимемо, що тут проблем нема, а ті, що є, відносяться до парсінґу, локалей і т.п., а не власне обчислень.

Ціна імпорту десяткового зовнішнього представлення у внутрішне двійкове така ж, як у відповідного експорту: для великого за модулем порядку — дуже велика. І знову шістнадцяткова форма тут вигідніше, все було сказано вище.

Висновки

Головне: не будьте роботами з класичного малюнку і розумійте, що і як ви виводите або приймаєте, і чому. Тримайте на увазі, коли з зовнішніми формами працюють люди, а коли — проґрами, і відповідайте цьому. Не робіть кроки, які втрачають точність, для цього контролюйте методи і формати.

Друге — продуктивність. Поки ви не сильно обмежені в часі або місці, стандартні засоби достатні. Якщо обмежені — доведеться зануритись у всі ці деталі.

В наступній частині: порівняння.

👍ПодобаєтьсяСподобалось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

Підписатись на коментарі