Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WW-4871 Fixes StringConverter and improves it's tests #173

Merged
merged 11 commits into from
Nov 9, 2017

Conversation

yasserzamani
Copy link
Member

No description provided.

format.setMinimumFractionDigits(1);
if (BigDecimal.class.isInstance(value))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use { } even if it is one line.

@aleksandr-m
Copy link
Contributor

Tests are failing.

@yasserzamani
Copy link
Member Author

Thanks! both fixed :)

@lukaszlenart
Copy link
Member

I think we are good ... @aleksandr-m what's your opinion?

format.setMaximumFractionDigits(Integer.MAX_VALUE);
}
else if (Double.class.isInstance(value)) {
format.setMaximumFractionDigits(15);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where 15 comes from? Shouldn't it be 16?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ouch! I mislead max fraction digits with max precision while they're different. thank you. fixed.

@aleksandr-m
Copy link
Contributor

As a quick fix it looks ok. In the future it would be good to make this configurable somehow (defaults, exact digits, settings from locale).

@lukaszlenart
Copy link
Member

As a quick fix it looks ok. In the future it would be good to make this configurable somehow (defaults, exact digits, settings from locale).

I would start with this and then add more options when requested

@yasserzamani
Copy link
Member Author

Tests for convert back also improved within NumberConverter's tests

format.setMaximumFractionDigits(Integer.MAX_VALUE);
}
else if (Double.class.isInstance(value)) {
format.setMaximumFractionDigits(325); //double MIN_VALUE
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yasserzamani Can you explain where these numbers coming from? How do you find them?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For BigDecimal theoretically there is no limit for count of fraction digits [1] but technically setMaximumFractionDigits cannot get bigger than Integer.MAX_VALUE.

For Double and Float, I converted Double.MIN_VALUE and Float.MIN_VALUE to a not scientific notated string by java 7 8 and 9 and found that numbers.

[1] http://download.oracle.com/javase/6/docs/api/java/math/BigDecimal.html

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aleksandr-m what do you think about keeping max integer for all instead:

if (BigDecimal.class.isInstance(value) || 
    Double.class.isInstance(value) || Float.class.isInstance(value)) {
          format.setMinimumFractionDigits(1);
          format.setMaximumFractionDigits(Integer.MAX_VALUE);
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's ok

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 Fixed

@aleksandr-m
Copy link
Contributor

Maybe we shouldn't format numbers at all. Just replace , and . according to locale. WDYT?

@lukaszlenart
Copy link
Member

Maybe we shouldn't format numbers at all. Just replace , and . according to locale. WDYT?

You mean calling .toString and replacing . with , when needed?

@lukaszlenart
Copy link
Member

Something like this:

        if (Number.class.isInstance(value)) {
            String str = value.toString();
            DecimalFormatSymbols otherSymbols = new DecimalFormatSymbols(locale);
            return str.replace('.', otherSymbols.getDecimalSeparator());
        } else {
            return Objects.toString(value, null);
        }

@aleksandr-m
Copy link
Contributor

@lukaszlenart Yes. The if (Number.class.isInstance(value)) should have same conditions as in number converter. And if (otherSymbols == '.') can be added to skip replacement if it isn't needed.

@lukaszlenart
Copy link
Member

The if (Number.class.isInstance(value)) should have same conditions as in number converter

Not sure what you mean by that, here we are converting a number to a String, while in the NumberConverter we are converting a String to a number.

@aleksandr-m
Copy link
Contributor

What point to convert number to string according to locale if it is cannot be converted back (e.g. on form submit).
Are we converting all instances of Number in number converter? If yes than it is ok.

@yasserzamani
Copy link
Member Author

@aleksandr-m , it was to fix WW-3171. And I think locale has not any problem to converting back from string, according to this PR's NumberConverterTest.

I think it's ok and just needs to expand max fraction digits which this PR does.

@lukaszlenart
Copy link
Member

All the converters are Locale-aware which means they will convert a String to a Number using user Locale and then they will be able convert the Number back to a String using the same Locale.

And as Yasser pointed out this is all about fraction digits ... what I like in your suggestion about using the .toString method is that it will resolve one problem which can be observed in the Select tag - when key is defined as a BigDecimal, current version of converter will add a .0 to it, with .toString there is no such problem.

@yasserzamani
Copy link
Member Author

Łukasz, if you like to not add a .0, deleting format.setMinimumFractionDigits(1); does this. I did it in my first commit but saw some test fails then reverted it back and thought it's needed.

@lukaszlenart
Copy link
Member

The .0 is not always needed, it would be good to compare it with tests before I had introduced the Locale aware converters.

@yasserzamani
Copy link
Member Author

The .0 is not always needed

How previous version detects when needed? I saw previous StringConverter and seems only can converts arrays and dates and returns null elsewhere !

@lukaszlenart
Copy link
Member

I meant check the tests :) The previous version was using .toString I think

@yasserzamani
Copy link
Member Author

yasserzamani commented Oct 26, 2017

Łukasz, I reviewed tests. It seems the root cause is a very old 13 years old issue, WW-455. I think not adding .0 will not cause any trouble with that issue any more. Maybe the old WebWork was adding .0 which is not rational I think.

I think we can fix Select-3.txt contents and rest tests in orther and delete format.setMinimumFractionDigits(1), then if this raised any issue, that issue should be fixed itself not by adding not meaningful .0 :) wdyt?

@lukaszlenart
Copy link
Member

This how it was before:

        // XW-409: If the input is a Number we should format it to a string using the choosen locale and use java's numberformatter
        if (Number.class.isAssignableFrom(toType)) {
            NumberFormat numFormat = NumberFormat.getInstance(getLocale(context));
            if (isIntegerType(toType)) {
                numFormat.setParseIntegerOnly(true);
            }
            numFormat.setGroupingUsed(true);
            numFormat.setMaximumFractionDigits(99); // to be sure we include all digits after decimal seperator, otherwise some of the fractions can be chopped

            String number = numFormat.format(value);
            if (number != null) {
                return number;
            }
        }

        return null; // no number

(XWorkBasicConverter)

@lukaszlenart
Copy link
Member

I have no idea why I did use the setMinimumFractionDigits(1) :\

@lukaszlenart
Copy link
Member

ouch! you are right, I have focused on BigDecimals and totally forgot about that, sorry :(

This makes me wonder why our tests didn't catch this.

@yasserzamani
Copy link
Member Author

@aleksandr-m , this is a known java floating point behavior because these types use a binary mantissa, they cannot precisely represent many finite decimal numbers, such as 0.1, because these numbers have repeating binary representations.

So, IMHO, I don't see any bad thing to display 100234.398 in jsp because it's the real value of fff which java will use in any computation.

When precise computation is necessary, such as when performing currency calculations, user should not use floating-point types. Instead, an alternative representation that can completely represent the necessary values should be used, e.g. BigDecimal.

Are you sure .toString() always has your mentioned behavior? e.g. for 0.1f?

@aleksandr-m
Copy link
Contributor

@yasserzamani Don't think about computation, think about presentation layer.

100234.398 in jsp because it's the real value of fff which java will use in any computation.

No. If you change formatter settings it will be 100234.3984375 or something else.

Are you sure .toString() always has your mentioned behavior? e.g. for 0.1f?

Why not? It is simple to string.

@yasserzamani
Copy link
Member Author

Thanks; I saw Float.toString docs, you're right, it's more suitable for presentation layer as will generally produce the shortest decimal representation that can unambiguously identify the true value of the floating-point number! NumberFormat.format seems create string from the actual real value till max fraction digits.

Maybe we can .toString, count it's result fraction digits, setMaxFractionDigits to that then format?

@yasserzamani
Copy link
Member Author

This makes me wonder why our tests didn't catch this.

Łukasz, I found some time to go deeper and saw that this happens when value cannot be represented exactly with a finite binary number. Failing test examples are added at end.

After all, contrary to my expectation, .toString has better behavior than Locale.NumberFormat.format ! It produces the shortest decimal representation that can unambiguously identify the true value of the floating-point number, but that, produces the true value as is !

Following tests fails:

    public void testDoubleToStringConversionPL() throws Exception {
        // given
        StringConverter converter = new StringConverter();
        Map<String, Object> context = new HashMap<>();
        context.put(ActionContext.LOCALE, new Locale("pl", "PL"));

        // when cannot be represented exactly with a finite binary number
        value = converter.convertValue(context, null, null, null, 0.1 + 0.1 + 0.1, null);

        // then produce the shortest decimal representation that can unambiguously identify the true value of the floating-point number
        assertEquals("0,3", value);
    }

    public void testFloatToStringConversionPL() throws Exception {
        // given
        StringConverter converter = new StringConverter();
        Map<String, Object> context = new HashMap<>();
        context.put(ActionContext.LOCALE, new Locale("pl", "PL"));

        // when cannot be represented exactly with a finite binary number
        value = converter.convertValue(context, null, null, null, 0.1f, null);

        // then produce the shortest decimal representation that can unambiguously identify the true value of the floating-point number
        assertEquals("0,1", value);
    }

@lukaszlenart
Copy link
Member

I think @aleksandr-m is right and the 0.1+0.1+0.1 example fails but I would expect that is must fail:

junit.framework.AssertionFailedError: 
Expected :0,3
Actual   :0,30000000000000004

user would also be surprised if this wouldn't fail.

@yasserzamani
Copy link
Member Author

You're right; 0.1+0.1+0.1 also fails with .toString. However, 0.1f does not.

@aleksandr-m
Copy link
Contributor

The point is, if you print the final number into the console from the action and later show it in the JSP both should be the same (except decimal separator because we take numbers according the locale).

If you're doing some computation like 0.1+0.1+0.1 in the action and want to show it in JSP then it is expected that you need to somehow beautify the numbers.

@yasserzamani
Copy link
Member Author

@aleksandr-m , @lukaszlenart , while decimal 0.1 cannot be represented exactly with a finite binary number, but this PR formats 0.1d (double) to 0,1 but formats 0.1f (float) to 0,10000000149011612. As for both the max fraction digits is Integer.MAX_VALUE and as both java float and double types follow IEEE 754, I cannot understand this different behavior.

wdyt? It seems we have the issue only with float data type.

@yasserzamani
Copy link
Member Author

Eureka! I reviewed java.text.NumberFormat.format:254 :

        } else if (number instanceof Number) {
            return format(((Number)number).doubleValue(), toAppendTo, pos);
        } 

((Number)number).doubleValue() causes the issue. I fixed that in above commit.

@aleksandr-m
Copy link
Contributor

@yasserzamani Looks like a hack. ;) Why not to use code provided by Lukasz #173 (comment) ?

@yasserzamani
Copy link
Member Author

Yes I see :) please let me leave it as is until I discuss this issue with jdk team. java.text.NumberFormat.format should not have different behavior against 0.1f and 0.1d, I think. For 0.1d it produces 0,1 but for 0.1f produces 0,10000000149011612 while both double and float are IEEE 754 ❗️ I'm almost sure this should be a bug.

Łukasz code is ok but please let me keep this hack until jdk's fix as we do not have this issue with other Numbers just for float which is jdk's failure, I think :)

@lukaszlenart
Copy link
Member

I don't like fixing JDK's bugs inside the framework, it's hard to track if the bug was fixed and we supposed to drop the workaround or even make it deprecated and allow users to switch to a new solution. If you decide keep the current solution, please add TODO or FIXME to document this workaround.

That being said, I would like to have this fixed and start preparing a new release.

@yasserzamani
Copy link
Member Author

Yes I see, I just was in wait for a reference jdk url to being added to the TODO (I reported at http://bugreport.java.com/ and they wrote they will list this in their database or contact me for more info if was needed).

However, I added the TODO; I'll add a reference url from jdk when was ready :) thanks!

format.setMaximumFractionDigits(Integer.MAX_VALUE);
// TODO: delete this if statement when jdk fixed java.text.NumberFormat.format's behavior with Float
if (Float.class.isInstance(value)) {
value = Double.valueOf(value.toString());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have just one doubt: re-assigning to an input parameter

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:) you're right! I added a test in a commit which confirms that StringConverter does not change input parameter value.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant that this isn't a problem from compiler point-of-view as parameters are always local (compiler makes a copy of them), it's rather a bad style :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh :) I thought you are in doubt because we pass the value parameter by type Object.

Thanks for learning this to me! I researched and it seems there are two best practices ; using a defined local variable, or returning from method at that line. what do you recommend?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use a dedicated local variable in this case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! I think I fixed it in my another commit ; I'm sorry for these a lot of commits :/

@lukaszlenart
Copy link
Member

@aleksandr-m I'm down with these changes and this PR can be merged IMO

@aleksandr-m
Copy link
Contributor

@lukaszlenart Better than current state. +1 for merging.

@yasserzamani
Copy link
Member Author

"a double value is able to hold this value precisely", they said after seven days in a comment and simply closed the issue 😮 The issue is visible on bugs.java.com at the following url JDK-8190928.

I replied back that if you believe a double value is able to hold 0.1d precisely, then please try:

System.out.println(new java.math.BigDecimal(0.1d));
System.out.println(0.1d + 0.1d + 0.1d);

The output is:

0.1000000000000000055511151231257827021181583404541015625
0.30000000000000004

IT IS NOT:

0.1
0.3

so a double value also is not able to hold 0.1d precisely, i think.

I hope they will reply. I know they're advanced experts but I cannot understand what is the wrong with my understanding :( I hope they will help.

@lukaszlenart
Copy link
Member

The linke explains everything ... but you must have at least PhD to understand that fully ;-)
https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html

@yasserzamani
Copy link
Member Author

yeah 😃 however all my current below evidences show this should be a bug:

  • Why java.text.NumberFormat.format(Float.MIN_VALUE) does not represent Float.MIN_VALUE correctly? it contains wastes digits coming from float -> double conversion ❗️
  • I searched 0.1 in above link and it does not distinguish between single or double precision for 0.1. It always says 0.1 cannot be represented exactly in binary in floating-point formats. It does not distinguish double for this rule.
  • Anyway, if java has implemented double in such a way which can stores 0.1 exactly, so, why System.out.println(0.1d + 0.1d + 0.1d); does not print 0.3 in java.

@lukaszlenart
Copy link
Member

LGTM 👍

@lukaszlenart lukaszlenart merged commit f5f53a9 into apache:master Nov 9, 2017
@lukaszlenart
Copy link
Member

The most discussed PR ever!

@yasserzamani yasserzamani deleted the WW-4871 branch November 9, 2017 18:22
@yasserzamani
Copy link
Member Author

yeah! I should thank Aleksandr and you for your time to review. In the end it became very better than my beginnings commits.

@gregh3269
Copy link
Contributor

As a workaround, you can do to restore to original output: ie 1.0 rather than just 1

format.double={0,number,##0.0}
<s:textfield name="bean.field" value="%{getText('format.double',{bean.field})}" />

Cheers Greg

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.

4 participants