不可否认, C++ 在过去十年乃至现在一直都是 windows 平台上的主流开发语言,而来势凶猛的 .NET 势必开辟一个崭新的局面,从目前的种种迹象来看, .NET 是大势所趋,而 C# 作为 .NET 平台上的第一开发语言自然备受关注,于是有很多程序员纷纷转向 C# ,这其中当然不乏 C++ 程序员。情况往往是这样,从一种语言过渡到另一种语言,哪怕是比较相似的语言,程序员也经常无意识地陷入原开发语言的思维定势,这样的结果通常只有一个,那就是导致连程序员自己也始终想不通的错误。本文由某 C++ 程序员提出的“难道 C# 中没有拷贝构造函数?”这一问题引出 C++ 与 C# 某些语言特性的对比。
一.发生了什么?
如果你是正在转向 C# 的 C++ 程序员,你一定对 C# 中的类没有拷贝构造函数和很少发生赋值运算符的调用感到不可理解,而且你看到的很多语句并不是像你想象的那样执行,比如
// 假设 Student 是一个类 ( C# )
Student s1 = new Student() ;
Student s2 ;
s2 = s1 ; // 此语句将发生什么?
因为你是一个熟练的 C++ 程序员,所以你在潜意识中就已经断定语句 Student s2 ; 将会在栈中生成一个 Student 对象(即实例),而语句 s2 = s1 ; 将会调用赋值运算符。即上述语句执行完后,会产生如下内存布局
<shapetype id="_x0000_t75" stroked="f" filled="f" path="m@4@5l@4@11@9@11@9@5xe" o:preferrelative="t" o:spt="75" coordsize="21600,21600"></shapetype><stroke joinstyle="miter"></stroke><formulas></formulas><f eqn="if lineDrawn pixelLineWidth 0"></f><f eqn="sum @0 1 0"></f><f eqn="sum 0 0 @1"></f><f eqn="prod @2 1 2"></f><f eqn="prod @3 21600 pixelWidth"></f><f eqn="prod @3 21600 pixelHeight"></f><f eqn="sum @0 0 1"></f><f eqn="prod @6 1 2"></f><f eqn="prod @7 21600 pixelWidth"></f><f eqn="sum @8 21600 0"></f><f eqn="prod @7 21600 pixelHeight"></f><f eqn="sum @10 21600 0"></f><lock aspectratio="t" v:ext="edit"></lock><shape id="_x0000_i1025" style="WIDTH: 248.25pt; HEIGHT: 115.5pt" type="#_x0000_t75"></shape><imagedata o:title="stack&heap03" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image001.jpg"></imagedata>
错了,全错了!!!
在 C# 中却不是这样。我先解释上述语句在 C# 中是怎样执行的。
Student s1 = new Student() ;
上面的代码将会在堆中生成一个对象,并且让引用 s1 指向这个对象,而引用 s1 本身位于栈中,占用四个字节(在 32 位处理器上,即一个指针的长度)。
<shape id="_x0000_i1026" style="WIDTH: 220.5pt; HEIGHT: 102pt" type="#_x0000_t75" o:ole=""></shape><imagedata o:title="" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image003.wmz"></imagedata>
Student s2 ;
该声明将会在栈中生成一个长度位 4 字节的引用变量 s2 ,并且缺省为 null ,即该引用不指向任何实例。
s2 = s1 ;
该赋值语句并没有调用赋值运算符,而是仅仅使 s2 指向 s1 所指向的对象。所以上述语句执行完后,内存布局大致如下图
<shape id="_x0000_i1027" style="WIDTH: 239.25pt; HEIGHT: 111pt" type="#_x0000_t75"></shape><imagedata o:title="stack & heap 02" src="file:///C:/DOCUME~1/ADMINI~1/LOCALS~1/Temp/msohtml1/01/clip_image005.jpg"></imagedata>
想要明白为什么,先要知道 C# 与 C++ 引用的区别。
二. C# 与 C++ 的引用区别
由上述简单的例子就会看到“引用”在 C# 和 C++ 中的表现是多么的不一样,其主要区别可以表述为一句话:
C++ 中的引用是紧绑定的,而 C# 中的引用是松绑定的。
C++ 中的引用使用“ & ”符号声明,而且声明时必须被初始化为一个有效的对象,而且引用一经初始化后,就不能再次赋值(即不能再令其指向其它对象),因此在 C++ 中编译器认为所有的引用都是有效的,不必进行类型检查等,这是 C++ 中引用没有指针灵活却比指针高效的原因。可以这么说,在 C++ 中,因为引用与对象是紧绑定的,我们可以认为 引用就是对象本身。 正如栈对象的名字就是栈对象本身一样。也可以这么想, C++ 中的引用只是某个对象的一个别名,这个名字仅仅因为这个对象的存在而存在。
请看如下 C++ 代码
// c++
Student s1 ;
Student& s2 = s1; //s2 是栈对象 s1 的别名
... ...
Student s3 ;
s2 = s3; // 非法!!! s2 不能再成为对象 s3 的别名
s3 = s2 ; // 将调用赋值运算符
正是由于 C++ 中的紧绑定特性,所以上面最后一条语句会调用赋值运算符,使对象 s2 和 s3 处于一样的状态。
再看看 C# 。
在 C# 中只有两种类型的数据:值类型和引用类型。值类型通常是基本数据类型,如 int , double ,还有 struct 等;而所有的自定义的类,还有数组、代表、接口等都是引用类型。由于这样的约定,所以你就不必对 C# 中没有“ & ”引用符而感到奇怪了。
所有的值类型对象永远只在栈中分配,即使你对值类型使用了 new ;
//C#
int age = new int(24) ; // 仍然在栈中为 age 分配空间 ,与语句 int age = 24 ; 等价。
同样所有引用类型对象永远只在堆中创建。要生成引用类型的实例一定要用 new ,而 new 返回的引用通常保存在栈中。仅仅声明引用类型对象,就相当于声明了一个空指针(即 C# 中的引用可以为空,这在 C++ 中是不允许的),只是在栈中分配了 4 个字节给这个引用,在该引用没有赋值之前(即没有指向有效的堆内存),不能使用该引用,因为该引用为空。
//C#
Student s5 ; // 仅仅声明一个引用,并没有创建任何对象
s5 = new Student() ; // 在堆中创建一个对象,并让 s5 指向该对象
其实, C# 中的引用更像 C++ 中的指针,也就是说
C# 中的引用是具有指针语义的引用。
所以, C# 中的引用赋值就像 C++ 中的指针赋值一样,仅仅是让其指向另外的对象。也就是说 C# 中使用的是最浅层次的拷贝。引用相互赋值时,仅仅是引用的值(表示逻辑内存地址数据)发生了改变,而对引用指向的对象的状态没有丝毫的影响――如果说有影响,那就是仅仅改变了 GC 对该对象的引用计数。
正是由于 C# 中的引用具有指针的语义,才方便了 GC 对对象的引用计数。当某个对象的引用计数变为 0 时, GC 就会释放这个对象,这就是 C# 中自动内存管理的基本原理。
三.引用传递和值传递
在 C++ 中按值传递对象时,会调用拷贝构造函数生成对象的副本,那么对应的 C# 中也是这样的吗?
无论是在 C++ 中还是在 C# 中,当变量或对象作为函数参数进行传递时都有两种方式:按值传递和按引用传递。
所谓按值传递是指在函数体内部使用的是对象的副本,在 C++ 中这个副本是调用对象的拷贝构造函数完成的,而函数对副本的修改不会影响原来的对象。如
//C++
void Fun1(Student ss)
{
... ... // 对 ss 进行处理和修改――实际处理的是传入对象的副本
}
... ...
Student s7 ;
Fun1(s7) ;// 此函数调用结束后,对象 s7 的状态并没有改变
... ...
所谓按引用传递是指传给函数的实际上是对象的地址,这样函数对对象的修改就会反应在对象中,使对象的状态发生变化。如
//C++
void Fun2(Student& ss)
{
... ... // 对 ss 进行处理和修改
}
... ...
Student s8 ;
Fun2(s8) ;// 此函数调用结束后,对象 s8 的状态发生改变
... ...
在 C++ 中,可以通过指针和“ & ”引用符实现引用传递。上面的例子用到了“ & ”引用符号,其实换成指针也可以达到同样的效果。如果我们再进一步去想,可以发现,当用指针进行引用传递时,也发生了复制,只不过复制的是指针的值(即对象的地址),而不是复制指针指向的对象。这可以通过如下例子得到证明。
//C++
void Fun3(Student* ss)
{
... ... // 对 ss 指向的对象进行处理和修改
ss = NULL ;
}
... ...
Student* s9 ;
Fun3(s9) ;// 此函数调用结束后, s9 指向的对象的状态发生了改变
... ...
但是在 Fun3 ( s9 )调用完后, s9 并不是 NULL ,这说明 Fun3 中使用的是指针 s9 的副本。如果再进一步,我们可以猜测用“ & ”符实现引用传递时也发生了同样的故事。事实上也是这样, C++ 中的引用只是一个受限却更加安全的指针而已。
那么按引用传递和按值传递各有什么好处了?
按引用传递不需要发生拷贝行为,因此速度快,特别是大对象时,这种优势很明显。按值传递时对传入对象的修改实际是对对象副本的修改,不会影响原对象的状态。
你也许会想到如果采用 const 引用传递那么就可以得到双倍的好处,可以这么说,但是不要走极端。
一般而言,将不允许改变的大对象作为 const 引用传递给函数是很合适的,但如果是简单类型或自定义的小对象直接用值传递就可以了。
如果外界一定要看到函数对对象的修改,那么只有一条路 ―― 按引用传递。
在 C# 中情况却发生了变化, C# 中的引用类型的对象都是按引用传递且只能按引用传递。而值类型对象(或者称为变量),通常情况下是按值传递的。如果要按引用传递值类型对象,那么就要使用关键字 ref 或 out 。 ref 和 out 的唯一区别是 ref 用修饰参数时要求传入的变量被初始化过。
由于类是引用类型,而所有的引用类型的对象的传递都是引用传递,所以在此过程中根本不会发生拷贝函数的调用。照这样看来,根本就没有必要有拷贝构造函数了。
我想现在你已经知道了 C# 中为什么不需要拷贝构造函数和很少调用赋值运算符了。你也许会问既然是很少调用赋值运算符,那一定还有调用赋值运算符的情况存在,那么这种情况是怎样的?那是因为类的相仿体――结构 struct 。
四. struct
C++ 中的 struct 和 class 几乎没有任何差别,唯一的差别在于 struct 的成员默认是公有的,而 class 的成员默认是私有的。然而情况在 C# 中发生了本质的变化,因为 C# 中的 struct 是值类型的,而 class 是引用类型的。从下面的分析可以看出 C# 的创造者在这点设计上真是独具匠心。那么好处在哪里?
C# 中所有值类型都在栈中创建,在栈中创建对象较之在堆中创建对象的优势在于:效率更高。因为在堆中分配对象之前要采用一定的算法寻找合适的内存块,而这可能是很费时间的,而创建值类型对象直接压栈就可以了;还有栈对象在函数返回时会自动释放,而堆对象要由 GC 来处理。如果我们设计的是一个不太大的类,而且其实例很少在函数间传递(因为函数间按非引用传递值类型对象会发生复制),那么我们可以考虑将其实现为 struct 来代替 class 。
既然 struct 是值类型,当两个同类型的 struct 相互赋值时,自然就会调用 struct 的赋值运算符。
另外,经过我的验证,在 C# 中确实没有提供拷贝构造函数,但是你可以通过重载构造函数来变相地得到拷贝构造函数,这个技术的实现是很简单的,此处就不多说了。
讲到这里,已经差不多了,所以你不必在为像“为什么 C# 中没有拷贝构造函数?”、“为什么 C# 中很少看到赋值运算符的调用?”这样的问题而疑惑了 :)