C#|.net core 基礎 - 值傳遞 vs 引用傳遞

IT规划师發表於2024-09-19

不知道你在開發過程中有沒有遇到過這樣的困惑:這個變數怎麼值被改?這個值怎麼沒變?

今天就來和大家分享可能導致這個問題的根本原因值傳遞 vs 引用傳遞。

在此之前我們先回顧兩組基本概念:

值型別 vs 引用型別

值型別: 直接儲存資料,資料儲存在棧上;

引用型別: 儲存資料物件的引用,資料實際儲存在堆上。

形參 vs 實參

形參: 即形式引數,表示呼叫方法時,方法需要你傳遞的值。方法宣告定義了其形參。也就是說在定義方法時,緊跟在方法名後面括號中的引數列表就是形參。

實參: 即實際引數,表示呼叫方法時,你傳遞給方法形參的值。呼叫程式碼在呼叫過程時提供實參。也就是說在呼叫方法時,緊跟在方法名後面括號中的引數列表就是實參。

再來回顧一下值型別和引用型別在記憶體中是怎麼儲存的呢?

對於值型別變數的值直接儲存在棧中,如下圖的int a=10,10就直接存在棧空間中,而其棧空間對應的記憶體地址為0x66666668;對於引用型別變數本身儲存的是例項物件的引用,即例項物件在堆中的實際記憶體地址,因此引用型別變數是儲存其例項物件的引用於棧上,如下圖中變數Test a在棧中實際儲存的是例項物件Test a在堆中的記憶體地址0x88888880,而棧空間對應的記憶體地址為0x66666668。

棧也是有記憶體地址的,這一點很重要,無論棧空間上儲存的是值還是引用地址,這個棧空間本身也有自己對應的記憶體地址。

什麼是值傳遞?什麼是引用傳遞?

值傳遞:如果變數按值傳遞給方法,則會把變數的副本傳遞給方法。對於值型別則把變數的副本傳遞給方法,對於引用型別則把變數的引用的副本傳遞給方法。因此被呼叫方法引數會建立一個新的記憶體地址用於接收儲存變數,因此在方法內部對變數修改並不會影響原來的值。

引用傳遞:如果變數按引用傳遞給方法,則會把變數的引用傳遞給方法,對於值型別則把變數的棧空間地址傳遞給方法,對於引用型別則把變數的引用的棧空間地址傳遞給方法。因此被呼叫方法引數不會建立一個新的記憶體地址用於接收儲存變數,意味著形參與實參共同指向相同的記憶體地址,因此在方法內部修對變數修改會影響原來的值。

上面的描述可能有點拗口,下面我們在基於值型別、引用型別、值傳遞、引用傳遞各種組合進行一個詳細說明。

01、值型別按值傳遞

當值型別按值傳遞時,呼叫者會把值型別變數的副本傳遞給方法,因此被呼叫方法引數會建立一個新的記憶體地址用於接收儲存變數,因此當在方法內部對引數進行修改時並不會影響呼叫者呼叫處的值型別變數。

傳遞值型別變數的副本就是相當於在棧上,又複製了一個同樣的值,而且記憶體地址還不一樣,所以互不影響。如下圖把a賦值給b,則b直接新開闢了一個棧空間,雖然a和b都是10,但是它們在不同的地址空間中,因此如果他們各自被修改了,也互不影響。

下面我們寫個例子演示一下,這個例子就是定義個變數a並賦值,然後呼叫一個方法此方法內對傳進來的引數a進行加1,具體程式碼如下:

public static void ValueByValueRun()
{
    var a = 10;
    Console.WriteLine($"呼叫者-呼叫方法前 a 值:{a}");
    ChangeValueByValue(a);
    Console.WriteLine($"呼叫者-呼叫方法後 a 值:{a}");
}
public static void ChangeValueByValue(int a)
{
    Console.WriteLine($"    被呼叫方法-接收到 a 值:{a}");
    a = a + 1;
    Console.WriteLine($"    被呼叫方法-修改後 a 值:{a}");
}

執行結果如下:

透過程式碼執行結果可以發現,方法內對變數的修改已經生效,但是不沒有影響到呼叫者呼叫處的變數值。

02、引用型別按值傳遞

當引用型別按值傳遞時,呼叫者會把引用型別變數的引用副本傳遞給方法,因此被呼叫方法引數會建立一個新的記憶體地址用於接收儲存變數,而對於一個引用型別變數來說其本身儲存的就是引用型別例項物件的引用副本,而方法接收到的也是此變數引用的副本,所以呼叫者引數和被呼叫方法引數是引用了同一個例項物件的兩個引用副本。如下圖Test a可以理解為呼叫者傳的實參,Test b可以理解為被呼叫方法定義的形參,這兩個引數都只是指向堆中Test a的引用副本。

因此可以得出兩個結論:

1、變數a和b都是指向例項物件Test a的引用,所以無論變數a或b,只要有一個更新了例項成員則另一個變數也會同步發生變化。

2、雖然變數a和b都是指向例項物件Test a的引用,但是他們儲存在棧上的記憶體地址卻不同,因此如果他們各種重新分配例項也就是new一個新物件,則另一個變數無法感知到還是保持原因狀態不變。

我們先用程式碼說明第一個結論:

public static void ChangeReferenceByValueRun()
{
    var a = new Test
    {
        Age = 10
    };
    Console.WriteLine($"呼叫者-呼叫方法前 a.Age 值:{a.Age}");
    ChangeReferenceByValue(a);
    Console.WriteLine($"呼叫者-呼叫方法後 a.Age 值:{a.Age}");
}
public static void ChangeReferenceByValue(Test a)
{
    Console.WriteLine($"    被呼叫方法-接收到 a.Age 值:{a.Age}");
    a.Age = a.Age + 1;
    Console.WriteLine($"    被呼叫方法-修改後 a.Age 值:{a.Age}");
}

執行結果如下:

可以看到被呼叫方法中a例項物件的Age屬性發生變化後,呼叫者中變數也同步發生了變化。

對於第二個結論我們這樣論證,在方法中直接對引數new一個新物件,看看原變數是否發生變化,程式碼如下:

public static void NewReferenceByValueRun()
{
    var a = new Test
    {
        Age = 10
    };
    Console.WriteLine($"呼叫者-呼叫方法前 a.Age 值:{a.Age}");
    NewReferenceByValue(a);
    Console.WriteLine($"呼叫者-呼叫方法後 a.Age 值:{a.Age}");
}
public static void NewReferenceByValue(Test a)
{
    Console.WriteLine($"    被呼叫方法-接收到 a.Age 值:{a.Age}");
    a = new Test
    {
        Age = 100
    };
    Console.WriteLine($"    被呼叫方法-new後 a.Age 值:{a.Age}");
}

執行結果如下:

可以發現當在方法中對變數執行new操作後,呼叫者處的變數並沒有發生變化。

為什麼會這樣呢?因為對於引用型別來說,形參和實參是對引用型別的例項物件引用的兩個副本,而這兩個副本儲存在棧上又分別在不同的記憶體地址空間上,而new主要就是重新分配記憶體,這就導致形參變數a=new後,棧上形參變數a指向了Test新的例項物件的引用,而實參變數a還是保持原有例項物件引用不變。

如下圖所示。

03、值型別按引用傳遞

當值型別按引用傳遞時,呼叫者會把值型別變數對應的棧空間地址傳遞給方法,因此被呼叫方法引數不會建立一個新的記憶體地址用於接收儲存變數,因此當在方法內部對引數進行修改時並同樣會影響呼叫者呼叫處的值型別變數。

傳遞值型別變數對應的棧空間地址就意味著形參與實參共同指向相同的記憶體地址,所以才導致對形參修改時,實參也會同步發生變化。

我們用一個小例子演示一下:

public static void ValueByReferenceRun()
{
    Console.WriteLine($"值型別按引用傳遞");
    var a = 10;
    Console.WriteLine($"呼叫者-呼叫方法前 a 值:{a}");
    ChangeValueByReference(ref a);
    Console.WriteLine($"呼叫者-呼叫方法後 a 值:{a}");
}
public static void ChangeValueByReference(ref int a)
{
    Console.WriteLine($"    被呼叫方法-接收到 a 值:{a}");
    a = a + 1;
    Console.WriteLine($"    被呼叫方法-修改後 a 值:{a}");
}

執行結果如下:

可以發現呼叫者處的值型別變數已經發生改變。

04、引用型別按引用傳遞

當引用型別按引用傳遞時,呼叫者會把引用型別變數對應的棧空間地址傳遞給方法,因此被呼叫方法引數不會建立一個新的記憶體地址用於接收儲存變數,因此當在方法內部對引數進行修改時並同樣會影響呼叫者呼叫處的引用型別變數。

傳遞引用型別變數對應的棧空間地址就意味著形參與實參共同指向相同的記憶體地址,因此對形參修改時,實參也會同步發生變化,而且這個裡的修改不單單指修改例項成員,還包括new一個新例項物件。

下面我們看一個修改例項成員的例子:

public static void ChangeReferenceByReferenceRun()
{
    Console.WriteLine($"引用型別按引用傳遞 - 修改例項成員");
    var a = new Test
    {
        Age = 10
    };
    Console.WriteLine($"呼叫者-呼叫方法前 a.Age 值:{a.Age}");
    ChangeReferenceByReference(ref a);
    Console.WriteLine($"呼叫者-呼叫方法後 a.Age 值:{a.Age}");
}
public static void ChangeReferenceByReference(ref Test a)
{
    Console.WriteLine($"    被呼叫方法-接收到 a.Age 值:{a.Age}");
    a.Age = a.Age + 1;
    Console.WriteLine($"    被呼叫方法-修改後 a.Age 值:{a.Age}");
}

執行結果如下:

再看看new一個新物件的例子:

public static void NewReferenceByReferenceRun()
{
    Console.WriteLine($"引用型別按引用傳遞 - new 新例項");
    var a = new Test
    {
        Age = 10
    };
    Console.WriteLine($"呼叫者-呼叫方法前 a.Age 值:{a.Age}");
    NewReferenceByReference(ref a);
    Console.WriteLine($"呼叫者-呼叫方法後 a.Age 值:{a.Age}");
}
public static void NewReferenceByReference(ref Test a)
{
    Console.WriteLine($"    被呼叫方法-接收到 a.Age 值:{a.Age}");
    a = new Test
    {
        Age = 100
    };
    Console.WriteLine($"    被呼叫方法-new後 a.Age 值:{a.Age}");
}

執行結果如下:

另外string是一個特殊的引用型別,string型別變數的按值傳遞和按引用傳遞和值型別是一致的,也就是要把string型別當值型別一樣看待就行。string型別的特殊性我們後面會單獨具體介紹。

在C#中以下修飾符可應用與引數宣告,並且會使得引數按引用傳遞:ref、out、readonly ref、in。對於每個修飾符具體怎麼使用就不再這裡細說了。

相信到這裡你應該就可以回答我之前在《LeetCode題集-2 - 兩數相加》最後提的問題了。

:測試方法程式碼以及示例原始碼都已經上傳至程式碼庫,有興趣的可以看看。https://gitee.com/hugogoos/Planner

相關文章