C++雾中风景6:拷贝构造函数与赋值函数
在进行C++类编写的过程之中,通常会涉及到类的拷贝构造函数与类的赋值函数。初涉类编写的代码,对于两类函数的用法一直是挺让人困惑的内容。这篇文章我们会详细来梳理拷贝构造函数与赋值函数的区别。
1.调用了哪个函数?
上述两种函数的使用和C++之中类的定义紧密相关,所以我们先定义一个类:
class Line { public: int getLength( void ); Line( int len ); //简单的构造函数 Line( const Line &obj) { //拷贝构造函数 cout << "调用拷贝构造函数" << endl; ptr = new int; *ptr = *obj.ptr; }; Line& operator=(const Line &obj) { //赋值函数 cout << "调用赋值函数" << endl; if(this == &obj) { return *this; } delete ptr; ptr = new int; *ptr = *obj.ptr; return *this; }; ~Line(); // 析构函数 private: int *ptr; };
这里我们显式声明了拷贝构造函数与赋值构造函数,接下来我们用一小段代码测试一下上面定义的类。(其他函数的定义并不完整,读者可以之行补全)
int main() { Line l1(); Line l2 = l1; Line l3(); l3 = l2; }
输出结果如下:
调用拷贝构造函数 调用赋值函数
看似很相似的两个表达式,却调用了不同的两个函数。初学C++时,这样的结果让我很困惑,所以我们接下来梳理一下这两个函数。
2.拷贝构造函数
上面的代码我们可以看到代码 Line l2 = l1调用了拷贝构造函数。
拷贝构造函数,顾名思义,是一个构造函数,但是它特殊的点就在于在创建对象时,是使用同一类中之前创建的对象来初始化新创建的对象。所以对于它的使用场合也很简单,只有在构造对象时才会调用到拷贝构造函数,显然Line l2 = l1是一个对象初始化的过程。我们知道每个类都会有构造函数,在对象初始化的过程之中,拷贝构造函数提供了一个通过一个同类型的对象对它进行初始化。
C++支持两种初始化形式:拷贝初始化(int a = 5;)和直接初始化(int a(5);)对于其他类型没有什么区别,对于类类型直接初始化直接调用实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数,也就是说:
A x(); //直接初始化,调用构造函数 A y = x; //复制初始化,调用拷贝构造函数
在C++中,下面几个场景中,拷贝构造函数会被调用:
- 一个对象需要通过另一个对象进行初始化
- 一个对象以值传递的方式作为参数传入函数
- 一个对象以值传递的方式作为返回值从函数返回
如果我们没有显式声明定义对应类的拷贝构造函数,C++编译器会默认生成对应的拷贝构造函数。既然C++编译器会自动生成拷贝构造函数,为什么我们又需要显式的去定义它呢?
因为由C++编译器提供的拷贝构造函数工作方式是浅拷贝。它单纯的使用了=操作符来拷贝类中的成员。但是如果类中用到了需要动态分配内存的成员(如Line类之中的ptr指针),则会出现内存安全的问题。同时对于类的封装性也是一种变相破坏,因为浅拷贝只是单纯拷贝了该成员的内存地址,但所指向的空间内容并没有复制,而是由两个对象共用,就容易出现double free等问题。所以此时就要手动重载拷贝构造函数,实现深拷贝。
3.赋值函数
许多文章,博客喜欢把Line& operator=(const Line &obj)重载=操作符的函数称之为赋值构造函数。个人认为其实是不准确的,会产生一个理解误区。其实重载的=操作符就是一个赋值函数。
赋值函数:是把一个新的对象赋值给一个原有的对象,如果原来的对象中有内存分配需要先把内存释放掉。如果我们没有在类之中显式重载对应类的赋值函数,C++编译器也会默认生成对应的赋值函数。生成的规则与拷贝构造函数类似,也是一种浅拷贝的形式。所以我们重载赋值函数的原因也与拷贝构造函数类型,需要实现深度赋值。
由上文的代码也可以看出,赋值函数与拷贝构造函数定义的内容之中,所做的工作大同小异。唯一需要注意的点是:在赋值函数之中需要检察一下两个对象是不是同一个对象,如果是,不做任何操作,直接返回。
x = x //可能会出现赋值给自身的操作,所以需要对赋值对象进行检查
所以现在回头再来看看前文的代码,我们应该可以理解C++编译器调用不同函数的理由了:
int main() { Line l1(); Line l2 = l1; Line l3(); l3 = l2; }
当对象不存在,且用别的对象来初始化,此时调用的便是拷贝构造函数。而当对象已经存在,用别的对象来给它进行赋值操作时,调用的就是赋值函数了。
最后的小Tips:一旦在类之中声明了拷贝构造函数与赋值函数,编译器将不会生成缺省的对应函数。所以我们可以通过将拷贝构造函数和赋值函数声明为私有函数,来阻止编译器生成的缺省函数,在编译阶段来拒绝不安全的浅拷贝。
class Line { public: int getLength( void ) { return *ptr; }; Line( int len ) { ptr = new int; *ptr = len; } //简单的构造函数 // ~Line(); // 析构函数 private: int *ptr; Line( const Line &obj); Line& operator=(const Line &obj); }; int main() { Line l1(); Line l2 = l1; //编译器报错,无法通过编译 Line l3(); l3 = l2; //同样的的编译器报错,无法通过编译 }
好的,关于拷贝构造函数与赋值函数,就先写到这里。下一篇雾中风景系列,来聊一聊命名空间吧~~