15-面向对象的信息隐藏

:star:模块需要隐藏的决策主要有"**职责的实现“和“实现的变更“**两类。 在面向对象方法中,需要做到的就是:①封装类的职责,隐藏职责的实现; ②预计将会发生的变更,抽象它的接口,隐藏它的内部机制。

下面从这两方面进行介绍

1. 课前测试

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);
}

2. 封装类的职责

2.1. 结构化设计中的信息隐藏

2.1.1. 信息隐藏

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

2.1.2. 设计细节应当被隐藏

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

2.2. 类的职责

2.2.1. 什么是职责?

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

2.2.2. 职责来源于需求

类与对象的职责是来源于需求的,否则就不会产生对系统的贡献,就没有存在的必要。

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

2.2.3. 职责的体现

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

3. 类的封装

  1. 目的是信息隐藏

3.1. 封装包含什么?

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

3.1.1. 接口

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

3.1.2. 实现

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

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

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

3.3. 封装数据类型

3.3.1. 封装的源头 — ADT

  1. ADT = Abstract Data Type 抽象数据类型
    1. 一个概念,并不是实现(逻辑上的)
    2. 一组(同构)对象以及对这些对象的一组操作
    3. 并没有体现出来这些操作是怎么实现的
    4. Example:栈
  2. Encapsulation = data abstraction + type 封装 = 数据的抽象 + 数据的类型
    1. 数据抽象:一组数据和操作
    2. 类型:比如对于 double 这个类型,使用它便有对应的操作,int 类型也有其对应的操作,因此一个 ADT 相当于抽象了数据和行为的一个类型

3.3.2. 为什么类型

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

3.4. 封装实现的细节 重要

封装需要隐藏接口之外所有的实现细节

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

3.4.1. 封装数据和行为

  • 如果不是必要,不应该提供get和set方法

  • 同样不应该从名字上暴露类内部的实现形式

  • 一般情况下,所有数据应该是private不是所有变量都有getter和setter的,如果没有需求需要,则不需要加,同时getter和setter是为了检查正确性的

  • JavaBean 通常要求为每个成员变最都提供一个 getter 方法与一个 setter 方法,那是因为 JavaBean 被认为是可能进行网络传输的,那么职责上就有数据打包和数据解包的间接 需求,自然就需要为所有的成员变量都提供 getter setter 方法。

3.4.1.1. 数据的封装 — 访问器和变量

  1. 如果需要,请使用访问器和变量,而不是公共成员
  2. 访问器和变量是有意义的行为:约束,转换,格式 …不要单纯地把这些方法与类的成员变量一一对应起来。例如,可以在 setter 方法中加入约束检查和数据转换
1
2
3
4
5
6
7
8
//防止非法输入
public void setSpeed(double newSpeed) {
if (newSpeed < 0) {
sendErrorMessage(...);
newSpeed = Math.abs(newSpeed);
}
speed = newSpeed;
}

3.4.1.2. 使用Getter和Setter方法的时候,不要暴露存储数据和推导数据之间的区别

  1. 类内部可能只是记录了生日,而并没有记录年龄,那么我们应该提供的是getAge()方法而不是calculateAge()暴露内部的实现

3.4.2. 封装内部结构

信息隐藏要求我们不能暴露数据结构的实现决策。如栈的实现可以是链表也可以是数组,不能暴露到底使用哪个

3.4.2.1. 暴露了内部结构

  • 不合适的get和set方法的命名:比如getPositionsArray()

  • getPosition()方法暴露了内部实现是数组这个设计决策,因为返回的是一个数组。如果日后设计师准备改用 Arraylist, 那么 getPosition() 方法的接口就需要进行修改,所有调用了该方法的其他方法也得跟着修改。所以这样的设计是不合理的。内部实现上的决策不应该影响到外部用户

    getPostion里面写成return new Position(position[index])也可以隐藏

  • 改动内部结构危险!外部修改内部结构,内部允许并且无法得知。

3.4.2.2. 隐藏内部结构

  1. 在函数名上应该避免直接暴露内部实现的方式,只应该暴露逻辑操作,而不能暴露实现逻辑操作的具体操作

3.4.2.3. Collection暴露了内部的结构

  1. 参考16章的迭代器模式

  1. 直接返回集合暴露了内部封装的行为。

    并且直接返回 List 会导致外部可以对内部进行修改

3.4.2.4. 迭代器实现

  1. 通过迭代器的封装来隐藏内部具体结构。
  2. 传递迭代器对象而不是原来对象(隐藏内部实现),迭代器只有 hasNext 和 next 方法,外部只能访问,不能修改

3.4.3. 封装其他对象的引用

  1. get 方法应该只是返回一个值,而不是原对象,如果返回原对象,那么外界可以通过 get 方法得到原对象并将其修改,set 方法的限制就失效了
  2. new一个新对象返回,防止原对象被修改。

3.4.3.1. 隐藏内部对象

  • 注意是重新创建了一个新的对象,而不是直接返回对象,避免通过引用的方式对原来的对象进行了修改。

3.4.3.2. 委托隐藏了与其他对象的协作

  1. 委托的方式隐藏了协作,左侧对象不知道它的一个方法事实上和右侧的对象有协作

  • 左侧使用代理,直接访问最右侧的
  • 多层调用会增加隐式耦合

设计模式中代理模式、中介模式等都是封装其他对象的引用的典型案例。

3.4.4. 封装类型信息

3.4.4.1. LSP就是封装类型信息的典型方法

  1. LSP 里氏替换原则:指向超类或接口的指针;
  2. 所有子类都能替换父类,用父类即可指向所有子类,隐藏了具体的类型

3.4.5. 封装潜在的变更

信息隐藏需要隐藏变更,[McConnell 1996b] 建议:如果预计类的实现中有特定地方会发生变更,就应该将其独立为单独的类或者方法,然后为单独的类或方法抽象建立稳定的接口,并在原类中使用该稳定接口以屏蔽潜在变更的影响。

3.4.5.1. 封装变更(或变化)

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

3.4.5.2. 封装变更

将 getLatitude()、 setLatitude()方法中会变更的地方独立出去,抽象为稳定接口 Math.toDegrees(phi) Math.toDegrees(theta) ,然后在 getLatitude( ) setLatitude()方法中调用这些接口。这样,即使将来真的发生了变更,也不会影响到 Position 类。

将经纬度改成极坐标,但对外接口完全不会改变,外部不需要知道内部是怎么变更的

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

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

3.6. 类和成员的可访问性

  • x表示可见

3.6.1. Example:最小化可达性

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

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

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

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

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

4. 为变更而设计

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

4.1.1. 职责修改的例子

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. ifelse switch等修改很复杂,修改后需要重新编译,有一定代价

4.1.2. 怎么解决上面修改的问题

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

4.1.3. 例子的解决方案

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);
}

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

  1. 软件实体应该开放进行扩展,而封闭以进行修改— B. Meyer,1988年/ R。Martin,1996年引用
  2. 扩展开放:模块的行为可以被扩展,比如新添加一个子类
  3. 修改关闭:模块中的源代码不应该被修改
  4. 开闭原则是指:在发生变更时,好的设计只需要添加新的代码而不需要修改原有的代码,就能够实现变更。

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

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

4.2.2. 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里面

4.3. 多态

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

  2. 以不论实际类型为何,直接调用该统一接口,这样系统就可以根据实际类型的不同表现出不同的行为。

  3. 所以,严格来说并不是非要有继承关系才会产生多态。对于强类型语言,多态通常意味 A 类型来源于 B 类型,或者 C 实现 B 接口。而对于弱类型语言,天生就是多态的。

4.3.1. 多态的分类

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

4.3.2. 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();
}
}

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

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

4.3.3. 开闭原则总结

不能100%实现这个原则,变更发生时,总会有部分代码需要修改。即使是使用了接口,那么在创建不同类型对象的部分代码总是要修改的。

所以 OCP 目的是保证业务逻辑代码部分不发生修改,而不是所有代码。

  1. 使用抽象获得显式关闭

  2. 根据预计可能发生的变化来设计程序:最小化未来的变更位置

  3. OCP需要DIP && LSP

    开闭原则需要依赖导致原则和里氏替换原则作为基础。

4.4. DIP 依赖倒置原则

依赖倒置原则是指:

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

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

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

4.4.2. 耦合的方向性

  • 左边A依赖于B,右边B依赖于A,一般是等价的
  • 假设 A 的其他方法是不稳定的,而 B 是非常稳定的,那 么方案1就会优于方案 2。因为 A 会发生修改,那么在方案 A 中每次 发生修改时就可能给 B 带来连锁影响,至少每次重新编译和链接之后 B 要被重新编译和链接。而在方案 1 中, 不论 A 发生何种变更,都不会对 B 造成任何影响。
  • 所以,很多时候耦合的方向是很重要的,这就是依赖倒置原则的主要关注点

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

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

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

4.4.4. DIP的实现

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

4.4.5. 依赖倒置的例子

  • 抽象: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);
}

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

  • 抽象的接口而不是实现

4.4.7. 依赖倒置总结

  1. 抽象类/接口:

    1. 倾向于不经常改变
    2. 抽象是"铰接点",在此更易于扩展/修改
    3. 不必修改代表抽象(OCP)的类/接口
  2. 例外情况

    1. 有些类很不可能修改
      1. 因此对插入抽象层没有什么好处
      2. 示例:字符串类
    2. 在这种情况下可以直接使用具体的类:在这种情况下可以直接使用具体的类…
  3. 传统方式不符合 DIP,右侧的设计无论那一层,都只是和一个稳定的抽象接口耦合,与具体的类没有耦合,具体的类发生变化,不会影响到使用的类

    image-20220523132004249

4.4.8. 如何应对变化

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

4.5. 总结

4.5.1. 信息隐藏:设计变更!

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