Java进阶3 —— 类和接口设计原则

原文链接:http://www.javacodegeeks.com/2015/09/how-to-design-classes-and-interfaces.html

本文是Java进阶课程的第三篇。

本课程的目标是帮你更有效的使用Java。其中讨论了一些高级主题,包括对象的创建、并发、序列化、反射以及其他高级特性。本课程将为你的精通Java的旅程提供帮助。

内容纲要

  1. 引言

  2. 接口

  3. 标记性接口

  4. 函数式接口,默认方法及静态方法

  5. 抽象类

  6. 不可变类

  7. 匿名类

  8. 可见性

  9. 继承

  10. 多重继承

  11. 继承与组合

  12. 封装

  13. Final类和方法

  14. 源码下载

  15. 下章概要

引言

不管使用哪种编程语言(Java也不例外),遵循好的设计原则是你编写干净、易读、易测试代码的关键,并且在程序的整个生命周期中,可提高后期的可维护性。在本章中,我们将从Java语言提供的基础构造模块开始,并引入一组有助于你设计出优秀结构的设计原则。

具体包括:接口接口的默认方法(Java 8新特性),抽象类final类不可变类继承组合以及在对象的创建与销毁中介绍过的可见性(访问控制)规则。

接口

在面向对象编程中,接口构成了基于契约的开发过程的基础组件。简而言之,接口定义了一组方法(契约),每个支持该接口的具体类都必须提供这些方法的实现。这是开发过程中一种简单却强有力的理念。

很多编程语言有一种或多种接口实现形式,而Java语言则提供了语言级的支持。下面简单看一下Java中的接口定义形式:

package com.javacodegeeks.advanced.design;

public interface SimpleInterface {
    void performAction();
} 

在上面的代码片段中,命名为SimpleInterface的接口只定义了一个方法performAction。接口与类的主要区别就在于接口定义了约定(声明方法),但不为他们提供具体实现。

在Java中,接口的用法非常丰富:可以嵌套包含其他接口、类、枚举和注解(枚举和注解将在枚举和注解的使用中介绍)以及常量,如下:

package com.javacodegeeks.advanced.design;

public interface InterfaceWithDefinitions {
    String CONSTANT = "CONSTANT";

    enum InnerEnum {
        E1, E2;
    }

    class InnerClass {
    }

    interface InnerInterface {
        void performInnerAction();
    }

    void performAction();
} 

针对上面的复杂场景,Java编译器强制为嵌套的类对象构造和方法声明提供了一组隐式的要求。首当其冲的便是接口中的每个声明必须是public(即便不指定也是public,并且不能设置为非public,详细规则可参考可见性部分介绍)。所以下面代码中的用法与上面看到的声明是等价的:

public void performAction();
void performAction(); 

另外,接口中定义的每个方法都被默认声明为abstract的,所以下面的声明都是等价的:

public abstract void performAction();
public void performAction();
void performAction(); 

对于常量字段,除了隐式的public外,也被加上了staticfinal修饰,所以下面的声明也是等价的:

String CONSTANT = "CONSTANT";
public static final String CONSTANT = "CONSTANT"; 

对于嵌套的类、接口或枚举的定义,也隐式的声明为static的,所以下面的声明也是等价的:

class InnerClass {
}

static class InnerClass {
} 

根据个人偏好可以使用任意的声明风格,不过了解上面的约定倒是可以减少一些不必要的代码编写。

标记性接口

标记性接口是接口的一种特殊形式:即没有任何方法或其他嵌套定义。在使用Object的通用方法章节中我们已经见过这种接口:Cloneable,下面是它的定义:

public interface Cloneable {
} 

标记性接口并不像普通接口声明一些契约,但却为类“附加”或"绑定"特定的特性提供了支持。例如对于Cloneable,实现了此接口的类就会被认为具有克隆的能力,尽管如何克隆并未在Cloneable中定义。另外一个广泛使用的标记性接口是Serializable

public interface Serializable {
} 

这个接口声明类可以被序列化或反序列化,同样它并未指定序列化过程中使用的方法。

尽管标记性接口并不满足接口作为契约的主要用途,不过在面向对象设计过程种仍然有一定的用武之地。

函数式接口,默认方法及静态方法

伴随着Java 8的发布,接口被赋予了新的能力:静态方法、默认方法以及从lambda表达式的自动转换(函数式接口)。

在上面的接口部分,我们强调过在Java中接口只能作为声明但不能提供任何实现。但默认方法打破了这一原则:在接口中可以为default标记的方法提供实现,如下:

package com.javacodegeeks.advanced.design;

public interface InterfaceWithDefaultMethods {
    void performAction();

    default void performDefaulAction() {
        // Implementation here
    }
} 

从对象实例层次看,默认方法可被任何的接口实现者重载;除此之外,接口还提供了另外的静态方法,如下:

package com.javacodegeeks.advanced.design;

public interface InterfaceWithDefaultMethods {
    static void createAction() {
        // Implementation here
    }
} 

也许你会认为在接口中提供实现违背了基于契约的开发过程,不也你也可以列出很多Java把这些特性引入其中的理由。不管是带来了帮助还是困扰,它们已然存在,你也可以使用它们。

函数式接口有着不同的场景,并被认为是对编程语言的一种强大的扩展。本质上,函数式接口也是接口,不过包含一个抽象的方法声明。Java 标准库中的Runnable接口就是这种理念的绝佳范例:

@FunctionalInterface
public interface Runnable {
    void run();
} 

Java 编译器在处理函数式接口时有所不同,并能把lamdba表达式转化为函数式接口的实现。我们先看一下下面方法的定义:

public void runMe( final Runnable r ) {
    r.run();
} 

在Java 7及以前的版本中,必须要提供Runnable接口的具体实现(例如使用匿名类),但在Java 8中却可以通过传递lambda表达式来运行run()方法:

runMe( () -> System.out.println( "Run!" ) ); 

最后,可以使用@FunctionalInterfact注解(注解会在枚举和注解的使用章节进行详细介绍)告知编译器以在编译阶段验证函数式接口中仅包含了一个抽象方法声明,从而保证未来任何变更的引入不会破坏该接口的函数式特性。

抽象类

抽象类是Java 语言支持的另外一个有趣的主题。抽象类与Java 7中的接口有些类似,与Java 8中支持默认方法的接口更为相像。不同于普通类,抽象类不能实例化,但可以被继承。更重要的是,抽象类能包含抽象方法:一种没有定义实现的特殊方法,类似于接口中的方法声明,如下:

package com.javacodegeeks.advanced.design;

public abstract class SimpleAbstractClass {
    public void performAction() {
        // Implementation here
    }

    public abstract void performAnotherAction();
} 

在上述例子中,SimpleAbstractClass类被声明为abstract,并且包含了一个abstract方法。当类有部分实现可被子类共享时,抽象类就变得特别有用,因为它还为子类对抽象方法的定制实现提供了支持入口。

另外,抽象类与接口还有一点不同在于接口只能提供public的声明,而抽象类可使用所有的访问控制规则来支持方法的可见性。

不可变类

不可变性在现代软件开发中的地位日益显著。随着多核系统的发展,随之而来引入了大量并发与数据共享的问题(并发最佳实践中会详细介绍并发相关主题)。但有一个理念是明确的:系统的可变状态越少(甚至不可变),扩展性和可维护性就越高。

遗憾的是,Java并未从语言特性上提供强大的不可变性支持。尽管如此,使用一些开发技巧依然能设计出不可变的类和系统。首先要保证类的所有字段均设置为final,当然这只是一个好的开始,你并不能单纯的通过final就完全保证不可变性:

package com.javacodegeeks.advanced.design;

import java.util.Collection;

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 getCollectionOfString() {
    return Collections.unmodifiableCollection( collectionOfString );
} 

对于数组,唯一能保证不可变性的方式只有逐一复制数组中的元素到新数组而不是直接返回原数组的引用。不过有些时候这种做法可能不切实际,因为过大的数组复制将会为增加垃圾回收的开销。

public String[] getArrayOfStrings() {
    return Arrays.copyOf( arrayOfStrings, arrayOfStrings.length );
} 

尽管上面的例子提供了一些示范,然而不可变性依然不是Java中的一等公民。当不可变类的字段引用了其他类的实例时,情况可能会变得更加复杂。其他类也应该保证不可变,然而并没有简单有效的途径进行保证。

有一些优秀的Java源码分析工具,像FindBugs 和 PMD能帮助你分析代码并找出常见的Java代码编写缺陷。对于任何一个程序员,这些工具都应当成为你的好帮手。

匿名类

在Java 8之前,匿名类是实现在类定义的地方一并完成实例化的唯一方式。匿名类的目的是减少不必要的格式代码,并以简捷的方式把类表示为表达式。下面看下Java中典型的创建线程的方式:

package com.javacodegeeks.advanced.design;

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中的lambda表达式和函数式接口消除了这些语法上的固有代码,使得Java代码可以变的更酷:

package com.javacodegeeks.advanced.design;

public class AnonymousClass {
    public static void main( String[] args ) {
        new Thread( () -> { /* Implementation here */ } ).start();
    }
} 

可见性

我们在对象的创建与销毁章节中已经学习过Java中的可见性与可访问性的概念,本部分我们回过头看看父类中定义的访问修饰符在子类里的可见性:

修饰符 包可见性 子类可见性 公开可见性
public 可见 可见 可见
protected 可见 可见 不可见
<无修饰符> 可见 不可见 不可见
private 不可见 不可见 不可见

表 1

不同的可见性级别限制了类或接口对其他类(例如不同的包或嵌套的包中的类)的可见性,也控制着子类对父类中定义的方法、函数方法及字段的可见与可访问性。

在接下面的继承,我们会看到父类中的定义对子类的可见性。

继承

继承是面向对象编程的核心概念之一,也是构造类的关系的基础。凭借着类的可见性与可访问性规则,通过继承可实现易扩展和维护的类层次关系。

语法上,Java 中实现继承的方式是通过extends关键字后跟着父类名实现的。子类从父类中继承所有public和protected的成员,如果子类与父类处于同一个包中,子类也将会继承只有包访问权限的成员。不过话说回来,在设计类时,应保持具有最少的公开方法或能被子类继承的方法。下面通过Parent类和Child类来说明不同的可见性及达到的效果:

package com.javacodegeeks.advanced.design;

public class Parent {
    // Everyone can see it
    public static final String CONSTANT = "Constant";

    // No one can access it
    private String privateField;
    // Only subclasses can access it
    protected String protectedField;

    // No one can see it
    private class PrivateClass {
    }

    // Only visible to subclasses
    protected interface ProtectedInterface {
    }

    // Everyone can call it
    public void publicAction() {
    }

    // Only subclass can call it
    protected void protectedAction() {
    }

    // No one can call it
    private void privateAction() {
    }

    // Only subclasses in the same package can call it
    void packageAction() {
    }
}
package com.javacodegeeks.advanced.design;

// Resides in the same package as parent class
public class Child extends Parent implements Parent.ProtectedInterface {
    @Override
    protected void protectedAction() {
        // Calls parent's method implementation
        super.protectedAction();
    }

    @Override
    void packageAction() {
        // Do nothing, no call to parent's method implementation
    }

    public void childAction() {
        this.protectedField = "value";
    }
}

继承本身就是一个庞大的主题, 在Java语言中也制定了一系列精细的规范。尽管如此,还是有一些易于遵循的原则帮助你实现精练的类层次结构。在Java中,子类可以重载从父类中继承过来的任意非final方法(final的概念参见Final类和方法)。

然而,起初在Java中并没有特定的语法或关键字标识方法是否是重载了的,这常常会给代码的编写引入混淆。因此后来引入了@Override注解用于解决这个问题:当你确实是在重载继承来的方法时,请使用@Override注解进行标记。

另外一个Java开发者经常需要权衡的问题在设计系统时使用类继承(具体类或抽象类)还是接口实现。这个建议就是优先选择接口实现而非继承。因为接口更为轻量,易于测试(通过接口mock)和维护,并能降低修改实现所带来的副作用。很多优秀的编程技术都偏向于依赖接口为标准Java库创建代理。

多重继承

不同于C++或其他编程语言,Java并不支持多重继承:Java中的每个类最多只能有一个直接的父类(在使用Object的通用方法中我们知道Object类处于继承层次的顶端)。然而Java中的类可以实现多个接口,所以实现多个接口是Java中达到多重继承效果的唯一途径。

package com.javacodegeeks.advanced.design;

public class MultipleInterfaces implements Runnable, AutoCloseable {
    @Override
    public void run() {
        // Some implementation here
    }

    @Override
    public void close() throws Exception {
       // Some implementation here
    }
} 

尽管实现多个接口的方式非常强大,但有时为了更好的重用某个接口的实现,你不得不通过更深的类继承层次以达到多重继承的效果:

public class A implements Runnable {
    @Override
    public void run() {
        // Some implementation here
    }
}
// Class B wants to inherit the implementation of run() method from class A.
public class B extends A implements AutoCloseable {
    @Override
    public void close() throws Exception {
       // Some implementation here
    }
}
// Class C wants to inherit the implementation of run() method from class A
// and the implementation of close() method from class B.
public class C extends B implements Readable {
    @Override
    public int read(java.nio.CharBuffer cb) throws IOException {
       // Some implementation here
    }
}

Java中引入的默认方法在一定程序上解决了类继承层次过深的问题。随着默认方法的引入,接口便不只是提供方法声明约束,同时还可以提供默认的方法实现。相应的,实现了此接口的类也顺带着继承了接口中实现的方法。示例如下:

package com.javacodegeeks.advanced.design;

public interface DefaultMethods extends Runnable, AutoCloseable {
    @Override
    default void run() {
        // Some implementation here
    }

    @Override
    default void close() throws Exception {
       // Some implementation here
    }
}

// Class C inherits the implementation of run() and close() methods from the
// DefaultMethods interface.
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() {
    }
} 

根据上面的定义,下面的接口E将会编译失败:

// E is not compilable unless it overrides performAction() as well
interface E extends B, C {
} 

坦白的说,作为面向对象编程语言,Java一向都在尽力避免一些极端场景。然而避免语言本身的发展,这些极端场景也逐渐暴露。

继承与组合

好在继承并非设计类的关系的唯一方式。组合是另外一种被大多开发者认为优于继承的设计方法。其主旨也相当简单:取代层次结构,类应该由其他类组合而来。

先看一个简单的例子:

public class Vehicle {
    private Engine engine;
    private Wheels[] wheels;
    // ...
} 

Vehicle类由enginewheels组成(简单起见,忽略了其他的组成部分)。
不过也有人会说Vehicle也是一个engine,因此应该使用继承的方式:

public class Vehicle extends Engine {
    private Wheels[] wheels;
    // ...
} 

到底哪种设计才是正确的呢?业界通用的原则分别称之为IS-AHAS-A规则。IS-A代表的是继承关系:子类满足父类的规则,从而是父类的一个(IS-A)变量。与之相反,HAS-A代表的是组合关系:类拥有(HAS-A)属于它的对象。通常,HAS-A优于IS-A,原因如下:

  • 设计更灵活,便于以后的变更

  • 模型更稳定,变化不会随着继承关系扩散

  • 依赖更松散,而继承把父类与子类紧紧的绑在了一起

  • 代码更易读,类所有的依赖都被包含在类的成员声明里

尽管如此,继承也有自己的用武之地,在解决问题时不应被忽略。在设计面向对象模型时,要时刻记着组合和继承这两种设计方法,尽可能多些尝试以做出最优选择。

封装

在面向对象编程中,封装的含义就是把细节(像状态、内部方法等)隐藏于内部而不暴露于实现之外。封装带来的好处就是提高了可维护性,并便于将来的变更。越少的细节暴露,就会带来越多的未来变更实现的控制权,而不用担心破坏其他代码(如果你是一位代码库或框架的开发者,一定会遇到这种情景)。

在Java语言中,封装是通过可见性和可访问性规则实现的。公认的优秀实践就是从不直接暴露类的字段,而是通过setter(如果字段没有声明为final)和getter的方式来访问它。请看下面的例子:

package com.javacodegeeks.advanced.design;

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:遵循"只能以setter和getter方法的方式暴露允许外部方法的字段"规则的普通Java类。

如我们在继承部分强调过的,遵守封装原则,把公开的信息最小化。不需要public的时候, 要使用private替代(或者protected/package/private,取决于具体的问题场景)。在将来的维护过程,你会得到回报:带给你足够的自由来优化设计而不会引入破坏性的变更(或者说对外部的变更达到最小化)。

Final 类和方法

在Java中,有一种方式能阻止类被其他类继承:把类声明为final

package com.javacodegeeks.advanced.design;

public final class FinalClass {
} 

final修饰在方法上时,也能达到阻止方法被重载的效果:

package com.javacodegeeks.advanced.design;

public class FinalMethod {
    public final void performAction() {
    }
} 

是否应该使用final修饰类或方法并无定论。Final的类和方法一定程度上会限制扩展性,并且在设计之初很难判断类是否会被继承、方法是否能被重载。对于类库开发者,尤其值得注意,使用final可能会严重影响类库的适用性。

Java标准库中有一些final类的例子,例如众所周知的String类。在很早时候,就把String设计成了final,从而避免了开发者们自行设计的好坏不一的字符串实现。

源码下载

可以从这里下载本文中的源码:advanced-java-part-3

下章概要

在本章节中,我们学习了Java中的面向对象设计的概念。同时简单介绍了基于契约的开发方式,涉及了一些函数式编程概念,也看到了编程语言随着时间的演进。在下一章中,我们将会学习到泛型编程,以及如何实现类型安全的编程。

你可能感兴趣的:(Java进阶3 —— 类和接口设计原则)