EagleBear2002 的博客

这里必须根绝一切犹豫,这里任何怯懦都无济于事

软件工程与计算II-15-面向对象的信息隐藏

本文主要内容来自 SpriCoder的博客,更换了更清晰的图片并对原文的疏漏做了补充和修正。

课前测试

1
2
3
4
5
6
7
8
9
10
11
12
13
void Copy(ReadKeyboard& r, WritePrinter& w) {
int c;
while ((c = r.read ()) != EOF)
w.write(c);
}
void Copy(ReadKeyboard& r, WritePrinter& wp, WriteDisk& wd, OutputDevice dev) {
int c;
while((c = r.read()) != EOF)
if(dev == printer)
wp.write(c);
else
wd.write(c);
}

封装类的职责

结构化设计中的信息隐藏

信息隐藏

每一个模块都隐藏了这个模块中关于重要设计决策的实现,以至于只有这个模块的每一个组成部分才能知道具体的细节

设计细节应当被隐藏

  1. 最重要的细节:职责的变更
    1. 隐藏对于软件设计者特别的信息
    2. 来自于需求规格说明文档
  2. 次要的细节:实现的变更
    1. 当设计者在实现一个模块的时候为隐藏次要秘密而做出的实现决策
    2. 变化

类的职责

什么是职责?

职责是类或对象维护一定的状态信息,并基于状态履行行为职能的能力。

职责来源于需求

  1. 业务类:Sales、Order(职责来自于业务)
  2. 辅助类:View、Data、exception、transaction(事务处理)
  3. 除了业务类以外,软件设计中添加很多辅助类的职责也是源自于需求的。

职责的体现

封装:一个模块应该通过稳定的接口对外体现其所承载的要求,而隐藏它对需求的内部实现细节。

类的封装

  1. 目的是信息隐藏

封装包含什么?

  1. 封装将数据和行为同时包含在类中,分离对外接口与内部是实现。

接口

  1. 接口是模块的可见部分:描述了一个类中的暴露到外界的可见特征

实现

  1. 实现被隐藏在模块之中:隐藏实现意味着只能在类内操作,更新数据,而不意味着隐藏接口数据。

面向对象中的接口通常包含的部分

  1. 对象之间交互的消息(方法名)
  2. 消息中的所有参数
  3. 消息返回结果的类型
  4. 与状态无关的不变量(前置条件和后置条件)
  5. 需要处理的异常

封装数据类型

封装的源头 — ADT

  1. ADT = Abstract Data Type 抽象数据类型
    1. 一个概念,并不是实现(逻辑上的)
    2. 一组(同构)对象以及对这些对象的一组操作
    3. 并没有体现出来这些操作是怎么实现的
    4. Example:栈
  2. Encapsulation = data abstraction + type 封装 = 数据的抽象 + 数据的类型
    1. 数据抽象:一组数据和操作
    2. 类型:隐藏实现,保证使用正确

为什么类型

  1. 一种数据类型可以看作是一套衣服(或盔甲),可以保护基础的无类型表示形式免受任意使用或意外使用。
  2. 它提供了一个保护性遮盖物,该遮盖物隐藏了底层表示并限制了对象与其他对象交互的方式。
  3. 在无类型的系统中,无类型的对象是裸露的,其基础表示形式公开给所有人看。
  4. type 代表(封装)了一些操作

封装实现的细节重要

  1. 封装数据和行为
  2. 封装内部结构
  3. 封装其他对象的引用
  4. 封装类型信息
  5. 封装潜在变更

封装数据和行为

  • 如果不是必要,不应该提供 get 和 set 方法
  • 同样不应该从名字上暴露类内部的实现形式
  • 所有数据应该是 private,不是所有变量都有 getter 和 setter 的,同时 getter 和 setter 是为了检查正确性的。
数据的封装 — 访问器和变量
  1. 如果需要,请使用访问器和变量,而不是公共成员
  2. 访问器和变量是有意义的行为:约束,转换,格式 ...
1
2
3
4
5
6
7
8
//防止非法输入
public void setSpeed(double newSpeed) {
if (newSpeed < 0) {
sendErrorMessage(...);
newSpeed = Math.abs(newSpeed);
}
speed = newSpeed;
}
使用 Getter 和 Setter 方法的时候,不单纯将这些方法与类的成员关联
  1. 类内部可能只是记录了生日,而并没有记录年龄,那么我们应该提供的是 getAge()法而不是 calculateAge()露内部的实现
  2. 所有数据应该是 private,不是所有变量都有 getter 和 setter 的,同时 getter 和 setter 是为了检查正确性的。

封装内部结构

暴露了内部结构

  • 不合适的 get 和 set 方法的命名:比如 getPositionsArray()
  • getPostion 里面写成 return new Position(position[index])可以隐藏
  • 目的是:不应该在接口的地方就带有实现性的实现接口,同时内部的修改不应该影响到外部的具体实现。
  • 改动内部结构危险!外部修改内部结构,内部允许并且无法得知。
隐藏内部结构

  1. 在函数名上应该避免直接暴露内部实现的方式
Collection 暴露了内部的结构
  1. 参考 16 章的迭代器模式

  1. 直接返回集合暴露了内部封装的行为。
迭代器实现

  1. 通过迭代器的封装来隐藏内部具体结构。
  2. 传递迭代器对象而不是原来对象(隐藏内部实现)
  3. 参考课本 249 页的例子

封装其他对象的引用

  1. new 一个新对象返回,防止原对象被修改。
隐藏内部对象

  • 注意是重新创建了一个新的对象,而不是直接返回对象,避免通过引用的方式对原来的对象进行了修改。
委托隐藏了与其他对象的协作
  1. 协同设计:组成; 委托

  • 左侧使用代理,直接访问最右侧的
  • 多层调用会增加隐式耦合
更多例子
  1. 参考 Sales 和 SaleItem 的例子,参考课本 250 页
  2. 关于以上的 Position 例子的描述同样也参考课本 250 页

封装类型信息

LSP 的隐藏
  1. LSP 里氏替换原则:指向超类或接口的指针;
  2. 所有派生类都必须可以替代其基类
  3. 子类要求更少,能做的更多,这样才能替换父类

封装潜在的变更

封装变更(或变化)
  1. 确定应用程序中可能更改的各个方面,并将它们与保持不变的部分分开。
  2. 将变化的部分封装起来,以便以后可以更改或扩展变化的部分,而不会影响不变的部分。
封装变更

  1. 接口是面向上层实现的,现有实现和接口对于外部都是合理的。

原则十:最小化类和成员的可访问性

  1. 抽象化:抽象集中于对象的外部视图,并将对象的行为与其实现分开
  2. 封装形式:类不应公开其内部实现细节
  3. 权限最小化原则

类和成员的可访问性

  • x 表示可见

Example:最小化可达性

  • public class:考虑问题:public 类的 public 方法可以被全局访问到,是不是需要 public
  • 是否应该对包内开放
  • 上面的设计是不合适的

  • 包内可见:没有 public
  • final:表示不能继承
  • 要做到:满足业务需求的最小的可达性

  • 包内可见,getPoint()以被包内方法和常规类加载器加载。

  • 全局可见,不可继承,方法是 public 的

  • getPoint()包内可见,是不是需要

为变更而设计

OCP(开闭原则,Open/Close Principle) 重要

职责修改的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
void Copy(ReadKeyboard& r, WritePrinter& wp, WriteDisk& wd, OutputDevice dev){
int c;
while((c = r.read())!= EOF)
if(dev == printer)
wp.write(c);
else
wd.write(c);
}
void Copy(ReadKeyboard& r, WritePrinter& w){
int c;
while ((c = r.read ()) != EOF)
w.write (c);
}
  1. 修改很复杂,修改后需要重新编译,有一定代价

怎么解决上面修改的问题

  1. 抽象是关键
  2. 使用多态依赖完成

例子的解决方案

1
2
3
4
5
6
7
8
DiskWriter::Write(c){
WriteDisk(c);
}
void Copy(ReadKeyboard& r, WritePrinter& w){
int c;
while ((c = r.read ()) != EOF)
w.write (c);
}

原则十一:开放/封闭原则(OCP)

  1. 软件实体应该开放进行扩展,而封闭以进行修改— B. Meyer,1988 年/ R。Martin,1996 年引用
  2. 扩展开放:模块的行为可以被扩展,比如新添加一个子类
  3. 修改关闭:模块中的源代码不应该被修改
  4. 统计数据表明,修正 BUG 最为频繁,但是影响很小;新增需求数量一般,但造成了绝大多数影响
  5. 应该编写模块,以便可以扩展它们而无需修改它们

RTTI:运行时类型信息是丑陋并且危险的

  1. RTTI = Run-Time Type Information RTTI = 运行时类型信息
  2. 如果模块尝试将基类指针动态转换为多个派生类,则每次扩展继承层次结构时,都需要更改模块
  3. 通过类型切换或 if-else-if 结构识别它们

RTTI 违背了开闭原则和里氏替换原则

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Shape {}
class Square extends Shape {
void drawSquare() {
// draw         
}     
}
class Circle extends Shape {
void drawCircle() {
// draw         
}     

void drawShapes(List<Shape> shapes) {
for (Shape shape : shapes) {
//这里写法不合适
if (shape instanceof Square) {
((Square) shape).drawSquare();
else if (shape instanceof Circle) {
((Circle) shape).drawCircle();
}
}
}
  1. 都有 draw 方法,应该将 draw 放到 shape 里面

多态

  1. 多态是指针对类型的语言限定,指的是不同类型的值能够通过统一的接口来操纵。

多态的分类

  1. 参数化多态:template
  2. C++使用 template 机制,Java 使用泛化机制。

Abstraction and Polymorphism that does not violate the open-closed principle and LSP 不违反开放原则和 LSP 的抽象和多态性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 interface Shape {
void draw();
}
class Square implements Shape {
void draw() {
// draw implementation
}
}
class Circle implements Shape {
void draw() {
// draw implementation
}
}
void drawShapes(List<Shape> shapes) {
for (Shape shape : shapes) {
shape.draw();
}
}

  • 违反开闭原则:对扩展开放,对修改关闭,因为如果发生扩展,必须要进行代码的修改。

  • 通过多态来满足开闭原则

开闭原则总结

没有重大的计划可以 100% 封闭 R.Martin,

  1. 使用抽象获得显式关闭
  2. 根据可能发生的变化来计划课程:最小化未来的变更地点
  3. OCP 需要 DIP && LSP 开闭原则需要依赖导致原则和里氏替换原则作为基础。

DIP 依赖倒置原则

依赖倒置原则是指:

  1. 抽象不应该依赖于细节,细节应该依赖于抽象。因为抽象是稳定的,细节是不稳定的。
  2. 高层模块不应该依赖于低层模块,而是双方都依赖于抽象,因为抽象是稳定的,而高层模块和低层模块都可能是不问稳定的。

原则十二:Dependency Inversion Principle (DIP) 依赖倒置原则

  1. 高级模块不应依赖于低级模块:两者都应依赖抽象。
  2. 抽象不应该依赖细节:详细信息应取决于抽象-R。 马丁(1996)

耦合的方向性

  • 左边 A 依赖于 B,右边 B 依赖于 A

依赖倒置:将接口从实现中分离出来 —— 抽象

  1. 设计接口,而不是实现!
  2. 使用继承来避免直接绑定到类

  1. 实现依赖于接口(都依赖于接口)

DIP 的实现

  1. 如果我们需要 B 依赖于 A
    1. 如果 A 是抽象的,那么符合 DIP
    2. 如果 A 不是抽象,那么不符合 DIP,我们为 A 建立抽象借口接口 IA,然后使用 B 依赖于 IA、A 实现 IA,所以这样子 B 就依赖于 IA,A 也依赖于 IA。

依赖倒置的例子

  • 抽象:Writer,扩展的时候只需要被扩展类实现 Writer
1
2
3
4
5
6
7
8
9
10
11
12
13
class Reader {
public:
virtual int read() = 0;
};
class Writer {
public:
virtual void write(int) = 0;
};
void Copy(Reader& r, Writer& w){
int c;
while((c = r.read()) != EOF)
w.write(c);
}

依赖倒置过程和面向对象结构层次

Procedura Architecture

Object-Oriented Architecture

  • 抽象的接口而不是实现

依赖倒置总结

  1. 抽象类/接口:
    1. 倾向于不经常改变
    2. 抽象是“铰接点”,在此更易于扩展/修改
    3. 不必修改代表抽象(OCP)类/接口
  2. 例外情况
    1. 有些类很不可能修改
      1. 因此对插入抽象层没有什么好处
      2. 示例:字符串类
    2. 在这种情况下可以直接使用具体的类:在这种情况下可以直接使用具体的类...
  3. 符合 DIP 的分层设计图参考课本 256 页

如何应对变化

  1. OCP 陈述了目标; DIP 陈述了机制;
  2. LSP 是 DIP 的保险

总结

信息隐藏:设计变更!

  1. 最常见的秘密是您认为可能会更改的设计决策。
  2. 然后,您可以通过将每个设计秘密分配给自己的类,子例程或其他设计单元来分离它们。
  3. 接下来,您隔离(封装)每个机密,这样,如果它确实发生了更改,则更改不会影响程序的其余部分。
  4. DIP 是有代价的,它增加了系统的复杂度,如果没有迹象(通常是需求的可变性)表明某个行为是不稳定的,就不要强行为其使用 DIP,否则会导致过度设计的问题。