返回IList<T>比返回T[]或List<T>更糟糕吗?

81

像这样的问题的答案:List<T> 或 IList<T> 总是认为返回接口比返回集合的具体实现更好。但我对此认为有些困惑。实例化接口是不可能的,因此如果你的方法返回一个接口,它实际上仍然返回一个特定的实现。我通过编写两个小方法进行了一些实验:

public static IList<int> ExposeArrayIList()
{
    return new[] { 1, 2, 3 };
}

public static IList<int> ExposeListIList()
{
    return new List<int> { 1, 2, 3 };
}

并在我的测试程序中使用它们:

static void Main(string[] args)
{
    IList<int> arrayIList = ExposeArrayIList();
    IList<int> listIList = ExposeListIList();

    //Will give a runtime error
    arrayIList.Add(10);
    //Runs perfectly
    listIList.Add(10);
}

在两种情况下,当我尝试添加一个新值时,我的编译器没有给出错误,但是显然将我的数组公开为IList<T>的方法在我尝试向其中添加内容时会产生运行时错误。因此,那些不知道我的方法内部情况但必须向其中添加值的人被迫首先将我的IList复制到List中,以便能够在不冒错误风险的情况下添加值。当然,他们可以进行类型检查,看看他们是否正在处理ListArray,但如果他们不这样做,并且他们想要向集合中添加项目,则即使它已经是List,他们也别无选择,只能将IList复制到List中。数组永远不应该公开为IList吗?
另一个我关心的问题基于链接问题的接受答案(强调是我的):
如果您通过库公开类供他人使用,通常应该通过接口而不是具体实现来公开它。这样做有助于在以后决定更改类的实现以使用不同的具体类时。在这种情况下,您的库的用户不需要更新其代码,因为接口没有更改。

如果只在内部使用,则可能无关紧要,使用List可能没问题。

想象一下,有人真的使用了从我的ExposeListIlist()方法中获得的IList<T>来添加/删除值。一切正常。但是现在,就像答案建议的那样,因为返回接口更加灵活,我返回一个数组而不是List(对我来说没有问题!),然后他们就会有一个惊喜...

简而言之:

1)公开接口会导致不必要的强制转换吗?这不重要吗?

2)有时,如果库的用户不使用强制转换,则当您更改方法时,他们的代码可能会出错,即使该方法仍然完全正常。

我可能过于深思熟虑了,但我不理解返回接口优于返回实现的共识。


9
返回IEnumerable<T>可以获得编译时的安全性,同时仍然可以使用所有LINQ扩展方法,这些方法通常会尝试通过将其转换为特定类型(例如像ICollection<T>这样的类型)来优化性能,以使用Count属性而不是枚举它。 - Tim Schmelter
4
数组通过一种"技巧"实现了IList<T>。如果要公开返回它的方法,返回一个真正实现该接口的类型。 - CodeCaster
3
不要做出你无法实现的承诺。当客户程序员看到你的“我会返回一个列表”的合同时,很可能会感到失望。这并不是正确的做法,所以不要这样做。 - Hans Passant
7
IList 接口是 ICollection 接口的子接口,也是所有非泛型列表的基本接口。IList 实现分为三类:只读、固定大小和可变大小。只读 IList 不能被修改。固定大小的 IList 不允许添加或删除元素,但允许修改现有元素。可变大小的 IList 允许添加、删除和修改元素。 - Hamid Pourjam
3
如果你希望消费者能够向你的列表中添加项目,那么一开始就不要将其声明为 IEnumerable<T>。这样返回 IList<T>List<T> 就可以了。 - Tim Schmelter
显示剩余11条评论
5个回答

110

也许这不是直接回答你的问题,但在.NET 4.5+中,我更喜欢遵循以下规则来设计公共或受保护的API:

  • 如果只有枚举可用,则返回IEnumerable<T>
  • 如果既有枚举又有项目计数,则返回IReadOnlyCollection<T>
  • 如果有枚举、项目计数和索引访问,则返回IReadOnlyList<T>
  • 如果具备枚举、项目计数和修改功能,则返回ICollection<T>
  • 如果同时具备枚举、项计数、索引访问和修改功能,则返回IList<T>

最后两个选项假设方法不能返回数组作为IList<T>实现。


2
确实不是答案,但这是一个很好的经验法则,对我非常有帮助 :) 最近我一直在为选择返回什么而苦恼。干杯! - Alexander Derck
1
这确实是正确的原则+1。为了完整起见,我会添加IReadOnlyCollection<T>ICollection<T>,它们适用于非可索引容器(例如后者实际上是EF实体子集导航属性的标准)。 - Ivan Stoev
不幸的是,ICollection<T>IList<T>都不从IReadOnlyCollection<T>IReadOnlyList<T>继承;但是,List<T>类和T[]数组都可以继承。在某些情况下,这可能是使用后者之一的充分理由。 - supercat
3
需要注意的是,IReadOnlyCollection<T>IReadOnlyList<T>具有与C++的const相同的缺陷。有两种可能性:集合是不可变的,或者只是访问集合的方式禁止您修改它。而你无法区分这两种情况。 - ach
1
@Panzercrisis:当然我会选择第二个选项——IEnumerable<T>。作为API开发人员,您完全了解允许的用例。如果方法结果仅打算枚举,则没有理由公开IList<T>。对结果的操作越少,方法实现就越灵活。例如,公开IEnumerable<T>允许您更改实现为yield return,而IList<T>不允许这样做。 - Dennis
显示剩余4条评论

31
No,因为消费者应该知道IList究竟是什么:

IList是ICollection接口的一个子类,也是所有非泛型列表的基本接口。IList的实现分为三类:只读、固定大小和可变大小。只读的IList不能被修改。固定大小的IList不允许添加或删除元素,但允许修改已有元素。可变大小的IList允许添加、删除和修改元素。

您可以检查IList.IsFixedSizeIList.IsReadOnly并根据需要进行操作。

我认为IList是一个“臃肿”的接口,它应该被拆分成多个更小的接口,而且当将数组作为IList返回时,它也违反了里氏替换原则

如果您想决定是否返回接口,请阅读更多


更新

我进一步发现IList<T>不实现IList,而且IsReadOnly通过基本接口ICollection<T>可以访问,但IList<T>没有IsFixedSize。了解更多有关为什么泛型IList<>不继承非泛型IList?


8
这些属性虽然很好,但在我看来仍然违背了接口的目的。对我而言,接口是一个契约,不应该依赖于任何检查或其他东西。 - Alexander Derck
1
如果开发人员提供了一个具有(非固定大小/非只读)List<T>语义的附加接口,那么对开发人员来说会更友好。但是,为什么没有包括这个接口可能有充分的理由...但是,是的,我认为你提出的涵盖各种功能的几个接口的想法会是一个更明智的选择; - spender
1
IList<T>是一个臃肿的接口,但它是从数组和列表中获得的最多的。因此,如果它被分成多个接口,您可能无法使用某些方法来处理列表和数组,而只能使用其中一个,即使所有要使用的方法都受支持(例如IList.Count或索引器)。所以在我看来这是一个好决定。如果在极少数情况下,当我们想在数组上使用Add时会导致NotSupportedException,那么这就是交易。 - Tim Schmelter
1
@dotctor 有意思,我昨天还在读有关 Liskov 替换原则的内容,这让我想了很多 :) 感谢你的回答。 - Alexander Derck
6
结合Dennis的规则,这个回答可以帮助更好地理解如何处理这个困境。毋庸置疑,在.NET框架中违反了Liskov替换原则,但这并不是什么特别需要担心的事情。 - Fabjan
显示剩余9条评论

13
与所有“接口与实现”问题一样,您需要意识到公开成员的含义:它定义了该类的公共API。如果将 List<T>作为成员(字段、属性、方法等)公开,您告诉该成员的使用者:通过访问此方法获得的类型 一个List<T>或其派生类型。
现在,如果您公开一个接口,则使用具体类型隐藏了该类的“实现细节”。当然,您无法实例化IList<T>,但您可以使用Collection<T>List<T>、派生自此的类型或实现了IList<T>的自己的类型。
实际问题是“为什么Array实现了IList<T>”,或者“为什么IList<T>接口有这么多成员”。
这也取决于您希望成员的使用者做什么。如果您通过Expose...成员实际返回内部成员,您将始终希望返回new List<T>(internalMember),否则消费者可能会尝试将它们转换为IList<T>并通过那个集合修改您的内部成员。
如果您只希望消费者遍历结果,请公开IEnumerable<T>IReadOnlyCollection<T>

3
实际的问题是“为什么Array实现了IList<T>接口”,或者说“IList<T>接口为什么有这么多成员”。这确实是我一直在纠结的事情。 - Alexander Derck
2
@Fabjan - IList<T>确实有添加和删除项的方法,这就是为什么它不适合数组实现的原因。IsReadOnly属性是一个hack,用于掩盖差异,但它应该被分成(至少)两个接口。 - Lee
@Lee 哎呀,你说得对,我删除了不准确的评论。 - Fabjan
1
@AlexanderDerck 这是我之前曾经问过的问题 https://dev59.com/7W025IYBdhLWcg3wf2KE - oleksii
@oleksii确实很相似,但我不会称其为重复。 - Alexander Derck

8

小心被断章取义的泛泛之谈。

返回接口比返回具体实现更好。

这个引用只有在SOLID原则的背景下使用才有意义。有5个原则,但是为了讨论方便,我们只谈最后3个。

依赖反转原则

应"依赖抽象,而不是具体实现."

在我看来,这个原则最难理解。但是如果您仔细看看引用,它看起来很像您的原始引用。

依赖于接口(抽象),不要依赖具体实现(具体化)。

这仍然有点困惑,但是如果我们开始同时应用其他原则,它就开始变得更加清晰了。

里氏替换原则

“程序中的对象应该是可以在不改变该程序正确性的情况下被它的子类实例替换的。”

正如你所指出的,返回一个Array和返回一个List<T>是明显不同的行为,即使它们都实现了IList<T>。这绝对违反了LSP。
重要的是要意识到接口是关于消费者的。如果你返回一个接口,你就创建了一个合同,该接口上的任何方法或属性都可以在不改变程序行为的情况下使用。
接口隔离原则
“许多特定于客户端的接口比一个通用接口更好。”
如果你返回一个接口,应该返回你的实现支持的最具体的客户端接口。换句话说,如果你不希望客户端调用Add方法,你不应该返回一个带有Add方法的接口。
不幸的是,在.NET框架中的接口(特别是早期版本)并不总是理想的客户端特定接口。尽管正如@Dennis在他的答案中指出的那样,在.NET 4.5+中有更多选择。

阅读Liskov原则是我最初对IList<T>感到困惑的原因。 - Alexander Derck
在这个特定的情况下,是数组违反了LSP。如果你要返回一个IList,就不应该返回一个数组,因为这会违反契约。你已经与调用者建立了协议,他们得到的是一个可以添加和删除项目的列表。 - craftworkgames

5
返回结果如下:

返回接口并不一定比返回集合的具体实现更好。你应该总是有一个很好的理由使用接口而不是具体类型。在你的例子中,这样做似乎毫无意义。

使用接口的有效原因可能包括:

  1. 你不知道返回接口的方法的实现会是什么样子,而且可能会有很多个这样的方法,随着时间的推移不断开发。这些方法可能是其他公司的其他人编写的。所以你只想就最基本的需求达成一致,让他们自己决定如何实现功能。

  2. 你想以类型安全的方式公开一些与你的类层次结构无关的常见功能。不同基础类型的对象应该实现你的接口提供相同的方法。

可以说1和2基本上是同样的原因。它们是两种不同的场景,最终导致相同的需求。

"这是一个合同"。如果合同是与你自己的应用程序以及其功能和时间都是封闭的,那么通常没有使用接口的意义。


1
我不是很同意,如果可以返回一个接口的话,我会返回一个接口,因为这是唯一需要的东西。如果一个方法期望一个接口,我可以传递一个只实现该接口的类,也可以传递一个实现该接口和其他许多东西的类。使用接口可以更容易地扩展你的应用程序,而无需重写现有的内容。 - Alexander Derck
2
以“如果有其他人使用这个类(库)?”和“如果我想要添加功能到我想要返回的具体类型?”的心态开发所有软件(尤其是您的类和类库)可以非常启迪人心。BCL类型可以被密封,因此您无法扩展它们。然后,如果您想要返回其他内容,则会陷入困境,因为您的API承诺公开该确切类型,而不是接口。返回接口几乎总是比返回具体实现更好。 - CodeCaster
1
我同意(真的),我应该添加第三个原因:“将返回对象的功能限制为其本质,以向调用者澄清消息”。我也尽可能返回IReadOnlyXxx接口,而不是用于组装集合的完整对象。 - Martin Maat

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