为什么IEnumerable比List更高效的解释

53

我一直听说在 .net 3.5 中应该使用 IEnumerable 而不是 List,但我找不到任何参考资料或文章来解释为什么它更有效率。有人知道任何解释这个问题的内容吗?

提出这个问题的目的是为了更好地理解 IEnumerable 在底层做了什么。如果您能提供任何链接,我将进行研究并发布答案。


1
感谢大家的贡献!!非常棒的答案!! - Zaffiro
7个回答

84

IEnumerable<T> 是一个接口,被 List<T> 实现。我怀疑你听到要使用 IEnumerable<T> 的原因是因为它具有更少的限制性接口要求。

例如,考虑以下方法签名:

void Output(List<Foo> foos) 
{ 
    foreach(var foo in foos) { /* do something */ }
}

这个方法需要传入一个List的具体实现。但它只是按顺序执行一些操作。它并不真正需要随机访问或其他一些List<T>甚至IList<T>提供的功能。相反,该方法应该接受一个IEnumerable<T>

void Output(IEnumerable<Foo> foos) 
{ 
    foreach(var foo in foos) { /* do something */ }
}

现在我们使用的是支持我们需要操作的最一般(最不具体)接口。这是面向对象设计的基本特征。我们通过仅要求所需内容而不是其他不必要的内容来降低耦合度。同时,我们创建了一种更加灵活的方法,因为foos参数可以是Queue<T>List<T>或实现IEnumerable<T>接口的任何类型。我们不会强制调用者不必要地将它们的数据结构转换为List。

因此,IEnumerable<T>并不比List在“性能”或“运行时”方面更有效率。而是因为IEnumerable<T>是一个更具体的指示,更符合你的设计要求,所以是一种更有效率的设计构造。(尽管在特定情况下,这可能会导致运行时收益。)


3
这一切都是真的,但你错过了关于返回 IEnumerable<T> 的方法延迟执行效率的重要点。 - jeremyalan
2
虽然并非所有返回 IEnumerable<T> 的方法都使用延迟执行(可能这就是为什么原始回答提到“这可能会导致特定情况下的运行时增益”), - Jeff Sternal
您可以在任何接口的自己的实现中,包括 IList<T>,或者在任何基类的自己的扩展中实现延迟执行。 - yfeldblum
2
Jeff是正确的,这就是为什么我放了那里。在问题的背景下,我认为任何运行时收益都是锦上添花。也就是说,它是遵循良好设计原则的奖励。 :) - Greg D

52

当将可枚举对象转换为列表时,会失去它们几个非常好的特性。其中包括:

  • 使用延迟执行
  • 可组合
  • 无限制

首先我将介绍延迟执行。问题来了:以下代码将对输入文件中的行进行多少次迭代?

IEnumerable<string> ReadLines(string fileName)
{
    using (var rdr = new StreamReader(fileName) )
    {
       string line;
       while ( (line = rdr.ReadLine()) != null) yield return line;
    }
}


var SearchIDs = new int[] {1234,4321, 9802};

var lines = ReadLines("SomeFile.txt")
              .Where(l => l.Length > 10 && l.StartsWith("ID: "));
              .Select(l => int.Parse(l.Substring(4).Trim()));
              .Intersect(SearchIDs);
答案是确切的零。在迭代结果之前,它实际上不会执行任何操作。您需要在打开文件之前添加此代码:
foreach (string line in lines) Console.WriteLine(line);

即使程序运行完成,也只会对每行进行一次循环。与此相比,你需要在以下代码中迭代多少次才能遍历所有行:

var SearchIDs = new int[] {1234,4321, 9802};
var lines = File.ReadAllLines("SomeFile.txt"); //creates a list
lines = lines.Where(l => l.Length > 10 && l.StartsWith("ID: ")).ToList();
var ids = lines.Select(l => int.Parse(l.Substring(4).Trim())).ToList();
ids = ids.Intersect(SearchIDs).ToList();

foreach (string line in lines) Console.WriteLine(line);

即使你忽略了File.ReadAllLines()的调用并使用第一个示例中相同的迭代器块,第一个示例仍然会更快。当然,你可以使用列表编写与其一样快的代码,但这需要将读取文件的代码与解析文件的代码绑定在一起。因此,你失去了另一个重要特性:可组合性

为了展示可组合性,我将添加一个最终功能——无界序列。考虑以下内容:

IEnumerable<int> Fibonacci()
{
   int n1 = 1, n2 = 0, n;
   yield return 1;
   while (true)
   {
        n = n1 + n2;
        yield return n;
        n2 = n1;
        n1 = n;
   }
}

这段代码看起来会一直运行下去,但你可以利用IEnumerable的可组合性属性来构建一个安全的东西,例如获取前50个值或小于给定数值的每个值:

  foreach (int f in Fibonacci().Take(50)) { /* ... */ }
  foreach (int f in Fibonacci().TakeWhile(i => i < 1000000) { /* ... */ }

最后,IEnumerable更加灵活。除非您绝对需要向列表追加元素或按索引访问项目,否则编写函数以接受IEnumerable作为参数几乎总是比编写函数以接受List更好。为什么呢?因为如果需要的话,您仍然可以将列表传递给该函数 - List 就是一个IEnumerable。同样,数组和许多其他集合类型也是如此。因此,通过在此处使用IEnumerable,您可以将完全相同的函数变得更加强大,因为它可以处理更多不同类型的数据。


1
说得好!我没有足够的精力把答案写得那么详细,所以很高兴你能做到! :) - jeremyalan
1
很抱歉,我不能给两个答案打分。但是非常感谢你的回答! - Zaffiro

4

IEnumerable<T>并不比List<T>更高效,因为List<T>本身就是一个IEnumerable<T>

IEnumerable<T>接口只是.NET使用迭代器模式的一种方式,没有任何其他作用。

这个接口可以在很多类型(包括List<T>)上实现,以允许这些类型返回迭代器(即IEnumerator<T>实例),以便调用者可以迭代一系列项目。


一个极简的IEnumerable<t>会比IList<t>更小更快。虽然差别不大,但是List<t>因为更加复杂而有开销。 - DevinB
我不同意。正如其他回答所指出的那样,可以返回一个IEnumerable<T>实现,而无需创建任何实例,并支持昂贵操作的延迟执行。 - jeremyalan
澄清一下:我不同意这个答案,而不是之前的评论。 - jeremyalan
1
@devinb?你认为这值得一个踩吗?我认为Andrew只是使用了经典的“is a”技术来描述List<T>作为IEnumerable<T>的实现者。还是我误解了你的评论? - Greg D
3
@devinb:这个问题没有提到任何关于性能的内容。“熟练”可能指的是性能或正确性。性能可能是一个问题,但并不是唯一的问题。 - Joel Coehoorn
显示剩余2条评论

3

这不是效率的问题(虽然这可能是真的),而是灵活性的问题。

如果您的代码可以消耗IEnumerable而不是List,那么它将变得更加可重用。至于效率,请考虑以下代码:

 function IEnumerable<int> GetDigits()
 {

    for(int i = 0; i < 10; i++)
       yield return i
 }

 function int Sum(List<int> numbers)
 {
    int result = 0; 
    foreach(int i in numbers)
      result += i;

    return i;
 }

: 如何在GetDigits生成的数字集中获取它们的总和?
: 我需要将GetDigits生成的数字集加载到List对象中,并将其传递给Sum函数。这会使用内存,因为所有数字都需要首先加载到内存中,然后才能进行求和。但是,可以通过更改Sum函数的签名来解决此问题:-

 function int Sum(IEnumerable<int> numbers)

这意味着我可以做到:

 int sumOfDigits = Sum(GetDigits());

没有将列表加载到内存中,我只需要为当前数字和累加器变量在总和中提供存储空间。


1

这是两个不同的东西,你不能真正地比较它们。例如,在 var q = from x in ... 中,qIEnumerable,但在底层它执行了一个非常昂贵的数据库调用。

IEnumerable 只是迭代器设计模式的接口,而 List/IList 是一个数据容器。


1
推荐方法返回IEnumerable的一个原因是它比List更加通用。这意味着你可以稍后改变方法内部使用的东西,以满足其更高效的需求,只要它是一个IEnumerable,你就不需要改变方法的契约。

0
在.NET 3.5中,使用IEnumerable可以编写具有延迟执行的方法,例如以下内容:

public class MyClass
{
   private <code>List<int></code> _listOne;
   private <code>List<int></code> _listTwo;<br>
   public <code>IEnumerable<int></code>
   GetItems ()
   {
      foreach (int n in _listOne)
      {
         yield return n;
      }
      foreach (int n in _listTwo)
      {
         yield return n;
      }
   }
}

这样可以让你将两个列表合并而不需要创建一个新的List<int>对象。


你并没有创建一个新的 List<int>,但是你允许编译器代表你创建一个状态机来使这一切工作。 - Andrew Hare

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