软件设计原则 - 设计模式课程第3讲

讲师:王亚沙,北京大学软件研究所 课程编号:0C107 设计模式 来源:田浩然上传的资料

内容提要

本讲是设计模式课程的第3讲,系统讲解软件设计的5大核心原则。全讲分为三大部分:

  1. 什么是设计 — 设计的定义、设计与实现的关系
  2. 软件设计中的7种臭味 — 设计质量的反面指标
  3. 软件设计的5大原则 — SRP / OCP / LSP / DIP / ISP

一、什么是设计

1. 设计的定义(IEEE 610.12-1990)

  • Design:
    1. 定义系统或组件的架构、组件、接口和其他特性的过程
    2. 上述过程的结果
  • Design Description(设计描述):描述系统或组件设计的文档,典型内容包括系统/组件架构、控制逻辑、数据结构、输入输出格式、接口描述、算法

2. 设计与实现的分与合

“分”的观点:

  • 人类处理复杂问题的方法是分解 → 软件开发分为需求、设计、实现、测试等阶段
  • 设计逻辑层次更高,注重与人交流(如UML图)
  • 实现基于设计进行,逻辑层次更低,包含更多细节,可直接被计算机处理

“合”的观点:

  • 随着软件工程发展(特别是MDA),设计与实现的边界越来越模糊
  • 高级语言出现以前,C++程序会被看作是形式化描述的设计
  • 当UML可以被直接编译成二进制代码时,UML就会成为编程语言
  • 趋势:编程语言越来越贴近人类思维,开发越来越自动化 → 设计与实现的边界在逐渐模糊

敏捷软件开发的观点:源代码就是设计

  • 高级程序设计语言(C++、Java)越来越贴近人类思维
  • 高级语言的完备性:所有设计语义尽在其中
  • 高级语言的无二义性:最忠实地反映程序的最终执行结果

本课程主要通过源代码分析软件设计,必要时加入UML图形辅助理解。


二、软件设计中散发出的7种臭味

1)僵化性(Rigidity)

很难对软件进行改动,因为每个改动都会迫使对系统其他部分进行许多改动

2)脆弱性(Fragility)

对系统的改动会导致系统中和改动地方在概念上无关的许多地方出现问题

3)牢固性(Immobility)

很难解开系统中某部分与其它部分之间的纠结,从而难以使其中任何部分被分离出来被其它系统复用

4)粘滞性(Viscosity)

做正确的事情要比做错误的事情困难。两种形式:

  • 软件粘滞性:破坏软件质量的修改比保持设计质量的修改更容易实施
  • 环境粘滞性:开发环境迟钝、低效(如编译时间长导致开发人员回避保持设计质量但需大规模重编译的改动)

5)不必要的复杂性(Needless Complexity)

设计中包含不具有任何好处的基础结构

6)不必要的重复(Needless Repitition)

设计中包含一些重复的结构,本可以通过单一抽象统一

  • Cut/Copy/Paste式的源代码级复用容易导致此问题
  • 代码级别冗余带来修改上的问题

7)晦涩性(Opacity)

很难阅读和理解

  • 时间会冲淡一切,不要以为你永远清楚每一行代码
  • 要站在阅读者的角度进行设计

设计臭味导致的后果

  1. 难以变更 — 需求变化时不断救火,设计逐渐混乱,人员丧失斗志
  2. 难以理解 — 复杂的结构、天书一样的程序无法带来愉悦
  3. 难以复用 — 本可复用的成分因为理不清关系而无法复用

解决之道:让设计符合下面这些原则


三、软件设计的5大原则

1. 单一职责原则(SRP, Single Responsibility Principle)

陈述:就一个类而言,应该只有一个导致其变化的原因。

分析:

  • 一个职责就是一个变化的轴线
  • 一个类如果承担的职责过多,等于将这些职责耦合在一起
  • 一个职责的变化可能会虚弱或抑止这个类完成其它职责的能力
  • 多职责将导致脆弱性的臭味

示例1:Rectangle类

违反SRP的设计:

Rectangle
+ draw()
+ area(): double

Rectangle同时承担了两个职责:

  1. 计算矩形面积的数学模型
  2. 将矩形在图形设备上绘制出来

导致的问题:

  • 包含不必要的代码(计算面积的应用被迫包含绘制代码)
  • 逻辑上无关的原因导致应用失败(GraphicalApplication的需求变化会导致ComputationalGeometryApplication也要重新构建、测试、部署)

修改后的设计:

  • GeometricRectangle:负责面积计算
  • Rectangle(GUI):负责绘制

示例2:Modem接口

class Modem {
public:
    virtual void dial(char* pno) = 0;
    virtual void hangup() = 0;
    virtual void send(char c) = 0;
    virtual void recv() = 0;
};

可能有两个职责:拨号 + 通信

关键问题:什么是职责? 职责是”变化的原因”。

两种情况:

  • 连接和通信可能独立变化 → 应该将职责分开(否则设计僵化)
  • 连接和通信同时变化 → 不必分开(分离反而导致”不必要的复杂性”)

刻舟求剑是错误的。——王亚沙

修改后的设计(接口分离):

  • Connection 接口:dial() / hangup()
  • DataChannel 接口:send() / recv()
  • ModemImplementation 同时实现两个接口

注意:ModemImplementation中实际还是集合了两个职责,但应用的其他部分通过接口分离已实现了职责分离。

常见错误:持久化与业务规则的耦合

  • 业务规则经常变化,持久化方法一般不变
  • 将两者耦合在一起,每次业务规则变化调整Employee类时,持久化部分代码也要跟着变

2. 开放封闭原则(OCP, Open-Closed Principle)

陈述:软件实体(类、模块、函数等)应该是可以扩展的,同时是不必修改的。

  1. 对扩展是开放的 — 需求变化时,可以对模块扩展,使其具有新行为
  2. 对更改是封闭的 — 扩展模块时,不必改动已有的源代码或二进制代码

分析:

  • 世界是变化的,软件是对现实的抽象 → 软件必须能够扩展
  • 如果任何修改都需要改变已存在的代码,可能导致牵一发动全身的雪崩效应

实现OCP的关键是抽象

示例1:client-server的具体耦合

违反OCP的设计:client直接依赖具体的server类

  • 如果想让client调用一个新的server类,不得不修改client的源代码 → 带来编译、链接、部署等一系列问题

修改后的设计:

  • client依赖抽象接口 ClientInterface
  • server从ClientInterface派生

深层洞察:为什么接口叫ClientInterface而不是AbstractServer?

  • client类中更多描述了高层的策略,server类是对策略的具体实现
  • 接口是策略的组成部分,和client端的关系更密切
  • 接口定义了client期望server做什么,而具体类是这种要求的实现
  • OCP要求清晰区分策略和策略的具体实现形式:允许扩展实现形式(开放),将扩展与策略隔离开(封闭)

示例2:Shape的DrawAllShapes(C语言式 vs OO式)

C语言式设计(违反OCP):

  • 用ShapeType枚举 + switch语句分派
  • 增加三角形需要修改Shape、Square、Circle、DrawAllShapes → 僵化、脆弱、牢固

OO式设计(符合OCP):

  • Shape抽象基类 + virtual Draw()
  • Square/Circle派生实现
  • DrawAllShapes通过多态调用

OCP的”谎言”:

  • 上述代码并不完全封闭——“如果希望正方形在所有圆之前绘制”怎么办?
  • 更糟糕的是,现有的设计反而成为实现排序功能的障碍

真实的谎言:

  • 无论模块多么”封闭”,都会存在一些无法对之封闭的变化
  • 没有对所有变化都封闭的模型
  • 策略:对模型应该封闭哪类变化作出选择,封闭最可能出现的变化
    • 需要领域了解、丰富经验和常识
    • 错误的判断反而不美(OCP需要额外开销,增加复杂度)
    • 敏捷思想:预测它们,但直到发现它们才行动

实现排序封闭的方案:

  • 在Shape中增加 Precedes(const Shape&) 纯虚函数
  • 用 operator< 和 sort 算法实现排序
  • 每个派生类实现自己的排序规则

数据驱动的封闭性:

  • 用typeOrderTable[]数组存储类型顺序
  • 表本身放在单独模块中,对表的改动不会影响其他模块
  • C++中甚至可以在链接时选择使用哪个表

3. 里氏替换原则(LSP, Liskov Substitution Principle)

陈述:子类型(Subtype)必须能够替换它们的基类型(Basetype)。

Barbara Liskov的正式陈述:

若对每个类型S的对象o1,都存在一个类型T的对象o2,使得在所有针对T编写的程序P中,用o1替换o2后,程序P的行为功能不变,则S是T的子类型。

分析:

  • 违反LSP将导致程序的脆弱性和对OCP的违反
  • 如果派生类实例传给接受基类指针的函数导致错误 → 设计脆弱
  • 如果需要针对每个派生类写测试 → 违反OCP

示例1:Shape用type tag + static_cast

  • DrawShape中通过type字段判断并强制转换
  • 违反OCP的根源是Circle和Square违反了LSP

示例2:Rectangle与Square(经典案例)

class Rectangle {
    void setWidth(double w);
    void setHeight(double h);
    double getWidth() const;
    double getHeight() const;
};
 
class Square : public Rectangle {
    void setWidth(double w);  // 同时设置宽和高
    void setHeight(double h); // 同时设置宽和高
};

第一步分析:非虚函数导致的问题

void f(Rectangle& r) { r.SetWidth(32); }
  • 将Square实例传给f时,height与width不等 → 破坏完整性 → 违反LSP

深层问题:即使设为虚函数仍有问题

void g(Rectangle& r) {
    r.setWidth(5);
    r.setHeight(4);
    assert(r.Area() == 20);
}
  • 函数g不能操作Square实例 → Square不能替换Rectangle → 违反LSP

LSP的核心洞察:

孤立地看,我们无法判断模型的有效性。 考虑一个设计是否恰当时,不能孤立地看待,应该从使用者所做出的假设来审视它。

  • “正方形是一种长方形”在数学上成立,但在OOD中,对象之间是否存在IS-A关系,应该从行为的角度来看待
  • 行为可以依赖客户程序做出合理的假设
  • 事先推测困难 → 敏捷思想:推迟判断,直到出现问题才解决

基于契约的设计(DBC, Design by Contract)

  • 类的编写者显式规定该类的契约,客户代码编写者通过契约获悉行为的依赖方式
  • 通过前置条件(preconditions)和后置条件(postconditions)指定:
    • 前置条件:方法执行前必须为真(对客户的要求)
    • 后置条件:方法执行后必须为真(对函数编写者的要求)

基类与派生类的契约关系:

  • 派生类重定义的成员函数,只能使用相等或更弱的前置条件替换原有
  • 只能使用相等或更强的后置条件替换原有
  • → 派生类必须接受基类已经接受的一切;派生类不能违反基类已经确定的规则
  • Eiffel语言原生支持契约,Java和C++需要自行考虑

4. 依赖倒置原则(DIP, Dependency Inversion Principle)

陈述:

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

分析:

  • “倒置”是相对于传统开发方法(结构化方法)中高层模块倾向于依赖低层模块而言
  • 高层包含应用程序的策略和业务模型,低层包含更多实现细节和平台相关细节
  • 高层依赖低层将导致:
    • 难以复用:改变软硬件平台导致逐层更改
    • 难以维护:低层通常是易变的

层次化的两种理解

简单理解(传统分层):

Policy Layer → Mechanism Layer → Utility Layer

自上而下的依赖

更好的理解(依赖倒置):

Policy Layer → <<interface>> Policy Service Interface ← Mechanism Layer ← Utility Layer
  • 依赖关系倒置:下层的实现依赖于上层的接口
  • 接口所有权倒置:客户拥有接口,服务者从接口派生

开发方法对比

依赖不倒置的开发:

  • 自顶向下设计软件分解结构
  • 先实现下层功能
  • 再实现上层,上层调用下层函数

依赖倒置的开发:

  • 首先设计上层需要调用的接口,实现上层
  • 低层类从上层接口派生,实现低层
  • → 接口属于上层

示例:Button与Lamp

不成熟的设计(Button直接依赖Lamp):

  • Lamp的任何变化都会影响Button
  • 无法复用Button控制Motor等其他设备

依赖倒置的设计:

  • Button依赖抽象接口 ButtonServer(turnOn / turnOff)
  • Lamp从ButtonServer派生,提供具体实现

关键洞察:

  • 高层策略是检测用户的开/关指令——这是抽象背后的本质
  • 用什么机制检测、目标对象是什么,都是无关紧要的细节

质疑与抗辩:

  • 质疑:是不是将Button对Lamp的依赖转嫁成了Lamp对Button的依赖?
  • 抗辩:Button依赖于ButtonServer接口,但接口不依赖于Button。任何知道如何操作ButtonServer接口的对象都可以操作Lamp
  • 改进建议:将接口名改为更抽象的名字,如 SwitchableDevice

5. 接口隔离原则(ISP, Interface Segregation Principle)

陈述:

  • 不应该强迫客户依赖于他们不用的方法
  • 一个类的不内聚的”胖接口”应该被分解成多组方法,每一组方法服务于不同的客户程序

示例:Door与Timer

初始设计:

class Door {
    virtual void Lock() = 0;
    virtual void Unlock() = 0;
    virtual bool IsDoorOpen() = 0;
};

新增需求:门打开时间过长时报警 → 需要与Timer对象交互

class Timer {
    void Register(int timeout, TimerClient* client);
};
class TimerClient {
    virtual void TimeOut() = 0;
};

常见的有问题方案:让Door继承TimerClient → Door接口增加Timeout方法

  • 接口污染:Door接口中加入的新方法只为一个子类带来好处
  • 每次子类需要新方法都加到基类 → 基类接口很快变胖
  • 胖接口导致SRP和LSP被违反 → 脆弱、僵化

客户的反作用力:

  • 接口变化导致客户改变
  • 但很多时候接口变化是因为客户需要它们变化
  • → Client对interface具有反作用力
  • TimedDoor的多个超时请求需求 → Timer接口增加timeOutId参数 → 传递到Door接口 → 所有Door都受影响 → 牵一发动全身

解决之道

方案一:使用委托(Adapter模式)

  • DoorTimerAdapter 继承 TimerClient
  • DoorTimerAdapter持有TimedDoor引用,将TimeOut调用转发给TimedDoor的DoorTimeOut
  • 隔离了Door接口和TimerClient接口

方案二:使用多继承

  • TimedDoor 同时继承 Door 和 TimerClient
  • TimedDoor既是Door也是TimerClient
  • 适用于两种接口都相对稳定的场景

金句与洞见

  1. “刻舟求剑是错误的。” — 设计原则的应用要灵活,不能教条
  2. 源代码就是设计 — 敏捷开发的观点,高级语言的完备性和无二义性使其成为最忠实的设计描述
  3. 设计与实现的边界在逐渐模糊 — 编程语言越来越人性化,开发越来越自动化
  4. OOD中的IS-A是行为层面的 — 数学上的is-a不代表编程中的is-a,要从客户程序的假设角度审视
  5. 接口属于客户 — 依赖倒置的核心:上层定义接口,下层服务于接口
  6. 没有对所有变化都封闭的模型 — OCP需要策略性地选择封闭哪些变化,而不是追求完美
  7. 胖接口是设计杀手 — 接口污染导致牵一发动全身,ISP通过分离接口保持内聚

与其他知识的关联

课程后续

本课程为设计模式系列,预计包含多讲:

  • ss-03(原则):本讲,五大设计原则
  • ss-04 / ss-05 / ss-06 / ss-07 / ss-08 / ss-09:具体设计模式讲解