C++ 中很多看似简单的代码,都隐含着对象生命周期管理:对象什么时候构造,什么时候复制或移动,什么时候析构,以及资源由谁释放。

特殊成员函数正是连接这些阶段的语言机制。理解它们,不只是为了记住“哪些函数会自动生成”,更是为了回答下面这些问题:

  • 为什么定义一个析构函数后,类可能突然不能移动?
  • T a = b 和 a = b 分别调用了什么函数?
  • std::move 之后的对象还能不能使用?
  • 为什么使用 std::vector、std::string 和智能指针时,通常不需要自己编写复制和移动函数?

本文以对象生命周期为主线,整理这些规则。示例以 C++17 为基准。

一、先理解对象生命周期

一个对象的生命周期通常可以分成四个阶段:

  1. 为对象准备存储空间并开始构造。
  2. 初始化对象及其基类、成员对象。
  3. 在生命周期内使用对象,也可能复制或移动对象。
  4. 离开生命周期后销毁对象及其拥有的资源。

最直观的例子是局部对象:

#include <iostream>
#include <string>
#include <utility>

class Trace {
public:
  explicit Trace(std::string name) : name_(std::move(name)) {
    std::cout << "construct " << name_ << '\n';
  }

  ~Trace() {
    std::cout << "destroy " << name_ << '\n';
  }

private:
  std::string name_;
};

void Demo() {
  Trace outer{"outer"};
  {
    Trace inner{"inner"};
  } // inner 在这里销毁
} // outer 在这里销毁

输出顺序为:

construct outer
construct inner
destroy inner
destroy outer

1.1 构造和销毁顺序

对于一个派生类对象,构造顺序是:

  1. 虚基类和非虚基类。
  2. 按声明顺序构造成员对象。
  3. 执行派生类构造函数体。

销毁顺序完全相反:

  1. 执行析构函数体。
  2. 按声明逆序销毁成员对象。
  3. 按继承逆序销毁基类对象。

成员的初始化顺序由成员声明顺序决定,而不是初始化列表中的书写顺序:

class Example {
public:
  // 即使这里先写 second,first 仍然先构造
  Example() : second_{}, first_{} {}

private:
  int first_;
  int second_;
};

因此,初始化列表最好始终按照成员声明顺序书写。这样既符合实际执行顺序,也不会因为编译器警告而遗漏依赖关系。

如果某个成员或基类构造失败,已经完成构造的成员和基类仍然会被销毁,但外层对象的析构函数体不会执行。这也是构造函数中使用 RAII 类型管理资源的重要原因。

1.2 自动存储和动态存储

对象的存储方式不同,生命周期的控制者也不同:

#include <memory>

void Demo() {
  Trace local{"local"};

  auto dynamic = std::make_unique<Trace>("dynamic");
} // local 和 dynamic 都在这里销毁

局部对象通常在离开作用域时销毁,动态对象则由拥有它的智能指针在自身销毁时释放。手动管理 new 和 delete 容易在异常、提前返回和多条分支中遗漏释放,优先使用 RAII 类型通常更安全。

二、什么是特殊成员函数

C++ 将下面六类函数称为特殊成员函数:

函数 作用
默认构造函数 T() 在没有提供初始化参数时构造对象
析构函数 ~T() 销毁对象并释放对象管理的资源
复制构造函数 T(const T&) 使用已有对象创建新对象
复制赋值运算符 T& operator=(const T&) 将已有对象的值复制给另一个已有对象
移动构造函数 T(T&&) 使用右值对象创建新对象
移动赋值运算符 T& operator=(T&&) 将右值对象的状态转移给另一个已有对象

下面的代码展示了它们对应的调用场景:

#include <string>
#include <utility>

class Widget {
public:
  Widget() = default;

  Widget(const Widget& other);            // 复制构造
  Widget& operator=(const Widget& other); // 复制赋值

  Widget(Widget&& other) noexcept;            // 移动构造
  Widget& operator=(Widget&& other) noexcept; // 移动赋值

  ~Widget();

private:
  std::string value_;
};

void Use() {
  Widget first;                   // 默认构造
  Widget second{first};           // 复制构造
  Widget third{std::move(first)}; // 移动构造

  second = third;                 // 复制赋值
  third = std::move(second);      // 移动赋值
}                                 // 依次析构 third、second、first

这里要区分“构造”和“赋值”:构造发生在对象还不存在时,赋值发生在目标对象已经存在时。

2.1 std::move 不会真正移动对象

std::move 本身只进行类型转换,将表达式转换为可以绑定到右值引用的形式。真正的移动行为由移动构造函数或移动赋值运算符实现。

std::string source = "hello";
std::string target = std::move(source); // 选择 string 的移动构造函数

移动后,普通类型对象通常仍然处于“有效但未指明”的状态。可以销毁它,也可以对它进行类型允许的重新赋值,但不能假设它仍然保存移动前的值。

对于 std::unique_ptr,移动后源指针为空:

auto source = std::make_unique<int>(42);
auto target = std::move(source);

if (source) {
  // source 只有在重新获得对象后才能解引用
  *source = 1;
}

*target = 2;

移动后的 source 不能直接解引用。对空 unique_ptr 使用 operator-> 或 operator* 会产生未定义行为。

三、隐式声明、定义和删除

“编译器自动生成函数”是一个便于理解但不够精确的说法。实际过程至少要区分三个概念:

3.1 隐式声明

当类没有写出某个特殊成员函数,并且满足语言规则时,编译器会将它隐式声明出来。例如,一个没有声明任何构造函数的类会获得一个隐式声明的默认构造函数。

class Point {
private:
  int x_ = 0;
  int y_ = 0;
};

// Point 获得了隐式声明的默认构造、复制构造、复制赋值和析构函数。
// 在没有其他抑制条件时,也可以获得隐式声明的移动构造和移动赋值。

注意:只要声明了任意构造函数,默认构造函数就不会再被隐式声明:

class Token {
public:
  explicit Token(int value) : value_(value) {}

private:
  int value_;
};

class Session {
private:
  Token token_;
};

// Session 没有用户声明的构造函数,因此会隐式声明默认构造函数。
// 但 Token 没有默认构造函数,所以 Session 的默认构造函数被定义为 deleted。
// Session session; // 编译错误

这里的关键是:成员或基类的能力会影响外层特殊成员函数。例如,成员不可复制时,外层类的隐式复制构造函数也无法正常工作。

3.2 隐式定义

隐式声明不代表编译器立即生成一个可以在目标文件中找到的函数实体。通常只有在函数被 odr-use,或者为了常量求值确实需要定义时,编译器才会隐式定义它。

例如,某个简单的默认构造函数可能是平凡函数,编译器可以直接内联或完全不发出独立符号。因此,使用 objdump 观察汇编只能说明某个编译器、ABI、优化级别下的实现,不能作为语言规则本身的证明。

3.3 被定义为 deleted

如果特殊成员函数被隐式声明,但因为基类或成员对象不满足要求而无法实现,它会被定义为 deleted:

class NonCopyable {
public:
  NonCopyable() = default;
  NonCopyable(const NonCopyable&) = delete;
  NonCopyable& operator=(const NonCopyable&) = delete;
};

class Owner {
private:
  NonCopyable value_;
};

// Owner 的复制构造和复制赋值会被定义为 deleted。
// Owner first;
// Owner second{first}; // 编译错误

deleted 函数仍然参与重载决议,但一旦被选中就会产生编译错误。使用 = delete 明确表达“不允许复制”也是一种常见的类接口设计方式。

四、六个特殊成员函数的生成规则

下面的表格总结常见规则。表中的“用户声明”包括显式写出函数、= default 和 = delete。

函数 隐式声明的常见条件 常见的 deleted 或抑制原因
默认构造 类中没有用户声明的构造函数 基类或成员没有可用的默认构造函数
析构 类中没有用户声明的析构函数 基类或成员的析构函数不可访问或已删除
复制构造 没有用户声明的复制构造函数 基类或成员不可复制;用户声明了移动构造或移动赋值
复制赋值 没有用户声明的复制赋值运算符 基类或成员不可复制赋值;用户声明了移动构造或移动赋值
移动构造 没有用户声明的复制、移动、赋值函数和析构函数 基类或成员不能按要求移动或复制
移动赋值 没有用户声明的复制、移动、赋值函数和析构函数 基类或成员不能按要求移动赋值

“没有隐式声明”和“隐式声明后被定义为 deleted”是两种不同情况。可以把判断过程理解为:

  1. 先判断这个函数是否会被隐式声明。
  2. 如果声明了,再检查基类和成员是否支持对应操作。
  3. 如果某个子对象不支持操作,外层函数可能被定义为 deleted。

4.1 用户声明的移动操作会影响复制操作

如果类声明了移动构造函数或移动赋值运算符,编译器仍可能声明复制构造和复制赋值,但这些复制函数会被定义为 deleted:

class MoveOnly {
public:
  MoveOnly() = default;

  MoveOnly(const MoveOnly&) = default;
  MoveOnly& operator=(const MoveOnly&) = default;

  MoveOnly(MoveOnly&&) noexcept = default;
  MoveOnly& operator=(MoveOnly&&) noexcept = default;
};

上面的类本身仍然是可复制的,因为复制操作被显式 = default。如果只声明移动操作而不显式恢复复制操作,则隐式复制操作会被定义为 deleted:

class MoveOnlyResource {
public:
  MoveOnlyResource() = default;
  MoveOnlyResource(MoveOnlyResource&&) noexcept = default;

  // 复制构造和复制赋值会因为移动操作的存在而被定义为 deleted。
};

这不是编译器“猜测类的意图”,而是语言规则对资源所有权语义的保护:既然类作者已经明确提供了移动行为,默认的按成员复制就可能不再安全。

4.2 用户声明的析构函数会影响移动操作

只要用户声明了析构函数,移动构造和移动赋值就不会被隐式声明。即使析构函数写成 = default,它仍然是用户声明的:

#include <memory>

class BadResource {
public:
  std::unique_ptr<int> value;
  ~BadResource() = default; // 仍然是用户声明的析构函数
};

class GoodResource {
public:
  std::unique_ptr<int> value;
  // 没有用户声明的析构函数,可以隐式获得移动操作
};

BadResource 的复制操作因为 unique_ptr 不可复制而被删除,同时又没有隐式移动构造,因此不能像 GoodResource 一样直接移动。若确实需要自定义析构函数,应根据实际资源语义显式 = default、= delete 或实现相应的移动操作。

五、初始化、复制、移动和销毁中的常见细节

5.1 默认初始化和列表初始化不同

对于内置类型成员,不同初始化形式可能得到不同结果:

struct Config {
  int port;
};

Config first;  // port 没有初始化,不能读取 first.port
Config second{}; // port 被值初始化为 0

实际代码中应优先使用成员默认初始化或初始化列表,避免依赖内置类型的未初始化状态:

struct Config {
  int port = 8080;
};

Config config{};

5.2 复制构造和复制赋值的差异

复制构造创建新对象,复制赋值操作已经存在的对象:

struct Message {
  std::string text;
};

Message first{"hello"};
Message second{first}; // 复制构造

Message third{"world"};
third = first;         // 复制赋值

如果类内部拥有资源,复制构造通常需要创建独立资源,复制赋值还要先处理目标对象原有资源。错误地使用默认按成员复制,容易导致两个对象管理同一块资源,最终产生重复释放或悬空指针。

5.3 移动构造和移动赋值的差异

移动构造从源对象构造新对象,移动赋值则需要先释放或替换目标对象当前拥有的资源:

Message source{"hello"};
Message target{std::move(source)}; // 移动构造

Message another{"another"};
another = std::move(target);       // 移动赋值

移动操作通常应标记为 noexcept。例如 std::vector 扩容搬迁元素时,如果元素的移动构造不抛异常,容器通常可以直接移动;否则在元素可复制时可能选择复制,以保证异常安全。

5.4 返回值和复制省略

返回临时对象时,C++17 对部分复制省略场景提供了语言保证:

Message MakeMessage() {
  return Message{"hello"};
}

Message message = MakeMessage();

这段代码不应简单理解为“先构造临时对象,再移动到 message”。在 C++17 的保证复制省略场景中,返回对象可以直接在目标位置构造。

返回具名局部对象时属于 NRVO,是否省略复制仍可能依赖编译器实现:

Message MakeMessage(bool condition) {
  Message first{"first"};
  Message second{"second"};
  return condition ? first : second; // 通常不能依赖 NRVO
}

如果局部对象本来可能触发 NRVO,不要在 return 中额外写 std::move。复制省略和返回值优化的完整讨论见:编译器中的 Copy elision 和 RVO 优化。

六、三法则、五法则和零法则

6.1 三法则

三法则是一条设计指导原则,而不是编译器强制执行的语法规则:

如果类需要用户自定义复制构造函数、复制赋值运算符或析构函数中的一个,通常也需要同时定义另外两个。

这条原则主要针对手动管理资源的类。因为复制构造、复制赋值和析构都可能涉及同一项资源的所有权:

  • 复制构造需要决定复制资源还是共享资源。
  • 复制赋值需要处理目标对象原有的资源。
  • 析构函数需要释放当前对象拥有的资源。

只实现其中一个函数,另外两个仍然使用默认按成员行为,通常会造成资源泄漏、重复释放或浅拷贝。

6.2 五法则

C++11 引入移动语义后,三法则扩展为五法则:

class Resource {
public:
  Resource(const Resource& other);            // 复制构造
  Resource& operator=(const Resource& other); // 复制赋值
  ~Resource();                                // 析构

  Resource(Resource&& other) noexcept;            // 移动构造
  Resource& operator=(Resource&& other) noexcept; // 移动赋值
};

如果类确实自己管理资源,就应明确设计这五个函数,或者明确删除不允许的操作。例如独占资源通常不能复制,但可以移动:

class UniqueResource {
public:
  UniqueResource() = default;

  UniqueResource(const UniqueResource&) = delete;
  UniqueResource& operator=(const UniqueResource&) = delete;

  UniqueResource(UniqueResource&&) noexcept = default;
  UniqueResource& operator=(UniqueResource&&) noexcept = default;

  ~UniqueResource() = default;
};

6.3 零法则

现代 C++ 更推荐零法则:让成员对象负责资源管理,自己的类不声明任何特殊成员函数。

#include <memory>
#include <string>
#include <vector>

class Document {
public:
  Document() = default;

private:
  std::string title_;
  std::vector<char> content_;
  std::unique_ptr<int> implementation_;
};

这个类的资源分别由 std::string、std::vector 和 std::unique_ptr 管理:

  • Document 可以移动。
  • 因为 unique_ptr 不可复制,Document 也不可复制。
  • 不需要手写析构、复制和移动函数。

零法则通常比手动实现五法则更可靠。RAII 类型已经经过充分测试,能够正确处理异常、移动后的状态和资源释放顺序。

七、如何验证特殊成员函数是否可用

不要只通过观察汇编判断特殊成员函数是否存在,优先使用类型特征进行编译期验证:

#include <memory>
#include <type_traits>

struct Copyable {
  int value = 0;
};

struct MoveOnly {
  std::unique_ptr<int> value = std::make_unique<int>(0);
};

static_assert(std::is_copy_constructible_v<Copyable>);
static_assert(std::is_move_constructible_v<Copyable>);

static_assert(!std::is_copy_constructible_v<MoveOnly>);
static_assert(std::is_move_constructible_v<MoveOnly>);

也可以通过显式删除函数验证接口约束:

struct NonCopyable {
  NonCopyable() = default;
  NonCopyable(const NonCopyable&) = delete;
  NonCopyable& operator=(const NonCopyable&) = delete;
};

编译示例:

g++ -std=c++17 -Wall -Wextra -pedantic lifecycle.cpp -o lifecycle

如果需要研究某个编译器实际生成的代码,可以再使用:

objdump -Cd lifecycle

但汇编结果会受到编译器版本、目标平台、ABI、优化级别和内联决策影响。它适合用来观察实现,不适合替代标准规则的分析。

八、总结

  • 对象生命周期包括构造、使用、复制或移动,以及析构。
  • 六个特殊成员函数分别负责默认构造、析构、复制构造、复制赋值、移动构造和移动赋值。
  • “隐式声明”“隐式定义”和“定义为 deleted”是三个不同概念。
  • 成员和基类的能力会影响外层类特殊成员函数是否可用。
  • 用户声明移动操作通常会删除隐式复制操作,用户声明析构函数会抑制隐式移动操作。
  • 三法则和五法则适用于手动资源管理;优先使用 RAII 类型遵循零法则。
  • std::move 只是类型转换,移动后的对象通常仍有效,但状态可能未指明。
  • C++17 对部分复制省略提供语言保证,NRVO 仍不应被无条件假设。

参考资料