C# in Depth學習筆記-值類型和引用類型
2.3 值類型和引用類型
本節簡要討論了為了深入更高版本C#的世界,C# 1的哪些主題的基本元素是必須理解的。
先來看看在現實世界和在.NET中,值類型和引用類型的基本差異是如何自然體現的。
2.3.1 現實世界中的值和引用
假定你在讀一份非常棒的東西,希望一個朋友也去讀它。你需要為朋友提供什麽才能讓他讀到文檔,這完全取決於閱讀的內容。
報紙與值類型
先假設你正在讀的是一份真正的報紙。為了給朋友一份,需要影印報紙的全部內容並交給他。屆時,他將獲得屬於他自己的一份完整的報紙。
在這種情況下,我們處理的是值類型的行為。所有信息都在你的手上,不需要從任何其他地方獲得。制作了副本之後,你的這份信息和朋友的那份是各自獨立的。可以在自己的報紙上添加一些註解,他的報紙根本不會改變。
網址與值類型
再假設你正在讀的是一個網頁。你需要給朋友的就是網頁的URL。這是引用類型的行為,URL代替引用。為了真正讀到文檔,必須在瀏覽器中輸入URL,並要求它加載網頁來導航引用。
另一方面,假如網頁由於某種原因發生了變化,你和你的朋友下次載入頁面時,都會看到那個改變。
在C#和.NET中,值類型和引用類型的差異與現實世界中的差別類似。
誰是值類型或引用類型
.NET中的大多數類型都是引用類型,除了以下總結的特殊情況,類是引用類型,而結構是值類型。特殊情況包括如下方面:
①數組類型是引用類型,即使元素類型是值類型(所以即便 int 是值類型, int[] 仍是引用類型);
②枚舉(使用 enum 來聲明)是值類型;
③委托類型(使用 delegate 來聲明)是引用類型;
④接口類型(使用 interface 來聲明)是引用類型,但可由值類型實現。
理解了引用類型和值類型的基本概念之後,接著要探討幾個最重要的細節。
2.3.2 值類型和引用類型基礎知識
學習值類型和引用類型時,要掌握的重要概念是一個特殊表達式的值是什麽。為使問題更加具體,我們使用了表達式最常見的例子——變量。但同樣的道理也適用於屬性、方法調用、索引器(indexer)和其他表達式。
表達式的值
值類型:表達式的值就是表達式的值
前面節說過,大多數表達式都有與其相關的靜態類型。對於值類型的表達式,它的值就是表達式的值,這很容易理解。例如,表達式“2+3”的值就是5。
引用類型:表達式的值是一個引用
對於引用類型的表達式,它的值是一個引用,而不是該引用所指代的對象。所以表達式 String.Empty 的值不是一個空字符串——而是對空字符串的一個引用。
二者在內存中的區別
存儲了兩個整數 x 和 y 的一個 Point 類型,它的一個構造函數能獲得兩個值。
這個類型可以實現為結構或類。圖2-3展示了執行下面兩行代碼的結果:
Point p1 = new Point(10, 20); Point p2 = p1;
圖2-3左邊的部分指出當 Point 是一個類(引用類型)時所涉及的值,右邊的部分展示了當Point 是一個結構(值類型)時的情形。
在兩種情況下, p1 和 p2 在賦值後都有相同的值:
①在 Point 是引用類型的情況下,那個值是引用: p1 和 p2 都引用同一個對象。
②在 Point 是值類型的情況下, p1 的值是一個“點”的完整的數據,也就是這個“點”的 x 和 y 值。將 p1 的值賦給 p2 ,會復制 p1 的所有數據。
變量的值在它聲明時的位置存儲
局部變量的值總是存儲在棧(stack)中,實例變量的值總是存儲在實例本身存儲的地方。引用類型實例(對象)總是存儲在堆(heap)中,靜態變量也是。
我的理解是:值類型的實例在棧上,那麽值類型的實例變量就在棧上,引用類型的實例在堆裏,引用類型的實例變量也就在堆裏,靜態變量和實例變量是一個道理。程序開始運行時,會用到的局部變量都會保存在棧上,至於這個變量是值類型實例還是引用類型實例——無所謂,他都在棧上。沒有在代碼想中聲明實例,實例字段就不會創建,實例字段和局部變量在概念上的差別一定要理解、分清。
值類型不可以派生出其他類型
這將導致的一個結果就是,值不需要額外的信息來描述值實際是什麽類型。
把它同引用類型比較,對於引用類型來說,每個對象的開頭都包含一個數據塊,它標識了對象的實際類型,同時還提供了其他一些信息。
永遠都不能改變對象的類型,強制轉換改變的是引用的類型
執行簡單的強制類型轉換時,運行時會獲取一個引用,檢查它引用的對象是不是目標類型的一個有效對象。如果有效,就返回原始引用,否則拋出異常。
引用本身並不知道對象的類型——所以同一個引用“值”可用於(引用)不同類型的多個變量。例如下面的代碼:
Stream stream = new MemoryStream(); MemoryStream memoryStream = (MemoryStream) stream;
第1行創建一個新的 MemoryStream 對象,並將 stream 變量的值設為對那個新對象的引用。
第2行檢查 stream 的值引用的是不是一個 MemoryStream (或派生類型)對象,並將MemoryStream 的值設為相同的值。
2.3.3 走出誤區
本節將處理一些典型錯誤,解釋真實的情況到底是什麽。
誤區1:“結構是輕量級的類”
使用值類型不取決於類型是否簡單
有人認為值類型不能或不應有方法或其他有意義的行為,它們應作為簡單的數據轉移類型來使用,只應該有 public 字段或簡單的屬性。
一個非常典型的反例就是 DateTime 類型:它作為值類型來提供是很有道理的,因為它非常適合作為和數字或字符相似的一個基本單位來使用。另外,它也理應被賦予對它的值執行計算的能力。
總之,具體應該如何決定,應取決於需要的是值類型的語義,還是引用類型的語義,而不是取決於這個類型簡單與否。
使用值類型不一定更節省性能
還有一些人認為值類型節省性能,因為它們不需要垃圾回收,除非被裝箱,不會因類型標識而產生開銷,也不需要解引用。
但在其他方面,引用類型顯得更輕。在傳遞參數、賦值、將值返回和執行類似的操作時,只需復制4或8字節,而不是復制全部數據。
性能問題都不是根據這種判斷來決定的。瓶頸從來都不是想當然的,根據性能進行設計之前,需要衡量不同的選擇。
誤區2:“引用類型保存在堆上,值類型保存在棧上”
第一部分是正確的——引用類型的實例總是在堆上創建的。
但第二部分就有問題了。前面講過,變量的值是在它聲明的位置存儲的。
所以,假定一個類中有一個 int 類型的實例變量,那麽在這個類的任何對象中,該變量的值總是和對象中的其他數據在一起,也就是在堆上。只有局部變量(方法內部聲明的變量)和方法參數在棧上。
對於C# 2及更高版本,很多局部變量並不完全存放在棧中,第5章中的匿名方法會講到這一點。
這些概念符合現實嗎?
一個較有爭議的說法是:寫托管代碼時,應該讓運行時去操心內存的最佳使用方式。事實上,在語言規範中,並沒有對什麽東西應該存儲在什麽地方做出硬性規定。在未來的某個版本的運行時中,也許會允許在棧上創建一些對象(前提是運行時知道這樣可行)。又或者,C#編譯器能生成幾乎完全用不到棧的代碼。
下一個誤區是由於對術語的理解出現了偏差。
誤區3:“對象在C#中默認是通過引用傳遞的”
CLR默認所有方法參數都傳值。
值傳遞值類型
拷貝值類型的值給棧上的局部變量,修改局部變量不影響原值。
值傳遞引用類型
拷貝引用類型的值(引用)給棧上的局部變量,在方法裏修改局部變量指向的堆中的對象,原值(引用)和其指向的是同一個對象,所以都會改變。
但修改方法裏的局部變量為null,只是使該引用指向null,不會對對象造成影響。
引用傳遞值類型
創建在棧上一個局部變量(引用),其指向棧上的原值的地址,在方法裏修改局部變量和值傳遞引用類型是一樣的。
引用傳遞引用類型
僅當方法返回對方法知道的一個對象的引用時,為引用類型使用out和ref才有意義。
CLR via C#對此做了更好的解釋,詳情訪問我在學習CLR via C#是關於值傳遞和引用傳遞的筆記博客
https://www.cnblogs.com/errornull/p/9822570.html
許多人對於裝箱和拆箱的理解也存在一定的誤區,下一節將對此加以澄清。
2.3.4 裝箱和拆箱
C#和.NET提供了一個名為裝箱(boxing)的機制,它允許根據值類型創建一個對象,然後使用對這個新對象的一個引用。
在接觸實際的例子之前,先來回顧兩個重要的事實:
①對於引用類型的變量,它的值永遠是一個引用;
②對於值類型的變量,它的值永遠是該值類型的一個值。
基於這兩個事實,下面3行代碼第一眼看上去似乎沒有太多道理:
int i = 5; object o = i; int j =(int) o;
裝箱和拆箱的過程
這裏有兩個變量: i 是值類型的變量, o 是引用類型的變量。
o 的值必須是一個引用,而數字5不是引用,它是一個整數值。實際發生的事情就是裝箱。
運行時將在堆上創建一個包含值(5)的對象(它是一個普通對象)。o 的值是對該新對象的一個引用。該對象的值是原始值的一個副本,改變 i 的值不會改變箱內的值。
第3行執行相反的操作——拆箱。
必須告訴編譯器將 object 拆箱成什麽類型。如果使用了錯誤的類型(比如 o 原先被裝箱成 unit 或者 long ,或者根本就不是一個已裝箱的值),就會拋出一個 InvalidCastException 異常。
同樣,拆箱也會復制箱內的值,在賦值之後, j 和該對象之間不再有任何關系。
另外,將值作為接口表達式使用時——把它賦給一個接口類型的變量,或者把它作為接口類型的參數來傳遞——也會發生裝箱。例如, IComparable x = 5; 語句會對數字5進行裝箱。
裝箱和拆箱影響性能
之所以要留意裝箱和拆箱,是由於它們可能會降低性能。一次裝箱或拆箱操作的開銷是微不足道的,但假如執行千百次這樣的操作,那麽不僅會增大程序本身的操作開銷,還會創建數量眾多的對象,而這些對象會加重垃圾回收器的負擔。
CLR via C#對此做了更多的解釋,詳情訪問我在學習CLR via C#是關於值類型的裝箱和拆箱的筆記博客
https://www.cnblogs.com/errornull/p/9744474.html
2.3.5 值類型和引用類型小結
本節討論了值類型和引用類型的差異,還澄清了圍繞它們存在的一些誤區。下面是一些要點。
①對於引用類型的表達式(如一個變量),它的值是一個引用,而非對象。
②引用就像URL——是允許你訪問真實信息的一小片數據。
③對於值類型的表達式,它的值是實際的數據。
④有時,值類型比引用類型更有效,有時恰好相反。
⑤引用類型的對象總是在堆上,值類型的值既可能在棧上,也可能在堆上,具體取決於上下文。
⑥引用類型作為方法參數使用時,參數默認是以“值傳遞”方式來傳遞的——但值本身是一個引用。
⑦值類型的值會在需要引用類型的行為時被裝箱;拆箱則是相反的過程。
既然我們已經討論了你需要適應的C# 1的所有特性。接著簡單看一下每個特性在更高版本的C#中得到了哪些增強。
C# in Depth學習筆記-值類型和引用類型