CLR via C#深解筆記三,
編程語言的基元類型
某些數據類型如此常用,以至於許多編譯器允許代碼以簡化的語法來操縱它們。
System.Int32 a = new System.Int32(); // a = 0
a = 1;
等價於:
int a = 1;
這種語法不僅增強了代碼的可讀性,其生成的IL代碼與使用System.Int32時生成的IL代碼是完全一致的。
編譯器直接支持的數據類型稱為基元類型(primitive type)。基元類型直接映射到Framework類庫(FCL)中存在的類型。如C#中,int直接映射System.Int32類型。
C#的語言規范稱:“從風格上說,最好是使用關鍵字,而不是使用完整的系統類型名稱。”其實,也許使用FCL類型名稱,避免使用基元類型名稱才是更好的做法。
CLR支持兩種類型:引用類型和值類型。FCL中的大多數類型都是引用類型,但是程序員用得最多的還是值類型。
引用類型
引用類型總是從托管堆上分配的,C#的new 操作符會返回對象的內存地址 - 也就是指向對象數據的內存地址。使用引用類型時候,須注意到一些性能問題,即一下事實:
#1, 內存必須從托管堆上分配
#2, 堆上分配的每個對象都有一些額外的成員,這些成員必須初始化
#3, 對象中的其他字節(為字段而設)總是設為零
#4, 從托管堆上分配一個對象時,可能強制執行一次垃圾收集操作
值類型
為了盡可能的提高性能,提升簡單的、常用的類型的性能,CLR提供了名為“值類型”的輕量級類型。值類型一般在線程棧上分配(因為也會作為字段,嵌入一個引用類型的對象中)。
在代表值類型實例的一個變量中,並不包含一個指向實例中的指針。相反,值類型的變量中包含了這個實例本身的字段(值),那麼操作實例中的字段,也就不再需要提領一個指針。值類型的實例不受垃圾回收器的控制。
值類型的設計和使用的好處如下:
#1, 緩解了托管堆中的壓力
#2, 減少了應用程序在其內存期內需要進行的垃圾回收次數
自定義值類型不可有基類型,但是可以實現一個或則多個接口。除此之外,所有的值類型都是隱式密封的(sealed),目的是防止將一個值類型作為其他任何引用類型或者值類型的基類型。
設計自己的類型時,仔細考慮是否應該將一個類型定義成值類型,而不是定義成引用類型。某些時候,值類型是可以提供更好的性能的。
值類型不在堆上分配內存,所以一旦定義了該類型的實例的方法不在處於活動狀態,為它們分配的存儲就會被釋放,這也意味著類型的實例在其內存被回收時,不會通過Finalize方法接受到一個通知。
CLR如何控制類型中的字段的布局
為了提高性能,CLR能按照它所選擇的任何方式來排列類型的字段。如,CLR可以在內存中重新安排字段的順序,從而將對象引用分為一組,同時正確排列和填充數據字段。然而,在定義一個類型時,針對類型的各個字段,
你可以指示CLR是嚴格按照自己指定的順序排列,還是采取CLR自己認為合適的方式重新排列。System.Runtime.InteropServices.StructLayoutAttribute。這反映在面向CLR的編譯器做的事情。如Microsoft C# 編譯器默認為引用類型(類)選擇LayoutKind.Auto, 而為值類型(結構)選擇LayoutKind.Sequential。
值類型的裝箱和拆箱
值類型是比引用類型“輕型”的一種類型,因為他們不作為對象在托管堆中分配,不會被垃圾回收,也不通過指針來引用。
很多情況下,都需要獲取對值類型的一個實例的引用。如果要獲取對值類型的一個實例的引用,該實例就必須裝箱。ArrayList對象的Add方法 public virtual Int32 Add(Object value);
ArrayList.Add需要獲取對托管堆上的一個對象的引用(或指針)來作為參數。若是需要向ArrayList填充一組Int32數字,那麼Int32數字必須轉換成一個真正的、在托管堆的對象,而且獲取對這個對象的一個引用。
如何將一個值類型轉換成一個引用類型,要使用一個名為裝箱(boxing)的機制。那麼,值類型的一個實例進行裝箱操作時在內部發生的事情:
#1, 在托管堆中分配好內存。分配的內存量是值類型的各個字段需要的內存量加上托管堆的所有對象都要的兩個額外成員(類型對象指針和同步塊索引)需要的內存量。
#2, 值類型的字段復制到新分配的對內存。
#3, 返回對象的地址。這個地址正是對這個對象的引用,值類型現在是一個引用類型。
注意,已裝箱值類型的生存期超過了未裝箱的值類型的生存期。FCL現在包含一組新的泛型集合類,它們使非泛型的集合類成為“過時”的東西。例如,泛型集合類進行了大量增強,性能也顯著提升。
最大的一個增強就是泛型集合類允許開發人員在操作值類型的集合時不需要對集合中的項進行裝箱/拆箱處理。除了提高性能上,還獲得了編譯時的類型安全性,源代碼也因為強制類型轉換的次數減少而變得更加清晰。
裝箱之後,往往還要面臨拆箱的可能。拆箱不是講裝箱倒過來進行,其代價也比裝箱低很多。 如:Int32 i = (Int32)a[0]; 拆箱操作分為兩步:
#1, 獲取已裝箱的對象中的各個字段的地址,這個過程就是拆箱。
#2, 將這些字段包含的值從堆中復制到基於棧(線程棧)的值類型實例中。
簡單地說,如果獲取對值類型的一個實例的引用,該實例就必須裝箱。如上的ArrayList.Add 方法,將一個值類型的實例傳給需要獲取一個引用類型的方法,就會發生這種情況。
前面提到,未裝箱的值類型是比引用類型更“輕型”的類型。這要歸結於一下兩個原因:
#1, 它們不在托管堆上分配。
#2, 它們沒有堆上的每個對象都要的額外成員,也就是一個“類型對象指針”和一個“同步塊索引”。
所以,由於未裝箱的值類型沒有同步塊索引,就不能使用System.Threading.Monitor類型的各種方法(或者C#的lock語句)讓多個線程同步對這個實例的訪問。
關於值類型的幾點說明:
#1, 值類型可以重寫Equals, GetHashCode或者ToString的虛方法,CLR可以非虛地調用該方法,因為值類型是隱式密封的(即不存在多態性),沒有任何類型能夠從它們派生。
#2, 此外,用於調用方法的值類型實例不會被裝箱。但是,如果你重寫的虛方法要調用方法在基類中的實現,那麼在調用基類的實現時,值類型實例就會裝箱,以便通過this指針將對一個堆對象的引用傳給基方法。
#3, 值類型調用一個非虛的、繼承的方法時(比如GetType或MemberwiseClone),無論如何都要對值類型進行裝箱。這是因為這些方法是由System.Object定義的,所以這些方法期望this實參是指向堆上一個對象的指針。
#4, 將值類型的一個未裝箱實例轉型為類型的某個接口時,要求對實例進行裝箱。這是因為接口變量必須包含對堆上的一個對象的引用。
感言:
任何.NET Framework開發人員只有在切實理解了這些概念之後,才能保證自己開發程序的長期成功。因為只有深刻理解了之後,才能更快、更輕松地構建高效率的應用程序。
重要提示:
在值類型中定義的成員不應該修改類型的任何實例字段。也就是說,值類型應該是不可變(immutable)的。事實上,我建議將值類型的字段都標記為readonly。這樣一來,如果不慎寫了一個方法企圖更改一個字段,編譯就無法通過。
因為假如一個方法企圖修改值類型的實例字段,因為裝箱的變化,調用這個方法就會產生非預期的行為。構造好一個值類型之後,如果不去調用任何會修改其狀態的方法(或者如果根本不存在這樣的方法),就不用再為什麼時候會發生裝箱和拆箱/字段復制而擔心。如果一個值類型是不可變的,只需簡單地復制相同的狀態就可以了(不用擔心任何方法會修改這些狀態),代碼的任何行為都將在你的掌握之中。
也許,你在看到值類型的這些細微末節時遠離自定義值類型,或者你從來就沒用過自定義值類型。但是,FCL的核心值類型(Byte, Int32, ... 以及所有的enums 都是“不可變”的);並且了解並記住這些可能問題,當代碼真正出現這些問題的時候,也就會心中有數。
對象相等性和同一性
對於Object的Equals方法的默認實現來說,它實現的實際是同一性(identity),而非相等性(equality)。
對象哈希碼
一種算法,讓同一個類的對象按照自己不同的特征盡量的有不同的哈希碼,但不表示不同的對象哈希碼完全不同。”哈希碼就是對象的身份證。“
dynamic
dynamic表達式其實是和System.Object一樣的類型。編譯器假定你在表達式上進行的任何操作都是合法的,所以不會生成任何警告或者錯誤。但如果試圖在運行時執行無效的操作,就會拋出異常。
類型和成員基礎
類型的各種成員
常量、字段、實例構造器、類型構造器、方法、操作符重載、轉換操作符、屬性、事件、類型
類型的可見性
public: 不僅對它的定義程序集中的所有代碼可見,還對其他程序集中的代碼可見。
internal: 類型僅對定義程序集中的所有代碼可見,對其他程序集中的代碼不可見。
若定義類型時,如果不顯式指定類型的可見性,C#編譯器默認將類型的可見性設為internal。
友元程序集
我們希望TeamA程序集能有一個辦法將其工具類型定義為internal, 同時仍然允許團隊TeamB訪問這些類型。
CLR和C#通過友元程序集(friend assembly)來提供這方面的支持。如果希望在一個程序集中包含代碼,對另一個程序集中的內部類型執行單元測試,友元程序集功能也能派上用場。
成員的可訪問性
CLR自己定義了一組可訪問性修飾符,但每種編程語言在向成員應用可訪問性時,都選擇了自己一組術語以及相應的語法。如,CLR使用Assembly來表明成員對同一程序集內的所有代碼可見,而C#對應的術語是internal。
C#: private, protected, internal, protected internal, public
一個派生類型重寫在它的基類型中定義的一個成員時,C#編譯器要求原始成員和重寫成員具有相同的可訪問性。也就是說,如果基類中的成員是protected的,派生類中的重寫成員必須也是protected的。但是,這只是C#語言本身的一個限制,而不是CLR的。從一個基類派生時,CLR允許放寬成員的可訪問性限制,但不允許收緊。之所以不允許在派生類中將對一個基類方法的訪問變得更嚴格,是因為CLR承諾派生類總是可以轉型為基類,並獲取對基類方法的訪問權。如果允許在派生類中對重寫方法進行更嚴格的訪問限制,CLR的承諾就無法兌現了。
分部類、結構和接口
這個功能完全是由C#編譯器提供的,CLR對於分部類、結構和接口是一無所知的。partial 這個關鍵字告訴C#編譯器,一個類,結構或者接口的定義,其源代碼可能要分散到一個或者多個源代碼文件中。
CLR如何調用虛方法、屬性和事件
方法代表在類型或者類型的實例上執行某些操作的代碼。在類型上執行操作,稱為靜態方法;在類型的實例上執行操作,稱為非靜態方法。任何方法都有一個名稱、一個簽名和一個返回值(可以是void)。
合理使用類型的可見性和成員的可訪問性
使用.Net Framework時,應用程序很可能是使用多個公司聲場的多個程序集所定義的類型構成的。這意味著開發人員對所用的組件(程序集)以及其中定義的類型幾乎沒有什麼控制權。開發人員通常無法訪問源代碼(甚至不知道組件是用什麼編程語言創建的),而且不同組件的版本發布一般都基於不同的時間表。除此之外,由於多態和受保護(protected)成員,基類開發人員必須信任派生類開發人員所寫的代碼。當然,派生類的開發人員也必須信任從基類繼承的代碼。設計組件和類型時,應該慎重考慮這些問題。具體地說,就是要著重討論如何正確設置類型的可見性和成員的可訪問性,以便取得最好的結果。
在定義一個新類型時,編譯器應該默認生成密封類,使它不能作為基類使用。但是包括C#編譯器在內的許多編譯器都默認生成非密封類,當然允許開發人員使用sealed顯式地將新類型標記為密封。
密封類之所以比非密封類更好,有以下三方面原因:
#1, 版本控制:類開始是密封的,將來可以不破壞兼容性的前提下更改為非密封的。反之,不然。
#2, 性能:類是密封的,就肯定不會有派生類,調用方法時,就不需判斷是哪個類型定義了要調用的方法,即不須在運行時查找對象的類型,而直接采用非虛的方式調用虛方法。
#3, 安全性和可預測性:派生類既可重寫基類的虛方法,也可直接帶哦用這個虛方法在基類中的實現。一旦將某個方法、屬性或事件設為virtual,基類就會喪失對它的行為和狀態的部分控制權。
下面是定義類時會遵循的一些原則:
擴展閱讀:
每個應用程序都要使用這樣或者那樣的資源,比如文件、內存緩沖區、屏幕空間、網絡連接、數據庫資源等。事實上,在面向對象的環境中,每個類型都代表可供程序使用的一種資源。
要使用這些資源,必須為代表資源的類型分配內存。
訪問一個資源所需的具體步驟如下:
#1,調用IL指令newobj, 為代表資源的類型分配內存。C#中使用new操作符,編譯器就會自動生成該指令。
#2,初始化內存,設置資源的初始狀態,使資源可用。類型的實例構造器負責設置該初始狀態。
#3,訪問類型的成員(可根據需要反復)來使用資源。
#4,摧毀資源的狀態以進行清理。
#5,釋放內存。垃圾回收將獨自負責這一步。
需要注意的是,值類型(含所有枚舉類型)、集合類型、String、Attribute、Delegate和Exception 所代表的資源無需執行特殊的清理操作。如,只要銷毀對象的內存中維護的字符數組,一個String資源就會被完全清理。
CLR要求所有的資源都從托管堆(managed heap)分配。應用程序不需要的對象會被自動清除。那麼“托管堆又是如何知道應用程序不再用一個對象?”
進程初始化時,CLR要保留一塊連續的地址空間,這個地址空間最初並沒有對象的物理內存空間。這個地址空間就是托管堆。托管堆還維護著一個指針,我把它稱為NextObjPtr。指向下一個對象在堆中的分配位置。剛開始時候,NextObjPtr設為保留地址空間的基地址。
IL指令newobj用於創建一個對象。許多語言都提供了一個new操作符,它導致編譯器在方法的IL代碼中生成一個newobj指令。newobj指令將導致CLR執行如下步驟:
#1,計算類型(極其所有基類型)的字段需要的字節數。
#2,加上字段的開銷所需的字節數。每個對象都有兩個開銷字段:一個是類型對象指針,和一個同步塊索引。
#3,CLR檢查保留區域是否能夠提供分配對象所需的字節數,如有必要就提交存儲(commit storage)。如果托管堆有足夠的可用空間,對象會被放入。對象是在NextObjPtr指針指向的地址放入的,並且為它分配的字節會被清零。接著,調用類型的實例構造器(為this參數傳遞NextObjPtr), IL指令newobj(或者C# new 操作符)將返回對象的地址。就在地址返回之前,NextObjPtr指針的值會加上對象占據的字節數,這樣會得到一個新值,它就指向下一個對象放入托管堆時的地址。
作為對比,讓我們看一下C語言運行時堆如何分配內存,它為對象分配內存需要遍歷一個由數據結構組成的鏈表,一旦發現一個足夠大的塊,那個塊就會被拆分,同時修改鏈表節點中的指針,以確保鏈表的完整性。
對於托管堆,分配對象只需在一個指針上加一個值 - 這顯然要快得多。事實上,從托管堆中分配對象的速度幾乎可以與從線程棧分配內存媲美!
另外,大多數堆(C運行時堆)都是在他們找到可用空間的地方分配對象。所以,如果連續創建幾個對象,這些對象極有可能被分散,中間相隔MB的地址空間。但在托管堆中,連續分配的對象可以確保它們在內存中是連續的。
托管堆似乎在實現的簡單性和速度方面遠遠優於普通的堆,如C運行時堆。而托管堆之所以有這些好處,是因為它做了一個相當大膽的假設 - 地址空間和存儲是無限的。而這個假設顯然是不成立的,也就是說托管堆必須通過某種機制來允許它做這樣的假設。這個機制就是垃圾回收器。