一.const與readonly的爭議
你一定寫過const,也一定用過readonly,但說起兩者的區別,並說出何時用const,何時用readonly,你是否能清晰有條理地說出個一二三? const與readonly之所以有如此爭議,是因為彼此都存在"不可改變"這一特性,對於二者而言,我們需要關心的是,什麼時候開始不可變?什麼是不可改變的?這就引出了我們下面要討論的話題.二.什麼時候開始不可變?
我們先拋出結論. const在程序運行的任何時候都是不可變的,無論什麼時候開始,什麼時候結束,它的值是固化在代碼中的,我們稱之為編譯期常量; readonly在某個具體實例第一次初始時指定它的值(出了構造函數後,對於這個實例而言,它就不能改變)或者是作為靜態成員在運行時加載它的值,我們稱之為運行時常量. 我們先談const: 1.const由於其值從不變化,我們稱之為常量,常量總是靜態的,因此const是天然static的,我們不能再用static修飾const.如下圖所示: 正確的定義應該是const float PI=3.14159F; 2.const既然是靜態的,因此它屬於整個類,而不屬於某個實例,我們可以直接通過類名來調用,如下所示: 3.由於常量的值是直接嵌入代碼的,因此在運行時不需要為常量分配任何內存,也不能獲取常量的地址,也不能以傳引用的方式傳遞常量. 什麼叫直接嵌入代碼?即:在編譯的過程中,編譯器首先將常量值保存到程序集元數據中,在引用常量的地方,編譯器將提取這個常量值並嵌入生成的IL代碼中,這也就是為什麼常量不需要分配任何內存的原因. 我們來驗證一下上面的結論,首先我們定義一個常量:1 public class MathHelper 2 { 3 public const float PI= 3.14159F; 4 }調用:
1 static void Main(string[] args) 2 { 3 float pi= MathHelper.PI; 4 }我們查看生成的IL代碼,如下: 標紅的那一行,即是將PI的值直接嵌入代碼之中.理解這一點不難,但是這種寫法會帶來潛在的問題:const不能支持很好支持程序集的跨版本.為了說明這個問題,我們需要對我們的代碼進行如下的改造: 第一步:我們將MathHelper單獨放到一個項目中,並生成一個單獨的程序集(程序集版本:1.0). 第二步:我們編譯應用程序為exe文件,采用上面的方法來查看IL代碼,我們看到const的值仍然嵌入了代碼之中. 第三步:我們修改PI的值為3.14,重新編譯MathHelper,生成一個單獨的程序集(程序集版本:2.0). 第四步:因為我們只是重新編譯了MathHelper所在的程序集,沒有重新編譯exe文件,我們查看exe的IL代碼,發現嵌入代碼的值仍為3.14159. 也就是在跨程序集的引用中,當改變了常量時,除非重新編譯所有引用了常量的程序集,否則改變不能體現在引用當中. 雖然有了這樣的bug隱患,也不是說const就一無是處,由於const在程序中不占用內存,所以它的速度非常之快,於是我們在設計程序時,如果一個值從不變化,我們可以將其定義常量來尋求速度上的效率上的提升.比如我們程序需要國際化的時候,簡體中文的編碼為2052,美國英語的編碼為1033,我們可以將它們定義為常量. 另外,我們說過常量是沒有地址的,因而不能以傳引用的方式傳遞常量,即下面的寫法是錯誤的: 說完const,我們來說readonly 1.readonly是實例的,因此通過類名是不可直接訪問readonly變量的 定義:
1 public class MathHelper 2 { 3 public readonly float PI; 4 }訪問: 2.readonly出了構造函數,對於這個實例而言就不可改變,因此下面的寫法也是錯誤的 既然,我們強調"出了構造函數",那是不是意味著,我們在構建函數內部,可以一次或多次改變它的值?為了驗證我們的猜想,我們對MathHelper改造如下:
1 public class MathHelper 2 { 3 public MathHelper() 4 { 5 this.PI = 3.15F; 6 this.PI = 3.14F; 7 } 8 public readonly float PI; 9 }調用代碼:
1 static void Main(string[] args) 2 { 3 MathHelper m = new MathHelper(); 4 Console.WriteLine(m.PI); 5 }輸出結果: 從以上的結果,我們可以看出,在構造函數中可以對readonly變量多次賦值,但一旦出了構建函數則是只讀的. 3.有了第2點的支撐,下面我們可以驗證readonly是實例的(不可變的第一種情況)這一結論,我們現在來驗證這個結論. 我們改造MathHelper如下:
1 public class MathHelper 2 { 3 public MathHelper(float pi) 4 { 5 this.PI = pi; 6 } 7 public readonly float PI; 8 }調用如下:
1 static void Main(string[] args) 2 { 3 MathHelper m1 = new MathHelper(3.14F); 4 Console.WriteLine(m1.PI); 5 6 MathHelper m2 = new MathHelper(3.15F); 7 Console.WriteLine(m2.PI); 8 9 Console.Read(); 10 }輸出結果: 我們實例化了兩個不同的MathHelper,給PI賦予了不同的值,PI的值屬於不同的實例,這也就驗證了我們的結論. 4.readonly的內聯寫法 那有的童鞋說了,我還用過這樣的寫法,這說明了readonly可以在構建方法外賦值.如下所示:
1 public class MathHelper 2 { 3 public readonly float PI=3.15F; 4 }其實,這是一種內聯寫法,是C#的一種語法糖,只是一種語法上的簡化,實際它們也是在構造方法中進行初始化的.C#允許使用這種簡化的內聯初始化語法來初始化類的常量、read/write字段和readonly字段。 5.readonly賦值的第二種情況:如果我用static修飾readonly會發生什麼? 前面講const時,我們說過const是靜態的,這種靜態不可以顯式指定,因此在const前加static會導致編譯器編譯失敗.那我們把static修飾readonly會發生什麼樣的結果? 首先,我們確定,靜態的是屬於類的,此時的readonly我們不能通過構造函數來指定.
1 public class MathHelper 2 { 3 public static readonly float PI=3.14F; 4 }調用:
1 static void Main(string[] args) 2 { 3 Console.WriteLine(MathHelper.PI); 4 Console.Read(); 5 }結果與我們預期的一致: 但我們的疑問不會就此打住:既然static readonly也是屬於類的,而且它的值也不能通過構造函數來賦值,那麼編譯器會像const一樣把它的值寫入IL代碼中麼?我們反編譯其IL代碼如下: 可以看到,這裡並沒有將值嵌入到代碼當中. 因此,我們可以大膽地預測,這種寫法不會造成支持程序集的跨版本問題.這裡就不寫驗證的過程,留給各位讀者朋友自行探索. 既然沒有嵌入代碼中,那麼在程序運行的時候,它的值是在什麼時候分配內存的呢? 我們引用《CLR via C#(第4版)》中的一句話來說明這個問題:對於靜態字段,其動態內存是在類中分配的,而類是在類型加載到AppDomain時創建的,那麼,什麼時候將類型加載到AppDomain中呢?答案是:通常是在引用了該類型的任何方法首次進行JIT編譯的時候.而對於前面第3點中的實例字段來說,其動態內存是在構造類型的實例時分配的.
三.什麼是不可變的?
前面我們花了大量的篇幅說明const與readonly的變量什麼時候才開始不可變,有的從一開始就不可變,有的是第一次加載的時候不可變,有的是出了構造函數後不可變,但是我們有一個十分關鍵的問題沒有弄清楚:什麼東西不可變?也許童鞋們很疑惑,值不可變呗!這話不完全對. 要想理解這個問題,我們需要明白const與readonly修飾的對象,也就是我們不變的內容. const可以修飾基元類型:Boolean、Char、Byte、SByte、Int16、UInt16、Int32、UInt32、Int64、UInt64、Single、Double、Decimal和String。也可以修改類class,但要把值設置為null。不可以修飾struct,因為struct是值類型,不可以為null. 對於基元類型來說,值是存儲在棧上的,因此我們可以認為不變的是值本身,這裡string是一個特殊的引用類型,這裡它也存在值類型的特征,因此也可以認為它不變的是值本身. 對於readonly而言,readonly可以修飾任何類型.對於基元類型而言,我們可以認為它與const無異,但是對於引用類型,我們需要謹慎對待,不可想當然,下面我們通過實驗來得出結論:1 public class Alphabet 2 { 3 public static readonly Char[] Letters = new Char[] {'A','B','C','D','E','F' }; 4 }調用:
1 static void Main(string[] args) 2 { 3 Alphabet.Letters[0] = 'a'; 4 Alphabet.Letters[0] = 'b'; 5 Alphabet.Letters[0] = 'c'; 6 Alphabet.Letters[0] = 'd'; 7 Alphabet.Letters[0] = 'e'; 8 Alphabet.Letters[0] = 'f'; 9 Console.WriteLine(Alphabet.Letters.Length); 10 Console.Read(); 11 }可賦值!!! 輸出結果如下: 現在,我們給它賦予一個新的對象: 不可賦值!!! 看到這裡你是不是心裡有答案了? 結論:對於引用類型而言,我們可以賦值,而不可以賦予一個新的對象,因為這裡不變的是引用,而不是引用的對象.
四:總結
到此,我們的const與readonly的庖丁解牛式的解析也就告一段落了,說了這麼多,我們其實也就是想說明以下2點: 1.const任何時候都不變,比readonly快,但不能解決跨版本程序集問題,readonly靜態時在第一次JIT編譯後不變,實例時在出了實例的構造函數後不可變. 2.const修飾基元類型,不變的是值;readonly修飾值類型時,其值不變,修改引用類型時,其引用不變. 以上.