17. 智能指针与裸指针混用:危险场景、最佳实践

说实话,这个话题我特别想聊。为什么呢?因为我在代码评审里,见过太多因为混用智能指针和裸指针翻车的案例了。我自己早年也踩过坑,有一次线上服务半夜 OOM,查到最后就是 shared_ptr 和裸指针混用导致的循环引用——嗯,那晚的泡面味道我现在还记得。

你可能会问:既然智能指针这么好,为什么还要跟裸指针打交道?

答案很现实:现实项目里,你不可能完全避开裸指针。老代码、第三方库、C 接口、性能敏感路径……这些地方裸指针依然活跃。关键不是「用不用」,而是「怎么安全地混用」。

17.1 混用的三大危险场景

我总结了一下,最常见的翻车现场就三种。咱们一个一个看。

场景一:裸指针管理权不清晰

说白了就是——谁负责释放?

典型错误:shared_ptr 内部的裸指针取出来,然后手动 delete。
// 危险!千万别这么写
auto sp = std::make_shared<Widget>(42);
Widget* raw = sp.get();   // 拿到了裸指针
delete raw;               // 崩溃!shared_ptr 会二次释放

我在项目中遇到过一位同事,他为了「性能优化」,把 shared_ptr 里的裸指针取出来传给一个 C 函数,那个 C 函数里居然调了 free()。结果就是 double free,程序直接挂掉。调试了一下午才定位到问题。

核心原则:谁分配,谁释放。智能指针分配的对象,永远不要手动释放。

场景二:裸指针悬空

这个更隐蔽。你拿着一个裸指针,但背后的 shared_ptr 已经被销毁了。

std::shared_ptr<Widget> sp = std::make_shared<Widget>(100);
Widget* raw = sp.get();
sp.reset();               // 对象被释放了
raw->doSomething();       // 未定义行为!悬空指针

为什么会这样?因为 shared_ptr 的引用计数归零时,对象就析构了。你手里的裸指针就成了「野指针」。你想想看,这种 bug 在线上多难查——它不一定马上崩溃,可能只是偶尔数据错乱。

场景三:循环引用 + 裸指针介入

这个稍微复杂一点。有时候为了打破循环引用,有人会手动用裸指针存一个「弱引用」。

class Child {
public:
    Parent* parent_;  // 裸指针,想打破循环引用
    // ...
};

class Parent {
public:
    std::shared_ptr<Child> child_;
    // ...
};

看起来没问题?但隐患在于:如果 parent_ 指向的对象已经被销毁了,你拿着这个裸指针去访问,就是悬空。正确的做法是用 std::weak_ptr,它至少能告诉你对象还在不在。

17.2 最佳实践:安全混用的五个原则

好,问题说完了。咱们聊聊怎么解决。我个人习惯把这五条贴在工位上,每次写代码前扫一眼。

原则 说明 推荐做法
原则一 所有权明确 每个对象只有一个「所有者」,要么智能指针,要么裸指针 + 手动管理
原则二 裸指针只作观察者 裸指针只用来「看」,不负责「删」
原则三 生命周期短的用裸指针 函数参数、局部变量,裸指针没问题
原则四 跨模块边界用智能指针 不同模块、不同线程之间传递,用 shared_ptr
原则五 需要弱引用用 weak_ptr 不要自己用裸指针模拟弱引用

17.3 具体场景下的代码模板

光说原则太抽象。我直接给你几个我常用的代码模板,你拿去就能用。

模板一:函数参数传递

如果函数只是临时用一下对象,不保存,用裸指针或引用。

// 推荐:只观察,不持有
void ProcessWidget(Widget* w) {
    if (w) {
        w->Update();
    }
}

// 调用方
auto sp = std::make_shared<Widget>(42);
ProcessWidget(sp.get());  // 安全!函数返回后裸指针就失效了

模板二:需要保存引用时

如果函数要保存对象的引用(比如注册回调),必须用 shared_ptrweak_ptr

class Observer {
public:
    void Register(std::shared_ptr<Widget> w) {
        widget_ = w;  // 增加引用计数,安全
    }
private:
    std::shared_ptr<Widget> widget_;
};

模板三:与 C 接口交互

这是最头疼的场景。C 接口通常只认裸指针。我的做法是:用智能指针管理生命周期,只在调用 C 接口的瞬间传裸指针。

// C 接口声明
void c_api_process(Widget* w);

// C++ 封装
void SafeProcess(std::shared_ptr<Widget> sp) {
    // 确保调用期间对象不会被销毁
    c_api_process(sp.get());  // 安全!sp 还在作用域内
}
我的小技巧:如果 C 接口会保存你的指针(比如注册回调),那就麻烦了。这时候我一般会写一个包装层,用 shared_ptr 管理,然后在 C 回调里用 weak_ptr 检查对象是否存活。

17.4 核心知识体系

下面这张图,是我自己总结的「智能指针与裸指针混用决策树」。每次拿不准的时候,我就照着这个走一遍。

智能指针 vs 裸指针:决策树 需要管理对象 生命周期谁控制? 自己控制 用 unique_ptr 所有权唯一,性能最优 共享控制 用 shared_ptr 注意循环引用! 裸指针只用于「观察」场景 函数参数、临时访问、C接口调用

17.5 避坑指南

最后,我把自己这些年踩过的坑,浓缩成几条避坑指南。你记下来,能省不少调试时间。

避坑一:我曾经在回调函数里保存了一个裸指针,结果对象被另一个线程释放了。从那以后,我定了一条铁律——跨线程传递对象,必须用 shared_ptr

避坑二:我曾经为了「性能」在热路径上用裸指针代替 shared_ptr,结果引用计数没处理好,内存泄漏了。后来我学乖了:先保证正确性,再优化性能。性能瓶颈用 profiler 说话,别靠猜。

避坑三:我见过最离谱的代码,是在一个类里混用了 shared_ptrunique_ptr 指向同一个对象。结果两个智能指针各自为政,double free 加内存泄漏一起来。记住:一个对象只能有一种所有权模型

嗯,关于智能指针和裸指针的混用,核心就这些。说白了就是一句话:所有权要清晰,裸指针只观察,生命周期短的用裸指针,跨模块跨线程用智能指针。你照着这个思路写代码,基本不会出大问题。

我个人习惯在代码里加注释,标注每个裸指针的「生命周期约束」。比如 // 注意:此指针仅在 Process() 函数内有效。这样半年后自己回来看,也不会懵。


公众号:蓝海资料掘金营,微信deep3321