Skip to content

Format zero as 0x0p+0 in hexfloat - #4878

Open
hexonal wants to merge 1 commit into
fmtlib:mainfrom
hexonal:fix-hexfloat-zero-exponent
Open

Format zero as 0x0p+0 in hexfloat#4878
hexonal wants to merge 1 commit into
fmtlib:mainfrom
hexonal:fix-hexfloat-zero-exponent

Conversation

@hexonal

@hexonal hexonal commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

fmt::format("{:a}", 0.0) gives 0x0p-1022 where printf("%a", 0.0) gives 0x0p+0:

value   printf         fmt
0.0     0x0p+0         0x0p-1022
-0.0    -0x0p+0        -0x0p-1022

Same for {:A}, {:#a} and any explicit precision. Non-zero values already agree.

Cause

basic_fp::assign maps a zero biased exponent to 1:

if (biased_e == 0)
  biased_e = 1;  // Subnormals use biased exponent 1 (min exponent).

That is right for subnormals, but zero shares the same biased exponent and is not a subnormal, so format_hexfloat ends up printing the minimum subnormal exponent for a value whose significand carries no information.

The fix resets the exponent in format_hexfloat rather than in assign, since the decimal path shares assign and relies on the subnormal mapping.

Scope

A zero significand can only come from ±0 — normals get the implicit bit added, and a subnormal has a nonzero significand by definition — so the guard cannot affect any other value. I checked the neighbours explicitly: denorm_min, min, max, ±inf and nan are byte-identical before and after, and denorm_min keeps fmt's existing denormalized form 0x0.0000000000001p-1022 that format_double already asserts.

Testing

The five new expectations were taken from printf output rather than derived by hand. All five fail without the change, with exactly the old values:

Which is: "0x0p-1022"
Which is: "-0x0p-1022"
Which is: "0X0P-1022"
Which is: "0x0.p-1022"
Which is: "0x0.000p-1022"

With it, ctest is 21/21 (Debug, macOS arm64, Apple clang 17). clang-format --dry-run -Werror is clean on both files. No existing test asserted the hexfloat form of zero, so nothing had to be updated.

I took printf as the reference because of your comments on #4657 — "the reference implementation of std::format always printed 0x like pretty much every other facility" — and LWG 4515. If you would rather keep zero consistent with how fmt prints subnormals instead, say so and I will close this.

fmt::format("{:a}", 0.0) produced 0x0p-1022 where printf's %a gives
0x0p+0, and likewise for -0.0, {:A}, {:#a} and any explicit precision.

basic_fp::assign maps a zero biased exponent to 1 ("subnormals use
biased exponent 1"), which is right for subnormals but not for zero, so
format_hexfloat inherited the minimum subnormal exponent for a value
whose significand carries no information. Reset the exponent there
rather than in assign, which the decimal path shares.

A zero significand can only come from +-0: normals get the implicit bit
added, and a subnormal has a nonzero significand by definition. So the
guard cannot affect anything else, and denorm_min keeps its existing
denormalized form (0x0.0000000000001p-1022, asserted in format_double).

The five new expectations were taken from printf rather than derived,
and all fail without this change.
@hexonal
hexonal requested a review from vitaut as a code owner August 3, 2026 05:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant