如果已经存在一个对象,我想对这个对象再复制一份,该怎么做呢?
有两种方法,拷贝构造和赋值运算符重载,但显然赋值运算符重载不是这里的重点,这里要讲的是前者。
拷贝构造函数是类的六大特殊成员函数之一,它是构造函数的一个重载形式,且参数只有一个,必须使用引用传参。
而且由于拷贝并不需要改变参数,所以参数部分还要用 “const” 来修饰。
下面是用例示范:
class Date {
public:
Date(int year = 2022, int month = 8, int day = 20)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year = 2022, int month = 8, int day = 20)" << endl;
}
Date(const Date& d)
:_year(d._year)
, _month(d._month)
, _day(d._day)
{
cout << "Date(const Date& d)" << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1;
Date d2(d1);
Date d3 = d1;
return 0;
}
运行结果如下:
拷贝构造是已经存在的对象拷贝给即将要创建的对象,所以 d3 虽然像是去调用赋值运算符重载函数,实际上还是拷贝构造。
一开始说拷贝构造必须传引用,那传值为什么不行呢?
首先传值调用的话,我们要明确一点,就是形参是实参的临时拷贝。
以下面的代码为例:
void f(Date d)
{}
int main() {
Date d;
f(d);
return 0;
}
现在已经明确了,传参时会调用一次拷贝构造函数。
那么如果拷贝构造的参数部分也是传值调用呢?
每次调用拷贝构造函数传参时都要进行临时拷贝,
临时拷贝又要调用拷贝构造函数,
如此往复,就引发了无穷递归。
下面的图就能很形象地解释这个问题:
如果我忘了写拷贝构造函数,但后边又调用了,会发生什么呢?
class Date {
public:
Date(int year = 2022, int month = 8, int day = 20)
:_year(year)
, _month(month)
, _day(day)
{
cout << "Date(int year = 2022, int month = 8, int day = 20)" << endl;
}
void Print() {
cout << _year << "-" << _month << "-" << _day << endl;
}
private:
int _year;
int _month;
int _day;
};
int main() {
Date d1;
Date d2(d1);
d2.Print();
return 0;
}
运行结果如下:
d2 也成功完成拷贝构造了。
拷贝构造函数毕竟是六个特殊的成员函数之一,所以我们不写编译器也是会自动生成的,编译器自动生成的这个就是所谓的默认拷贝构造函数。
同样地,和编译器默认生成的构造函数一样,默认拷贝构造函数对内置类型会完成拷贝,对自定义类型会去调用它的拷贝构造函数:
class A {
public:
A()
:_a(0)
{
cout << "A()" << endl;
}
A(const A& aa)
:_a(aa._a)
{
cout << "A(const A& aa)" << endl;
}
private:
int _a;
};
class B {
public:
B()
:_b(0)
{
cout << "B()" << endl;
}
private:
int _b;
A aa;
};
int main() {
B b1;
B b2(b1);
return 0;
}
运行结果如下:
前面调用的编译器自动生成的拷贝构造函数,也完成了我们想要的效果。
既然编译器自动生成的默认拷贝构造函数也能完成拷贝构造,那我们是不是可以不写了呢?
看下面一段代码:
class Stack {
public:
Stack(int k = 4)
:_arr(new int[k])
,_size(0)
,_capacity(k)
{
cout << "Stack(int k = 4)" << endl;
}
~Stack() {
cout << "~Stack()" << endl;
delete[] _arr;
_arr = nullptr;
_size = _capacity = 0;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main() {
Stack st1;
Stack st2(st1);
return 0;
}
这是粗略地写了一个 Stack 类,~Stack() 是析构函数,出对象的作用域会自动调用,完成对象内容的清理,这里是释放掉 _arr 指向的空间并置空。
那么程序走起来:
程序运行过程中挂掉了。
调试看一下监视窗口:
st1 和 st2 的 _arr 竟然都指向一块空间。
这里就可以看出系统默认生成的拷贝构造函数完成的是浅拷贝,或者说是值拷贝。
因为 arr 只是一个指针变量,它的值是指向的空间的首地址,所以 st2.arr 是直接得到了 st1.arr 的值,所以他俩指向一块空间,实际上就是他俩之间进行了值拷贝。
很显然,这种场景下编译器默认生成的拷贝构造函数已然满足不了我们的需求,这时就要我们自己写一个拷贝构造,自己完成深拷贝。
那什么是深拷贝呢?
前面我们已经知道,编译器默认生成的拷贝构造函数只能进行简单的浅拷贝。
所谓浅拷贝,其实就是按内存存储按字节序完成拷贝。
形象一点就是,假设一个四个字节的变量存放的内容是 0x11223344,,那么浅拷贝就会把它存放的内容依次复制过去,拷贝后的结果也是 0x11223344。如果这块空间存放的是地址,那么拷贝到的也是同样的地址,这就会导致有两个指针指向同一块地址,这并不符合我们想要的拷贝效果。
我们想要的拷贝效果是拷贝构造的对象指向一块与源对象不同的空间,但空间大小和存放的内容都相同。
这其实就是所谓的深拷贝。
知道了想要的功能,下面就去实现它。
还是以我们自己写的 Stack 类为主:
class Stack {
public:
Stack(int k = 4)
:_arr(new int[k])
,_size(0)
,_capacity(k)
{}
Stack(const Stack& st)
:_arr(new int[st.capacity()])
,_capacity(st.capacity())
,_size(st.size())
{
memcpy(_arr, st._arr, st.size() * sizeof(int));
}
~Stack() {
delete[] _arr;
_arr = nullptr;
_size = _capacity = 0;
}
int size() const {
return this->_size;
}
int capacity() const {
return this->_capacity;
}
private:
int* _arr;
int _size;
int _capacity;
};
int main() {
Stack st1;
Stack st2(st1);
return 0;
}
运行一下:
可见 st1._arr 和 st2.arr 确确实实指向了两块空间,这里并没有存放数据,所以没看出来数据的拷贝,不过无伤大雅~
总结一下,上面我们简单认识了一下浅拷贝和深拷贝。编译器自动生成的默认拷贝构造函数只能帮我们完成浅拷贝,所以当没有深拷贝需求时是可以不用我们自己写拷贝构造函数的。但当成员变量有指针且指向一块空间,需要拷贝构造的对象需要指向另外一块空间时,就需要我们自己写拷贝构造完成深拷贝了。
我们下面要讨论的是自定义类型做函数返回值时传值返回和自定义类型做函数参数传值调用的场景。
首先明确一点,传值返回返回的并不是函数栈帧里的对象,而是临时拷贝出了一个对象,而且这个临时拷贝的对象是常量性质的,这个在后面会验证。
可以看下面一段代码:
class A {
public:
A(int a = 0) {
cout << "A()" << endl;
}
A(const A& aa) {
cout << "A(A& aa)" << endl;
}
private:
int _a;
};
A f1() {
static A aa;
return aa;
}
int main() {
f1();
return 0;
}
注意这里调用 f1 函数并没有用一个对象接收它的返回值,
运行结果如下:
函数体内调用了构造函数,返回时调用了一次拷贝构造函数,即使 aa 的生命周期是全局的。
这是第一点需要明确的。
其次还要明确一点,拷贝构造函数是一个已经存在的变量拷贝给一个即将创建的变量,比如下面一段代码:
A a1;
A a2 = a1;
这里调用的是拷贝构造函数而不是赋值运算符重载函数:
这是第二点需要明确的。
明确了以上两点后在第一段代码的基础上改变一下 main 函数:
int main() {
A a1 = f1();
return 0;
}
先分析一波:
这里 f1 返回的时候会临时拷贝一份对象返回,即使要返回的对象是 static 性质的。这是第一次调用拷贝构造函数。而这里又用返回值拷贝构造了一个新对象 a1,这是第二次拷贝构造。
那么看运行结果:
运行结果表示这里只调用了一次拷贝构造函数,与我们所分析的相悖。
而这里也就是我所想说的编译器对于同一个表达式中连续拷贝构造的优化,把两个拷贝构造优化成了一个。
但这个优化并不是所有编译器都支持的,一般新版的编译器(比如VS2019…)会做这样的优化,因为这种优化并不是C++标准所规定的,所以做不做就取决于开发者了。
上面我们还提到了函数返回时临时拷贝出来的对象是常量性质的,这一点用下面的代码去验证:
class A {
public:
A(A& aa) {
cout << "A(A& aa)" << endl;
}
private:
int _a;
};
A f1() {
static A aa;
return aa;
}
int main() {
A a1 = f1();
return 0;
}
注意,这里只写了一个拷贝构造函数,它的参数类型不是 const ,这时程序是跑不动的:
原因就在于拷贝出来的临时对象是常量性质的,拷贝构造 a1 时需要把这个常量性质的临时对象作为参数传给拷贝构造函数,实参是 const 类型,虚参是普通变量,传一下参操作权限就放大了,显然是错误的,所以这里会报错,也印证了临时对象的常量性质。
但这同时也说明了一个问题,虽然编译器把两次连续拷贝优化成了一次,但我们写代码分析时还是要按着两次拷贝的逻辑来,不然上面这段代码也不会出错了。
这里多提一嘴,对于函数返回时临时拷贝的那个常量性质的对象或变量是存在哪呢?
这里直接给结论,如果拷贝对象比较小,只有 4/8byte 时,可能依靠寄存器临时存放;如果比较大时,可能就会在上一个函数栈帧中预留出一块空间留着拷贝,比如上面可能就会在 main 函数中预留出一块存放临时拷贝对象的空间。
代码中类的定义部分就不写出来了,跟上面都是统一的。
首先看下面一段代码:
void f2(A aa)
{}
int main() {
A a1;
f2(a1);
return 0;
}
首先分析一下,先是调用构造函数创建了一个对象 a1 ,然后函数传参时由于是传值调用,所以传过去的是实参的一份拷贝,所以这里会临时拷贝出来一个对象,所以还会调用一次拷贝构造函数。
程序运行结果也可以验证这一点:
先引入一种新的对象,因为后面需要用。
当我们创建一个对象变量时一般是这样 A aa;
但还有一种方式就是 A()
,
我们称之为匿名对象,它的生命周期只在这一行,可以看下面的代码验证一下:
有了匿名对象的概念我们接着讨论。
上面函数传参传过去一个已经存在的对象时需要对实参进行临时拷贝,而如果我这样做呢:
void f2(A aa)
{}
int main() {
f2(A());
return 0;
}
按照正常的逻辑,应该会调用一次构造函数创建匿名对象,然后再调用拷贝构造函数对实参进行临时拷贝。
那么看运行结果:
这里编译器只调用了一次构造函数,并没有进行拷贝,这也是编译器进行优化的一点。
在同一个表达式中,如果产生了临时对象,再用临时对象去拷贝构造一个对象,那么编译器可能会优化,两个对象合二为一,直接构造出一个对象。
-优化一定发生在一个表达式中的连续步骤,连续步骤可以是连续拷贝构造,也可以是一次构造+一次拷贝构造。而且优化掉的都是临时对象,或者是匿名对象。
注意,上面说的是可能优化,只有部分比较新的编译器支持这种操作。
当然,无论再怎么优化,传值调用或传值返回总是避免不了临时拷贝,所以当能传引用的时候还是要传引用,尽量避免传值。