看到“类和对象”这个标题估计不少初学者的反应是“又是语法概念枯燥”。但如果你真把C的六大默认成员函数当成“语法”去背那后面写代码会非常痛苦。这六个函数其实是编译器在背后帮你管理对象“生老病死”的一套完整机制理解了它们你才算真正开始懂C。这篇文章我不打算写成教科书。我更想用做项目、写实际代码的视角把这六个函数掰开揉碎讲清楚。你不用一次全记住但至少要明白每个函数是干什么的、什么时候被编译器悄悄调用、以及最关键的——什么时候你必须自己动手写。1. 先从“对象的一生”说起为什么偏偏是这六个函数很多教程开篇就列清单构造函数、析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值然后逐个讲语法。我当年也是这么学的结果学完就忘因为脑子里没有一条主线。后来做实际项目才想明白这六个函数其实是围绕对象生命周期中几个“关键时刻”设计的对象出生时需要初始化对象寿命结束时需要清理资源对象被拿来复制时需要拷贝对象之间需要互相赋值以及现代C为了性能新增的“偷资源”式的移动语义。你可以把这些函数理解为编译器给每个类的“默认剧本”。如果你不写编译器会自己生成一个合理版本但一旦你的类涉及动态内存、文件句柄、网络连接这类“外部资源”编译器生成的默认版本往往就是灾难根源。这里先给个整体框架后面逐个展开成员函数触发时机编译器的默认行为风险点构造函数对象创建时逐个初始化成员变量内置类型可能不被初始化析构函数对象销毁时逐个析构成员变量不释放堆内存导致泄漏拷贝构造函数用已有对象创建新对象逐成员拷贝浅拷贝多个对象指向同一块内存拷贝赋值函数已存在的对象之间赋值逐成员拷贝浅拷贝同拷贝构造可能重复释放移动构造函数用临时对象“偷”资源逐成员搬移源对象状态需要置空移动赋值函数临时对象赋值给已有对象逐成员搬移需处理自赋值和旧资源你可以发现核心风险都集中在“资源管理”上。所以记住了这六个函数的默认版本适合成员变量全是纯数值、简单结构体的类一旦有指针、有动态分配你就得操心了。2. 构造函数和析构函数对象的入口与出口2.1 构造函数不是“用来初始化成员变量”那么简单很多初学者有个误解构造函数就是给成员变量赋值的。这话对新手阶段没错但往深了说构造函数真正要做的是建立对象的初始状态包括申请资源、建立连接、设置不变式。举一个实际点的例子。我在写一个日志类时构造函数里要打开日志文件、写入头信息、启动缓冲线程。没有构造函数每次创建对象都得手动调用一个init函数一旦忘调整个对象就是一个烂摊子。构造函数存在的意义就是保证“对象一旦被创建就处于完整可用的状态”。语法上要注意几个细节class Logger { public: Logger() { m_file.open(app.log); // 其他初始化逻辑 } private: std::ofstream m_file; };关于构造函数我踩过几个坑值得单独说构造函数没有返回值。它不能返回错误码出错只能抛异常。这跟普通函数完全不同。构造函数的初始化列表比函数体赋值更优。对于const成员、引用成员、没有默认构造函数的类类型成员必须在初始化列表里初始化。构造函数可以重载但要注意避免“默认参数重载”导致的二义性调用。2.2 析构函数没人提醒你释放你得自己记得析构函数是对象死前的最后一个动作负责清理资源。编译器默认生成的析构函数只会逐个析构成员变量但它不会帮你delete指针。这句话要刻在脑子里如果你的类里有裸指针new出来的那就必须自己写析构函数。我们看常见的错误写法class Person { public: Person(const char* name) { m_name new char[strlen(name) 1]; strcpy(m_name, name); } private: char* m_name; };这个类没有写析构函数。当Person对象销毁时m_name指向的那块堆内存永远不会被释放程序每创建一个Person就泄漏一次。正确做法是~Person() { delete[] m_name; }这里有一个特别容易被忽略的顺序问题析构函数执行完函数体之后编译器还会自动析构所有成员变量。如果成员变量里有管理资源的对象比如std::string、std::vector它们会自己释放资源但裸指针不会。所以现代的C实践就是少用裸指针多用智能指针和STL容器这样很多时候你根本不需要写析构函数。2.3 一个容易踩的坑构造函数里抛异常怎么办构造函数里如果抛了异常比如打开文件失败、申请内存失败析构函数不会被调用。这意味着构造函数里已经成功申请的部分资源会全部泄漏。处理方法通常是在构造函数内部用try-catch捕获异常把已分配的资源释放掉再重新抛出。或者更保险的做法用RAII思想把资源包在智能指针里这样构造函数抛异常时智能指针会自动清理。我印象很深的一次经历写一个网络客户端类构造函数里依次做了“创建socket、连接服务器、开启心跳线程”三件事结果第二步失败抛异常socket资源直接泄漏。后来改成socket用unique_ptr管理问题立刻解决。这就是为什么我每次都强调能在成员变量的RAII类里管理的资源绝不要裸持有。3. 拷贝构造函数与拷贝赋值看似简单坑却最深3.1 默认是浅拷贝浅拷贝会要命编译器生成的拷贝构造函数和拷贝赋值函数默认行为是逐成员拷贝。当成员变量是整数、浮点数时没问题但当成员变量是指针时逐成员拷贝只会复制指针的“地址值”结果两个对象的指针指向同一块内存。这就是传说中的“浅拷贝”。带来的问题有两类第一个问题双重释放。两个对象析构时都会对同一块内存delete程序直接崩溃。第二个问题修改联动。改一个对象指向的数据另一个对象“莫名其妙”也跟着变了。看这个经典例子class Person { public: Person(const char* name) { m_name new char[strlen(name) 1]; strcpy(m_name, name); } // 没写拷贝构造编译器生成浅拷贝 private: char* m_name; }; Person a(张三); Person b(a); // b.m_name 和 a.m_name 指向同一块内存3.2 深拷贝怎么实现谁申请的资源谁复制一份正确的做法是“深拷贝”为新对象重新申请一块内存然后把原对象的数据内容复制过去。对于上面的Person类拷贝构造函数应该这样写Person(const Person other) { m_name new char[strlen(other.m_name) 1]; strcpy(m_name, other.m_name); }这里你要注意深拷贝的本质是“复制内容而不是复制指针”。做到了这一点两个对象各自持有独立的内存互不干扰各自析构也互不影响。拷贝赋值函数比拷贝构造函数复杂一点点因为“赋值”发生时对象已经存在可能已经持有资源。所以必须先释放旧资源再拷贝新内容同时还要处理“自赋值”问题Person operator(const Person other) { if (this other) { return *this; // 自赋值直接返回 } delete[] m_name; // 释放旧资源 m_name new char[strlen(other.m_name) 1]; strcpy(m_name, other.m_name); return *this; }拷贝赋值的“自赋值检查”不是可有可无的是必须有的。如果a a先把a的m_name释放了再尝试从other.m_name拷贝那时other和this是同一个对象数据已经没了程序就崩了。3.3 拷贝构造和拷贝赋值的区分技巧初学者最容易搞混的是拷贝构造和拷贝赋值。我分享一个简单判断标准看对象是否已经存在。Person c a; // c还没创建这是拷贝构造 Person d; // d已创建 d a; // d已存在这是拷贝赋值有没有一个更隐蔽的场景函数传参。按值传参时会触发拷贝构造返回局部对象时也可能触发拷贝构造虽然现代编译器有优化后面移动语义部分细说。所以在写代码时看到“创建一个新对象并初始化为已有对象的值”就是拷贝构造看到“已存在的对象被赋予新值”就是拷贝赋值。3.4 成员变量有指针时一定要用深拷贝吗如果你已经用了std::string、std::vector这类容器它们的拷贝构造已经实现了深拷贝你不用操心。但如果你执意用裸指针或C风格数组那就必须自己写深拷贝。我个人的经验是只要类里自己管理了堆内存拷贝构造、拷贝赋值、析构函数这三个必须一起写这就是所谓的“三之法则”Rule of Three。少写任何一个都会留下隐患。这不是教条而是无数崩溃现场总结出来的。4. 移动构造与移动赋值性能优化的大杀器4.1 为什么需要移动语义拷贝太慢了C11之前性能上有一个很尴尬的局面从一个临时对象复制数据时明明临时对象马上就被销毁了却还要把它的数据完整复制一份浪费时间和内存。举一个典型场景std::vectorstd::string getNames() { std::vectorstd::string names; names.push_back(张三); names.push_back(李四); return names; } auto allNames getNames();在C11之前这个函数的返回值会被复制很多次函数返回时复制一份到临时对象临时对象再复制一份到allNames。如果vector里有100万个元素那就是100万次字符串拷贝。移动语义的核心理念是既然临时对象马上要销毁那我不如直接把它的资源“接手”过来指针指过来然后源对象置空。这样一次拷贝都不用做纯粹就是指针交接。4.2 移动构造函数的写法偷资源 置空源对象移动构造函数的参数是右值引用写法长这样Person(Person other) noexcept : m_name(other.m_name) { // 直接把指针偷过来 other.m_name nullptr; // 源对象置空防止析构时释放同一块内存 }注意这里的细节偷了指针后必须把源对象的指针置空。因为源对象之后会被析构如果它的m_name还指向那块内存析构时就会delete掉你已经接手的内存你手里就变成了悬空指针。移动赋值写法也类似但多了一步“释放自己的旧资源”Person operator(Person other) noexcept { if (this other) return *this; delete[] m_name; // 释放自己原来的资源 m_name other.m_name; // 接手新资源 other.m_name nullptr; // 源对象置空 return *this; }移动操作的这些 noexcept 声明很重要不展开讲标准细节你只需要记住移动构造函数和移动赋值函数最好声明为noexcept否则标准库容器在扩容时可能不会选择移动性能优化就落空了。4.3 什么时候自动调用移动构造移动构造并不是什么时候都会触发。常见场景有函数的返回值是局部对象时编译器优先尝试移动传入的参数是临时对象时比如Person p(Person(临时对象))std::move显式把左值转为右值引用时。其中std::move是最需要理解清楚的。它本质上只是一个类型转换工具把左值变成右值引用好让编译器选择移动构造函数。注意std::move本身不移动任何东西它只是“告诉编译器请优先用移动而不是拷贝”。有一个很常见的误用Person a(张三); Person b(std::move(a)); // 此时a的资源已经被偷走a.m_name已经为nullptr // 不要再用a去访问数据了很多新手move完还继续使用源对象结果读到空指针或者随机数据。律己被移动过的对象只能对其赋值或析构不能继续访问它的资源。4.4 什么时候需要自己写移动构造如果你的类只有纯内置类型成员编译器生成的移动构造完全够用。但如果你的类管理了堆内存且你手动写了析构函数、拷贝函数编译器不会自动生成移动构造和移动赋值这个规则很多人不知道。这时写不写移动构造取决于你的使用场景如果你大量使用临时对象传参、返回对象建议写上移动构造性能提升明显如果你已经把所有资源都用智能指针和STL容器管理了那编译器生成的移动构造就已经很完美你不用自己写。5. 从“三之法则”到“五之法则”到底该记哪几个我以前带新人时常有人问六个默认成员函数到底要写几个才算完整其实有个通用判断逻辑。如果你用了裸指针管理资源那要记住“五之法则”析构函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数这五个最好都写或者干脆禁用不需要的操作。如果你完全用现代C的vector、string、unique_ptr、shared_ptr管理资源那五个函数你基本都不用写编译器生成的默认版本就够了。这不是偷懒而是RAII思想的胜利。类的情况需要手写的函数成员全是内置类型或STL容器一般不需要手写任何默认成员函数有裸指针/手动管理堆内存析构、拷贝构造、拷贝赋值、移动构造、移动赋值五之法则或delete掉拷贝相关函数需要的是单例等特殊语义把拷贝构造和拷贝赋值delete掉成员中有const或引用拷贝赋值函数不能默认生成需自己处理或禁止这里我想特别强调“ delete”这个实用技巧。有些类本质上不应该被拷贝比如管理网络连接、文件锁的类。这时与其花精力写“正确的深拷贝”不如直接把拷贝构造和拷贝赋值禁掉class FileGuard { public: FileGuard(const char* path) { /* 打开文件 */ } ~FileGuard() { /* 关闭文件 */ } FileGuard(const FileGuard) delete; FileGuard operator(const FileGuard) delete; };这样一旦有人不小心拷贝这个对象编译期就会直接报错从源头上杜绝资源重复释放的问题。我个人在后端项目里经常这么干比“辛辛苦苦写深拷贝”更务实。6. 实战总结判断一个类是否需要手动处理默认成员函数最后我分享一套自己用了多年的判断流程写类之前先过一遍这个检查能省很多调试崩溃的时间。第一步看类里有哪些成员变量。如果每个成员都是int、double、bool这种纯值类型或者都是string、vector这种自带资源管理的STL类型那恭喜你六个默认成员函数基本可以全交给编译器省心又安全。第二步看类里是否有裸指针、原始数组、文件句柄、socket等外部资源。只要有就必须考虑自己写析构函数。如果你不想自己写析构那就把裸指针改成unique_ptr或shared_ptr让RAII帮你兜底。第三步如果已经确认真有手工管理的资源那就一次性把这套规则写完整析构释放、拷贝深复制、移动偷资源并用nullptr“过河拆桥”。不要只写一个析构函数然后假装万事大吉因为拷贝构造和拷贝赋值默认浅拷贝迟早出大问题。第四步检查语义。这个类被设计成可以安全的复制吗如果能走深拷贝路线如果不能直接用 delete禁掉拷贝。千万不要“默认浅拷贝当成没问题”C不会替你报错它只会在运行时悄悄崩溃。在实际编码过程中我还有一个心得尽量让编译器干活少自己动手。现在C已经发展得很成熟了unique_ptr、vector、string、std::optional这些工具把资源管理做得非常好。我自己写业务逻辑时已经很少手写裸指针和深拷贝了但正因为如此理解六大默认成员函数的底层机制才更加重要——只有理解了编译器默认行为会带来什么问题你才知道什么时候该依赖STL什么时候该自己介入。如果你刚学到类和对象这一章别被这六个函数吓住。先动手写几个小类练手让类里放一个int指针成员分别测试默认版本和手动版的行为差异跑一遍就全通了。编程这东西背十遍不如亲手写一遍。