您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > JAVA

如何在 Java 中安全地使用子类型

时间:2019-10-08 14:56:28  来源:  作者:

你可能还记得,Liskov 代换原则是关于承诺和契约的规则。但具体是怎样的承诺呢?为了确保 subtype(子类型)的安全性,意味着必须保证可以合理地从超类型推导出 subtype,而且这个过程具有传递关系。在数学中,对所有 a,b,c ∈ x,如果 aRb 并且 bRc,那么 aRc。在面向对象程序设计中,subclass 即对应 subtype,然而这不是正确的打开方式(这篇文章中 subclass 特指 subtype)。我们必须确保不会违反继承超类的承诺,我们不会依赖于一些无法控制的东西。如果它发生更改,则可以影响其他对象(这些是不可变对象)。实际上,subclass 甚至可能导致 bug。

译注:Liskov 于1987年提出了一个关于继承的原则:“继承必须确保超类所拥有的性质在子类中仍然成立”。也就是说,只有当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有 is-A 关系。

1. 为什么要安全地使用subtype(子类型)

实际上,subclass 是一种特殊的 subtype,它允许 subtype 重用 supertype 的实现(目的是防止因超类中的小改动导致重新实现)。我们可以认为 subclass 是 subtype,但不能说 subtype,但不能说 subtype 是 subclass。subclass 主要有两个工作:subtype(多态)和代码重用。subtype 的影响最大,父类中任何 public 或 protected 更改都将影响其子类。subetype 有时候是,但并不总是 Is-A 关系。实际上,subtype 是一种程序上的代码重用技术,也是一种实现动态多态性(dynamic polymorphism)的工具。

subclass 只关心实现的内容和方式,而非承诺的内容。如果违背了基类承诺会发生什么,如何保证它们之间能够兼容?即使编译器也无法理解这种错误,留给你的会是代码中的 bug,比如下面这个例子:

class DoubleEndedQueue {
 void insertFront(Node node) {
 // ...
 // 在队列的前面插入节点
 }
 void insertEnd(Node node) {
 // ...
 // 在队列末尾插入节点
 }
 void deleteFront(Node node) {
 // ...
 // 删除队列前面的节点
 }
 void deleteEnd(Node node) {
 // ...
 // 删除队列末尾的节点
 }
}
class Stack extends DoubleEndedQueue {
 // ...
}

如果 Stack 类希望使用 subtype 实现代码重用,那么它可能会继承一个违反自身原则的行为,比如 insertFront。让我们接着看另一个代码示例:

public class DataHashSet extends HashSet {
 private int addCount = 0;
 public function DataHashSet(Collection collection) {
 super(collection);
 }
 public function DataHashSet(int initCapacity, float loadFactor) {
 super(initCapacity, loadFactor);
 }
 public boolean function add(Object object) {
 addCount++;
 return super.add(object);
 }
 public boolean function addAll(Collection collection) {
 addCount += collection.size();
 return super.addAll(collection);
 }
 public int function getAddCount() {
 return addCount;
 }
}

上面的示例使用 DataHashSet 类重新实现 HashSet 跟踪插入操作。DataHashSet 继承 HashSet 并成为它的一个子类。我们可以在 JAVA 中传入 DataHashSet 对象替换 HashSet 对象。此外,我的确重写(override)了基类中的一些方法。这在 Liskov 代换原则中合法吗?由于没有对基类行为进行任何更改,只是加入跟踪插入操作代码,似乎完全合法。但我认为这显然是错误的 subtype 代码。

首先,应该看一下 add 方法到底能做什么。它把 addCount 属性加1,并调用父类方法。这段代码存在一个溜溜球问题。让为我们看看 addAll 方法。首先,它把 addCount 的值加上集合大小,然后调用父类的 addAll 方法。但是父类的 addAll 方法具体怎么执行呢?它将多次调用 add 方法(循环遍历集合)。问题来了,父类将调用哪个 add 方法?是当前子类的 add 还是父类中 add?答案是子类中的 add 方法。因此,count 大小增加两倍。调用 addAll 时,count 增加一次,当父类调用子类 add 方法时,count 会再增加一次。这就是为什么称之为悠悠球问题。

译注:yo-yo problem 溜溜球问题。在软件开发中,溜溜球问题是一种反模式(anti-pattern)。当阅读和理解一个继承层次非常长且复杂的程序时,程序员不得不在许多不同的类定义之间切换,以便遵循程序的控制流程。这种情况经常发生在面向对象程序设计。该术语来自比较弹跳注意的程序员的上下运动的玩具溜溜球。Ganti、Taenzer 和 Podar 解释为什么这么命名时说道:“当我们试图理解这些信息时,常常会有一种骑着溜溜球的感觉”。

这里还有一个例子证明 subtype 是有风险的,看下面这段代码:

class A {
 void foo(){
 ...
 this.bar();
 ...
 }
 void bar(){
 ...
 }
}
class B extends A {
 // 重写 bar
 void bar(){
 ...
 }
}
class C {
 void bazz(){
 B b = new B();
 // 这里会调用哪个 bar 函数?
 B.foo();
 }
}

调用 bazz 方法时,将调用哪个 bar 方法?当然是 B 类中的 bar 方法。这会带来什么问题?问题在于 A 类中的 foo 方法不知道被 B 类中 bar 方法重写,由于 A 中的 foo 方法认为调用的是自己定义的 bar 方法,从而导致类中的不变量和封装遭到破坏。这个问题也称为脆弱的基类问题(fragile base-class problem)。

subtype 会引发更关键的耦合问题:程序中的一部分对另一部分产生非预期得依赖(紧耦合)。强耦合的经典案例就是全局变量。例如,如果修改全局变量的类型,那么使用该变量(即与变量耦合)的所有函数都可能受到影响,因此必须检查、修改和重新测试所有代码。

此外,所有使用全局变量的函数都因为它彼此耦合。也就是说,如果变量的值在某个不恰当的时间更改,那么一个函数可能错误地影响另一个函数的行为。在多线程程序中,这种问题尤其可怕。

2. 如何安全地 subclass

subclass 最安全的方法是避免 subtype。如果类设计时并不希望支持 subclass,那么可以把构造函数设为 private 或在类的声明中加 final 关键字防止 subclass。如果希望支持 subclasss,那么可以新建一个包装类(wrApper class)实现代码重用。

这时,我们需要对代码重用进行模块化推理,即在无需了解实现细节的情况下重用代码的能力。有几种方法可以做到这一点,这里介绍其中的一些方法。一种方法是将可重写(overridable)的功能限制在少数 protected 方法中,避免自我调用可重写方法。

例如,通过语言自身机制或规范来防止重写其他方法。在 DataHashSet 类中,避免 addAll 调用 add 方法。另外,避免在类中调用可重写方法减少重写对其他函数的直接影响。让我们用前面的例子继续说明:

class A {
 void foo(){
 ...
 this.insideBar();
 ...
 }
 void insideBar(){
 ...
 }
 void bar(){
 this.insideBar();
 }
}
class B extends A {
 // 重写 bar
 void bar(){
 ...
 }
}
class C {
 void bazz(){
 B b = new B();
 B.foo();
 }
}

在上面的代码中,仅仅添加了 insideBar 方法,防止重写导致不必要的更改,就可以解决问题。大多数情况下,创建包装类是降低 subtype 风险的好方法。相比 subtype 我更喜欢组合(composition)或委托(delegation)。

有些时候必须不惜一切代价避免 subtype。如果有不止一种方法实现 subtype,那么最好使用委托。或者父类中包含一些没有调用的方法时,意味着不需要使用继承(Liskov 代换原则)。同样的规则对 class 也适用。我的意思是不应该在启用共享类(shared class)的时候对重用该类。

译注:shared class 共享类技术。Java5 平台的 IBM 实现中新的共享类特性提供了一种完全透明和动态的方法,可以共享已经装载的所有类,而不会对共享类数据的 JVM 施加限制。这个特性为减少虚拟内存占用和改进启动时间提供了一个简单且灵活的解决方案,大多数应用程序都能够因此受益。

3. subtype 委托

subtype 模式可以把类看做模板,它定义了所有实例的结构。每个实例都具备类属性与行为,但不包含属性值。因为类的所有实例(包括子类的实例)都使用类定义的属性,所以对类属性的任何更改都将影响到所有实例。

一个实例包含了父类(superclass)和子类所有信息的组合。subtype 呈一种线性的上下关系(Java 与 C++ 不同,不能有多个子类)。值存储在实例中,而不是类中,并且不支持共享。在子类中,实例之间互相独立,更改一个实例的状态值不会影响任何其他实例,而且每个实例都有自己的父对象。

委托表示使用另一个对象进行方法调用。在委托实例中,不通过类共享属性或行为,通常称之为无类实例。要重用某个类,可以使用它的一个实例。假设有一个面积计算器类,能够接受不同形状并返回其计算的面积。只要创建一个面积计算器对象,调用不同的面积计算类。在子类中,针对每种类型的面积计算,必须创建一个带有父类型的独立对象。

如果计算器对象将一个方法或变量委托给一个原型,那么修改任何属性或值都将同时影响对象和原型。使用这种方式,委托关系中的对象会互相依赖。在委托实现中,需要启动多个对象。与 subtype 相反,对象可以是不同类型。此外,还需要用正确的方式组合实例,以满足类的需要。

由于没有父类,因此不能直接使用对象属性。在 subtype 中,子类可以使用父类中的属性或方法;在委托中,必须先定义才能访问。

在委托中,只需要建立同这些类的连接,一个重用类(reuse class)可以重复使用多个重用类,这些类都包含在同一个实例中。但在 subtype 中,重用类必须是其他重用类的子类(具备继承关系)。

让我们用委托来解决 DataHashSet 中的问题:

public class DataHashSet implements Set {
 private int addCount = 0;
 private Set set;
 public
 function DataHashSet(Set set) {
 This.set = set;
 }
 public boolean
 function add(Object object) {
 addCount++;
 return This.set.add(object);
 }
 public boolean
 function addAll(Collection collection) {
 addCount += collection.size();
 return This.set.addAll(collection);
 }
 public int
 function getAddCount() {
 return addCount;
 }
}

4. 如何使用 Skeletal 模式?

Skeletal 模式既不损失灵活性,又能享受 subtype 的优点。它为每个接口提供一个实现该接口的抽象类,不指定基础方法(primitive method)。这意味着将方法设为 abstract 由子类实现,同时它还定义了非基础方法。然后,由使用该接口的开发者实现接口,负责框架实现。它不如包装类灵活,比如组合或委托。为了增加其灵活性,可以使用包装类将调用委托给框架实现的匿名子类对象。



Tags:Java 子类型   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,如有任何标注错误或版权侵犯请与我们联系(Email:2595517585@qq.com),我们将及时更正、删除,谢谢。
▌相关推荐
你可能还记得,Liskov 代换原则是关于承诺和契约的规则。但具体是怎样的承诺呢?为了确保 subtype(子类型)的安全性,意味着必须保证可以合理地从超类型推导出 subtype,而且这个过程...【详细内容】
2019-10-08  Tags: Java 子类型  点击:(95)  评论:(0)  加入收藏
▌简易百科推荐
面向对象的特征之一封装 面向对象的特征之二继承 方法重写(override/overWrite) 方法的重载(overload)和重写(override)的区别: 面向对象特征之三:多态 Instanceof关键字...【详细内容】
2021-12-28  顶顶架构师    Tags:面向对象   点击:(2)  评论:(0)  加入收藏
一、Redis使用过程中一些小的注意点1、不要把Redis当成数据库来使用二、Arrays.asList常见失误需求:把数组转成list集合去处理。方法:Arrays.asList 或者 Java8的stream流式处...【详细内容】
2021-12-27  CF07    Tags:Java   点击:(3)  评论:(0)  加入收藏
文章目录 如何理解面向对象编程? JDK 和 JRE 有什么区别? 如何理解Java中封装,继承、多态特性? 如何理解Java中的字节码对象? 你是如何理解Java中的泛型的? 说说泛型应用...【详细内容】
2021-12-24  Java架构师之路    Tags:JAVA   点击:(5)  评论:(0)  加入收藏
大家好!我是老码农,一个喜欢技术、爱分享的同学,从今天开始和大家持续分享JVM调优方面的经验。JVM调优是个大话题,涉及的知识点很庞大 Java内存模型 垃圾回收机制 各种工具使用 ...【详细内容】
2021-12-23  小码匠和老码农    Tags:JVM调优   点击:(12)  评论:(0)  加入收藏
前言JDBC访问Postgresql的jsonb类型字段当然可以使用Postgresql jdbc驱动中提供的PGobject,但是这样在需要兼容多种数据库的系统开发中显得不那么通用,需要特殊处理。本文介绍...【详细内容】
2021-12-23  dingle    Tags:JDBC   点击:(13)  评论:(0)  加入收藏
Java与Lua相互调用案例比较少,因此项目使用需要做详细的性能测试,本内容只做粗略测试。目前已完成初版Lua-Java调用框架开发,后期有时间准备把框架进行抽象,并开源出来,感兴趣的...【详细内容】
2021-12-23  JAVA小白    Tags:Java   点击:(11)  评论:(0)  加入收藏
Java从版本5开始,在 java.util.concurrent.locks包内给我们提供了除了synchronized关键字以外的几个新的锁功能的实现,ReentrantLock就是其中的一个。但是这并不意味着我们可...【详细内容】
2021-12-17  小西学JAVA    Tags:JAVA并发   点击:(11)  评论:(0)  加入收藏
一、概述final是Java关键字中最常见之一,表示“最终的,不可更改”之意,在Java中也正是这个意思。有final修饰的内容,就会变得与众不同,它们会变成终极存在,其内容成为固定的存在。...【详细内容】
2021-12-15  唯一浩哥    Tags:Java基础   点击:(17)  评论:(0)  加入收藏
1、问题描述关于java中的日志管理logback,去年写过关于logback介绍的文章,这次项目中又优化了下,记录下,希望能帮到需要的朋友。2、解决方案这次其实是碰到了一个问题,一般的情况...【详细内容】
2021-12-15  软件老王    Tags:logback   点击:(19)  评论:(0)  加入收藏
本篇文章我们以AtomicInteger为例子,主要讲解下CAS(Compare And Swap)功能是如何在AtomicInteger中使用的,以及提供CAS功能的Unsafe对象。我们先从一个例子开始吧。假设现在我们...【详细内容】
2021-12-14  小西学JAVA    Tags:JAVA   点击:(22)  评论:(0)  加入收藏
最新更新
栏目热门
栏目头条