为什么当集合为空时,.NET的foreach循环会抛出NullRefException异常?

295

我经常遇到这种情况……Do.Something(...) 返回一个空集合,就像这样:

int[] returnArray = Do.Something(...);

然后,我尝试这样使用这个集合:

foreach (int i in returnArray)
{
    // do some more stuff
}

我只是好奇,为什么foreach循环不能在一个空集合上运行?对我来说,0次迭代会在空集合上执行...相反它抛出了NullReferenceException。有人知道这可能是为什么吗?

这很烦人,因为我正在使用不清楚返回值的API,所以到处都是if (someCollection != null)


4
按照同样的推理,当给定一个"null"值时,C# 中所有语句变为无操作应该是明确定义的。您是否仅建议针对 "foreach" 循环进行此操作,还是其他语句也要包括在内? - Ken
9
@ Ken... 我觉得只需要使用 foreach 循环,因为对程序员来说,如果集合为空或不存在,显然什么都不会发生。 - Polaris878
1
类似于https://dev59.com/p2w15IYBdhLWcg3wtNyO和https://dev59.com/PWgt5IYBdhLWcg3w5xc_#11734449。 - Nathan Hartley
@Polaris878,因为“null”是缺乏知识而不是知道有一个空列表的知识。把“null”视为它绝对不是的东西,一点也不明显,可能会隐藏严重的错误。 - Rune FS
在空集合上使用foreach循环是正确的,应该抛出异常。你是否希望在空引用上调用方法也是无操作的呢?或者将空集合序列化等同于空集合的序列化?空和空集合是两个不同的概念,在C#中以一致的方式处理所有情况。 - kaalus
对于所有试图证明为什么不能在null集合上使用foreach的人,我只想说你可以在nullIDisposable上使用using。这样做没有真正的技术原因;这是编译器中的一个疏忽,如果有足够的需求改变它,那么每个人都应该在https://github.com/dotnet/csharplang上表达自己的意见。我已经厌倦了因为忘记检查集合是否为空而得到空引用异常。当没有理性的人不愿意将其视为空时,这是完全不必要的。 - Emperor Eto
12个回答

327

简短的回答是“因为编译器设计者就是这样设计的”。但实际上,你的集合对象是 null,所以编译器无法通过枚举器循环遍历该集合。

如果你确实需要执行类似的操作,可以尝试使用 null 合并运算符。

int[] array = null;

foreach (int i in array ?? Enumerable.Empty<int>())
{
   System.Console.WriteLine(string.Format("{0}", i));
}

6
请原谅我的无知,但这是高效的吗?每次迭代不会导致比较吗? - tinonetic
25
我不这么认为。查看生成的IL代码,循环在空值比较之后。 - Robaticus
17
圣死亡...有时候你需要查看IL以了解编译器的操作,以确定是否存在任何效率问题。User919426曾经问过它是否会在每次迭代中进行检查。虽然答案对于某些人来说可能是显而易见的,但对于不是所有人来说都不明显,提供提示,即查看IL将告诉您编译器正在做什么,有助于帮助人们在未来自己寻找答案。 - Robaticus
2
@Robaticus(即使以后)IL看起来是这样的,因为规范是这样说的。语法糖(又称foreach)的扩展是评估右侧的表达式并在结果上调用GetEnumerator - Rune FS
2
@RuneFS - 正确。理解规范或查看IL是找出“为什么”的一种方法。或者评估两种不同的C#方法是否归结为相同的IL。这本质上是我对Shimmy的观点。 - Robaticus
显示剩余9条评论

164

foreach循环调用GetEnumerator方法。
如果集合是null,则此方法调用将导致NullReferenceException异常。

返回null集合是一种不良的实践;您的方法应该返回一个空集合。


10
我同意,应该总是返回空集合... 不过我没有编写这些方法 :) - Polaris878
22
@Polaris,空值合并运算符来帮忙了!int[] returnArray = Do.Something() ?? new int[] {}; - JSBձոգչ
3
我不同意一种不好的做法:如果一个函数失败了,它可以返回一个空集合——这是一个构造函数的调用、内存分配,以及可能需要执行的一堆代码。或者你可以只返回“null”→显然只有一个代码要返回和一个非常短的代码要检查参数是否为“null”。这只是一个性能问题。 - Hi-Angel
我还要补充一点:当然,合并运算符无论如何都会创建一个空列表。但这已经是用户的决定了:如果在某处调用函数,比如在GUI内部,在那里性能不重要,他们可能会决定这样做。但是,如果他们正在执行需要性能的操作,则只需插入检查“如果结果不为空,请进行foreach”。虽然对于高性能通常使用C++ :Ь - Hi-Angel
@Hi-Angel,你的方法失败(从而构造对象/空集合)的频率与你需要进行空检查的频率有多大区别?如果你返回null,最后一个肯定是总是要进行空检查的。因此,如果一般情况下该方法不会失败,你的建议可能存在性能问题。 - Rune FS
@kjbartel的答案(在“https://dev59.com/p2w15IYBdhLWcg3wtNyO#32134295”)是最佳解决方案,因为它不会:a)涉及性能降级(即使不是`null`)将整个循环泛化到`Enumerable`的LCD中(使用`??`会),b)需要向每个项目添加扩展方法,或c)需要避免`null` IEnumerable(Pffft!Puh-LEAZE!SMH.)开始(因为null表示N/A,而空列表表示它适用但当前为空,即员工可能有非销售的N/A佣金或销售佣金为空)。 - Tom

62

空集合和对集合的null引用之间存在很大的区别。

在使用foreach时,内部调用了IEnumerable的GetEnumerator()方法。当引用为null时,会引发异常。

但是,拥有一个空的IEnumerableIEnumerable<T>也是完全有效的。在这种情况下,foreach不会"迭代"任何内容(因为集合是空的),但它也不会抛出异常,因为这是一个完全有效的场景。


编辑:

个人而言,如果需要解决这个问题,我建议使用扩展方法:

public static IEnumerable<T> AsNotNull<T>(this IEnumerable<T> original)
{
     return original ?? Enumerable.Empty<T>();
}

你可以直接调用:

foreach (int i in returnArray.AsNotNull())
{
    // do some more stuff
}

5
为什么foreach在获取枚举器之前不进行空值检查? - Polaris878
13
@Polaris878:因为它本来就不打算用在空集合上。在我看来,这是一件好事 - 因为空引用和空集合应该分别处理。如果你想解决这个问题,有一些方法... 我会编辑并展示另一个选项... - Reed Copsey
1
@Polaris878:我建议您重新措辞您的问题:“为什么运行时在获取枚举器之前应该进行空值检查?” - Reed Copsey
1
我想我在问“为什么不呢?”哈哈,看起来行为仍然会被很好地定义。 - Polaris878
2
@Polaris878:我想,从我的角度来看,对于集合返回null是一个错误。现在的情况是,运行时会在这种情况下给你一个有意义的异常,但如果你不喜欢这种行为,很容易绕过它(例如:上面的代码)。如果编译器将其隐藏,你将失去运行时的错误检查,但没有办法“关闭”它... - Reed Copsey
显示剩余4条评论

23

这个问题早就被回答了,但我试图用以下方法来避免空指针异常,可能对使用C#的空检查运算符的某些人有用。

     //fragments is a list which can be null
     fragments?.ForEach((obj) =>
        {
            //do something with obj
        });

2
@kjbartel在一年前就已经做到了(在“https://dev59.com/p2w15IYBdhLWcg3wtNyO#32134295”)。;) 这是最好的解决方案,因为它不会:a)涉及将整个循环泛化到Enumerable的LCD中(即使不是null),也不会降低性能(使用??会),b)需要向每个项目添加扩展方法,c)需要避免一开始就使用null IEnumerables(Pffft!Puh-LEAZE!SMH.)。 - Tom

12

另一个解决这个问题的扩展方法:

public static void ForEach<T>(this IEnumerable<T> items, Action<T> action)
{
    if(items == null) return;
    foreach (var item in items) action(item);
}

有多种方式进行消费:

(1) 使用接受 T 的方法:

returnArray.ForEach(Console.WriteLine);

(2) 使用表达式:

returnArray.ForEach(i => UpdateStatus(string.Format("{0}% complete", i)));

(3) 使用多行匿名方法
int toCompare = 10;
returnArray.ForEach(i =>
{
    var thisInt = i;
    var next = i++;
    if(next > 10) Console.WriteLine("Match: {0}", i);
});

第三个例子只是缺少一个右括号。否则,这是一段漂亮的代码,可以通过有趣的方式进行扩展(例如循环、反转、跳跃等)。感谢分享。 - user1908746
谢谢你提供如此精彩的代码。但是我没有理解第一个方法,为什么将 Console.WriteLine 作为参数传递,虽然它打印了数组元素,但我还是不理解。 - Ajay Singh
@AjaySingh Console.WriteLine 只是一个接受一个参数(Action<T>)的方法示例。1、2和3是展示将函数传递给 .ForEach 扩展方法的示例。 - Jay
@kjbartel在“https://dev59.com/p2w15IYBdhLWcg3wtNyO#32134295”上的回答是最佳解决方案,因为它不会:a)涉及性能退化(即使不是`null`),将整个循环泛化到`Enumerable`的LCD中(使用`??`会导致),b)需要添加扩展方法到每个项目,或者c)要避免出现`null`的`IEnumerable`(Pffft!Puh-LEAZE!SMH.),因为`null`表示N/A,而空列表表示正在应用,但当前为空,即员工可能有销售人员没有 N/A 或空的佣金)。 - Tom
@Tom - 但是那个答案仅适用于List<T>,这是唯一具有.ForEach<T>()的集合,我认为这很愚蠢。因此,在例如 string.Split() 之后,您不能使用该答案,而不会产生另一个性能问题:将返回的字符串数组转换为列表以使用.ForEach()。所有这些折衷方案都很愚蠢,他们应该修复它,或者从内置方法中永远不返回 null(我认为始终返回 List<T> 不可行)。 - yzorg

6

因为空集合和空的集合不是同一件事。一个空的集合是一个没有元素的集合对象;一个空的集合是不存在的对象。

这里有些事情要尝试:声明两个任意类型的集合。正常初始化一个使其为空,并将其他赋值为null。然后尝试向两个集合添加一个对象,看看会发生什么。


4

只需编写一个扩展方法来帮助您:

public static class Extensions
{
   public static void ForEachWithNull<T>(this IEnumerable<T> source, Action<T> action)
   {
      if(source == null)
      {
         return;
      }

      foreach(var item in source)
      {
         action(item);
      }
   }
}

3

这是Do.Something()的错误。在这里最好的做法是返回一个大小为0的数组(这是可能的),而不是null。


2

我认为这里提供的答案已经很清楚地解释了为什么会抛出异常。我只是希望补充一下我通常使用这些集合的方式。因为有时我需要多次使用这些集合,并且每次都需要测试是否为空。为了避免这种情况,我会采取以下做法:

    var returnArray = DoSomething() ?? Enumerable.Empty<int>();

    foreach (int i in returnArray)
    {
        // do some more stuff
    }

这样,我们可以放心地多次使用集合而无需担心异常,并且不会用过多的条件语句来污染代码。
使用空检查运算符?.也是一个很好的方法。但是,在数组(如问题中的示例)的情况下,应先将其转换为List:
    int[] returnArray = DoSomething();

    returnArray?.ToList().ForEach((i) =>
    {
        // do some more stuff
    });

6
在代码库中,我讨厌的一件事情是为了使用ForEach方法而将其转换为列表。 - huysentruitw
1
我同意... 我尽可能避免那个问题。 :( - Alielson Piffer

2

因为在幕后,foreach会获取一个枚举器,相当于以下代码:

using (IEnumerator<int> enumerator = returnArray.getEnumerator()) {
    while (enumerator.MoveNext()) {
        int i = enumerator.Current;
        // do some more stuff
    }
}

3
那为什么它不能先检查是否为空,然后跳过循环呢?也就是说,与扩展方法中显示的内容完全相同。问题是,如果为null默认跳过循环还是抛出异常更好?我认为最好跳过!似乎空容器应该被跳过而不是循环,因为循环旨在在容器非空时执行某些操作。 - AbstractDissonance
@AbstractDissonance 你可以用相同的论点去讨论所有作为 null 引用的情况,例如在访问成员时。通常这是一个错误,如果不是的话,使用另一位用户提供的扩展方法来处理就足够简单了。 - Lucero
2
我不这么认为。foreach循环是用来操作集合的,与直接引用空对象是不同的。虽然有人可能会争辩说,但我敢打赌,如果你分析世界上所有的代码,你会发现大多数foreach循环都有一些前置的空值检查,以便在集合为“null”(因此被视为为空)时跳过循环。我认为没有人会认为在空集合上进行循环是他们想要的,他们宁愿忽略循环,如果集合为空的话。也许,相反,可以使用foreach?(var x in C)。 - AbstractDissonance
1
我主要想表达的是,这会在代码中产生一些垃圾,因为人们不得不每次检查,而没有充分的理由。当然,扩展可以工作,但是可以添加语言特性来避免这些问题,而不会有太多问题。(我认为当前的方法主要会产生隐藏的错误,因为程序员可能会忘记进行检查,从而导致异常...因为他期望循环之前的某个地方进行检查,或者认为它已经被预初始化(但它可能已经改变了)。但无论哪种情况,行为都与空白相同。 - AbstractDissonance
@AbstractDissonance 嗯,通过一些适当的静态分析,您可以知道哪里可能有空值,哪里不可能。如果您在不希望出现空值的地方得到了一个空值,与其默默地忽略问题,我认为最好是失败(遵循“快速失败”的精神)。因此,我认为这是正确的行为。 - Lucero

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