八、复用(3)

  • Post author:
  • Post category:其他




本章概要

  • final 关键字

    • final 数据
    • 空白 final
    • final 参数
    • final 方法
    • final 和 private
    • final 类
    • final 忠告
  • 类初始化和加载

    • 继承和初始化



final关键字

根据上下文环境,Java 的关键字

final

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

final

以下几节讨论了可能使用

final

的三个地方:数据、方法和类。



final 数据

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

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

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

final

修饰。你必须在定义常量的时候进行赋值。

一个被

static



final

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

当用

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

因为

valueOne



VALUE_TWO

都是带有编译时值的

final

基本类型,它们都可用作编译时常量,没有多大区别。

VALUE_THREE

是一种更加典型的常量定义的方式:

public

意味着可以在包外访问,

static

强调只有一个,

final

说明是一个常量。

按照惯例,带有恒定初始值的

final


static

基本变量(即编译时常量)命名全部使用大写,单词之间用下划线分隔。(源于 C 语言中定义常量的方式。)

我们不能因为某数据被

final

修饰就认为在编译时可以知道它的值。由上例中的

i4



INT_5

可以看出,它们在运行时才会赋值随机数。示例部分也展示了将

final

值定义为

static

和非

static

的区别。此区别只有当值在运行时被初始化时才会显现,因为编译器对编译时数值一视同仁。(而且编译时数值可能因优化而消失。)当运行程序时就能看到这个区别。注意到

fd1



fd2



i4

值不同,但

INT_5

的值并没有因为创建了第二个

FinalData

对象而改变,这是因为它是

static

的,在加载时已经被初始化,并不是每次创建新对象时都初始化。


v1



VAL_3

变量说明了

final

引用的意义。正如你在

main()

中所见,

v2



final

的并不意味着你不能修改它的值。因为它是引用,所以只是说明它不能指向一个新的对象。这对于数组具有同样的意义,数组只不过是另一种引用。(我不知道有什么方法能使数组引用本身成为

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

的,它就不是基类接口的一部分。它只是隐藏在类内部的代码,且恰好有相同的命名而已。但是如果你在派生类中以相同的命名创建了

public



protected

或包访问权限的方法,这些方法与基类中的方法没有联系,你没有覆写方法,只是在创建新的方法而已。由于

private

方法无法触及且能有效隐藏,除了把它看作类中的一部分,其他任何事物都不需要考虑到它。



final 类

当说一个类是

final



final

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

// 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

构造器中的第一步操作)。基类构造器和派生类构造器一样以相同的顺序经历相同的过程。当基类构造器完成后,实例变量按文本顺序初始化。最终,构造器的剩余部分被执行。



版权声明:本文为GXL_1012原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。