圖解C#的值型別,引用型別,棧,堆,ref,out

zhangweiwen發表於2013-06-27

C# 的型別系統可分為兩種型別,一是值型別,一是引用型別,這個每個C#程式設計師都瞭解。還有託管堆,棧,ref,out等等概念也是每個C#程式設計師都會接觸到的概念,也是C#程式設計師面試經常考到的知識,隨便搜搜也有無數的文章講解相關的概念,貌似沒寫一篇值型別,引用型別相關部落格的不是好的C#程式設計師。我也湊個熱鬧,試圖徹底講明白相關的概念。

程式執行的原理

要徹底搞明白那一堆概念及其它們之間的關係似乎並不是一件容易的事,這是因為大部分C#程式設計師並不瞭解託管堆(簡稱“堆”)和執行緒棧(簡稱“棧”),或者知道它們,但瞭解得並不深入,只知道:引用型別儲存在託管堆裡,而值型別“通常”儲存在棧裡。要搞明白那一堆概念的關係,我認為先要明白程式執行的基本原理,從而理解棧和託管堆的作用,才能理清它們的關係。考慮下面程式碼,Main呼叫Method1,Method1呼叫Method2:

class Program
{
    static void Main(string[] args)
    {
        var num = 120;
        Method1(num);
    }

    static void Method1(int num)
    {
        var num2 = num + 250;
        Method2(num2);
        Console.WriteLine(num);
    }

    static void Method2(int i)
    {
        Console.WriteLine(i);
    }
}

大家都知道Windows程式通常是多個執行緒的,這裡不考慮多執行緒的問題。程式由Main方法進入開始執行,這時這個(主)執行緒會分配得到一個1M大小的只屬於它自己的執行緒棧。這1M的的棧空間用於向方法傳遞引數,定義區域性變數。所以在Main方法進入Method1前,大家心理面要有一個”記憶體圖“:把num壓入執行緒棧,如下圖:

image

接著把num作為引數傳入Method1方法,同樣在Method1內定義一個區域性變數num2,呼叫加方法得到最後的值,所以在進入Method2前,“記憶體圖”如下,num是引數,num2是區域性變數

image

接著呼叫Method2的過程雷同,然後退出Method2方法,回到上圖的樣子,再退出Method1方法,再回到第一副圖的樣子,然後退出程式,整個過程如下圖:

image

所以去除那些if,for,多執行緒等等概念,只保留物件記憶體分配相關概念的話,程式的執行可以簡單總結為如下:

程式由Main方法進入執行,並不斷重複著“定義區域性變數,呼叫方法(可能會傳參),從方法返回”,最後從Main方法退出。在程式執行過程中,不斷壓入引數和區域性變數到執行緒棧裡,也不斷的出棧。

注意,其實壓入棧的還有方法的返回地址等,這裡忽略了。

引用型別和堆

上面的例子我只用了一種簡單的int值型別,目的是為了只關注執行緒棧的壓棧(生長)和出棧(消亡)。很明顯C#還有種引用型別,引入引用型別,再考慮上面的問題,看下面程式碼:

static void Main(string[] args)
{
    var user = new User { Age = 15 };
    var num = 23;
    Console.WriteLine(user.Age);
    Console.WriteLine(num);
}

class User
{
    public int Age;
}

我想很多人都應該知道,這時應該引入托管堆的概念了,但這裡我想跟上面一樣,先從棧的角度去考慮問題,所以在呼叫WriteLine前,“記憶體圖”應該是這樣的(地址是亂寫的):

image

這也就是人們常說的:對於引用型別,棧裡儲存的是指向在堆裡的例項物件的地址(指標,引用)。既然只是個地址,那麼要獲取一個物件的例項應該有一個根據地址或尋找物件的步驟,而事實正是這樣,如果Console.WriteLine(num),這樣獲取棧裡的num的值給WriteLine方法算一步的話,要獲取上面user的例項物件,在執行時是要分兩步的,也就是多了根據地址去尋找託管堆裡例項物件的欄位或方法的步驟。IL反編譯上面的Main方法,刪去一些無關程式碼後:

//load local 0=>獲取區域性變數0(是一個地址) 
IL_0012:  ldloc.0
// load field => 將指定物件中欄位的值推送到堆疊上。
IL_0013:  ldfld      int32 CILDemo.Program/User::Age
IL_0018:  call       void [mscorlib]System.Console::WriteLine(int32)
//load local 1=>獲取區域性變數1(是一個值) 
IL_001e:  ldloc.1
IL_001f:  call       void [mscorlib]System.Console::WriteLine(int32)

第二個WriteLine方法前,只需要一個ldloc.1(load local 1)讀取區域性變數1指令即可獲取值給WriteLine,而第一個WriteLine前需要兩條指令完成這個任務,就是上面說的分兩步。

當然,大家都知道對我們來說,這是透明的,所以很多人喜歡畫這樣的圖去幫助理解,畢竟,我們是感覺不到那個0x0612ecb4地址存在的。

image

也有一種說法就是,引用型別分兩段儲存,一是在託管堆裡的值(例項物件),二是持有它的引用的變數。對於區域性變數(引數)來說,這個引用就在棧裡,而作為型別的欄位變數的話,引用會跟隨這個物件。

欄位和區域性變數(引數)

上面圖的託管堆,大家應該看到,作為值型別的Age的值是儲存在託管堆裡的,並不是儲存在棧裡,這也是很多C#新手所犯的錯誤:值型別的值都是儲存在棧裡。

很明顯他們不知道這個結論是在我們上面討論程式執行原理時,區域性變數(引數)壓棧和出棧時這個特定的場景下的結論。我們要搞清楚,就像上面程式碼一樣,除了可以定義int型別的num這個區域性變數儲存23這個值外,我們還可以在一個型別裡定義一個int型別Age欄位成員來儲存一個整形數字,這時這個Age很明顯不是儲存在棧,所以結論應該是:值型別的值是在它宣告的位置儲存的。即區域性變數(引數)的值會在棧裡,作為型別成員的話,會跟隨物件。

當然,引用型別的值(例項物件)總是在託管堆裡,這個結論是正確的。

ref和out

C#有值型別和引用型別的區別,再有傳參時有ref和out這兩個關鍵字使得人們對相關概念的理解更加模糊。要理解這個問題,還是要從棧的角度去理解。我們分四種情況討論:正常傳遞值型別,正常傳遞引用型別,ref(out)傳遞值型別,ref(out)傳遞引用型別。

注意,對於執行時來說,ref和out是一樣,它們的區別是C#編譯器對它們的區別,ref要求初始化好,out沒有要求。因為out沒有要求初始化,所以被呼叫的方法不能讀取out引數,且方法返回前必須賦值。

正常傳遞值型別

static void Main(string[] args)
{
    var num = 120;
    Method1(num);
    Console.WriteLine(num);//輸出=>120
}

static void Method1(int num)
{
    Console.WriteLine(num);
    num = 180;
}

這種場景大家都熟悉,Method1的那句賦值是不起作用的,如果要畫圖的話,也跟上面第二幅圖類似:

image

也就是說傳參是把棧裡的值複製到Method1的num引數,Method1操作的是自己的引數,對Main的區域性變數完全沒有影響,即影響不到屬於Main方法的棧裡的資料。

正常傳遞引用型別

static void Main(string[] args)
{
    var user = new User();
    user.Age = 15;
    Method2(user);
    Debug.Assert(user != null);
    Console.WriteLine(user.Age);//輸出=> 18
}

static void Method2(User user)
{
    user.Age = 18;
    user = null;
}

留意這裡的Method2的程式碼,把Age設為18,影響到了Main方法的user,而把user設為null卻沒有影響。要分析這個問題,還是要先從棧的角度去看,棧圖如下(地址亂寫):

image

看到第二幅圖,大家應該大概明白了這個事實:無論值型別也好,引用型別也好,正常傳參都是把棧裡的值複製給引數,從棧的角度看的話,C#預設是按值傳參的。

既然都是“按值傳參”,那麼引用型別為什麼表現出可以影響到呼叫方法的區域性變數這個跟值型別不同的表現呢?仔細想想也不難發現,這個不同的表現不是由傳參方式不同引起的,而是值型別和引用型別的區域性變數(引數)在記憶體的儲存不同引起的。對於Main方法的區域性變數user和Method2的引數user在棧裡是各自儲存的,棧裡的資料(地址,指標,引用)互不影響,但它們都指向同一個在託管堆裡的例項物件,而user.Age = 18這一句操作的正是對託管堆裡的例項物件的操作,而不是棧裡的資料(地址,指標,引用)。num = 180操作的是棧裡的資料,而user.Age = 18卻是託管堆,就是這樣造成了不同的表現。

對於user = null這一句不會響應Main的區域性變數,看了第三幅圖應該也很容易明白,user = null跟user.Age = 18不一樣,user = null是把棧裡的資料(地址,指標,引用)設空,所以並不會影響Main的user。

這裡再補充一下,對引用型別來說,var user = null,var user = new User(),user1 = user2都會影響棧裡的資料(地址,指標,引用),第一個會設null,第二個會得到一個新的資料(地址,指標,引用),第三個跟上面傳參一樣,都是棧資料複製。

ref(out)傳遞值型別

static void Main(string[] args)
{
    var num = 10;
    Method1(num);
    Console.WriteLine(num);//輸出=> 10
    Method3(ref num);
    Console.WriteLine(num);//輸出=> 28
}

static void Method1(int num)
{
    Console.WriteLine(num);
    num = 18;
}

static void Method3(ref int num)
{
    Console.WriteLine(num);
    num = 28;
}

程式碼很簡單,而且輸出應該都很清楚,沒有難度。ref的使用看似簡單平常,背後其實是C#為我們做了大部分工作。畫圖的話,“棧圖”如下(地址亂寫):

image

看到這圖,不少人應該迷惑了,Method3的引數明明寫的是int型別的num,怎麼在棧裡卻是一個指標(地址,引用)呢?這其實C#“欺騙”了我們,IL反編譯看看:

image

可以看到,加了ref(out)的Method3編譯出來的方法引數是不一樣,再來看看方法裡對引數取值的IL程式碼:

//這是Method1的程式碼
//load arg 0=>讀取索引0的引數,直接就是一個值
IL_0001:  ldarg.0

//這是Method3的程式碼
//load arg 0=>讀取索引0的引數,這是一個地址
IL_0001:  ldarg.0
//將位於上面地址處的 int32 值作為 int32 載入到堆疊上。
IL_0002:  ldind.i4

可以看到,同樣是獲取引數值給WriteLine,Method1只需一個指令,而Method3則需要2個,即多了一個根據地址去尋值的步驟。不難想到,賦值也有同樣的區別:

//Method1
//把18放入棧中
IL_0008:  ldc.i4.s   18
//store arg=> 把值賦給引數變數num
IL_000a:  starg.s    num

//Method3
//load arg 0=>讀取索引0的引數,這是一個地址
IL_0009:  ldarg.0
//把28放入棧中
IL_000a:  ldc.i4.s   28
//在給定的地址儲存 int32 值。
IL_000c:  stind.i4

沒錯,雖然同樣是num = 5這樣一個對引數的賦值語句,有沒有ref(out)關鍵字,實際上執行時發生的事情是不一樣的。有ref(out)的方法跟上面取值一樣有給定地址然後去操作(這裡是賦值)的指令。

看到這裡大家應該明白,給引數加了ref(out)後,引數才是引用傳遞,這時傳遞的是棧地址(指標,引用),否則就是正常的值傳遞--棧資料複製。

ref(out)傳遞引用型別

加了ref(out)的引用型別的引數有什麼奧祕,這個留給大家去思考。可以肯定的是,還是從棧的角度去考慮的話,跟值型別是沒有區別的,都是傳遞棧地址。

我個人認為,貌似給引用型別加ref(out)沒什麼用處。惡魔

總結

在考慮這一大堆概念問題時,我們首先要搞明白程式執行的基本原理,只不過是棧的生長和消亡的過程。明白這個過程後,要學會“從棧的角度”去思考問題,那麼很多事情將會迎刃而解。為什麼叫“值”型別和“引用”型別呢?其實這個“值”和“引用”是從棧的角度去考慮的,在棧裡,值型別的資料就是值,引用型別在棧裡只是一個地址(指標,引用)。還要注意到,變數除了可以是一個區域性變數(引數)外,還可以作為一個型別的欄位成員存在。知道這些後,“值型別的物件是儲存在那裡?”這些問題應該就一清二楚了。最後就是明白C#預設是按值傳參的,也就是把棧裡的資料賦值給引數,這跟在同一個方法內把一個變數賦值給同一型別的另一個變數是一樣的,而加了ref(out)為什麼這個神奇,其實是C#背後做了更多的事情,編譯成不同的IL程式碼了。

參考:《CLR via C#》

相關文章