☰
拷贝构造与拷贝赋值:调用时机、深浅拷贝与复制消除
2026/10/4 16:35:46 网站建设 项目流程

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

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

Ta(1);T b=a;// 这一行:拷贝构造(copy constructor)b=a;// 这一行:拷贝赋值(copy assignment)

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

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

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

#include<iostream>structBlob{intid;Blob(inti):id(i){std::cout<<"默认构造 #"<<id<<"\n";}Blob(constBlob&o):id(o.id){std::cout<<"拷贝构造 #"<<id<<"\n";}Blob&operator=(constBlob&o){id=o.id;std::cout<<"拷贝赋值 #"<<id<<"\n";return*this;}~Blob(){std::cout<<"析构 #"<<id<<"\n";}};voidbyValue(Blob x){std::cout<<"byValue 收到 #"<<x.id<<"\n";}BlobmakeBlob(inti){Blobt(i);// 默认构造returnt;// 返回:C++17 起强制复制消除(guaranteed copy elision)}intmain(){std::cout<<"[1] 用同类型对象初始化 -> 拷贝构造\n";Bloba(1);Blob b=a;// 拷贝构造std::cout<<"[2] 值传递参数 -> 拷贝构造\n";byValue(a);// 拷贝 a 到形参 xstd::cout<<"[3] 按值返回 -> C++17 复制消除,只构造 1 次\n";Blob c=makeBlob(2);// 不调用拷贝构造(elided)std::cout<<"[4] 拷贝赋值(对象已存在)\n";Blobd(3);d=a;// 拷贝赋值,不是拷贝构造std::cout<<"--- main 结束,开始析构 ---\n";}
[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的前提下完全等价,都调拷贝构造:

#include<iostream>structT{intid;T(inti):id(i){std::cout<<"构造 #"<<id<<"\n";}T(constT&o):id(o.id){std::cout<<"拷贝构造 #"<<id<<"\n";}T&operator=(constT&){std::cout<<"拷贝赋值\n";return*this;}};intmain(){Tsrc(1);T a=src;// 拷贝初始化(copy-initialization)Tb(src);// 直接初始化(direct-initialization)}
构造 #1 拷贝构造 #1 拷贝构造 #1

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

// 反例,不要这么写:把拷贝构造声明为 explicit,会禁用拷贝初始化 T x = ystructBad{explicitBad(constBad&){}// explicit 拷贝构造};Bad x;Bad y=x;// 编译失败:拷贝初始化不允许调用 explicit 构造Badz(x);// OK:直接初始化允许

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

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

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

// 反例,不要这么写:裸指针成员只拷地址不拷数据 -> 浅拷贝 -> double freestructShallow{int*p=newint(42);~Shallow(){deletep;}// 两个对象析构时都 delete 同一块 -> double freeShallow(constShallow&o):p(o.p){}// 只复制指针,没复制所指数据};Shallow x;Shallow y=x;// 浅拷贝:x.p 与 y.p 指向同一块;析构时 delete 两次 -> 未定义行为
浅拷贝(危险):两个对象共享同一块堆内存 x.p ─────┐ ▼ [ 42 ] <- 同一块,析构时被 delete 两次 -> double free ▲ y.p ─────┘ 深拷贝(安全):各自拥有一份独立副本 x.p ──► [ 42 ] y.p ──► [ 42 ] <- 两块独立内存,各自析构,互不干扰

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

#include<iostream>#include<vector>structBuffer{std::vector<int>data;Buffer(std::initializer_list<int>il):data(il){}voidshow(constchar*tag)const{std::cout<<tag<<": ";for(intv:data)std::cout<<v<<" ";std::cout<<"\n";}};intmain(){Buffer a{1,2,3};Buffer b=a;// 拷贝构造 -> vector 深拷贝,b 拥有独立副本b.data[0]=999;// 改 b 不影响 aa.show("a");b.show("b");}
a: 1 2 3 b: 999 2 3

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

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

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

#include<iostream>#include<vector>structVec{// 一个自带深拷贝的小向量(Rule of Zero 思维)std::vector<int>d;Vec(std::initializer_list<int>il):d(il){}voidput(intv){d.push_back(v);}voidshow(constchar*t)const{std::cout<<t<<"(size="<<d.size()<<"): ";for(intv:d)std::cout<<v<<" ";std::cout<<"\n";}};intmain(){Vec a{1,2};Vec b=a;// 拷贝构造:深拷贝b.put(3);// 只改 ba.show("a");b.show("b");Vec c{9};c=a;// 拷贝赋值:深拷贝c.put(0);a.show("a");c.show("c");}
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的缓冲地址就明白:

// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<iostream>#include<utility>#include<vector>intmain(){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";}
拷贝体重新分配了缓冲 = 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的前提下才敢用移动,否则为了保持强异常保证,它会退回用拷贝。

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

// 反例,不要这么写:移动构造只搬指针却不置空源对象 -> 两个对象析构时 double free#include<cstddef>classBuffer{public:explicitBuffer(std::size_t n):data_{newint[n]},size_{n}{}~Buffer(){delete[]data_;}Buffer(Buffer&&o)noexcept:data_{o.data_},size_{o.size_}{}// 少了一行 o.data_ = nullptrprivate:int*data_;std::size_t size_;};

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

// 正确写法:搬走资源后把源对象置空,并声明 noexcept#include<cstddef>#include<memory>#include<utility>classBuffer{public:explicitBuffer(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的,就别自己扛。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询