拷贝构造与拷贝赋值:调用时机、深浅拷贝与复制消除

「为什么我这行 T b = a; 调用的是拷贝构造,而 b = a; 却是拷贝赋值?」「为什么函数按值返回明明该拷贝两次,实测却只构造了一次?」拷贝语义是 C++ 里最容易「想当然」、却处处是坑的一块。这篇不堆定义,直接上打印计数器:把拷贝构造的触发时机、深浅拷贝的后患、以及编译器悄悄做的「复制消除(copy elision)」优化,用实跑输出一条条亮出来。

两行极像的代码,走的不是同一个函数

cpp 复制代码
T a(1);
T b = a;   // 这一行:拷贝构造(copy constructor)
b = a;     // 这一行:拷贝赋值(copy assignment)

它们的签名完全不同:拷贝构造是 T(const T&),用来凭空造出一个新对象;拷贝赋值是 T& operator=(const T&),作用在已经存在的对象上,把旧值覆盖掉。一个从无到有,一个以旧换新。这条线先钉死,后面就不用反复回头查了。

拷贝构造的三个触发场景 + 复制消除

拷贝构造(copy constructor)在三种情况下被调用:用同类型对象初始化新对象,对象按值传递给函数,函数按值返回对象。下面用一个带打印的 Blob 把三次触发和复制消除一并实证:

cpp 复制代码
#include <iostream>
struct Blob {
    int id;
    Blob(int i) : id(i) { std::cout << "默认构造 #" << id << "\n"; }
    Blob(const Blob& o) : id(o.id) { std::cout << "拷贝构造 #" << id << "\n"; }
    Blob& operator=(const Blob& o) {
        id = o.id;
        std::cout << "拷贝赋值 #" << id << "\n";
        return *this;
    }
    ~Blob() { std::cout << "析构 #" << id << "\n"; }
};
void byValue(Blob x) { std::cout << "byValue 收到 #" << x.id << "\n"; }
Blob makeBlob(int i) {
    Blob t(i);          // 默认构造
    return t;           // 返回:C++17 起强制复制消除(guaranteed copy elision)
}
int main() {
    std::cout << "[1] 用同类型对象初始化 -> 拷贝构造\n";
    Blob a(1);
    Blob b = a;         // 拷贝构造

    std::cout << "[2] 值传递参数 -> 拷贝构造\n";
    byValue(a);         // 拷贝 a 到形参 x

    std::cout << "[3] 按值返回 -> C++17 复制消除,只构造 1 次\n";
    Blob c = makeBlob(2);   // 不调用拷贝构造(elided)

    std::cout << "[4] 拷贝赋值(对象已存在)\n";
    Blob d(3);
    d = a;             // 拷贝赋值,不是拷贝构造

    std::cout << "--- main 结束,开始析构 ---\n";
}
text 复制代码
[1] 用同类型对象初始化 -> 拷贝构造
默认构造 #1
拷贝构造 #1
[2] 值传递参数 -> 拷贝构造
拷贝构造 #1
byValue 收到 #1
析构 #1
[3] 按值返回 -> C++17 复制消除,只构造 1 次
默认构造 #2
[4] 拷贝赋值(对象已存在)
默认构造 #3
拷贝赋值 #1
--- main 结束,开始析构 ---
析构 #1
析构 #2
析构 #1
析构 #1

注意 [3]。makeBlob(2) 内部 t 默认构造一次,返回时本该再拷贝一次,可输出里压根没有「拷贝构造」,连移动构造也没有。这就是复制消除(copy elision):C++17 起,prvalue(纯右值)到同类型对象的初始化被强制消除拷贝和移动,直接在目标位置构造。零开销拿到返回值,说的就是这件事。

这里容易误以为「少了一次拷贝」是编译器开 -O2 优化出来的。其实不是,C++17 之后它是语言规则,关掉优化照样消除。

官方文档:cppreference · 复制消除:C++17 起 guaranteed copy elision 的规则。

拷贝构造 vs 拷贝赋值:一句话区分

写法 调用 前提
T b = a; / T b(a); 拷贝构造 T(const T&) b 是尚未存在的新对象
b = a; 拷贝赋值 T& operator=(const T&) b 已经存在,被覆盖

关键:看等号左边那个对象「有没有已经存在」 。上面输出 [4] 里 d 先用 Blob d(3) 构造出来,再 d = a 才是赋值;如果写 Blob d = a; 就是构造了。这个区分决定了你是否要写(或删掉)对应的特殊成员。

拷贝初始化 vs 直接初始化

T a = b;(拷贝初始化,copy-initialization)和 T a(b);(直接初始化,direct-initialization)在拷贝构造非 explicit 的前提下完全等价,都调拷贝构造:

cpp 复制代码
#include <iostream>
struct T {
    int id;
    T(int i) : id(i) { std::cout << "构造 #" << id << "\n"; }
    T(const T& o) : id(o.id) { std::cout << "拷贝构造 #" << id << "\n"; }
    T& operator=(const T&) { std::cout << "拷贝赋值\n"; return *this; }
};
int main() {
    T src(1);
    T a = src;   // 拷贝初始化(copy-initialization)
    T b(src);    // 直接初始化(direct-initialization)
}
text 复制代码
构造 #1
拷贝构造 #1
拷贝构造 #1

坑在 explicit 拷贝构造上。一旦标成 explicit,T a = b; 这种拷贝初始化就会编译失败(explicit 构造不能用于隐式上下文),而 T a(b); 照样能用。标准容器和大量算法都依赖可拷贝初始化,所以拷贝构造几乎永远不该加 explicit 。麻烦的是报错落在调用处,你得回头去找那个 explicit 声明:

cpp 复制代码
// 反例,不要这么写:把拷贝构造声明为 explicit,会禁用拷贝初始化 T x = y
struct Bad {
    explicit Bad(const Bad&) {}   // explicit 拷贝构造
};
Bad x;
Bad y = x;   // 编译失败:拷贝初始化不允许调用 explicit 构造
Bad z(x);    // OK:直接初始化允许

官方文档:cppreference · 拷贝初始化:explicit 构造在拷贝初始化中被排除。

浅拷贝 vs 深拷贝:double free 的根源

成员里如果有裸指针,编译器生成的拷贝构造只做按位复制指针地址,两个对象指向同一块内存,这就是浅拷贝(shallow copy)。析构时两个对象各自 delete 同一块,触发 double free,属于未定义行为:

cpp 复制代码
// 反例,不要这么写:裸指针成员只拷地址不拷数据 -> 浅拷贝 -> double free
struct Shallow {
    int* p = new int(42);
    ~Shallow() { delete p; }              // 两个对象析构时都 delete 同一块 -> double free
    Shallow(const Shallow& o) : p(o.p) {} // 只复制指针,没复制所指数据
};
Shallow x;
Shallow y = x;   // 浅拷贝:x.p 与 y.p 指向同一块;析构时 delete 两次 -> 未定义行为
text 复制代码
浅拷贝(危险):两个对象共享同一块堆内存
  x.p ─────┐
           ▼
         [ 42 ]   <- 同一块,析构时被 delete 两次 -> double free
           ▲
  y.p ─────┘

深拷贝(安全):各自拥有一份独立副本
  x.p ──► [ 42 ]
  y.p ──► [ 42 ]   <- 两块独立内存,各自析构,互不干扰

正确做法是深拷贝,让副本拥有独立的数据。现代 C++ 最省心的办法是用 std::vector / std::string 这类自带深拷贝的成员(Rule of Zero),拷贝构造交给编译器生成,天然就是深拷贝:

cpp 复制代码
#include <iostream>
#include <vector>
struct Buffer {
    std::vector<int> data;
    Buffer(std::initializer_list<int> il) : data(il) {}
    void show(const char* tag) const {
        std::cout << tag << ": ";
        for (int v : data) std::cout << v << " ";
        std::cout << "\n";
    }
};
int main() {
    Buffer a{1, 2, 3};
    Buffer b = a;          // 拷贝构造 -> vector 深拷贝,b 拥有独立副本
    b.data[0] = 999;       // 改 b 不影响 a
    a.show("a");
    b.show("b");
}
text 复制代码
a: 1 2 3
b: 999 2 3

b 改了第 0 个元素,a 纹丝不动,说明确实是两份独立数据。换成裸指针浅拷贝就不是这个结果了:a 和 b 会同时变成 999,然后析构时崩。

拷贝构造、拷贝赋值与深拷贝独立性

把「拷贝构造 vs 拷贝赋值」和「深拷贝互不影响」放进一个程序收尾:

cpp 复制代码
#include <iostream>
#include <vector>
struct Vec {                       // 一个自带深拷贝的小向量(Rule of Zero 思维)
    std::vector<int> d;
    Vec(std::initializer_list<int> il) : d(il) {}
    void put(int v) { d.push_back(v); }
    void show(const char* t) const {
        std::cout << t << "(size=" << d.size() << "): ";
        for (int v : d) std::cout << v << " ";
        std::cout << "\n";
    }
};
int main() {
    Vec a{1, 2};
    Vec b = a;          // 拷贝构造:深拷贝
    b.put(3);           // 只改 b
    a.show("a");
    b.show("b");

    Vec c{9};
    c = a;              // 拷贝赋值:深拷贝
    c.put(0);
    a.show("a");
    c.show("c");
}
text 复制代码
a(size=2): 1 2
b(size=3): 1 2 3
a(size=2): 1 2
c(size=3): 1 2 0

b 与 c 都是从 a 深拷贝出来的独立副本:给 b 加 3、给 c 加 0,a 始终是 1 2,三者互不干扰。把资源管理交给 RAII 成员,拷贝语义就是白送的。

拷贝 vs 移动:账要算在「资源有多大」上

知道拷贝构造什么时候被调用之后,下一个问题自然是能不能不拷贝。C++11 起的答案是移动(move):

操作 复杂度 代价来源 什么时候该用它
拷贝构造 T(const T&) O(n) 为副本重新分配缓冲、逐元素复制 源对象后面还要继续用
移动构造 T(T&&) O(1) 只搬几个指针和长度 源对象之后不再需要
拷贝赋值 / 移动赋值 同上 同上,另外还要先释放自己的旧资源 对象已经存在时
return std::move(local) 反而更慢 破坏 NRVO,平白多一次移动 不要这么写
push_back(std::move(x)) O(1) 容器接管 x 的资源 确定 x 不再用

「深拷贝」到底贵在哪,看一眼 std::vector 的缓冲地址就明白:

cpp 复制代码
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <iostream>
#include <utility>
#include <vector>

int main() {
    std::vector<int> src{1, 2, 3, 4, 5};
    int* original = src.data();                 // 记住原始缓冲的地址

    std::vector<int> copied = src;              // 拷贝:新缓冲 + 逐元素复制
    std::vector<int> moved  = std::move(src);   // 移动:直接偷走那块缓冲

    std::cout << "拷贝体重新分配了缓冲     = " << (copied.data() != original) << '\n';
    std::cout << "移动体拿到的还是原缓冲   = " << (moved.data() == original) << '\n';
    std::cout << "copied.size() = " << copied.size() << '\n';
    std::cout << "moved.size()  = " << moved.size() << '\n';
    std::cout << "src.size()    = " << src.size() << "   <- 被搬空,但仍是有效对象\n";
}
text 复制代码
拷贝体重新分配了缓冲     = 1
移动体拿到的还是原缓冲   = 1
copied.size() = 5
moved.size()  = 5
src.size()    = 0   <- 被搬空,但仍是有效对象

copied 的缓冲地址和原来不同,它老老实实分配了新内存再逐个复制。moved 的缓冲地址和原地址完全相同,它只是把 src 内部那三个指针(起始、结束、容量末尾)接了过来,src 因此变成空的。这就是「移动是 O(1)、拷贝是 O(n)」的真实含义:差别不在复制了多少个字节,而在要不要申请并填满一块新内存。数据量越大、分配越贵,差距越扎眼。

还有两条纪律。第一,src.size() == 0 只是当前实现的合理结果,标准只保证被移动后的对象「有效但未指定(valid but unspecified)」:能安全析构,能重新赋值,但别去读它的内容。第二,自己写移动构造时必须把源对象的指针置空,否则源对象析构时会把已经交出去的内存再 delete 一次,直接变成 double free。

与 Rule of Five 的衔接:什么时候必须手写特殊成员

把前面的讨论收拢成一张清单,也就是类的五个特殊成员函数(special member functions):

特殊成员 签名 什么时候需要手写
析构函数 ~T() 持有需要手动释放的资源(裸指针、句柄)
拷贝构造 T(const T&) 需要深拷贝(成员是指针/裸句柄)
拷贝赋值 T& operator=(const T&) 同上,且必须处理自赋值与异常安全
移动构造 T(T&&) noexcept 想让对象能被容器、std::move 高效搬走
移动赋值 T& operator=(T&&) noexcept 同上

规则可以概括成一正一反两句话:

  • Rule of Zero :成员用 std::string / std::vector / 智能指针这类自带正确拷贝语义的类型,五个特殊成员一个都不用写,编译器生成的版本天然是深拷贝。前面那两个 Buffer 例子就是这么做的。
  • Rule of Five :一旦手写了其中一个(比如为了管理裸指针),五个就得一起考虑清楚,写全或者显式 = delete。只写一半更糟,编译器按「已声明」的规则停掉隐式生成,类就卡在「能拷贝不能移动」这种半残状态。

三个最常见的错误值得单独记住:

  1. 只写析构,不写移动 。一旦声明了析构函数,编译器不再隐式生成移动构造和移动赋值(拷贝操作仍会生成,但被弃用),于是 std::vector<T> 扩容时会把移动悄悄退化成拷贝。性能掉下去,代码却看不出任何问题。
  2. 拷贝赋值不处理自赋值 。a = a 时先释放自己的资源,等于把源对象的数据一起释放了,接着再从已释放内存里读。典型的 use-after-free。
  3. 移动操作漏了 noexcept 。std::vector 扩容时只有在移动构造被标记为 noexcept 的前提下才敢用移动,否则为了保持强异常保证,它会退回用拷贝。

移动构造最经典的翻车写法是「只搬指针,不置空源对象」:

cpp 复制代码
// 反例,不要这么写:移动构造只搬指针却不置空源对象 -> 两个对象析构时 double free
#include <cstddef>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_{new int[n]}, size_{n} {}
    ~Buffer() { delete[] data_; }
    Buffer(Buffer&& o) noexcept : data_{o.data_}, size_{o.size_} {}   // 少了一行 o.data_ = nullptr
private:
    int*   data_;
    std::size_t size_;
};

正确做法是搬完之后立刻把源对象置成可安全析构的状态:指针置 nullptr,长度清零,再配上 noexcept:

cpp 复制代码
// 正确写法:搬走资源后把源对象置空,并声明 noexcept
#include <cstddef>
#include <memory>
#include <utility>

class Buffer {
public:
    explicit Buffer(std::size_t n) : data_{std::make_unique<int[]>(n)}, size_{n} {}
    Buffer(Buffer&& o) noexcept : data_{std::move(o.data_)}, size_{o.size_} { o.size_ = 0; }
    // ... 拷贝构造写成深拷贝,拷贝赋值用 copy-and-swap 兼顾自赋值与异常安全
private:
    std::unique_ptr<int[]> data_;
    std::size_t size_ = 0;
};

用 std::unique_ptr 托管数组之后,连析构都不用写了。unique_ptr 的移动构造天然「搬走并置空」,Rule of Five 直接退化成 Rule of Zero 的一个变体。所以优先 Rule of Zero,实在要手写才上 Rule of Five,顺序别搞反。

延伸阅读

收个尾

区分拷贝构造和拷贝赋值,就看等号左边那个对象有没有已经存在。这一条够用了,其余都是推论。

真正要花心思的其实不是背定义,而是尽量别手写这几个特殊成员。手写一次,Rule of Five 就缠上你,漏掉哪个编译器都不吭声,只在 std::vector 扩容时悄悄退化成拷贝。能交给 std::vector 和 std::string 的,就别自己扛。

相关推荐
朝朝辞暮i2 小时前
C++ 第一阶段总结:从零基础到 ROS2 前置语法
开发语言·c++·算法
朝朝辞暮i2 小时前
C++ 第 25 课:getter / setter + this
java·javascript·c++
All for pursuit.2 小时前
【回溯-4】46.全排列
数据结构·c++·算法·leetcode
无名猿2 小时前
裸指针还能不能用:不拥有与拥有的边界
c++·内存管理·现代c++·语法基础
Yyyyyy~3 小时前
【C++】STLday1
c++
无名猿3 小时前
左值、右值、纯右值、将亡值:值类别入门
c++·内存管理·现代c++·语法基础
程序员老陆3 小时前
C++ 将亡值(xvalue)深度解析:从表达式分类到参数传递陷阱
c++·程序设计
沫璃染墨3 小时前
《Qt从零入门系列(十二):Qt文件操作详解——从QFile读写到QFileInfo与记事本实战》
开发语言·网络·c++·qt·交互·信号处理·文件
青少儿编程课堂4 小时前
树上启发式合并详解:子树颜色众数统计与复杂度拆解
c++·python·算法·bfs·信息学竞赛