C++设计模式实战:单例、工厂与适配器在真实项目中的应用

C++设计模式实战:单例、工厂与适配器在真实项目中的应用

1. 项目概述:从“玩具”到“实战”的设计模式演练

在C++开发这条路上,我们常常会接触到各种设计模式。很多教程里的例子,比如一个Logger单例、一个Shape工厂,虽然能让你明白模式的结构,但总感觉和真实项目隔着一层纱。它们像实验室里的“玩具”,干净、简单,但缺乏真实世界的“泥泞感”——那些复杂的依赖、性能的考量、线程的陷阱以及如何优雅地融入现有架构。最近,我正好在重构一个老旧的音视频处理模块,其中就密集地用到了单例、工厂和适配器模式。我决定把这次实战中的思考,结合几个更贴近真实场景的Demo分享出来。这些例子不再是孤立的代码片段,而是模拟了你在开发一个中小型C++应用(比如一个简易的媒体播放器后台、一个游戏引擎的子系统或一个网络服务框架)时可能遇到的典型场景。如果你已经理解了模式的基本概念,但困惑于如何将它们用得“地道”和“安全”,那么接下来的内容应该能给你一些直接的参考。

2. 单例模式实战:一个线程安全的配置管理器

单例模式大概是争议最大也最容易被误用的模式。我们需要的不是一个简单的static变量,而是一个在多线程环境下安全、可延迟初始化、且便于进行资源管理和测试的配置中心。

2.1 核心需求与设计选型

在一个分布式日志采集系统或游戏引擎中,全局配置(如数据库连接串、日志级别、资源路径)需要被各个模块频繁读取。这个配置管理器必须满足:

  1. 全局唯一访问点:确保所有代码获取的配置来源一致。
  2. 线程安全初始化:防止多线程同时调用getInstance()时创建多个实例。
  3. 延迟加载:系统启动时可能不需要所有配置,应避免不必要的开销。
  4. 可测试性:便于在单元测试中替换或重置配置。

C++11之后,利用局部静态变量的线程安全特性(Magic Static)是实现单例最简洁、高效的方式,它完美解决了双重检查锁定(DCLP)在旧标准下的复杂性和潜在风险。

2.2 贴近实战的ConfigManager实现

下面是一个模拟从文件加载JSON配置的ConfigManager

#include <iostream> #include <fstream> #include <string> #include <unordered_map> #include <mutex> #include <memory> #include <nlohmann/json.hpp> // 假设使用 nlohmann/json 库 using json = nlohmann::json; class ConfigManager { public: // 删除拷贝构造和赋值操作符,确保唯一性 ConfigManager(const ConfigManager&) = delete; ConfigManager& operator=(const ConfigManager&) = delete; // 获取单例实例的全局访问点 static ConfigManager& getInstance() { static ConfigManager instance; // C++11保证此初始化是线程安全的 return instance; } // 从文件加载配置 bool loadConfig(const std::string& filePath) { std::lock_guard<std::mutex> lock(configMutex_); // 加载配置时需要加锁 try { std::ifstream file(filePath); if (!file.is_open()) { std::cerr << "无法打开配置文件: " << filePath << std::endl; return false; } json j; file >> j; configData_ = j; // 假设configData_是json类型或已转换为内部结构 file.close(); std::cout << "配置文件加载成功: " << filePath << std::endl; return true; } catch (const json::exception& e) { std::cerr << "JSON解析错误: " << e.what() << std::endl; return false; } catch (...) { std::cerr << "未知错误加载配置文件。" << std::endl; return false; } } // 获取配置值(模板函数,支持不同类型) template<typename T> T getValue(const std::string& key, const T& defaultValue = T{}) const { // 注意:这里读取也需要加锁,因为json不是线程安全的。 // 但为了性能,如果配置加载后只读,可以考虑使用读写锁或原子操作。 std::lock_guard<std::mutex> lock(configMutex_); try { return configData_.value(key, defaultValue); } catch (...) { return defaultValue; } } // 仅供演示:打印所有配置 void printAll() const { std::lock_guard<std::mutex> lock(configMutex_); std::cout << "当前配置: " << configData_.dump(2) << std::endl; } private: // 私有构造函数,防止外部构造 ConfigManager() { std::cout << "ConfigManager 实例创建。" << std::endl; // 可以在这里初始化默认配置 configData_ = json::object(); } // 私有析构函数 ~ConfigManager() { std::cout << "ConfigManager 实例销毁。" << std::endl; } mutable std::mutex configMutex_; // mutable允许在const成员函数中加锁 json configData_; };

使用示例:

int main() { // 获取单例引用 auto& config = ConfigManager::getInstance(); // 加载配置 if (!config.loadConfig("app_config.json")) { std::cerr << "启动失败:缺少配置文件。" << std::endl; return -1; } // 从不同“模块”获取配置 int threadPoolSize = config.getValue<int>("performance.thread_pool_size", 4); std::string logLevel = config.getValue<std::string>("logging.level", "INFO"); std::string dbHost = config.getValue<std::string>("database.host", "localhost"); std::cout << "线程池大小: " << threadPoolSize << std::endl; std::cout << "日志级别: " << logLevel << std::endl; std::cout << "数据库主机: " << dbHost << std::endl; // 打印全部配置 config.printAll(); return 0; }

2.3 关键细节与避坑指南

  1. 线程安全是核心:示例中getInstance()的线程安全由C++11标准保证。但注意,configData_的读写(getValue,loadConfig)也需要用mutex保护,因为nlohmann::json库本身不是线程安全的。如果配置在初始化后是只读的,那么getValue可以不加锁以提升性能,但loadConfig(写操作)必须独占。
  2. 延迟加载与静态局部变量static ConfigManager instance;这行代码只有在第一次调用getInstance()时才会执行构造。这是最优雅的延迟初始化。
  3. 防止拷贝和移动:通过= delete显式删除拷贝构造和赋值操作符,这是现代C++防止单例被意外复制的标准做法。
  4. 关于析构函数:单例的析构顺序在程序结束时可能是个问题,特别是当其他全局/静态对象在析构时还试图访问单例。因此,单例中应避免持有需要复杂清理的资源(如网络连接),或者确保其生命周期足够长。
  5. 可测试性的考虑:硬编码的单例会阻碍单元测试。一个改进方案是引入一个IConfigManager接口,让ConfigManager实现它。在生产代码中使用单例,而在测试中,你可以通过依赖注入的方式提供一个MockConfigManager。这需要一些框架设计上的配合。

实操心得:在真实项目中,我倾向于使用“单例接口+依赖注入容器”的方式来替代传统的静态getInstance。这样既保持了全局访问的便利性,又为测试和灵活性留了后路。但对于很多中小型项目或模块内部工具类,上述基于Magic Static的单例实现已经足够稳健和高效。

3. 简单工厂模式进阶:可扩展的压缩算法工厂

简单工厂模式并不属于GoF的23种设计模式,但它非常实用,常用于创建一系列具有共同接口但实现不同的对象。一个典型的“玩具”例子是创建不同的Shape。我们来做一个更贴近实际的:一个支持多种压缩算法(如ZIP, GZIP, 自定义LZ4)的压缩器工厂。

3.1 场景分析与设计思路

假设我们在开发一个文件备份工具或网络传输模块,需要根据文件类型、目标平台或用户选择来动态采用不同的压缩算法。每种算法都是一个独立的类,但它们都遵循相同的压缩/解压接口。简单工厂的核心价值在于将对象的创建逻辑集中管理,客户端无需关心具体的实现类。

设计要点:

  1. 抽象产品ICompressor接口,定义compressdecompress纯虚函数。
  2. 具体产品ZipCompressorGzipCompressorLz4Compressor等。
  3. 工厂类CompressorFactory,根据传入的枚举类型或字符串标识符,创建对应的压缩器实例。

3.2 支持注册机制的动态工厂实现

基础简单工厂使用switch-caseif-else,但添加新算法需要修改工厂类,违反了开闭原则。我们可以实现一个支持运行时注册的“准工厂”,提高扩展性。

#include <iostream> #include <memory> #include <unordered_map> #include <string> #include <functional> // 1. 抽象产品类 class ICompressor { public: virtual ~ICompressor() = default; virtual std::string compress(const std::string& data) = 0; virtual std::string decompress(const std::string& compressedData) = 0; virtual std::string getName() const = 0; }; // 2. 具体产品类 class ZipCompressor : public ICompressor { public: std::string compress(const std::string& data) override { // 模拟ZIP压缩逻辑 std::cout << "[ZipCompressor] 正在压缩 " << data.size() << " 字节的数据..." << std::endl; return "ZIP_COMPRESSED(" + data + ")"; } std::string decompress(const std::string& compressedData) override { std::cout << "[ZipCompressor] 正在解压数据..." << std::endl; // 模拟解压逻辑,移除假想的前缀 if (compressedData.find("ZIP_COMPRESSED(") == 0) { return compressedData.substr(15, compressedData.size() - 16); // 去掉头尾 } return compressedData; } std::string getName() const override { return "ZIP"; } }; class GzipCompressor : public ICompressor { public: std::string compress(const std::string& data) override { std::cout << "[GzipCompressor] 使用GZIP算法压缩..." << std::endl; return "GZIP_COMPRESSED(" + data + ")"; } std::string decompress(const std::string& compressedData) override { std::cout << "[GzipCompressor] 使用GZIP算法解压..." << std::endl; if (compressedData.find("GZIP_COMPRESSED(") == 0) { return compressedData.substr(16, compressedData.size() - 17); } return compressedData; } std::string getName() const override { return "GZIP"; } }; // 3. 可注册的工厂类 class CompressorFactory { public: using CreatorFunc = std::function<std::unique_ptr<ICompressor>()>; // 获取工厂单例(工厂本身也可以是单例) static CompressorFactory& getInstance() { static CompressorFactory instance; return instance; } // 注册创建器 bool registerCompressor(const std::string& compressorType, CreatorFunc creator) { if (creatorRegistry_.find(compressorType) != creatorRegistry_.end()) { std::cerr << "压缩器类型 '" << compressorType << "' 已注册!" << std::endl; return false; } creatorRegistry_[compressorType] = std::move(creator); std::cout << "成功注册压缩器: " << compressorType << std::endl; return true; } // 创建压缩器 std::unique_ptr<ICompressor> createCompressor(const std::string& compressorType) { auto it = creatorRegistry_.find(compressorType); if (it == creatorRegistry_.end()) { std::cerr << "未知的压缩器类型: " << compressorType << std::endl; // 可以返回一个默认的压缩器或nullptr return nullptr; } return it->second(); // 调用注册的创建函数 } // 列出所有支持的压缩器 void listSupported() const { std::cout << "支持的压缩器类型: "; for (const auto& pair : creatorRegistry_) { std::cout << pair.first << " "; } std::cout << std::endl; } private: CompressorFactory() { // 在构造函数中注册内置的压缩器 registerCompressor("ZIP", []() -> std::unique_ptr<ICompressor> { return std::make_unique<ZipCompressor>(); }); registerCompressor("GZIP", []() -> std::unique_ptr<ICompressor> { return std::make_unique<GzipCompressor>(); }); } ~CompressorFactory() = default; std::unordered_map<std::string, CreatorFunc> creatorRegistry_; }; // 4. 一个第三方或后续新增的压缩器 class Lz4Compressor : public ICompressor { public: std::string compress(const std::string& data) override { std::cout << "[Lz4Compressor] 高速LZ4压缩中..." << std::endl; return "LZ4(" + data + ")"; } std::string decompress(const std::string& compressedData) override { std::cout << "[Lz4Compressor] 高速LZ4解压中..." << std::endl; if (compressedData.find("LZ4(") == 0) { return compressedData.substr(4, compressedData.size() - 5); } return compressedData; } std::string getName() const override { return "LZ4"; } };

使用示例:

int main() { auto& factory = CompressorFactory::getInstance(); factory.listSupported(); // 动态创建压缩器 std::string data = "这是一段需要被压缩的原始数据。"; auto zipCompressor = factory.createCompressor("ZIP"); if (zipCompressor) { auto compressed = zipCompressor->compress(data); auto decompressed = zipCompressor->decompress(compressed); std::cout << "ZIP解压后数据: " << decompressed << std::endl; } auto gzipCompressor = factory.createCompressor("GZIP"); if (gzipCompressor) { auto compressed = gzipCompressor->compress(data); auto decompressed = gzipCompressor->decompress(compressed); std::cout << "GZIP解压后数据: " << decompressed << std::endl; } // 动态注册一个新的压缩器(例如,在插件加载时) bool registered = factory.registerCompressor("LZ4", []() -> std::unique_ptr<ICompressor> { return std::make_unique<Lz4Compressor>(); }); if (registered) { auto lz4Compressor = factory.createCompressor("LZ4"); if (lz4Compressor) { auto compressed = lz4Compressor->compress(data); auto decompressed = lz4Compressor->decompress(compressed); std::cout << "LZ4解压后数据: " << decompressed << std::endl; } } factory.listSupported(); // 现在应该包含LZ4 return 0; }

3.3 模式变体与经验总结

  1. 从“简单”到“可扩展”:基础的简单工厂模式在createCompressor函数里用switch判断compressorType。而上述“注册表”模式将创建逻辑分散到各个注册调用中,新增算法时无需修改工厂类,只需调用registerCompressor即可。这更符合开闭原则。
  2. 工厂方法的结合:当对象创建逻辑非常复杂,或者我们希望将产品的创建延迟到子类时,简单工厂会演变为工厂方法模式(每个具体产品对应一个具体工厂)。简单工厂更适合创建逻辑相对固定、类型已知且不多的场景。
  3. 返回智能指针:工厂返回std::unique_ptr<ICompressor>明确表达了所有权转移,避免了手动管理内存和内存泄漏的风险,这是现代C++的推荐做法。
  4. 错误处理:工厂方法应能处理未知类型请求。示例中返回了nullptr,客户端代码必须检查。也可以选择抛出一个标准异常(如std::invalid_argument),或者返回一个默认的“空对象”(Null Object)。

注意事项:这个“可注册工厂”本身也使用了单例模式来管理全局的注册表。在动态库(插件)加载的场景下,需要注意静态初始化顺序问题(Static Initialization Order Fiasco)。一个常见的解决方案是使用“显式初始化”函数,在插件加载后主动调用注册函数,而不是依赖静态变量的初始化。

4. 适配器模式实战:集成遗留的第三方SDK

适配器模式(Adapter)常用于解决接口不兼容的问题。教科书例子是把一个方形钉子适配到圆孔里。现实中,更常见的场景是:你需要集成一个老旧的、接口设计不佳的第三方库或遗留代码到你的新系统中。

4.1 真实场景:统一日志接口

假设你的新系统定义了一个简洁的日志接口IModernLogger,但你必须使用一个陈旧的第三方日志库LegacyLogger,它的函数名古怪、参数顺序反人类,而且不支持你的日志级别枚举。

目标接口(你的系统标准):

// 现代日志接口 class IModernLogger { public: virtual ~IModernLogger() = default; enum class Level { DEBUG, INFO, WARN, ERROR }; virtual void log(Level level, const std::string& message) = 0; };

需要适配的遗留类(你无法修改的第三方库):

// 陈旧的第三方日志库(假设是C风格接口或一个设计很差的类) class LegacyLogger { public: // 函数名奇怪,参数顺序是(消息, 严重程度数值),且级别定义不同 void recordLog(const char* msg, int severityCode) { std::cout << "[LegacyLogger] Severity(" << severityCode << "): " << msg << std::endl; } // 可能还有其他不一致的方法... };

4.2 对象适配器与类适配器的选择与实现

适配器模式有两种主要形式:对象适配器(使用组合)和类适配器(使用继承,在C++中需要多重继承)。在C++中,对象适配器更灵活、更常用,因为它不依赖于继承被适配者,可以适配一个类及其所有子类。

对象适配器实现:

#include <iostream> #include <string> // 适配器类:继承目标接口,组合被适配者 class LegacyLoggerAdapter : public IModernLogger { public: // 构造函数注入被适配的遗留对象(可以是引用或指针,这里用指针更灵活) LegacyLoggerAdapter(LegacyLogger* legacyLogger) : legacyLogger_(legacyLogger) { if (!legacyLogger_) { throw std::invalid_argument("LegacyLogger pointer cannot be null"); } } void log(Level level, const std::string& message) override { // 适配逻辑:将现代接口的参数转换为遗留接口所需的格式 int severityCode = convertLevelToSeverity(level); legacyLogger_->recordLog(message.c_str(), severityCode); } private: LegacyLogger* legacyLogger_; // 持有被适配对象的指针 // 转换函数:将现代枚举映射到遗留的严重程度代码 int convertLevelToSeverity(Level level) const { switch (level) { case Level::DEBUG: return 1; // 假设遗留库中1代表调试 case Level::INFO: return 2; // 2代表信息 case Level::WARN: return 3; // 3代表警告 case Level::ERROR: return 4; // 4代表错误 default: return 2; // 默认信息级别 } } };

使用示例:

int main() { // 遗留系统的日志对象 LegacyLogger oldLogger; // 创建适配器,将旧日志器适配到新接口 LegacyLoggerAdapter adapter(&oldLogger); // 现在可以使用现代接口来记录日志了 IModernLogger* logger = &adapter; logger->log(IModernLogger::Level::INFO, "系统启动成功。"); logger->log(IModernLogger::Level::WARN, "磁盘空间不足。"); logger->log(IModernLogger::Level::ERROR, "数据库连接失败!"); // 你的系统其他部分只依赖于IModernLogger接口,完全感知不到LegacyLogger的存在 return 0; }

4.3 处理更复杂的适配场景

现实中的适配可能更复杂:

  1. 接口粒度不匹配IModernLogger可能有一个logWithContext(Level, const string& msg, const LogContext& ctx)方法,而LegacyLogger只有recordLog。适配器需要在logWithContext内部将ctx的信息拼接到msg字符串中,或者调用多次recordLog
  2. 适配多个源:你可能需要同时适配LegacyLogger和另一个AnotherOldLogger,并提供一个统一的接口。这时,适配器内部可能需要根据配置或消息类型决定使用哪一个被适配对象,或者实现一个“多重适配器”。
  3. C风格函数适配:如果遗留库是C风格的函数(如void legacy_log(int code, const char* fmt, ...)),适配器类需要处理可变参数列表,将其转换为字符串后再调用。
// 示例:适配一个C风格的可变参数日志函数 extern "C" { void legacy_log(int severity, const char* format, ...); } class CVarargsLoggerAdapter : public IModernLogger { public: void log(Level level, const std::string& message) override { int sev = convertLevel(level); // 这里需要将std::string转换为C风格字符串并调用 // 注意:legacy_log是可变参数的,我们这里简单适配,假设它内部处理了格式化 // 更复杂的适配可能需要自己解析格式字符串,这里只是一个演示 legacy_log(sev, "%s", message.c_str()); } private: int convertLevel(Level level) { /* ... */ } };

4.4 适配器模式的边界与思考

  1. 何时使用:当你想使用一个已有的类,但其接口与你系统的其他部分不兼容时;或者当你需要创建一个可复用的类,该类需要与多个不兼容的接口协作时。
  2. 与外观模式(Facade)的区别:外观模式是为复杂的子系统提供一个简化的统一接口,它通常涉及多个类。而适配器模式主要是解决两个已有接口之间的不兼容问题,通常只涉及一个被适配者。适配器是“事后补救”,外观是“事前设计”。
  3. 性能开销:适配器层会引入一个微小的间接调用开销。在性能极其敏感的代码路径上需要评估。但对于日志、IO等操作,这点开销通常可以忽略不计。
  4. 保持适配器单一职责:一个适配器最好只做一件事——转换接口。不要在其中添加业务逻辑。如果转换逻辑非常复杂,考虑将其抽离到独立的“转换器”(Converter)类中。

踩坑实录:我曾经遇到一个第三方SDK,它的初始化函数init()必须在主线程调用,且调用后会产生一个全局回调。而我们的系统是事件驱动的。直接调用会阻塞事件循环。最终的解决方案是设计了一个SDKThreadAdapter,它继承自我们的IEventEmitter接口,内部封装了SDK对象和一个专用线程。适配器的init()方法将任务派发到专用线程执行,并通过信号/槽或回调将SDK的异步事件转换并转发到主线程的事件系统中。这已经超出了简单的接口转换,涉及到了线程边界的适配,是适配器模式一个更高级的应用。

5. 模式联合作战:一个简易插件系统的设计草图

在实际项目中,这些模式很少孤立存在。让我们构思一个简单的插件系统,看看它们如何协同工作:

  1. 单例模式:用于管理全局的PluginManager,它掌握所有已加载插件的信息。
  2. 简单工厂模式PluginManager内部可能有一个工厂,根据插件类型(如“音频过滤器”、“视频解码器”)创建统一的插件接口IPlugin的具体实例。这个工厂可以是可注册的,以支持动态插件。
  3. 适配器模式:如果某个插件是用C语言写的,或者接口与我们的IPlugin不匹配,我们就需要为它编写一个适配器类。

这个系统的大致框架如下:

// 目标插件接口 class IPlugin { public: virtual ~IPlugin() = default; virtual std::string getName() const = 0; virtual void initialize() = 0; virtual void execute() = 0; }; // 单例的插件管理器 class PluginManager { public: static PluginManager& getInstance(); bool loadPlugin(const std::string& path); std::unique_ptr<IPlugin> createPlugin(const std::string& type); // ... 其他管理函数 private: std::unordered_map<std::string, std::function<std::unique_ptr<IPlugin>()>> pluginFactoryRegistry_; // ... }; // 假设有一个遗留的C语言插件 extern "C" { typedef struct legacy_plugin_t legacy_plugin_t; legacy_plugin_t* legacy_plugin_create(); void legacy_plugin_do_work(legacy_plugin_t*); void legacy_plugin_destroy(legacy_plugin_t*); } // 适配器:将C插件适配到IPlugin接口 class LegacyPluginAdapter : public IPlugin { legacy_plugin_t* legacyPlugin_; public: LegacyPluginAdapter() : legacyPlugin_(legacy_plugin_create()) {} ~LegacyPluginAdapter() override { legacy_plugin_destroy(legacyPlugin_); } std::string getName() const override { return "LegacyPlugin (Adapted)"; } void initialize() override { /* C插件可能不需要显式初始化 */ } void execute() override { legacy_plugin_do_work(legacyPlugin_); } }; // 在PluginManager的初始化中注册适配器 PluginManager::getInstance().registerPluginFactory("LEGACY", []() { return std::make_unique<LegacyPluginAdapter>(); });

通过这样的组合,系统核心部分只与稳定的IPlugin接口交互,单例的PluginManager负责生命周期,工厂负责创建,适配器负责兼容。这使得系统核心非常稳定,而将变化(新增插件类型、集成老旧代码)隔离在了边缘模块中。

设计模式的价值不在于生搬硬套,而在于理解其背后“封装变化”、“面向接口编程”、“松耦合”的思想。当你面对一个具体的设计难题时,这些模式就像是工具箱里的标准件,能帮你更快地构建出清晰、健壮、易于维护的代码结构。希望这三个贴近实战的Demo,能让你下次在代码中应用单例、工厂和适配器时,更加得心应手。