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

Ensure there is no precision loss on Number conversion #1692

Merged
merged 1 commit into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,20 @@

public class NumberUtil {

/**
* Minimal Long that can be safely in double (without precision loss)
*
* Because Double precision floating point format only has 52 bits to represent the mantissa
*/
private static final long MIN_SAFE_DOUBLE_INTEGER = -(1L << 53) + 1;

/**
* Maximum Long that can be safely in double (without precision loss)
*
* Because Double precision floating point format only has 52 bits to represent the mantissa
*/
private static final long MAX_SAFE_DOUBLE_INTEGER = (1L << 53) - 1;

/**
* Convert the given number to long without loss allowing Floating-point number conversion
*
Expand All @@ -46,15 +60,19 @@ public static Long numberToLong(Number number) throws IllegalArgumentException {
*/
public static Long numberToLong(Number number, boolean permissiveNumberConversion) throws IllegalArgumentException {
// handle INTEGER
if (number instanceof Byte || number instanceof Short || number instanceof Integer || number instanceof Long) {
if (number instanceof Long) {
return (Long) number;
}
if (number instanceof Byte || number instanceof Short || number instanceof Integer) {
return number.longValue();
}
if (number instanceof BigInteger) {
// check big integer is not too big for a long
BigInteger bigInt = (BigInteger) number;
if (bigInt.compareTo(BigInteger.valueOf(Long.MIN_VALUE)) < 0
|| bigInt.compareTo(BigInteger.valueOf(Long.MAX_VALUE)) > 0) {
throw new IllegalArgumentException(String.format("%s : can not be store in a long", bigInt));
throw new IllegalArgumentException(
String.format("BigInteger %s : can not be store in a long", bigInt));
}
return bigInt.longValue();
}
Expand All @@ -71,7 +89,8 @@ public static Long numberToLong(Number number, boolean permissiveNumberConversio
try {
return bigDec.longValueExact();
} catch (ArithmeticException e) {
throw new IllegalArgumentException(String.format("%s : can not be store in a long", bigDec));
throw new IllegalArgumentException(
String.format("BigDecimal %s : can not be store in a long", bigDec));
}
}
}
Expand All @@ -81,7 +100,7 @@ public static Long numberToLong(Number number, boolean permissiveNumberConversio
long longValue = number.longValue();
// if long value is negative this means that this is a too long unsigned long
if (longValue < 0) {
throw new IllegalArgumentException(String.format("%s : can not be store in a long", number));
throw new IllegalArgumentException(String.format("ULong %s : can not be store in a long", number));
}
return longValue;
}
Expand Down Expand Up @@ -126,7 +145,8 @@ public static ULong numberToULong(Number number, boolean permissiveNumberConvers
// check big integer is not too big for a long
BigInteger bigInt = (BigInteger) number;
if (bigInt.signum() == -1 || bigInt.compareTo(ULong.MAX_VALUE) > 0) {
throw new IllegalArgumentException(String.format("%s : can not be store in an unsigned long", bigInt));
throw new IllegalArgumentException(
String.format("BigInteger %s : can not be store in an unsigned long", bigInt));
}
return ULong.valueOf(bigInt);
}
Expand All @@ -141,18 +161,19 @@ public static ULong numberToULong(Number number, boolean permissiveNumberConvers
}
if (bigDec != null) {
if (bigDec.signum() == -1) {
throw new IllegalArgumentException(
String.format("%s : can not be store in an unsigned long", bigDec));
throw new IllegalArgumentException(String
.format("BigDecimal %s : can not convert negative number to an unsigned long", bigDec));
} else {
try {
BigInteger bigInt = bigDec.toBigIntegerExact();
if (bigInt.compareTo(ULong.MAX_VALUE) > 0) {
throw new IllegalArgumentException(
String.format("%s : can not be store in an unsigned long", bigInt));
String.format("BigDecimal %s : can not be store in an unsigned long", bigInt));
}
return ULong.valueOf(bigInt);
} catch (ArithmeticException e) {
throw new IllegalArgumentException(String.format("%s : can not be store in a long", bigDec));
throw new IllegalArgumentException(
String.format("BigDecimal %s : can not be store in a long", bigDec));
}
}
}
Expand Down Expand Up @@ -185,12 +206,76 @@ public static EInteger unsignedLongToEInteger(long v) {
* @throws IllegalArgumentException if the number can not be store in a long.
*/
public static Double numberToDouble(Number number, boolean permissiveNumberConversion) {
if (permissiveNumberConversion)
return number.doubleValue();
if (permissiveNumberConversion) {
if (number instanceof Byte || number instanceof Short || number instanceof Integer) {
return number.doubleValue();
}
if (number instanceof Long) {
// check if long can be safely converted
if (MIN_SAFE_DOUBLE_INTEGER <= (Long) number && (Long) number <= MAX_SAFE_DOUBLE_INTEGER) {
// this is safe zone where all integer can be store without precision loss in double
return number.doubleValue();
} else {
// Convert long to double
double convertedDouble = number.doubleValue();

// Check if the double accurately represents the long
if ((long) convertedDouble != number.longValue()) {
throw new IllegalArgumentException(
String.format("Can not convert Long %s to double safely", number.toString()));
}
return convertedDouble;
}

}
if (number instanceof BigInteger) {
// check if big integer can be safely converted
BigInteger bigInt = (BigInteger) number;
if (bigInt.compareTo(BigInteger.valueOf(MIN_SAFE_DOUBLE_INTEGER)) >= 0
&& bigInt.compareTo(BigInteger.valueOf(MAX_SAFE_DOUBLE_INTEGER)) <= 0) {
// this is safe zone where all integer can be store without precision loss in double
return number.doubleValue();
} else {
// Convert BigInteger to double
double convertedDouble = number.doubleValue();

if (number instanceof Float || number instanceof Double || number instanceof BigDecimal)
// Convert the double back to BigInteger exact representation
try {
BigInteger reconstructedValue = new BigDecimal(convertedDouble).toBigIntegerExact();
if (!number.equals(reconstructedValue)) {
throw new IllegalArgumentException(
String.format("Can not convert BigInteger %s to double safely", bigInt));
}
} catch (ArithmeticException e) {
throw new IllegalArgumentException(
String.format("Can not convert BigInteger %s to double safely", number));
}
return convertedDouble;
}
}
}

if (number instanceof Double) {
return (Double) number;
}
if (number instanceof Float) {
return number.doubleValue();
}
if (number instanceof BigDecimal) {
// We can not really ensure that BigDecimal fit safely in a double.
// Even for very simple decimal number, the internal numeric value could differ.
// Eg. : 0.1 Bigdecimal value can not exactly fit in double but it will be encoded as
// 0.099999999999999...
// (see https://observablehq.com/@benaubin/floating-point)

// So the best we can do is to be sure big decimal is not out of range.
Double result = number.doubleValue();
if (result == Double.POSITIVE_INFINITY || result == Double.NEGATIVE_INFINITY) {
throw new IllegalArgumentException(
String.format("Can not convert Bigdecimal %s to double safely (out of range)", number));
}
return result;
}
throw new IllegalArgumentException(String.format("Floating-point number expected but was %s (%s)", number,
number.getClass().getCanonicalName()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package org.eclipse.leshan.core.datatype;

import static org.eclipse.leshan.core.util.datatype.NumberUtil.longToInt;
import static org.eclipse.leshan.core.util.datatype.NumberUtil.numberToDouble;
import static org.eclipse.leshan.core.util.datatype.NumberUtil.numberToLong;
import static org.eclipse.leshan.core.util.datatype.NumberUtil.numberToULong;
import static org.junit.jupiter.api.Assertions.assertEquals;
Expand Down Expand Up @@ -272,4 +273,77 @@ public void too_long_to_int() {
longToInt(2147483648l);
});
}

@Test
public void convert_number_to_double() {
assertEquals(-128d, numberToDouble(Byte.valueOf("-128"), true));
assertEquals(127d, numberToDouble(Byte.valueOf("127"), true));

assertEquals(-32768d, numberToDouble(Short.valueOf("-32768"), true));
assertEquals(32767d, numberToDouble(Short.valueOf("32767"), true));

assertEquals(-2147483648d, numberToDouble(Integer.valueOf("-2147483648"), true));
assertEquals(2147483647d, numberToDouble(Integer.valueOf("2147483647"), true));

// safe limit
assertEquals(-9007199254740991d, numberToDouble(Long.valueOf("-9007199254740991"), true));
assertEquals(9007199254740991d, numberToDouble(Long.valueOf("9007199254740991"), true));
// outside safe limit
// see for more details : https://observablehq.com/@benaubin/floating-point
assertEquals(9007199254740992d, numberToDouble(Long.valueOf("9007199254740992"), true));
assertEquals(9007199254740992d, numberToDouble(Long.valueOf("9007199254740992"), true));
assertEquals(9007199254740994d, numberToDouble(Long.valueOf("9007199254740994"), true));

// safe limit
assertEquals(-9007199254740991d, numberToDouble(new BigInteger("-9007199254740991"), true));
assertEquals(9007199254740991d, numberToDouble(new BigInteger("9007199254740991"), true));
// outside safe limit
// see for more details : https://observablehq.com/@benaubin/floating-point
assertEquals(-9007199254740992d, numberToDouble(new BigInteger("-9007199254740992"), true));
assertEquals(9007199254740992d, numberToDouble(new BigInteger("9007199254740992"), true));
assertEquals(9007199254740994d, numberToDouble(new BigInteger("9007199254740994"), true));

// floating point
assertEquals(-340282346638528859811704183484516925440d, numberToDouble(new Float(-Float.MAX_VALUE), true));
assertEquals(340282346638528859811704183484516925440d, numberToDouble(new Float(Float.MAX_VALUE), true));

assertEquals(
-179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d,
numberToDouble(new Double(-Double.MAX_VALUE), true));
assertEquals(
179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d,
numberToDouble(new Double(Double.MAX_VALUE), true));

assertEquals(
-179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d,
numberToDouble(new BigDecimal(-Double.MAX_VALUE), true));
assertEquals(
179769313486231570814527423731704356798070567525844996598917476803157260780028538760589558632766878171540458953514382464234321326889464182768467546703537516986049910576551282076245490090389328944075868508455133942304583236903222948165808559332123348274797826204144723168738177180919299881250404026184124858368d,
numberToDouble(new BigDecimal(Double.MAX_VALUE), true));
}

@Test
public void long_does_not_fit_in_for_double() {
assertThrowsExactly(IllegalArgumentException.class, () -> {
numberToDouble(Long.valueOf("9007199254740993"), true);
});
}

@Test
public void biginteger_does_not_fit_in_double() {
assertThrowsExactly(IllegalArgumentException.class, () -> {
numberToDouble(new BigInteger("-9007199254740993"), true);
});
}

@Test
public void bigdecimal_does_not_fit_in_for_double() {
assertThrowsExactly(IllegalArgumentException.class, () -> {
numberToDouble(new BigDecimal(Double.MAX_VALUE).add(new BigDecimal(Double.MAX_VALUE)), true);
});

assertThrowsExactly(IllegalArgumentException.class, () -> {
numberToDouble(new BigDecimal(-Double.MAX_VALUE).add(new BigDecimal(-Double.MAX_VALUE)), true);
});
}
}