CLR簡介
什麼是通用語言運行時(CLR),簡單來講:
CLR是一個支持多種編程語言及多語言互操作,完整的高級虛擬機。
有點拗口,而且不是很有啟發性,但上面的文字是將又大又複雜的CLR的功能歸類以便容易理解的第一步。它從一萬英尺的高度來幫助我們理解CLR的設計目標。從這個高度明了CLR之後,我們可以深入其各個組件了。
CLR: 一個(極少見的)完整編程平台
每個程序在運行的時候都有驚人數量的運行時依賴。雖然程序很明顯都是由一種特定的編程語言寫就,但這只是程序員編寫程序多種依據中的一種。每個有用的程序都需要某些 _運行時函數庫_ 以便其能跟電腦的其它資源(如用戶輸入設備,磁碟文件,網路通信等)交互。程序也需要轉換成計算機硬體可以直接執行的某種格式。這些依賴的數量是如此之多,範圍之廣,使得編程語言的設計者通常都引用其它標準來規範它們。例如C++編程語言不會規定C++程序的格式,每個C++編譯器都會與特定的硬體架構(如x86架構)關聯,與特定的操作系統環境(如Windows,Linux或者Mac OS)關聯,這些架構和環境會規定可執行文件的文件格式以及載入的方式。因此,程序員不是在編寫一個「C++可執行程序」,而是「Windows X86可執行程序」或「Power PC Mac OS可執行程序」。
復用現有的硬體或操作系統標準通常都是好事情,但其使得在現有標準之上抽象出新的規範變得很難。例如,今天的操作系統沒有支持垃圾回收的堆。因此也就無法使用現有的標準來支持垃圾回收的介面(如,將字元串傳來傳去,不需要關注刪除它們)。同樣,一個典型的可執行文件格式只提供足夠運行程序的信息,但不足夠編譯器將其他可執行文件綁定在一起運行。比如說,C++程序一般都使用包含經常使用功能(如printf)的標準庫(在Windows里是msvcrt.dll),但只有這個庫是不夠的。沒有對應的頭文件(如,stdio.h),程序員是無法使用這些函數庫的。因此,已有的可執行文件格式標準不能同時描述可執行的文件格式,並添加其它一些信息。
CLR通過定義一個 [非常完整的規範][ecma-spec](由ECMA標準化)來描述一個程序從編譯、到部署時綁定依賴、到運行整個生命周期的所有信息。因此,除去其他細節,CLR定義了
- 一套支持GC,並包含自己的執行程序基本操作的指令集(通用中間語言 - CLI)的虛擬機,這也就意味著CLR不需要依賴指定類型的CPU;
- 一套描述程序里聲明的元素(如類型、值、變數等等)的元數據,以便編譯器在生成其它可執行文件時有足夠的信息來從「外部」調用程序里的功能;
- 一個精確描述位元組應該如何在文件裡布局的文件格式,這樣我們在討論CLR EXE的時候,不與某個特定的操作系統或電腦硬體綁定;
- 進程的生命周期語義,即一個CLR EXE引用其它CLR EXE的機制,和CLR在運行時找到進程依賴文件的規則;
- 利用CLR內置功能(如垃圾回收、異常和泛型等)的類庫,其除了提供如整形、字元串、數組、列表和字典等基本功能意外,還提供了如文件、網路和UI交互等操作系統服務。
多編程語言支持
定義、規範和實現這些細節是一個艱巨的任務,這也就是類似CLR的完整抽象非常少的原因。實際上,大部分抽象都是為單個編程語言設計的。例如,Java運行時,Perl解釋器或者早期的Visual Basic運行時提供了類似的完整抽象。但CLR跟這些先行者不同之處在於其支持多種編程語言。可能除了Visual Basic(因為它採用了COM對象模型),僅使用單個編程語言的體驗是非常好的,但是要與其它編程語言互操作時體驗就有點差了。編程語言之間互操作之所以困難,是因為這些編程語言僅能通過操作系統提供的原語來與「外族」編程語言通信。而操作系統的抽象層次太低階(如操作系統不提供內存垃圾回收),就不得不採用一些複雜的技術。通過提供 **通用語言運行時**,CLR允許編程語言之間採用高階結構(如可GC的數據結構)通信,大量減輕了互操作的麻煩。
由於運行時在 __許多__ 語言之間共享,這就意味著更多的資源可被支持。為一個編程語言實現好的調試器和性能分析工具需要大量的工作,因此只有一些很重要的編程語言才有完整的工具鏈支持。然而,CLR上實現的編程語言可以共享這些基礎架構,實現新的編程語言的工作量也大大縮減了。也許更重要的是,所有在CLR上實現的編程語言都可以訪問 __所有__ 在CLR上實現的類庫。龐大且不斷增長的(嚴格調試和支持)功能是CLR如此成功的一個重要原因。
簡單來講,CLR是一個將位元組存到文件以創建和運行程序的完整規範。虛擬機可以使用不同編程語言寫就的類庫來運行這些程序。這個虛擬機,還有運行其上的不斷增長的類庫,就是我們說的通用語言運行時(CLR)。
CLR的首要目標
到目前我們已經對CLR有了初步的了解,對幫助了解CLR要解決的問題非常有用。從很高的層次上講,CLR只有一個目標:
CLR的目標是使編程變得更簡單。
有兩個因素使得這個陳述很有用。首先,這是CLR後續升級中 _非常_ 有用的指導原則。如,只有簡單的東西才容易用,因此在CLR中添加一些 **用戶可見** 的複雜度是有疑慮的。對添加一個新功能來說,比投資產出比更重要的是 _複雜度/所有場景應用收益權重_ 比。理想情況是這個比例是負的,即添加一個新功能可以移除某些限制或者將某些特殊場景泛化。但一般情況下是通過最小化複雜度和最大化新功能支持的使用場景來盡量降低這個比值。
第二個原因是這個目標之所以重要,是因為 **CLR的成功基於易於使用**。CLR並不是因為其比原生程序更快或者更小(實際上,設計良好的原生程序經常是贏家)而獲得成功,CLR也不是因為其包含某些特定功能而成功(如垃圾回收,平台無關,面向對象編程或者支持多版本)。CLR之所以成功是因為其整合這麼多以及其他很多功能,使得編程比其他平台更簡單,一些非常重要但是經常被忽視的功能如:
- 簡化的編程語言(如C#和Visual Basic比C++簡單不少)。
- 一套致力於易用使用的類庫(如,我們只有一種字元串類型,而且它還是不可修改的,這個特性極大簡化了處理字元串的API)。
- 嚴謹的類庫命名規範(如要求API都使用完整單詞並且統一命名規範)。
- 強大的編程工具鏈(如果Visual Studio是的編寫CLR程序極其簡單,智能感知也極大方便編程時查找正確類型和函數)。
就是這個致力於簡單的做法成為CLR成功的重要因素。奇怪的是,很多重要的易用使用的功能都是相當乏味的。如,任何編程環境都要求統一命名規範,但是在一個很大的類庫里做到這一點需要大量的工作。而且這個工作經常與其他目標衝突(如保持跟之前版本的介面的兼容性),或者就是有很高的成本(如在一個 _非常_ 龐大的代碼庫里重命名一個函數的成本)。也就是這個目標時刻提醒我們並在工作中將其放在最優先的位置。
CLR的重要功能
CLR有很多功能,可以歸類如下:
- 基礎功能 - 是其他功能的設計的根基,包括:
- 垃圾回收
- 內存安全和類型安全
- 對編程語言的高階支持
- 次要功能 - 基於基礎功能,但不是被很多程序用到
- 進程隔離的應用程序域(AppDomain)機制
- 進程安全和沙盒環境
- 其它功能 - 運行環境所需但是不基於基礎功能,它們是為了創建一個完整的編程環境而設計:
- 多版本
- 調試/進程剖析
- 互操作
CLR里的垃圾回收
在所有CLR提供的功能里,垃圾回收值得特別說明。垃圾回收是自動內存回收機制的普遍術語。在垃圾回收系統里,用戶進程不需要調用特定的操作來清空內存。運行時負責在垃圾回收內存堆里實時跟蹤所有引用,它會遍歷內存找出仍被進程使用到的那些引用。而其它內存則被當作 _垃圾_ 並可以用來處理新的內存分配請求。
垃圾回收是一個非常有用的功能,因為其簡化了編程。顯而易見的簡化是不再需要顯式的刪除操作。雖然去掉刪除操作的確很重要,但其給程序員帶來其它更實質的價值:
- 垃圾回收簡化了介面設計,因為你不再需要仔細設計介面的那一方負責刪除介面傳遞的對象。如:CLR介面只簡簡單單的返回字元串,不附帶字元串的緩存和長度。這也意味著不需要擔心字元串緩存的長度是否過小。因此,垃圾回收使得運行時上的所有介面都比之前的簡單。
- 垃圾回收消除了很多常見的錯誤。在處理一個對象的生命周期時很容易犯錯,要麼就是銷毀太早了(導致內存破壞),要麼就是太遲了(導致內存泄漏)。一般程序會用到百萬級別的對象,導致發生這種錯誤的概率很高。而且,追蹤這樣的生命周期方面的bug非常難,特別是對於那些被很多對象引用到的對象。消除這類內存方面的錯誤避免了很多悲劇。
然而,不是因為垃圾回收以上有用的功能使得我們在這裡特別說明它。它對運行時的簡單要求使得其更重要:
垃圾回收要求所有指向GC堆的引用都是可跟蹤的。
雖然這是一個非常簡單的要求,其實它對運行時有著深遠的意義。正如你想像的那樣,在程序運行的任意時刻知道指向一個對象的每一個指針,是非常難的。於是我們採取了折中的辦法,技術上來說,只要GC發生的時候才會滿足這個要求(因此,在理論上將我們不需要隨時知道所有的GC引用,只需要在GC的時候知道即可)。在實際操作中,就連這個折中的辦法都因為CLR的另外一個功能而無法完全滿足:
CLR支持在一個進程里運行多個並發線程。
在任意時刻,正在執行的其它線程的分配請求會觸發一個GC。而多個並發的線程的執行順序是無法準確獲知的。我們無法精準獲知如果一個線程觸發了GC請求,另外一個線程正在幹什麼。因此,GC可以在任何時候觸發。CLR不需要 _立即_ 響應其它線程的GC請求,CLR需要一點「迴旋空間」來避免實時追蹤GC引用,但它需要在其它線程觸發GC請求時有足夠空間來「及時」響應。
這意味著CLR _幾乎_ 需要實時追蹤 _所有_ 指向GC堆的引用。由於GC引用也許存在於機器的寄存器上、在局部變數里、靜態或者其他欄位里,有很多地方需要跟蹤。最麻煩的地方是寄存器和局部變數,因為它們與用戶代碼的實際運行情況密切相關。事實上,這對操作GC引用的 _機器代碼_ 有一個額外的要求:它必須跟蹤所有它用到的GC引用。這意味著編譯器需要做一些額外工作來產生跟蹤這些引用的指令。
請查看文章 [垃圾回收設計文檔](garbage-collection.md) 來了解更多信息。
「託管代碼」概念
能夠執行額外記錄一般在「幾乎任何時刻」報告其正在使用的有效GC引用的代碼,就稱做 _託管代碼_ (因為其被CLR「管理」)。不能實現這個目標的代碼叫 _非託管代碼_。因此在CLR之前存在的代碼都是非託管代碼,特別來說,所有操作系統的代碼都是非託管的。
堆棧展開的問題
由於託管代碼需要用到操作系統的服務,所有有時託管代碼需要調用非託管代碼。相應的,託管代碼最初是操作系統啟動的,因此有時非託管代碼也會調用託管代碼。因此,當你在任意位置中斷託管程序,堆棧上混合了由託管代碼和非託管代碼創建的幀。
非託管代碼的堆棧幀對其上運行的程序 _沒有_ 要求。即一般不要求 _展開_ (非託管堆棧幀)來找到它的調用者。這意味著當程序在非託管函數中斷時,一般<sup>[1]</sup> 是沒有辦法找到它的調用函數的。只能在調試器里才能做到,這是因為在符號(PDB)文件里保存了額外信息。但這些信息不保證一直存在(這就是為什麼在調試器里無法獲取準確堆棧信息的原因)。這在託管代碼里是很大的問題,因為在無法展開的堆棧里可能包含託管代碼幀(它包含了需要收集的GC引用)。
託管代碼有一些額外需求:不僅是在運行過程中需要跟蹤所有的GC引用,而且還需要能展開它的調用函數。另外,無論何時從託管代碼到非託管代碼(或發過來)的過渡,託管代碼都需要做一些額外的記錄來避免非託管代碼無法展開堆棧帶來的影響。實際上,託管代碼在堆棧里將託管代碼幀鏈接起來了。因此,即使在無法利用額外信息展開非託管代碼堆棧幀的情況下,還是能在堆棧上找到託管代碼塊並枚舉託管代碼幀。
[1] 最近的平台ABI(應用程序二進位介面 application binary interfaces)定義了編碼這些信息的約定,但其不是一個所有代碼都必須遵循的嚴格規範。
託管代碼的「世界」
結果就是進出託管代碼的每次過渡都要做特殊的記錄。託管代碼只能在CLR理解的「世界」。這兩個世界是非常不同的(在任何時刻,代碼要麼在 _託管世界_,要麼在 _非託管世界_)。而且,因為在CLR格式里定義了託管代碼的執行(即 [通用中間語言][cil-spec](CIL)),並由CLR在原生硬體上解釋執行,CLR對運行情況有 _非常多_ 的控制。例如,CRL可以更改從一個對象里獲取欄位的值或調用一個函數的意思。實際上,CLR在創建MarshalByReference對象時就是這麼做的。它們看起來是普通的本地對象,但實際上存在於另外一台機器。簡單來說,CLR的託管世界裡有很多 _運行時鉤子_ 來支持後續章節要介紹的強大功能。
另外,託管代碼還有一個很重要但不是很明顯的衍生物。在非託管代碼里,GC指針是不被允許的(因為其不可被跟蹤),在託管和非託管之間的切換有一個記錄的成本。這意味著雖然你 _可以_ 在託管代碼里調用任意的非託管方法,但過程通常不是很方便。非託管代碼無法使用GC對象作為其參數或者返回值,也就是說這些非託管方法創建和使用的任何「對象」或「對象引用」都需要顯式釋放。這實在是個悲劇。因為這些API無法享受到諸如異常或繼承這樣的CLR的功能的益處,這將會導致與託管代碼交互時「不匹配」的用戶體驗。
其結果就是大部分非託管介面在暴露給託管代碼開發者時是 _封裝的_。舉個例子,當訪問文件時,一般不使用操作系統提供的Win32 CreateFile 函數,而是用封裝了其的System.IO.File託管代碼類。實際上直接使用非託管API的地方非常少。
雖然這種封裝在一些地方看起來「差勁」(更多的代碼不見得做的更多),實際上它還是增加了一些價值的。請記住直接暴露非託管介面總是_可能的_;但我們_選擇_封裝這些功能,為什麼?因為整個運行時設計的首要目標是**使編程更簡單**,而且一般來說非託管代碼不是足夠簡單。通常來說,非託管介面一開始就_不是_為了使用簡單而設計的,而是為完整性優化的。當你看到CreateFile或者CreateProcess函數的參數列表時,很難將其歸類到簡單里。幸運的是,這些介面進入託管世界「整了次容」,雖然過程很「沒技術含量」(無非就是重命名,簡化,重組其功能),但非常有用。為CLR編寫的一個非常重要的文檔就是 [Framework Design Guidelines][fx-design-guidelines]。這本800+頁的文檔詳細描述了創建新的託管代碼類庫的最佳實踐。
到這裡,我們分析了託管代碼和非託管代碼里兩個重要不同:
- 高科技:代碼在兩個不同的世界裡運行,而CLR在程序運行時的各個方面進行良好的把控(甚至到單個指令級別),而且CLR可以檢測什麼時候進入或退出託管代碼的運行。這點使很多有用的功能變得可能。
- 低技術含量:在託管和非託管代碼之間有切換成本,而且非託管代碼無法使用GC對象這點事實鼓勵用facade模式封裝非託管代碼。即通過遵循一系列的命名和設計指南來達到一定程度的一致性和可發現性來「整容」並簡化(操作系統)介面。
上面**兩個**特性對於託管代碼的成功都非常重要。
內存和類型安全
GC一個不怎麼明顯但是影響深遠的功能就是內存安全。內存安全的意思很簡單:只有程序只訪問其分配(且沒有被釋放)的內存就是內存安全的。這意味著你不會有指向任意位置(精確來說就是過早釋放的內存)的「野」(懸著的)指針。內存安全當然是我們希望所有程序都有的功能。野指針一般都是bug,而且跟蹤它們相當困難。
GC _必須_ 保障內存安全
你可以很快看到GC對於內存安全的好處,因為其移除了用戶過早釋放內存(也就無法訪問到沒有正確分配的內存)的可能性。但另一個不怎麼明顯的因素是,如果你需要保證內存安全(即讓程序員 _不可能_ 創建一個內存不安全的程序),在實際操作中無法避免垃圾回收器。這是因為通常程序都需要_堆_(動態)內存分配系統,而這樣對象的生命周期是任意的(與棧分配或者靜態分配的內存不同,它們都是高度遵守分配協議的)。在這樣不受控的環境下,通過分析程序來預測某個顯式釋放內存語句是否正確是不可能的。實際上,唯一判斷釋放語句是否正確只能在運行時做。這正是GC所做的事情(通過檢查內存是否仍然有效)。因此,對於任何一個需要堆分配內存的程序來說,如果要保證內存安全,那_必須_使用GC。
雖然GC是保障內存安全的必要手段,但還是不夠。GC無法阻止程序在數組裡做越界索引或者在對象的結尾之後訪問欄位(可以通過對象基址和偏移量計算欄位的地址做到)。當然,如果我們防範了這些情形,那麼我們的確使程序員無法創建內存不安全的程序。
雖然 [通用中間語言][cil-spec](CIL) 提供了存取任意內存位置的指令(即違背了內存安全原則),但它也有下列內存安全的指令集,並且CLR強烈建議使用它們:
- 欄位訪問指令集(LDFLD, STFLD, LDFLDA),根據名字讀寫欄位地址。
- 數組訪問指令集(LDELEM, STELEM, LDELEMA),根據索引讀寫一個數組元素地址。所有數組都帶有指示其長度的標籤,它用來在每次存取時做越界檢查。
通過在用戶代碼中使用這些指令集,而不是底層(且不安全的)_內存讀寫_ 指令集,還可以規避其他不安全 [CIL][cil-spec] 的操作(如那些允許跳轉到任意且可能是非法的地址),這些都是構建一個內存安全系統所必須的。但CLR不只做這個,它支持更嚴謹的規則:類型安全。
類型安全是指每次內存分配都跟一個類型關聯。所有操作內存的指令從理念上都與類型關聯。類型安全要求讀寫指定內存只能使用與其關聯的類型有效的指令集。這不僅保障了內存安全(沒有野指針),也對每個類型加了一層額外的保護。
這些類型相關的保障中有一個重要的性質就是類型的可見性要求(特別是對於欄位來說)也被強制保證了。因此,如果一個欄位被聲明為私有(即只能被類型本身定義的函數可見),那麼這個私密性要求會被所有類型安全的代碼所遵守。比如說,某個類可能定義了一個名為count的欄位來記錄其名為table的集合里的元素個數。假設table和count欄位都是私有的,而且只有更新這兩個欄位的代碼同時更新兩個,那麼table集合里元素的個數和count欄位的值同步這一點有了強有力的保證。無論是否了解類型安全,程序員都是使用類型安全的概念來推理程序邏輯的。CLR將類型安全從編程語言/編譯器之間的簡單約定,上升到可以在運行時遵守的規範了。
可驗證代碼 - 強制內存和類型安全
從理念上來說,為了保證類型安全,程序執行的每個指令都需要檢查其是否符合內存關聯的類型要求。雖然可以在運行時做這個檢查,但性能會非常慢。所以CLR採用 [CIL][cil-spec] 驗證的概念,即根據[CIL][cil-spec] 靜態分析程序來確認大部分指令集是類型安全的。運行時只用來補充靜態分析不能檢查的地方。實際上,運行時的檢查次數很少。它們包括下面這些指令:
- 將一個基類的指針強制轉換為派生類型(反過來的轉換可以放在靜態分析里)。
- 數組越界檢查(如同內存安全一樣的道理)。
- 將指針數組裡的元素替換成一個新(指針)值。這點是因為CLR數組的自由轉換規則(在後文分析)。
這些檢查對CLR提了如下這些要求:
- GC里所有的內存都要關聯類型(這樣強制轉換操作才能實現)。類型信息必須對運行時可見,而且要豐富到可以判斷強制轉換是否有效(即運行時需要知道類型的繼承層次)。實際上,每個對象在GC堆的第一個欄位就指向關聯類型在運行時的數據結構對象。
- 所有的數組都必須包含其大小(用來做越界檢查)。
- 數組必須知道其元素的完整類型信息。
幸運的是,有些開銷很大的要求(給堆上的內存打標籤)也是支持垃圾回收所必要的(GC需要知道正在掃描的對象所有欄位信息),因此支持類型安全的額外成本實際上不高。
因此,按照[CIL][cil-spec]驗證代碼加上少量的運行時檢查,CLR可以保證類型安全(和內存安全)。儘管如此,在編程彈性上,額外的安全帶來嚴格的代價。CLR有直接的內存讀寫指令,為了保證代碼可驗證性,這些指令的使用範圍很有限。如所有的指針運行都會使代碼無法通過驗證,因此很多C和C++的典型用法都不能在要通過驗證的代碼里使用;你必須使用數組。雖然這樣讓編碼有點不舒服,但也不是很差(數組也很有用),而且好處是現成的(更少的「詭異」的bug)。
CLR強烈建議使用可驗證的,類型安全的代碼。即使這樣,有時還是要用到無法驗證的代碼(主要是跟非託管代碼交互)。CLR運行這樣,但是最佳實踐是盡量限制(類型)不安全的代碼的使用。一般的程序只有極少部分的不安全代碼,而其它的是類型安全代碼。
高階特性
支持垃圾回收對運行時的一個深遠影響是所有代碼都需要做額外的記錄。而類型安全也有一個重要影響,即要求對程序需要從更高的層面([CIL][cil-spec])來描述,即欄位和函數都需要有詳細的類型信息。類型安全還強制[CIL][cil-spec]支持其它高階編程語言元素,而表述這些高階元素也需要運行時支持。這些高階特性中最重要的兩個特性用來支持面向對象編程的兩個基本元素:繼承和虛擬函數調度。
面向對象編程
繼承是一個相對來說比較簡單的機制。其基本思路是`子`類型里的欄位是其`基類`里欄位的超集,在`子`類的布局裡,先布局`基類`里的欄位,這樣,需要處理指向一個`基類`實例的指針的代碼,即使給它傳遞一個實際指向`子`類型實例也`可以工作`。這樣一來,我們就說`子`類是從`基`類繼承而來的,即它可以用在任何需要用到`基類`的代碼。代碼被稱為 _多態_ 是因為相同的代碼可以被不同的類型使用。因為運行時需要保證類型強制,所以其需要規範繼承的方式以便驗證類型安全。
虛擬函數調度普及了繼承的多態性。它允許基類定義可以在子類里 _重寫_ 的函數。處理基類類型變數的代碼,在運行時可以期望對其虛擬函數的調用會被調度到對象實際類型里重寫的函數上。雖然這種 _運行時調度的邏輯_ 可以使用不通過CLR直接支持的原生[CIL][cil-spec]指令實現,但這樣做有兩個缺點:
- 這樣就不類型安全了(調度表裡的錯誤將造成災難性的後果)。
- 每個面向對象編程語言在實現虛擬函數調度邏輯時很可能採取稍具差別的做法。這樣一來,跨編程語言之間的互操作就無法實現了(即一個編程語言無法繼承另一個編程語言里定義的基類)。
因為這樣的原因,CLR直接內置基本的面向對象的特性。CLR嘗試將繼承模型「編程語言中立」,即不同的編程語言共享相同的繼承層次結構。不幸的是,這個不是一直都有可能做到的。實際上,多重繼承可以用很多不同的方式實現。CLR決定對於定義了欄位的類型不支持多重繼承,但是對少數不包含欄位定義的特定類型(稱作介面)支持多重繼承。
特別需要注意的是,雖然CLR支持這些面向對象的理念,但是它不強制必須用它們。沒有繼承概念的編程語言(如函數式編程語言)就不會使用這些特性。
值類型(和裝箱)
面向對象編程的一個微妙但影響深遠的理念是對象身份:即使所有的欄位值都相同,但對象(通過不同的分配函數創建)可以是不同這個理念。對象身份這個概念跟對象是通過引用(指針)訪問而不是通過值來訪問這個概念強烈有關。如果兩個變數賦值了相同的對象(即指針指向相同的內存),那麼更新其中一個變數會影響另一個。
然而,對象身份這個概念不是對所有類型都是一個好想法。例如,程序員一般不會將整數當作對象處理。如果數字「1」是在兩個地方分配的,程序員通常希望將兩個對看成是相等的,但又不希望更新一個卻影響到另一個。實際上,有很多編程語言如`函數式編程`索性放棄了對象身份和引用語義這些概念。
雖然有可能創建一個「純」面向對象系統,即將所有東西(包括整數)都當作對象(Smalltalk-80就是這樣做的),一些違背這一統一理念的實現「技巧」來達到更高的效率是必要的。一些編程語言(Perl, Java, Javascript)採用實用主義做法,通過值來處理一些類型(如整數),而通過引用來處理其他類型。CLR也採用這種混合模式,但與其他的不同,允許用戶自定義的值類型。
值類型的關鍵特性如下:
- 每個局部變數,欄位或者值類型數組中的元素都有獨立的數據拷貝。
- 當一個變數、欄位或者數組元素被賦值給另外一個變數,那麼值被拷貝過去了。
- 相等是在變數里的數據中定義的(而不是位置)。
- 每個值類型有一個對應的引用類型,這個引用類型只有一個隱式的未命名的欄位。其被稱為裝箱值。裝箱後的值類型可以參與繼承並且也有對象身份(雖然不建議這麼做)。
值類型跟C(和C++)里的結構體非常相似。與C類似,指針可以指向值類型,但是指向類型的指針與結構體類型本身是不一樣的。
異常
CLR直接支持的另一個高階編程元素就是異常。異常是允許程序員在錯誤發生的時候 _拋出_ 一個任意對象的編程特性。當一個對象被拋出後,CLR在堆棧里搜索可以 _捕捉_ 這個異常的函數。如果找到了捕捉函數,則從此位置繼續執行。異常的作用就是避免了不檢查函數返回的錯誤值的這個常見編程錯誤。因為異常是幫助程序員避免犯錯的(也就是編程變得更簡單),所以CLR支持它也就不奇怪了。
從另一個角度說,雖然異常避免了一個常見錯誤(不檢查錯誤),但是卻沒有阻止另一個(在錯誤發生的時候將數據架構恢復到穩定狀態)。這也意味著,當異常被捕捉後,繼續程序的執行是否會導致其他額外錯誤(由第一個錯誤引發)是很難獲知的,這個CLR在將來很有可能改進的地方。當然,以目前的情況,實現了異常還是向前邁進了一大步(我們只是想繼續往前)。
參數化類型(泛型)
CLR 2.0之前,可參數化的類型只有數組。其它容器(如哈希表,列表,隊列等)只能操作一個通用的Object類型。不能創建類似 List<ElemT>,或Dictionary<KeyT, ValueT>對性能是有負面影響的,因為值類型在集合中必須是裝箱過的,並且在讀取的時候需要拆箱強制轉換。即使這樣,都不是CLR支持泛型的最大理由,主要因素是 **泛型使得編程更簡單**。
這個理由是很好理解的。最簡單的辦法是設想所有類型都被替換成通用的Object類型後的類庫。這個結果跟類似JavaScript的動態語言不一樣,在這樣的世界裡,程序員有很多種辦法編寫錯誤(但是類型安全)的程序。函數的參數類型應該是列表?字元串?整數?還是都可以?在函數的聲明中很難看出來。更糟糕的,如果函數返回一個Object實例,有哪些函數可以接受其作為參數?一般框架都有幾百個函數;如果都接受Object類型作為參數,很難判斷一個對象實例適合被哪些函數處理。簡短來說,強類型幫助程序員更明白的表達其意圖,而且也允許工具(如編譯器)保證他的意圖,這在效率上有很大的提升。
這些好處不僅僅因為類型可以被放進List或者Dictionary而消失。為一個的問題是泛型是應該當作一個編程語言特性,在生成CIL指令時被「編譯器丟掉」,還是應該作為CLR的一等公民支持。兩種實現方法都可行。CLR團隊採取將泛型作為CLR內置的支持,因為如果不這樣做,泛型可能被不同編程語言採用不同方法實現。這就意味著編程語言互操作會變得很麻煩。在一個類庫里由泛型來表達程序員意圖最具價值的地方 _是在介面_。如果CLR本身不支持泛型,那麼類庫就無法用它,而一個很重要的可用性特性就沒有了。
將程序當作數據(反射API)
CLR的最重要的功能是垃圾回收,類型安全和高階編程語言特性。這些特性強制對程序的描述在一個比較高的級別。一旦這些數據存在於運行時(在C和C++程序里不是這樣的),將這些豐富的信息公開給程序員是非常有價值的。這個想法的結果就是System.Reflection里的介面(這樣命名是因為允許程序檢視自己)。這個介面允許你探索程序的方方面面(它的類型,繼承關係,都定義了哪些函數和欄位)。實際上,因為幾乎沒損失什麼信息,為託管代碼製作一個好的「反編譯器」是可能的(如[NET Reflector](http://www.red-gate.com/products/reflector/))。雖然這個功能在保護知識產權方面有些驚悚(這個可以由刻意銷毀這些信息的_混淆_工具來彌補),但這也體現了託管代碼在運行時有豐富的信息。
除了在運行時可以檢視程序,還可以操控它們(如調用函數,給欄位賦值等)。還有更強大的功能,即在運行時生成代碼(System.Reflection.Emit)。實際上,有些函數庫使用這個功能來創建匹配字元串的代碼(System.Text.RegularExpressions),和創建代碼將「序列化」對象保存到文件或通過網路傳輸。這種能力在之前是無法做到的(要不你就得寫一個編譯器)。在CLR的幫助下,我們可以解決很多編程問題。
雖然反射的功能很強大,但需要謹慎使用。反射通常比靜態編譯要慢很多。更重要的是,自引用的系統很難理解。因此Reflection和Reflection.Emit里的功能只在確定有價值的情況下才使用。
其他功能
CLR最後一組功能跟其基礎架構(如GC、類型安全、高階的規範)無關,但對於完善運行時系統很有幫助。
與非託管代碼互操作
託管代碼需要能調用非託管代碼的功能。有兩種互操作「風味」。第一個是可以直接調用那個非託管函數(這個叫平台調用或PINVOKE)。非託管代碼同樣有面向對象的互操作模型叫做COM(組件對象模型),比任意的函數調用有更多的結構。由於COM和CLR都有對象和其他約定(如如何處理錯誤,對象的生命周期等)模型,CLR跟COM的互操作在有一些特別支持的情況下可以做的更好。
提前編譯
在CLR里,託管代碼是以CIL指令,而不是原生指令呈現的。轉換成原生指令的過程發生在運行時。作為一個優化,從CIL里轉換的原生代碼可以用一個稱為crossgen(類似.NET Framework里的ngen)工具保存在文件里。這樣可以節省運行時很多編譯的時間,當類庫很大的時候這個就變得更重要了。
線程
CLR早就預見需要在託管代碼里支持多線程的必要性。從一開始,CLR函數庫里就有System.Threading.Thread這個類,作為操作系統里線程的1對1封裝。然而,因此其僅是操作系統線程的簡單封裝,創建一個System.Threading.Thread相對來說比較昂貴(需要幾個毫秒來創建)。在大多數情況這都可以接受。在只處理很小工作量(只有幾十毫秒),這個在伺服器端代碼很常見(每個任務處理一個web頁面請求),或者需要利用多核特性的代碼(如多核排序演算法)。為了支持這種情況,CLR提供了線程池來給工作任務排隊。在這種情況下,CLR負責創建必要的線程來完成工作。雖然CLR通過System.Threading.Threadpool類型來直接公開線程池,更推薦的方式是採用[Task Parallel Library](https://msdn.microsoft.com/en-us/library/dd460717(v=vs.110).aspx),其對常見的並行控制添加了額外的支持。
從實現的角度來看,線程池的一個重要創新是其負責保證一個優化數量的線程來負責完成工作。CLR使用一個反饋系統來監控線程數和生產率,並調整線程的數量以最大化生產率。這樣程序只需要考慮是否需要「並行」(也就是創建工作任務),而不是需要多少並行量(取決於工作量和運行程序的硬體)這樣更基礎的問題。
結論和資源
呼!CLR做了不少!僅僅是描述CLR的_一些_功能就用了不少頁碼,還沒有開始討論起內部實現機制。希望這個介紹能給你對內部實現細節的更深入的理解。框架的基本結構是:
- CLR是一個支持很多編程語言的框架
- CLR的目標是使編程更簡單
- CLR的基礎功能是:
- 垃圾回收
- 內存和類型安全
- 支持高階編程語言的特性
參考鏈接
- [MSDN Entry for the CLR][clr]
- [Wikipedia Entry for the CLR](http://en.wikipedia.org/wiki/Common_Language_Runtime)
- [ECMA Standard for the Common Language Infrastructure (CLI)][ecma-spec]
- [.NET Framework Design Guidelines](http://msdn.microsoft.com/en-us/library/ms229042.aspx)
- [CoreCLR Repo Documentation](README.md)
[clr]: http://msdn.microsoft.com/library/8bs2ecf4.aspx
[ecma-spec]: ../project-docs/dotnet-standards.md
[cil-spec]: http://download.microsoft.com/download/7/3/3/733AD403-90B2-4064-A81E-01035A7FE13C/MS%20Partition%20III.pdf
[fx-design-guidelines]: http://msdn.microsoft.com/en-us/library/ms229042.aspx
推薦閱讀:
※微軟宣布 .NET 開源了,如何學好.NET?
※Node.js和.Net 4.5下的await、async相比有什麼不同?
※ASP.NET開源以後會有更多的網站選擇這個平台么?
※c# 為什麼不脫離.net平台,實現跨平台呢?
※.net怎麼實現自己的非同步方法?