My Personal Website

Cpp

January 23, 2026

探析 C++ 中的移动语义:一个关于 std::unique_ptr 与 std::priority_queue 的深度分析

今天调试代码时,我遇到了一个很经典的 C++ 编译错误:

#include <memory>
#include <queue>

struct Foo {
    std::unique_ptr<Foo> a;
};

int main() {
    std::priority_queue<Foo> pq;
    Foo foo;
    pq.push(foo);  // 编译错误
    return 0;
}

这段代码无法编译。C++ 编译器通常会抛出一大堆令人困惑的错误信息,但最终的核心问题是:

/usr/bin/../lib64/gcc/x86_64-suse-linux/15/../../../../include/c++/15/bits/stl_construct.h:94:37: note:
      because '::new ((void *)0) _Tp(std::declval<_Args>()...)' would be
      invalid: call to implicitly-deleted copy constructor of 'Foo'
   94 |       && requires { ::new((void*)0) _Tp(std::declval<_Args>()...); }
      |                                     ^

为什么会出现这个错误?

理解 std::priority_queue 的工作机制

std::priority_queue 默认使用 std::vector 作为底层容器。当我们调用 push() 时,它实际上会调用底层容器的 push_back() 方法。根据 C++ 标准库的文档,push_back() 的作用是:

Appends a copy of value to the end of the container.

关键点:它创建的是值的副本(copy),而不是移动它。

当我们传入一个左值(如 foo)时,函数需要调用复制构造函数(copy constructor)来创建容器中的新元素。这就是为什么编译器抱怨”call to implicitly-deleted copy constructor of ‘Foo’“。

复制构造函数到底是什么?

在 C++ 中,复制构造函数是一种特殊的成员函数,用于从一个对象创建另一个对象。例如:

class A {
    int x;
    int y;
};

对于上面这个简单的类,编译器会自动生成默认的构造函数,包括:

  1. 默认构造函数A()
  2. 复制构造函数A(const A&)
  3. 赋值运算符A& operator=(const A&)
  4. 析构函数~A()

我们可以这样使用复制构造函数:

A a;
A aa = a;  // 触发复制构造函数

但是,当类管理动态内存时,简单的按位复制就会出问题。考虑这个例子:

class A {
    int* a;
    int* b;
    
    ~A() {
        delete a;
    }
};

如果只是简单地复制指针值,两个对象就会指向同一块内存。当第一个对象离开作用域时,它会释放内存,另一个对象的指针就会变成悬挂指针(dangling pointer),导致二次释放(double free)或其他未定义行为。

因此,C++ 标准建议:如果类管理动态资源(如内存),必须显式定义复制构造函数、赋值运算符和析构函数(即 “Rule of Three”)。现代 C++ 通常建议遵循 “Rule of Five”,还要加上移动构造函数和移动赋值运算符。

分析 Foo 结构的问题

回到我们的 Foo 结构体:

struct Foo {
    std::unique_ptr<Foo> a;
};

为什么 Foo 没有可用的复制构造函数?

unique_ptr 的设计哲学

std::unique_ptr 是一个独占所有权的智能指针。它遵循 RAII(Resource Acquisition Is Initialization)原则,在作用域结束时自动释放内存。

关键设计决策unique_ptr 不允许复制:

// unique_ptr 的源码片段(GCC 15)
unique_ptr(const unique_ptr&) = delete;
unique_ptr& operator=(const unique_ptr&) = delete;

为什么?让我们思考一下:

  1. 如果直接复制指针值,两个 unique_ptr 会指向同一块内存,当其中一个被销毁时,另一个将成为悬挂指针。这违反了独占所有权的原则。
  2. 如果进行深拷贝,需要复制指针指向的对象。但 unique_ptr 通常管理多态对象,不知道具体类型,无法正确复制。

因此,编译器删除了 Foo 的默认复制构造函数(因为它的成员 unique_ptr 的复制构造函数被删除了)。这也是为什么 pq.push(foo) 会失败——它需要调用一个不存在的复制构造函数。

解决方案:使用移动语义

既然复制不行,那我们真正想要的是什么?将对象移动到容器中,而不是复制。这就是移动语义(Move Semantics)的用武之地。

移动语义是什么?

在 C++11 引入移动语义之前,我们经常需要浅拷贝再深拷贝的繁琐操作。移动语义允许我们将资源从一个对象”转移”到另一个对象,而不用复制数据本身。

使用 std::move() 可以将左值转换为右值引用,从而触发移动版本的函数(如移动构造函数):

std::priority_queue<Foo> pq;
Foo foo;
pq.push(std::move(foo));  // ✅ 现在可以编译了

为什么能工作?

  1. std::move(foo)foo 转换为右值引用(Foo&&
  2. priority_queue 需要调用移动构造函数来构造容器中的新元素
  3. Foo 的移动构造函数可以唯一地获取 foo.a 中的指针转移所有权
  4. fooa 成员变为 nullptr,但对象本身仍然有效

如果需要显式定义移动操作:

struct Foo {
    std::unique_ptr<Foo> a;
    
    // 移动构造函数(可以使用编译器自动生成的)
    Foo(Foo&& other) : a(std::move(other.a)) {}
    
    // 移动赋值运算符
    Foo& operator=(Foo&& other) {
        if (this != &other) {
            a = std::move(other.a);
        }
        return *this;
    }
    
    // 注意:不定义复制操作,因为 unique_ptr 不允许复制
    Foo(const Foo&) = delete;
    Foo& operator=(const Foo&) = delete;
};

更广义的规则总结

关于资源管理的规则

C++ 中有三个经典的规则需要记住:

  1. Rule of Three(C++03)
    • 如果你需要自定义析构函数复制构造函数赋值运算符三者之一,通常需要全部定义它们
    • 适用于管理动态资源的类
  2. Rule of Five(C++11 及以后)
    • 在 Rule of Three 的基础上,增加移动构造函数移动赋值运算符
    • 允许高效的资源转移
  3. Rule of Zero(现代 C++)
    • 如果你使用智能指针、容器或其他 RAII 类型管理资源
    • 不需要自定义析构函数、复制 constructor、赋值运算符、移动 constructor 或移动赋值运算符
    • 让编译器自动生成或删除(遵循 “delete” 策略)

unique_ptr 在类设计中的使用

当你在类中包含 unique_ptr 时,你的类会自动:

获得移动语义(编译器自动生成,除了用户禁用的情况) ❌ 失去复制语义(因为它的成员复制构造函数被删除)

如果你的类需要被复制(例如,在某些算法或标准容器中),你需要考虑:

  1. 使用 shared_ptr 替代:如果需要共享所有权
  2. 深拷贝实现:手动实现复制操作,复制 unique_ptr 指向的对象
  3. 重新设计:考虑是否真的需要复制这个对象,或者移动语义就够了

性能与正确性

为什么 move 比 copy 更高效?

对于包含大量数据的类,移动操作通常是 O(1) 的,它只转移几个指针,而复制可能是 O(n) 的,需要复制整个数据图。

还要小心释放后的使用(Dangling after move)

移动后,源对象通常处于”有效但未指定”的状态。对于 unique_ptr,移动后变为 nullptr。所以:

Foo foo;
pq.push(std::move(foo));

// ❌ 危险:foo.a 已经是 nullptr
// 但 foo 对象本身还是有效的(可以安全地析构)
std::cout << foo.a.get() << std::endl;  // 输出 nullptr