15-面向对象的信息隐藏
- 1. 课前测试
- 2. 封装类的职责
- 3. 类的封装
- 4. 为变更而设计
:star:模块需要隐藏的决策主要有"**职责的实现“和“实现的变更“**两类。 在面向对象方法中,需要做到的就是:①封装类的职责,隐藏职责的实现; ②预计将会发生的变更,抽象它的接口,隐藏它的内部机制。
下面从这两方面进行介绍
1. 课前测试
1 | void Copy(ReadKeyboard& r, WritePrinter& w){ |
2. 封装类的职责
2.1. 结构化设计中的信息隐藏
2.1.1. 信息隐藏
每一个模块都隐藏了这个模块中关于重要设计决策的实现,以至于只有这个模块的每一个组成部分才能知道具体的细节
2.1.2. 设计细节应当被隐藏
- 最重要的细节:职责的变更
- 隐藏对于软件设计者特别的信息
- 来自于需求规格说明文档
- 次要的细节:实现的变更[外界不需要知道是怎么变的]
- 当设计者在实现一个模块的时候为隐藏次要秘密而做出的实现决策
- 变化
2.2. 类的职责
2.2.1. 什么是职责?
职责是类或对象维护一定的状态信息,并基于状态履行行为职能的能力。
2.2.2. 职责来源于需求
类与对象的职责是来源于需求的,否则就不会产生对系统的贡献,就没有存在的必要。
- 业务类:Sales、Order(职责来自于业务)
- 辅助类:View、Data、exception、transaction(事务处理)
- 除了业务类以外,软件设计中添加很多辅助类的职责也是源自于需求的。
2.2.3. 职责的体现
封装:一个模块应该通过稳定的接口对外体现其所承载的要求,而隐藏它对需求的内部实现细节。
3. 类的封装
- 目的是信息隐藏
3.1. 封装包含什么?
- 封装将数据和行为同时包含在类中
- 分离对外接口与内部实现。
3.1.1. 接口
- 接口是模块的可见部分:描述了一个类中的暴露到外界的可见特征
3.1.2. 实现
- 实现被隐藏在模块之中:隐藏实现意味着只能在类内操作,更新数据,而不意味着隐藏接口数据。
3.2. 面向对象中的接口通常包含的部分
- 对象之间交互的消息(方法名)
- 消息中的所有参数
- 消息返回结果的类型
- 与状态无关的不变量(前置条件和后置条件)
- 需要处理的异常
3.3. 封装数据类型
3.3.1. 封装的源头 — ADT
- ADT = Abstract Data Type 抽象数据类型
- 一个概念,并不是实现(逻辑上的)
- 一组(同构)对象以及对这些对象的一组操作
- 并没有体现出来这些操作是怎么实现的
- Example:栈
- Encapsulation = data abstraction + type 封装 = 数据的抽象 + 数据的类型
- 数据抽象:一组数据和操作
- 类型:比如对于 double 这个类型,使用它便有对应的操作,int 类型也有其对应的操作,因此一个 ADT 相当于抽象了数据和行为的一个类型
3.3.2. 为什么类型
- 一种数据类型可以看作是一套衣服(或盔甲),可以保护基础的无类型表示形式免受任意使用或意外使用。
- 它提供了一个保护性遮盖物,该遮盖物隐藏了底层表示并限制了对象与其他对象交互的方式。
- 在无类型的系统中,无类型的对象是裸露的,其基础表示形式公开给所有人看。
- type代表(封装)了一些操作
3.4. 封装实现的细节 重要
封装需要隐藏接口之外所有的实现细节
- 封装数据和行为
- 封装内部结构
- 封装其他对象的引用
- 封装类型信息
- 封装潜在变更
3.4.1. 封装数据和行为
-
如果不是必要,不应该提供get和set方法
-
同样不应该从名字上暴露类内部的实现形式
-
一般情况下,所有数据应该是private,不是所有变量都有getter和setter的,如果没有需求需要,则不需要加,同时getter和setter是为了检查正确性的。
-
JavaBean 通常要求为每个成员变最都提供一个 getter 方法与一个 setter 方法,那是因为 JavaBean 被认为是可能进行网络传输的,那么职责上就有数据打包和数据解包的间接 需求,自然就需要为所有的成员变量都提供 getter setter 方法。
3.4.1.1. 数据的封装 — 访问器和变量
- 如果需要,请使用访问器和变量,而不是公共成员
- 访问器和变量是有意义的行为:约束,转换,格式 …不要单纯地把这些方法与类的成员变量一一对应起来。例如,可以在 setter 方法中加入约束检查和数据转换
1 | //防止非法输入 |
3.4.1.2. 使用Getter和Setter方法的时候,不要暴露存储数据和推导数据之间的区别
- 类内部可能只是记录了生日,而并没有记录年龄,那么我们应该提供的是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. 隐藏内部结构
- 在函数名上应该避免直接暴露内部实现的方式,只应该暴露逻辑操作,而不能暴露实现逻辑操作的具体操作
3.4.2.3. Collection暴露了内部的结构
- 参考16章的迭代器模式
-
直接返回集合暴露了内部封装的行为。
并且直接返回 List 会导致外部可以对内部进行修改
3.4.2.4. 迭代器实现
- 通过迭代器的封装来隐藏内部具体结构。
- 传递迭代器对象而不是原来对象(隐藏内部实现),迭代器只有 hasNext 和 next 方法,外部只能访问,不能修改
3.4.3. 封装其他对象的引用
- get 方法应该只是返回一个值,而不是原对象,如果返回原对象,那么外界可以通过 get 方法得到原对象并将其修改,set 方法的限制就失效了
- new一个新对象返回,防止原对象被修改。
3.4.3.1. 隐藏内部对象
- 注意是重新创建了一个新的对象,而不是直接返回对象,避免通过引用的方式对原来的对象进行了修改。
3.4.3.2. 委托隐藏了与其他对象的协作
- 委托的方式隐藏了协作,左侧对象不知道它的一个方法事实上和右侧的对象有协作
- 左侧使用代理,直接访问最右侧的
- 多层调用会增加隐式耦合
设计模式中代理模式、中介模式等都是封装其他对象的引用的典型案例。
3.4.4. 封装类型信息
3.4.4.1. LSP就是封装类型信息的典型方法
- LSP 里氏替换原则:指向超类或接口的指针;
- 所有子类都能替换父类,用父类即可指向所有子类,隐藏了具体的类型
3.4.5. 封装潜在的变更
信息隐藏需要隐藏变更,[McConnell 1996b] 建议:如果预计类的实现中有特定地方会发生变更,就应该将其独立为单独的类或者方法,然后为单独的类或方法抽象建立稳定的接口,并在原类中使用该稳定接口以屏蔽潜在变更的影响。
3.4.5.1. 封装变更(或变化)
- 确定应用程序中可能更改的各个方面,并将它们与保持不变的部分分开。
- 将变化的部分封装起来,以便以后可以更改或扩展变化的部分,而不会影响不变的部分。
3.4.5.2. 封装变更
将 getLatitude()、 setLatitude()方法中会变更的地方独立出去,抽象为稳定接口 Math.toDegrees(phi) Math.toDegrees(theta) ,然后在 getLatitude( ) setLatitude()方法中调用这些接口。这样,即使将来真的发生了变更,也不会影响到 Position 类。
将经纬度改成极坐标,但对外接口完全不会改变,外部不需要知道内部是怎么变更的
3.5. 原则十:最小化类和成员的可访问性
- 抽象化:抽象集中于对象的外部视图,并将对象的行为与其实现分开
- 封装形式:类不应公开其内部实现细节
- 权限最小化原则
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 | void Copy(ReadKeyboard& r, WritePrinter& wp, WriteDisk& wd, OutputDevice dev){ |
- ifelse switch等修改很复杂,修改后需要重新编译,有一定代价
4.1.2. 怎么解决上面修改的问题
- 抽象是关键
- 使用多态依赖完成
4.1.3. 例子的解决方案
1 | DiskWriter::Write(c){ |
4.2. 原则十一:开放/封闭原则(OCP)
- 软件实体应该开放进行扩展,而封闭以进行修改— B. Meyer,1988年/ R。Martin,1996年引用
- 对扩展开放:模块的行为可以被扩展,比如新添加一个子类
- 对修改关闭:模块中的源代码不应该被修改
- 开闭原则是指:在发生变更时,好的设计只需要添加新的代码而不需要修改原有的代码,就能够实现变更。
4.2.1. RTTI:运行时类型信息是丑陋并且危险的
- RTTI = Run-Time Type Information RTTI = 运行时类型信息
- 如果模块尝试将基类指针动态转换为多个派生类,则每次扩展继承层次结构时,都需要更改模块
- 通过类型切换或if-else-if结构识别它们
4.2.2. RTTI违背了开闭原则和里氏替换原则
1 | class Shape {} |
- 都有draw方法,应该将draw放到shape里面
4.3. 多态
-
多态是指针对类型的语言限定,指的是不同类型的值能够通过统一的接口来操纵。
-
以不论实际类型为何,直接调用该统一接口,这样系统就可以根据实际类型的不同表现出不同的行为。
-
所以,严格来说并不是非要有继承关系才会产生多态。对于强类型语言,多态通常意味 A 类型来源于 B 类型,或者 C 实现 B 接口。而对于弱类型语言,天生就是多态的。
4.3.1. 多态的分类
- 参数化多态:template
- C++使用template机制,Java使用泛化机制。
4.3.2. Abstraction and Polymorphism that does not violate the open-closed principle and LSP 不违反开放原则和LSP的抽象和多态性
1 | interface Shape { |
- 违反开闭原则:对扩展开放,对修改关闭,因为如果发生扩展,必须要进行代码的修改。
- 通过多态来满足开闭原则
4.3.3. 开闭原则总结
不能100%实现这个原则,变更发生时,总会有部分代码需要修改。即使是使用了接口,那么在创建不同类型对象的部分代码总是要修改的。
所以 OCP 目的是保证业务逻辑代码部分不发生修改,而不是所有代码。
-
使用抽象获得显式关闭
-
根据预计可能发生的变化来设计程序:最小化未来的变更位置
-
OCP需要DIP && LSP
开闭原则需要依赖导致原则和里氏替换原则作为基础。
4.4. DIP 依赖倒置原则
依赖倒置原则是指:
- 抽象不应该依赖于细节,细节应该依赖于抽象。因为抽象是稳定的,细节是不稳定的。
- 高层模块不应该依赖于低层模块,而是双方都依赖于抽象,因为抽象是稳定的,而高层模块和低层模块都可能是不问稳定的。
4.4.1. 原则十二:Dependency Inversion Principle (DIP) 依赖倒置原则
- 高级模块不应依赖于低级模块:两者都应依赖抽象。
- 抽象不应该依赖细节:详细信息应取决于抽象-R. 马丁(1996)
4.4.2. 耦合的方向性
- 左边A依赖于B,右边B依赖于A,一般是等价的
- 假设 A 的其他方法是不稳定的,而 B 是非常稳定的,那 么方案1就会优于方案 2。因为 A 会发生修改,那么在方案 A 中每次 发生修改时就可能给 B 带来连锁影响,至少每次重新编译和链接之后 B 要被重新编译和链接。而在方案 1 中, 不论 A 发生何种变更,都不会对 B 造成任何影响。
- 所以,很多时候耦合的方向是很重要的,这就是依赖倒置原则的主要关注点
4.4.3. 依赖倒置:将接口从实现中分离出来 —— 抽象
- 设计接口,而不是实现!
- 使用继承来避免直接绑定到类
- 实现依赖于接口(都依赖于接口)
4.4.4. DIP的实现
- 如果我们需要B依赖于A
- 如果A是抽象的,那么符合DIP
- 如果A不是抽象,那么不符合DIP,我们为A建立抽象接口IA,然后使用B依赖于IA、A实现IA,所以这样子B就依赖于IA,A也依赖于IA。
4.4.5. 依赖倒置的例子
- 抽象:Writer,扩展的时候只需要被扩展类实现Writer
1 | class Reader { |
4.4.6.依赖倒置过程和面向对象结构层次
- 抽象的接口而不是实现
4.4.7. 依赖倒置总结
-
抽象类/接口:
- 倾向于不经常改变
- 抽象是"铰接点",在此更易于扩展/修改
- 不必修改代表抽象(OCP)的类/接口
-
例外情况
- 有些类很不可能修改
- 因此对插入抽象层没有什么好处
- 示例:字符串类
- 在这种情况下可以直接使用具体的类:在这种情况下可以直接使用具体的类…
- 有些类很不可能修改
-
传统方式不符合 DIP,右侧的设计无论那一层,都只是和一个稳定的抽象接口耦合,与具体的类没有耦合,具体的类发生变化,不会影响到使用的类
4.4.8. 如何应对变化
- OCP陈述了目标; DIP陈述了机制;
- LSP是DIP的保证
4.5. 总结
4.5.1. 信息隐藏:设计变更!
- 最常见的秘密是您认为可能会更改的设计决策。
- 然后,您可以通过将每个设计秘密分配给自己的类,子例程或其他设计单元来分离它们。
- 接下来,您隔离(封装)每个机密,这样,如果它确实发生了更改,则更改不会影响程序的其余部分。
- DIP是有代价的,它增加了系统的复杂度,如果没有迹象(通常是需求的可变性)表明某个行为是不稳定的,就不要强行为其使用DIP,否则会导致过度设计的问题。