¿Alguien puede explicar este extraño comportamiento con flotadores firmados en C #?

247

Aquí está el ejemplo con comentarios:

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

Entonces, ¿qué te parece esto?

Alexander Efimov
fuente
2
Para hacer las cosas más extrañas se c.d.Equals(d.d)evalúa truecomo lo hacec.f.Equals(d.f)
Justin Niessner
2
No compare las carrozas con una comparación exacta como .Equals. Es simplemente una mala idea.
Thorsten79
66
@ Thorsten79: ¿Cómo es eso relevante aquí?
Ben M
2
Esto es de lo más extraño. Usar un largo en lugar de un doble para f introduce el mismo comportamiento. Y agregar otro campo corto lo corrige nuevamente ...
Jens
1
Extraño: solo parece suceder cuando ambos son del mismo tipo (flotante o doble). Cambie uno a flotante (o decimal) y D2 funciona igual que D1.
tvanfosson

Respuestas:

387

El error está en las siguientes dos líneas de System.ValueType: (Entré en la fuente de referencia)

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

(Ambos métodos son [MethodImpl(MethodImplOptions.InternalCall)])

Cuando todos los campos tienen 8 bytes de ancho, por CanCompareBitserror devuelve verdadero, lo que resulta en una comparación bit a bit de dos valores diferentes, pero semánticamente idénticos.

Cuando al menos un campo no tiene 8 bytes de ancho, CanCompareBitsdevuelve falso, y el código continúa usando la reflexión para recorrer los campos y llamar Equalsa cada valor, que se trata correctamente -0.0como igual a 0.0.

Aquí está la fuente 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
fuente
159
Entrando en System.ValueType? Eso es muy duro hermano.
Pierreten
2
No explica cuál es el significado de "8 bytes de ancho". ¿Una estructura con todos los campos de 4 bytes no tendría el mismo resultado? Supongo que tener un solo campo de 4 bytes y un campo de 8 bytes solo dispara IsNotTightlyPacked.
Gabe
1
@Gabe escribí antes esoThe bug also happens with floats, but only happens if the fields in the struct add up to a multiple of 8 bytes.
SLaks
1
Con .NET como software de código abierto ahora, aquí hay un enlace a la implementación Core CLR de ValueTypeHelper :: CanCompareBits . No quería actualizar su respuesta ya que la implementación ha cambiado ligeramente de la fuente de referencia que publicó.
Inspeccionable el
59

Encontré la respuesta en http://blogs.msdn.com/xiangfan/archive/2008/09/01/magic-behind-valuetype-equals.aspx .

La pieza central es el comentario fuente CanCompareBits, que se ValueType.Equalsusa para determinar si se debe usar la memcmpcomparación de estilos:

El comentario de CanCompareBits dice "Devuelve verdadero si el valuetype no contiene puntero y está muy empaquetado". Y FastEqualsCheck usa "memcmp" para acelerar la comparación.

El autor continúa exponiendo exactamente el problema descrito por el OP:

Imagina que tienes una estructura que solo contiene un flotador. ¿Qué ocurrirá si uno contiene +0.0 y el otro contiene -0.0? Deberían ser iguales, pero la representación binaria subyacente es diferente. Si anida otra estructura que anula el método Equals, esa optimización también fallará.

Ben M
fuente
Me pregunto si el comportamiento de Equals(Object)para double, floaty Decimalcambiado durante los primeros borradores de .NET; Me gustaría pensar que es más importante contar con lo virtual X.Equals((Object)Y)única recompensa truecuando Xy Yson indistinguibles, que tener ese método coincide con el comportamiento de otros sobrecargas (sobre todo teniendo en cuenta que, debido a la conversión de tipos implícita, sobrecargados Equalsmétodos ni siquiera definen una relación de equivalencia !, por ejemplo, 1.0f.Equals(1.0)da falso, pero 1.0.Equals(1.0f)da verdadero!) El verdadero problema en mi humilde opinión no es con la forma en que se comparan las estructuras ...
supercat
1
... pero con la forma en que esos tipos de valores se anulan Equalspara significar algo más que equivalencia. Supongamos, por ejemplo, que uno quiere escribir un método que toma un objeto inmutable y, si aún no se ha almacenado en caché, se ejecuta ToStringen él y almacena en caché el resultado; Si se ha almacenado en caché, simplemente devuelva la cadena en caché. No es una cosa irrazonable, pero fracasaría mucho Decimalya que dos valores pueden ser iguales pero producir cadenas diferentes.
supercat
52

La conjetura de Vilx es correcta. Lo que hace "CanCompareBits" es verificar si el tipo de valor en cuestión está "apretado" en la memoria. Una estructura apretada se compara simplemente comparando los bits binarios que componen la estructura; una estructura compacta se compara llamando a Equals en todos los miembros.

Esto explica la observación de SLaks de que repros con estructuras que son todas dobles; tales estructuras siempre están apretadas.

Desafortunadamente, como hemos visto aquí, eso introduce una diferencia semántica porque la comparación bit a bit de dobles y la comparación igual a dobles da resultados diferentes.

Eric Lippert
fuente
3
Entonces, ¿por qué no es un error? A pesar de que MS recomienda anular Equals en los tipos de valor siempre.
Alexander Efimov
14
Supera a los diablos de mí. No soy un experto en lo interno del CLR.
Eric Lippert
44
... no lo eres? Seguramente su conocimiento de las partes internas de C # lo conduciría a un conocimiento considerable sobre cómo funciona el CLR.
CaptainCasey
37
@CaptainCasey: He pasado cinco años estudiando las partes internas del compilador de C # y probablemente en total un par de horas estudiando las partes internas del CLR. Recuerde, soy un consumidor de CLR; Entiendo su superficie pública razonablemente bien, pero sus elementos internos son una caja negra para mí.
Eric Lippert
1
Mi error, pensé que los compiladores CLR y VB / C # estaban más estrechamente acoplados ... así que C # / VB -> CIL -> CLR
CaptainCasey
22

Media respuesta:

Reflector nos dice que ValueType.Equals()hace algo como esto:

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

Desafortunadamente, ambos CanCompareBits()y FastEquals()(ambos métodos estáticos) son externos ([MethodImpl(MethodImplOptions.InternalCall)] ) y no tienen una fuente disponible.

Volviendo a adivinar por qué un caso puede ser comparado por bits, y el otro no (¿problemas de alineación tal vez?)

Vilx-
fuente
17

Que no dan cierto para mí, la GMC de Mono 2.4.2.3.

Matthew Flaschen
fuente
55
Sí, también lo he probado en Mono, y también me da la verdad. Parece que MS hace algo de magia por dentro :)
Alexander Efimov
3
interesante, todos enviamos a Mono?
WeNeedAnswers
14

Caso de prueba más 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;
}

EDITAR : El error también ocurre con flotantes, pero solo ocurre si los campos en la estructura suman un múltiplo de 8 bytes.

SLaks
fuente
Parece una regla optimizadora que dice: si todo es doble que hacer una comparación de bits, de lo contrario, haga el doble por separado. Llamadas iguales
Henk Holterman
No creo que este sea el mismo caso de prueba, ya que el problema presentado aquí parece ser que el valor predeterminado para Bad.f no es 0, mientras que el otro caso parece ser un problema Int vs.Doble.
Driss Zouak
66
@Driss: el valor predeterminado para double es 0 . Te equivocas.
SLaks
10

Debe estar relacionado con una comparación bit a bit, ya que 0.0debe diferir de -0.0solo el bit de señal.

João Angelo
fuente
5

…¿qué piensas sobre esto?

Siempre anule Equals y GetHashCode en los tipos de valor. Será rápido y correcto.

Viacheslav Ivanov
fuente
Aparte de una advertencia de que esto solo es necesario cuando la igualdad es relevante, esto es exactamente lo que estaba pensando. Tan divertido como es observar las peculiaridades del comportamiento de igualdad de tipo de valor predeterminado, como lo hacen las respuestas más votadas, hay una razón por la cual existe CA1815 .
Joe Amenta
@JoeAmenta Perdón por una respuesta tardía. En mi opinión (solo en mi opinión, por supuesto), la igualdad es siempre ( ) relevante para los tipos de valor. La implementación de igualdad predeterminada no es aceptable en casos comunes. ( ) Excepto casos muy especiales. Muy. Muy especial. Cuando sabes exactamente qué estás haciendo y por qué.
Viacheslav Ivanov
Creo que estamos de acuerdo en que anular las verificaciones de igualdad para los tipos de valor es casi siempre posible y significativo con muy pocas excepciones, y generalmente lo hará estrictamente más correcto. El punto que estaba tratando de transmitir con la palabra "relevante" era que hay algunos tipos de valores cuyas instancias nunca se compararán con otras instancias para la igualdad, por lo que la anulación daría como resultado un código muerto que debe mantenerse. Esos (y los casos especiales extraños a los que aludirías) serían los únicos lugares donde lo omitiría.
Joe Amenta
4

Solo una actualización para este error de 10 años: se ha solucionado ( Descargo de responsabilidad : soy el autor de este PR) en .NET Core que probablemente se lanzaría en .NET Core 2.1.0.

La publicación del blog explicaba el error y cómo lo solucioné.

Jim Ma
fuente
2

Si haces D2 así

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

es verdad.

si lo haces así

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

Sigue siendo falso.

i t parece que es falso si la estructura sólo se mantiene dobles.

Morten Anderson
fuente
1

Debe estar relacionado con cero, ya que cambiar la línea

dd = -0.0

a:

dd = 0.0

da como resultado que la comparación sea verdadera ...

usuario243357
fuente
Por el contrario, los NaN pueden compararse entre sí para un cambio, cuando en realidad usan el mismo patrón de bits.
Harold