复制拷贝构造函数 和 赋值运算符

发布时间 2023-10-31 22:27:01作者: 黄河大道东

参考

拷贝构造函数 和 赋值运算符:https://blog.csdn.net/weixin_44737923/article/details/104588106

C++ 默认构造/拷贝构造/赋值构造/带参构造 函数详解:https://blog.csdn.net/weixin_43821643/article/details/125303717

一、 前言

  本文主要介绍复制拷贝构造函数赋值运算符的区别,简单的分析深拷贝和浅拷贝问题,以及在什么时候调用复制拷贝构造函数、什么情况下调用赋值运算符

  默认情况下(当开发工程师在设计一个C++类时没有明确的定义或者明确的显式删除复制拷贝构造函数和赋值运算符的话)编译器会自动默认生成一个 复制拷贝构造函数 和 赋值运算符 。但开发工程师将 复制拷贝构造函数 和 赋值运算符 定义成私有的(private),则开发工程师就不能在后续代码中使用 复制拷贝构造函数 和 赋值运算符 了。另一种禁用方式是使用 delete 来指定不生成 复制拷贝构造函数 和 赋值运算符 ,那么这个类的对象就不能通过值传递,也不能进行赋值运算。

class Person {
public:
    Person(const Person &p) = delete;
    Person &operator=(const Person &p) = delete;
private:
    int age;
    string name;
};

  上面的定义的类 Person 显式的删除了 复制拷贝构造函数 和 赋值运算符,在需要调用 复制拷贝构造函数 或者 赋值运算符 的地方,会提示无法调用该函数,它是已删除的函数。
  注意:复制拷贝构造函数 必须以 引用的方式传递参数。这是因为 以值传递的方式 传递给一个函数的时候,会调用 复制拷贝构造函数 生成函数的实参。如果拷贝构造函数的参数仍然是以值的方式,就会无限循环的调用下去,直到函数的栈溢出。

#include <iostream>
class A {
public:
    A &operator=(const A &a) {
        std::cout << "重写默认的赋值运算符" << std::endl;
        return *this;
    }
    A &operator=(const int &a) {
        std::cout << "重写赋值运算符 int a" << std::endl;
        return *this;
    }
    A &operator=(const std::string &a) {
        std::cout << "重写赋值运算符 std::string a" << std::endl;
        return *this;
    }
};
int main(int argc, char *argv[], char *env[]) {
    A a;
    A b;
    a = b;
    a = 1;
    a = "abc";
    std::cout << &a << std::endl;
    std::cout << &b << std::endl;
    return 0;
}

  上面定义的A类重写了三种赋值操作符,其返回值一般都要返回调用者的引用,参数没有明确限制,根据场景可以选择合适的。

二、区别

  复制拷贝构造函数 和 赋值运算符 的行为比较相似,都是将一个对象的值复制给另一个对象;但是其结果却有些不同,复制拷贝构造函数 使用传入对象的值生成一个新的对象的实例,而 赋值运算符 是将对象的值复制给一个已经存在的实例。这种区别从两者的名字也可以很轻易的分辨出来,复制拷贝构造函数 也是一种构造函数,那么它的功能就是创建一个新的对象实例;赋值运算符是执行某种运算,将一个对象的值复制给另一个对象(已经存在的)。

  调用的是 复制拷贝构造函数 还是 赋值运算符,主要是看是否有新的对象实例产生。如果产生新的对象实例,那调用的就是拷贝构造函数;如果没有,那就是对已有的对象赋值,调用的是赋值运算符。

三、复制拷贝构造函数

1、调用时机

  • 对象作为函数的参数,以值传递的方式传给函数
  • 对象作为函数的返回值,以值的方式从函数返回
  • 使用一个对象给另一个对象初始化

  举例说明,假设 a 是一个 Fly 对象,则下面四种声明都将调用 复制拷贝构造函数 :

Fly b(a);
Fly c = a;
Fly d = Fly(a);
Fly *e = new Fly(a);		// new Fly(a) 属于匿名对象

  其中中间的两种声明可能会使用拷贝构造函数直接创建 c 和 d(具体取决于你使用的编译器是否开启了ROV优化),也可能使用 复制拷贝构造函数 生成一个临时对象,然后将临时对象的内容赋给 c 和 d,取决于具体的实现。最后一种声明使用 a 初始化一个匿名对象,并将新对象的地址赋给 e 指针

  当程序生成了对象副本时,编译器都将使用 复制拷贝构造函数 。具体地说,当函数按值传递对象或函数返回对象时,都将使用 复制拷贝构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用 复制拷贝构造函数 。

  因为按值传递对象将调用 复制拷贝构造函数 产生临时对象,所以应该按引用的方式传递对象。这样可以节省调用 构造函数 的时间以及存储新对象的空间。

2、默认复制拷贝构造函数的功能

  默认的 复制拷贝构造函数 会逐个复制非静态成员(成员复制也称为浅拷贝),复制的是成员的值。

  下面语句:

Fly a = b;

  与下面的代码等效:

Fly a;
a.str = b.str;
a.len = b.len;

  但上述代码是会出现浅拷贝的错误的,原因在于默认的复制拷贝构造函数是按值进行复制。

a.str = b.str;

  这里复制的不是字符串,而是一个指向字符串的指针。也就是说,将 a 初始化为 b 后,得到的是两个指向同一个字符串的指针。当析构函数被调用时,这将引发问题。析构函数释放 str 指针指向的内存,因此释放 a 的效果如下:

delete []a.str;

  但是它被赋值为 b.str,而 b.str 指向的正是上述字符串,但上述字符串所指向的内存已经被释放,然后程序释放 b 如下:

delete []b.str;

  但是 b.str 指向的内存已经被 a 的析构函数释放,这将导致不确定的、可能有害的后果,试图释放内存两次可能导致程序异常终止,这通常是内存管理不善的表现。

3、自定义复制拷贝构造函数解决问题

  解决类设计中这种问题的方法是进行深度复制(deep copy),也就是说,复制拷贝构造函数 应当复制字符串数据并将新复制的数据内存地址赋给 str 成员,而不仅仅是复制字符串的指针地址。这样每个对象都有自己的字符串,而不会试图去释放已经被释放的字符串。

  可以这样编写String的复制构造函数:

Fly::Fly(const Fly &st) {
	len = st.len;
	str = new char[len + 1];
	std::strcpy(str, st.str);
}

  必须定义 复制拷贝构造函数 的原因在于:一些类成员是使用new初始化的、是指向数据的指针,而不是数据本身。

  小结:如果类中包含了使用 new 初始化的指针成员,应当定义一个拷贝构造函数,以复制指针指向的数据,而不是简单的只复制指针本身的地址,这被称为深度复制(即深拷贝)。复制的另一种形式(成员复制或浅复制)只是复制指针值。浅拷贝仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。

四、赋值运算符

  C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:

T& T::operator=(const T&);

  它接受并返回一个指向类对象的引用,例如 Fly 类默认的的赋值运算符的原型如下:

Fly& Fly::operator=(const Fly&);

1、赋值运算符的功能及何时使用它

  将已有的对象赋给另一个对象时,将使用重载的赋值运算符:

Fly a("hello world");
Fly b;
b = a;

  初始化对象时,并不一定会使用赋值运算符:

Fly c = b;

  这里,c 是一个新创建的对象,被初始化为 b 的值,因此使用 复制拷贝构造函数。然而,正如前面所指出的,实现时也可能分两步来处理这条语句:使用 复制拷贝构造函数 创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说:初始化时总是会调用拷贝构造函数,而使用=运算符也可能调用赋值运算符。

  与 复制拷贝构造函数 相似,赋值运算符 的隐式实现也对成员进行逐个复制,即浅拷贝。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响。

2、解决赋值的问题

  • 对于由于默认赋值运算符不合适而导致的问题,解决办法是提供赋值运算符(进行深拷贝)定义。其实现与拷贝构造函数相似,但也有一些差别。

  • 由于目标对象可能引用了以前分配的数据,所以使用函数 detele[] 来释放这些数据。

  • 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容。

  • 函数返回一个指向调用对象的引用。

  按照如下编写赋值运算符操作:

Fly& Fly::operator=(const Fly &st) {
	if(this == &st){
        return *this;
    }
	detele []str;
	len = st.len;
	str = new char[len + 1];
	std::strcpy(str, st.str);
	return *this;
}

  代码首先检查自我复制,这是通过查看赋值操作符右边的地址 (&st) 是否与接收对象 (this) 的地址相同来完成的,如果相同,程序将返回*this,然后结束。

  如果地址不同,函数将释放调用者对象中的成员变量 str 指向的内存,这是因为稍后将把一个新字符串的地址赋给 str 。如果不首先使用 delete 操作符,则上述字符串将保留在内存中。由于程序程序不再包含指向字符串的指针,因此这些内存被浪费掉(内存泄漏)。

  接下来的操作与拷贝构造函数相似,即为新字符串分配足够的内存空间,然后复制字符串。

上述操作完成后,程序将返回*this并结束。

  具体的说,该方法应完成这些操作

(1)检查自我赋值情况

(2)释放成员指针以前指向的内存

(3)复制数据而不仅仅是数据的地址

(4)返回一个指向调用对象的引用