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