C++常用设计模式高效学习详解

内容来自:程序员老廖的个人空间

第1章 单例模式(C++)

单例模式的描述是: 确保一个类只有一个实例,并提供对该实例的全局访问。

单例模式的最重要特点就是:一个类最多只有一个对象。 意图:确保系统中某类只有唯一实例,并提供全局访问点。

动机:全局配置、日志器、线程池、连接池等只需一份共享状态与资源,避免重复创建、减少竞争与内存浪费。

类图(核心角色)

时序图:多线程首次访问

实现要点

  • 线程安全与内存可见性是面试核心
  • C++11 起,函数内局部静态变量初始化为线程安全
  • DCLP 需要正确的 memory order(acquire/release)与指针发布
  • std::call_once 保证初始化只执行一次

核心代码(五种实现对比)

仅展示关键片段,完整可运行代码见 src/

// B站程序员老廖
​
// 1. Meyers 单例(推荐)
class MeyersSingleton {
public:
    static MeyersSingleton& instance() {
        static MeyersSingleton instance; // C++11 线程安全
        return instance;
    }
    void doWork();
};
​
// 2. DCLP(双重检查锁)
class DCLPSingleton {
public:
    /*
    // 这个是错误写法
        if (!instance_) {
            lock;
            if (!instance_) {
                instance_ = new X;
            }
        }
    */
    
    // 双重检查锁(DCLP)的目标
    static DCLPSingleton* instance() {
        DCLPSingleton* tmp = instance_.load(std::memory_order_acquire);
        if (!tmp) {  // 只有在实例未创建时,才进入加锁区
            std::lock_guard<std::mutex> lock(mutex_);    // 加锁后再检查一次(防止多个线程同时进入创建)
            tmp = instance_.load(std::memory_order_relaxed);
            if (!tmp) {
                tmp = new DCLPSingleton();
                // “释放”这个指针,表示“我准备好了”
                instance_.store(tmp, std::memory_order_release); // 保证:上面所有操作(包括构造)都已完成
            }
        }
        return tmp; //第一次检查:无锁,快速返回已有实例
    }
    // 普通加锁方案的问题 每次调用都要加锁,性能差
    static DCLPSingleton* instance_common() {
        std::lock_guard<std::mutex> lock(mutex_);
        if (!instance_) {
            instance_ = new DCLPSingleton(); 
        }
    return instance_;
}
private:
    static std::atomic<DCLPSingleton*> instance_;
    static std::mutex mutex_;
};
​
// 3. call_once 单例
class CallOnceSingleton {
public:
    static CallOnceSingleton& instance() {
        std::call_once(flag_, [](){ instance_.reset(new CallOnceSingleton()); });
        return *instance_;
    }
private:
    static std::once_flag flag_;
    static std::unique_ptr<CallOnceSingleton> instance_;
};
​
// 4. 饿汉式单例(程序启动时立即初始化)
class EagerSingleton {
public:
    static EagerSingleton& instance() {
        return instance_; // 直接返回,无需检查
    }
private:
    static EagerSingleton instance_; // 程序启动时构造
};
​
// 5. 懒汉式单例(首次使用时初始化,互斥锁保证线程安全)
class LazySingleton {
public:
    static LazySingleton& instance() {
        std::lock_guard<std::mutex> lock(mutex_);
        if (!instance_) {
            instance_.reset(new LazySingleton());
        }
        return *instance_;
    }
private:
    static std::unique_ptr<LazySingleton> instance_;
    static std::mutex mutex_;
};

双重检查锁定(Double-Checked Locking Pattern, DCLP

DCLP 的作用与背景

1. 目标

实现一个线程安全的单例模式(Singleton),并且在首次创建后,后续调用 instance() 时不加锁、高性能。

2. 普通加锁方案的问题

static DCLPSingleton* instance() {
    std::lock_guard<std::mutex> lock(mutex_);
    if (!instance_) {
        instance_ = new DCLPSingleton();
    }
    return instance_;
}

✅ 正确 ❌ 每次调用都要加锁,性能差

3. 双重检查锁(DCLP)的目标

  • 第一次检查:无锁,快速返回已有实例
  • 只有在实例未创建时,才进入加锁区
  • 加锁后再检查一次(防止多个线程同时进入创建)
  • 创建后存储实例

这就是“双重检查”的由来。

为什么需要 std::atomic 和 内存序?

这是最容易被误解的部分。我们来看原始代码:

static DCLPSingleton* instance() {
    DCLPSingleton* tmp = instance_.load(std::memory_order_acquire);
    if (!tmp) {
        std::lock_guard<std::mutex> lock(mutex_);
        tmp = instance_.load(std::memory_order_relaxed);
        if (!tmp) {
            tmp = new DCLPSingleton();
            instance_.store(tmp, std::memory_order_release);
        }
    }
    return tmp;
}

1. 为什么 instance_ 必须是 std::atomic<T*>

因为多个线程会并发读写 instance_ 指针。

  • 如果不用 atomic,多个线程同时读写非原子变量 → 数据竞争(data race) → 未定义行为(UB)
  • std::atomic 提供了原子性保证,避免读写撕裂(tearing)

✅ 所以 atomic 是必须的,不是可选项。

2. 为什么需要 memory_order_acquirememory_order_release

这才是最精妙的地方。我们来一步步拆解。

❌ 问题:编译器/CPU 会重排序!

考虑这行代码:

tmp = new DCLPSingleton();
instance_.store(tmp, std::memory_order_release);

在底层,new DCLPSingleton() 包含两个动作:

  1. 分配内存
  2. 调用构造函数初始化对象

但编译器或 CPU 可能重排序这些操作,导致:

指针 instance_ 先被赋值,但对象还没构造完成!

此时,另一个线程执行:

DCLPSingleton* tmp = instance_.load(); // 看到了非空指针
if (!tmp) { ... } // 跳过
return tmp; // 返回一个“半成品”对象!崩溃!

这就是典型的重排序导致的安全问题。

解决方案:用 acquire-release 内存序建立“同步关系”

我们使用:

  • load(std::memory_order_acquire):获取操作,确保后续读写不会被重排到它前面
  • store(std::memory_order_release):释放操作,确保前面的读写不会被重排到它后面

当一个线程执行 store(release),另一个线程执行 load(acquire) 并读到了这个值,就形成了 synchronizes-with 关系。

这意味着:所有在 release 前的写操作,对 acquire 后的读操作可见。

举个例子:

线程 A(创建):

tmp = new DCLPSingleton();           // 构造完成
instance_.store(tmp, release);       // 发布指针

线程 B(读取):

tmp = instance_.load(acquire);       // 读到非空
// 由于 acquire-release 同步,保证能看到构造完成的对象!

✅ 安全!

为什么第二次检查用 relaxed

tmp = instance_.load(std::memory_order_relaxed);

因为在持有 mutex 锁的情况下,mutex 本身已经提供了同步语义(mutex 是 acquire/release 的)。

所以这里的 load 只是为了判断是否需要创建,不需要额外的内存序约束,用 relaxed 更高效。

面试高频与深入剖析

为什么 Meyers 单例在 C++11 后默认线程安全?

  • 函数内静态变量的初始化由运行时提供“magic static”一次性、线程安全机制,并建立初始化与后续读取的 happens-before。

DCLP(Double-Checked Locking Pattern,双重检查锁定模式)如何写才安全?

  • 使用 std::atomic<T*> 指针;发布侧 store(memory_order_release),读取侧 load(memory_order_acquire);锁内读可用 relaxed;避免半初始化与指令重排。

call_once 与 Meyers 的取舍?

  • Meyers 简洁、懒加载、零样板;call_once 便于自定义销毁与资源管理(如 unique_ptr)与显式初始化点。

静态析构顺序如何处理?

  • 跨 TU 析构次序不定:可选择不析构(服务进程)、使用 std::atexit 注册销毁、或提供 shutdown() 明确释放顺序。

受控销毁代码片段(跨 TU 析构次序)

// B站程序员老廖
#include <cstdlib>
#include "singleton/singleton.hpp"
​
// 在进程退出时按顺序销毁单例,避免跨 TU 析构次序不定
static void destroy_singletons_in_order() {
  CallOnceSingleton::shutdown();  // 先释放可控资源(unique_ptr)
  DCLPSingleton::destroy();       // 再释放 DCLP 指针实例
  // MeyersSingleton 通常不主动析构(由 OS 回收),避免析构次序风险
}
​
int main() {
  std::atexit(destroy_singletons_in_order);
  // ... 业务逻辑
}

运行与验证

见根目录 README 的构建命令。可执行程序会在多线程下对三种实现各执行 9 次 doWork(),并断言计数。

反例:非线程安全懒汉(用于说明问题)

class UnsafeLazySingleton {
public:
    static UnsafeLazySingleton* instance() {
        if (!instance_) instance_ = new UnsafeLazySingleton();
        return instance_;
    }
};

多个线程同时通过空检查可能创建多个实例,且存在发布-逸出问题。

实战映射

  • 日志器、配置加载、线程池、连接池
  • 多模块共享监控/指标上报器(例如全局 MetricsRegistry)

真实案例:线程安全日志器单例(C++)

代码位置:src/logger/logger.hppsrc/logger/examples_logger.cpp

类图

时序图:客户端并发写日志

核心使用代码(片段)

仅展示要点,完整示例见 src/logger/examples_logger.cpp

// B站程序员老廖
#include "logger/logger.hpp"
#include <thread>
#include <vector>
​
int main() {
  Logger::instance().logInfo("启动服务");
  std::vector<std::thread> ws;
  for (int i = 0; i < 4; ++i) {
    ws.emplace_back([i]{
      Logger::instance().logInfo("worker " + std::to_string(i) + " 开始");
      Logger::instance().logError("worker " + std::to_string(i) + " 警告");
    });
  }
  for (auto& t : ws) t.join();
  Logger::instance().logInfo("服务退出");
}

面试速答清单

Meyers 单例为什么线程安全?

  • C++11 magic static 提供一次性、线程安全初始化并建立可见性。

DCLP 全称与中文?

  • Double-Checked Locking Pattern,双重检查锁定模式。

DCLP 的可见性如何保证?

  • 原子指针 + 发布侧 release、读取侧 acquire;锁内读可用 relaxed。

如何避免静态析构次序问题?

  • 不析构或 atexit 管理,或提供 shutdown() 明确销毁顺序。

第2章 工厂方法模式(C++)

工厂方法模式的描述是: 定义创建对象的接口,让子类决定实例化哪个类。工厂方法使类的实例化延迟到子类。

工厂方法模式的最重要特点就是:将对象创建延迟到子类,符合开闭原则。

意图:定义一个创建对象的接口,让子类决定实例化哪一个类。工厂方法使一个类的实例化延迟到其子类。

动机:当系统需要创建多种相关产品,且产品种类可能扩展时,直接在客户端 new 会导致代码耦合。工厂方法通过抽象工厂接口,让具体工厂负责创建具体产品,支持无修改扩展。

类图(分解展示:简单工厂 vs 工厂方法)

1. 简单工厂模式结构

2. 工厂方法模式结构

3. 产品继承关系

时序图:工厂方法创建产品

实现要点

  • 工厂方法将对象创建延迟到子类,每个具体工厂负责创建对应产品
  • 符合开闭原则:新增产品只需添加具体工厂和具体产品,无需修改现有代码
  • 与简单工厂对比:简单工厂集中创建但违反开闭原则,工厂方法分散创建但易扩展
  • 可结合模板方法模式,在抽象工厂中定义通用业务流程

核心代码(简单工厂 vs 工厂方法 vs 参数化工厂)

仅展示关键片段,完整可运行代码见 src/factory/

// B站程序员老廖
​
// 抽象产品
class Logger {
public:
    virtual ~Logger() = default;
    virtual void log(const std::string& message) = 0;
    virtual std::string getType() const = 0;
};
​
// 具体产品
class FileLogger : public Logger {
public:
    explicit FileLogger(const std::string& filename) : filename_(filename) {}
    void log(const std::string& message) override {
        std::cout << "[FILE:" << filename_ << "] " << message << std::endl;
    }
    std::string getType() const override { return "FileLogger"; }
private:
    std::string filename_;
};
​
// 1. 简单工厂(违反开闭原则)
class SimpleLoggerFactory {
public:
    enum class LoggerType { FILE, CONSOLE, NETWORK };
    static std::unique_ptr<Logger> createLogger(LoggerType type, const std::string& param = "") {
        switch (type) {
            case LoggerType::FILE: return std::make_unique<FileLogger>(param);
            case LoggerType::CONSOLE: return std::make_unique<ConsoleLogger>();
            // 新增类型需要修改此处,违反开闭原则
        }
    }
};
​
// 2. 工厂方法(符合开闭原则)
class LoggerFactory {
public:
    virtual ~LoggerFactory() = default;
    virtual std::unique_ptr<Logger> createLogger() = 0; // 延迟到子类
    
    // 模板方法:使用工厂方法的通用业务逻辑
    void processLog(const std::string& message) {
        auto logger = createLogger();
        logger->log("Processing: " + message);
    }
};
​
class FileLoggerFactory : public LoggerFactory {
public:
    explicit FileLoggerFactory(const std::string& filename) : filename_(filename) {}
    std::unique_ptr<Logger> createLogger() override {
        return std::make_unique<FileLogger>(filename_);
    }
private:
    std::string filename_;
};
​
// 3. 参数化工厂(兼顾便利性和扩展性)
class ParameterizedLoggerFactory {
public:
    using CreateFunc = std::function<std::unique_ptr<Logger>(const std::string&)>;
    
    static void registerLogger(const std::string& type, CreateFunc creator) {
        creators_[type] = creator; // 支持运行时注册新类型
    }
    
    static std::unique_ptr<Logger> createLogger(const std::string& type, const std::string& param = "") {
        auto it = creators_.find(type);
        return it != creators_.end() ? it->second(param) : nullptr;
    }
    
private:
    static std::unordered_map<std::string, CreateFunc> creators_;
};

面试高频与深入剖析

简单工厂 vs 工厂方法的核心区别?

  • 简单工厂:集中创建逻辑,通过参数决定创建哪种产品,但新增产品需修改工厂类(违反开闭原则)。工厂方法:将创建延迟到子类,每个具体工厂创建对应产品,新增产品只需添加工厂子类(符合开闭原则)。

为什么工厂方法更易扩展?

  • 工厂方法遵循"对扩展开放,对修改关闭"原则。新增产品类型时,只需新增具体产品类和具体工厂类,无需修改已有代码,降低了系统耦合度和引入bug的风险。

工厂方法的缺点是什么?

  • 类的数量成倍增长(每个产品需要对应的工厂类),增加了系统复杂度。对于产品种类较少且变化不频繁的场景,简单工厂可能更合适。

如何选择工厂模式?

  • 产品种类固定且少:简单工厂。产品种类多且需要扩展:工厂方法。需要运行时动态注册:参数化工厂(结合注册器模式)。

运行与验证

见根目录 README 的构建命令。可执行程序会演示三种工厂模式的使用方式,包括运行时注册新产品类型的扩展性验证。

真实案例:日志系统工厂(C++)

代码位置:src/factory/factory.hppsrc/factory/examples.cpp

业务场景类图

工厂类继承关系

产品类继承关系

核心使用代码(片段)

仅展示要点,完整示例见 src/factory/examples.cpp

// B站程序员老廖
#include "factory/factory.hpp"
​
int main() {
    // 1. 简单工厂使用
    auto logger1 = SimpleLoggerFactory::createLogger(
        SimpleLoggerFactory::LoggerType::FILE, "app.log");
    logger1->log("简单工厂创建的日志");
    
    // 2. 工厂方法使用
    std::unique_ptr<LoggerFactory> factory = 
        std::make_unique<FileLoggerFactory>("method.log");
    factory->processLog("工厂方法处理的消息");
    
    // 3. 参数化工厂使用(支持运行时扩展)
    auto logger2 = ParameterizedLoggerFactory::createLogger("file", "param.log");
    
    // 运行时注册新类型
    ParameterizedLoggerFactory::registerLogger("debug", 
        [](const std::string& level) -> std::unique_ptr<Logger> {
            return std::make_unique<DebugLogger>(level);
        });
    auto debugLogger = ParameterizedLoggerFactory::createLogger("debug", "VERBOSE");
}

面试速答清单

简单工厂 vs 工厂方法的本质区别?

  • 简单工厂集中创建,工厂方法分散创建;前者违反开闭原则,后者符合开闭原则。

工厂方法如何体现开闭原则?

  • 新增产品时只需添加具体产品类和具体工厂类,无需修改现有代码。

工厂方法的缺点?

  • 类数量成倍增长,增加系统复杂度;适合产品种类多且需扩展的场景。

什么时候用简单工厂?

  • 产品种类少且相对固定,追求代码简洁时;如配置文件解析器的格式选择。

内容太多,需要以下章节内容的可以观看以下视频自行领取完整的学习文档

C++少走弯路系列2-设计模式如何高效学习教程分享

第3章 抽象工厂模式(C++)

第4章 策略模式(C++)

第5章 观察者模式(C++)

第6章 适配器模式(C++)

第7章 责任链模式(C++)

#秋招白月光##后端##秋招##校招##c++#
全部评论

相关推荐

点赞 评论 收藏
分享
10-26 22:34
C++
点赞 评论 收藏
分享
评论
1
8
分享

创作者周榜

更多
牛客网
牛客网在线编程
牛客网题解
牛客企业服务