为什么IEnumerable<T>.ToList<T>()返回List<T>而不是IList<T>?

68
扩展方法ToList()返回一个List<TSource>ToDictionary()遵循相同的模式,返回Dictionary<TKey,TSource>
我很好奇为什么这些方法不将它们的返回值类型设置为IList<TSource>IDictionary<TKey,TSource>。这似乎更奇怪,因为ToLookup<TSource,TKey>将其返回值类型设置为接口而不是实际实现。
查看使用dotPeek或其他反编译器的这些扩展方法的源代码,我们可以看到以下实现(显示较短的ToList()):
public static List<TSource> ToList<TSource>(this IEnumerable<TSource> source) { 
   if (source == null) throw Error.ArgumentNull("source");
   return new List<TSource>(source); 
}

为什么该方法将其返回值类型定义为接口的特定实现而不是接口本身呢?唯一的改变就是返回类型。

我很好奇,因为 IEnumerable<> 扩展在它们的签名中非常一致,除了这两种情况。我一直认为这有点奇怪。

此外,为了使事情更加混乱,ToLookup() 的文档声明:

  

根据指定的键选择器函数从 IEnumerable 创建 Lookup。

但返回类型却是 ILookup<TKey, TElement>

Edulinq中,Jon Skeet提到返回类型是 List<T> 而不是 IList<T>,但没有进一步讨论这个主题。
经过广泛搜索,没有找到答案,所以我问你:

不将返回值类型定义为接口背后是否存在任何设计决策,还是只是偶然事件?


3
List<T> 实现了 IList<T> 接口。你无法实例化一个接口,必须实例化某些东西。即使在后台,IEnumerable<T> 也是真正的其他某些东西,您只是使用接口来访问它。但是接口并不是一个完整的对象。 - sircodesalot
15
@sircodesalot Waldfee 明显已经意识到这一点。他的整个问题都建立在这个事实之上! - RB.
2
@sircodesalot:确实如此。如果不是这样的话,这个问题就没有意义了。 - Daniel Hilgarth
3
既然它必须将列表包装在具体类型中并返回给调用者,那么它返回列表也无妨,因为这是使用IList的特定实现。只要返回一个IList实现,对您来说就没有任何区别。公平地说,该方法也被称为 ToList 而不是 ToIList - Charleh
4
我不确定没有来自 .Net 设计团队的评论,我们是否能得出一个明确的答案,但我们可以假设这是一个务实的选择。List 类比 IList 接口具有更丰富的扩展功能。在效用和纯度之间需要做出选择。 - Jodrell
显示剩余10条评论
10个回答

50

返回 List<T> 的优点在于,那些不是 IList<T> 的一部分的 List<T> 方法很容易使用。您可以使用 List<T> 做很多事情,而不能使用 IList<T>

相比之下,Lookup<TKey, TElement> 只有一个可用方法,而 ILookup<TKey, TElement> 没有 (ApplyResultSelector),而且您可能永远不会使用它。


8
这可能是答案,但如果没有记录设计过程中做出的决定,我们怎么知道呢? (+1) - Jodrell
2
同意,问题的开头是“为什么”,而不是“为什么会”。我相信没有人会误解我的答案是理由,而不是一个理由。 :) - user743382
4
对于正确答案要点赞。然而,微软应该将特定于List<T>的那些方法更改为对IList<T>进行扩展方法 - 这样,不仅是List<T>所有IList<T>实现都可以受益于 Reverse()Sort()等方法!我几年前在MS Connect上提出了这个建议;它得到了很多赞同,并且他们说这是一个好主意,但一直把它推迟到下一个版本。然后他们完全删除了这个建议 :( - BlueRaja - Danny Pflughoeft
@BlueRaja-DannyPflughoeft: 添加此类扩展方法的一个主要问题是很难确定其语义应该是什么。它是否应该调用其中一个方法从集合中删除所有项目并以正确的顺序添加它们,或者是否应该删除每个项目并将其重新插入到其适当的位置,或者其他是什么?我希望这些事情已经包含在 IList<T> 中,但框架提供了实现者可以链接的方法,以及更好的查询集合能够支持的操作和可承诺的可变性或不可变性的方法。 - supercat
@supercat,我在这里没有问题- Reverse()等的实现应该是他们文档中所说的。没有理由不用in-place方式执行,所以我想这就是他们要做的。此外,我的建议是将这些扩展方法添加到IList<T>中,而不是更通用的ICollection<T>-通过实现IList<T>,实现者已经同意某种程度的可变性。 - BlueRaja - Danny Pflughoeft
接受这个答案,因为它非常简洁明了。正如Jodrell所评论的那样,只有设计团队真正知道答案,但这听起来可能是原因。 - Waldfee

12

这些决定可能感觉是任意的,但我猜测 ToList() 返回 List<T> 而不是接口是因为 List<T> 既实现了 IList<T> 但又添加了其他在普通的 IList<T> 类型对象中不存在的成员。

例如,AddRange()

请参阅 IList<T> 应该实现的内容 (http://msdn.microsoft.com/zh-cn/library/5y536ey6.aspx):

public interface IList<T> : ICollection<T>, 
    IEnumerable<T>, IEnumerable

并且 List<T> (http://msdn.microsoft.com/en-us/library/6sh2ey19.aspx):

public class List<T> : IList<T>, ICollection<T>, 
    IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T>, 
    IEnumerable

也许您的代码并不需要IReadOnlyList<T>IReadOnlyCollection<T>ICollection,但.NET Framework和其他产品中的其他组件可能依赖于更专业的列表对象,这就是.NET开发团队决定不返回接口的原因。

不要认为总是返回接口是最佳实践。只有在您的代码或第三方代码需要此类封装时才会使用它们。


非常清晰的解释,谢谢。我很少使用List实现的功能,除了IList,因此没有想到它。 - Waldfee
关于你最后一段的问题,返回接口或具体类型是一个情境决定,而不是适用于所有情况,就像其他事情一样 - 我的问题特别针对这种情况。 - Waldfee
1
@Waldfee 没问题。认为.NET FW是许多事情的基础。也许一些类型和决策并没有反映在框架本身中,而是由其他产品的要求甚至用户故事来决定。我不是来自.NET devteam,但这应该是原因。关于最后一段,当然,我只是想指出返回接口并不总是必需的 :) - Matías Fidemraizer
问题不在于 IList<T> 应该实现什么,而是它实际包含了什么。IList<T> 中应该有很多东西,但实际上并没有;即使像 SortAddRange 这样的东西可以通过接受 IList<T> 并对单个项进行操作的静态帮助方法来处理,并且许多实现可能会链接到这些方法,但性能敏感的实现在许多情况下可以通过直接在其后备存储上实现这些操作来实现超过2倍(如果不是10倍)的速度提升。 - supercat
@supercat 是的。顺便说一下,这超出了问题和答案范围的讨论。ArgumentOutOfRangeException: 问题非常简单,而参数过于复杂 :D - Matías Fidemraizer
显示剩余4条评论

8
仅拥有一个List而非IList有许多优点。首先,List拥有一些IList没有的方法。您也可以“了解”其实现方式,从而可以推断出其行为方式。例如,您知道它可以高效地添加到结尾,但不能添加到开头,您知道它的索引器非常快等等。
您不需要担心数据结构会被更改为LinkedList并破坏应用程序的性能。在这种情况下,了解数据结构的实现方式,而不仅仅是遵循的规范,确实在许多情况下非常重要。行为永远不应该改变。
您还无法将IList传递给接受List的方法,这是您经常看到的情况。经常使用ToList,因为人们真的需要List实例,以匹配他们无法控制的签名,而IList对此无济于事。
然后,我们问自己返回IList有哪些优点。嗯,我们可能会返回一些其他类型的列表的实现,但正如之前提到的,这可能会产生非常糟糕的后果,几乎肯定会比使用任何其他类型带来更多的负面影响。使用接口而不是实现可能会让您感到舒适,但即使在这种情况下,我也认为这不是一种好的心态。通常情况下,返回接口并不优于返回具体的实现方式。在您的方法参数中,应尽可能使用定义所需功能最少的接口,以便您的调用方可以传入执行您需要的任何实现,而您应该提供一个具体的实现,调用方可以“看到”并能够将结果与对象一起使用。传递接口会限制该值,这只有在偶尔需要这样做时才是想要的。
现在我们来讨论一下,为什么要返回ILookup而不是Lookup?首先,Lookup不是一个公共类。在System.Collections.*中没有Lookup。通过LINQ公开的Lookup类并没有公开构造函数。你只能通过ToLookup来使用它。此外,Lookup类并没有比ILookup多出任何功能。在这种情况下,他们专门围绕着这个方法(ToLookup)设计了接口,并且Lookup类是专门设计用来实现这个接口的。因为所有这些,几乎所有关于List的讨论都不适用于这里。返回Lookup是否会有问题呢?实际上并没有太大关系。在这种情况下,两种方式均可。

好而全面的回答,尽管我不同意最后一段的第一部分:System.Linq.Lookup<>是公开可访问的,仅仅因为它没有公共构造函数并不意味着它不能作为返回类型使用。然而,第二部分对于为什么返回类型是接口的解释很好。 - Waldfee
@Waldfee 它并不是为公众访问而设计的。从代码角度来看,它在技术上是“public”的,但它并不是专门为公众使用而设计的,因此您可以将该类视为内部类。 - Servy

6
在我看来,返回一个List<T>是合理的,因为方法名称是ToList。否则它必须被命名为ToIList。这个方法的目的是将一个不具体的IEnumerable<T>转换为特定类型的List<T>
如果您有一个名称不具体的方法,比如GetResults,那么像IList<T>IEnumerable<T>这样的返回类型对我来说似乎是合适的。
如果您使用反射器查看Lookup<TKey, TElement>类的实现,您会看到很多仅对LINQ本身可访问的internal成员。没有公共构造函数,而且Lookup对象是不可变的。因此直接暴露Lookup没有任何优势。 Lookup<TKey, TElement>类似乎是LINQ内部使用的,并不适用于公开使用。

2
但是为什么ToLookup()的返回类型是一个接口呢? - Waldfee
3
可能是因为 ToLookup 方法返回的对象在逻辑上是不可变的,这意味着工厂可以根据它将包含哪些内容来自由优化返回类型。例如,如果要返回的实例恰好只有一个项目,那么为其构建哈希表就是愚蠢的。让 ToLookup 返回接口使得在这种情况下可以返回不同的具体类型;即使当前的实现没有对该情况进行优化,未来的实现也可以这样做。 - supercat

4
这是程序员在使用接口和具体类型时常常难以理解的常见问题之一。
返回实现了IList接口的具体List只会给方法使用者提供更多信息。以下是List对象通过MSDN实现的内容:
[SerializableAttribute]
public class List<T> : IList<T>, ICollection<T>, IList, ICollection, 
    IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable

作为一个 List<T> 返回给我们的是除了 List<T> 本身以外,所有这些接口的成员都可以调用。例如,我们只能在 List<T> 上使用 List.BinarySearch(T),因为它存在于 List<T> 中,但不存在于 IList<T> 中。
一般来说,为了最大程度地提高方法的灵活性,我们应该将最抽象的类型作为参数(即只使用我们要使用的内容),并返回尽可能不抽象的类型(以允许更多的功能性返回对象)。

4

我认为返回List<>而不是IList<>的决定是因为调用ToList的更常见用例之一是强制立即评估整个列表。通过返回List<>可以保证这一点。使用IList<>,实现仍然可以是延迟的,这将破坏调用的“主要”目的。


3

通常在调用ToList()方法时,您需要寻找具体类型,否则该项目可能仍然保持IEnumerable类型。除非您正在执行需要具体列表的操作,否则不需要将其转换为List。


5
他没有在 English.stackexchange.com 发布;-) - RB.
3
我不喜欢 IList 接口,因为数组伪装成实现了它,但是当你想调用 Add 或者 Remove 这样的 IList 特定方法时,你会遇到一个异常 :-(。当然你可以测试 IsReadOnly 属性,但仍然觉得缩小(或至少限制)接口而不是扩展它是违反Liskov替换原则的。 - Olivier Jacot-Descombes
在API设计中,消费接口而返回实现是很好的做法(总的来说是这样的;但返回数组应该被禁止)。 - Morten Mertner
@MortenMertner 真的吗?我本以为应该是另一种方式。你有任何讨论这个问题的参考资料吗? - RB.
3
如果你认为一个函数的返回类型是承诺,那么把这个承诺变得更小是有意义的。 - Jodrell
显示剩余12条评论

3
简短回答是,通常建议按照权威的框架设计指南返回最特定的类型。(抱歉我没有手头的引用,但我清楚地记得这一点,因为它与更倾向于相反的Java社区指南形成鲜明对比)。
对我来说,这很有道理。你总是可以做例如IList<int> list = x.ToList(),只有库作者需要考虑能够支持具体的返回类型。 ToLookup<T>在众多方法中是独特的。但完全符合指南:它是库作者愿意支持的最具体类型(正如其他人所指出的,具体的Lookup类型似乎更多地是一个内部类型,不适合公开使用)。

1
在Docs Microsoft上有一个关于框架设计指南的全面而详细的主题,可能是Stephen提到的那本书: https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/ - koviroli

2

因为 List<T> 实际上实现了多种接口,而不仅仅是 IList 接口:

public class List<T> : IList<T>, ICollection<T>, IList, ICollection, IReadOnlyList<T>, IReadOnlyCollection<T>, IEnumerable<T>, IEnumerable{

}

每个接口都定义了List必须符合的一系列功能。选择其中一个特定接口,会使大部分实现无法使用。
如果您确实想返回IList,那么您可以拥有自己的简单包装器。
public static IList<TSource> ToIList<TSource>(this IEnumerable<TSource> source)
{
    if (source == null) throw new ArgumentNullException(source);
    return source.ToList();
}

1
如果一个函数返回一个新构造的不可变对象,调用者通常不需要关心返回的具体类型,只要它能够容纳实际包含的数据即可。例如,一个应该返回一个IImmutableMatrix的函数可能通常会返回一个由私有数组支持的ImmutableArrayMatrix,但如果所有单元格都为零,则可能返回一个仅由WidthHeight字段(带有一个始终返回零的getter)支持的ZeroMatrix。调用者不会在意是否给了一个ImmutableArrayMatrix矩阵或ZeroMatrix;两种类型都允许读取它们的所有单元格,并保证其值永远不会改变,这才是调用者关心的。
另一方面,返回允许开放式变异的新构造对象的函数通常应该返回调用者期望的精确类型。除非调用者可以通过请求不同的返回类型(例如通过调用ToyotaFactory.Build("Corolla")ToyotaFactory.Build("Prius"))来获得其他返回类型,否则声明的返回类型没有其他理由。虽然返回不可变数据持有对象的工厂可以根据要包含的数据选择类型,但返回自由可变类型的工厂将无法知道可能放入其中的数据。如果不同的调用者有不同的需求(例如,对于现有示例,某些调用者的需求可以通过数组满足,而其他人则不能),则应该为他们提供一组工厂方法的选择。
顺便说一下,类似于IEnumerator<T>.GetEnumerator()的东西是一个特殊情况。返回的对象几乎总是可变的,但只以非常高度限制的方式;事实上,预计返回的对象无论其类型如何,都将具有恰好一个可变状态:它在枚举序列中的位置。尽管IEnumerator<T>预计是可变的,但派生类实现中会发生变化的状态部分并不相同。

相同的签名,相同的返回类型,不是吗?也许具体实现会有所不同,但返回类型不能改变。 - Jodrell
@Jodrell:问题在于一个函数,其现有实现始终返回特定类型,是否应该返回精确的类型,还是应该返回不太具体的类型。我认为,工厂可以返回最适合未来版本可能要返回的所有内容的最具体类型,并且在许多情况下,可自由变动类型的工厂要比不可变或基本不可变类型的工厂更不可能返回广泛变化的类型。 - supercat

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