3、智能指针实战:shared_ptr、unique_ptr、weak_ptr的使用场景与陷阱
智能指针这东西,说白了就是C++给咱们这些做游戏服务器的开发者发的一根救命稻草。我早年做MMO服务器时,内存泄漏排查到凌晨三点是家常便饭。后来全面改用智能指针,嗯,至少凌晨两点就能收工了。
但别高兴太早。智能指针用不好,照样能给你整出循环引用、悬空指针、性能雪崩这些幺蛾子。今天我就把这三个家伙的底裤扒干净,说说它们到底该怎么用,又该避开哪些坑。
3.1 shared_ptr:共享所有权的双刃剑
shared_ptr 是我用得最多的智能指针。它的核心机制是引用计数——每多一个指向同一对象的 shared_ptr,计数加1;析构时减1,减到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);
// 析构时,两个对象都释放不了!
为什么会这样?player 和 guild 互相牵着对方,引用计数都是2。外部 player 和 guild 变量销毁后,计数降到1,但永远不会到0。
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 拥有所有 Monster,Player 拥有自己的 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 只能移动,不能拷贝。这意味着你必须在代码中显式表达所有权的转移。我个人觉得这其实是好事——让代码的意图更清晰。
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%。
- 能用 unique_ptr 就别用 shared_ptr
- 必须共享时,优先考虑 weak_ptr + 短期提升
- 避免在热点路径(如每帧更新)中拷贝 shared_ptr
- 使用
make_shared和make_unique,而不是直接 new
3.5 实战避坑指南
最后,分享几个我血泪换来的教训:
shared_ptr 的引用计数是线程安全的,但指向的对象不是。多线程读写同一个 shared_ptr 指向的对象,该加锁还得加锁。
如果你需要在类内部获取指向自己的 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);
}
};
有时候你需要用智能指针管理非堆内存(比如内存池分配的对象)。这时可以传入自定义删除器。但要注意:
make_shared 不支持自定义删除器,得用 shared_ptr 的构造函数。
// 内存池分配的对象,用自定义删除器归还
auto deleter = [&pool](Monster* p) {
pool.deallocate(p);
};
std::shared_ptr<Monster> monster(
pool.allocate(),
deleter
);
嗯,智能指针这东西,用好了是神器,用砸了是灾难。我建议你在项目初期就定好规范:什么场景用哪种指针,团队统一执行。别等到线上出问题了再来补坑,那代价可就大了。