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

Best practice for parameter: IEnumerable vs. IList vs. IReadOnlyCollection

我知道什么时候会 return 一个方法中的 IEnumerable — 当延迟执行有价值时。 return 一个 ListIList 几乎应该只在结果要被修改时,否则我会 return 一个 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。这在被接受的方面并不自由。

这种情况下的最佳做法是什么?

此方法不是 returning IReadOnlyCollection 因为最终 Where 使用延迟执行可能有价值,因为整个列表不是 必需的进行枚举。但是,需要枚举 Select,因为如果没有 HashSet.Contains 的成本将非常可怕。

我调用 ToList 没有问题,我只是想如果我需要一个 List 来避免多重枚举,为什么我不直接在范围?所以这里的问题是,如果我不想在我的方法中使用 IEnumerable,我是否应该真正接受一个以便变得自由(并且 ToList 我自己),或者我应该把负担放在ToList().AsReadOnly()?

的来电者

为不熟悉 IEnumerables 的人提供的更多信息

这里真正的问题不是 Any()ToList() 的成本。我知道枚举整个列表比 Any() 花费更多。但是,假设调用者将从上述方法中消费 return IEnumerable 中的所有项目,并假设 source 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
)

如果 RemovedHandledForDateAny Where,你将承担两次 的 5 秒费用,而不是一次。这就是为什么 您应该始终竭尽全力避免多次枚举 IEnumerable。不要相信它实际上是无害的,因为将来某些倒霉的开发人员可能会在某天使用您从未想过的新实现 IEnumerable 调用您的方法,它具有不同的特征。

IEnumerable 的合同说你可以枚举它。它不承诺多次这样做的性能特征。

事实上,一些 IEnumerablesvolatile 并且不会 return 在随后的枚举中有任何数据!如果与多重枚举相结合,切换到一个将是一个完全破坏性的变化(如果稍后添加多重枚举,则很难诊断)。

不要对 IEnumerable 进行多次枚举。

如果您接受一个 IEnumerable 参数,您实际上承诺精确枚举它 0 次或 1 次。

肯定有一些方法可以让您接受 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));
}

您可以在方法中使用 IEnumerable<T>,并使用类似于 here 的 CachedEnumerable 来包装它。

这个 class 包装了一个 IEnumerable<T> 并确保它只被枚举一次。如果您尝试再次枚举它,它会从缓存中产生项目。

请注意,此类包装器不会立即从包装的枚举中读取所有项目。当您从包装器中枚举单个项目时,它仅从包装的可枚举中枚举单个项目,并沿途缓存单个项目。

这意味着如果您在包装器上调用 Any,则只会从包装的枚举中枚举单个项目,然后缓存该项目。

如果您随后再次使用枚举,它将首先从缓存中产生第一个项目,然后从它离开的地方继续枚举原始枚举器。

您可以像这样使用它:

public IEnumerable<Data> RemoveHandledForDate(IEnumerable<Data> data, DateTime dateTime)
{
    var dataWrapper = new CachedEnumerable(data);
    ...
}

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

我认为这不能仅通过更改输入类型来解决。如果您想允许比 List<T>IList<T> 更通用的结构,那么您必须决定 if/how 来处理这些可能的边缘情况。

要么为最坏的情况做计划并花一点 time/memory 创建一个具体的数据结构,要么为最好的情况做计划并冒着偶尔查询被执行两次的风险。

您可能会考虑 记录 该方法多次枚举集合,以便 调用者 可以决定是否要传递一个"expensive" 查询,或在调用该方法之前对查询进行水合。

我认为 IEnumerable<T> 是参数类型的一个不错的选择。它是一种简单、通用且易于提供的结构。 IEnumerable 契约没有任何内在的暗示人们应该只迭代一次。

一般来说,测试 .Any() 的性能成本可能并不高,但当然不能保证如此。在您描述的情况下,显然迭代第一个元素会产生相当大的开销,但这绝不是普遍的。

将参数类型更改为 IReadOnlyCollection<T>IReadOnlyList<T> 之类的东西是一种选择,但在该接口提供的部分或全部 properties/methods 的情况下可能只是一个好的选择是必需的。

如果您不需要该功能,而是想保证您的方法只迭代 IEnumerable 一次,您可以通过调用 .ToList() 或将其转换为其他适当的方式来实现集合类型,但这是方法本身的实现细节。如果您正在设计的合约需要"something which can be iterated"那么IEnumerable<T>是一个非常合适的选择。

您的方法有能力保证任何集合将被迭代多少次,您不需要在方法范围之外公开该细节。

相比之下,如果您确实选择在方法中重复枚举 IEnumerable<T>,那么您还必须考虑该选择可能导致的每一种可能性,例如可能在不同的方法中得到不同的结果由于延迟执行的情况。

也就是说,作为最佳实践点,我认为尽量避免您自己的代码返回的 IEnumerables 中的任何副作用是有意义的 - 像 [=36 这样的语言=] 可以在整个过程中安全地使用惰性求值,因为它们竭尽全力避免副作用。如果不出意外,使用你代码的人可能没有你那么勤奋地防范多重枚举。

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();