「为什么我这行 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。只写一半更糟,编译器按「已声明」的规则停掉隐式生成,类就卡在「能拷贝不能移动」这种半残状态。
三个最常见的错误值得单独记住:
- 只写析构,不写移动 。一旦声明了析构函数,编译器不再隐式生成移动构造和移动赋值(拷贝操作仍会生成,但被弃用),于是
std::vector<T>扩容时会把移动悄悄退化成拷贝。性能掉下去,代码却看不出任何问题。 - 拷贝赋值不处理自赋值 。
a = a时先释放自己的资源,等于把源对象的数据一起释放了,接着再从已释放内存里读。典型的 use-after-free。 - 移动操作漏了
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,顺序别搞反。
延伸阅读
- cppreference · 拷贝构造函数:触发时机与隐式定义条件。
- cppreference · 复制消除:C++17 guaranteed copy elision 为什么能让拷贝「消失」。
- cppreference · 拷贝初始化:
T a = b与 explicit 的关系。 - C++ Core Guidelines · C.60--C.63:拷贝/移动赋值应成对、且返回
*this的规范。 - cppreference · 移动构造函数:移动构造的隐式生成条件与
noexcept的影响。 - C++ Core Guidelines · C.20/C.21:Rule of Zero 与 Rule of Five 的官方表述。
- cppreference · std::move:确认它只是一次类型转换,不搬移任何东西。
收个尾
区分拷贝构造和拷贝赋值,就看等号左边那个对象有没有已经存在。这一条够用了,其余都是推论。
真正要花心思的其实不是背定义,而是尽量别手写这几个特殊成员。手写一次,Rule of Five 就缠上你,漏掉哪个编译器都不吭声,只在 std::vector 扩容时悄悄退化成拷贝。能交给 std::vector 和 std::string 的,就别自己扛。