参数的最佳实践:IEnumerable与IList对比IReadOnlyCollection

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

但是,在参数输入方面,我有点不太清楚。 我可以采用IEnumerable ,但如果我需要多次枚举怎么办?

俗话说“ 你发送的东西要保守,你接受的东西要自由 ”,建议拿一个IEnumerable是好的,但我不太确定。

例如,如果以下IEnumerable参数中没有元素,则可以通过首先检查.Any()来保存此方法中的大量工作,这需要在此之前使用ToList()避免枚举两次

 public IEnumerable RemoveHandledForDate(IEnumerable data, DateTime dateTime) { var dataList = data.ToList(); if (!dataList.Any()) { return dataList; } var handledDataIds = new HashSet( GetHandledDataForDate(dateTime) // Expensive database operation .Select(d => d.DataId) ); return dataList.Where(d => !handledDataIds.Contains(d.DataId)); } 

所以我想知道什么是最好的签名,在这里? 一种可能性是IList data ,但接受列表表明您计划修改它,这是不正确的 – 此方法不会触及原始列表,因此IReadOnlyCollection似乎更好。

但是IReadOnlyCollection强制调用者每次执行ToList().AsReadOnly()都会变得有点难看,即使使用自定义扩展方法.AsReadOnlyCollection 。 在接受的东西中,这并不是自由主义者。

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

此方法不返回IReadOnlyCollection因为最终使用延迟执行可能有值,因为不需要枚举整个列表。 但是,需要枚举Select ,因为没有HashSet ,执行.Contains的成本会很糟糕。

我没有调用ToList的问题,我刚想到如果我需要一个List以避免多次枚举,为什么我不只是在参数中要求一个? 所以这里的问题是,如果我不想在我的方法中使用IEnumerable ,我是否真的应该接受一个以便自由(并自己ToList ),或者我应该把调用者的负担加到ToList().AsReadOnly()

有关IEnumerables不熟悉的人的更多信息

这里真正的问题不是Any()ToList()的成本。 我知道枚举整个列表的成本比执行Any() 。 但是,假设调用者将使用上述方法返回IEnumerable中的所有项,并假设源IEnumerable data参数来自此方法的结果:

 public IEnumerable 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 ,只枚举一次并确保你不多次查询数据库。 我能想到的解决方案:

  • 而不是使用AnyWhere可以直接使用枚举器。 调用MoveNext而不是Any来查看集合中是否有任何项目,并在进行数据库查询后手动迭代。
  • 使用Lazy初始化您的HashSet

第一个似乎很难看,第二个可能实际上很有意义:

 public IEnumerable RemoveHandledForDate(IEnumerable data, DateTime dateTime) { var ids = new Lazy>( () => new HashSet( GetHandledDataForDate(dateTime) // Expensive database operation .Select(d => d.DataId) )); return data.Where(d => !ids.Value.Contains(d.DataId)); } 

您可以在方法中使用IEnumerable ,并使用类似于此处的CachedEnumerable来包装它。

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

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

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

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

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

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

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

IReadOnlyCollectionIEnumerable IReadOnlyCollection添加一个Count属性和相应的承诺,即没有延迟执行 。 如果参数是您要解决此问题的位置,那么它将是要求的适当参数。

但是,我建议请求IEnumerable ,并在实现本身中调用ToList()

观察:两种方法的缺点是多重枚举可能在某些时候被重构,使参数更改或ToList()调用冗余,我们可能会忽略。 我不认为这是可以避免的。

这个案例的确代表在方法体中调用ToList() :由于多个枚举是一个实现细节,避免它应该也是一个实现细节。 这样,我们就可以避免影响API了。 如果多次枚举被重构,我们也会避免更改API。 我们还避免通过一系列方法传播需求,否则可能都会决定要求IReadOnlyCollection ,这只是因为我们的多次枚举。

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

 param = param as IList ?? param.ToList(); 

当然,我们可以做得更好,因为我们只需要防止延迟执行 – 不需要一个成熟的IList

 param = param as IReadOnlyCollection ?? param.ToList(); 

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

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

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

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

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

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

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

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

相反,如果您确实选择在方法中重复枚举IEnumerable那么您还必须考虑可能是该选择的结果的每个可能性,例如由于延迟执行可能在不同情况下获得不同的结果。

也就是说,作为最佳实践的一点,我认为尽可能避免在您自己的代码返回的IEnumerables任何副作用是有意义的 – 像Haskell这样的语言可以安全地使用惰性评估,因为它们去了努力避免副作用。 如果不出意外,那些使用你的代码的人在防止多次枚举时可能不会像你那样愚蠢。