เหตุใดการแปลงแบบไปกลับผ่านสตริงจึงไม่ปลอดภัยสำหรับการดับเบิล


185

เมื่อเร็ว ๆ นี้ฉันต้องเรียงลำดับคู่เป็นข้อความแล้วจึงนำกลับมาใช้ใหม่ ดูเหมือนว่าค่าจะไม่เทียบเท่า:

double d1 = 0.84551240822557006;
string s = d1.ToString("R");
double d2 = double.Parse(s);
bool s1 = d1 == d2;
// -> s1 is False

แต่ตามMSDN: สตริงรูปแบบตัวเลขมาตรฐานตัวเลือก "R" ควรจะรับประกันความปลอดภัยแบบไปกลับ

ตัวระบุรูปแบบ round-trip ("R") ถูกใช้เพื่อให้แน่ใจว่าค่าตัวเลขที่ถูกแปลงเป็นสตริงจะถูกวิเคราะห์คำกลับเป็นค่าตัวเลขเดียวกัน

ทำไมสิ่งนี้ถึงเกิดขึ้น


6
ฉันบั๊กใน VS ของฉันและการกลับมาของจริงที่นี่
Neel

19
ฉันทำซ้ำมันกลับเท็จ คำถามที่น่าสนใจมาก
Jon Skeet

40
.net 4.0 x86 - จริง, .net 4.0 x64 - false
Ulugbek Umirov

25
ขอแสดงความยินดีกับการค้นหาข้อผิดพลาดที่น่าประทับใจใน. net
Aron

14
@Casperah การเดินทางไปกลับมีความหมายเฉพาะเพื่อหลีกเลี่ยงความไม่สอดคล้องจุดลอยตัว
Gusdor

คำตอบ:


178

ฉันพบข้อผิดพลาด

.NET ทำสิ่งต่อไปนี้ใน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ค่อนข้างง่าย - เพียงแค่เรียก_ecvtซึ่งอยู่ใน 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;
    }
}

ปรากฎว่าผลตอบแทนสตริง_ecvt845512408225570

สังเกตเห็นศูนย์ต่อท้ายหรือไม่ ปรากฎว่าสร้างความแตกต่าง!
เมื่อมีศูนย์อยู่ผลลัพธ์จะแยกวิเคราะห์กลับเป็นจริง0.84551240822557006ซึ่งก็คือหมายเลขเดิมของคุณดังนั้นจึงทำการเปรียบเทียบให้เท่ากันและส่งกลับเพียง 15 หลัก

อย่างไรก็ตามหากฉันตัดทอนสตริงที่ศูนย์84551240822557นั้นฉันก็จะได้รับกลับ0.84551240822556994ซึ่งไม่ใช่หมายเลขเดิมของคุณและด้วยเหตุนี้มันจะคืนค่า 17 หลัก

หลักฐาน: เรียกใช้โค้ด 64- บิตต่อไปนี้ (ซึ่งส่วนใหญ่ฉันแยกจาก Microsoft Shared Source CLI 2.0) ในดีบักเกอร์ของคุณและตรวจสอบvในตอนท้ายของ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;
}

4
+1คำอธิบายที่ดี รหัสนี้มาจากshared-source-cli-2.0ใช่ไหม? นี่เป็นสิ่งเดียวที่ฉันคิดว่าพบ
Soner Gönül

10
ฉันต้องบอกว่ามันค่อนข้างน่าสงสาร สตริงที่มีความเท่าเทียมกันทางคณิตศาสตร์ (เช่นเดียวกับศูนย์ต่อท้ายหรือสมมติว่า 2.1e-1 เทียบกับ 0.21) ควรให้ผลลัพธ์ที่เหมือนกันเสมอและสตริงที่เรียงลำดับทางคณิตศาสตร์ควรให้ผลลัพธ์ที่สอดคล้องกับการสั่งซื้อ
gnasher729

4
@MrLister: ทำไมไม่ควร "2.1E-1 เท่ากับ 0.21 แบบนั้น"
user541686

9
@ gnasher729: ฉันค่อนข้างเห็นด้วยกับ "2.1e-1" และ "0.21" ... แต่สตริงที่มีศูนย์ต่อท้ายไม่เท่ากับหนึ่งโดยไม่มี - ในอดีตศูนย์เป็นตัวเลขที่สำคัญและเพิ่ม ความแม่นยำ
cHao

4
@cHao: เอ่อ ... มันเพิ่มความแม่นยำ แต่นั่นมีผลเฉพาะกับวิธีที่คุณตัดสินใจปัดเศษคำตอบสุดท้ายถ้า sigfigs สำคัญสำหรับคุณไม่ใช่คอมพิวเตอร์ควรคำนวณคำตอบสุดท้ายตั้งแต่แรก งานของคอมพิวเตอร์คือการคำนวณทุกอย่างด้วยความแม่นยำสูงสุดโดยไม่คำนึงถึงจำนวนการวัดจริง มันเป็นปัญหาของโปรแกรมเมอร์ถ้าเขาต้องการปัดเศษผลลัพธ์สุดท้าย
user541686

107

สำหรับฉันแล้วดูเหมือนว่านี่เป็นเพียงข้อบกพร่อง ความคาดหวังของคุณนั้นสมเหตุสมผล ฉันทำซ้ำโดยใช้. NET 4.5.1 (x64) โดยเรียกใช้แอปคอนโซลต่อไปนี้ซึ่งใช้DoubleConverterคลาสของฉัน DoubleConverter.ToExactStringแสดงค่าที่แน่นอนที่แสดงโดยdouble:

using System;

class Test
{
    static void Main()
    {
        double d1 = 0.84551240822557006;
        string s = d1.ToString("r");
        double d2 = double.Parse(s);
        Console.WriteLine(s);
        Console.WriteLine(DoubleConverter.ToExactString(d1));
        Console.WriteLine(DoubleConverter.ToExactString(d2));
        Console.WriteLine(d1 == d2);
    }
}

ผลลัพธ์ใน. NET:

0.84551240822557
0.845512408225570055719799711368978023529052734375
0.84551240822556994469749724885332398116588592529296875
False

ผลลัพธ์ใน Mono 3.3.0:

0.84551240822557006
0.845512408225570055719799711368978023529052734375
0.845512408225570055719799711368978023529052734375
True

หากคุณระบุสตริงจาก Mono ด้วยตนเอง (ซึ่งมี "006" อยู่ท้ายสุด). NET จะทำการวิเคราะห์คำนั้นกลับไปเป็นค่าดั้งเดิม ดูเหมือนว่าปัญหาอยู่ในการToString("R")จัดการมากกว่าการแยกวิเคราะห์

ดังที่ระบุไว้ในความคิดเห็นอื่น ๆ ดูเหมือนว่านี่เป็นลักษณะเฉพาะสำหรับการทำงานภายใต้ x64 CLR หากคุณรวบรวมและเรียกใช้การกำหนดเป้าหมายโค้ดด้านบน x86 มันก็ดี:

csc /platform:x86 Test.cs DoubleConverter.cs

... คุณได้รับผลลัพธ์เช่นเดียวกับโมโน เป็นที่น่าสนใจที่จะทราบว่าข้อผิดพลาดปรากฏขึ้นภายใต้ RyuJIT - ฉันไม่ได้ติดตั้งในขณะนี้เอง โดยเฉพาะอย่างยิ่งฉันสามารถจินตนาการว่านี่อาจเป็นข้อบกพร่องของ JIT หรือเป็นไปได้ว่ามีการใช้งานภายในที่แตกต่างกันโดยสิ้นเชิงdouble.ToStringตามสถาปัตยกรรม

ฉันขอแนะนำให้คุณส่งข้อผิดพลาดที่http://connect.microsoft.com


1
งั้นเหรอ? เพื่อยืนยันว่านี่เป็นข้อบกพร่องใน JITer ToString()หรือไม่? ในขณะที่ฉันพยายามแทนที่ค่าฮาร์ดโค้ดด้วยrand.NextDouble()และก็ไม่มีปัญหา
Aron

1
ใช่มันอยู่ในการToString("R")แปลงแน่นอน ลองToString("G32")และสังเกตว่ามันพิมพ์ค่าที่ถูกต้อง
user541686

1
@Aron: ฉันไม่สามารถบอกได้ว่ามันเป็นข้อผิดพลาดใน JITter หรือในการดำเนินการตาม BCL เฉพาะ x64 ฉันสงสัยอย่างมากว่ามันเรียบง่ายเหมือน inline การทดสอบด้วยค่าสุ่มไม่ได้ช่วยอะไรมากนัก IMO ... ฉันไม่แน่ใจว่าคุณคาดหวังอะไร
Jon Skeet

2
สิ่งที่เกิดขึ้นฉันคิดว่ารูปแบบ "การเดินทางไปกลับ" กำลังแสดงมูลค่าที่มากกว่า 0.498ulp ที่ควรจะเป็นและบางครั้งตรรกะในการแยกวิเคราะห์จึงผิดพลาดขึ้นรอบ ๆ เศษส่วนเล็ก ๆ อันสุดท้ายของ ulp ฉันไม่แน่ใจว่าโค้ดใดที่ฉันตำหนิมากกว่านี้เนื่องจากฉันคิดว่ารูปแบบ "การเดินทางไปกลับ" ควรส่งออกค่าตัวเลขซึ่งอยู่ภายในหนึ่งส่วนสี่ของการแก้ไขความถูกต้องเชิงตัวเลข การแยกวิเคราะห์ตรรกะซึ่งให้ค่าภายใน 0.75ulp ของสิ่งที่ระบุนั้นง่ายกว่าตรรกะซึ่งต้องให้ผลลัพธ์ภายใน 0.502ulp ของสิ่งที่ระบุ
supercat

1
เว็บไซต์ของ Jon Skeet ไม่ทำงาน? ฉันพบว่าไม่น่าเป็นไปได้ที่ฉันจะสูญเสียศรัทธาทั้งหมด
Patrick M

2

เมื่อเร็ว ๆ นี้ฉันพยายามที่จะแก้ไขปัญหานี้ ตามที่ปรากฏในโค้ด double.ToString ("R") จะมีตรรกะดังนี้:

  1. ลองแปลง double เป็น string อย่างแม่นยำ 15
  2. แปลงสตริงกลับเป็นสองเท่าและเปรียบเทียบกับคู่เดิม หากเป็นเหมือนกันเราจะส่งคืนสตริงที่แปลงแล้วซึ่งมีความแม่นยำเท่ากับ 15
  3. มิฉะนั้นให้แปลง double เป็น string ด้วยความแม่นยำ 17

ในกรณีนี้ double.ToString ("R") เลือกผลที่ผิดพลาดอย่างแม่นยำ 15 ดังนั้นข้อผิดพลาดจะเกิดขึ้น มีวิธีแก้ปัญหาอย่างเป็นทางการในเอกสาร MSDN:

ในบางกรณีค่า Double ที่จัดรูปแบบด้วยสตริงรูปแบบตัวเลขมาตรฐาน "R" จะไม่ประสบความสำเร็จในการไปกลับหากคอมไพล์โดยใช้ / platform: x64 หรือ / platform: anycpu switch และรันบนระบบ 64 บิต เมื่อต้องการแก้ไขปัญหานี้คุณสามารถจัดรูปแบบค่าสองเท่าโดยใช้สตริงรูปแบบตัวเลขมาตรฐาน "G17" ตัวอย่างต่อไปนี้ใช้สตริงรูปแบบ "R" ที่มีค่า Double ที่ไม่สามารถปัดเศษได้สำเร็จและยังใช้สตริงรูปแบบ "G17" เพื่อปัดเศษกลับไปเป็นค่าดั้งเดิมได้สำเร็จ

ดังนั้นหากปัญหานี้ได้รับการแก้ไขคุณต้องใช้ double.ToString ("G17") เพื่อทำการปัดเศษ

อัปเดต : ตอนนี้มีปัญหาเฉพาะเพื่อติดตามข้อผิดพลาดนี้

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.