《On Java 中文版 基础卷》阅读笔记 复用 14

本文深入解析Java面向对象设计中组合与继承的选择依据,强调向上转型是判断是否使用继承的关键标准;系统阐述protected访问控制的实用主义定位;详细说明final关键字在数据、方法和类三个层面的语义与使用规范,包括空白final、final参数、final方法与private的关系、final类的设计约束,并警示滥用final对可扩展性的危害。

《On Java 中文版 基础卷》阅读笔记 复用 14

8.5 选择组合还是继承

**组合和继承都会将子对象放置在新类中(组合是显式执行此操作,而继承是隐式执行)**​。
你可能想知道两者之间的区别,以及如何在两者之间做出选择。

当希望在新类中使用现有类的功能而不是其接口时,应该使用组合。
也就是说,在新类中嵌入一个对象(通常是 private)来实现自己的特性。
新类的用户看到的是新类定义的接口,而不是嵌入对象的接口。

对于新类里通过组合得到的成员,有时候允许类的使用者直接访问它们是合理的。
为此,可以将成员对象设为 public(你可以将其视为一种 “半委托”​)​。
成员对象隐藏自己的实现,所以这种做法是安全的。
当用户了解到你正在组装一堆组件时,会更容易理解你的接口。
car对象就是一个很好的例子:

package reuse;
// reuse/Car.java
// (c)2021 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.
// 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);
  }
}

汽车的组合是问题分析的一部分(而不是底层设计的一部分)​。
将成员设为 public 有助于客户程序员理解如何使用该类,并为该类的创建者减少了代码复杂性。
但是要记住,这是一种特殊情况。通常来说应该将字段设为 private。

当使用继承时,你通过现有的类来生成它的一个特殊版本。
这通常意味着对通用类进行定制,使它可以用于特定需求。
稍加思考你就会发现,用一个 “交通工具” 对象来组合成一部 “汽车” 是毫无意义的——汽车并不包含交通工具,它是一种交通工具。
继承是用来表示 “is-a” 关系的,而组合是用来表示 “has-a” 关系的。

8.6 protected 关键字

现在你已经了解了继承,关键字 protected 就变得有意义了。
理想情况下,private 关键字就足够了。
但在实际项目中,有时尽管会对外部世界隐藏一些东西,但还是希望子类的成员能访问它们。

protected 关键字是对实用主义的一个认可。
它像是在说:​“就类的用户而言,这是 private 的,但对继承该类的任何类或同一包中的其他类来说,它是可用的。​”​
(protected 还提供包访问权限。​)

尽管可以创建 protected 字段,但最好的方法是将字段设置为 private,并始终保留更改底层实现的权利。
然后,你可以通过 protected 方法来控制类继承者的访问权限:

package reuse;
// reuse/Orc.java
// (c)2021 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.
// The protected keyword

class Villain {
  private String name;
  protected void set(String nm) { name = nm; }
  Villain(String name) { this.name = name; }
  @Override public String toString() {
    return "I'm a Villain and my name is " + name;
  }
}

public class Orc extends Villain {
  private int orcNumber;
  public Orc(String name, int orcNumber) {
    super(name);
    this.orcNumber = orcNumber;
  }
  public void change(String name, int orcNumber) {
    set(name); // Available because it's protected
    this.orcNumber = orcNumber;
  }
  @Override public String toString() {
    return "Orc " + orcNumber + ": " + super.toString();
  }
  public static void main(String[] args) {
    Orc orc = new Orc("Limburger", 12);
    System.out.println(orc);
    orc.change("Bob", 19);
    System.out.println(orc);
  }
}
/* Output:
Orc 12: I'm a Villain and my name is Limburger
Orc 19: I'm a Villain and my name is Bob
*/

change() 方法可以访问 set() 方法,因为它是 protected 的。注意 Orc 的 toString() 方法是根据 toString() 的基类版本定义的。
在这里插入图片描述

8.7 向上转型

继承最重要的方面不是它为新类提供了方法,而是它可以表达新类和基类之间的关系。这种关系可以概括为 “新类是现有类的一种类型”​。

这种描述并非只是一种解释继承的新奇方式——它直接由语言支持。
例如,考虑一个名为 Instrument 的基类——它代表乐器,以及一个名为 Wind 的子类。
因为继承保证了基类中的所有方法在子类中也可用,所以发送给基类的任何消息也都可以发送给子类。
如果 Instrument 类有一个 play() 方法,那么 Wind 乐器也会有。
这意味着你可以说 Wind 对象也是一种 Instrument,这没有任何问题。
以下示例显示了编译器如何支持此概念:

package reuse;
// reuse/Wind.java
// (c)2021 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 & upcasting

class Instrument {
  public void play() {}
  static void tune(Instrument i) {
    // ...
    i.play();
  }
}

// Wind objects are instruments
// because they have the same interface:
public class Wind extends Instrument {
  public static void main(String[] args) {
    Wind flute = new Wind();
    Instrument.tune(flute); // Upcasting
  }
}

在这里插入图片描述
tune() 方法接受一个 Instrument 引用。
然而,在 Wind.main() 中,tune() 方法被传递了一个 Wind 引用。
鉴于 Java 对类型检查很讲究,这里接受一种类型的方法竟然可以很容易地接受另一种类型,这似乎很奇怪,
直到你意识到 Wind 对象也是一个 Instrument 对象,而且 tune() 在 Instrument 里调用的任何方法,都一定会存在于 Wind 中。
tune() 中的代码对 Instrument 和 Instrument 的任何子类都起作用,
这种将 Wind 引用转换为 Instrument 引用的行为称为向上转型 (upcasting)。

该术语是基于类继承图的传统绘制方式而来的:根类在页面顶部,向下绘制(当然,也可以用任何你觉得有用的方式来绘图)​。
Wind.java 的继承图如图 8-1 所示。
图 8-1
在这里插入图片描述
你还可以执行向上转型的反向操作,即向下转型 (downcasting),但这涉及一个难题,我们会在下一章和第 19 章中进一步解释。

再论组合与继承

在面向对象编程中,创建和使用代码最有可能的方式是,将数据和方法一起打包到一个类中,然后使用该类的对象。
你也会通过组合使用现有类来构建新类。
你还可能会使用继承,不过尽管在教授面向对象编程时继承得到了很多重视,但这并不意味着你要尽可能使用它。
相反,只有当继承能明显发挥作用时,才应该谨慎地使用它。
确定是使用组合还是继承的最清晰方法之一,就是询问是否需要从新类向上转型到基类。
如果必须向上转型,则继承是必要的,但如果不是,那就要再仔细想想自己是否的确需要继承。
下一章提供了使用向上转型的最令人信服的理由之一,
但如果记得问一下 “我需要向上转型吗”​,你会有一个很好的工具,来帮你在组合和继承之间做出选择。

8.8 final 关键字

Java 的 final 关键字在不同的上下文环境里含义可能会略有不同,但一般来说,它表示 “这是无法更改的”​。
阻止更改可能出于两个原因:设计或效率。
由于这两个原因很不一样,因此 final 关键字可能会被误用。

本节余下部分讨论了三个可以使用 final 的地方:数据、方法和类。

8.8.1 final 数据

许多编程语言有某种方法来告诉编译器某块数据是 “恒定的”​。常量之所以有用,有两个原因:

1.它可以是一个永远不会改变的编译时常量;

2.它可以是在运行时初始化的值,而你不希望它被更改。

对编译时常量来说,编译器可以将常量值 “折叠” 到计算中;也就是说,计算可以在编译时进行,这节省了一些运行时开销。
在 Java 里,这些常量必须是基本类型,并用 final 关键字表示。在定义常量时必须提供一个值。

一个既是 static 又是 final 的字段只会分配一块不能改变的存储空间。
在这里插入图片描述
在这里插入图片描述

当 final 关键字与对象引用而非基本类型一起使用时,其含义可能会令人困惑。
对于基本类型,final 使其值恒定不变,但对于对象引用,final 使引用恒定不变。
一旦引用被初始化为一个对象,它就永远不能被更改为指向另一个对象了。
但是,对象本身是可以修改的。
Java 没有提供使对象恒定不变的方法。​(但是,你可以编写类,使对象具有恒定不变的效果。​)这个限制同样适用于数组,它们也是对象。

下面是一个演示 final 字段的示例:

package reuse;
// reuse/FinalData.java
// (c)2021 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.
// The effect of final on fields
import java.util.*;

class Value {
  int i; // Package access
  Value(int i) { this.i = i; }
}

public class FinalData {
  private static Random rand = new Random(47);
  private String id;
  public FinalData(String id) { this.id = id; }
  // Can be compile-time constants:
  private final int valueOne = 9;
  private static final int VALUE_TWO = 99;
  // Typical public constant:
  public static final int VALUE_THREE = 39;
  // Cannot be compile-time constants:
  private final int i4 = rand.nextInt(20);
  static final int INT_5 = rand.nextInt(20);
  private Value v1 = new Value(11);
  private final Value v2 = new Value(22);
  private static final Value VAL_3 = new Value(33);
  // Arrays:
  private final int[] a = { 1, 2, 3, 4, 5, 6 };
  @Override public String toString() {
    return id + ": " + "i4 = " + i4
      + ", INT_5 = " + INT_5;
  }
  public static void main(String[] args) {
    FinalData fd1 = new FinalData("fd1");
    //- fd1.valueOne++; // Error: can't change value
    fd1.v2.i++; // Object isn't constant!
    fd1.v1 = new Value(9); // OK -- not final
    for(int i = 0; i < fd1.a.length; i++)
      fd1.a[i]++; // Object isn't constant!
    //- fd1.v2 = new Value(0); // Error: Can't
    //- fd1.VAL_3 = new Value(1); // change reference
    //- fd1.a = new int[3];
    System.out.println(fd1);
    System.out.println("Creating new FinalData");
    FinalData fd2 = new FinalData("fd2");
    System.out.println(fd1);
    System.out.println(fd2);
  }
}
/* Output:
fd1: i4 = 15, INT_5 = 18
Creating new FinalData
fd1: i4 = 15, INT_5 = 18
fd2: i4 = 13, INT_5 = 18
*/

valueOne 和 VALUE_TWO 是具有编译时值的 final 基本类型,因此它们都可以用作编译时常量,并且没有什么重大区别。
VALUE_THREE 是更典型的定义常量的方式:public 表示它可以在包外使用,static 强调只有一个,final 表示它是一个常量。

按照惯例,具有常量初始值的 final static 基本类型(即编译时常量)全部使用大写字母命名,单词之间用下划线分隔(就像 C 常量一样,它也是该命名风格的起源地)​。

即使某字段是 final 的,也并不意味着它的值在编译时就是已知的。
这可以通过 i4 和 INT_5 来证明,它们通过在运行时使用随机生成的数值来初始化。
示例的这一部分还显示了将 final 值设为静态或非静态之间的区别。
只有当这些值的初始化操作发生在运行时,这种差异才会出现,
这是因为编译器对编译时值的处理方式相同(因此编译时的初始化差异可能会被优化掉)​。
当你运行程序时,差异就显示出来了。注意,fd1 和 fd2 的 i4 值是不同的,
但创建第二个 FinalData 对象并没有改变 INT_5 的值。这是因为它是静态的,只会在加载时初始化一次,而不是每次创建新对象时都初始化。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
变量 v1~VAL_3 展示了final引用的含义。
正如在 main() 中看到的那样,v2 是 final 的并不意味着不能改变它的值。这是因为它是一个引用,
final 只是意味着你不能将 v2 重新绑定到一个新对象上。
同样的含义也适用于数组,它只不过是另一种引用(我不知道有什么方法可以让数组引用自身成为 final)​。
在这里插入图片描述
把引用设为 final 看起来好像没有把基本类型设为 final 有用。

1.空白 final

空白 final 是没有初始值的 final 字段。
编译器会确保在使用前初始化这个空白 final 字段。
这样类中的 final 字段就可以对每个对象来说都不同,同时还保持了其不可变的特性:

package reuse;
// reuse/BlankFinal.java
// (c)2021 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.
// "Blank" final fields

class Poppet {
  private int i;
  Poppet(int ii) { i = ii; }
}

public class BlankFinal {
  private final int i = 0; // Initialized final 初始化了的 final
  private final int j; // Blank final 空白 final
  private final Poppet p; // Blank final reference 空白 final 引用
  // Blank finals MUST be initialized in constructor:
  public BlankFinal() {
    j = 1; // Initialize blank final 初始化空白 final
    p = new Poppet(1); // Init blank final reference 初始化空白 final 引用
  }
  public BlankFinal(int x) {
    j = x; // Initialize blank final 初始化 final
    p = new Poppet(x); // Init blank final reference 初始化空白 final 引用
  }
  public static void main(String[] args) {
    new BlankFinal();
    new BlankFinal(47);
  }
}

对 final 执行赋值的操作只能发生在两个地方:要么在字段定义处使用表达式进行赋值,要么在每个构造器中。
这保证了 final 字段在使用前总是被初始化。

2.final 参数

可以通过在参数列表中进行声明来创建 final 参数。这意味着在方法内部不能更改参数引用指向的内容:

package reuse;
// reuse/FinalArguments.java
// (c)2021 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.
// Using "final" with method arguments

class Gizmo {
  public void spin() {}
}

public class FinalArguments {
  void with(final Gizmo g) {
    //- g = new Gizmo(); // Illegal -- g is final
  }
  void without(Gizmo g) {
    g = new Gizmo(); // OK -- g not final
    g.spin();
  }
  // void f(final int i) { i++; } // Can't change
  // You can only read from a final primitive:
  int g(final int i) { return i + 1; }
  public static void main(String[] args) {
    FinalArguments bf = new FinalArguments();
    bf.without(null);
    bf.with(null);
  }
}

f() 和 g() 方法展示了当基本类型参数为 final 时会发生什么:可以读取这个参数,但不能修改它。
此功能主要用于将数据传递给匿名内部类,你将在第 11 章中学习它。
在这里插入图片描述

8.8.2 final 方法

使用 final 方法的原因有两个。
第一个原因是在方法上放置一个 “锁”​,这样就可以防止继承类通过重写来改变该方法的含义。这样做一般是出于设计原因,比如你想要确保在继承期间使方法的行为保持不变。

过去建议使用 final 方法的第二个原因是效率。
在 Java 的早期实现中,如果创建了一个 final 方法,编译器可以将任何对该方法的调用转换为内联调用 (inline call)。
当编译器看到 final 方法调用时,它可以(自行决定)跳过正常的方法调用方式,通过复制方法体中实际代码的副本来代替方法调用。
而正常的方法调用方式则是插入代码来执行方法调用的机制(将参数压入栈,跳到方法代码处并执行,然后跳回并清除栈上的参数,最后处理返回值)​。
这节省了方法调用的开销。当然,如果一个方法很大,这种内联方式就会让你的代码开始膨胀,你可能不会看到内联带来的性能提升,
因为调用和返回中的任何速度提升,都可能被在方法内花费的时间所抵消。

在相对较早的时候,JVM(特别是 hotspot 相关技术)就开始检测这些情况,并会优化掉额外的间接访问。
长期以来,Java 都不鼓励使用 final 来进行优化。你应该让编译器和 JVM 来处理效率问题,
只有在想明确防止重写的情况下才创建一个 final 方法。

final 和 private

类中的任何 private 方法都是隐式的 final。这是因为你不能访问一个 private 方法,也不能重写它。
我们可以将 final 修饰符添加到 private 方法中,但它不会赋予该方法任何额外的意义。

这可能会令人困惑,因为如果尝试重写 private 方法(隐含是 final 的)​,似乎是可以的,并且编译器也不会给出错误消息。

package reuse;
// reuse/FinalOverridingIllusion.java
// (c)2021 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.
// It only looks like you can override
// a private or private final method

class WithFinals {
  // Identical to "private" alone:
  private final void f() {
    System.out.println("WithFinals.f()");
  }
  // Also automatically "final":
  private void g() {
    System.out.println("WithFinals.g()");
  }
}

class OverridingPrivate extends WithFinals {
  private final void f() {
    System.out.println("OverridingPrivate.f()");
  }
  private void g() {
    System.out.println("OverridingPrivate.g()");
  }
}

class OverridingPrivate2 extends OverridingPrivate {
  public final void f() {
    System.out.println("OverridingPrivate2.f()");
  }
  public void g() {
    System.out.println("OverridingPrivate2.g()");
  }
}

public class FinalOverridingIllusion {
  public static void main(String[] args) {
    OverridingPrivate2 op2 = new OverridingPrivate2();
    op2.f();
    op2.g();
    // You can upcast:
    OverridingPrivate op = op2;
    // But you can't call the methods:
    //- op.f();
    //- op.g();
    // Same here:
    WithFinals wf = op2;
    //- wf.f();
    //- wf.g();
  }
}
/* Output:
OverridingPrivate2.f()
OverridingPrivate2.g()
*/

“重写” 只有在方法是基类接口的一部分时才会发生。
也就是说,必须能将一个对象向上转型为其基类类型并能调用与其相同的方法(下一章中你会更理解这一点)​。
如果一个方法是 private 的,它就不是基类接口的一部分。它只是隐藏在类中的代码,只不过恰好具有相同的名称而已。
即使在子类中创建了具有相同名称的 public、protected 或包访问权限的方法,它与基类中这个相同名称的方法也没有任何联系。
你并没有重写该方法,只不过是创建了一个新的方法。
private 方法是不可访问的,并且可以有效地隐藏自己,因此除了定义它的类的代码组织之外,它不会影响任何事情。

请注意,在上面的示例中使用 @Override 可以产生能指出这个错误的有用消息。
@Override 明确告诉编译器你想重写这个方法。
在这里插入图片描述

8.8.3 final 类

将整个类定义为 final 时(通过在其定义前加上 final 关键字)​,就阻止了该类的所有继承。
这样做是因为,出于某种原因,你希望自己对这个类的设计永远不要被修改;或者出于安全考虑,你不希望它有子类。

package reuse;
// reuse/Jurassic.java
// (c)2021 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.
// Making an entire class final

class SmallBrain {}

final class Dinosaur {
  int i = 7;
  int j = 1;
  SmallBrain x = new SmallBrain();
  void f() {}
}

//- class Further extends Dinosaur {}
// error: Cannot extend final class 'Dinosaur'

public class Jurassic {
  public static void main(String[] args) {
    Dinosaur n = new Dinosaur();
    n.f();
    n.i = 40;
    n.j++;
  }
}

final 类的字段可以是 final,也可以不是,根据个人选择而定。
无论类是否定义为 final,相同的规则都适用于字段的 final 定义。
然而,由于 final 类禁止继承,它的所有方法都是隐式 final 的,因为无法重写它们。
你可以在 final 类的方法中包含 final 修饰符,但它不会添加任何意义。

8.8.4 关于 final 的忠告

在设计类时将方法设为 final 似乎是明智的。你可能会觉得没有人想要重写这个方法。有时的确是这样的。

但要当心你所做的假设。一般来说,很难预测一个类是如何被复用的,尤其对通用类来说更是如此。
如果你将一个方法定义为 final,则可能会阻止其他程序员的项目通过继承来复用你的类,而这只是因为你无法想象它会被那样使用。

Java 的标准库就是一个很好的例子。特别值得一提的是 Java 1.0/1.1 中很常用的 Vector 类。
如果不是因为以效率的名义(这几乎肯定是一种错觉)​,将所有方法都设为 final,那么它可能会更有用。
很容易就能想到,人们可能会继承和重写这样一个有用的基础类。
但 Java 的设计者不知何故,认为这不合适。
这挺有讽刺意味的,原因有二。
首先 Stack 继承了 Vector,它表示 Stack 是一个 Vector,从逻辑上来说这就不太对。
不管怎么说,连 Java 的设计者自己都有一个需要继承 Vector 的场景。
当他们以这种方式创建 Stack 时,就应该意识到将 Vector 里的方法设为 final 过于严格了。

其次,Vector 的许多最重要的方法,例如 addElement() 和 elementAt(),都是同步的。进阶卷第 5 章表明,这会带来显著的性能开销,可能会抹去 final 带来的任何收益。
程序员总是无法正确地猜测优化应该发生在哪里,而此例更强化了这个观点。
这么笨拙的设计,居然进了标准库,使大家都不得不想办法来应付,实在是太可惜了。
幸运的是,现代的 Java 集合库用 ArrayList 取代了 Vector,它的行为更加合理。
不幸的是,仍然有新代码使用旧的集合库,包括 Vector。

Hashtable 是另一个重要的 Java 1.0/1.1 标准库类(后来被 HashMap 取代)​,它没有任何 final 方法。
正如本书其他地方提到的,很明显不同的类是由不同的人设计的。
这里的又一个证据就是,和 Vector 中的方法名称相比,Hashtable 中的方法名称要简洁得多。
这正是那种不应该让库使用者明显注意到的事情。
当事情不一致时,它只会让用户做更多的工作——这是对设计和代码演练价值的又一赞歌。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值