深入解析C++六大默认成员函数:构造、析构、拷贝与赋值
学C的过程里类和对象这一章几乎是第一道分水岭。我见过不少朋友前面指针、引用、函数重载都学得挺好结果一碰到class就开始懵我明明没写构造函数对象为什么照样能建出来为什么两个对象用等号一赋值程序跑着跑着突然崩了这些问题的答案全集中在类的六大默认成员函数里。所谓“默认”是指你并没有显式定义它们但编译器会在需要时偷偷生成一份默认实现。这篇文章就把这六个函数全部拆开讲重点放在三件事上哪些函数编译器能帮你写且写得没问题哪些函数它写得很不负责需要你重写哪些函数你几乎可以无视它的存在。文中代码都以最直观的方式呈现适合刚学完类和对象语法、正在做第一个小项目的初学者也适合那些写了几个月C却始终没弄清楚浅拷贝和深拷贝到底怎么回事的朋友。1. 类设计的地基为什么默认的六个成员函数躲不掉1.1 从struct到class数据开始拥有行为如果你写过C语言结构体的代码基本都经历过这种模式定义好struct然后写一堆以结构体指针为参数的函数比如InitStudent(Student* s, ...)、PrintStudent(Student* s)每次调用都要手动传指针进去一旦忘了对象就停留在半初始化状态。C里class把成员变量和操作这些变量的函数打包在一起表面上调用方式变了从PrintStudent(s)变成s.Print()但更关键的变化是编译器在幕后给每个成员函数塞了一个this指针指向当前对象于是成员函数天生就知道该操作哪块内存。六大默认成员函数其实是围绕this指针工作的六个特殊成员。什么叫“默认”就是你一个都没写编译器也会在需要的时候生成对应的实现。这也解释了为什么很多人刚学class时觉得“莫名其妙就能跑了”——不是你的代码神奇而是编译器默默替你把事情做了。理解这个前提之后我们再看它们各自长什么样、什么时候触发、什么时候必须重写就会清晰很多。1.2 空类不是真空编译器悄悄生成了什么先看一个经典例子class Empty { }; int main() { Empty e1; Empty e2(e1); e2 e1; Empty* p e1; const Empty e3; const Empty* cp e3; return 0; }这段代码每一条语句都能编译通过。一个没有任何成员的空类居然支持构造、拷贝、赋值、取地址原因就是编译器为它补全了六个默认成员函数默认成员函数典型形式触发场景默认构造函数Empty()Empty e1;析构函数~Empty()对象生命周期结束时拷贝构造函数Empty(const Empty)Empty e2(e1); / Empty e3 e1;赋值运算符重载Empty operator(const Empty)e2 e1;普通取地址重载Empty* operator()e1const取地址重载const Empty* operator() const对const对象取地址这里有个配套的小知识空类对象的大小通常被编译器设为1字节。因为C规定每个对象都要有独一无二的地址空类没有任何成员如果大小为0两个不同的空对象就会共用同一个地址这不符合语言要求所以编译器硬塞了1个字节进去。这个问题经常在面试题里出现顺手记住就好。还要注意编译器生成这些默认版本是有前提的你并没有自己声明同名的函数。一旦亲手写了任何一个构造函数编译器就不再替你生成默认构造函数一旦写了拷贝构造默认拷贝构造也不会生成。这些“生成时机”的规则值得先刻在脑子里。1.3 全文的两条演示主线Date 和 Stack后文会反复用两个类做演示。第一个是Date成员全是year、month、day这类int值不涉及动态资源第二个是Stack内部用new开了一块动态数组属于典型的需要管理资源的类。这两个例子几乎覆盖了日常写类会遇到的所有情况。这里先给出一条判断准线如果类里所有成员都是普通值类型那六大默认成员大多可以放心交给编译器一旦类里出现了裸指针、动态数组、文件句柄、锁这类资源就要立刻警觉——后面的拷贝构造、赋值、析构基本都逃不掉要自己写。记住这条准线后面所有细节才有落点。2. 构造函数与析构函数对象一生的开始与收尾2.1 构造函数的定义规则把初始化从手动变成自动构造函数有三个核心特点函数名与类名完全相同、没有返回值、创建对象时自动调用且只调用一次。它支持重载也支持默认参数先看一个最经典的Date写法class Date { public: Date(int year 2024, int month 1, int day 1) { _year year; _month month; _day day; } void Print() const { std::cout _year - _month - _day std::endl; } private: int _year; int _month; int _day; }; int main() { Date d1; // 使用全缺省参数等价于默认构造 Date d2(2025, 6, 30); // 使用指定参数 d1.Print(); d2.Print(); return 0; }因为参数全部给了缺省值所以Date d1;这种“不传任何实参”的写法也能编译通过我们称它构成了“默认构造函数”。C里对默认构造的定义是不用传实参就能调用的构造函数。全缺省版本和无参版本都算但如果你把二者同时写出来编译器分不清该调谁反而会报“二义性”错误。如果类里完全没有写任何构造函数编译器也会生成一个默认构造函数但注意这个默认版本对内置类型成员通常不做初始化也就是Date d;之后_y、_m、_d可能是随机值。更常见的坑是你只要手动写了一个带参数的构造函数编译器就不再提供无参版本了这时候再写Date d;就会编译失败。这类错误几乎每个初学C的人都碰到过。2.2 初始化列表构造函数真正的初始化现场很多人以为构造函数函数体里写赋值就是在初始化其实不是。真正的初始化发生在函数体之前的“初始化列表”函数体里的操作是“先构造后赋值”。初始化列表的语法是在构造函数参数列表后面加一个冒号再按成员(参数)的格式写class Date { public: Date(int year, int month, int day) : _year(year), _month(month), _day(day) { } private: int _year; int _month; int _day; };表面上这只是另一种写法但对三种成员来说初始化列表不是可选项而是必选项const成员、引用成员、没有默认构造函数的类类型成员。原因很直接const成员一旦定义就不能再赋值只能在初始化那一刻设置好引用成员必须在构造时绑定目标没有默认构造的类类型成员如果没有在初始化列表里显式构造函数体里根本无法给它赋值。还有一个非常隐蔽的细节初始化列表的执行顺序不按照列表书写顺序而按照成员在类中的声明顺序。所以如果成员之间有依赖比如_a用_b的值初始化那么顺序很可能和你预期不一致。平时写代码尽量让初始化列表书写顺序与成员声明顺序保持一致可以省掉不少排查时间。2.3 析构函数与对象生命周期谁负责回收款析构函数的名字是~类名没有参数也没有返回值一个类只能有一个析构函数不能重载。对象生命周期结束时自动调用栈对象离开作用域时调用delete堆对象时也会先调用析构再释放内存。给Stack类加上资源管理class Stack { public: Stack(size_t capacity 10) : _a(new int[capacity]), _capacity(capacity), _size(0) { } ~Stack() { delete[] _a; _a nullptr; _capacity _size 0; } private: int* _a; size_t _capacity; size_t _size; };Stack用new[]向堆申请了一块连续内存析构函数就要用delete[]把它还回去。这里必须强调new和delete配对、new[]和delete[]配对混用属于未定义行为轻则内存泄漏重则运行时崩溃。多个对象同时存在时析构顺序遵循“后构造的先析构”。比如先定义d1再定义d2程序结束时先析构d2再析构d1。这个顺序和局部变量的销毁规则一致初学阶段可以先不用深究为什么但遇到依赖关系时要想一想如果d2析构时需要用到d1的资源顺序很可能和你以为的相反。2.4 构造/析构阶段最容易忽略的三个习惯第一构造函数里不要做重活尤其是连接数据库、打开网络这种可能失败的操作。你辛辛苦苦在构造函数里初始化到一半突然失败对象已经构造完成但状态是错误的调用方拿到的就是一个“半成品”排查成本极高。建议把这类逻辑放到专门的Init函数或工厂函数里。第二析构函数释放资源后尽量把指针置空这能避免后续误访问已经释放的内存也能让调试时从变量面板看出“这个对象已经空了”。第三尽早接受RAII思想资源的获取放在构造函数里自动完成资源的释放放在析构函数里自动完成让资源生命周期和对象生命周期绑定。Stack把new数组的开辟和释放绑定到构造和析构就是RAII最初的模样后面学智能指针时会反复遇到。3. 拷贝构造函数默认拷贝是怎么埋雷的3.1 拷贝构造的定义与触发时机拷贝构造函数就是用同一个类型的已有对象去创建一个新对象时调用的构造函数。典型触发场景有四个Date d1(2025, 1, 1); Date d2(d1); // 直接拷贝构造 Date d3 d1; // 注意这行也是拷贝构造不是赋值 void Func(Date d); // 传值传参时实参会拷贝构造形参 Date Func2() { Date tmp(1, 2, 3); return tmp; } // 返回对象时可能触发拷贝很多新手看到Date d3 d1;有等号就以为是赋值运算这是整章最大的误区之一。判断标准很简单看等号左边的对象是不是这行代码里第一次定义。如果之前不存在那这就是初始化走拷贝构造如果已经定义过了再赋值那才是赋值运算符重载。这个区分在下一章还会反复用到。顺便说一句传值和返回对象时现代编译器通常有返回值优化RVO/NRVO并不一定真的每次都会发生拷贝。但初学阶段先按“可能触发拷贝”来理解能把拷贝构造的各种行为和代价看得更清楚。3.2 默认拷贝构造的原理逐成员浅拷贝编译器生成的默认拷贝构造规则只有一句话对内置类型成员直接值拷贝对自定义类型成员调用它自己的拷贝构造。简单来说就是把a对象的每个成员往b对象里原样复制一份。对Date这种成员全是int的类这个规则毫无问题。三个整数数字复制过去内容一样彼此独立谁也不会影响谁所以Date完全不需要手写拷贝构造。真正的问题出在成员本身“只是地址”的情况上比如指针成员。3.3 浅拷贝事故MyString 的二次释放用一个最简单的MyString类来还原事故现场#include cstring class MyString { public: MyString(const char* str ) { _str new char[strlen(str) 1]; strcpy(_str, str); } ~MyString() { delete[] _str; _str nullptr; } private: char* _str; }; int main() { MyString s1(hello); MyString s2(s1); // 这里调用编译器默认生成的拷贝构造 return 0; }假设s1的_str指向堆上地址0x1234默认拷贝构造会把s2的_str也复制成0x1234两个对象管理的是同一块内存。主函数结束时s2先析构对0x1234执行delete[]随后s1析构又对0x1234执行一次delete[]这就是对同一地址的二次释放结果通常是堆校验失败或直接崩溃。这个现象就是典型的浅拷贝问题浅拷贝只复制了指针变量本身没有复制指针指向的那片空间。打个比方指针变量就像一张写着门牌号的纸条浅拷贝等于你抄了一份门牌号给别人两个人拿同一把钥匙开同一扇门。更麻烦的是两个人各自离开时都要“负责把房子拆了”第二个人动手时房子已经被拆过一遍了。有人可能会问s2析构时不是已经把_str置空了吗注意s2里的变量置空并不影响s1里的_strs1的指针仍然指向那块已经被释放的内存所以它析构时依然会二次释放。这告诉我们释放后置空是保护自己的好习惯但救不了“共享同一块内存的另一个对象”。3.4 深拷贝实现自己动手丰衣足食解决办法就是手写拷贝构造做一次真正的“内容复制”MyString(const MyString s) { _str new char[strlen(s._str) 1]; strcpy(_str, s._str); }核心逻辑是先测量源对象的字符串长度给新对象分配一块同样大小的独立内存再把内容一个一个拷过去。这样s2的_str指向的是一块全新的内存里面内容与s1相同但两者互不干涉各自析构时各释放各的内存谁都不会伤害对方。这里有个高频疑问strlen(s._str)里的s._str是私有成员为什么MyString的拷贝构造里能访问原因是拷贝构造函数本身是MyString的成员函数C允许同一个类的不同对象之间互相访问对方的私有成员。这个规则初学阶段很容易产生疑惑但记住“同类之间没有私密”这一点就够了。3.5 到底哪些类需要写拷贝构造判断标准非常单一类里有没有“无法被简单复制的资源成员”。如果所有成员都是int、double、char这类内置类型或者成员是std::string、std::vector这种已经自己实现了深拷贝的标准库容器那编译器默认生成的拷贝构造就够用了。因为std::string的拷贝构造自己会做深拷贝默认拷贝构造会去调用它的拷贝构造。真正需要动手的是你自己用裸指针管理了动态内存、文件句柄、锁等资源的类比如Stack和MyString。这类类一旦出现浅拷贝问题用std::vector也救不了因为容器只负责放你的类并不懂你的类内部该怎么拷贝。4. 赋值运算符重载与拷贝构造“双胞胎”的差异化生存4.1 等号到底是构造还是赋值先分清边界这一章开头必须再强调一次两个容易混淆的语法Date d1(2025, 1, 1); Date d2(d1); // 拷贝构造d2是新建的 Date d3 d1; // 也是拷贝构造d3同样是新建的 d2 d1; // 赋值运算符重载d2已经存在只是重新赋值判断方法我已经说了就看等号左边的对象是不是刚刚定义出来的。只要这一行之前它不存在那就是拷贝构造如果它早就定义好了这条语句就是赋值。很多初学者把Date d3 d1;当成赋值运算符来理解导致后面看源码时运算符重载的调用时机全乱了。4.2 默认赋值重载浅拷贝之外还多送一个内存泄漏编译器生成的默认赋值运算符规则和默认拷贝构造类似内置类型成员直接赋值自定义类型成员调用它自己的operator。它同样不会帮你管理资源而且比浅拷贝的问题更严重。请看MyString s1(hello); MyString s2(world); s2 s1; // 默认operator浅拷贝默认赋值会把s1._str的指针值直接覆盖给s2._str于是发生了两件事。第一s2原本指向的那块“world”字符串内存从此没有任何指针指向它没人能释放这就是内存泄漏。第二赋值完成后s1和s2又指向同一块内存栈上这两个对象到了生命周期结束时依然会二次释放、程序崩溃。所以凡是需要手写拷贝构造的类几乎同时需要手写赋值运算符重载一个都不能漏。4.3 手写深拷贝赋值三个关键点缺一不可一个健壮的深拷贝赋值运算符长这样MyString operator(const MyString s) { if (this ! s) // 关键点1自赋值检测 { delete[] _str; // 关键点2释放旧资源 _str new char[strlen(s._str) 1]; strcpy(_str, s._str); // 关键点3分配新空间并拷内容 } return *this; // 返回引用支持连续赋值 }三个关键点都值得展开。第一个是自赋值检测也就是判断this和s是否指向同一个对象。如果没有这一步单独执行s1 s1时会先delete掉自己的_str然后再去strlen和strcpy一个已经被释放的地址属于未定义行为后果不可预期。第二个是先释放旧资源再复制顺序非常重要。如果先分配新空间再释放旧空间一旦分配失败旧数据也丢了反过来先释放旧的再分配新的虽然分配失败时对象会变成空状态但至少不会二次释放。第三个是返回类型必须是引用返回*this。这样做是为了支持s3 s2 s1这种连续赋值语句因为的结合方向是从右到左先计算s2 s1把结果继续赋给s3时调用方需要拿到一个可被继续赋值的对象同时也避免了按值返回时多一次拷贝构造的开销和内置类型int的赋值行为保持一致。如果已经对引用和临时对象比较熟悉还可以参考现代C里“拷贝并交换”的习惯用法参数改成按值传递函数体内直接交换指针利用临时对象销毁时自动释放旧资源这能写出异常安全的赋值运算符。但初阶阶段先把上面三个关键点吃透才是最重要的。4.4 关于“三/五法则”的最简单版本很多人会问到底什么时候要写析构什么时候要写拷贝构造和赋值有没有一个简单的规律。有如果一个类需要手写析构函数那它大概率也需要手写拷贝构造和赋值运算符。原因在于需要手写析构说明这个类在管理某种资源而资源的所有权不能被两个对象共享拷贝构造和赋值必须同时配套去维护这种资源。这套规律后面学C11的移动语义时会扩展成三/五法则但初阶阶段记住这个简化版已经能避开绝大多数坑。5. 两个“取地址”成员函数存在感最低但要知道规则5.1 默认的 operator 到底长什么样六大默认成员里最后两个是关于取地址的重载函数。你可能写代码几年都没手写过它们但它们确实一直存在。默认实现其实就是返回this指针class Date { public: Date* operator() { return this; } const Date* operator() const { return this; } };第一个是非const版本返回Date*第二个是const版本返回const Date*。其实你自己不写这两个重载编译器也会生成一模一样的版本所以“默认成员函数”这个名字名正言顺。5.2 为什么会有两个版本const对象调用规则这里涉及一个容易被忽略的机制成员函数后面加const本质上是把this指针的类型做了一层限制。普通成员函数的this类型是Date* const意思是这个指针本身不能改但指向的内容可以通过它修改const成员函数的this类型是const Date* const意思是既不能改指针本身也不能通过它修改指向的对象内容。C的重载机制会自动根据对象是否const来选择调用哪一个版本普通对象调用普通operator返回普通指针const对象调用const版本返回const指针。这就保证了“const对象取地址后拿到的指针也不允许修改内容”类型系统在编译层面就做好了约束。明白这个机制后面看任何const成员函数都有底气了。5.3 重载取地址的真实场景日常开发里这两个函数基本不用重写但偶尔也有实际的用途。一是调试钩子在operator里打印当前对象地址并返回this可以在不改动调用方代码的情况下观察对象的创建和取址时机二是限制访问某些设计里刻意让取地址返回nullptr避免外部绕过接口直接拿到对象内部指针做危险操作三是配合自定义指针或侵入式容器实现需要控制取地址的语义。需要提醒一句默认版本的operator已经足够满足99%的场景初学阶段千万不要为了炫技去改变取地址的语义否则原本obj这个所有人都熟悉的操作会突然失效排查起来极其痛苦。知道它们存在、知道const版本调用的逻辑这一章的学习目标就算达到了。6. 六大成员函数速查什么时候交给编译器什么时候必须亲自动手6.1 一眼判断不同类的处理方式到了这一步前面所有内容可以浓缩成一张表写类的时候对着查就行类的情形建议处理方式所有成员都是内置值类型如Date默认版本基本够用不用手写成员是std::string / std::vector这类标准库容器默认版本够用容器自己管好资源类内部用裸指针管理动态资源如Stack / MyString析构、拷贝构造、赋值全部手写管理文件句柄、锁、数据库连接等不可复制资源析构手写拷贝构造和赋值用 delete 禁用或重写需要在对象创建/销毁时打日志、统计等构造、析构手写其余默认即可这张表背后的逻辑只有一个默认生成的成员函数会不会造成资源的重复释放或者泄漏。不会就放心交给编译器会就老老实实自己写。资源管理这件事在类设计阶段想清楚比在崩溃堆栈里排查省太多时间。6.2 C11之后的变化默认成员其实不止六个严格来说C11开始特殊成员函数从六个增加到了八个新加入了移动构造函数和移动赋值运算符。这篇初阶文章的核心仍然是六大因为它们是整个体系的地基。但有一个现象值得现在就知道如果你自己定义了析构函数编译器通常不会再隐式生成移动构造于是你的类放进std::vector时扩容操作依然会走拷贝构造而拷贝构造如果也没写就会落到浅拷贝头上最终出现前面说的二次释放或内存泄漏。等学到右值引用和移动语义时再回到这个知识点你就能明白为什么现代C代码总在强调“自定义析构时要同步考虑拷贝和移动”。6.3 初学者最容易踩的8个坑写了带参数的构造函数却没写默认构造然后又写了Date d;这种语句编译直接报错。把Date d3 d1;当成赋值运算符实际它是拷贝构造导致对调用时机的理解全偏了。用裸指针管理资源的类没写拷贝构造然后放进vector里vector扩容时浅拷贝程序偶发崩溃。构造函数里new了内存析构函数里却忘了delete程序内存只增不减慢慢卡死。初始化列表书写顺序跟成员声明顺序不一致成员初始化结果和预期不符。赋值运算符没做自赋值检测执行a a时把自己释放了再复制释放掉的内存。new[]出来的数组用了delete释放或者new出来的单对象用了delete[]触发未定义行为。析构函数只delete不置空悬空指针被后续代码无意重新访问灾难延后爆发。这些坑我基本都踩过或帮人排查过。每一条看着简单真实环境里表现都很有迷惑性尤其浅拷贝问题经常表现为“偶尔崩、几乎复现不了”因为释放过的内存可能还没被其他数据覆盖程序暂时还能跑直到某次分配把这块内存挪用才突然炸掉。6.4 一点经验之谈我最初写C时曾经自定义过一个链表节点类里面有两个指针成员当时偷懒没写拷贝构造觉得编译器自动生成的版本应该够用。等我把节点放进一个数组里让节点自己复制时程序毫无征兆地在退出时崩溃打印堆栈追了半天才找到析构函数才发现两个节点共享了同一个next指针析构时反复释放同一块内存。从那以后我写任何一个类的第一件事就是问自己这个类管理着资源吗如果要管理那析构、拷贝构造、赋值一个都逃不掉。如果读这篇文章的你正在初学阶段最好的练习是用MyString这个类亲自走一遍默认构造、深拷贝构造、深拷贝赋值、析构全部写出来然后故意把拷贝构造注释掉用有问题的版本跑一遍亲眼看它怎么崩。崩溃一次、对比一次、修复一次比背十遍结论都管用。类与对象这章真正拉开差距的地方从来不是语法背得熟不熟而是遇到资源管理问题时你愿不愿意动手把编译器藏起来的那些默认行为翻出来看清楚。