复用类(4):final关键字、初始化与类的加载

    根据上下文环境,java的关键字final的含义存在着细微的区别,但通常它指的是“这是无法改变的。”不想做改变可能出于两种理由:设计或效率。由于这两个原因相差很远,所以关键字final有可能被误用。以下谈论了可能使用到final的三种情况:数据、方法和类。

一、final数据

    许多编程语言都有某种方法,来向编译器告知这一块数据是恒定不变的。有时数据的恒定不变是很有用的,比如:

  1. 一个永不改变的编译时常量。
  2. 一个在运行时被初始化的值,而你不希望它被改变。

    对于编程期常量这种情况,编译器可以将该常量值代入任何可能用到它的计算式中,也就是说,可以在编译时执行计算式,这减轻了一些运行时的负担。在java中,这类常量必须是基本数据类型,并且以关键字final表示。在对这个常量进行定义的时候,必须对其进行赋值。

    一个既是static又是final的域只占据一段不能改变的存储空间。

    当对对象引用而不是基本类型运用final时,其含义会有一点令人迷惑。对于基本类型,final使数值恒定不变;而用于对象引用,final使引用恒定不变。一旦引用被初始化指向一个对象,就无法再把它改为指向另一个对象。然而,对象其自身却是可以被修改的,java并未提供使任何对象恒定不变的途径(但可以自己编写类以取得使对象恒定不变的效果)。这一限制同样适用于数组,它也是对象。

    下面的示例示范了final域的情况。注意,根据惯例,既是static又是final的域(即编译期常量)将用大写表示,并使用下划线分隔各个单词:

import java.util.Random;

class Value {
	int i;

	public Value(int i) {
		this.i = i;
	}
}

public class FinalData {
	private static Random r = new Random();
	private String id;

	public FinalData(String id) {
		this.id = id;
	}

	private final int valueOne = 9;
	private static final int VALUE_TWO = 99;
	public static final int VALUE_THREE = 39;
	private final int i4 = r.nextInt(20);
	static final int INT_5 = r.nextInt(20);
	private Value v1 = new Value(11);
	private final Value v2 = new Value(22);
	private static final Value VAL_3 = new Value(33);
	private final int[] a = { 1, 2, 3, 4, 5, 6 };

	public String toString() {
		return id + " : " + "i4= " + i4 + ",INT_5=" + INT_5;
	}

	public static void main(String[] args) {
		FinalData fd1 = new FinalData("fd1");
		// fd1.valueOne++;
		fd1.v2.i++;
		fd1.v1 = new Value(9);
		for (int i = 0; i < fd1.a.length; i++)
			fd1.a[i]++;
		// fd1.v2=new Value(0);
		// fd1.VAL_3 = new Value(1);
		// 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);
	}
}

    由于valueOne和VAL_TWO都是带有编译时数值的final基本类型,所以它们二者均可以用作编译期常量,并且没有重大区别。VAL_THREE是一种更加典型的对常量进行定义的方式:定义为public,则可以被用于包之外;定义为static,则强调只有一份;定义为final,则说明它是一个常量。请注意,带有恒定初始值(即,编译期常量)的final static基本类型全用大写字母命名,并且字与字之间用下划线隔开(这就像C常量一样,C常量是这一命名传统的发源地)。

    我们不能因为某数据是final的就认为在编译时可以知道它的值。在运行时使用随机生成的数值来初始化i4和INT_5就说明了这一点。示例部分也展示了将final数值定义为静态和非静态的区别。此区别只有当数值在运行时内被初始化时才会显现,这是因为编译器对编译时数值一视同仁(并且它们可能因优化而消失)。当运行程序时就会看到这个区别。请注意,在fd1和fd2中,i4的值是唯一的,但INT_5的值是不可以通过创建第二个FinalData对象而加以改变的。这是因为它是static的,在装载时已被初始化,而不是每次创建新对象时都初始化。

    v1到VAL_3这些变量说明了final引用的意义。正如在main()中所看到的,不能因为v2是final的,就认为无法改变它的值。由于它是一个引用,final意味着无法将v2再次指向另一个新的对象。这对数组具有同样的意义,数组只不过是另一种引用(我还不知道有什么办法能使数组引用本身成为final)。看起来,使引用成为final没有使基本类型成为final的用处大。

(1)空白final

    java允许生成“空白final”,所谓空白final是指被声明为final但又未给定初值的域。无论什么情况,编译器都确保空白final在使用前必须被初始化。但是,空白final在关键字final的使用上提供了更大的灵活性,为此,一个类中的final域就可以做到根据对象而有所不同,却又保持其恒定不变的特性。下面即为一例:

class Poppet {
	private int i;

	Poppet(int i1) {
		i = i1;
	}
}

public class BlankFinal {
	private final int i = 0;
	private final int j;
	private final Poppet p;

	public BlankFinal() {
		j = 1;
		p = new Poppet(1);
	}

	public BlankFinal(int x) {
		j = x;
		p = new Poppet(x);
	}

	public static void main(String[] args) {
		new BlankFinal();
		new BlankFinal(47);
	}
}

    注意:必须在域的定义处或者每个构造器中用表达式对final进行赋值,这正是final域在使用前总是被初始化的原因所在。

(2)final参数

    java允许在参数列表中以声明的方式将参数指明为final。这意味着你无法在方法中更改参数引用所指向的对象:

class Gizmo {
	public void spin() {
	}
}

public class FinalArguments {
	void with(final Gizmo g) {
		// g=new Gizmo();
	}

	void without(Gizmo g) {
		g = new Gizmo();
		g.spin();
	}

	void f(final int i) {
		// i++;
	}

	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方法来进行优化了。事实上,这种做法正在逐渐地受到劝阻。在使用java SE5/6时,应该让编译器和JVM去处理效率问题,只有在想要明确禁止覆盖时,才将方法设置为final的。

(1)final和private关键字

    类中所有的private方法都隐式地指定为final的。由于无法取用private方法,所以也就无法覆盖它。可以对private方法添加final修饰词,但这并不能给该方法增加任何额外的意义。

    这一问题会造成混淆。因为,如果你试图覆盖一个private方法(隐含是final的),似乎是奏效的,而且编译器也不会给出错误信息:

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

	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();
		OverridingPrivate op = op2;
		// op.f();
		// op.g();
		WithFinals wf = op2;
		// wf.f();
		// wf.g();
	}
}

    “覆盖”只有在某方法是基类的接口的一部分时才会出现。即,必须能将一个对象向上转型为它的基本类型并调用相同的方法。如果某方法为private,它就不是基类接口的一部分。它仅是一些隐藏于类中的程序代码,只不过是具有相同的名字而已。但如果在导出类中以相同的名称生成一个public、protected或包访问权限方法的话,该方法就不会产生在基类中出现的“仅具有相同名称”的情况。此时你并没有覆盖该方法,仅是生成了一个新的方法。由于private方法无法触及而且能有效隐藏,所以除了把它看成是因为它所归属的类的组织结构的原因存在外,其他任何事物都不需要考虑到它。

三、final类

    当将某个类的整体定义为final时(通过将关键字final置于它的定义之前),就表明了你不打算继承该类,而且也不允许别人这样做。换句话说,出于某种考虑,你对该类的设计永不需要做任何变动,或者出于安全的考虑,你不希望它有子类。

class SmallBrain {
}

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

	void f() {
	}
}

// class Further extends 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的,应该说是明智的。你可能会觉得,没人会想要覆盖你的方法。有时这是对的。

    但请留意你所作的假设。要预见类是如何被复用的一般是很困难的,特别是对于一个通用类而言更是如此。如果将一个方法指定为final,可能会妨碍其他程序员在项目中通过继承来复用你的类,而这只是因为你没有想到它会以那种方式被运用。

    java标准程序库就是一个很好的例子。特别是java 1.0/1.1中Vector类被广泛地运用,而且从效率考虑(这近乎是一个幻想),如果所有的方法均未被指定为final的话,它可能会更加有用。很容易想象到,人们可能会想要继承并覆盖如此基础而有用的类,但是设计者确认为这样做不太合适。这里有两个令人意外的原因。第一,Stack继承自Vector,就是说Stack是个Vector,这从逻辑的观点看是不正确的。尽管如此,java的设计者们自己仍旧继承了Vector。在以这种方式创建Stack时,他们应该意识到final方法显得过于严苛了。

    第二,Vector的许多最重要的方法--如addElement()和elementAt()是同步的。这将导致很大的执行开销,可能会抹杀final所带来的好处。这种情况增强了人们关于程序员无法正确猜测优化应当发生于何处的观点。如此蹩脚的设计,却要置于我们每个人都得使用的标准程序库中,这是很糟糕的(幸运的是,现代java的容器库用ArrayList替代了Vector。ArrayList的行为要合理的多。遗憾的是仍然存在用旧容器库编写新程序代码的情况)。

    留心一下Hashtable,这个例子同样有趣,它也是一个重要的java1.0/1.1标准库类,而且不含任何final方法。对于类库的使用者来说,这又是一个本不该如此轻率的事物。这种不规则的情况只能使用户付出更多的努力。这是对粗糙的设计和代码的又一讽刺(请注意,现代java的容器库用HashMap替代了Hashtable)。

五、初始化与类的加载

    在许多传统语言中,程序是作为启动过程的一部分立刻被加载的。然后是初始化,紧接着程序开始运行。这些语言的初始化过程必须小心控制,以确保定义为static的东西,其初始化顺序不会造成麻烦。例如C++中,如果某个static期望另一个static在被初始化之前就能有效的使用它,那么就会出现问题。

    java就不会出现这个问题,因为它采用了一种不同的加载方式。加载是众多变得更加容易的动作之一,因为java中所有事物都是对象。请记住,每个类的编译代码都存在于它自己的独立文件中。该文件只在需要使用程序代码时才会被加载。一般来说,可以说:“类的代码在初次使用时才加载。”这通常是指加载发生于创建类的第一个对象之时,但是当访问static域或static方法时,也会发生加载。

    初次使用之处也是static初始化发生之处。所有的static对象和static代码段都会在加载时依程序中的顺序(即,定义类时的书写顺序)而依次初始化。当然,定义为static的东西只会被初始化一次。

(1)继承与初始化

    了解包括继承在内的初始化全过程,以对所发生的一切有个全局性的把握,是很有益的。例如:

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

    在Beetle上运行java时,所发生的第一件事情就是试图访问Beetle.main()(一个static方法),于是加载器开始启动并找出Beetle类的编译代码(在名为Beetle.class的文件之中)。在对它进行加载过程中,编译器注意到它有一个基类(这是由关键字extends得知的),于是它继续进行加载。不管你是否打算产生一个该基类的对象,这都要发生(请尝试将对象创建的代码注释掉,以证明这一点)。

    如果该基类还有其自身的基类,那么第二个基类就会被加载,如此类推。接下来,根基类中的static初始化(在此例中为Insect)即会被执行,然后是下一个导出类,以此类推。这种方式很重要,因为导出类的static初始化可能会依赖于基类成员能否被正确初始化。

    至此为止,必要的类都已加载完毕,对象就可以被创建了。首先,对象中所有的基本类型都会被设为默认值,对象引用被设为null--这是通过将对象内存设为二进制零值而一举生成的。然后,基类的构造器会被调用。在本例中,它是被自动调用的。但也可以用super来指定对基类构造器的调用(正如在Beetle()构造器中的第一步操作)。基类构造器和导出类的构造器一样,以相同的顺序来经历相同的过程。在基类构造器完成之后,实例变量按其次序被初始化。最后,构造器的其余部分被执行。

如果本文对您有很大的帮助,还请点赞关注一下。

发布了100 篇原创文章 · 获赞 2 · 访问量 1万+

猜你喜欢

转载自blog.csdn.net/qq_40298351/article/details/104130551