第十三章 抽象类和接口
13.1 引言
父类中定义了相关子类中的共同行为。接口可以用于定义类的共同行为(包括非相关的类)。
可以使用 java.util.Arrays.sort
方法来对数值和字符串进行排序。
13.2 抽象类
抽象类不可以用于创建对象。抽象类可以包含抽象方法,这些方法将在具体的子类中实现。
在继承的层次结构中,每个新的子类都使类变得更加明确和具体。如果从一个子类向父类追溯,类就会变得更通用、更加不明确。类的设计应该确保父类包含它的子类的共同特征。有时候,一个父类设计得非常抽象,以至于它都没有任何具体的实例。这样的类称为抽象类(abstract class)。
GeometricObject
类定义成 Circle
类和 Rectangle
类的父类。Geometric-Object
类对几何对象的共同特征进行了建模。Circle
类和 Rectangle
类都包含分别用于计算圆和矩形的面积和周长的 getArea()
方法和getPerimeter()
方法。因为可以计算所有几何对象的面积和周长,所以最好在 GeometricObject
类中定义getArea()
和 getPerimeter()
方法。但是,这些方法不能在 GeometricObject
类中实现,因为它们的实现取决于几何对象的具体类型。这样的方法称为抽象方法(abstract method),在方法头中使用 abstract
修饰符表示。在 GeometricObject
类中定义了这些方法后,GeometricObject
就成为一个抽象类。
在类的头部使用 abstract
修饰符表示该类为抽象类。在UML图形记号中,抽象类和抽象方法的名字用斜体表示,如下图所示。如下图给出了新的 Geometricobject
类的源代码。
抽象类和常规类很像,但是不能使用new操作符创建它的实例。抽象方法只有定义而没有实现。它的实现由子类提供。一个包含抽象方法的类必须声明为抽象类。抽象类的构造方法定义为protected
,因为它只被子类使用。创建一个具体子类的实例时,其父类的构造方法被调用以初始化父类中定义的数据域。
13.2.1 为何使用抽象方法
为什么在 GeometricObject
类中定义方法 getArea()
和 getPerimeter()
为抽象的而不是在每个子类中定义它们会有什么好处。如下图所示中,就能看出在 Geometricobject
中定义它们的好处。程序创建了两个几何对象:一个圆和一个矩形,调用 equalArea
方法来检查它们的面积是否相同,然后调用 displayGeometricObject
方法来显示它们。
13.2.2 抽象类的几点说明
下面是关于抽象类值得注意的几点:
- 抽象方法不能包含在非抽象类中。如果抽象父类的子类不能实现所有的抽象方法,那么子类也必须定义为抽象的。换句话说,在继承自抽象类的非抽象子类中,必须实现所有的抽象方法。还要注意到,抽象方法是非静态的。
- 抽象类不能使用
new
操作符来初始化。但是,仍然可以定义它的构造方法,这个构造方法在它的子类的构造方法中调用。例如,GeometricObject
类的构造方法在Circle
类和Rectange
类中调用。 - 包含抽象方法的类必须是抽象的。然而,可以定义一个不包含抽象方法的抽象类。这个抽象类用于作为定义新子类的基类。
- 子类可以重写父类的方法并将它定义为抽象的。这很少见,但是它在当父类的方法实现在子类中变得无效时是很有用的。在这种情况下,子类必须定义为抽象的。即使子类的父类是具体的,这个子类也可以是抽象的。例如,
Object
类是具体的但是它的子类如GeometricObject
可以是抽象的。
不能使用 new
操作符从一个抽象类创建一个实例,但是抽象类可以用作一种数据类型。因此,下面的语句创建一个元素是 GeometricObject
类型的数组是正确的:
GeometricObject[] objects = new GeometricObject[10];
然后可以创建一个 Geometricobject
的实例,并将它的引用赋值给数组,如下所示:
objects[0] = new Circle();
13.5 接口
接口是一种与类相似的结构,用于为对象定义共同的操作。
接口在许多方面都与抽象类很相似,但是它的目的是指明相关或者不相关类的对象的共同行为。例如,使用适当的接口,可以指明这些对象是可比较的、可食用的或者可克隆的。
为了区分接口和类,Java采用下面的语法来定义接口:
modifier interface InterfaceName {
/** Constant declarations */
/** Abstract method signatures */
}
下面是一个接口的例子:
public interface Edible {
/** Describe how to eat */
public abstract String howToEat();
}
在Java中,接口被看作是一种特殊的类。就像常规类一样,每个接口都被编译为独立的字节码文件。使用接口或多或少有点像使用抽象类。例如,可以使用接口作为引用变量的数据类型或类型转换的结果等。与抽象类相似,不能使用 new
操作符创建接口的实例。
可以使用 Edible
接口来指定一个对象是否是可食用的。这需要使用 implements
关键字让对象所属的类实现这个接口。例如,如下图中的Chicken
类和Fruit
类(第20和39行)实现了Edible
接口。类和接口之间的关系称为接口继承(interface inheritance)。因为接口继承和类继承本质上是相同的,所以我们将它们都简称为继承。
这个例子使用了多个类和接口。它们的继承关系如下图所示。
13.6 Comparable 接口
Comparable 接口定义了 compareTo 方法,用于比较对象。
假设要设计一个找出两个相同类型对象中较大者的通用方法。这里的对象可以是两个学生、两个日期、两个圆、两个矩形或者两个正方形。为了实现这个方法,这两个对象必须是可比较的。因此,这两个对象都该有的共同行为就是 comparable
(可比较的)。为此,Java提供了 Comparable
接口。接口的定义如下所示:
compareTo
方法判断这个对象相对于给定对象o
的顺序,并且当这个对象小于、等于或大于给定对象o
时,分别返回负整数、0或正整数。Comparable
接口是一个泛型接口。
13.7 Cloneabe 接口
Cloneable接口指定了一个对象可以被克隆。
经常希望创建一个对象的拷贝。为了实现这个目的,需要使用 clone
方法并理解 Cloneable
接口。接口包括常量和抽象方法,但是 Cloneable
接口是一个特殊情况。在java.lang
包中的 Cloneable
接口的定义如下所示:
package java.lang;
public interface Cloneable {
}
这个接口是空的。一个方法体为空的接口称为标记接口(marker interface)。一个标记接口既不包括常量也不包括方法。它用来表示一个类拥有某些希望具有的特征。实现 Cloneable
接口的类标记为可克降的,而且它的对象可以使用在Object
类中定义的 clone()
方法克隆。
13.8 接口与抽象类
一个类可以实现多个接口,但是只能继承一个父类。
接口的使用和抽象类的使用基本类似,但是,定义一个接口于定义一个抽象类有所不同。下表总结了这些不同点。
Java 只允许为类的继承做单一继承,但是允许使用接口做多重继承。例如,
public class NewCass extends BaseClass implements Interface1,..., InterfaceN{...}
利用关键字 extends
,接口可以继承其他接口。这样的接口称为子接口(subinterface)。例如,在下面代码中,NewInterface
是 Interface1,…,InterfaceN
的子接口。
public interface NewInterface extends Interface1,...InterfaceN {
//constants and abstract methods
}
13.10 类的设计原则
类的设计原则有助于设计出合理的类。
13.10.1 内聚性
类应该描述一个单一的实体,而所有的类操作应该在逻辑上相互契合来支持一个一致的目的。例如: 可以设计一个类用于学生,但不应该将学生与教职工组合在同一个类中,因为学生和教职工是不同的实体。
如果一个实体承担太多的职责,就应该按各自的职责分成几个类。例如string
类 StringBuffer
类和 StringBuilder
类都用于处理字符串,但是它们的职责不同。string
类处理不可变字符串,StringBuilder
类创建可变字符串,StringBuffer
与 StringBuilder
类似,只是 StringBuffer
类还包含更新字符串的同步方法。
13.10.2 一致性
遵循标准Java程序设计风格和命名习惯。为类、数据域和方法选取传递信息的名字。通常的风格是将数据声明置于构造方法之前,并且将构造方法置于普通方法之前。
选择名字要保持一致。给类似的操作选择不同的名字并非好的做法。例如: length()
方法返回 String
、StringBuilder
和 StringBuffer
的大小。如果在这些类中给这个方法用不同的名字就不一致了。
一般来说,应该具有一致性地提供一个公共无参构造方法,用于构建默认实例。如果一个类不支持无参的构造方法,要用文档写下原因。如果没有显式地定义构造方法,则会提供一个具有空方法体的公有默认无参构造方法。
如果不想让用户创建类的对象,可以在类中声明一个私有的构造方法,Math
类和 GuessDate
类就是如此。
13.10.3 封装性
一个类应该使用 private
修饰符隐藏其数据,以免用户直接访问它。这使得类更易于维护。只在希望数据域可读的情况下,才提供获取方法; 也只在希望数据域可更新的情况下才提供设置方法。例如: Rational
类为numerator
和 denominator
提供了获取方法,但是没有提供设置方法,因为 Rational
对象是不可改变的。
13.10.4 清晰性
为使设计清晰,内聚性、一致性和封装性都是很好的设计原则。除此之外,类应该有一个很清晰的合约,从而易于解释和易于理解。
用户可以以各种不同组合、不同顺序,以及在各种环境中结合使用多个类。因此,在设计一个类时,这个类不应该限制用户如何以及何时使用该类;设计属性时,应该允许用户按任何顺序和任何组合来设置值;设计方法应该使得功能的实现与它们出现的顺序无关。例如: Loan
类包含属性 loanAmount
、numberOfYears
和annualInterestRate
,这些属性的值可以按任何顺序来设置。
方法应在不产生混淆的情况下进行直观定义。例如: string
类中的 substring (int beginIndex, int endIndex)
方法就有点容易混淆。这个方法返回从 beginIndex
到 endIndex-1
而不是 endIndex
的子串。该方法应该返回从 beginIndex
到 endIndex
的子字符串,从而更加直观。
不应该声明一个可以从其他数据域推导出来的数据域。例如,下面的 Person
类有两个数据域: birthDate
和 age
。由于age
可以从 birthDate
导出,所以 age
不应该声明为数据域。
public class Person{
private java.uti1.Date birthDate;
private int age;
}
13.10.5 完整性
类是为许多不同用户的使用而设计的。为了能在大范围的应用中使用,一个类应该通过属性和方法提供各种自定义功能的实现方式。例如: string
类包含了40多个实用的方法来用于各种应用。
13.10.6 实例和静态
依赖于类的具体实例的变量或方法必须是一个实例变量或方法。
应该总是使用类名 (而不是引用变量) 引用静态变量和方法,以增强可读性并避免错误。
13.10.7 继承和聚合
继承和聚合之间的差异,就是 is-a
( 是一种 ) 和 has-a
( 具有 ) 之间的关系。例如,苹果是一种水果,因此,可以使用继承来对 Apple
类和 Fruit
类之间的关系进行建模。人具有名字,因此,可以使用聚合来对 Person
类和 Name
类之间的关系建模。
13.10.8 接口和抽象类
接口和抽象类都可以用于为对象指定共同的行为。如何决定是采用接口还是类呢?通常,比较强的 is-a
(是一种)关系清晰地描述了父子关系,应该采用类来建模。例如,因为橘子是一种水果,它们的关系就应该采用类的继承关系来建模。弱的is-a
关系,也称为 is-kind-of
(是一类)关系,表明一个对象拥有某种属性。弱的is-a
关系可以使用接口建模。例如,所有的字符串都是可以比较的,因此String
类实现了Comparable
接口。圆或者矩形是一个几何对象,因此Circle
可以设计为Geometricobject
的子类。有不同的半径,并且可以基于半径进行比较,因此Circle
可以实现Comparable
接口。
接口比抽象类更加灵活,因为一个子类只能继承一个父类,但是却可以实现任意个数的接口。然而,接口不能包含数据域。Java8
中,接口可以包含默认方法和静态方法,这对简化类的设计非常有用。