Skip to content

Preserve Float(double) rounding when replacing wrapper constructors - #970

Draft
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/primitive-wrapper-float-double-rounding
Draft

Preserve Float(double) rounding when replacing wrapper constructors#970
martinfrancois wants to merge 3 commits into
openrewrite:mainfrom
martinfrancois:fix/primitive-wrapper-float-double-rounding

Conversation

@martinfrancois

@martinfrancois martinfrancois commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Suggested review order: 29 of 52 (Score: 3.5)
Review first: openrewrite/rewrite#8444

What's changed?

When the argument of new Float(...) has the primitive type double, PrimitiveWrapperClassConstructorToValueOf now always emits Float.valueOf((float) X), where X is the original argument with its source text unchanged, in parentheses of its own when it is a J.Binary, J.Ternary, J.Assignment or J.AssignmentOperation, the double-typed expression shapes that bind less tightly than a cast. A double literal is no longer rewritten into a String literal: it now takes the same branch a double variable already takes, keeping its source text, its suffix and its radix.

Before

In which d1 and d2 are variables of the primitive type double.

Float value = new Float(1.0000000596046448);
Float sum = new Float(d1 + d2);

Actual after the recipe

Using main today.

Float value = Float.valueOf("1.0000000596046448");
Float sum = Float.valueOf((float) d1 + d2);

Expected after the recipe

The corrected result is shown below.

Float value = Float.valueOf((float) 1.0000000596046448);
Float sum = Float.valueOf((float) (d1 + d2));

An argument typed as the boxed class java.lang.Double still becomes Float.valueOf(boxed.floatValue()), exactly as on main. The implementation change is ten added and seven removed lines in one method; the other 123 added lines are tests.

What's your motivation?

Recipe: org.openrewrite.staticanalysis.PrimitiveWrapperClassConstructorToValueOf.

For the compound arguments covered here whose static type remains double, today's output does not compile: javac 21 reports no suitable method found for valueOf(double) for d1 + d2 and d1 > d2 ? d1 : d2, and required: variable for an assignment argument. Where the cast in front of the first operand alone leaves the whole argument with the static type float it does compile instead, and then computes a different value. With double huge = 1e39, new Float(huge * 0) computes 0.0f, because huge * 0 is the double value 0.0 and the constructor narrows it. Main's output, Float.valueOf((float) huge * 0), computes NaN, because (float) huge overflows to Infinity and Infinity * 0 is NaN; it compiles because a float multiplied by an int has the static type float. This branch emits Float.valueOf((float) (huge * 0)), which computes 0.0f again.

A double literal argument changes the computed value too. Float(double value) is specified as this.value = (float) value, so the literal is rounded to a double and then narrowed to a float, whereas Float.valueOf(String) rounds the text straight to a float. For 1.0000000596046448, Float.floatToRawIntBits of the constructor's result is 0x3f800000 and of today's output 0x3f800001, one ulp higher. The String form is also rebuilt from the parsed value, so a hexadecimal literal, a D suffix or digit separators all become plain decimal: on main new Float(0x1.0000002p0) becomes Float.valueOf("1.0000000074505806"). Reproduced on 2.40.0 and on 2.41.0-SNAPSHOT built from main 5785534a.

Confirmed real-world execution

The released recipe places the float cast around only the left operand of a multiplication by the double literal 100.0. Binary numeric promotion makes the complete argument a double, so the generated Float.valueOf(...) call does not compile.

Anything in particular you'd like reviewers to focus on?

doubleToFloat already existed on main, and this change alters an expectation that used to hold there. Its input Float f = new Float(2.0d); is unchanged; the expected output was Float.valueOf("2.0") and is now Float.valueOf((float) 2.0d). Its other three assertions, for f2, f3 and f4, are untouched.

Three limits this change does not address, all the same on main and on this branch:

  • Comments outside the argument are dropped, for every wrapper class.
  • In Groovy the type of a compound double argument is not resolved, so that argument gets no cast.
  • valueOf may return a cached instance, so == on the result can behave differently from == on a constructor result. The recipe description already spells out this caching.

Have you considered any alternatives or workarounds?

Parenthesizing in every case would be simpler: one template instead of a conditional, about six implementation lines, deleting the branch that rewrites a double literal into a String literal and changing the template "Float.valueOf((float) #{any(double)})" to "Float.valueOf((float) (#{any(double)}))". I kept the form without parentheses for a simple argument because main already produces it for a double variable, it came in with #476 (closed), the issue that led to the (float) cast this change extends, and doubleToFloat asserts it in Float f4 = Float.valueOf((float) d2);, one of the three assertions this change leaves alone. That test is also the only existing one the alternative would touch: Float f and Float f4 would become Float.valueOf((float) (2.0d)) and Float.valueOf((float) (d2)). Say so in review and I will switch.

Any additional context

Pre-existing tests changed: PrimitiveWrapperClassConstructorToValueOfTest.java.doubleToFloat (updated).

This change adds 5 tests to PrimitiveWrapperClassConstructorToValueOfTest. Without the code change in this pull request, these 4 tests fail:

  • compoundDoubleExpressionToFloatIsParenthesized
  • doubleLiteralToFloatKeepsBinary64Rounding
  • doubleLiteralToFloatKeepsSourceForm
  • the pre-existing doubleToFloat, with its updated expectation

These 2 new tests pass either way:

  • doubleExpressionToFloatUsesCast
  • floatLiteralUnchangedByDoubleHandling

This change was prepared with AI assistance (Claude Code). I reviewed the code, the tests and this description.

Checklist

…nding

`new Float(<double literal>)` was retyped to a String and emitted as
`Float.valueOf("<decimal>")`. `Float(double)` is specified as
`(float) value`, so it rounds binary64 to binary32, while
`Float.valueOf(String)` rounds the decimal straight to binary32, and the
two can differ in the last bit: `(float) 1.0000000596046448` is `1.0f`
(bits 0x3f800000) while `Float.valueOf("1.0000000596046448")` is one ulp
higher (0x3f800001). Drop the String path so a literal argument goes
through `Float.valueOf((float) <literal>)` like every other primitive
double argument.

That cast template placed the argument directly in the cast operand. A
cast binds tighter than binary, ternary and assignment operators, so a
compound argument was only partly covered and the output either failed
to compile (`Float.valueOf((float) a + b)` passes a `double`) or rounded
one step too early (`(float) huge * 0` is `NaN` rather than `0.0f` for
`double huge = 1e39`). Parenthesize the argument for those expression
kinds; identifiers, field accesses and method invocations keep the form
they already had.

The existing `doubleToFloat` test expected `Float.valueOf("2.0")` for
`new Float(2.0d)` and now expects `Float.valueOf((float) 2.0d)`.
@martinfrancois
martinfrancois marked this pull request as draft August 16, 2026 01:10
@martinfrancois martinfrancois changed the title PrimitiveWrapperClassConstructorToValueOf: preserve Float(double) rounding Preserve Float(double) rounding when replacing wrapper constructors Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants