参数的最佳实践:IEnumerable vs. IList vs. IReadOnlyCollection

17

当有值需要延迟执行时,从方法中返回一个 IEnumerable 是合适的。如果结果不会被修改,返回一个 List 或者 IList 应该只是为了当结果需要被修改时使用。否则,我会返回一个 IReadOnlyCollection,这样调用方就知道他得到的结果不能被修改(并且这使得方法可以重复使用其他调用方的对象)。

然而,在参数输入方面,我的理解还不太清楚。我可以接受一个 IEnumerable,但是如果我需要枚举多次怎么办呢?

“在发送时要保守,在接收时要开明”这句话建议使用 IEnumerable 是好的,但我不太确定。

例如,如果以下 IEnumerable 参数没有任何元素,则可以通过先检查 .Any() 来节省大量工作,而这需要在之前使用 ToList() 来避免枚举两次。

public IEnumerable<Data> RemoveHandledForDate(IEnumerable<Data> data, DateTime dateTime) {
   var dataList = data.ToList();

   if (!dataList.Any()) {
      return dataList;
   }

   var handledDataIds = new HashSet<int>(
      GetHandledDataForDate(dateTime) // Expensive database operation
         .Select(d => d.DataId)
   );

   return dataList.Where(d => !handledDataIds.Contains(d.DataId));
}

我在想这里应该使用什么样的参数签名才是最好的。一种可能的方案是IList<Data> data,但是接受一个列表意味着你计划对其进行修改,而这是不正确的——这个方法并没有改动原始列表,因此IReadOnlyCollection<Data>似乎更好。

但是,IReadOnlyCollection会强制调用者每次都要执行ToList().AsReadOnly(),这有点丑陋,即使使用自定义扩展方法.AsReadOnlyCollection也是如此。而且这并不是对可接受类型放宽要求。

在这种情况下,最佳实践是什么?

该方法不返回IReadOnlyCollection,因为在最终的Where中可能存在使用延迟执行,不需要枚举整个列表。然而,必须枚举Select,因为如果没有HashSet,执行.Contains的代价将非常高。

我不认为调用ToList会有问题,只是突然想到,如果我需要一个List来避免多次枚举,那么为什么不直接在参数中请求一个呢?所以这里的问题是,如果我不想在我的方法中使用IEnumerable,那么我是否应该接受一个IEnumerable以保持灵活性(并自行执行ToList),还是应该让调用者承担ToList().AsReadOnly()的负担?

对于不熟悉IEnumerables的人的进一步信息

真正的问题不在于Any()ToList()的成本。我知道枚举整个列表的成本比执行Any()更高。然而,假设调用者将消耗上述方法返回的所有IEnumerable项,并且假设源IEnumerable<Data> data参数来自此方法的结果:

public IEnumerable<Data> GetVeryExpensiveDataForDate(DateTime dateTime) {
    // This query is very expensive no matter how many rows are returned.
    // It costs 5 seconds on each `.GetEnumerator` call to get 1 value or 1000
    return MyDataProvider.Where(d => d.DataDate == dateTime);
}

现在,如果你这样做:

var myData = GetVeryExpensiveDataForDate(todayDate);
var unhandledData = RemoveHandledForDate(myData, todayDate);
foreach (var data in unhandledData) {
   messageBus.Dispatch(data); // fully enumerate
)
如果RemovedHandledForDate使用了AnyWhere,那么你会承担两次5秒的成本,而不是一次。这就是为什么你应该尽力避免对一个IEnumerable进行多次枚举。不要依赖于你知道实际上它是无害的这一点,因为将来可能会有不幸的开发人员在某一天调用你的方法并传入一个你从未考虑过的、具有不同特性的新实现IEnumerableIEnumerable的合约规定可以枚举它,但它没有保证重复枚举的性能特征。
事实上,一些IEnumerables是易变的,并且在后续枚举时不会返回任何数据!如果与多次枚举组合使用,则切换到其中一个将完全破坏代码(如果稍后添加了多次枚举,则非常难以诊断)。
不要对IEnumerable进行多次枚举。 如果你接受一个IEnumerable参数,实际上是在承诺对其进行0或1次枚举。

1
@BrunoJoaquim,成本不在于 ToList()Any()。在回答之前,请努力理解 IEnumerables。成本在于具有大量启动惩罚的 IEnumerable(例如查询数据库)。如果您开始两次枚举,则可能会调用两次数据库!你在这里走错了方向。 - ErikE
1
我对调用 ToList 没有问题,但随后我想到,如果我需要一个列表,为什么不在参数中直接请求一个呢?所以这里的问题是,如果我不想在我的方法中使用 IEnumerable,那么我是否应该接受它以保持自由并自己进行 ToList 转换呢? - ErikE
1
我一直认为.ToList()是一个重载的调用,它不是分配了另一个数组并将所有项复制到新数组中吗?这不会更慢吗? - Bruno Joaquim
1
@BrunoJoaquim 在某些情况下,它可能会变慢 - 但在某些情况下,它可能会提高性能。 - D Stanley
3
这个例子很好——如果底层对象是一个昂贵的查询,那么调用ToList()然后再使用两个LINQ方法会比执行两次查询更快。这就是问题的要点。 - D Stanley
显示剩余6条评论
5个回答

7

IReadOnlyCollection<T>是在IEnumerable<T>的基础上添加了一个Count属性和相应的承诺,即没有延迟执行。如果参数是您想解决此问题的地方,那么它将是适当的参数。

然而,我建议请求IEnumerable<T>,并在实现中调用ToList()

观察结果:这两种方法都有缺点,即多次枚举可能在某个时候被重构,使得参数更改或ToList()调用变得多余,我们可能会忽略这一点。我认为这是无法避免的。

在方法体中调用ToList()的情况确实表明了这一点:由于多次枚举是一种实现细节,避免它也应该是一种实现细节。这样,我们就避免了影响API。如果多次枚举被重构掉,我们也避免了将API改回来的情况。我们还避免了通过方法链传播要求,所有这些方法都必须请求IReadOnlyCollection<T>,仅仅因为我们需要多次枚举。

如果您担心创建额外列表的开销(当输出已经是一个列表或其他情况时),Resharper建议采用以下方法:

param = param as IList<SomeType> ?? param.ToList();

当然,我们可以做得更好,因为我们只需要保护延迟执行 - 不需要完整的 IList<T>
param = param as IReadOnlyCollection<SomeType> ?? param.ToList();

4
有一些方法可以让你接受 IEnumerable<T>,只枚举一次并确保不查询多次数据库。我能想到的解决方案有:
  • 不使用 AnyWhere,而是直接使用枚举器。调用 MoveNext 而不是 Any 来查看集合中是否有任何项,并在进行数据库查询后手动迭代。
  • 使用 Lazy 初始化你的 HashSet
第一个方法似乎不太好,第二个方法可能更加合理。
public IEnumerable<Data> RemoveHandledForDate(IEnumerable<Data> data, DateTime dateTime)
{
    var ids = new Lazy<HashSet<int>>(
        () => new HashSet<int>(
       GetHandledDataForDate(dateTime) // Expensive database operation
          .Select(d => d.DataId)
    ));

    return data.Where(d => !ids.Value.Contains(d.DataId));
}

如果 data 没有任何项,则不会导致对 GetHandleDataForDate 进行评估 -> 在这种情况下,Where 谓词将不会被调用。 - MarcinJuraszek
哦,我明白了,你是对的!谢谢你纠正我。我会删除我的评论。 - ErikE
经过审查,我注意到Lazy具有不同的不良特性,即在消费者迭代输出可枚举对象之前,不会迭代输入可枚举对象。这可能是意外和不希望的。 - ErikE
2
这是预期和理想的,因为您接受IEnumerable作为参数并返回IEnumerable - IEnumerable应该是惰性迭代的! - pkuderov

3
您可以在方法中使用 IEnumerable<T>,并使用类似于这里的 CachedEnumerable 对其进行包装。
该类包装了一个 IEnumerable<T> 并确保只枚举一次。如果您尝试再次枚举它,则从缓存中产生项目。
请注意,这样的包装器不会立即读取包装的可枚举项中的所有项。它仅在您从包装器枚举单个项时枚举来自包装的可枚举项的单个项,并沿途缓存单个项。
这意味着如果您在包装器上调用 Any,则仅会从包装的可枚举项中枚举单个项,然后将此类项缓存。
如果您再次使用可枚举项,则它将首先从缓存中生成第一个项,然后继续从离开处枚举原始枚举器。
您可以按照以下方式使用它:
public IEnumerable<Data> RemoveHandledForDate(IEnumerable<Data> data, DateTime dateTime)
{
    var dataWrapper = new CachedEnumerable(data);
    ...
}

注意这里的方法本身包装了参数data。这样,你不会强制使用你的方法的消费者做任何事情。


1
需要注意的是,CachedEnumerable 仍然在幕后创建一个列表,只是在枚举集合时才这样做。因此,其净效果与先调用 ToList 然后枚举列表相同。 - D Stanley
@DStanley,这是不同的。如果您只消耗了5个项目,并且原始可枚举包含100个项目,则使用“CachedEnumerable”将仅从原始可枚举中读取5个项目。如果您使用“ToList”,则在您仅需要5个项目时将读取全部100个项目。 - Yacoub Massad
那么在这种情况下,你应该执行 Take(5).ToList(),它会产生相同的效果。 - D Stanley
1
@DStanley,在这种情况下的区别在于使用CachedEnumerable可以让您自然地多次使用它作为IEnumerable<T>(例如,使用Any,然后使用Take(10)...),同时知道内部上,原始可枚举对象只会被枚举一次。如果没有使用CachedEnumerable,您决定在获取5个项目后再获取10个项目会发生什么?原始可枚举对象将被枚举两次。 - Yacoub Massad
6年后,我选择了你的答案。你的答案是唯一一个允许使用.Any()而不必将整个东西转换为.ToList()的答案,同时也不会付出多次枚举可能带来的性能或甚至缺乏正确性的代价。很抱歉我没有早些接受你的答案! - ErikE

1
我认为仅通过更改输入类型无法解决这个问题。如果您想允许比 List<T>IList<T> 更一般的结构,则必须决定如何处理这些可能的边缘情况。
要么为最坏情况做好准备,花费一点时间/内存创建具体的数据结构,要么为最好情况做好准备,冒险偶尔执行查询两次。
您可以考虑“记录”该方法多次枚举集合,以便“调用者”可以决定是否要传递“昂贵”的查询,或在调用该方法之前使查询变得有效。

1
我认为记录方法枚举集合多次的做法并不是一个好的解决方案。IEnumerable 永远不应该被重复枚举,这是使用该接口的契约的一部分。多次枚举是不好的做法重复枚举会导致指数级的减速避免对 IEnumerable 进行多次枚举等等。 - ErikE
1
我认为这是一个“成功之坑”与“失败之坑”的问题。如果你多次枚举,最终在你的系统中某个地方会有人犯错并引起问题。我更喜欢编写不会崩溃的代码,即使调用者做了我没有预料到的事情,并且不想做出可能会被调用者以有害方式违反的假设。就像将用户字符串内联到SQL代码中一样——这是一个非常糟糕的想法,即使您确切知道您的应用程序目前运行良好。有一天,有人将使用您的方法并传递不可信任的用户输入。哎呀。 - ErikE
那么,为最坏的情况做计划并创建一个清单 - 我看不到同时拥有两种方式的简单方法。人寿保险并非免费。你必须为它付费。 - D Stanley
没错!我同意你的观点!所以这个问题实际上是关于参数数据类型的,而不是(必须)是否使用 ToList。你对这方面有什么想法吗? - ErikE
就像我说的那样 - 我不认为改变参数类型会解决问题。没有一个接口类型定义了一个“IEnumerable可以安全地枚举多次”。如果你不想多次枚举底层对象,那就不要这样做。 - D Stanley
显示剩余3条评论

1
我认为 IEnumerable<T> 是一个很好的参数类型选择。它是一个简单、通用且易于提供结构的类型。没有任何关于 IEnumerable 的契约暗示只能迭代一次。
一般来说,测试 .Any() 的性能成本可能不高,但当然不能保证如此。在您描述的情况下,显然迭代第一个元素的开销可能相当大,但这并不普遍适用。
将参数类型更改为类似 IReadOnlyCollection<T>IReadOnlyList<T> 的选项可能是一个好选择,但可能只有在需要该接口提供的某些或所有属性/方法时才是一个好选择。
如果您不需要该功能,而是想保证您的方法仅迭代一次 IEnumerable,则可以通过调用 .ToList() 或将其转换为其他适当类型的集合来实现,但这是方法本身的实现细节。如果您设计的契约要求“可以迭代的东西”,那么 IEnumerable<T> 是一个非常合适的选择。
你的方法可以保证任何集合迭代的次数,但你不需要在方法之外暴露这个细节。相比之下,如果你选择在方法内部反复枚举一个 IEnumerable<T> ,那么你必须考虑到由于延迟执行可能会在不同情况下得到不同的结果等所有可能性。话虽如此,作为最佳实践,我认为尽可能避免在你自己的代码返回的 IEnumerables 中引入任何副作用是有意义的——像 Haskell 这样的语言可以安全地使用懒惰求值,因为它们非常努力地避免副作用。如果没有其他问题,消费你的代码的人可能没有你那样勤奋地防止多次枚举。

我不同意“IEnumerable”协定中没有任何暗示单次枚举的内在含义。也许这并不是真正的协定级别,但有一个非常强烈的暗示。返回“IEnumerable”和“IQueryable”的方法的编写者无需保护用户免受多次枚举的后果,并且可以自由实现多次枚举会产生负面影响。基本上,如果您两次枚举“IEnumerable”,则绝对接受其中的任何成本,并且不能依赖于该成本在未来不发生变化。这几乎肯定会导致糟糕的代码。 - ErikE
如果您确实需要多次枚举,比如在您希望底层提供程序重新获取数据的情况下(也许您正在轮询数据库并期望在方法执行期间发生更改),那么请随意多次枚举! - ErikE
@ErikE 当你将惰性求值与副作用结合使用时,这就成为了一个问题。如果你选择使用来自外部源的原始 IEnumerable 并多次枚举它,则绝对必须防范可能产生的后果。然而,个人而言,作为一种风格,我总是尽量确保我的函数返回的 IEnumerable 是无副作用的,这样它们就可以被安全地重复枚举。由于LINQ提供了函数式特性,因此在使用它时采用函数式习惯对我来说是有意义的。 - TheInnerLight
我同意你的观点,尽可能地,我编写的返回IEnumerable的函数应该尽可能无副作用。然而,当你想要对它们执行组合操作时,有些IEnumerable(以及IQueryable)就不适用于此。 - ErikE

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