直接讲结论:C++的类和对象,难点根本不在语法本身,而在"编译器悄悄帮你干了什么"这件事上。类和对象这部分学完之后,真正让你和"会用"拉开差距的,是六个默认成员函数——它们在你没写的时候被编译器自动生成,在你不理解的时候偷偷帮你干活,也在你理解错了的时候让你Debug到怀疑人生。
以我这些年看代码和带新人的经验,这六个函数就是面向对象从"纸面语法"走向"真实内存管理"的分水岭。很多经典崩溃场景,比如double free、访问已释放内存、拷贝之后两个对象互相影响,根源都在这六个函数的深浅拷贝问题上。这篇文章我就把这六个默认成员函数逐个拆开,讲清楚它们什么时候被调用、编译器默认做了什么、什么时候必须自己写,最后用一个完整的String类串起来做个实战收尾。
1. 构造函数:对象诞生前的"初始化仪式"
1.1 为什么必须有构造函数,它解决了什么问题
在C语言里,结构体变量创建后就是一个随机内存块,你必须手动调用init函数去初始化。C++的构造函数把"创建"和"初始化"合并成了一个动作,这在设计思想上是一次关键升级——你不再有机会使用一个"半成品"对象,因为对象在出生那一刻就是完整的。
构造函数的三个硬性特征必须记牢:
- 函数名和类名完全一致,没有返回值(连void都不能写,注意不是返回void,是什么都不写)。
- 创建对象时自动调用,你无法显式调用构造函数。
- 支持重载,一个类可以有多个构造函数,靠参数列表区分。
看一个最简单的例子:
class Student { public: Student() { // 无参构造 _name = "unknown"; _id = 0; } Student(const char* name, int id) { // 带参构造 _name = name; _id = id; } private: std::string _name; int _id; }; int main() { Student s1; // 调用无参构造 Student s2("张三", 1001); // 调用带参构造 return 0; }这里有个所有新手都会困惑的点:Student s1;和Student s1();有什么区别?第一个会调用无参构造函数,第二个是一个返回Student对象的函数声明——这是C++最著名的"最令人困惑的解析"之一。实际声明对象时不要加括号,加括号配合new倒是正确的:Student* p = new Student();。
1.2 构造函数生成规则:编译器不是永远都帮你生成
构造函数是六个默认成员函数里最特殊的一个,因为只要用户显式定义了任意一个构造函数,编译器就不再生成默认构造函数了。这个规则是面试最爱考的,也是新手最容易踩的坑。
具体分三种情况:
| 场景 | 编译器行为 | 后果 |
|---|---|---|
| 没写任何构造函数 | 自动生成无参默认构造 | 可以正常创建对象 |
| 写了一个带参构造 | 不再生成默认构造 | Student s1;会编译失败 |
| 写了带参构造还想用无参方式创建 | 必须自己补一个无参构造 | 无参构造和带参构造并存 |
我见过太多次这样的编译错误:定义了带参构造后,代码里写Obj o;,编译器报"没有合适的默认构造函数"。原因就是这个规则。解决办法很简单,在类里显式补一个无参构造函数,或者用C++11的= default强制生成:
class Student { public: Student() = default; // 强制编译器生成默认构造 Student(const char* name, int id) { _name = name; _id = id; } // ... };另一种情况是使用= delete禁用一个构造函数,比如你不希望对象被默认构造,就可以Student() = delete;。这在设计单例类、工具类时非常实用。
1.3 初始化列表:构造函数里最容易被低估的细节
构造函数里有个东西必须单独拿出来说——初始化列表。它的语法是在构造函数参数列表后面跟一个冒号,然后逐个初始化成员:
class Date { public: Date(int year, int month, int day) : _year(year), _month(month), _day(day) // 初始化列表 { // 函数体里面还可以做额外的事情 } private: int _year; int _month; int _day; };很多人刚学时觉得初始化列表和函数体内赋值"差不多",实际上差别很大。初始化列表才是真正的"初始化",函数体里的等号是"赋值"。对于内置类型(int, double等)两者看起来没区别,但对于const成员、引用成员,它们是必须在初始化列表里初始化的,因为这两种成员一旦创建就不能再赋值:
class Example { public: Example(int x) : _constVal(x), _refVal(_constVal) { // 不能在这里写 _constVal = x,会编译报错 } private: const int _constVal; int& _refVal; };这个知识点权重很高,因为在真实项目里,构造函数初始化列表几乎是标配写法,而新手从"函数体赋值"过渡到"初始化列表"往往需要一个适应期。我的建议是:从一开始写构造函数就用初始化列表,强迫自己养成习惯,以后遇到继承场景、组合场景,理解成本会低很多。
1.4 explicit关键字:防止隐式转换的"隐形陷阱"
构造函数还有个容易忽略的坑,就是它允许隐式类型转换。看这个例子:
class Date { public: Date(int year) : _year(year) {} private: int _year; }; void func(Date d) {} int main() { func(2026); // 一个整型直接传进去了?!编译器隐式调用了Date(2026) Date d1 = 2025; // 这里也隐式创建了临时对象 return 0; }这种写法编译能通过。它"方便"吗?单参数构造函数确实有这个语义,但大多数情况下这不是你想要的行为——别人看到func(2026),并不知道这里其实是在构造一个Date对象,代码可读性很差,而且很容易写出意图不明的隐式转换。
解决办法就是在构造函数前加explicit关键字:
class Date { public: explicit Date(int year) : _year(year) {} }; int main() { // func(2026); // 编译报错 func(Date(2026)); // 必须显式构造 return 0; }后续学习STL源码时会见到大量explicit,因为在容器、智能指针这些类中,隐式转换往往会导致资源管理的严重错误。
2. 析构函数:资源回收的最后一道闸门
2.1 析构函数什么时候被调用
构造函数是对象的"出生仪式",析构函数就是对象的"告别仪式"。它的命名规则是类名前面加~,没有参数、没有返回值、一个类只能有一个析构函数。
调用时机是四个字:对象销毁。具体分情况:
- 栈上创建的对象,在离开作用域时自动调用析构函数。
- new出来的堆对象,在调用delete时触发析构函数。
- 程序结束时,全局对象销毁。
这里必须强调一个高频错误:很多人以为new出来的对象会在程序结束时自动析构。不会。堆上对象只有在显式delete时才被销毁。如果只new不delete,不仅析构函数不会执行,还会造成内存泄漏。
举个直观例子:
class Resource { public: ~Resource() { std::cout << "析构函数被调用" << std::endl; } }; int main() { Resource r1; // 离开main时自动析构 Resource* r2 = new Resource(); // 这里没有delete r2,析构函数不会执行,资源不会释放 return 0; }运行后你会发现只有一次"析构函数被调用",另一个对象的内存泄漏了。这也是为什么现代C++强烈推荐使用智能指针,让unique_ptr、shared_ptr在合适时机自动管理堆对象的生命周期。
2.2 什么时候必须自己写析构函数
默认析构函数对内置类型成员(int, double, 指针)什么都不做,对类类型成员(如std::string、std::vector)会自动调用它们的析构函数。所以如果你的类只包含这些成员,完全不需要自己写析构函数。
但有一个例外:你的类持有原始指针,并且负责释放这块内存。典型场景就是动态申请了内存:
class String { public: String(const char* str) { _size = std::strlen(str); _capacity = _size; _str = new char[_capacity + 1]; std::strcpy(_str, str); } ~String() { delete[] _str; // 必须自己释放,否则内存泄漏 } private: char* _str; size_t _size; size_t _capacity; };这个类如果不写析构函数,new[]出来的char数组内存就永远无法释放。每创建一个String对象,就泄漏一块内存,跑久了程序内存持续上涨。
判断自己是否需要写析构函数,可以问一个问题:这个类是否拥有独占资源?拥有资源才需要自己释放,不拥有就不需要。这是实践中最好用的判断标准。
提示:一个类如果写了析构函数,几乎可以肯定它也需要写拷贝构造和赋值运算符,否则浅拷贝会导致同一块内存被释放两次。这一点第三节会详细展开。
2.3 析构顺序和构造顺序是反的
析构函数还有一个被很多人忽略的细节:析构顺序与构造顺序完全相反。
当对象包含多个成员,或者存在继承关系时,析构函数的执行顺序是:先执行自己析构函数的函数体,再按声明顺序的逆序析构成员变量,最后析构基类部分。也就是说,构造是先基类后派生类、先成员声明顺序;析构是先从派生类到基类、成员逆序。
设计类时如果成员之间存在依赖关系,这个顺序会影响析构的正确性。比如类A持有指向B的指针,B持有指向A的指针,析构时A的析构函数试图访问B,但B可能已经被析构了——这类问题在真实项目里排查起来非常隐蔽。解决思路通常是用智能指针配合引用计数,打破这种相互依赖。
3. 拷贝控制成员:浅拷贝陷阱与"三/五法则"
3.1 拷贝构造和赋值运算符:看起来像,本质完全不同
第三个和第四个默认成员函数是拷贝构造函数和赋值运算符重载。很多新手分不清它俩,这里我先给个最简洁的区分:
- 拷贝构造:用一个已存在的对象创建一个新对象。新对象"出生"时就用旧对象的内容来初始化。
- 赋值运算符:两个对象都已存在,把右边对象的内容拷贝到左边对象里。
代码上区分最直观:
Student s1("张三", 1001); Student s2(s1); // 拷贝构造:s2被创建 Student s3 = s1; // 也是拷贝构造!虽然用了等号,但s3之前不存在 s3 = s1; // 赋值运算符:s3已存在,现在把s1赋值给它特别注意Student s3 = s1;这种写法,看起来像赋值,但实际上是拷贝构造。判断标准只有一个:等号左边对象之前是否已经存在。不存在就是拷贝构造,存在才是赋值运算符。
3.2 默认拷贝构造的危险:逐字节拷贝的双重释放
默认拷贝构造函数的行为是"逐字节拷贝"(位拷贝),也就是把源对象的内存原样复制一份给目标对象。对于简单的日期类这种纯内置类型成员,这完全没问题,两个对象各存各的。但如果成员里有指针,问题就来了:
class String { public: String(const char* str = "") { _size = std::strlen(str); _str = new char[_size + 1]; std::strcpy(_str, str); } ~String() { delete[] _str; // 释放自己持有的内存 } // 注意:这里没有自定义拷贝构造 private: char* _str; size_t _size; }; int main() { String s1("hello"); String s2(s1); // 默认拷贝构造:s2._str = s1._str,两个指针指向同一块内存 // 离开作用域时: // s2先析构,delete[] 了这块内存 // s1再析构,再次delete[] 同一块内存 —— double free! return 0; }这就是经典的双重释放崩溃,而且它的表现非常随机:有时候程序直接崩,有时候内存被破坏导致后续运行出现各种诡异bug。我在实际带项目时见过最头疼的case就是这种,崩溃点根本不在这段代码,而在几万行之外的某个地方。
解决方案是深拷贝:拷贝构造函数里重新申请一块内存,把内容复制过去,让两个对象各持有自己独立的内存:
String(const String& s) { _size = s._size; _str = new char[_size + 1]; std::strcpy(_str, s._str); }这下s1和s2各自有一块内存,析构时互不影响。
3.3 拷贝构造参数为什么必须是引用
拷贝构造的形参必须是引用类型,这是绝大多数人第一个在语法层面"死记硬背"但没理解透的规则。
想想看:如果拷贝构造的参数不是引用,而是传值,会发生什么?传值本身就要做一次拷贝,而拷贝又要调用拷贝构造,拷贝构造又要传值,于是无限递归。哪怕编译器允许你写出String(String s)这样的签名,调用s2(s1)时就已经进入死循环了。
为了切断这个自指递归,拷贝构造的参数必须用引用。更进一步,通常用const引用,因为拷贝构造不应该修改源对象。这也是"拷贝构造签名长什么样"的标准答案:ClassName(const ClassName& other)。
3.4 赋值运算符:三个必须遵守的细节
赋值运算符重载比拷贝构造要多留意三个细节:
细节一:返回类型是引用,支持链式赋值。我们习惯写a = b = c这种链式赋值,这要求b = c的返回值还能继续被赋值,所以返回*this的引用:
String& operator=(const String& s) { // ... return *this; }细节二:必须处理自赋值。如果用户写了s1 = s1,而你的实现是先释放自己再复制,那自赋值时就会把数据释放掉再复制一份垃圾数据。稳妥做法是先判断地址是否相同:
if (this == &s) { return *this; }细节三:先释放旧资源,再分配新资源。赋值是"把右边拷给左边",左边原本持有的资源要先释放掉,否则内存泄漏。但如果释放后new又失败了,整个对象会处于不完整状态。现代C++推荐的思路是拷贝并交换(copy-and-swap):
String& operator=(const String& s) { if (this != &s) { String tmp(s); // 先拷贝 std::swap(_str, tmp._str); // 再交换 _size = tmp._size; } return *this; }这里用到了一个临时对象,s的内容被拷贝进tmp,然后tmp的资源通过swap跟当前对象交换。tmp离开作用域时析构,释放的恰好是当前对象原本的资源。这种写法天然安全,就算后续new失败,当前对象状态也不受影响。
3.5 什么时候必须写拷贝控制成员:三/五法则
有个经典的经验法则叫"三/五法则":如果你需要显式定义析构函数,那么你几乎一定也需要定义拷贝构造函数和赋值运算符。更宽泛一点的"五法则"在此基础上还包括移动构造函数和移动赋值运算符。
判断自己需要不需要写拷贝控制成员,还是看那个标记:类是否拥有独占资源。你写了析构去释放它,说明资源是独有的,那么默认的浅拷贝必然导致同一资源被多次释放。要么深拷贝,要么把拷贝禁掉(= delete),要么用智能指针让资源共享。三条路选哪条,取决于设计意图。
实际项目里,一个类同时需要自定义析构、拷贝构造、赋值运算符的情况非常常见。这个"三件套"往往应该作为一个整体来设计和审查,孤立地只写其中一个,反而最容易埋下隐患。
4. 取地址运算符重载:默认的最后两个"小透明"
4.1 为什么这两个函数一般不碰
剩下两个默认成员函数是operator&的两个重载版本——普通取地址和const取地址。它们的默认行为就是返回对象的地址:
Date* operator&() { return this; } const Date* operator&() const { return this; }这两个函数绝大多数情况下不需要重载,因为对象取地址这种基础操作,默认实现完全够用且正确。而且重载取地址是一个相当危险的操作,它会改变&运算符对所有该类型对象的行为,影响可真不小。
你可能听过一个老面试题:如何让一个类无法被取地址。标准答案就是重载operator&并把它设成private或者用= delete删除:
class Unaddressable { public: Unaddressable() = default; private: // 把取地址操作禁掉 Unaddressable* operator&() = delete; const Unaddressable* operator&() const = delete; };但讲真,这种需求在实际项目里几乎遇不到。STL容器、智能指针这些标准库组件设计者之所以要求"不要重载取地址运算符",是因为容器内部可能要获取对象的地址去做某些操作,如果被你重载成了返回别的东西,整个容器的行为都会变得不可预期。比如调试模式下,有的调试工具会重载operator&来拦截指针操作,这本身就是个让人头疼的副作用。
4.2 什么时候真的需要重载取地址
极少见的情况下,如果对象的地址和this不直接对应,比如某些代理对象、内存池里的对象,地址需要经过偏移计算才能拿到,这时重载取地址运算符才有意义。但这种场景基本出现在库的设计者层面,应用层开发几乎不会碰。我的建议很简单:了解它们存在即可,千万不要主动重载。默认的就已经是最安全的选择。
5. 实战:手写一个完整String类,把六兄弟全部用上
5.1 类设计思路
理论讲再多,不如手写一个完整的类来得踏实。我带你从头实现一个简化版String类,把构造函数、析构函数、拷贝构造、赋值运算符全部串起来,覆盖这篇文章讲到的所有核心点。
5.2 完整实现与逐段解析
#include <iostream> #include <cstring> class MyString { public: // 1. 构造函数(带默认参数,同时充当默认构造和带参构造) MyString(const char* str = "") { _size = std::strlen(str); _capacity = _size; _str = new char[_capacity + 1]; std::strcpy(_str, str); } // 2. 析构函数(释放自己持有的资源) ~MyString() { delete[] _str; _str = nullptr; // 防御性编程,防止悬空指针 } // 3. 拷贝构造(深拷贝) MyString(const MyString& s) { _size = s._size; _capacity = s._capacity; _str = new char[_capacity + 1]; std::strcpy(_str, s._str); } // 4. 赋值运算符(拷贝并交换) MyString& operator=(const MyString& s) { if (this != &s) { // 检查自赋值 MyString tmp(s); // 调用拷贝构造创建临时对象 std::swap(_str, tmp._str); std::swap(_size, tmp._size); std::swap(_capacity, tmp._capacity); // tmp离开作用域,释放的是旧资源 } return *this; } // 5. 取地址运算符(保持默认行为即可,此处仅展示) MyString* operator&() { return this; } const MyString* operator&() const { return this; } // 辅助接口 const char* c_str() const { return _str; } size_t size() const { return _size; } private: char* _str; size_t _size; size_t _capacity; }; int main() { MyString s1("hello"); MyString s2(s1); // 拷贝构造 MyString s3; s3 = s1; // 赋值运算符 std::cout << s1.c_str() << std::endl; std::cout << s2.c_str() << std::endl; std::cout << s3.c_str() << std::endl; return 0; }这个类写完后,拷贝、赋值都不会出现双重释放,每个对象都拥有自己独立的内存。你可以试试把拷贝构造注释掉,然后运行程序——大概率会在程序退出时崩溃,这就是浅拷贝露出的马脚。
我还额外做了两件事值得说说。第一,析构函数里delete[]之后把_str置空,这是很多老手都坚持的防御性习惯,防止有人对这个空指针做非法访问时更隐蔽。第二,赋值运算符用的是拷贝并交换,而不是传统的"释放-拷贝"两步走,这样异常安全更好,代码也简洁。
5.3 测试清单:怎么验证你的类写对了
写完一个类,光编译通过不算数。我一般建议跑一跑这几个自测用例:
- 默认构造:
MyString s;,确认s.c_str()返回空串,不是乱码。 - 带参构造:
MyString s("test");,确认内容正确。 - 拷贝构造:拷贝后修改源对象,确认目标对象不受影响(这验证了深拷贝)。
- 赋值运算符:连续赋值
a = b = c,确认结果正确且各对象独立。 - 自赋值:
s = s;,确认不崩溃、数据不丢失。 - 析构:在函数里创建局部对象,确认栈上对象销毁时资源被释放;new出来的对象delete后确认析构被调用。
这些用例可以在编译时加-fsanitize=address跑一遍,AddressSanitizer会在内存访问异常时直接告诉你哪一行出了问题。这是排查内存类bug最省事的工具,强烈推荐。
最后补一个实操心得:初学者学默认成员函数时,最容易犯的毛病是"背语法"而不是"理解语义"。六个函数背后其实是同一个思想——对象的生命周期管理。构造函数回答"对象怎么开始存在",析构函数回答"对象怎么结束存在",拷贝构造和赋值运算符回答"对象的内容怎么复制",取地址运算符回答"对象怎么被定位"。把这四条线理清楚了,遇到再复杂的类都能立刻判断出它哪些地方藏了坑。我当初从频频踩内存坑到能一次写对类,靠的就是把"默认行为"和"必须自己写"的分界线想透——这条线,就是有没有独占资源这一个判断标准。你把这个标准记牢,六个默认成员函数对你来说就不再是死记硬背的考点了。