C++ / 2026-08-01
CS106L 第 13 讲:特殊成员函数
理解对象如何被创建、复制、赋值、移动与销毁,以及资源管理规则。
本课程根据 CS106L Lecture 13: Special Member Functions 重构。
本节课要解决的核心问题
我们已经能够自己设计类、编写构造函数、重载运算符,看起来已经可以让对象正常工作了。
但是,一旦程序出现下面这些代码,新的问题就来了:
Widget first;
Widget second = first;
Widget third;
third = first;
这里发生了什么?
second是怎样根据first创建出来的?third = first和Widget second = first中的=是同一种操作吗?- 即使我们没有写复制函数,为什么代码仍然能够编译?
- 如果对象内部保存了一个指针,复制指针是否等于复制了指针指向的数据?
- 如果某种对象根本不应该被复制,怎样让编译器禁止这种操作?
- 如果原对象马上就不再使用,为什么还要辛苦复制全部数据?
这些问题共同指向了 C++ 类中一组特殊的函数:
特殊成员函数(special member functions,SMFs)
它们负责管理对象完整的一生:
创建对象
↓
复制对象
↓
给已有对象赋值
↓
转移对象资源
↓
对象生命周期结束
学习本节所需的前置知识
阅读本节课程前,只需要对下面的内容有基本印象:
- 类与对象;
public和private;- 构造函数与析构函数;
- 引用,例如
const Widget&; - 指针;
new[]和delete[];- 基础运算符重载;
std::string、std::vector等标准库类型。
本节会涉及动态内存、右值引用和移动语义。相关内容第一次出现时都会重新解释。
从上一讲开始:运算符也是函数
上一讲讨论了运算符重载。一个类可以把 < 写成成员函数:
class StudentID {
public:
bool operator<(const StudentID& other) const;
private:
int id_number_;
};
表达式:
first < second
可以理解为:
first.operator<(second)
左操作数 first 是调用函数的对象,右操作数 second 是参数。
我们也可以把 < 写成非成员函数:
bool operator<(const StudentID& lhs, const StudentID& rhs);
这时:
first < second
可以理解为:
operator<(first, second)
两个操作数都作为普通参数出现。
为什么非成员版本更对称
假设某个类型允许从整数进行转换:
Number number{10};
对于成员运算符,左操作数必须先是调用该成员函数的对象:
number + 5;
但下面的表达式不一定能够使用同一个成员运算符:
5 + number;
非成员运算符会同时考虑左右两边的类型转换,因此通常更加对称。
不过,非成员函数并不天然拥有访问类私有成员的权利。
成员函数为什么能访问另一个对象的私有成员
下面的写法是合法的:
bool StudentID::operator<(const StudentID& other) const {
return id_number_ < other.id_number_;
}
虽然 other.id_number_ 属于另一个对象,但访问控制是以“类”为单位的,而不是以“对象”为单位的。
StudentID 的成员函数可以访问任何 StudentID 对象的私有成员。
非成员函数如何取得数据
一种方法是通过公开接口访问:
#include <iostream>
class StudentID {
public:
explicit StudentID(int id_number)
: id_number_(id_number) {
}
int idNumber() const {
return id_number_;
}
private:
int id_number_;
};
bool operator<(const StudentID& lhs, const StudentID& rhs) {
return lhs.idNumber() < rhs.idNumber();
}
int main() {
StudentID first{100};
StudentID second{200};
std::cout << std::boolalpha;
std::cout << (first < second) << '\n';
}
程序输出:
true
另一种方法是把非成员函数声明为友元函数(friend function):
class StudentID {
public:
explicit StudentID(int id_number)
: id_number_(id_number) {
}
friend bool operator<(const StudentID& lhs,
const StudentID& rhs);
private:
int id_number_;
};
bool operator<(const StudentID& lhs, const StudentID& rhs) {
return lhs.id_number_ < rhs.id_number_;
}
friend 并不会把这个函数变成成员函数。它仍然是非成员函数,只是获得了访问私有成员的权限。
对课件示例的纠正
课件的相关代码中存在几处容易误导初学者的问题。
首先,头文件通常应当写成:
#include "StudentID.h"
而不是:
#include StudentID.h
其次,课件中的 StanfirdID 是拼写错误,应为 StanfordID 或统一后的 StudentID。
更重要的是,非成员函数末尾不能添加成员函数专用的 const:
bool operator<(const StudentID& lhs,
const StudentID& rhs) const; // 错误
末尾的 const 表示:
这个成员函数不会修改调用它的对象。
非成员函数没有隐含的调用对象,也没有 this,因此不能使用这种末尾 const。
正确写法是:
bool operator<(const StudentID& lhs,
const StudentID& rhs);
这段回顾为本节留下了一个重要线索:
operator=也是一个运算符函数。
当两个对象进行赋值时,C++ 也需要决定究竟调用哪个函数。
练习:找出非法声明
下面哪个声明不合法?为什么?
class Box {
public:
bool operator<(const Box& other) const;
};
bool operator==(const Box& lhs, const Box& rhs) const;
答案与解释
成员版本的 < 合法:
bool Box::operator<(const Box& other) const;
最后的 const 表示这个函数不会通过 this 修改左侧对象。
非成员版本的 == 不合法:
bool operator==(const Box& lhs, const Box& rhs) const;
非成员函数没有 this,因此末尾不能添加成员函数限定符 const。
正确写法是:
bool operator==(const Box& lhs, const Box& rhs);
这里两个参数本身已经是 const Box&,因此函数不能通过它们修改对象。
类中那些“没有写出来”的函数
考虑一个非常普通的类:
#include <string>
class Student {
private:
std::string name_;
int score_;
};
我们没有写任何构造函数、复制函数、赋值运算符或析构函数。
可是下面的很多操作仍然可能成立:
Student first;
Student second = first;
Student third;
third = second;
这说明编译器可能为类提供了一些隐含的操作。
这些操作就是特殊成员函数。
六种特殊成员函数
假设类名为 T,六种特殊成员函数的典型形式如下:
| 名称 | 典型形式 | 主要职责 |
|---|---|---|
| 默认构造函数 | T() |
在没有提供初始化参数时创建对象 |
| 析构函数 | ~T() |
在对象生命周期结束时清理对象 |
| 复制构造函数 | T(const T&) |
根据已有对象创建新对象 |
| 复制赋值运算符 | T& operator=(const T&) |
用另一个对象替换已有对象的状态 |
| 移动构造函数 | T(T&&) |
从另一个对象转移资源并创建新对象 |
| 移动赋值运算符 | T& operator=(T&&) |
把资源转移给一个已经存在的对象 |
一个完整的声明框架如下:
class Widget {
public:
Widget();
~Widget();
Widget(const Widget& other);
Widget& operator=(const Widget& other);
Widget(Widget&& other);
Widget& operator=(Widget&& other);
};
这里的 Widget&& 是右值引用(rvalue reference)。它主要服务于移动语义,稍后会建立基本直觉。
编译器是否一定会生成全部六个函数
不能简单地理解成:
每个类一定无条件拥有六个可用函数。
更准确的说法是:
编译器会根据类中已有的声明以及各成员的能力,决定是否隐式声明、隐式定义或删除某些特殊成员函数。
例如:
- 一旦我们声明了其他构造函数,编译器通常不会再自动提供无参数默认构造函数;
- 如果某个成员本身不能复制,整个类的复制操作也可能被隐式删除;
- 用户自己声明析构函数或复制操作,会影响移动操作能否自动生成;
- 即使某个函数被声明了,它也可能因为成员无法执行相应操作而成为删除函数。
初学阶段可以先记住一个实用判断:
简单类通常可以直接使用编译器生成的版本;
一旦类需要自己管理资源,就必须认真检查默认行为是否正确。
对象是刚出生,还是已经存在
复制构造函数和复制赋值运算符非常容易混淆,因为它们都可能出现 =。
观察下面两段代码:
Widget first;
Widget second = first;
以及:
Widget first;
Widget second;
second = first;
它们看起来只差了一行,但语义完全不同。
复制构造:创建一个新对象
Widget second = first;
虽然写出了 =,这里仍然是在声明并初始化 second。
执行前:
first:已经存在
second:还不存在
执行后:
first:原对象
second:新创建的独立对象
因此调用的是复制构造函数:
Widget::Widget(const Widget& other);
下面两种写法都调用复制构造函数:
Widget second = first;
Widget third(first);
在这个场景中,= 属于初始化语法,而不是赋值运算。
复制赋值:修改一个已有对象
Widget second;
second = first;
执行赋值前,两个对象都已经构造完成:
first:已经存在
second:已经存在
赋值操作的任务是:
清理或替换
second原有的状态,使其变成first的副本。
调用的是复制赋值运算符:
Widget& Widget::operator=(const Widget& other);
这里:
- 左侧对象是
*this; - 右侧对象是
other; - 返回值通常是左侧对象自身的引用。
典型框架如下:
Widget& Widget::operator=(const Widget& other) {
// 用 other 的状态替换当前对象
return *this;
}
返回 *this 使下面的连续赋值成为可能:
first = second = third;
计算顺序可以理解为:
first = (second = third);
second = third 返回 second 的引用,然后再赋给 first。
用日志观察调用过程
#include <iostream>
class Widget {
public:
Widget() {
std::cout << "default constructor\n";
}
Widget(const Widget&) {
std::cout << "copy constructor\n";
}
Widget& operator=(const Widget&) {
std::cout << "copy assignment\n";
return *this;
}
~Widget() {
std::cout << "destructor\n";
}
};
int main() {
Widget first;
Widget second = first;
Widget third;
third = second;
}
程序输出:
default constructor
copy constructor
default constructor
copy assignment
destructor
destructor
destructor
逐步分析:
Widget first;创建
first,调用默认构造函数。Widget second = first;second尚不存在,因此调用复制构造函数。Widget third;创建
third,调用默认构造函数。third = second;third已经存在,因此调用复制赋值运算符。main()结束。三个局部对象按照创建顺序的相反顺序销毁:
third second first
析构函数何时执行
课件用“对象离开作用域时调用析构函数”建立直觉,这对局部对象通常成立:
void function() {
Widget widget;
} // widget 的析构函数在这里执行
更准确地说:
析构函数会在对象的生命周期结束时执行。
不同对象的生命周期结束方式不完全相同:
- 局部自动对象通常在离开作用域时销毁;
- 使用
new创建的对象在对应的delete时销毁; - 容器中的元素在容器清除或销毁时被销毁;
- 静态对象通常在程序结束时销毁。
本节主要关注局部对象和动态资源。
练习:判断调用的函数
判断每一行调用了哪种特殊成员函数。
Widget first;
Widget second(first);
Widget third;
third = first;
答案与解释
Widget first;
调用默认构造函数。first 是一个新对象,没有传入其他参数。
Widget second(first);
调用复制构造函数。second 尚不存在,现在根据 first 创建。
Widget third;
调用默认构造函数。
third = first;
调用复制赋值运算符。third 已经构造完成,这一行是在替换它的状态。
最容易误判的是下面两行:
Widget second = first;
third = first;
判断标准不是“有没有 =”,而是:
左侧对象是否正在这一行被创建?
- 正在创建:初始化,通常调用复制构造函数;
- 早已存在:赋值,调用复制赋值运算符。
构造函数体开始前,成员已经开始初始化
回到一个简化版动态数组:
template <typename T>
class Vector {
public:
Vector();
private:
std::size_t size_;
std::size_t capacity_;
T* data_;
};
一种构造函数写法是:
template <typename T>
Vector<T>::Vector() {
size_ = 0;
capacity_ = 4;
data_ = new T[capacity_];
}
这段代码能够表达我们的意图,但需要分清两个阶段。
构造函数体不是成员初始化的起点
在进入构造函数体之前,所有成员都已经经历了初始化阶段。
然后才执行大括号内部的语句。
对于类类型成员,下面的写法可能发生两步操作:
class Person {
private:
std::string name_;
public:
Person() {
name_ = "unknown";
}
};
执行过程可以理解为:
第一步:
name_ 先调用 std::string 的默认构造函数,成为空字符串
第二步:
进入构造函数体
执行 name_ = "unknown"
调用字符串赋值操作
也就是说,先构造为空字符串,再进行赋值。
如果直接在成员初始化列表中构造:
Person()
: name_("unknown") {
}
name_ 会直接以 "unknown" 为内容构造。
对内置类型要更加严谨
课件把所有成员都描述成“先默认初始化,再重新赋值”,这对建立直觉有帮助,但对 int 和裸指针并不完全准确。
例如:
class Example {
private:
int value_;
int* pointer_;
public:
Example() {
value_ = 10;
pointer_ = nullptr;
}
};
进入构造函数体前,value_ 和 pointer_ 并不会自动得到安全的零值。它们可能处于不确定状态,随后才在函数体中被赋值。
因此,对这类成员来说,不一定发生了昂贵的“两次完整操作”;但在构造函数体开始前没有得到目标值,仍然不够理想。
现代 C++ 更推荐直接初始化:
Example()
: value_(10),
pointer_(nullptr) {
}
成员初始化列表
成员初始化列表(member initializer list)位于构造函数参数列表和函数体之间:
Vector<T>::Vector()
: size_(0),
capacity_(4),
data_(new T[capacity_]) {
}
它的含义不是“进入函数体后逐个赋值”,而是:
在对象构造阶段,直接用指定表达式初始化每个成员。
执行顺序可以理解为:
1. 为完整对象准备存储空间
2. 按成员声明顺序初始化成员
3. 进入构造函数体
4. 执行函数体中的语句
真正的初始化顺序由声明顺序决定
观察:
class Vector {
private:
std::size_t size_;
std::size_t capacity_;
int* data_;
};
初始化顺序固定为:
size_
capacity_
data_
即使初始化列表写成:
Vector()
: data_(new int[capacity_]),
capacity_(4),
size_(0) {
}
真正的初始化顺序仍然是:
size_
capacity_
data_
不会按照初始化列表的书写顺序执行。
因此,应当让初始化列表与成员声明顺序保持一致:
Vector()
: size_(0),
capacity_(4),
data_(new int[capacity_]) {
}
这里 capacity_ 在 data_ 之前声明,因此初始化 data_ 时,capacity_ 已经是 4。
为什么有些成员必须使用初始化列表
考虑常量成员和引用成员:
class Binding {
public:
Binding(int id, int& target)
: id_(id),
target_(target) {
}
private:
const int id_;
int& target_;
};
const int 一旦初始化完成就不能重新赋值。
引用也必须在创建时立刻绑定到某个对象,之后不能改为引用另一个对象。
因此下面的写法无法工作:
Binding(int id, int& target) {
id_ = id; // 错误:const 成员不能赋值
target_ = target; // 这不是“重新绑定引用”
}
第二行尤其容易误解。
假设 target_ 已经引用某个整数,那么:
target_ = target;
表示把 target 的值赋给被 target_ 引用的整数,并不会改变引用的绑定目标。
引用成员必须在初始化列表中绑定:
Binding(int id, int& target)
: id_(id),
target_(target) {
}
初始化列表可用于所有构造函数
初始化列表不只属于默认构造函数。
#include <string>
class Course {
public:
Course(const std::string& name, int units)
: name_(name),
units_(units) {
}
private:
std::string name_;
int units_;
};
无论构造函数是否带参数,都应优先考虑使用成员初始化列表。
练习:为什么不能放进函数体
下面的类无法编译:
class Account {
public:
Account(int number, int& balance) {
number_ = number;
balance_ = balance;
}
private:
const int number_;
int& balance_;
};
请指出两个问题,并修正代码。
答案与解释
第一个问题是:
number_ = number;
number_ 是 const int。进入构造函数体时,它应该已经完成初始化,之后不能被赋值。
第二个问题是引用成员 balance_ 必须在创建时立刻绑定。
正确写法:
class Account {
public:
Account(int number, int& balance)
: number_(number),
balance_(balance) {
}
private:
const int number_;
int& balance_;
};
假设:
int money = 100;
Account account{123, money};
对象关系为:
account
┌────────────────────────┐
│ number_ = 123 │
│ balance_ ───────────────┼──→ money
└────────────────────────┘
balance_ 不保存一份新的余额,而是引用外部变量 money。
编译器的默认复制究竟复制了什么
对于下面的类:
#include <string>
class Student {
private:
std::string name_;
int score_;
};
编译器生成的复制构造函数,其效果大致相当于:
Student::Student(const Student& other)
: name_(other.name_),
score_(other.score_) {
}
每个成员分别复制,这称为:
逐成员复制(member-wise copy)
对于 int 和 std::string,这通常正是我们想要的。
原对象
┌─────────────────┐
│ name_ = "Mishi" │
│ score_ = 100 │
└─────────────────┘
复制对象
┌─────────────────┐
│ name_ = "Mishi" │
│ score_ = 100 │
└─────────────────┘
两个对象各自拥有自己的 std::string。
修改复制对象的字符串不会修改原对象。
这正是编译器默认复制在简单类上表现良好的原因。
但是,如果成员是一个负责拥有动态内存的裸指针,情况就会发生变化。
指针值的复制,不等于指向内容的复制
考虑一个简化版动态数组:
class IntArray {
private:
std::size_t size_;
int* data_;
};
假设原对象的状态是:
original
┌─────────────┐
│ size_ = 5 │
│ data_ ──────┼────→ [0][1][2][3][4]
└─────────────┘
如果使用默认的逐成员复制:
IntArray copied = original;
size_ 的值被复制,data_ 中保存的地址也被复制。
结果是:
original 动态数组
┌─────────────┐ ┌───┬───┬───┬───┬───┐
│ size_ = 5 │ │ 0 │ 1 │ 2 │ 3 │ 4 │
│ data_ ──────┼──────────────→└───┴───┴───┴───┴───┘
└─────────────┘ ↑
│
copied │
┌─────────────┐ │
│ size_ = 5 │ │
│ data_ ──────┼─────────────────────────┘
└─────────────┘
两个指针指向同一个数组。
这称为浅复制(shallow copy)。
浅复制会产生什么问题
问题一:修改一个对象影响另一个对象
copied.data_[0] = 99;
由于两个指针指向同一块内存,original 观察到的第一个元素也会变成 99。
问题二:出现悬空指针
假设 copied 先销毁:
delete[] copied.data_;
动态数组已经被释放,但 original.data_ 中仍然保存着旧地址:
original.data_
↓
[已经被释放的内存]
这种不再指向有效对象的指针称为悬空指针(dangling pointer)。
继续访问会产生未定义行为。
问题三:同一块内存被释放两次
当 original 随后销毁时,它也可能执行:
delete[] original.data_;
但这块内存已经被 copied 释放过。
对同一块动态内存执行两次 delete[] 是未定义行为。
程序可能:
- 崩溃;
- 表面正常运行;
- 破坏其他数据;
- 在完全不同的位置出错。
浅复制是否永远错误
浅复制本身不是绝对错误。
如果两个对象本来就应该共享同一份数据,并且由合适的机制管理共享所有权,浅复制可能是有意设计。
危险的是:
两个对象都认为自己独占并负责释放同一个裸指针所指向的资源。
这里的核心概念是所有权(ownership):
谁负责在资源不再使用时释放它?
如果一个类负责 delete[] data_,那么 data_ 通常是一个拥有资源的指针。
这种类不能直接依赖默认的逐成员复制。
深复制:创建真正独立的数据
我们希望复制后的结构是:
original
┌─────────────┐
│ data_ ──────┼────→ [0][1][2][3][4]
└─────────────┘
copied
┌─────────────┐
│ data_ ──────┼────→ [0][1][2][3][4]
└─────────────┘
两个数组内容相同,但位于不同的内存位置。
这称为深复制(deep copy):
复制对象不仅复制表面的成员值,还为所拥有的资源创建独立副本。
一个简化的模板复制构造函数可以写成:
template <typename T>
Vector<T>::Vector(const Vector<T>& other)
: size_(other.size_),
capacity_(other.capacity_),
data_(new T[other.capacity_]) {
for (std::size_t i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
}
执行过程如下。
第一步:初始化大小和容量
size_(other.size_)
capacity_(other.capacity_)
新对象获得与 other 相同的逻辑大小和容量。
第二步:分配新数组
data_(new T[other.capacity_])
这里没有复制 other.data_ 中保存的地址,而是重新申请一块数组。
第三步:复制有效元素
for (std::size_t i = 0; i < size_; ++i) {
data_[i] = other.data_[i];
}
逐个复制数组元素。
最终两个对象完全独立。
对课件模板代码的纠正
课件中出现了类似下面的形式:
Vector<T>::Vector<T>(const Vector::Vector<T>& other)
更准确的写法应为:
template <typename T>
Vector<T>::Vector(const Vector<T>& other)
构造函数的名称是 Vector,不能在构造函数名后再次写 <T>。
此外,课件提到可以像普通函数一样把实现放进 .cpp。这对普通类成立,但对于类模板,需要额外注意:
模板定义通常必须在使用位置可见,因此类模板的函数实现通常也写在头文件中,或者写在被头文件包含的实现文件中。
否则编译器可能在实例化 Vector<int> 时看不到函数定义,最终产生链接错误。
本节后面的完整案例使用非模板类,以便清晰展示 .h 与 .cpp 的分离。
练习:预测两个危险
下面的类使用编译器生成的复制构造函数:
class Buffer {
public:
explicit Buffer(std::size_t size)
: size_(size),
data_(new int[size]) {
}
~Buffer() {
delete[] data_;
}
private:
std::size_t size_;
int* data_;
};
然后执行:
Buffer first{10};
Buffer second = first;
至少会出现哪两个问题?
答案与解释
编译器生成的复制构造函数会逐成员复制:
second.size_ = first.size_
second.data_ = first.data_
于是两个对象的 data_ 指向同一块数组。
第一个问题是共享修改:
first.data_ ──┐
├──→ 同一个动态数组
second.data_ ──┘
通过一个对象修改数组,另一个对象也会观察到变化。
第二个问题是重复释放。
作用域结束时:
先销毁 second:
delete[] second.data_
再销毁 first:
delete[] first.data_
两次 delete[] 操作的是同一地址,产生未定义行为。
除此之外,第一个对象销毁后,第二个对象的指针还会变成悬空指针。
只写析构函数为什么通常还不够
当一个类需要自己释放动态资源时,我们会编写析构函数:
~Buffer() {
delete[] data_;
}
这说明类很可能拥有某种资源。
一旦对象拥有资源,就必须继续回答两个问题:
- 复制构造时怎样复制资源?
- 给已有对象赋值时怎样替换旧资源?
这引出了三法则(Rule of Three):
如果一个类需要自定义析构函数,它通常也需要自定义复制构造函数和复制赋值运算符。
三者分别负责:
析构函数
负责释放资源
复制构造函数
负责为新对象建立独立资源
复制赋值运算符
负责先处理左侧对象原有资源,再复制右侧资源
对课件说法的纠正
课件中提到:
由于手动管理内存,编译器无法自动生成复制操作。
这句话不够准确。
编译器往往仍然能够生成复制构造函数和复制赋值运算符。
真正的问题是:
编译器生成的版本只会逐成员复制,并不知道这个裸指针代表“独占资源”。
因此,它生成的代码可能能够编译,却在语义上错误。
这类错误比直接编译失败更加危险。
复制赋值还要处理旧状态
复制构造函数面对的是一个尚未存在的新对象:
Buffer second = first;
second 没有旧资源,因此只需要申请并复制。
复制赋值面对的对象已经存在:
Buffer first{10};
Buffer second{100};
second = first;
赋值前:
first.data_ ──→ 10 个元素
second.data_ ──→ 100 个元素
赋值时不能直接覆盖 second.data_:
data_ = new int[other.size_];
否则原来那块 100 个元素的数组地址会丢失,造成内存泄漏。
复制赋值通常需要:
1. 准备右侧对象的副本
2. 释放左侧对象原有资源
3. 接管新资源
4. 返回 *this
还需要考虑自赋值:
buffer = buffer;
如果一上来就释放自己的资源:
delete[] data_;
那么右侧的 other.data_ 也同时失效,因为 other 就是当前对象。
常见保护方式是:
if (this == &other) {
return *this;
}
其中:
this是指向左侧对象的指针;&other是右侧对象的地址;- 两者相等表示正在把对象赋值给自身。
有些对象从设计上就不应该复制
并不是所有类都应该提供复制功能。
假设我们正在设计一个管理敏感信息的对象:
#include <string>
#include <vector>
class PasswordManager {
public:
PasswordManager() = default;
private:
std::vector<std::string> passwords_;
};
默认情况下,std::vector 和 std::string 都支持复制,因此整个 PasswordManager 也可能被复制:
PasswordManager first;
PasswordManager second = first;
但是,类的设计者可能不希望密码管理器被无意复制。
另一类更典型的对象是:
- 文件句柄的唯一拥有者;
- 网络连接;
- 互斥锁;
- 独占系统资源;
- 唯一所有权智能指针。
这些对象复制后会产生“究竟谁拥有资源”的问题。
使用 = delete 禁止操作
C++ 可以显式删除某个函数:
class PasswordManager {
public:
PasswordManager() = default;
PasswordManager(const PasswordManager&) = delete;
PasswordManager& operator=(
const PasswordManager&) = delete;
private:
std::vector<std::string> passwords_;
};
现在下面的代码会在编译期失败:
PasswordManager first;
PasswordManager second = first;
复制赋值也会失败:
PasswordManager first;
PasswordManager second;
second = first;
这里的 = delete 表示:
这个函数参与类的接口设计,但任何尝试调用它的代码都是非法的。
它和释放动态内存的 delete 表达式不是一回事。
delete pointer;
delete[] array;
上面两个 delete 用来释放资源。
PasswordManager(const PasswordManager&) = delete;
这里的 = delete 用来禁止函数调用。
为什么要让编译器阻止错误
如果复制操作在设计上没有合理语义,最安全的做法不是写注释:
// 请不要复制这个对象
而是让代码根本无法编译:
PasswordManager(const PasswordManager&) = delete;
这样错误会在编译期被发现,而不是在运行时变成资源冲突或安全问题。
std::unique_ptr 的思路
std::unique_ptr 表示唯一所有权。
它的核心规则是:
同一时刻只有一个 unique_ptr 拥有对应资源
因此它不允许复制:
std::unique_ptr<int> first;
std::unique_ptr<int> second = first; // 编译错误
但所有权可以通过移动转交给另一个对象。
这正是后面移动语义要解决的问题:
对象不能复制,但可以把资源所有权交出去。
练习:删除了哪个函数还不够
下面的类只删除了复制构造函数:
class Connection {
public:
Connection() = default;
Connection(const Connection&) = delete;
};
判断下面两段代码是否都会失败:
Connection first;
Connection second = first;
Connection first;
Connection second;
second = first;
答案与解释
第一段代码会失败:
Connection second = first;
它需要调用复制构造函数,而复制构造函数已经被删除。
第二段代码不一定因为这一条声明而失败:
second = first;
它需要的是复制赋值运算符:
Connection& operator=(const Connection&);
删除复制构造函数和删除复制赋值运算符是两个不同的决定。
如果类完全不允许复制,应当同时删除:
class Connection {
public:
Connection() = default;
Connection(const Connection&) = delete;
Connection& operator=(const Connection&) = delete;
};
判断时仍然需要先问:
是在创建新对象,还是在修改已有对象?
零法则:能不自己管理资源,就不要自己管理
我们已经看到,拥有裸指针的类需要处理很多细节:
- 析构;
- 深复制;
- 复制赋值;
- 自赋值;
- 异常发生时是否泄漏;
- 移动操作;
- 空对象状态。
如果某个标准库类型已经解决了这些问题,我们通常不应该重新实现一遍。
这就是零法则(Rule of Zero):
如果编译器生成的特殊成员函数已经正确,就不要自己定义特殊成员函数。
例如:
#include <string>
class StringWithId {
public:
StringWithId(int id, const std::string& text)
: id_(id),
text_(text) {
}
int id() const {
return id_;
}
const std::string& text() const {
return text_;
}
private:
int id_;
std::string text_;
};
这个类没有定义:
- 析构函数;
- 复制构造函数;
- 复制赋值运算符;
- 移动构造函数;
- 移动赋值运算符。
因为:
int不需要手动释放;std::string会管理自己的内存;std::string已经正确实现复制、移动和析构。
编译器对 StringWithId 进行逐成员操作,就能得到合理行为。
对课件示例语法的纠正
课件中出现了类似:
class a_string_with_an_id() {
};
类名后不能写函数参数列表。
正确语法是:
class StringWithId {
};
类定义结束后还需要分号。
用标准库容器代替裸数组
需要保存一组整数时,可以写:
#include <vector>
class BetterBuffer {
public:
explicit BetterBuffer(std::size_t size)
: data_(size) {
}
private:
std::vector<int> data_;
};
我们不需要自己写:
~BetterBuffer();
BetterBuffer(const BetterBuffer&);
BetterBuffer& operator=(const BetterBuffer&);
std::vector<int> 已经负责:
- 申请内存;
- 释放内存;
- 深复制;
- 移动资源;
- 处理空容器。
这不是偷懒,而是把资源管理交给已经经过充分测试的组件。
三法则、五法则与零法则之间的关系
三法则
当类自己管理资源时,通常需要考虑:
析构函数
复制构造函数
复制赋值运算符
这就是三法则。
五法则
C++11 引入移动语义后,资源管理类还需要考虑:
移动构造函数
移动赋值运算符
于是形成五法则(Rule of Five):
析构
复制构造
复制赋值
移动构造
移动赋值
这并不表示每个资源管理类都必须手写五个函数,而是说:
一旦你开始手动管理资源,就必须认真决定这五种操作分别应该怎样表现。
零法则
更理想的方向是:
不要直接拥有需要手动释放的裸资源
↓
使用 std::string、std::vector、智能指针等资源管理类型
↓
让成员自己完成复制、移动与析构
↓
当前类不需要自定义特殊成员函数
现代 C++ 的优先顺序通常是:
优先:零法则
必要时:三法则或五法则
操作没有意义时:使用 = delete
课堂综合判断:每一行究竟是什么操作
下面这段代码来自课件中的综合判断题:
#include <vector>
std::vector<int> func(std::vector<int> vec0) {
std::vector<int> vec1;
std::vector<int> vec2(3);
std::vector<int> vec3{3};
std::vector<int> vec4();
std::vector<int> vec5(vec2);
std::vector<int> vec6{};
std::vector<int> vec7{
static_cast<int>(vec2.size() + vec6.size())
};
std::vector<int> vec8 = vec2;
vec8 = vec2;
return vec8;
}
先独立判断每一行的含义。
答案与解释
函数参数 vec0
std::vector<int> func(std::vector<int> vec0)
vec0 是按值传递的参数。
究竟调用复制构造还是移动构造,取决于调用方式。
如果调用者传入左值:
std::vector<int> values;
func(values);
通常需要根据 values 构造参数 vec0,这里会发生复制。
如果传入可以被移动的临时对象或右值:
func(std::vector<int>{1, 2, 3});
可能使用移动或直接构造。
因此,只看函数定义不能绝对断言参数一定由复制构造产生。
课件把它作为“复制构造”的额外判断,是假设调用者传入普通左值。
vec1
std::vector<int> vec1;
调用默认构造函数。
结果是空向量:
vec1 = {}
size = 0
vec2
std::vector<int> vec2(3);
调用带大小参数的普通构造函数,不是特殊成员函数。
它创建三个值初始化的整数:
vec2 = {0, 0, 0}
size = 3
这里的 3 表示元素数量。
vec3
std::vector<int> vec3{3};
这是列表初始化。
对于 std::vector<int>,它会优先匹配接收 std::initializer_list<int> 的构造函数。
结果是只有一个元素的向量:
vec3 = {3}
size = 1
最容易误判成“创建三个元素”。
对比:
std::vector<int> first(3); // {0, 0, 0}
std::vector<int> second{3}; // {3}
括号和花括号在这里具有不同含义。
vec4
std::vector<int> vec4();
这不是创建对象。
它被解析为函数声明:
声明一个名为 vec4 的函数
不接收参数
返回 std::vector<int>
这种现象常被称为最令人困惑的解析(most vexing parse)。
需要创建空向量时,应写:
std::vector<int> vec4;
或者:
std::vector<int> vec4{};
vec5
std::vector<int> vec5(vec2);
vec5 是新对象,根据已有对象 vec2 创建。
调用复制构造函数。
结果:
vec2 = {0, 0, 0}
vec5 = {0, 0, 0}
两个向量管理各自独立的存储空间。
vec6
std::vector<int> vec6{};
语法形式是空列表初始化。
对于 std::vector<int>,它最终创建一个空向量。可以把它理解为通过空列表进行值初始化,并选择默认构造行为。
结果:
vec6 = {}
size = 0
vec7
std::vector<int> vec7{
static_cast<int>(vec2.size() + vec6.size())
};
目前:
vec2.size() = 3
vec6.size() = 0
表达式结果是:
3 + 0 = 3
使用花括号,因此创建的是包含一个元素 3 的向量:
vec7 = {3}
size = 1
static_cast<int> 把无符号大小类型转换为 int,避免花括号初始化中的窄化转换问题。
它并不是创建三个元素。
vec8 的初始化
std::vector<int> vec8 = vec2;
vec8 正在这一行被创建,因此调用复制构造函数。
不是复制赋值。
vec8 的赋值
vec8 = vec2;
vec8 已经存在,因此调用复制赋值运算符。
return vec8
return vec8;
课件把这一行标记为复制构造,但在现代 C++ 中不能简单断言一定发生复制。
编译器可能进行返回值优化,直接在调用者需要的位置构造结果。
即使没有执行复制消除,返回局部变量时也通常会优先尝试移动构造。
更准确地说:
可能直接消除复制或移动;
否则通常尝试移动;
只有无法移动时才可能退回复制。
因此,不要为了“优化”而写:
return std::move(vec8);
这种写法有时反而会妨碍返回值优化。
为什么复制仍然可能太浪费
到目前为止,我们已经能够:
- 创建对象;
- 销毁对象;
- 根据已有对象创建副本;
- 用一个对象替换另一个已有对象;
- 禁止不合理的复制。
但复制还有一个性能问题。
考虑一个保存大量字符串的表格:
#include <map>
#include <string>
class StringTable {
public:
StringTable() = default;
private:
std::map<int, std::string> values_;
};
假设 values_ 中有大量元素。
现在我们需要把 StringTable 交给另一个位置,而且原对象之后再也不会使用:
旧对象:
拥有一张很大的表
目标对象:
需要接管这张表
旧对象之后:
不再使用
使用复制时,程序需要创建完全独立的数据:
原表格
[大量字符串和节点]
逐个复制
↓
新表格
[另一套大量字符串和节点]
复制完成后,原表格马上又被销毁。
这相当于:
先把整个仓库的物品全部复印一份
再把原仓库扔掉
既然原对象不再需要,是否可以直接把它拥有的资源交给新对象?
这就是移动语义(move semantics)出现的原因。
为移动语义建立直觉
可以用搬运购物车建立直觉。
复制
原对象:拥有一辆装满物品的购物车
复制过程:
另外找一辆车
把每件物品复制一份
逐个放入新车
结果:
原对象有一车物品
新对象也有一车独立物品
移动
原对象:拥有一辆装满物品的购物车
移动过程:
直接把购物车的控制权交给新对象
结果:
新对象拥有原来的购物车
原对象不再拥有那辆车
类比只用于建立直觉。
正式语义并不是“内存中的对象瞬间搬到另一处”,而是:
移动操作允许新对象复用旧对象拥有的资源,而不必进行昂贵的深复制。
对于拥有动态数组的对象,移动构造可能只需要转交几个成员:
size_
data_ 指针
而不是复制数组中的每个元素。
移动构造函数
典型声明:
Widget(Widget&& other);
它根据一个可以被转移资源的对象创建新对象。
移动赋值运算符
典型声明:
Widget& operator=(Widget&& other);
它把资源转移给一个已经存在的对象。
T&& 是什么
T&& 称为右值引用(rvalue reference)。
本节先建立下面的直觉:
const T&
表示:我只读取这个对象,不夺走它的资源
T&&
表示:这个对象允许被当作资源来源
这不是完整的左值、右值规则,但足以理解两种函数的目的。
std::move 本身并不搬东西
下面的代码:
#include <utility>
destination = std::move(source);
std::move(source) 本身不会复制或转移任何资源。
它主要是在告诉编译器:
可以把
source当作一个允许被移动的对象,尝试选择移动赋值运算符。
真正的资源转移由对象的移动构造函数或移动赋值运算符完成。
移动后的对象仍然存在
移动后,源对象不会凭空消失,也不会立刻析构。
它仍然是一个有效对象,但其具体内容通常处于有效但未指定状态(valid but unspecified state)。
这意味着:
- 可以安全地销毁;
- 可以重新赋值;
- 可以调用明确允许在这种状态下使用的操作;
- 不应假设它仍然保存移动前的内容。
例如:
#include <utility>
#include <vector>
int main() {
std::vector<int> source{1, 2, 3};
std::vector<int> destination = std::move(source);
source.clear();
source.push_back(10);
}
移动后仍然可以重新使用 source,但不要依赖它移动后立刻具有某个特定大小。
课件在本讲结尾只负责提出移动语义的动机。完整的右值分类、移动构造实现以及 std::move 的细节会成为后续内容。
初学者最容易出现的错误
把所有 = 都当成赋值
Widget second = first;
这是初始化,调用复制构造函数。
second = first;
这才是赋值,调用复制赋值运算符。
判断关键是左侧对象此前是否已经存在。
认为默认构造函数不会初始化成员
默认构造函数的“默认”不是“不处理成员”。
完整对象中的所有成员都必须经历初始化。
区别只在于它们使用什么方式初始化。
在构造函数体中给 const 成员赋值
Example(int value) {
constant_ = value;
}
进入函数体时已经错过了初始化常量成员的时机。
必须使用初始化列表。
认为初始化顺序由初始化列表决定
Example()
: second_(first_),
first_(10) {
}
实际顺序取决于成员声明顺序,而不是列表顺序。
应当让两者保持一致。
复制拥有资源的裸指针
复制指针只复制地址,不复制资源。
默认逐成员复制可能导致:
- 共享修改;
- 悬空指针;
- 重复释放;
- 未定义行为。
只写析构函数,不处理复制
自己写析构函数往往说明类拥有资源。
此时默认复制通常仍能编译,却可能执行危险的浅复制。
忘记自赋值
object = object;
这是合法语句。
复制赋值运算符需要保证这种情况不会先释放再读取同一资源。
把 = delete 和 delete 混为一谈
Function() = delete;
禁止调用函数。
delete pointer;
释放动态对象。
两者作用完全不同。
认为 std::move 一定执行移动
std::move 只是转换表达式的类别。
是否真的发生移动,还取决于类型是否提供可用的移动操作,以及重载选择结果。
综合练习:实现一个能够安全复制的动态缓冲区
下面这个类拥有一块动态数组:
class IntBuffer {
public:
explicit IntBuffer(std::size_t size)
: size_(size),
data_(new int[size]) {
}
~IntBuffer() {
delete[] data_;
}
private:
std::size_t size_;
int* data_;
};
请完成以下任务:
- 说明编译器默认复制为什么不安全;
- 编写默认构造函数;
- 编写深复制构造函数;
- 编写复制赋值运算符;
- 正确处理自赋值;
- 提供下标访问;
- 编写程序验证复制对象相互独立;
- 说明对象销毁时每块内存由谁释放。
综合练习参考实现
IntBuffer.h
#ifndef INT_BUFFER_H
#define INT_BUFFER_H
#include <cstddef>
class IntBuffer {
public:
IntBuffer() = default;
explicit IntBuffer(std::size_t size);
IntBuffer(const IntBuffer& other);
IntBuffer& operator=(const IntBuffer& other);
~IntBuffer();
std::size_t size() const;
int& operator[](std::size_t index);
const int& operator[](std::size_t index) const;
private:
std::size_t size_ = 0;
int* data_ = nullptr;
};
#endif
这里:
IntBuffer() = default;
表示显式要求编译器提供默认构造函数。
默认成员初始化器:
std::size_t size_ = 0;
int* data_ = nullptr;
保证空缓冲区处于有效状态:
size_ = 0
data_ = nullptr
IntBuffer.cpp
#include "IntBuffer.h"
#include <algorithm>
#include <stdexcept>
IntBuffer::IntBuffer(std::size_t size)
: size_(size),
data_(size == 0 ? nullptr : new int[size]{}) {
}
IntBuffer::IntBuffer(const IntBuffer& other)
: size_(other.size_),
data_(other.size_ == 0
? nullptr
: new int[other.size_]) {
if (size_ != 0) {
std::copy_n(other.data_, size_, data_);
}
}
IntBuffer& IntBuffer::operator=(const IntBuffer& other) {
if (this == &other) {
return *this;
}
int* new_data =
other.size_ == 0
? nullptr
: new int[other.size_];
if (other.size_ != 0) {
std::copy_n(
other.data_,
other.size_,
new_data
);
}
delete[] data_;
data_ = new_data;
size_ = other.size_;
return *this;
}
IntBuffer::~IntBuffer() {
delete[] data_;
}
std::size_t IntBuffer::size() const {
return size_;
}
int& IntBuffer::operator[](std::size_t index) {
if (index >= size_) {
throw std::out_of_range{
"IntBuffer index out of range"
};
}
return data_[index];
}
const int& IntBuffer::operator[](
std::size_t index
) const {
if (index >= size_) {
throw std::out_of_range{
"IntBuffer index out of range"
};
}
return data_[index];
}
main.cpp
#include "IntBuffer.h"
#include <iostream>
#include <string>
void printBuffer(
const std::string& name,
const IntBuffer& buffer
) {
std::cout << name << ": ";
for (std::size_t i = 0; i < buffer.size(); ++i) {
std::cout << buffer[i] << ' ';
}
std::cout << '\n';
}
int main() {
IntBuffer original{3};
original[0] = 10;
original[1] = 20;
original[2] = 30;
IntBuffer copied = original;
copied[0] = 99;
IntBuffer assigned{1};
assigned = original;
assigned[1] = 88;
original = original;
printBuffer("original", original);
printBuffer("copied", copied);
printBuffer("assigned", assigned);
}
编译命令:
g++ -std=c++20 main.cpp IntBuffer.cpp -o main
运行:
./main
程序输出:
original: 10 20 30
copied: 99 20 30
assigned: 10 88 30
创建 original
IntBuffer original{3};
调用:
IntBuffer::IntBuffer(std::size_t size)
对象状态:
original
┌────────────┐
│ size_ = 3 │
│ data_ ─────┼──→ [0][0][0]
└────────────┘
new int[size]{} 中的 {} 会把整数初始化为零。
创建 copied
IntBuffer copied = original;
调用复制构造函数。
它重新申请一块数组:
original
┌────────────┐
│ data_ ─────┼──→ [10][20][30]
└────────────┘
copied
┌────────────┐
│ data_ ─────┼──→ [10][20][30]
└────────────┘
两个 data_ 保存不同地址。
随后:
copied[0] = 99;
只修改 copied 的数组。
所以:
original = [10][20][30]
copied = [99][20][30]
给 assigned 赋值
IntBuffer assigned{1};
此时:
assigned.data_ ──→ [0]
接着:
assigned = original;
复制赋值运算符先申请新数组,并复制 original 的数据。
只有准备成功后,才执行:
delete[] data_;
释放 assigned 原先那块长度为 1 的数组。
然后让 assigned.data_ 指向新数组。
这样不会泄漏原有资源。
自赋值
original = original;
调用时:
this == &other
因此直接返回:
if (this == &other) {
return *this;
}
原对象保持不变。
生命周期结束
main() 结束时,对象按逆序销毁:
assigned
copied
original
每个对象的 data_ 指向不同数组,因此每个析构函数释放自己的资源:
~assigned() → 释放 assigned 的数组
~copied() → 释放 copied 的数组
~original() → 释放 original 的数组
不存在重复释放。
这个实现还留下了什么问题
虽然它已经正确实现三法则,但代码仍然相当复杂。
我们需要自己处理:
- 动态分配;
- 深复制;
- 自赋值;
- 越界;
- 空对象;
- 析构。
如果把成员改为:
std::vector<int> data_;
绝大部分特殊成员函数都可以删除,重新回到零法则。
这也是实际项目中更推荐的设计。
本节知识的完整串联
本节课的逻辑可以串成一条连续的问题链:
运算符可以被实现为函数
↓
operator= 也是一个函数
↓
对象创建时的复制与已有对象赋值不是一回事
↓
C++ 使用复制构造函数和复制赋值运算符分别处理
↓
即使没有手写,编译器也可能提供逐成员版本
↓
对于 int、std::string、std::vector,逐成员操作通常正确
↓
对于拥有动态资源的裸指针,逐成员复制只会复制地址
↓
多个对象可能共同释放同一资源
↓
需要深复制、析构与正确的复制赋值
↓
形成三法则
↓
如果复制没有合理语义,就使用 = delete
↓
如果成员已经会管理资源,优先遵守零法则
↓
如果原对象即将不再使用,深复制又可能非常浪费
↓
需要移动构造和移动赋值
↓
形成五法则,并自然进入移动语义
六种特殊成员函数也可以按照对象生命周期重新排列:
创建一个普通对象
T()
根据旧对象创建新对象
T(const T&)
T(T&&)
修改一个已有对象
operator=(const T&)
operator=(T&&)
结束对象生命周期
~T()
判断具体调用哪个函数时,可以依次询问:
1. 是否正在创建一个新对象?
是:
- 没有来源对象 → 普通或默认构造
- 来源对象仍需保留 → 复制构造
- 来源对象允许交出资源 → 移动构造
2. 左侧对象是否已经存在?
是:
- 来源对象仍需保留 → 复制赋值
- 来源对象允许交出资源 → 移动赋值
3. 对象生命周期是否结束?
是:
- 调用析构函数
下一步:资源为什么能够被“移动”
我们已经知道移动操作的目标:
避免昂贵的深复制
直接转移资源所有权
但仍然留下了几个尚未解决的问题:
- 什么样的表达式可以绑定到
T&&? - 左值和右值究竟是什么?
- 为什么一个有名字的
T&&变量本身又会表现得像左值? std::move为什么只进行类型转换?- 移动构造函数怎样安全地“偷走”指针?
- 移动后为什么必须把原指针设为
nullptr? - 为什么移动构造函数经常标记为
noexcept? - 编译器在什么情况下不会自动生成移动操作?
这些问题需要进一步学习右值引用、移动构造函数和移动赋值运算符。
到那时,我们会把现在的资源关系:
source ──→ 大型资源
变成:
移动前:
source destination
│ │
▼ ▼
大型资源 空状态
移动后:
source destination
│ │
▼ ▼
有效空状态 原大型资源
这会完成特殊成员函数的最后两块拼图。