Skip to content

Printf fixes around handling of -0.0 (negative zero) - #18147

Merged
T-Gro merged 19 commits into
dotnet:mainfrom
jwosty:printf-fixes
Aug 25, 2026
Merged

Printf fixes around handling of -0.0 (negative zero)#18147
T-Gro merged 19 commits into
dotnet:mainfrom
jwosty:printf-fixes

Conversation

@jwosty

@jwosty jwosty commented Dec 14, 2024

Copy link
Copy Markdown
Contributor

Description

Fixes #15557 and #15558

Checklist

  • Test cases added

  • Release notes entry updated:

    Please make sure to add an entry with short succinct description of the change as well as link to this pull request to the respective release notes file, if applicable.

    Release notes files:

    • If anything under src/Compiler has been changed, please make sure to make an entry in docs/release-notes/.FSharp.Compiler.Service/<version>.md, where <version> is usually "highest" one, e.g. 42.8.200
    • If language feature was added (i.e. LanguageFeatures.fsi was changed), please add it to docs/release-notes/.Language/preview.md
    • If a change to FSharp.Core was made, please make sure to edit docs/release-notes/.FSharp.Core/<version>.md where version is "highest" one, e.g. 8.0.200.

    Information about the release notes entries format can be found in the documentation.
    Example:

    If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

@jwosty
jwosty requested a review from a team as a code owner December 14, 2024 22:22
@github-actions

github-actions Bot commented Dec 14, 2024

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/FSharp.Core` docs/release-notes/.FSharp.Core/11.0.100.md

Comment thread src/FSharp.Core/printf.fs Outdated
Comment thread src/FSharp.Core/printf.fs Outdated
Comment thread src/FSharp.Core/printf.fs Outdated
Comment thread src/FSharp.Core/printf.fs Outdated
Comment thread src/FSharp.Core/printf.fs Outdated
Comment thread src/FSharp.Core/printf.fs Outdated
Comment thread src/FSharp.Core/printf.fs Outdated
@jwosty

jwosty commented Dec 16, 2024

Copy link
Copy Markdown
Contributor Author

OK so after reading some of the review comments here, I went and checked the output of the currently released sprintf against both .NET 9 and .NET framework.

The code:

printfn "-- floats --"
printfn "%%f +0.0f => %f" +0.0f
printfn "%%f -0.0f => %f" -0.0f

printfn "%%+f +0.0f => %+f" +0.0f
printfn "%%+f -0.0f => %+f" -0.0f

printfn "%%010.3f +0.0f => %010.3f" +0.0f
printfn "%%010.3f -0.0f => %010.3f" -0.0f

printfn "-- decimals --"
printfn "%%f +0.0m => %f" +0.0m
printfn "%%f -0.0m => %f" -0.0m

printfn "%%+f +0.0m => %+f" +0.0m
printfn "%%+f -0.0m => %+f" -0.0m

printfn "%%010.3f +0.0m => %010.3f" +0.0m
printfn "%%010.3f -0.0m => %010.3f" -0.0m

Output for .NET 9:

-- floats --
%f +0.0f => 0.000000
%f -0.0f => -0.000000
%+f +0.0f => +0.000000
%+f -0.0f => +-0.000000
%010.3f +0.0f => 000000.000
%010.3f -0.0f => 0000-0.000
-- decimals --
%f +0.0m => 0.000000
%f -0.0m => 0.000000
%+f +0.0m => +0.000000
%+f -0.0m => +0.000000
%010.3f +0.0m => 000000.000
%010.3f -0.0m => 000000.000

Output for .NET Framework 4.8:

-- floats --
%f +0.0f => 0.000000
%f -0.0f => 0.000000
%+f +0.0f => +0.000000
%+f -0.0f => +0.000000
%010.3f +0.0f => 000000.000
%010.3f -0.0f => 000000.000
-- decimals --
%f +0.0m => 0.000000
%f -0.0m => 0.000000
%+f +0.0m => +0.000000
%+f -0.0m => +0.000000
%010.3f +0.0m => 000000.000
%010.3f -0.0m => 000000.000

As you can see, neither #15557 nor #15558 are present in the .NET framework run (which makes some sense given @tannergooding's comments). Furthermore, the .NET framework version always treats (decimal and float) negative zero as positive zero for formatting purposes (which I didn't realize before since I didn't think to also test against Framework).

Therefore I will amend this PR to preserve the .NET framework behavior in both cases. I personally don't particularly care how -0.0f and -0.0m print, just as long as it's consistent (my original purpose in this PR was just to fix the bugs; that's all). So I'm not going to file a proposal for any breaking changes, but if anyone else feels strongly about it, don't let me stop you from doing so yourself.

Additionally, it looks like decimal behavior is consistent and therefore needs no bug-fixing-changes after all. So I will be reverting those.

@jwosty

jwosty commented Dec 16, 2024

Copy link
Copy Markdown
Contributor Author

Actually there is still one scenario that I'd argue is a bug under .NET framework, too:

sprintf "%+f" -0.0000001

Under .NET framework this prints "0.000000" which is definitely a bug (+ flag should always cause output to have some sign on it). Now the question is, should the sign be - or +? My gut tells me that it should behave the same as -0 (meaning it should print +0.000000), but that will complicate things a little bit since it breaks the current assumption that a negative number will always have a negative sign. The implementation would have to start caring about whether the number rounds to zero, which I fear could fall prey to precision errors or other weird floating point stuff... Formatting this kind of input with a negative sign would be the much simpler solution, but that also feels wrong (since it would now be possible to get printf to print a negative zero, but not when the number is actually negative zero - might lead to a very strange experience for anyone who stumbles across this behavior).

Thoughts?

@tannergooding

tannergooding commented Dec 16, 2024

Copy link
Copy Markdown
Member

Under .NET framework this prints "0.000000" which is definitely a bug

.NET Framework has many bugs that will never be fixed due to its much stronger back-compat bar. Other bugs around IEEE 754 floating-point include things like double.Parse(x.ToString("R")) not always roundtripping, x.ToString() silently resulting in loss of precision in many scenarios (including Math.PI), not correctly preserving negative zero, etc. Much of this is discussed in https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/

Typically this much stronger backwards compatibility bar means that the behavior should be preserved "as is" on .NET Framework and fixes should only be taken on .NET [Core] where relevant. F# could decide differently, but that itself comes with its own risks and potential for new problems due to it differing from what users may expect and differing from what they'd experience using the regular BCL APIs.

but that will complicate things a little bit since it breaks the current assumption that a negative number will always have a negative sign

This assumption is already broken (on .NET Framework) by negative zero. Negative zero itself exists due to there being negative non-zero results which round towards zero due to the precision limitations of the underlying format, for scientific and other mathematical domains this information is often relevant and so is pertinent to display and preserve (which is different from decimal which is designed as a currency type and where corresponding features are lacking that prevent or hinder its usage in such domains).

If you're printing with a limited number of digits, then today this functions (in both F# and the BCL) by functionally rounding to that many digits; thus if you print to 2 fractional digits then 0.005 becomes 0.01 and 0.004 becomes 0.00. Under the same premise, -0.004 becomes -0.00 and that then displays (using the BCL APIs) as negative zero would, without the sign on .NET Framework and with the sign on .NET Core.

Edit: Noting that "rounding to that many digits" is meant to account for the exact underlying represented value, not strictly the literal the user visualizes, thus 0.005 is actually 0.005000000000000000104083408558608425664715468883514404296875 and that's why it rounds "up" to 0.01 instead of down towards the non-odd value (as per the default rounding mode "to nearest; ties to even", since it isn't actually a tied value).

@jwosty

jwosty commented Dec 16, 2024

Copy link
Copy Markdown
Contributor Author

Under .NET framework this prints "0.000000" which is definitely a bug

.NET Framework has many bugs that will never be fixed due to its much stronger back-compat bar.

I should correct myself; I meant it's that sprintf "%+f" -0.0000001 printing "0.0000001" is most certainly a bug in FSharp.Core's sprintf implementation even when running under .NET Framework. I think F# has been okay with "breaking" backwards compatibility with regard to longstanding bugs (in contrast with the .NET Framework), though I of course defer to the F# team's wisdom.

but that will complicate things a little bit since it breaks the current assumption that a negative number will always have a negative sign

This assumption is already broken (on .NET Framework) by negative zero.

Again, let me refine that statement:

but that [making sprintf "%+f" -0.0000001 print "+0.000000"] will complicate things a little bit since it breaks the sprintf user's current assumption that a negative number will nonzero negative number should always have a negative sign


EDIT:

That being said, given this comment:

If you're printing with a limited number of digits, then today this functions (in both F# and the BCL) by functionally rounding to that many digits; thus if you print to 2 fractional digits then 0.005 becomes 0.01 and 0.004 becomes 0.00. Under the same premise, -0.004 becomes -0.00 and that then displays (using the BCL APIs) as negative zero would, without the sign on .NET Framework and with the sign on .NET Core.

If we were to decide in FSharp.Core to fix the bug by making sprintf "%+f" -0.0000001 print "+0.000000", would it be a sane approach to actually just do some kind of rounding calculation internally (to some number of decimal points), and just use that number to decide what sign to use for the number? Effectively changing the logic (in pseudocode) from:

let determineSign n = if n >= 0.0 then "+" else "-"

to something like:

let determineSign n nDecimalPlaces = if (roundTo n nDecimalPlaces) >= 0.0 then "+" else "-"

My feeling was that there would still be room for some floating-point weirdness, but perhaps I was wrong; would that actually be a reasonable approach to take?

@T-Gro

T-Gro commented Aug 14, 2025

Copy link
Copy Markdown
Member

I am reviewing PRs that could in theory still make it for NET10 if pursued.
@jwosty : Are you interested in finishing that?

If yes, do you want any guidance?
(I haven't yet followed the full discussion in detail)

@jwosty

jwosty commented Aug 20, 2025

Copy link
Copy Markdown
Contributor Author

@T-Gro Sure I'd be interested in finishing this. I had unresolved questions about certain fixes (see #18147 (comment) and #18147 (comment) - though honestly we should probably all just re-read through this thing to refresh ourselves; there's a lot of corner cases)

@T-Gro

T-Gro commented Aug 17, 2026

Copy link
Copy Markdown
Member

Here is a proposal.

Instead of stripping the sign everywhere, make isPositive agree with each runtime's ToString per-TFM — then the existing sign/padding logic just works, and fixupSign isn't needed:

// GenericNumber
#if NETSTANDARD2_1_OR_GREATER            // ns2.1 + net10 (Core family)
    let inline singleIsPositive (n: single) = not (Single.IsNegative n)
    let inline doubleIsPositive (n: double) = not (Double.IsNegative n)
#else                                    // ns2.0 (.NET Framework contract)
    let inline singleIsPositive (n: single) = n >= 0.0f
    let inline doubleIsPositive (n: double) = n >= 0.0
#endif
    let inline decimalIsPositive (n: decimal) = n >= 0.0M   // unchanged: no signed zero
  • -0.0 now routes as negative on Core, so %+f/zero-padding place the sign where ToString already put it → -0.000000, -00000.000. No post-hoc string fixup.
  • ns2.0 keeps >= 0.0 (IsNegative doesn't exist there) → .NET Framework output byte-for-byte unchanged.
  • decimal untouched (no meaningful negative zero).
  • NaN is moot here: the float path always passes the real isNumber (false for NaN), so isPositive is never consulted for NaN; per-type IsNegative also avoids any float→double NaN normalization.

WDYT?

@jwosty

jwosty commented Aug 18, 2026

Copy link
Copy Markdown
Contributor Author

Sounds reasonable.

Though it does make the assumption that netstandard 2.0 = .net framework behavior, which is 99% of the time fine but I think there technically are situations where that could be violated (i.e. somehow paket (and nuget?) edge cases causing the ns 2.0 FSharp.Core to be used for .net core family; alternate runtimes like Unity / mono). However those could probably be considered collateral damage, I think at worst you'd just have the original "bugged" behavior persisting (the status quo).

Actually a few more options occurred to me which could sidestep that and fix it everywhere (at the cost of like 1 iota more complexity):

  • use the actual sign from the .NET formatted string (i.e. n.ToString(fmt, CultureInfo.InvariantCulture))
    • basically sound across the board, including rounds-to-zero bug i.e. sprintf "%+f" -0.0000001 (yes, a breaking change for .Net Framework -- but I'd vote to take the bug fix)
  • probe which runtime behavior we're dealing with by looking at (-0.0).ToString(CultureInfo.InvariantCulture)[0] = '-' and taking that into account to choose how isPositive behaves
    • perhaps computed once initially - seems okay because there's already established precedent for static initialization in FSharp.Core
    • would compose with your proposal -- we could just use the simpler Double.IsNegative etc when we know for sure it's .Net Core family and this could be the fallback

jwosty added 2 commits August 20, 2026 01:41
…ork on net framework, net core on net core), and reimplement the logic

All tests passing now (locally).
Comment thread src/FSharp.Core/printf.fs Outdated
jwosty added 3 commits August 20, 2026 13:48
…ly running due to partial application

It seems these tests have not been running for quite a while, if ever
@jwosty

jwosty commented Aug 20, 2026

Copy link
Copy Markdown
Contributor Author

@T-Gro should we perhaps enable all printf tests? Maybe it's running faster now / we all have better hardware? At least on my machine it runs the full thing in less than a minute (14 seconds for both net11.0 and net472, not including build time).

(or perhaps the reason is that it's still too slow in the CI VMs?)

@T-Gro

T-Gro commented Aug 21, 2026

Copy link
Copy Markdown
Member

@jwosty : Yeah lets run them all, nice catch (I did not know about this randomized setting)

@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Aug 25, 2026
@T-Gro

T-Gro commented Aug 25, 2026

Copy link
Copy Markdown
Member

@charlesroddie : I am going to merge this as the behavior this fixes is not good no matter the angle you take (+-0).

I have recently merged support for extension members and operators in SRTP - this could open up the door to using a dedicated Rational type instead of floating points for programs that do not want to follow IEEE 754 rules.

@T-Gro
T-Gro merged commit c251d06 into dotnet:main Aug 25, 2026
52 checks passed
@charlesroddie

Copy link
Copy Markdown
Contributor

I have recently merged support for extension members and operators in SRTP - this could open up the door to using a dedicated Rational type instead of floating points for programs that do not want to follow IEEE 754 rules.

No work is needed to create Rational types and that's not this issue. The solution to floating point tostring rendering with "-0" is not to avoid floating point numbers. We need to fix this. I'm going to try to do this starting with non-reflection methods.

@jwosty

jwosty commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@charlesroddie I think I understand what you're saying now. I can see how it's reasonable to argue that %f in particular (no format specifiers, the "default" way someone reaches for when printing a number in F#) should not surface "-0.0" as a possible output (because for those who are trying to avoid it, it's annoying to work around).

However such a thing would very much be a breaking change, since %f already does that today on .NET Core. And by virtue of inaction for so long I'd say F# has implicitly defined itself (for better or for worse) as aligning with that. I think we're 6 years too late to change it at this point: there may be some people who have come to expect / rely on the current behavior now, and deviating from .NET where we already (implicitly) "deviated" once to align with it would be surprising.

Probably the best remedy at this point would be to have a new format flag that lets you opt-out of signed zero formatting behavior. Like %~f or something. What do you think? It would require something like the earlier "polyfill"-like logic I drafted but it could definitely be done

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

%+f printf format specifier adds both plus and minus for negative zero (-0) on .NET Core

5 participants