有趣的是,一些人选择使用
IEnumerable<T>
,而另一些人则坚持使用
IReadOnlyList<T>
。
现在,让我们诚实点。
IEnumerable<T>
很有用,非常有用。在大多数情况下,您只需将此方法放入某个库中,并将实用函数抛到您认为是集合的任何地方,然后完成它。但是,正确使用
IEnumerable<T>
有点棘手,我将在这里指出...
IEnumerable
假设OP正在使用Linq并想从序列中获取随机元素,他最终使用了@Yannick的代码,该代码最终包含在实用程序辅助函数库中:
public static T AnyOne<T>(this IEnumerable<T> source)
{
int endExclusive = source.Count();
int randomIndex = Random.Range(0, endExclusive);
return source.ElementAt(randomIndex);
}
现在,这基本上有两个作用:
- 计算源中元素的数量。如果源是一个简单的
IEnumerable<T>
,这意味着要遍历列表中的所有元素;如果源是一个类似于 List<T>
的东西,则会使用 Count
属性。
- 重置可枚举对象,转到元素
randomIndex
,抓取并返回它。
这里有两件事情可能会出错。首先,您的 IEnumerable 可能是一个缓慢、顺序存储的集合,做
Count
可能会以意想不到的方式破坏应用程序的性能。例如,从设备流式传输可能会让您陷入麻烦。尽管如此,您完全可以认为这是集合特性固有的特征,这种争论我个人认为是正确的。
其次 - 这也许更重要 - 没有保证你的可枚举对象在每次迭代中都返回相同的序列(因此也没有保证你的代码不会崩溃)。例如,请考虑这个看似无害的代码片段,可能对测试目的有用:
IEnumerable<int> GenerateRandomDataset()
{
Random rnd = new Random();
int count = rnd.Next(10, 100);
for (int i=0; i<count; ++i)
{
yield return new rnd.Next(0, 1000000);
}
}
第一次迭代(调用
Count()
),您可能会生成99个结果。 您选择第98个元素。 接下来您调用
ElementAt
,第二次迭代仅生成12个结果,您的应用程序崩溃了,这不好。
修复IEnumerable实现
正如我们所见,
IEnumerable<T>
实现的问题在于您必须两次遍历数据。 我们可以通过一次遍历数据来解决这个问题。
“技巧”其实很简单:如果我们已经看到了一个元素,我们肯定希望考虑返回它。 考虑到所有元素,有50%/ 50%的机会我们会返回这个元素。 如果我们看到第三个元素,则有33%/ 33%/ 33%的机会我们将返回此元素。 因此,更好的实现可能是这个:
public static T AnyOne<T>(this IEnumerable<T> source)
{
Random rnd = new Random();
double count = 1;
T result = default(T);
foreach (var element in source)
{
if (rnd.NextDouble() <= (1.0 / count))
{
result = element;
}
++count;
}
return result;
}
顺便提一下:如果我们使用 Linq,那么我们期望操作只使用 IEnumerable<T>
一次(仅限一次!)。现在你知道为什么了。
让它适用于列表和数组
虽然这是一个巧妙的技巧,但是如果我们在 List<T>
上工作,性能将会变慢,这没有任何意义,因为由于索引和 Count
可以使用,我们知道有更好的实现可用。
我们正在寻找的是此更好解决方案的共同点,它在尽可能多的集合中使用。我们最终得到的东西就是实现了我们所需的一切的 IReadOnlyList<T>
接口。
由于我们知道 IReadOnlyList<T>
的属性为真,因此我们现在可以安全地使用索引和 Count
,而不会冒着崩溃应用程序的风险。
但是,虽然 IReadOnlyList<T>
看起来很吸引人,但由于某种原因,IList<T>
似乎没有实现它...这基本上意味着在实践中,IReadOnlyList<T>
有点冒险。在这方面,我相信有很多 IList<T>
实现,而不是 IReadOnlyList<T>
实现。因此,最好支持两个接口。
这就引导我们到了这里的解决方案:
public static T AnyOne<T>(this IEnumerable<T> source)
{
var rnd = new Random();
var list = source as IReadOnlyList<T>;
if (list != null)
{
int index = rnd.Next(0, list.Count);
return list[index];
}
var list2 = source as IList<T>;
if (list2 != null)
{
int index = rnd.Next(0, list2.Count);
return list2[index];
}
else
{
double count = 1;
T result = default(T);
foreach (var element in source)
{
if (rnd.NextDouble() <= (1.0 / count))
{
result = element;
}
++count;
}
return result;
}
}
注意:对于更复杂的情况,请查看策略模式。
随机数
@Yannick Motton指出,需要小心使用Random
,因为如果您多次调用此类方法,则不会真正获得随机结果。 Random是使用RTC初始化的,因此如果您多次创建新实例,则不会更改种子。
一个简单的解决方法如下:
private static int seed = 12873;
Random rnd = new Random(Interlocked.Increment(ref seed));
这样一来,每次调用 AnyOne 函数时,随机数生成器都会接收到另一个种子,并且即使在紧密的循环中也能正常工作。
总结:
因此,需要逐个迭代 IEnumerable,不要重复迭代。否则,用户可能会得到意外的结果。
如果您拥有比简单枚举更好的功能,则无需遍历所有元素。最好立即获取正确的结果。
非常仔细地考虑您正在检查的接口。虽然 IReadOnlyList 明显是最佳选择,但它没有从 IList 继承,这意味着在实践中效果会较差。
最终结果是完美运行。
IList<T>
没有实现IReadOnlyList<T>
。可能是因为你可以有一个只能写而不能读的列表。这就是为什么我添加的答案包括了两种情况。对于自定义列表,你可能会遇到同样的麻烦;不幸的是,比起IReadOnlyList
,IList
有更多的实现。 - atlasteIEnumerable<T>
是错误的。然而,问题是实现者是否在实现阶段考虑了这些隐含的约束条件。我认为这不现实;个人认为这是Linq中一个严重的设计缺陷,我见过很多真实的bug就是因为这个原因发生的。 - atlasteAnyOne
是一个实用函数,这意味着适当的设计将归结为“可重用性”。考虑到这一点,对于这种情况,IEnumerable<T>
听起来是一个相当合理的设计要求,即使我们同意它在某些方面有缺陷。如果实现细节然后需要Count和索引器,则仅意味着我们必须强制执行它。这可能听起来出乎意料,但实际上已经有很多Linq函数这样做了(例如OrderBy)。 - atlasteIList<T>
(自2.0版本起可用) - Ivan Stoev