0x00 前言
我在之前的游戲公司工作的時候,常常是作為一只埋頭實現業務邏輯的碼農。在工作之中不常有同事會對關於編程的話題進行交流,而工作之余也沒有專門的時間進行技術分享。所以對我而言上家雖然是一家游戲公司,但是工作卻鮮有樂趣可言。不過還好,現在來到了一家同樣做游戲的公司,但是有技術交流也有技術分享,雖然還不是那么成熟,但卻能夠讓人感到工作的樂趣。這不,上周和同事聊到了C#語言的ref/out關鍵字在處理多態時的問題,仔細想想這個話題,又能引申到另一個更好玩的題目,C#語言的方法參數的傳遞機制。那么,本文就來聊聊這個事情吧。
0x01 復習
在開始正式的話題之前,我們先來復習一下C#的類型基礎吧。C#語言的類型大體上可以分為兩種,其一是值類型,另一種是引用類型。很多人都知道,值類型的值是它本身,而引用類型的值是一個引用,但是很多朋友對這句話又不完全的理解。下面,我們就通過幾個小例子來明確一下相關的概念吧。
值類型(value type)
常見的值類型包括一些簡單的類型例如int,float,long以及枚舉類型和使用struct聲明的類型。而很多接觸C#語言不久的人經常會誤認為string類型也是值類型,事實上string是一種引用類型。只不過它的實例一旦創建,就無法再次更改了。因此它也被稱為不可變類型。也正是因為這一點,很多人常常會把它的行為和值類型的行為混淆。
值類型變量的值就是其本身,值類型變量的賦值事實上是一次值的復制過程。我們可以通過一個小例子來看一下這個過程。
public struct ValueTypeTest
{
public int intValue;
}
static void Main(string[] args)
{
ValueTypeTest valueOne = new ValueTypeTest();
valueOne.intValue = 1;
ValueTypeTest valueTwo = valueOne;
valueOne.intValue = 2;
Console.WriteLine(valueTwo.intValue);
}
輸出結果我想大家一定都清楚,結果是1。
這是因為valueOne 在向valueTwo賦值時valueOne的值是1,valueOne將1復制給valueTwo之后兩者便再無聯系了。
引用類型(reference type)
諸如類(class)、委托(delegate)、接口(interface)、數組等等是一些常見的引用類型。引用類型的值是對某個對象的引用,而非對象本身。這一點,我想很多人都了解。
例如下面這句代碼:
Object obj = new Object();
“Object obj”將作為一個引用類型變量出現,而“new Object”則會在堆上創建一個Object對象,obj的值是對Object對象的一個引用,而非Object對象本身。
我們還通過上文中的小例子來看看這個過程,唯一的不同是這次我們使用class取代struct來定義我們的類型。
public class RefTypeTest
{
public int intValue;
}
static void Main(string[] args)
{
RefTypeTest refOne = new RefTypeTest();
refOne.intValue = 1;
RefTypeTest refTwo = refOne;
refOne.intValue = 2;
Console.WriteLine(refTwo.intValue);
Console.ReadLine();
}
在這里我們聲明了一個名為refOne的變量,並創建了一個RefTypeTest類的對象,之后將這個RefTypeTest類的對象的引用賦值給變量refOne。接着,我們將refOne的值賦值給新聲明的refTwo變量。這樣,refOne和refTwo都指向了同一個RefTypeTest類的對象。如果我們通過refOne修改了被引用的對象的內容,則通過refTwo再次去訪問同一個對象,那么看到的自然也是修改后的內容了。所以此次的輸出是2。
但是,我們要注意的(也是很多人忽略的)是,refOne和refTwo雖然引用了同一個對象,但是它們自身是獨立的、無關的。
修改refOne的值,不會影響refTwo。例如,我們可以將refOne的值變成對一個null對象的引用,但是refTwo不會因此也指向null對象。
RefTypeTest refOne = new RefTypeTest();
refOne.intValue = 1;
RefTypeTest refTwo = refOne;
refOne.intValue = 2;
refOne = new RefTypeTest();
refOne.intValue = 3;
Console.WriteLine(refTwo.intValue);
Console.ReadLine();
0x02 方法參數
在C#語言中,方法的參數傳遞默認是按值傳遞的。當然,使用一些關鍵字可以改變這種參數傳遞行為,例如使用ref/out關鍵字可以使方法參數按引用傳遞。
但是一提到值、引用這樣的字眼,很多人都會立馬想到值類型和引用類型。而這也是一個常見的誤區:把方法參數傳遞的概念和類型的概念搞混。
這兩種概念是不同的,默認情況下無論是值類型還是引用類型參數都是按照值來傳遞的,而使用了ref/out參數時,值類型也可以按照引用來傳遞。所以值類型既可以按值傳遞,也可以按引用傳遞(而且不存在裝箱的問題);引用類型既可以按值傳遞,也可以按引用傳遞。
這一部分就來聊聊方法參數的傳遞機制吧。
值類型按值傳遞
方法的參數傳遞默認是按值傳遞的,值類型變量按值傳遞給方法簡單的說就是傳遞一份值類型變量的拷貝給方法。在聲明方法時,會默認為參數分配一塊新的內存空間用來保存參數的拷貝。
因此,方法內對參數的修改不會影響最初的值。
下面這個小例子可以演示這一點:
static void SquareIt(int x)
{
x *= x;
Console.WriteLine("inside: {0}", x);
}
static void Main()
{
int n = 5;
Console.WriteLine("before: {0}", n);
SquareIt(n);
Console.WriteLine("after: {0}", n);
}
輸出的值依次是:before: 5、inside: 25、after:5。
簡單來分析一下這段代碼,變量n是一個值為5的值類型變量。當調用SquareIt 方法時,n的值便會被拷貝給參數x。而在Main函數中,n的值在調用SquareIt 方法前后是不變的。而在SquareIt 方法中求平方的操作僅僅影響了x。
引用類型按值傳遞
同樣,默認情況下引用類型的參數也是按值傳遞的。所以,和值類型變量按值傳遞類似,引用類型變量按值傳遞同樣是將變量的值拷貝給方法。
回憶一下前文的內容,引用類型變量的值是什么呢?對,是對一個對象的引用。所以我們可以在方法內修改引用類型參數所引用的對象,但是在方法內對參數的修改同樣不會影響最初的值。即我們無法在方法內部改變原來的引用類型變量對對象的引用。
下面這個小例子可以演示這一點:
static void Change(int[] pArray)
{
pArray[0] = 888;
pArray = new int[5] { -3, -1, -2, -3, -4 };
Console.WriteLine("inside: {0}", pArray[0]);
}
static void Main()
{
int[] arr = { 1, 4, 5 };
Console.WriteLine("before: {0}", arr[0]);
Change(arr);
Console.WriteLine("after: {0}", arr[0]);
Console.ReadKey();
}
輸出的結果依次是:before:1、inside:-3、after:888。
簡單來分析一下這段代碼,arr是一個引用類型變量,它引用了一個Array的對象。在這里,arr的值被拷貝給參數pArray,因此pArray也引用了同一個Array對象。此時在方法內的修改都是對同一個Array對象的修改。但是之后,使用new關鍵字又創建了一個新的Array對象,同時pArray的值變成了對新對象的引用。因此,之后的操作就變成了對新對象的操作。方法外的arr變量的值(對老Array對象的引用)並不會被修改。
值類型按引用傳遞
按引用傳遞參數,簡單的說並非傳遞變量的值,而是傳遞對變量的引用,同時方法內操作的也不是變量的值,而是通過引用直接操作變量本身。因此方法內部不會再為參數分配一塊內存空間,相反,方法會直接操作變量所在的那塊內存空間。
我們還可以通過上面的那個例子來看看值類型變量按引用傳遞給方法。
static void SquareIt(ref int x)
{
x *= x;
Console.WriteLine("inside: {0}", x);
}
static void Main()
{
int n = 5;
Console.WriteLine("before: {0}", n);
SquareIt(ref n);
Console.WriteLine("after: {0}", n);
Console.ReadKey();
}
在這個例子中,n的值沒有被傳遞給方法。相反,這次傳遞是對變量n的引用。因此參數x並非是int,而是一個對int變量的引用,在這個例子中x是對變量n的引用。
於是這次在方法內對x求平方的結果就是對n求平方,輸出的結果也相應的變成了:before:5、inside:25、after:25。
引用類型按引用傳遞
最后,我們來看看引用類型變量按引用傳遞的例子。類似的,我們仍然使用“引用類型按值傳遞”部分用到的小例子,只不過這次我們使用ref關鍵字來改變參數的傳遞方式。
static void Change(ref int[] pArray)
{
pArray[0] = 888;
pArray = new int[5] { -3, -1, -2, -3, -4 };
Console.WriteLine("inside: {0}", pArray[0]);
}
static void Main()
{
int[] arr = { 1, 4, 5 };
Console.WriteLine("before: {0}", arr[0]);
Change(ref arr);
Console.WriteLine("after: {0}", arr[0]);
Console.ReadKey();
}
在這個例子中,arr的引用被傳遞給了方法。因此pArray是對arr的引用,修改pArray事實上就是對arr的修改。
因此,這次的輸出結果也變成了:before:1、inside:-3、after:-3。
0x03 ref/out不支持多態
好了,聊完了類型和方法參數傳遞的話題最后終於要來聊聊我們在工作討論的那個小問題了。使用了ref/out關鍵字的方法是否支持多態呢?如果不支持的話又是為什么呢?
對於ref/out是否支持多態的問題,答案很簡單。
static void Foo(ref A a)
{
}
public class A { };
public class B : A { };
static void Main()
{
B b = new B();
Foo(ref b);
}
ref/out不支持多態。
但是為什么呢?
假設我們有兩個類型T和U,則T和U的關系大概可以分為以下幾種:
- T比U的派生程度更小
- T比U的派生程度更大
- T和U相同
- T和U無關
具體來說,我們可以通過下圖來作為例子:
從圖中我們可以看到:B類的派生程度比D類更小,同時比A類更大,但是又和C類、F類無關。
我們都知道變量對應着一個存儲位置。而在C#語言中所有的存儲位置都有與它們相關聯的類型,在運行時我們只能將一個和該類型相同或比它的派生程度更大的實例分配在該存儲位置。也就是說B類型的存儲位置可以保存一個D類型的實例,但是C類型的實例不能保存在這個存儲位置。
好了,再讓我們把關注點轉移到ref/out和方法的參數傳遞上來。
通過上文我們已經知道了使用ref/out的方法參數是按照引用來傳遞的,方法的參數指向了方法外部的變量所在的內存位置。假設我們有一個方法“void Foo(ref B b)”,那么我們可以向該方法傳遞一個A類型的變量嗎?
答案是不能。因為A變量的存儲位置保存的可能是一個F類型的實例,但是F類型和B類型沒關系啊!這種情況就是所謂的類型不安全。
那么我們可以傳遞一個D類型的變量嗎?感覺應該是可以的吧,因為D比B的派生程度更大啊。但是答案同樣是不能!
的確,如果只看方法簽名的話類型D的派生程度的確要大於類型B。但是在Foo方法內,我們可以修改類型D的變量啊!如果Foo方法的功能是下面這樣呢?
static void Foo(ref B b)
{
b = new E();
}
要知道,類型D和類型E是無關的。因此這樣也會產生類型安全的問題。
所以為了解決這種類型安全問題所導致的隱患,在編譯時就會報出類似下面這樣的異常。
一切都是為了類型安全啊。