
写了不少年代码也带过不少刚入门C的同学。每次聊到类和对象大家最容易卡住的地方不是语法本身而是那几个默认成员函数。面试十次有八次会问拷贝构造函数和析构函数工作中排查内存崩溃排查到凌晨两三点十有八九也是浅拷贝挖的坑。这次就静下心把这几个默认成员函数彻底捋一遍希望能帮你把这块地基打扎实。C里默认成员函数指的是编译器在你没写的情况下自动生成的几个特殊函数构造函数、析构函数、拷贝构造函数、赋值运算符重载还有取地址运算符重载含const版本C11之后又加上了移动构造和移动赋值。它们决定了一个对象从生到死的完整流程怎么创建、怎么拷贝、怎么赋值、怎么释放资源。理解这几个函数等于掌握了C对象生命周期的控制权。1. 先把六个默认成员函数认识清楚1.1 空类真的“空”吗有人觉得写一个空类就什么都没干class Empty { };看起来干干净净但你要是用sizeof去量一下你会发现——它的大小是1不是0。原因很好理解如果一个类型大小为0那两个不同对象的地址就可以相同这在C的内存模型里是不允许的。所以编译器强制给空类占一个字节。更值得注意的是这个空类虽然一个成员函数都没声明但它实际上拥有六个默认成员函数的声明。你在外面尝试调用它们编译器看到“类里没定义那就现场生成一个”。换句话说一个看起来什么都不写的空类编译器在背后替你做了一整套对象生命周期管理函数。这六个函数分别是默认构造函数析构函数拷贝构造函数拷贝赋值运算符operator取地址运算符operatorconst取地址运算符const operator我见过一些初学者以为编译器这六个函数都会立刻生成其实不是。更准确的说法是编译器在需要用到它们的时候才会去生成而且生成规则有明确的限制条件。先把这个本质记在心里后面就顺了。1.2 编译器生成的时机和规则默认构造函数不是凭空就会生成的。只要用户自己写了任意一个构造函数哪怕是带参数的编译器就不会再帮你生成那个什么都不做的默认构造。这个规则我当年第一次踩坑是因为写了这样的代码class Date { public: Date(int year, int month, int day) { _year year; _month month; _day day; } private: int _year; int _month; int _day; }; int main() { Date d; // 编译报错没有合适的默认构造函数 }原因很直接你提供了一个带参数的构造函数编译器默认不再生成无参版本。这时候除非你自己补一个默认构造否则Date d;这行代码就编不过。拷贝构造和拷贝赋值的生成规则也很相似只要用户声明了拷贝构造函数或者析构函数编译器对拷贝相关的默认生成就会变得保守。C11还有一个更严格的规则一旦你声明了析构函数、拷贝构造、拷贝赋值中任何一个移动构造和移动赋值就不会被隐式生成除非你用default把它要回来。这条规则对实际编码影响很大等后面讲移动语义的时候再展开。1.3 默认实现到底做了什么说“默认生成”很多人以为编译器生成了一个什么都干不了的函数。这话只对了一半。对于内置类型成员int、double、指针等默认构造函数确实什么都不干不会帮你去初始化所以局部对象的成员值是不确定的。但默认构造函数会把每个类类型成员调用一遍它自己的默认构造函数这里面有一套递归逻辑。默认拷贝构造做的事情叫做“逐成员拷贝”把源对象的每一个成员按字节抄一遍到目标对象。对于指针成员它拷贝的是地址不会去拷贝指针指向的那块数据。这就是后面浅拷贝问题的源头。默认赋值运算符也一样逐成员赋值碰到指针照样只是地址的复制。默认析构函数则是在函数体为空的情况下从最后一个成员开始按声明顺序的逆序依次调用每个成员的析构函数。理解这个背后逻辑非常重要编译器生成的默认函数用的是一套“逐成员处理”的策略它不关心你类内部的资源关系也不关心指针指向的堆内存应该怎么处置。当你的类足够简单全是内置类型的时候这套策略够用一旦有了指针和堆资源默认策略就会给你挖坑。2. 构造函数对象的“户口登记”2.1 构造函数到底解决了什么问题回到C语言时代的场景。用结构体定义一个变量之后如果你忘了给成员赋值拿到的就是一段不确定的内存轻则逻辑错乱重则直接跑飞。C设计构造函数核心目的就是保证对象在被创建的那一刻就已经处于一个合法、可用的状态。构造函数有几个特征值得单独说函数名和类名一致。没有返回值连void都不能写。支持重载可以提供一个无参构造也可以提供一个全缺省的构造。写构造函数的时候我强烈建议给成员一个明确的初始值。最省事的方式是写全缺省参数class Date { public: Date(int year 1900, int month 1, int day 1) { _year year; _month month; _day day; } };这样Date d1;、Date d2(2024, 10, 1);、Date d3(2024);都能用同一个构造函数。既保证了默认情况下对象有值又保留了指定初始化的能力。2.2 初始化列表比函数体赋值更靠谱很多人一开始写构造函数都在函数体里做赋值这能用但有些场合会被卡住。先看三个必须用初始化列表的特殊情况const成员变量引用成员变量没有默认构造函数的类类型成员这三类成员有一个共同点它们必须在定义的那一刻就被初始化。初始化列表正好做到了这一点。反过来如果你把它们放在函数体里赋值等函数体执行的时候这个成员已经被定义过了而const和引用在定义之后就不允许再赋值于是编译期直接报错。还有一个很多人忽略的坑初始化列表的执行顺序不是按初始化列表的书写顺序而是按成员在类中声明的顺序。我举个例子class Test { public: Test(int a) : _y(a), _x(_y) {} private: int _x; int _y; };你可能会以为_x用_y的值去初始化也就是_x a。但实际执行顺序是_x先被初始化而这时候_y还没初始化_x拿到的其实是一个未定义值。这是C初始化顺序的经典陷阱写代码的时候一定要按照声明顺序去写初始化列表避免这种“看起来能用但结果是错的”的情况。至于效率问题初始化列表也比函数体赋值更优。函数体赋值的过程是先调用默认构造函数创建成员对象再执行赋值而初始化列表是直接调用匹配的构造函数少了一次无谓的默认构造和赋值。2.3 explicit、隐式转换和易踩的坑构造函数还有一个容易忽略的性质它可以用来做隐式类型转换。class Date { public: Date(int year) : _year(year) {} private: int _year; }; void func(Date d) {} int main() { Date d1 2024; // 等价于 Date d1(2024) func(2024); // 整型被隐式转换成了 Date }单参数构造允许这种隐式转换某些场景下确实很方便比如std::string str hello就是利用了这个机制。但更多时候隐式转换会带来意想不到的副作用代码可读性也变差。如果你不想让一个整型无声无息地变成一个类对象就在构造函数前面加explicit关键字class Date { public: explicit Date(int year) : _year(year) {} };加上之后Date d1 2024;就会编译报错必须写成Date d1(2024);或者static_castDate(2024)。C11之后多参构造函数也可以用花括号做隐式类型转换这同样值得警惕。我的习惯是构造函数不是明确需要做隐式转换的一律加explicit。这个习惯能帮你省掉不少调试时间。2.4 析构函数与RAII析构函数在对象生命周期结束时自动调用。栈对象离开作用域、全局对象程序退出、delete一个堆对象这些场景都会触发析构函数。我第一次真正意识到析构函数的价值是在学RAII资源获取即初始化的时候。这个思想的核心很简单把资源的申请放在构造函数里资源的释放放在析构函数里让资源生命周期和对象生命周期严格绑定。class FileGuard { public: FileGuard(const char* filename) { _fp fopen(filename, r); } ~FileGuard() { if (_fp) { fclose(_fp); } } private: FILE* _fp; };这样写不管中途代码怎么走哪怕是抛异常FileGuard对象析构的时候文件都会被关闭。这就是后来智能指针能自动管理内存的底层逻辑。析构函数有一个铁律不要在析构函数里抛异常。如果析构函数在执行过程中抛异常而此刻异常处理机制已经在处理另一个异常程序就会直接调用terminate终止。具体到实践中析构函数里的清理操作要尽量用noexcept来兜底真有失败情况记录下来都比让程序崩溃强。3. 拷贝构造和赋值重载最容易出内存事故的地方3.1 拷贝构造函数的触发时机拷贝构造函数的写法是一个类名加一个同类的const引用参数Date(const Date d) { _year d._year; _month d._month; _day d._day; }触发时机要区分清楚Date d2(d1);—— 显式拷贝构造Date d2 d1;—— 这行也是拷贝构造因为d2是刚创建的void func(Date d)—— 传值传参时触发拷贝构造函数返回值返回对象时也可能触发拷贝构造容易混淆的地方是“赋值”和“初始化”的区别。看这两行Date d2 d1; // 初始化调用拷贝构造 d2 d1; // 赋值调用operator判断标准很简单如果对象是首次创建的[]里的操作就是初始化调用构造函数如果对象已经存在就是赋值调用赋值运算符。传值传参和返回对象这两个场景里编译器会做返回值优化RVO/NRVO很多拷贝有机会被省掉。这个话题后面再细说。3.2 浅拷贝两次析构杀死了程序当一个类里有指针成员并且你用默认的拷贝构造或者默认赋值去复制它问题就来了。看这个经典的栈类class Stack { public: Stack(int capacity 4) { _data new int[capacity]; _size 0; _capacity capacity; } ~Stack() { delete[] _data; } private: int* _data; int _size; int _capacity; };如果这样用Stack s1; Stack s2(s1);_data是一个int*类型的成员。默认拷贝构造做的事情就是把_data的地址原封不动地复制给s2._data。此时s1和s2的_data指向同一块堆内存。等这两个对象出作用域各析构一次同一块内存被delete[]两次。这在堆管理器中就是典型的double free运行时大概率报这样的错free(): double free detected in tcache 2 Aborted (core dumped)问题的根源不是析构函数写错了而是拷贝的时候只复制了指针本身没有复制指针指向的数据。这种复制方式叫浅拷贝。注意像std::string这样的标准库类型它的拷贝构造内部已经实现了深拷贝所以你平时用vector、string没觉得有问题。但一旦你自己管理裸指针就必须自己处理。3.3 深拷贝的实现要领深拷贝的意思很直观不仅要复制指针变量的值还要复制它指向的那块数据。拿上面的Stack举例子Stack(const Stack st) { _data new int[st._capacity]; memcpy(_data, st._data, sizeof(int) * st._size); _size st._size; _capacity st._capacity; }写深拷贝时要留意两件事一是要重新申请一块内存大小和源对象一致二是把源对象的数据整体拷贝到新内存。这块内存是两个对象各自独立的后面即使一个对象析构了另一个对象的数据也不受影响。我再补充一个字符串类的例子这个更贴近日常class MyString { public: MyString(const char* str ) { _str new char[strlen(str) 1]; strcpy(_str, str); } MyString(const MyString s) { _str new char[strlen(s._str) 1]; strcpy(_str, s._str); } ~MyString() { delete[] _str; } private: char* _str; };这样写完之后拷贝出来的对象和原来的对象各自管理各自的内存互不干扰。3.4 赋值运算符重载的正确写法赋值运算符的重载格式是固定的Date operator(const Date d) { // 自赋值检测 if (this ! d) { _year d._year; _month d._month; _day d._day; } return *this; }返回类型是Date以支持链式赋值比如a b c这种表达式。如果不返回引用而返回值会多一次无意义的拷贝构造效率和语义都不理想。对于里面有动态资源的类赋值比初始化多了一个步骤必须把旧空间释放掉再申请新空间。标准四步是判断是否自赋值。这一步能避免s s时先释放再拷贝导致的数据丢失。释放原有的堆资源。根据源对象的大小重新申请空间并拷贝数据。返回*this。不过这里有个经验之谈如果两个对象的容量差不多可以复用旧内存而不必频繁申请释放。比如Stack的赋值如果目标对象capacity够大直接把数据拷进去就行。不过刚学阶段还是先按严格四步走把逻辑写对再谈优化。还有一种更优雅的写法叫copy-and-swap用拷贝构造加swap函数来实现赋值Stack operator(Stack st) { swap(st); return *this; }这里Stack st参数本身就是通过拷贝构造生成的如果传入时发生了深拷贝那么把st和当前对象的资源一交换旧资源的释放由st的析构函数去处理。这种写法天然具备异常安全性代码也更简洁。建议掌握。3.5 三法则写完拷贝就要写析构C里有一条非常实用的经验法则叫三法则。如果一个类需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个那么通常这三个都需要自定义。为什么因为这三者往往是因为同一件事才需要写这个类自己在管理资源。一个类需要自定义析构函数说明它在析构时要专门释放某些东西比如堆内存那它拷贝的时候就需要深拷贝赋值的时候也需要深拷贝。如果一个类既不管理堆内存也没有任何需要特殊清理的资源那么析构函数、拷贝构造、赋值运算符全都不用自己写让编译器默认生成就好。如果一个类管理堆内存但你觉得拷贝没有意义、不希望对象被复制那么把拷贝构造和赋值运算符直接删除掉Stack(const Stack) delete; Stack operator(const Stack) delete;这在管理独占资源的场景比如unique_ptr中是常见做法。要么正确实现深拷贝要么干脆彻底禁止拷贝千万别停留在默认浅拷贝的状态那是踩内存崩溃坑的最快方式。4. 取地址重载与C11带来的新默认函数4.1 取地址运算符重载的用武之地取地址运算符重载是六个默认成员函数里最被冷落的一个。写法如下class Date { public: Date* operator() { return this; } const Date* operator() const { return this; } };绝大多数情况下我们不会去重载它因为默认版本已经能够正确返回对象地址了。官方库和优秀开源项目里也基本看不到对operator的重载。不过把它列进默认成员函数有一个存在感极强的意义它提醒我们在C里几乎每个运算符都能被自定义。当你不需要改变取地址行为时就让它保持默认真遇到需要做代理类或者特殊调试的场景你也有能力接管它。C11之后可以用default来强制要求编译器生成这个函数的默认实现这在模板编程里有些时候用得着。4.2 move构造和move赋值默认函数家族的新成员C11引入了右值引用之后类的默认成员函数家族从六个变成八个新增了移动构造函数和移动赋值运算符。它们解决的问题特别实在与其把资源深拷贝一份不如直接把源对象的资源“偷”过来。class MyString { public: MyString(MyString s) noexcept : _str(s._str) { s._str nullptr; } MyString operator(MyString s) noexcept { if (this ! s) { delete[] _str; _str s._str; s._str nullptr; } return *this; } private: char* _str; };移动构造做的事情是把源对象的指针直接拿过来然后把源对象的指针置空。这样新对象获得数据源对象被置为空壳它的析构函数调用时不会二次释放因为指针已经是nullptr了。这里最容易让人误解的是std::move这个名字。它并不会做任何移动操作本质上只是把对象转换成一个右值引用告诉编译器“你可以放心把资源拿走了”。真正的移动语义是在移动构造函数或移动赋值运算符里实现的。移动构造和移动赋值的隐式生成规则相当严苛。C11标准规定只有当一个类没有用户声明的析构函数、拷贝构造函数、拷贝赋值运算符、移动赋值运算符时编译器才会生成默认移动构造函数。换句话说你只要写了一个析构函数移动操作就不会被自动生成。这其实是一条保护措施因为如果你自己写析构函数很可能意味着这个类在管理某种复杂资源编译器不敢在没搞清规则的情况下冒险帮你“偷”。5. 默认成员函数的实战经验与排查记录5.1 构造析构日志观察对象生命周期的利器学默认成员函数最直观的实验方法就是在每个特殊函数里打印日志。class Trace { public: Trace() { printf(default构造\n); } Trace(const Trace) { printf(copy构造\n); } Trace operator(const Trace) { printf(copy赋值\n); return *this; } Trace(Trace) { printf(move构造\n); } Trace operator(Trace) { printf(move赋值\n); return *this; } ~Trace() { printf(析构\n); } };然后写几个函数去触发这些操作观察输出。通过日志你能直观看到按值传参调用了什么、返回对象调用了什么、容器扩容时发生了什么、什么时候临时对象的拷贝被编译器优化掉了。我在实际排查中经常会往怀疑有问题的类里临时加日志跑一遍程序把构造析构的顺序记录下来很多诡异的问题马上就有头绪。比如看到一个对象被反复构造析构了很多次就可能意味着你的代码在频繁传值拷贝如果你看到析构日志重复出现在同一个内存地址上那基本就是双重释放的前兆。5.2 常见编译错误和运行错误速查报错现象常见原因解决办法编译报“no matching function for call to Date::Date()”写了带参构造但没写默认构造而代码需要无参构造补一个无参构造或给现有构造全加缺省参数编译报“不能使用已删除的函数”拷贝构造或赋值被delete但代码还在复制对象改用引用传参或调整设计允许拷贝编译报“member is not assignable”类里有const成员或引用成员默认赋值运算符无法生成自定义赋值运算符或者用初始化列表构造运行报“double free or corruption”浅拷贝导致同一块内存被释放两次实现深拷贝或直接delete拷贝相关函数运行报“malloc(): invalid size”赋值时未妥善申请/释放内存或对象被悬空指针复用了旧内存按四步流程重写赋值注意自赋值判断移动后源对象行为异常移动构造只置空了指针没把size、capacity等成员重置把所有状态一致更新保证源对象处于有效空状态多态场景析构不完整基类析构函数不是虚函数delete基类指针时子类资源没释放把基类析构函数声明为virtual最后一条我专门想多说一句因为它特别隐蔽。假设基类里管理了一块内存子类又管理了另一块你用一个基类指针指向子类对象然后delete这个基类指针。如果基类析构不是虚的析构时只会调用基类析构子类的那部分资源就漏掉了。C里凡是准备作为基类的类析构函数十有八九都要写成虚函数。5.3 写类时能少踩的坑写类的时候先想清楚这个类需要不需要拷贝语义。这个问题的答案会直接引导你后续怎么处理默认成员函数如果类里全是内置类型的简单成员而且语义上允许复制那就尽量什么都不写让编译器帮你生成。如果类里管理着堆内存或者其他独占资源而且需要支持拷贝那就老老实实把拷贝构造、赋值、析构三件套自己写一遍别偷懒。如果类里的资源按设计不允许共享比如网络连接、文件句柄那就把拷贝构造和赋值 delete从源头杜绝问题。初始化列表能写就在初始化列表里写不要放到函数体里凑热闹。尤其是const成员、引用成员、没有默认构造的对象成员这三样就是奔着初始化列表来的。还有人问成员命名为什么要加_或者m_前缀我的体会是加了前缀之后参数名可以放心直接写成int year函数体里_year year一眼就能分清谁是成员谁是参数少了无谓的命名纠结。多态基类的析构函数记得加virtual这基本是C内存安全的最低要求。写析构函数时不要在内部抛异常。如果想在做清理时处理可能的错误就把异常捕获住记个日志让析构函数保持住稳定的运行状态。对编译器优化要有基本认知。现代编译器的返回值优化这段时间按值返回大对象时可能根本不会调用拷贝构造函数直接就在调用方的栈上构造了。这导致你光靠日志去数拷贝次数会不准但不代表拷贝的问题不存在。优化只对局部对象和临时对象生效该深拷贝的资源一点都省不掉。我个人的习惯是写完一个管理资源的类后会先在单测里跑一遍“拷贝构造一个对象、赋值给另一个对象、再让两个对象同时析构”的场景确认没有double free确认拷贝后修改对象A不会影响对象B确认自赋值之后对象仍然保持数据完好。这组测试花不了十分钟却能省下后面可能的一整个通宵。最后再分享一个小技巧如果你不确定编译器到底给你生成了哪些默认函数可以用static_assert和类型特征配合检查比如看一个类是否满足std::is_copy_constructible如果结果是false说明这个类已经不可拷贝了。写框架级别的东西时这个特性常用来在设计期就挡住非法用法。C的默认成员函数像一个隐形的协议把这份协议吃透写出来的类会更干净程序也会更稳。