一、介绍
无论你使用哪一种编程语言(在这里Java也不排除),遵循一种好的设计规则对于写出清晰、易于理解、可测试、长生命周期以及易于维护的代码来说非常关键。在本系列文章的这一部分我们将会讨论Java语言提供的基础构件和引进的一些设计规则来帮助大家在项目研发过程中做出最好的设计决策。
确切的讲,我们将会讨论接口(interfaces)和接口默认方法(interfaces with default methods)(Java 8新特性),抽象(abstract)和final类,不可变类( immutable classes),继承(inheritance),composition以及一些可见性(或者访问性)规则。
二、接口(Interfaces)
在面向对象编程中,接口的概念形成了约定驱动(或者基于约定)开发的基础。概括的来讲,接口定义一组方法(约定),然后每个声明支持该特定接口的类必须提供这些方法的实现:概念非常简答,但是作用非常大。
很多编程语言都有不同形式的接口,但是Java语言对接口也提供了语言支持。让我们来看一个Java中一个简单的接口定义。
public interface SimpleInterface {
void performAction();
}
在上面的代码片段中,接口被命名为SimpleInterface 并定义了名为performAction的方法。接口相对于类来说最主要的不同点就是接口框定了约定内容(定义方法),但是它并不提供方法的实现。
但是,Java中的接口可能比这更复杂:他可以包含一些被嵌入的接口,类,枚举,注解以及常量。比如:
public interface InterfaceWithDefinitions {
String CONSTANT = "CONSTANT";
enum InnerEnum {
E1, E2;
}
class InnerClass {
}
interface InnerInterface {
void performInnerAction();
}
void performAction();
}
在这个相对复杂的例子中,对于嵌入的结构和方法定义来说,接口的一些约束被隐式的导入,并且这些是Java编译器强制要求的。首先也是最重要的一点,如果我们没有明确的指明,在接口中定义的每一个成员默认都是public修饰(并且仅仅只能是public)。因此,下面的方法声明是等价的:
public void performAction();
void performAction();
值得提及的是接口之中每一个单独的方法被隐式的定义为abstract方法,基于此下面的方法定义是等价的:
public abstract void performAction();
public void performAction();
void performAction();
至于常量字段声明,除了默认使用public关键字修饰之外,它们被隐式的声明为static和final类型,所以下面的声明也是等价的:
String CONSTANT = "CONSTANT";
public static final String CONSTANT = "CONSTANT";
最后,对于嵌入类、接口或者枚举类型,除了默认是public修饰之外,他们被隐式的定义为static类型。例如下面的两个定义是等价的:
class InnerClass {
}
static class InnerClass {
}
选择哪一种类型是个人的偏好,然而这些简单的接口特质知识能够帮助你减少一些不必要的代码输入。
三、标记接口(Marker Interfaces)
标记接口是一种特殊的类型的接口,他没有声明方法和其他的一些嵌入结构。我们在“Java高级系列——使用所有对象的通用方法”一文中已经看到过一个标记接口的例子,这个接口是Cloneable。下面是该接口在Java库中的定义:
public interface Cloneable {
}
标记接口本身不是约定,但是是可以将某些特定的特征“附加(attach)”或“捆绑(tie)”到类中的有用技术。比如说就Cloneable而言,被标记的类就可用于克隆,然而被标记的类应该或者能够通过什么样的方式去完成克隆并不包含在接口中。另一个非常出名和被广泛使用的标记接口就是Serializable:
public interface Serializable {
}
该接口所标记的类可用于序列化和反序列化,此外,它并不指定被标记的类应该或者能够通过什么样的方式去实现序列化和反序列化。
标记接口在面向对象的设计中占有一席之地,尽管它们不能满足接口作为约定的主要目的。
四、函数式接口,默认和静态方法(Functional interfaces, default and static methods)
随着Java 8的发布,接口获得了一些新的非常有趣的功能:静态方法、默认方法和从lambdas的自动转换(函数式接口)。
在接口的那一节我们已经强调了事实上Java中接口仅仅只是定义了一些方法但是并不允许提供这些方法的实现。使用默认方法之后就不再是这样了:接口中可以使用default关键字标记一个方法并且为被标记的方法提供实现。例如:
public interface InterfaceWithDefaultMethods {
void performAction();
default void performDefaulAction() {
// Implementation here
}
}
在实例级别,默认方法可以被每个接口实现者重写,但是现在,接口也可以包含static方法,比如:
public interface InterfaceWithDefaultMethods {
static void createAction() {
// Implementation here
}
}
有人可能会说,在接口中提供一个实现,就违背了基于约定开发的全部意图,但是也有很多为什么这些特性会被引入Java语言中的原因,并且不管他们是有用还是令人困惑,他们的存在都是为了有朝一日您能够用的到他们。
函数式接口是一个非常不同的特性了,它被证明是附加到语言中的最有用的特性。基本上,函数式接口就是一个定义了一个单独的抽象方法在内的普通接口。根据这个概念我们可以看一下在Java标准库中的Runnable接口:
@FunctionalInterface
public interface Runnable {
void run();
}
Java编译器以不同的方式处理函数式接口,并且能够转化lambda函数为函数式接口的实现,让我们看一下下面的函数定义:
public void runMe( final Runnable r ) {
r.run();
}
在Java 7以及更低的版本中,如果我们要调用这个函数,必须提供Runnable接口的实现(比如使用匿名类),但是在Java 8 中使用Lambda语法传递run()方法就足够了:
runMe( () -> System.out.println( "Run!" ) );
此外,@FunctionalInterface注解提示编译器去验证接口是否只是仅仅包含一个抽象方法,未来引入接口的任何变化都不会破坏这个假设。
五、抽象类(Abstract classes)
Java语言支持的另外一个非常有趣的概念就是抽象类概念。抽象类在某些特性上有点类似Java 7中的接口,并且非常接近于Java 8中接口的默认方法。与常规的类相比,抽象类不能够被实例化但是可以被继承(继承的概念我们将会在下文中讲解)。最重要的是,抽象类可以包含抽象方法(未提供具体实现的特殊方法,非常类似接口中的方法)。比如:
public abstract class SimpleAbstractClass {
public void performAction() {
// Implementation here
}
public abstract void performAnotherAction();
}
在本实例中,类SimpleAbstractClass被定义为抽象(abstract)类并且声明了一个抽象(abstract)方法。在很多甚至是部分的实现细节需要被多个子类共享时抽象类是非常有用的。然而,它们仍然敞开大门,通过抽象的方法来定制每个子类的内在行为。
有一些要点需要提及,和接口相比,接口只能包含public声明,抽象类可以使用所有的访问控制规则控制抽象方法的可见性。
抽象类可总结为以下几点:
抽象类不能被实例化(初学者很容易犯的错),如果被实例化,就会报错,编译无法通过。只有抽象类的非抽象子类可以创建对象。
抽象类中不一定包含抽象方法,但是有抽象方法的类必定是抽象类。
抽象类中的抽象方法只是声明,不包含方法体,就是不给出方法的具体实现也就是方法的具体功能。
构造方法,类方法(用static修饰的方法)不能声明为抽象方法。
抽象类的子类必须给出抽象类中的抽象方法的具体实现,除非该子类也是抽象类。
六、不可变类(Immutable classes)
在如今的软件开发中,不可变性已经变得越来越重要。多核系统的兴起提升了很多关于数据共享和并发性的关注度。但是有一点肯定会出现:可变状态越少(甚至不存在)将会带来更好的拓展性和更简单的系统维护。
不幸的是,对于类的不可变性Java语言并没有提供很强的支持。然而,使用一些技术的组合是可以设计出不可变类的。
首先,类的所有字段应该声明为final类型。这样做是一个很好的开端但是并不能保证绝对不可变。
public class ImmutableClass {
private final long id;
private final String[] arrayOfStrings;
private final Collection< String > collectionOfString;
}
其次,遵循合适的初始化规则:如果字段引用了一个集合或者数组,不要直接从构造参数中分配字段,使用副本来代替。这样能够保证集合或者数组的状态不会因为外部原因而发生变化。
public ImmutableClass( final long id, final String[] arrayOfStrings,final Collection< String > collectionOfString) {
this.id = id;
this.arrayOfStrings = Arrays.copyOf( arrayOfStrings, arrayOfStrings.length );
this.collectionOfString = new ArrayList<>( collectionOfString );
}
最后,提供合适的存取入口(getters)。对于集合,不可变视图应该使用Collections.unmodifiableXxx包装器暴露。
public Collection<String> getCollectionOfString() {
return Collections.unmodifiableCollection( collectionOfString );
}
对于数组,确保不可变的唯一方式就是提供提供一个数组的副本而不是数组的引用。从实际的角度来看,这可能是不可接受的,因为它很大程度上取决于数组的大小,并可能给垃圾收集器带来很大的压力。
public String[] getArrayOfStrings() {
return Arrays.copyOf( arrayOfStrings, arrayOfStrings.length );
}
虽然我们的这个简单的实例给出了一个很好的思想,即不可变性不是Java中的头等公民。如果不可变类的字段引用了其他类的实例,问题就会真正的变得复杂。如果不可变类的字段引用了其他类的实例,那么被引用的类也应该是不可变的,然而要强制这些被引用的类为不可变类并不是那么简单。
有几个很好的Java源代码分析器,如FindBugs(http://findbugs.sourceforge.net/)和PMD(https://pmd.github.io/),可以通过检查您的代码并指出常见的Java编程缺陷来帮助您。 这些工具是所有Java开发人员的好朋友。
七、匿名类(Anonymous classes)
在Java 8之前,匿名类是提供就地类定义和即时实例化的唯一方法。匿名类的目的是为了减少样板,并提供简洁和简单的方法来表示类。让我们来看看在Java中产生新线程的典型老式方法:
public class AnonymousClass {
public static void main( String[] args ) {
new Thread(
// Example of creating anonymous class which implements
// Runnable interface
new Runnable() {
@Override
public void run() {
// Implementation here
}
}
).start();
}
}
在这个例子中,Runnable接口的实现是作为匿名类来提供的。虽然有一些与匿名类相关的限制,他们使用的一些基本缺点是相当冗长的语法结构,但是这种语法结构被Java强制作为语言的一部分。甚至最简单的不做任何具体事情的匿名类实现都至少需要写5行代码:
new Runnable() {
@Override
public void run() {
}
}
幸运的是,在Java 8之后,lambdas和函数式接口让这些样板都将消失,最终上面的代码段可以按照如下改造:
public class AnonymousClass {
public static void main( String[] args ) {
new Thread( () -> { /* Implementation here */ } ).start();
}
}
八、可见性(Visibility)
我们在本系列的文章的Java高级系列——如何创建和销毁对象一文中已经谈论过关于Java的可见性和可访问性的问题,本文我们再继续看一看在继承情况下的可见性情况。
修饰符 | 包 | 子类 | 其他 |
---|---|---|---|
public | 可访问 | 可访问 | 可访问 |
protected | 可访问 | 可访问 | 不可访问 |
无修饰符 | 可访问 | 不可访问 | 不可访问 |
private | 不可访问 | 不可访问 | 不可访问 |
不同的可见性级别限制了类是否可以看到或者访问其他的类和接口(比如在不同的包中)以及子类是否可以看到或者访问父类的方法,构造器和字段。
九、继承(Inheritance)
继承是面向对象编程的一个关键的概念,提供了构建类关系的基础。与可见性和可访问性规则结合,继承允许设计可拓展和可维护的类层级。
从概念上来讲,Java中的继承指的是一个类从父类获得属性和方法的过程,通过extends关键字实现。子类继承所有父类的public和protected成员。此外,如果子类和父类在同一个包内,那么子类可继承父类的package-private(无访问修饰符修饰的成员)成员。话说回来,无论你如何去设计,类暴露出去的public方法或者子类能够继承的方法集合都应该保持在一个最小范围。比如,让我们来通过一个父类Parent和它的子类Child来说明不同访问控制级别和他们的实际效果。
public class Parent {
// 所有类都可以访问
public static final String CONSTANT = "Constant";
// 除当前类之外,其他类中无法访问
private String privateField;
// 仅仅子类可以访问
protected String protectedField;
// 除当前类之外,其他类中无法调用
private class PrivateClass {
}
// 仅仅子类可以调用
protected interface ProtectedInterface {
}
// 所有类都可以调用
public void publicAction() {
}
// 仅仅子类可以调用
protected void protectedAction() {
}
// 除当前类之外,其他类中无法调用
private void privateAction() {
}
// 仅仅是和和父类在同一个包中的子类可以调用
void packageAction() {
}
}
//子类和父类在同一个包中
public class Child extends Parent implements Parent.ProtectedInterface {
@Override
protected void protectedAction() {
// 调用父类的方法实现
super.protectedAction();
}
@Override
void packageAction() {
// 什么也不做,不调用父类的方法实现
}
public void childAction() {
// 继承父类属性并设置值
this.protectedField = "value";
}
}
继承本身就是一个非常大的话题,具有很多Java特有的细节。然而,有一些非常容易的规则能够帮助你最大限度的保持类层次结构的简洁。在Java中,每一个子类可以重写所有从父类继承过来的方法,除非父类将方法定义为final。
然而,没有特殊的语法或者关键字去标识方法被重写将会导致很多的混淆,这也是为什么@Override注解被引入的原因,无论何时你想重写继承过来的方法,请记住使用@Override注解标记。
Java开发人员在设计中经常面临的另一个困境是构建类层次结构与接口实现。强烈建议尽可能的使用接口或者抽象类,接口更轻量级,更容易测试和维护,以及它们最小化了接口的实现变更造成的影响。许多先进的编程技术,比如在标准Java库中创建类代理,很大程度上依赖于接口。
十、多继承(Multiple inheritance)
相比于C++和其他语言,Java不支持多继承:在Java中每个类只能有一个确切的直接父类(Object类在类层次结构之的最顶端)。但是,类可以实现多个接口。
public class MultipleInterfaces implements Runnable, AutoCloseable {
@Override
public void run() {
// Some implementation here
}
@Override
public void close() throws Exception {
// Some implementation here
}
}
多接口的实现实际上是非常强大的,但是在Java 8之前,如果需要重用一个接口的实现,那么就会导致很深的类层次结构,但通过这种方式可以克服Java中多继承缺失的支持,我们来看下面这个实例。
public class A implements Runnable {
@Override
public void run() {
// Some implementation here
}
}
// 类B从类A继承run()方法的实现并实现了接口AutoCloseable的close()方法
public class B extends A implements AutoCloseable {
@Override
public void close() throws Exception {
// Some implementation here
}
}
// 类C从类A继承了run()方法的实现并且从类B继承了close()方法的实现
public class C extends B implements Readable {
@Override
public int read(java.nio.CharBuffer cb) throws IOException {
// Some implementation here
}
}
Java 8的发布通过引入默认方法解决了这个问题。因为默认方法,接口实际已经开始提供的不仅仅是约定(方法),同时也提供了实现。所以,实现接口的类可以自动继承这些已经被默认实现的方法。例如:
public interface DefaultMethods extends Runnable, AutoCloseable {
@Override
default void run() {
// Some implementation here
}
@Override
default void close() throws Exception {
// Some implementation here
}
}
// 类C从DefaultMethods接口继承了run()方法和close()方法的实现
public class C implements DefaultMethods, Readable {
@Override
public int read(java.nio.CharBuffer cb) throws IOException {
// Some implementation here
}
}
需要特别关心的是多继承的功能特别强大,但同时也是一个危险的工具。众所周知的“死亡钻石(Diamond of Death)”问题经常被用来比喻多继承实现的基本缺陷,所以要求开发者需要非常仔细地设计类层次结构。不幸的是,具有默认方法的Java 8接口也成为这些缺陷的受害者。
interface A {
default void performAction() {
}
}
interface B extends A {
@Override
default void performAction() {
}
}
interface C extends A {
@Override
default void performAction() {
}
}
我们先定义接口A,定义接口B继承自接口A,定义接口C继承自接口A,接下来我们来看下一段代码,编译器在编译这段代码时会编译失败:
// E没有被编译,除非它也重写performAction()方法
interface E extends B, C {
}
在这一点上,可以说Java作为一种语言总是在尝试逃避面向对象编程语言的一些极少数的情况,但随着语言的发展,其中一些情况开始出现。
十一、组合(composition)与继承(inheritance)
幸运的是,继承并不是设计类的唯一方式。除了继承之外,许多开发者还想到了比继承更好的一种方式,那就是组合(composition)。思想非常简单:不建立类层次结构,新类应该由其他的类组合而成。我们来看一个组合的例子:
public class Vehicle {
private Engine engine;
private Wheels[] wheels;
// ...
}
Vehicle类由engine类和wheels类组合而成(为了简单起见,需要添加的其他部分我们暂且先放在一边)。然而,有人可能会说,Vehicle类也可以是一个engine并且可以使用继承向下面这种方式一样设计:
public class Vehicle extends Engine {
private Wheels[] wheels;
// ...
}
那种设计方案是正确的呢?一般的参考方式呢是需要考虑IS-A和HAS-A规则。
IS-A是继承关系:子类满足父类的规范,继承父类方法和属性;
HAS-A是组合关系:新类拥有属于自己的对象(在新类中使用现有类的对象,也就是设计类的时候把要组合的类的对象加入到该类中作为自己的成员变量)。
在大多数情况下,HAS-A规则比IS-A规则运用的更多,同时在设计类时也更好,主要有如下一些原因:
在类需要变更时这种设计相对灵活;
这种模式相对稳定,类的变更不会影响到类的层次结构;
类的对象和他的组合对象之间相比于继承的子类和父类之间来说耦合度更低;
类的结构也非常简单,因为所有的依赖都包含在类中;
然而,继承也有他自己的一片天地,以不同的方式解决实际的设计问题不容忽视。在设计面向对象模型时请牢记这两种选择。
十二、封装(Encapsulation)
面向对象编程中封装的概念其实就是对外隐藏实现细节(比如状态,内部方法等等)。封装的好处就是能够提升代码的可维护性和易于修改特性。类的内部细节暴露得越少,开发人员就越能更好的控制和修改内部实现,而不用担心破坏现有的代码(如果你开发的一个库或者框架被很多人使用,那就更需要仔细考虑封装问题)。
在Java中,封装主要是通过使用访问控制和可见性规则来实现。在Java中,最好的做法是不要直接暴露字段,只有通过getter和setter(如果该字段没有声明为final),才能访问这些字段。比如:
public class Encapsulation {
private final String email;
private String address;
public Encapsulation( final String email ) {
this.email = email;
}
public String getAddress() {
return address;
}
public void setAddress(String address) {
this.address = address;
}
public String getEmail() {
return email;
}
}
这个例子在Java中我们称之为JavaBeans:按照一组约定编写的常规Java类,这些约定之一就是仅仅只允许仅使用getter和setter方法访问字段。
在我们阐述继承的那一节我们强调,请遵守封装原则,尽量最小化public约定(尽量少暴露类的方法)。至于什么时候,什么情况下需要将那些不应该使用public修饰的使用private(protected/package-private)修饰,这完全取决于你说解决的问题的具体场景。
十三、Final类和方法
在Java中,有一种方法可以阻止类被其他的类继承:这种方式就是将类定义为Final类型。
public final class FinalClass {
}
在方法定义时使用相同的final关键字可以阻止被修饰的方法被子类重写的问题。
public class FinalMethod {
public final void performAction() {
}
}
没有通用的规则去决定是否一个类或者方法应该被定义成final。Final类和方法限制了拓展性,并且我们也很难超前的去想象一个类是否会应该被继承,一个方法是否应该被重写。对库开发者来说,在做设计决定的时候这就相当重要,因为这将会在很大的程度上影响到库的适用性。
Java标准类库也有一些Final类的实例,比如众所周知的String类。在早期阶段,将String类设计成Final类型的这个决定被提出来就是为了阻止任何开发者尝试去提出自己认为更好的字符串实现。
在本文中我们阐述了Java中的一些面向对象的设计理念。我们还简要地介绍了基于约定的开发,接触到了一些函数的概念,并且看到了语言随着时间的推移如何演变。接下来将会介绍泛型以及泛型如何改变了我们处理类型安全编程的方式。