C++默认成员函数深度解析:构造、析构、拷贝与移动的生成规则和避坑实践
上周帮一个新人review代码类里面明明只写了一个构造函数他却一脸困惑地问我“这个类不是应该有个默认构造函数吗为什么我创建一个不传参的对象它也能编译过去”我一看他以为的“默认构造函数”是编译器隐式生成的那个但实际上是他自己写的带默认实参的构造函数。一场概念混战由此展开。这件事让我意识到很多人对C默认成员函数的理解停留在“编译器会生成”这几个字上但什么时候生成、什么时候被删除、生成出来的函数长什么样、调用时机是什么、跟构造析构怎么配合其实都没彻底搞透。这篇文章我就把C的默认成员函数、构造函数、析构函数这一整套规则摊开来讲。不讲虚的直接带着你从规则表走到调用时机再从调用时机走进底层原理最后把我在实际项目里踩过的坑一并倒出来。无论你是刚入门C的新手还是写了几年、但对某些细节仍然含糊的老手这篇都值得收藏。光一个“拷贝构造函数在哪些场景下会被调用”面试就够问三轮了。1. 六个默认成员函数的生成与删除规则编译器不是傻子它按章办事很多人以为只要写一个空类class Foo {};编译器就会“好心”地自动生成构造函数、析构函数、拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符这六大王牌函数。这个说法大方向没错但细节非常苛刻。C11之后的标准委员会明确规定了这些隐式函数的生成条件稍有不满足编译器就直接把某个函数标记为delete而不是帮你生成一个。1.1 默认构造与析构最容易被误解的两个生成时机先看默认构造。编译器只有当这个类“没有任何用户声明的构造函数”时才会隐式生成默认构造函数。什么叫“用户声明的”就是你亲手在类里写了任何一个构造函数哪怕是一个带参数的编译器就不再帮你生成那个无参版本。这时候你如果还试图Foo f;创建一个无参对象编译报错是活该不是编译器的锅。我见过一个特别典型的写法class Config { public: Config(const std::string path) : path_(path) {} private: std::string path_; }; Config c; // 编译错误没有默认构造函数很多人想当然地认为写了带参构造之后编译器仍然会“顺带”生成一个默认构造。事实上不会只要存在任何一个用户声明的构造函数默认构造就不再生成。这里顺带说一句如果成员变量本身有默认成员初始化器花括号初始化那即使不写默认构造也可以通过聚合初始化或者依赖成员的默认值来规避一部分问题这是另一个话题后面会讲。析构函数的生成规则相对宽松只要你没有声明析构函数编译器一定会隐式生成一个。析构还要注意一点如果基类的析构函数是虚的派生类隐式生成的析构函数自动也是虚的不管你有没有写virtual关键字。很多人不敢写 default的析构就是怕把虚函数表搞乱实际上virtual ~Base() default;是现代C最干净的写法。1.2 拷贝与移动四件套一言不合就删除拷贝构造函数、拷贝赋值运算符、移动构造函数、移动赋值运算符这四个生成条件才是真正的重头戏。标准里的规则可以用一张表读懂条件拷贝构造拷贝赋值移动构造移动赋值用户声明了移动构造或移动赋值被删除被删除不生成已有用户声明不生成用户声明了析构函数生成但已弃用生成但已弃用不生成不生成用户声明了拷贝构造或拷贝赋值其中一个用户声明另一个仍可能生成同左不生成不生成成员或基类不可拷贝如unique_ptr成员被删除被删除可能不生成可能不生成类内有const或引用成员拷贝构造仍生成拷贝赋值被删除被删除不生成不生成这张表你乍一看可能有点晕我帮你总结成一句话移动操作是“娇贵”的只要用户声明了拷贝、移动或析构中的任何一个移动构造和移动赋值就不会被隐式生成。而拷贝构造和拷贝赋值相对“皮实”哪怕你声明了析构或另一个拷贝操作它们依然会生成只是标准建议你别依赖这种旧时代的行为了。这里还隐藏着一个新手特别容易踩的坑类里有一个std::unique_ptr成员那么编译器不会生成拷贝构造和拷贝赋值因为unique_ptr自己就禁止拷贝。如果你强行复制这个类会得到一条“attempting to reference a deleted function”之类的编译错误。这不是你代码逻辑有问题而是条件表触发了删除规则。1.3 特殊成员const成员和引用成员的赋值陷阱继续说一个高频面试点类里有const成员或者引用成员时拷贝赋值运算符会被直接删除。为什么因为const成员不能重新赋值引用的绑定关系也不能改变编译器没法保证“逐个成员赋值”的语义干脆删掉。但拷贝构造函数不受影响因为拷贝构造是“初始化”不是“赋值”。class C { public: C(int x) : ref_(x) {} private: int ref_; // 引用成员 }; C a(10); C b(a); // 可以拷贝构造没问题 b a; // 编译错误拷贝赋值运算符被删除这个例子能让你彻底明白初始化与赋值的根本区别也是为什么我一直建议所有成员尽量用初始化列表而不是在构造函数体内赋值的原因之一。2. 构造函数从成员顺序到初始化列表每个细节都是性能与正确性的分水岭构造函数是整个对象生命的入口但它内部的执行顺序很多人背着背着就乱了。我直接给你一个最关键的结论成员变量的初始化顺序只跟它们在类体内的声明顺序一致跟初始化列表的书写顺序没有任何关系。这一点看似基础但引发的Bug比想象中多得多。2.1 初始化列表的书写顺序会骗你假设你写了这样的代码class Test { public: Test(int n) : b_(n), a_(b_) {} void print() { std::cout a_ b_ std::endl; } private: int a_; int b_; };请问构造之后a_是多少很多答错的人抱着“初始化列表写在前面的先初始化”的错觉认为b_先初始化为n然后a_用b_初始化结果是n n。但真实输出是b_先被初始化为n然后a_才被初始化为b_的值而且因为声明顺序是a_在前所以实际执行顺序是a_先被初始化此时b_还未构造读到的是一块未初始化的内存然后b_才被初始化为n。最终a_的值完全是未定义的垃圾值。修复方法也很简单把初始化列表的书写顺序调整成和声明顺序一致或者更稳妥的做法是不要在初始化列表中依赖其他成员的当前值——因为那是一个典型的顺序陷阱。编译器通常会给出-Wreorder警告我已经习惯把这种警告当成硬错误处理了。2.2 为什么“初始化”优于“赋值”少一次默认构造多一层const安全在构造函数体内写a_ value;和用初始化列表a_(value)表面效果看起来一样底层差别极大。体内赋值意味着成员会先被默认构造或者被初始化成不定值然后再被赋值一遍而初始化列表是直接使用对应构造函数初始化目标成员一步到位。这里有一个实践判断标准如果某个成员是const类型或者引用类型你根本没有选择的余地必须用初始化列表。因为它们在进入构造函数体之前就必须被初始化完毕体内赋值根本来不及。我以前维护过的老代码库里有人把const成员直接放在体内赋值编译器的报错信息能绕地球三圈最后这位同事自己都没想明白原因。2.3 委托构造与默认实参少写重复代码的两种姿势C11之后支持委托构造函数也就是在一个构造函数里调用另一个构造函数目的是让多个重载共享初始化逻辑。比如class Server { public: Server() : Server(8080) {} explicit Server(int port) : port_(port) {} private: int port_; };注意这里有个细节委托构造只能把初始化逻辑委托给另一个构造函数不能在“委托目标还带初始化列表”的同时自己又初始化别的成员。标准里明确禁止“同时委托和初始化”的写法编译器会直接报错。你要么全部委托要么自己初始化不存在两者兼顾的写刑法。默认实参则是另一个维度的便利。比如上面那个Server可以干脆写成explicit Server(int port 8080) : port_(port) {}这样Server s;和Server s(9090);都能编译。这也是许多老手爱用的技巧。但注意一旦你写了带默认实参的构造函数它会和隐式默认构造函数产生冲突吗不会因为它本身就是用户声明的构造函数编译器不会再去生成那个无参版本你的默认实参恰好补上了那个无参调用的缺口。2.4 explicit关键字不要让你的构造函数悄悄变成转换运算符最后一个常被忽略的点是explicit。一个非explicit的单参数构造函数C会允许它作为隐式转换运算符。举个例子class Port { public: Port(int p) : value_(p) {} private: int value_; }; void listen(const Port p); listen(80); // 合法但完全隐式地把80变成Port这行代码能通过编译是因为Port(int)不是explicit编译器干了“隐式构造”的活。在早期代码里这种写法非常危险你传一个int参数给任何形如void f(const Port)的函数都不会报错等发现逻辑错误的时候调试难度直线上升。所以我的建议很简单所有单参数构造函数默认加explicit除非你真的想要那种隐式转换的便利性。这是C社区近几年一致推荐的纪律。3. 析构函数与RAII为什么说析构才是C资源管理的灵魂构造函数负责“开局”析构函数负责“收尾”。但我发现很多人在学习时只把析构当成“类结束前清理内存的函数”视野太窄了。析构函数的本质是对象生命周期到终点时编译器自动帮你调用的那个约定。这个约定配合栈对象的自动销毁构成了C独有的RAII资源获取即初始化范式也是C跟Java、Go最大的不同。3.1 对象生命周期与析构时机栈、堆、容器三种场景先搞清先讲最基础的栈上对象在离开作用域时析构这个时机是精确且确定的。堆上对象则不同只有在你手动delete时才析构。这也意味着如果delete忘了写析构永远不会被调用资源泄漏就这么来了。容器的析构就更有意思了。std::vector析构时会先把内部每个元素按逆序析构后插入的元素先析构然后释放底层内存。如果你用vector存了裸指针那它会帮你把指针变量本身析构掉但不会delete指针指向的对象。这又是一个“容器销毁但不负责内部裸指针对象”的坑。正确做法是使用智能指针让shared_ptr/unique_ptr作为容器元素这样容器析构时智能指针的析构会顺带释放堆对象形成链式反应。还有一个特别容易忽视的时机函数返回值临时对象。一个函数返回一个类对象时如果返回值没有被绑定到引用那个临时对象在函数调用链的下一个分号处就会析构。这个时机看似无关紧要但一旦析构函数里有日志或者副作用控制台输出顺序会让你怀疑人生。3.2 RAII的实战威力互斥锁、文件句柄和堆内存一网打尽RAII的核心思路是“把资源的生命周期绑定在对象生命周期上”。我举个最常用的例子class LockGuard { public: explicit LockGuard(std::mutex mtx) : mtx_(mtx) { mtx_.lock(); } ~LockGuard() { mtx_.unlock(); } private: std::mutex mtx_; };使用时只需要在函数开头放一个LockGuard lock(mtx);就再也不需要担心任何中间分支提前return导致锁没释放了。因为函数无论从哪条路退出栈对象都会析构。这就是为什么现代C代码里看不到成对的lock()和unlock()了大家全部用std::lock_guard和std::unique_lock。本质上C的智能指针std::unique_ptr也是这个套路。资源获取时构造函数接收指针并持有它析构时delete那块内存。所以遇到异常导致栈展开时智能指针的析构一定会被执行内存不会漏。这套机制是语言级的不是靠程序员自觉这才叫真正的资源安全。3.3 虚析构多态基类绝对不能省的关键词这条建议我给每一个写C的人反复强调只要这个类打算作为基类使用它的析构函数必须是虚的。否则当你用一个Base*指向Derived对象然后delete base_pointer行为是未定义的。绝大多数编译器在实现时只会调用Base的析构而Derived的析构和它里面资源的释放全部被跳过。这个问题的本质是动态绑定。只有虚函数才参与动态绑定普通的非虚析构在编译期就决定了要调用哪个版本。当你的指针类型是Base*编译器就调Base::~Base至于实际对象是不是Derived它完全不关心。代码会继续跑甚至不报错但泄漏的资源和半初始化的清理逻辑会藏在深处等性能测试才发现不对劲。修正方法就一行class Base { public: virtual ~Base() default; };用 default让编译器生成虚析构函数体比手写空的{}更符合现代C的简洁性和异常安全要求。值得一提的还有一点一旦你把析构声明为虚的编译器就不会隐式生成移动构造函数了因为移动生成条件里明确要求类不能有用户声明的析构函数如果你需要移动语义得手动补上Base(Base) default;。这也是很多人改基类时踩的第二脚。3.4 成员析构顺序声明逆序、派生先于基类最后补充一个细节。一个类的析构函数体执行完毕后编译器会自动按成员声明顺序的逆序析构所有成员变量最后再调用基类的析构。如果稍不注意你就可能写出“在析构体里使用某个成员但这个成员其实还活着”这种代码——注意这是允许的因为析构函数体执行时成员还没有开始析构。真正的危险是不要在成员析构之后还企图通过引用或指针使用它。举例来说如果你在析构体里把某个资源句柄交给第三方库清理而清理过程发生在成员析构之后那就会造成悬垂引用。4. 拷贝构造的调用时机不是只有“复制对象”时才触发拷贝构造函数是整个默认成员函数体系里最让人心力交瘁的一个。因为它的调用时机远比字面意思宽泛很多你以为在赋值、传参、返回的场景实际都在悄悄调用拷贝构造。理解了这些时机才能解释为什么某些代码“慢得离谱”且“疯狂创建临时对象”。4.1 五大典型触发场景先看结论拷贝构造通常在以下场景触发用一个同类型的对象初始化另一个对象包括A a(b);和A a b;注意等号在这里是“拷贝初始化”不是赋值。函数参数按值传递比如void f(A a);调用f(b)时形参a由b拷贝构造而来。函数返回一个对象且返回值的临时对象需要拷贝给调用方C17之前常见C17后多半被拷贝消除接管。标准容器插入元素比如vector.push_back(obj)元素被拷贝进容器的存储空间。抛出和捕获异常时异常对象会被拷贝。我挑一个最容易看走眼的写出来std::string getFullName() { std::string name C; return name; }很多人以为return name;一定是调用了移动构造因为返回的是左值变量名是左值老标准里这确实会调用拷贝构造除非你写std::move(name)。但从C11开始只要函数返回的表达式是局部变量满足NRVO条件编译器就可以直接省略拷贝/移动构造把局部对象“构造”到调用方的存储空间上一次拷贝都不发生。这个优化叫具名返回值优化NRVO。为什么大家感觉代码“没那么慢”因为编译器已经帮你省掉最昂贵的那次拷贝了。4.2 浅拷贝的悲剧指针成员导致的“双重释放”现在讲拷贝构造最容易出Bug的地方——浅拷贝。默认生成的拷贝构造函数是“逐成员拷贝”对整数、浮点这种值类型没问题但对原生指针成员它拷贝的是指针本身而不是指针指向的数据。于是两个对象拥有同一个堆内存地址析构时先析构的那个释放了堆后析构的那个再次释放程序直接崩掉“double free”。我故意写一个反面教材class StringBad { public: StringBad(const char* s) { len_ strlen(s); data_ new char[len_ 1]; strcpy(data_, s); } // 没有自定义拷贝构造默认浅拷贝 ~StringBad() { delete[] data_; } private: char* data_; int len_; };StringBad a(hello); StringBad b(a);这一行之后a.data_和b.data_指向同一块内存。到程序退出时先析构的那个把内存delete了第二个析构又delete一次堆管理器直接报警。如果你有文章开头热词里那个c#调用c出现access violation c0000005的报错很多时候不是跨语言调用本身的问题而是C侧把裸指针或引用传出去后生命周期管理没做好——比如说返回了一个指向栈对象的引用或者内部对象过早析构——这一类问题跟浅拷贝引发的悬垂指针是同族病根。修复方式是提供正确的深拷贝构造StringGood(const StringGood other) { len_ other.len_; data_ new char[len_ 1]; strcpy(data_, other.data_); }不过在真正的工程里最优解是“不手写字符串类”直接用std::string。这就引出下面要讲的Rule of Zero。4.3 引用、指针和移动三种绕开拷贝的替代方案如果某个对象拷贝成本高你有三个方向减少拷贝传引用函数参数改成const A只读访问完全不会触发拷贝这也是所有C教程强调的“能用引用就不用值”的原因。传指针老式C风格做法但裸指针容易造成生命周期混乱尽量用std::shared_ptr或std::unique_ptr来传递所有权而不是暴露裸指针。移动把即将销毁的对象“转移”到新对象里用std::move(a)转成右值引用触发移动构造。移动构造通常只是把指针所有权交割一下开销几乎是常数级。我给团队定的规矩是自定义类型如果不需要拷贝就明确delete拷贝构造和拷贝赋值从根上杜绝意外拷贝。这里可以把 delete看成是“把编译器本来会生成的函数扼杀在摇篮里”class NonCopyable { public: NonCopyable() default; NonCopyable(const NonCopyable) delete; NonCopyable operator(const NonCopyable) delete; };5. 移动语义与三五法则现代C的默认成员函数实践路线前面讨论了拷贝现在轮到这个时代的主角——移动语义。很多人在学习时有一个误区以为写了std::move(x)就一定会调用移动构造。真相是std::move只是把左值强转成右值引用真正调不调用移动构造得看这个类有没有可用的移动构造以及有没有被条件规则删除。如果类没有移动构造编译器会退回去调用拷贝构造甚至因为拷贝构造也被禁止而直接编译失败。5.1 Rule of Three、Rule of Five与Rule of Zero老生常谈的“三法则”说的是如果你需要自定义析构、拷贝构造、拷贝赋值中的任何一个通常三个都得自己写。延伸到C11“五法则”加上移动构造和移动赋值。但现在C社区更推崇的是Rule of Zero尽量设计出不需要自己管理资源的类把所有资源管理工作交给标准库组件string、vector、unique_ptr、shared_ptr。这样编译器生成的默认拷贝、移动、析构基本都是正确的你不需要手写任何特殊的成员函数。什么时候需要手写五件套只有这个类在直接管理裸资源时。比如你确实要写一个底层内存池、一个原始文件句柄包装类、一个自研的缓冲区你才有必要触碰拷贝控制和移动控制的细节。项目里90%的类你只需要让成员全是RAII类型就行。为了让读者一眼看懂我把三/五/零法则汇总成一张速查表类型析构拷贝构造拷贝赋值移动构造移动赋值适用场景Rule of Three手写手写手写无需无需C03遗留代码风格管理裸资源Rule of Five手写手写手写手写手写需要显式支持移动的裸资源管理器Rule of Zero默认默认默认默认默认成员均由标准库RAII类型组成5.2 移动构造函数被“静默忽略”的陷阱noexcept的力量移动构造有个职业病它默认就要声明为noexcept才好用。为什么因为标准容器在重新分配内存比如vector扩容时需要把旧元素搬运到新内存。如果移动构造可能抛出异常容器为了保证强异常安全会选择使用拷贝构造而不是移动构造。也就是说你明明提供了一个高效的移动构造但因为漏掉了noexcept容器在扩容时还是傻乎乎地逐个拷贝性能直接掉回解放前。实际验证过这个坑确实存在class Item { public: Item() default; Item(Item other) noexcept : data_(std::move(other.data_)) {} private: std::string data_; };上面这行noexcept是关键。如果你写的是Item(Item other)没有加noexceptstd::vectorItem的扩容操作可能退化为拷贝而拷贝std::vector这种底层对象又可能递归触发更多拷贝性能影响很可能不是“一点”而是成倍放大。当你用std::move搬一个对象时如果编译器检查到该类没有noexcept移动构造即使有移动构造它也可能选择拷贝。这一步纯属标准库的“自我保护”不怪编译器“不听话”。5.3 返回值优化与移动语义的衔接别乱用std::move“帮倒忙”还有一个反直觉的细节在“返回一个局部变量”时如果你画蛇添足写成return std::move(local);编译器反而无法执行NRVO因为此时返回的已经不是“具名局部变量”这个形态而是一个右值引用表达式。在C17前的标准下编译器可能被迫调用移动构造虽然移动也不算太慢但如果你原本可以在调用点直接构造出对象零拷贝这一下就亏了。到了C17带有std::move的返回值又不一定触发强制拷贝消除。所以我的建议是局部变量直接return name;编译器知道该如何优化别自作聪明。5.4 移动操作生成后的“遗留问题”拷贝构造被禁代码为何编译失败最后强调一个非常容易让团队卡壳的情况你给类补了一个移动构造函数但没补移动赋值或者你只写了拷贝构造然后手动加了一个移动构造编译器会依据第一节那张表把另一侧操作删除或弃用。这个时候代码里原本能编译的obj1 obj2;突然开始报错排查半天才发现是移动操作的声明影响了拷贝赋值的生成。这类问题时标准库组件尤其常见你封装了一个类它里面是unique_ptr成员移动构造是默认生成的而拷贝构造被删除了所以任何试图按值传这个类的代码全部编译失败。看清删除条件和生成条件比死记函数原型重要得多。6. 实战避坑集锦从access violation到vscode跳转都是构造析构惹的祸文章写到这儿核心理论已经过了一遍。但真实项目永远比规则表复杂我最后把几个高频坑集中拿出来每一个都是我或团队伙伴实际踩过的纯输出不带铺垫。6.1 崩溃排查现场双重析构与悬垂引用回到前面提到的c#调用c出现access violation c0000005。这类跨语言访问违规很可能不是C#的错而是C侧把对象的生命期“交”出去了。举个例子C导出一个函数返回类的实例但内部实际上返回的是static对象的引用或者裸指针C#端多线程收到后又一次调用了释放函数导致同一块内存被两个线程抢着清理。遇到这种崩溃我的排查顺序永远固定成先看析构函数里有没有delete再看返回的裸指针是从哪来的最后看有没有浅拷贝把同一块资源复制给多个所有者。99%的段错误和访问违规都能在这三步里找到答案。6.2 写类成员时的一个习惯优先默认成员初始化器有些兄弟不喜欢写初始化列表喜欢在构造函数体里给成员赋值导致程序初始化顺序不统一。我的建议是成员变量能就地初始化的直接在声明处给默认值class Task { private: std::string name unnamed; int priority 0; };这样做的好处是如果某个构造函数忘了在初始化列表里初始化这个成员成员也有一个合理初始状态不会出现半初始化状态。尤其是指针成员我会直接int* data_ nullptr;这样未构造彻底的对象也不至于持有野指针。这个习惯在写大型类、有十几个构造函数重载时能省下大把“这个成员为什么是一堆乱码”的调试时间。6.3 vscode与函数跳转不生效的连带问题热词里有“vscode c所有的函数变量都没办法跳转”这其实也常跟类和模板的定义位置有关。C的语法解析高度依赖“先声明后使用”如果你把函数定义都写在.cpp里而.h只有声明ctags或IntelliSense就很难完整索引到实现。解决办法是把.h和.cpp同时加入工作区并让配置指向正确的编译器路径。另外模板类的成员函数必须写在头文件里否则其他翻译单元根本看不到实现跳转自然失效。这虽然不是构造析构本身的问题但排查一上午才发现是因为类模板的成员定义放错了文件确实让人血压飙升。6.4 分文件编译时别在头文件里写“隐藏副作用”的构造析构还有一个我多次强调的纪律不要在头文件里的构造函数或析构函数中写太复杂的逻辑。头文件被多个翻译单元包含后每个翻译单元都可能生成这个构造/析构的实例虽然链接器会合并这些弱符号但代码膨胀和跨编译单元的初始化顺序问题会成倍放大。尤其析构函数里如果依赖了某个全局静态资源而那个资源在另一个编译单元析构启动顺序可能先于全局资源销毁形成“使用已析构对象”的未定义行为。遇到这种情况最好的方法是把构造析构声明留在头文件实现放到.cpp里确保它只被实例化一次。这几轮经验走下来我对默认成员函数最大的体会是能用编译器自动生成的就绝不自己手写能加到 default的就绝不写空函数体能不加的拷贝控制就绝不定义。移动语义、拷贝消除这些现代特性本来就是来帮我们省事的你非要手动造轮子反而会触发那一堆删除规则把项目引向深渊。学习这套规则不是为了让面试官给你点头而是让你在哪个对象先析构、哪个函数被悄悄删除、哪个拷贝被无声优化掉这些最容易被忽视的战场上不再输得稀里糊涂。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →