谁能用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 2010年

6
@ Thorsten79:这和这里有什么关系?
本M

2
这是最奇怪的。对f使用long而不是double会引入相同的行为。并添加另一个短字段可以对其进行更正...
Jens

1
很奇怪-似乎只有当两种类型都相同时才发生(浮动或双精度)。将其更改为浮点(或十进制),D2与D1相同。
tvanfosson

Answers:


387

该错误在以下两行中System.ValueType:(我进入参考源)

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

(两种方法都是[MethodImpl(MethodImplOptions.InternalCall)]

当所有字段均为8个字节宽时,CanCompareBits错误地返回true,从而导致两个不同但在语义上相同的值的按位比较。

当至少一个字段的宽度不为8个字节时,CanCompareBits返回false,代码继续使用反射在字段上循环并调用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

158
进入System.ValueType吗?那是非常顽固的兄弟。
Pierreten 2010年

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已成为开源软件,这是ValueTypeHelper :: CanCompareBits的Core CLR实现的链接。不想更新您的答案,因为实现与您发布的参考源略有不同。
IInspectable '17

59

我在http://blogs.msdn.com/xiangfan/archive/2008/09/01/magic-behind-valuetype-equals.aspx找到了答案。

核心部分是上的源注释CanCompareBits,该注释ValueType.Equals用于确定是否使用memcmp-style比较:

CanCompareBits的注释说:“如果valuetype不包含指针并且紧密包装,则返回true”。而FastEqualsCheck使用“ memcmp”来加速比较。

作者继续陈述OP所描述的问题:

假设您有一个仅包含浮点数的结构。如果一个包含+0.0,另一个包含-0.0,将会发生什么?它们应该相同,但是基础二进制表示形式不同。如果嵌套其他覆盖Equals方法的结构,则优化也会失败。


我想知道.net的早期草稿中Equals(Object)for doublefloat和的行为是否Decimal发生了变化;我认为,X.Equals((Object)Y)trueXY不能区分时才返回虚拟,比使该方法匹配其他重载的行为更重要(尤其是考虑到由于隐式类型强制,重载的Equals方法甚至都没有定义等价关系,这一点尤其重要)!,例如,结果为1.0f.Equals(1.0)false,但结果为1.0.Equals(1.0f)true!)恕我直言,真正的问题不在于结构的比较方法……
supercat 2012年

1
...但是这些值类型会覆盖Equals以表示等效项以外的其他方式。例如,假设有人想编写一种方法,该方法采用一个不可变的对象,如果尚未ToString缓存该对象,则对该对象执行并缓存结果;如果已缓存,则只需返回缓存的字符串。这不是一件不合理的事情,但是这样做会严重失败,Decimal因为两个值可能比较相等,但产生不同的字符串。
supercat

52

维尔克斯的猜想是正确的。“ CanCompareBits”的作用是检查所查看的值类型是否在内存中“紧密包装”。通过简单地比较构成结构的二进制位,可以比较紧密打包的结构。通过在所有成员上调用Equals来比较松散的结构。

这就解释了SLaks的观察,即它的复制结构都是double的。这样的结构总是紧密地包装在一起。

不幸的是,正如我们在这里看到的那样,这引入了语义上的差异,因为双精度的按位比较和双精度的相等比较得出不同的结果。


3
那为什么不是bug?即使MS建议始终覆盖值类型上的Equals。
Alexander Efimov

14
打败我。我不是CLR内部专家。
埃里克·利珀特

4
...你不是吗?当然,您对C#内部知识的了解会使您对CLR的工作原理有相当的了解。
CapeyCasey 2010年

37
@CaptainCasey:我花了五年时间研究C#编译器的内部,可能总共花了几个小时来研究CLR的内部。记住,我是CLR 的消费者;我相当了解它的公共表面区域,但是它的内部结构对我来说却是一个黑匣子。
埃里克·利珀特

1
我的错误,我认为CLR和VB / C#编译器之间的联系更加紧密...所以C#/
VB-

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

编辑:该错误也发生在浮点数,但仅当结构中的字段加起来为8字节的倍数时才会发生。


看起来像是一条优化器规则:如果它的所有值都比位比较值翻倍,则单独做一个double.Equal调用
Henk Holterman 2010年

我认为这与测试问题不一样,因为Bad.f的默认值不为0,而另一种情况似乎是Int vs. Double问题。
Driss Zouak 2010年

6
@Driss:为默认值double 0。你错了。
SLaks


5

…你怎么看待这件事?

始终在值类型上覆盖Equals和GetHashCode。这将是快速而正确的。


除了警告,只有在平等相关时才需要这样做,这正是我在想的。像投票最高的答案一样,查看默认值类型相等行为的怪癖虽然很有趣,但是存在CA1815的原因。
Joe Amenta 2015年

@JoeAmenta对不起,您迟到了。在我看来(当然,就我而言),等价始终与()与值类型相关。在通常情况下,默认的平等实现是不可接受的。()除特殊情况外。非常。非常特别。当您完全知道自己在做什么以及为什么时。
Viacheslav Ivanov

我认为我们同意,几乎总是可以覆盖值类型的相等性检查,并且几乎没有例外,并且通常会使它更加正确。我试图用“相关”一词传达的观点是,有些值类型的实例永远不会与其他实例进行相等性比较,因此,覆盖将导致需要维护无效代码。这些(以及您提到的怪异的特殊情况)将是我唯一会跳过的地方。
Joe Amenta 2015年

4

只是对该10年错误的更新:它在.NET Core中得到修复免责声明:我是本PR的作者),该漏洞可能会在.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

它必须是零相关的,因为更改行

dd = -0.0

至:

dd = 0.0

结果比较是正确的...


相反,当NaN实际上使用相同的位模式时,它们可以相互比较以进行更改。
哈罗德
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.