C++的拷贝赋值函数

本文详细讲解了C++中的拷贝构造函数和赋值运算符的概念、定义、使用场景、不同类型拷贝的区别以及编译器自动生成或合成的情况。同时讨论了自定义拷贝构造函数和赋值运算符时需要注意的问题,包括深拷贝和析构函数的配合。
摘要由CSDN通过智能技术生成

前言

本篇文章讲解C++的拷贝构造函数和赋值运算符

拷贝构造函数

定义

如果一个构造函数的第一个参数是自身类类型的引用,并且任何额外的参数都有默认值。则此构造函数就是拷贝构造函数

  • 参数必须是自身类类型的引用
    自身类类型好理解,为什么是引用?
    因为如果不是引用的话,在执行拷贝构造函数的时候,需要先对实参进行拷贝,此时执行的也是拷贝构造函数,这样就无限递归循环了,所以,必须是引用
  • 一般而言,拷贝构造函数的第一个参数是const类型
    为什么是const呢?首先拷贝构造函数的意思在于构造和拷贝,一般我们不需要改变原始的对象值。
    另外一个,对于非const类型,是不能传递const对象执行拷贝构造函数的。这样就限制了拷贝构造函数的能力
  • 一般而言,拷贝构造函数不使用explicit的
    因为explicit不允许隐式转换,而使用等号进行初始化的时候编译器会默认判断位发生隐式转换,会报错,所以建议拷贝构造函数不使用explicit的
    看下面的例子:
    int main(int argc, const char* argv[])
    {
    	A a(1);
    	A a3=a;
    	A a4 = {a};
    	std::cout << "拷贝后:" << a3.a << std::endl;
    }
    
    如果A的拷贝构造函数声明为explicit的,上述代码会报错。

使用情况

下面这些情况会调用拷贝构造函数:

  • 将一个对象作为实参传递给非引用类型的形参
  • 函数返回一个非引用类型的对象时
  • 用花括号列表初始化数组或者聚合类成员的时候
  • 使用等号拷贝对象的时候
  • 初始化标准库容器的时候
  • 使用insert或者push给标准库容器添加元素的时候

下面这个例子综合了上述所有情况

#include <iostream>
#include <vector>
class A
{
public:
    int a;
    A() :a(0) { std::cout << "A的默认构造函数执行了" << std::endl; };
    A(int pA) :a(pA) { std::cout << "A的构造函数执行了" << std::endl; }
    A(const A& va) :a(va.a) { std::cout << "A的拷贝构造函数执行了"<<std::endl; };
};
class B
{
public:
    A a;
};

A test(A a);
int main(int argc, const char* argv[])
{
    std::cout << "创建A" << std::endl;
    A a(1);
    std::cout << "传递A非引用实参" << std::endl;
    A a1 = test(a);
    std::cout << "构建A的数组" << std::endl;
    A a2[3]{ a1 };
    std::cout << "初始化聚合类B" << std::endl;
    B b{a1};
    std::cout << "拷贝A" << std::endl;
    A a3 = a1;
    std::cout << "使用A初始化vector" << std::endl;
    std::vector<A> a4{a3};
    std::cout << "通过push_back向vector添加A" << std::endl;
    a4.push_back(a3);
    std::cout << "通过emplace向vector添加A" << std::endl;
    a4.emplace(a4.end(),1);
}
A test(A a)
{
    std::cout << "返回A非引用对象" << std::endl;
    return A(a);
}

运行结果如下:

创建A
A的构造函数执行了
传递A非引用实参
A的拷贝构造函数执行了
返回A非引用对象
A的拷贝构造函数执行了
构建A的数组
A的拷贝构造函数执行了
A的默认构造函数执行了
A的默认构造函数执行了
初始化聚合类B
A的拷贝构造函数执行了
拷贝A
A的拷贝构造函数执行了
使用A初始化vector
A的拷贝构造函数执行了
A的拷贝构造函数执行了
通过push_back向vector添加A
A的拷贝构造函数执行了
A的拷贝构造函数执行了
通过emplace向vector添加A
A的构造函数执行了
A的拷贝构造函数执行了
A的拷贝构造函数执行了

有个地方需要注意,就是使用A初始化vector的时候,这个时候执行了两次拷贝构造函数:

  • 第一次是a3作为参数传递给vector的时候
  • 第二次是vector内部初始化的时候会进行一次拷贝,我们起始着重理解的是这一点
    注意:使用emplace方法创建元素的时候使用的直接初始化
    如果类自己定义了拷贝构造函数,就需要自己负责所有成员常量的赋值,编译器不再进行任何赋值操作

拷贝构造函数的分类

我们对于拷贝构造函数,有三种分类:

  1. 没有拷贝构造函数,编译器也没有合成默认拷贝构造函数,这种情况下,对象也可以使用拷贝构造函数,底层会直接复制内存数据,这种情况相当于进行了全拷贝。
  2. 编译器合成了拷贝构造函数,这个时候,拷贝构造函数会依次拷贝类的非静态成员变量,如果成员是基本类型,直接复制,如果是类类型,调用成员的拷贝构造函数。对于数组,队列,栈或者容器类成员,会依次拷贝他们的成员到新的对象中,如果类存在虚函数表,需要拷贝虚函数表指针。如果类存在虚基类表指针,需要拷贝虚基类指针。也就是说,编译器的合成方法会帮我们做所有我们想做的事情。这个时候的拷贝相当于全自动的,从用户的角度来看起始和第一类是相似的,但是本质不同。当然性能也不一样。
  3. 自己定义拷贝构造函数,这个时候,所有成员变量的拷贝工作由我们自己负责,但是,虚函数指针和虚基类指针的拷贝我们不用管,就像构造函数中我们也没有管这两个指针的赋值一样,编译器会为我们插入相关代码。所以,这种相当于一个半自动的拷贝。一般情况下,我们会对一个类的拷贝进行手动控制,尤其是类中存在手动分配内存的情况下

第一种情况好理解,我们就不介绍了,我们通过一个例子看一下2、3的区别:

#include <iostream>
#include <vector>
class A
{
public:
    int a;
    A() :a(0) { std::cout << "A的默认构造函数执行了" << std::endl; };
    A(int pA) :a(pA) { std::cout << "A的构造函数执行了" << std::endl; }
    A(const A& va) { std::cout << "A的拷贝构造函数执行了"<<std::endl; };
    virtual void print() {};
    virtual ~A() { std::cout << "A的析构函数执行了" << std::endl; };
};
class B
{
public:
    int a;
    B() :a(0) { std::cout << "B的默认构造函数执行了" << std::endl; };
    B(int pA) :a(pA) { std::cout << "B的构造函数执行了" << std::endl; }
    virtual void print() {};
    virtual ~B() { std::cout << "B的析构函数执行了" << std::endl; };
};

A test(A a);
int main(int argc, const char* argv[])
{
    A a(1);
    B b(1);
    A a1 = a;
    B b1 = b;
    std::cout << "a1.a::" <<a1.a<< std::endl;
    std::cout << "b1.a::" << b1.a << std::endl;
}

上面的例子中类A和类B基本一致,唯一的不同就是A定义了自己的拷贝构造函数,类B使用合成的拷贝构造函数,什么时候合成后面会介绍。
看一下打印结果:

A的构造函数执行了
B的构造函数执行了
A的拷贝构造函数执行了
a1.a::-858993460
b1.a::1
B的析构函数执行了
A的析构函数执行了
B的析构函数执行了
A的析构函数执行了

就像我们上面介绍的,A使用自己写的拷贝构造函数,所以不对a的值进行拷贝,需要自己写代码,如果没写,a的值就是为定义的。但是B使用合成的拷贝构造函数,a的值进行了拷贝。

然后我们看一下具体的汇编代码:
这是类A执行拷贝的汇编代码

00007FF667F61E94  mov         rax,qword ptr [this]  
00007FF667F61E9B  lea         rcx,[A::`vftable' (07FF667F6BCF8h)]

这是类B执行拷贝的汇编代码

00007FF667F66AD6  mov         rax,qword ptr [this]  
00007FF667F66ADD  lea         rcx,[B::`vftable' (07FF667F6BC30h)]  
00007FF667F66AE4  mov         qword ptr [rax],rcx  
00007FF667F66AE7  mov         rax,qword ptr [this]  
00007FF667F66AEE  mov         rcx,qword ptr [__that]  
00007FF667F66AF5  mov         ecx,dword ptr [rcx+8]  
00007FF667F66AF8  mov         dword ptr [rax+8],ecx

我们可以很清楚的看到:

  • 类A和类B都给虚函数表指针赋值了
  • 类B给a赋值了,类A没有

总结:如果类的设计者手动添加了拷贝构造函数和拷贝赋值运算符,那么必须为类的成员变量和基类的拷贝赋值负责

什么时候合成拷贝构造函数

编译器生成拷贝构造函数的情况:

  • 如果一个类没有任何拷贝构造函数,但是包含一个类类型的成员变量,并且该成员变量还有一个拷贝构造函数。如果代码中有涉及类的复制构造时,编译器会为当前类生成一个拷贝构造函数,来调用成员变量的拷贝构造函数。
  • 如果一个类没有任何拷贝构造函数,但是父类有一个拷贝构造函数。如果代码中有涉及类的复制构造时,编译器会为当前类生成一个拷贝构造函数,来调用父类的拷贝构造函数。
  • 如果一个类没有任何拷贝构造函数,但是该类含有虚函数,如果代码中有涉及类的复制构造时,编译器会为当前类生成一个拷贝构造函数,来为类对象的虚函数表指针赋值。
  • 如果一个类没有任何拷贝构造函数,但是该类带有虚基类,如果代码中有涉及类的复制构造时,编译器会为当前类生成一个拷贝构造函数,来为类对象的虚函数表指针赋值。

总结:编译器生成拷贝构造函数的前提是类一定没有定义任何拷贝构造函数,并且代码中存在类的复制构造

设置拷贝构造函数为删除

我们通过将拷贝构造函数设置成delete,可以避免当前类发生拷贝工作。对于单例使用的类我们一般都会这样设值。

设置默认拷贝构造函数

我们可以通过在拷贝构造函数的结尾添加default关键字来显式让编译器合成一个拷贝构造函数。这里需要注意两点:

  • 采用这种方式编译器一定会合成一个拷贝构造函数,不考虑是否需要
  • 如果default关键字添加到函数声明的后面,函数将是内联的,如果关键字添加到类外部的函数定义,函数不是内联的

拷贝赋值运算符

定义

我们通过使用

当前对象的引用 operator = (const 拷贝对象的引用)

来定义一个赋值运算符。注意:

  • 如果我们自己定义赋值运算符相当于是一个运算符的重载过程
  • 赋值运算符返回调用者的引用
  • 赋值运算符必须定义到类的内部

赋值和初始化

再次重申一下赋值和初始化的区别:
初始化和赋值的区别就看操作的是不是一个新对象,如果一个新对象被定义,那就是初始化,如果没有新对象定义,那就是赋值。

看下面的代码:

int main(int argc, const char* argv[])
{
    A a(1);
    A a1 = a;
    A a2;
    a2 = a;
}

a1执行的是拷贝构造函数
a2执行的是拷贝赋值运算符

什么时候合成赋值运算符

编译器合成赋值运算符的条件和拷贝构造函数是一致的。
也就是说:

  • 有一个成员有拷贝赋值运算符
  • 有一个父类有拷贝赋值运算符
  • 有虚函数
  • 有虚基类指针

前两个我们好理解,因为要用,第三四条,构造函数的时候我们也好理解,毕竟需要赋值啊,现在拷贝的时候还有用吗?
看下面的例子:

#include <iostream>
#include <vector>
class A
{
public:
    int a;
    A() :a(0) { std::cout << "A的默认构造函数执行了" << std::endl; };
    A(int pA) :a(pA) { std::cout << "A的构造函数执行了" << std::endl; }
    A(const A& va) { std::cout << "A的拷贝构造函数执行了"<<std::endl; };
    virtual void print() {};
    virtual ~A() { std::cout << "A的析构函数执行了" << std::endl; };
};
int main(int argc, const char* argv[])
{
    std::cout << "A:::" << sizeof(A) << std::endl;
    A a(1);
    A a2;
		// 此时执行拷贝赋值运算符
    a2 = a;
}

执行拷贝赋值运算符的时候的汇编代码如下:

// 我们只截取了关键部分
00007FF67A6D24A6  mov         rax,qword ptr [this]  
00007FF67A6D24AD  mov         rcx,qword ptr [__that]  
00007FF67A6D24B4  mov         ecx,dword ptr [rcx+8]  
00007FF67A6D24B7  mov         dword ptr [rax+8],ecx  
00007FF67A6D24BA  mov         rax,qword ptr [this]

我们发现只把that的从第8个字节开始的4字节内容赋值给了this从第8字节开始处,我们知道,前8个字节正是虚函数指针。所以说:
拷贝赋值运算符并没有对虚函数指针进行复制。

拷贝赋值运算符要适应自拷贝

如果对于一个需要在堆上分配空间的类来说,通常我们要实现拷贝赋值运算符,那我们应该怎么写呢?
看下面例子:

class A
{
public:
    int* a;
    A& operator=(const A& va) 
    { 
		if (a)
		{
			delete a;
		}
		a = new int();
		*a = *va.a;
		return *this;
    }
    virtual ~A() 
    {
        if (a)
        {
            delete a;
            a = nullptr;
        }
    };
};

这样写有问题吗?
看着不错,先删除自己这块内存,使用new int() 新开辟一块内存,然后把va的值复制过来,这样就实现了深拷贝。

但是,想象一下,如果我自己拷贝自己呢?
也就是va就是*this,这时候上面代码还能用吗?

很显然,这个时候删除自己这块内存的时候已经把va的a删除了,后面肯定会出现异常,下面是一个优化的写法:

A& operator=(const A& va) 
{ 
    int* temp = a;
    a = new int();
    *a = *va.a;
    if (temp)
    {
        delete temp;
    }
    return *this;
}

一般要自定义析构函数

想想我们为什么需要自定义拷贝构造函数和拷贝赋值运算符,一般情况是下是因为类中存在需要我们深拷贝的成员,比如在堆上手动申请的内存。这种情况下,基本上也需要定义析构函数,因为要释放内存。所以总的来说:

  • 无论是拷贝构造函数还是赋值运算符,不要漏掉成员,可以是赋值,也可能是初始化,此为收
  • 如果有需要手动释放的内存对象,一定记得在析构函数释放,此为放

收放自如,张弛有度!!!

  • 74
    点赞
  • 43
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值