面向对象的模块化

面向对象中的模块

模块化是消除软件复杂度的一个重要方法,它将一个复杂系统分解为若干个代码片段,每个代码片段完成一个功能,并且包含完成这个功能所需要的信息。每个代码片段相对独立,这样能够提高可维护性。在面向对象方法中,代码片段可以是模块,也可以是方法,但更重要的是类,整个类的所有代码联合起来构成独立的代码片段。

模块化希望代码片段由两部分组成:接口和实现。接口就是代码片段之间用来交互的协议,包括供接口(供给别人使用的契约)和需接口(需要使用别人的契约)。实现则是该协议具体的实施。函数的供接口是函数的声明,包括函数名、输入参数、输出返回值,可以通过使用该声明来达到对函数的访问。函数的需接口是其实现中调用的其他函数。

对于类来说,类的供接口是所有共有的成员变量和成员方法的声明,这些都是可以被别的类直接访问的名代表了类愿意与他人协作的一个协议。类的需接口则是在其实现中使用到的其他类及其相关协议。

类之间的关系

方法调用相互发送消息,模块化希望各个模块之间尽可能相互独立 – 低耦合。内容耦合、重复耦合和公共耦合是不允许的;控制耦合与印记耦合是可以接受的;数据耦合是最好的。在面向对象方法中,除了不同类的方法之间存在调用关系之外,类与类之间还会存在其他复杂关系:

  • 关联:如果某个类关联了另一个类,那么它就持有另一个类的引用,则这个类的所有的对象都具有向另一个类的对象发送消息的能力
  • 继承:子类可以访问弗雷德成员方法和成员变量

访问耦合

什么是访问耦合

关联关系产生的耦合

访问耦合分为4级,如下表所示,耦合性从高到低。

类型 解释 例子
隐式访问 B既没在A的规格中出现,也没在实现中出现 Cascading Message
实现中访问 B的引用是A方法中的局部变量 通过引入局部变量,避免Cascading Message;在方法中创建一个对象,将其引用赋予方法的局部变量,并使用
成员变量访问 B的引用是A的成员变量 类的规格中包含所有需接口和供接口(需要特殊语言机制)
参数变量访问 B的引用是A的方法的参数变量 类的规格中包含所有需接口和供接口(需要特殊语言机制)
无访问 理论最优,无关联耦合,维护时不需要对方任何信息 完全独立

Cascading Message(级联消息)是指如 Client 类中出现连续的方法调用 a.methodA().methodB()。这样写的坏处是,从代码上我们很难看出来 ClientB 是有关系的。既没有出现在规格中,也没有出现在实现中。因此在 B 修改后,可能并不知道 Client 也需要修改。

衡量两个类之间的耦合度,除了看它们之间存在的访问耦合关系的复杂程度,还得看存在具体访问的次数。访问的次数多,则耦合强,反之则弱。

在几种访问耦合关系中,隐式访问是需要避免的,例外情况是使用标准类库的时候(通常标准类库都比较稳定不会发生变化)允许出现级联访问。实现访问是可以接受的,也是必要的,毕竟不可能将所有使用的其他类都作为成员变量或者写为方法的参数。成员变量访问和参数变量访问是比较好的,也是提倡的。

降低访问耦合的方法

针对接口编程 Programming to interface

考虑(非继承)类与类之间的关系时,一方面要求只访问对方的接口(直接属性访问会导致公共耦合),另一方面要避免隐式访问。如果为每个类都定义明确的契约(包括供接口和需接口),并按照契约组织和理解软件结构。那么就可以满足上述要求。这就是针对接口编程

针对接口要求我们在设计时要在类规格中明确类的契约。一种是语言提供的机制,另一种是文档。

契约式设计

接口最小化/接口隔离原则 Interface Segregation Principle(ISP)

Programming to Simpler Interface

Many client-specific interfaces are better than one general purpose interface(多个针对客户功能的接口要比一个总的接口好)

  1. 不是所有客户(或者实现接口的类)都想要实现所有接口
  2. 会导致不必要的依赖,以及破坏了单一职责的原则。

接口隔离原则

访问耦合的合理范围/迪米特法则 The Law of Demeter

避免隐式访问耦合

  1. 每个单元对于其他的单元只能拥有有限的知识,只是与当前单元紧密联系的单元
  2. 每个单元只能和它的朋友交谈,不能和陌生单元交谈
  3. 只和自己直接的朋友交谈

即对于对象 O 中的一个方法 M,那么 M 只能调用下列对象的方法

  1. O 自己
  2. M 中的参数对象
  3. 任何在 M 中创建的对象
  4. O 的成员变量

继承耦合

什么是继承耦合

继承关系产生的耦合

在面向对象方法中,由于有继承关系,父类和子类之间也存在耦合。下表从耦合性高到低简单介绍了四种继承耦合

类型 解释
修改(modification) 规格 子类任意修改从父类继承回来的方法的接口
实现 子类任意修改从父类继承回来的方法的实现
精化(refinement) 规格 子类只根据已经定义好的规则(语义)来修改父类的方法,且至少有一个方法的接口被改动
实现 子类只根据已经定义好的规则(语义)来修改父类的方法,但只改动了方法的实现
扩展(extension) 子类知识增加新的方法和成员变量,不对从父类继承回来的任何成员进行更改
无(nil) 两个类之间没有继承关系

上述的继承耦合关系中,修改规格、修改实现、精化规格三种类型事不可接受的。精化实现是可以接受的,也是经常被使用的。扩展是最好的继承耦合,但是并非每个继承关系都能达到只扩展不调整的程度

降低继承耦合的方法

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

里氏替换原则:子类型必须能够替换掉基类型而起同样的作用。

使用组合代替继承 组合/聚合复用原则(CARP)

组合/聚合复用原则

内聚

内聚有不同的类型:

  1. 方法的内聚
  2. 类的内聚
  3. 子类与父类的继承内聚

方法内聚和结构化的函数内聚一致,主要是体现在方法实现时语句之间的内聚性。内聚性由高到低分为:功能内聚、通信内聚、过程内聚、时间内聚、逻辑内聚、偶然内聚

类应该是信息内聚,又应该是功能内聚的:

  1. 方法和属性是否一致
  2. 属性之间是否体现一个职责
  3. 属性之间是否可以抽象

单一职责原则

单一职责原则

耦合和内聚的度量

总结:模块化的九大原则

  1. Global Variables Consider Harmful:不要使用全局变量
  2. To be Explicit:成员变量的定义应该显式,而不是通过一个数组存放所有的成员变量定义
  3. Do not Repeat:不要有重复的代码,这是一个模块决策泄漏的体现,也是一种重复耦合
  4. Programming to Interface:契约式设计,面向接口编程
  5. The Law of Demeter:迪米特法则,最少知识原则,只应该知道与自身密切相关的信息且也只能与这些朋友通信
  6. Interface Segregation Principle:接口隔离原则,接口要小,每个接口应该承担独立角色
  7. Liskov Substitution Principle:里氏替换原则,父类的引用一定能替换成子类的对象,判断是否能够使用继承的一个好原则,同时是实现开闭原则的一个重要方法
  8. Favor Composite Over Inheritance:组合/聚合复用原则,优先使用组合/聚合而不是继承
  9. Single Responsibility Principle:单一职责原则,一个类的数据和行为应该只对应着一种职责。

Reference

  1. 南京大学软件学院2022春季学期《软件工程与计算二》
文章作者: ZY
文章链接: https://zyinnju.com/2022/06/16/面向对象的模块化/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ZY in NJU