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

C# 的類型系統可分爲兩種類型,一是值類型,一是引用類型,這個每一個C#程序員都瞭解。還有託管堆,棧,ref,out等等概念也是每一個C#程序員都會接觸到的概念,也是C#程序員面試常常考到的知識,隨便搜搜也有無數的文章講解相關的概念,貌似沒寫一篇值類型,引用類型相關博客的不是好的C#程序員。我也湊個熱鬧,試圖完全講明白相關的概念。html

程序執行的原理

要完全搞明白那一堆概念及其它們之間的關係彷佛並非一件容易的事,這是由於大部分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,多線程等等概念,只保留對象內存分配相關概念的話,程序的執行能夠簡單總結爲以下:3d

程序由Main方法進入執行,並不斷重複着「定義局部變量,調用方法(可能會傳參),從方法返回」,最後從Main方法退出。在程序執行過程當中,不斷壓入參數和局部變量到線程棧裏,也不斷的出棧。指針

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

引用類型和堆

上面的例子我只用了一種簡單的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前,「內存圖」應該是這樣的(地址是亂寫的):blog

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#》

相關文章
相關標籤/搜索