Skip to content

创建型模式(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,编译器替你把线程安全和生命周期都管好了。

回链:死锁与单例、RAII

面试怎么问:"手写一个线程安全的单例?" → 别上来就双检锁,直接给出函数内 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) 展示了能编译期解决的就别留到运行时。

回链:C++11 新特性、原子与内存序

面试怎么问:"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++ 里的落地形态。

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

面试怎么问:"工厂里 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 让每个参数只搬一次。

回链:移动语义、vector

面试怎么问:"什么时候还需要 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 一样直观,却不引入任何额外类型。

回链:C++ 杂项与陷阱、C++11 新特性

面试怎么问:"指定初始化器有什么限制?" → 只能用于聚合类型;必须按成员声明顺序;不能重复初始化同一成员;不支持嵌套 .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 里再写 clone

clone 的样板只写一次,派生类零成本获得;新增派生类时不会漏实现。

回链:CRTP、模板与元编程

面试怎么问:"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++ 里怎么做、有什么代价?" → 构造函数注入接口是最常见形态,测试可换假实现;代价是引入间接层与生命周期管理,过度使用会让依赖关系隐式化、可读性下降,宜按需而用。

相关 ​

糌粑的面经笔记 · VitePress 版