15-面向对象的信息隐藏
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. 设计细节应当被隐藏
- 最重要的细节:职责的变更
- 隐藏对于软件设计者特别的信息
- 来自于需求规格说明文档
- 次要的细节:实现的变更
- 当设计者在实现一个模块的时候为隐藏次要秘密而做出的实现决策
- 变化
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 封装 = 数据的抽象 + 数据的类型
- 数据抽象:一组数据和操作
- 类型:隐藏实现,保证使用正确
3.3.2. 为什么类型
- 一种数据类型可以看作是一套衣服(或盔甲),可以保护基础的无类型表示形式免受任意使用或意外使用。
- 它提供了一个保护性遮盖物,该遮盖物隐藏了底层表示并限制了对象与其他对象交互的方式。
- 在无类型的系统中,无类型的对象是裸露的,其基础表示形式公开给所有人看。
- type代表(封装)了一些操作
3.4. 封装实现的细节 重要
- 封装数据和行为
- 封装内部结构
- 封装其他对象的引用
- 封装类型信息
- 封装潜在变更
3.4.1. 封装数据和行为
- 如果不是必要,不应该提供get和set方法
- 同样不应该从名字上暴露类内部的实现形式
- 所有数据应该是private,不是所有变量都有getter和setter的,同时getter和setter是为了检查正确性的。
3.4.1.1. 数据的封装 — 访问器和变量
- 如果需要,请使用访问器和变量,而不是公共成员
- 访问器和变量是有意义的行为:约束,转换,格式 …
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方法的时候,不单纯将这些方法与类的成员关联
- 类内部可能只是记录了生日,而并没有记录年龄,那么我们应该提供的是getAge()方法而不是calculateAge()暴露内部的实现
- 所有数据应该是private,不是所有变量都有getter和setter的,同时getter和setter是为了检查正确性的。
3.4.2. 封装内部结构
3.4.2.1. 暴露了内部结构
- 不合适的get和set方法的命名:比如getPositionsArray()
- getPostion里面写成return new Position(position[index])也可以隐藏
- 目的是:不应该在接口的地方就带有实现性的实现接口,同时内部的修改不应该影响到外部的具体实现。
- 改动内部结构危险!外部修改内部结构,内部允许并且无法得知。
3.4.2.2. 隐藏内部结构
- 在函数名上应该避免直接暴露内部实现的方式
3.4.2.3. Collection暴露了内部的结构
- 参考16章的迭代器模式
- 直接返回集合暴露了内部封装的行为。
3.4.2.4. 迭代器实现
- 通过迭代器的封装来隐藏内部具体结构。
- 传递迭代器对象而不是原来对象(隐藏内部实现)
- 参考课本249页的例子
3.4.3. 封装其他对象的引用
- new一个新对象返回,防止原对象被修改。
3.4.3.1. 隐藏内部对象
- 注意是重新创建了一个新的对象,而不是直接返回对象,避免通过引用的方式对原来的对象进行了修改。
3.4.3.2. 委托隐藏了与其他对象的协作
- 协同设计:组成; 委托
- 左侧使用代理,直接访问最右侧的
- 多层调用会增加隐式耦合
3.4.3.3. 更多例子
- 参考Sales和SaleItem的例子,参考课本250页
- 关于以上的Position例子的描述同样也参考课本250页
3.4.4. 封装类型信息
3.4.4.1. LSP的隐藏
- LSP 里氏替换原则:指向超类或接口的指针;
- 所有派生类都必须可以替代其基类
- 子类要求更少,能做的更多,这样才能替换父类
3.4.5. 封装潜在的变更
3.4.5.1. 封装变更(或变化)
- 确定应用程序中可能更改的各个方面,并将它们与保持不变的部分分开。
- 将变化的部分封装起来,以便以后可以更改或扩展变化的部分,而不会影响不变的部分。
3.4.5.2. 封装变更
- 接口是面向上层实现的,现有实现和接口对于外部都是合理的。
3.5. 原则十:最小化类和成员的可访问性
- 抽象化:抽象集中于对象的外部视图,并将对象的行为与其实现分开
- 封装形式:类不应公开其内部实现细节
- 权限最小化原则
3.6. 类和成员的可访问性
3.6.1. Example:最小化可达性
- public class:考虑问题:public类的public方法可以被全局访问到,是不是需要public
- 是否应该对包内开放
- 上面的设计是不合适的
- 包内可见:没有public
- final:表示不能继承
- 要做到:满足业务需求的最小的可达性
- 包内可见,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); }
|
- 修改很复杂,修改后需要重新编译,有一定代价
4.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)
- 软件实体应该开放进行扩展,而封闭以进行修改— B. Meyer,1988年/ R。Martin,1996年引用
- 对扩展开放:模块的行为可以被扩展,比如新添加一个子类
- 对修改关闭:模块中的源代码不应该被修改
- 统计数据表明,修正BUG最为频繁,但是影响很小;新增需求数量一般,但造成了绝大多数影响
- 应该编写模块,以便可以扩展它们而无需修改它们
4.2.1. RTTI:运行时类型信息是丑陋并且危险的
- RTTI = Run-Time Type Information RTTI = 运行时类型信息
- 如果模块尝试将基类指针动态转换为多个派生类,则每次扩展继承层次结构时,都需要更改模块
- 通过类型切换或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() { } } class Circle extends Shape { void drawCircle() { } } void drawShapes(List<Shape> shapes) { for (Shape shape : shapes) { if (shape instanceof Square) { ((Square) shape).drawSquare(); } else if (shape instanceof Circle) { ((Circle) shape).drawCircle(); } } }
|
- 都有draw方法,应该将draw放到shape里面
4.3. 多态
- 多态是指针对类型的语言限定,指的是不同类型的值能够通过统一的接口来操纵。
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 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() { } } class Circle implements Shape { void draw() { } } void drawShapes(List<Shape> shapes) { for (Shape shape : shapes) { shape.draw(); } }
|
- 违反开闭原则:对扩展开放,对修改关闭,因为如果发生扩展,必须要进行代码的修改。
4.3.3. 开闭原则总结
没有重大的计划可以100%封闭 R.Martin,
- 使用抽象获得显式关闭
- 根据可能发生的变化来计划课程:最小化未来的变更地点
- OCP需要DIP && LSP 开闭原则需要依赖导致原则和里氏替换原则作为基础。
4.4. DIP 依赖倒置原则
依赖倒置原则是指:
- 抽象不应该依赖于细节,细节应该依赖于抽象。因为抽象是稳定的,细节是不稳定的。
- 高层模块不应该依赖于低层模块,而是双方都依赖于抽象,因为抽象是稳定的,而高层模块和低层模块都可能是不问稳定的。
4.4.1. 原则十二:Dependency Inversion Principle (DIP) 依赖倒置原则
- 高级模块不应依赖于低级模块:两者都应依赖抽象。
- 抽象不应该依赖细节:详细信息应取决于抽象-R。 马丁(1996)
4.4.2. 耦合的方向性
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 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. 依赖倒置总结
- 抽象类/接口:
- 倾向于不经常改变
- 抽象是"铰接点",在此更易于扩展/修改
- 不必修改代表抽象(OCP)的类/接口
- 例外情况
- 有些类很不可能修改
- 因此对插入抽象层没有什么好处
- 示例:字符串类
- 在这种情况下可以直接使用具体的类:在这种情况下可以直接使用具体的类…
- 符合DIP的分层设计图参考课本256页
4.4.8. 如何应对变化
- OCP陈述了目标; DIP陈述了机制;
- LSP是DIP的保险
4.5. 总结
4.5.1. 信息隐藏:设计变更!
- 最常见的秘密是您认为可能会更改的设计决策。
- 然后,您可以通过将每个设计秘密分配给自己的类,子例程或其他设计单元来分离它们。
- 接下来,您隔离(封装)每个机密,这样,如果它确实发生了更改,则更改不会影响程序的其余部分。
- DIP是有代价的,它增加了系统的复杂度,如果没有迹象(通常是需求的可变性)表明某个行为是不稳定的,就不要强行为其使用DIP,否则会导致过度设计的问题。