八、复用(3)

本章概要

  • final 关键字
    • final 数据
    • 空白 final
    • final 参数
    • final 方法
    • final 和 private
    • final 类
    • final 忠告
  • 类初始化和加载
    • 继承和初始化

final关键字

根据上下文环境,Java 的关键字 final 的含义有些微的不同,但通常它指的是“这是不能被改变的”。防止改变有两个原因:设计或效率。因为这两个原因相差很远,所以有可能误用关键字 final

以下几节讨论了可能使用 final 的三个地方:数据、方法和类。

final 数据

许多编程语言都有某种方法告诉编译器有一块数据是恒定不变的。恒定是有用的,如:

  1. 一个永不改变的编译时常量。
  2. 一个在运行时初始化就不会改变的值。

对于编译时常量这种情况,编译器可以把常量带入计算中;也就是说,可以在编译时计算,减少了一些运行时的负担。在 Java 中,这类常量必须是基本类型,而且用关键字 final 修饰。你必须在定义常量的时候进行赋值。

一个被 staticfinal 同时修饰的属性只会占用一段不能改变的存储空间。

当用 final 修饰对象引用而非基本类型时,其含义会有一点令人困惑。对于基本类型,final 使数值恒定不变,而对于对象引用,final 使引用恒定不变。一旦引用被初始化指向了某个对象,它就不能改为指向其他对象。但是,对象本身是可以修改的,Java 没有提供将任意对象设为常量的方法。(你可以自己编写类达到使对象恒定不变的效果)这一限制同样适用数组,数组也是对象。

下面例子展示了 final 属性的使用:

// reuse/FinalData.java
// 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);
    }
}

输出:

fd1: i4 = 15, INT_5 = 18
Creating new FinalData
fd1: i4 = 15, INT_5 = 18
fd2: i4 = 13, INT_5 = 18

因为 valueOneVALUE_TWO 都是带有编译时值的 final 基本类型,它们都可用作编译时常量,没有多大区别。VALUE_THREE 是一种更加典型的常量定义的方式:public 意味着可以在包外访问,static 强调只有一个,final 说明是一个常量。

按照惯例,带有恒定初始值的 final static 基本变量(即编译时常量)命名全部使用大写,单词之间用下划线分隔。(源于 C 语言中定义常量的方式。)

我们不能因为某数据被 final 修饰就认为在编译时可以知道它的值。由上例中的 i4INT_5 可以看出,它们在运行时才会赋值随机数。示例部分也展示了将 final 值定义为 static 和非 static 的区别。此区别只有当值在运行时被初始化时才会显现,因为编译器对编译时数值一视同仁。(而且编译时数值可能因优化而消失。)当运行程序时就能看到这个区别。注意到 fd1fd2i4 值不同,但 INT_5 的值并没有因为创建了第二个 FinalData 对象而改变,这是因为它是 static 的,在加载时已经被初始化,并不是每次创建新对象时都初始化。

v1VAL_3 变量说明了 final 引用的意义。正如你在 main() 中所见,v2final 的并不意味着你不能修改它的值。因为它是引用,所以只是说明它不能指向一个新的对象。这对于数组具有同样的意义,数组只不过是另一种引用。(我不知道有什么方法能使数组引用本身成为 final。)看起来,声明引用为 final 没有声明基本类型 final 有用。

空白 final

空白 final 指的是没有初始化值的 final 属性。编译器确保空白 final 在使用前必须被初始化。这样既能使一个类的每个对象的 final 属性值不同,也能保持它的不变性。

// reuse/BlankFinal.java
// "Blank" final fields
class Poppet {
    private int i;
    
    Poppet(int ii) {
        i = ii;
    }
}

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

你必须在定义时或在每个构造器中执行 final 变量的赋值操作。这保证了 final 属性在使用前已经被初始化过。

final 参数

在参数列表中,将参数声明为 final 意味着在方法中不能改变参数指向的对象或基本变量:

// reuse/FinalArguments.java
// 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 is 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 基本类型参数的使用情况。你只能读取而不能修改参数。这个特性主要用于传递数据给匿名内部类。这将在”内部类“章节中详解。

final 方法

使用 final 方法的原因有两个。第一个原因是给方法上锁,防止子类通过覆写改变方法的行为。这是出于继承的考虑,确保方法的行为不会因继承而改变。

过去建议使用 final 方法的第二个原因是效率。在早期的 Java 实现中,如果将一个方法指明为 final,就是同意编译器把对该方法的调用转化为内嵌调用。当编译器遇到 final 方法的调用时,就会很小心地跳过普通的插入代码以执行方法的调用机制(将参数压栈,跳至方法代码处执行,然后跳回并清理栈中的参数,最终处理返回值),而用方法体内实际代码的副本替代方法调用。这消除了方法调用的开销。但是如果一个方法很大代码膨胀,你也许就看不到内嵌带来的性能提升,因为内嵌调用带来的性能提高被花费在方法里的时间抵消了。

在最近的 Java 版本中,虚拟机可以探测到这些情况(尤其是 hotspot 技术),并优化去掉这些效率反而降低的内嵌调用方法。有很长一段时间,使用 final 来提高效率都被阻止。你应该让编译器和 JVM 处理性能问题,只有在为了明确禁止覆写方法时才使用 final

final 和 private

类中所有的 private 方法都隐式地指定为 final。因为不能访问 private 方法,所以不能覆写它。可以给 private 方法添加 final 修饰,但是并不能给方法带来额外的含义。

以下情况会令人困惑,当你试图覆写一个 private 方法(隐式是 final 的)时,看上去奏效,而且编译器不会给出错误信息:

// reuse/FinalOverridingIllusion.java
// 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();
    }
}

输出:

OverridingPrivate2.f()
OverridingPrivate2.g()

"覆写"只发生在方法是基类的接口时。也就是说,必须能将一个对象向上转型为基类并调用相同的方法(这一点在下一章阐明)。如果一个方法是 private 的,它就不是基类接口的一部分。它只是隐藏在类内部的代码,且恰好有相同的命名而已。但是如果你在派生类中以相同的命名创建了 publicprotected 或包访问权限的方法,这些方法与基类中的方法没有联系,你没有覆写方法,只是在创建新的方法而已。由于 private 方法无法触及且能有效隐藏,除了把它看作类中的一部分,其他任何事物都不需要考虑到它。

final 类

当说一个类是 finalfinal 关键字在类定义之前),就意味着它不能被继承。之所以这么做,是因为类的设计就是永远不需要改动,或者是出于安全考虑不希望它有子类。

// reuse/Jurassic.java
// 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 修饰符,但不会增加任何意义。

final 忠告

在设计类时将一个方法指明为 final 看上去是明智的。你可能会觉得没人会覆写那个方法。有时这是对的。

但请留意你的假设。通常来说,预见一个类如何被复用是很困难的,特别是通用类。如果将一个方法指定为 final,可能会防止其他程序员的项目中通过继承来复用你的类,而这仅仅是因为你没有想到它被以那种方式使用。

Java 标准类库就是一个很好的例子。尤其是 Java 1.0/1.1 的 Vector 类被广泛地使用,然而它的所有方法出于"效率"考虑(然而并没有提升效率,只是幻觉)全被指定为 final ,如果不指定 final 的话,可能会更加有用。很容易想到,你可能会继承并覆写这么一个基础类,但是设计者们认为这么做不合适。有两个讽刺的原因。第一,Stack 继承自 Vector,就是说 Stack 是个 Vector,但从逻辑上来说不对。尽管如此,Java 设计者们仍然这么做,在用这种方式创建 Stack 时,他们应该意识到了 final 方法过于约束。

第二,Vector 中的很多重要方法,比如 addElement()elementAt() 方法都是同步的。在“并发编程”一章中会看到同步会导致很大的执行开销,可能会抹煞 final 带来的好处。这加强了程序员永远无法正确猜到优化应该发生在何处的观点。如此笨拙的设计却出现在每个人都要使用的标准库中,太糟糕了。庆幸的是,现代 Java 容器用 ArrayList 代替了 Vector,它的行为要合理得多。不幸的是,仍然有很多新代码使用旧的集合类库,其中就包括 Vector

Java 1.0/1.1 标准类库中另一个重要的类是 Hashtable(后来被 HashMap 取代),它不含任何 final 方法。本书中其他地方也提到,很明显不同的类是由不同的人设计的。Hashtable 就比 Vector 中的方法名简洁得多,这又是一条证据。对于类库的使用者来说,这是一个本不应该如此草率的事情。这种不规则的情况造成用户需要做更多的工作——这是对粗糙的设计和代码的又一讽刺。

类初始化和加载

在许多传统语言中,程序在启动时一次性全部加载。接着初始化,然后程序开始运行。必须仔细控制这些语言的初始化过程,以确保 statics 初始化的顺序不会造成麻烦。在 C++ 中,如果一个 static 期望使用另一个 static,而另一个 static 还没有初始化,就会出现问题。

Java 中不存在这样的问题,因为它采用了一种不同的方式加载。因为 Java 中万物皆对象,所以加载活动就容易得多。记住每个类的编译代码都存在于它自己独立的文件中。该文件只有在使用程序代码时才会被加载。一般可以说“类的代码在首次使用时加载“。这通常是指创建类的第一个对象,或者是访问了类的 static 属性或方法。构造器也是一个 static 方法尽管它的 static 关键字是隐式的。因此,准确地说,一个类当它任意一个 static 成员被访问时,就会被加载。

首次使用时就是 static 初始化发生时。所有的 static 对象和 static 代码块在加载时按照文本的顺序(在类中定义的顺序)依次初始化。static 变量只被初始化一次。

继承和初始化

了解包括继承在内的整个初始化过程是有帮助的,这样可以对所发生的一切有全局性的把握。考虑下面的例子:

// reuse/Beetle.java
// The full process of initialization
class Insect {
    private int i = 9;
    protected int j;
    
    Insect() {
        System.out.println("i = " + i + ", j = " + j);
        j = 39;
    }
    
    private static int x1 = printInit("static Insect.x1 initialized");
    
    static int printInit(String s) {
        System.out.println(s);
        return 47;
    }
}

public class Beetle extends Insect {
    private int k = printInit("Beetle.k.initialized");
    
    public Beetle() {
        System.out.println("k = " + k);
        System.out.println("j = " + j);
    }
    
    private static int x2 = printInit("static Beetle.x2 initialized");
    
    public static void main(String[] args) {
        System.out.println("Beetle constructor");
        Beetle b = new Beetle();
    }
}

输出:

static Insect.x1 initialized
static Beetle.x2 initialized
Beetle constructor
i = 9, j = 0
Beetle.k initialized
k = 47
j = 39

当执行 java Beetle,首先会试图访问 Beetle 类的 main() 方法(一个静态方法),加载器启动并找出 Beetle 类的编译代码(在名为 Beetle.class 的文件中)。在加载过程中,编译器注意到有一个基类,于是继续加载基类。不论是否创建了基类的对象,基类都会被加载。(可以尝试把创建基类对象的代码注释掉证明这点。)

如果基类还存在自身的基类,那么第二个基类也将被加载,以此类推。接下来,根基类(例子中根基类是 Insect)的 static 的初始化开始执行,接着是派生类,以此类推。这点很重要,因为派生类中 static 的初始化可能依赖基类成员是否被正确地初始化。

至此,必要的类都加载完毕,对象可以被创建了。首先,对象中的所有基本类型变量都被置为默认值,对象引用被设为 null —— 这是通过将对象内存设为二进制零值一举生成的。接着会调用基类的构造器。本例中是自动调用的,但是你也可以使用 super 调用指定的基类构造器(在 Beetle 构造器中的第一步操作)。基类构造器和派生类构造器一样以相同的顺序经历相同的过程。当基类构造器完成后,实例变量按文本顺序初始化。最终,构造器的剩余部分被执行。

你可能感兴趣的:(#,On,Java,基础卷,final,关键字,类初始化和加载)