目录
抽象类和接口
完全解耦
组合多个接口
通过继承扩展接口
适配接口
接口中的字段
嵌套接口
接口和工厂
新特性:接口的private方法
新特性:密封类和密封接口
本笔记参考自: 《On Java 中文版》
在Java 8添加了默认方法后,如何选择抽象类和接口也成了一个问题。下列表格会将两者进行区分:
特性 | 接口 | 抽象类 |
---|---|---|
组合 | 可以在新类中组合多个接口 | 只能继承一个抽象类 |
状态 | 不能包含字段(除静态字段,但静态字段无法表示对象状态) | 可以包含字段,非抽象类可以使用这些字段 |
默认方法和抽象方法 | 默认方法不需要在子类型中实现 | 抽象方法必须在子类型中实现 |
构造器 | 不能有构造器 | 可以有构造器 |
访问权限控制 | 方法默认被public static abstract修饰 常量默认被public static final修饰 |
可以设置,默认是包访问权限 |
经验法则告诉我们:在合理的范围内尽可能抽象。当然,除非必要,否则抽象类和接口都还是不用为好,因为常规类已经足够解决问题。
一个方法若只存在于常规的类中,那么这个方法只能被这个类及其子类调用。一旦想要让这个方法脱离这一继承层次结构,就会发现这是难以做到的。接口就放宽了这种限制,使得代码可以更加容易被复用。
import java.util.Arrays;
class Processor {
public String name() {
return getClass().getSimpleName();
}
public Object process(Object input) {
return input;
}
}
class Upcase extends Processor {
@Override
public String process(Object input) { // 返回类型是协变的
return ((String) input).toUpperCase();
}
}
class Downcase extends Processor {
@Override
public String process(Object input) {
return ((String) input).toLowerCase();
}
}
class Splitter extends Processor {
@Override
public String process(Object input) {
// split()方法可以分割字符串
return Arrays.toString(((String) input).split(" "));
}
}
public class Applicator {
public static void apply(Processor p, Object s) {
System.out.println("使用方法:" + p.name());
System.out.println(p.process(s));
System.out.println();
}
public static void main(String[] args) {
String s = "We are Programmer";
apply(new Upcase(), s);
apply(new Downcase(), s);
apply(new Splitter(), s);
}
}
程序执行的结果是:
上述的Applicator.apply()方法可以任何类型的Processor,将接收的类型转型为Object类型,并输出最终结果。像这样,创建一个方法,这个方法可以根据传递的参数对象表现出不同的行为,这就是策略设计模式。
方法包含了算法的固定部分,而策略包含了算法变化的部分。
现在,假设有一组更好用的类:
从上图可以发现,Filter和Processor有相同的接口元素process,但因为Filter没有继承Processor,所有无法将Filter类型的对象传递到Applicator.apply()中进行使用。这种情况下,认为Applicator.apply()与Processor之间的耦合超过了所需的程度。
在这组新的类中,process()方法的输入和输出参数都是Waveform。
若Processor是一个接口,因为约束足够宽松,就可以复用参数为Processor接口类型的Applicator.apply()方法了。下面是Processor和Applicator的修改:
package interfaceprocessor;
public interface Processor {
default String name() {
return getClass().getSimpleName();
}
Object process(Object input);
}
package interfaceprocessor;
public class Applicator {
public static void apply(Processor p, Object s) {
System.out.println("使用方法:" + p.name());
System.out.println(p.process(s));
}
}
一种复用代码的方式是,调用者可以编写符合这个接口的类:
package interfaceprocessor;
import java.util.Arrays;
interface StringProcessor extends Processor {
// @Override不是必要的,但它可以指出返回类型发生了从Object到String的协变
@Override
String process(Object input);
String s = "You are an Programmer"; // 在接口内定义的字段是static和final的
public static void main(String[] args) { // main()方法的定义也是被允许的
Applicator.apply(new Upcase(), s);
Applicator.apply(new Downcase(), s);
Applicator.apply(new Splitter(), s);
}
}
class Upcase implements StringProcessor {
@Override
public String process(Object input) { // 返回类型是协变的
return ((String) input).toUpperCase();
}
}
class Downcase implements StringProcessor {
@Override
public String process(Object input) {
return ((String) input).toLowerCase();
}
}
class Splitter implements StringProcessor {
@Override
public String process(Object input) {
return Arrays.toString(((String) input).split(" "));
}
}
程序执行的结果如下:
但也有上述这种处理方式应付不了的情况。因为库一般是被发现而不是被创建的,在这种情况下,就会需要使用到适配器设计模式:
package interfaceprocessor;
import filters.*;
class FilterAdapter implements Processor {
Filter filter;
FilterAdapter(Filter filter) {
this.filter = filter;
}
@Override
public String name() { // 使用了委托
return filter.name();
}
public Waveform process(Object input) { // 返回类型是协变的,这允许我们产生一个Waveform
return filter.process((Waveform) input);
}
}
public class FilterProcessor {
public static void main(String[] args) {
Waveform w = new Waveform();
Applicator.apply(new FilterAdapter(new LowPass(1.0)), w);
Applicator.apply(new FilterAdapter(new HighPass(2.0)), w);
Applicator.apply(new FilterAdapter(new BandPass(3.0, 4.0)), w);
}
}
接口与实现的解耦允许我们将一个接口应用于多个不同的实现。
接口没有实现,也就是说,没有与接口有关联的储存储。这为多个接口组合在一起提供了合理性。
Java没有强制要求一个子类的基类是抽象的或是具体的。一个子类只能继承一个非接口,但同时,这个子类也可以继承复数的接口(这些接口名都应该被放置在implement关键字之后,并用逗号隔开)。例如:
// 一组接口
interface CanFight {
void fight();
}
interface CanSwim {
void swim();
}
interface CanFly {
void fly();
}
// 一个基类
class ActionCharacter {
public void fight() {
};
}
class Hero extends ActionCharacter
implements CanFight, CanSwim, CanFly {
// 此处没有为fight提供定义
@Override
public void swim() {
}
@Override
public void fly() {
}
}
public class Adventure {
public static void t(CanFight x) {
x.fight();
}
public static void u(CanSwim x) {
x.swim();
}
public static void v(CanFly x) {
x.fly();
}
public static void w(ActionCharacter x) {
x.fight();
}
public static void main(String[] args) {
Hero h = new Hero();
t(h); // 当作一个Canfight类型
u(h); // 把Hero当作一个CanSwim类型
v(h); // 同样进行了转型
w(h); // 当作一个ActionCharacter类型
}
}
注意:当通过上述这种方式结合具体的类和接口时,具体的类必须在前面,然后才是接口。
上述程序中,CanFight和ActionCharacter包含了同样签名的方法fight(),并且Hero中并没有为fight()提供具体的定义。但是在创建一个对象时,所有的定义都必须是已经存在的。此处之所以没有触发报错,是因为ActionCharacter提供了fight()的定义。
使用接口的两个原因:
若可以在没有任何方法定义或成员变量的情况下创建基类,就应该使用接口而不是抽象类。
可以使用继承向接口中添加新的方法声明,也可以通过继承组合多个接口。这两种方式最终都会得到一个新的接口:
interface Monster {
void menace();
}
interface DangerousMonster extends Monster {
void destroy();
}
interface Lethal {
void kill();
}
class DragonZilla implements DangerousMonster {
@Override
public void menace() {
}
@Override
public void destroy() {
}
}
interface Vampire extends DangerousMonster, Lethal {
void drinkBlood();
}
class VeryBadVampire implements Vampire {
@Override
public void menace() {
}
@Override
public void destroy() {
}
@Override
public void kill() {
}
@Override
public void drinkBlood() {
}
}
public class HorroShow {
static void u(Monster b) {
b.menace();
}
static void v(DangerousMonster d) {
d.menace();
d.destroy();
}
static void w(Lethal l) {
l.kill();
}
public static void main(String[] args) {
DangerousMonster barney = new DragonZilla();
u(barney);
v(barney);
Vampire vlad = new VeryBadVampire();
u(vlad);
v(vlad);
w(vlad);
}
}
在进行新接口的创建时,extends关键字可以用来关联多个父接口。注意接口名称要用逗号分隔。
组合接口时的名称冲突
在之前CanFight和ActionCharacter的例子中,接口和类具有void fight()方法。因为ActionCharacter提供了定义,因此没有任何问题。但如果方法的签名或返回类型不同,情况就会发生改变。
// 3个接口
interface I1 {
void f();
}
interface I2 {
int f(int i);
}
interface I3 {
int f();
}
// 提供了一个声明
class C {
public int f() {
return 1;
}
}
// 对不同的接口进行组合
class C2 implements I1, I2 {
@Override
public void f() {
}
@Override
public int f(int i) { // 发生重载
return 2;
}
}
class C3 extends C implements I2 {
@Override
public int f(int i) { // 发生重载
return 3;
}
}
class C4 extends C implements I3 { // 两者的f()方法定义完全相同,可以直接使用
}
// 下面是无法组合的情况:方法只有返回类型不同
// class C5 extends C implements I1 {
// }
// interface I4 extends I1, I3 {
// }
上述程序中,最后的两种组合将重写、实现和重载混在了一起,若取消注释并尝试编译,会引发报错:
因此,在接口中应该尽量避免使用相同的方法名称。
引入接口的又一个原因是,接口可以允许同一个接口有多个实现。这可以体现为一个接收接口的方法,调用者实现该接口,并将接口作为对象传递给方法。这就回到了之前说的策略设计模式,这种方法灵活、通用并且有更高的可复用性。
例如,java.util包提供了一个Scanner类,这个类的构造器会接收一个Readable接口作为参数。Readable是一个专门为Scanner创建的接口,这样Scanner的参数就不会受到类型的约束。若想要让一个类能够和Scanner一起被使用,只需要让这个新类实现Readable接口即可:
import java.nio.CharBuffer;
import java.util.Random;
import java.util.Scanner;
public class RandomStrings implements Readable {
private static Random rand = new Random(47);
private static final char[] CAPITALS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".toCharArray();
private static final char[] LOWERS = "abcdefghijklmnopqrstuvwxyz".toCharArray();
private static final char[] VOWELS = "aeiou".toCharArray();
private int count;
public RandomStrings(int count) {
this.count = count;
}
@Override
public int read(CharBuffer cb) {
if (count-- == 0) // 若输入已经结束
return -1;
cb.append(CAPITALS[rand.nextInt(CAPITALS.length)]);
for (int i = 0; i < 4; i++) {
cb.append(VOWELS[rand.nextInt(VOWELS.length)]);
cb.append(LOWERS[rand.nextInt(LOWERS.length)]);
}
cb.append(" ");
return 10;
}
public static void main(String[] args) {
Scanner s = new Scanner(new RandomStrings(10));
while (s.hasNext())
System.out.println(s.next());
}
}
程序执行的结果是:
通过java.lang提供的Readable接口可以实现一个read()方法。read()方法的参数列表是一个CharBuffer类型的参数,可以在文档中找到关于这个类型的描述:
可以向这个参数中通过各种方法添加数据,或者当没有输入(此时返回-1)。
但若一个类型没有实现Readable,要让其能够与Scanner一起工作,就会需要使用到多重继承。现在,假设有一个没有实现Readable的接口RandomDoubles:
import java.util.Random;
public interface RandomDoubles {
Random RAND = new Random(47);
default double next() {
return RAND.nextDouble();
}
public static void main(String[] args) {
RandomDoubles rd = new RandomDoubles() {
};
for (int i = 0; i < 7; i++) {
System.out.println(rd.next() + " ");
}
}
}
此时,就可以使用适配器模式,组合两个不同的接口来创建一个适配的类。现在,使用interface关键字,生成一个新的类,这个新的类会实现RandomDouble和Readable:
import java.nio.CharBuffer;
import java.util.Scanner;
public class AdaptedRandomDoubles
implements RandomDoubles, Readable {
private int count;
public AdaptedRandomDoubles(int count) {
this.count = count;
}
@Override
public int read(CharBuffer cb) {
if (count-- == 0)
return -1;
String result = Double.toString(next()) + " ";
cb.append(result);
return result.length();
}
public static void main(String[] args) {
Scanner s = new Scanner(new AdaptedRandomDoubles(7)); // 使用Scanner
while (s.hasNextDouble())
System.out.println(s.nextDouble() + " ");
}
}
程序执行的结果是:
任何现有类都可以通过这种适配器的方式进行接口的添加,这意味着把接口作为参数的方法可以让任何类适应它。
因为接口中的任何字段都是static和final的,所有接口也是创建一组常量值的便捷工具:
public class Months {
int JABUARY = 1,
FABRUARY = 2;
// ...
}
注意:Java中具有常量初始值的static final字段的命名全部使用大写字符(并且使用下划线分隔单词)。
在Java 5之前,Java经常通过这种方式实现枚举。
初始化接口中的字段
接口中的定义字段不能是“空白的final”,但可以通过非常量表达式进行初始化:
import java.util.Random;
public interface RandVals {
Random RAND = new Random();
int RANDOM_INT = RAND.nextInt(10);
long RANDOM_LONG = RAND.nextLong() * 10;
float RANDOM_FLOAT = RAND.nextFloat() * 10;
double RANDOM_DOUBLE = RAND.nextDouble() * 10;
}
这些字段都是静态的,它们会在接口第一次被加载时初始化。简单地看看:
public class TestRandVals {
public static void main(String[] args) {
System.out.println(RandVals.RANDOM_DOUBLE);
System.out.println(RandVals.RANDOM_DOUBLE);
}
}
程序执行的结果是:
接口中定义的字段不是接口的一部分。这些字段的值会储存在接口的静态存储区中。
接口可以嵌套在类和其他接口中:
package nesting;
class A {
interface B {
void f();
}
public class BImp implements B {
@Override
public void f() {
}
}
private class BImp2 implements B {
@Override
public void f() {
}
}
private interface C {
void f();
}
private class CImp implements C {
@Override
public void f() {
}
}
public class CImp2 implements C {
@Override
public void f() {
}
}
public C getC() {
return new CImp2();
}
private C cRef;
public void receiveC(C c) {
cRef = c;
cRef.f();
}
}
interface D {
interface E {
void f();
}
public interface F { // 此处可以省略public
void f();
}
void g();
// 不能在接口中使用private
// private interface H {
// }
}
public class NestingInterfaces {
public class BImp implements A.B {
@Override
public void f() {
}
}
// private的接口只能在定义的类中实现
// class DImp implements A.D {
// public void f() {
// };
// }
class DImp implements D {
@Override
public void g() {
};
class DE implements D.E {
@Override
public void f() {
}
}
}
public static void main(String[] args) {
A a = new A();
// A.C无法访问:
// A.C ac = a.getC();
// 只能返回A.C:
// A.CImp2 ci2 = a.getC(); // 无法接收返回值
// 无法访问接口C中的方法
// a.getC().f();
// 需要使用到第二个A对象,才能处理getC()
A a2 = new A();
a2.receiveC(a.getC());
}
}
在类中进行接口嵌套的语句与正常使用几乎没有区别。它们都可以具有public或是包访问权限。
值得一提的是,接口也可以是private的,就像A.C一样。这种接口会被用于:① 实现像CImp一样的私有内部类;② 像CImp2一样的public类,这种类只有自己的类型,在外界看来其与接口C无关,这种做法限制了接口C中的方法定义,也就是说,private的接口不允许任何的向上转型。
上述程序中a.getC()的使用无疑是特殊的:这个方法的返回值必须传递给一个有权使用它的对象,也就是另一个A。
所有的接口元素都必须是public的,所以嵌套在其他接口中的接口默认也是public的(也只能是)。
通过接口,可以进行多种的实现。若想要生成适合某个接口的对象,就可以采取工厂方法设计模式:不直接调用构造器,而是在工厂对象上调用创建方法,这种创建方法可以产生接口实现。
interface Service {
void method1();
void method2();
}
interface ServiceFactory {
Service getService();
}
// 1号服务
class Service1 implements Service {
Service1() { // 将构造器限定为包访问,不允许外部使用
}
@Override
public void method1() {
System.out.println("1号服务:方法1");
}
@Override
public void method2() {
System.out.println("1号服务:方法2");
}
}
class Service1Factory implements ServiceFactory {
@Override
public Service getService() {
return new Service1();
}
}
// 2号服务
class Service2 implements Service {
Service2() { // 具有包访问权限的构造器
}
@Override
public void method1() {
System.out.println("2号服务:方法1");
}
@Override
public void method2() {
System.out.println("2号服务:方法2");
}
}
class Service2Factory implements ServiceFactory {
@Override
public Service getService() {
return new Service2();
}
}
public class Factories {
public static void serviceConsumer(ServiceFactory fact) {
Service s = fact.getService();
s.method1();
s.method2();
}
public static void main(String[] args) {
// 通过“工厂”,调用不同的服务
serviceConsumer(new Service1Factory());
System.out.println();
serviceConsumer(new Service2Factory());
}
}
程序执行的结果是:
通过工厂方法进行额外层的添加,这种做法可以用来创建框架。假设所需实现的方法更加复杂,框架的存在就会更加方便对代码的复用。
JDK 9最终确定,可以将接口中的方法转换为private方法:
interface JDK9 {
private void fd() { // private方法默认是default的
System.out.println("JDK9::fd()");
}
private static void fs() {
System.out.println("JDK::fs()");
}
default void f() {
fd();
}
static void g() {
fs();
}
}
class ImplJDK9 implements JDK9 {
}
public class PrivateInterfaceMethods {
public static void main(String[] args) {
new ImplJDK9().f();
JDK9.g();
}
}
程序运行的结果如下:
JDK 17最终确定引入密封类(sealed)和密封接口,这样基类或接口就可以限制自己能派生的类:
sealed class Base permits D1, D2 {}
final class D1 extends Base {}
final class D2 extends Base {}
// 这是非法的:
//final class D3 extends Base {}
若继承了未在permits子句中列出的子类,就会发生报错(如:D3)。通过这种方式,我们可以确保自己的任何代码只需要考虑D1和D2。
也可以对接口和抽象类进行密封:
// 密封接口
sealed interface Ifc permits Imp1, Imp2 {}
final class Imp1 implements Ifc {}
final class Imp2 implements Ifc {}
// 密封抽象类
sealed abstract class AC permits X {}
final class X extends AC {}
若需要继承基类的子类都在同一个文件夹中,就不需要permit子句:
sealed class Shape {}
final class Circle extends Shape {}
final class Triangle extends Shape {}
而permits子句允许我们在单独的文件夹中定义子类:
sealed类的子类只允许使用下列的某个修饰符进行定义:
注意:一个sealed类有至少一个子类。而sealed的子类会保持对层次结构的严格控制。
record
JDK 16的record也可以实现接口的密封。record是隐式的final,因此它不需要与final并用:
package interfaces;
sealed interface Employee permits CLevel, Programer { }
record CLevel(String type)
implements Employee { }
record Programer(String experience)
implements Employee{}
编译器会阻止我们从密封层次结构中向下转型为非法类型:
sealed interface II permits JJ {}
final class JJ implements II {}
class Something {}
public class CheckedDowncast {
public void f() {
II i = new JJ(); // 向上转型
JJ j = (JJ) i; //强制类型转换
// Something s = (Something) i; // 不可转换
}
}
最后:接口在程序设计中,往往是处于用来进行优化的角色。若在程序一开始就使用接口,最终可能会使程序变得太过复杂。接口应该是在必要时用来重构的工具。因此,可以这么说:应该优先使用类而不是接口。