对于像 C 语言等面向过程语言来说,“复用”通常指的就是“复制代码”。任何语言都可通过简单复制来达到代码复用的目的,但是这样做的效果并不好。Java 围绕“类”(Class)来解决问题。我们可以直接使用别人构建或调试过的代码,而非创建新类、重新开始。

如何在不污染源代码的前提下使用现存代码是需要技巧的。在本章里,将介绍两种方式来达到这个目的:

  1. 第一种方式直接了当。在新类中创建现有类的对象。这种方式叫做“组合”(Composition),通过这种方式复用代码的功能,而非其形式。
  2. 第二种方式叫做“继承”(Inheritance),编译器会做大部分的工作。继承是面向对象编程(OOP)的重要基础之一。更多功能相关将在多态(Polymorphism)章节中介绍。

一、组合的语法

把对象的引用(object references)放置在一个新的类里就是组合。例如,假设你需要一个对象,其中内置了几个 String 对象,两个基本类型(primitives)的属性字段,一个其他类的对象。对于非基本类型对象,将引用直接放置在新类中,对于基本类型属性字段则仅进行声明。

class WaterSource {
  private String s;
  WaterSource() {
    System.out.println("WaterSource()");
    s = "Constructed";
  }
  @Override
  public String toString() { return s; }
}

public class SprinklerSystem {
  private String valve1, valve2, valve3, valve4;
  private WaterSource source = new WaterSource();
  private int i;
  private float f;
  @Override
  public String toString() {
    return
      "valve1 = " + valve1 + " " +
      "valve2 = " + valve2 + " " +
      "valve3 = " + valve3 + " " +
      "valve4 = " + valve4 + "\n" +
      "i = " + i + " " + "f = " + f + " " +
      "source = " + source; // [1]
  }
  public static void main(String[] args) {
    SprinklerSystem sprinklers = new SprinklerSystem();
    System.out.println(sprinklers);
  }
}
/* Output:
WaterSource()
valve1 = null valve2 = null valve3 = null valve4 = null
i = 0 f = 0.0 source = Constructed
*/

这两个类中定义的一个方法是特殊的: toString()。每个非基本类型对象都有一个 toString() 方法,在编译器需要字符串但它有对象的特殊情况下调用该方法。因此,在 [1] 中,编译器看到你试图“添加”一个 WaterSource 类型的字符串对象 。因为字符串只能拼接另一个字符串,所以它就先会调用 toString()source 转换成一个字符串。然后,它可以拼接这两个字符串并将结果字符串传递给 System.out.println()。要对创建的任何类允许这种行为,只需要编写一个 toString() 方法。在 toString() 上使用 @Override 注解来告诉编译器,以确保正确地覆盖。@Override 是可选的,但它有助于验证你没有拼写错误 (或者更微妙地说,大小写字母输入错误)。类中的基本类型字段自动初始化为零,但是对象引用被初始化为 null,如果你尝试调用其任何一个方法,你将得到一个异常(一个运行时错误)。方便的是,打印 null 引用却不会得到异常。

编译器不会为每个引用创建一个默认对象,这是有意义的,因为在许多情况下,这会导致不必要的开销。初始化引用有四种方法:

  1. 当对象被定义时。这意味着它们总是在调用构造函数之前初始化。
  2. 在该类的构造函数中。
  3. 在实际使用对象之前。这通常称为延迟初始化。在对象创建开销大且不需要每次都创建对象的情况下,它可以减少开销。
  4. 使用实例初始化。

上述四种初始化引用的例子如下所示:

// 定义对象时初始化
public class MyClass {
    private MyObject myObject = new MyObject(); 
}

// 在构造函数中初始化
public class MyClass {
    private MyObject myObject;

    public MyClass() {
        myObject = new MyObject(); 
    }
}

public class MyClass {
    private MyObject myObject;

    public void doSomething() {
        if (myObject == null) {
            myObject = new MyObject(); // 在实际使用对象之前初始化
        }
        // 使用myObject进行操作
    }
}

public class MyClass {
    private MyObject myObject;

    {
        myObject = new MyObject(); // 实例初始化块中初始化
    }
}

实例初始化块

实例初始化块(Instance Initializer Block)是Java中用于初始化实例变量的一种特殊块。

在Java类中,除了构造函数外,你还可以使用实例初始化块来初始化实例变量。实例初始化块是包含在类中的一段代码块,没有任何参数或返回值,并用花括号括起来。

当创建类的新实例时,实例初始化块会在构造函数执行之前被执行。它在每次创建实例时都会执行,不管使用哪个构造函数。实例初始化块对于在多个构造函数中共享相同的初始化逻辑非常有用。

下面是一个示例,展示了如何使用实例初始化块来初始化实例变量:

public class MyClass {
    private int myVariable;

    {
        // 实例初始化块
        myVariable = 10; // 初始化实例变量
        // 可以在这里执行其他初始化操作
    }

    public MyClass() {
        // 构造函数
        // 可以在这里执行其他初始化操作
    }
}

在上述示例中,实例初始化块使用myVariable = 10语句来初始化实例变量myVariable。每次创建MyClass的新实例时,实例初始化块都会被执行,并将myVariable设置为10

需要注意的是,实例初始化块在类加载时会被执行,并且按照在类中出现的顺序执行。如果一个类有多个实例初始化块,它们将按照出现的顺序依次执行。

实例初始化块对于在构造函数之前执行一些共同的初始化逻辑非常有用,并且可以提高代码的可维护性和复用性。

实例变量

实例变量(Instance Variables)是定义在类中的变量,也称为成员变量(Member Variables)。它们属于类的实例(对象)而不是类本身,每个类的实例都会拥有自己的一组实例变量。

与局部变量(Local Variables)相比,实例变量具有以下特点:

  • 属于对象:实例变量与类的每个对象相关联,每个对象都有自己的一组实例变量,它们存储对象的状态和特征。
  • 默认初始化:如果没有显式地对实例变量进行初始化,它们会被自动初始化为默认值。数值类型默认为0,布尔类型默认为false,对象引用默认为null。
  • 可以被多个方法访问:实例变量可以被类中的各个方法访问和修改,它们在整个对象的生命周期内都是可见的。
  • 存储在堆内存中:实例变量存储在对象的堆内存中,每个对象都有自己的实例变量的副本。

二、继承的语法

继承是所有面向对象语言的一个组成部分。其实在创建类时继承是必然发生的,要么显式地继承其他类,要么隐式地继承 Java 的标准根类对象(Object)。

组合的语法很明显,但是继承使用了一种特殊的语法。当我们使用继承时,需要使用关键字 extends 后跟基类的名称。此时我们可以自动获得基类中的所有字段和方法。这里有一个例子:

class Cleanser {
  	private String s = "Cleanser";
  	public void append(String a) { s += a; }
  	public void dilute() { append(" dilute()"); }
  	public void apply() { append(" apply()"); }
  	public void scrub() { append(" scrub()"); }
  	@Override
  	public String toString() { return s; }
  	public static void main(String[] args) {
    	Cleanser x = new Cleanser();
    	x.dilute(); x.apply(); x.scrub();
    	System.out.println(x);
  	}
}

public class Detergent extends Cleanser {
  	// Change a method:
  	@Override
  	public void scrub() {
    	append(" Detergent.scrub()");
    	super.scrub(); // Call base-class version
  	}
  	// Add methods to the interface:
  	public void foam() { append(" foam()"); }
  	// Test the new class:
  	public static void main(String[] args) {
    	Detergent x = new Detergent();
    	x.dilute();
    	x.apply();
    	x.scrub();
    	x.foam();
    	System.out.println(x);
    	System.out.println("Testing base class:");
    	Cleanser.main(args);
  	}
}

输出:

Cleanser dilute() apply() Detergent.scrub() scrub() foam()
Testing base class:
Cleanser dilute() apply() scrub()

上面所述的代码演示了一些特性,首先,CleanserDetergent 都包含一个 main() 方法。你可以为每个类创建一个 main() ; 这允许对每个类进行简单的测试。当你完成测试时,不需要删除 main(); 你可以将其留在以后的测试中。即使程序中有很多类都有 main() 方法,惟一运行的只有在命令行上调用的 main()。这里,当你使用 java Detergent 时候,就调用了 Detergent.main()。但是你也可以使用 java Cleanser 来调用 Cleanser.main(),即使 Cleanser 不是一个公共类。即使类只具有包访问权,也可以访问 public main()

在这里,Detergent.main() 显式地调用 Cleanser.main(),从命令行传递相同的参数(当然,你可以传递任何字符串数组)。

Cleanser 中的所有方法都是公开的。请记住,如果不使用任何访问修饰符,则成员默认为包访问权限,这只允许包内成员访问。因此,如果没有访问修饰符,那么包内的任何人都可以使用这些方法。例如,Detergent 就没有问题。但是,如果其他包中的类继承 Cleanser,则该类只能访问 Cleanser 的公共成员。因此,为了允许继承,一般规则是所有字段为私有,所有方法为公共。(注意,protected成员允许派生类访问)

Cleanser 的接口中有一组方法: append()、dilute()、apply()、scrub() 和 toString()。因为 Detergent 是从 Cleanser 派生的(通过 extends 关键字),所以它会在其接口中自动获取所有这些方法,即使你没有在 Detergent 中看到所有这些方法的显式定义,所以可以把继承看作是复用类的一种方式。如在 scrub() 中所见,可以在派生类中使用基类中定义的方法并修改它。在这里,你可以在新类中调用基类的该方法。但是在 scrub() 内部,不能直接调用 scrub(),因为这会产生递归调用。为了解决这个问题,Java的 super 关键字引用了当前类继承的“超类”(基类)。因此表达式 super.scrub() 调用方法 scrub() 的基类版本。

继承时,我们可以在基类的方法的基础上拓展其他方法:只需定义它即可,方法 foam() 就是一个例子。在Detergent.main() 中可以看到,对于 Detergent 对象,我们可以调用 Cleanser 和 Detergent 中可用的所有方法 (如 foam() )。

2.1 初始化基类

从外部看,新类与基类具有相同的接口,可能还有一些额外的方法和字段。但是继承并不只是复制基类的接口。当你创建派生类的对象时,它包含基类的子对象。这个子对象与你自己创建基类的对象是一样的。只是从外部看,基类的子对象被包装在派生类的对象中。

我们必须正确初始化基类子对象,而且只有一种方法可以保证这一点 : 通过调用基类构造函数在构造函数中执行初始化,该构造函数具有执行基类初始化所需的所有适当信息和特权。Java 自动在派生类构造函数中插入对基类构造函数的调用。下面的例子展示了三个层次的继承:

// reuse/Cartoon.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Constructor calls during inheritance

class Art {
  Art() {
    System.out.println("Art constructor");
  }
}

class Drawing extends Art {
  Drawing() {
    System.out.println("Drawing constructor");
  }
}

public class Cartoon extends Drawing {
  public Cartoon() {
    System.out.println("Cartoon constructor");
  }
  public static void main(String[] args) {
    Cartoon x = new Cartoon();
  }
}

输出:

Art constructor
Drawing constructor
Cartoon constructor

构造从基类“向外”进行,因此基类在派生类构造函数能够访问它之前进行初始化。即使不为 Cartoon 创建构造函数,编译器也会为你合成一个无参数构造函数,调用基类构造函数:

// reuse/Cartoon.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Constructor calls during inheritance

class Art {
    Art() {
        System.out.println("Art constructor");
    }
}

class Drawing extends Art {
    Drawing() {
        System.out.println("Drawing constructor");
    }
}

public class Cartoon extends Drawing {
    public static void main(String[] args) {
        Cartoon x = new Cartoon();
    }
}

输出:

Art constructor
Drawing constructor

2.2 带参数的基类构造函数

上面的所有例子中构造函数都是无参数的 ; 编译器很容易调用这些构造函数,因为不需要参数。如果没有无参数的基类构造函数,或者必须调用具有参数的基类构造函数,则必须使用 super 关键字和适当的参数列表显式地编写对基类构造函数的调用:

// reuse/Chess.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Inheritance, constructors and arguments

class Game {
  Game(int i) {
    System.out.println("Game constructor");
  }
}

class BoardGame extends Game {
  BoardGame(int i) {
    super(i);
    System.out.println("BoardGame constructor");
  }
}

public class Chess extends BoardGame {
  Chess() {
    super(11);
    System.out.println("Chess constructor");
  }
  public static void main(String[] args) {
    Chess x = new Chess();
  }
}

输出:

Game constructor
BoardGame constructor
Chess constructor

如果没有在 BoardGame 构造函数中调用基类构造函数,编译器就会报错找不到 Game() 的构造函数。此外,对基类构造函数的调用必须是派生类构造函数中的第一个操作。(如果你写错了,编译器会提醒你。)

三、结合组合与继承

我们将经常同时使用组合和继承。下面的例子展示了使用继承和组合创建类,以及必要的构造函数初始化:

// reuse/PlaceSetting.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Combining composition & inheritance

class Plate {
  Plate(int i) {
    System.out.println("Plate constructor");
  }
}

class DinnerPlate extends Plate {
  DinnerPlate(int i) {
    super(i);
    System.out.println("DinnerPlate constructor");
  }
}

class Utensil {
  Utensil(int i) {
    System.out.println("Utensil constructor");
  }
}

class Spoon extends Utensil {
  Spoon(int i) {
    super(i);
    System.out.println("Spoon constructor");
  }
}

class Fork extends Utensil {
  Fork(int i) {
    super(i);
    System.out.println("Fork constructor");
  }
}

class Knife extends Utensil {
  Knife(int i) {
    super(i);
    System.out.println("Knife constructor");
  }
}

// A cultural way of doing something:
class Custom {
  Custom(int i) {
    System.out.println("Custom constructor");
  }
}

public class PlaceSetting extends Custom {
  private Spoon sp;
  private Fork frk;
  private Knife kn;
  private DinnerPlate pl;
  public PlaceSetting(int i) {
    super(i + 1);
    sp = new Spoon(i + 2);
    frk = new Fork(i + 3);
    kn = new Knife(i + 4);
    pl = new DinnerPlate(i + 5);
    System.out.println("PlaceSetting constructor");
  }
  public static void main(String[] args) {
    PlaceSetting x = new PlaceSetting(9);
  }
}
/* Output:
Custom constructor
Utensil constructor
Spoon constructor
Utensil constructor
Fork constructor
Utensil constructor
Knife constructor
Plate constructor
DinnerPlate constructor
PlaceSetting constructor
*/

尽管编译器强制你初始化基类,并要求你在构造函数的开头就初始化基类,但它并不监视你以确保你初始化了成员对象。注意类是如何干净地分离的。你甚至不需要方法重用代码的源代码。你最多只导入一个包。(这对于继承和组合都是正确的。)

3.1 保持适当的清理

Java 没有 C++ 中析构函数的概念,析构函数是在对象被销毁时自动调用的方法。原因可能是,在Java中,通常是忘掉而不是销毁对象,从而允许垃圾收集器根据需要回收内存。通常这是可以的,但是有时你的类可能在其生命周期中执行一些需要清理的活动。初始化和清理章节提到,你无法知道垃圾收集器何时会被调用,甚至它是否会被调用。因此,如果你想为类清理一些东西,必须显式地编写一个特殊的方法来完成它,并确保客户端程序员知道他们必须调用这个方法。最重要的是——正如在"异常"章节中描述的——你必须通过在 finally子句中放置此类清理来防止异常。

请考虑一个在屏幕上绘制图片的计算机辅助设计系统的例子:

// reuse/CADSystem.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Ensuring proper cleanup
// {java reuse.CADSystem}
package reuse;

class Shape {
  Shape(int i) {
    System.out.println("Shape constructor");
  }
  void dispose() {
    System.out.println("Shape dispose");
  }
}

class Circle extends Shape {
  Circle(int i) {
    super(i);
    System.out.println("Drawing Circle");
  }
  @Override
  void dispose() {
    System.out.println("Erasing Circle");
    super.dispose();
  }
}

class Triangle extends Shape {
  Triangle(int i) {
    super(i);
    System.out.println("Drawing Triangle");
  }
  @Override
  void dispose() {
    System.out.println("Erasing Triangle");
    super.dispose();
  }
}

class Line extends Shape {
  private int start, end;
  Line(int start, int end) {
    super(start);
    this.start = start;
    this.end = end;
    System.out.println(
      "Drawing Line: " + start + ", " + end);
  }
  @Override
  void dispose() {
    System.out.println(
      "Erasing Line: " + start + ", " + end);
    super.dispose();
  }
}

public class CADSystem extends Shape {
  private Circle c;
  private Triangle t;
  private Line[] lines = new Line[3];
  public CADSystem(int i) {
    super(i + 1);
    for(int j = 0; j < lines.length; j++)
      lines[j] = new Line(j, j*j);
    c = new Circle(1);
    t = new Triangle(1);
    System.out.println("Combined constructor");
  }
  @Override
  public void dispose() {
    System.out.println("CADSystem.dispose()");
    // The order of cleanup is the reverse
    // of the order of initialization:
    t.dispose();
    c.dispose();
    for(int i = lines.length - 1; i >= 0; i--)
      lines[i].dispose();
    super.dispose();
  }
  public static void main(String[] args) {
    CADSystem x = new CADSystem(47);
    try {
      // Code and exception handling...
    } finally {
      x.dispose();
    }
  }
}
/* Output:
Shape constructor
Shape constructor
Drawing Line: 0, 0
Shape constructor
Drawing Line: 1, 1
Shape constructor
Drawing Line: 2, 4
Shape constructor
Drawing Circle
Shape constructor
Drawing Triangle
Combined constructor
CADSystem.dispose()
Erasing Triangle
Shape dispose
Erasing Circle
Shape dispose
Erasing Line: 2, 4
Shape dispose
Erasing Line: 1, 1
Shape dispose
Erasing Line: 0, 0
Shape dispose
Shape dispose
*/

这个系统中的所有东西都是某种 Shape (它本身是一种 Object,因为它是从根类隐式继承的) 。除了使用 super 调用该方法的基类版本外,每个类还覆盖 dispose() 方法。特定的 Shape 类——Circle、Triangle 和 Line,都有 “draw” 构造函数,尽管在对象的生命周期中调用的任何方法都可以负责做一些需要清理的事情。每个类都有自己的 dispose() 方法来将非内存的内容恢复到对象存在之前的状态。

在 main() 中,有两个关键字是你以前没有见过的,在"异常"一章之前不会详细解释: try 和 finally。try 关键字表示后面的块 (用花括号分隔 )是一个受保护的区域,这意味着它得到了特殊处理。其中一个特殊处理是,无论 try 块如何退出,在这个保护区域之后的 finally 子句中的代码总是被执行。(通过异常处理,可以用许多不同寻常的方式留下 try 块。)这里,finally 子句的意思是,“无论发生什么,始终调用 x.dispose()。”

在清理方法 (在本例中是 dispose() ) 中,还必须注意基类和成员对象清理方法的调用顺序,以防一个子对象依赖于另一个子对象。首先,按与创建的相反顺序执行特定于类的所有清理工作。(一般来说,这要求基类元素仍然是可访问的。) 然后调用基类清理方法,如这所示。

在很多情况下,清理问题不是问题;你只需要让垃圾收集器来完成这项工作。但是,当你必须执行显式清理时,就需要多做努力,更加细心,因为在垃圾收集方面没有什么可以依赖的。可能永远不会调用垃圾收集器。如果调用,它可以按照它想要的任何顺序回收对象。除了内存回收外,你不能依赖垃圾收集来做任何事情。如果希望进行清理,可以使用自己的清理方法,不要使用 finalize()。

3.2 名称隐藏

如果 Java 基类的方法名多次重载,则在派生类中重新定义该方法名不会隐藏任何基类版本。不管方法是在这个级别定义的,还是在基类中定义的,重载都会起作用:

// reuse/Hide.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Overloading a base-class method name in a derived
// class does not hide the base-class versions

class Homer {
  char doh(char c) {
    System.out.println("doh(char)");
    return 'd';
  }
  float doh(float f) {
    System.out.println("doh(float)");
    return 1.0f;
  }
}

class Milhouse {}

class Bart extends Homer {
  void doh(Milhouse m) {
    System.out.println("doh(Milhouse)");
  }
}

public class Hide {
  public static void main(String[] args) {
    Bart b = new Bart();
    b.doh(1);
    b.doh('x');
    b.doh(1.0f);
    b.doh(new Milhouse());
  }
}
/* Output:
doh(float)
doh(char)
doh(float)
doh(Milhouse)
*/

Homer 的所有重载方法在 Bart 中都是可用的,尽管 Bart 引入了一种新的重载方法。正如你将在下一章中看到的那样,比起重载,更常见的是覆盖同名方法,使用与基类中完全相同的方法签名^2和返回类型。否则会让人感到困惑。

你已经看到了Java 5 @Override注解,它不是关键字,但是可以像使用关键字一样使用它。当你打算重写一个方法^3时,你可以选择添加这个注解,如果你不小心用了重载而不是重写,编译器会产生一个错误消息:

// reuse/Lisa.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// {WillNotCompile}

class Lisa extends Homer {
  @Override void doh(Milhouse m) {
    System.out.println("doh(Milhouse)");
  }
}

{WillNotCompile} 标记将该文件排除在本书的 Gradle 构建之外,但是如果你手工编译它,你将看到:method does not override a method from its superclass.方法不会重写超类中的方法, @Override 注解能防止你意外地重载。

四、组合与继承的选择

组合和继承都允许在新类中放置子对象(组合是显式的,而继承是隐式的)。你或许想知道这二者之间的区别,以及怎样在二者间做选择。

当你想在新类中包含一个已有类的功能时,使用组合,而非继承。也就是说,在新类中嵌入一个对象(通常是私有的),以实现其功能。新类的使用者看到的是你所定义的新类的接口,而非嵌入对象的接口。

有时让类的用户直接访问到新类中的组合成分是有意义的。只需将成员对象声明为 public 即可(可以把这当作“半委托”的一种)。成员对象隐藏了具体实现,所以这是安全的。当用户知道你正在组装一组部件时,会使得接口更加容易理解。下面的 car 对象是个很好的例子:

// reuse/Car.java
// Composition with public objects
class Engine {
    public void start() {}
    public void rev() {}
    public void stop() {}
}

class Wheel {
    public void inflate(int psi) {}
}

class Window {
    public void rollup() {}
    public void rolldown() {}
}

class Door {
    public Window window = new Window();

    public void open() {}
    public void close() {}
}

public class Car {
    public Engine engine = new Engine();
    public Wheel[] wheel = new Wheel[4];
    public Door left = new Door(), right = new Door(); // 2-door

    public Car() {
        for (int i = 0; i < 4; i++) {
            wheel[i] = new Wheel();
        }
    }

    public static void main(String[] args) {
        Car car = new Car();
        car.left.window.rollup();
        car.wheel[0].inflate(72);
    }
}

因为在这个例子中 car 的组合也是问题分析的一部分(不是底层设计的部分),所以声明成员为 public 有助于客户端程序员理解如何使用类,且降低了类创建者面临的代码复杂度。但是,记住这是一个特例。通常来说,属性还是应该声明为 private。

当使用继承时,使用一个现有类并开发出它的新版本。通常这意味着使用一个通用类,并为了某个特殊需求将其特殊化。稍微思考下,你就会发现,用一个交通工具对象来组成一部车是毫无意义的——车不包含交通工具,它就是交通工具。这种“是一个”的关系是用继承来表达的,而“有一个“的关系则用组合来表达。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐