按引用傳遞的參數算是C#與很多其他語言相比的一大特色,想要深入理解這一概念應該說不是一件容易的事,再把值類型和引用類型給參雜進來的話就變得更加讓人頭暈了。
經常看到有人把按引用傳遞和引用類型混為一談,讓我有點不吐不快。再加上前兩天碰到的一個有意思的問題,讓我更加覺得應該整理整理關於ref和out的內容了。
一、什麼是按引用傳遞
ref和out用起來還是非常簡單的,就是在普通的按值傳遞的參數前加個ref或者out就行,方法定義和調用的時候都得加。
ref和out都是表示按引用傳遞,CLR也完全不區分ref還是out,所以下文就直接以ref為例來進行說明。
大家都知道,按值傳遞的參數在方法內部不管怎麼改變,方法外的變量都不會受到影響,這從學C語言時候就聽老師說過的了。
在C語言裡想要寫一個Swap方法該怎麼做?用指針咯。
那麼在C#裡該怎麼做?雖然也可以用指針,但是更通常也更安全的做法就是用ref咯。
說到這裡,有一點需要明確,按值傳遞的參數到底會不會被改變。
如果傳的是int參數,方法外的變量肯定是完完全全不變的咯,可是如果傳的是個List呢?方法內部對這個List的所有增刪改都會反映到方法外頭,方法外查一下Count就能看出來了是吧。
那麼傳List的這個情況,也代表了所有引用類型參數的情況,方法外的變量到底變沒變?
不要聽信某些論調說什麼“引用類型就是傳引用”,不用ref的情況下引用類型參數仍然傳的是“值”,所以方法外的變量仍然是不變的。
以上總結起來就是一句話:
按值傳遞參數的方法永遠不可能改變方法外的變量,需要改變方法外的變量就必須按引用傳遞參數。
PS:不是通過傳參的方式傳入的變量當然是可以被改變的,本文不對這種情況做討論。
二、參數傳遞的是什麼
按值傳參傳的就是值咯,按引用傳參傳的就是引用咯,這麼簡單的問題還有啥可討論的呢。
可是想一想,值類型變量和引用類型變量組合上按值傳參和按引用傳參,一共四種情況,某些情況下“值”和“引用”可能指的是同一個東西。
先簡單地從變量說起吧,一個變量總是和內存中的一個對象相關聯。
對於值類型的變量,可以認為它總是包含兩個信息,一是引用,二是對象的值。前者即是指向後者的引用。
對於引用類型的變量,可以認為它也包含兩個信息,一是引用,二是另一個引用。前者仍然是指向後者的引用,而後者則指向堆中的對象。
所謂的按值傳遞,就是傳遞的“二”;按引用傳遞,就是傳遞的“一”。
也就是說,在按值傳遞一個引用類型的時候,傳遞的值的內容是一個引用。
大概情況類似於這樣:
按值傳遞時就像是這樣:
可以看到,不管方法內部對“值”和“B引用”作什麼修改,兩個變量包含的信息是不會有任何變化的。
但是也可以看到,方法內部是可以通過“B引用”對“引用類型對象”進行修改的,這就出現了前文所說的發生在List上的現象。
而按引用傳遞時就像是這樣:
可以看到,這個時候方法內部是可以通過“引用”和“A引用”直接修改變量的信息的,甚至可能發生這樣的情況:
這個時候的方法實現可能是這樣的:
void SampleMethod(ref object obj) { //..... obj = new object(); //..... }
三、從IL來看差異
接下來看一看IL是怎麼對待按值或者按引用傳遞的參數。比如這一段C#代碼:
class Class { void Method(Class @class) { } void Method(ref Class @class) { } // void Method(out Class @class) { } }
這一段代碼是可以正常通過編譯的,但是取消注釋就不行了,原因前面也提到了,IL是不區分ref和out的。
也正是因為這一種重載的可能性,所以在調用方也必須寫明ref或out,不然編譯器沒法區分調用的是哪一個重載版本。
Class類的IL是這樣的:
.class private auto ansi beforefieldinit CsConsole.Class extends [mscorlib]System.Object { // Methods .method private hidebysig static void Method ( class CsConsole.Class 'class' ) cil managed { // Method begins at RVA 0x20b4 // Code size 1 (0x1) .maxstack 8 IL_0000: ret } // end of method Class::Method .method private hidebysig static void Method ( class CsConsole.Class& 'class' ) cil managed { // Method begins at RVA 0x20b6 // Code size 1 (0x1) .maxstack 8 IL_0000: ret } // end of method Class::Method } // end of class CsConsole.Class
為了閱讀方便,我把原有的默認無參構造函數去掉了。
可以看到兩個方法的IL僅僅只有一個&符號的差別,這一個符號的差別也是兩個方法可以同名的原因,因為它們的參數類型是不一樣的。out和ref參數的類型則是一樣的。
現在給代碼裡加一點內容,讓差別變得更明顯一些:
class Class { int i; void Method(Class @class) { @class.i = 1; } void Method(ref Class @class) { @class.i = 1; } }
現在的IL是這樣的:
.class private auto ansi beforefieldinit CsConsole.Class extends [mscorlib]System.Object { // Fields .field private int32 i // Methods .method private hidebysig instance void Method ( class CsConsole.Class 'class' ) cil managed { // Method begins at RVA 0x20b4 // Code size 8 (0x8) .maxstack 8 IL_0000: ldarg.1 IL_0001: ldc.i4.1 IL_0002: stfld int32 CsConsole.Class::i IL_0007: ret } // end of method Class::Method .method private hidebysig instance void Method ( class CsConsole.Class& 'class' ) cil managed { // Method begins at RVA 0x20bd // Code size 9 (0x9) .maxstack 8 IL_0000: ldarg.1 IL_0001: ldind.ref IL_0002: ldc.i4.1 IL_0003: stfld int32 CsConsole.Class::i IL_0008: ret } // end of method Class::Method } // end of class CsConsole.Class
帶ref的方法裡多了一條指令“ldind.ref”,關於這條指令MSDN的解釋是這樣的:
將對象引用作為 O(對象引用)類型間接加載到計算堆棧上。
簡單來說就是從一個地址取了一個對象引用,這個對象引用與無ref版本的“arg.1”相同的,即按值傳入的@class。
再來換一個角度看看,把代碼改成這樣:
class Class { void Method(Class @class) { @class = new Class(); } void Method(ref Class @class) { @class = new Class(); } }
IL是這樣的:
.class private auto ansi beforefieldinit CsConsole.Class extends [mscorlib]System.Object { // Methods .method private hidebysig instance void Method ( class CsConsole.Class 'class' ) cil managed { // Method begins at RVA 0x20b4 // Code size 8 (0x8) .maxstack 8 IL_0000: newobj instance void CsConsole.Class::.ctor() IL_0005: starg.s 'class' IL_0007: ret } // end of method Class::Method .method private hidebysig instance void Method ( class CsConsole.Class& 'class' ) cil managed { // Method begins at RVA 0x20bd // Code size 8 (0x8) .maxstack 8 IL_0000: ldarg.1 IL_0001: newobj instance void CsConsole.Class::.ctor() IL_0006: stind.ref IL_0007: ret } // end of method Class::Method } // end of class CsConsole.Class
這一次兩方的差別就更大了。
無ref版本做的事很簡單,new了一個Class對象然後直接賦給了@class。
但是有ref版本則是先取了ref引用留著待會用,再new了Class,然後才把這個Class對象賦給ref引用指向的地方。
在來看看調用方會有什麼差異:
class Class { void Method(Class @class) { } void Method(ref Class @class) { } void Caller() { Class @class = new Class(); Method(@class); Method(ref @class); } }
.method private hidebysig instance void Caller () cil managed { // Method begins at RVA 0x20b8 // Code size 22 (0x16) .maxstack 2 .locals init ( [0] class CsConsole.Class 'class' ) IL_0000: newobj instance void CsConsole.Class::.ctor() IL_0005: stloc.0 IL_0006: ldarg.0 IL_0007: ldloc.0 IL_0008: call instance void CsConsole.Class::Method(class CsConsole.Class) IL_000d: ldarg.0 IL_000e: ldloca.s 'class' IL_0010: call instance void CsConsole.Class::Method(class CsConsole.Class&) IL_0015: ret } // end of method Class::Caller
差別很清晰,前者從局部變量表取“值”,後者從局部變量表取“引用”。
四、引用與指針
說了這麼久引用,再來看一看同樣可以用來寫Swap的指針。
很顯然,ref參數和指針參數的類型是不一樣的,所以這麼寫是可以通過編譯的:
unsafe struct Struct { void Method(ref Struct @struct) { } void Method(Struct* @struct) { } }
這兩個方法的IL非常有意思:
.class private sequential ansi sealed beforefieldinit CsConsole.Struct extends [mscorlib]System.ValueType { .pack 0 .size 1 // Methods .method private hidebysig instance void Method ( valuetype CsConsole.Struct& 'struct' ) cil managed { // Method begins at RVA 0x2050 // Code size 1 (0x1) .maxstack 8 IL_0000: ret } // end of method Struct::Method .method private hidebysig instance void Method ( valuetype CsConsole.Struct* 'struct' ) cil managed { // Method begins at RVA 0x2052 // Code size 1 (0x1) .maxstack 8 IL_0000: ret } // end of method Struct::Method } // end of class CsConsole.Struct
ref版本是用了取地址運算符(&)來標記,而指針版本用的是間接尋址運算符(*),含義也都很明顯,前者傳入的是一個變量的地址(即引用),後者傳入的是一個指針類型。
更有意思的事情是這樣的:
unsafe struct Struct { void Method(ref Struct @struct) { @struct = default(Struct); } void Method(Struct* @struct) { *@struct = default(Struct); } }
.class private sequential ansi sealed beforefieldinit CsConsole.Struct extends [mscorlib]System.ValueType { .pack 0 .size 1 // Methods .method private hidebysig instance void Method ( valuetype CsConsole.Struct& 'struct' ) cil managed { // Method begins at RVA 0x2050 // Code size 8 (0x8) .maxstack 8 IL_0000: ldarg.1 IL_0001: initobj CsConsole.Struct IL_0007: ret } // end of method Struct::Method .method private hidebysig instance void Method ( valuetype CsConsole.Struct* 'struct' ) cil managed { // Method begins at RVA 0x2059 // Code size 8 (0x8) .maxstack 8 IL_0000: ldarg.1 IL_0001: initobj CsConsole.Struct IL_0007: ret } // end of method Struct::Method } // end of class CsConsole.Struct
兩個方法體的IL是一模一樣的!可以想見引用的本質到底是什麼了吧~?
五、this和引用
這個有趣的問題是前兩天才意識到的,以前從來沒有寫過類似這樣的代碼:
struct Struct { void Method(ref Struct @struct) { } public void Test() { Method(ref this); } }
上面這段代碼是可以通過編譯的,但是如果像下面這樣寫就不行了:
class Class { void Method(ref Class @class) { } void Test() { // 無法將“<this>”作為 ref 或 out 參數傳遞,因為它是只讀的 Method(ref this); } }
紅字部分代碼會報出如注釋所述的錯誤。兩段代碼唯一的差別在於前者是struct(值類型)而後者是class(引用類型)。
前面已經說過,ref標記的參數在方法內部的修改會影響到方法外的變量值,所以用ref標記this傳入方法可能導致this的值被改變。
有意思的是,為什麼struct裡的this允許被改變,而class裡的this不允許被改變呢?
往下的內容和ref其實沒啥太大關系了,但是涉及到值和引用,所以還是繼續寫吧:D
MSDN對“this”關鍵字的解釋是這樣的:
this 關鍵字引用類的當前實例
這裡的“當前實例”指的是內存中的對象,也就是下圖中的“值”或“引用類型對象”:
如果對值類型的this進行賦值,那麼“值”被修改,“當前實例”仍然是原來實例對象,只是內容變了。
而如果對引用類型的this進行復制,那麼“B引用”被修改,出現了類似於這個圖的情況,現在的“當前實例”已經不是原來的實例對象了,this關鍵字的含義就不再明確。所以引用類型中的this應該是只讀的,確保“this”就是指向的“這個”對象。
最後也沒想到有啥可多說的,那就到此為止吧~