The world of computing often relies on precise numerical representation, yet a common pitfall can lead to unexpected data loss and inaccuracies: round-trip conversion via a string is not safe for a double. While it might seem intuitive to convert a floating-point number to its string representation and then back again, especially for serialization or logging, this process introduces a significant risk of precision loss. Developers, data scientists, and anyone working with critical numerical data must understand why this seemingly innocent operation can compromise data integrity. This article delves into the underlying mechanisms of floating-point numbers, the intricacies of string conversion, and the tangible implications of such an unsafe practice, offering robust strategies to maintain numerical stability in your applications.
Understanding Floating-Point Numbers: The Root of the Issue
To truly grasp why a round-trip conversion via a string is not safe for a double, we must first understand how computers store and manipulate floating-point numbers. Unlike integers, which have exact binary representations, most decimal fractions do not. The IEEE 754 standard, which defines the format for floating-point numbers like double, allocates a fixed number of bits to represent these values. This finite storage means that many real numbers, particularly those with repeating or non-terminating binary representations, can only be approximated.
Consider the simple decimal fraction 0.1. In our base-10 system, it’s straightforward. However, when converted to binary, 0.1 becomes a repeating fraction: 0.0001100110011… This infinite sequence must be truncated to fit within the 64 bits allocated for a double. The consequence is that 0.1 is not stored as precisely 0.1 but as a very close approximation, such as 0.1000000000000000055511151231257827021181583404541015625. This inherent floating-point precision limitation is the fundamental reason why exact representations are often elusive in binary.
The IEEE 754 Standard and Its Limitations
The IEEE 754 standard specifies how floating-point numbers are encoded into a sign bit, an exponent, and a significand (also known as a mantissa). A double uses 64 bits: 1 for the sign, 11 for the exponent, and 52 for the significand. While 52 bits provide a substantial range for precision, they are not infinite. This fixed-size representation means there are gaps between representable numbers. Any number falling into these gaps must be rounded to the nearest representable value, introducing tiny, often invisible, rounding errors right from the moment a number is stored.
- Doubles use a fixed number of bits (64 bits) for representation.
- Many decimal numbers do not have an exact binary equivalent.
- The IEEE 754 standard dictates how approximations are handled.
- Small rounding errors are introduced upon initial storage.
The String Conversion Process: Where Precision is Lost
The core problem emerges when you convert a double to a string and then parse it back. When a double is converted to a string, the system often has to decide how many decimal places to include. If it prints too few digits, information about the original, slightly more precise binary approximation is lost. For instance, if a double holding the value 0.100000000000000005551115123… is converted to the string “0.1”, the extra precision is discarded. This truncation is a common side effect of creating a human-readable representation.
A round-trip conversion via a string is not safe for a double because the textual representation often truncates the number of significant digits, losing the subtle nuances of its original binary approximation. When this truncated string is then parsed back into a double, the new double value is generated based on the rounded string, not the original binary representation. This creates a new, potentially different, binary approximation. The result is that the original double value and the round-tripped double value are no longer bit-for-bit identical, even if they appear the same when printed.
Loss of Information During Serialization
This issue is particularly critical in scenarios involving data serialization, where numerical values are converted to strings (e.g., JSON, XML) for transmission or storage and then deserialized back into their original types. The intermediate string format acts as a bottleneck, forcing the precise binary representation of the double to conform to a decimal string, which may not capture all its significant digits. This is not just an academic concern; it has direct consequences for data integrity and system reliability.
- String conversion often truncates the internal precision of the
double. - Parsing the truncated string back creates a new, potentially different, binary approximation.
- The original and round-tripped values may differ at the binary level.
- This leads to a loss of data integrity.
Real-World Implications and Risks
Question & Answer :
Recently I have had to serialize a double into text, and then get it back. The value seems to not be equivalent:
double d1 = 0.84551240822557006; string s = d1.ToString("R"); double d2 = double.Parse(s); bool s1 = d1 == d2; // -> s1 is False
But according to MSDN: Standard Numeric Format Strings, the “R” option is supposed to guarantee round-trip safety.
The round-trip (“R”) format specifier is used to ensure that a numeric value that is converted to a string will be parsed back into the same numeric value
Why did this happen?
I found the bug.
.NET does the following in clr\src\vm\comnumber.cpp:
DoubleToNumber(value, DOUBLE_PRECISION, &number); if (number.scale == (int) SCALE_NAN) { gc.refRetVal = gc.numfmt->sNaN; goto lExit; } if (number.scale == SCALE_INF) { gc.refRetVal = (number.sign? gc.numfmt->sNegativeInfinity: gc.numfmt->sPositiveInfinity); goto lExit; } NumberToDouble(&number, &dTest); if (dTest == value) { gc.refRetVal = NumberToString(&number, 'G', DOUBLE_PRECISION, gc.numfmt); goto lExit; } DoubleToNumber(value, 17, &number);
DoubleToNumber is pretty simple – it just calls _ecvt, which is in the C runtime:
void DoubleToNumber(double value, int precision, NUMBER* number) { WRAPPER_CONTRACT _ASSERTE(number != NULL); number->precision = precision; if (((FPDOUBLE*)&value)->exp == 0x7FF) { number->scale = (((FPDOUBLE*)&value)->mantLo || ((FPDOUBLE*)&value)->mantHi) ? SCALE_NAN: SCALE_INF; number->sign = ((FPDOUBLE*)&value)->sign; number->digits[0] = 0; } else { char* src = _ecvt(value, precision, &number->scale, &number->sign); wchar* dst = number->digits; if (*src != '0') { while (*src) *dst++ = *src++; } *dst = 0; } }
It turns out that _ecvt returns the string 845512408225570.
Notice the trailing zero? It turns out that makes all the difference!
When the zero is present, the result actually parses back to 0.84551240822557006, which is your original number – so it compares equal, and hence only 15 digits are returned.
However, if I truncate the string at that zero to 84551240822557, then I get back 0.84551240822556994, which is not your original number, and hence it would return 17 digits.
Proof: run the following 64-bit code (most of which I extracted from the Microsoft Shared Source CLI 2.0) in your debugger and examine v at the end of main:
#include <stdlib.h> #include <string.h> #include <math.h> #define min(a, b) (((a) < (b)) ? (a) : (b)) struct NUMBER { int precision; int scale; int sign; wchar_t digits[20 + 1]; NUMBER() : precision(0), scale(0), sign(0) {} }; #define I64(x) x##LL static const unsigned long long rgval64Power10[] = { // powers of 10 /*1*/ I64(0xa000000000000000), /*2*/ I64(0xc800000000000000), /*3*/ I64(0xfa00000000000000), /*4*/ I64(0x9c40000000000000), /*5*/ I64(0xc350000000000000), /*6*/ I64(0xf424000000000000), /*7*/ I64(0x9896800000000000), /*8*/ I64(0xbebc200000000000), /*9*/ I64(0xee6b280000000000), /*10*/ I64(0x9502f90000000000), /*11*/ I64(0xba43b74000000000), /*12*/ I64(0xe8d4a51000000000), /*13*/ I64(0x9184e72a00000000), /*14*/ I64(0xb5e620f480000000), /*15*/ I64(0xe35fa931a0000000), // powers of 0.1 /*1*/ I64(0xcccccccccccccccd), /*2*/ I64(0xa3d70a3d70a3d70b), /*3*/ I64(0x83126e978d4fdf3c), /*4*/ I64(0xd1b71758e219652e), /*5*/ I64(0xa7c5ac471b478425), /*6*/ I64(0x8637bd05af6c69b7), /*7*/ I64(0xd6bf94d5e57a42be), /*8*/ I64(0xabcc77118461ceff), /*9*/ I64(0x89705f4136b4a599), /*10*/ I64(0xdbe6fecebdedd5c2), /*11*/ I64(0xafebff0bcb24ab02), /*12*/ I64(0x8cbccc096f5088cf), /*13*/ I64(0xe12e13424bb40e18), /*14*/ I64(0xb424dc35095cd813), /*15*/ I64(0x901d7cf73ab0acdc), }; static const signed char rgexp64Power10[] = { // exponents for both powers of 10 and 0.1 /*1*/ 4, /*2*/ 7, /*3*/ 10, /*4*/ 14, /*5*/ 17, /*6*/ 20, /*7*/ 24, /*8*/ 27, /*9*/ 30, /*10*/ 34, /*11*/ 37, /*12*/ 40, /*13*/ 44, /*14*/ 47, /*15*/ 50, }; static const unsigned long long rgval64Power10By16[] = { // powers of 10^16 /*1*/ I64(0x8e1bc9bf04000000), /*2*/ I64(0x9dc5ada82b70b59e), /*3*/ I64(0xaf298d050e4395d6), /*4*/ I64(0xc2781f49ffcfa6d4), /*5*/ I64(0xd7e77a8f87daf7fa), /*6*/ I64(0xefb3ab16c59b14a0), /*7*/ I64(0x850fadc09923329c), /*8*/ I64(0x93ba47c980e98cde), /*9*/ I64(0xa402b9c5a8d3a6e6), /*10*/ I64(0xb616a12b7fe617a8), /*11*/ I64(0xca28a291859bbf90), /*12*/ I64(0xe070f78d39275566), /*13*/ I64(0xf92e0c3537826140), /*14*/ I64(0x8a5296ffe33cc92c), /*15*/ I64(0x9991a6f3d6bf1762), /*16*/ I64(0xaa7eebfb9df9de8a), /*17*/ I64(0xbd49d14aa79dbc7e), /*18*/ I64(0xd226fc195c6a2f88), /*19*/ I64(0xe950df20247c83f8), /*20*/ I64(0x81842f29f2cce373), /*21*/ I64(0x8fcac257558ee4e2), // powers of 0.1^16 /*1*/ I64(0xe69594bec44de160), /*2*/ I64(0xcfb11ead453994c3), /*3*/ I64(0xbb127c53b17ec165), /*4*/ I64(0xa87fea27a539e9b3), /*5*/ I64(0x97c560ba6b0919b5), /*6*/ I64(0x88b402f7fd7553ab), /*7*/ I64(0xf64335bcf065d3a0), /*8*/ I64(0xddd0467c64bce4c4), /*9*/ I64(0xc7caba6e7c5382ed), /*10*/ I64(0xb3f4e093db73a0b7), /*11*/ I64(0xa21727db38cb0053), /*12*/ I64(0x91ff83775423cc29), /*13*/ I64(0x8380dea93da4bc82), /*14*/ I64(0xece53cec4a314f00), /*15*/ I64(0xd5605fcdcf32e217), /*16*/ I64(0xc0314325637a1978), /*17*/ I64(0xad1c8eab5ee43ba2), /*18*/ I64(0x9becce62836ac5b0), /*19*/ I64(0x8c71dcd9ba0b495c), /*20*/ I64(0xfd00b89747823938), /*21*/ I64(0xe3e27a444d8d991a), }; static const signed short rgexp64Power10By16[] = { // exponents for both powers of 10^16 and 0.1^16 /*1*/ 54, /*2*/ 107, /*3*/ 160, /*4*/ 213, /*5*/ 266, /*6*/ 319, /*7*/ 373, /*8*/ 426, /*9*/ 479, /*10*/ 532, /*11*/ 585, /*12*/ 638, /*13*/ 691, /*14*/ 745, /*15*/ 798, /*16*/ 851, /*17*/ 904, /*18*/ 957, /*19*/ 1010, /*20*/ 1064, /*21*/ 1117, }; static unsigned DigitsToInt(wchar_t* p, int count) { wchar_t* end = p + count; unsigned res = *p - '0'; for ( p = p + 1; p < end; p++) { res = 10 * res + *p - '0'; } return res; } #define Mul32x32To64(a, b) ((unsigned long long)((unsigned long)(a)) * (unsigned long long)((unsigned long)(b))) static unsigned long long Mul64Lossy(unsigned long long a, unsigned long long b, int* pexp) { // it's ok to losse some precision here - Mul64 will be called // at most twice during the conversion, so the error won't propagate // to any of the 53 significant bits of the result unsigned long long val = Mul32x32To64(a >> 32, b >> 32) + (Mul32x32To64(a >> 32, b) >> 32) + (Mul32x32To64(a, b >> 32) >> 32); // normalize if ((val & I64(0x8000000000000000)) == 0) { val <<= 1; *pexp -= 1; } return val; } void NumberToDouble(NUMBER* number, double* value) { unsigned long long val; int exp; wchar_t* src = number->digits; int remaining; int total; int count; int scale; int absscale; int index; total = (int)wcslen(src); remaining = total; // skip the leading zeros while (*src == '0') { remaining--; src++; } if (remaining == 0) { *value = 0; goto done; } count = min(remaining, 9); remaining -= count; val = DigitsToInt(src, count); if (remaining > 0) { count = min(remaining, 9); remaining -= count; // get the denormalized power of 10 unsigned long mult = (unsigned long)(rgval64Power10[count-1] >> (64 - rgexp64Power10[count-1])); val = Mul32x32To64(val, mult) + DigitsToInt(src+9, count); } scale = number->scale - (total - remaining); absscale = abs(scale); if (absscale >= 22 * 16) { // overflow / underflow *(unsigned long long*)value = (scale > 0) ? I64(0x7FF0000000000000) : 0; goto done; } exp = 64; // normalize the mantisa if ((val & I64(0xFFFFFFFF00000000)) == 0) { val <<= 32; exp -= 32; } if ((val & I64(0xFFFF000000000000)) == 0) { val <<= 16; exp -= 16; } if ((val & I64(0xFF00000000000000)) == 0) { val <<= 8; exp -= 8; } if ((val & I64(0xF000000000000000)) == 0) { val <<= 4; exp -= 4; } if ((val & I64(0xC000000000000000)) == 0) { val <<= 2; exp -= 2; } if ((val & I64(0x8000000000000000)) == 0) { val <<= 1; exp -= 1; } index = absscale & 15; if (index) { int multexp = rgexp64Power10[index-1]; // the exponents are shared between the inverted and regular table exp += (scale < 0) ? (-multexp + 1) : multexp; unsigned long long multval = rgval64Power10[index + ((scale < 0) ? 15 : 0) - 1]; val = Mul64Lossy(val, multval, &exp); } index = absscale >> 4; if (index) { int multexp = rgexp64Power10By16[index-1]; // the exponents are shared between the inverted and regular table exp += (scale < 0) ? (-multexp + 1) : multexp; unsigned long long multval = rgval64Power10By16[index + ((scale < 0) ? 21 : 0) - 1]; val = Mul64Lossy(val, multval, &exp); } // round & scale down if ((unsigned long)val & (1 << 10)) { // IEEE round to even unsigned long long tmp = val + ((1 << 10) - 1) + (((unsigned long)val >> 11) & 1); if (tmp < val) { // overflow tmp = (tmp >> 1) | I64(0x8000000000000000); exp += 1; } val = tmp; } val >>= 11; exp += 0x3FE; if (exp <= 0) { if (exp <= -52) { // underflow val = 0; } else { // denormalized val >>= (-exp+1); } } else if (exp >= 0x7FF) { // overflow val = I64(0x7FF0000000000000); } else { val = ((unsigned long long)exp << 52) + (val & I64(0x000FFFFFFFFFFFFF)); } *(unsigned long long*)value = val; done: if (number->sign) *(unsigned long long*)value |= I64(0x8000000000000000); } int main() { NUMBER number; number.precision = 15; double v = 0.84551240822557006; char *src = _ecvt(v, number.precision, &number.scale, &number.sign); int truncate = 0; // change to 1 if you want to truncate if (truncate) { while (*src && src[strlen(src) - 1] == '0') { src[strlen(src) - 1] = 0; } } wchar_t* dst = number.digits; if (*src != '0') { while (*src) *dst++ = *src++; } *dst++ = 0; NumberToDouble(&number, &v); return 0; }