设计模式课程总复习(基于C++的设计模式实践)
北京大学 王亚沙 老师课程期末复习,共146页PPT,涵盖C++语言进阶、面向对象设计5大原则、8种经典设计模式。
一、C++语言进阶
1. 面向对象三大特征
封装(Encapsulation)
- 将数据和操作数据的方法绑定在一起,对外隐藏内部实现
- 访问控制:public / protected / private
- 好处:降低复杂性、提高安全性、便于维护
继承(Inheritance)
- “is a”关系,派生类是基类的具体化
- 语法:
class Y : public X { ... }; - 构造函数、析构函数、重载的=操作符不会被自动继承
- 构造函数调用顺序:从继承树的根节点开始,直至叶子节点;成员对象按类中声明顺序初始化(与初始化表达式无关)
多态(Polymorphism)
- 同一属性或服务名在一般类及其特殊类中具有不同语义
- “Polymorphism allows a client to treat different objects in the same way even if they were created from different classes and exhibit different behaviors.”
2. 组合 vs 继承
| 关系 | 含义 | 例子 |
|---|---|---|
| 组合 | ”has a” — 组装出的新类 | 汽车由轮胎、发动机、车门、车身组成 |
| 继承 | ”is a” — 派生类是基类的具体化 | 汽车是一种交通工具,奔驰跑车是一种汽车 |
选择原则:概念上是”是一种”用继承,概念上是”有一个”用组合。
3. 向上映射(Upcasting)
定义:将派生类的对象、引用或指针转变为基类对象、引用、指针的活动。
为什么可以向上映射:
- 概念上:施加在一般对象上的操作一定可以施加在特殊对象上
- 实现上:每个派生类对象内存中必然保存着一个基类对象
向上映射的危机:
- 向上映射后行为总是基类的行为,而非派生类的行为
- 根源:早绑定(静态绑定/编译时绑定)
- 解决之道:晚绑定(动态绑定/运行时绑定) → 虚函数
4. 动态绑定(虚函数)的实现原理
三步机制:
-
设置虚表(VTABLE)
- 每个包含虚函数的类(或其派生类)有一个VTABLE
- VTABLE中存放所有声明为virtual的函数地址
- 派生类未重定义则使用基类地址
- 所有VTABLE中虚函数地址的顺序完全相同
-
初始化虚指针(VPTR)
- 每个对象中放置VPTR(通常在对象开头)
- VPTR指向相应的VTABLE
-
插入虚函数调用代码
- 通过基类指针调用虚函数时,编译器插入代码
- 通过VPTR找到VTABLE,根据偏移量找到正确的函数地址
示例:
class base {
public:
virtual void f1() { cout << "base f1" << endl; }
virtual void f2() { cout << "base f2" << endl; }
virtual void f3() { cout << "base f3" << endl; }
};
class derived : public base {
int j;
public:
void f2() { cout << "derived f2" << endl; }
};
base* bp = &d;
bp->f2(); // 调用 derived::f2() — 动态绑定汇编码示意:
push si // 存放this指针
mov bx, word ptr[si] // bx = VPTR → VTABLE
call word ptr[bx+4] // 调用VTABLE中第2个函数(f2)
add sp, 4 // 退栈
5. 纯虚函数与抽象基类
- 纯虚函数语法:
virtual void x() = 0; - 抽象基类:包含纯虚函数的类,不能实例化
- 意义:确立派生类的公共接口,阻止直接创建基类对象
- 抽象基类的VTABLE是不完全的
6. C++其他重要语法
构造函数与析构函数
- 构造函数:对象创建时自动调用,用于初始化
- 析构函数:对象销毁时自动调用,用于清理资源
- 析构函数应声明为虚函数(多态销毁时保证正确调用派生类析构)
重载与缺省参数
- 重载:同名函数,参数列表不同(类型或数量)
- 实质:编译器做name mangling(名字修饰)
- 不能用返回值区分重载(调用时无法判定)
- 缺省参数:参数列表后部的参数可设默认值
- 只在声明时指出,定义处不重新定义
const关键字
-
值替代 — 用const代替#define
- 类型检查、常量折叠
- 通常不分配空间(存在符号表中),取地址时才分配
- const用于集合(数组)会分配内存,不能编译时使用
-
指针与const
- 指向const的指针:
const int* x;或int const* x;(指向的值不可改) - const指针:
int * const x = &d;(指针本身不可改) - 赋值规则:变量地址可赋给常量指针,反之不成立
- 指向const的指针:
-
函数参数和返回值
- 传const值:函数创建者的工具,保护参数不被修改
- 返回const值:对自定义类型,返回const值不能作左值
引用
- 引用是别名,不是指针
- 引用必须初始化,不能重新绑定
- 引用作为函数参数:避免拷贝,可修改实参
- 引用作为返回值:返回左值,可链式调用
拷贝构造函数
- 签名:
X(const X&) - 缺省情况:bit-copy(逐位拷贝)
- 问题:
- 对象构造/析构不能正确调用,无法维护完整性
- 对象内部有对其他对象的引用时,bit-copy不合适
- 深拷贝 vs 浅拷贝:需要自己管理资源的类必须实现深拷贝
静态成员
- 静态数据成员:类的所有对象共享,在类外初始化
- 静态成员函数:只能访问静态成员,没有this指针
二、面向对象的设计原则
每个原则都应了解其内涵,并能举例说明违反该原则会带来什么问题。
1. 单一职责原则(SRP, Single Responsibility Principle)
陈述:就一个类而言,应该只有一个导致其变化的原因。
分析:
- 一个职责就是一个变化的轴线
- 多职责耦合在一起 → 脆弱性的臭味
- 一个职责的变化可能削弱或抑制类完成其他职责的能力
示例:Rectangle类
- 问题:Rectangle同时负责”计算面积”(数学模型)和”绘制矩形”(GUI)
- 违反SRP的后果:
- 包含不必要的代码(计算面积的应用被迫包含GUI代码)
- 无关原因导致应用失败(GUI需求变化导致计算几何应用也要重新编译部署)
- 解决方案:拆分为GeometricRectangle(数学)和GraphicalRectangle(GUI)
2. 开放封闭原则(OCP, Open-Closed Principle)
陈述:软件实体(类、模块、函数等)应该是可以扩展的,同时是不必修改的。
- 对扩展开放:需求变化时可以扩展模块功能
- 对更改封闭:扩展时不必改动已有源代码或二进制代码
分析:
- 世界是变化的,软件必须能扩展
- 任何修改都改已有代码 → 牵一发动全身 → 雪崩效应 → 质量下降
示例:Shape绘图(C语言版 vs C++ OOP版)
C语言版(违反OCP):
// 用switch-case处理不同形状
switch (s->itsType) {
case square: DrawSquare((Square*)s); break;
case circle: DrawCircle((Circle*)s); break;
}- 问题:增加三角形需要修改Shape、Square、Circle、DrawAllShapes
- 症状:僵化(大量重新编译部署)、脆弱(switch/if难维护易出错)、牢固(复用困难)
OOP版(符合OCP):
class Shape {
public:
virtual void Draw() const = 0;
};
void DrawAllShapes(Vector<Shape*>& list) {
for (auto i = list.begin(); i != list.end(); ++i)
(*i)->Draw();
}- 新增Triangle只需新增一个类,不改动已有代码
OCP的”谎言”:
- 没有对所有变化都封闭的模型
- 必须有策略地选择封闭哪些变化 → 封闭最可能出现的变化
- 需要领域知识、经验和常识
- 敏捷思想:预测变化,但直到真正发现才行动(过早抽象反而增加复杂度)
3. 里氏替换原则(LSP, Liskov Substitution Principle)
陈述:子类型必须能够替换它们的基类型。
Barbara Liskov的原始表述:
若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P的行为功能不变,则S是T的子类型。
分析:
- 违反LSP → 程序脆弱性 → 违反OCP
- 如果f(Base* p)传入Derived实例会出错,则Derived对f是脆弱的
- 写测试保证d传给f时行为正确 → 违反OCP(f无法对Base的所有派生类封闭)
经典示例:正方形与长方形
class Rectangle {
double width, height;
public:
virtual void setWidth(double w) { width = w; }
virtual void setHeight(double h) { height = h; }
double getWidth() const { return width; }
double getHeight() const { return height; }
};
class Square : public Rectangle {
public:
void setWidth(double w) { Rectangle::setWidth(w); Rectangle::setHeight(w); }
void setHeight(double h) { Rectangle::setWidth(h); Rectangle::setHeight(h); }
};问题函数:
void g(Rectangle& r) {
r.setWidth(5);
r.setHeight(4);
assert(r.Area() == 20); // Square会失败!
}核心洞见:
- “正方形是一种长方形”在数学上成立,但在OOD中不一定成立
- OOD中对象之间是否存在IS-A关系,应该从行为的角度来看待
- 行为依赖于客户程序做出的合理假设
- 孤立地看无法判断模型有效性 → 必须从使用者的假设来审视
4. 依赖倒置原则(DIP, Dependency Inversion Principle)
陈述:
- 高层模块不应该依赖于低层模块。二者都应该依赖于抽象。
- 抽象不应该依赖于细节。细节应该依赖于抽象。
分析:
- “倒置”是相对于传统结构化方法(高层依赖低层)而言
- 高层包含应用策略和业务模型,低层包含实现细节和平台相关细节
- 高层依赖低层的后果:
- 难以复用:换平台需要逐层更改
- 难以维护:低层通常是易变的
示例:Button与Lamp
不成熟的设计(违反DIP):
- Button直接依赖Lamp → Lamp变化影响Button → Button无法复用来控制Motor
依赖倒置的设计(符合DIP):
- Button依赖于抽象接口(ButtonServer)
- Lamp实现ButtonServer接口
- 高层策略是”检测用户的开/关指令”,与具体机制无关
5. 接口隔离原则(ISP, Interface Segregation Principle)
(本PPT中5个原则,第5个是ISP,内容在课件中)
陈述:客户端不应该被迫依赖它们不使用的方法。
分析:
- 胖接口(Fat Interface)导致客户端依赖不需要的方法
- 接口污染:一个接口包含太多方法,实现类被迫实现无用方法
- 解决方案:将胖接口拆分为多个小而专的接口
三、8种设计模式
1. Façade(外观)模式
问题场景:照相机拍摄模式
- 菜鸟拍照需要调光圈、焦距、闪光灯、微距……太复杂
- 夜间肖像模式:相机自动配置好所有参数,一键拍摄
软件中的对应问题:
- 系统复杂,分而治之 → 多个子系统、多个模块
- 客户只关注特定功能,却需要与诸多模块交互
- 后果:使用难度增加、系统逻辑复杂、维护成本高(客户端紧耦合内部模块)
解决方案:Façade模式
- 提供一个统一的高层接口,封装子系统的复杂性
- Façade类作为客户端与子系统之间的中介
结构:
Client1 ─┐
Client2 ─┼─► Façade类 ─┬─► 子系统1
Client3 ─┘ ├─► 子系统2
├─► 子系统3
└─► 子系统4
要点:
- 简化使用接口,降低客户端与子系统的耦合
- 不阻止客户端直接访问子系统(需要时仍可深入)
- 常用于:为复杂子系统提供简单入口、分层设计中每层的入口
2. Adapter(适配器)模式
意图:将一个类的接口转换成客户希望的另一个接口。使原本由于接口不兼容而不能一起工作的类可以一起工作。
两种形式:
- 对象Adapter:Adapter持有Adaptee的引用(组合)——更灵活,推荐
- 类Adapter:Adapter同时继承Target和Adaptee(多继承)——C++特有
示例:画图程序
- 客户类通过Shape接口操作所有图形
- 需要新增Circle,但已有一个XXCircle类接口不符合Shape要求
- 用Adapter包装XXCircle,使其符合Shape接口
适用场景:
- 使用已有类,但其接口不符合需求
- 创建可复用的类,与未知接口的类协作
- (对象Adapter)需要适配多个已有类的子类
3. Strategy(策略)模式
问题:一个问题有多种可选策略,概念上功能相同但适用环境不同。
反例:电子商务订单系统的类爆炸
- 初始:SalesOrder + 3国税计算 → 3个派生类
- 增加打印格式(大单/小单/固定票据)→ 3×3 = 9个类
- 增加GUI输入方式(Browser/Client)→ 3×3×2 = 18个类!
症状:
- 类的个数迅速失控
- 代码大量重复、冗余
- 复用无法进行
解决方案:Strategy模式
- 封装变化:将每个策略封装为独立的类(策略类)
- 策略类实现共同的接口
- 上下文(Context)持有策略接口的引用,运行时可切换策略
效果:乘法变加法
- 3国税 × 3打印格式 × 2GUI = 18类(继承)
- 3 + 3 + 2 = 8个策略类 + 1上下文类(Strategy模式)
适用场景:
- 多个类只在算法/行为上不同
- 需要在运行时动态切换算法
- 算法有复杂的条件判断(用Strategy替代if-else/switch)
4. Bridge(桥接)模式
故事:蜡笔与毛笔
- 蜡笔:3种粗细 × 12种颜色 = 36只蜡笔
- 毛笔:3只毛笔 + 12种颜料 = 15件物品
核心思想:将抽象(abstraction)与实现(implementation)分离,使二者可以独立变化。
- 毛笔 = 抽象(高层操作对象)
- 颜料 = 实现(底层具体功能)
- 毛笔使用颜料作画,但画家不需要知道颜料如何染色
意图:将抽象与其实现解耦,使它们可以独立变化。
示例:日志记录工具
传统设计(违反Bridge):
- 维度1:记录方式(DatabaseLog / FileLog)
- 维度2:运行平台(.NET / Java)
- 类数量:2 × 2 = 4个(扩展后乘法爆炸)
- 问题:违反SRP(两个变化原因)、重复代码、扩展性差
Bridge模式设计:
- 抽象层:Log(抽象接口)
- 实现层:LogImpl(实现接口)
- 抽象端可独立扩展(FileLog / DatabaseLog)
- 实现端可独立扩展(NetLogImpl / JavaLogImpl)
适用场景:
- 类有两个独立变化的维度
- 不希望使用继承(导致类爆炸)
- 需要在运行时切换实现
5. Abstract Factory(抽象工厂)模式
两个关注点:
- 关注点分离:对象的创建与对象的使用分离
- 对象成套创建:确保产品族内的对象配套使用
关注点分离
问题示例:图形显示系统
- 根据硬件配置选择驱动(高/低分辨率的显示驱动 + 打印驱动)
- 不好的设计:在ApControl构造函数中用switch-case创建具体驱动
- 问题:对象创建与对象使用混在一起,Client依赖具体类
逐步改进:
- 引入工厂类(CreateLRD / CreateHRD)→ 分离了创建和使用
- 但Client仍依赖具体工厂类 → 未完全解耦
- 引入抽象工厂基类(CreateRD)→ Client仅依赖抽象接口
- 具体工厂在Client外部创建,通过参数传入 → 完全解耦
对象成套创建
示例:动物世界游戏
- 美洲:美洲虎(食肉)+ 美洲羊(食草)
- 非洲:非洲虎(食肉)+ 非洲羊(食草)
不好的设计:
if (position == "美洲") {
a = new 美洲虎; b = new 美洲羊;
} else {
a = new 非洲虎; b = new 非洲羊;
}问题:
- 依赖具体类的创建细节(new 美洲虎散布各处)
- 无法从设计上约束产品族配套(可能出现”美洲虎吃非洲羊”的关公战秦琼)
Abstract Factory模式:
- 抽象工厂接口:
class ContinentFactory { virtual Carnivore* CreateCarnivore(); virtual Herbivore* CreateHerbivore(); } - 具体工厂:AmericaFactory、AfricaFactory
- 每个具体工厂创建一整套配套产品
意图:为特定类型的应用创建一个对象族。
适用场景:
- 系统需要独立于产品的创建、组合和表示
- 产品族需要一起使用(配套约束)
- 提供产品类库,只想暴露接口而非实现
6. Decorator(装饰器)模式
问题:给对象动态添加功能,而非给整个类添加功能。
反例:汽车功能定制
- 基础:汽车(行驶、转向、停止)
- 扩展1:豪华房车(天窗、GPS、防弹、冰箱)
- 扩展2:超炫跑车(顶棚、8档变速、涡轮加速、自动驾驶)
- 定制问题:用户自由组合功能 → 可能的组合有2^8 = 256种!
- 继承无法应对(类爆炸)
解决方案:Decorator模式
- 装饰器与被装饰对象实现相同的接口(Component)
- 装饰器持有一个Component的引用
- 装饰器在转发调用的前后添加自己的功能
- 可以多层嵌套装饰
结构:
Component (接口: Operation())
├── ConcreteComponent (基本功能)
└── Decorator (持有Component引用,转发调用)
├── ConcreteDecoratorA (添加功能A)
└── ConcreteDecoratorB (添加功能B)
示例:TextView组件
- 基本功能:TextView(显示文本)
- 可选装饰:ScrollDecorator(滚动条)、BorderDecorator(边框)
- 组合方式:
// 最简单 vp = tv; // 有边框 vp = new BorderDecorator(tv, 1); // 有滚动条 vp = new ScrollDecorator(tv); // 既有边框又有滚动条 vp = new BorderDecorator(new ScrollDecorator(tv), 1);
要点:
- 比继承更灵活:运行时动态添加/移除功能,可任意组合
- 避免类爆炸:n个装饰器有2
- 缺点:产生很多小对象,调试困难;装饰顺序敏感
7. Observer(观察者)模式
问题:对象状态的改变导致其他对象状态变化(一对多依赖)。
经典例子:MFC的Doc-View结构
- Doc存数据,View负责展示
- Doc变化时,所有View自动刷新
反例:电子商务新用户注册
方案一(最差):Customer类内部直接调用欢迎邮件和地址验证
- 问题:紧耦合 → 僵化、牢固、难复用
方案二(稍好):Customer调用独立的WelcomLetter和AddrVerification类
- 问题:仍紧耦合具体类,addCustomer必须知道要通知哪些对象,新增功能需修改Customer
解决方案:Observer模式
- 两类对象:Subject(主体/被观察者) 和 Observer(观察者)
- 一对多关系:一个Subject可以有多个Observer
- 动态注册:Observer运行时向Subject注册/注销
- 通知机制:Subject事件发生时,通知所有注册的Observer
结构:
Subject Observer
+ Attach(obs) + Update()
+ Detach(obs)
+ Notify()
Customer (Subject) WelcomLetter AddrVerification
+ setCustomerInfo() + Update() + Update()
+ getCustomerInfo()
进阶:Observer + Adapter
- 如果已有类(WelcomLetter、AddrVerification)接口不一致
- 用Adapter模式包装成统一的Observer接口
适用场景:
- 一个对象的改变需要其他对象跟着改变
- 不知道有多少对象需要改变
- 对象之间松耦合的通知机制
8. Template Method(模板方法)模式
两类问题:
- 复用问题:框架结构相似,细节不同 → 如何复用代码?
- 例:数据库查询,Oracle和SQL Server语法略有不同,但流程相同(连接→构造语句→执行)
- 约束和规则:希望某些规则不会被违反 → 如何一次性编码约束?
- 例:使用临界资源必须遵守”进入临界区→使用→退出临界区”的流程
思路:
- 写一个Template,编码高层策略、规则、流程
- 具体细节通过继承扩展
结构:
- 抽象基类:定义模板方法(Template Method)+ 抽象原语操作(Primitive Operation)
- 模板方法:固定算法骨架/流程,调用原语操作
- 原语操作:子类重写实现具体细节
- 具体子类:实现原语操作
示例:View绘图约束
class View {
public:
void display() { // 模板方法 — 编码约束
setFocus(); // 步骤1:获得焦点(固定)
doDisplay(); // 步骤2:具体绘图(子类实现)
resetFocus(); // 步骤3:释放焦点(固定)
}
protected:
virtual void doDisplay() = 0; // 原语操作
private:
void setFocus();
void resetFocus();
};
class MyView : public View {
protected:
void doDisplay() { /* 具体绘图逻辑 */ }
};要点:
- 模板方法编码不变的部分(算法骨架、约束规则)
- 原语操作封装变化的部分(具体实现)
- 保证约束不被违反(约束在基类中一次性编码)
- 代码复用:公共逻辑上提到基类
四、模式与原则的关系
| 设计模式 | 主要体现的原则 |
|---|---|
| Façade | 迪米特法则(最少知识)、降低耦合 |
| Adapter | OCP(扩展新接口不修改原有代码) |
| Strategy | OCP、SRP(策略与上下文分离) |
| Bridge | SRP(两个维度各负其责)、OCP |
| Abstract Factory | DIP(依赖抽象)、OCP |
| Decorator | OCP(动态扩展)、SRP(每个装饰器一个职责) |
| Observer | OCP、松耦合 |
| Template Method | OCP、DRY(复用骨架代码) |
五、考试要点
题型
- 选择题(20分):以C++语言部分为主
- 简答题(35分):C++语言、原则、模式都有
- 问答题(10分):设计模式
- 编程题(35分):
- 给出软件需求 + 不好的设计方案(类图)
- 要求:① 指出缺陷 ② 给出改进后的设计 ③ 说明使用了哪些设计模式
复习建议
- C++部分:虚函数/动态绑定机制、拷贝构造函数、const、引用、继承与组合
- 原则部分:每个原则的内涵 + 举例说明违反后的问题
- 模式部分:每个模式的意图、结构、适用场景、能画出UML、能写出核心代码
关联笔记: