Skip to content

结构型模式(Modern C++ 写法) ​

结构型模式回答的是「对象怎么组装起来」。它们关心的是把大块拆小、把小块拼大、让变化点隔离在边界上。在 C++ 里,这些模式几乎都能用几样现代工具重写:std::unique_ptr 表达独占所有权、std::shared_ptr 表达共享、std::function 表达可替换的行为、std::variant 表达封闭的类型集合、std::span/std::string_view 表达非拥有的视图。

贯穿全篇的判断:结构型模式的现代写法,核心就是"用类型表达所有权与生命周期"。一旦所有权用智能指针写清楚了,Pimpl、组合、装饰器、享元的实现都会自然浮现。

1. Pimpl(编译防火墙) ​

一句话:头文件只留接口和前向声明的 Impl 指针,实现细节全塞进 .cpp,切断重编译链。

老写法 → 现代写法

  • 老:私有成员、第三方头文件全写在头文件里,改一行私有字段就让所有 #include 它的地方重编译(大项目"改一行编十分钟")。
  • 现代:头文件里放 std::unique_ptr<Impl>,Impl 前向声明;析构函数必须在 .cpp 里定义(那里才能看到 Impl 完整类型)。附带的三个好处:编译防火墙 + ABI 稳定 + 强异常安全(成员改在这里,二进制接口不变)。
小例子
cpp
// widget.h —— 使用者只看到接口
#include <memory>
class Widget {
public:
    Widget();
    ~Widget();                        // 声明,定义放 .cpp
    Widget(Widget&&) noexcept;        // 移动语义:unique_ptr 可移动
    Widget& operator=(Widget&&) noexcept;
    void draw();
private:
    struct Impl;                      // 前向声明,隐藏细节
    std::unique_ptr<Impl> pimpl_;
};
cpp
// widget.cpp —— 实现细节与第三方头都在这里
#include "widget.h"
#include <string>
#include <vector>
struct Widget::Impl {                 // 完整类型只对本文件可见
    std::vector<std::string> cache;
    int hidden_state = 0;
};
Widget::Widget() : pimpl_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;          // 此处 Impl 完整,才能默认析构 unique_ptr
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::draw() { /* 使用 pimpl_ */ }

改 Impl 里的字段不会让使用者重编译——因为它压根没看到 Impl 的定义。

回链:工程与演进、ABI 与符号

面试怎么问:"Pimpl 的代价和维护要点是什么?" → 代价是多一次堆分配和一次指针间接访问;维护要点:析构/移动必须定义在 .cpp(否则 unique_ptr 遇到不完整类型会编译报错),并按 Rule of Five 一并处理拷贝/移动(通常 Pimpl 类禁拷贝、支持移动)。

2. 组合 Composite(树) ​

一句话:用 unique_ptr 表达"父拥有子"的所有权,用 std::variant 表达"叶子或分支"这两类节点。

老写法 → 现代写法

  • 老:Node* children[] 裸指针数组 + enum type 判类型,谁拥有谁、谁释放谁全靠人脑记;树删除容易漏层次。
  • 现代:子节点用 std::vector<std::unique_ptr<Node>>(父唯一拥有,销毁自动递归);节点类型用 std::variant<Leaf, Branch> 表达,配合 std::get_if/std::visit,无需手工 enum 与 switch。
小例子
cpp
#include <memory>
#include <variant>
#include <vector>

struct Node;                                   // 前向声明
struct Leaf   { int value; };
struct Branch { std::vector<std::unique_ptr<Node>> children; };

struct Node {
    std::variant<Leaf, Branch> kind;           // 要么是叶子,要么是分支
    int total() const {
        if (auto* l = std::get_if<Leaf>(&kind)) return l->value;
        int sum = 0;
        for (auto& c : std::get<Branch>(kind).children) sum += c->total();
        return sum;
    }
};

// auto root = std::make_unique<Node>(Node{Branch{}});
// std::get<Branch>(root->kind).children.push_back(
//     std::make_unique<Node>(Node{Leaf{42}}));
// assert(root->total() == 42);

所有权与节点种类都被类型系统写死:删根节点即递归释放整棵树,不会有孤儿。

回链:unique_ptr、pair 与 tuple

面试怎么问:"树形结构怎么用现代 C++ 表达?" → 子节点用 vector<unique_ptr<Node>> 表达独占所有权、自动递归释放;节点类型用 variant 表达封闭集合,用 visit 分派;对开放扩展的类型集合则仍用继承 + 虚函数。

3. 适配器 Adapter(std::function) ​

一句话:把签名各异的老接口,适配成统一的 std::function,调用方只认一种形状。

老写法 → 现代写法

  • 老:为适配不同后端,写一堆适配器类继承同一个 Interface,样板多、纯虚函数难内联。
  • 现代:统一成 std::function<Ret(Args...)>,用 lambda 把老函数"包一层"——无需定义任何新类,且能捕获上下文。
小例子
cpp
#include <cstring>
#include <functional>
#include <string>

// 老后端:C 风格接口,签名别扭
struct LegacySvc { const char* call(const char* in); };

// 统一接口形状
using Handler = std::function<std::string(const std::string&)>;

Handler adapt(LegacySvc& s) {                 // 适配器函数,而非适配器类
    return [&s](const std::string& in) {      // 捕获后端,转换参数/返回值
        const char* r = s.call(in.c_str());
        return std::string(r ? r : "");
    };
}

// Handler h = adapt(svc);  std::string out = h("ping");

没有新增类型,只有一次 lambda 包装,老接口就融入了新世界。

回链:函数对象与可调用、lambda 与 auto

面试怎么问:"适配器和装饰器都包一层,区别在哪?" → 适配器改接口形状(让 A 看起来像 B),装饰器保持接口不变只增强行为;两者都能用 std::function + lambda 实现。

4. 外观 Facade(string_view / span 统一入口) ​

一句话:外观提供一个简单入口,内部各子系统用非拥有视图(string_view/span)串起来。

老写法 → 现代写法

  • 老:外观接口用 const std::string& 或 const char* + 长度,调用方被逼着构造临时 std::string,或传裸指针带长度易错。
  • 现代:只读字符串参数用 std::string_view、只读连续序列用 std::span——统一入口、零拷贝、兼容 std::string/字面量/数组。
小例子
cpp
#include <span>
#include <cstddef>
#include <string>
#include <string_view>

struct Http { void post(std::string_view url, std::span<const std::byte> body); };
struct Auth { void sign(std::string_view raw); };
struct Cache { void put(std::string_view url); };

// 外观:对外只暴露一个简单动作,内部编排子系统
struct Api {
    Http http; Auth auth; Cache cache;

    void send(std::string_view url, std::span<const std::byte> body) {
        auth.sign(url);                 // 子系统 1
        cache.put(url);                 // 子系统 2
        http.post(url, body);           // 子系统 3
    }
};

// api.send("https://x", body);   // 传字面量也不用构造 string

调用方只需要记得"一个 send",内部顺序和子系统变化被挡在门外;string_view/span 让入口零拷贝。

回链:views 与 format、string

面试怎么问:"string_view 用错了会怎样?" → 它不拥有数据,若上游是临时 std::string,视图悬空即 UB;所以它只适合短命的只读参数,绝不能拿来当返回值或成员长期保存。

5. 装饰器 Decorator(std::function 包装) ​

一句话:接口不变、行为可叠加,用 std::function 层层包裹最省事。

老写法 → 现代写法

  • 老:装饰器类继承同一接口、持有被装饰对象指针,每个装饰点都要写一个类,组合爆炸。
  • 现代:若被装饰物本身就是个函数,直接用 std::function 把"包装"写成函数返回函数,行为可任意叠加、可运行时组装。
小例子
cpp
#include <functional>
#include <iostream>

using Fn = std::function<int(int)>;

// 一个装饰器:加日志,接口保持不变
Fn with_log(Fn inner, const char* tag) {
    return [inner, tag](int x) {         // 捕获被装饰函数
        std::cout << tag << " in=" << x << '\n';
        int r = inner(x);
        std::cout << tag << " out=" << r << '\n';
        return r;
    };
}

// Fn base    = [](int x) { return x * 2; };
// Fn wrapped = with_log(with_log(base, "B"), "A");   // 叠加两层
// wrapped(3);   // A 在 B 外层,按包裹顺序进入

装饰逻辑只写一次,想叠几层就套几层,无需新增类型。

回链:函数对象与可调用、lambda 与 auto

面试怎么问:"装饰器和继承子类增强相比好在哪?" → 装饰器是运行时组合、可任意叠加,避免子类组合爆炸;继承是编译期固定、改动需重编译。代价是装饰器引入一层间接调用。

6. 装饰器 vs 组合链(unique_ptr 链) ​

一句话:当对象是"有状态的多态对象"而非纯函数时,改用 unique_ptr 持有被装饰对象组成链。

老写法 → 现代写法

  • 老:Decorator* inner_ 裸指针,谁 delete、析构顺序如何全靠约定。
  • 现代:std::unique_ptr<Base> inner_ 表达"我拥有被装饰对象",链式所有权清晰,析构自底向上自动发生、无泄漏。
小例子
cpp
#include <memory>
#include <string>
#include <string_view>

struct Stream { virtual ~Stream() = default; virtual void write(std::string_view) = 0; };

struct FileStream : Stream {
    void write(std::string_view) override { /* 写文件 */ }
};

struct Compress : Stream {                        // 装饰器:压缩后再写
    explicit Compress(std::unique_ptr<Stream> inner) : inner_(std::move(inner)) {}
    void write(std::string_view data) override {
        inner_->write(compress(data));            // 转发 + 增强
    }
    static std::string compress(std::string_view d) { return std::string(d); }
private:
    std::unique_ptr<Stream> inner_;               // 拥有被装饰对象
};

// std::unique_ptr<Stream> s = std::make_unique<Compress>(std::make_unique<FileStream>());

链上每一环独占下一环,销毁时自动逐层释放;可运行时按需拼装任意组合。

回链:unique_ptr、虚析构

面试怎么问:"装饰器链为什么基类析构要虚?" → 通过基类 unique_ptr 删除派生对象,若析构非虚就是 UB,只析构基类部分、派生部分泄漏;所以多态基类必有虚析构。

7. 桥接 Bridge(抽象与实现分离) ​

一句话:把"抽象"和"实现"各做成一条继承线,抽象持有实现指针,两条线可独立变化。

老写法 → 现代写法

  • 老:抽象与实现揉在一个继承体系里,M 种抽象 × N 种实现要写 M×N 个类(组合爆炸)。
  • 现代:抽象持有 shared_ptr<Impl>(或 unique_ptr),两条继承线各自演化——类数量从 M×N 降到 M+N。它本质是 Pimpl 的"面向接口"版本。
小例子
cpp
#include <memory>
#include <string>

// 实现线:怎么画
struct Renderer { virtual ~Renderer() = default; virtual void draw_circle(double) = 0; };
struct GLRenderer : Renderer { void draw_circle(double) override {} };

// 抽象线:画什么
struct Shape {
    explicit Shape(std::shared_ptr<Renderer> r) : r_(std::move(r)) {}
    virtual ~Shape() = default;
    virtual void draw() = 0;
protected:
    std::shared_ptr<Renderer> r_;      // 桥接到实现
};

struct CircleShape : Shape {
    CircleShape(double x, std::shared_ptr<Renderer> r) : Shape(std::move(r)), x_(x) {}
    void draw() override { r_->draw_circle(x_); }
private:
    double x_;
};

// 新增"画什么"(如 Rectangle)不动 Renderer;新增"怎么画"(如 D3D)不动 Shape。

两条维度独立扩展,不必为每种组合各写一个类。

回链:多态与虚函数、工程与演进

面试怎么问:"桥接和 Pimpl 什么关系?" → Pimpl 是"实现细节"隐藏到 .cpp,桥接是把"实现"抽象成接口、可在运行时替换;二者都在做抽象与实现的分离,桥接更偏"面向接口的可替换实现"。

8. 享元 Flyweight(shared_ptr 共享) ​

一句话:把大量对象的"可共享内部状态"抽出来共享,用 shared_ptr/弱引用缓存复用。

老写法 → 现代写法

  • 老:每个对象各存一份字体、图标、纹理,内存成倍浪费;或用裸指针缓存池,谁删谁不删说不清。
  • 现代:用 std::shared_ptr<const T> 共享不可变内部状态,配合 weak_ptr 做缓存(不阻止回收);共享对象不可变,因此天然线程安全。
小例子
cpp
#include <memory>
#include <unordered_map>

struct Glyph { /* 字形位图,构造成本高 */ };
Glyph load(char c);

std::shared_ptr<const Glyph> get_glyph(char c) {
    static std::unordered_map<char, std::weak_ptr<const Glyph>> cache;   // 弱引用缓存
    if (auto it = cache.find(c); it != cache.end())
        if (auto sp = it->second.lock()) return sp;                      // 命中,复用
    auto sp = std::make_shared<const Glyph>(load(c));                    // 未命中,加载
    cache[c] = sp;                                                       // 只记弱引用
    return sp;
}

// auto a = get_glyph('A');   // 多次调用共享同一份字形,避免重复加载

相同字形只加载一次、被所有使用者共享;弱引用缓存不会阻止无用对象回收。

回链:shared_ptr、weak_ptr

面试怎么问:"为什么缓存里用 weak_ptr 而不是 shared_ptr?" → shared_ptr 会让缓存永久持有对象、无法回收;weak_ptr 只在外部仍有强引用时复用,否则允许自然析构,下次再按需重建。

9. 享元与内存复用(std::pmr) ​

一句话:用 std::pmr 的单调缓冲区把大量小分配收敛到一块 arena,减少碎片与分配开销。

老写法 → 现代写法

  • 老:大量 std::string/容器各自 new,分配次数多、碎片多、释放时逐块回收。
  • 现代:把分配器换成 std::pmr::monotonic_buffer_resource,所有小对象从同一块连续内存分配,整体一次性释放;适合"生命周期一致、只增不减"的场景。
小例子
cpp
#include <memory_resource>
#include <string>
#include <vector>

void build() {
    std::pmr::monotonic_buffer_resource pool;             // 一块 arena
    std::pmr::vector<std::pmr::string> names{&pool};      // 都从这个 pool 分配
    names.emplace_back("alice");
    names.emplace_back("bob");
    names.emplace_back("carol");
    // 三者及 vector 缓冲共享同一 arena;pool 析构时一次整体归还
}

把几十次零散分配压成一块 arena 上的顺序推进,分配近乎 O(1)、释放一次完成。

回链:对齐与内存、malloc 与 new

面试怎么问:"std::pmr 适合什么场景、不适合什么?" → 适合生命周期一致、分配密集、释放可批量(如解析器、短命对象图);不适合频繁单独释放或生命周期参差的场景——单调资源无法单独归还,且需注意 pmr 容器的类型与普通容器不同、接口间要匹配分配器。

相关 ​

糌粑的面经笔记 · VitePress 版