14-面向对象的模块化

1. 模块化的原则(总结)

  1. 核心就是上面的
  2. 题目是,给例子,发现违反的原则并纠正

2. 面向对象中的模块与耦合

2.1. 类

  1. 模块化是消除软件复杂度的一个重要方法,它有效地将一个复杂系统分解为若干个代码片段,每一个代码片段完成一个功能,并且包含完成这个功能所需要的信息。
  2. 模块化希望代码片段由两部分组成:接口和实现。

2.2. 模块

  1. 一段代码
    1. 方法
    2. 模块(包)
  2. 耦合:通过段
  3. 聚合:内部段

2.3. 耦合中的结构方法与OO方法

  1. 耦合:耦合是对从一个模块到另一个模块的连接所建立的关联强度的度量。
  2. 结构化方法:连接是对其他地方定义的某些标签或地址的引用
  3. 面向对象方法:
    1. 访问耦合
    2. 继承耦合

2.4. 降低耦合的设计原则

在13节中

  1. 原则一:Global Variables Consider Harmful
  2. 原则二:To be Explicit
  3. 原则三:Do not Repeat
  4. 原则四:Programming to Interface

3. 访问耦合

上面的规格是指参数。

这里耦合性的强弱是根据在A中能否很轻易的看到B被引用,越容易看到,耦合度越低

隐式访问是需要避免的,实现访问是可以接受的,后面两者是提倡的

3.1. 隐式耦合:Cascading Message 级联调用问题

3.1.1. 解决方案 — 引入局部变量

显式的展现耦合关系

  • 避免隐式耦合,变为显式耦合,降低耦合度

3.1.2. Cascading Message问题案例

  • 上面的代码中通过A.b.c就实现了调用,看不出代码和customer之间是有关系的。这样的方式不合适

3.1.3. 解决方案 — 委托

  • 使用委托的方式来解决,委托给一个类来完成这个业务,让Account去完成下一步的调用,这样左边的类和customer就没关系了

4. 组件耦合原理

4.1. 原则四:面向接口编程

  1. 编程到所需的接口,不仅是受支持的接口
  2. 按照约定设计
    1. 模块/类合同:所需方法/提供的方法
    2. 方法合同:前提条件,后置条件,不变式
  3. 在考虑(非继承的)类与类之间的关系时,一方面要求值访问对方的接口,另一方面要避免隐式访问。
  4. 课本231页关于契约的含义的补充:
    1. 前置条件
    2. 后值条件
    3. 不变式
  5. 案例

4.2. 原则五:迪米特法则

  1. 通俗说法【不和陌生人说话
    1. 你可以自己玩。(调用this的方法)
    2. 你可以玩自己的玩具,但不能拆开它们(自己的成员变量)
    3. 你可以玩送给你的玩具。(传进来的参数)
    4. 你可以玩自己制作的玩具。(自己创建的对象)
  2. 更加形式化的说法:
    1. 每个单元对于其他单元只能拥有有限的知识,只是与当前单元紧密联系的单元
    2. 每个单元只能和它的朋友交谈,不能和陌生单元交谈
    3. 只和自己的直接的朋友交谈
  3. 例子:如果对象O有方法M,那么M只能调用下列对象中的方法【对应上面的四条】:
    1. O自己
    2. O的成员变量
    3. M中的参数对象
    4. 在M中创建的对象

迪米特法则特别强调不要出现如 a.b.Method的方法应该只有 a.Method,这与避免隐式耦合的观点一致

4.2.1. 问题案例

  • 通过联系人获得信息
  • 如何获得其他的引用?
    1. this
    2. 成员变量:√在Contact里面持有PostalArea的一个成员变量。
    3. 方法传入的参数
    4. 自己创建

4.3. 原则六:接口隔离原则(ISP)/也叫接口最小化原则

  1. 不应强迫客户端依赖于不使用的接口。 马丁(R. Martin),1996年

    【不应当依赖于一个大接口】

  2. 面向简单接口编程

  3. 许多客户专用接口比一个通用接口要好

4.4. 解释接口隔离原则

  1. 多用途的类
    1. 方法分成不同组
    2. 没有一个用户使用所有的方法
  2. 可能会导致不想要的依赖:使用类的一个方面的接口也间接依赖于其他方面的依赖性
  3. ISP有助于解决问题:使用多个客户特定的接口

4.4.1. 案例一:GUI界面问题

  • 进一步细化接口,避免出现不必要的依赖。【把大接口分成小接口

4.4.2. 案例二:Application的依赖问题

  • 想法一:将ApplicationForm拆开
  • 想法二:将Controller合并
  • 根据具体情况选择想法一 和想法二

5. 继承耦合

  1. 在以上的各种类型的继承关系中,修改规格、修改实现、精化规格是不可以接受的。
  2. 精化实现是可以接受的,也是经常被使用的,即方法的重写
  3. 扩展是最好的继承耦合,但不可能每个继承都只是只拓展不调整的

5.1. 修改继承耦合

  1. 没有任何规则和限制的修改
  2. 最差的继承耦合
  3. 如果客户端使用父引用,则需要使用parent和child方法
    1. 隐含的
    2. 有两个连接,比较复杂
  4. 危害多态

5.1.1. 案例

  • 父类能做的子类都能做吗?√
  • 子类能做的父类都能做吗?×

5.2. 精化继承耦合

  1. 定义新信息
  2. 继承的信息仅根据预定规则进行更改
  3. 如果客户使用父母参考,则需要整个父母和子女的修饰
    1. 1+connections
  4. 常见的

5.3. 扩展继承耦合

  1. 子类仅添加方法和实例变量,而没有修改或修饰任何继承的方法和实例变量
  2. 如果客户端使用父引用,则仅需要父引用:一次引用

6. 降低继承耦合的方法

6.1. 继承耦合原理

6.2. 原则七:里氏替换原则[LSP]

  1. 所有派生类都必须可以替代其基类并起到相同的作用,如果违反了,那么父类和子类一定有较强的耦合
  2. “使用指针或对基类的引用的函数必须能够在不知道的情况下使用派生类的对象。” -R. Martin,1996年

6.2.1. 问题案例一:银行问题

  • 继承关系有问题吗?
  • 继承后子类能够当做父类看待吗?不能,因为子类要求比父类更强,因此在execute中if判断余额是大于要取钱的数目时,会调用payer,但子类中要求余额减去债务才能取钱,要求更高了,所以方法调用会出错
  • 解决方案:在父类中增加新的变量完成,这样在execute方法中执行就不会出错了,因为此时判断的就是可

6.2.2. 问题案例二:Is a Square a Rectangle?

1
2
3
4
5
6
7
8
9
10
11
12
13
Rect r = new Rect();
setWidth = 4;
setHeight = 5;
assert(20 == getArea());
class Square extends Rect{
// Square invariant, height = width
setWidth(x) {
setHeight()=x;
}
setHeight(x) {
setWidth(x)
}
} // violate LSP?
  1. 正方形继承长方形:正方形条件比长方形条件更强,多限制条件。
  2. 正方形继承长方形是不合适的。
  3. 长方形继承正方形也是不合适的

6.2.3. 问题案例三:Penguin is a bird?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Bird {
// has beak, wings,...
public: virtual void fly();
// Bird can fly
};
class Parrot : public Bird {
// Parrot is a bird
public: virtual void mimic();
// Can Repeat words...
};
class Penguin : public Bird {
public: void fly() {
error ("Penguins don’t fly!");
}
};
  • 不应该被叫做brid,而应该是flyingBird
  • Penguins Fail to Fly!
1
2
3
4
5
void PlayWithBird (Bird abird) {
abird.fly();
// OK if Parrot.
// if bird happens to be Penguin...OOOPS!!
}
  1. 不建模:“企鹅不可能”,它建模"企鹅可能很好,但如果他们尝试是错误的",则尝试运行时错误→不可取
  2. 考虑可替代性-LSP失败

6.3. 里氏替换原则总结

  1. LSP与语义和替换有关
    1. 设计前先了解
      1. 必须清楚地记录每个方法和类的含义和目的
      2. 缺乏用户理解将导致事实上违反LSP
    2. 可替换性至关重要
      1. 每当任何系统中的任何代码引用任何类时,
      2. 该类别的任何将来或现有的子类别都必须100%可替换

"在派生类中重写一种方法时,只能用一个较弱的方法代替其先决条件,而用一个较强的方法代替其后置条件" — B. Meyer,1988年

  1. 合同设计
    1. 对象的行为:
      1. 更弱的前置条件
      2. 更强的后置条件
  2. 派生类服务应该require no more且 promise no less【要求不能更高,父类要求100就可以,子类不能要求超过;实现的不能更低,父类实现A,子类至少要实现A,可以实现ABC更多】
  3. LSP用来判断是否可以进行继承
  4. 符合 LSP 的继承关系可以减少耦合:
    1. 在 client 类中持有 S,S 有 N 种子类,那么 client 只需要了解 S 即可,因为耦合度为 1
    2. 如果不使用继承,那么 client 需要了解 N 个子类,耦合度为 N
    3. 如果继承不符合 LSP,那么client 必须要知道引用的具体类型,因此不但要了解 S,还有了解 N 个子类,所以耦合度反而增加 N+1

6.3.1. 课堂练习

  1. 两种设计都不好,都让 commonDoor 去实现了alarm,而它不应该能发出警报。因此子类不能当作父类来看待,因为父类要求的警报功能,子类 commonDoor 做不到,没有实现【注意不能把方法继承了,但里面什么都不做,这样是不对的】

  2. 正确的应该时,door 里面不能有 alarm(),两个子类继承 door,并且 alarmDoor 去实现 alarm接口。

    这样就能用子类去替换父类了

6.4. 设计原则八:组合代替继承

  1. 组合优于继承
  2. 使用继承实现多态,否则尽量组合
  3. **使用委托【组合】**而不是使用继承去重用代码!
  4. 希望复用代码又不能满足 LSP 时,往往会用组合来替代继承。用继承的时候一 定要符合 LSP, 不要只为了代码复用而使用继承。

6.4.1. Coad的继承规则

  1. 仅在满足以下所有条件时才使用继承:
    1. 子类表示"是一种特殊的",而不是"是一种角色"
    2. 子类的实例永远不需要成为另一个类的对象
    3. 子类扩展而不是覆盖或取消其父类的职责
    4. 子类不会扩展仅是实用程序类的功能

6.4.2. 继承/组合 实例一

  • 如果出现一个用户既是 Passenger 也是 Agent
  • Java不允许多继承

  • 直接的想法就是直接组合
  • Person里面持有Passenger、Agent,但是这时候对于单一身份的人是很奇怪的

6.4.3. 继承/组合 示例二【经典方式:star:】

  • Person持有Role,Passenger和Agent实现抽象接口PersonRole
  • Role可以是一个List,因此可以实现一个人既是 passenger 也是 agent
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Object {  
public: virtual void update() {};
virtual void draw() {};
virtual void collide(Object objects[]) {};
};
class Visible : public Object {
public:
virtual void draw() {
/* draw model at position of this object */ };
private: Model* model;
};
class Solid : public Object {
public:
virtual void collide(Object objects[]) {
/* check and react to collisions with objects */ };
};
class Movable : public Object {
public:
virtual void update() {
/* update position */ };
};
  • 问题:游戏引擎中存在很多的对象,三个类分别实现方法之一
  • 继承三件事但是只做了一件,Promise No Less不符合
  • 接口应该拆成3个

7. 内聚

  1. 方法内聚和结构化中的函数内聚一致,功能内聚、信息内聚、过程内聚、时间内聚、逻辑内聚、偶然内聚。
  2. 类的内聚衡量类的成员变量和方法之间的内聚:类应该是信息内聚的又应该是功能内聚的

  • 方法和属性保持一致

  • 提高内聚性:将一个类分为三个类

  • 将时间抽象出来,可以进一步抽象

7.1. 方法内聚

  1. 一类方法是普通耦合
  2. 所有方法尽一责
    1. 信息内聚
    2. 相对功能(功能内聚)
    3. 第九个原则:单一职责原理

7.2. 提高内聚的方法

7.2.1. 原则九:单一责任原则(SRP)

一个高内聚的类不仅要是信息内聚的,还应该是功能内聚的,也就是说,信息与行为除了要集中之外,还要联合起来表达一个内聚的概念,而不是单纯的堆砌,这就是单一职责原则

一个类只有一个改变的理由 a class should have only one reason to change”-罗伯特·马丁(Robert Martin)

  1. 与内聚性相关并从中导出,即模块中的元素应在功能上紧密相关
  2. 类履行某种职责的责任也是类变化的原因
  3. 一个高内聚的类不仅要是功能内聚的,还应该是信息内聚的。

7.2.1.1. 问题案例

  • 修改的原因:
    • 业务逻辑
    • XML格式
  • 如何修改如何分开

7.2.1.2. 结局方案

  • 我们将两部分职责分开

7.2.2. 单一职责原则

  1. 类只有一个改变的理由:职能/职责的凝聚力
  2. 几个职责:表示更改的几个原因→更频繁的更改
  3. 听起来很简单
    1. 在现实生活中并非如此轻松
    2. 实现单一职责可能会导致具有复杂性,重复性,不透明性

7.3. 课堂练习

  • 打电话和挂起两个职责分离开

  • 几何画板:Draw和Area的计算如何分开

  • 解决方案:集合长方形和图形长方形一一对应

8. 耦合和内聚的度量

8.1. 类之间的耦合度量

8.1.1. 第一种度量:CBO(方法调用耦合)

Coupling Between Objects

  1. 对象类之间的耦合(CBO)

  2. CBO = 该类访问其他类的成员方法的数量 + 其他类的成员访问该类的成员方法的数量

    外界的其他类不包括存在继承关系的类。

  3. 越低越好

8.1.2. 第二种度量:DAC(数据抽象耦合)

Data Abstraction Coupling

  1. 数据抽象耦合(DAC)

  2. DAC = 统计一类包含的其他类的其他类的实例的数量,不包括继承关系带来的实例引用

  3. 越低越好

8.1.3. 第三种度量:Ca和Ce(有效和)

  1. Ce和Ca(有效和有效偶联)
    1. Ca:在此类之外依赖于这类内部的类的数量
    2. Ce:这个类中依赖于这个类的外部的类的数量
  2. 越低越好

8.1.4. 第四种度量:DIT 继承树的深度

Depth of the Inheritance Tree

  1. 继承树的深度

  2. 统计从继承树的根节点到叶节点的长度。

  3. 随着DIT的增长,由于高度的继承性,很难预测类的行为

  4. DIT 越大越好,因为 DIT 越大意味着继承机制减少耦合的效果越强,复用代码的程度也越高

    但 DIT 越大也意味着子类需要遵守的约束越多而且越隐晦(因为父类距离太远),越难以保证 LSP 的实现

  5. 因此应当适中,当DIT>3同样也需要审查继承机制的正确性,确保能遵守 LSP。

8.1.5. 第五种度量: NOC 子类的数量

Number of Children

  1. 是一个类的直接子类的数量
  2. 随着 NOC 的增长,可复用性增加,抽象减弱了【和 DIT 增长的效果一样
  3. 随着 NOC 的增长,抽象可能变得稀疏即当子类变多后,如果能将这么多子类都抽象成一个父类,难度比较大,因为子类多了,共性就会减少
  4. 一般 NOC 超过 3,就需要认真审查继承机制的正确性,检查是否满足 LSP

8.1.6. 衡量类凝聚力 LCOM(不考)

Lack of cohesion in methods (LCOM)

  • 交集为空则在P中,交集不为空则在Q中
  1. 值越低越好

  2. 如果LCOM>= 1,说明所有方法中的相互之间隔离比较大,相交的部分很少,因此它们不应该放在一个类中,应将类划分

  3. 还定义了许多其他版本的LCOM

  4. 使用连通图【如果两个方法有相交的实例变量,则连接】的数目来表示方法之间的联系,如果连通图数目大于 1,说明有不相交的部分,这两个部分应该分开

8.2. Summary:Principles from Modularization 模块化的原则

  1. 《Global Variables Consider Harmful》 全局变量被认为是有害的
  2. 《To be Explicit》让代码清晰一点
  3. 《Do not Repeat》避免重复
  4. 《Programming to Interface(Design by Contract)》面向接口编程,按照契约设计
  5. 《The Law of Demeter》迪米特法则
  6. 《Interface Segregation Principle(ISP)》接口分离原则
  7. 《Liskov Substitution Principle (LSP)》里氏替换原则:Request No More, Promise No Less
  8. 《Favor Composition Over Inheritance》 选择组合而不是继承
  9. 《Single Responsibility Principle》单一职责原理