3
\$\begingroup\$

Background

In many languages we have built-in data types for representing decimal floating point numbers. In .NET that's decimal, and in Java we have BigDecimal.

Now these are fundamentally different types. The .NET decimal is a fixed size value type, so its representable range and precision are limited (±1.0 × 10-28 to ±7.9228 × 1028).

On the other hand, the Java BigDecimal is arbitrary range and precision. Due to this it's not fixed size, but the length of its internal byte array depends on the size and precision of the represented number.

Due to this it's impossible to implement guaranteed lossless conversion between these two types (it's possible in the .NET→Java direction, but not the other way around).

What I want to implement is a "best effort" conversion, which throws an exception if the Java BigDecimal doesn't fit into a .NET decimal.

Requirements

The language is Java.
I have the following type, which models the internal representation of a .NET decimal.

public class DotNetDecimal {
    public final int mantissaLsb;

    public final long mantissaMsb;

    public final int exponentAndSign;

    public DotNetDecimal(int mantissaLsb, long mantissaMsb, int exponentAndSign) {
        this.mantissaLsb = mantissaLsb;
        this.mantissaMsb = mantissaMsb;
        this.exponentAndSign = exponentAndSign;
    }
}

This is based on a suggestion for a Protobuf representation (which is also the motivation for this whole conversion story) described here.

What I'd like to have is a class with the following signature.

public class DecimalHelper {
    public static BigDecimal fromDotNetDecimal(DotNetDecimal value) {
        ...
    }

    public static DotNetDecimal toDotNetDecimal(BigDecimal value) throws Exception {
        ...
    }
}

And the requirements are the following.

  • fromDotNetDecimal should convert to a BigDecimal, which is always possible, and it's implementation is relatively straightforward.
  • toDotNetDecimal is trickier, we have the following cases:
    • If value can be exactly represented as a .NET decimal, then convert it.
    • If value is in the range of what a .NET decimal can represent, but its fractional part has more digits (too high precision), then convert it, and round it so that it fits into a .NET decimal. This is where we lose precision.
    • If value is out of the range of what a .NET decimal can represent, then all bets are off, throw an exception.

Implementation

This is my implementation, with a fair amount of comments.

public class DecimalHelper {
    public static final BigDecimal minValue;
    public static final BigDecimal maxValue;

    static {
        minValue = new BigDecimal(BigInteger.valueOf(0xffffffffL).shiftLeft(32).add(BigInteger.valueOf(0xffffffffL))
                .shiftLeft(32).add(BigInteger.valueOf(0xffffffffL)).negate());
        maxValue = new BigDecimal(BigInteger.valueOf(0xffffffffL).shiftLeft(32).add(BigInteger.valueOf(0xffffffffL))
                .shiftLeft(32).add(BigInteger.valueOf(0xffffffffL)));
    }

    public static BigDecimal fromDotNetDecimal(DotNetDecimal value) {
        long mantissaMsbUpper = value.mantissaMsb >>> 32;
        long mantissaMsbLower = value.mantissaMsb & 0xffffffffL;
        BigInteger integer = BigInteger.valueOf(mantissaMsbUpper).shiftLeft(32)
                .add(BigInteger.valueOf(mantissaMsbLower)).shiftLeft(32)
                .add(BigInteger.valueOf(value.mantissaLsb & 0xffffffffL));

        BigDecimal decimal = new BigDecimal(integer, (value.exponentAndSign & 0xff0000) >> 16);
        if (value.exponentAndSign < 0) {
            decimal = decimal.negate();
        }

        return decimal;
    }

    public static DotNetDecimal toDotNetDecimal(BigDecimal value) throws Exception {
        if (value.compareTo(minValue) < 0) {
            throw new Exception("Input number is out of range.");
        }

        if (value.compareTo(maxValue) > 0) {
            throw new Exception("Input number is out of range.");
        }

        // The largest representable number with DotNetDecimal has 29 decimal digits.
        if (value.precision() > 29) {
            // If the precision of the BigDecimal is more than 29, we have to decrease it to
            // 29, and do rounding. With this we're losing some precision, but this only
            // happens to either very large numbers, or numbers with a lot of fraction digits.
            // In this case it's certainly not a round number, otherwise the previous range
            // checks would've failed.
            // This call will remove (with rounding) as many digits from the fractional
            // parts as needed to make the total number of digits 29.
            value = value.setScale(value.scale() - (value.precision() - 29), BigDecimal.ROUND_HALF_UP);
        }

        // The representation of the mantissa in DotNetDecimal has 96 bits, so we cannot
        // represent more.
        if (value.unscaledValue().bitLength() > 96) {
            // Even if we're inside the range of the min and max DotNetDecimal, and we don't
            // have more than 29 decimal digits, it might be that the unscaled value is
            // still more than 96 bits, which can not be represented with a DotNetDecimal.
            // This is the case for a number like 9.9999999999999999999999999999 (exactly 29
            // digits).
            // In this case, we decrease the scale with one more.
            value = value.setScale(value.scale() - 1, BigDecimal.ROUND_HALF_EVEN);
        }

        // At this point we know that value is in the range of what DotNetDecimal can
        // represent, with the same precision, so the last thing to do is to transform
        // to the representation.
        boolean isNegative = value.compareTo(BigDecimal.valueOf(0)) < 0;
        if (value.scale() >= 0) {
            // In this case the unscaled value is the mantissa, and the scale and the sign
            // has to go into the exponent_and_sign.

            // For simplicity, we negate the number if it's negative.
            if (isNegative) {
                value = value.negate();
            }

            var bytes = value.unscaledValue().toByteArray();

            long msb = unscaledBytesToMsb(bytes);

            int lsb = unscaledBytesToLsb(bytes);

            int exponentAndSign = value.scale() << 16;

            if (isNegative) {
                // We have to set the first (MSB) bit to 1.
                exponentAndSign = exponentAndSign | 0x80000000;
            }

            return new DotNetDecimal(lsb, msb, exponentAndSign);
        } else {
            // In this case the unscaled value is not the real mantissa, we have to scale it
            // up, and the exponent will be zero.
            if (isNegative) {
                value = value.negate();
            }

            // We create a new BigInteger by multiplying the original number by ten to the
            // power of the negation of the scale.
            var scaledUp = value.unscaledValue().multiply(BigInteger.valueOf(10).pow(-value.scale()));

            var bytes = scaledUp.toByteArray();

            long msb = unscaledBytesToMsb(bytes);

            int lsb = unscaledBytesToLsb(bytes);

            // Since scale() is negative, we know it's a round number, so the exponent is 0.
            int exponentAndSign = 0;

            if (isNegative) {
                // We have to set the first (MSB) bit to 1.
                exponentAndSign = exponentAndSign | 0x80000000;
            }

            return new DotNetDecimal(lsb, msb, exponentAndSign);
        }
    }

    // NOTE: When we build the mantissa MSB and the LSB, before bitwise ORing the
    // byte to the accumulator in every iteration,
    // we need to interpret the byte as an unsigned number.
    // So instead of simply doing (msb | bytes[i]), we do (msb | ((long)bytes[i] & 0xFF))
    // Otherwise the byte would be automatically casted to a long, and interpreted
    // as a negative number, so all its higher bits would be set to 1.
    private static int unscaledBytesToLsb(byte[] bytes) {
        var bytesLength = bytes.length;

        // If the first byte in the array is 0, then it was included only for the sign
        // bit, we have to ignore it.
        int startIndex = bytes[0] == 0 ? 1 : 0;

        int lsb = 0;
        int j = 0;
        // Convert the LSB part:
        for (int i = bytesLength - 1; i >= startIndex && i >= bytesLength - 4; i--) {
            lsb = lsb | ((int) bytes[i] & 0xFF) << (j * 8);
            j++;
        }

        return lsb;
    }

    private static long unscaledBytesToMsb(byte[] bytes) {
        var bytesLength = bytes.length;

        // If the first byte in the array is 0, then it was included only for the sign
        // bit, we have to ignore it.
        int startIndex = bytes[0] == 0 ? 1 : 0;

        long msb = 0;
        int j = 0;
        // Convert the MSB part:
        for (int i = bytesLength - 5; i >= startIndex; i--) {
            msb = msb | ((long) bytes[i] & 0xFF) << (j * 8);
            j++;
        }

        return msb;
    }
}

(The fromDotNetDecimal implementation is based on this answer.)

I'm pretty confident the implementation is mostly correct, because I tested it with a wide range of inputs. I know the code is probably rather naive in terms of optimizations, but I'm not worried about perf too much.

What I'd be interested in is any suggestions/mistakes you can see in terms of the correctness of the code, if you can think of any edge case that's not correctly covered.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.