ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C++类与对象(下)精讲:默认成员函数、拷贝构造与浅拷贝陷阱全解析

C++类与对象(下)精讲:默认成员函数、拷贝构造与浅拷贝陷阱全解析

C++类与对象这个话题,很多人在学完基本语法后,总会卡在“下篇”这层窗户纸上。类与对象(下)讲的全是那些看着简单、用起来全是坑的东西:默认成员函数、拷贝构造、赋值重载、static、友元、内部类。我当年也是被六大默认成员函数绕晕的人之一,尤其是浅拷贝导致的二次释放,真真切切崩到怀疑人生。这篇文章我就按自己的学习路线和踩坑记录,把类与对象(下)里最难啃的部分拆开揉碎讲清楚,配合可以直接跑的示例代码和调试思路。适合刚掌握类的基础语法、想把生命周期和资源管理彻底拿下的人,也适合准备面试想系统梳理C++对象模型的进阶者。

1. 六个默认成员函数,真正要解决什么问题

1.1 编译器不是替你“写好了”,只是替你“草稿了一个”

很多人听到“默认成员函数”就会产生幻觉:类不写就能用,编译器已经帮我们完成了。这句话没错,但默认生成的是“最朴素”的版本。六个默认成员函数分别是:构造函数、析构函数、拷贝构造函数、赋值运算符重载、普通对象取地址、const对象取地址。

对于内置类型成员,编译器默认生成的构造函数基本不做事,在C++11之前,像int _a;这种成员读完值是随机的,完全不是0。这个细节让我第一次写二分搜索树时吃了大亏——定义一个节点对象,成员变量以为默认是空,结果全是垃圾值,导致插入逻辑疯狂死循环。你现在可以做个测试:

struct Demo { int a; double b; }; int main() { Demo d; std::cout << d.a << std::endl; // 未定义行为,可能打印负数或垃圾值 return 0; }

默认构造函数的“不作为”并非设计缺陷,而是C++追求性能的表现:对于内置类型,不初始化是零成本;对于自定义类型,会递归调用成员对象的构造函数。这是理解整个类与对象(下)的起点:所谓默认,不等于安全,只等于“形式上存在”。

1.2 构造函数和析构函数,本质是生命周期管理

构造函数不是用来“构造”的,它是用来“初始化”的。对象在创建时,编译器会把你构造函数的代码插到对象内存准备好之后,成员变量可以初始化。析构函数则是对象销毁时的收尾动作,对于栈上的对象,生命周期结束时自动调用;对于堆上new出来的对象,只有显式delete才会调用析构函数。

这里最应该建立的是RAII意识。什么叫RAII?资源获取即初始化。比如用智能指针、文件句柄、线程锁封装资源,把资源的释放绑定在析构函数上。你不需要记得释放时机,反正对象出了作用域,析构函数一定会执行。下面这段代码能很直观看到生命周期:

class Logger { public: Logger() { std::cout << "Logger构造\n"; } ~Logger() { std::cout << "Logger析构\n"; } }; void run() { Logger log; // 构造 std::cout << "run函数执行\n"; } // run结束,析构自动调用 int main() { run(); return 0; }

这个点看似基础,但实际操作中有无数人写出“忘记delete”的痛苦代码。如果你懒得自己管理,至少也要理解为什么析构函数会被频繁调用:临时对象会在表达式结束时析构,函数参数会在函数结束时析构,返回值在接收完成或优化后析构。把握了构造和析构的时机,写代码时才能判断哪些资源是安全的,哪些会悬空。

1.3 什么时候必须自己写析构函数,什么时候必须重写拷贝

判断是否要显式定义析构函数,其实有一条很简单的经验:如果类里有动态申请的资源,比如malloc或new出来的内存、打开的文件、网络连接,就需要自己写析构来释放;如果类成员全是int、double、标准库容器这种自动管理资源的类型,那么默认析构就够了。

但更常见的坑藏在“自定义析构函数”出现之后。C++社区有一句俗话:如果你需要自定义析构函数,那大概率也需要自定义拷贝构造和赋值重载,这就是所谓的“三/五法则”。为什么?因为默认的拷贝是浅拷贝,它会把一个对象的成员值逐字复制到另一个对象。假如成员里有个指针,两个对象就指向同一块堆内存,等到两次析构执行,同内存被释放两次,直接崩溃。这个规律我后面会用一个完整的例子演示,你可以把它当作模板反复套用。

2. 拷贝构造、赋值重载与浅拷贝陷阱(核心)

2.1 三种“看起来不像拷贝”的拷贝场景

很多人一听说拷贝构造就会盯着Date d2(d1);这种写法,但实际工程里,拷贝往往藏在更容易忽略的角落,而且每个都是坑。

第一种最常见的场景是函数传参。当你写void print(Date d)时,实参传给形参的过程就会调用拷贝构造。如果Date内部有动态内存,而你没有写深拷贝,形参一销毁,原对象的内存就悬空了。

第二种是函数返回值。假设函数返回一个对象,编译器在早期实现中会创建一个临时对象,再把临时对象拷贝给接收变量。现代编译器做了返回值优化(RVO),经常会把拷贝省略掉,但你不能依赖优化来弥补设计缺陷。

第三种是标准库容器操作。比如std::vector<MyString> v; v.push_back(s);,在vector扩容时,元素会被反复拷贝。如果拷贝构造是浅拷贝,程序可能在第一次扩容就崩。

来看一段最典型的浅拷贝场景:

class MyString { public: MyString(const char* str = "") { _str = new char[strlen(str) + 1]; strcpy(_str, str); } ~MyString() { delete[] _str; } private: char* _str; }; int main() { MyString s1("hello"); MyString s2(s1); // 默认拷贝构造,只复制指针 return 0; // 崩溃:s1和s2共用同一块堆内存,析构两次 }

解决方式很简单,自己实现深拷贝:开辟新空间,拷贝内容。你可能会想,这种问题不是一眼就能看出来吗?真正写项目时,一个类经常嵌套多层对象,拷贝会层层触发,你根本没法靠眼睛追踪,只能靠规则约束。

2.2 浅拷贝和深拷贝,一个道理解释清楚

浅拷贝,就是只拷贝“值”。如果值是地址,拷贝出的新对象和原对象持有同一个地址,指向同一块资源。深拷贝,则是把资源本身复制一份,让每个对象拥有独立的资源。

画一张内存图可能会更清楚:s1._str指向地址0x1234,这块内存里存着字符串;浅拷贝时s2._str也指向0x1234;深拷贝时你申请新地址0x5678,把字符串内容复制进去,s2._str指向0x5678。之后各自释放,互不干扰。

实现深拷贝也不是什么高深技巧,记住两步:申请新空间、逐字节复制。下面是一个完整的实现大纲:

class MyString { public: // 拷贝构造 MyString(const MyString& s) { _str = new char[strlen(s._str) + 1]; strcpy(_str, s._str); } // 赋值重载 MyString& operator=(const MyString& s) { if (this != &s) { // 自赋值检测 delete[] _str; // 释放旧空间 _str = new char[strlen(s._str) + 1]; strcpy(_str, s._str); } return *this; } private: char* _str; };

注意赋值重载不能直接拷贝,因为当前对象已经有资源了,必须先释放,再申请新内存。如果不释放就开新坑,旧内存就泄漏了。

2.3 赋值重载的返回值,为什么必须是引用?自赋值检查真是形式主义?

先解决一个最简单也最容易猜的疑问:为什么赋值运算符返回引用?因为C++支持链式赋值,比如a = b = c;,解析成a.operator=(b.operator=(c))。如果返回值是对象,中间会多一次拷贝构造和析构。返回引用既避免了拷贝,也不影响链式赋值。你只要知道这个设计是为了效率本身,也就理解了为什么主流的写法几乎都是MyString& operator=(const MyString& s)。

自赋值检查更是个经典话题。有人觉得if (this != &s)是多余的,反正自己不给自己赋值也不会出大问题。但这种想法在资源管理类中是致命的。如果类里有指针成员,自赋值时先释放了老资源,再用老资源去赋值,结果可能把内存二次释放了。写一个极端的例子:

MyString& operator=(const MyString& s) { // 没检查自赋值 delete[] _str; _str = new char[strlen(s._str) + 1]; strcpy(_str, s._str); return *this; }

当执行s = s;时,第一步释放了this的资源,第二步strlen(s._str)访问了已经释放的内存,结果是未定义行为。自赋值检查虽然只占一行,但对资源管理类的正确性有决定性意义。

3. 初始化列表、explicit、default/delete:被低估的三个小细节

3.1 初始化列表和构造函数体,不是“两种写法”的关系

很多教材把初始化列表和构造函数体并列介绍,导致初学者误以为它们只是等价的不同风格。实际上,它们之间有本质区别:初始化列表发生在对象成员真正构造的那一时刻;函数体中的赋值,是对象已经构造完成以后的操作。

直接看一个const成员的问题:

class Date { public: Date(int year, int month, int day) { _year = year; // 错误:const成员不能赋值 _month = month; _day = day; } private: const int _year; int _month; int _day; };

这里为什么会报错?因为const成员变量在进构造函数体之前,必须完成初始化,后续不能再赋值。初始化列表正是做这件事的:

Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}

引用成员和没有默认构造函数的自定义类型成员,也有同样的要求。如果你在函数体里写赋值,等于先让引用或自定义类型“空构造”了一把,再修改,这在语义上是不允许的。认清“初始化列表负责构造,函数体负责再修改”的分工后,你就不会再对这些规则感到莫名其妙。

3.2 初始化顺序,由成员声明顺序决定

初始化列表有个反直觉的规则:初始化顺序不是按照列表里写的顺序,而是按照成员在类中的声明顺序。这个坑非常隐蔽,可能连工作多年的开发者都踩过。

class Demo { public: Demo(int v) : _b(v), _a(_b) {} void print() { std::cout << _a << " " << _b << std::endl; } private: int _a; int _b; }; int main() { Demo d(10); d.print(); // 输出:垃圾值 10,还是 10 10? }

如果按初始化列表顺序,先初始化_b再初始化_a,输出就是10 10。但实际是先声明_a,所以_a(_b)执行时_b还没有被初始化,_a得到的是一个随机值。现代编译器即使没有开启-Wall,也可能给你一个顺序警告。记住一句话:初始化列表别自作主张排序,永远和成员声明的顺序保持一致。否则后续维护代码的人会非常痛苦。

3.3 explicit 比你想的更实用

看这段代码:

class Date { public: Date(int year) : _year(year) {} private: int _year; }; int main() { Date d = 2024; // 隐式调用构造 return 0; }

看起来很方便,但隐式转换会引发一些奇怪的场景。比如你写一个函数void setDate(const Date& d),调用setDate(2024)也是合法的,它会把2024隐式构造出一个临时Date对象。这种可读性和意图性都打折扣。解决办法就是在构造函数前加explicit:

explicit Date(int year) : _year(year) {}

加上之后,Date d = 2024;就成了编译错误,必须写Date d(2024);。别觉得这限制了灵活性,很多C++项目因为隐式转换,在重载解析中选出你根本不想要的版本,加explicit是防御性编程的好习惯。对单参构造函数,我建议一律explicit,除非你有非常明确的转换语义需求。

再顺带提一下=default和=delete。=default能让编译器重新生成默认实现,适合你声明了拷贝构造但还想保留默认行为的情况;=delete则直接禁用某个函数。比如想做一个不可拷贝的类:

class NonCopyable { public: NonCopyable(const NonCopyable&) = delete; NonCopyable& operator=(const NonCopyable&) = delete; };

这两兄弟极大改善了“声明了函数就必须定义”的旧时代痛苦,也让“禁止拷贝”有了统一的表达方式。

4. static成员、友元、内部类与匿名对象:对象的“邻居”们也大有玄机

4.1 static成员:属于类,但不属于对象

static成员变量是所有对象共享的一份数据,它存储在静态区,不是放在某个对象内部。声明在类内,但定义和初始化必须在类外进行(C++17之后inline static可以在类内初始化,早期版本不行)。

最经典的应用是统计类的实例数量:

class Counter { public: Counter() { ++_count; } Counter(const Counter&) { ++_count; } ~Counter() { --_count; } static int getCount() { return _count; } private: static int _count; }; int Counter::_count = 0; int main() { Counter c1; Counter c2(c1); std::cout << Counter::getCount() << std::endl; // 2 return 0; }

在main里构造了两个对象,计数是2。如果你忘记在拷贝构造中加计数,统计就会缺一,这种细节常见于各种监控系统和内存池设计。static成员函数没有this指针,所以它不能访问普通成员变量,只能访问static成员。理解了这句话,你就不会犯“在static函数里调用普通成员函数”的编译错误。

4.2 友元:打破封装要慎重,但有时真得靠它

“友元函数”和“友元类”能访问类的私有成员,相当于在封装墙上开了个门。很多初学阶段第一反应是“既然是私有,就该禁止外部访问”,可现实中总有些场景让你不得不破例。

最典型的是重载流插入和流提取运算符。标准库的cout和cin应该怎么输出你的自定义类型?由于左操作数是std::ostream,你没法把它定义为成员函数(成员函数的左操作数是this),只能写成全局函数:

class Point { friend std::ostream& operator<<(std::ostream& out, const Point& p); friend std::istream& operator>>(std::istream& in, Point& p); public: Point(int x = 0, int y = 0) : _x(x), _y(y) {} private: int _x; int _y; }; std::ostream& operator<<(std::ostream& out, const Point& p) { out << p._x << ", " << p._y; return out; }

这里友元是必要的,因为没有它,operator<<就无法读取私有成员。友元的代价是削弱封装,任何一个新增的友元都会提高类与外部环境的耦合度。我的建议是:能用公有接口解决就不用友元;必须用就用,但尽量收敛在运算符重载或特定工具函数里。

4.3 内部类和匿名对象,各有各的用法

在类里面再定义类,称为内部类。很多人误以为内部类对象与外部类对象绑在一起,其实完全不是。内部类是独立的类,只是定义在外部类的类域中。内部类有个特点:它是外部类的友元,内部类可以直接访问外部类的私有成员,但外部类不能直接访问内部类的私有成员。

匿名对象就是直接类名()创建出来的临时对象,它没有名字,生命周期停留在当前行的表达式末尾。实际用途可以是简化代码:

class Show { public: void print() { std::cout << "print\n"; } }; int main() { Show().print(); // 匿名对象直接调用成员函数 return 0; }

再比如你只关心返回值不关心对象:

// 原本:Data d(1, 2); d.start(); Data(1, 2).start(); // 匿名对象

匿名对象的析构时机很容易被人忽略。假如调用链里还有延迟求值,匿名对象可能比预期更早析构,导致悬空引用。所以在使用时要确保调用的方法不依赖对象超长生命周期,或者干脆别在长作用域里用匿名对象保存引用。

5. 常见问题与排查技巧实录

5.1 程序崩溃在析构:先查是不是二次释放

如果你看到一个程序在退出时崩溃,栈回溯指向某个析构函数,多半是浅拷贝引起的堆内存重复释放。我遇到过初学者写的栈类,内部用arr = new int[_size],默认拷贝构造导致两个栈对象共享同一块arr,当它们依次析构,第二次释放未分配内存,直接触发abort。

排查思路分三步:第一步,看崩溃栈里是不是同一个delete执行了两次;第二步,在拷贝构造和赋值重载里打印日志,检查是不是有意外拷贝;第三步,用调试器监控堆指针的地址,确认两个对象的成员指针是否相同。找到相同地址,基本可以锁定浅拷贝。

写代码时还有一种常见的动作:先释放了旧资源,再因为异常没有分配新空间,让对象处于空悬状态。处理这个问题可以在赋值重载里使用“先创建临时对象,再交换数据”的思路,也就是copy-and-swap。这个模式不仅代码简洁,而且天然豁免自赋值问题。

5.2 自赋值检查被忽略了,但其实就是一行的事

自赋值问题在链式表达式里尤其容易发生。假设你重载了operator=,但不做检查,并执行a = b = a,此时中间过程会触发自赋值情况。默认的拷贝行为会把资源释放,最终拿到一个悬空指针。

我记得用调试器单步的时候,发现事情的发展路径极其诡异:先进入operator=,释放了“要拷贝的那个对象”的资源,然后源对象的内容已经没了。加了自赋值检查后一切正常。如果你觉得检查多余,请记住:编译器不会阻止你写出s = s;,而后果只有运行期才浮现,这种“看不见的崩溃”在大型项目里最难排查。

另一个注意点是赋值重载里的delete顺序。无论是否检查自赋值,都不要在申请新内存之前释放源对象资源。很多人写出来的版本是:释放旧资源、再拷贝源对象,但如果源对象和当前对象有某种共享关系,释放动作会波及源对象。稳妥的写法是先用局部变量保存或复制需要的值,再释放旧资源,最后赋值。

5.3 如何区分拷贝构造和赋值重载到底谁被调用

这个坑在学习阶段特别常见,因为它们的语法长得太像了:

MyString s1("hello"); MyString s2(s1); // 拷贝构造 MyString s3 = s1; // 也是拷贝构造,不是赋值! s3 = s1; // 赋值重载

判断标准很简单:对象在创建时,哪怕用了等号,只要本质是用已有对象初始化新对象,调用的就是拷贝构造;对已经存在的对象做赋值,才调用赋值重载。想知道编译器到底调用了哪个函数,最朴素的方法就是在两个函数内部打印日志。

不过还需要留意,现代C++编译器会做返回值优化和移动语义,某些你以为是拷贝构造的场景,实际可能被优化掉了,例如:

MyString func() { MyString tmp("hi"); return tmp; // 可能不调用拷贝构造,被NRVO优化 }

在写高要求代码时,不能只依赖日志判断逻辑,因为优化开关会影响输出。比较稳妥的办法是禁用优化编译,或者在对象内部用static计数统计调用次数。这样你至少能清楚“理论调用点”和“实际优化结果”的差异。

最后再分享两点实践心得

第一,写类之前先画“内存图”。把每个成员变量画成盒子,动态资源画成外挂的指针。然后问自己三个问题:每个指针由谁分配?由谁释放?如果对象被拷贝,指针指向的资源是否要复制一份?这套流程做熟了,深浅拷贝的坑基本能避掉一半。

第二,类与对象(下)这块内容,不是背规则就能学会的,一定要动手写。我建议你自己实现一个MyString,完整写出构造、析构、拷贝构造、赋值重载,再往标准库容器里使用它,观察扩容时的拷贝次数。接着试试把explicit、static计数器、匿名对象都用上去,然后编译、运行、调试、看栈帧。只有亲自见过析构函数二次释放的崩溃现场,才算真正掌握了类与对象(下)的精髓。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进