1. 程式人生 > >第九回:品味型別---值型別與引用型別(中)-規則無邊

第九回:品味型別---值型別與引用型別(中)-規則無邊

 本文將介紹以下內容:

型別的基本概念 
值型別深入
引用型別深入
值型別與引用型別的比較及應用
 

1. 引言

上回[第八回:品味型別---值型別與引用型別(上)-記憶體有理]的釋出,受到大家的不少關注,我們從記憶體的角度瞭解了值型別和引用型別的所以然,留下的任務當然是如何應用型別的不同特點在系統設計、效能優化等方面發揮其作用。因此,本回是對上回有力的補充,同時應朋友的希望,我們盡力從記憶體除錯的角度來著眼一些設計的分析,這樣就有助於對這一主題進行透徹和全面的理解,當然這也是下一回的重點。

從記憶體角度來討論值型別和引用型別是有理有據的,  而從規則的角度來了解值型別和引用型別是無邊無際的。本文旨在從上文呼應的角度,來把這個主題徹底的融會貫通,無邊無跡的應用,還是來自反覆無常的實踐,因此對應用我只能說以一個角度來闡釋觀點,但是肯定不可能力求全域性。因此,我們從以下幾個角度來完成對值型別與引用型別應用領域的討論。 

2. 通用規則與比較

通用有規則:

string型別是個特殊的引用型別,它繼承自System.Object肯定是個引用型別,但是在應用表現上又凸現出值型別的特點,那麼究竟是什麼原因呢?例如有如下的一段執行:
 

 簡單的說是由於string的immutable特性,因此每次對string的改變都會在託管堆中產生一個新的string變數,上述string作為引數傳遞時,實際上執行了s=s操作,在託管堆中會產生一個新的空間,並執行資料拷貝,所以才有了類似於按值傳遞的結果。但是根據我們的記憶體分析可知,string在本質上還是一個引用型別,在引數傳遞時發生的還是按址傳遞,不過由於其特殊的恆定特性,在函式內部新建了一個string物件並完成初始化,但是函式外部取不到這個變化的結果,因此對外表現的特性就類似於按值傳遞。至於string型別的特殊性解釋,我推薦Artech的大作《深入理解string和如何高效地使用string》。

另外,string型別過載了==操作符,在型別比較是比較的是實際的字串,而不是引用地址,因此有以下的執行結果:


            string aString = "123";
            string bString = "123";
            Console.WriteLine((aString == bString)); //顯示為true,等價於aString.Equals(bString);
            string cString = bString;
            cString = "456";
            Console.WriteLine((bString == cString)); //顯示為false,等價於bString.Equals(cString);

通常可以使用Type.IsValueType來判斷一個變數的型別是否為值型別,典型的操作為: 
    public struct MyStructTester
    { }

    public class isValueType_Test
    {
        public static void Main()
        {
            MyStructTester aStruct = new MyStructTester();
            Type type = aStruct.GetType();
            if (type.IsValueType)
            {
                Console.WriteLine("{0} belongs to value type.", aStruct.ToString());
            }
 
        }
    }
.NET中以操作符ref和out來標識值型別按引用型別方式傳遞,其中區別是:ref在引數傳遞之前必須初始化;而out則在傳遞前不必初始化,且在傳遞時必須顯式賦值。
值型別與引用型別之間的轉換過程稱為裝箱與拆箱,這值得我們以專門的篇幅來討論,因此留待後文詳細討論這一主題。
sizeof()運算子用於獲取值型別的大小,但是不適用於引用型別。
值型別使用new操作符完成初始化,例如:MyStruct aTest = new MyStruct(); 而單純的定義沒有完成初始化動作,此時對成員的引用將不能通過編譯,例如: 
MyStruct aTest;
Console.WriteLine(aTest.X);
引用型別在效能上欠於值型別主要是因為以下幾個方面:引用型別變數要分配於託管堆上;記憶體釋放則由GC完成,造成一定的CG堆壓力;同時必須完成對其附加成員的記憶體分配過程;以及物件訪問問題。因此,.NET系統不能由純粹的引用型別來統治,效能和空間更加優越和易於管理的值型別有其一席之地,這樣我們就不會因為一個簡單的byte型別而進行復雜的記憶體分配和釋放工作。Richter就稱值型別為“輕量級”型別,簡直恰如其分,處理資料較小的情況時,應該優先考慮值型別。
值型別都繼承自System.ValueType,而System.ValueType又繼承自System.Object,其主要區別是ValueType重寫了Equals方法,實現對值型別按照例項值比較而不是引用地址來比較,具體為:
char a = 'c';
char b = 'c';
Console.WriteLine((a.Equals(b))); //會返回true;
基元型別,是指編譯器直接支援的型別,其概念其實是針對具體程式語言而言的,例如C#或者VB.NET,通常對應用.NET Framework定義的內建值型別。這是概念上的界限,不可混淆。例如:int對應於System.Int32,float對應於System.Single。
比較出真知:

值型別繼承自ValueType(注意:而System.ValueType又繼承自System.Object);而引用型別繼承自System.Object。
值型別變數包含其例項資料,每個變數儲存了其本身的資料拷貝(副本),因此在預設情況下,值型別的引數傳遞不會影響引數本身;而引用型別變數儲存了其資料的引用地址,因此以引用方式進行引數傳遞時會影響到引數本身,因為兩個變數會引用了記憶體中的同一塊地址。
值型別有兩種表示:裝箱與拆箱;引用型別只有裝箱一種形式。我會在下節以專門的篇幅來深入討論這個話題。
典型的值型別為:struct,enum以及大量的內建值型別;而能稱為類的都可以說是引用型別。 struct和class主要的區別可以參見我的拙作《第四回:後來居上:class和struct》來詳細瞭解,也是對值型別和引用型別在應用方面的有力補充。
值型別的記憶體不由GC(垃圾回收,Gabage Collection)控制,作用域結束時,值型別會自行釋放,減少了託管堆的壓力,因此具有效能上的優勢。例如,通常struct比class更高效;而引用型別的記憶體回收,由GC來完成,微軟甚至建議使用者最好不要自行釋放記憶體。
值型別是密封的(sealed),因此值型別不能作為其他任何型別的基類,但是可以單繼承或者多繼承介面;而引用型別一般都有繼承性。 
值型別不具有多型性;而引用型別有多型性。
值型別變數不可為null值,值型別都會自行初始化為0值;而引用型別變數預設情況下,建立為null值,表示沒有指向任何託管堆的引用地址。對值為null的引用型別的任何操作,都會丟擲NullReferenceException異常。
值型別有兩種狀態:裝箱和未裝箱,執行庫提供了所有值型別的已裝箱形式;而引用型別通常只有一種形式:裝箱。
3. 對症下藥-應用場合與注意事項

現在,在記憶體機制瞭解和通用規則熟悉的基礎上,我們就可以很好的總結出值型別和引用型別在系統設計時,如何作出選擇?當然我們的重點是告訴你,如何去選擇使用值型別,因為引用型別才是.NET的主體,不必花太多的關照就可以贏得市場。

3.1 值型別的應用場合

MSDN中建議以型別的大小作為選擇值型別或者引用型別的決定性因素。資料較小的場合,最好考慮以值型別來實現可以改善系統性能;
結構簡單,不必多型的情況下,值型別是較好的選擇;
型別的性質不表現出行為時,不必以類來實現,那麼用以儲存資料為主要目的的情況下,值型別是優先的選擇;
引數傳遞時,值型別預設情況下傳遞的是例項資料,而不是記憶體地址,因此資料傳遞情況下的選擇,取決於函式內部的實現邏輯。值型別可以有高效的記憶體支援,並且在不暴露內部結構的情況下返回例項資料的副本,從安全性上可以考慮值型別,但是過多的值傳遞也會損傷效能的優化,應適當選擇;
值型別沒有繼承性,如果型別的選擇沒有子類繼承的必要,優先考慮值型別;
在可能會引起裝箱與拆箱操作的集合或者佇列中,值型別不是很好的選擇,因為會引起對值型別的裝箱操作,導致額外記憶體的分配,例如在Hashtable。關於這點我將在後續的主題中重點討論。 
3.2 引用型別的應用場合

可以簡單的說,引用型別是.NET世界的全值殺手,我們可以說.NET世界就是由類構成的,類是面向物件的基本概念,也是程式框架的基本要素,因此靈活的資料封裝特性使得引用型別成為主流;
引用型別適用於結構複雜,有繼承、有多型,突出行為的場合;
引數傳遞情況也是考慮的必要因素;    
4. 再論型別判等

型別的比較通常有Equals()、ReferenceEquals()和==/!=三種常見的方法,其中核心的方法是Equals。我們知道Equals是System.Object提供的虛方法,用於比較兩個物件是否指向相同的引用地址,.NET Framework的很多型別都實現了對Equals方法的重寫,例如值型別的“始祖”System.ValueType就過載了Equal方法,以實現對例項資料的判等。因此,型別的判等也要從重寫或者過載Equals等不同的情況具體分析,對值型別和引用型別判等,這三個方法各有區別,應多加註意。

4.1 值型別判等

Equals,System.ValueType過載了System.Object的Equals方法,用於實現對例項資料的判等。
ReferenceEquals,對值型別應用ReferenceEquals將永遠返回false。
==,未過載的==的值型別,將比較兩個值是否“按位”相等。
4.2 引用型別判等

Equals,主要有兩種方法,如下  
public virtual bool Equals(object obj);
public static bool Equals(object objA, object objB);

 一種是虛方法,預設為引用地址比較;而靜態方法,如果objA是與objB相同的例項,或者如果兩者均為空引用,或者如果objA.Equals(objB)返回true,則為true;否則為false。.NET的大部分類都重寫了Equals方法,因此判等的返回值要根據具體的重寫情況決定。

ReferenceEquals,靜態方法,只能用於引用型別,用於比較兩個例項物件是否指向同一引用地址。
==,預設為引用地址比較,通常進行實現了==的過載,未過載==的引用型別將比較兩個物件是否引用地址,等同於引用型別的Equals方法。因此,很多的.NET類實現了對==操作符的過載,例如System.String的==操作符就是比較兩個字串是否相同。而==和equals方法的主要區別,在於多型表現上,==是被過載,而Equals是重寫。
有必要在自定義的型別中,實現對Equals和==的重寫或者過載,以提高效能和針對性分析。

5. 再論型別轉換

型別轉換是引起系統異常一個重要的因素之一,因此在有必要在這個主題裡做以簡單的總結,我們不力求照顧全面,但是追去提綱挈領。常見的型別轉換包括:

隱式轉換:由低階型別項高階型別的轉換過程。主要包括:值型別的隱式轉換,主要是數值型別等基本型別的隱式轉換;引用型別的隱式轉換,主要是派生類向基類的轉換;值型別和引用型別的隱士轉換,主要指裝箱和拆箱轉換。
顯示轉換:也叫強制型別轉換。但是轉換過程不能保證資料的完整性,可能引起一定的精度損失或者引起不可知的異常發生。轉換的格式為, 
(type)(變數、表示式)
例如:int a = (int)(b + 2.02);

值型別與引用型別的裝箱與拆箱是.NET中最重要的型別轉換,不恰當的轉換操作會引起效能的極大損耗,因此我們將以專門的主題來討論。
以is和as操作符進行型別的安全轉換,詳見本人拙作《第一回:恩怨情仇:is和as》。
System.Convert類定義了完成基本型別轉換的便捷實現。
除了string以外的其他型別都有Parse方法,用於將字串型別轉換為對應的基本型別;
使用explicit或者implicit進行使用者自定義型別轉換,主要給使用者提高自定義的型別轉換實現方式,以實現更有目的的轉換操作,轉換格式為,
static 訪問修飾操作符 轉換修飾操作符 operator 型別(引數列表);
 例如:

public Student
{
    //
   
    static public explicite opertator Student(string name, int age)
    {
        return new Student(name, age);
    }

    //
}
其中,所有的轉換都必須是static的。  

6. 結論

現在,我們從幾個角度延伸了上回對值型別和引用型別的分析,正如本文開頭所言,對型別的把握還有很多可以挖掘的要點,但是以偏求全的辦法我認為還是可取的,尤其是在技術探求的過程中,力求面面俱到的做法並不是好事。以上的幾個角度,我認為是對值型別和引用型別把握的必經之路,否則在實際的系統開發中常常會在細小的地方栽跟頭,摸不著頭腦。

品味型別,我們以應用為要點撬開值型別和引用型別的規矩與方圓。

品味型別,我們將以示例為導航,開動一個層面的深入分析,下回《第十回:品味型別---值型別與引用型別(下)-應用征途》我們再見。