【问题标题】:Why does Enumerable.Single() iterate all elements, even when more than one item has already been found?为什么 Enumerable.Single() 迭代所有元素,即使已经找到多个元素?
【发布时间】:2018-10-29 09:51:34
【问题描述】:

在分析我们的一个应用程序时,我们发现在一些代码中出现了一个神秘的减速现象,其中我们为一个大型集合调用 Enumerable.Single(source, predicate),该集合在集合开头附近有多个与谓词匹配的项目。

调查发现the implementation of Enumerable.Single()如下:

public static TSource Single<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate) 
{
        TSource result = default(TSource);
        long count = 0;
        // Note how this always iterates through ALL the elements:
        foreach (TSource element in source) { 
            if (predicate(element)) {
                result = element;
                checked { count++; }
            }
        }
        switch (count) {
            case 0: throw Error.NoMatch();
            case 1: return result;
        }
        throw Error.MoreThanOneMatch();
    }

该实现将遍历序列的每个元素,即使多个元素已经与谓词匹配。

以下实现似乎会产生相同的结果:

public static TSource Single<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    TSource result = default(TSource);
    long count = 0;
    foreach (TSource element in source) {
        if (predicate(element)) {
            if (count == 1) // Exit loop immediately if more than one match found.
                throw Error.MoreThanOneMatch();

            result = element;
            count++; // "checked" is no longer needed.
        }
    }

    if (count == 0)
        throw Error.NoMatch();

    return result;
}

有谁知道为什么实际的实现不使用这种明显的优化?有什么我想念的吗? (我无法想象如此明显的优化会被忽略,因此肯定有一些具体的原因。)

(注意:我意识到这个问题可能会吸引意见的答案;我希望答案能够提供迭代所有元素的具体原因。如果答案实际上是“因为设计师认为这样的优化不是必要”,那么这个问题是无法回答的,我想我应该删除它......)


为了比较,看一下不带谓词的Single()的实现:

public static TSource Single<TSource>(this IEnumerable<TSource> source) 
{
    IList<TSource> list = source as IList<TSource>;
    if (list != null) {
        switch (list.Count) {
            case 0: throw Error.NoElements();
            case 1: return list[0];
        }
    }
    else {
        using (IEnumerator<TSource> e = source.GetEnumerator()) {
            if (!e.MoveNext()) throw Error.NoElements();
            TSource result = e.Current;
            if (!e.MoveNext()) return result;
        }
    }
    throw Error.MoreThanOneElement();
}

在这种情况下,他们努力为IList 添加优化。

【问题讨论】:

  • 优化故障路径很少值得做。
  • @Damien_The_Unbeliever 既然如此,他们为什么优化SingleOrDefault&lt;TSource&gt;(this IEnumerable&lt;TSource&gt; source) 将源转换为IList 并直接检查计数?
  • 第二次匹配后枚举序列时如果抛出异常会怎样?早点回来会改变行为。哪个是期望的结果是值得怀疑的。
  • 有趣 - 所以 Where 的性能实际上比 Single 更好(在完整的 .NET 框架上)与 Single 的谓词。很高兴找到@MatthewWatson。
  • 由于某种原因,很难找到 2015/2016 年的重复文件...我们只有 2011 年和 2013 年 - stackoverflow.com/questions/17743231/…。有人需要决定以哪种方式关闭 - 可能是旧版的,因为它有新的答案。

标签: c# linq .net-4.0


【解决方案1】:

你似乎不是唯一一个这样想的人。 .NET Core implementation 有一个优化版本:

using (IEnumerator<TSource> e = source.GetEnumerator())
{
    while (e.MoveNext())
    {
        TSource result = e.Current;
        if (predicate(result))
        {
            while (e.MoveNext())
            {
                if (predicate(e.Current))
                {
                    throw Error.MoreThanOneMatch();
                }
            }

            return result;
        }
    }
}

所以回答你的问题:似乎没有一个“好”的理由,只是开发人员不考虑优化这个用例。

【讨论】:

  • 不幸的是,这不仅仅是“一个不考虑优化的开发人员”,它是一个(摊销的)渐近低效的琐碎算法的实现。这是一个直截了当的性能错误……而且非常糟糕。
  • 你可以这样分类,我同意。这就是社区在优化 .NET Core 性能方面投入如此多工作的原因。正是这些微小的变化对性能产生了巨大的影响@KonradRudolph
  • 看起来程序行为的一部分是如果有多个匹配项则抛出错误。如果不遍历整个集合,它将无法满足上述行为
  • @danielmhanover 这不是真的。代码检查“不止一个”。二大于一,所以当你有第二场比赛时你可以停下来。
  • @IMil 它与向后兼容性无关,因为语义相同,只是性能特征发生了变化(剧烈地)。而且您对抛出异常的理解也是错误的:是的,应该避免这条路径,但具有讽刺意味的是,那是 fast 路径。慢速路径不会抛出异常(但需要检查每个元素)。公平地说,预期的性能因此总是 O(n),它只会在异常情况下发生变化。
【解决方案2】:

优化was applied in .NET Core

现在的代码是:

public static TSource Single<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    if (source == null)
    {
        throw Error.ArgumentNull(nameof(source));
    }

    if (predicate == null)
    {
        throw Error.ArgumentNull(nameof(predicate));
    }

    using (IEnumerator<TSource> e = source.GetEnumerator())
    {
        while (e.MoveNext())
        {
            TSource result = e.Current;
            if (predicate(result))
            {
                while (e.MoveNext())
                {
                    if (predicate(e.Current))
                    {
                        throw Error.MoreThanOneMatch();
                    }
                }

                return result;
            }
        }
    }

    throw Error.NoMatch();
}

代码甚至会在可能的情况下检查目标是否为IList&lt;T&gt;,以便避免迭代:

public static TSource Single<TSource>(this IEnumerable<TSource> source)
{
    if (source == null)
    {
        throw Error.ArgumentNull(nameof(source));
    }

    if (source is IList<TSource> list)
    {
        switch (list.Count)
        {
            case 0:
                throw Error.NoElements();
            case 1:
                return list[0];
        }
    }
    else
    {
        using (IEnumerator<TSource> e = source.GetEnumerator())
        {
            if (!e.MoveNext())
            {
                throw Error.NoElements();
            }

            TSource result = e.Current;
            if (!e.MoveNext())
            {
                return result;
            }
        }
    }

    throw Error.MoreThanOneElement();
}

更新

检查git blame 输出表明迭代优化早在 2016 年就已应用!

IList&lt;&gt; 优化是 1 年前添加的,可能是 Core 2.1 优化的一部分

【讨论】:

  • 这回答了这个问题:这实际上是一个错失的机会,已得到纠正。
  • @MatthewWatson 在引入 2.1 时添加了很多优化,但 Single.cs 似乎可以追溯到 2016 年
【解决方案3】:

正如其他答案所指出的那样,优化已被应用,但我只想提出一个假设,即他们已经这样做了,最初是因为他们无法保证谓词函数确实如此没有副作用。

我不确定是否真的会有这样的行为会被使用/有用的情况,但这是一个需要牢记的考虑因素。

【讨论】:

  • 从技术上讲,问题是“为什么它会遍历所有元素” 另一个答案是所有国家监督,这是我给出替代答案的原因,即没有声称设计师无能的答案
  • 我明白你的观点@TurtleKwitty,我很抱歉没有第一次看到它。感谢您的澄清。我删除了我的评论。
  • 我认为这个答案没有多大意义,因为同样的推理可以应用于其他方法,例如First(),它有一个很好的优化实现。
  • @Kapol,不完全; First() 从字面上获取集合中的第一项,而 single 进行搜索;第一个不需要接触变量或运行任何算法,它只是得到第一个不问问题
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-11-02
  • 1970-01-01
  • 2020-12-16
相关资源
最近更新 更多