Java中的抽象类与接口
在Java中什么时候应该选择抽象类而不是接口?接受挑战吧!了解这些Java语言元素之间的区别以及如何在你的程序中使用它们。
在Java代码中,甚至在Java开发工具包(JDK)本身中,都有大量的抽象类和接口。每个代码元素都有一个基本的目的:
- 接口是一种代码契约,必须由一个具体的类来实现。
- 抽象类与普通类相似,不同的是它们可以包括抽象方法,也就是没有主体的方法。抽象类不能被实例化。
许多开发者认为接口和抽象类是相似的,但它们实际上是完全不同的。让我们来探讨一下它们之间的主要区别。
接口的本质
从本质上讲,接口是一个契约,所以它依赖于一个实现来达到其目的。一个接口永远不可能有状态,所以它不能使用可变的实例变量。一个接口只能使用最终变量。
何时使用接口
接口对[解耦代码和实现多态性非常有用。我们可以在JDK中看到一个例子,就是List
接口:
public interface List<E> extends Collection<E> {
int size();
boolean isEmpty();
boolean add(E e);
E remove(int index);
void clear();
}
正如你可能注意到的,这段代码很短,而且描述性很强。我们可以很容易地看到方法的签名,我们将用一个具体的类来实现接口中的方法。
List
接口包含一个契约,可以由ArrayList
,Vector
,LinkedList
, 和其他类来实现。
为了使用多态性,我们可以简单地用List
来声明我们的变量类型,然后选择任何一个可用的实例化。这里有一个例子:
List list = new ArrayList();
System.out.println(list.getClass());
List list = new LinkedList();
System.out.println(list.getClass());
下面是这段代码的输出:
class java.util.ArrayList
class java.util.LinkedList
在这种情况下,ArrayList
,LinkedList
, 和Vector
的实现方法都是不同的,这就是使用接口的一个很好的场景。如果你注意到许多类都属于一个父类,其方法动作相同,但行为不同,那么使用接口是个好主意。
接下来,让我们来看看我们可以用接口做的几件事。
重写一个接口方法
记住,接口是一种必须由具体类来实现的契约。接口方法是隐含的抽象的,也需要一个具体类的实现。
这里有一个例子:
public class OverridingDemo {
public static void main(String[] args) {
Challenger challenger = new JavaChallenger();
challenger.doChallenge();
}
}
interface Challenger {
void doChallenge();
}
class JavaChallenger implements Challenger {
@Override
public void doChallenge() {
System.out.println("Challenge done!");
}
}
下面是这段代码的输出:
Challenge done!
注意这个细节,接口方法是隐式抽象的。这意味着我们不需要明确地将它们声明为抽象的。
常量变量
另一条要记住的规则是,一个接口只能包含常量变量。因此,下面的代码是可以的:
public class Challenger {
int number = 7;
String name = "Java Challenger";
}
注意,这两个变量都是隐含的final
和static
。这意味着它们是常量,不依赖于一个实例,而且不能被改变。
如果我们试图改变Challenger
接口中的变量,例如,像这样:
Challenger.number = 8;
我们会触发一个编译错误,像这样:
Cannot assign a value to final variable 'number'
Cannot assign a value to final variable 'name'
缺省方法
当默认方法在Java 8中被引入时,一些开发者认为它们会和抽象类一样。然而这并不正确,因为接口不能有状态。
默认方法可以有一个实现,而抽象方法则不能。默认方法是lambdas和流的伟大创新的结果,但我们应该谨慎使用它们。
JDK中使用默认方法的一个方法是forEach()
,它是Iterable
接口的一部分。我们可以简单地重用forEach
方法,而不是将代码复制到每个Iterable
的实现中:
default void forEach(Consumer<? super T> action) {
// Code implementation here…
任何Iterable
实现都可以使用forEach()
方法,而不需要新的方法实现。然后,我们可以用一个默认方法来重用代码。
让我们来创建我们自己的默认方法:
public class DefaultMethodExample {
public static void main(String[] args) {
Challenger challenger = new JavaChallenger();
challenger.doChallenge();
}
}
class JavaChallenger implements Challenger { }
interface Challenger {
default void doChallenge() {
System.out.println("Challenger doing a challenge!");
}
}
下面是输出结果:
Challenger doing a challenge!
关于默认方法,需要注意的是,每个默认方法都需要一个实现。默认方法不能是静态的。
现在,让我们继续讨论抽象类。
抽象类的本质
抽象类可以有实例变量的状态。这意味着一个实例变量可以被使用和变异。这里有一个例子:
public abstract class AbstractClassMutation {
private String name = "challenger";
public static void main(String[] args) {
AbstractClassMutation abstractClassMutation = new AbstractClassImpl();
abstractClassMutation.name = "mutated challenger";
System.out.println(abstractClassMutation.name);
}
}
class AbstractClassImpl extends AbstractClassMutation { }
下面是输出结果:
mutated challenger
抽象类中的抽象方法
就像接口一样,抽象类可以有抽象方法。抽象方法是一个没有主体的方法。与接口不同,抽象类中的抽象方法必须明确地声明为抽象的。这里有一个例子:
public abstract class AbstractMethods {
abstract void doSomething();
}
试图声明一个没有实现的方法,而且没有abstract
关键字,像这样:
public abstract class AbstractMethods {
void doSomethingElse();
}
导致了一个编译错误,像这样:
Missing method body, or declare abstract
什么时候使用抽象类
当你需要实现可改变状态时,使用抽象类是一个好主意。作为一个例子,Java集合框架包括AbstractList类,它使用变量的状态。
在你不需要维护类的状态的情况下,通常使用一个接口更好。
实践中的抽象类
设计模式中的模板方法是使用抽象类的好例子。模板方法模式在具体方法中操作实例变量。
抽象类和接口的区别
从面向对象编程的角度来看,接口和抽象类的主要区别是,接口不能有状态,而抽象类可以用实例变量来有状态。
另一个关键区别是,类可以实现一个以上的接口,但它们只能扩展一个抽象类。这是一个基于多重继承(扩展一个以上的类)会导致代码死锁的设计决定。Java的工程师们决定要避免这种情况。
另一个区别是,接口可以被类实现,也可以被接口扩展,但类只能被扩展。
还需要注意的是,lambda表达式只能用于功能接口(指只有一个方法的接口),而只有一个抽象方法的抽象类不能使用lambdas。
接受Java代码挑战吧!
让我们通过一个Java代码挑战来探索接口和抽象类的主要区别。我们在下面提供了代码挑战,你也可以[用视频的形式观看抽象类与接口的挑战
在下面的代码中,同时声明了一个接口和一个抽象类,而且代码中还使用了lambdas:
public class AbstractResidentEvilInterfaceChallenge {
static int nemesisRaids = 0;
public static void main(String[] args) {
Zombie zombie = () -> System.out.println("Graw!!! " + nemesisRaids++);
System.out.println("Nemesis raids: " + nemesisRaids);
Nemesis nemesis = new Nemesis() { public void shoot() { shoots = 23; }};
Zombie.zombie.shoot();
zombie.shoot();
nemesis.shoot();
System.out.println("Nemesis shoots: " + nemesis.shoots +
" and raids: " + nemesisRaids);
}
}
interface Zombie {
Zombie zombie = () -> System.out.println("Stars!!!");
void shoot();
}
abstract class Nemesis implements Zombie {
public int shoots = 5;
}
你认为当我们运行这段代码时,会发生什么?请从下列选项中选择一个。
选项A
Compilation error at line 4
选项B
Graw!!! 0
Nemesis raids: 23
Stars!!!
Nemesis shoots: 23 and raids:1
选项C
Nemesis raids: 0
Stars!!!
Graw!!! 0
Nemesis shoots: 23 and raids: 1
选项D
Nemesis raids: 0
Stars!!!
Graw!!! 1
Nemesis shoots: 23 and raids:1
选项E
Compilation error at line 6
Java代码挑战视频
你为这个挑战选择了正确的输出吗?请观看视频或继续阅读以了解答案。
了解接口和抽象类及方法
这个Java代码挑战展示了许多关于接口、抽象方法等的重要概念。逐行浏览代码会让我们了解到输出中发生的很多事情。
代码挑战的第一行包括Zombie
接口的lambda表达式。请注意,在这个lambda中,我们正在增加一个静态字段。实例字段在这里也可以使用,但在lambda之外声明的局部变量就不行了。因此,到目前为止,这段代码可以正常编译。还要注意的是,lambda表达式还没有执行,所以nemesisRaids
字段还不会被递增。
在这一点上,我们将打印nemesisRaids
字段,它没有被增加,因为λ表达式还没有被调用,只是被声明。因此,这一行的输出将是:
Nemesis raids: 0
这个Java代码挑战中另一个有趣的概念是,我们正在使用一个匿名的内层类。这基本上意味着任何将实现Nemesis
抽象类的方法的类。我们并没有真正实例化Nemesis
抽象类,因为它实际上是一个匿名的类。还要注意的是,第一个具体的类在扩展它们的时候总是有义务实现抽象的方法。
在Zombie
接口里面,我们用一个lambda表达式声明了zombie
static
Zombie
接口。因此,当我们调用zombie shoot
方法时,我们会打印以下内容:
Stars!!!
下一行代码调用了我们在开始时创建的lambda表达式。因此,nemesisRaids
这个变量将被递增。然而,由于我们使用的是后增量运算符,它将只在这条代码语句之后被增量。接下来的输出将是:
Graw!!! 0
现在,我们将从nemesis
中调用shoot
方法,这将改变其shoots
实例变量为23
。 注意,这部分代码展示了接口和抽象类之间的最大区别。
最后,我们打印nemesis.shoots
和nemesisRaids
的值。因此,输出结果将是:
Nemesis shoots: 23 and raids: 1
综上所述,正确的输出是选项C:
Nemesis raids: 0
Stars!!!
Graw!!! 0
Nemesis shoots: 23 and raids: 1