8.2 复用(protected+向上转型+final关键字+类的初始化)

一、protected

在编写代码时,有时候我们希望将一个模块尽量对外界隐藏,却希望派生类的成员能够访问,关键字protected就起到这个作用。它表示“就类的用户而言,这是 private 的。但对于任何继承它的子类或在同一包中的类,它是可访问的。”(protected 也提供了包访问权限)

尽管可以创建 protected 属性,但是最好的方式是将属性声明为 private,然后定义 protected 方法来控制类的继承者的访问权限。

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

输出:

Orc 12: I'm a Villain and my name is Limburger
Orc 19: I'm a Villain and my name is Bob

二、向上转型

继承最重要的方面不是为新类提供方法,而是提供了新类与基类的一种关系,这种关系可以表述为:“新类是已有类的一种类型”。如下的代码例子展示了这种关系:

// reuse/Wind.java
// 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 引用。一个接收一种类型的方法接受了另一种类型看起来是很奇怪的,这里就体现了上述的”新类是基类的一种类型“, Wind 对象其实同时也是一个 Instrument 对象,而且 Instrument 的 tune 方法一定会存在于 Wind 中。在 tune() 中,代码对 Instrument 和 所有 Instrument 的派生类起作用,这种把 Wind 引用转换为 Instrument 引用的行为称作向上转型。

该术语是基于传统的类继承图:图最上面是根,然后向下铺展。Wind.java 的类图是:

在这里插入图片描述
继承图中派生类转型为基类是向上的,所以通常称作向上转型。因为是从一个更具体的类转化为一个更一般的类,所以向上转型永远是安全的。也就是说,派生类是基类的一个超集。它可能比基类包含更多的方法,但它必须至少具有与基类一样的方法。在向上转型期间,类接口只可能失去方法,不会增加方法。这就是为什么编译器在没有任何明确转型或其他特殊标记的情况下,仍然允许向上转型的原因。

也可以执行与向上转型相反的向下转型,但是会有问题,对于该问题会放在下一章和“类型信息”一章进行更深入的探讨。

2.1 从向上转型出发看组合和继承

在面向对象编程中,创建和使用代码最有可能的方法是将数据和方法一起打包到类中,然后使用该类的对象。也可以使用已有的类通过组合来创建新类。继承其实不太常用。因此尽管在教授 OOP 的过程中我们多次强调继承,但这并不意味着要尽可能使用它。恰恰相反,尽量少使用它,除非确实使用继承是有帮助的。一种判断使用组合还是继承的最清晰的方法是问一问自己是否需要把新类向上转型为基类。如果必须向上转型,那么继承就是必要的,但如果不需要,则要进一步考虑是否该采用继承。“多态”一章提出了一个使用向上转型的最有力的理由,但是只要记住问一问“我需要向上转型吗?”,就能在这两者中作出较好的选择。

三、final关键字

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

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

3.1 final修饰数据

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

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

对于编译时常量这种情况,编译器可以把常量带入计算中;也就是说,可以在编译时计算,减少了一些运行时的负担。在 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 有用。

3.1.1 空白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);
    }
}

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

3.2 final修饰方法

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

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

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

3.2.1 final和private

类中所有的 private 方法都隐式地指定为 final。可以给 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();
    }
}

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

3.3 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++;
    }
}

3.3.1 final修饰类的建议

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

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

Java 标准类库就是一个很好的例子。尤其是 Java 1.0/1.1 的 Vector 类被广泛地使用,然而它的所有方法出于"效率"考虑(然而并没有提升效率,只是幻觉)全被指定为 final ,如果不指定 final 的话,可能会更加有用[^1]。很容易想到,你可能会继承并覆写这么一个基础类,但是设计者们认为这么做不合适。有两个讽刺的原因。第一,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 中的方法名简洁得多,这又是一条证据。对于类库的使用者来说,这是一个本不应该如此草率的事情。这种不规则的情况造成用户需要做更多的工作——这是对粗糙的设计和代码的又一讽刺。

四、类初始化和加载

在Java中万物皆对象,这个特性使得加载活动相比其他语言要容易一些。Java中每个类的编译代码都存在于它自己独立的文件中,该文件只有在程序代码被使用时才会被加载。所以可以说“类的代码在首次被使用时加载“。“使用”通常是指创建类的第一个对象,或者是访问了类的 static 属性或方法。构造器也是一个 static 方法(它的 static 关键字是隐式的)。因此,准确地说,一个类当它任意一个 static 成员被访问时,就会被加载。

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

4.1 继承和初始化

了解包括继承在内的整个初始化过程是有帮助的,考虑下面的例子:

// 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();
    }
}

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

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

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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值