Quelqu'un peut-il expliquer ce comportement étrange avec des flotteurs signés en C #?

247

Voici l'exemple avec des commentaires:

class Program
{
    // first version of structure
    public struct D1
    {
        public double d;
        public int f;
    }

    // during some changes in code then we got D2 from D1
    // Field f type became double while it was int before
    public struct D2 
    {
        public double d;
        public double f;
    }

    static void Main(string[] args)
    {
        // Scenario with the first version
        D1 a = new D1();
        D1 b = new D1();
        a.f = b.f = 1;
        a.d = 0.0;
        b.d = -0.0;
        bool r1 = a.Equals(b); // gives true, all is ok

        // The same scenario with the new one
        D2 c = new D2();
        D2 d = new D2();
        c.f = d.f = 1;
        c.d = 0.0;
        d.d = -0.0;
        bool r2 = c.Equals(d); // false! this is not the expected result        
    }
}

Alors, qu'est-ce que tu en penses?

Alexander Efimov
la source
2
Pour rendre les choses plus étranges, il en c.d.Equals(d.d)va truede mêmec.f.Equals(d.f)
Justin Niessner
2
Ne comparez pas les flotteurs avec une comparaison exacte comme .Equals. C'est simplement une mauvaise idée.
Thorsten79
6
@ Thorsten79: En quoi est-ce pertinent ici?
Ben M
2
C'est très étrange. Utiliser un long à la place un double pour f introduit le même comportement. Et l'ajout d'un autre champ court le corrige à nouveau ...
Jens
1
Bizarre - cela ne semble se produire que lorsque les deux sont du même type (flottant ou double). Remplacez-le par flottant (ou décimal) et D2 fonctionne de la même manière que D1.
tvanfosson

Réponses:

387

Le bug est dans les deux lignes suivantes de System.ValueType: (je suis entré dans la source de référence)

if (CanCompareBits(this)) 
    return FastEqualsCheck(thisObj, obj);

(Les deux méthodes sont [MethodImpl(MethodImplOptions.InternalCall)])

Lorsque tous les champs ont une largeur de 8 octets, CanCompareBitsrenvoie par erreur true, ce qui entraîne une comparaison au niveau du bit de deux valeurs différentes, mais sémantiquement identiques.

Lorsqu'au moins un champ ne fait pas 8 octets de large, CanCompareBitsrenvoie false et le code continue à utiliser la réflexion pour boucler sur les champs et appeler Equalschaque valeur, qui est correctement traitée -0.0comme égale à 0.0.

Voici la source CanCompareBitsde SSCLI:

FCIMPL1(FC_BOOL_RET, ValueTypeHelper::CanCompareBits, Object* obj)
{
    WRAPPER_CONTRACT;
    STATIC_CONTRACT_SO_TOLERANT;

    _ASSERTE(obj != NULL);
    MethodTable* mt = obj->GetMethodTable();
    FC_RETURN_BOOL(!mt->ContainsPointers() && !mt->IsNotTightlyPacked());
}
FCIMPLEND
SLaks
la source
159
Vous entrez dans System.ValueType? C'est un frère assez hardcore.
Pierreten
2
Vous n'expliquez pas la signification de "8 octets de large". Une structure avec tous les champs de 4 octets n'aurait-elle pas le même résultat? Je suppose que le fait d'avoir un seul champ de 4 octets et un champ de 8 octets se déclenche IsNotTightlyPacked.
Gabe
1
@Gabe J'ai écrit plus tôt queThe bug also happens with floats, but only happens if the fields in the struct add up to a multiple of 8 bytes.
SLaks
1
Avec .NET étant un logiciel open source maintenant, voici un lien vers l'implémentation Core CLR de ValueTypeHelper :: CanCompareBits . Je ne voulais pas mettre à jour votre réponse car l'implémentation est légèrement modifiée par rapport à la source de référence que vous avez publiée.
mars 2017
59

J'ai trouvé la réponse sur http://blogs.msdn.com/xiangfan/archive/2008/09/01/magic-behind-valuetype-equals.aspx .

L'élément central est le commentaire source sur CanCompareBits, qui permet ValueType.Equalsde déterminer s'il faut utiliser la memcmpcomparaison -style:

Le commentaire de CanCompareBits dit "Retourne vrai si le type de valeur ne contient pas de pointeur et est bien emballé". Et FastEqualsCheck utilise "memcmp" pour accélérer la comparaison.

L'auteur poursuit en énonçant exactement le problème décrit par le PO:

Imaginez que vous ayez une structure qui ne contient qu'un flotteur. Que se passera-t-il si l'un contient +0,0 et l'autre contient -0,0? Ils doivent être identiques, mais les représentations binaires sous-jacentes sont différentes. Si vous imbriquez une autre structure qui remplace la méthode Equals, cette optimisation échouera également.

Ben M
la source
Je me demande si le comportement Equals(Object)pour double, floatet Decimalchangé au cours des premières ébauches de .net; Je pense qu'il est plus important d'avoir le X.Equals((Object)Y)retour virtuel uniquement truelorsque Xet ne Ypeuvent être distingués, que de faire correspondre cette méthode au comportement d'autres surcharges (d'autant plus que, en raison de la contrainte de type implicite, les Equalsméthodes surchargées ne définissent même pas une relation d'équivalence !, par exemple 1.0f.Equals(1.0)donne faux, mais 1.0.Equals(1.0f)donne vrai!) Le vrai problème à
mon humble avis
1
... mais avec la façon dont ces types de valeurs remplacent Equalspour signifier autre chose que l'équivalence. Supposons, par exemple, que l'on veuille écrire une méthode qui prend un objet immuable et, s'il n'a pas encore été mis en cache, l'exécute ToStringet met en cache le résultat; s'il a été mis en cache, renvoyez simplement la chaîne mise en cache. Ce n'est pas une chose déraisonnable à faire, mais cela échouerait gravement Decimalcar deux valeurs pourraient se comparer mais donner des chaînes différentes.
supercat
52

La conjecture de Vilx est correcte. "CanCompareBits" vérifie si le type de valeur en question est "étroitement compressé" en mémoire. Une structure très compacte est comparée en comparant simplement les bits binaires qui composent la structure; une structure peu serrée est comparée en appelant Equals sur tous les membres.

Ceci explique l'observation de SLaks qu'elle reproche avec des structures qui sont toutes doubles; ces structures sont toujours bien emballées.

Malheureusement, comme nous l'avons vu ici, cela introduit une différence sémantique car la comparaison au niveau du bit des doubles et la comparaison égale des doubles donne des résultats différents.

Eric Lippert
la source
3
Alors pourquoi ce n'est pas un bug? Même si MS recommande de toujours remplacer Equals sur les types de valeurs.
Alexander Efimov
14
Beats the heck out of me. Je ne suis pas un expert des internes du CLR.
Eric Lippert
4
... Tu ne l'es pas? Certes, votre connaissance des internes C # conduirait à une connaissance considérable du fonctionnement du CLR.
CaptainCasey
37
@CaptainCasey: J'ai passé cinq ans à étudier les composants internes du compilateur C # et probablement au total quelques heures à étudier les composants internes du CLR. N'oubliez pas que je suis un consommateur du CLR; Je comprends assez bien sa surface publique, mais ses internes sont une boîte noire pour moi.
Eric Lippert
1
Mon erreur, je pensais que le CLR et les compilateurs VB / C # étaient plus étroitement couplés ... donc C # / VB -> CIL -> CLR
CaptainCasey
22

Une demi-réponse:

Reflector nous dit que cela ValueType.Equals()fait quelque chose comme ceci:

if (CanCompareBits(this))
    return FastEqualsCheck(this, obj);
else
    // Use reflection to step through each member and call .Equals() on each one.

Malheureusement, les deux CanCompareBits()et FastEquals()(les deux méthodes statiques) sont extern ( [MethodImpl(MethodImplOptions.InternalCall)]) et n'ont aucune source disponible.

Retour à deviner pourquoi un cas peut être comparé par bits, et l'autre pas (problèmes d'alignement peut-être?)

Vilx-
la source
17

Il ne donne vrai pour moi, avec les gmcs de Mono 2.4.2.3.

Matthew Flaschen
la source
5
Oui, je l'ai aussi essayé en Mono, et ça me donne aussi raison. On dirait que MS fait de la magie à l'intérieur :)
Alexander Efimov
3
intéressant, nous expédions tous à Mono?
WeNeedAnswers
14

Cas de test plus simple:

Console.WriteLine("Good: " + new Good().Equals(new Good { d = -.0 }));
Console.WriteLine("Bad: " + new Bad().Equals(new Bad { d = -.0 }));

public struct Good {
    public double d;
    public int f;
}

public struct Bad {
    public double d;
}

EDIT : Le bogue se produit également avec des flottants, mais ne se produit que si les champs de la structure totalisent un multiple de 8 octets.

SLaks
la source
On dirait une règle d'optimisation qui va: si tout est double que faire un peu de comparaison, sinon faites un double séparé. Appels égaux
Henk Holterman
Je ne pense pas que ce soit le même cas de test que ce que le problème présenté ici semble être, c'est que la valeur par défaut pour Bad.f n'est pas 0, tandis que l'autre cas semble être un problème Int vs Double.
Driss Zouak
6
@Driss: La valeur par défaut de double est 0 . Vous vous trompez.
SLaks
10

Elle doit être liée à une comparaison bit par bit, car elle 0.0ne doit différer -0.0que du bit de signal.

João Angelo
la source
5

…Que pensez-vous de ceci?

Remplacez toujours Equals et GetHashCode sur les types de valeur. Ce sera rapide et correct.

Viacheslav Ivanov
la source
Hormis le fait que cela n'est nécessaire que lorsque l'égalité est pertinente, c'est exactement ce que je pensais. Aussi amusant que cela soit de regarder les bizarreries du comportement d'égalité du type de valeur par défaut comme le font les réponses les plus votées, il y a une raison pour laquelle CA1815 existe.
Joe Amenta
@JoeAmenta Désolé pour une réponse tardive. À mon avis (juste à mon avis, bien sûr), l'égalité est toujours ( ) pertinente pour les types de valeur. L'implémentation de l'égalité par défaut n'est pas acceptable dans les cas courants. ( ) Sauf cas très particuliers. Très. Très spécial. Quand vous savez exactement ce que vous faites et pourquoi.
Viacheslav Ivanov
Je pense que nous convenons que le dépassement des contrôles d'égalité pour les types de valeur est pratiquement toujours possible et significatif à très peu d'exceptions, et le rendra généralement plus correct. Le point que j'essayais de transmettre avec le mot «pertinent» était qu'il existe certains types de valeur dont les instances ne seront jamais comparées à d'autres instances pour l'égalité, donc la substitution entraînerait un code mort qui doit être conservé. Ces (et les cas spéciaux étranges auxquels vous faites allusion) seraient les seuls endroits où je sauterais.
Joe Amenta
4

Juste une mise à jour pour ce bug de 10 ans: il a été corrigé ( Avertissement : je suis l'auteur de ce PR) dans .NET Core qui serait probablement publié dans .NET Core 2.1.0.

Le billet de blog a expliqué le bogue et comment je l'ai corrigé.

Jim Ma
la source
2

Si vous faites D2 comme ça

public struct D2
{
    public double d;
    public double f;
    public string s;
}

c'est vrai.

si tu le fais comme ça

public struct D2
{
    public double d;
    public double f;
    public double u;
}

C'est toujours faux.

i t semble que c'est faux si le struct ne détient que double.

Morten Anderson
la source
1

Il doit être lié à zéro, car le changement de ligne

dd = -0,0

à:

dd = 0,0

résulte que la comparaison est vraie ...

user243357
la source
Inversement, les NaN pourraient se comparer égaux les uns aux autres pour un changement, lorsqu'ils utilisent en fait le même motif binaire.
harold