导读:本期聚焦于画家创作的《如何避免在C++中使用裸异常?裸new的正确替代方案详解》,敬请观看详情。裸new和裸delete一直是C++代码中事故高发的地方:内存泄漏、双重释放、异常导致资源未回收等问题屡见不鲜。当一个构造函数在new表达式中抛出异常时,如果不理解求值顺序,很容易误以为内存已经安全,实际上可能埋下隐患。这篇文章将分析裸new在异常场景下的风险根源,讲解std::make_unique和std::make_shared为什么是更安全的选择,对比它们在异常安全、内存分配次数、控制块布局上的差异,并进一步讨论RAII原则、nothrow new以及noexcept在异常安全设计中的作用,帮助你写出更健壮的现代C++代码。

在C++中,newdelete是最基础的内存管理工具,但直接使用它们往往意味着你要手动承担资源释放的全部责任。一旦代码路径中存在异常抛出,泄漏几乎是必然结果。所谓裸异常场景下的裸new问题,指的是在异常可能被抛出的环境中依然使用原始的new表达式获取资源,而没有借助任何自动化管理机制。这篇文章将从原理入手,详细分析问题的成因,并给出多种实践中可行的替代方案。

如何避免在C++中使用裸异常?裸new的正确替代方案详解

裸new在异常场景下的风险根源

先看一段典型的错误代码。假设我们有一个类Widget,它接受一个文件名并在构造时打开文件,如果文件不存在就抛出异常:

class Widget {
public:
    explicit Widget(const std::string& path) {
        file_ = std::fopen(path.c_str(), "r");
        if (!file_) {
            throw std::runtime_error("cannot open file");
        }
    }
    ~Widget() { if (file_) std::fclose(file_); }
private:
    std::FILE* file_;
};

void bad() {
    Widget* w = new Widget("config.txt");
    process(*w);      // 如果这里抛出异常
    delete w;         // 这一行永远不会执行,内存泄漏
}

上述代码的问题很明显:如果process函数抛出异常,栈会立即展开,delete w这行代码被跳过,分配的内存无法释放。更隐蔽的情况是,如果一个函数中先new了两个对象,在构造第二个时抛出异常,第一个对象同样会泄漏:

void worse() {
    Widget* a = new Widget("a.txt");
    Widget* b = new Widget("b.txt"); // 假设这里抛出异常
    // a 已经泄漏,没有任何机制会释放它
    delete b;
    delete a;
}

这里的核心矛盾在于:new表达式把内存分配和对象构造耦合在一起,而C++编译器不会为你自动插入释放逻辑。资源的生命周期完全依赖手工配对调用,一旦控制流被异常打断,配对关系就遭到破坏。这就是为什么编码规范(如C++ Core Guidelines中的R.11条款)明确建议:避免显式调用new和delete,把资源交给句柄类管理。

用make_unique和make_shared替代裸new

现代C++提供了两个工厂函数来解决上述问题:std::make_unique(C++14起)和std::make_shared(C++11起)。它们将分配和构造封装在一条语句内,并把结果直接交给智能指针管理,从语言层面保证了异常安全:

#include <memory>

void good() {
    auto a = std::make_unique<Widget>("a.txt");
    auto b = std::make_unique<Widget>("b.txt"); // 即使抛出异常
    process(*a);                                 // a 也会被自动释放
    // 无需任何 delete,作用域结束时自动回收
}

这两者的安全性来源于编译器保证:在函数调用中,一旦某个实参求值完成,而在另一个实参求值期间抛出异常,已完成的求值结果不会产生副作用泄漏(对于工厂函数来说,分配的内存会被内部清理)。而手写的foo(std::unique_ptr<T>(new T), bar())形式在C++17之前没有这种保证,可能因为求值顺序交错而泄漏,这是C++面试中的经典考点。

make_shared相比手写std::shared_ptr<T>(new T)还有一个性能优势:它只进行一次堆分配,把对象本体和控制块(引用计数、弱计数)放在同一块连续内存中;而后者需要两次独立的堆分配。代价是make_shared分配的内存在所有weak_ptr都销毁之前无法整体归还,如果对象很大且存在长生命周期的weak_ptr,需要权衡使用。下表总结了三种方式的对比:

方式异常安全堆分配次数是否暴露裸指针
裸new + delete1
shared_ptr(new T)基本安全2短暂暴露
make_shared安全1

需要注意的限制:如果类的构造函数是私有的(比如工厂模式、单例模式),make_unique和make_shared无法直接调用,此时需要写一个辅助的透传函数,或者退回到std::unique_ptr<T>(new T)的形式并确保该语句独立成行。

RAII原则与noexcept的设计配合

单纯替换new只是治标,更深层的解决方案是遵循RAII(Resource Acquisition Is Initialization)原则:资源的获取和释放绑定到对象的构造与析构,由栈展开机制自动触发清理。标准库中的std::vectorstd::lock_guardstd::fstream都是RAII的典型实现。当你设计自己的类时,也应该让每个类只管理一种资源,把多资源类拆分为单资源成员的组合,这样即使构造中途抛出异常,已构造完成的成员也会被正确析构。

另一个容易被忽视的点是noexcept的正确使用。移动构造函数标记为noexcept后,std::vector在扩容时才会选择移动而非复制元素,这既是性能问题也是异常安全问题:如果移动过程中抛出异常,vector将处于无法回滚的状态。对于确实不能抛出异常的函数,明确加上noexcept既能表达意图,也能让编译器做更好的优化。

最后,如果你的代码运行在禁止异常的环境(如部分嵌入式项目或某些游戏引擎),可以考虑nothrow形式的new:new (std::nothrow) T在分配失败时返回nullptr而不是抛出std::bad_alloc,随后通过判空处理错误路径。但要注意,这只覆盖分配失败这一种情况,对象构造函数中的异常依然会正常抛出,所以nothrow new只是特定场景下的补充手段,不能替代RAII式的资源管理。

总结一下实践建议:优先使用make_unique和make_shared创建动态对象;容器代替手工数组;遵循RAII封装资源;对不抛异常的接口明确标注noexcept;把裸new的出现当作一次代码评审的信号,审视是否缺少了资源管理句柄。养成这些习惯后,异常导致的内存泄漏问题会从根本上消失。

C++异常安全make_unique智能指针修改时间:2026-09-07 15:38:44

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260907/52291.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。