在讲解 C++ 的复制构造函数之前这里先明确一个概念,C++ 的复制构造函数的意思并不是字面上的意思复制一个构造函数,而是有一种专门用于复制内容的构造函数被叫做复制构造函数。
复制构造函数对于 C++ 来说是非常重要的概念,所以我们必须掌握并牢记它,下面我们就一起来逐步的了解复制构造函数。
1. 前言
已经学习过 C 语言或已经学习过 C++ 基础语法函数部分内容的小伙伴们因该都知道我们在给函数传递参数时需要先将实参复制到函数的形参,函数内部再使用形参当中的值,注意这种复制规则也适用于对象(类的实例)类型数据。下面来看一个函数参数传递的简单例子,计算圆的面积,如下代码。
double circleArea(double r)
{
return 3.14 * r * r;
}
例如 circleArea(10)
先将实参 10
复制到函数的形参 r
中,函数内部再使用形参 r
中的值计算面积。
2. 复制类对象
对于普通类型的数据对象来说,它们之间的复制很简单,直接复制当然不会出现问题。但是对象类型与普通类型不同,类对象内部结构一般较为复杂,存在各种类型的成员变量,所以 C++ 对象类型的复制需要借助 复制构造函数,下面我们来看一个简单的对象类型复制的例子。
#include<iostream>
using namespace std;
class Animal {
private:
int foot;
int tail;
public:
// 普通构造函数
Animal(int i, int j) {
foot = i;
tail = j;
}
void show() {
cout << "foot:" << foot << endl;
cout << "tail:" << tail << endl;
}
};
int main()
{
Animal dog(4, 1);
Animal cat = dog;
cat.show();
return 0;
}
实例化对象 dog
并使用数值 4 和 1 来初始化私有实例变量 foot
和 tail
,再定义对象类型变量 cat
,并将实例对象 dog
复制到对象类型变量 cat
中(注意在这里是复制而不是赋值到 cat
,后面会说明这两者的区别)。
运行程序,最终函数输出 4 和 1。从运行结果可以看出,系统为对象 cat
分配了内存并完成了与对象 dog
的复制过程。
3. 认识复制构造函数
前面已经说了,类对象的复制需要借助复制构造函数,但是在前面的程序中并没有复制构造函数,但对象以及其内部成员变量确实复制成功了,这是怎么回事呢?
这是因为在我们没有显示的定义复制构造函数的情况下,编译器会给我们自动产生一个复制构造函数,这就是 默认拷贝构造函数。如果我们编写了复制构造函数,则编译器就不会再给我们生成默认的复制构造函数了。
3.1 复制构造函数定义
复制构造函数是一种特殊的构造函数,专门用于数据的复制,复制构造函数的名称必须和类名称一致(这一点和普通的构造函数一样),它必须的一个参数是本类型的一个 引用变量。
class Animal {
public:
Animal(const Animal & animal) {
}
};
3.2 默认复制构造函数
默认复制构造函数很简单,仅仅是实现了从源对象到目标对象逐个字节的复制,即使得目标对象的每个成员变量都变得和源对象相等,如下所示。
#include<iostream>
using namespace std;
class Animal {
private:
int foot;
int tail;
public:
// 普通构造函数
Animal(int i, int j) {
foot = i;
tail = j;
}
// 复制构造函数
Animal(Animal & animal) {
foot = animal.foot;
tail = animal.tail;
}
void show() {
cout << "foot:" << foot << endl;
cout << "tail:" << tail << endl;
}
};
int main()
{
Animal dog(4, 1);
Animal cat = dog;
cat.show();
return 0;
}
程序中函数 Animal(Animal & animal) {}
就是我们自定义的构造函数,虽然这个复制构造函数是我们自定义的,实际上在我们没有定义复制构造函数时编译器给我们生成的默认复制构造函数就是这样的。
所以程序中我们定义的复制构造函数就是默认复制构造函数,只是我们将这个默认复制构造函数显式定义了。
4. 浅拷贝与深拷贝概念
4.1 概念理解
在讲解浅拷贝与深拷贝之前我们需要先来了解浅拷贝和深拷贝的概念,要理解概念我们可以通过一个 C 语言例程来讲解,如下。
#include <stdio.h>
unsigned char * str = NULL;
void speak_init(unsigned char * s)
{
str = s;
}
void speak()
{
printf("%s\n", str);
}
int main()
{
speak_init("Hello, World!");
speak();
}
函数 speak_init()
用于初始化函数 speak()
要输出的内容,将形参指针 s
指向的内容 Hello, World!
的首地址复制到指针 str
中。最后函数 speak()
输出内容。
但是这里有一个问题,就是 speak_init()
执行完后,形参指针 s
就被销毁,指针 s
指向的地址变为无效。这就导致函数 speak()
使用指针 str
访问无效地址上的内容而发生错误。
所以正确的做法是开辟一段足够并且不会被别人销毁的存储空间,将可能会被销毁地址上的内容复制到这个空间中。后续需要访问这些内容时通过访问这个空间内的地址来访问内容,如下。
#include <stdio.h>
#include <string.h>
unsigned char str[16];
void speak_init(unsigned char * s)
{
memset(str, '\0', 16);
strcpy(str, s);
}
void speak()
{
printf("%s\n", str);
}
int main()
{
speak_init("Hello, World!");
speak();
}
所以浅拷贝问题并不是 C++ 的特色,仔细追究起来这还是从 C 语言身上继承而来的问题呢,谁叫 C++ 是 C 语言的扩展呢。
4.2 概念总结
浅拷贝:浅拷贝只复制指向某个对象的指针(即只复制内容的首地址),而不复制具体的内容,新旧对象还是共享同一块内存,如果内容地址随着旧对象被销毁而无效,新对象也将无法继续访问地址上的内容。
深拷贝:深拷贝会另外开辟存储空间,并将新的对象内容拷贝到该存储空间下,新对象跟原对象不共享内存,此时旧对象的销毁也不会影响到新对象的内容。
所以浅拷贝问题主要体现在指针类型拷贝的场景下,普通数据类型进行一对一的复制是没有问题的。
5. 浅拷贝问题分析
很多时候在我们都不知道拷贝构造函数的情况下,传递对象给函数参数或者函数返回对象都能很好的进行,这是因为编译器会给我们自动产生一个拷贝构造函数,这就是 默认拷贝构造函数,这个构造函数很简单,仅仅使用 旧对象 的数据成员的值对 新对象 的数据成员一对一赋值(所以默认复制构造函数执行的是浅拷贝),一般具有以下形式。
// 复制构造函数
Animal::Animal(Animal & animal) {
foot = animal.foot;
tail = animal.tail;
}
5.1 浅拷贝
class Rect {
private:
int width;
int height;
int * p; // 指针成员
public:
Rect() { // 构造函数,p 指向堆中分配的一空间,并赋值 100。
p = new int(100);
}
~Rect() { // 析构函数,释放动态分配的空间
if (p != NULL)
delete p;
}
};
int main()
{
Rect rect1;
Rect rect2 = rect1; // 复制对象
return 0;
}
在这段代码运行结束之前,会出现一个运行错误。原因就在于在进行对象复制时,
对于动态分配的内容没有进行正确的操作。我们来分析一下:
在运行定义 rect1
对象后,由于在构造函数中有一个动态分配的语句,因此执行后将开辟一段内存。
在使用 rect1
复制 rect2
时,由于执行的是浅拷贝,只是将成员的值进行赋值,这时 rect1.p = rect2.p
,也就是这两个指针指向了同一个空间。
在销毁对象时,两个对象的析构函数将对同一个内存空间释放两次,这就是运行错误的原因。我们想要的结果当然不是两个指针有相同的地址值(没有人会关心数据所在的地址),而是希望两个指针指向的空间有相同的内容,解决办法就是使用 深拷贝。
5.2 深拷贝
对象复制操作会执行类的复制构造函数,我们在复制构造函数中会为新对象的指针成员变量分配内存空间,并将旧对象的指针成员变量所指向的内容复制到我们给当前对象的指针成员变量新分配的缓冲区中,程序如下。
class Rect {
private:
int width;
int height;
int * p; // 指针成员
public:
Rect() { // 构造函数,指针 p 指向堆中分配的一空间,并赋值 100。
p = new int(100);
}
Rect(const Rect & r) { // 复制构造函数
width = r.width;
height = r.height;
p = new int; // 为新对象重新动态分配空间
*p = *(r.p); // 将旧对象的值拷贝到新地址上
}
~Rect() { // 析构函数,释放动态分配的空间
if (p != NULL)
delete p;
}
};
int main()
{
Rect rect1;
Rect rect2 = rect1; // 复制对象
return 0;
}
执行之后对象 rect1
的指针和对象 rect2
的指针各自指向一段内存空间,虽然指向的空间不同但它们指向的空间具有相同的内容,最终执行的拷贝就是 深拷贝。
6. 调用复制构造函数
6.1 区分对象的复制和赋值
在 C++ 中赋值和复制这两者在语法上是完全相同的,此时我们该如何来区分当前场景是使用复制还是赋值操作。实际上区分很简单,只需要知道 =
号左边的对象是否已经被定义,还是正在被定义。
- 如果
=
左边的对象正在定义,那么给正在定义的对象赋值就属于初始化对象,初始化对象执行的是复制操作。
Complex c1;
Complex c2 = c1;
- 如果
=
左边的对象已经定义,对象定义的过程中即使我们没有提供值初始化对象,编译器也将使用一个默认的值初始化对象。所以给定义后的对象赋值就不再属于初始化对象,而是属于修改对象,此时执行的是赋值操作。
Complex c1, c2;
c1 = c2;
6.2 复制构造函数调用场景
(1) 当用一个对象去初始化同类的另一个对象时,会引发复制构造函数被调用。例如,下面的两条语句都会引发复制构造函数的调用,用以初始化 c2。
Complex c2(c1);
Complex c2 = c1;
注意:这两条语句是等价的,都是初始化对象。
(2) 如果函数 Func 的参数是类 Hello 的对象,那么当 Func 被调用时,类 Hello 的复制构造函数将被调用。换句话说,作为形参的对象,是用复制构造函数初始化的,而且调用复制构造函数时的参数,就是调用函数时所给的实参。
#include<iostream>
using namespace std;
class Hello {
public:
Hello() {
}
Hello(Hello & hello) {
cout << "Copy constructor called" << endl;
}
};
void Func(Hello hello)
{
}
int main()
{
Hello hello;
Func(hello);
return 0;
}
前面说过,函数的形参的值等于函数调用时对应的实参,现在可以知道这不一定是正确的。如果形参是一个对象,那么形参的值是否等于实参,取决于该对象所属的类的复制构造函数是如何实现的。
以对象作为函数的形参,在函数被调用时,生成的形参要用复制构造函数初始化,这会带来时间上的开销。如果用对象的引用而不是对象作为形参,就没有这个问题了。但是以引用作为形参有一定的风险,因为这种情况下如果形参的值发生改变,实参的值也会跟着改变。如果要确保实参的值不会改变,又希望避免复制构造函数带来的开销,解决办法就是将形参声明为对象的 const
引用。
void Func(const Hello & hello)
{
}
这样使用 const
引用之后,Func 函数中出现任何有可能导致 hello
的值被修改的语句,都会引发编译错误。
(3) 如果函数的返冋值是类 A 的对象,则函数返冋时,类 A 的复制构造函数被调用。换言之,作为函数返回值的对象是用复制构造函数初始化 的,而调用复制构造函数时的实参,就是 return 语句所返回的对象。
#include<iostream>
using namespace std;
class Hello {
public:
int a;
Hello(int n) {
a = n;
};
Hello(const Hello & hello) {
a = hello.a;
cout << "Copy constructor called" << endl;
}
};
Hello Func()
{
Hello hello(4);
return hello;
}
int main()
{
cout << Func().a << endl;
return 0;
}
调用了 Func
函数,其返回值是一个对象,该对象就是用复制构造函数初始化的, 而且调用复制构造函数时,实参就是第 return 语句所返回的 hello
。复制构造函数确实完成了复制的工作,所以 Func 函数的返回值和 Func
内部的 hello
对象相等。
7. 防止默认复制发生
通过前面的对象复制的分析,可以发现对象的复制大多在进行 值传递 时发生,可以在类中声明一个私有复制构造函数来防止按值传递。甚至不必去定义这个复制构造函数,因为声明复制构造函数是私有的,如果用户试图按值传递或函数返回该类对象,将得到一个编译错误,从而可以避免按值传递或返回对象。