程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> .NET網頁編程 >> C# >> C#入門知識 >> 你真的了解C#中的值和引用嗎?(上)

你真的了解C#中的值和引用嗎?(上)

編輯:C#入門知識

術語解釋

在閱讀本文之前,你需要了解以下這幾個術語是不同的:值、引用、值類型、引用類型。

  • C#中有三種值(value),分別是值類型實例的值、引用類型實例的值和引用。
  • 值類型表達式的值是數據本身。
  • 引用類型表達式的值是引用。
  • 引用的值是一個數據塊,指向引用類型的實例。

注意,上面我說的都是值類型表達式引用類型表達式,包括局部變量和成員(如字段、屬性、索引器)等。現在,我們來考慮以下問題:

  • 值類型總是存儲在棧上嗎?
  • 值類型的局部變量總是存儲在棧上嗎?
  • 值類型要麼存儲在棧上,要麼存儲在堆上,是這樣嗎?
  • 引用的值是引用類型實例所在的地址嗎?

對於上面這些問題,您的答案是什麼呢?


誤區:值類型到底存儲在哪?

在談到值類型和引用類型的區別時,很多初學者常說值類型分配在方法的調用棧(或線程棧)上,引用類型分配在托管堆上,這種說法是錯誤的,至少前半部分是錯誤的。實際上這根本不應該成為值類型和引用類型區別的答案,這是所答非所問。值類型和引用類型的區別在語義層面,與存儲位置無關,並不是值類型和引用類型不同的分配方式導致了它們行為上的差異,而是因為和*引用*這兩種類型在語義上的差異,才導致了他們不同的分配方式。本文只討論存儲位置,不會深入介紹它們的區別。

有些朋友可能會說,詳細的分配方式應該是這樣的:

  • 引用類型的實例總是分配在托管堆上(在棧上至只保留實例的引用
  • 值類型的實例總是分配在它聲明的地方(聲明為局部變量時被分配在棧上,聲明為引用類型成員時則被分配在托管堆上)

具體來講也就是說,當值類型作為引用類型的私有字段時,它將作為引用類型實例的一部分,也分配在托管堆上。而當引用類型作為值類型的成員變量時,棧上將保留該成員的引用,其實際數據還是保存在堆中。

在C# 2出現之前,這樣的說法沒有問題。但C# 2引入了匿名方法和迭代器塊後,以上說法就過於籠統了,它只看到了代碼層面的東西,而沒有看到編譯器層面的東西。值類型實例作為局部變量不都是分配在棧上。這是因為C#代碼中的局部變量,很可能在編譯為IL後就不再是局部變量了。比如,如果匿名方法使用了外部變量(外部方法中聲明的局部變量),或者迭代器塊中聲明了變量,那麼這些變量將被提升為隱藏類的字段,因此也將分配在堆上

由此可見,雖然MSDN的文檔上也說,“值類型分配在棧上”,但這顯然是不合適的。因為

  • 這是不正確的。正確的說法應該是:值類型可以存儲在棧上
  • 這是無關的。在C#中,存儲的種類是隱藏在後台的,是實現細節。
  • 這是不完整的。比如引用是什麼?它既不是值類型,也不是引用類型的實例。引用也是值,也需要存儲在某個地方。

值類型的存儲位置

這樣,關於值類型的存儲位置,正確、完整的說法應該是:對於值類型來說,在微軟桌面CLR的C#實現中,如果值類型的實例是局部變量、Lambda表達式或匿名方法中封閉的臨時變量,且方法體不是迭代器塊,並且JIT不對該值進行寄存,那麼這時該值類型將存儲在棧上。

夠啰嗦吧,其實每一句都必不可少:

  • 其他廠商實現C#時,完全可以對臨時變量采用不同的分配策略。C#並沒有要求必須將局部的值類型變量存儲在棧上。
  • 微軟提供了多個CLI版本,有的用於嵌入式系統,有的用於Web浏覽器。這些CLI運行在不同的硬件設備上,其分配策略是未知的。可能這些硬件根本就沒有棧,也可能每個線程包含多個棧,也可能所有的東西都分配在堆上。
  • Lambda表達式和匿名方法會將局部變量提升為分配在堆上的字段。
  • 現在桌面CLR的C#實現中,迭代器塊也將局部變量提升為分配在堆上的字段。但這不是必須的。微軟也可以選擇其他的實現,將其存儲在棧上。
  • 除了棧和堆以外,還有其他的內存管理方式,但人們總是忽略這一點。比如寄存器,它既不在堆上,也不在棧上。如果寄存器的大小適當,值類型也完全可以位於一個寄存器中。如果有東西存儲在棧上是重要的,那麼為什麼存儲在寄存器中就不重要呢?相反,如果JIT編譯器的寄存器規劃算法是不重要的,那麼為什麼棧的分配策略就不能是不重要的呢?

存儲位置與生存時間

之所以會有這樣的誤區,是因為人們總是錯誤地以為類型系統與存儲分配策略有關。然而究竟是存儲在棧上還是堆上,與要存儲的類型沒有任何關系。分配機制的選擇只與存儲所需的生存時間(lifetime)有關

明確了這些之後,我們可以得出以下結論:

  • 值共有三種:值類型實例、引用類型實例和引用。(C#代碼不能直接操縱引用類型的實例,但可以通過引用來操縱。在不安全代碼下,指針類型被視為值類型,以決定其值的存儲需求)
  • 值存儲在存儲位置(storage location)中。
  • 程序所操作的所有值都存儲在某個存儲位置中。
  • 所有引用(空引用除外)都指向一個存儲位置。
  • 所有存儲位置都有一個生存時間(在這段時間內,存儲位置中的內容是有效的)
  • 從某個特定方法的開始,到方法返回或拋出異常為止,這段時間成為方法執行的活動期(activation period)。
  • 方法中的代碼可以請求一個存儲位置(即聲明一個局部變量)。如果該存儲位置所需的生存時間大於當前方法執行的活動期,那麼這個存儲位置就稱為是長期的(long lived)。否則為短期的(short lived)。(注意,當方法M調用方法N時,M會要求使用傳入N的參數和N返回值的存儲位置。)

現在我們來看一下實現細節。在微軟CLR對C#的實現中:

  • 共有三種存儲位置:棧位置、堆位置和寄存器。
  • 長期的存儲位置通常是堆位置。
  • 短期的存儲位置通常是棧位置或寄存器。
  • 在某些情況下,編譯器或運行時很難決定某個特定的存儲位置是短期的還是長期的。這時,會謹慎地認為它們是長期的。例如,引用類型實例的存儲位置總是認為是長期的,盡管可能為短期的。因此,它們總是位於堆上。

這樣就可以很自然地得出:

  • 就存儲來說,引用和值類型實例實質上是一回事,都存儲在棧上、寄存器中或堆上,這取決於值的存儲是短期的還是長期的。
  • 數組元素、引用類型的字段、迭代器塊中的局部變量以及Lambda或匿名方法中的非封閉局部變量,它們的生存期都必須比第一次請求這些存儲的方法的活動期要長。即使少數情況下它們的生存時間要短於方法的活動期,也很難或根本沒法通知編譯器。因此不得不保守地對待:所有這些存儲都將位於堆上。
  • 局部變量和臨時值通常可以通過編譯時分析,認為在方法活動期之後就沒用了,因此為短期的,可以存儲在棧上或寄存器中。

一旦你摒棄值的類型與存儲有關這個瘋狂的想法,一切就會豁然開朗了。其實,你無需知道這些,除非要編寫不安全代碼或與非托管代碼交互。你盡可以讓編譯器和運行時來管理存儲位置的生存時間,這正是它們所擅長的。


誤區:引用就是地址


下面我們來看一個關於引用的誤區。雖然連《CLR via C#》中都有類似的描述:引用類型的變量保存的是對象的地址,但這是不正確的。引用類型的變量保存的是對象的引用

引用是一個模糊的概念。指針與引用類似,可以通過跟蹤其位置找到一些數據。但指針更智能,比如可以進行數學運算等。指針也更強大,引用能做的事,指針都能做,反之則不然。指針的缺點是對初學者來說太難理解了,很可能搬石頭砸自己的腳。

指針是通過地址實現的。地址是一個數字,表示對進程的整個虛地址空間的一個偏移量(offset)。正因為地址是數字,所以才能對指針進行數學運算。

有些時候,指針是無法替代的;而大多數時候,又不需要這麼復雜的概念。因此,C#中既包含指針,也包含引用。

C#語言規范中對引用的描述是十分模糊的:引用類型的變量存儲了對某個對象的引用。同樣,對指針的描述也是很模糊的:指針變量存儲了對象的地址。不過,規范中從來沒有說過引用就是地址。因此C#的引用是一個十分模糊的概念。你只能對一個引用進行解引用(dereference),或比較兩個引用是否相等,除此之外不能進行任何操作。

實際上,在後台,對於托管對象的引用,CLR將其實現為GC所擁有的對象的地址。但這是實現細節。C#引用應該實現為只對GC有意義的不透明的句柄,只是這個句柄恰巧為運行時地址。這是實現細節,你既不應該知道,也不應該依賴於此。

所以,你不能說“引用即地址”這樣的話。它並不是必須為地址,實現細節完全有可能改變。而且對初學者來說,你還要解釋什麼是地址,什麼是偏移量。對了解指針的人來說,還會帶來困擾:既然引用和指針都是地址,那麼應該可以將引用轉換為unsafe的指針。但這是不正確的。

綜上所述,如果你不是要向別人解釋C#的內存模型,請不要使用“引用即地址”這種論調。我們應該說:引用是一個小的數據塊,它包含一些信息,CLR可以根據這些信息來找到引用所指向的對象。這很模糊,但卻正確,並且沒有多余的暗示。


結論

 

  • 值類型可以存儲在棧上,也可能不存儲在棧上。即便存儲在棧上,這也屬於實現細節。微軟完全可以不這麼做。
  • 引用在當今的CLR實現為地址,這也是實現細節。

你會發現,我們“無意中”從很多書籍和資料中了解到了CLR的實現細節,如果不是要深入研究這些細節,其實是沒有必要知道的。我並不是說這些細節不重要,而是說它們會給我們帶來誤導,讓我們誤以為必須是這樣。

參考資料

The Stack Is An Implementation Detail

  • The Truth About Value Types
  • Memory in .NET - what goes where
  • References are not addresses

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved