Lecture 13 (Week 7 - Tuesday): 特殊成员函数 (Special Member Functions)

目录 · ← l12 · l14 →

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::stringstd::vector),就一个都别写。
    • Rule of Three:如果你需要自定义析构函数(通常意味着手动管理资源),那么也必须自定义拷贝构造和拷贝赋值。只写析构、不写拷贝,浅拷贝/双重释放就会乘虚而入。
    • Rule of Five:如果定义了拷贝构造/拷贝赋值/析构(Rule of Three 三者之一),通常还应定义移动构造和移动赋值,否则代码会退回到昂贵的拷贝路径(性能问题,非正确性问题)。
  • 设计意图与最佳实践:从”默认能用就不写”(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 intint&。构造函数用初始化列表 _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] 不影响 vu = v; 触发拷贝赋值:先 delete[]u 原来的数组,再分配新内存并复制。
  • 特性机制解说:拷贝构造的 _data(new T[other._capacity]) 是关键——为副本分配全新的内存,然后循环逐元素复制,这就是深拷贝,代价是 O(n)。而默认的逐成员拷贝会把 _data 原样复制,导致 vw 共享同一数组:修改互相影响,且两个析构函数会 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 的宝物(改名、改价)不会动 bd = b; 用拷贝赋值把 b 的内容复制进已有的 db = 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 定义了析构却没有定义拷贝,编译器照样生成逐成员拷贝,于是两个对象共享堆内存。局部对象按”后声明先析构”的顺序销毁:bdeleteadelete 同一块内存——第二遍 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
}
  • 代码做什么:类只含 intstd::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: Treebookassign5/)直接相关:作业要求你在 user.h/user.cpp 中为 User 类(内含裸指针数组 _friends)实现/删除特殊成员函数:

  1. 实现析构 ~User()——释放 _friends 数组(本讲”析构释放堆内存”)。
  2. 实现拷贝构造 User(const User& user)——在成员初始化列表中分配新的 _friends 数组,再循环复制元素,并设置 _size_capacity_name(本讲 Vector/Pirate 深拷贝的完全同款写法)。
  3. 实现拷贝赋值 User& operator=(const User& user)——先释放旧数组、深拷贝新数组、返回 *this,记得自赋值检查。
  4. 删除移动构造 User(User&&) = delete; 与移动赋值 User& operator=(User&&) = delete;——用本讲的 = delete 语法(与 std::unique_ptr 相反:unique_ptr 保留移动、删除拷贝;Treebook 要求删除移动、保留拷贝)。
  5. Part 3 的 operator<<(friend 函数,访问私有 _friends)和 operator<operator+= 则呼应了上一讲的重载内容。

编译命令 g++ -std=c++20 main.cpp user.cpp -o main 中的多文件编译会在 Lecture 16 详解。