参数的最佳实践:IEnumerable vs. IList vs. IReadOnlyCollection
本文关键字:vs IEnumerable IList IReadOnlyCollection 最佳 参数 | 更新日期: 2023-09-27 18:21:20
当从方法返回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
,我真的应该接受一个以成为自由主义者(并且自己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 次。
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>
,只枚举一次并确保不会多次查询数据库。我能想到的解决方案:
- 您可以直接使用枚举器,而不是使用
Any
和Where
。调用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这样的语言可以安全地使用惰性评估,因为它们会竭尽全力避免副作用。 如果不出意外,使用您的代码的人在防范多重枚举方面可能不像您那样勤奋。