第4章 接口、抽象类与包
构造Java语言程序有两大基本构件:类和接口。事实上,程序设计的任务就是构建各种类和接口,并由它们组装出程序。接口由常量和抽象方法构成。一个接口可以扩展多个接口,一个接口也可以被多个接口所继承。
在Java中,抽象类可以用来表示那些不能或不需要实例化的抽象概念,抽象需要被继承,在抽象类中包含了一些子类共有的属性和行为。抽象类中可以包含抽象方法,抽象类的非抽象的继承类需要实现抽象方法。
在Java语言中可以把一组相关类和接口存放在一个“包”中,构成一个“类库”,然后供多个场合重复使用,这种机制称为类复用。类复用体现了面向对象编程的优点之一。每个Java包也为类和接口提供了一个不同的命名空间,一个包中的类和接口可以和其它包中的类和接口重名。
4.1接口
在Java语言中,接口是一个特殊的语法结构,其中可以包含一组方法声明(没有实现的方法)和一些常量。接口和类构成Java的两个基本类型,但接口和类有着不同的目的,它可用于在树形层次结构上毫不相关的类之间进行交互。一个Java类可以实现多个Java接口,这也解决了Java类不支持多重继承带来的弱点。
4.1.1接口定义
Java接口的定义方式与类基本相同,不过接口定义使用的关键字是interface,其格式如下:
public interface InterfaceName extends I1,...,Ik //接口声明
{//接口体,其中可以包含方法声明和常量
...
}
接口定义由接口声明和接口体两部分组成。具有public访问控制符的接口,允许任何类使用;没有指定为public的接口,其访问将局限于所属的包。
在接口定义中,InterfaceName指定接口名。
在接口定义中,还可以包含关键词extends,表明接口的继承关系,接口继承没有唯一性限制,一个接口可以继承多个接口。
位于extends关键字后面的I1、…、Ik就是被继承的接口,接口InterfaceName叫I1、…、Ik的子接口(subinterface),I1、…、Ik叫InterfaceName的父接口(superinterface)。
由一对花括号{}栝起的部分是接口体,其中定义抽象方法(abstract methods参见4.2小节)和常量。在接口内也可以嵌套类和接口的定义,不过这并不多见。
在接口体中,方法声明的常见格式如下:
ReturnType MethodName (Parameter-List);
此方法声明由方法返回值类型(ReturnType)、方法名(MethodName)和方法参数列表(Parameter-List)组成,不需要其它修饰符。在Java接口中声明的方法,将隐式地声明为公有的(public)和抽象的(abstract)。
由于接口没有为其中声明的方法提供实现,在方法声明后会需要一个分号。如果把分号换成一对花括号{},即使花括号{}中没有任何内容,也表示一个方法被实现,只是这是一个没有任何操作的空方法。
在Java接口中声明的变量其实都是常量,接口中的变量声明,将隐式地声明为public、static和final,即常量,所以接口中定义的变量必须初始化。
interface MyInterface {
//变量a声明不合法,a为常量,必须初始化
int a;
//下面的变量声明,等同于public static final int b = 200;
int b = 200;
//下面的方法声明,等同于public abstract void m();
void m();
}
和类不同,一个Java接口可以继承多个父接口,子接口也可以对父接口的方法和变量进行覆盖。例如:
interface A{
int x = 1;
void method1();
}
interface B{
int x = 2;
void method2();
}
interface C extends A,B{
int x = 3;
void method1();
void method2();
}
在该例中接口C的常量x覆盖了父接口A和B中的常量x,方法method1()覆盖了父接口A中的方法method1(),方法method2()覆盖了父接口B中的方法method2()。
和类还有一个重要的区别,在Java接口中不存在构建器。
4.1.2接口的实现
Java接口中声明了一组抽象方法,它构成了实现该接口的不同类共同遵守的约定。在类定义中可以用关键字implements来指定其实现的接口。一个类实现某个接口,就必须为该接口中的所有方法(包括因继承关系得到的方法)提供实现,它也可以直接引用接口中的常量。
例4.1.1 Example.java
interface A{
int x = 1;
void method1();
}
interface B extends A{
int x = 2;
void method2();
}
public class Example implements B{
public void method1(){
System.out.println("x = " + x);
System.out.println("A.x = " + A.x);
System.out.println("B.x = " + B.x);
System.out.println("Example.x = " + Example.x);
}
public void method2(){
}
public static void main(String[] args){
Example d = new Example();
d.method1();
}
}
程序运行结果:
x = 2
A.x = 1
B.x = 2
Example.x = 2
在上面的例子中类Example实现了接口B,它为接口B中声明的方法method2()提供了实现,虽然method2()的方法体为空。在类Example中,还要实现接口B继承接口A得到的方法method1()。从类Example的方法method1()中,可以引用其实现接口B而继承的变量x,此变量属于类成员,我们也可以通过类名来引用。
√Java类只允许单一继承,即一个类只能继承(extends)一个父类;但一个类可以实现多个接口,Java支持接口的多重继承。在Java类定义中,可以同时包括extends子句和implements子句,如果存在extends子句,则implements子句应跟随extends子句后面。
Java接口常用于不同对象之间进行通信,接口定义对象之间通信的协议,下面通过一个具体的例子来说明。
例4.1.2 EventProducer.java
import java.util.Vector;
//事件
class SimpleEvent{
}
//凡需要处理SimpleEvent事件的监听器,要求实现该接口
interface EventListener{
void processEvent( SimpleEvent e);
}
//SimpleEvent事件的监听器,实现EventListener接口
class EventConsumer implements EventListener{
public void processEvent( SimpleEvent e){
System.out.println("Receive event: " + e);
}
}
//事件源,产生SimpleEvent事件
public class EventProducer{
//对象容器,存储事件监听器
Vector listeners = new Vector();
//事件监听器向事件源注册自身
public synchronized void registeListener(EventListener listener){
listeners.add(listener);
}
public void demo() {
SimpleEvent e = new SimpleEvent();
for(int i=0; i<listeners.size(); i++) {
EventConsumer consumer =
(EventConsumer)listeners.elementAt(i);
consumer.processEvent(e);
}
}
public static void main(String[] args) {
EventProductor productor = new EventProductor();
EventConsumer consumer = new EventConsumer();
productor.registeListener(consumer);
productor.demo();
}
}
程序运行结果:
Receive event: SimpleEvent@9cab16
4.1.3接口作为类型
和类一样,Java接口也是一种数据类型,可以在任何使用其他数据类型的地方使用接口名来表示数据类型。我们可以用接口名来声明一个类变量、一个方法的形参或者一个局部变量。
用接口名声明的引用型变量,可以指向实现该接口的任意类的对象。例如:
例4.1.3 Server.java
class Worker implements Runnable{
public void run(){
System.out.print("Worker run!");
}
}
public class Server{
public static void main(String[] args){
Runnable w = new Worker();
(new Thread(w)).start();
}
}
程序运行结果:
Worker run!
该例中的Runnable是Java语言包中的一个非常重要接口,Worker是实现了Runnable接口的类,在程序中我们创建了Worker对象,并赋给了声明为Runnable类型的变量w。有关本例中使用的接口Runnable和类Thread,可参见本书第10章多线程。
4.1.4接口不应改变
一个接口声明了方法,但没有实现它们。位于树型结构中任何位置的任何类都可以实现它,实现某个接口的类,要为这个接口中的每个方法提供具体的实现,由此形成某些一致的行为协议。
如果有一天,你想修改某个接口,为其添加一个方法,这个简单的修改可能会造成牵一发而动全身的局面:所有实现这个接口的类都将无法工作,因为现在他们已经不再实现这个接口了。你要么放弃对这个接口的修改,要么连同修改所有实现这个接口的所有类。
在设计接口的最初,预测出接口的所有功能,这可能是不太现实。如果觉得接口非改不行,可以创建一个新的接口或者扩展这个接口,算是一种折衷的解决方法。其他相关的类可以保持不变,或者重新实现这个新的接口。
4.2抽象类
在面向对象的概念中,所有的对象都是通过类来描述的,但并不是所有的类都是用来描绘对象的,如果一个类中没有包含足够的信息来描绘一个具体的对象,这样的类就是抽象类。抽象类往往用来表征我们在对问题领域进行分析、设计中得出的抽象概念,是对一系列看上去不同,但是本质上相同的具体概念的抽象。如果我们要开发一个作图软件包,就会发现问题领域存在着点、线、三角形和圆等这样一些具体概念,它们是不同的,但是它们又都属于形状这样一个概念,形状这个概念在问题领域是不存在的,它就是一个抽象概念。正是因为抽象的概念在问题领域没有对应的具体概念,所以用以表征抽象概念的抽象类是不能够实例化的,抽象类必须被继承。
4.2.1抽象方法
在讨论抽象类之前,我们首先来了解什么是抽象方法。抽象方法(abstract method)在形式上就是包含abstract修饰符的方法声明,它没有方法体,也就是没有实现方法。抽象方法的声明格式如下:
abstract returnType abstractMethodName([paramlist]);
抽象方法只能出现在抽象类中。如果一个类中含有抽象方法,那么该类也必须声明为抽象的,否则在编译时编译器会报错,例如:
class Test{
abstract int f();
}
编译时的错误信息为:
Test.java:1: Test should be declared abstract; it does not define f() in Test class Test{
^
1 error
4.2.2抽象类
在现实世界中存在的一些概念,这些的概念通常用来泛指一类事物,比如家具,它用来指桌子、凳子、柜子等一系列具体的实物,就家具本身而言,并没有确定的对应实物。在Java中,我们可以定义一个抽象类,来表示这样概念。
定义一个抽象类需要关键字abstract,其基本格式如下:
abstract class ClassName{
...
}
√作为类的修饰符abstract和final,两者不可同时出现在类的声明中,因为final将限制一个类被继承,而抽象类却必须被继承。
抽象类不能被实例化,在程序中如果试图创建一个抽象类的对象,编译时Java编译器会提示出错。
抽象类中最常见的成员就是抽象方法。
抽象类中也可以包含供所有子类共享的非抽象的成员变量和成员方法。继承抽象类的非抽象子类只需要实现其中的抽象方法,对于非抽象方法既可以直接继承,也可以重新覆盖。
下面我们通过一个具体的例子来说明抽象类的使用。在一有关各种图形的应用程序中,我们可以将各种图形的共有的、相似的状态和行为提取出来,放在一个抽象类(Graphic)中,那些具体的图形,例如点、线、圆等都继承这个类。
在类Graphic中我们定义了一个方法area(),用来返回一个图形的面积。在Graphic中,这个方法只是简单的返回一个值0,对于点和线这样的对象来说,直接继承这个方法是合适的;而对于一个圆来说,直接继承该方法显然是错误的,所以在类Circle中需要重新实现该方法。
在类Graphic中还声明了一个抽象方法draw(),该方法用来绘制一个图形。每个图形都具有这个行为,但它们具体绘制方式却各不相同,所以在Graphic中将draw()方法声明为抽象的,留待至各个继承类中去实现,见例4.2.1:
例 4.2.1 GraphicDemo.java
abstract class Graphic{
public static final double PI = 3.1415926;
double area(){
return 0;
};
abstract void draw();
}
class Point extends Graphic{
protected double x, y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
void draw(){
//在此实现画一个点
System.out.println("Draw a point at ("
+x+","+y+")");
}
public String toString(){
return "("+x+","+y+")";
}
}
class Line extends Graphic{
protected Point p1, p2;
public Line(Point p1, Point p2){
this.p1 = p1;
this.p2 = p2;
}
void draw(){
//在此实现画一条线
System.out.println("Draw a line from "
+p1+" to "+p2);
}
}
class Circle extends Graphic{
protected Point o;
protected double r;
public Circle(Point o, double r) {
this.o = o;
this.r = r;
}
double area() {
return PI * r * r;
}
void draw() {
//在此实现画一个圆
System.out.println("Draw a circle at "
+o+" and r="+r);
}
}
public class GraphicDemo{
public static void main(String []args){
Graphic []g=new Graphic[3];
g[0]=new Point(10,10);
g[1]=new Line(new Point(10,10),new Point(20,30));
g[2]=new Circle(new Point(10,10),4);
for(int i=0;i<g.length;i++){
g[i].draw();
System.out.println("Area="+g[i].area());
}
}
}
4.2.2抽象类和接口的比较
抽象类在Java语言中体现了一种继承关系,要想使得继承关系合理,抽象类和继承类之间必须存在“是一个(is a)”关系,即抽象类和继承类在本质上应该是相同的。而对于接口来说,并不要求接口和接口实现者在本质上是一致的,接口实现者只是实现了接口定义的行为而已。
Java中一个类只能继承一个父类,对抽象类来说也不能例外。但是,一个类却可以实现多个接口。在Java中按照继承关系,所有的类形成了一个树型的层次结构,抽象类位于这个层次中的某个位置。而接口却不曾在这种树型的层次结构,位于树型结构中任何位置的任何类都可以实现一个或者多个不相干的接口。
√在抽象类的定义中,我们可以定义方法,并赋予的默认行为。而在接口的定义中,只能声明方法,不能为这些方法提供默认行为。抽象类的维护要比接口容易一些,在抽象类中,增加一个方法并赋予的默认行为,并不一定要修改抽象类的继承类。而接口一旦修改,所有实现该接口的类都被破坏,需要重新修改。
下面我们通过一个应用案例来说明抽象类和接口的使用。在一个超市的管理软件中,所有的商品都具有价格,我们可以把商品的价格、设置和获取商品价格的方法,定义成一个抽象类Goods:
abstract class Goods{
//商品价格
protected double cost;
//设置商品价格
abstract public void setCost();
//获取商品价格
abstract public double getCost();
...
}
某些商品,例如食品,具有一定保质器,我们需要为这类商品设置过期日期,并希望在过期时,能够通知过期消息。对于这样的行为,我们是否可以把它们也整合在类Goods中呢?显然这并不适合,对于其他商品来说并不存在这样的行为,比如服装,而Goods中的方法,应该是所有子类共有的行为。我们可以将过期这样的行为,设计在一个接口Expiration中,Goods的子类可以选择是否要实现Expiration接口。
interface Expiration{
//设置过期日期
void setExpirationDate();
//通知过期
void expire();
}
对于服装这类商品,我们需要继承抽象类Goods中的属性和方法,对其中的抽象方法必需提供具体的实现,至于Expiration接口可以全然不管。而食品这样的商品,我们既要继承Goods抽象类;又要实现Expiration接口。
class Clothes extends Goods{
public void setCost(){
...
}
public double getCost(){
...
return cost;
}
//...
}
class Food extends Goods implements Expiration{
public void setCost(){
...
}
public double getCost(){
...
return cost;
}
public void setExpirationDate() {
...
}
public void expire() {
...
}
...
}
仔细体味一下个中关系,抽象类Goods(商品)和类Clothes(服装)及Food(食品)存在着“is a”的关系;而接口Expiration和Food具有联系,和Clothes就不存在联系。
4.3包
包(package)是一组相关类和接口的集合,通常称为“类库”。 Java语言提供了一些系统级基本包;程序员也可以自行定义应用系统的包,以存放相关的类和接口。包提供了名称空间管理和访问保护,包也为类复用提供了方便的途径。
4.3.1包的作用
包的作用和其他编程语言中的函数库类似。它将实现某方面功能的一组类和接口集合为包进行发布。Java语言本身就是一组包组成,每个包实现了某方面的功能。下面简单介绍一些常见Java系统包的作用:
l 语言包(java.lang)提供的支持包括字符串处理、多线程处理、异常处理、数学函数处理等,可以用它简单地实现Java程序的运行平台。
l 实用程序包(java.util)提供的支持包括哈希表、堆栈、可变数组、时间和日期等。
l 输入输出包(java.io)用统一的流模型来实现所有格式的I/O,包括文件系统、网络、输入。
l 网络包(java.net)支持Internet的TCP/IP协议,用于实现Socket编程;提供了与Internet的接口,支持URL连接,WWW的即时访问,并且简化了用户/服务器模型的程序设计。
l 抽象图形用户接口包(javax.swing)实现了不同平台的计算机的图形用户接口部件,包括窗口、菜单、滚动条、对话框等,使得 Java可以移植到不同的平台。
创建一个包的方法十分简单,只要将一个包的声明放在Java源程序的头部即可。包声明格式如下:
package packageName;
package语句的作用范围是整个源文件,而且同一个package声明可以放到多个源文件中,所有定义在这些源文件中的类和接口都属于这个包的成员。
如果我们准备开发一个自己的图形工具,就可以定义一个名叫Graphics的包,将所有相关的类放在这个包里。如下:
package Graphics;
class Square{
...
}
class Triangle{
...
}
class Circle{
...
}
在一些小的或临时的应用程序中,我们可以忽略package声明,那么我们的类和接口被放在一个默认包(default package)中,默认包没有名称。
在前面的章节我们曾提到package访问控制,只有声明为public的包成员才可以从一个包的外部进行访问。
4.3.2包命名
包是实现某方面功能的程序集合,因此一个有意义的包名应该体现包的功能。另一方面,全球所有的java程序员都在轰轰烈烈地开发自己的java程序,命名自己的程序包,因此保证包名的唯一性也就成了一个问题。
各公司组织达成一个约定,在他们的包名称中使用自己的Internet域名的反序形式。例如常见的包名格式都是这样的:
com.company.package
这种方式可以有效的防止各公司组织之间在命名Java程序包上的冲突。在一个公司内部冲突可能还会存在,这需要公司内部的软件规范来解决,通常可以在公司名称后面增加项目的名称来解决。例如:
com.company.projectname.package
这种方式可以有效的确保Java程序包名的唯一性,但是包中的成员还是可能重名。例如在javax.swing包和java.util中都有一个类Timer,如果我们在同一段程序中同时引入了这两个包,那么下面这个语句就存在二义性:
Timer timer = new Timer() ;
在这种情况下,我们就需要采用类的完全限定名称来消除二义性。一个类的完全限定名称就是包含包名的类名。例如:
java.util.Timer timer = new java.util.Timer() ;
Java平台采用层次化的文件系统来管理Java源文件和类文件。Java包名称的每个部分对应一层子目录。例如下面是一个名为MyMath.java的java源文件:
package edu.njust.cs;
public class MyMath{
...
}
class Helper {
...
}
该文件在文件系统(以Windows系统为例)中的存储位置为:
src/edu/njust/cs/MyMath.java
编译后,对应的类文件为:
classes/edu/njust/cs/MyMath.class
classes/edu/njust/cs/Helper.class
其中src和classes分别对应具体应用程序的源文件和类文件根目录,src目录和classes目录可以在一起,也可以各自独立。
在此顺便指出,在编译Java源码时,编译器为一个源文件中定义的每个类和接口都创建一个单独的输出文件。输出文件的基本名称是类或接口名加上文件扩展名.class。
4.3.3包的使用
一个包中的public类或public接口可以被包外代码访问;非public的类型则以包作为作用域,在同一包内可以访问,对外是隐藏的,甚至对于嵌套包也是隐藏的。
所谓嵌套包,是指一个包嵌套在另一个包中。例如javax.swing.event是一个包,同样javax.swing也是一个包,所以可以称javax.swing.event包嵌套在javax.swing中。
当我们要使用某个包时,要通过关键字import实现:
import packagename;
比如:
//表示引入java.io包, .*表示java.io包中所有的类和接口
import java.io.*;
也可以指明只引入包中的某个类或是接口:
//表示只引入java.io包中的File类
import java.io.File;
在Java程序如果我们通过类的完全限定名称来使用一个类,可以省略import语句,不过这显然给程序的书写带来诸多不便。
√在引入包时,并不会自动引入嵌套包中的类和接口,例如:
import java.swing.event.*;
只是表示引入包java.swing.event中的所有类和接口,但是包java.swing中的类和接口并不会被引入。
4.3.3.1使用系统提供的包
我们已经知道,系统提供了大量的类和接口供程序开发人员使用,并且按照功能的不同,存放在不同的包中。例如,如果在程序中需要用到一个接收用户输入的对话框,就可以使用javax.swing包中的JOptionPane类,如例4.3.1所示:
例4.3.1 DialogDemo.java
//引入包javax.swing中的JOptionPane类
import javax.swing.JOptionPane;
public class DialogDemo{
public static void main(String []args){
String input=JOptionPane.showInputDialog("Please input text");
System.out.println(input);
}
}
程序运行结果见图4.3.1:
图4.3.1 输入对话框
我们可以发现,需要使用包中的类或是接口时,总是需要先引入。读者可以将DialogDemo.java中的import语句注释掉,观察编译结果。
4.3.3.2使用自定义包
下面,我们来定义一个自己的数学类MyMath(其中只包含一个方法max),并将该类存放在包edu.njust.cs中。可以创建MyMath的类文件如下:
例4.3.2 MyMath.java
package edu.njust.cs;
public class MyMath{
public static int max(int a,int b){
System.out.println("edu.njust.cs.MyMath's max() is called ");
return a>b?a:b;
}
}
注意,程序的第一行使用了package语句,用于指定包名。此外,源文件MyMath.java必须存放在和包名一致的目录中,这里为edu/njust/cs。至于edu之上是否还包含目录无关紧要。为了说明问题,先将MyMath.java存放在d:/lib/edu/njust/cs目录中。
然后,我们在d:/lib开发了一个程序TestMyMath.java:
例4.3.3 TestMyMath.java
import edu.njust.cs.MyMath;
public class TestMyMath{
public static void main(String []args){
int a=MyMath.max(100,200);
System.out.println(a);
}
}
同样,在TestMyMath.java中需要引入包。编译并运行例4.2.3,结果如下:
edu.njust.cs.MyMath's max() is called
200
可以发现确实使用了我们自定义的包。
下面我们将TestMyMath.java存放到另一个目录下,例如c:/myprogram,MyMath.java的存放位置不变。进入c:/myprogram编译TestMyMath.java,会出现以下错误:
C:/myprogram>javac TestMyMath.java
TestMyMath.java:1: cannot resolve symbol
symbol : class MyMath
location: package cs
import edu.njust.cs.MyMath;
^
TestMyMath.java:4: cannot resolve symbol
symbol : variable MyMath
location: class TestMyMath
int a=MyMath.max(100,200);
^
2 errors
编译器提示找不到类MyMath。
为什么会是这样呢?当编译器在编译时,会自动在以下位置查找需要用到的类文件:
l 当前目录
l 系统环境变量CLASSPATH指定的目录,称之为类路径
l JDK的运行库rt.jar,在JDK安装目录的jre/lib子目录中
由于TestMyMath.java中用到了MyMath类,因此编译器会在上述的3个位置搜索MyMath类文件。由于MyMath不在这3个位置的任何一个地方,所以编译器找不到MyMath的类文件,因而报错。可以使用两种方法来解决:
一是在编译时指定类文件的搜索路径:
C:/myprogram>javac -classpath .;d:/lib TestMyMath.java
上面的命令中使用了参数-classpath来指定类文件的搜索路径。不同的搜索路径之间使用分号隔开。
另一方法是直接设置系统的环境变量CLASSPATH,设置方法类似于PATH,参见第一章中的环境变量设定:在系统变量区域找到变量CLASSPATH(如果没有,则新建一个CLASSPATH变量),双击该行就可以编辑该环境变量的值。在该变量已有的值后追加“;d:/lib”(不包括引号)即可。
4.4小结
接口(interface)是用于说明仅由抽象方法和常量组成类型的一种途径,允许对这些方法加以任意的实现。接口纯粹从设计着眼,而类不仅要考虑设计,还要加以实现。类可以按其设计者选择的任何方式来实现接口中的方法。因此,和类相比,接口中的方法有更多种可能的实现。接口采用纯抽象方式来描述约定,因而接口只有在被类实现以后才有意义。使用extends关键字,可以对接口进行扩展。和类不同的是,接口可以同时扩展多个接口。
接口中所有的方法均被默认是抽象的(abstract)。因为接口对其说明的方法不可能给出具体的实现,所以也就没有必要采用abstract显式地说明其方法是抽象的。每一个实现接口的类必须实现接口中的所有方法,如果仅实现接口中的部分方法,该类必须被说明为抽象的。因为静态(static)方法是类特有的,而接口中的方法仅可能是抽象的,所以接口中的方法不可能是静态的。
接口内的方法总是公用的(public)。接口中的变量总是static和final的。
抽象类是不能直接实例化的类,抽象类需要被继承才能实例化。抽象类中可以包含抽象方法,却不一定要包含抽象方法。
包(Package)由一组类(class)和接口(interface)组成。它是管理大型名字空间,避免名字冲突的工具。每一个类和接口的名字都包含在某个包中。定义一个编译单元的包由package语句定义。
使用package语句,编译单元的第一行必须无空格,也无注释。若编译单元无package语句,则该单元被置于一个缺省的无名的包中。包的设计应当保证同一包中仅包含功能上相关的类或接口。