Appearance
结构型模式(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 的定义。
面试怎么问:"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);所有权与节点种类都被类型系统写死:删根节点即递归释放整棵树,不会有孤儿。
面试怎么问:"树形结构怎么用现代 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 包装,老接口就融入了新世界。
面试怎么问:"适配器和装饰器都包一层,区别在哪?" → 适配器改接口形状(让 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 让入口零拷贝。
面试怎么问:"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 外层,按包裹顺序进入装饰逻辑只写一次,想叠几层就套几层,无需新增类型。
面试怎么问:"装饰器和继承子类增强相比好在哪?" → 装饰器是运行时组合、可任意叠加,避免子类组合爆炸;继承是编译期固定、改动需重编译。代价是装饰器引入一层间接调用。
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'); // 多次调用共享同一份字形,避免重复加载相同字形只加载一次、被所有使用者共享;弱引用缓存不会阻止无用对象回收。
面试怎么问:"为什么缓存里用 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)、释放一次完成。
面试怎么问:"std::pmr 适合什么场景、不适合什么?" → 适合生命周期一致、分配密集、释放可批量(如解析器、短命对象图);不适合频繁单独释放或生命周期参差的场景——单调资源无法单独归还,且需注意 pmr 容器的类型与普通容器不同、接口间要匹配分配器。
相关
创建型模式(Modern C++ 写法):单例、工厂、建造者、原型、对象池、依赖注入。工程与演进:接口与实现分离、组合优于继承的工程准则。智能指针:unique_ptr/shared_ptr/weak_ptr表达所有权的完整用法。CRTP:静态多态与样板批量生成的模板手法。函数对象与可调用:std::function做适配器与装饰器的基础。views 与 format:string_view/span/ranges 视图的非拥有语义。对齐与内存:std::pmr背后的分配器与内存布局基础。