Chapter 移步现代c++之特殊成员函数
Item 17: Understand special member function generation
总结:
有必要了解各个函数什么时候自动生成;自动生成的函数有可能产生预期外的行为;
特殊成员函数(编译器自动生成):默认构造函数,析构函数,拷贝构造函数,拷贝赋值运算符,移动构造函数,移动赋值运算符;
默认构造函数:仅当类不存在用户声明的构造函数时才自动生成;
拷贝构造函数:仅当类没有显式声明拷贝构造函数时才自动生成,并且如果用户声明了移动操作,拷贝构造就是delete;
拷贝赋值运算符:仅当类没有显式声明拷贝赋值运算符时才自动生成,并且如果用户声明了移动操作,拷贝赋值运算符就是delete;
移动操作:仅当类没有显式声明移动操作,拷贝操作,析构函数时才自动生成;
c++11的三五法则:用户自定义了析构函数的类必须定义拷贝构造函数和拷贝赋值运算符;尽量定义移动构造函数和移动赋值运算符;
理解特殊成员函数的生成(也就是你自己没有声明的但是c++编译器会帮你自动生成的成员函数)。
c++98有四个这样的函数:默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符。注意:这几个函数只有在需要的时候才会被生成,比如你在代码中使用了这几个函数但是类中没有明确声明。
c++11添加了两个函数:移动构造函数、移动赋值运算符。
移动操作被编译器自动生成后,执行的动作是:对类的non-static数据成员逐成员移动,但其实这个移动不一定会确实发生,因为对于不可移动类型执行的还是拷贝操作,所以确切来说,自动生成的移动构造函数和移动赋值运算符执行的操作是对类的non-static数据成员逐成员移动,支持移动的就移动,不支持移动的就拷贝。
编译器生成拷贝操作和移动操作的区别:
- 两个拷贝函数是独立的。即:你只声明了其中的一个拷贝函数,但是在代码中有使用另一个拷贝函数,那编译器也会自动为你生成这个拷贝函数;(c++98和c++11中都是这样的规则)
- 两个移动函数不是独立的。即:你只声明了其中的一个移动函数,编译器不管你在代码中是否有使用另一个,也不会再帮你生成了;
- 声明拷贝不会生成移动。即:如果一个类显示声明了拷贝函数(构造或赋值),编译器就不会自动生成移动函数了;
- 声明移动不会生成拷贝。如果一个类显示声明了移动函数(构造或赋值),编译器就不会自动生成拷贝函数了;
1. 两个拷贝函数是独立的
class Sample {
public:
Sample() : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = i * i;
}
printf("Default Constructor Called, Address %p \n", this);
}
Sample(const Sample& other) : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = other.p[i];
}
printf("Copy Constructor, Address %p \n", this);
}
~Sample() {
printf("Deconstructor Called, Address %p \n", this);
delete[] p;
}
public:
int* p;
};
int main() {
Sample s1;
Sample s2{s1};
Sample s3;
s3 = s2;
printf("s1.p %p\n", s1.p);
printf("s2.p %p\n", s2.p);
printf("s3.p %p\n", s3.p);
}
代码的输出如下,当然这个代码存在一个问题(后面讲述),这里主要看下两个拷贝函数是如何自动生成的。
Default Constructor Called, Address 0x7ff7b0146338 // 构造s1调用默认构造函数 Sample s1; s1地址 0x7ff7b0146338
Copy Constructor, Address 0x7ff7b0146330 // 构造s2调用拷贝构造函数 Sample s2{s1}; s2地址 0x7ff7b0146330
Default Constructor Called, Address 0x7ff7b0146318 // 构造s3调用默认构造函数 Sample s3; s3地址 0x7ff7b0146318
s1.p 0x7f94fe706000
s2.p 0x7f94fe705f80
s3.p 0x7f94fe705f80 // s3的p地址和s2的p地址一样,说明这里还是调用了拷贝赋值运算符 s3 = s2;
Deconstructor Called, Address 0x7ff7b0146318 // 析构s3
Deconstructor Called, Address 0x7ff7b0146330 // 析构s2
base1(88042,0x7ff85025db80) malloc: Double free of object 0x7fcc5ff05f80 // 析构s2的时候出现了问题,原因是在s3 = s2
base1(88042,0x7ff85025db80) malloc: *** set a breakpoint in malloc_error_break to debug
接下来看下这个代码的问题base1(88042,0x7ff85025db80) malloc: Double free of object 0x7fcc5ff05f80
,object 0x7fcc5ff05f80
这个地址的对象被free
了两次,为啥会这样呢?
主要还是编译器自动生成的拷贝函数执行的是逐成员拷贝操作,s3 = s2;
这行代码调用编译器自动生成的拷贝赋值运算符时,会生成类似这样的代码:
Sample& operator=(const Sample& other) {
if (this != &other) {
this->p = other.p;
}
return *this;
}
这样的话s3的p和s2的p指向了同一份内容,那在析构完s2再析构s3就会出现析构两次的问题了。所以说后面会讲述一个原则:我们自己声明定义任意的一个拷贝函数后,另一个建议也同时自定义。
需要我们再次添加一个拷贝赋值运算符的代码,
Sample& operator=(const Sample& other) {
if (this != &other) {
int* new_p = new int[10];
for (int i = 0; i < 10; ++i) {
new_p[i] = other.p[i];
}
delete[] p;
p = new_p;
}
printf("Copy Assignment, Address %p \n", this);
return *this;
}
2. 两个移动函数不是独立的
如果你给类声明了一个移动构造函数,就表明对于移动操作你有自己的具体实现,那这个与编译器应生成的默认逐成员移动有些区别。如果逐成员移动构造有问题,那么逐成员移动赋值同样也可能有问题。所以声明移动构造函数阻止移动赋值运算符的生成,声明移动赋值运算符同样阻止编译器生成移动构造函数。
具体的理解可以借鉴上面1中的解释,逐成员操作可能会出现问题。
lass Sample {
public:
Sample() : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = i * i;
}
printf("Default Constructor Called, Address %p \n", this);
}
Sample(const Sample& other) : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = other.p[i];
}
printf("Copy Constructor, Address %p \n", this);
}
Sample& operator=(const Sample& other) {
if (this != &other) {
int* new_p = new int[10];
for (int i = 0; i < 10; ++i) {
new_p[i] = other.p[i];
}
delete[] p;
p = new_p;
}
printf("Copy Assignment, Address %p \n", this);
return *this;
}
Sample(Sample&& other) {
p = other.p;
other.p = nullptr;
printf("Move Constructor Called, Address %p \n", this);
}
~Sample() {
printf("Deconstructor Called, Address %p \n", this);
delete[] p;
}
public:
int* p;
};
int main() {
Sample s1;
printf("s1.p %p\n", s1.p);
Sample s2{std::move(s1)};
printf("s1.p %p\n", s1.p);
printf("s2.p %p\n", s2.p);
Sample s3;
s3 = std::move(s2);
printf("s3.p %p\n", s3.p);
}
代码的输出如下,可以看到代码中虽然调用了移动赋值运算符s3 = std::move(s2);
的操作,但是输出的还是Copy Assignment, Address 0x7ff7bc3dd318
,也就是调用的依旧是拷贝赋值运算符,编译器并没有为我们自动生成移动赋值运算符的函数。
Default Constructor Called, Address 0x7ff7bc3dd338 // Sample s1;
s1.p 0x7fb5b8706000
Move Constructor Called, Address 0x7ff7bc3dd320 // Sample s2{std::move(s1)};
s1.p 0x0
s2.p 0x7fb5b8706000
Default Constructor Called, Address 0x7ff7bc3dd318 // Sample s3;
Copy Assignment, Address 0x7ff7bc3dd318 // s3 = std::move(s2);
s3.p 0x7fb5b8705f00
Deconstructor Called, Address 0x7ff7bc3dd318
Deconstructor Called, Address 0x7ff7bc3dd320
Deconstructor Called, Address 0x7ff7bc3dd338
3. 声明拷贝不会生成移动
如果声明拷贝函数(构造或者赋值)就表示一般的拷贝对象的方法(逐成员拷贝)不适用于该类,编译器会明白如果逐成员拷贝对拷贝操作来说不合适,逐成员移动也可能对移动操作来说不合适。
class Sample {
public:
Sample() : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = i * i;
}
printf("Default Constructor Called, Address %p \n", this);
}
Sample(const Sample& other) : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = other.p[i];
}
printf("Copy Constructor, Address %p \n", this);
}
Sample& operator=(const Sample& other) {
if (this != &other) {
int* new_p = new int[10];
for (int i = 0; i < 10; ++i) {
new_p[i] = other.p[i];
}
delete[] p;
p = new_p;
}
printf("Copy Assignment, Address %p \n", this);
return *this;
}
// Sample(Sample&& other) {
// p = other.p;
// other.p = nullptr;
// printf("Move Constructor Called, Address %p \n", this);
// }
// Sample& operator=(Sample&& other) {
// if (this != &other) {
// delete[] p;
// p = other.p;
// other.p = nullptr;
// }
// printf("Move Assignment Called, Address %p \n", this);
// return *this;
// }
~Sample() {
printf("Deconstructor Called, Address %p \n", this);
delete[] p;
}
public:
int* p;
};
int main() {
Sample s1;
Sample s2{std::move(s1)};
Sample s3;
s3 = std::move(s2);
}
在代码中我们注释掉了移动函数,具体输出如下所示,可以看到虽然在代码中调用Sample s2{std::move(s1)};
和s3 = std::move(s2);
计划去调用移动函数,但是程序的输出还是调用的是拷贝函数,这就说明了我们在代码中声明了拷贝函数之后编译器也就不会为我们自动生成移动函数了,而且即使使用std::move
计划去调用移动操作,其实实际调用的还是拷贝操作。
Default Constructor Called, Address 0x7ff7be439338
Copy Constructor, Address 0x7ff7be439330
Default Constructor Called, Address 0x7ff7be439318
Copy Assignment, Address 0x7ff7be439318
Deconstructor Called, Address 0x7ff7be439318
Deconstructor Called, Address 0x7ff7be439330
Deconstructor Called, Address 0x7ff7be439338
4. 声明移动不会生成拷贝
这个同第3点类似,我们直接看代码吧,下面的代码把拷贝函数都注释掉了。那在程序实际调用该两个函数的时候,可以看到编译器有告警,提示我们并没有声明和定义这两个函数。
class Sample {
public:
Sample() : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = i * i;
}
printf("Default Constructor Called, Address %p \n", this);
}
// Sample(const Sample& other) : p(new int[10]) {
// for (int i = 0; i < 10; ++i) {
// p[i] = other.p[i];
// }
// printf("Copy Constructor, Address %p \n", this);
// }
// Sample& operator=(const Sample& other) {
// if (this != &other) {
// int* new_p = new int[10];
// for (int i = 0; i < 10; ++i) {
// new_p[i] = other.p[i];
// }
// delete[] p;
// p = new_p;
// }
// printf("Copy Assignment, Address %p \n", this);
// return *this;
// }
Sample(Sample&& other) {
p = other.p;
other.p = nullptr;
printf("Move Constructor Called, Address %p \n", this);
}
Sample& operator=(Sample&& other) {
if (this != &other) {
delete[] p;
p = other.p;
other.p = nullptr;
}
printf("Move Assignment Called, Address %p \n", this);
return *this;
}
~Sample() {
printf("Deconstructor Called, Address %p \n", this);
delete[] p;
}
public:
int* p;
};
int main() {
Sample s1;
// 编译器告警:无法引用 函数 "Sample::Sample(const Sample &)" (已隐式声明) -- 它是已删除的函数
Sample s2{s1};
Sample s3;
// 编译器告警:无法引用 函数 "Sample::operator=(const Sample &)" (已隐式声明) -- 它是已删除的函数
s3 = s1;
}
三五法则
在C++中,三五法则(也称为rule-of-three/five)是关于如何正确管理资源(如内存、文件句柄、网络连接等)的一条最佳实践准则。这个法则随着C++11的引入而从三规则变成了五规则。
rule-of-three
在C++11之前,如果你的类需要自定义以下三个特殊成员函数中的任何一个,那么你通常也需要自定义另外两个:
- 析构函数(Destructor)
- 拷贝构造函数(Copy Constructor)
- 拷贝赋值运算符(Copy Assignment Operator)
这是因为如果你需要自定义析构函数来管理资源,那么你很可能也需要自定义拷贝构造函数和拷贝赋值运算符来正确地复制这些资源,防止例如双重释放等问题。
rule-of-five
C++11引入了移动语义和右值引用,这意味着除了上述三个函数,还有两个函数也可能需要自定义:
- 移动构造函数(Move Constructor)
- 移动赋值运算符(Move Assignment Operator)
如果你的类管理的资源可以被“移动”(例如,通过转移指针的所有权而不是复制整个数据),那么你应该提供移动构造函数和移动赋值运算符。这样可以提高效率,特别是在涉及到临时对象或大量数据转移的情况下。
所以,如果你自定义了析构函数,为了确保类的行为正确,你可能需要考虑实现或删除(通过显式声明为= delete)这五个特殊成员函数中的其他函数。这样做可以确保对象的拷贝和移动操作是安全的,不会导致资源泄露或其他错误。
成员函数模板不会阻止特殊成员函数的自动生成
即使我们定义的类中有类似拷贝构造函数和拷贝赋值运算符的模板函数,编译器依旧会为我们自动生成特殊成员函数。
下面的代码中,可以看到我们定义了两个类拷贝操作Sample(const T& other)
和Sample& operator=(const T& other)
,但是从程序的输出s1
和s2
的地址一样,s1.p
和s2.p
的地址一样,不难看出其实也是执行了复制的操作的。也就是说编译器还是自动生成了拷贝构造函数和拷贝赋值运算符。
class Sample {
public:
Sample() : p(new int[10]) {
for (int i = 0; i < 10; ++i) {
p[i] = i * i;
}
printf("Default Constructor Called, Address %p \n", this);
}
template <typename T>
Sample(const T& other) {}
template <typename T>
Sample& operator=(const T& other) {}
public:
int* p;
};
int main() {
Sample s1;
Sample s2{s1};
printf("s1 addr %p\n", &s1);
printf("s2 addr %p\n", &s2);
printf("s1.p addr %p\n", s1.p);
printf("s2.p addr %p\n", s2.p);
}
上述代码的输出是
Default Constructor Called, Address 0x7ff7b48ed338
s1 addr 0x7ff7b48ed338
s2 addr 0x7ff7b48ed330
s1.p addr 0x7faa53f06000
s2.p addr 0x7faa53f06000
标签:int,s2,C++,Sample,填坑,other,Address,EffectiveModern,拷贝
From: https://www.cnblogs.com/pplearn/p/18048632