Enumerable.Single的执行不好?

时间:2011-01-28 06:12:08

标签: c# .net linq algorithm

我在反射器的Enumerable.cs中遇到了这个实现。

public static TSource Single<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
{
    //check parameters
    TSource local = default(TSource);
    long num = 0L;
    foreach (TSource local2 in source)
    {
        if (predicate(local2))
        {
            local = local2;
            num += 1L;
            //I think they should do something here like:
            //if (num >= 2L) throw Error.MoreThanOneMatch();
            //no necessary to continue
        }
    }
    //return different results by num's value
}

我认为如果有超过2个项目符合条件,他们应该打破循环,为什么他们总是遍历整个集合?如果反射器不正确地拆卸了dll,我会写一个简单的测试:

class DataItem
{
   private int _num;
   public DataItem(int num)
   {
      _num = num;
   }

   public int Num
   {
      get{ Console.WriteLine("getting "+_num); return _num;}
   }
} 
var source = Enumerable.Range(1,10).Select( x => new DataItem(x));
var result = source.Single(x => x.Num < 5);

对于这个测试用例,我认为它将打印“获得0,获得1”然后抛出异常。但实际情况是,它保持“变为0 ......变为10”并抛出异常。 他们是否有任何算法原因来实现这种方法?

编辑有些人认为这是因为谓词表达式的副作用,经过深思熟虑和一些测试用例后,我得出结论侧在这种情况下,效果无关紧要。如果您不同意这个结论,请举例说明。

7 个答案:

答案 0 :(得分:23)

是的,我确实发现它有点奇怪,特别是因为没有带谓词的重载(即只对序列起作用) 似乎具有快速“优化”。


然而,在BCL的辩护中,我会说单次抛出的 InvalidOperation异常是boneheaded exception,通常不应该用于控制流。没有必要这样做案件要由图书馆优化。

使用Single的代码,其中零或多个匹配是完全有效的可能性,例如:

try
{
     var item = source.Single(predicate);
     DoSomething(item);
}

catch(InvalidOperationException)
{
     DoSomethingElseUnexceptional();    
}

应该重构为使用控制流异常的代码,例如(只是一个样本;这可以更有效地实现):

var firstTwo = source.Where(predicate).Take(2).ToArray();

if(firstTwo.Length == 1) 
{
    // Note that this won't fail. If it does, this code has a bug.
    DoSomething(firstTwo.Single()); 
}
else
{
    DoSomethingElseUnexceptional();
}

换句话说,当我们希望序列只包含 一个匹配时,我们应该使用Single。它应该与First的行为相同,但使用额外的运行时断言,该序列不包含多个匹配项。与任何其他断言一样,失败,即Single抛出的情况,应该用于表示程序中的错误(在运行查询的方法中或在由传递给它的参数中)呼叫者)。

这给我们留下了两个案例:

  1. 断言成立:只有一场比赛。在这种情况下,我们希望Single使用整个序列 来断言我们的声明。 “优化”没有任何好处。实际上,人们可能会争辩说OP提供的“优化”的示例实现实际上会因为检查循环的每次迭代而变慢。
  2. 断言失败:有零个或多个匹配。在这种情况下,我们比我们可能更晚,但这并不是什么大问题,因为异常是愚蠢的:它表示一个错误必须修复。
  3. 总而言之,如果“糟糕的实施”让你在生产中表现出色,那么:

    1. 您错误地使用了Single
    2. 计划中存在错误。一旦修复了错误,这个特定的性能问题就会消失。
    3. 编辑:澄清了我的观点。

      编辑:这是有效使用Single,其中失败表示调用代码中的错误(错误参数):

      public static User GetUserById(this IEnumerable<User> users, string id)
      {
           if(users == null)
              throw new ArgumentNullException("users");
      
           // Perfectly fine if documented that a failure in the query
           // is treated as an exceptional circumstance. Caller's job 
           // to guarantee pre-condition.        
           return users.Single(user => user.Id == id);    
      }
      

答案 1 :(得分:8)

  

更新
  我得到了一些非常好的反馈,这让我重新思考。因此,我将首先提供说明我的“新”观点的答案;你仍然可以在下面找到我原来的答案。请务必阅读介于两者之间的评论,以了解我的第一个答案错过了哪一点。

新答案:

假设Single在不满足前提条件时应该抛出异常;也就是说,Single检测到的时间比没有,或者集合中的多个项目与谓词匹配。

Single只有通过遍历整个集合才能成功而不会抛出异常。它必须确保只有一个匹配的项目,因此它必须检查集合中的所有项目。

这意味着提前抛出异常(一旦找到第二个匹配项),本质上是一种优化,只有当Single的前提条件无法满足且何时抛出时才能受益例外。

正如用户CodeInChaos在下面的评论中清楚地说明的那样,优化不会是错误的,但它没有意义,因为人们通常会引入优化,这将使正确工作的代码受益,而不是有利于故障代码的优化。

因此,Single可以提前抛出异常实际上是正确的;但它没有必要,因为实际上没有额外的好处。


旧答案:

我无法给出技术上的理由为什么该方法是按原样实现的,因为我没有实现它。但是,我可以陈述我对Single运算符的目的的理解,并从那里得出我的个人结论,它确实执行得很糟糕:

我对Single

的理解

Single的目的是什么,它与例如什么不同? FirstLast

使用Single运算符基本上表达了一个假设,即必须从集合中返回一个项目:

  • 如果您没有指定谓词,则应该意味着该集合应该只包含一个项目。

  • 如果确实指定了谓词,那么它应该意味着集合中的一个项目应该满足该条件。 (使用谓词应该与items.Where(predicate).Single()具有相同的效果。)

这使Single与其他运营商(例如FirstLastTake(1)不同。这些运营商都没有要求完全一个(匹配)项目。

Single什么时候应该抛出异常?

基本上,当它发现你的假设是错误的;即,当底层集合正好产生一个(匹配)项目时。也就是说,当零个或多个项目时。

什么时候应该使用Single

当程序的逻辑可以保证集合只产生一个项目和一个项目时,使用Single是合适的。如果抛出异常,那应该意味着程序的逻辑包含错误。

如果您处理“不可靠”的集合,例如I / O输入,则应先将输入验证,然后再将其传递给SingleSingle与异常catch块一起,以确保该集合只有一个匹配项。当您调用Single时,已经已确保只有一个匹配项。

结论:

以上说明了我对Single LINQ运算符的理解。如果您遵循并同意这种理解,您应该得出结论 Single应该尽早抛出异常。没有理由等到(可能非常大)集合结束,因为一旦检测到集合中的第二个(匹配)项目,Single的前提条件就会被违反。

答案 2 :(得分:4)

在考虑这个实现时,我们必须记住,这是BCL:在各种场景中应该能够很好地运行 的通用代码。

首先,采取以下方案:

  1. 迭代10个数字,第一个和第二个元素相等
  2. 迭代超过1.000.000个数字,其中第一个和第三个元素相等
  3. 原始算法适用于10个项目,但1M会严重浪费周期。所以在这些情况下我们知道在序列的早期有两个或更多,所提议的优化会产生很好的效果。

    然后,看看这些场景:

    1. 迭代10个数字,其中第一个和最后一个元素相等
    2. 迭代超过1.000.000个数字,其中第一个和最后一个元素相等
    3. 在这些情况下,仍然需要算法检查列表中的每个项目。没有捷径。原始算法将执行良好的足够的,它履行合同。更改算法,在每次迭代中引入if实际上降低性能。对于10个项目,它可以忽略不计,但是1M它将是一个巨大的打击。

      IMO,原始实施是正确的,因为它对大多数情况来说都足够好。知道Single的实现是好的,因为它使我们能够根据我们对我们使用它的序列的了解做出明智的决定。如果某个特定场景中的性能测量显示Single正在导致瓶颈,那么:我们可以实现我们自己的变体,在该特定场景中更好

      更新:CodeInChaosEamon已正确指出,优化中引入的if测试确实对每个项目执行,仅在谓词匹配块内执行。在我的例子中,我完全忽略了这样一个事实,即提议的更改不会影响实现的整体性能。

      我同意引入优化可能会使所有方案都受益。很高兴看到最终,实施优化的决定是在性能测量的基础上做出的。

答案 3 :(得分:3)

我认为这是一个不成熟的优化“bug”。

为什么这不是因副作用而导致的合理行为

有些人认为,由于副作用,应该预期整个清单会被评估。毕竟,在正确的情况下(序列确实只有1个元素),它是完全枚举的,并且为了与这个正常情况保持一致,在所有情况下枚举整个序列更好。

尽管这是一个合理的论点,但它在整个LINQ库中面对一般实践:它们在任何地方都使用惰性求值。完全枚举序列是的一般做法,除非绝对必要;实际上,有些方法更喜欢在任何迭代时使用IList.Count - 即使该迭代可能有副作用。

此外,没有谓词的.Single() 不会出现此行为:它会尽快终止。如果论证.Single()应该尊重枚举的副作用,那么你应该期望所有重载都是等效的。

为什么速度的情况不成立

Peter Lillevold做了一个有趣的观察,它可能会更快......

foreach(var elem in elems)
    if(pred(elem)) {
        retval=elem;
        count++;
    }
if(count!=1)...

大于

foreach(var elem in elems)
    if(pred(elem)) {
        retval=elem;
        count++;
        if(count>1) ...
    }
if(count==0)...

毕竟,第二个版本,一旦检测到第一个冲突就会退出迭代,需要在循环中进行额外的测试 - 在“正确”中的测试纯粹是镇流器。整洁的理论,对吧?

除此之外,这并不是数字所致;例如在我的机器上(YMMV)Enumerable.Range(0,100000000).Where(x=>x==123).Single()实际上更快而不是Enumerable.Range(0,100000000).Single(x=>x==123)

这可能是这台机器上这个精确表达式的JITter怪癖 - 我并没有声称Where后跟无谓词Single总是更快。

但无论如何,快速失败的解决方案不太可能明显变慢。毕竟,即使在正常情况下,我们正在处理一个廉价的分支:一个从未采取的分支,因此在分支预测器上很容易。而且当然;只有在持有时才会遇到分支 - 在正常情况下每次呼叫都会遇到一次。与委托调用pred及其实现的成本相比,该成本可以忽略不计,加上接口方法.MoveNext().get_Current()的成本及其实现

与其他抽象惩罚相比,你几乎不太可能注意到一个可预测分支导致的性能下降 - 更不用说大多数序列和谓词实际上自己做了什么。

答案 4 :(得分:2)

对我来说似乎很清楚。

Single适用于调用者知道枚举只包含一个匹配的情况,因为在任何其他情况下都会抛出一个昂贵的异常。

对于此用例,采用谓词的重载必须遍历整个枚举。在没有每个循环的附加条件的情况下,这样做会稍快一些。

在我看来,当前的实现是正确的:它针对包含恰好一个匹配元素的枚举的预期用例进行了优化。

答案 5 :(得分:1)

在我看来,这似乎是一个糟糕的实施。

只是为了说明问题的潜在严重性:

var oneMillion = Enumerable.Range(1, 1000000)
                           .Select(x => { Console.WriteLine(x); return x; });

int firstEven = oneMillion.Single(x => x % 2 == 0);

以上将在抛出异常之前输出从1到1000000的所有整数。

这肯定是一个令人头疼的问题。

答案 6 :(得分:0)

我在https://connect.microsoft.com/VisualStudio/feedback/details/810457/public-static-tsource-single-tsource-this-ienumerable-tsource-source-func-tsource-bool-predicate-doesnt-throw-immediately-on-second-matching-result#

提交报告后才发现此问题

副作用论证并不成立,因为:

  1. 副作用并没有真正起作用,因为某种原因它们被称为Func
  2. 如果你确实需要副作用,那么声称在整个序列中具有副作用的版本比对于立即引发的版本声称具有副作用更合理。
  3. 它与First的行为或Single的其他重载不符。
  4. 它至少与Single的其他一些实现不匹配,例如Linq2SQL使用TOP 2来确保只返回测试多个匹配所需的两个匹配案例。
  5. 我们可以构建我们应该期望程序停止的情况,但它不会停止。
  6. 我们可以构造抛出OverflowException的情况,这不是记录的行为,因此显然是一个错误。
  7. 最重要的是,如果我们处于这样一种情况,即我们期望序列只有一个匹配元素,但我们却没有,那么显然出现了问题。除了一般原则之外,在检测错误状态时你应该做的唯一事情是在抛出之前清理(并且这个实现延迟了),具有多个匹配元素的序列的情况将与以下情况重叠:一个序列总共比预期的元素多 - 可能是因为序列有一个导致它意外循环的错误。因此,正是在一组可能触发异常的错误中,异常最为延迟。

    编辑:

    Peter Lillevold提到重复测试可能是作者选择采用他们所采用的方法的原因,作为对非特殊情况的优化。如果是这样的话,那也是不必要的,即使除了Eamon Nerbonne,它也不会有太大改善。在初始循环中不需要重复测试,因为我们可以在第一场比赛时改变我们正在测试的内容:

    public static TSource Single<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)
    {
      if(source == null)
        throw new ArgumentNullException("source");
      if(predicate == null)
        throw new ArgumentNullException("predicate");
      using(IEnumerator<TSource> en = source.GetEnumerator())
      {
        while(en.MoveNext())
        {
          TSource cur = en.Current;
          if(predicate(cur))
          {
            while(en.MoveNext())
              if(predicate(en.Current))
                throw new InvalidOperationException("Sequence contains more than one matching element");
           return cur;
          }
        }
      }
      throw new InvalidOperationException("Sequence contains no matching element");
    }