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是无关的。因此这样也会产生类型安全的问题。
所以为了解决这种类型安全问题所导致的隐患,在编译时就会报出类似下面这样的异常。
一切都是为了类型安全啊。