Neden bir dize ile gidiş-dönüş dönüşümü bir çift için güvenli değildir?


185

Son zamanlarda bir çift metni metne dizmek ve sonra geri almak zorunda kaldı. Değer eşdeğer görünmüyor:

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

Ancak MSDN: Standart Sayısal Biçim Dizeleri'ne göre, "R" seçeneğinin gidiş-dönüş güvenliğini garanti etmesi bekleniyor.

Gidiş dönüş ("R") biçimi belirteci, bir dizeye dönüştürülen sayısal değerin aynı sayısal değere ayrıştırılmasını sağlamak için kullanılır

Bu neden oldu?


6
VS'mde hata ayıkladım ve burada geri döndü
Neel

19
Yanlış döndürerek çoğalttım. Çok ilginç bir soru.
Jon Skeet

40
.net 4.0 x86 - doğru, .net 4.0 x64 - yanlış
Ulugbek Umirov

25
.Net'te böyle etkileyici bir hata bulduğunuz için tebrikler.
Aron

14
@Casperah Gidiş-dönüş özellikle kayan nokta tutarsızlıklarından kaçınmak içindir
Gusdor

Yanıtlar:


178

Hatayı buldum.

.NET aşağıdakileri yapar 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);

DoubleToNumberoldukça basit - sadece _ecvtC çalışma zamanında olan çağırır :

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;
    }
}

Dizeyi _ecvtdöndüren çıkıyor 845512408225570.

Sondaki sıfırı fark ettiniz mi? Tüm farkı yaratan ortaya çıkıyor!
Sıfır mevcut olduğunda, sonuç aslında orijinal numaranız0.84551240822557006olandeğerinegeri ayrılır- böylece eşittir ve bu nedenle yalnızca 15 basamak döndürülür.

Bununla birlikte, dizeyi sıfırda kesersem 84551240822557, o zaman geri dönerim 0.84551240822556994, bu orijinal numaranız değildir ve bu nedenle 17 basamak döndürür.

İspat: hata ayıklayıcınızda aşağıdaki 64 bit kodu (çoğu Microsoft Paylaşılan Kaynak CLI 2.0'dan çıkardığım) çalıştırın vve sonunda inceleyin 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
İyi açıklama +1. Bu kod paylaşılan kaynak-cli-2.0 değil mi? Bulduğum tek düşünce bu.
Soner Gönül

10
Bunun oldukça acıklı olduğunu söylemeliyim. Matematiksel olarak eşit olan dizeler (sondaki sıfıra sahip olan veya 2.1e-1'e karşı 0.21 diyelim) her zaman aynı sonuçları vermeli ve matematiksel olarak sıralanan dizeler sıralamayla tutarlı sonuçlar vermelidir.
gnasher729

4
@MrLister: Neden "2.1E-1 bu şekilde 0.21 ile aynı olmamalı?"
user541686

9
@ gnasher729: "2.1e-1" ve "0.21" üzerine biraz katılıyorum ... ama sondaki sıfırı olan bir dize tam olarak bire eşit değil - ilkinde sıfır önemli bir basamaktır ve ekler hassas.
cHao

4
@cHao: Ee ... hassasiyet katar, ancak bu sadece sigfigs sizin için önemliyse son cevabı nasıl yuvarlayacağınıza etki eder, bilgisayarın ilk cevabı nasıl hesaplaması gerektiğini değil. Bilgisayarın işi, sayıların gerçek ölçüm hassasiyetlerinden bağımsız olarak her şeyi en yüksek hassasiyetle hesaplamaktır ; nihai sonucu yuvarlamak istiyorsa programcının sorunu budur.
user541686

107

Bana öyle geliyor ki bu sadece bir hata. Beklentileriniz tamamen makul. DoubleConverterSınıfımı kullanan aşağıdaki konsol uygulamasını çalıştırarak .NET 4.5.1 (x64) kullanarak çoğalttım . DoubleConverter.ToExactStringa ile gösterilen tam değeri gösterir 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 sonuçları:

0.84551240822557
0.845512408225570055719799711368978023529052734375
0.84551240822556994469749724885332398116588592529296875
False

Mono 3.3.0 sonuçları:

0.84551240822557006
0.845512408225570055719799711368978023529052734375
0.845512408225570055719799711368978023529052734375
True

Dizeyi Mono'dan (sonunda "006" yı içeren) el ile belirtirseniz, .NET bunu orijinal değerine ayrıştırır. Sorun ToString("R")ayrıştırma yerine işlemde gibi görünüyor .

Diğer yorumlarda belirtildiği gibi, bunun x64 CLR altında çalışmaya özgü olduğu görülüyor. Yukarıdaki kod hedefleme x86'yı derleyip çalıştırırsanız sorun olmaz:

csc /platform:x86 Test.cs DoubleConverter.cs

... Mono ile aynı sonuçları elde edersiniz. Hatanın RyuJIT altında görünüp görünmediğini bilmek ilginç olurdu - şu anda kendim yüklü değil. Özellikle, bunun muhtemelen bir JIT hatası olduğunu hayal edebiliyorum , ya da double.ToStringmimariye dayalı iç kısımların tamamen farklı uygulamaları olması oldukça mümkün .

Http://connect.microsoft.com adresinde bir hata bildirmenizi öneririm


1
Jon? Onaylamak için, bu JITer'de bir satır ToString()mı? Sabit kodlanmış değeri değiştirmeyi denediğimde rand.NextDouble()ve bir sorun yoktu.
Aron

1
Evet, kesinlikle ToString("R")dönüşümde. ToString("G32")Doğru değeri yazdırdığını fark etmeye çalışın .
user541686

1
@Aron: Bunun JITter'da mı yoksa BCL'nin x64'e özgü uygulamasında mı hata olduğunu söyleyemem. Yine de inlining kadar basit olduğundan şüphe ediyorum. Rastgele değerlerle test etmek pek yardımcı olmuyor, IMO ... Bunun gösterilmesini beklediğinizden emin değilim.
Jon Skeet

2
"Gidiş-dönüş" formatının olması gerekenden 0.498ulp daha büyük bir değer çıkardığını ve mantık ayrıştırmanın bazen yanlış bir şekilde ulp'nin son küçük kısmını yuvarladığını düşünüyorum. Hangi kod daha suçluyum emin değilim, çünkü bir "gidiş-dönüş" formatı sayısal olarak doğru çeyrek-ULP içinde sayısal bir değer çıktı gerektiğini düşünürdüm; Belirtilenin 0,75 katı içinde bir değer veren ayrıştırma mantığı, belirtilenin 0,502 katı içinde bir sonuç vermesi gereken mantığa göre çok daha kolaydır.
supercat

1
Jon Skeet'in web sitesi kapalı mı? Bu kadar olası görmüyorum ki ... tüm inancýmý kaybediyorum.
Patrick M

2

Son zamanlarda, bu sorunu çözmeye çalışıyorum . İşaret edildiği gibi kod boyunca , double.ToString ( "R"), mantık aşağıdadır;

  1. Çifte 15 değerini dize dönüştürmeye çalışın.
  2. Dizeyi tekrar ikiye dönüştürün ve orijinal çiftle karşılaştırın. Aynılarsa, hassasiyeti 15 olan dönüştürülmüş dizeyi döndürürüz.
  3. Aksi takdirde, çifti 17 hassasiyetinde dizeye dönüştürün.

Bu durumda, double.ToString ("R") sonucu 15 hassasiyetinde yanlış seçti, böylece hata oluştu. MSDN belgesinde resmi bir geçici çözüm vardır:

Bazı durumlarda, "platform" x64 veya / platform: anycpu anahtarları kullanılarak derlenir ve 64 bit sistemlerde çalıştırılırsa, "R" standart sayısal biçim dizesiyle biçimlendirilmiş Çift değerler başarılı bir şekilde geri dönmez. Bu soruna geçici bir çözüm bulmak için <a0> </a0>, "G17" standart sayısal biçim dizesini kullanarak çift değerleri biçimlendirebilirsiniz. Aşağıdaki örnek, başarılı bir şekilde gidiş-dönüş yapmayan bir Double değeri olan "R" biçiminde dizeyi kullanır ve orijinal değerde başarılı bir şekilde gidiş-dönüş yapmak için "G17" biçiminde dizeyi kullanır.

Bu nedenle, bu sorun çözülmedikçe, yuvarlak açma için double.ToString ("G17") kullanmanız gerekir.

Güncelleme : Şimdi bu hatayı izlemek için belirli bir sorun var .

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.