$ cat ~ / posts /c++ /oop4 4k Words ~ 16 Mins
cover.png
面向对象程序设计04

#面向对象程序设计04

exdoubled Lv5

浅复制的问题

先看一个资源类:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Buffer {
public:
explicit Buffer(size_t size)
: data_(new char[size]), size_(size) {}

~Buffer() {
delete[] data_;
}

private:
char* data_;
size_t size_;
};

如果直接写:

1
2
Buffer a(1024);
Buffer b = a;

在没有自定义复制构造函数时,编译器会生成默认复制构造,默认复制是逐成员复制:

  • data_ 复制指针值
  • size_ 复制整数值

结果是 a.data_ == b.data_,两个对象指向同一块堆内存

它们析构时都会执行 delete[] data_,从而发生双重释放

这就是浅复制:复制了指针值,却没有复制指针背后的资源

对资源拥有者来说,浅复制通常是错误的

浅复制、深复制与移动

三种策略可以这样区分:

策略本质结果复杂度
浅复制复制成员值,指针只复制地址共享同一资源,资源类上常常错误O(1)
深复制新建资源并复制内容两个对象独立存在O(N)
移动转移资源所有权目标接管资源,源对象被置为空壳O(1)

深复制的目标是兑现“复制后独立存在”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Buffer {
Buffer& operator=(const Buffer& other) {
if (this != &other) { // 自赋值检查
delete[] data_; // 释放旧资源

size_ = other.size_;
data_ = new char[size_];
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}
};

Buffer a(1024);
// = 重载了复制构造函数和复制赋值运算符,执行深复制
Buffer b = a; // b 拥有自己的 1024 字节内存

此时 ab 的内容相同,但各自拥有独立资源

修改 b 不影响 a,析构时也各自释放自己的内存

自赋值检查是为了防止 b = b 这种情况,如果不检查,可能先释放 b 的内存,再从已经释放的内存复制,导致未定义行为

移动则不是复制,而是接管:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Buffer {
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
}
return *this;
}
}

Buffer makeBuffer() {
return Buffer(1024);
}

Buffer b = makeBuffer();

临时对象马上就要销毁,重新分配再复制内容没有意义

更合理的做法是把临时对象持有的内存直接交给 b,再把临时对象置为空状态,让它安全析构

左值、右值与 std::move

左值和右值可以用一句话理解:

这个对象能否安全地被偷走资源?

  • 左值有名字,通常之后还要继续使用,例如 ax
  • 右值通常是临时对象,例如 Buffer(1024)、函数返回的临时值
  • 右值引用用 T&& 表示,可以绑定到右值
1
2
3
Buffer a(1024);          // a 是左值
Buffer b = Buffer(512); // Buffer(512) 是右值
int z = x + 5; // x 是左值,x + 5 是右值

std::move 本身不移动任何东西,只是一个类型转换:把左值转换成右值引用,告诉编译器“这个对象可以被移动”

1
2
Buffer a(1024);
Buffer b = std::move(a);

真正执行移动的是移动构造函数移动赋值运算符,不是 std::move

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int a = 10;        // a: 左值,10: 右值
int b = a; // b: 左值,a: 左值(但被读取)
int c = a + b; // a+b: 右值(临时结果)

// 取地址
int* p1 = &a; // 对 左值可取地址
int* p2 = &(a+b); // 错 右值不可取地址

// 赋值
a = 20; // 对 左值在左侧可被赋值
20 = a; // 错 右值不能在左侧

// 引用绑定
int& ref1 = a; // 对 左值引用绑定左值
int& ref2 = 20; // 错 左值引用不能绑定右值
const int& ref3 = 20; // 对 const左值引用可绑定右值(延长生命周期)
int&& ref4 = 20; // 对 右值引用绑定右值(C++11)

误区:

1
2
3
4
5
void foo(Buffer&& b);

Buffer&& rref = Buffer(512);
foo(rref); // 错误:rref 有名字,因此 rref 本身是左值
foo(std::move(rref)); // 正确:显式转换为右值引用

具名的右值引用表达式是左值

是否为左值,不看类型写着 &&,而看表达式有没有可继续使用的身份

移动语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Buffer {
char* data_;
size_t size_;
public:
// 移动构造函数(接受右值)
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr; // 窃取资源
other.size_ = 0;
}
};

Buffer a(1024);
Buffer b = std::move(a); // 调用移动构造(a被"移动"到b)
// 现在 a 为空,b 拥有资源

完美转发

1
2
3
4
5
6
7
8
template<typename T>
void wrapper(T&& arg) { // 万能引用(转发引用)
process(std::forward<T>(arg)); // 保持 arg 的左右值属性
}

int x = 10;
wrapper(x); // T = int&,转发为左值
wrapper(20); // T = int,转发为右值

区分重载

1
2
3
4
5
6
7
8
9
10
void process(int& x) { 
std::cout << "左值: " << x << "\n";
}
void process(int&& x) {
std::cout << "右值: " << x << "\n";
}

int a = 10;
process(a); // 调用左值版本
process(20); // 调用右值版本

四个特殊成员函数

复制和移动各自有“构造新对象”和“给已有对象赋值”两种情况:

类别构造新对象给已有对象赋值
复制复制构造函数复制赋值运算符
移动移动构造函数移动赋值运算符

复制构造函数

复制构造函数用于用一个已有对象构造新对象:

1
2
3
4
5
6
7
8
9
10
11
class Buffer {
public:
Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::memcpy(data_, other.data_, size_);
}

private:
char* data_;
size_t size_;
};

它的参数必须是 const Buffer&。如果按值传参:

1
Buffer(Buffer other); // 错误设计

为了调用这个构造函数,需要先复制实参生成 other,这又会调用复制构造函数,导致无限递归

使用引用可以避免再次复制;使用 const 表示复制源不会被修改,也允许从常量对象复制

常见触发场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Buffer b2 = b1;
/*
- 尝试将 b1 转化为 Buffer 类型
- 然后调用拷贝构造函数/移动构造函数
- 允许隐式类型转换
Buffer b2 = 1024; // 如果构造函数 explicit,这里会报错
// 等价于:Buffer b2 = Buffer(1024); // 隐式转换
*/
Buffer b3(b1);
/*
- 直接调用匹配的构造函数
- 不允许隐式类型转换
Buffer b3(1024); // 对 直接调用 explicit Buffer(size_t)
*/
Buffer b4{b1};
/*
- 列表初始化,禁止窄化转化
int x{3.14}; // 错 编译错误:窄化转换
int x = 3.14; // 对 警告但不报错(值变为3)
*/
foo(b1); // 如果 foo 按值接收 Buffer

std::memcpy 只适合 char、基本类型和 trivially copyable 类型

如果元素是复杂类类型,应使用 std::copy 或逐元素构造,不能绕过对象的复制语义

场景1:explicit 构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Buffer {
public:
explicit Buffer(size_t size); // explicit 阻止隐式转换
Buffer(const Buffer& other); // 拷贝构造函数
};

Buffer b1(1024);

Buffer b2 = b1; // 对 拷贝初始化(直接使用 b1,无需转换)
Buffer b3(b1); // 对 直接初始化
Buffer b4{b1}; // 对 列表初始化

Buffer b5 = 1024; // 错 错误:explicit 构造函数不能被隐式调用
Buffer b6(1024); // 对
Buffer b7{1024}; // 对

场景2:std::initializer_list 冲突

1
2
3
4
5
6
7
8
9
class Vec {
public:
Vec(int x, int y) { /* ... */ }
Vec(std::initializer_list<int> list) { /* ... */ }
};

Vec v1(10, 20); // 调用 Vec(int, int)
Vec v2{10, 20}; // 调用 Vec(initializer_list)!优先
Vec v3 = {10, 20}; // 调用 Vec(initializer_list)

场景3:返回值优化(RVO)

1
2
3
4
5
6
Buffer createBuffer() {
return Buffer(1024); // 直接初始化,可能触发 RVO
}

Buffer b1 = createBuffer(); // 拷贝初始化,可能触发移动语义
// 现代编译器会优化掉拷贝/移动

复制赋值运算符

复制赋值处理的是左边对象已经存在的情况:

1
Buffer& operator=(const Buffer& other);

目标对象已经持有资源,所以复制赋值比复制构造更麻烦,简单实现如下:

1
2
3
4
5
6
7
8
9
10
11
Buffer& operator=(const Buffer& other) {
if (this == &other) {
return *this;
}

delete[] data_;
data_ = new char[other.size_];
size_ = other.size_;
std::memcpy(data_, other.data_, size_);
return *this;
}

这里必须处理自赋值:

1
b = b;

如果不判断 this == &other,可能先释放自己的内存,再从已经释放的同一块内存复制

但这个实现还有异常安全问题:如果 new char[other.size_] 抛异常,对象原资源已经被释放,可能进入无效状态

更推荐 copy-and-swap:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Buffer {
public:
// friend 函数授予非成员函数或其他类访问本类私有成员的权限
friend void swap(Buffer& a, Buffer& b) noexcept {
std::swap(a.data_, b.data_);
std::swap(a.size_, b.size_);
}

Buffer& operator=(const Buffer& other) {
Buffer tmp(other);
swap(*this, tmp);
return *this;
}

private:
char* data_ = nullptr;
size_t size_ = 0;
};

先构造副本 tmp,如果复制失败,当前对象完全不变;复制成功后再交换资源

旧资源被交换到 tmp,函数返回时由 tmp 析构释放,这提供了强异常安全,也自然处理自赋值

赋值运算符返回 Buffer&,是为了支持链式赋值:

1
a = b = c;

b = c 返回 b 本身,然后再执行 a = b

移动构造函数

移动构造用一个右值对象构造新对象,负责接管资源:

1
2
3
4
5
6
7
8
9
10
11
12
class Buffer {
public:
Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}

private:
char* data_ = nullptr;
size_t size_ = 0;
};

关键操作:

  • 目标对象接管源对象的资源;
  • 源对象被放入可析构、可重新赋值的状态

移动后的源对象不是“原来的对象还在,只是内容也复制了一份”

标准说法是:源对象有效但未指定,也就是说,它可以析构,可以重新赋值,但不应继续依赖它原来的值

1
2
3
4
5
Buffer a(1024);
Buffer b(std::move(a));

// a 可以析构,也可以重新赋值
// 不应再假设 a 仍有 1024 字节内容

移动赋值运算符

移动赋值处理的是目标对象已经存在、右边对象可以被接管的情况:

1
2
3
4
5
6
7
8
9
10
11
12
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) {
return *this;
}

delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}

移动赋值要先释放目标对象原来持有的资源,再接管源对象资源

最后必须把源对象置空,否则两个对象仍然指向同一资源,析构时会双重释放

编译器如何选择复制或移动

不同写法会触发不同特殊成员函数

1
2
3
4
5
6
7
8
Buffer a(1024);

Buffer b = a; // 复制构造:a 是左值
Buffer c = std::move(a); // 移动构造:std::move(a) 是右值

Buffer d(2048);
d = b; // 复制赋值
d = std::move(c); // 移动赋值

函数返回值:

1
2
3
4
5
6
Buffer makeBuffer() {
Buffer tmp(1024);
return tmp;
}

Buffer x = makeBuffer();

这里可能发生返回值优化,也就是 RVO/NRVO

编译器可以直接在 x 的存储位置构造返回对象,从而连移动构造都省掉

C++17 起,一些临时对象返回场景有强制复制省略

因此不要为了“强行移动”局部返回值而写:

1
return std::move(tmp); // 通常不推荐

这可能阻碍返回值优化。多数情况下直接 return tmp; 更好

noexcept 与标准库行为

移动操作通常应当写 noexcept,前提是它只做资源指针转移、置空、释放旧资源等不会抛异常的动作

1
2
Buffer(Buffer&& other) noexcept;
Buffer& operator=(Buffer&& other) noexcept;

noexcept 会影响标准库容器的策略

std::vector 扩容为例,扩容时需要把旧存储区里的元素搬到新存储区:

  • 如果元素移动构造是 noexcept,容器更敢于使用移动;
  • 如果移动可能抛异常,为了保持异常安全,容器可能退回复制。

所以对资源类来说,移动操作如果只是转移所有权,通常应该声明为 noexcept

三法则、五法则与零法则

三法则

如果一个类需要自定义以下任意一个:

  • 析构函数;
  • 复制构造函数;
  • 复制赋值运算符;

通常这三个都要一起考虑

原因是:一旦类手动管理资源,编译器默认复制往往变成浅复制,既然负责析构释放资源,就也必须负责复制时资源如何被复制

五法则

C++11 引入移动语义后,还要把移动构造函数和移动赋值运算符纳入考虑:

  • 析构函数;
  • 复制构造函数;
  • 复制赋值运算符;
  • 移动构造函数;
  • 移动赋值运算符。

这些特殊成员函数会相互影响

自定义析构或复制操作后,编译器可能不再自动生成移动操作;自定义移动操作后,复制操作也可能被删除。因此资源类应把五个函数作为整体设计

一个完整资源类大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Buffer {
public:
explicit Buffer(size_t size)
: data_(new char[size]), size_(size) {}

~Buffer() {
delete[] data_;
}

Buffer(const Buffer& other)
: data_(new char[other.size_]), size_(other.size_) {
std::memcpy(data_, other.data_, size_);
}

Buffer& operator=(const Buffer& other) {
Buffer tmp(other);
swap(*this, tmp);
return *this;
}

Buffer(Buffer&& other) noexcept
: data_(other.data_), size_(other.size_) {
other.data_ = nullptr;
other.size_ = 0;
}

Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) {
return *this;
}
delete[] data_;
data_ = other.data_;
size_ = other.size_;
other.data_ = nullptr;
other.size_ = 0;
return *this;
}

friend void swap(Buffer& a, Buffer& b) noexcept {
std::swap(a.data_, b.data_);
std::swap(a.size_, b.size_);
}

private:
char* data_ = nullptr;
size_t size_ = 0;
};

零法则

现代 C++ 更推荐零法则:尽量不要自己管理资源,而是让成员类型管理资源

1
2
3
4
5
class Person {
std::string name_;
std::string email_;
int age_{};
};

std::string 已经正确实现复制、移动和析构,int 不需要资源管理

因此 Person 不需要手写任何特殊成员函数

如果 Buffer 改成:

1
2
3
4
5
6
class Buffer {
std::vector<char> data_;

public:
explicit Buffer(size_t size) : data_(size) {}
};

复制、移动、析构都可以交给 std::vector

这比手写 new[]delete[] 更安全,也更符合现代 C++ 风格

类型语义设计

成员类型会直接影响类的默认语义:

成员类型推导出的语义
std::stringstd::vector可复制,可移动
std::unique_ptr不可复制,可移动
std::mutex不可复制,不可移动

因此类是否支持复制和移动,应先从语义出发:

  • std::stringBuffer 这类值对象,复制一份有意义,移动也有意义
  • 文件句柄、std::unique_ptr 这类唯一所有权对象,不应复制,但可以移动
  • std::mutex 这类有身份的同步原语,不应复制,也不应移动

可以用 = delete= defaultexplicit 明确表达设计:

1
2
3
4
5
6
7
8
9
10
11
class FileHandle {
public:
explicit FileHandle(const char* path);
~FileHandle();

FileHandle(const FileHandle&) = delete;
FileHandle& operator=(const FileHandle&) = delete;

FileHandle(FileHandle&&) noexcept = default;
FileHandle& operator=(FileHandle&&) noexcept = default;
};

文件句柄不能复制,因为复制一个文件所有权没有清晰语义;但可以移动,因为所有权转让有意义

std::move 的正确与错误用法

适合使用 std::move 的场景:

  • 明确之后不再需要原对象
  • 在移动构造或移动赋值中转移成员
  • 按值传参后,局部副本不再需要
1
2
3
std::vector<Buffer> pool;
Buffer a(1024);
pool.push_back(std::move(a)); // a 后续只应析构或重新赋值

常见误用:

1
2
const Buffer a(1024);
Buffer b = std::move(a); // const 右值通常不能调用 Buffer&& 移动构造,可能退回复制

std::move 不会去掉 const

移动通常需要修改源对象,把它置空;const 源对象不能被修改,因此很多时候无法真正移动

另一个误用是移动后继续读取源对象的业务值:

1
2
3
Buffer a(1024);
Buffer b = std::move(a);
// 不要继续假设 a 仍保存原内容

移动后的对象有效但未指定

$ discussion
# Comments
waline