拷贝控制

当定义一个类时,实际上也在定义:

  • 对象如何拷贝
  • 如何赋值
  • 如何移动
  • 如何销毁

这些由一组特殊成员函数控制,称为:

拷贝控制成员

共 5 个:

  1. 拷贝构造函数
  2. 拷贝赋值运算符
  3. 移动构造函数
  4. 移动赋值运算符
  5. 析构函数

五个特殊成员函数

拷贝构造函数

控制:

同类型另一个对象初始化新对象时做什么

例如:

1
2
T a;
T b = a; // 拷贝构造

拷贝赋值运算符

控制:

一个已存在对象被赋值为另一个同类型对象时做什么

例如:

1
2
T a, b;
a = b; // 拷贝赋值

移动构造函数

控制:

用一个**将要被销毁的对象(右值)**初始化新对象时做什么

例如:

1
T a = T();   // 可能调用移动构造

移动赋值运算符

控制:

把一个右值赋给已存在对象时做什么

析构函数

控制:

对象销毁时做什么

常用于:

  • 释放动态内存
  • 关闭文件
  • 释放锁
  • 归还系统资源

拷贝、赋值与销毁

这是拷贝控制的基础部分。

拷贝构造函数

定义

如果一个构造函数:

  • 第一个参数是本类类型的引用
  • 且其余参数都有默认值

那么它就是拷贝构造函数

例如:

1
2
3
4
5
class Foo {
public:
Foo(); // 默认构造函数
Foo(const Foo&); // 拷贝构造函数
};

为什么参数必须是引用

如果写成:

1
Foo(Foo);

那么为了把实参传给这个构造函数,就要先拷贝一次对象,
而拷贝本身又要调用拷贝构造函数,形成无限递归。

所以拷贝构造函数的参数必须是引用。

编译器合成的拷贝构造函数

如果类没有自己定义拷贝构造函数,编译器通常会合成一个。

这个合成版本会:

逐成员拷贝

即:每个非 static 成员各自按其类型的拷贝方式复制。

拷贝初始化与直接初始化

这是考试和理解中很容易混的地方。

直接初始化

1
2
string s1("hello");
string s2(s1);

特点:

  • 直接调用某个构造函数
  • 由编译器做普通重载匹配

拷贝初始化

1
2
string s3 = s1;
string s4 = "hello";

特点:

  • 形式上用了 =
  • 本质上不是赋值,而是初始化
  • 通常调用拷贝构造函数(或移动构造函数)

常见会发生拷贝初始化的场景

= 定义变量

1
T a = b;

将对象作为实参传给非引用形参

1
void f(T x);   // 调用时会拷贝初始化 x

函数返回非引用类型对象

1
2
3
4
T f() {
T x;
return x; // 可能拷贝/移动
}

用花括号列表初始化数组元素或容器元素时的某些情况

注意

初始化 ≠ 赋值

  • T a = b; 是初始化
  • a = b; 是赋值

前者调构造函数,后者调赋值运算符。

拷贝赋值运算符

作用

控制已存在对象如何接受另一个同类型对象的值:

1
2
Foo a, b;
a = b;

形式

赋值运算符是名为 operator= 的成员函数:

1
2
3
4
class Foo {
public:
Foo& operator=(const Foo&);
};

通常:

  • 参数是 const Foo&
  • 返回 Foo&

为什么返回引用

为了与内置类型行为一致,支持连续赋值:

1
a = b = c;

所以一般写成:

1
Foo& operator=(const Foo&);

编译器合成版本

如果未定义,编译器也会合成一个逐成员赋值的版本。

析构函数

作用

析构函数在对象销毁时自动调用,用来:

  • 释放资源
  • 做清理工作

形式:

1
2
3
4
class Foo {
public:
~Foo();
};

特点:

  • 没有返回值
  • 没有参数
  • 不能重载

析构函数做什么

构造函数负责“建立对象”,
析构函数负责“清理对象”。

例如,若类中有动态内存:

1
2
3
4
5
class Str {
char *data;
public:
~Str() { delete[] data; }
};

析构时成员也会自动销毁

析构函数函数体执行完后,编译器还会自动销毁对象的成员:

  • 类类型成员:调用它们自己的析构函数
  • 内置类型成员:无需额外操作

注意:

内置指针成员本身销毁时不会自动 delete 它指向的对象

这正是很多资源管理类要自己定义析构函数的原因。

析构函数调用时机

对象被销毁时,析构函数自动执行。

常见情况:

局部对象离开作用域

1
2
3
{
Foo x;
} // 这里调用 ~Foo()

对象的成员随对象一起销毁

容器销毁时,元素被销毁

动态对象被 delete

1
2
Foo *p = new Foo;
delete p;

临时对象在完整表达式结束时销毁

引用和指针本身离开作用域,不会销毁对象

1
2
3
4
Foo *p = new Foo;
{
Foo *q = p;
} // q 离开作用域,不会 delete p 指向对象

三法则 / 五法则

这是拷贝控制最重要的总结之一。

三法则(Rule of Three)

如果一个类需要自定义以下三个中的一个:

  1. 析构函数
  2. 拷贝构造函数
  3. 拷贝赋值运算符

那么它通常也需要自定义另外两个

为什么

因为这类类往往管理某种资源,如:

  • 动态内存
  • 文件句柄
  • 网络连接

如果只定义析构函数而不定义拷贝操作,默认拷贝往往只是浅拷贝,会导致:

  • 多个对象指向同一资源
  • 重复释放
  • 悬空指针

五法则(Rule of Five)

C++11 后多了移动语义,因此扩展为“五法则”:

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

  1. 析构函数
  2. 拷贝构造函数
  3. 拷贝赋值运算符
  4. 移动构造函数
  5. 移动赋值运算符

那么通常应该认真考虑是否需要同时定义全部五个

怎么记

简版记忆:

  • 管理资源的类,一般要考虑五个特殊成员函数
  • 定义了一个,通常说明默认行为不够了

=default

如果你希望:

明确告诉编译器“请使用默认合成版本”

可以写:

1
2
3
4
5
6
7
class Foo {
public:
Foo() = default;
Foo(const Foo&) = default;
Foo& operator=(const Foo&) = default;
~Foo() = default;
};

作用

  • 保留编译器默认行为
  • 同时让代码意图更清晰
  • 有时可控制函数是否被声明/定义

注意

只能对这些“可合成的特殊成员函数”使用 =default

  • 默认构造函数
  • 拷贝构造
  • 拷贝赋值
  • 移动构造
  • 移动赋值
  • 析构函数

=delete:阻止拷贝或赋值

如果希望某个操作根本不能用,可将其声明为删除函数:

1
2
3
4
5
6
7
class NoCopy {
public:
NoCopy() = default;
NoCopy(const NoCopy&) = delete;
NoCopy& operator=(const NoCopy&) = delete;
~NoCopy() = default;
};

含义

  • 函数被声明了
  • 但不能调用

这样编译器会在使用时报错。

常见用途

禁止拷贝类

例如:

  • 互斥锁类
  • 文件句柄类
  • 单例类

注意点

=delete 必须写在第一次声明处

析构函数一般不能轻易删除

如果析构函数被删除,则该类型对象根本无法正常销毁。

旧式做法:private 拷贝控制

在 C++11 前,常通过把拷贝构造和拷贝赋值声明为 private 来禁止拷贝:

1
2
3
4
5
6
7
class NoCopy {
private:
NoCopy(const NoCopy&);
NoCopy& operator=(const NoCopy&);
public:
NoCopy() = default;
};

现代 C++ 更推荐:

1
= delete

因为:

  • 更直观
  • 错误信息更清楚
  • 语义更明确

拷贝控制与资源管理

这是理解“为什么要自己写拷贝控制”的核心。

类大体有两种设计风格:

类值行为(像值)

拷贝后,新对象和原对象彼此独立

例如 stringvector

1
2
3
string s1 = "abc";
string s2 = s1;
s2[0] = 'x';

修改 s2 不影响 s1

这种类的拷贝通常要:

  • 拷贝底层数据
  • 各自独立管理资源

类指针行为(像指针)

拷贝后,新旧对象共享底层状态

例如某些智能指针、句柄类:

1
2
shared_ptr<int> p1 = make_shared<int>(42);
shared_ptr<int> p2 = p1;

修改共享对象会被双方看到。

复习重点

设计类时要先想清楚:

这个类是“像值”还是“像指针”?

不同设计,拷贝控制实现完全不同。

对象移动

C++11 引入移动语义,目的是:

避免不必要的深拷贝,提高效率

尤其对以下类型很重要:

  • string
  • vector
  • 资源管理类

右值引用

移动语义的基础是:

右值引用 &&

定义

右值引用只能绑定到右值

1
int &&rr = 42;   // 正确

左值和右值

左值

有名字、可取地址、持久存在的对象:

1
2
int x = 10;
x // 左值

右值

临时结果、字面值、即将销毁的对象:

1
2
3
42
x + 1
string("abc")

基本例子

1
2
3
4
int i = 42;
int& r = i; // 正确,左值引用绑定左值
int&& rr = i; // 错误,右值引用不能绑定左值
int&& rr2 = 42; // 正确

为什么右值适合“移动”

因为右值通常是:

  • 临时对象
  • 即将销毁
  • 没有其他稳定使用者

所以可以“偷走”它的资源,而不必完整拷贝。

注意:右值引用变量本身是左值

1
2
int&& rr1 = 42;
int&& rr2 = rr1; // 错误

虽然 rr1 的类型是右值引用,但表达式 rr1 本身是左值

std::move

要把一个左值“显式地当作右值使用”,用:

1
std::move(x)

头文件:

1
#include <utility>

作用

move 不会真的移动任何东西,它只是:

把一个左值转换成对应的右值引用

例如:

1
2
string s = "hello";
string t = std::move(s);

此后 s 成为移后源对象

使用 move 后的对象

规则:

  • 仍然有效
  • 可以析构
  • 可以重新赋值
  • 但其值不应再作任何假设

也就是:

1
2
3
string s = "hello";
string t = std::move(s);
// 此时不要再假设 s 还是 "hello"

移动构造函数

形式

1
2
3
4
class Foo {
public:
Foo(Foo&&) noexcept;
};

参数是:

  • 本类类型的右值引用

本质

移动构造不是复制资源,而是:

接管源对象的资源

例如:

1
2
3
4
5
6
7
class StrVec {
public:
StrVec(StrVec&& s) noexcept
: elements(s.elements), first_free(s.first_free), cap(s.cap) {
s.elements = s.first_free = s.cap = nullptr;
}
};

为什么源对象要置空

因为资源所有权已经转移给新对象。
如果源对象还保留旧指针,析构时会重复释放。

所以移动后应保证源对象处于:

有效、可析构、但值未指定 的状态

noexcept

移动操作通常应该标记为:

1
noexcept

因为很多标准库容器在移动元素时,只有确认“不会抛异常”才愿意优先使用移动而不是拷贝。

所以复习时记:

移动构造 / 移动赋值通常应写 noexcept

移动赋值运算符

形式

1
Foo& operator=(Foo&&) noexcept;

实现思路

移动赋值一般要做三件事:

  1. 释放左侧对象原有资源
  2. 接管右侧对象资源
  3. 将右侧对象置于可析构状态

示例

1
2
3
4
5
6
7
8
Foo& Foo::operator=(Foo&& rhs) noexcept {
if (this != &rhs) {
free(); // 释放已有资源
data = rhs.data; // 接管资源
rhs.data = nullptr; // rhs 置为空
}
return *this;
}

注意自赋值

移动赋值也要考虑自赋值:

1
a = std::move(a);

虽然这种写法少见,但实现时最好仍能正确处理。

编译器何时合成移动操作

这是高频易混点。

基本结论

编译器不会总是自动生成移动构造和移动赋值。

一般来说,只有当:

  • 类没有自定义拷贝控制成员
  • 且所有成员都支持移动

时,编译器才可能合成移动操作。

重要影响

如果类定义了:

  • 拷贝构造函数
  • 拷贝赋值运算符
  • 析构函数

中的任意一个,

那么编译器通常不会再自动帮你生成移动操作

所以很多资源管理类若自己写了析构函数,往往还要自己把移动操作补上。

拷贝与移动的选择规则

可以简单记为:

移动右值,拷贝左值

左值优先拷贝

1
2
Foo a;
Foo b = a; // 拷贝

右值优先移动

1
Foo b = Foo();   // 若有移动构造,则优先移动

如果没有移动操作

那就退回去用拷贝操作。

即:

没有移动构造时,右值也可能被拷贝

移动迭代器

标准库提供了移动迭代器适配器

1
make_move_iterator(it)

作用:

  • 普通迭代器解引用得到左值
  • 移动迭代器解引用得到右值引用

这样算法在处理元素时会优先移动而不是拷贝。

例如:

1
2
3
4
5
vector<string> src = {"a", "b", "c"};
vector<string> dst(
make_move_iterator(src.begin()),
make_move_iterator(src.end())
);

此时元素会从 src 移动到 dst

不要滥用 std::move

这是实践中非常重要的一点。

std::move 的含义是:

我保证这个对象接下来不再按原值使用,可以把它的资源拿走

所以:

  • 不能对还要正常使用的对象随便 move
  • move 后只能安全地:
  • 析构
  • 重新赋值
  • 少量不依赖原值的操作

右值引用与成员函数重载

除了构造和赋值,普通成员函数也可以同时提供:

  • 左值版本
  • 右值版本

常见例子就是容器的 push_back

1
2
void push_back(const T&); // 拷贝
void push_back(T&&); // 移动

记忆方式

通常成对出现:

  • const T&:接受左值,也能接受右值
  • T&&:专门接受可移动右值

引用限定符

成员函数还可以限制:

只能被左值对象调用,或只能被右值对象调用

写法是在参数列表后加:

  • &
  • &&

例子

1
2
3
4
class Foo {
public:
Foo& operator=(const Foo&) &;
};

这里的 & 表示:

  • 该赋值运算符只能用于左值对象

即:

1
2
Foo a, b;
a = b; // 正确

但:

1
Foo() = b;   // 错误,左侧是右值

为什么有用

可以防止一些无意义或危险的调用,比如:

  • 给临时对象赋值
  • 对右值调用只适合持久对象的成员函数

const 与引用限定符顺序

如果同时写,顺序必须是:

1
func() const &

而不是:

1
func() & const   // 错

注意

如果某个成员函数有引用限定符,那么同名同参数列表的其他重载通常也应使用引用限定符,保持一致。

一个典型资源管理类要做什么

假设类中有动态内存:

1
2
3
4
5
6
7
8
9
10
11
class MyStr {
private:
char *data;
public:
MyStr(const char *s);
~MyStr();
MyStr(const MyStr&);
MyStr& operator=(const MyStr&);
MyStr(MyStr&&) noexcept;
MyStr& operator=(MyStr&&) noexcept;
};

复习时应知道这五个函数的职责:

  • 析构函数:释放 data
  • 拷贝构造:分配新内存并拷贝内容
  • 拷贝赋值:先释放旧资源,再深拷贝
  • 移动构造:直接拿走指针
  • 移动赋值:释放旧资源,再接管指针

易错点总结

T a = b; 是初始化,不是赋值

调用的是拷贝/移动构造,不是赋值运算符。

拷贝构造函数参数必须是引用

否则会无限递归。

析构函数不会自动 delete 指针成员指向的对象

如果类自己管理资源,必须手动释放。

定义了析构函数的类,通常也要考虑拷贝构造和拷贝赋值

这就是三法则的核心。

定义了拷贝控制成员后,编译器可能不再自动生成移动操作

std::move 只是类型转换,不是真的移动

真正移动发生在移动构造/移动赋值内部。

移后源对象仍然有效,但值不确定

只能安全地析构或重新赋值。

右值引用变量本身是左值

1
2
3
T&& rr = ...;
f(rr); // rr 是左值
f(std::move(rr)); // 才是右值

赋值运算符通常返回左侧对象引用

1
T& operator=(const T&);

noexcept 对移动操作很重要

特别是标准库容器会依赖它决定是否优先移动。

小结

拷贝控制的主线可以概括为:

  1. 类通过五个特殊成员函数控制:
  • 拷贝
  • 赋值
  • 移动
  • 销毁
  1. 若不自定义,编译器会尽量生成合成版本
  2. 合成版本通常是逐成员操作
  3. 管理资源的类通常不能直接依赖默认行为
  4. 三法则:析构、拷贝构造、拷贝赋值常常需要一起考虑
  5. 五法则:再加上移动构造和移动赋值
  6. 右值引用和 std::move 是移动语义的基础
  7. 移动的本质不是复制资源,而是转移资源所有权

如果类管理资源,就必须认真设计拷贝、赋值、移动和析构;否则默认行为很容易出错。