ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++六个默认成员函数:构造函数、析构函数与拷贝控制详解

C++六个默认成员函数:构造函数、析构函数与拷贝控制详解 直接讲结论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;编译器报没有合适的默认构造函数。原因就是这个规则。解决办法很简单在类里显式补一个无参构造函数或者用C11的 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-swapString 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后确认析构被调用。这些用例可以在编译时加-fsanitizeaddress跑一遍AddressSanitizer会在内存访问异常时直接告诉你哪一行出了问题。这是排查内存类bug最省事的工具强烈推荐。最后补一个实操心得初学者学默认成员函数时最容易犯的毛病是背语法而不是理解语义。六个函数背后其实是同一个思想——对象的生命周期管理。构造函数回答对象怎么开始存在析构函数回答对象怎么结束存在拷贝构造和赋值运算符回答对象的内容怎么复制取地址运算符回答对象怎么被定位。把这四条线理清楚了遇到再复杂的类都能立刻判断出它哪些地方藏了坑。我当初从频频踩内存坑到能一次写对类靠的就是把默认行为和必须自己写的分界线想透——这条线就是有没有独占资源这一个判断标准。你把这个标准记牢六个默认成员函数对你来说就不再是死记硬背的考点了。
返回列表