Шестнадцатеричная запись чисел с плавающей точкой в C++, Java, Go

от автора

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

В этой коротенькой заметке-памятке — взглянем на формат записи (он может показаться не вполне логичным и не вполне шестнадцатеричным, вопреки названию) и поддержку стандарта разными языками. Основным же применением для этой «фичи» может быть, наверное — троллить друг друга на собеседованиях 🙂

Если вам случалось использовать такую запись на практике — поделитесь в комментариях. Или хотя бы если вы можете придумать случай когда она потенциально пригодится.

Мотивация

Вероятно почти все помнят популярное представление чисел с плавающей точкой (стандарт IEEE 754) — в 4 или 8 байтах хранятся пара двоичных чисел — мантисса и экспонента (со знаками). По аналогии с тем как мы записываем число в «научной» нотации, вроде 2.026 * 10^3 — внутри используется похожее двоичное представление.

Например 0.101*10^11 (если все компоненты этой записи считать двоичными) будет числом 101 т.е. десятичное 5.

Есть проблема с тем что хотя любое такое дробное двоичное число можно записать в виде конечного десятичного (потому что 2 делитель 10), но обратное не верно. Например 0.1 (одна десятая) представить конечной двоичной дробью нельзя. Это обнаруживается даже при простейших вычислениях:

print(1.2 - 1.1) # печатает 0.09999999999999987

Иногда это может вести к неприятным проблемам:

s = 0i = 0while s != 1:    s += 0.125    i += 1print(i)

Этот хрестоматийный школьный пример печатает количество итераций — их будет 8 если переменная увеличивается на 0.125 однако замените инкремент на 0.1 или 0.2 — и получается бесконечный цикл, поскольку значение переменной не окажется равным точно 1 после 10 или 5 итераций соответственно — и цикл проскочит мимо условия выхода.

И вот кому-то показалось желательным, чтобы можно было при необходимости задать точное двоичное представление числа с плавающей точкой. Конечно двоичными цифрами записывать его неудобно (длинновато), поэтому использовали шестнадцатеричные.

Формат

Число начинается с 0x как и целые шестнадцатеричные числа.

Дальше следуют шестнадцатеричные разряды и, возможно, точка — хотел было написать «десятичная точка», но она не десятичная по смыслу. Например 0x1.8 — это было бы «полтора». Но такую запись компилятор не пропустит.

Обязательно нужен разделитель экспоненциальной части — в виде буквы p (латинская «пэ») и собственно экспоненциальная часть, хотя бы просто 0.

То есть наше «полтора» можно записать как 0x1.8p0. Выглядит жутковато, да? 🙂

Но жуть на этом не заканчивается, а только начинается. Если вы не знакомы с этой записью, ни за что не догадаетесь что экспонента указывается не по основанию 16, а по основанию 2. То есть те же полтора можно записать как 0x3p-1 , 0x6p-2, 0xcp-3, либо 0x.18p4 . Мантиса «сдвигается» на 1 бит за каждую единицу экспоненты, а не на 4 бита. То есть в десятичной записи 10e0 == 1.0e1 но в 16-ричной 0x10p0 == 0x8p1 — отличный способ заморочить голову тем, кто будет читать код после тебя 🙂

Как будто этого мало — экспонента указывается не шестнадцатеричным, а десятичным числом. То есть 0x1p10 / 0x1p9 == 2 (а не 128).

Мантиса пишется в 16-ричном формате, а экспонента в 10-чном и работает с 2-ичным представлением числа.

Поддержка в языках программирования

Как уже упомянуто, такая запись была добавлена в стандарт C99 (в 1999 году, кэп) — кроме неё появились и спецификаторы формата для методов семейства printf — можно указывать %A (или %a) для вывода числа с плавающей точкой в таком вот «недо-шестнадцатеричном формате».

В первом приближении считается что эта «фича» перекочевала в другие языки с похожим синтаксисом. Как мы увидим это не совсем так. Вернее, что она более распространилась в компилируемых языках и не нашла поддержки в интерпретируемых:

  • в стандарт IEEE 754 добавлено в 2008 году

  • Java поддержала, возможно, раньше всех, если не ошибаюсь в версии 1.5 (2005 год)

  • С++ поддерживает со стандарта C++17 (2017 год)

  • Go с версии 1.13 (2019 год)

  • Perl с версии 5.22 (2015 год)

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

  • Python (можно найти PR на гитхабе, закрытый)

  • JavaScript (можно найти краткую дискуссию на форуме но по-видимому никого не заинтересовало)

  • PHP (кажется вообще никто вопрос не поднимал)

  • C# — если не ошибаюсь, тоже никому не понадобилось

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

Поддержка в printf и scanf-методах

Выше упомянуто что в C вместе с литералами появились и указатели формата для функции printf (и прочих из её семейства). Эти же указатели формата работают и для функции чтения scanf — так что числа в такой записи можно и распечатать и прочесть обратно (если, например, какой-то фантазёр в таком виде вывел их в файл и его нужно загрузить в память). Вот демонстрационная программа — как видим, модификаторы длины используются как обычно:

#include <stdio.h>int main(void) {    double a;    char s[128];    sprintf(s, "%la", 42.0);    printf("%s\n", s); // печатает 0x1.5p+5    sscanf(s, "%la", &a);    printf("%f\n", a); // печатает 42.0    return 0;}

В других языках ситуация может быть чуть более запутанной

  • в Java формат %a работает в printf-методах; прямых аналогов scanf-методов нет, но функция Float.parseFloat() воспринимает такую запись адекватно.

  • в Perl формат %a тоже работает в printf , но произвести обратную конверсию мне не удалось (функции scanf нет, а автоматическое преобразование из строки не работает).

  • в Go нет даже формата %a в printf.

Кроме того…

В качестве демонстрации слабой распространённости такой записи, посмотрим как справляется с ней подсветка синтаксиса, используемая здесь, на Хабре.

// Javadouble x = 0x1.5p+3;double y = 1.3e+3;

как видим, для Java обычная десятичная «научная» запись числа распознаётся как единый токен, а шестнадцатеричная «распадается» на куски; можно проверить что для Go и Perl результат такой же.

Немного неожиданная ситуация с C++ (отдельно C для подсветки вроде бы нет):

// C++double x = 0x1.5p+3;double y = 1.5e+3;

Не распознаётся даже обычная запись. Может я что-то не знаю или забыл про C++?

Неполнота

Расширяя мысль, отмеченную в комментарии, важным недостатком этой нотации остаётся то что она не связана жёстко с физическим представлением числа в машине. В частности можно указать больше знаков чем вмещает мантиса (и они будут потеряны).

Отсутствует поддержка всевозможных дополнительных нюансов представления чисел — например в Java есть (или уже «была»?) возможность задавать числа с расширенной экспонентой (она занимала столько же бит но обозначала степени 4 а не 2 если я не путаю).

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

Заключение

Хотя в принципе можно придумать случай, когда использование подобной записи может пригодиться (разработка поддержки софтварных библиотек для плавающей точки что ли?) — но в целом кажется, что это один из наиболее забавных примеров курьёзной фичи, которая проросла в стандарт и была воплощена поскольку «ничего не ломает», но в то же время претендует на роль одной из самых маловостребованных (чтобы избежать эпитета «бесполезных»).

По большому счету, работая над каким-нибудь здоровым проектом из набора сервисов в типичном энтерпрайзе, можно и вообще с дробными числами сталкиваться очень редко. А уж шестнадцатеричный формат для них — ну такое себе…

Отдельной проблемой по-видимому является то что такая запись попросту плоховато читается.

ссылка на оригинал статьи https://habr.com/ru/articles/1040360/