从.NET中委托写法的演变谈开去(下):性能相关

在上一篇文章中,我们详细讲述了C# 3.0中Lambda表达式(构造委托)的使用方式,它在语义上的优势及对编程的简化——这些内容已经属于委托的“扩展内容”。不如这次谈得更远一些,就来讨论一下上文中“编程方式”的性能相关话题。

循环分离及其性能

在上文的第一个示例中,我们演示了如何使用Lambda表达式配合.NET 3.5中定义的扩展方法来方便地处理集合中的元素(筛选,转化等等)。不过有朋友可能会提出,那个“普通写法”并非是性能最高的实现方法。方便起见,也为了突出“性能”方面的问题,我们把原来的要求简化一下:将序列中的偶数平方输出为一个列表。按照那种“普通写法”可能就是:

static List<int> EvenSquare(IEnumerable<int> source)
{
    var evenList = new List<int>();
    foreach (var i in source)
    {
        if (i % 2 == 0) evenList.Add(i);
    }

    var squareList = new List<int>();
    foreach (var i in evenList) squareList.Add(i * i);

    return squareList;
}

从理论上来说,这样的写法的确比以下的做法在性能要差一些:

static List<int> EvenSquareFast(IEnumerable<int> source)
{
    List<int> result = new List<int>();
    foreach (var i in source)
    {
        if (i % 2 == 0) result.Add(i * i);
    }

    return result;
}

在第二种写法直接在一次遍历中进行筛选,并且直接转化。而第一种写法会则根据“功能描述”将做法分为两步,先筛选后转化,并使用一个临时列表进行保存。在向临时列表中添加元素的时候,List可能会在容量不够的时候加倍并复制元素,这便造成了性能损失。虽然我们通过“分析”可以得出结论,不过实际结果还是使用CodeTimer来测试一番比较妥当:

List<int> source = new List<int>();
for (var i = 0; i < 10000; i++) source.Add(i);

// 预热
EvenSquare(source);
EvenSquareFast(source);

CodeTimer.Initialize();
CodeTimer.Time("Normal", 10000, () => EvenSquare(source));
CodeTimer.Time("Fast", 10000, () => EvenSquareFast(source));

我们准备了一个长度为10000的列表,并使用EvenSquare和EvenSquareFast各执行一万次,结果如下:

Normal
        Time Elapsed:   3,506ms
        CPU Cycles:     6,713,448,335
        Gen 0:          624
        Gen 1:          1
        Gen 2:          0

Fast
        Time Elapsed:   2,283ms
        CPU Cycles:     4,390,611,247
        Gen 0:          312
        Gen 1:          0
        Gen 2:          0

结果同我们料想中的一致,EvenSquareFast无论从性能还是GC上都领先于EvenSquare方法。不过,在实际情况下,我们该选择哪种做法呢?如果是我的话,我会倾向于选择EvenSquare,理由是“清晰”二字。

EvenSquare虽然使用了额外的临时容器来保存中间结果(因此造成了性能和GC上的损失),但是它的逻辑和我们需要的功能较为匹配,我们可以很容易地看清代码所表达的含义。至于其中造成的性能损失在实际项目中可以说是微乎其微的。因为实际上我们的大部分性能是消耗在每个步骤的功能上,例如每次Int32.Parse所消耗的时间便是一个简单乘法的几十甚至几百倍。因此,虽然我们的测试体现了超过50%的性能差距,不过由于这只是“纯遍历”所消耗的时间,因此如果算上每个步骤的耗时,性能差距可能就会变成10%,5%甚至更低。

当然,如果是如上述代码那样简单的逻辑,则使用EvenSquareFast这样的实现方式也没有任何问题。事实上,我们也不必强求将所有步骤完全合并(即仅仅使用1次循环)或完全分开。我们可以在可读性与性能之间寻求一种平衡,例如将5个步骤使用两次循环来完能是更合适的方式。

说到“分解循环”,其实这类似于Martin Fowler在他的重构网站所上列出的重构方式之一:“Split Loop”。虽然Split Loop和我们的场景略有不同,但是它也是为了代码的可读性而避免将多种逻辑放在一个循环内。将循环拆开之后,还可以配合“Extract Method”或“Replace Temp with Query”等方式实现进一步的重构。自然,它也提到拆分后的性能影响:

You often see loops that are doing two different things at once, because they can do that with one pass through a loop. Indeed most programmers would feel very uncomfortable with this refactoring as it forces you to execute the loop twice - which is double the work.

But like so many optimizations, doing two different things in one loop is less clear than doing them separately. It also causes problems for further refactoring as it introduces temps that get in the way of further refactorings. So while refactoring, don't be afraid to get rid of the loop. When you optimize, if the loop is slow that will show up and it would be right to slam the loops back together at that point. You may be surprised at how often the loop isn't a bottleneck, or how the later refactorings open up another, more powerful, optimization.

这段文字提到,当拆分之后,您可能会发现更好的优化方式。高德纳爷爷也认为“过早优化是万恶之源”。这些说法都在“鼓励”我们将程序写的更清晰而不是“看起来”更有效率。

扩展方法的延迟特性

对于上面的简化需求,使用Lambda表达式和.NET 3.5中内置的扩展方法便可以写成这样:

static List<int> EvenSquareLambda(IEnumerable<int> source)
{
    return source.Where(i => i % 2 == 0).Select(i => i * i).ToList();
}

应该已经有许多朋友了解了.NET 3.5中处理集合时扩展方法具有“延迟”的效果,也就是说Where和Select中的委托(两个Lambda表达式)只有在调用ToList方法的时候才会执行。这是优点也是陷阱,在使用这些方法的时候我们还是需要了解这些方法的效果如何。不过这些方法其实都没有任何任何“取巧”之处,换句话说,它们的行为和我们正常思维的结果是一致的。如果您想得明白,能够自己写出类似的方法,或者能够“自圆其说”,十有八九也不会有什么偏差。但是如果您想不明白它们是如何构造的,还是通过实验来确定一下吧。实验的方式其实很简单,只要像我们之前验证“重复计算”陷阱那种方法就可以了,也就是观察委托的执行时机和顺序进行判断。

好,回到我们现在的问题。我们知道了“延迟”效果,我们知道了Where和Select会在ToList的时候才会进行处理。不过,它们的处理方式是什么样的,是像我们的“普通方法”那样“创建临时容器(如List),并填充返回”吗?对于这点我们不多作分析,还是通过“观察委托执行的时机和顺序”来寻找答案。使用这种方式的关键,便是在委托执行时打印出一些信息。为此,我们需要这样一个Wrap方法(您自己做试验时也可以使用这个方法):

static Func Wrap(
    Func func,
    string messgaeFormat)
{
    return i =>
    {
        var result = func(i);
        Console.WriteLine(messgaeFormat, i, result);
        return result;
    };
}

Wrap方法的目的是将一个Func委托对象进行封装,并返回一个类型相同的委托对象。每次执行封装后的委托时,都会执行我们提供的委托对象,并根据我们传递的messageFormat格式化输出。例如:

var wrapper = Wrap<int, int>(i => i + 1, "{0} + 1 = {1}");
for (var i = 0; i < 3; i++) wrapper(i);

则会输出:

0 + 1 = 1
1 + 1 = 2
2 + 1 = 3

那么,我们下面这段代码会打印出什么内容呢?

List<int> source = new List<int>();
for (var i = 0; i < 10; i++) source.Add(i);

var finalSource = source
    .Where(Wrap<int, bool>(i => i % 3 == 0, "{0} can be divided by 3? {1}"))
    .Select(Wrap<int, int>(i => i * i, "The square of {0} equals {1}."))
    .Where(Wrap<int, bool>(i => i % 2 == 0, "The result {0} can be devided by 2? {1}"));

Console.WriteLine("===== Start =====");
foreach (var item in finalSource)
{
    Console.WriteLine("===== Print {0} =====", item);
}

我们准备一个列表,其中包含0到9共十个元素,并将其进行Where…Select…Where的处理,您可以猜出经过foreach之后屏幕上的内容吗?

===== Start =====
0 can be divided by 3? True
The square of 0 equals 0.
The result 0 can be devided by 2? True
===== Print 0 =====
1 can be divided by 3? False
2 can be divided by 3? False
3 can be divided by 3? True
The square of 3 equals 9.
The result 9 can be devided by 2? False
4 can be divided by 3? False
5 can be divided by 3? False
6 can be divided by 3? True
The square of 6 equals 36.
The result 36 can be devided by 2? True
===== Print 36 =====
7 can be divided by 3? False
8 can be divided by 3? False
9 can be divided by 3? True
The square of 9 equals 81.
The result 81 can be devided by 2? False

列表中元素的执行顺序是这样的:

  1. 第一个元素“0”经过Where…Select…Where,最后被Print出来。
  2. 第二个元素“1”经过Where,中止。
  3. 第三个元素“2”经过Where,中止。
  4. 第四个元素“4”经过Where…Select…Where,中止。
  5. ……

这说明了,我们使用.NET框架自带的Where或Select方法,最终的效果和上一节中的“合并循环”类似。因为,如果创建了临时容器保存元素的话,就会在第一个Where中把所有元素都交由第一个委托(i => i % 3 == 0)执行,然后再把过滤后的元素交给Select中的委托(i => i * i)执行。请注意,在这里“合并循环”的效果对外部是隐藏的,我们的代码似乎还是一步一步地处理集合。换句话说,我们使用“分解循环”的清晰方式,但获得了“合并循环”的高效实现。这就是.NET框架这些扩展方法的神奇之处1

在我们进行具体的性能测试之前,我们再来想一下,这里出现了那么多IEnumerable对象实现了哪个GoF 23中的模式呢?枚举器?看到IEnumerable就说枚举器也太老生常谈了。其实这里同样用到了“装饰器”模式。每次Where或Select之后其实都是使用了一个新的IEnumerable对象来封装原有的对象,这样我们遍历新的枚举器时便会获得“装饰”后的效果。因此,以后如果有人问您“.NET框架中有哪些的装饰器模式的体现”,除了人人都知道的Stream之外,您还可以回答说“.NET 3.5中System.Linq.Enumerable类里的一些扩展方法”,多酷。

扩展方法的性能测试

经过上节的分析,我们知道了Where和Select等扩展方法统一了“分解循环”的外表和“合并循环”的内在,也就是兼顾了“可读性”和“性能”。我们现在就使用下面的代码来验证这一点:

List<int> source = new List<int>();
for (var i = 0; i < 10000; i++) source.Add(i);

EvenSquare(source);
EvenSquareFast(source);
EvenSquareLambda(source);

CodeTimer.Initialize();
CodeTimer.Time("Normal", 10000, () => EvenSquare(source));
CodeTimer.Time("Fast", 10000, () => EvenSquareFast(source));
CodeTimer.Time("Lambda", 10000, () => EvenSquareLambda(source));

结果如下:

Normal
        Time Elapsed:   3,127ms
        CPU Cycles:     6,362,621,144
        Gen 0:          624
        Gen 1:          3
        Gen 2:          0

Fast
        Time Elapsed:   2,031ms
        CPU Cycles:     4,070,470,778
        Gen 0:          312
        Gen 1:          0
        Gen 2:          0

Lambda
        Time Elapsed:   2,675ms
        CPU Cycles:     5,672,592,948
        Gen 0:          312
        Gen 1:          156
        Gen 2:          0

从时间上看,“扩展方法”实现的性能介于“分解循环”和“合并循环”两者之间。而GC方面,“扩展方法”实现也优于“分解循环”(您同意这个看法吗?)。因此我们可以得出以下结论:

  性能 可读性
分解循环 No. 3 No. 2
合并循环 No. 1 No. 3
扩展方法 No. 2 No. 1

至于选择哪种方式,就需要您自行判断了。

值得注意的是,无论是“延迟”还是“分解循环”的效果,刚才我们都是针对于Where和Select来谈的。事实上,还有并不是所有的扩展方法都有类似的特性,例如:

  • 非延迟:ToArray、ToList、Any,All,Count……
  • 非分解循环:OrderBy,GroupBy,ToDictionary……

不过别担心,正如上节所说,是否“延迟”,是否“分解循环”都是非常明显的。如果您可以写出类似的方法,或者能够“自圆其说”,一般您的判断也不会有什么错误。例如,OrderBy为什么不是“分解循环”的呢?因为在交由下一步枚举之前,它必须将上一步中的所有元素都获取出来才能进行排序。如果您无法“很自然”地想象出这些“原因”,那么就写一段程序来自行验证一番吧。

其他性能问题

一般来说,这些扩展方法本身不太会出现性能问题,但是任何东西都可能被滥用,这才是程序中的性能杀手。例如:

IEnumerable<int> source = ...;
for (var i = 0; i < source.Count(); i++)
{ 
    ...
}

这段代码的问题,在于每次循环时都需要不断地计算出source中元素的数量,这意味着不断地完整遍历、遍历。对于一些如Count或Any这样“立即执行”的方法,我们在使用时脑子里一定要留有一些概念:它会不会出现性能问题。这些问题的确很容易识别,但是我的确看到过这样的错误。即使在出现这些扩展方法之前,我也看到过某些朋友编写类似的代码,如在for循环中不断进行str.Split(',').Length。

还是再强调一下“重复计算”这个问题吧,它可能更容易被人忽视。如果您“重复计算”的集合是内存中的列表,这对性能来说可能影响不大。但是,试想您每次重复计算时,都要重复去外部数据源(如数据库)获取原始数据,那么此时造成的性能问题便无法忽视了。

总结

这个系列的文章就写到这里,似乎想谈的也谈的差不多了。这三篇文章是由《关于最近面试的一点感想》引起的,我写文章一开始的目的也是希望证明“委托也是个值得一提的内容”。了解委托的写法,了解这些变化并非仅仅是“茴有几种写法”。这里引用钧梓昊逑同学的评论,我认为说得很有道理:

我觉得问某件事物在不同的版本中有什么区别还是可以比较容易判断出他的掌握和理解程度的。新版本中每一个新特性的加入并不是随意加的,为什么要加,加之前有什么不足,加了之后有什么好处,在什么时候什么场景下用这些新特性,用了有什么风险和弊端,等等。如果能非常流利得回答,就算不是很正确也至少说明他去认真思考过了,因为这些东西可能是网上书上所没有的。

所以,我在面试时也会提出“delegate写法演变”或类似的问题。甚至我认为这是一个非常好的入手点,因为在.NET中如此经典的演变并不多见。如果一个人可以把这些内容都理清,我有理由相信他对待技术的态度是非常值得肯定的。反观原文后许多带有嘲讽的评论,在这背后我不知道他们是真正了解了委托而认为这些内容不值一提,还是“自以为”理解了全部内容,却不知道在这看似简单的背后还隐藏着庞大的深层的思维和理念。

我想,我们还是不要轻易地去“轻视”什么东西吧,例如在面试时轻视对方提出的问题,看重框架而轻视语言,看重所谓“底层”而轻视“应用”。最后,我打算引用韦恩卑鄙同学的话来结束全文:

一般说C语言比C#强大的,C语言也就写到Hello World而已。

相关文章

  • 从.NET中委托写法的演变谈开去(上):委托与匿名方法
  • 从.NET中委托写法的演变谈开去(中):Lambda表达式及其优势
  • 从.NET中委托写法的演变谈开去(下):性能相关
  •  

    注1:虽然Where和Select具有“延迟”效果,但是内部实现是“分解循环”还是“合并循环”则是另一种选择。您能否尝试在“延迟”的前提下,提供“分解循环”和“合并循环”两种Where或Select的实现呢?

    你可能感兴趣的:(从.NET中委托写法的演变谈开去(下):性能相关)