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

本文关键字:vs IEnumerable IList IReadOnlyCollection 最佳 参数 | 更新日期: 2023-09-27 18:21:20

当从方法返回IEnumerable时,当延迟执行有价值时,我会得到。并且返回一个ListIList几乎应该只在结果将被修改时才返回,否则我会返回一个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,我真的应该接受一个以成为自由主义者(并且自己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确实Any并且确实Where,您将产生两次 5 秒的成本,而不是一次。这就是为什么您应该始终非常努力地避免多次枚举IEnumerable。不要依赖你的知识,认为它实际上是无害的,因为未来一些倒霉的开发人员有一天可能会用你从未想过的新实现IEnumerable调用你的方法,它具有不同的特征。

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

事实上,有些IEnumerables易失性的,在后续枚举时不会返回任何数据!如果与多个枚举结合使用,切换到一个将是一个完全重大的更改(如果以后添加多个枚举,则很难诊断一个(。

不要对 IEnumerable 进行多次枚举。

如果您接受 IEnumerable 参数,则实际上承诺将其枚举 0 或 1 次。

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

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

肯定有一些方法可以让您接受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>,并使用类似于此处的 CachedEnumerable 来包装它。

此类包装IEnumerable<T>并确保仅枚举一次。如果尝试再次枚举它,它将从缓存中生成项。

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

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

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

你可以做这样的事情来使用它:

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

请注意,此处的方法本身将参数包装data。这样,您就不会强迫您的方法的使用者执行任何操作。

我认为这

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

要么计划最

坏的情况并花费一点时间/内存来创建具体的数据结构,要么计划最佳情况并冒着偶尔执行两次查询的风险。

您可以考虑记录该方法多次枚举集合,以便调用方可以决定他们是要传入"昂贵"查询,还是在调用该方法之前冻结查询。

我认为IEnumerable<T>是参数类型的一个不错的选择。 它是一种简单、通用且易于提供的结构。 IEnumerable合约没有任何固有的含义意味着人们应该只迭代一次。

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

将参数类型更改为 IReadOnlyCollection<T>IReadOnlyList<T> 是一种选择,但可能仅在需要该接口提供的部分或全部属性/方法的情况下才是一个好的选择。

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

您的方法能够保证任何集合将迭代多少次,您无需在方法边界之外公开该详细信息。

相比之下,如果您选择在方法中重复枚举IEnumerable<T>,则还必须考虑可能是该选择结果的每个可能性,例如,由于延迟执行,在不同情况下可能会获得不同的结果。

也就是说,作为最佳实践的一点,我认为尽量避免自己的代码返回IEnumerables中的任何副作用是有意义的 - 像Haskell这样的语言可以安全地使用惰性评估,因为它们会竭尽全力避免副作用。 如果不出意外,使用您的代码的人在防范多重枚举方面可能不像您那样勤奋。