Lecture 13 (Week 7 - Tuesday): 特殊成员函数 (Special Member Functions)
Lecture 13 (Week 7 - Tuesday): 特殊成员函数 (Special Member Functions)
概述
本讲介绍 C++ 类的六大特殊成员函数(Special Member Functions, SMFs)——默认构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符。这些函数编译器会自动生成,但一旦类管理堆内存(如指针成员),默认的”逐成员拷贝”就会造成浅拷贝、双重释放等严重 bug,因此我们需要理解它们何时被调用、何时必须手写。本讲还涵盖成员初始化列表(const/引用成员的唯一初始化途径)、= delete 删除函数,以及 Rule of Zero / Three / Five 三条黄金法则。
核心特性与语法详解
1. 六大特殊成员函数(SMFs)总览
- 定义与目的:特殊成员函数负责类的”生命周期”——对象的创建、复制、移动与销毁。每个类都”天然拥有”这 6 个函数:只要你在使用它们(且没有显式定义过),编译器就会自动生成默认版本。C++ 允许你显式覆盖其中任意一个,从而精确控制类的行为。
- 核心语法:
T(); // 默认构造函数 ~T(); // 析构函数 T(const T& other); // 拷贝构造函数 T& operator=(const T& other); // 拷贝赋值运算符 T(T&& other); // 移动构造函数(本讲先了解,下讲详解) T& operator=(T&& other); // 移动赋值运算符 - 设计意图与最佳实践:编译器免费生成的版本对大多数”自管理”的类已经足够(见 Rule of Zero);只有当默认行为不正确时(典型场景:类持有指向堆内存的裸指针)才需要手写。手写时遵循 Rule of Three/Five。
2. 拷贝构造 vs 拷贝赋值:触发时机
- 定义与目的:两者都做”拷贝”,但语义完全不同,是最容易混淆的一对。
- 核心语法:
Widget widgetTwo = widgetOne; // 拷贝构造:新建对象时初始化 Widget a, b; a = b; // 拷贝赋值:对象已存在,替换内容 - 设计意图与最佳实践:判断口诀——等号右侧对象是否”刚被创建”。如果是新对象的初始化(
T b = a;、按值传参、按值返回),走拷贝构造;如果两边都已存在(a = b;),走拷贝赋值。特别注意T b = a;是拷贝构造不是赋值!另外,T b(a);也是拷贝构造。
3. 成员初始化列表(Member Initializer List)
- 定义与目的:在构造函数体执行之前直接以期望的值构造成员变量。若在函数体内赋值,成员会先被默认初始化、再被赋值,等于做了两遍工作(低效);更严重的是,const 成员和引用成员只能通过初始化列表初始化,因为它们在诞生那一刻就必须有值、之后不可改。
- 核心语法:
template <typename T> Vector<T>::Vector() : _size(0), _capacity(4), _data(new T[_capacity]) {} - 设计意图与最佳实践:所有构造函数(包括带参的)都应优先使用初始化列表;成员按声明顺序(而非列表书写顺序)初始化,因此列表书写顺序最好与声明顺序一致,避免混淆。
4. 浅拷贝 vs 深拷贝
- 定义与目的:默认的拷贝构造/拷贝赋值对每个成员做”逐成员拷贝(member-wise copy)”。当成员是指针时,逐成员拷贝只复制指针值本身,两个对象将指向同一块堆内存——这就是浅拷贝。任何一方修改或销毁数据都会影响另一方,析构时还会双重释放。深拷贝则分配一块新的内存并把数据逐个复制过去,得到完全独立的一份。
- 核心语法:
// 浅拷贝(编译器默认,危险!) Vector(const Vector& other) : _size(other._size), _capacity(other._capacity), _data(other._data) {} // 深拷贝(手写,正确) Vector(const Vector& other) : _size(other._size), _capacity(other._capacity), _data(new T[other._capacity]) { for (size_t i = 0; i < _size; ++i) _data[i] = other._data[i]; } - 设计意图与最佳实践:只要类拥有”独享”的堆资源,就必须手写深拷贝版本的拷贝构造与拷贝赋值,同时手写析构释放资源(三者成组出现,即 Rule of Three)。
5. = delete 删除函数
- 定义与目的:显式地移除某个特殊成员函数(或普通函数)的功能。典型用途:禁止拷贝(如
std::unique_ptr就是删除拷贝、只保留移动)、禁止默认构造、禁止某些重载参与重载决议。 - 核心语法:
class PasswordManager { public: PasswordManager(const PasswordManager&) = delete; // 禁止拷贝构造 PasswordManager& operator=(const PasswordManager&) = delete; // 禁止拷贝赋值 }; - 设计意图与最佳实践:任何尝试拷贝的代码都会在编译期报错,把错误从运行时提前到编译期。对比 C++98 把拷贝构造放进
private且不实现的”hack”,= delete意图清晰、报错信息友好。
6. Rule of Zero / Three / Five
- 定义与目的:回答”我到底要手写几个特殊成员函数?”的经验法则。
- 核心语法(三条规则):
- Rule of Zero:如果默认生成的 SMF 就能正确工作(成员都是自管理的类型,如
std::string、std::vector),就一个都别写。 - Rule of Three:如果你需要自定义析构函数(通常意味着手动管理资源),那么也必须自定义拷贝构造和拷贝赋值。只写析构、不写拷贝,浅拷贝/双重释放就会乘虚而入。
- Rule of Five:如果定义了拷贝构造/拷贝赋值/析构(Rule of Three 三者之一),通常还应定义移动构造和移动赋值,否则代码会退回到昂贵的拷贝路径(性能问题,非正确性问题)。
- Rule of Zero:如果默认生成的 SMF 就能正确工作(成员都是自管理的类型,如
- 设计意图与最佳实践:从”默认能用就不写”(Zero)出发;一旦涉及手工资源管理,则按 Three 补齐拷贝、按 Five 再补移动。
代码示例与逐步解说(核心)
示例 1:成员初始化列表与 const/引用成员(C++11)
// C++11
#include <iostream>
class MyClass {
const int _constant; // const 成员:只能初始化,不能赋值
int& _reference; // 引用成员:诞生时必须绑定对象
public:
// 只有成员初始化列表能初始化 const 和引用成员
MyClass(int value, int& ref) : _constant(value), _reference(ref) {}
void print() const {
std::cout << "constant = " << _constant
<< ", reference = " << _reference << '\n';
}
};
int main() {
int x = 42;
MyClass obj(7, x);
obj.print(); // constant = 7, reference = 42
x = 100; // 修改外部变量 x
obj.print(); // constant = 7, reference = 100(引用跟着变)
}
- 代码做什么:
MyClass有两个”非可赋值”成员——const int和int&。构造函数用初始化列表_constant(value), _reference(ref)在成员诞生时直接赋值。main 中构造obj并打印两次,第二次打印前外部变量x被改成 100,于是_reference显示 100,而_constant保持 7。 - 特性机制解说:
const成员一旦被赋值就不能再改,引用成员必须在初始化时绑定目标——两者都”不可重新赋值”。如果在构造函数体内写_constant = value;,此时成员已经被默认初始化过了(const 成员的默认初始化甚至不合法),必然编译失败。这正是初始化列表存在的意义:在成员构造那一刻就给出初值,一步到位,避免”先默认构造、再赋值”的双倍开销。
示例 2:Vector 的深拷贝(改写自幻灯片与课堂 Vector)(C++11)
// C++11
#include <iostream>
template <typename T>
class Vector {
public:
// 默认构造:初始化列表一步到位
Vector() : _size(0), _capacity(4), _data(new T[_capacity]) {}
// 拷贝构造:深拷贝
Vector(const Vector& other)
: _size(other._size), _capacity(other._capacity),
_data(new T[other._capacity]) {
for (size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
}
// 拷贝赋值:先释放自己的旧内存,再深拷贝
Vector& operator=(const Vector& other) {
if (this == &other) return *this; // 自赋值保护
delete[] _data;
_size = other._size;
_capacity = other._capacity;
_data = new T[_capacity];
for (size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
return *this;
}
// 析构:释放堆数组
~Vector() { delete[] _data; }
void push_back(const T& value) {
if (_size == _capacity) {
_capacity *= 2;
T* bigger = new T[_capacity];
for (size_t i = 0; i < _size; ++i) bigger[i] = _data[i];
delete[] _data;
_data = bigger;
}
_data[_size++] = value;
}
size_t size() const { return _size; }
T& operator[](size_t i) { return _data[i]; }
private:
size_t _size;
size_t _capacity;
T* _data;
};
int main() {
Vector<int> v;
v.push_back(10);
v.push_back(20);
Vector<int> w = v; // 拷贝构造:深拷贝
w[0] = 99; // 只修改 w,不影响 v
std::cout << v[0] << ' ' << v[1] << '\n'; // 10 20
std::cout << w[0] << ' ' << w[1] << '\n'; // 99 20
Vector<int> u;
u = v; // 拷贝赋值
std::cout << u[0] << ' ' << u[1] << '\n'; // 10 20
}
- 代码做什么:
v里存了两个元素;Vector<int> w = v;触发拷贝构造,w获得独立的内存并逐个复制元素;修改w[0]不影响v。u = v;触发拷贝赋值:先delete[]掉u原来的数组,再分配新内存并复制。 - 特性机制解说:拷贝构造的
_data(new T[other._capacity])是关键——为副本分配全新的内存,然后循环逐元素复制,这就是深拷贝,代价是 O(n)。而默认的逐成员拷贝会把_data原样复制,导致v和w共享同一数组:修改互相影响,且两个析构函数会delete[]同一块内存两次——双重释放(double free),属于未定义行为。拷贝赋值还必须先delete[]自己的旧数据,否则会泄漏旧内存;自赋值检查if (this == &other)防止u = u时”先释放再复制”把数据弄丢。
示例 3:Pirate 课堂练习完成版——深拷贝全家桶(C++11)
// C++11(课堂 pirate.cpp 练习的完成版)
#include <iostream>
#include <string>
class Treasure {
public:
std::string name;
int goldValue;
Treasure(std::string name, int goldValue)
: name(name), goldValue(goldValue) {}
};
class Pirate {
private:
Treasure* treasure; // 指向堆上的 Treasure
public:
// 1. 默认构造:分配一块新的 Treasure
Pirate() : treasure(new Treasure("Rusty Spoon", 1)) {}
// 2. 带参构造
Pirate(std::string itemName, int value)
: treasure(new Treasure(itemName, value)) {}
// 3. 拷贝构造:深拷贝!
Pirate(const Pirate& other)
: treasure(new Treasure(other.treasure->name,
other.treasure->goldValue)) {}
// 4. 拷贝赋值:自赋值检查 → 释放旧的 → 深拷贝
Pirate& operator=(const Pirate& other) {
if (this == &other) return *this;
delete treasure;
treasure = new Treasure(other.treasure->name,
other.treasure->goldValue);
return *this;
}
// 5. 析构:释放堆内存
~Pirate() { delete treasure; }
void renameTreasure(std::string newName) { treasure->name = newName; }
void upgradeTreasure(int extraGold) { treasure->goldValue += extraGold; }
void print() const {
std::cout << treasure->name << " worth "
<< treasure->goldValue << " gold\n";
}
};
int main() {
Pirate b("Golden Crown", 500);
Pirate c = b; // 拷贝构造:深拷贝
c.renameTreasure("Fake Crown");
c.upgradeTreasure(-400);
b.print(); // Golden Crown worth 500 gold(b 不受影响)
c.print(); // Fake Crown worth 100 gold
Pirate d("Broken Bottle", 2);
d = b; // 拷贝赋值:先清理再深拷贝
d.renameTreasure("Stolen Crown");
d.upgradeTreasure(300);
b.print(); // Golden Crown worth 500 gold
d.print(); // Stolen Crown worth 800 gold
b = b; // 自赋值:安全,什么都不发生
}
- 代码做什么:
Pirate c = b;用深拷贝构造出独立的海盗;改c的宝物(改名、改价)不会动b。d = b;用拷贝赋值把b的内容复制进已有的d;b = b自赋值被if (this == &other) return *this;拦住。 - 特性机制解说:
Pirate的成员是裸指针Treasure*,编译器默认的浅拷贝会让两个海盗共享同一份宝物——改一个另一个跟着变,而且析构时双重释放。深拷贝为每个副本new一块独立内存,使拷贝成为完全独立的个体。拷贝赋值比拷贝构造多两步:先释放自己原有的资源(否则旧宝物泄漏),再检查自赋值(否则b = b会先 delete 掉自己的宝物再读它,读到悬垂指针)。三个函数(析构 + 拷贝构造 + 拷贝赋值)必须成套出现,正是 Rule of Three。
示例 4:浅拷贝的双重释放与 = delete(C++11)
// C++11(危险示例,仅用于演示浅拷贝)
#include <iostream>
class Shallow {
int* data;
public:
explicit Shallow(int v) : data(new int(v)) {}
~Shallow() { delete data; } // 自定义析构,但拷贝仍是默认的逐成员拷贝!
int get() const { return *data; }
};
int main() {
Shallow a(42);
Shallow b = a; // 默认拷贝构造:b.data 与 a.data 指向同一块内存
std::cout << b.get() << '\n'; // 42(暂时正常)
} // 析构顺序:b 先析构 → delete 共享内存;a 再析构 → 再次 delete → 双重释放!
// C++11:用 = delete 阻止拷贝
#include <iostream>
class PasswordManager {
public:
PasswordManager() = default;
PasswordManager(const PasswordManager&) = delete; // 禁止拷贝构造
PasswordManager& operator=(const PasswordManager&) = delete; // 禁止拷贝赋值
};
int main() {
PasswordManager pm;
// PasswordManager pm2 = pm; // ❌ 编译错误:拷贝构造已删除
// pm = PasswordManager(); // ❌ 编译错误:拷贝赋值已删除
std::cout << "Copies are disabled.\n";
}
- 代码做什么:第一个程序能编译、能打印 42,但程序结束时会双重释放(double free)——运行时崩溃或未定义行为。第二个程序把拷贝构造/拷贝赋值标为
= delete,任何拷贝尝试都变成编译错误。 - 特性机制解说:
Shallow定义了析构却没有定义拷贝,编译器照样生成逐成员拷贝,于是两个对象共享堆内存。局部对象按”后声明先析构”的顺序销毁:b先delete,a再delete同一块内存——第二遍 delete 是未定义行为。这演示了 Rule of Three 的反例:只写析构、不写拷贝 = 双重释放。= delete与”不定义”不同:它是显式地让函数参与重载决议但一调用就报编译错误,比 C++98 的 private 技巧更清晰、更早暴露问题。std::unique_ptr正是靠删除拷贝、保留移动来实现”唯一所有权”的。
示例 5:Rule of Zero——什么都不用写(C++11)
// C++11:Rule of Zero
#include <iostream>
#include <string>
#include <utility>
class a_string_with_an_id {
public:
a_string_with_an_id(int id, std::string str)
: id_(id), str_(std::move(str)) {}
void print() const {
std::cout << id_ << ": " << str_ << '\n';
}
private:
int id_;
std::string str_; // std::string 是自管理类型
};
int main() {
a_string_with_an_id a(1, "hello");
a_string_with_an_id b = a; // 自动生成的拷贝构造
b.print(); // 1: hello
a_string_with_an_id c = std::move(a); // 自动生成的移动构造
c.print(); // 1: hello
}
- 代码做什么:类只含
int和std::string两个自管理成员,因此一行 SMF 都不用写。拷贝构造、拷贝赋值、移动构造、移动赋值、析构全部由编译器自动生成且行为完全正确。 - 特性机制解说:编译器生成的 SMF 会递归调用成员自己的 SMF:拷贝
str_时调用std::string的拷贝构造(深拷贝其内部缓冲),析构时调用std::string的析构(释放其内部缓冲)。因为std::string自己已经正确实现了全套 SMF,外层类就无需重复实现——这就是 Rule of Zero 的底层原理:把资源管理委托给自管理的成员,自己什么都不写。
与旧标准(如C++98)的对比
= delete是 C++11 才有的:C++98 中要禁止拷贝,只能把拷贝构造/拷贝赋值声明在private区且不给定义;”意外调用”会得到晦涩的链接错误,且类内部/友元仍可能误用。C++11 的= delete把禁止意图写在声明处,任何调用(包括类内)都是清晰的编译错误。- 移动构造/移动赋值是 C++11 才有的:C++98 只有”默认构造 + 析构 + 拷贝构造 + 拷贝赋值”四个 SMF(即 Rule of Three),一切”转移”都只能靠拷贝模拟,性能浪费严重。C++11 新增移动语义(详见 Lecture 14)。
- 成员初始化列表 C++98 已有:初始化列表不是新特性,但 C++98 中 const/引用成员同样只能靠它初始化;现代 C++ 只是更强调”初始化优于赋值”(统一初始化、
{}语法等)。 - C++98 的替代手段:想要”只允许一份实例”时,C++98 的做法是私有拷贝 + 友元或单例模式;现代 C++ 直接用
= delete更简单直接。
关键要点
- 六大特殊成员函数(默认构造、析构、拷贝构造、拷贝赋值、移动构造、移动赋值)由编译器按需自动生成;手写任何一个拷贝/析构相关函数时,就要想想是否该成套补齐。
- 判断拷贝构造还是拷贝赋值:新对象初始化 → 拷贝构造;两边都已存在 → 拷贝赋值;
T b = a;是拷贝构造而不是赋值。 - 涉及裸指针/堆内存时,默认的浅拷贝会造成共享内存、双重释放;必须手写深拷贝(分配新内存 + 逐元素复制)。
- 用成员初始化列表初始化成员:更快,而且是 const/引用成员唯一合法的初始化方式。
- 用
= delete在编译期禁止不需要的操作(如std::unique_ptr禁止拷贝);遵循 Rule of Zero(默认能用就不写)→ Three(写析构就补拷贝)→ Five(再补移动)。
常见陷阱与注意事项
- 忘记深拷贝:只写了析构没写拷贝构造/拷贝赋值(违反 Rule of Three),导致浅拷贝共享内存、双重释放。写”Big Three/Four”时要成套。
- 忘记自赋值检查:拷贝赋值里若先
delete自己的资源再复制other,遇到a = a时会读悬垂指针。开头加if (this == &other) return *this;。 T b = a;误以为是”赋值”:这是拷贝构造!真正触发拷贝赋值的是b = a;(b 已存在)。- 最令人头疼的解析(Most Vexing Parse):
Widget w();声明的是一个返回Widget的函数,而不是默认构造!想要默认构造请写Widget w;或Widget w{};(幻灯片小测中的vector<int> vec4 ();即为此陷阱)。 - 在构造函数体内给成员赋值而非用初始化列表:低效(先默认构造再赋值),且 const/引用成员直接编译失败。
- 拷贝赋值中忘记释放旧资源:直接
_data = new ...会让旧内存泄漏。
关联作业提示
本讲与 A5: Treebook(assign5/)直接相关:作业要求你在 user.h/user.cpp 中为 User 类(内含裸指针数组 _friends)实现/删除特殊成员函数:
- 实现析构
~User()——释放_friends数组(本讲”析构释放堆内存”)。 - 实现拷贝构造
User(const User& user)——在成员初始化列表中分配新的_friends数组,再循环复制元素,并设置_size、_capacity、_name(本讲 Vector/Pirate 深拷贝的完全同款写法)。 - 实现拷贝赋值
User& operator=(const User& user)——先释放旧数组、深拷贝新数组、返回*this,记得自赋值检查。 - 删除移动构造
User(User&&) = delete;与移动赋值User& operator=(User&&) = delete;——用本讲的= delete语法(与std::unique_ptr相反:unique_ptr 保留移动、删除拷贝;Treebook 要求删除移动、保留拷贝)。 - Part 3 的
operator<<(friend 函数,访问私有_friends)和operator<、operator+=则呼应了上一讲的重载内容。
编译命令 g++ -std=c++20 main.cpp user.cpp -o main 中的多文件编译会在 Lecture 16 详解。
