ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

抽象工厂与单件模式:C++双数据库切换代码实现深度解析

抽象工厂与单件模式:C++双数据库切换代码实现深度解析

简介:一份软件构造课程实验报告,面向学习设计模式与数据库抽象层开发的软件工程专业学生。报告以徐奕同学2019年完成的抽象工厂模式与单件模式编程实验为基础,针对MySQL与SQL Server两种数据库,给出用户、部门、项目三类对象的具体实现,并说明如何借助单件模式确保工厂实例唯一,适合作为课程设计、实验报告撰写或设计模式复习的参考。包体为1个PDF文件,大小约301KB,内容包含实验UML图、IFactory抽象工厂、具体工厂类及主要函数代码分析,结构完整,便于对照阅读。该资源已有122人学习,对希望理解多产品族创建和全局唯一实例控制方法的读者具有直接参考价值。

1. 一个实验报告能讲明白的事:抽象工厂怎么管住两套数据库

这份实验报告表面上看是一份课程作业,实际是一个典型的「设计模式示范工程」:作者模拟了一个公司的数据库场景,用户表、部门表、项目表三张表都要支持查询和添加,而底层数据库可能是 MySQL 也可能是 SQL Server。如果老老实实写两套实现,每个数据操作都要写 if 判断走哪个分支,代码一多就乱成一团。抽象工厂模式正是解决这类「同一套业务、多个实现族」问题的标准答案——它把「选哪个数据库」这件事从业务代码里剥离出去,调用方只跟抽象的 IUser、IDepartment、IProject 打交道,至于背后是 MySQL 还是 SQL Server,由工厂决定。报告中还叠加了单件模式,让每个工厂在整个程序里只存在一个实例。适合人群很明确:正在学设计模式的软件工程学生、想给老系统换数据库的开发者、以及对「多个产品族 + 工厂唯一性」这类组合设计感兴趣的人。读完这份报告,你能把一个带双数据库切换能力的小型 C++ 工程从结构到代码逐层看透,甚至顺手改成自己项目里可用的底座。

2. 架构拆解:用户、部门、项目三个产品族与双工厂的职责划分

2.1 抽象工厂模式的本质:不是“一个大工厂”,而是“一组生产线”

初学抽象工厂模式时最容易犯的错,是把它理解成一个「啥都能造的超级工厂」。实际上抽象工厂模式解决的是「产品族」的问题:一份业务里通常有多个产品,比如用户、部门、项目,它们各自有不同数据库的实现版本。如果不用设计模式,你会写出createUserByMysql()、createUserBySqlServer()这种函数名满天飞的代码,每次新增数据库都要全局搜索修改。而抽象工厂的思路是:定义一组抽象产品接口(IUser、IDepartment、IProject),再为每个数据库定义一套「生产线」——SqlServerFactory 只生产 SqlServer 系列的三个产品,MysqlFactory 只生产 Mysql 系列的三个产品。调用方拿到一个工厂实例,就可以通过统一的CreateUser()、CreateDepartment()、CreateProject()方法创建出完整的一组产品对象。这组对象天然是配套的,不会出现「用户是 MySQL 的、部门是 SQL Server 的」这种混乱局面。

在此之上还有一层设计:每个工厂类同时用单件模式约束,保证全程序只有一套 MySQL 生产线或 SQL Server 生产线。为什么这样设计?因为产品对象本身是轻量的,工厂类内部不维护变化的状态,重复创建纯粹是浪费;更重要的是抽象工厂模式的应用里,一套业务通常只需要一个具体工厂实例就够了。两个模式配合起来,既解决了产品族的动态选择问题,又避免了工厂对象被反复创建的资源开销。

2.2 目录与类结构:13 个类的职责对照

整份代码没有分文件,所有类都写在同一个 main 级别的源文件里(实验报告场景一般如此,真实工程会按类拆文件)。但逻辑上可以清晰地划分出 13 个类,它们组成了「4 层结构」:

  • 抽象工厂层:IFactory,定义了创建产品对象的三个纯虚函数。
  • 抽象产品层:IUser、IDepartment、IProject 三个接口,分别定义 Insert 添加和 GetXxx 查询两个方法。
  • 具体产品层:每张表都有两个实现版本,共 6 个类。
类名所属层次核心职责
IFactory抽象工厂声明 CreateUser、CreateDepartment、CreateProject 三个接口
SqlserverFactory具体工厂(SQL Server 族)创建 SQL Server 版的用户/部门/项目对象,单件约束
MysqlFactory具体工厂(MySQL 族)创建 MySQL 版的用户/部门/项目对象,单件约束
User / Department / Project简单数据类各自携带 int id 字段,模拟一条表记录
IUser / IDepartment / IProject抽象产品接口定义 Insert 与 GetXxx 方法,与具体数据库解耦
SqlserverUser / SqlserverDepartment / SqlserverProject具体产品(SQL Server 族)实现 SQL Server 版的添加与查询(模拟输出)
MysqlUser / MysqlDepartment / MysqlProject具体产品(MySQL 族)实现 MySQL 版的添加与查询(模拟输出)

数据类(User、Department、Project)故意做成只有int id的极简类,因为这份实验的核心不是数据建模,而是展示「如何在不同实现族之间做隔离」。如果你想把它用到真实项目里,只需要在这些类里补充业务字段(比如用户名字段、部门名称字段、项目预算字段),并把具体产品类的Insert、GetXxx方法里的cout替换成真正的 SQL 语句执行逻辑。

2.3 单件模式在工厂层的作用:为什么不能有两个 MySQL 工厂

报告中有一句意图很明确:「注意使用单件模式写 MySQL 和 SQL Server 的具体工厂时,只能对自身单件」。这句话包含两个关键信息点。第一,单件是「各自单件」而不是「全局唯一」,程序运行期间可以同时存在一个 SqlServerFactory 和一个 MysqlFactory,但不允许同时存在两个 MysqlFactory。这个设计完全合理:切换数据库的诉求意味着不同数据库的工厂可能需要共存,而同一种工厂只需要一个。第二,单件的实现方式是「懒汉式」——第一次调用 GetInstance 时才创建实例,之后所有调用都返回同一个指针。懒汉式的优点是延迟加载、不用则已用则建,缺点是第一次访问时在多线程环境下有竞态问题,这个话题留到第 5 章避坑时再展开。

从报告给出的代码来看,两个具体工厂类都把构造函数放在private区,这样客户端就不能直接new SqlserverFactory(),只能通过GetInstance()获取。这是单件模式的标准姿势,用语言特性而不是口头约定来强制约束。

3. 源码走读:从 IFactory 到 SqlServer 与 MySQL 的两条生产线

3.1 IFactory 抽象工厂:工厂接口的三种创建方法

class IFactory{ public: virtual ~IFactory(){ cout << "抽象工厂实例删除!" << endl; } virtual IUser* CreateUser() = 0; virtual IDepartment* CreateDepartment() = 0; virtual IProject* CreateProject() = 0; };

三个纯虚函数对应三条产品生产线的入口。CreateUser返回的是 IUser 抽象类型指针,调用方拿到这个指针后,不需要关心具体是哪个数据库的实现。这里有一个细节值得注意:析构函数是virtual的,但函数体是空的。写成虚析构是正确的——因为后续具体工厂类会被IFactory*指针持有,删除基类指针时必须走虚析构才能正确释放派生类对象。不过实验中工厂对象实际上是单件且不显式删除的,所以这个析构函数大概率不会被调用。如果你要在自己的工程里用同样的设计,析构函数体里记得delete factory,否则静态指针指向的对象永远不会释放,跑一次不觉得,跑一万次循环就会看到内存只涨不掉。

3.2 三组具体产品类:同一套方法签名,两套实现逻辑

以用户产品为例,报告中的实现是这样的:

class IUser{ public: virtual void Insert(User* user) = 0; virtual User* GetUser(int id) = 0; }; class SqlserverUser: public IUser{ public: void Insert(User* user){ cout << "增加一个用户" << endl; } User* GetUser(int id){ cout << "得到一个用户" << endl; return NULL; } }; class MysqlUser: public IUser{ public: void Insert(User* user){ cout << "增加一个用户" << endl; } User* GetUser(int id){ cout << "得到一个用户" << endl; return NULL; } };

两个具体类的方法体一模一样,区别只是类名不同。这在实验代码里是正常的——它们的目的是展示「结构正确」,而不是「真的发出 SQL」。但你在真实项目里会替换这两处逻辑:SqlserverUser 的 Insert 里写EXEC 存储过程或拼 T-SQL 的 INSERT 语句,MysqlUser 的 Insert 里写INSERT INTO user_table VALUES (...);GetUser 同理,一个写SELECT * FROM users WHERE id=?(对应 SQL Server 的参数化写法),另一个写SELECT * FROM users WHERE id=?的 MySQL 版本(占位符风格是%s或?取决于驱动)。Department 和 Project 两组类的结构与此完全相同,唯一的差异不过是从「用户」换成了「部门」「项目」。

3.3 具体工厂与单件:两个 GetInstance 的实现差异

这是整段代码里最值得逐行读的部分,因为两个工厂的单件实现存在值得说道的差异:

class SqlserverFactory: public IFactory{ public: ~SqlserverFactory(){ cout << "SQL Server 抽象工厂实例删除!" << endl; } IUser* CreateUser(){ return new SqlserverUser(); } IDepartment* CreateDepartment(){ return new SqlserverDepartment(); } IProject* CreateProject(){ return new SqlserverProject(); } static IFactory *GetInstance(){ if (factory == NULL){ return new SqlserverFactory(); cout << "抽象工厂实例生成!" << endl; } else { cout << "Error, 抽象工厂实例已有!" << endl; } return factory; } private: SqlserverFactory(){} static SqlserverFactory* factory; }; SqlserverFactory* SqlserverFactory::factory = NULL;

这段代码有三个问题,任何一个放到真实工程里都是隐藏炸弹。第一,GetInstance里return new SqlserverFactory()之后紧跟着一行cout,这行输出是永远执行不到的不可达代码。第二,更致命的是这行return new只创建了对象,却没有把对象赋值给静态成员factory,所以第二次调用GetInstance()时factory仍然是 NULL,又会重新 new 一个实例,单件约束直接失效。第三,GetInstance的返回类型声明为IFactory*,实际返回SqlserverFactory*,虽然可以隐式转换,但语义上并不严谨。

再看 MySQL 工厂的实现作对比:

class MysqlFactory: public IFactory{ public: ~MysqlFactory(){ cout << "MySQL 抽象工厂实例删除!" << endl; } IUser* CreateUser(){ return new MysqlUser(); } IDepartment* CreateDepartment(){ return new MysqlDepartment(); } IProject* CreateProject(){ return new MysqlProject(); } static IFactory *GetInstance(){ if (factory == NULL){ cout << "抽象工厂实例生成!" << endl; factory = new MysqlFactory(); } else { cout << "Error, 抽象工厂实例已有!" << endl; } return factory; } private: MysqlFactory(){} static MysqlFactory* factory; }; MysqlFactory* MysqlFactory::factory = NULL;

MysqlFactory 的写法才是正确的单件:先打印日志,再把 new 出来的对象赋值给静态成员变量,最后返回该成员。两次调用GetInstance(),第二次因为factory != NULL走 else 分支打印「Error」。两份代码一对比,明显 SqlserverFactory 版本是一个手误或笔误,很可能是作者写完 SqlServer 版本后急着改 MySQL 版本,把赋值语句漏了。这也解释了为什么实验报告的结果图中「第二次生成 MySQL 工厂失败」——那个输出是 MySQL 工厂产生的,而 SqlServer 工厂如果真被客户端连续调用两次,逻辑上会翻车。报告给出的实验总结里也提到「注意使用单件模式时只能对自身单件」,但并没有意识到 SqlServer 这个函数体里的实现错误。读者在跑这份代码时如果发现「第二次居然也能 new 出 SqlServer 工厂」,不是设计问题,是这一行赋值漏了。

4. 客户端流程:从创建到输出“增加一个用户”的分步过程

4.1 main 函数调用链路与执行顺序

客户端的 main 函数是整份代码的入口,把前面的类全部串起来。流程可以拆成六个步骤:

int main(){ User* user = new User(); Department* dept = new Department(); Project* pro = new Project(); cout << endl << "MySQL" << endl; IFactory *factory1 = MysqlFactory::GetInstance(); IFactory *factory2 = MysqlFactory::GetInstance(); // 第二次获取,应失败 cout << "factory1 == factory2 ? " << (factory1 == factory2 ? "true" : "false") << endl; 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); return 0; }

注意这段代码是报告原文 main 函数的一个「可运行」补全版——原文里创建了 factory2 并调用了 CreateUser 但输出示例中只有 MySQL 字样;实际实验中应该能看到「Error, 抽象工厂实例已有!」。整个执行顺序是:先 new 三个数据对象(它们没有构造函数,等于三个空壳对象),然后第一次调用 GetInstance 触发单件的创建分支,打印「抽象工厂实例生成!」,同时静态指针 factory 被赋值为新工厂对象;第二次调用 GetInstance 时 factory 已非空,于是走 else 分支打印「Error, 抽象工厂实例已有!」,并返回同一个指针。之后的三组调用分别走 MySQL 版实现类的方法,每组都输出「增加一个XXX」「得到一个XXX」。注意 main 里没有 delete 任何对象,单件工厂对象也不建议 delete——程序退出后系统回收。

4.2 预期输出结果的逐行解读

如果按修正后的代码执行,标准输出应该是:

MySQL 抽象工厂实例生成! Error, 抽象工厂实例已有! factory1 == factory2 ? true 增加一个用户 得到一个用户 增加一个部门 得到一个部门 增加一个项目 得到一个项目

每一行输出都对应一段代码逻辑:「抽象工厂实例生成!」来自第一次 GetInstance 的 if 分支;「Error, 抽象工厂实例已有!」来自第二次 GetInstance 的 else 分支;factory1 == factory2判断为 true 是因为两次返回的是同一个单件指针;后面六行分别是三个产品族的 Insert 和 GetXxx 方法。如果直接跑报告里的原始代码而不做任何修改,SqlServer 工厂版本的问题可能不会暴露——因为 main 只在 MySQL 工厂上连续调用了两次 GetInstance。但如果你把 main 里的MysqlFactory替换成SqlserverFactory,运行结果会变成:第一次 GetInstance 返回了新对象但没存指针,第二次 GetInstance 因为 factory 仍然为 NULL,再次进入 if 分支,再次打印「抽象工厂实例生成!」——这就意味着「Error」永远不会出现,单件约束形同虚设。跑这种代码时,测试端口不应该只覆盖正常路径,至少要覆盖两次调用这个边界条件。

4.3 编译运行与实验环境

这份代码是标准 C++,不依赖任何第三方库。报告日期是 2019 年,实验环境大概率是 Visual Studio 或 Dev-C++,但用现代 GCC 也能直接编译。把除 main 之外的所有类定义按顺序粘贴到一个factory_demo.cpp文件里,然后执行:

g++ -o factory_demo factory_demo.cpp -std=c++11 ./factory_demo

-std=c++11是为了启用当前标准语法,实际这份代码用 C++98 也能编译——它只用到了类继承、虚函数、静态成员这些非常基础的特性。如果你在 Windows 的 Visual Studio 里运行,把 main 函数里的endl系列输出换成中文没问题,VS 默认编码的源文件需要用 UTF-8 保存,否则中文cout会乱码;这是实验环境里经常踩到的第一个坑。

5. 避坑清单:这段实验代码里的 5 个典型陷阱

5.1 第二次创建 SqlServer 工厂竟能成功

现象:按报告代码运行 SqlServerFactory::GetInstance() 两次,第二次仍然打印「抽象工厂实例生成!」,没有显示「Error, 抽象工厂实例已有!」。 原因:SqlserverFactory 的 GetInstance 里写了return new SqlserverFactory(),但少了一步factory = new SqlserverFactory()赋值操作,导致静态成员 factory 永远为 NULL,单件约束从逻辑上失效。对照 MysqlFactory 的实现,可以看到正确的顺序是「先赋值,再返回」。 解决:把 SqlserverFactory 的 GetInstance 修正为与 MysqlFactory 一致的写法:先判断factory == NULL,然后在 if 分支里factory = new SqlserverFactory(); cout << "抽象工厂实例生成!";最后统一return factory;。改完后重新编译运行,第二次调用就会正常走 else 分支。

5.2 中文字符的引号导致编译报错

现象:代码里大量出现cout << “增加一个用户”,用 GCC 编译时报错invalid character或expected expression,提示位置正好在开头引号附近。 原因:报告原文里的引号是中文全角引号「“」和「”」,C++ 语法要求字符串字面量必须用英文半角双引号包住。这些全角引号也许在 Word 里显示正常,但不属于合法的 C++ 词法单元。 解决:全文搜索替换两个字符:中文左引号和中文右引号,都替换成英文半角"。注意cout << "..."里的字符串内容可以保留中文,但引号本身必须是半角字符。这个工程最简单:在 IDE 里直接 Ctrl+F 输入全角引号(从代码中复制),替换为半角引号。

5.3 return new 后面的 cout 永远不执行

现象:SqlserverFactory::GetInstance() 中return new SqlserverFactory(); cout << "抽象工厂实例生成!" << endl;永远看不到打印出的日志,但代码看起来逻辑没问题。 原因:return 语句已经结束函数的执行,后面的语句是「不可达代码」,编译器会给出 warning(unreachable code),但不会报错。作者可能想先打印再返回,但把 return 的顺序写错了。 解决:把 return 移到函数末尾,将赋值和打印放在前面。正确顺序是:判断 —— 赋值 —— 打印 —— return。当你写任何「先输出再返回」的代码时,记住 return 是终结点,不要在其后追加任何逻辑。

5.4 懒汉式单件在多线程下不是线程安全的

现象:如果这个代码被改造成多线程应用,第一次有多个线程同时调用 GetInstance 时,可能出现 factory 被创建多次的情况——不同线程拿到不同指针。 原因:懒汉式单件「先判断再创建」是非原子操作,线程 A 判断完 factory == NULL 后,还没赋值,线程 B 也执行了判断,同样看到 NULL,于是两个线程各自 new 出一个实例。报告里的实验是单线程运行,所以看不到这个现象。 解决:最小改动是在 GetInstance() 内部加锁,C++11 可以用std::call_once配合局部静态变量实现线程安全的单件(又叫 Meyers Singleton)。或者干脆用「饿汉式」——在类外部直接初始化静态成员,甚至 C++11 之后可以写成static LocalSingleton& getInstance(){ static LocalSingleton instance; return instance; },由编译器保证局部静态变量的线程安全初始化。

5.5 静态成员变量的初始化位置容易漏写

现象:链接时出现undefined reference to 'SqlserverFactory::factory'的报错。没有这一行代码,编译能过,链接过不了。 原因:类内static SqlserverFactory* factory;只是声明,必须在类外部定义并初始化,报告里放在了类结束后的全局位置SqlserverFactory* SqlserverFactory::factory = NULL;。初学者容易把静态成员当成普通成员,只写声明不写定义。 解决:每个具体工厂类后面紧跟一行全局定义。另一个小陷阱:两个工厂类的静态成员名都叫 factory,但因为作用域不同(分别属于 SqlserverFactory 和 MysqlFactory),互不干扰。在实际工程中,你可以把两个定义放在类定义的下方一起维护,避免漏掉。

6. 二次开发思路:把双数据库切换改造成参数化工厂

拿到这份代码后,最自然的改造方向不是继续对着控制台输出,而是把它变成一个「可以动态选择数据库」的组件。原始设计里,客户端要确定用哪个数据库,只能手动 new 对应的工厂,例如MysqlFactory::GetInstance()。真实项目里数据库连接配置通常来自配置文件、环境变量或运行时参数,客户端不该写死数据库类型。我建议的改造是加一个「选择层」,用一个函数取代客户端的手动选择:

#include <string> #include <map> class FactoryRegistry { public: static IFactory* getFactory(const std::string& type) { static std::map<std::string, IFactory*> factories; if (factories.empty()) { factories["mysql"] = MysqlFactory::GetInstance(); factories["sqlserver"] = SqlserverFactory::GetInstance(); } auto it = factories.find(type); return it != factories.end() ? it->second : nullptr; } };

这个 Registry 用 map 维护数据库名与工厂实例的映射,工厂实例依然走单件,但客户端代码变成了IFactory* factory = FactoryRegistry::getFactory("mysql");。如果要新增 PostgreSQL 支持,只需要新增一个 PostgresqlFactory 和一组 PostgresqlUser、PostgresqlDepartment、PostgresqlProject,然后在注册表里加一行,main 函数和业务代码完全不用改。这正好体现了抽象工厂模式「新增一个产品族不影响既有代码」的开放封闭原则。

改造后的验证方法也很简单,写一个测试函数循环遍历注册表里的数据库类型,对每个工厂依次创建用户、部门、项目并调用 Insert 和 GetXxx,断言输出与预期一致。这个测试脚本同时也是很好的回归用例——以后改任何具体产品类,跑一遍就能知道哪些数据库分支被影响了。做完这个改造,这份实验代码就从「作业」变成了「可扩展的多数据库操作骨架」:数据类补字段、具体产品类补 SQL、工厂类补日志和资源回收。我当时改完这些之后,每次拿到一份设计模式示范代码,第一件事就是看它的单件写法是不是真的能拦住第二次创建,然后立刻写一个两次调用 GetInstance 的测试用例去验证,而不是直接跳进业务逻辑。这种「先验证模式约束、再填业务细节」的习惯,帮我在后续好几个项目里躲开了单件失效的坑,希望也能帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进