從.NET中委托寫法的演變談開去(下):性能相關

作者: Jeffrey Zhao  來源: 博客園  發布時間: 2010-07-27 10:53  閱讀: 582 次  推薦: 0   原文鏈接   [收藏]  

上一篇文章中,我們詳細講述了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<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

結果同我們料想中的一致,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<T>),并填充返回”嗎?對于這點我們不多作分析,還是通過“觀察委托執行的時機和順序”來尋找答案。使用這種方式的關鍵,便是在委托執行時打印出一些信息。為此,我們需要這樣一個Wrap方法(您自己做試驗時也可以使用這個方法):

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

 

Wrap方法的目的是將一個Func<T, TResult>委托對象進行封裝,并返回一個類型相同的委托對象。每次執行封裝后的委托時,都會執行我們提供的委托對象,并根據我們傳遞的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而已。

相關文章

 

注1:雖然Where和Select具有“延遲”效果,但是內部實現是“分解循環”還是“合并循環”則是另一種選擇。您能否嘗試在“延遲”的前提下,提供“分解循環”和“合并循環”兩種Where或Select的實現呢?

0
0
 
 
 
 

文章列表

全站熱搜
創作者介紹
創作者 大師兄 的頭像
大師兄

IT工程師數位筆記本

大師兄 發表在 痞客邦 留言(0) 人氣()