C++雾中风景6:拷贝构造函数与赋值函数

时间:2023-01-31 17:33:15

在进行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(10);
Line l2 = l1;

Line l3(5);
l3 = l2;
}

输出结果如下:

调用拷贝构造函数
调用赋值函数

看似很相似的两个表达式,却调用了不同的两个函数。初学C++时,这样的结果让我很困惑,所以我们接下来梳理一下这两个函数。

2.拷贝构造函数

上面的代码我们可以看到代码 Line l2 = l1调用了拷贝构造函数。
拷贝构造函数,顾名思义,是一个构造函数,但是它特殊的点就在于在创建对象时,是使用同一类中之前创建的对象来初始化新创建的对象。所以对于它的使用场合也很简单,只有在构造对象时才会调用到拷贝构造函数,显然Line l2 = l1是一个对象初始化的过程。我们知道每个类都会有构造函数,在对象初始化的过程之中,拷贝构造函数提供了一个通过一个同类型的对象对它进行初始化。

C++支持两种初始化形式:拷贝初始化(int a = 5;)和直接初始化(int a(5);)对于其他类型没有什么区别,对于类类型直接初始化直接调用实参匹配的构造函数,拷贝初始化总是调用拷贝构造函数,也就是说:

A x(2);    //直接初始化,调用构造函数
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(10);
Line l2 = l1;

Line l3(5);
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(10);
Line l2 = l1; //编译器报错,无法通过编译

Line l3(5);
l3 = l2; //同样的的编译器报错,无法通过编译
}

好的,关于拷贝构造函数与赋值函数,就先写到这里。下一篇雾中风景系列,来聊一聊命名空间吧~~