注意:
构造函数是指没有返回值的函数,而重载赋值 和 重载移动赋值都有返回值,因此严格意义上说不属于构造函数,因此下文中使用 “重载赋值” 来代替 “赋值构造” ,“重载移动赋值” 来代替 “移动赋值构造”。市面上很多书都是用 “赋值构造” 和 “移动赋值构造” 这样的字眼,我无法断言是否准确,因为我没有去研究过编译器。
个人认为之所以很多书中使用了 “赋值构造” 和 “移动赋值构造” 这样的带有 “构造” 二字的字眼,主要是因为这两个函数在行为上触发了 “拷贝构造” 和 “移动构造”,在[c++] explicit的用法_linux c++ explicit-CSDN博客 一文中有描写,重载赋值其实是两步动作:1)通过拷贝构造创建临时对象;2)调用重载赋值函数进行相关动作。
我们也可以通过编写测试代码来进行验证:
1)如果定义了拷贝构造,那么移动构造会被删除,反之亦然。
2)如果定义了赋值重载,拷贝构造不会被删除,说明编译器没有把这二者当作类似的内容对待。
3)如果定义了赋值重载,但是把拷贝构造定义为=delete,那么编译不通过,因为赋值重载以来拷贝构造。
4)鉴于3),我们不显式的通过=delete删除拷贝构造,而是只定义移动构造,那么会发现赋值重载同样无法通过编译,这验证了拷贝构造和移动构造在编译器看来是同一类。
前言:
c++编译器为我们做了很多默认动作,这其中非常重要的一部分就是关于构造函数的。
默认构造函数:
默认构造函数是指 没有参数的构造函数,如果一个构造函数都没有定义,那么编译器会为我们创建默认构造函数,这个构造函数什么都不做。这个由编译器为我们创建的构造函数成为 合成的默认构造函数。
ps:所有由编译器为我们创建的构造函数都叫做 合成的 xxx 构造函数。
拷贝构造函数:
原型:A(const A& a)
- 拷贝构造函数尽量不要被声明为explicit,因为很多时候拷贝构造都是隐式调用的,比如类作为函数参数进行传值调用;除非使用者知道自己在做什么!
- 编译器始终会为类创建 合成的拷贝构造函数,不论当前是不是有其他构造函数,这点有别于默认构造函数;
- 关闭拷贝构造函数的两个方法:1)使用 =delete;2)定义移动拷贝,而不定义拷贝构造;
- 让拷贝构造函数为自定义行为 或者 为空白动作 的唯一方法就是显示声明和定义拷贝构造函数。
编译器生成的拷贝构造函数行为:大部分情况下,编译器生成的拷贝构造函数会将 非static成员变量挨个拷贝构造给新的对象。如果是基础类型,则直接赋值,如果是类类型,则调用类类型的拷贝构造。
注:如果成员变量中有类类型(不是指针),那么就要求这个成员变量的类类型具备拷贝构造函数,例子如下:
#include "pch.h"
#include <iostream>
class A {
public:
A()=default;
//A(const A& a) = delete; //如果放开,则编译不通过
A& operator=(const A& a) = delete; //禁用赋值构造不会影响 B b2(b1);这行,因为此行调用拷贝构造而不是赋值构造
};
class B {
public:
int i;
A a;
};
int main()
{
B b1;
b1.i = 10;
B b2(b1);
std::cout << "Hello World!\n";
}
拷贝赋值函数:
原型:A& operator=(const A& a)
所有特性类比拷贝构造函数的四点。
注:如果成员变量中有类类型(不是指针),那么就要求这个成员变量的类类型具备拷贝构造函数,注意,这里同样要求是具备拷贝构造,而不是要求具备赋值构造,可见编译器的默认动作都是拷贝,而不是赋值。例子如下:
#include "pch.h"
#include <iostream>
class A {
public:
A()=default;
//A(const A& a) = delete; //如果放开,则编译不通过,赋值构造在进行类类型的成员变量拷贝赋值时使用的拷贝构造,而不是赋值构造
A& operator=(const A& a) = delete; //禁用赋值构造,不会影响B b2 = b1;这条语句,因为编译器在底层使用的是拷贝构造完成成员变量的拷贝赋值
};
class B {
public:
int i;
A a;
};
int main()
{
B b1;
b1.i = 10;
B b2 = b1;
std::cout << "Hello World!\n";
}
赋值构造函数的固定写法:
A& A::operator=(const A& a){
... //成员变量挨个赋值 ,以及其他的一些想要附加的动作
return *this; //(!)把指向自己的指针this的值返回,即就是自己的引用
}
//注:在赋值运算符中,切记不可对static成员变量进行赋值,即上面的 ... 中不能对
//static成员做赋值,一是没意义,二是可能编译不通过,三是即便通过运行起来可能会
//有问题
移动构造函数:
原型:A(A&& a)
移动赋值函数:
原型:A& operator=(A&& a)
上述五个函数之间的关联:
正如本文开篇所属,编译器为我们做了很多隐藏的动作,如果我们搞不明白这些动作的运行机制,那么在开发的时候往往就是,丈二和尚摸不着头脑,遇到问题也是一脸懵逼。下面就来梳理一下这些默认动作的相互关联:
- 如果用户没有定义任何构造函数,那么编译器为我们生成默认构造函数,如果自行定义了随便什么样的构造函数,那么编译器就不会为我们生成默认构造函数。假如我们也没有自行定义默认构造函数,那么所有使用此类默认构造函数的地方都将编译不通过。
- 拷贝构造,重载赋值,移动拷贝构造,重载移动赋值,这四个函数编译器会为我们自动生成,除非发生如下情况:
- 一旦自行定义了拷贝构造,那么移动构造也需要显示定义,同理如果定义了移动构造,那么拷贝构造也要显示定义,因为编译器会将另一个置为 =delete;
- 编译器创建默认的 “移动拷贝构造” 和 “重载移动赋值” 要满足如下条件:所有非 static 都是可移动的;
三/五法则:
三:拷贝构造,重载赋值,析构函数,如果其中一个定义了,那么其他两个也要定义
五:拷贝构造,重载赋值,析构函数,移动拷贝构造,重载移动赋值,如果其中一个定义了,那么其他四个也要定义
五 是对 三 的 扩展
案例:
案例一:
#include "stdafx.h"
#include <vector>
#include <algorithm>
using namespace std;
class A{
public:
A()=default;
~A()=default;
A(const A&& tmp){
this->vec = tmp.vec;
}
public:
vector<int> *vec;
public:
void setvec(){
vec = new vector<int>(10, 100);
}
};
int _tmain(int argc, _TCHAR* argv[])
{
A a1;
a1.setvec();
printf("a1 -> %p\n", a1.vec);
A a2(move(a1)); //这里用std::move来把类实例转换成右值引用
printf("a1 -> %p\n", a1.vec);
printf("a2 -> %p\n", a2.vec);
getchar();
return 0;
}
输出: a1 -> 00495CA8
a1 -> 00495CA8 //只要未进行手动释放,那么原来的指针还是原来的位置
a2 -> 00495CA8 //并没有分配新内存,而是直接指过去
案例二:
#include "stdafx.h"
#include <vector>
#include <algorithm>
using namespace std;
class A{
public:
A()=default;
~A()=default;
A(A&& tmp){ //为了能释放源对象资源,这里不使用const
this->vec = tmp.vec;
tmp.vec = nullptr; //手动释放
}
public:
vector<int> *vec;
public:
void setvec(){
vec = new vector<int>(10, 100);
}
};
int _tmain(int argc, _TCHAR* argv[])
{
A a1;
a1.setvec();
printf("a1 -> %p\n", a1.vec);
A a2(move(a1)); //这里用std::move来把类实例转换成右值引用
printf("a1 -> %p\n", a1.vec);
printf("a2 -> %p\n", a2.vec);
getchar();
return 0;
}
输出: a1 -> 00495CA8
a1 -> 00000000 //原对象的资源指针已经被释放
a2 -> 00495CA8 //并没有分配新内存,而是直接指过去
其他:
拷贝行为 和 移动行为
如果一个类没有定义移动行为(移动拷贝构造和移动赋值构造),但是定义了拷贝行为(拷贝构造和赋值构造),那么试图通过move调用移动行为时不会成功,取而代之,会只用拷贝行为作为替代。
A a1,a2;
a1 = move(a2); //如果没有移动赋值,那么这句话会被编译器翻译为 a1 = a2;
A a3(move(a1)); //如果没有移动拷贝,那么这句话会被编译器翻译为 a3(a1);
左值和右值
左值:可以被赋值的值,指内存中一块区域的代名词,即变量名
右值:不可以被赋值的值,是一个运算结果,不对应内存中的任何一块区域,是一个表达式
如果我们现在已经有一个左值,那么怎么把左值的实际值作为常量赋给右值引用呢???
int i = 100;
int &lr_i = i;
int &&rr_i = i; //错误
int &&rr_i = std::move(i); //正确
int &&rr_i = std::move(lr_i); //正确我们可以使用move函数(c++11)来实现一个动作 “把左值里的值提取来,同时把左值对应的内存销毁, 然后
把左值的值作为右值赋值给右值引用“ ,注意这里的 “提取出来”,我们可以理解 move 动作是 “把指定内存
单元中的值提出来作为常量” ,这样就方便理解为什么是右值了。
move的伴随动作时销毁左值对应的变量。即一旦调用move,下文将无法再访问变量(实际上可以,为什么???)