Question Pourquoi une conversion aller-retour via une chaîne n'est-elle pas sûre pour un double?


Récemment, j'ai dû sérialiser un double en texte, puis le récupérer. La valeur ne semble pas être équivalente:

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

Mais selon MSDN: chaînes de format numérique standard, l'option "R" est censée garantir la sécurité aller-retour.

Le spécificateur de format aller-retour ("R") est utilisé pour garantir qu'une valeur numérique convertie en chaîne sera analysée dans la même valeur numérique.

Pourquoi est-ce arrivé?


183
2018-06-19 05:58


origine


Réponses:


J'ai trouvé le bug.

.NET fait ce qui suit dans 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 est assez simple - il suffit d'appeler _ecvt, qui est dans le runtime C:

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

Il se trouve que _ecvt retourne la chaîne 845512408225570.

Notez le zéro final? Il s'avère que cela fait toute la différence!
Lorsque le zéro est présent, le résultat analyse 0.84551240822557006qui est votre original number - il est donc égal, et seulement 15 chiffres sont retournés.

Cependant, si je tronque la chaîne à ce zéro à 84551240822557, alors je reviens 0.84551240822556994, lequel est ne pas votre numéro d'origine, et par conséquent il retournerait 17 chiffres.

Preuve: exécutez le code 64 bits suivant (extrait pour la plupart de la CLI 2.0 de Microsoft Shared Source) dans votre débogueur et examinez v au bout du 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;
}

174
2018-06-19 06:50



Il me semble que c'est simplement un bug. Vos attentes sont entièrement raisonnables. Je l'ai reproduit en utilisant .NET 4.5.1 (x64), exécutant l'application de console suivante qui utilise mon DoubleConverter classe.DoubleConverter.ToExactString montre le exact valeur représentée par un 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);
    }
}

Résultats dans .NET:

0.84551240822557
0.845512408225570055719799711368978023529052734375
0.84551240822556994469749724885332398116588592529296875
False

Résultats en Mono 3.3.0:

0.84551240822557006
0.845512408225570055719799711368978023529052734375
0.845512408225570055719799711368978023529052734375
True

Si vous spécifiez manuellement la chaîne de Mono (qui contient le "006" à la fin), .NET l'analysera à la valeur d'origine. A cela ressemble le problème est dans le ToString("R") manipulation plutôt que l'analyse.

Comme indiqué dans d'autres commentaires, il semble que cela soit spécifique à l'exécution sous le CLR x64. Si vous compilez et exécutez le code ci-dessus ciblant x86, c'est bien:

csc /platform:x86 Test.cs DoubleConverter.cs

... vous obtenez les mêmes résultats qu'avec Mono. Il serait intéressant de savoir si le bogue apparaît sous RyuJIT - je ne l’ai pas installé pour le moment. En particulier, je peux imaginer cela peut-être être un bug JIT, ou il est tout à fait possible qu'il y ait des implémentations entières différentes des internes de double.ToString basé sur l'architecture.

Je vous suggère de déposer un bug à http://connect.microsoft.com


106
2018-06-19 06:09



Récemment, J'essaie de résoudre ce problème. Comme indiqué à travers le code , le double.ToString ("R") a la logique suivante:

  1. Essayez de convertir le double en chaîne avec une précision de 15.
  2. Convertissez la chaîne en double pour la comparer au double d'origine. Si elles sont identiques, nous retournons la chaîne convertie dont la précision est 15.
  3. Sinon, convertissez le double en chaîne avec une précision de 17.

Dans ce cas, double.ToString ("R") a choisi à tort le résultat avec une précision de 15, de sorte que le bogue se produit. Il existe une solution de contournement officielle dans le document MSDN:

Dans certains cas, les valeurs doubles formatées avec la chaîne de format numérique standard "R" ne réussissent pas à aller-retour si elles sont compilées à l'aide de / platform: x64 ou / platform: anycpu bascule sur des systèmes 64 bits. Pour contourner ce problème, vous pouvez mettre en forme des valeurs doubles à l'aide de la chaîne de format numérique standard "G17". L'exemple suivant utilise la chaîne de format "R" avec une valeur Double qui ne marche pas avec succès, et utilise également la chaîne de format "G17" pour arrondir avec succès la valeur d'origine.

Donc, à moins que ce problème ne soit résolu, vous devez utiliser double.ToString ("G17") pour le contournement.

Mettre à jour: Maintenant il y a un problème spécifique pour suivre ce bug.


2
2017-07-18 02:24



Wow - une question de 3 ans et tout le monde semble avoir manqué un point - même Jon Skeet! (@Jon: Respect. J'espère que je ne me ridiculise pas.)

Pour mémoire, j'ai exécuté l'échantillon de code et dans mon environnement (Win10 x64 AnyCPU Debug, cible .NetFx 4.7) le test après l'aller-retour a renvoyé true.

Voici une expérience Les chiffres sont alignés pour aider à faire le point ...

Ce code ...

  string Breakdown(double v)
  {
    var ret = new StringBuilder();
    foreach (byte b in BitConverter.GetBytes(v))
      ret.Append($"{b:X2} ");
    ret.Length--;
    return ret.ToString();
  }

  {
    var start = "0.99999999999999";
    var incr = 70;
    for (int i = 0; i < 10; i++)
    {
      var dblStr = start + incr.ToString();
      var dblVal = double.Parse(dblStr);
      Console.WriteLine($"{dblStr} : {dblVal:N16} : {Breakdown(dblVal)} : {dblVal:R}");
      incr++;
    }
  }

  Console.WriteLine();

  {
    var start = 0.999999999999997;
    var incr =  0.0000000000000001;
    var dblVal = start;
    for (int i = 0; i < 10; i++)
    {
      Console.WriteLine($"{i,-18} : {dblVal:N16} : {Breakdown(dblVal)} : {dblVal:R}");
      dblVal += incr;
    }
  }

Produit cette sortie (les astérisques *** ont été ajoutés par la suite) ...

    0.9999999999999970 : 0.9999999999999970 : E5 FF FF FF FF FF EF 3F : 0.999999999999997
    0.9999999999999971 : 0.9999999999999970 : E6 FF FF FF FF FF EF 3F : 0.99999999999999711
    0.9999999999999972 : 0.9999999999999970 : E7 FF FF FF FF FF EF 3F : 0.99999999999999722
    0.9999999999999973 : 0.9999999999999970 : E8 FF FF FF FF FF EF 3F : 0.99999999999999734
*** 0.9999999999999974 : 0.9999999999999970 : E9 FF FF FF FF FF EF 3F : 0.99999999999999745
*** 0.9999999999999975 : 0.9999999999999970 : E9 FF FF FF FF FF EF 3F : 0.99999999999999745
    0.9999999999999976 : 0.9999999999999980 : EA FF FF FF FF FF EF 3F : 0.99999999999999756
    0.9999999999999977 : 0.9999999999999980 : EB FF FF FF FF FF EF 3F : 0.99999999999999767
    0.9999999999999978 : 0.9999999999999980 : EC FF FF FF FF FF EF 3F : 0.99999999999999778
    0.9999999999999979 : 0.9999999999999980 : ED FF FF FF FF FF EF 3F : 0.99999999999999789

    0                  : 0.9999999999999970 : E5 FF FF FF FF FF EF 3F : 0.999999999999997
    1                  : 0.9999999999999970 : E6 FF FF FF FF FF EF 3F : 0.99999999999999711
    2                  : 0.9999999999999970 : E7 FF FF FF FF FF EF 3F : 0.99999999999999722
    3                  : 0.9999999999999970 : E8 FF FF FF FF FF EF 3F : 0.99999999999999734
+++ 4                  : 0.9999999999999970 : E9 FF FF FF FF FF EF 3F : 0.99999999999999745
    5                  : 0.9999999999999980 : EA FF FF FF FF FF EF 3F : 0.99999999999999756 
    6                  : 0.9999999999999980 : EB FF FF FF FF FF EF 3F : 0.99999999999999767
    7                  : 0.9999999999999980 : EC FF FF FF FF FF EF 3F : 0.99999999999999778
    8                  : 0.9999999999999980 : ED FF FF FF FF FF EF 3F : 0.99999999999999789
    9                  : 0.9999999999999980 : EE FF FF FF FF FF EF 3F : 0.999999999999998

Cela se fait artificiellement mais dans la 1ère section, la boucle compte par incréments de décimal 0.0000000000000001.
Remarquez comment deux "valeurs consécutives" (***) ont la même représentation binaire interne.

Dans la deuxième section - parce que nous ne sautons pas à travers des cerceaux pour forcer l'addition décimale - l'interne la valeur continue de taper dans le bit le moins significatif. Les deux séquences de 10 valeurs ne sont plus synchronisées après 5 itérations.

Le fait est que les doubles (en interne binaires) ne peuvent pas avoir de représentations décimales exactes et vice-versa.
Nous pouvons seulement essayer d'obtenir une chaîne décimale représentant notre valeur "aussi proche que possible".
Ici, la chaîne au format R 0.99999999999999745 est ambiguë "plus proche de" soit 0,9999999999999974 ou 0,9999999999999975.

J'apprécie que la question semble "montrer cette fonctionnalité dans l'autre sens" (une représentation décimale mappant de manière ambiguë à deux binaires différents) mais n'a pas réussi à recréer cela.
Après tout, nous sommes à la limite de la précision des doubles et c'est pourquoi des chaînes de format R sont nécessaires.

J'aime penser de cette façon "Le spécificateur de format aller-retour produit une chaîne représentant la double valeur la plus proche à votre double valeur qui peut être arrondie."En d’autres termes, la chaîne formatée R doit être capable de faire des allers-retours, pas nécessairement la valeur."

Pour travailler le point, il ne faut pas supposer que "value -> string -> même valeur" est possible mais
devrait pouvoir compter sur "valeur -> chaîne -> valeur voisine -> même chaîne -> même valeur voisine -> ... 

Rappelles toi

  1. La représentation interne des doubles dépend de l'environnement / de la plate-forme

  2. Même dans un écosystème entièrement Microsoft, il existe encore de nombreuses variantes possibles

    une. Options de construction (x86 / x64 / AnyCPU, Release / Debug)

    b. Matériel (les processeurs Intel ont un registre de 80 bits pour l'arithmétique - qui pourrait être utilisé différemment par le code de construction de débogage et de publication)

    c. Qui sait où le code IL pourrait se trouver en cours d'exécution (mode 32 bits sous 64 bits sur le système d'exploitation X / Y, etc.)?

Cela devrait "réparer" le code de la question originale ...

double d1 = 0.84551240822557006;
string s1 = d1.ToString("R");
double d2 = double.Parse(s1);
// d2 is not necessarily == d1
string s2 = d2.ToString("R");
double d3 = double.Parse(s2);
// you must get true here
bool roundTripSuccess = d2 == d3;

0
2017-09-10 08:16