资讯中心

C++构造与析构:从资源管理到RAII的完整实战指南

📅 2026/9/29 7:21:50
C++构造与析构:从资源管理到RAII的完整实战指南
1. 从资源管理讲起为什么构造和析构是C的生命线先说我以前带过的一个新同事。他写代码很快但有个习惯很危险——new出来的对象不delete。问起来他还很委屈反正程序就运行几秒钟退出的时候操作系统会回收内存干嘛要管 后来他负责的模块跑了一个通宵内存占用从 200MB 一路涨到 6GB第二天早上服务器直接 OOM 崩了。这哥们儿不是不会写delete而是他从心底里觉得内存在程序退出后总会释放这件事是可以接受的默认值。C 不是这样玩的。C 的设计哲学是你创建了什么你就有责任销毁什么。而构造函数和析构函数就是 C 把这份责任做成语法机制的地方。简单说构造函数是对象创建时自动调用的特殊成员函数用来初始化对象的状态析构函数是对象生命周期结束时自动调用的特殊成员函数用来释放对象占用的资源。只要你定义了一个类的对象编译器就保证这两个函数一定会被调用不需要你手动去调。这个自动到底是好事还是坏事很多人学了半年 C 都还没真正理解它不是帮你省了写两行代码的功夫而是给你提供了一套可以精确控制资源生命周期的钩子。有个比喻我一直觉得很好用构造函数就像是入住酒店时的登记手续析构函数就是退房时的结算和交钥匙。不管你是住一晚还是住一年这两道手续都必须发生区别只是你什么时候办理。C 通过栈对象的自动构造/析构让你甚至连办理这个动作都不用操心——对象出了作用域析构函数自动执行就像你走出酒店大门房卡自动失效一样。这篇文章适合所有正在学 C 的同学尤其是刚看完语法书、准备开始写实际代码的人。我默认你了解基本的类、对象、成员变量和成员函数但你不一定彻底搞清楚了构造函数和析构函数的全部细节。读完这篇文章你会明白如何写出安全的构造函数、什么时候必须自己写析构函数、拷贝和移动到底在背后做了什么以及在实战中哪些写法会踩坑。2. 构造函数的写法与细节这不只是给成员变量赋值2.1 默认构造函数编译器替你做了什么很多人刚接触类的时候会写这样的代码class Player { public: std::string name; int level; };然后直接Player p;接着就访问p.name、p.level。注意此时name是空字符串level的值却是不确定的——它可能是 0可能是 39281取决于栈上之前残留了什么数据。很多初学者在这里误以为编译器会帮我初始化实际上编译器只负责调用默认构造函数而默认构造函数对内置类型int、double、指针等做的是不初始化。这里有个非常容易栽跟头的点如果你写了任何构造函数比如带参数的编译器就不会再隐式生成默认构造函数。此时你写Player p;会直接编译报错。解决办法就是Player() default;或Player() {}显式地把默认构造函数请回来。我曾经在一个项目里见过这样的代码class Config { public: Config(int timeout) : timeout_(timeout) {} int timeout_; };然后业务代码里写Config c;想构造一个默认配置对象编译器直接报错。写代码的同事一脸茫然我记得默认构造函数是自动有的啊 这就是典型的我定义了带参构造函数、却忘了默认构造不存在了的失误。2.2 带参构造函数与初始化列表为什么推荐用初始化列表带参构造函数最常见的写法有两种// 方式一在函数体内赋值 Player(const std::string n, int lv) { name n; level lv; } // 方式二初始化列表 Player(const std::string n, int lv) : name(n), level(lv) {}两者结果看起来一样但过程不一样。方式一是先调用默认构造函数创建name和level再在函数体里重新赋值方式二是直接以指定值构造成员变量少了一次默认构造和一次拷贝赋值。对于std::string这种成员方式一的代价是先构造一个空字符串再把传入的字符串拷贝/移动进去。对于庞大的字符串或者自定义的复杂类型这个额外开销是实打实的。所以我个人的习惯是能用初始化列表就用初始化列表尤其是成员变量里有const、引用或者没有默认构造函数的类类型时不用初始化列表根本编译不过去。class Logger { public: Logger(std::ostream out, int level) : out_(out), level_(level) {} private: std::ostream out_; // 引用成员必须在初始化列表里绑定 const int level_; // const 成员同样必须在初始化列表里初始化 };2.3 构造函数重载与默认参数从 Java 转过来的同学注意了热词里出现java构造函数链说明有些读者可能是从 Java 转过来的。Java 里构造函数重载很常见this(...)链式调用也很方便。C 也支持构造函数重载但没有直接的this(...)链式调用语法通常用两种方式替代一是委托构造函数C11 起支持class Timer { public: Timer() : Timer(1000) {} // 委托给下面的构造函数 explicit Timer(int ms) : interval_ms_(ms) {} private: int interval_ms_; };二是用默认参数合并多个重载Timer(int ms 1000); // 本质可以当作 Timer() 和 Timer(1000) 两种用法但用默认参数有个坑如果你写Timer(int ms 1000)而调用方写Timer t(0)ms就是 0不会变成 1000因为默认参数只在调用方没传那个位置时才生效。这是逻辑层面要注意的。还有个 C 特有的最令人苦恼的解析most vexing parse如果你写Timer t();编译器会认为你在声明一个返回Timer的函数而不是定义一个默认构造的对象。正确写法是Timer t;或者Timer t{};。这个坑我在面试时问过很多人错的人不在少数。2.4 explicit 关键字防止隐式转换带来的意外如果构造函数只接收一个参数而且你没加explicit那么这个构造函数就允许隐式类型转换。看个例子class Score { public: Score(int value) : value_(value) {} private: int value_; }; void printScore(const Score s) {} int main() { printScore(95); // 隐式把 int 转成 Score编译通过 return 0; }有些场景下这个特性确实有用但更多时候它会让代码变得难懂。我在开发中见过void hit(int damage)被一个Monster参数调用的诡异代码就是因为构造函数没加explicit编译器自动做了隐式转换。所以我的建议是单个参数的构造函数除非你确认要支持隐式转换否则一律加explicit。3. 拷贝构造与移动构造对象复制背后的性能账3.1 三/五法则什么时候必须自己写C 有一条著名的三法则如果你需要自定义析构函数、拷贝构造函数或拷贝赋值运算符中的任何一个那么这三个通常都应该自定义。C11 增加了移动构造函数和移动赋值运算符后这个法则扩展成了五法则。为什么因为需要自定义析构函数往往意味着这个类管理着某种资源堆内存、文件句柄、网络连接等。一旦你手动管理资源默认的逐成员拷贝就会出大问题——两个对象会指向同一个资源析构时对同一块内存释放两次。class StringBuffer { public: StringBuffer(size_t size) : size_(size), data_(new char[size]) {} ~StringBuffer() { delete[] data_; } private: size_t size_; char* data_; }; int main() { StringBuffer a(1024); StringBuffer b a; // 默认拷贝b.data_ 和 a.data_ 指向同一块内存 return 0; // a、b 析构时同一块内存被 delete[] 两次 - 崩溃 }这段代码几乎必然崩溃。默认拷贝构造函数做的是浅拷贝只复制data_指针本身不复制指针指向的缓冲区。修复方法就是实现深拷贝——为b分配一块新的内存把内容复制过去。3.2 实现拷贝构造函数的正确姿势拷贝构造函数的标准写法StringBuffer(const StringBuffer other) : size_(other.size_), data_(new char[other.size_]) { std::copy(other.data_, other.data_ size_, data_); }这里我有几件事要提醒你第一参数必须用const引用。如果传值StringBuffer(const StringBuffer other)会为了拷贝参数再调用一次拷贝构造函数无限递归编译都过不了。第二别忘了处理自赋值。虽然自赋值在拷贝构造函数里不太可能出现StringBuffer b b;这种代码正常人不会写但在拷贝赋值运算符里非常常见。第三拷贝赋值运算符要处理已有资源的释放。如果你写StringBuffer operator(const StringBuffer other) { // 先判断自赋值 if (this other) return *this; // 释放旧的 delete[] data_; // 拷贝新的 size_ other.size_; data_ new char[size_]; std::copy(other.data_, other.data_ size_, data_); return *this; }如果不先delete[] data_就会内存泄漏。如果先delete再new而new抛异常对象状态会处于半损坏状态。一个更安全的做法是拷贝并交换不过这属于进阶范畴这里先不多展开。3.3 移动构造函数C11 带来的性能革命传统 C 里std::vector扩容时要把旧内存里的对象逐个拷贝到新内存然后销毁旧对象。如果对象自己管理堆内存这个拷贝-销毁过程非常昂贵。C11 引入了移动语义允许直接把资源搬到新对象旧对象留下一个空壳。移动构造函数的长相是这样的StringBuffer(StringBuffer other) noexcept : size_(other.size_), data_(other.data_) { other.data_ nullptr; other.size_ 0; }表示右值引用说明这个构造函数的实参是一个即将销毁的临时对象我们可以放心地偷走它的资源。把data_指针直接拿过来然后把other.data_置空这样other析构时delete[] nullptr是安全的删除空指针是合法的。noexcept也很重要std::vector扩容时如果移动构造函数声明了noexcept编译器可以放心地用移动而不是拷贝如果没声明为了异常安全很多容器会退回使用拷贝操作性能优势就没了。3.4 编译器自动生成的规则什么时候不用自己写C 的规则是如果你一个都没写编译器会自动生成默认构造、拷贝构造、拷贝赋值、移动构造、移动赋值和析构函数。但只要你声明了任何一个拷贝操作或析构函数编译器就不会隐式生成移动操作。这意味着如果你的类定义了析构函数比如释放内存又没有显式 default;移动构造那么std::vector扩容时该对象的移动就不存在会退化为拷贝。所以现代 C 实践里有一个建议如果你不需要自定义析构就别写空析构函数占位如果你需要自定义析构那请认真考虑是否要 default移动操作或者干脆全部显式声明。这不仅仅是性能问题有时候是正确性问题——当容器存储你的对象时移动和拷贝的行为差异会直接影响程序结果。4. 析构函数与 RAII栈对象销毁时的那道保险4.1 析构函数的执行时机局部对象、成员对象、容器元素析构函数的调用时机我见过很多人搞混。核心规则是对象在离开作用域时析构。具体分为几种情况栈上的局部对象离开它所在的花括号作用域时析构。类的成员对象在包含它的对象的析构函数完成后析构。容器元素容器销毁时先析构容器内的每个元素再销毁容器自身。堆上对象new出来的只有在delete时析构。如果没有delete析构函数不会被调用——这就是内存泄漏的根源不仅是内存没释放还意味着析构函数里做的清理工作写过日志、关闭的文件、断开连接全都不会执行。看一个经典例子class DatabaseConnection { public: ~DatabaseConnection() { std::cout 关闭数据库连接 std::endl; // 假设这里有实际的 disconnect() 调用 } }; void process() { DatabaseConnection conn; // 构造 // ... 业务逻辑中途可能抛异常 } // conn 析构连接被关闭注意这个process函数如果在括号内的业务逻辑抛出了异常conn的析构函数依然会被调用——这是 C 栈展开stack unwinding机制保证的。相比之下如果你用new创建对象然后用裸指针管理一旦异常发生delete那行代码可能永远执行不到资源就泄漏了。4.2 虚析构函数为什么基类析构函数几乎总是要写 virtual这是一个让很多初学者头疼的问题多态场景下delete一个指向派生类对象的基类指针如果没有虚析构函数行为是未定义的。更直白地说很可能你只析构了基类部分派生类部分没被析构资源泄漏。class Base { public: virtual ~Base() default; // 推荐写法 }; class Derived : public Base { public: ~Derived() override { // 释放 Derived 特有的资源 } }; int main() { Base* p new Derived; delete p; // 没有虚析构 - 调用哪个析构未定义实际上通常只调用 Base::~Base() return 0; }加上virtual后delete p会根据实际指向的对象类型先调用Derived::~Derived()然后自动调用Base::~Base()。这条规则我建议当成铁律只要一个类会被当作基类使用就把析构函数声明为 virtual。如果类不打算被继承那就别加 virtual省一个虚函数表的开销。4.3 RAII把资源生命周期绑定到栈对象RAIIResource Acquisition Is Initialization是 C 里最核心的设计思想之一。虽然名字很长但内容非常朴素资源在构造函数中获取在析构函数中释放。这样资源的管理就被绑定到对象的生命周期上只要对象活着资源就存在对象销毁资源必然释放。举个例子C11 的std::lock_guard就是 RAII 的典型应用void updateBalance() { std::lock_guardstd::mutex lock(mutex_); // 构造时 lock() balance_ 100; // 函数结束lock 析构自动 unlock() }你不用手动unlock()即使函数在加锁后抛出异常lock_guard的析构函数也会确保锁被解锁。这就是 RAII 最大的价值异常安全的资源管理。C 面试里很多公司爱问智能指针和裸指针有什么区别本质就是在考 RAII。std::unique_ptr在构造时接管裸指针析构时自动delete让动态内存也有了确定性释放的保证。我见过太多 C 初学者在学完类和对象后代码里大量使用new/delete却没想过用 RAII 把这些资源包装起来。实际上现代 C 的最佳实践是裸new应该只出现在智能指针的构造函数里。4.4 析构函数的异常问题为什么不要让异常逃离析构函数析构函数里如果抛出了异常而且此时栈上正在展开比如另一个异常正在传播过程中程序会直接调用std::terminate整个进程崩溃。所以一个重要的实践经验是析构函数里尽量不抛异常。如果确实需要做可能失败的操作比如关闭文件时刷写缓存失败一个常见的选择是把异常捕获并记录日志或者把失败状态保存下来供外部查询而不是直接丢异常。~FileWriter() { try { flush(); close(); } catch (...) { // 记录日志即可不能让异常逃出析构函数 std::cerr 析构时刷新文件失败 std::endl; } }5. 一些常见的构造析构实战坑帮你提前踩一遍5.1 坑一成员初始化顺序和声明顺序不一致初始化列表里成员的初始化顺序不是按你写的顺序而是按类定义中声明的顺序。这个坑很隐蔽。class Order { public: Order(int a) : b_(a), a_(b_) {} // 以为 a_ 先用 b_ 初始化其实顺序反了 private: int a_; // 先初始化 int b_; // 后初始化 };实际执行时先初始化a_此时b_还是未初始化状态再把b_设为a。a_的值是垃圾值因为它是从未初始化的b_拷贝来的。为了避免这种问题最好的办法是让初始化列表的顺序和成员声明顺序保持一致同时编译时打开-Wreorder警告GCC/Clang 都支持。5.2 坑二std::vector扩容时对象被移动指针/迭代器失效如果你自定义了一个类存到std::vector里vector扩容时会移动或拷贝元素。如果你的类管理了资源但没正确实现移动/拷贝语义扩容后旧内存被释放元素里的指针会变成悬空指针。这个问题的排查比较难因为程序可能在扩容很久之后才崩溃。我建议的做法是凡是自定义析构函数的类做完之后写一个小测试把它塞进std::vector并让它扩容很多次用 ASanAddressSanitizer编译运行看有没有问题。g -fsanitizeaddress -g -stdc17 test.cpp -o test ./test如果是资源管理类ASan 能在第一时间报告 double-free 或者 use-after-free。5.3 坑三构造函数中抛异常时的内存管理如果构造函数在执行中途抛了异常该对象的析构函数不会被调用——因为对象还没构造完成。但构造函数中已经初始化的成员变量会被正确析构。这个规则有一个视角下的建议不要在构造函数里用裸new分配多个资源因为一旦第二个new抛异常第一个new的资源就泄漏了。更好的方式是用智能指针成员或者标准库容器来管理资源让这个规则替你兜底。class BadExample { public: BadExample() { data1_ new int[100]; // 如果下面这行抛异常data1_ 泄漏 data2_ new int[100]; // 假设这里抛异常 } ~BadExample() { delete[] data1_; delete[] data2_; } private: int* data1_; int* data2_; };改成这样更安全class GoodExample { public: GoodExample() : data1_(std::make_uniqueint[](100)), data2_(std::make_uniqueint[](100)) {} private: std::unique_ptrint[] data1_; std::unique_ptrint[] data2_; };5.4 坑四使用memset或memcpy处理非平凡类型从 C 转来的同学容易犯一个错误用memset(obj, 0, sizeof(obj))来重置一个对象或者用memcpy复制含std::string、std::vector等成员的对象。这是非常危险的操作。std::string对象内部是指针和大小直接memset后对象内部的指针指向的内存可能未释放或者变成一个无效地址后续析构必然崩溃。C 的正确做法是用赋值运算符或构造新的对象而不要用 C 风格的内存操作函数处理带有非平凡成员的类。5.5 结合调试工具观察构造析构的调用顺序如果你在学习阶段想亲眼看到构造和析构的顺序可以在构造函数和析构函数里打印日志。不过这里有一个需要注意的点日志输出不要过度设计。一个小实验class Trace { public: Trace(const char* name) : name_(name) { std::cout 构造 name_ std::endl; } ~Trace() { std::cout 析构 name_ std::endl; } private: const char* name_; }; int main() { Trace t1(t1); { Trace t2(t2); } // t2 在这里析构 return 0; } // t1 在这里析构运行结果很直观构造顺序是 t1、t2析构顺序是 t2、t1。局部对象的析构顺序和构造顺序相反后构造的先析构。这个特性在很多代码中都有体现比如一个函数里先创建日志对象再创建数据库连接对象函数结束时会先断开数据库连接再关闭日志需要习惯这种方式。6. 从构造析构到现代 C这些语法你需要掌握6.1 default和 delete显式控制编译器生成C11 提供了两种显式控制特殊成员函数的方式。 default告诉编译器这个特殊成员函数就用默认实现 delete告诉编译器这个函数不存在调用即编译错误。它们的用处很实用。当你自定义了其他构造函数但又想让默认构造存在时写ClassName() default;最干净。当你想禁止拷贝时写class UniqueResource { public: UniqueResource(const UniqueResource) delete; UniqueResource operator(const UniqueResource) delete; };在 C11 之前禁拷贝的通用写法是把拷贝构造和拷贝赋值声明为私有且不实现相比之下 delete的意图明确得多报错信息也更清晰。6.2 委托构造函数和继承构造函数的区别前面提到过委托构造函数是一个构造函数调用同类的另一个构造函数。C11 同时引入了继承构造函数允许派生类直接继承基类的构造函数class Base { public: Base(int x) {} Base(double y, int z) {} }; class Derived : public Base { public: using Base::Base; // 继承 Base 的所有构造函数 };但这里要留意一个边界继承构造函数并不会继承基类的拷贝/移动构造那些仍然按默认规则生成。而且需要注意如果派生类自己定义了同签名的构造函数它会覆盖继承来的那个。6.3 构造析构相关的编译选项建议如果你刚配置好 VSCode 的 C/C 环境热词里也有人搜这个我建议在编译时打开这些警告能帮你提早发现构造析构相关的问题g -stdc17 -Wall -Wextra -Werror -Wreorder -Wnon-virtual-dtor main.cpp-Wreorder提示初始化列表顺序和成员声明顺序不一致。-Wnon-virtual-dtor提示基类的析构函数没有声明为 virtual。-Werror把警告当作错误强制你解决它们而不是拖延。写完构造和析构相关的类用这三个选项编译一遍能排除掉相当多常见的坑。6.4 一个综合练习写一个共享所有权的字符串类如果你想检验自己对构造函数、析构函数、拷贝构造、移动构造、拷贝赋值、移动赋值是否真的掌握我建议你尝试实现一个简单的SharedString类底层用共享计数来管理内存。这个练习覆盖了所有特殊成员函数比单纯默写语法定义有用得多。我这里提供一个简化版的骨架你可以自己动手补全class SharedString { public: SharedString() : data_(nullptr), count_(nullptr) {} explicit SharedString(const char* str); ~SharedString(); SharedString(const SharedString other); SharedString operator(const SharedString other); SharedString(SharedString other) noexcept; SharedString operator(SharedString other) noexcept; const char* c_str() const { return data_; } private: char* data_; int* count_; // 指向引用计数 };这个练习做完你会发现几个问题不是靠背能解决的拷贝构造里怎么增加引用计数移动构造里怎么把count_置空赋值运算里怎么能做到不泄漏也不 double-free析构函数里怎么判断何时真正释放内存。这些问题的思考和解决过程比读十遍语法书都有用。7. 几个调试构造析构问题时常用的工具思路7.1 打印构造析构时建议用__func__或行号辅助定位在调构造函数和析构函数的调用顺序时我见过很多人在构造函数里写死日志文本结果大量日志中很难定位到具体对象实例。一个更好的办法是把对象地址打印出来Trace(const char* name) : name_(name) { std::cout 构造 name_ 地址: this std::endl; }这样你可以在日志里看到同一个对象地址的构造和析构是否成对出现。如果出现了一个地址只构造没析构的基本就是泄漏了。7.2 利用 sanitizer 快速定位常见构造析构问题前面提过 AddressSanitizer我再补充一个经验用法。很多人写的类在单线程测试中一切正常一旦丢进多线程就崩这往往是对象在错误的时间被拷贝或析构。这时你可以用 ThreadSanitizerTSang -fsanitizethread -g -stdc17 test.cpp -o testTSan 能检测数据竞争帮你找到某个对象在 A 线程析构、同时 B 线程还在访问它的问题。这类问题单靠打日志是很难稳定复现的一旦借助工具会快很多。7.3 内存泄漏检测关于valgrind和 ASan 的取舍valgrind在检测内存泄漏方面非常经典但它有两个缺点一是运行速度慢到令人发指二是在某些新版 glibc 环境下配合得并不总是完美。Asan 是编译时插桩运行开销比 valgrind 小很多实践中我优先用 ASan。如果你在 Windows 上开发Visual Studio 的/fsanitizeaddress也支持 AddressSanitizer或者你可以借助 CRT 的泄漏检测函数_CrtDumpMemoryLeaks()。#ifdef _MSC_VER #define _CRTDBG_MAP_ALLOC #include crtdbg.h #endif int main() { #ifdef _MSC_VER _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF); #endif // 你的代码 }这套方案在 Windows 下挺好用但注意不要把它写进生产代码只放在测试入口即可。7.4 善用调试器观察对象布局和生命周期如果你用 VSCode 配好了 C/C 调试环境可以在构造函数和析构函数里分别打断点。然后单步执行观察对象的成员变量在构造的每一步是怎么变化的。这对理解构造函数初始化列表先执行、函数体后执行非常有帮助。特别是碰上const成员或引用成员时你能亲眼看到它们在函数体里赋值会直接编译报错而在初始化列表里才能工作。8. 我个人的一点体会写 C 这些年我最大的感受是构造函数和析构函数表面上是语法知识底子上是资源管理和生命周期思维。刚入门时你可能觉得它们就是两个特殊成员函数会写就行写过一定规模的项目之后你会开始关心拷贝和移动的语义、异常安全、容器行为、多线程下的生命周期问题。再往后你会发现现代 C 的智能指针、RAII、移动语义所有这些好用的东西底层都建立在构造析构这套机制上。如果你现在还在为什么时候该写析构函数、拷贝构造和移动构造到底写不写纠结我可以给你一个非常实用的默认策略优先使用标准库容器和智能指针来管理资源让它帮助你避免手动管理。如果你的类只是简单聚合数据不需要自定义析构那就用默认的。当你确实需要自定义析构时认真考虑五法则全都要不要参与。这套策略可能不是最优的但它能让你在绝大多数场景下不踩大坑。最后理解 C 和 C 的一个关键区别是C 让你手动管理生命周期C 给了构造和析构这两个逻辑上的挂载点让编译器替你在正确的时机执行你的逻辑。把这个思路想透你写出来的 C 代码风格会发生根本变化——从完成功能变成设计对象的完整生命周期。这种思维转变是初学者和资深开发者之间一个很明显的分水岭。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案