设计模式课程总复习(基于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. 动态绑定(虚函数)的实现原理

三步机制:

  1. 设置虚表(VTABLE)

    • 每个包含虚函数的类(或其派生类)有一个VTABLE
    • VTABLE中存放所有声明为virtual的函数地址
    • 派生类未重定义则使用基类地址
    • 所有VTABLE中虚函数地址的顺序完全相同
  2. 初始化虚指针(VPTR)

    • 每个对象中放置VPTR(通常在对象开头)
    • VPTR指向相应的VTABLE
  3. 插入虚函数调用代码

    • 通过基类指针调用虚函数时,编译器插入代码
    • 通过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关键字

  1. 值替代 — 用const代替#define

    • 类型检查、常量折叠
    • 通常不分配空间(存在符号表中),取地址时才分配
    • const用于集合(数组)会分配内存,不能编译时使用
  2. 指针与const

    • 指向const的指针:const int* x; 或 int const* x;(指向的值不可改)
    • const指针:int * const x = &d;(指针本身不可改)
    • 赋值规则:变量地址可赋给常量指针,反之不成立
  3. 函数参数和返回值

    • 传const值:函数创建者的工具,保护参数不被修改
    • 返回const值:对自定义类型,返回const值不能作左值

引用

  • 引用是别名,不是指针
  • 引用必须初始化,不能重新绑定
  • 引用作为函数参数:避免拷贝,可修改实参
  • 引用作为返回值:返回左值,可链式调用

拷贝构造函数

  • 签名:X(const X&)
  • 缺省情况:bit-copy(逐位拷贝)
  • 问题:
    1. 对象构造/析构不能正确调用,无法维护完整性
    2. 对象内部有对其他对象的引用时,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)

陈述:

  1. 高层模块不应该依赖于低层模块。二者都应该依赖于抽象。
  2. 抽象不应该依赖于细节。细节应该依赖于抽象。

分析:

  • “倒置”是相对于传统结构化方法(高层依赖低层)而言
  • 高层包含应用策略和业务模型,低层包含实现细节和平台相关细节
  • 高层依赖低层的后果:
    • 难以复用:换平台需要逐层更改
    • 难以维护:低层通常是易变的

示例: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(适配器)模式

意图:将一个类的接口转换成客户希望的另一个接口。使原本由于接口不兼容而不能一起工作的类可以一起工作。

两种形式:

  1. 对象Adapter:Adapter持有Adaptee的引用(组合)——更灵活,推荐
  2. 类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(抽象工厂)模式

两个关注点:

  1. 关注点分离:对象的创建与对象的使用分离
  2. 对象成套创建:确保产品族内的对象配套使用

关注点分离

问题示例:图形显示系统

  • 根据硬件配置选择驱动(高/低分辨率的显示驱动 + 打印驱动)
  • 不好的设计:在ApControl构造函数中用switch-case创建具体驱动
  • 问题:对象创建与对象使用混在一起,Client依赖具体类

逐步改进:

  1. 引入工厂类(CreateLRD / CreateHRD)→ 分离了创建和使用
  2. 但Client仍依赖具体工厂类 → 未完全解耦
  3. 引入抽象工厂基类(CreateRD)→ Client仅依赖抽象接口
  4. 具体工厂在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(模板方法)模式

两类问题:

  1. 复用问题:框架结构相似,细节不同 → 如何复用代码?
    • 例:数据库查询,Oracle和SQL Server语法略有不同,但流程相同(连接→构造语句→执行)
  2. 约束和规则:希望某些规则不会被违反 → 如何一次性编码约束?
    • 例:使用临界资源必须遵守”进入临界区→使用→退出临界区”的流程

思路:

  • 写一个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迪米特法则(最少知识)、降低耦合
AdapterOCP(扩展新接口不修改原有代码)
StrategyOCP、SRP(策略与上下文分离)
BridgeSRP(两个维度各负其责)、OCP
Abstract FactoryDIP(依赖抽象)、OCP
DecoratorOCP(动态扩展)、SRP(每个装饰器一个职责)
ObserverOCP、松耦合
Template MethodOCP、DRY(复用骨架代码)

五、考试要点

题型

  1. 选择题(20分):以C++语言部分为主
  2. 简答题(35分):C++语言、原则、模式都有
  3. 问答题(10分):设计模式
  4. 编程题(35分):
    • 给出软件需求 + 不好的设计方案(类图)
    • 要求:① 指出缺陷 ② 给出改进后的设计 ③ 说明使用了哪些设计模式

复习建议

  • C++部分:虚函数/动态绑定机制、拷贝构造函数、const、引用、继承与组合
  • 原则部分:每个原则的内涵 + 举例说明违反后的问题
  • 模式部分:每个模式的意图、结构、适用场景、能画出UML、能写出核心代码

关联笔记: