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
  • 永远不要用裸指针表达所有权

三种模式的知识图谱

下面这张图总结了三种模式中智能指针的角色和关系:

智能指针在三种设计模式中的实践 Pimpl 惯用法 unique_ptr<Impl> 隐藏实现细节 减少编译依赖 独占所有权 工厂模式 unique_ptr<Product> 返回专属对象 调用者拥有所有权 可转为 shared_ptr 观察者模式 weak_ptr<Observer> 打破循环引用 观察但不拥有 lock() 安全访问 核心原则总结 • unique_ptr:表达独占所有权,零开销抽象 • shared_ptr:表达共享所有权,注意循环引用 • weak_ptr:表达观察关系,配合 shared_ptr 打破循环

避坑指南

最后,分享几个我踩过的坑:

  • 不要在工厂里返回 shared_ptr 除非你真的需要共享。 我见过一个项目,所有工厂都返回 shared_ptr,结果对象生命周期完全失控,内存占用居高不下。
  • Pimpl 中记得声明移动操作。 如果不声明,unique_ptr 的移动语义虽然还在,但你的类会变成只可移动不可复制——这往往不是你想要的行为。
  • 观察者模式中,记得清理失效的 weak_ptr 虽然 lock() 能安全判断,但列表里堆积大量空指针会影响性能。我一般会在 notify() 里顺手清理。

这三种模式,说白了就是「谁拥有、谁观察、谁隐藏」的问题。智能指针只是工具,真正重要的是想清楚对象的所有权归属。


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