在上文的***个示例中,我们演示了如何使用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< int>可能会在容量不够的时候加倍并复制元素,这便造成了性能损失。虽然我们通过“分析”可以得出结论,不过实际结果还是使用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
Lambda表达式的执行:性能比对与结论
结果同我们料想中的一致,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.
这段文字提到,当拆分之后,您可能会发现更好的优化方式。高德纳爷爷也认为“过早优化是万恶之源”。这些说法都在“鼓励”我们将程序写的更清晰而不是“看起来”更有效率。
以上便分析了使用C# Lambda表达式编码时需要优先考虑代码清晰度的理由。
【编辑推荐】