抽象工厂与单件模式组合实战:从C++代码看数据库无缝切换设计
简介《徐奕E21614061软件构造实验报告11》是一份软件构造课程的PDF实验报告面向需要掌握设计模式落地写法的学生和开发者重点演示抽象工厂模式与单件模式的结合使用。报告以模拟公司数据库操作为背景针对MySQL与SQL Server两种数据库定义了IFactory抽象工厂及其具体实现SqlserverFactory、MysqlFactory通过CreateUser、CreateDepartment、CreateProject三个方法分别创建用户、部门、项目三类对象同时将单件模式应用于工厂类利用私有构造和静态方法保证整个程序仅有一个工厂实例从而降低资源消耗并简化对象管理。全文包含实验目的、原理讲解、UML图、C代码分析与关键类实现覆盖IUser、IDepartment、IProject等抽象接口以及SqlserverUser、MysqlUser等具体子类的定义过程结构清晰能够帮助读者理解多产品族场景下的工厂组织方式并可直接仿照用于课程设计或项目开发。压缩包内为单个PDF文件大小301KB目前已有122人学习适合需要快速掌握设计模式应用的开发者和高校学生参考。1. 抽象工厂 单件为什么“切换数据库”会被设计得这么绕一个简单的需求公司数据库有用户、部门、项目三张表每张表支持添加和查询数据库在 MySQL 与 SQL Server 之间切换。直觉做法是写一组 if-else根据当前数据库类型创建不同的操作对象几分钟就能写完。但这份实验报告给出的方案却把同样的功能拆成抽象工厂加单件两个模式、十几个类三个抽象产品接口、六个具体产品类、一个抽象工厂、两个具体工厂外加“工厂只能有一个”的全局约束。第一眼看是过度设计真正的分水岭在“换数据库”那一瞬间if-else 版本里每个调用点都直接 new 具体类切换一次要改遍全工程抽象工厂版本里客户端只见 IUser、IDepartment、IProject 这些抽象接口换库只改一个工厂。理解这个组合的关键是搞清楚两个模式各自解决了哪一半问题——抽象工厂负责“产品族整体切换”单件负责“工厂入口全局唯一”。2. 抽象工厂产品族与产品等级结构如何被拆成两张正交的“表”2.1 三个抽象产品接口把“添加/查询”收敛成协议实验先定义了三个数据对象它们只带一个 int 类型的 id 字段User、Department、Project。数据对象本身没有任何行为纯粹是后续操作的参数容器。真正有语义的是三个抽象产品接口class IUser{ public: virtual void Insert(User* user) 0; virtual User* GetUser(int id) 0; }; class IDepartment{ public: virtual void Insert(Department* department) 0; virtual Department* GetDepartment(int id) 0; }; class IProject{ public: virtual void Insert(Project* project) 0; virtual Project* GetProject(int id) 0; };每个接口正好两个方法Insert 对应“添加”GetXxx 对应“查询”。注意这里的返回值类型是User*、Department*、Project*也就是客户端发起查询后拿到的是一个数据对象指针。这三个接口做的事情是把“对表的操作”按表分组定义成一套与数据库无关的协议。MySQL 和 SQL Server 的差异被协议挡在了接口后面。一个容易被忽略的细节是Insert 的参数是具体的数据对象指针而不是 void* 或模板参数。这意味着抽象产品接口和具体数据类之间仍有编译期依赖。在这个实验里没问题但如果数据对象的字段在不同数据库下差异很大这种设计会迫使接口变成最小公分母。这是产品等级结构建模的经典取舍后面第 6 章的边界分析会展开。2.2 产品族与产品等级结构一张表看懂六个具体类实验里最让人困惑的是类名高度重复MysqlUser 和 SqlserverUser 都实现 IUserMysqlDepartment 和 SqlserverDepartment 都实现 IDepartment。把它们按两个维度排开逻辑立刻清晰抽象产品接口MySQL 产品族SQL Server 产品族IUserMysqlUserSqlserverUserIDepartmentMysqlDepartmentSqlserverDepartmentIProjectMysqlProjectSqlserverProject表格的“行”方向是产品等级结构每一行内部是继承关系MysqlUser 是 IUser 的实现类。表格的“列”方向是产品族每一列内部是协作关系MysqlUser、MysqlDepartment、MysqlProject 三个类加起来组成“MySQL 全家桶”。抽象工厂模式的全部动机就是保证客户端在某一时刻拿到的所有产品都来自同一列绝不会出现“用户在 MySQL、项目在 SQL Server”这种跨族混搭。“产品族必须整体切换”这个需求本身源于真实系统里数据库切换是全局动作换库意味着连接串、方言、事务行为全部换掉只换一张表的存储引擎是没有意义的。抽象工厂把这种业务约束翻译成了代码结构——一个具体工厂只产出一个产品族的全部产品CreateUser()和CreateProject()返回的一定是同源的。2.3 具体工厂的分组逻辑一个工厂只认一个数据库抽象工厂接口定义了三个创建方法对应三个抽象产品class IFactory{ public: virtual ~IFactory(){ cout 抽象工厂实例删除 endl; } virtual IUser* CreateUser() 0; virtual IDepartment* CreateDepartment() 0; virtual IProject* CreateProject() 0; };两个具体工厂分别实现这套接口。以 MySQL 工厂为例class MysqlFactory: public IFactory{ public: IUser* CreateUser() { return new MysqlUser(); } IDepartment* CreateDepartment() { return new MysqlDepartment(); } IProject* CreateProject() { return new MysqlProject(); } };关键在返回类型接口声明返回IUser*具体工厂里实际new的是MysqlUser。这是 C 的协变返回类型在起作用——子类覆盖父类虚函数时允许把返回类型窄化成子类自己的类型。但更重要的是客户端会把这个指针交给基类指针保存之后所有操作都走虚函数。这里看不到任何if (dbType mysql)之类的分支数据库类型的选择被提前到了“选哪个工厂”这一步而不是分散到每个产品创建点。2.4 客户端依赖倒置全程序只出现 I 开头的类型实验的 main 函数提供了一个纯净的观察窗口。去掉单件调用后客户端逻辑实际只有六行IFactory *factory1 MysqlFactory::GetInstance(); IUser* iu factory1-CreateUser(); iu-Insert(user); iu-GetUser(1); IDepartment* idept factory1-CreateDepartment(); idept-Insert(dept); idept-GetDepartment(1); IProject* ipro factory1-CreateProject(); ipro-Insert(pro); ipro-GetProject(1);iu、idept、ipro三个变量的静态类型全部是抽象接口不包含任何 MySQL 字样。这是依赖倒置原则最直接的体现高层模块main 函数不依赖低层模块MysqlUser 这些具体类两者都依赖抽象IUser。编译期编译器看到的是 IUser 上声明的 Insert运行期虚函数表会把调用导向 MysqlUser::Insert。客户端保持“无知”正是抽象工厂能实现整体切换的前提。2.5 隐藏代价加一张表要动六个文件抽象工厂的设计目标是“换产品族的代价极低、加产品等级的代价线性增长”。假设系统要新增一张订单表 Order改动清单是新增 Order 数据对象新增 IOrder 抽象接口新增 MysqlOrder 和 SqlserverOrder 两个具体类IFactory 增加 CreateOrder() 声明MysqlFactory 和 SqlserverFactory 各增加一个 CreateOrder() 实现。合计六个文件。这就是为什么实验选了“恰好三张表、恰好两种数据库”的边界场景产品族数量稳定产品数量少抽象工厂的优势正好跑满维护成本还没开始膨胀。如果表数量持续增长抽象工厂会变成类爆炸的源头这一点在数据访问层设计里需要提前预判。3. 单件模式工厂的唯一性不是靠约定而是靠语言强制3.1 私有构造函数把 new 变成编译错误单件模式的第一个动作是把构造函数放进 private 区class MysqlFactory: public IFactory{ public: static IFactory *GetInstance(); IUser* CreateUser(); IDepartment* CreateDepartment(); IProject* CreateProject(); private: MysqlFactory(){} // 私有构造函数 static MysqlFactory* factory; // 全局唯一实例指针 }; MysqlFactory* MysqlFactory::factory NULL;MysqlFactory(){}放在 private 区之后任何写在类外部的new MysqlFactory()都会直接编译报错。访问控制在这里不是设计建议而是语言强制——客户端想要工厂对象唯一的合法入口是GetInstance()。这一点至关重要单件模式如果只靠“程序员约定我不 new 第二个”迟早会在某个加班深夜被绕过把构造函数私有化是从编译层面消灭了“创建第二个实例”这个动作的语法合法性。3.2 static 成员与类外初始化实例归“类”不归“对象”static MysqlFactory* factory是类级别的成员它不属于任何一个 MysqlFactory 对象而是属于整个类。C 的规则要求静态成员变量必须在类外定义一次MysqlFactory* MysqlFactory::factory NULL;这行代码出现在类定义之外完成了两件事为静态指针分配存储空间并把它初始化为 NULL。很多人在写单件时在这里踩坑——忘记类外定义会导致链接错误或者把初始化放在头文件里导致多编译单元重复定义。实验报告里特意在总结中强调“具体工厂的指针需要在类外部全局部分进行初始化为 NULL”正是指这一条。3.3 懒汉式执行路径第一次创建、之后拒绝实验的 GetInstance 采用典型的懒汉式写法static IFactory *GetInstance(){ if (factory NULL ){ cout 抽象工厂实例生成 endl; factory new MysqlFactory(); } else { cout Error, 抽象工厂实例已有 endl; } return factory; }第一次调用时factory 还是 NULL进入 if 分支创建实例并打印“抽象工厂实例生成”。第二次调用时factory 已经指向堆上的对象进入 else 分支打印 Error并原样返回已有指针。所以报告实验结果里特意标注“第二行是再次生成一个 MySQL 工厂失败”——factory1 和 factory2 指向同一个堆对象。这是懒汉式单件的核心语义延迟到首次真正需要时才创建之后每次调用都只是“取引用”。这种写法的代价是检查与创建两步之间存在竞态窗口第 4 章会专门展开线程安全问题。3.4 工厂为什么必须唯一状态、配置与产品族一致性把单件套在工厂上表面理由是“一个应用里一般每个产品只需要一个具体工厂的实例”深层原因有三个。第一工厂类本身无状态MysqlFactory 没有任何成员变量存放数据它的所有创建方法只是return new Xxx()多实例不会带来任何额外能力只会造成语义混乱。第二实际系统中的工厂通常要持有数据源配置——连接串、账号、密码、连接池句柄。如果允许存在两个 MysqlFactory就相当于允许两份 MySQL 配置同时生效改配置时只改了一个实例另一个还拿着旧连接串。第三工厂的唯一性天然保证了“当前正在用的是哪个产品族”这个全局事实只有一个答案。一旦 factory 实例唯一客户端从它手里拿到的产品对象就必然全部属于同一个产品族。这三条里第一条是“省内存”层面的效率动机第二、三条才是正确性动机。报告摘要里提到的“降低系统资源消耗、简化对象管理”实际上只覆盖了最表层的理由。3.5 现代 C 的推荐写法magic static 与线程安全实验的懒汉式写法在 C11 之后有了更简洁的替代class MysqlFactory: public IFactory{ public: static IFactory GetInstance(){ static MysqlFactory instance; // 函数内静态局部变量 return instance; } };C11 标准规定函数内静态局部变量的初始化是线程安全的——编译器会自动生成保护代码确保多个线程同时首次进入时只有一个线程执行构造函数其余线程等待初始化完成后直接复用。这种写法同时拿到了懒汉式的“延迟创建”和饿汉式的“线程安全”还顺带解决了销毁问题局部静态变量在进程退出时由运行时自动析构不需要手动 delete。相比之下实验里的手写指针版本既要在类外写初始化又要考虑加锁还永远等不到自动销毁——三个问题一个解法全消。如果用表格对比三种典型实现方式取舍关系会更清晰实现方式初始化时机线程安全销毁责任饿汉式类外静态指针 main 前初始化进程启动基本安全但有静态初始化顺序问题进程退出时由系统回收懒汉式GetInstance 内判空 new首次调用不安全需要加锁需要手动 delete易悬垂Magic Static函数内 static 局部变量首次调用C11 起由标准保证自动析构无需干预4. 避坑这份实验代码里的单件实现其实没写对这一章的内容来自对实验报告源码的逐行核对。不夸张地说两个工厂的 GetInstance 实现里各藏着一个让单件失效的 bug——它们恰好是最好的反面教材。以下每条都按“现象 → 原因 → 解决”展开。4.1 return new 没赋值单件全程失效先看 SqlserverFactory 的原始实现static IFactory *GetInstance(){ if (factory NULL ){ return new SqlserverFactory(); // 直接 returnfactory 没被赋值 cout抽象工厂实例生成endl; } else { coutError, 抽象工厂实例已有endl; } return factory; }现象连续调用两次SqlserverFactory::GetInstance()返回的两个指针地址不同单件完全没有“单”起来。原因return new SqlserverFactory()这条语句创建了堆对象并立即返回它的地址但没有把它赋值给静态成员 factory。factory 从头到尾保持 NULL所以每次调用都满足factory NULL每次都 new 一个新对象。解决必须先把新对象交给 factory再返回它static IFactory *GetInstance(){ if (factory NULL ){ cout 抽象工厂实例生成 endl; factory new SqlserverFactory(); } else { cout Error, 抽象工厂实例已有 endl; } return factory; }这个 bug 很有意思它语法完全合法编译器不会报错甚至第一次调用时功能看起来正常——只有在第二次调用时你才会发现“唯一性”根本没有实现。实验报告的实验结果里MySQL 工厂的行为是符合预期的SQL Server 工厂则会当场翻车两者的差异恰好暴露了这个问题。4.2 return 后面的 cout 是永远不会执行的死代码现象Sqlserver 版本的 GetInstance 无论执行多少次都看不到“抽象工厂实例生成”这一行输出而 MySQL 版本在首次调用时能正常打印。原因return new SqlserverFactory();之后的cout 抽象工厂实例生成位于同一个 if 块内但 return 已经终止了当前函数的执行流后面的语句永远不会被运行属于不可达代码。编译器通常会对这种情况给出 unreachable code 警告。解决把输出语句移到 return 之前——也就是上一节给出的修复版本。这不是风格问题而是一个信息误导开发者以为“生成实例时打了日志”实际上日志从没执行过排查问题时会被带偏。4.3 析构函数公有单件能被 delete 成悬垂指针现象客户端拿到IFactory* factory1 MysqlFactory::GetInstance();之后如果手滑执行delete factory1;编译照样通过。之后再调用GetInstance()依然返回同一个地址但该地址指向的内存已经被释放调用 CreateUser() 时行为完全随机——可能崩溃可能返回垃圾对象。原因仔细看类定义virtual ~IFactory()和两个具体工厂的析构函数都写在 public 区。构造函数私有堵住了 new析构函数公有却留了 delete 的口子。而且静态指针 factory 在 delete 之后不会被置空仍然指向已释放内存成为悬垂指针。解决把析构函数也放进 private 区或者干脆改用 magic static 返回引用把生命周期完全交给运行时管理。单件类的规则是“创建和销毁都只能由类自己控制”只堵住一半等于没堵。4.4 懒汉式多线程竞态两个线程拿到两个工厂现象假设上述 bug 已修复factory 赋值正常。两个线程同时首次调用 GetInstance()可能各自进入 if 分支各自 new 一个工厂对象返回两个不同地址——单件被多线程撕碎。原因if (factory NULL)是读取判断factory new MysqlFactory()是写入这两步之间不是原子的。线程 A 刚判断完 NULL 还未赋值时线程 B 也判断到了 NULL于是两条执行路径各自创建实例。懒汉式的固有缺陷和写法是否规范无关。解决C11 之前用双重检查锁或 pthread_onceC11 之后直接使用函数内静态局部变量编译器会生成线程安全的初始化代码不需要任何手工加锁。4.5 两份同构代码实现不一致复制粘贴留下的行为分叉现象把 SqlserverFactory 和 MysqlFactory 的 GetInstance 并排 diff会发现逻辑不完全一样——一个先赋值再返回一个 return new 忘赋值更隐蔽的是 MysqlFactory 的析构函数里打印的字符串竟然是“SQL Server 抽象工厂实例删除”复制粘贴后连文本都没改。原因这类“同一模式套两遍”的代码通常是复制后手改改动时只关注了 CreateUser 这些方法名忘了核对 GetInstance 和析构输出。解决代码评审时强制对同构类做 diff 审查。这一条也提醒所有读这份实验报告的人报告里的“实验结果”只验证了 MySQL 工厂成立SQL Server 工厂的单件性其实从未被验证过——拿同一份代码跑两组实验结果南辕北辙这比报错更危险因为报错至少会让你停下排查。5. 运行时行为main 函数里到底发生了什么5.1 完整调用链从 GetInstance 到 Insert 的三次断点把 main 函数压缩成可观察的最小版本在三个关键位置打断点int main(){ User* user new User(); IFactory *factory1 MysqlFactory::GetInstance(); // 断点 1 IUser* iu factory1-CreateUser(); // 断点 2 iu-Insert(user); // 断点 3 iu-GetUser(1); return 0; }断点 1 处发生了第一次堆分配MysqlFactory::GetInstance()发现静态指针为 NULL执行factory new MysqlFactory()在堆上创建工厂对象地址交给静态成员保存。断点 2 处factory1-CreateUser()是虚函数调用实际执行到MysqlFactory::CreateUser()它return new MysqlUser()在堆上又创建了一个产品对象返回类型从MysqlUser*窄化到IUser*后存入iu。断点 3 处iu-Insert(user)走虚函数表实际调用MysqlUser::Insert执行入参校验此处省略后打印“增加一个用户”。整个调用链有一个值得注意的特征堆上对象数量此时已有四个——User 数据对象一个、MysqlFactory 工厂一个、MysqlUser 产品一个。后两个都不是 main 函数直接 new 出来的而是工厂模式代为创建的。客户端负责业务顺序工厂负责对象生产职责边界清楚。5.2 虚函数表抽象指针为什么能调到正确的具体实现上一节的调用链能成立依赖于一个看似平常实则关键的机制——虚函数表。iu的声明类型是IUser*但它的动态类型是MysqlUser*。C 对象的内存布局里带有虚函数的类会有一个隐藏的 vptr 指针指向本类的虚函数表。MysqlUser的虚函数表中Insert 这一项指向MysqlUser::Insert的机器码地址。当客户端写iu-Insert(user)时编译器生成的不是直接调用指令而是两步间接寻址先通过iu取出 vptr再通过 vptr 找到虚表中 Insert 对应的函数指针然后跳转。这一步跳转就是整个抽象工厂模式在 C 里能 work 的地基。它意味着同一个IUser*如果指向 MysqlUser 就执行 MySQL 的逻辑如果指向 SqlserverUser 就执行 SQL Server 的逻辑而调用方代码一个字节都不用改。所谓“面向接口编程”落在汇编层面就是“通过 vptr 间接跳转”这六个字。没有虚函数机制抽象工厂的客户端代码就退化成一片需要手写 switch 的胶水层。5.3 切换数据库只改一行依赖倒置带来的直接收益现在可以回答第 1 章提出的问题了。切换数据库时main 函数只需要改一行IFactory *factory1 MysqlFactory::GetInstance(); // 改为 IFactory *factory1 SqlserverFactory::GetInstance();剩余所有代码——CreateUser、CreateDepartment、CreateProject 以及各自的 Insert、GetXxx——一个字母都不需要动。因为这些调用的静态类型全是抽象接口 IUser、IDepartment、IProject它们不感知具体数据库。对比 if-else 方案下切换数据库的工作量所有出现new MysqlUser()的地方都要变成new SqlserverUser()所有声明为MysqlUser*的局部变量都要改成SqlserverUser*如果这些调用点分布在十来个业务模块里那就是全工程扫荡式的改动漏掉一处编译期不报错、运行期才暴露。抽象工厂把切换成本从“改遍全工程”压缩到了“改一行工厂类型”这就是模式对可维护性的真实贡献。5.4 内存语义谁 new、谁 delete、谁来背锅把这份实验代码的内存语义彻底盘一遍main 函数里 new 了一个 User、一个 Department、一个 Project 三个数据对象GetInstance 内部 new 了一个工厂对象三个 CreateXxx 方法各 new 了一个产品对象。整个程序从头到尾没有一次 delete——进程退出时操作系统回收全部堆内存所以 demo 能跑完不崩。但真实系统不能这么写。工厂对象的所有权归 GetInstance如果让它裸 new 出来且不保存泄漏保存了但允许外部 delete悬垂。产品对象的所有权归调用方CreateUser 返回的指针由谁释放、何时释放必须在接口文档里写清楚否则会出现“工厂创建、业务释放、工厂复用”的混乱。数据对象则是最容易漏的——User 在栈上亦可非要 new 出来就得有人负责回收。把这个实验的内存语义想明白再去看现代 C 的 RAII 容器unique_ptr、shared_ptr配单件工厂的思路会豁然开朗指针所有权问题从来不是模式本身的义务而是使用者必须自己回答的问题。6. 设计取舍抽象工厂与单件的组合边界到底在哪抽象工厂加单件这套组合能 work前提藏在两个假设里产品族的数量稳定且族内产品整齐实验中三张表对应三个产品新增一种数据库只需加三个具体类和一个具体工厂代价可接受工厂本身无状态没有多实例的业务理由单件约束不会成为负担。这两个假设一旦松动组合的优势就开始反噬。表数量快速增长时每加一张表要新增一个抽象接口、两个具体产品类、三个工厂方法声明和两个实现合计六处改动类数量随表数量线性膨胀到后期维护成本会超过收益。这时候更合适的思路是把操作收敛成数据源无关的通用接口配合注册表或依赖注入来切换实现。反过来如果工厂内部要持有连接池、要缓存元数据它就有了真实状态“全局唯一”就不再是纪律而是正确性要求——两个连接池同时存在等于两份配置同时生效行为无法预期。验证自己是否真懂这套设计可以做一个五分钟的实操把报告里两个工厂的 GetInstance 分别跑两遍打印返回指针的地址。会发现 MySQL 工厂返回同一个地址SQL Server 工厂返回两个不同地址——同一个模式两种命运。语言机制没有约束住设计意图时模式就只是画在纸上的 UML。修完 bug 后再试着加一张 Order 表亲手走一遍六个文件的改动流程比读十篇模式讲解都直观。这份完整实验报告含 UML 图、两个工厂的完整代码与实验结果截图可以直接获取阅读时建议对照第 4 章把两份工厂代码改成一致再跑一遍看输出。从那以后我读任何设计模式代码都强制自己先标出三个点哪个类是入口哪个状态是全局的销毁责任在谁手里。三个点里任何一个含糊模式再漂亮也只是纸老虎。希望帮到你。本文还有配套的精品资源点击获取