封装原则倡导通过隐藏抽象的实现细节和隐藏变化等,实现关注点分离和信息隐藏
- 隐藏实现细节
抽象向客户端程序只暴露提供的功能,将实现方式隐藏起来
实现方式(细节)包含抽象的内部表示(如抽象用的数据成员和数据结构)以及有关方法是如何实现的细节(如方法使用的算法)
- 隐藏变化
隐藏类型或实现结构的实现变化
通过隐藏变化,更容易在不给客户端程序带来太大影响的情况下修改抽象的实现
违反封装原则导致的坏味
不充分的封装
对于抽象的一个或多个成员,声明的访问权限超过了实际需求,导致这种坏味
极端表现形式是,存在一些用全局变量、全局数据结构等表示的全局状态,整个软件系统的所有抽象都可以访问它们
为什么要有充分的封装?
封装主要意图是将接口和实现分离,以便几乎独立地修改它们
关注点分离让客户端只依赖于抽象的接口,对它们隐藏实现细节
暴露实现细节,导致抽象和客户端紧密耦合
修改抽象的实现细节时,都影响客户端程序
提供超过需要的访问权限可能向客户端程序暴露实现细节,违反“隐藏原则”
不充分的封装的潜在原因
- 方便测试
将抽象的私有方法改成公有的
私有方法涉及抽象的实现细节,改为公有将破坏抽象的封装
代码的可测试性是衡量代码质量的一个重要指标
- 在面向对象编程中采用过程型思维
以全局变量的方式暴露多个抽象需要使用的数据,导致这种坏味
|
|
不充分的封装的典型,变量channel 设置为public不合适
创建对象时就已经指定发布的频道号,被设置为public,频道号在客户端使用的时候就可以随意的被访问修改
客户端会了解消息发布类的内部实现,造成直接依赖,违反了“高内聚,低耦合”原则
修改内部实现时都会对客户端造成影响
更重要是变量channel有范围限定(1-100),客户端用的时候随意的修改,可能造成越界错误
正确做法是将channel变量设为私有,并提供合适的set方法
重构后
|
|
还有一种极端表现形式:全局变量
全局变量存在两种不同情形
- 将一或多个成员设置为全局可见,只少量类会访问
- 将一或多个成员设置为全局可见,大量的类会访问
第一种重构,可以通过参数传递必要的变量
第二种重构,可以根据其承担的责任创建合适的抽象,并在这些抽象中封装原来的全局变量,这样客户端就会使用这些抽象,而不是直接使用全局变量
总结
不充分的封装坏味时,可重用性大打折扣,客户程序直接依赖大家都可以访问的状态,难以在其它地方重用客户程序
抽象允许直接访问其数据成员时,确保数据和整个抽象完整性的职责,由抽象转移到各个客户程序。增加运行阶段发生问题的可能性
用存取器方法控制对变量访问修改带来的好处,对于存取器性能开销忽略不讦
泄露的封装
抽象通过公有接口(方法)暴露或泄露实现细节时,导致这种坏味
即使抽象不存在"不充分的封装" 坏味,其公有接口也有可能泄露实现细节
为什么不能泄露封装?
实现有效封装,必须将抽象的接口(抽象的内容)和实现(抽象的方式)分离
遵循隐藏原则,必须对客户程序隐藏抽象的实现
通过公有接口暴露了实现细节(违反了隐藏原则)可能会造成:
- 修改实现,可能影响客户程序
- 暴露的实现细节可能会让客户程序能够通过公有接口访问内部数据结构,有意或无意地损坏抽象的内部状态
泄露的封装的潜在原因
- 不知道该隐藏哪些东西
开发人员无意之间泄露实现细节
- 使用细粒度接口
接口直接提供了细粒度的方法,会向客户程序暴露不必要的实现细节
更好的做法是在接口提供粗粒度的方法,在粗粒度方法内部使用细粒度的私有方法
demo1
|
|
方法返回类型,它暴露了内部细节,用List存储
getListEntries()的返回类型是List,如果修改这个方法的返回类型(list.add …),可能破坏依赖于这个方法的客户程序
如果要支持数据结构的变更,方法返回类型可以用Iterable,在不改变方法签名的条件下(里氏替换原则),替换数据结构
重构后
|
|
demo2
假设显式图像包含4个步骤,这些步骤必须按照特定顺序执行,图形才可以正常显示
Image类中提供4个公有方法load(),process(),validate(),show()供客户程序使用
但是写客户程序的开发人员不一定会按照正确顺序调用方法使用
且客户程序只是想要显式图像,为什么要向它们暴露4个内部步骤?
这就是泄露的封装的潜在原因——使用细粒度接口
|
|
要解决这个问题,让Image类只向客户程序暴露一个方法Display(),然后在这个方法内部按照特定顺序调用4个步骤方法
|
|
总结
- 抽象通过公有接口暴露或泄露了实现细节时,客户程序可能直接依赖于实现细节,直接依赖性难以在不破坏既有客户代码的情况下对设计进行修改或扩展
- 抽象泄露了内部数据结构时,抽象的完整性遭到了破坏。增加代码运行阶段发生问题的可能性
缺失封装
没有将实现变化封装在抽象和层次结构中时,导致这种坏味
表现形式:
- 客户程序与其需要的服务变种紧密耦合,每当需要支持新变种或修改既有变种时,都将影响客户程序
- 每当需要在层次结构中支持新变种时,都添加了大量不必要的类,增加设计的复杂度
为什么不能缺失封装?
开闭原则(OCP)指出,类型应对扩展开放,对修改关闭
通过扩展而不是修改来改变类型的行为
没有在类型或层次结构中封装实现变化时,便违反了OCP
缺失封装潜在的原因
- 未意识到关注点会不断变化
进而没有在设计中正确封装这些关注点
- 混合关注点
将彼此独立的各个关注点聚合在一个层次结构中,而不是分开时,如果关注点发生变化,可能导致类的数量呈爆炸式增长
- 幼稚的设计决策
采用过于简单的方法,如为每种变化组合创建一个类时,可能导致设计无谓的复杂
demo1
Entryption类,要用加密算法对数据加密
加密算法有很多,DES、AES等。Entryption类用DES
|
|
新需求,要求使用AES
最差的方案
|
|
- Encryption类变得更大、更难以维护,实现了多种加密算法,但是每次只使用一种
- 难以添加新算法以及修改既有算法,加密算法是Encryption类不可分割的部分
- 加密算法向Encryption类提供服务,但是与Encryption类紧紧耦合在一起,无法在其它地方重用
先用继承进行重构,有2种方案
一、
Encryption根据需求继承AESEncryptionAlgorithm或DESEncryptionAlgorithm类,并提供encrypt()
问题是Encryption类在编译阶段就将关联到特定的加密算法
更严重的是类之间的关系并不是is-a关系
|
|
|
|
|
|
二、
创建子类AESEncryption和DESEncryption,都扩展了Encryption类,分别包含AES和DES的实现
客户程序创建Encryption的引用,指向特定子类对象
通过添加新的子类,很容易支持新的加密算法
但AESEncryption和DESEncryption将继承Encryption类的其它方法,降低了加密算法的可重用性
|
|
|
|
|
|
用策略模式:
- 可在运行阶段给Encryption对象配置特定的加密算法
- 可在其它地方重用层次结构EncryptionAlgorithm中定义的算法
- 很容易根据需要支持新的算法
|
|
|
|
|
|
|
|
demo2
支持使用不同算法(DES和AES)对各种内容(Image和Text)进行加密
最简单最直观的的设计
有两个变化点:支持的内容类型和加密算法类型
这两个变化点的每种可能组合,都使用了一个类来表示
严重的问题:支持新加密算法TDES和新内容类型Data,类的数量呈爆炸性增长
变化点混在了一起,没有分别进行封装
使用桥接模式进行封装
使用桥接模式,分别封装这两个关注点的变化
引入新内容类型Data和新加密算法TDES,只需要添加两个新类
既解决了类数量呈爆炸增长的问题,又增加了根为接口EncryptionAlgorithm层次结构中的加密算法的可重用性
总结
- 不相关的关注点混在一起,抽象将变得难以重用
- 对业务中可能的变化点,要给予扩展点,保证开闭原则(OCP),对扩展开放,对修改关闭
未利用封装
客户代码使用显式类型检查(一系列if-else或switch检查对象的类型),而不利用出层次结构内已封装的类型变化时,将导致这种坏味
为什么要利用封装?
臭名昭著的坏味是,在客户代码中使用条件语句(if-else或switch)显式地检查类型,并根据类型执行相应的操作
要检查的类型都封装在了层次结构中,但没有利用这一点,即使用显式类型检查,而不依赖于动态多态性
问题:
-
显式类型检查让客户程序和具体类型紧密耦合,降低设计的可维护性
如,引入新类型后,必须修改客户程序,在其中检查新类型以及执行相应操作的代码
-
客户程序必须显式地检查层次结构中所有相关的类型
如果未检查一个或多个这样的类型,客户程序在运行阶段可能出现意外的行为。如果利用了运行时多态,完全可以避免这种问题
未利用封装潜在的原因
- 以过程型思维使用面向对象语言
开发思维是以代码执行过程为导向,用if-else语句和switch语句
- 未应用面向对象原则
无力将面向对象的概念付诸实践