3、智能指针实战:shared_ptr、unique_ptr、weak_ptr的使用场景与陷阱

智能指针这东西,说白了就是C++给咱们这些做游戏服务器的开发者发的一根救命稻草。我早年做MMO服务器时,内存泄漏排查到凌晨三点是家常便饭。后来全面改用智能指针,嗯,至少凌晨两点就能收工了。

但别高兴太早。智能指针用不好,照样能给你整出循环引用、悬空指针、性能雪崩这些幺蛾子。今天我就把这三个家伙的底裤扒干净,说说它们到底该怎么用,又该避开哪些坑。

3.1 shared_ptr:共享所有权的双刃剑

shared_ptr 是我用得最多的智能指针。它的核心机制是引用计数——每多一个指向同一对象的 shared_ptr,计数加1;析构时减1,减到0就释放内存。

听起来很完美对吧?但我在项目中遇到过一个大坑:循环引用

循环引用陷阱:两个对象互相持有对方的 shared_ptr,导致引用计数永远降不到0,内存泄漏。

看个例子:

class Player {
    std::shared_ptr<Guild> guild_;
public:
    void joinGuild(const std::shared_ptr<Guild>& g) { guild_ = g; }
};

class Guild {
    std::shared_ptr<Player> leader_;
public:
    void setLeader(const std::shared_ptr<Player>& p) { leader_ = p; }
};

// 使用
auto player = std::make_shared<Player>();
auto guild = std::make_shared<Guild>();
player->joinGuild(guild);
guild->setLeader(player);
// 析构时,两个对象都释放不了!

为什么会这样?playerguild 互相牵着对方,引用计数都是2。外部 playerguild 变量销毁后,计数降到1,但永远不会到0。

我的建议:在游戏服务器中,父子关系、层级关系非常常见。我个人的习惯是:谁拥有谁,谁就持有 shared_ptr;被拥有的对象,用 weak_ptr 指回去

3.2 weak_ptr:打破循环的破冰船

weak_ptr 不增加引用计数。它像一个观察者,可以随时查看对象是否还活着,但不能直接操作对象。

weak_ptr 改写上面的例子:

class Guild {
    std::weak_ptr<Player> leader_;  // 改用 weak_ptr
public:
    void setLeader(const std::shared_ptr<Player>& p) { leader_ = p; }
    
    void notifyLeader() {
        if (auto leader = leader_.lock()) {  // 尝试提升为 shared_ptr
            leader->receiveNotification();
        } else {
            // 玩家已离线或销毁,安全处理
            log("Leader no longer exists");
        }
    }
};

这里有个关键操作:lock()。它会尝试将 weak_ptr 提升为 shared_ptr。如果原对象已被销毁,返回空指针。我在做战斗系统时,经常用这个模式来处理技能释放者和目标的关系——目标可能中途死亡,但技能逻辑不能崩溃。

使用场景总结
  • 缓存系统:缓存对象用 shared_ptr 持有,外部用 weak_ptr 引用,避免缓存导致对象无法释放
  • 观察者模式:被观察者持有观察者的 weak_ptr 列表
  • 反向引用:子对象指向父对象时,用 weak_ptr

3.3 unique_ptr:独占所有权的性能担当

unique_ptr 是我在游戏服务器中最推荐的智能指针。为什么?因为它零开销——和裸指针一样快,但自动管理生命周期。

你想想看,游戏服务器里什么最多?玩家对象、怪物对象、物品对象。这些对象通常有明确的拥有者:Scene 拥有所有 MonsterPlayer 拥有自己的 Inventory。这种场景用 unique_ptr 再合适不过。

class Scene {
    std::unordered_map<int, std::unique_ptr<Monster>> monsters_;
public:
    void spawnMonster(int id) {
        auto monster = std::make_unique<Monster>(id);
        monsters_[id] = std::move(monster);  // 转移所有权
    }
    
    // 注意:不能拷贝,只能移动
    std::unique_ptr<Monster> removeMonster(int id) {
        auto it = monsters_.find(id);
        if (it != monsters_.end()) {
            auto monster = std::move(it->second);
            monsters_.erase(it);
            return monster;  // 所有权转移给调用者
        }
        return nullptr;
    }
};

这里有个细节:unique_ptr 只能移动,不能拷贝。这意味着你必须在代码中显式表达所有权的转移。我个人觉得这其实是好事——让代码的意图更清晰。

我曾经踩过的坑:把 unique_ptr 传进函数时,忘了用 std::move 或者传引用。结果编译报错,排查了半天才发现是拷贝构造被删除了。后来我养成了习惯:如果函数不需要接管所有权,就传裸指针或引用
// 好的做法:不需要所有权,传裸指针
void applyDamage(Monster* monster, int damage) {
    if (monster) monster->takeDamage(damage);
}

// 调用
auto monster = std::make_unique<Monster>(42);
applyDamage(monster.get(), 100);  // 用 get() 获取裸指针

3.4 性能对比与选择策略

在游戏服务器里,性能就是生命。我专门做过压测,对比三种智能指针的开销:

指针类型 构造/析构开销 拷贝开销 线程安全 推荐场景
unique_ptr 零开销(同裸指针) 禁止拷贝 不涉及 明确所有权的对象
shared_ptr 较高(原子操作计数) 高(原子递增) 引用计数线程安全 共享生命周期
weak_ptr 中等 中等 lock() 操作原子 打破循环引用

看到没?shared_ptr 的拷贝涉及原子操作,在高并发场景下会成为性能瓶颈。我见过一个项目,每秒百万次 shared_ptr 拷贝,直接把CPU干到100%。

我的选择原则
  1. 能用 unique_ptr 就别用 shared_ptr
  2. 必须共享时,优先考虑 weak_ptr + 短期提升
  3. 避免在热点路径(如每帧更新)中拷贝 shared_ptr
  4. 使用 make_sharedmake_unique,而不是直接 new

3.5 实战避坑指南

最后,分享几个我血泪换来的教训:

陷阱1:shared_ptr 的线程安全问题
shared_ptr 的引用计数是线程安全的,但指向的对象不是。多线程读写同一个 shared_ptr 指向的对象,该加锁还得加锁。
陷阱2:enable_shared_from_this 的正确用法
如果你需要在类内部获取指向自己的 shared_ptr,必须继承 std::enable_shared_from_this,然后用 shared_from_this()。千万别自己从 this 构造 shared_ptr,那会搞出两个独立的引用计数。
class Monster : public std::enable_shared_from_this<Monster> {
public:
    void spawnMinion() {
        // 正确:获取指向自己的 shared_ptr
        auto self = shared_from_this();
        world_->addMinion(self);
    }
};
陷阱3:自定义删除器
有时候你需要用智能指针管理非堆内存(比如内存池分配的对象)。这时可以传入自定义删除器。但要注意:make_shared 不支持自定义删除器,得用 shared_ptr 的构造函数。
// 内存池分配的对象,用自定义删除器归还
auto deleter = [&pool](Monster* p) {
    pool.deallocate(p);
};
std::shared_ptr<Monster> monster(
    pool.allocate(), 
    deleter
);

嗯,智能指针这东西,用好了是神器,用砸了是灾难。我建议你在项目初期就定好规范:什么场景用哪种指针,团队统一执行。别等到线上出问题了再来补坑,那代价可就大了。