C# 可为空类型的相等操作,为什么 null <= null 的结果是 false?

40

为什么在.NET中

null >= null

解析为false,但是

null == null 

是否解析为true?

换句话说,为什么null >= null不等同于null > null || null == null

有没有人知道官方答案?


有趣的是注意到(object)null >= (object)null是一个编译错误(好吧,它没有定义>=,当然会出错)。IL中到底发生了什么?当然还有(int?)null >= (int?)null等,这显示了上面的行为。 - user166390
在投票关闭之前,请查看“可能的重复项”。虽然它涵盖了一些相同的方面(应该被视为伴随问题阅读/查看),但回答涵盖了不同的角度,就像问题本身一样——特别是另一个问题已经假定了Nullable<T> - user166390
1
我不确定它是否是一个完全重复的问题...另一个问题谈论了一个特定情况,其中操作数的类型是已知的(可空)。这更进一步地寻找了关于空字面量的解释,其中它们没有指定的类型。 - Jeff Mercado
11个回答

32

这个行为在C#规范(ECMA-334)的第14.2.7节中定义(我已经强调了相关部分):

对于关系运算符

< > <= >=

如果操作数类型都是非空值类型,且结果类型为 bool,那么就存在运算符的提升形式。提升形式是通过在每个操作数类型上添加单个 ? 修饰符来构建的。如果一个或两个操作数都是 null,则提升运算符会产生值 false。否则,提升运算符会解包操作数并应用基础运算符以产生 bool 结果。

特别地,这意味着关系的通常规律不成立;x >= y 并不意味着 !(x < y)

详细说明

有些人问为什么编译器首先将 int? 视为提升运算符的类型。现在我们来看看这其中的原因。 :)

我们从 14.2.4,即“二进制运算符重载决议”,开始讲起。这里详细描述了要遵循的步骤。

  1. 首先,检查用户定义的运算符是否合适。这是通过检查 >= 两侧类型定义的运算符来完成的...这也就引发了一个问题: null 的类型是什么!实际上, null 字面量在没有被赋予类型之前根本没有类型,它只是 “null 字面量”。按照 14.2.5 节的指示,我们发现这里没有适合的运算符,因为 null 字面量没有定义任何运算符。

  2. 此步骤指示我们检查预定义运算符集的适用性。(由于双侧都不是枚举类型,因此此节还排除了枚举。)相关的预定义运算符列在 14.9.1 至 14.9.3 中,这些运算符都是基本数值类型的运算符,以及这些运算符的提升版本(请注意,string 的运算符不包括在此处)。

  3. 最后,我们必须使用这些运算符和 14.4.2 中的规则执行重载决议。

实际上执行此决议将非常繁琐,但幸运的是有一个快捷方式。在 14.2.6 下,给出了重载决议结果的信息性示例,其中指出:

...考虑二进制 * 运算符的预定义实现:

int operator *(int x, int y);
uint operator *(uint x, uint y);
long operator *(long x, long y);
ulong operator *(ulong x, ulong y);
void operator *(long x, ulong y);
void operator *(ulong x, long y);
float operator *(float x, float y);
double operator *(double x, double y);
decimal operator *(decimal x, decimal y);

当对这组运算符应用重载解析规则(§14.4.2)时,其效果是从操作数类型中存在隐式转换的第一个运算符被选择。

由于两个值都是 null,我们可以立即排除所有未提升的运算符。这样,就只剩下了所有基元数值类型上的提升后的数值运算符。

然后,利用前面的信息,我们选择第一个存在隐式转换的运算符。由于 null 文字可以隐式转换为可空类型,而 int 的可空类型是存在的,我们从列表中选择第一个运算符,即 int? >= int?


1
如果没有解释为什么 null >= null 会变成 (int?)null >= (int?)null,从而使其符合上述“提升形式”,我无法点赞此答案。 - user166390
我没有将其包括进来,因为这需要深入了解,而且对于所讨论的代码并没有影响。稍后我会添加它 :) - porges
我试图更深入地探讨。真是个痛苦的过程。 :) - Jeff Mercado
更新了我的尝试。我相信自上而下的方法比自下而上的检查更符合编译器的本质。 - porges
引用标准从来不能回答“为什么”的问题,它只会引发同样问题的重新表述:为什么在提升方面,">="和"<="被视为与"<"和">"相同,而不是与"=="分组更合适? - Paul Childs

27
一些答案涉及规范。在一次不寻常的事件中,C# 4 规范没有明确说明两个 null 字面量比较的行为。实际上,如果严格阅读规范,"null == null"应该会产生模棱两可的错误!(这是由于在 C# 2 规范清理准备 C# 3 时发生的编辑错误; 这并不是规范作者故意使其非法。)
仔细阅读规范,如果您不相信我的话。它说,int、uint、long、ulong、bool、decimal、double、float、string、enums、delegates 和 objects 上定义了相等运算符,以及所有值类型运算符的提升到空值版本。
现在我们面临一个问题; 这个集合是无限大的。在实践中,我们不会形成所有可能的委托和枚举类型上所有操作符的无限集合。规范需要在这里进行修正,以指出添加到候选集中的枚举和委托类型的唯一运算符是任一参数的类型。
因此,让我们暂时排除枚举和委托类型,因为没有一个参数有类型。
现在我们有一个重载决议问题;我们必须首先消除所有不适用的运算符,然后确定适用的运算符中最好的运算符。
显然,定义在所有不可空值类型上的运算符都是不适用的。那就只剩下可空值类型、字符串和对象上的运算符了。
现在我们可以出于"更好性"的原因消除一些内容。更好的运算符是具有更具体类型的运算符。int? 比其他任何可空数值类型更具体,所以所有这些都被排除了。String 比 object 更具体,所以 object 被排除。
这样就只剩下了字符串、int?和 bool? 的相等运算符作为适用的运算符。哪一个是最好的?没有一个比其他的更好。因此,这应该是一个歧义错误。为了使该行为符合规范,我们需要修改规范以指出“null == null”的语义是字符串相等,并且它是编译时常量true。
我昨天刚刚发现这一事实;真奇怪你要问起它。
回答其他答案中关于为什么null >= null会给出有关与int比较的警告的问题?-- 与我刚才所做的分析相同。非空值类型上的>=运算符不适用,并且剩下的运算符中int?上的运算符最好。没有关于bool?或string的>=运算符定义,因此没有歧义错误。编译器正确地将运算符分析为可空int的比较。
回答更一般的关于为什么对于null (而不是文字)的操作具有特定的不寻常行为的问题,请参见我的回答重复问题。它清楚地解释了这个决策的设计标准。简而言之:对null的操作应具有“我不知道”的操作语义。一个你不知道的数量是否大于或等于另一个你不知道的数量?唯一明智的答案是“我不知道!”但是我们需要将其转换为bool,合理的bool是“false”。但是,在比较相等性时,大多数人认为null应该等于null,即使将两个你不知道的东西进行比较也应该得到“我不知道”的结果。这种设计决策是在许多不良结果之间权衡产生的,以找到使该特性工作的最糟糕的一个,它确实使语言有些不一致,我同意。

1
非常好的答案。我终于(有点)满意了。 - user166390
1
"这里需要修正规范,以便指出在枚举和委托类型上添加到候选集的唯一运算符是那些作为任一参数类型的枚举或委托类型的运算符。" 我相信这已经在第四版中得到了解决。 - porges
此外,null == null的行为是由规范(§14.9.6)明确定义的:“如果类型A和B都是null类型,则不执行重载解析,并且结果是一个常量true或false,如§14.9中所指定的那样。” - porges
1
@Porges:谢谢!这个引用是来自哪个版本的规范?我以为Mads和我在v3规范中删除了所有关于“null类型”的引用。 - Eric Lippert
@Porges:然后第四版的装订本中有一些文字,而Word文档中没有;也许它们已经不同步了。明天我回到办公室时会检查一下。 - Eric Lippert
显示剩余6条评论

5
编译器推断在比较操作符的情况下,null 被隐式地视为 int? 类型。
Console.WriteLine(null == null); // true
Console.WriteLine(null != null); // false
Console.WriteLine(null < null);  // false*
Console.WriteLine(null <= null); // false*
Console.WriteLine(null > null);  // false*
Console.WriteLine(null >= null); // false*

Visual Studio 提供了一个警告:

*与 'int?' 类型的 null 进行比较总是产生 'false'

可以通过以下代码进行验证:

static void PrintTypes(LambdaExpression expr)
{
    Console.WriteLine(expr);
    ConstantExpression cexpr = expr.Body as ConstantExpression;
    if (cexpr != null)
    {
        Console.WriteLine("\t{0}", cexpr.Type);
        return;
    }
    BinaryExpression bexpr = expr.Body as BinaryExpression;
    if (bexpr != null)
    {
        Console.WriteLine("\t{0}", bexpr.Left.Type);
        Console.WriteLine("\t{0}", bexpr.Right.Type);
        return;
    }
    return;
}
PrintTypes((Expression<Func<bool>>)(() => null == null)); // constant folded directly to bool
PrintTypes((Expression<Func<bool>>)(() => null != null)); // constant folded directly to bool
PrintTypes((Expression<Func<bool>>)(() => null < null));
PrintTypes((Expression<Func<bool>>)(() => null <= null));
PrintTypes((Expression<Func<bool>>)(() => null > null));
PrintTypes((Expression<Func<bool>>)(() => null >= null));

输出:

() => True
        System.Boolean
() => False
        System.Boolean
() => (null < null)
        System.Nullable`1[System.Int32]
        System.Nullable`1[System.Int32]
() => (null <= null)
        System.Nullable`1[System.Int32]
        System.Nullable`1[System.Int32]
() => (null > null)
        System.Nullable`1[System.Int32]
        System.Nullable`1[System.Int32]
() => (null >= null)
        System.Nullable`1[System.Int32]
        System.Nullable`1[System.Int32]

为什么?

这对我来说似乎很合理。首先,这是C# 4.0规范的相关部分。

空字面量 §2.4.4.6:

空字面量可以隐式转换为引用类型或可空类型。

二进制数字提升 §7.3.6.2:

二进制数字提升适用于预定义的+、-、*、/、%、&、|、^、==、!=、>、<、>=和<=二元运算符的操作数。二进制数字提升隐式地将两个操作数转换为一个公共类型,该公共类型在非关系运算符的情况下也成为操作的结果类型。二进制数字提升由按照以下规则应用的规则组成:

• 如果任一操作数为decimal类型,则将另一个操作数转换为decimal类型,或者如果另一个操作数为float或double类型,则出现绑定时错误。
• 否则,如果任一操作数为double类型,则将另一个操作数转换为double类型。
• 否则,如果任一操作数为float类型,则将另一个操作数转换为float类型。
• 否则,如果任一操作数为ulong类型,则将另一个操作数转换为ulong类型,或者如果另一个操作数为sbyte、short、int或long类型,则出现绑定时错误。
• 否则,如果任一操作数为long类型,则将另一个操作数转换为long类型。
• 否则,如果任一操作数为uint类型且另一个操作数为sbyte、short或int类型,则两个操作数都转换为long类型。
• 否则,如果任一操作数为uint类型,则将另一个操作数转换为uint类型。
• 否则,两个操作数都转换为int类型。

可提升运算符 §7.3.7:

可提升运算符允许预定义和用户定义的操作符用于非空值类型的可空形式。可提升运算符由满足某些要求的预定义和用户定义的操作符构成,如下所述:

• 对于关系运算符
< > <= >=
如果操作数类型都是非空值类型并且结果类型为bool,则存在运算符的可提升形式。通过在每个操作数类型中添加单个?修饰符来构造可提升形式。如果一个或两个操作数为null,则可提升运算符产生false值。否则,可提升运算符展开操作数并应用基础运算符以产生bool结果。

单独的 null 值并没有明确的类型,它的类型是由赋值对象推断出来的。但是在这里没有进行任何赋值操作。只考虑具有语言支持(即关键字)的内置类型,object 或任何可空类型都是不错的选择。然而,object 不可比较,因此被排除在外。这就留下了可空类型作为良好的选择。但是哪种类型呢?由于左右操作数都没有指定类型,它们默认转换为(可空)int。由于两个可空值都为 null,所以返回 false。


写这个花了一些时间,不分享一下就太浪费了。 - Jeff Mercado

3

编译器似乎将null视为整数类型。 VS2008备注:"Comparing with null of type 'int?' always produces 'false'"

提示:与“int?”类型的空值进行比较始终会产生“false”。


+1 因为我认为这个答案暗示了正在发生什么:null >= null => (int?)null >= (int?)null,但是为什么 - user166390

2
这是因为编译器足够聪明,已经判断出>= null永远为false,并将您的表达式替换为常量值false。请看以下示例:
using System;

class Example
{
    static void Main()
    {
        int? i = null;

        Console.WriteLine(i >= null);
        Console.WriteLine(i == null);
    }
}

这将编译成以下代码:
class Example
{
    private static void Main()
    {
        int? i = new int?();
        Console.WriteLine(false);
        Console.WriteLine(!i.HasValue);
    }
}

但是为什么 null >= null 总是为假?这意味着 null >= null 不等同于 null == null || null > null - R. Martinho Fernandes
正如答案所述,>= null始终为false。因此,null >= null是false。你是正确的(“null >= null不等同于null == null || null > null”);如果这是真的,那么你就不会看到这种行为! - Kirk Broadhurst
@Kirk:规范中没有提到短路评估,因为它不是短路的:Func<int?> f = () => { Console.Write("foo"); return 42; }; Console.Write(null >= f());会输出"fooFalse"。我好奇的是为什么">= null总是false",而不是">总是false,并且>=在其他地方像> || ==一样运作"。 - R. Martinho Fernandes
@Martinho 但是 a.Value >= b.Value 没有被评估,因为它在 && 之后,并且 HasValue 没有被满足。如果 >= 部分没有被评估,那不就是短路了吗? - Kirk Broadhurst
1
@Kirk:是的,那部分代码短路了&&运算符。但你不能因此说>=也是短路的,因为两个操作数已经被评估过了。Func<int?> f = () => { throw new Exception(); }; a >= f()会抛出异常,但是Func<bool> f = () => { throw new Exception(); }; false && f()不会。&&可能只评估一个操作数,但是>=总是评估两个操作数。这就是一个短路,而另一个不是的原因。 - R. Martinho Fernandes
显示剩余8条评论

2
当我运行时
null >= null

我收到了一个警告信息:

与 'int?' 类型的 null 进行比较 总是产生 'false'

但我想知道为什么它被强制转换成了 int。


1
+1 因为我认为这个答案暗示了正在发生什么:null >= null => (int?)null >= (int?)null,但是为什么 - user166390

2

这不是“官方”答案,这只是我的最佳猜测。但如果你正在处理可空整数并进行比较,那么如果你处理的是两个为null的“int?”时,你很可能希望比较返回false。这样,如果返回true,你就可以确定你实际上比较了两个整数,而不是两个空值。这只是消除了单独的空值检查的需要。

话虽如此,如果它不是你期望的行为,它有潜在的混淆!


2
本行为在有关可空类型的页面中有所记录。虽然该页面没有给出真正的解释,但我的解释如下。当涉及到null时,><没有任何意义。 null是值的缺失,因此只等于另一个也没有值的变量。既然><没有意义,那么这种情况也适用于>=<=

但是你可以定义它,使得(X)null >= (X)null成立,其中X定义了>=(和<=)。因此,这是null >= null特定情况(与非法的(object)null >= (object)null不同)。 - user166390

2

他们似乎将C和SQL的范例混合在一起了。

在可空变量的上下文中,如果没有任何值被知道,null == null应该返回false,因为相等性没有意义,然而,如果针对整个C#语言这样做,会在比较引用时引发问题。


除非...你可以通过Object.ReferenceEquals()这样的方式来比较引用。 - R. Martinho Fernandes
确实如此,但那会破坏很多代码。 - 500 - Internal Server Error
+1,我非常确定这就是将所有提升的相等和不相等运算符定义为返回false的原因,如果操作数中有任何一个为null。请记住,直到C#2才引入了Nullable<T>,此时开发人员已经习惯了对于引用类型的if (myObj == null)习惯用法。对于空可空类型而言,使用相同的习惯用法会很奇怪(并且打破现有的引用类型行为是不可能的)。 - LukeH

1

简短的回答是“因为这些运算符在规范中是这样定义的”。

来自ECMA C# spec第8.19节:

“==”和“!=”的提升形式将两个空值视为相等,将空值视为与非空值不相等。“<”,“>”,“<=”,和“>=”的提升形式如果一个或两个操作数为空,则返回false。


但是什么使得 null >= null "被提升"? - user166390
@pst:提升运算符通常被定义为将未提升的运算符扩展到可空域,以便如果任一参数为空,则结果为 null。不幸的是,C# 规范即使对于不将返回值提升为可空布尔值的运算符(例如比较运算符),也使用术语“提升”。 - Eric Lippert

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接