Чи може хтось пояснити цю дивну поведінку підписаними плавцями в C #?


247

Ось приклад із коментарями:

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

Отже, що ви думаєте з цього приводу?


2
Зробити речі незнайомими c.d.Equals(d.d)оцінює так, trueяк це робитьсяc.f.Equals(d.f)
Джастін Нісснер

2
Не порівнюйте поплавці з точним порівнянням, як .Equals. Це просто погана ідея.
Thorsten79

6
@ Thorsten79: Наскільки це актуально тут?
Бен М

2
Це найдивніше. Використання довгих замість подвійного для f вводить ту саму поведінку. І додавши ще одне коротке поле, це знову виправляє ...
Єнс

1
Дивно - це, здається, трапляється лише тоді, коли обидва однотипні (поплавкові або подвійні). Змініть один на плаваючий (або десятковий), і D2 працює так само, як і D1.
tvanfosson

Відповіді:


387

Помилка знаходиться в наступних двох рядках System.ValueType: (Я перейшов до опорного джерела)

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

(Обидва методи є [MethodImpl(MethodImplOptions.InternalCall)])

Коли всі поля шириною 8 байтів, CanCompareBitsпомилково повертає істину, що призводить до бітового порівняння двох різних, але семантично однакових значень.

Коли принаймні одне поле не має 8 байтів, CanCompareBitsповертає помилкове значення, і код продовжує використовувати відображення для переходу до полів та виклику Equalsкожного значення, яке правильно трактується -0.0як рівне 0.0.

Ось джерело для 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

159
Перехід до System.ValueType? Це досить хардкор брато.
П’єртен

2
Ви не пояснюєте, яке значення має "8 байт у ширину". Чи не матиме структуру з усіма 4-байтними полями однаковий результат? Я здогадуюсь, що наявність одного 4-байтового поля та 8-байтових полів просто запускає IsNotTightlyPacked.
Гейб

1
@Gabe Я писав раніше про цеThe bug also happens with floats, but only happens if the fields in the struct add up to a multiple of 8 bytes.
SLaks

1
Оскільки .NET зараз є програмним забезпеченням з відкритим кодом, ось посилання на реалізацію Core CLR ValueTypeHelper :: CanCompareBits . Не хотіли оновити свою відповідь, оскільки реалізація дещо змінена від опублікованого довідкового джерела.
Неочікуваний

59

Я знайшов відповідь на http://blogs.msdn.com/xiangfan/archive/2008/09/01/magic-behind-valuetype-equals.aspx .

Основний фрагмент - це коментар джерела CanCompareBits, який ValueType.Equalsвизначає, чи слід використовувати memcmpпорівняння стилю:

У коментарі CanCompareBits йдеться про "Повернення правдиво, якщо тип валієтипу не містить вказівника і щільно упакований". І FastEqualsCheck використовує "memcmp", щоб пришвидшити порівняння.

Автор далі констатує проблему, описану ОП:

Уявіть, у вас є структура, яка містить лише поплавок. Що відбудеться, якщо одна містить +0,0, а друга містить -0,0? Вони повинні бути однаковими, але основні бінарні представлення різні. Якщо ви вкладете іншу структуру, яка перекриває метод рівних, оптимізація також буде невдалою.


Цікаво , якщо поведінка Equals(Object)для double, floatі Decimalзмінилися в протягом ранніх проектів .net; Я б подумав, що важливіше віртуальне X.Equals((Object)Y)повернення лише trueтоді, коли Xі Yйого нерозрізнено, ніж те, щоб цей метод відповідав поведінці інших перевантажень (особливо якщо врахувати, що через примусовий примусовий тип, перевантажені Equalsметоди навіть не визначають відношення еквівалентності !, наприклад, 1.0f.Equals(1.0)дає помилкове значення, але 1.0.Equals(1.0f)поступається правді!) Справжня проблема ІМХО не в тому, як порівнюються структури ...
supercat

1
... але з тим, як ці типи значень переосмислюються, Equalsщоб означати щось інше, ніж еквівалентність. Припустимо, наприклад, хочеться написати метод, який бере незмінний об'єкт і, якщо він ще не був кешований, виконує ToStringна ньому і кешує результат; якщо він був кешований, просто поверніть кешований рядок. Нерозумно це робити, але це буде погано, Decimalоскільки два значення можуть порівнювати рівні, але давати різні рядки.
supercat

52

Гіпотеза Вількса правильна. Що CanCompareBits робить, це перевірити, чи є відповідний тип значення "щільно упакованим" в пам'ять. Щільно упакована структура порівнюється шляхом простого порівняння двійкових бітів, що складають структуру; нещільно упакована структура порівнюється за допомогою виклику рівних для всіх членів.

Це пояснює спостереження SLaks про те, що він спростовує структури, які є парними; такі структури завжди щільно упаковані.

На жаль, як ми бачили тут, це вносить смислову різницю, оскільки побітове порівняння парних та рівних порівнянь парних дає різні результати.


3
Тоді чому це не помилка? Навіть незважаючи на те, що MS рекомендує завжди перевищувати рівність на типи значень.
Олександр Єфімов

14
Вибиває чорт з мене. Я не є експертом з питань внутрішніх справ CLR.
Ерік Ліпперт

4
... Ти ні? Безумовно, ваші знання про внутрішній C # призведуть до значних знань про те, як працює CLR.
CaptainCasey

37
@CaptainCasey: Я провів п’ять років, вивчаючи внутрішні програми компілятора C # і, мабуть, загалом пару годин вивчав внутрішні положення CLR. Пам'ятайте, я споживач CLR; Я розумію його загальнодоступну поверхню досить добре, але її внутрішність - це чорний ящик для мене.
Ерік Ліпперт

1
Моя помилка, я вважав, що компілятори CLR та VB / C # більш щільно з'єднані ... тому C # / VB -> CIL -> CLR
CaptainCasey

22

Половина відповіді:

Reflector каже нам, що ValueType.Equals()робить щось подібне:

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

На жаль, і обидва, CanCompareBits()і FastEquals()обидва статичні методи є extern ( [MethodImpl(MethodImplOptions.InternalCall)]) і не мають джерела.

Назад до здогадки, чому один випадок можна порівняти по бітах, а інший не може (проблеми з вирівнюванням можливо?)



14

Простіший тестовий випадок:

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 : Помилка також буває з плаваючими, але відбувається лише в тому випадку, якщо поля в структурі складають кратне 8 байт.


Виглядає як правило оптимізатора: якщо його все подвоюється, ніж біт-порівняння, інакше робити окремі подвійні. Рівні дзвінки
Хенк Холтерман

Я не думаю, що це той самий тестовий випадок, що, як видається, представлена ​​тут проблема полягає в тому, що значення Bad.f за замовчуванням не дорівнює 0, тоді як інший випадок видається проблемою Int проти Double.
Дрісс Зуак

6
@Driss: Значенням за замовчуванням double є 0 . Ви помиляєтеся.
СЛАкс

10

Він повинен бути пов'язаний з побітним порівнянням, оскільки 0.0має відрізнятися -0.0лише від бітового сигналу.


5

…що ти думаєш про це?

Завжди переосмислюйте значення рівності та значення GetHashCode. Це буде швидко і правильно.


Окрім застереження, що це потрібно лише тоді, коли рівноправність є актуальною, це саме те, що я думав. Настільки ж цікаво, як дивитися на химерність поведінки за рівнем типового значення рівності, як це роблять відповіді з найвищим голосом, є причина, чому CA1815 існує.
Джо Амента

@JoeAmenta Вибачте за пізню відповідь. На мій погляд (звичайно, на мій погляд, звичайно), рівність завжди ( ) актуальна для типів значень. Реалізація рівності за замовчуванням неприйнятна у звичайних випадках. ( ) За винятком дуже особливих випадків. Дуже. Дуже особливий. Коли ви точно знаєте, чим займаєтесь і чому.
В’ячеслав Іванов

Я думаю, що ми погоджуємось, що переосмислення перевірок рівності для типів цінності практично завжди можливо та осмислено за дуже невеликими винятками, і зазвичай це робить суворіше правильним. Суть, яку я намагався передати слову "релевантний", полягала в тому, що існують деякі типи значень, екземпляри яких ніколи не будуть порівнюватися з іншими екземплярами за рівність, тому переосмислення призведе до мертвого коду, який потрібно підтримувати. Ці (і дивні особливі випадки, на які ви натякаєте) були б єдиними місцями, які я б пропустив.
Джо Амента

4

Просто оновлення для цієї 10-річної помилки: вона була виправлена ( Застереження : я автор цього PR) в .NET Core, який, можливо, буде випущений у .NET Core 2.1.0.

Повідомлення в блозі пояснило помилку та те, як я її усунув.


2

Якщо зробити D2 таким

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

це правда.

якщо ти зробиш так

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

Це все ще помилково.

мені здається, що це помилково, якщо структура містить лише подвійні.


1

Він повинен бути пов'язаний з нулем, оскільки змінюється лінія

дд = -0,0

до:

дд = 0,0

результати в порівнянні є правдивими ...


І навпаки, NaN можуть порівнювати однакові між собою для зміни, коли вони фактично використовують один і той же біт.
Гарольд
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.