Appearance
创建型模式(Modern C++ 写法)
创建型模式的本质是把"对象的创建"与"对象的使用"解耦。传统 C++ 笔试题里的单例、工厂、建造者、原型,很多都写在
new/delete横飞、锁与双检锁手写的年代。这一页用 C++11/17/20 的语义重写它们:用栈对象和函数内static消灭生命周期管理,用智能指针表达所有权,用std::function消灭类型分支,用语言特性替代设计模式。一条主线贯穿全篇:能不用模式就不用模式。C++ 的现代特性(聚合初始化、指定初始化器、
constexpr、std::make_unique)已经把最简单的几个创建型模式"降维"成了语言本身的能力。
1. 单例 Singleton(函数内 static)
一句话:C++11 起函数局部 static 的初始化是线程安全的,Meyers Singleton 是零额外开销的正解。
老写法 → 现代写法
- 老:裸指针 + 手动
lock,或者脆弱的双检锁(DCLP,未用原子/内存序时在弱内存模型下会重排,存在读到半初始化对象的经典 bug)。 - 现代:把实例放进函数局部
static,首次调用时构造、由编译器保证只构造一次且线程安全(__cxa_guard_*),不占额外内存、无锁开销、无需显式删除(程序退出自动析构,且析构顺序被正确处理)。
小例子
cpp
#include <string>
class Config {
public:
static Config& instance() { // 唯一入口
static Config inst; // C++11:线程安全的一次性构造
return inst;
}
void set_port(int p) { port_ = p; }
int port() const { return port_; }
private:
Config() = default; // 构造函数私有,禁止外部创建
Config(const Config&) = delete; // 禁拷贝
Config& operator=(const Config&) = delete;
int port_ = 8080;
};
// int main() {
// Config::instance().set_port(9090);
// assert(Config::instance().port() == 9090);
// }这段演示了:无需锁、无需指针、无需 delete,编译器替你把线程安全和生命周期都管好了。
面试怎么问:"手写一个线程安全的单例?" → 别上来就双检锁,直接给出函数内 static 版本并说明 C++11 保证线程安全;若追问"为什么双检锁会错",答:缺内存序时 new 的两步(分配 + 构造)会被重排,别的线程可能拿到未构造完的指针,需 std::atomic + acquire/release 或 std::call_once 修正。
2. 单例进阶:std::call_once 与编译期单例
一句话:需要把构造逻辑拆出去用 std::call_once;需要编译期常量就用 inline constexpr 变量。
老写法 → 现代写法
- 老:用
bool initialized_+mutex自己写"只初始化一次",容易漏锁、性能和正确性都难保证。 - 现代:
std::call_once+std::once_flag提供标准化的「恰好执行一次」语义;若单例本身是纯数据/纯计算,则直接用 C++17 的inline constexpr变量,在编译期就存在,无运行时构造、无 ODR 问题。
小例子
cpp
#include <mutex>
#include <memory>
#include <limits>
// (A) 构造逻辑复杂、需要显式控制时机时用 call_once
class Pool {
public:
static Pool& instance() {
std::call_once(flag_, [] { p_.reset(new Pool); });
return *p_;
}
private:
Pool() = default;
static std::once_flag flag_;
static std::unique_ptr<Pool> p_;
};
std::once_flag Pool::flag_;
std::unique_ptr<Pool> Pool::p_;
// (B) 纯数据的"单例":编译期常量,无需任何运行时机制
struct Limits { int max_conn = 128; constexpr int limit() const { return max_conn; } };
inline constexpr Limits kLimits{}; // C++17 inline variable
// static_assert(kLimits.limit() == 128); // 编译期即可用(A) 把"只跑一次"交给标准库;(B) 展示了能编译期解决的就别留到运行时。
面试怎么问:"call_once 和函数内 static 有什么区别?" → 语义近似(都保证一次),但 call_once 可对任意逻辑使用、可配合动态时机(如按配置延迟初始化),函数内 static 更简洁且零额外开销;二者都不是"每次调用都加锁",而是用高效的 once 状态判断。
3. 工厂方法 Factory Method
一句话:工厂返回 std::unique_ptr<Base>,用 std::make_unique 构造,把所有权明确交给调用者。
老写法 → 现代写法
- 老:
Base* create(...)返回裸指针,调用者要记得delete;异常路径上极易泄漏;类型选择常写成switch/if-else硬编码。 - 现代:工厂返回
std::unique_ptr<Base>,用std::make_unique<Derived>(...);异常安全、自文档化所有权;无法创建时返回nullptr或std::optional,比new失败抛bad_alloc更可控。
小例子
cpp
#include <memory>
#include <string>
struct Shape {
virtual ~Shape() = default;
virtual void draw() const = 0;
};
struct Circle : Shape { void draw() const override {} };
struct Square : Shape { void draw() const override {} };
// 工厂方法:所有权通过返回类型表达清楚
std::unique_ptr<Shape> make_shape(const std::string& kind) {
if (kind == "circle") return std::make_unique<Circle>();
if (kind == "square") return std::make_unique<Square>();
return nullptr; // 调用方必须判空,不再是"忘了 delete"
}
// int main() {
// if (auto s = make_shape("circle")) s->draw(); // 离开作用域自动释放
// }重点看返回类型——读签名就知道"这东西归我管",并且任何提前 return 都能自动回收。
回链:unique_ptr、多态与虚函数
面试怎么问:"工厂方法怎么用现代 C++ 写?" → 一句话:返回 unique_ptr、用 make_unique 造、用 override 写虚函数虚拟析构;如果创建类型多,改成注册表工厂(见下一条)。
4. 抽象工厂 / 注册表工厂(std::function)
一句话:用"名字 → 创建函数"的注册表替代 if-else 类型分支,新增类型只需注册,不改工厂。
老写法 → 现代写法
- 老:一大堆
if (type == "A") ... else if (type == "B") ...,每加一种类型都要回来改工厂(违反开闭原则);或者大量小工厂类继承体系。 - 现代:
std::unordered_map<std::string, std::function<std::unique_ptr<Base>()>>作为注册表,各类型在启动时自注册,工厂本体永远不变——用std::function把"创建动作"变成一等公民。
小例子
cpp
#include <functional>
#include <memory>
#include <string>
#include <unordered_map>
struct Shape { virtual ~Shape() = default; virtual void draw() const = 0; };
struct Circle : Shape { void draw() const override {} };
class ShapeFactory {
public:
using Creator = std::function<std::unique_ptr<Shape>()>;
void reg(std::string key, Creator c) { table_[std::move(key)] = std::move(c); }
std::unique_ptr<Shape> create(const std::string& key) const {
auto it = table_.find(key);
return it == table_.end() ? nullptr : it->second();
}
private:
std::unordered_map<std::string, Creator> table_;
};
// 注册:factory.reg("circle", [] { return std::make_unique<Circle>(); });新增类型只写"注册"这一行,工厂的 create 一行不改——这就是开闭原则在 C++ 里的落地形态。
面试怎么问:"工厂里 if-else 太多怎么办?" → 用注册表 + std::function:类型名到创建函数的映射,新增类型只注册不改本体;代价是每次创建多一次哈希查找和一次间接调用。
5. 建造者 Builder(链式调用)
一句话:Builder 的每个 setter 返回 *this,把"分步构造"拼成一条流畅的链。
老写法 → 现代写法
- 老:构造参数一长串("伸缩构造函数"),或者写一个臃肿的 Builder 类、
build()里拷来拷去。 - 现代:链式 setter 返回
*this,可读性好、顺序灵活;配合移动语义(参数按值传 +std::move)避免多余拷贝。
小例子
cpp
#include <string>
#include <utility>
#include <vector>
class HttpRequest {
public:
HttpRequest& url(std::string u) { url_ = std::move(u); return *this; }
HttpRequest& timeout(int ms) { timeout_ = ms; return *this; }
HttpRequest& header(std::string k, std::string v) {
headers_.emplace_back(std::move(k), std::move(v));
return *this;
}
private:
std::string url_;
int timeout_ = 1000;
std::vector<std::pair<std::string, std::string>> headers_;
};
// auto req = HttpRequest{}.url("https://x").timeout(500).header("A", "b");一条链读下来就是一句自然语言;emplace_back + std::move 让每个参数只搬一次。
面试怎么问:"什么时候还需要 Builder,什么时候不需要?" → 参数多、有默认值、有必填/可选组合时才值得;若只是给一个聚合类型填字段,C++20 的指定初始化器(下一条)是更轻的替代品。
6. Builder 的替代品:聚合初始化与 C++20 指定初始化器
一句话:字段都是公开数据时,用聚合初始化和 .field = value 直接构造,省掉整个 Builder。
老写法 → 现代写法
- 老:为了"命名参数"效果硬造一个 Builder 类,几十行样板代码只为初始化几个字段。
- 现代:类型满足聚合条件时,直接
Options{ .url = ..., .timeout_ms = ... }(C++20 指定初始化器),可读性与 Builder 相当,代码量为零;注意成员须按声明顺序初始化、不支持嵌套指定。
小例子
cpp
#include <string>
struct Options {
std::string url;
int timeout_ms = 1000; // 有默认值
bool keep_alive = true;
};
// C++20:指定初始化器,跳过不需要设的字段
Options a{ .url = "https://x", .timeout_ms = 500 };
Options b{ .url = "https://y" }; // keep_alive 用默认
// 对比 C++17 之前的写法(必须按顺序、不能跳):
// Options c{"https://z", 800, false};新的写法让"只想改一个字段"变得和 Builder 一样直观,却不引入任何额外类型。
面试怎么问:"指定初始化器有什么限制?" → 只能用于聚合类型;必须按成员声明顺序;不能重复初始化同一成员;不支持嵌套 .a.b;不能与普通位置初始化混用。
7. 原型 Prototype(值语义 clone)
一句话:需要"克隆多态对象"时,让基类提供 virtual std::unique_ptr<Base> clone() const。
老写法 → 现代写法
- 老:
Base* clone() const返回裸指针,忘了删就泄漏;或者干脆用类型擦除到处dynamic_cast。 - 现代:返回
std::unique_ptr<Base>,实现里用拷贝构造(std::make_unique<Derived>(*this))复用编译器生成的成员级拷贝,简单又不漏字段。
小例子
cpp
#include <memory>
struct Shape {
virtual ~Shape() = default;
virtual std::unique_ptr<Shape> clone() const = 0; // 多态克隆接口
};
struct Circle : Shape {
explicit Circle(double r) : r_(r) {}
std::unique_ptr<Shape> clone() const override {
return std::make_unique<Circle>(*this); // 值语义拷贝
}
double r_ = 0;
};
// auto copy = original.clone(); // 静态类型 unique_ptr<Shape>,动态类型仍是 Circle*this 触发的是派生类的拷贝构造,所以 Circle 的字段一个不落,也不会漏写。
回链:unique_ptr、多态与虚函数
面试怎么问:"怎么克隆一个只知道基类指针的多态对象?" → 基类定义 virtual clone(),派生类用 make_unique<Derived>(*this) 实现拷贝构造;这样克隆结果保留真实动态类型和全部字段。
8. 原型进阶:CRTP 自动生成 clone
一句话:用 CRTP 把 clone 的样板集中到基类模板,派生类只写"我是谁"。
老写法 → 现代写法
- 老:每个派生类手写一遍
clone(),改接口(比如换返回值)时所有类都要改。 - 现代:写一个
Cloneable<Derived>基类模板,clone只实现一次,派生类继承Cloneable<Self>即自动具备——静态多态复用样板,运行时仍是虚函数语义。
小例子
cpp
#include <memory>
struct Shape {
virtual ~Shape() = default;
virtual std::unique_ptr<Shape> clone() const = 0;
};
template <class Derived>
struct Cloneable : Shape {
std::unique_ptr<Shape> clone() const override {
// 通过 CRTP 静态地知道真实类型,复用其拷贝构造
return std::make_unique<Derived>(static_cast<const Derived&>(*this));
}
};
struct Circle : Cloneable<Circle> {
explicit Circle(double r) : r_(r) {}
Circle(const Circle&) = default; // 值语义拷贝
double r_ = 0;
};
// auto p = std::unique_ptr<Shape>(new Circle(1.0));
// auto q = p->clone(); // 无需在 Circle 里再写 cloneclone 的样板只写一次,派生类零成本获得;新增派生类时不会漏实现。
面试怎么问:"CRTP 有什么实际用途?" → 静态多态(省虚表开销)、给派生类批量生成样板(如 clone、operator==、计数器);代价是每个派生类一个独立基类实例、调试时类型名变长。
9. 对象池 Object Pool
一句话:用 unique_ptr 加自定义删除器,让"借出即升级为智能指针、归还自动回池"。
老写法 → 现代写法
- 老:
acquire()/release()成对调用,忘记归还就耗尽池子;异常路径更没有保障。 - 现代:
acquire()返回带自定义删除器的unique_ptr,一旦借出就受 RAII 管理,离开作用域自动归还,不会漏还。
小例子
cpp
#include <functional>
#include <memory>
#include <vector>
struct Conn { /* 连接资源 */ };
class ConnPool {
public:
// 返回的智能指针析构时会把连接放回池子
std::unique_ptr<Conn, std::function<void(Conn*)>> acquire() {
if (free_.empty()) free_.push_back(std::make_unique<Conn>());
Conn* raw = free_.back().release();
free_.pop_back();
return { raw, [this](Conn* c) { free_.emplace_back(c); } }; // 自定义删除器 = 归还
}
private:
std::vector<std::unique_ptr<Conn>> free_;
};
// {
// auto c = pool.acquire();
// // 用 c ...
// } // 作用域结束,连接自动回到 free_把"归还"从"记得调用"变成"析构必做",异常安全也随之而来。
回链:RAII、unique_ptr
面试怎么问:"对象池怎么保证连接一定归还?" → 让 acquire 返回带自定义删除器的 unique_ptr,删除器执行归还逻辑;这样即使中途抛异常,栈展开也会触发删除器。
10. 依赖注入 Dependency Injection(简)
一句话:把依赖从"类内部 new"改为"构造时传入接口",用 shared_ptr/std::function 解耦。
老写法 → 现代写法
- 老:类内部直接
new MySQLLogger,测试时无法替换、编译期强耦合到具体实现。 - 现代:构造注入依赖的抽象(
shared_ptr<Logger>表达共享所有权;或std::function表达更松的函数式依赖),使用方只依赖接口,测试时可注入假实现。
小例子
cpp
#include <functional>
#include <memory>
#include <string>
struct Logger {
virtual ~Logger() = default;
virtual void log(const std::string&) = 0;
};
class Service {
public:
explicit Service(std::shared_ptr<Logger> logger) // 构造注入
: logger_(std::move(logger)) {}
void run() { logger_->log("service running"); }
private:
std::shared_ptr<Logger> logger_;
};
// 生产:Service(std::make_shared<FileLogger>());
// 测试:Service(std::make_shared<FakeLogger>()); // 无需改 Service 源码
// 更松的写法:直接注入可调用对象
class Service2 {
public:
explicit Service2(std::function<void(const std::string&)> log) : log_(std::move(log)) {}
private:
std::function<void(const std::string&)> log_;
};依赖由外部决定,切换实现/注入 mock 都不动被测代码。
回链:shared_ptr、工程与演进
面试怎么问:"依赖注入在 C++ 里怎么做、有什么代价?" → 构造函数注入接口是最常见形态,测试可换假实现;代价是引入间接层与生命周期管理,过度使用会让依赖关系隐式化、可读性下降,宜按需而用。