K KASS 返回文章列表
公开文章

READING APPEARANCE

选择阅读主题

选择会保存在当前设备,下次阅读自动沿用。

C++ / 2026-08-01

CS106L 第 13 讲:特殊成员函数

理解对象如何被创建、复制、赋值、移动与销毁,以及资源管理规则。

CS106L 第 13 讲:特殊成员函数 的封面
C++ · CLASS-C

本课程根据 CS106L Lecture 13: Special Member Functions 重构。

本节课要解决的核心问题

我们已经能够自己设计类、编写构造函数、重载运算符,看起来已经可以让对象正常工作了。

但是,一旦程序出现下面这些代码,新的问题就来了:

Widget first;
Widget second = first;

Widget third;
third = first;

这里发生了什么?

  • second 是怎样根据 first 创建出来的?
  • third = firstWidget second = first 中的 = 是同一种操作吗?
  • 即使我们没有写复制函数,为什么代码仍然能够编译?
  • 如果对象内部保存了一个指针,复制指针是否等于复制了指针指向的数据?
  • 如果某种对象根本不应该被复制,怎样让编译器禁止这种操作?
  • 如果原对象马上就不再使用,为什么还要辛苦复制全部数据?

这些问题共同指向了 C++ 类中一组特殊的函数:

特殊成员函数(special member functions,SMFs)

它们负责管理对象完整的一生:

创建对象
   ↓
复制对象
   ↓
给已有对象赋值
   ↓
转移对象资源
   ↓
对象生命周期结束

学习本节所需的前置知识

阅读本节课程前,只需要对下面的内容有基本印象:

  • 类与对象;
  • publicprivate
  • 构造函数与析构函数;
  • 引用,例如 const Widget&
  • 指针;
  • new[]delete[]
  • 基础运算符重载;
  • std::stringstd::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

逐步分析:

  1. Widget first;

    创建 first,调用默认构造函数。

  2. Widget second = first;

    second 尚不存在,因此调用复制构造函数。

  3. Widget third;

    创建 third,调用默认构造函数。

  4. third = second;

    third 已经存在,因此调用复制赋值运算符。

  5. 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)

对于 intstd::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_;
}

这说明类很可能拥有某种资源。

一旦对象拥有资源,就必须继续回答两个问题:

  1. 复制构造时怎样复制资源?
  2. 给已有对象赋值时怎样替换旧资源?

这引出了三法则(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::vectorstd::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;

这是合法语句。

复制赋值运算符需要保证这种情况不会先释放再读取同一资源。

= deletedelete 混为一谈

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_;
};

请完成以下任务:

  1. 说明编译器默认复制为什么不安全;
  2. 编写默认构造函数;
  3. 编写深复制构造函数;
  4. 编写复制赋值运算符;
  5. 正确处理自赋值;
  6. 提供下标访问;
  7. 编写程序验证复制对象相互独立;
  8. 说明对象销毁时每块内存由谁释放。

综合练习参考实现

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
   │             │
   ▼             ▼
有效空状态      原大型资源

这会完成特殊成员函数的最后两块拼图。