Czy ktoś może wyjaśnić to dziwne zachowanie za pomocą podpisanych pływaków w C #?

247

Oto przykład z komentarzami:

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

Więc, co o tym myślisz?

Alexander Efimov
źródło
2
Żeby uczynić rzeczy dziwniejszymi, c.d.Equals(d.d)ocenia się truepodobniec.f.Equals(d.f)
Justin Niessner
2
Nie porównuj liczb zmiennoprzecinkowych z dokładnym porównaniem, takim jak .Equals. To po prostu zły pomysł.
Thorsten79
6
@ Thorsten79: Jak to ma znaczenie?
Ben M
2
To jest najbardziej dziwne. Użycie długiego zamiast podwójnego dla f wprowadza to samo zachowanie. A dodanie kolejnego krótkiego pola ponownie to poprawia ...
Jens
1
Dziwne - wydaje się, że zdarza się to tylko wtedy, gdy oba są tego samego typu (zmiennoprzecinkowe lub podwójne). Zmień jeden na zmiennoprzecinkowy (lub dziesiętny), a D2 działa tak samo jak D1.
tvanfosson

Odpowiedzi:

387

Błąd występuje w następujących dwóch wierszach System.ValueType: (wszedłem do źródła odniesienia)

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

(Obie metody są [MethodImpl(MethodImplOptions.InternalCall)])

Gdy wszystkie pola mają szerokość 8 bajtów, CanCompareBitsomyłkowo zwraca wartość true, co powoduje bitowe porównanie dwóch różnych, ale semantycznie identycznych wartości.

Gdy co najmniej jedno pole nie ma 8 bajtów szerokości, CanCompareBitszwraca wartość false, a kod kontynuuje użycie odbicia w celu zapętlenia pól i wywołania Equalskażdej wartości, co poprawnie traktuje -0.0jako równe 0.0.

Oto źródło CanCompareBitsSSCLI:

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
źródło
159
Wkraczasz w System.ValueType? To dość hardcorowy bracie.
Pierreten
2
Nie wyjaśniasz, jakie znaczenie ma „szerokość 8 bajtów”. Czy struct z wszystkimi 4-bajtowymi polami nie miałby tego samego wyniku? Zgaduję, że posiadanie pojedynczego 4-bajtowego pola i 8-bajtowego pola po prostu wyzwala IsNotTightlyPacked.
Gabe,
1
@ Gabe Napisałem wcześniej, żeThe bug also happens with floats, but only happens if the fields in the struct add up to a multiple of 8 bytes.
SLaks
1
Ponieważ .NET jest teraz oprogramowaniem typu open source, oto link do implementacji ValueRypeHelper :: CanCompareBits w Core CLR . Nie chciałem aktualizować odpowiedzi, ponieważ implementacja została nieznacznie zmieniona z opublikowanego źródła referencyjnego.
Widoczny
59

Odpowiedź znalazłem na stronie http://blogs.msdn.com/xiangfan/archive/2008/09/01/magic-behind-valuetype-equals.aspx .

Najważniejszy jest komentarz źródłowy CanCompareBits, który ValueType.Equalssłuży do określenia, czy użyć memcmpporównania w stylu:

Komentarz CanCompareBits mówi „Zwróć wartość true, jeśli typ wartości nie zawiera wskaźnika i jest ciasno upakowany”. A FastEqualsCheck używa „memcmp”, aby przyspieszyć porównanie.

Następnie autor dokładnie stwierdza problem opisany przez PO:

Wyobraź sobie, że masz strukturę, która zawiera tylko liczbę zmiennoprzecinkową. Co się stanie, jeśli jeden zawiera +0,0, a drugi zawiera -0,0? Powinny być takie same, ale podstawowa reprezentacja binarna jest inna. Jeśli zagnieżdżasz inną strukturę, która przesłania metodę Equals, optymalizacja również się nie powiedzie.

Ben M.
źródło
Zastanawiam się, czy zachowanie Equals(Object)za double, floati Decimalzmieniane podczas wczesnych projektów .net; Myślę, że to ważne, aby mieć wirtualny X.Equals((Object)Y)zwrócić tylko truekiedy Xi Ysą nie do odróżnienia, niż mieć że metoda dopasować zachowanie innych przeciążeń (szczególnie zważywszy, że ze względu na niejawny typu przymusu, przeciążone Equalsmetody nie nawet zdefiniować relację równoważności !, np. 1.0f.Equals(1.0)daje wynik fałszywy, ale 1.0.Equals(1.0f)daje wynik prawdziwy!) Prawdziwy problem IMHO nie polega na sposobie porównywania struktur ...
supercat 13.12.12
1
... ale w sposób, w jaki te typy wartości zastępują Equalscoś innego niż równoważność. Załóżmy na przykład, że chcemy napisać metodę, która pobiera niezmienny obiekt i, jeśli nie został jeszcze zbuforowany, wykonuje ToStringgo i buforuje wynik; jeśli został buforowany, po prostu zwróć buforowany łańcuch. Nie jest to nierozsądne, ale nie powiedzie się to źle, Decimalponieważ dwie wartości mogą się równać, ale dają różne ciągi.
supercat
52

Przypuszczenie Vilxa jest prawidłowe. „CanCompareBits” sprawdza, czy dany typ wartości jest „ściśle upakowany” w pamięci. Ciasno upakowaną strukturę porównuje się po prostu przez porównanie bitów binarnych tworzących strukturę; luźno spakowaną strukturę porównuje się przez wywołanie równości dla wszystkich członków.

To wyjaśnia spostrzeżenie SLaksa, że ​​reprosuje strukturami, które są podwójne; takie struktury są zawsze szczelnie zapakowane.

Niestety, jak widzieliśmy tutaj, wprowadza to różnicę semantyczną, ponieważ porównanie bitowe podwójnych i równe porównanie podwójnych daje różne wyniki.

Eric Lippert
źródło
3
To dlaczego nie jest to błąd? Mimo że MS zaleca zawsze zastępowanie równości dla typów wartości.
Alexander Efimov
14
Do diabła ze mną. Nie jestem ekspertem od elementów wewnętrznych CLR.
Eric Lippert
4
... nie jesteś? Z pewnością twoja wiedza na temat wewnętrznych elementów C # doprowadziłaby do znacznej wiedzy na temat działania CLR.
CaptainCasey
37
@CaptainCasey: Spędziłem pięć lat studiując wewnętrzne kompilatory C # i prawdopodobnie w sumie kilka godzin studiując wewnętrzne CLR. Pamiętaj, że jestem konsumentem CLR; Rozumiem jego powierzchnię publiczną dość dobrze, ale jej elementy wewnętrzne są dla mnie czarną skrzynką.
Eric Lippert
1
Mój błąd, myślałem, że kompilatory CLR i VB / C # były ściślej sprzężone ... więc C # / VB -> CIL -> CLR
CaptainCasey
22

Połowa odpowiedzi:

Reflektor mówi nam, że ValueType.Equals()robi coś takiego:

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

Niestety obie CanCompareBits()i FastEquals()(obie metody statyczne) są extern ( [MethodImpl(MethodImplOptions.InternalCall)]) i nie mają dostępnego źródła.

Wróć do zgadywania, dlaczego jeden przypadek może być porównywany bitami, a drugi nie. (Może problemy z wyrównaniem?)

Vilx-
źródło
17

To ma dać prawdziwe dla mnie, z GMC Mono 2.4.2.3.

Matthew Flaschen
źródło
5
Tak, próbowałem też w Mono, i to też mi odpowiada. Wygląda na to, że stwardnienie rozsiane ma w sobie trochę magii :)
Alexander Efimov
3
ciekawe, wszyscy wysyłamy do Mono?
WeNeedAnswers
14

Prostszy przypadek testowy:

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

EDYCJA : Błąd występuje również w przypadku liczb zmiennoprzecinkowych, ale występuje tylko wtedy, gdy pola w strukturze sumują się do wielokrotności 8 bajtów.

SLaks
źródło
Wygląda jak reguła optymalizatora, która się sprawdza: jeśli wszystko podwaja się, to nie porównuj bitów, w przeciwnym razie wykonaj osobne podwójne. Równe połączenia
Henk Holterman
Nie sądzę, że jest to ten sam przypadek testowy, co wydaje się, że przedstawiony tutaj problem polega na tym, że domyślna wartość Bad.f nie wynosi 0, podczas gdy drugi przypadek wydaje się być problemem Int vs. Double.
Driss Zouak
6
@Driss: Wartość domyślna double to 0 . Jesteś w błędzie.
SLaks,
10

Musi być związany z porównaniem bit po bicie, ponieważ 0.0powinien różnić się -0.0tylko bitem sygnału.

João Angelo
źródło
5

…co o tym myślisz?

Zawsze zastępuj Equals i GetHashCode dla typów wartości. Będzie szybko i poprawnie.

Wiaczesław Iwanow
źródło
Poza zastrzeżeniem, że jest to konieczne tylko wtedy, gdy równość jest istotna, właśnie o tym myślałem. Choć fajnie jest patrzeć na dziwactwa zachowania o domyślnym typie równości typu wartości, jak w przypadku najlepiej głosowanych odpowiedzi, istnieje powód, dla którego istnieje CA1815 .
Joe Amenta
@JoeAmenta Przepraszamy za spóźnioną odpowiedź. Moim zdaniem (oczywiście tylko moim zdaniem) równość jest zawsze ( ) istotna dla typów wartości. Domyślna implementacja równości jest niedopuszczalna w typowych przypadkach. ( ) Z wyjątkiem bardzo szczególnych przypadków. Bardzo. Bardzo specjalny. Kiedy dokładnie wiesz, co robisz i dlaczego.
Wiaczesław Iwanow
Myślę, że zgadzamy się, że zastąpienie kontroli równości dla typów wartości jest praktycznie zawsze możliwe i znaczące z nielicznymi wyjątkami i zwykle sprawi, że będzie ono bardziej poprawne. Chciałem przekazać słowem „istotne”, że istnieją pewne typy wartości, których instancje nigdy nie będą porównywane z innymi instancjami zapewniającymi równość, więc zastąpienie spowoduje martwy kod, który należy zachować. Te (i dziwne przypadki szczególne, o których wspominasz) byłyby jedynymi miejscami, w których bym to pominął.
Joe Amenta
4

Tylko aktualizacja tego 10-letniego błędu: została naprawiona ( Disclaimer : Jestem autorem tego PR) w .NET Core, który prawdopodobnie zostałby wydany w .NET Core 2.1.0.

Blogu wyjaśnił błąd i jak go naprawiłem.

Jim Ma
źródło
2

Jeśli stworzysz D2 w ten sposób

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

to prawda.

jeśli tak to zrobisz

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

To wciąż fałsz.

I t wydaje się, że to fałsz, jeśli struktura posiada tylko podwaja.

Morten Anderson
źródło
1

Musi to być zero, ponieważ zmiana linii

dd = -0,0

do:

dd = 0,0

powoduje, że porównanie jest prawdziwe ...

użytkownik243357
źródło
I odwrotnie, NaN mogą porównywać się dla zmiany, gdy faktycznie używają tego samego wzorca bitowego.
Harold