本节课要点:
- 浅复制
- 深复制
- 复制控制
- 复制构造函数
- 转移对象和转移语义
- 转移复制构造函数
- 运算符函数
- 转移赋值运算符函数
- 禁止复制
- 类的const成员
基于对双向链表的实现。
目录
复制控制
1. 复制和赋值
2. 复制构造函数
3. 浅复制和深复制
4. 转移对象和转移语义
5. 赋值运算符函数
6. 禁止复制
7. 类的const成员
main.cpp
//main.cpp
#include
#include "dlist.h"
void print(value_type& v) { //type: void (value_type&)
std::cout << v << std::endl;
}
dlist f(dlist k) { //值参数;传值调用 k是l的副本
k.push_back(7);
return k; //返回一个右值对象;产生一个匿名对象,将k的值赋给匿名对象
}
//expiring 返回一个将亡对象;即匿名对象失效后立即被析构
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
f(l);
return 0;
}
运行报错:
in dlist()
1
in ~dlist()
=================================================================
==5529==ERROR: AddressSanitizer: attempting free on address which was not malloc()-ed: 0x7ffd09382a88 in thread T0
...
不使用内存消毒器,再次运行报错:
in dlist()
1
in ~dlist()
free(): invalid pointer
已放弃 (核心已转储)
不使用内存消毒器的方法:
Makefile
source = *.cpp # 偷懒 target = dlist CXX = g++ CXXFLAG = -Wall -g -std=c++23 ASAN = -fsanitize=address # 新变量 LIB = all: $(CXX) $(source) $(CXXFLAG) $(ASAN) -o $(target) $(LIB) clean: rm $(target)
终端输入:
make ASAN=
在命令行里给出的变量值将会覆盖掉Makefile里给的变量值。
注释掉析构函数中的 _destroy(),再次运行:
in dlist()
1
in ~dlist()
0
in ~dlist()
18446744073709551615
in ~dlist()
18446744073709551614
18446744073709551615 —— 64位-1的补码
18446744073709551614 —— 64位-2的补码
分析:
我们手工编码的构造函数只被调用了一次,但是析构函数被调用了三次 —— 编译器默默地为类合成了一个构造函数。这个构造函数的参数是链表 l,而其功能是将参数对象的数据成员复制到目标对象的对应成员中。
这种带有类自身类型参数的构造函数称为复制构造函数。如果它是编译器合成的,那么也称为合成复制构造函数。
合成复制构造函数的功能是将参数对象的数据成员复制到目标对象的对应成员中,从而导致两个链表共享了一个资源。而带来灾难性结果的原因正是这种共享!
复制
常见的复制主要发生在以下场合:
(1) 对象初始化时
dlist l2{l1}; // 直接初始化
dlist l3 = l1; // 复制初始化
(2)函数的参数是非指针、非引用的值对象
当实参对象向形参对象传递值时,实参对象被复制到形参对象。
dlist f(dlist k) {
k.push_back(7);
return k;
}
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
f(l); // 实参对象l将会向形参对象k传递值
return 0;
}
(3)函数返回的是非指针、非引用的值对象
dlist f(dlist k) {
k.push_back(7);
return k; // 返回时将产生一个匿名对象,将k的值赋给匿名对象
}
这个匿名对象是一个临时对象,也是一个右值对象。
赋值
赋值操作实际上也是一种复制。
struct X {int a; double b;};
X o1{1, 2.3}; // 定义对象并初始化
X o2{o1}; // 初始化,o2是o1的复制品,等价于X o2 = o1 —— 构造时复制
X o3;
o3 = o1; // 赋值,同样也是复制 —— 运行时赋值
无论是构造时的复制还是运行时的赋值,其实它们都是内存的对拷。
显式定义的复制构造函数的语法形式为:
类名(const 类名 &);
说明:
由前面对复制的介绍可知,实参和形参结合时要调用类的复制构造函数。因此,如果复制构造函数的参数也是值参数的话,那么实参和形参结合就要调用复制构造函数自身了,从而形成无休止的递归调用。
因为引用参数传递的是对象本身,所以不会引起任何构造函数的调用,从而避免了递归的发生!
浅复制
dlist(const dlist & l) : head(l.head), tail(l.tail), length(l.length) {
std::cout << "in copy dlist()" << std::endl;
std::cout << ++count << std::endl;
}
运行报错:
in dlist()
1
in copy dlist()
2
in copy dlist()
3
in ~dlist()
2
in ~dlist()
1
in ~dlist()
0
=================================================================
==4106==ERROR: LeakSanitizer: detected memory leaks
...
可以看到,显式定义的复制构造函数起了作用,但其功能与合成复制构造函数没有本质上的区别,因此没有从根本上解决数据共享带来的问题。
在复制时,试图使两个对象共享相同资源的模式称为 浅复制。
深复制
为了避免浅复制带来的问题,就应该将被复制对象的数据成员连带资源统统复制一遍,这就是 深复制 的思想。
dlist(const dlist & l) : dlist() { // 委托
std::cout << "in copy dlist()" << std::endl;
// 使用工作指针把实参链表中的数据全部打入新链表中
for (auto p = l.head.next; p != &l.tail; p = p->next)
push_back(p->data);
}
运行正常:
in dlist()
1
in dlist()
2
in copy dlist()
in dlist()
3
in copy dlist()
in ~dlist()
2
in ~dlist()
1
in ~dlist()
0
考察如下代码:
dlist f(dlist k) {
k.push_back(7);
return k;
}
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
auto h = f(l); // 构造时复制
return 0;
}
对象h 是 函数f() 返回的 局部对象k 的复制品,具体过程如下:
- k 被复制到一个临时匿名对象中。为了方便描述,不妨将其命名为 x。这个复制是通过调用 x 的复制构造函数完成的。此后 k 失效,其拥有的结点被释放。
- x 被复制到 h 中,h的复制构造函数被调用。此后 x 失效,其拥有的结点被释放。
这个过程显然是可以被优化的:既然 x 马上就要失效了,那么是否可以省掉第二个复制操作,直接将其结点转移给 h 呢?
C++ 用转移语义解决了这个问题。
转移复制构造函数
临时对象 x 被称为转移对象,具有可转移属性,被标记为一个右值引用。
dlist(dlist && l) : head(l.head), tail(l.tail), length(l.length) {
std::cout << "in move copy dlist()" << std::endl;
// 把l的资源链到新链表上 —— 新链表全面接管l的资源
l.head.next->prior = &head;
l.tail.prior->next = &tail;
// 将l初始化
l._init();
}
main.cpp
int main() {
dlist l{1, 2, 3, 4, 5, 6};
f(std::move(l)); // 把l伪装成一个右值对象
return 0;
}
运行结果:
in dlist()
1
in move copy dlist()
2
in move copy dlist()
3
in ~dlist()
2
in ~dlist()
1
in ~dlist()
0
工作原理1:
工作原理2:
由前面关于赋值的介绍可知,赋值完成的也是数据成员的逐一复制。为了避免“共享资源”的发生,我们也应该为复杂类显式定义赋值操作。
在C++中,赋值运算符=被视为是一种函数,称为运算符函数,并且能被重载,其语法形式为:
T& T::operator=(const T& rhs);
与类的复制构造函数类似,如果类没有显式重载的赋值运算符函数,则编译器会为其合成一个默认的赋值运算符函数,其行为与聚集类型的赋值是相同的,即逐一复制数据成员。
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
dlist k{7, 8, 9};
// 无法引用 函数 "dlist::operator=(const dlist &)" (已隐式声明) -- 它是已删除的函数
k = l;
return 0;
}
编译报错:
g++ *.cpp -Wall -g -std=c++23 -fsanitize=address -o dlist
main.cpp: In function ‘int main()’:
main.cpp:21:9: error: use of deleted function ‘constexpr dlist& dlist::operator=(const dlist&)’
21 | k = l;
| ^
In file included from main.cpp:5:
dlist2.h:10:7: note: ‘constexpr dlist& dlist::operator=(const dlist&)’ is implicitly declared as deleted because ‘dlist’ declares a move constructor or move assignment operator
10 | class dlist {
| ^~~~~
make: *** [Makefile:11:all] 错误 1
赋值运算符函数
dlist& operator=(const dlist & l) {
_destroy(); // 先释放当前资源
_init(); // 初始化
std::cout << "in =()" << std::endl;
for (auto p = l.head.next; p != &l.tail; p = p->next)
push_back(p->data);
return *this;
}
赋值运算符函数与复制构造函数的区别:
复制发生在对象的初始化时,此时只有右操作对象存在,而左操作对象正在被创建。赋值则不同,在赋值时,赋值号左右的两个对象都已经存在了,也就是说,左右操作对象都可能拥有了各自的资源。因此,在完成赋值之前,必须先释放左操作对象的原有资源。
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
dlist k{7, 8, 9};
k = l;
return 0;
}
运行结果:
in dlist()
in dlist()
in =()
in ~dlist()
in ~dlist()
转移赋值运算符函数
在赋值运算中,如果右操作对象是个转移对象,那么使用转移语义也会提高运行效率。
dlist& operator=(dlist && l) {
std::cout << "in move =()" << std::endl;
//完成四个指针的交换
std::swap(head.next, l.head.next);
std::swap(head.next->prior, l.head.next->prior);
std::swap(tail.prior, l.tail.prior);
std::swap(tail.prior->next, l.tail.prior->next);
std::swap(length, l.length);
return *this;
}
这是一种经济型做法 —— 交换两者的资源。被赋值对象拥有了转移对象的资源,其原有的资源将在转移对象失效时释放。
示意图:
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
dlist k{7, 8, 9};
k = std::move(l);
k.traverse(print);
return 0;
}
运行结果:
in dlist()
in dlist()
in =()
1
2
3
4
5
6
in ~dlist()
in ~dlist()
dlist(const dlist&) = delete; // 删除所有的复制构造函数
int main() {
const dlist l{1, 2, 3, 4, 5, 6};
std::cout << l.size() << std::endl; // 报错。
return 0;
}
因为 成员函数size() 是无约束的,在其实现代码中有潜在的修改某些数据成员的可能,所以会引发编译错误。
因此,必须显式地告诉编译器,在 size() 的实现中,只会以只读的方式访问成员,而不会去改写成员。这可以通过指明其是 const成员函数 完成。
size_t size() const {
return this->length;
}
此时,size() 的 this 指针也被隐式地说明成为:
const dlist * const this;