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< 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表達式編碼時需要優先考慮代碼清晰度的理由。
【編輯推薦】