简介:《徐奕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 都实现 IUser,MysqlDepartment 和 SqlserverDepartment 都实现 IDepartment。把它们按两个维度排开,逻辑立刻清晰:
| 抽象产品接口 | MySQL 产品族 | SQL Server 产品族 |
|---|---|---|
| IUser | MysqlUser | SqlserverUser |
| IDepartment | MysqlDepartment | SqlserverDepartment |
| IProject | MysqlProject | SqlserverProject |
表格的“行”方向是产品等级结构:每一行内部是继承关系,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 与线程安全
实验的懒汉式写法在 C++11 之后有了更简洁的替代:
class MysqlFactory: public IFactory{ public: static IFactory& GetInstance(){ static MysqlFactory instance; // 函数内静态局部变量 return instance; } };C++11 标准规定,函数内静态局部变量的初始化是线程安全的——编译器会自动生成保护代码,确保多个线程同时首次进入时只有一个线程执行构造函数,其余线程等待初始化完成后直接复用。这种写法同时拿到了懒汉式的“延迟创建”和饿汉式的“线程安全”,还顺带解决了销毁问题:局部静态变量在进程退出时由运行时自动析构,不需要手动 delete。相比之下,实验里的手写指针版本既要在类外写初始化,又要考虑加锁,还永远等不到自动销毁——三个问题,一个解法全消。
如果用表格对比三种典型实现方式,取舍关系会更清晰:
| 实现方式 | 初始化时机 | 线程安全 | 销毁责任 |
|---|---|---|---|
| 饿汉式(类外静态指针 + main 前初始化) | 进程启动 | 基本安全,但有静态初始化顺序问题 | 进程退出时由系统回收 |
| 懒汉式(GetInstance 内判空 new) | 首次调用 | 不安全,需要加锁 | 需要手动 delete,易悬垂 |
| Magic Static(函数内 static 局部变量) | 首次调用 | C++11 起由标准保证 | 自动析构,无需干预 |
4. 避坑:这份实验代码里的单件实现,其实没写对
这一章的内容来自对实验报告源码的逐行核对。不夸张地说,两个工厂的 GetInstance 实现里各藏着一个让单件失效的 bug——它们恰好是最好的反面教材。以下每条都按“现象 → 原因 → 解决”展开。
4.1 return new 没赋值,单件全程失效
先看 SqlserverFactory 的原始实现:
static IFactory *GetInstance(){ if (factory == NULL ){ return new SqlserverFactory(); // 直接 return,factory 没被赋值 cout<<"抽象工厂实例生成!"<<endl; } else { cout<<"Error, 抽象工厂实例已有!"<<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,于是两条执行路径各自创建实例。懒汉式的固有缺陷,和写法是否规范无关。解决:C++11 之前用双重检查锁或 pthread_once;C++11 之后直接使用函数内静态局部变量,编译器会生成线程安全的初始化代码,不需要任何手工加锁。
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 章把两份工厂代码改成一致,再跑一遍看输出。
从那以后,我读任何设计模式代码,都强制自己先标出三个点:哪个类是入口,哪个状态是全局的,销毁责任在谁手里。三个点里任何一个含糊,模式再漂亮也只是纸老虎。希望帮到你。
本文还有配套的精品资源,点击获取