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

时间:2015-11-10 18:48:32

标签: c# collections parameters

当得到延迟执行中的值时,我会从方法返回 IEnumerable时得到。返回ListIList应该只是在修改结果时,否则我会返回IReadOnlyCollection,因此来电者知道他是什么&#39得到的不是用于修改(这使得该方法甚至可以重用其他调用者的对象)。

但是,在参数输入端,我不太清楚。我可以接受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>似乎更好。

但是,即使使用自定义扩展方法IReadOnlyCollectionToList().AsReadOnly()也会强制调用者每次执行.AsReadOnlyCollection都会变得有点难看。并且在接受的内容中并不自由。

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

此方法未返回IReadOnlyCollection,因为使用延迟执行的最终Where中可能有值,因为整个列表不是 required 要枚举的。但是,Select需要枚举,因为如果没有.Contains,执行HashSet的费用会很高。

我在调用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 volatile ,并且在后续枚举时不会返回任何数据!如果与多个枚举相结合,切换到一个将是一个完全破坏性的变化(如果稍后添加了多个枚举,则很难诊断出来)。

不要对IEnumerable进行多次枚举。

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

5 个答案:

答案 0 :(得分:3)

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

答案 1 :(得分:2)

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

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

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

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

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

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

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

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

答案 2 :(得分:2)

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

答案 3 :(得分:1)

我不认为只需改变输入类型就可以解决这个问题。如果您想允许比List<T>IList<T>更多的一般结构,那么您必须决定是否/如何处理这些可能的边缘情况。

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

您可以考虑记录该方法多次枚举该集合,以便调用者可以决定是否要传入一个“昂贵的”#34;在调用方法之前查询或水合查询。

答案 4 :(得分:1)

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

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

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

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

您的方法有权保证迭代任何集合的次数,您不需要将该细节暴露在方法的边界之外。

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

那就是说,作为最佳实践的一点,我认为尽可能避免在你自己的代码返回的IEnumerables中出现任何副作用是有意义的 - 像Haskell这样的语言可以利用懒惰的评估贯穿始终,因为他们竭尽全力避免副作用。如果不出意外,那些使用你的代码的人在防止多次枚举方面可能不会像你那样愚蠢。