看到“类和对象”这个标题,估计不少初学者的反应是“又是语法概念,枯燥”。但如果你真把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 为什么需要移动语义:拷贝太慢了
C++11之前,性能上有一个很尴尬的局面:从一个临时对象复制数据时,明明临时对象马上就被销毁了,却还要把它的数据完整复制一份,浪费时间和内存。
举一个典型场景:
std::vector<std::string> getNames() { std::vector<std::string> names; names.push_back("张三"); names.push_back("李四"); return names; } auto allNames = getNames();在C++11之前,这个函数的返回值会被复制很多次:函数返回时复制一份到临时对象,临时对象再复制一份到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指针成员,分别测试默认版本和手动版的行为差异,跑一遍就全通了。编程这东西,背十遍不如亲手写一遍。