Шестнадцатеричная запись чисел с плавающей точкой в C++, Java, Go
- пятница, 29 мая 2026 г. в 00:00:18
Эта возможность есть в нескольких популярных языках программирования - но она настолько невостребованна, что и не все-то коллеги о ней знают - а в других не менее популярных языках её отказываются добавлять, несмотря на запросы энтузиастов. Этакий живой курьёз.
В этой коротенькой заметке-памятке - взглянем на формат записи (он может показаться не вполне логичным и не вполне шестнадцатеричным, вопреки названию) и поддержку стандарта разными языками. Основным же применением для этой "фичи" может быть, наверное - троллить друг друга на собеседованиях :)
Если вам случалось использовать такую запись на практике - поделитесь в комментариях. Или хотя бы если вы можете придумать случай когда она потенциально пригодится.
Вероятно почти все помнят популярное представление чисел с плавающей точкой (стандарт 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 = 0 i = 0 while s != 1: s += 0.125 i += 1 print(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# - если не ошибаюсь, тоже никому не понадобилось
Почему разработчики компиляторов и интерпретаторов не стремятся добавлять этот функционал? Легко сообразить что одним распознаванием литералов в исходном коде дело не ограничивается. Нужно добавлять описание в спецификацию языка, а кроме того, вероятно, в функции преобразования чисел в строки. Посмотрим на это чуть подробнее.
Выше упомянуто что в 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.
В качестве демонстрации слабой распространённости такой записи, посмотрим как справляется с ней подсветка синтаксиса, используемая здесь, на Хабре.
// Java double 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 если я не путаю).
Таким образом мы имеем инструмент который имеет единственную цель - уметь точно задать число с учётом физического представления его в машине - и даже этой цели в общем-то не вполне достигает.
Хотя в принципе можно придумать случай, когда использование подобной записи может пригодиться (разработка поддержки софтварных библиотек для плавающей точки что ли?) - но в целом кажется, что это один из наиболее забавных примеров курьёзной фичи, которая проросла в стандарт и была воплощена поскольку "ничего не ломает", но в то же время претендует на роль одной из самых маловостребованных (чтобы избежать эпитета "бесполезных").
По большому счету, работая над каким-нибудь здоровым проектом из набора сервисов в типичном энтерпрайзе, можно и вообще с дробными числами сталкиваться очень редко. А уж шестнадцатеричный формат для них - ну такое себе...
Отдельной проблемой по-видимому является то что такая запись попросту плоховато читается.