持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情
1 抽象类
当编写一个类时,常常会为该类定义一些方法,这些方法用以描述该类的行为方式,那么这些方法都有具体的方法体。但在某些情况下,某个父类只是知道其子类应该包含怎样的方法,但无法准确地知道这些子类如何实现这些方法。例如定义了一个Shape类,这个类应该提供一个计算周长的方法calPerimeter(),但不同Shape子类对周长的计算方法是不一样的,即Shape类无法准确地知道其子类计算周长的方法。
如何既能让Shape类里包含calPerimeter()方法,又无须提供其方法实现呢?使用抽象方法即可满足该要求:抽象方法是只有方法签名,没有方法实现的方法。
1.1 抽象方法和抽象类
抽象方法和抽象类必须使用abstract修饰符来定义,有抽象方法的类只能被定义成抽象类,抽象类里可以没有抽象方法。
抽象方法和抽象类的规则如下。
- 抽象类必须使用
abstract修饰符来修饰,抽象方法也必须使用abstract修饰符来修饰,抽象方法不能有方法体。 - 抽象类
不能被实例化,无法使用new关键字来调用抽象类的构造器创建抽象类的实例。即使抽象类里不包含抽象方法,这个抽象类也不能创建实例。 - 抽象类可以包含Field、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类、枚举类6种成分。抽象类的构造器不能用于创建实例,主要是用于被其子类调用。
- 含有抽象方法的类(包括直接定义了一个抽象方法;继承了一个抽象父类,但没有完全实现父类包含的抽象方法,以及实现了一个接口,但没有完全实现接口包含的抽象方法3种情况)只能被定义成抽象类。
注意 归纳起来,可以用“有得有失”4个字来描述抽象类。“得”指的是抽象类多了一个能力:抽象类可以包含抽象方法;“失”指的是抽象类失去了一个能力:抽象类不能用于创建实例。
当使用abstract修饰类时,表明这个类只能被继承;当使用abstract修饰方法时,表明这个方法必须由子类提供实现(即重写)。而final修饰的类不能被继承,final修饰的方法不能被重写。因此final和abstract永远不能同时使用。
除此之外,当使用static修饰一个方法时,表明这个方法属于该类本身,即通过类就可调用该方法,但如果该方法被定义成抽象方法,则将导致通过该类来调用该方法时出现错误(调用了一个没有方法体的方法肯定会引起错误)。因此static和abstract不能同时修饰某个方法,即没有所谓的类抽象方法。
注意: abstract关键字修饰的方法必须被其子类重写才有意义,否则这个方法将永远不会有方法体,因此abstract方法不能定义为private访问权限,即private和abstract不能同时使用。
1.2 抽象类的作用
抽象类是从多个具体类中抽象出来的父类,它具有更高层次的抽象。从多个具有相同特征的类中抽象出一个抽象类,以这个抽象类作为其子类的模板,从而避免了子类设计的随意性。
在下面这个范例的抽象父类中,父类的普通方法依赖于一个抽象方法,而抽象方法则推迟到子类中提供实现。
public abstract class SpeedMeter
{
//转速
private double turnRate;
public SpeedMeter()
{
}
//把返回车轮半径的方法定义成抽象方法
public abstract double getRadius();
public void setTurnRate(double turnRate)
{
this.turnRate=turnRate;
}
//定义计算速度的通用算法
public double getSpeed()
{
//速度等于 车轮半径 * 2 * PI * 转速
return java.lang.Math.PI * 2 * getRadius() * turnRate;
}
}
public class CarSpeedMeter extends SpeedMeter
{
public double getRadius()
{
return 0.28;
}
public static void main(String[] args)
{
CarSpeedMeter csm=new CarSpeedMeter();
csm.setTurnRate(15);
System.out.println(csm.getSpeed());
}
}
下面是使用模板模式的一些简单规则。
- 抽象父类可以只定义需要使用的某些方法,把不能实现的部分抽象成抽象方法,留给其子类去实现。
- 父类中可能包含需要调用的其他系列方法的方法,这些被调方法既可以由父类实现,也可以由其子类实现。父类里提供的方法只是定义了一个通用算法,其实现也许并不完全由自身实现,而必须依赖于其子类的辅助。
2 更彻底的抽象:接口
抽象类是从多个类中抽象出来的模板,如果将这种抽象进行得更彻底,则可以提炼出一种更加特殊的“抽象类”——接口(interface),接口里不能包含普通方法,接口里的所有方法都是抽象方法。
2.1 接口的概念
当我们说PCI接口时,指的是主机板上那个插槽遵守了PCI规范,而具体的PCI插槽只是PCI接口的实例。
可见,接口是从多个相似类中抽象出来的规范,接口不提供任何实现。接口体现的是规范和实现分离的设计哲学。
让规范和实现分离正是接口的好处,让软件系统的各组件之间面向接口耦合,是一种松耦合的设计。
2.2 接口的定义
[修饰符] interface 接口名 extends 父接口1, 父接口2...
{
零个到多个常量定义...
零个到多个抽象方法定义...
}
语法的详细说明
- 修饰符可以是
public或者省略,如果省略了public访问控制符,则默认采用包权限访问控制符,即只有在相同包结构下才可以访问该接口。 - 接口名应与类名采用相同的命名规则,即如果仅从语法角度来看,接口名只要是合法的标识符即可;如果要遵守Java可读性规范,则接口名应由多个有意义的单词连缀而成,每个单词首字母大写,单词与单词之间无须任何分隔符。
- 一个接口可以有
多个直接父接口,但接口只能继承接口,不能继承类。
由于接口定义的是一种规范,因此接口里不能包含构造器和初始化块定义。接口里可以包含Field (只能是常量)、方法(只能是抽象实例方法)、内部类(包括内部接口、枚举)定义。
接口里定义的是多个类共同的公共行为规范,因此接口里的所有成员,包括常量、方法、内部类和枚举类都是public访问权限。定义接口成员时,可以省略访问控制修饰符,如果指定访问控制修饰符,则只能使用public访问控制修饰符。
对于接口里定义的常量Field而言,它们是接口相关的,而且它们只能是常量,因此系统会自动为这些Field增加static和final两个修饰符。也就是说,在接口中定义Field时,不管是否使用public static final修饰符,接口里的Field总将使用这三个修饰符来修饰。而且,接口里没有构造器和初始化块,因此接口里定义的Field只能在定义时指定默认值。
//系统自动为接口里定义的Field增加public static final修饰符
int MAX_SIZE=50;
public static final int MAX_SIZE=50;
对于接口里定义的方法而言,它们只能抽象方法,因此系统会自动为其增加abstract修饰符;由于接口里的方法全部是抽象方法,因此接口里不允许定义静态方法,即不可使用static修饰接口里定义的方法。不管定义接口里方法时是否使用public abstract修饰符,接口里的方法总是使用public abstract来修饰。
注意:
接口里定义的内部类、接口、枚举类默认都采用public static两个修饰符,不管定义时是否指定这两个修饰符,系统都会自动使用public static对它们进行修饰。
2.3 接口的继承
接口的继承和类继承不一样,接口完全支持多继承,即一个接口可以有多个直接父接口。
2.4 使用接口
接口不能用于创建实例,但接口可以用于声明引用类型变量。当使用接口来声明引用类型变量时,这个引用类型变量必须引用到其实现类的对象。除此之外,接口的主要用途就是被实现类实现。
一个类可以实现一个或多个接口,继承使用extends关键字,实现则使用implements关键字。因为一个类可以实现多个接口,这也是Java为单继承灵活性不足所做的补充。
[修饰符] class 类名 extends 父类 implements 接口1,接口2...
{
类体部分
}
一个类实现了一个或多个接口之后,这个类必须完全实现这些接口里所定义的全部抽象方法(也就是重写这些抽象方法);否则,该类将保留从父接口那里继承到的抽象方法,该类也必须定义成抽象类。
注意: 实现接口方法时,必须使用public访问控制修饰符,因为接口里的方法都是public的,而子类(相当于实现类)重写父类方法时访问权限只能更大或者相等,所以实现类实现接口里的方法时只能使用public访问权限。
2.5 接口和抽象类
共同特征:
- 接口和抽象类
都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。 - 接口和抽象类
都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实现这些抽象方法。
差别: 接口作为系统与外界交互的窗口,接口体现的是一种规范。对于接口的实现者而言,接口规定了实现者必须向外提供哪些服务(以方法的形式来提供);对于接口的调用者而言,接口规定了调用者可以调用哪些服务,以及如何调用这些服务(就是如何来调用方法)。当在一个程序中使用接口时,接口是多个模块间的耦合标准;当在多个应用程序之间使用接口时,接口是多个程序之间的通信标准。
抽象类则不一样,抽象类作为系统中多个子类的共同父类,它所体现的是一种模板式设计。抽象类作为多个子类的抽象父类,可以被当成系统实现过程中的中间产品,这个中间产品已经实现了系统的部分功能(那些已经提供实现的方法),但这个产品依然不能当成最终产品,必须有更进一步的完善,这种完善可能有几种不同方式。
-
接口里只能包含抽象方法,不包含已经提供实现的方法;抽象类则完全可以包含普通方法。
-
接口里不能定义静态方法;抽象类里可以定义静态方法。
-
接口里只能定义静态常量Field,不能定义普通Field;抽象类里则既可以定义普通Field,也可以定义静态常量Field。
-
接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作。
-
接口里不能包含初始化块;但抽象类则完全可以包含初始化块。
-
一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口,通过实现多个接口可以弥补Java单继承的不足。
2.6 面向接口编程
1.简单工厂模式
有一个场景:假设程序中有个Computer类需要组合一个输出设备,现在有两个选择:直接让Computer类组合一个Printer,或者让Computer类组合一个Output,那么到底采用哪种方式更好呢?假设让Computer类组合一个Printer对象,如果有一天系统需要重构,需要使用BetterPrinter来代替Printer,于是我们需要打开Computer类源代码进行修改。如果系统中只有一个Computer类组合了Printer还好,但如果系统中有100个类组合了Printer,甚至1000个、10000个……将意味着我们要打开100个、1000个、10000个类进行修改,这是多么大的工作量!
为了避免这个问题,我们让Computer类组合一个Output类型的对象,将Computer类与Printer类完全分离。
public class Computer
{
private Output out;
public Computer(Output out)
{
this.out=out;
}
//定义一个模拟获取字符串输入的方法
public void keyIn(String msg)
{
out.getData(msg);
}
//定义一个模拟打印的方法
public void print()
{
out.out();
}
}
上面的Computer类已经完全与Printer类分离,只是与Output接口耦合。Computer不再负责创建Output对象,系统提供一个Output工厂来负责生成Output对象。这个OutputFactory工厂类代码如下:
public class OutputFactory
{
public Output getOutput()
{
return new Printer();
}
public static void main(String[] args)
{
OutputFactory of=new OutputFactory();
Computer c=new Computer(of.getOutput());
c.keyIn("轻量级Java EE企业应用实战");
c.keyIn("疯狂Java讲义");
c.print();
}
}
在该OutputFactory类中包含了一个getOutput方法,该方法返回一个Output实现类的实例,该方法负责创建Output实例,具体创建哪一个实现类的对象由该方法决定(具体由该方法中的粗体部分控制,当然也可以增加更复杂的控制逻辑)。如果系统需要将Printer改为BetterPrinter实现类,只需让BetterPrinter实现Output接口,并改变OutputFactory类中的getOutput方法即可。
public class BetterPrinter implements Output
{
private String[] printData
=new String[MAX_CACHE_LINE * 2];
//用以记录当前需打印的作业数
private int dataNum=0;
public void out()
{
//只要还有作业,就继续打印
while(dataNum > 0)
{
System.out.println("高速打印机正在打印:" + printData[0]);
//把作业队列整体前移一位,并将剩下的作业数减1
System.arraycopy(printData , 1, printData, 0, --dataNum);
}
}
public void getData(String msg)
{
if (dataNum >=MAX_CACHE_LINE * 2)
{
System.out.println("输出队列已满,添加失败");
}
else
{
//把打印数据添加到队列里,已保存数据的数量加1
printData[dataNum++]=msg;
}
}
}
通过这种方式,我们把所有生成Output对象的逻辑集中在OutputFactory工厂类中管理,而所有需要使用Output对象的类只需与Output接口耦合,而不是与具体的实现类耦合。
2.命令模式
考虑这样一种场景:某个方法需要完成某一个行为,但这个行为的具体实现无法确定,必须等到执行该方法时才可以确定。具体一点:假设有个方法需要遍历某个数组的数组元素,但无法确定在遍历数组元素时如何处理这些元素,需要在调用该方法时指定具体的处理行为。
这个要求看起来有点奇怪:这个方法不仅需要普通数据可以变化,甚至还有方法执行体也需要变化,难道我们能把“处理行为”作为一个参数传入该方法?
因为Java不允许代码块单独存在,因此我们使用一个Command接口来定义一个方法,用这个方法来封装“处理行为”。下面是该Command接口的代码。
public interface Command
{
//接口里定义的process方法用于封装“处理行为”
void process(int[] target);
}
下面是需要处理数组的处理类,在这个处理类中包含一个process方法,这个方法无法确定处理数组的处理行为,所以定义该方法时使用了一个Command参数,这个Command参数负责对数组的处理行为。该类的程序代码如下。
public class ProcessArray
{
public void process(int[] target , Command cmd)
{
cmd.process(target);
}
}
通过一个Command接口,就实现了让ProcessArray类和具体“处理行为”的分离,程序使用Command接口代表了对数组的处理行为。Command接口也没有提供真正的处理,只有等到需要调用ProcessArray对象的process方法时,才真正传入一个Command对象,才确定对数组的处理行为。
public class CommandTest
{
public static void main(String[] args)
{
ProcessArray pa=new ProcessArray();
int[] target={3, -4, 6, 4};
//第一次处理数组,具体处理行为取决于PrintCommand
pa.process(target , new PrintCommand());
System.out.println("------------------");
//第二次处理数组,具体处理行为取决于AddCommand
pa.process(target , new AddCommand());
}
}
运行程序结果:
下面分别是PrintCommand类和AddCommand类的代码。
public class PrintCommand implements Command
{
public void process(int[] target)
{
for (int tmp : target )
{
System.out.println("迭代输出目标数组的元素:" + tmp);
}
}
}
public class AddCommand implements Command
{
public void process(int[] target)
{
int sum=0;
for (int tmp : target )
{
sum +=tmp;
}
System.out.println("数组元素的总和是:" + sum);
}
}
对于PrintCommand和AddCommand两个实现类而言,实际有意义的部分就是process(int[] target)方法,该方法的方法体就是传入ProcessArray类里的process方法的“处理行为”,通过这种方式就可实现process方法和“处理行为”的分离。