|
在上一篇文章中,我們通過一些示例談論了IL與CLR中的一些特性。IL與C#等高級語言的作用類似,主要用于表示程序的邏輯。由于它同樣了解太多CLR中的高級特性,因此它在大部分情況下依舊無法展現出比那些高級語言更多的CLR細節。因此,如果您想要通過學習IL來了解CLR,那么這個過程很可能會“事倍功半”。因此,從這個角度來說,老趙并不傾向于學習IL。不過嚴格說來,即使IL無法看出CLR的細節,也不足以說明“IL無用”——這里說“無用”自然有些夸張。但是,如果我們還發現,那些原本被認為需要通過IL挖掘到的東西,現在都可以使用更好的方法來獲得,并且可以起到“事半功倍”的效果,那么似乎我們真的沒有太多理由去追逐IL了。
在這篇文章中,我們使用最多的工具便是.NET Reflector,從.NET 1.x開始,.NET Reflector就是一個探究.NET框架(主要是BCL)內部實現的有力工具,它可以把一個程序集高度還原成C#等高級語言的代碼。在它的幫助下,幾乎所有程序集實現都變得一目了然,這大大方便了我們的工作。老趙對此深有感觸,因為在某段不算短的時間內,我使用.NET Reflector閱讀過的代碼數量遠遠超過了自己編寫的代碼。與此相反的是,老趙幾乎沒有使用IL探索過.NET框架下的任何問題。這可能還涉及到方式方法和個人做事方式,但是如果這真有效果的話,為什么要舍近求遠呢?希望您看過了這篇文章,也可以像我一樣擺脫IL,投入.NET Reflector的懷抱。
示例一:探究語言細節
C#語言從1.0到3.0版本的進化過程中,大部分新特性都是依靠編譯器的魔法。就拿C#3.0的各種新特性來說,Lambda表達式,LINQ,自動屬性等等,完全都是基于CLR 2.0中已有的功能,再配合新的C#編譯器而產生的各種神奇效果。有些朋友認為,掌握IL之后便把握了.NET的根本,以不變應萬變,只要讀懂IL,那么這些新特性都不會對您形成困擾。這話說的并沒有錯,只是老趙認為,“掌握IL”在這里只是一個“充分條件”而不是一個“必要條件”,我們完全可以使用.NET Reflector將程序集反編譯成C#代碼來觀察這些。
這里我們使用.NET Reflector來觀察最最常見,最最普通的foreach關鍵字的功能。我們都知道foreach是遍歷一個IEnumerble對象內元素的方式,我們也都知道foreach其實是GoF Iterator模式的實現,通過MoveNext方法和Current屬性進行配合共同完成。不過大部分朋友似乎都是從IL進行觀察,或是“聽別人說”而了解這些的。事實上,.NET Reflector也可以很容易地證實這一點,只是這中間還有些“特別”的地方。那么首先,我們還是來準備一個最簡單的foreach語句:
static void DoEnumerable(IEnumerable<int> source){ foreach (int i in source) { Console.WriteLine(i); }}
如果觀察它的IL代碼,即使不了解IL的朋友也一定可以看出,其中涉及到了GetEnumerator,MoveNext和Current等成員的訪問:
.method private hidebysig static void DoEnumerable( class [mscorlib]System.Collections.Generic.IEnumerable`1 source) cil managed{ .maxstack 1 .locals init ( [0] int32 i, [1] class [mscorlib]System.Collections.Generic.IEnumerator`1 CS$5$0000) L_0000: ldarg.0 L_0001: callvirt instance class [mscorlib]System.Collections.Generic.IEnumerator`1 [mscorlib]System.Collections.Generic.IEnumerable`1::GetEnumerator() L_0006: stloc.1 L_0007: br.s L_0016 L_0009: ldloc.1 L_000a: callvirt instance !0 [mscorlib]System.Collections.Generic.IEnumerator`1::get_Current() L_000f: stloc.0 L_0010: ldloc.0 L_0011: call void [mscorlib]System.Console::WriteLine(int32) L_0016: ldloc.1 L_0017: callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext() L_001c: brtrue.s L_0009 L_001e: leave.s L_002a L_0020: ldloc.1 L_0021: brfalse.s L_0029 L_0023: ldloc.1 L_0024: callvirt instance void [mscorlib]System.IDisposable::Dispose() L_0029: endfinally L_002a: ret .try L_0007 to L_0020 finally handler L_0020 to L_002a}
但是,如果使用.NET Reflector觀察它的C#代碼又會如何呢?
private static void DoEnumerable(IEnumerable source){ foreach (int i in source) { Console.WriteLine(i); }}
請注意,以上這段是由.NET Reflector從IL反編譯后得到的C#代碼,這簡直……不是簡直,是完完全全真真正正地和我們剛才寫的代碼一模一樣!這就是.NET Reflector的強大之處,由于它意識到IL調用了IEnumerable.GetEnumerator方法,因此它就“留心”判斷這個調用的“模式”是否符合一個標準的foreach,如果是,那么就會顯示為一個foreach語句而不是while...MoveNext。不過,這難道不就掩蓋了“事物本質”了嗎?要知道我們的目標可是探究foreach的形式,既然.NET Reflector幫不了的話,我們不還是需要去觀察IL嗎?
剛才老趙提到,.NET Reflector在判斷IL代碼時發現一些標準的模式時會進行代碼“優化”。那么我們能否讓.NET Reflector不要做這種“優化”呢?答案是肯定的,只是需要您在.NET Reflector中進行一些簡單的設置:
打開View菜單中的Options對話框,在左側Disassembler選項卡中修改Optimization級別,默認很可能是.NET 3.5,而現在我們要將其修改為None。這么做會讓.NET Reflector最大程度地“直接”翻譯IL代碼,而不做一些額外優化。將Optimization級別設為None以后,DoEnumerable方法的代碼就變為了:
static void DoEnumerable(IEnumerable<int> source){ int num; IEnumerator<int> enumerator; enumerator = source.GetEnumerator();Label_0007: try { goto Label_0016; Label_0009: num = enumerator.Current; Console.WriteLine(num); Label_0016: if (enumerator.MoveNext() != null) { goto Label_0009; } goto Label_002A; } finally { Label_0020: if (enumerator == null) { goto Label_0029; } enumerator.Dispose(); Label_0029: ; }Label_002A: return;}
這是C#代碼嗎?為什么會有那么多的goto?為什么MoveNext方法返回的布爾值可以和null進行比較?其實您把這段代碼復制粘貼后會發現,它能夠正常編譯通過,效果也和剛才的foreach語句完全一樣。這就是去除“優化”的效果。老趙在上一篇文章中談到說:IL和C#一樣,都是用于表現程序邏輯。C#使用if...else、while、for等等豐富語法,而在IL中就會變成判斷+跳轉語句。上面的C#代碼便直接保留了IL的這個“特性”。不過還好,我們還是可以看出try...finally,可以看出MoveNext方法和Current屬性的訪問,可以看到程序使用Console.WriteLine輸出數據。至此,我們便發現了foreach語句的真面目。從現在開始,在您準備深入IL之前,老趙也建議您可以嘗試一下使用None Optimization來觀察C#代碼。
實事求是地說,上面的C#代碼的“轉向邏輯”并不那么清晰,因此您在理解的時候可以把它復制到編輯器中,進行一些簡單調整。但是從老趙的經驗上來看,需要使用None Optimization進行探索的地方非常少見。foreach是一個,還有便是C#中的其他一些“別名”,如使用using關鍵字管理IDisposable對象,以及lock關鍵字。而且,其實這段邏輯也只是沒有優化IL中的跳轉語句而已,已經比IL本身要直觀許多了。此外,關于對象創建,變量聲明,方法調用,屬性訪問,事件加載……一切的一切都還是最常用的C#代碼。因為還是那個原因:從大部分情況上來看,IL也只是表現了程序邏輯,并沒有比C#等語言體現出更多的細節。
我在這里舉了一個較為極端的例子,因為我發現不少朋友并沒有嘗試過使用None Optimization來觀察過代碼。這里也可以看出,.NET Reflector的“優化級別”還不夠“細致”。不過這應該是一個“產品設計”的正常結果,因為foreach/using/lock的關鍵字都是從.NET 1.0誕生伊始就存在的,也就是說,即使.NET Reflector選擇將IL編譯為C# 1.0,它的表現形式依舊是“標準模式”,這方面可能就不能過于強求了吧。至于其他一些探索,例如C#中的自動屬性,Lambda表達式構建表達式樹或匿名委托,乃至C# 4.0中的dynamic關鍵字,都是使用.NET 3.5 Optimization進行探索便可得知的結果。您可以回憶一下自己看過的文章,其中有多少是使用IL解釋問題的呢?
示例二:學習.NET平臺上的其他語言
在.NET平臺上,任何語言都會先編譯為IL,然后再運行時由JIT轉化為機器碼。因此有種說法是,只要把握了IL,.NET平臺上各種語言之間的遷移都會變得容易。對此老趙有不同看法。在以前討論語言是否重要的時候,老趙提到,語言它并不僅僅是一種文字表現形式,而是一種“思維方式”的改變,這可能會影響到您程序的編碼風格,API設計乃至架構(這個鏈接可能打不開,因為……)。實際上,如果您只是在C#與VB.NET之間進行遷移,原本就是一件相當容易的事情,因為它們之間“語言”的各種概念和特性都非常接近。而一種改變您思維的語言,才是真正有價值,而且值得進行比較和探索的。如果一味地追求“把握本源”,那么甚至還有比IL更低抽象的事務,但這些就已經違背了“創造一門語言”,以及您學習它的目的了,不是嗎?
當然,探索也是需要的,尤其是.NET平臺上的各種語言,他們被統一在同樣的平臺上,這本身就是一種很好的資源。這種資源就是所謂的“比較學習”。您可以把新的語言和您熟悉的語言進行對比,吸收其中的長處(如優秀的思維方式),這樣便可以更好地使用舊有語言。例如,您把F#類庫轉化為C#代碼進行觀察之后,發現其中大量函數式編程風格的API是使用“委托”來實現的,您可能就會想到是否可以設計出函數式編程風格的C# API,是否可以把F#中List或Seq模塊中的各種高階函數移植到您自己的項目中來。這就有了更好的價值,這價值也不僅僅只是您“學會了新的語言”。
例如,我們現在使用尾遞歸來計算斐波那契數列。在之前的文章中,我們的作法是:
private static int FibTail(int n, int acc1, int acc2){ if (n == 0) return acc1; return FibTail(n - 1, acc2, acc1 + acc2);}public static int Fib(int n){ return FibTail(n, 0, 1);}
為了“尾遞歸”,我們必須定義一個私有的FibTail方法,接收三個參數。而對外的接口還是一個公有的Fib方法,它返回斐波那契數列第n項的結果。這個示例很簡單,作法也沒有任何問題。但是老趙有時候會覺得,我們為什么非要定義一個額外的“輔助方法”,然后在現有的方法里只是進行一個簡單的轉發?如果這個輔助方法會在其他地方得到調用也就罷了(我們遵守了DRY原則),但是現在卻有點“平白無故”地在代碼里增加了一個方法,這樣在VS的Class View或編輯器上方的下拉列表中也會多出一項。此外,為了表示兩個方法的關系,您可能還會使用region把它們包裹起來……
不過在F#中,上面的尾遞歸就可以這樣寫:
let fib n = let rec fibTail x acc1 acc2 = match x with | 0 -> acc1; | _ -> fibTail (x - 1) acc2 (acc1 + acc2) fibTail n 0 1
在fib方法內部,我們可以重新定義一個fibTail方法,其中實現了尾遞歸。對于外部來說,只有fib方法是公開的,外界絲毫不知道fibTail方法的存在,這種定義內部函數的作法在F#中非常常見。而編譯后,我們在.NET Reflector中便可看到與之對應的C#實現:
public static int fib(int n){ switch (n) { case 0: return 0; } return fibTail@7@7(n - 1, 1, 1);}internal static int fibTail@7@7(int x, int acc1, int acc2){ ...}
在F#中沒有internal的訪問級別,您可以認為這里internal便是private。于是我們得知(可能您本身也猜得到):由于.NET本身并沒有“嵌套方法”特性,因此在這里編譯器會重新生成一個特殊的私有方法,并且在fib方法里進行調用。于是我們想到,這個“自動生成方法”的特性,在C#中也有體現啊。例如,IEnmuerable有一個擴展方法是Where,我們可以用Lambda表達式構造一個匿名委托作為參數……唔唔,這不就相當于把一個方法定義在另一個方法內部了嗎?于是,我們修改一下之前C#的尾遞歸的實現:
public static int Fib(int n){ Func<int, int, int, int> fibTail = null; fibTail = (x, acc1, acc2) => { if (x == 0) return acc1; return fibTail(x - 1, acc2, acc1 + acc2); }; return fibTail(n, 0, 1);}
如果沒有F#的“提示”,可能我們只能想到list.Where(i => i % 2 == 0)這種形式的用法,我們平時不會在方法內部額外地“創建一個委托”,然后加以調用,而且還用到了“遞歸”——甚至還是“尾遞歸”(雖然C#編譯器在這里沒有進行優化,而且這里其實也只是個“偽遞歸”,因為fibTail其實是個可改變的“函數指針”)。不過,由于我們剛才通過C#來觀察F#的編譯結果,聯想到它和我們以前觀察到的C#中“某個特性”非常相似,再加上合理的嘗試,最終同樣得出了一個還算“令人滿意”的使用方式。
這只是一個示例,我并不是說這種作法是所謂的“最佳實踐”。任何辦法一旦遭到濫用也肯定不會有好處,您要根據當前情況判斷是否應該采取某種作法。剛才的演示只是為了說明,我們應該如何從其他語言中吸取優勢思想,改進我們的編程工作。當然,您使用IL來探索新的語言也沒有太大問題,C#能看到的東西用IL也可以看到。但是請您回想一下,即使您平時學習IL,您想過直接使用IL來寫程序嗎?您學習和探索新語言的目的,只是為了搞清楚它的IL表現形式嗎?為什么您不使用簡單易懂的C#,卻要糾纏于IL中那些紛繁復雜的指令呢?
示例三:性能相關
學習IL對寫出高性能的.NET程序有幫助嗎?
記得以前在學習“計算機系統概論”課程時,有一個實驗就是為幾段C程序進行優化。當時的手段可謂無所不用其極,例如內聯一個子過程以避免call指令的消耗,或把一段C代碼使用匯編進行替換等等。從結果上看,它們都能對性能有“明顯”的提高。不過,那些都是為了加深概念而進行的練習,并不是說在現代程序中應該使用這種方式進行優化。現在早已不是在“指令級別”進行性能優化的時期了,連操作系統內核也只是在一些對性能要求非常高的地方,如內存管理,線程調度中的細微方面使用匯編來編寫,其余部分也都是用C語言來完成。這并不是僅僅是因為“可維護性”等考慮,也有部分原因是因為在目前編譯技術的發展下,一些極端的做法已經很難產生有效的優化效果了(例如一般來說來,程序員寫出的C代碼的性能會優于他寫的匯編代碼)。
此外,在您不知道JIT究竟作了什么事情的情況下,觀察IL這樣一種高度抽象的語言,您還是無法真正判斷出一個程序從微觀上的性能如何。不過這并不是說,現代程序不應該“主動”追究性能,而是說,現代程序在性能優化問題上并非如此簡單,它涉及到的東西會更多,需要更加合適的手段。例如,即使您內聯了一個子過程,也只是減少了call指令的所帶來的消耗,但是這與這個子過程本身“一長串”指令相比,所帶來的提高是微乎其微的。而如果您一旦破壞了Locality或造成了False Sharing,或造成了資源競爭等等,這可能就會造成數倍甚至更多的性能損耗。換句話說,影響現代應用程序的性能的因素大都是“宏觀”的,用通俗的話來說,一般都是“寫法”上造成的問題。
這也是為什么說“Make clean code fast”遠比“Make fast code clean”來的容易,現代程序更注重的是“清晰”而并非是“性能”。因為程序清晰,更容易讓人發現性能瓶頸究竟在何處,可以進行有針對性地優化(即使是那種在極端性能要求下故意進行的“丑陋”寫法,也是為了高性能而“丑陋”,而不是因為“丑陋”而高性能,分清這一點很重要)。換句話說,如果我們有一種更清晰地方式來查看同樣的程序實現,不也降低了探索程序性能瓶頸的難度嗎?那么,同樣一段程序,您會通過C#進行觀察,還是使用IL呢?
有朋友可能會說:即使無法把握JIT對于IL的優化,但是從IL中可以看出高級語言,如C#的編譯器的優化效果啊。這話本沒有錯,但問題還是在于,C#的編譯器優化效果,是否在“反編譯”回來之后就無法觀察到了呢?“優化過程”往往都是不可逆的,它會造成信息丟失,導致我們很難從“優化結果”中看出“原始模樣”,這一點在上一篇文章中也有過論述。換句話說,我們通過C# => IL => C#這一系列“轉化”之后,幾乎都可以清楚地發現C#編譯器做過哪些優化。這里還是使用經典的foreach作為示例,您知道以下兩個方法的性能高低如何?
static void DoArray(int[] source){ foreach (int i in source) { Console.WriteLine(i); }}static void DoEnumerable(IEnumerable<int> source){ foreach (int i in source) { Console.WriteLine(i); }}
經過了C#編譯器的優化,再使用.NET Reflector查看IL反編譯成C#(None Optimization)的結果,就會發現它們變成了此般模樣:
private static void DoArray(int[] source){ int num; int[] numArray; int num2; numArray = source; num2 = 0; goto Label_0014;Label_0006: num = numArray[num2]; Console.WriteLine(num); num2 += 1;Label_0014: if (num2 < ((int)numArray.Length)) { goto Label_0006; } return;}private static void DoEnumerable(IEnumerable<int> source){ int num; IEnumerator<int> enumerator; enumerator = source.GetEnumerator();Label_0007: try { goto Label_0016; Label_0009: num = enumerator.Current; Console.WriteLine(num); Label_0016: if (enumerator.MoveNext() != null) { goto Label_0009; } goto Label_002A; } finally { Label_0020: if (enumerator == null) { goto Label_0029; } enumerator.Dispose(); Label_0029: ; }Label_002A: return;}
C#編譯器的優化效果表露無遺:對于int數組的foreach其實是被轉化為類似于for的下標訪問遍歷,而對于IEnumerable還是保持了foreach關鍵字中標準的“while...MoveNext”模式(如果您把Console.WriteLine語句去掉的話,就可以使用.NET 3.5 Optimization直接看出兩者的不同,您不妨一試)。由此看來,DoArray的性能會比后兩者要高。事實上,由于性能主要是由“實現方式”決定的,因此我們可以通過反編譯成C#代碼的方式來閱讀.NET框架中的大部分代碼,IL在這里起到的效果很小。例如在文章《泛型真的會降低性能嗎?》里,Teddy大牛就通過閱讀.NET代碼來發現數組的IEnumerable實現,為什么性能遠低于IEnumerable。
不過,判斷兩者性能高低,最簡單,也最直接的方式還是進行性能測試。例如您可以使用CodeTimer來比較DoArray和DoEnumerable方法的性能,一目了然。
值得一提的是,如果要進行性能優化,需要做的事情有很多,而“閱讀代碼”在其中的重要性其實并不高,而且它也最容易誤入歧途的一種。“閱讀代碼”充其量是一種人工的“靜態分析”,而程序的運行效果是“動態”的。這篇文章解釋了為什么使用foreach對ArrayList進行遍歷的性能會比List低,其中使用了Profiler來說明問題。Profiler能告訴我們很多難以觀察到的事情,例如在遍歷中究竟是ArrayList哪個方法消耗時間最長。此外它還發現了ArrayList在遍歷時創建了大量的對象,這種對于內存資源的消耗,幾乎不可能從一小段代碼中觀察得出。此外,不同環境下,同樣的代碼可能執行效果會有不同。如果沒有Profiler,我們可能會選擇把一段執行了100遍的代碼性能提升1秒鐘,卻不會把一段執行100000遍的代碼性能提升100毫秒。性能優化的關鍵是“有的放矢”,如果沒有Profiler幫我們指明道路,做到這一點相當困難。
其實老趙對于性能方面說的這些,可以大致歸納為以下三點:
- 關注IL,對于從微觀角度觀察程序性能很難有太大幫助,因為您很難具體指出JIT對IL的編譯方式。
- 關注IL,對于從宏觀角度觀察程序性能同樣很難有太大幫助,因為它的表述能力不會比C#來的直觀清晰。
- 性能優化,最關鍵的一點是使用Profiler來找出性能瓶頸,有的放矢。
所以,如果您問老趙:“學習IL,對寫出高性能的.NET程序有幫助嗎?”我會回答:“有,肯定有啊”。
但是,如果您問老趙:“我想寫出高性能的.NET程序,應該學習IL嗎?”我會回答:“別,別學IL”。
總結
feilng在前文留下的一些評論,我認為說得非常有道理:
IL只是在CLR的抽象級別上說明干什么,而不是怎么干……重要的是要清楚在現實條件下,需要進入那個層次才能獲取足夠的信息,掌握接口的完整語義和潛在副作用。
IL的確比C#等高級語言來的所謂“底層”,但是很明顯,IL本身也是一種高級抽象。而即使是機器碼,它也可以說是基于CPU的抽象,CPU上如流水線,并行,內存模型,Cache Lock等東西對于匯編/機器碼來說也可以說是一種“封裝”。從不同層次可以獲得不同信息,我們追求“底層”的目的肯定也不是“底層”這兩個字,而是一種收獲。了解自身需要什么,然后能夠選擇一個合理的層次進入,并得到更好的收益,這本身也是一種能力。追求IL的做法,本身并沒有錯,只是追求IL一定是當前情況下的最優選擇嗎?這是一個值得不斷討論的問題,我的這篇文章也只是表達了我個人對某些問題的看法。
NET技術:老趙談IL(3):IL可以看到的東西,其實大都也可以用C#來發現,轉載需保留來源!
鄭重聲明:本文版權歸原作者所有,轉載文章僅為傳播更多信息之目的,如作者信息標記有誤,請第一時間聯系我們修改或刪除,多謝。