【c++面向对象编程】第35篇:构造函数与异常:如何避免资源泄露?
目录
一、构造函数可以抛异常吗?
二、构造函数抛异常时,哪些资源会自动释放?
三、裸指针的陷阱:资源泄露
四、解决方案1:用智能指针管理动态资源
五、解决方案2:函数级 try-catch
六、完整例子:对比三种方案
七、构造函数异常与继承
八、最佳实践总结
九、这一篇的收获
一、构造函数可以抛异常吗?
可以,而且有时候必须抛异常。
构造函数没有返回值,无法像普通函数那样返回错误码。如果构造过程中发生错误(比如分配内存失败、参数无效),唯一的选择就是抛出异常。
cpp
class Buffer { private: int* data; size_t size; public: Buffer(size_t n) : size(n) { if (n == 0) { throw std::invalid_argument("大小不能为0"); } data = new int[n]; // 可能抛出 bad_alloc } ~Buffer() { delete[] data; } };二、构造函数抛异常时,哪些资源会自动释放?
C++ 保证:已完成构造的成员对象会自动析构。
cpp
#include <iostream> #include <stdexcept> using namespace std; class Member { string name; public: Member(const string& n) : name(n) { cout << "Member 构造: " << name << endl; } ~Member() { cout << "Member 析构: " << name << endl; } }; class Container { Member m1; Member m2; Member m3; public: Container() : m1("m1"), m2("m2"), m3("m3") { cout << "Container 构造函数体开始" << endl; throw runtime_error("构造失败"); cout << "Container 构造函数体结束" << endl; } ~Container() { cout << "Container 析构" << endl; } }; int main() { try { Container c; } catch (const exception& e) { cout << "捕获异常: " << e.what() << endl; } return 0; }输出:
text
Member 构造: m1 Member 构造: m2 Member 构造: m3 Container 构造函数体开始 Member 析构: m3 Member 析构: m2 Member 析构: m1 捕获异常: 构造失败
关键观察:
先构造成员对象(
m1→m2→m3)构造函数体抛异常
成员对象按构造逆序自动析构(
m3→m2→m1)Container的析构函数不会被调用(因为对象本身未完成构造)
三、裸指针的陷阱:资源泄露
上面的规则有一个重要的例外:裸指针指向的动态内存不会自动释放。
cpp
class Leaky { private: int* p1; int* p2; public: Leaky() { p1 = new int[1000]; // 第一步 p2 = new int[1000]; // 第二步:如果这里抛出异常 } ~Leaky() { delete[] p1; delete[] p2; } };如果new int[1000]抛出bad_alloc:
p1已经指向一块内存异常被抛出,
Leaky对象构造失败~Leaky()不会被调用(因为对象未完成构造)p1指向的内存永远不会被释放 →内存泄露
这就是构造函数异常中最隐蔽的内存泄露来源。
四、解决方案1:用智能指针管理动态资源
cpp
#include <memory> using namespace std; class Safe { private: unique_ptr<int[]> p1; unique_ptr<int[]> p2; public: Safe(size_t n1, size_t n2) : p1(make_unique<int[]>(n1)), p2(make_unique<int[]>(n2)) { // 即使 p2 的构造抛异常,p1 也会被 unique_ptr 自动释放 // 因为 p1 作为成员对象,已经完成构造,会被自动析构 } // 不需要手动写析构函数! };原理:unique_ptr是成员对象,C++ 保证已构造的成员对象会被自动析构。即使构造函数体还没执行,初始化列表中的成员构造失败也会正确清理。
五、解决方案2:函数级 try-catch
如果必须使用裸指针,可以在构造函数中使用function-try-block:
cpp
class Safe { private: int* p1; int* p2; public: Safe(size_t n1, size_t n2) try : p1(new int[n1]), p2(new int[n2]) { // 构造函数体 } catch (...) { // 注意:这里必须清理已分配的资源 delete[] p1; // p2 分配失败时,p1 已被分配,需要手动清理 delete[] p2; throw; // 重新抛出异常 } ~Safe() { delete[] p1; delete[] p2; } };但这种写法比智能指针繁琐且易错,不推荐。
六、完整例子:对比三种方案
cpp
#include <iostream> #include <memory> #include <stdexcept> using namespace std; // ========== 方案1:裸指针(危险)========== class LeakyPointer { int* big1; int* big2; public: LeakyPointer() { big1 = new int[1000000]; cout << "已分配 big1" << endl; // 模拟第二个分配失败 throw bad_alloc(); big2 = new int[1000000]; } ~LeakyPointer() { delete[] big1; delete[] big2; cout << "LeakyPointer 析构" << endl; } }; // ========== 方案2:智能指针(安全)========== class SafePointer { unique_ptr<int[]> big1; unique_ptr<int[]> big2; public: SafePointer() : big1(make_unique<int[]>(1000000)), big2(make_unique<int[]>(1000000)) { // 如果这里抛异常 cout << "SafePointer 构造完成" << endl; } // 不需要析构函数 }; // ========== 方案3:成员对象管理(安全)========== class BigArray { unique_ptr<int[]> data; public: BigArray(size_t n) : data(make_unique<int[]>(n)) { cout << "BigArray 构造 " << n << " 个元素" << endl; } }; class Composite { BigArray a1; BigArray a2; public: Composite() : a1(1000000), a2(1000000) { cout << "Composite 构造完成" << endl; } // 不需要析构函数 }; int main() { cout << "=== 测试智能指针方案(安全)===" << endl; try { SafePointer sp; } catch (const bad_alloc& e) { cout << "捕获异常: " << e.what() << endl; } cout << "\n=== 测试成员对象方案(安全)===" << endl; try { Composite c; } catch (const bad_alloc& e) { cout << "捕获异常: " << e.what() << endl; } cout << "\n=== 测试裸指针方案(危险)===" << endl; try { LeakyPointer lp; // 内存泄露! } catch (const bad_alloc& e) { cout << "捕获异常: " << e.what() << endl; cout << "注意:big1 的内存已经泄露" << endl; } return 0; }输出(示意):
text
=== 测试智能指针方案(安全)=== SafePointer 构造完成 === 测试成员对象方案(安全)=== BigArray 构造 1000000 个元素 BigArray 构造 1000000 个元素 Composite 构造完成 === 测试裸指针方案(危险)=== 已分配 big1 捕获异常: std::bad_alloc 注意:big1 的内存已经泄露
七、构造函数异常与继承
派生类构造函数抛异常时,基类子对象已经被构造,会被自动析构。
cpp
class Base { int* data; public: Base() : data(new int[1000]) { cout << "Base 构造" << endl; } ~Base() { delete[] data; cout << "Base 析构" << endl; } }; class Derived : public Base { int* more_data; public: Derived() : Base(), more_data(new int[1000]) { cout << "Derived 构造" << endl; throw runtime_error("构造失败"); } ~Derived() { delete[] more_data; cout << "Derived 析构" << endl; } }; int main() { try { Derived d; } catch (...) { cout << "捕获异常" << endl; } return 0; }输出:
text
Base 构造 Base 析构 捕获异常
Derived的析构函数没有被调用(因为对象未完成构造),但Base子对象已经被析构,data被正确释放。
八、最佳实践总结
| 规则 | 说明 |
|---|---|
| 用智能指针代替裸指针 | unique_ptr、shared_ptr自动管理资源 |
| 遵循 RAII | 资源应在构造函数中获取,析构函数中释放 |
| 使用成员对象管理资源 | 让vector、string、智能指针等 RAII 类持有资源 |
| 避免在构造函数体中分配资源 | 优先在初始化列表中分配 |
| 如果必须用裸指针 | 使用 function-try-block 并手动清理 |
| 不要在构造函数中抛异常后留下裸指针资源 | 这是内存泄露的常见来源 |
九、这一篇的收获
你现在应该理解:
构造函数可以抛异常,这是报告构造失败的唯一方式
已完成构造的成员对象会自动析构(包括智能指针)
裸指针指向的动态内存不会自动释放,即使它是成员变量
用智能指针(
unique_ptr、shared_ptr)代替裸指针,自动解决这个问题派生类构造失败时,基类子对象会被正确析构
💡 小作业:写一个
FileHandler类,构造函数接收文件名并打开文件(用fopen)。如果文件打开失败,抛出异常。用unique_ptr<FILE, CustomDeleter>管理文件句柄,确保构造失败时不会泄露。写测试代码验证异常安全。
下一篇预告:第36篇《析构函数应永远不抛出异常——原因与最佳实践》——为什么析构函数抛异常是“死刑”?栈展开过程中抛异常会导致terminate。如何写“不抛异常”的析构函数?catch 所有异常并记录日志是最佳实践。
