大纲

设计可复用的类

  • 继承和重写
  • 重载(Overloading)
  • 参数多态和泛型编程
  • 行为子类型与Liskov替换原则
  • 组合与委托

设计可复用库与框架

  • API和库 - 框架
  • Java集合框架(一个例子)

设计可复用的类

在OOP中设计可复用的类
封装和信息隐藏
继承和重写
多态性,子类型和重载
泛型编程
行为子类型和Liskov替代原则(LSP)
组合与委托

(1)行为子类型和Liskov替换原则(LSP)

行为子类型
子类型多态性:客户端代码可以统一处理不同种类的对象。 子类型多态:客户端可用统一的方式处理不同类型的对象

  • 如果Cat的类型是Animal的一个子类型,那么只要使用Animal类型的表达式,就可以使用Cat类型的表达式。

假设q(x)是T类型对象x可证明的性质,那么对于S类型的对象y,q(y)应该是可证明的,其中S是T的一个子类型。 - Barbara Liskov

Java编译器执行的规则(静态类型检查)

  • 子类可以添加,但不能删除方法
  • 具体类必须实现所有未定义的方法
  • 重写方法必须返回相同的类型或子类型
  • 重写方法必须接受相同的参数类型

重写方法不会抛出额外的异常

也适用于指定的行为(方法):

  • 相同或更强的不变量
  • 相同或较弱的先决条件
  • 相同或更强的后置条件

Liskov替代原则(LSP)

LSP是一种特定的子类型关系定义,称为强行为子类型化
在编程语言中,LSP依赖于以下限制:

  • 先决条件不能在子类型中加强。前置条件不能强化
  • 后置条件在子类型中不能被削弱。后置条件不能弱化
  • 超类型的不变式必须保存在一个子类型中。不变量要保持
  • 子类型中方法参数的变换。子类型方法参数:逆变
  • 子类型中返回类型的协边。子类型方法的返回值:协变
  • 子类型的方法不应引发新的异常,除非这些异常本身是超类型方法抛出的异常的子类型。 异常类型:协变(这将在第7-2节讨论)

Covariance (协变)

父类型到子类型:
越来越具体specific
返回值类型:不变或变得更具体
异常的类型:也是如此

Contravariance (反协变、逆变)

父类型到子类型:
越来越具体specific
参数类型:要相反的变化,要不变或越来越抽象

从逻辑上讲,它被称为子类型中方法参数的逆变。
这在Java中实际上是不允许的,因为它会使重载规则复杂化。

协变和反协变

数组是协变的:根据Java的子类型规则,T []类型的数组可能包含T类型的元素或T的任何子类型。
在运行时,Java知道这个数组实际上是作为一个整数数组实例化的,它只是简单地通过Number []类型的引用来访问。

  • 区分:对象的类型与引用的类型

考虑泛型中的LSP

泛型是类型不变的

  • ArrayList <String>是List <String>的子类型
  • List <String>不是List <Object>的子类型

编译完成后,编译器会丢弃类型参数的类型信息; 因此这种类型的信息在运行时不可用。
这个过程被称为类型擦除
泛型不是协变的。

什么是类型擦除?

类型擦除:如果类型参数是无界的,则将泛型类型中的所有类型参数替换为它们的边界或对象。 因此,生成的字节码只包含普通的类,接口和方法。

泛型中的通配符

无界通配符类型使用通配符(?)指定,例如List <?>。

  • 这被称为未知类型的列表。

有两种情况,无界通配符是一种有用的方法:

  • 如果您正在编写可以使用Object类中提供的功能实现的方法。
  • 代码使用泛型类中不依赖于类型参数的方法。 例如,List.size或List.clear。 事实上,Class <?>经常被使用,因为Class <T>中的大多数方法不依赖于T.

下限通配符:<? super A>
上限通配符:<? extends A>

考虑具有通配符的泛型的LSP

List<Number>是List<?>的一个子类
List<Number> 是List<? extends Object>的一个子类
List<Object>是List<? super String>的一个子类

(2)委托和组合

Interface Comparator<T>
int compare(T o1,T o2):比较它的两个参数的顺序。

  • 一个比较函数,它对某些对象集合进行总排序。
  • 可以将比较器传递给排序方法(如Collections.sort或Arrays.sort),以便精确控制排序顺序。 比较器也可以用来控制某些数据结构(例如排序集合或排序映射)的顺序,或者为没有自然排序的对象集合提供排序。

如果你的ADT需要比较大小,或者要放入Collections或Arrays进行排序,可实现Comparator接口并重写compare()函数。

该接口对每个实现它的类的对象进行总排序。

这种顺序被称为类的自然顺序,类的compareTo方法被称为其自然比较方法。
另一种方法:让你的ADT实现Comparable接口,然后重写compareTo()方法
与使用Comparator的区别:不需要构建新的Comparator类,比较代码放在ADT内部。

委托

委托只是当一个对象依赖另一个对象来实现其功能的某个子集时(一个实体将某个事物传递给另一个实体)
委派/委托:一个对象请求另一个对象的功能

  • 例如分拣机正在委托比较器的功能

委派是复用的一种常见形式

  • 分拣机可以重复使用任意的排序顺序
  • 比较器可以重复使用需要比较整数的任意客户端代码

委托可以被描述为在实体之间共享代码和数据的低级机制。

  • 显式委托:将发送对象传递给接收对象
  • 隐式委托:由语言的成员查找规则

委托模式是实施委托的一种软件设计模式,虽然这个术语也用于松散地进行咨询或转发。

委托依赖于动态绑定,因为它要求给定的方法调用可以在运行时调用不同的代码段。
处理

  • 接收者对象将操作委托给Delegate对象
  • 接收者对象确保客户端不会滥用委托对象。

委托与继承

继承:通过新操作扩展基类或重写操作。
委托:捕获操作并将其发送给另一个对象。
许多设计模式使用继承和委派的组合。

将继承替换为委派

问题:你有一个只使用其超类的一部分方法的子类(或者它不可能继承超类数据)。
解决方案:创建一个字段并在其中放入一个超类对象,将方法委托给超类对象,并消除继承。
实质上,这种重构拆分了两个类,并使超类成为子类的帮助者,而不是其父类。

  • 代替继承所有的超类方法,子类将只有必要的方法来委派给超类对象的方法。
  • 一个类不包含从超类继承的任何不需要的方法。

合成继承原则

或称为合成复用原则(CRP)

  • 类应该通过它们的组合(通过包含实现所需功能的其他类的实例)实现多态行为和代码复用,而不是从基类或父类继承。
  • 最好组合一个对象可以做的事(has_a)而不是扩展它(is_a)。

委托可以被看作是在对象层次上的复用机制,而继承是类层次上的复用机制。
“委托”发生在objet层面,而“继承”发生在类层面

合成继承原则

组合继承的实现通常始于创建代表系统必须展现的行为的各种接口。
实现已识别的接口的类将根据需要构建并添加到业务域类中。
这样,系统行为就没有继承地实现了。
使用接口定义不同侧面的行为
接口之间通过扩展实现行为的扩展(接口组合)
类实现组合接口

委托的类型

使用(A使用B)
组合/聚合(A拥有B)
关联(A有B)
这种分类是根据被委托者和委托者之间的“耦合程度”。

(1)依赖:临时性的委托

使用类的最简单形式是调用它的方法;
这两种类别之间的关系形式被称为“uses-a”关系,其中一个类使用另一个类而不实际地将其作为属性。 例如,它可能是一个参数或在方法中本地使用。
依赖关系:对象需要其他对象(供应商)实施的临时关系。

(2)关联:永久性的委托

关联:对象类之间的一种持久关系,它允许一个对象实例使另一个对象代表它执行一个动作。

  • has_a:一个类有另一个作为属性/实例变量
  • 这种关系是结构性的,因为它指定一种对象与另一种对象相连,并不代表行为。

(3)组成:更强的委托

组合是一种将简单对象或数据类型组合成更复杂的对象的方法。

  • is_part_of:一个类有另一个作为属性/实例变量
  • 实现了一个对象包含另一个对象。

(4)聚合

聚合:对象存在于另一个之外,在外部创建,所以它作为参数传递给构造者。

  • has_a

组合(Composition)与聚合(Aggregation)

在组合中,当拥有的对象被破坏时,被包含的对象也被破坏。

  • 一所大学拥有多个部门,每个部门都有一批教授。 如果大学关闭,部门将不复存在,但这些部门的教授将继续存在。

在聚合中,这不一定是正确的。

  • 大学可以被看作是一个部门的组合,而部门则拥有一批教授。 一位教授可以在一个以上的部门工作,但一个部门不能成为多个大学的一部分。

设计系统级可复用的库和框架

实际中的库和框架

定义关键抽象及其接口
定义对象交互和不变量
定义控制流程
提供体系结构指导
提供默认值
之所以库和框架被称为系统层面的复用,是因为它们不仅定义了1个可复用的接口/类,而是将某个完整系统中的所有可复用的接口/类都实现出来,并且定义了这些类之间的交互关系,调用关系,从而形成了系统整体的“架构”。

更多条款

API:应用程序编程接口,库或框架的接口
客户端:使用API的代码
插件:定制框架的客户端代码
扩展点:框架内预留的“空白”,开发者开发出符合接口要求的代码(即插件),框架可调用,从而相当于开发者扩展了框架的功能
协议:API和客户端之间预期的交互顺序
回调:框架调用来访问定制功能的插件方法
生命周期方法:根据协议和插件状态按顺序调用的回调方法

(1)API设计

为什么API设计很重要?

如果你编程,你是一个API设计师,并且API可以是你最大的资产之一

  • 好的代码是模块化的
  • 每个模块都有一个API
  • 用户大量投资:收购,写作,学习
  • 根据API思考改进代码质量
  • 成功的公共API捕捉用户

也可以是你最大的责任

  • 糟糕的API可能会导致无尽的支持调用流
  • 可以抑制前进的能力

公共API是永远的

  • 有一个机会让它正确
  • 一旦模块拥有用户,就不能随意更改API

(1)API应该做一件事,做得好

功能应该很容易解释

  • 如果名称很难,那通常是一个不好的迹象
  • 好名字推动发展
  • 适合分解和合并模块

(2)API应该尽可能小,但不能更小

API应该满足其要求

  • 功能,类别,方法,参数等
  • 你可以随时添加,但你永远不能删除

寻找一个很好的功率重量比

(3)实施不应该影响API

API中的实施细节是有害的

  • 迷惑用户
  • 禁止改变执行的自由

请注意什么是实施细节

  • 不要过分指定方法的行为

例如:不要指定散列函数

  • 所有调整参数都是可疑的

不要让实现细节“泄露”到API中

  • 序列化表单,抛出异常

尽量减少一切的可达性(信息隐藏)

  • 让班级成员尽可能私人化
  • 公共班级不应该有公共领域

(4)文件事宜

记录每个类,接口,方法,构造函数,参数和异常

  • 类:什么是实例
  • 方法:方法和客户之间的契约

先决条件,后置条件,副作用

  • 参数:指示单位,表格,所有权

文件线程安全
如果类是可变的,则记录状态空间

重复使用比说要容易得多。 这样做需要良好的设计和非常好的文档。 即使我们看到良好的设计(这仍然不常见),如果没有良好的文档,我们也不会看到组件被复用。 - D. L. Parnas软件老化,ICSE 1994

(5)考虑绩效后果

不好的决定会限制性能

  • 使类型变化
  • 提供构造函数而不是静态工厂
  • 使用实现类型而不是接口

不要扭曲API来获得性能

  • 潜在的性能问题将得到解决,但头痛将永远伴随着你

良好的设计通常与良好的性能相吻合
糟糕的API决策的性能影响可能是真实且永久的

  • Component.getSize()返回Dimension,但Dimension是可变的,因此每个getSize调用都必须分配Dimension,导致数百万无用的对象分配

(6)API必须与平台和平共存

习惯做什么

  • 遵守标准的命名约定
  • 避免过时的参数和返回类型
  • 模仿核心API和语言中的模式

利用API友好功能

  • 泛型,可变参数,枚举,函数接口

了解并避免API陷阱和陷阱

  • 终结器,公共静态最终数组等。

不要音译API

(7)类设计

最小化可变性:除非有充分的理由否则类应该是不可变的

  • 优点:简单,线程安全,可重复使用
  • 缺点:为每个值分开对象
  • 如果可变,保持状态空间小,定义明确。

只有子类才有意义:子类化会影响替代性(LSP)

  • 除非存在某种关系,否则不要继承。 否则,请使用委托或组合。
  • 不要为了复用实现而继承子类。
  • 继承违反封装,子类对超类的实现细节很敏感

(8)方法设计

不要让客户做任何模块可以做的事情

  • 客户通常通过剪切和粘贴,这是丑陋的,烦人的,错误的。

API应该快速失败:尽快报告错误。 编译时间最好 - 静态类型,泛型。

  • 在运行时,第一个错误的方法调用是最好的
  • 方法应该是失败原子的

以字符串形式提供对所有可用数据的编程访问。 否则,客户端会解析字符串,这对客户来说很痛苦
过度谨慎。 通常最好使用不同的名称。
使用适当的参数和返回类型。

  • 欢迎界面类型的类输入灵活性,性能
  • 使用最具体的可能输入参数类型,从而将错误从运行时移到编译时间。

避免长参数列表。 三个或更少的参数是理想的。

  • 如果你必须使用很多参数呢?

避免需要特殊处理的返回值。 返回零长度数组或空集合,不为null。

(2)框架设计

白盒和黑盒框架

白盒框架

  • 通过继承和覆盖方法进行扩展
  • 通用设计模式:模板方法
  • 子类具有主要方法,但对框架进行控制

黑盒框架

  • 通过实现插件接口进行扩展
  • 通用设计模式:策略,观察者
  • 插件加载机制加载插件并对框架进行控制

白盒与黑盒框架

白盒框架使用子类/子类型---继承

  • 允许扩展每个非私有方法
  • 需要了解超类的实现
  • 一次只能有一个分机
  • 汇编在一起
  • 通常所谓的开发者框架

黑盒框架使用组合 - 委派/组合

  • 允许扩展在界面中显示的功能
  • 只需要了解接口
  • 多个插件
  • 通常提供更多的模块化
  • 可以单独部署(.jar,.dll,...)
  • 通常称为最终用户框架,平台

框架设计考虑

一旦设计好,改变的机会就很小
关键决策:将通用部件与可变部件分开

  • 你想解决什么问题?

可能的问题:

  • 扩展点太少:限于狭窄的用户类别
  • 延伸点过多:难以学习,速度缓慢
  • 太通用:很少复用价值

“最大限度地利用重复使用最小化”

典型的框架设计和实现

定义你的域名

  • 识别潜在的公共部分和可变部分
  • 设计和编写示例插件/应用程序

分解和实施通用部件为框架

为可变部分提供插件接口和回调机制

  • 在适当的地方使用众所周知的设计原则和模式...

获得大量的反馈,并迭代
这通常被称为“域工程”。

进化设计:提取共同点

提取界面是进化设计中的一个新步骤:

  • 抽象类是从具体类中发现的
  • 接口是从抽象类中提取的

一旦架构稳定就开始

  • 从课堂上删除非公开的方法
  • 将默认实现移动到实现接口的抽象类中

运行一个框架

一些框架可以自行运行

  • 例如 Eclipse

其他框架必须扩展才能运行

  • Swing,JUnit,MapReduce,Servlets

加载插件的方法:

  • 客户端写入main(),创建一个插件并将其传递给框架
  • Framework写入main(),客户端将plugin的名称作为命令行参数或环境变量传递
  • Framework在一个神奇的位置查找,然后配置文件或.jar文件被自动加载和处理。

(3)Java集合框架

什么是收集和收集框架?

集合:对元素进行分组的对象
主要用途:数据存储和检索,以及数据传输

  • 熟悉的例子:java.util.Vector,java.util.Hashtable,Array

集合框架:一个统一的架构

  • 接口 - 实现独立
  • 实现 - 可复用的数据结构
  • 算法 - 可复用的功能

最着名的例子

  • C++标准模板库(STL)
  • Java集合框架(JCF)

同步包装(不是线程安全的!)

同步包装:线程安全的新方法

  • 匿名实现,每个核心接口一个
  • 静态工厂需要收集适当的类型
  • 如果通过包装进行全部访问,线程安全保证
  • 必须手动同步迭代

那时是新的; 现在已经老了!

  • 同步包装很大程度上已经过时
  • 由同时收集而过时

总结

设计可复用的类

  • 继承和重写
  • 超载
  • 参数多态和泛型编程
  • 行为分类和Liskov替代原则(LSP)
  • 组成和委派

设计系统级可复用的库和框架

  • API和库
  • 框架
  • Java集合框架(一个例子)

啥也博士
13 声望2 粉丝