27. 智能指针在大型项目中的实践:Pimpl 惯用法、工厂模式、观察者模式
说实话,很多C++开发者学了智能指针之后,最大的困惑不是「怎么用」,而是「在哪儿用」。
我见过不少项目,头文件里塞满了裸指针,编译一次要等十分钟。也见过有人把shared_ptr当传家宝一样到处复制,最后内存没泄漏,性能倒先崩了。
这一章,我就结合自己踩过的坑,聊聊智能指针在三种经典场景下的实战套路。
Pimpl 惯用法:用 unique_ptr 藏起实现细节
Pimpl(Pointer to Implementation)说白了就是「接口与实现分离」。你想想看,一个类的头文件如果包含了太多内部依赖,哪怕只改一行私有成员,所有包含这个头文件的 .cpp 都得重新编译。大型项目里,这种连锁编译能让人崩溃。
我习惯的做法是这样的:
// widget.h
#include <memory>
class Widget {
public:
Widget();
~Widget();
Widget(Widget&&) noexcept; // 移动构造
Widget& operator=(Widget&&) noexcept; // 移动赋值
void doSomething();
private:
struct Impl;
std::unique_ptr<Impl> pImpl_;
};
// widget.cpp
#include "widget.h"
#include <string>
#include <iostream>
struct Widget::Impl {
std::string name_;
int id_ = 0;
// 这里可以放任何复杂的依赖
};
Widget::Widget() : pImpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default; // 必须在这里定义析构函数
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::doSomething() {
std::cout << pImpl_->name_ << std::endl;
}
注意:析构函数必须在 .cpp 中定义,因为 unique_ptr 在析构时需要看到 Impl 的完整定义。如果你在头文件里写 ~Widget() = default;,编译器会报错。
我曾经在一个模块里用 Pimpl 重构了十几个类,编译时间从 45 秒降到了 12 秒。效果立竿见影。
工厂模式:用 unique_ptr 返回「专属」对象
工厂模式的核心是「创建对象但不暴露具体类型」。我推荐用 unique_ptr 作为工厂的返回值。为什么?因为语义清晰——调用者拿到对象后,它就是你的了,不需要纠结所有权。
class Product {
public:
virtual ~Product() = default;
virtual void use() = 0;
};
class ConcreteProductA : public Product {
public:
void use() override {
// 具体实现
}
};
class ConcreteProductB : public Product {
public:
void use() override {
// 具体实现
}
};
std::unique_ptr<Product> createProduct(const std::string& type) {
if (type == "A") {
return std::make_unique<ConcreteProductA>();
} else if (type == "B") {
return std::make_unique<ConcreteProductB>();
}
return nullptr;
}
小技巧:如果调用者需要共享对象,可以在工厂返回 unique_ptr 后,由调用者决定是否转为 shared_ptr。反过来就不行——从 shared_ptr 降级到 unique_ptr 是危险的。
观察者模式:用 weak_ptr 打破循环引用
观察者模式里,主题(Subject)通常持有观察者(Observer)的列表。如果双方都用 shared_ptr 互相引用,循环引用就来了——你析构我,我等你的引用计数归零,结果谁也跑不掉。
解决方案很简单:主题用 weak_ptr 持有观察者。
class Observer {
public:
virtual ~Observer() = default;
virtual void update(const std::string& msg) = 0;
};
class Subject {
public:
void attach(std::shared_ptr<Observer> obs) {
observers_.push_back(obs);
}
void notify(const std::string& msg) {
for (auto& weakObs : observers_) {
if (auto obs = weakObs.lock()) {
obs->update(msg);
} else {
// 观察者已销毁,可以清理掉这个空 weak_ptr
}
}
}
private:
std::vector<std::weak_ptr<Observer>> observers_;
};
这里的关键是 weak_ptr::lock()。它会返回一个临时的 shared_ptr,如果观察者还活着,就能正常调用;如果已经销毁,返回空指针。嗯,这个设计很优雅。
核心原则:
- 所有权明确时,用
unique_ptr - 需要共享所有权时,用
shared_ptr - 需要观察但不拥有时,用
weak_ptr - 永远不要用裸指针表达所有权
三种模式的知识图谱
下面这张图总结了三种模式中智能指针的角色和关系:
避坑指南
最后,分享几个我踩过的坑:
- 不要在工厂里返回
shared_ptr除非你真的需要共享。 我见过一个项目,所有工厂都返回shared_ptr,结果对象生命周期完全失控,内存占用居高不下。 - Pimpl 中记得声明移动操作。 如果不声明,
unique_ptr的移动语义虽然还在,但你的类会变成只可移动不可复制——这往往不是你想要的行为。 - 观察者模式中,记得清理失效的
weak_ptr。 虽然lock()能安全判断,但列表里堆积大量空指针会影响性能。我一般会在notify()里顺手清理。
这三种模式,说白了就是「谁拥有、谁观察、谁隐藏」的问题。智能指针只是工具,真正重要的是想清楚对象的所有权归属。
公众号:蓝海资料掘金营,微信deep3321