Java谜题5——类谜题

Java谜题5——类谜题谜题46:令人混淆的构造器案例 | 谜题47:啊呀!我的猫变成狗了 | 谜题48:我所得到的都是静态的 | 谜题49:比生命更大 | 谜题50:不是你的类型 | 谜题51:那个点是什么? | 谜题52:合计数的玩笑 | 谜题53:按你的意愿行事 | 谜题54:Null与Void | 谜题55:特创论 谜题46:令人混淆的构造器案例本谜题呈现给你了两个容易令人混淆的构造器。main方法调用了一个构造器,但是它调用的到底是哪一个呢?该程序的输出取决于这个问题的答案。那么它到底会打印出什么呢?甚至它是否是合法的呢? public class Confusing { private Confusing(Object o) { System.out.println("Object"); } private Confusing(double[] dArray) { System.out.println("double array"); } public static void main(String[] args) { new Confusing(null); } } 传递给构造器的参数是一个空的对象引用,因此,初看起来,该程序好像应该调用参数类型为Object的重载版本,并且将打印出Object。另一方面,数组也是引用类型,因此null也可以应用于类型为double[ ]的重载版本。你由此可能会得出结论:这个调用是模棱两可的,该程序应该不能编译。如果你试着去运行该程序,就会发现这些直观感觉都是不对的:该程序打印的是double array。这种行为可能显得有悖常理,但是有一个很好的理由可以解释它。 Java的重载解析过程是以两阶段运行的。第一阶段选取所有可获得并且可应用的方法或构造器。第二阶段在第一阶段选取的方法或构造器中选取最精确的一个。如果一个方法或构造器可以接受传递给另一个方法或构造器的任何参数,那么我们就说第一个方法比第二个方法缺乏精确性[JLS 15.12.2.5]。 在我们的程序中,两个构造器都是可获得并且可应用的。构造器Confusing(Object)可以接受任何传递给Confusing(double[ ])的参数,因此Confusing(Object)相对缺乏精确性。(每一个double数组都是一个Object,但是每一个Object并不一定是一个double数组。)因此,最精确的构造器就是Confusing(double[ ]),这也就解释了为什么程序会产生这样的输出。 如果你传递的是一个double[ ]类型的值,那么这种行为是有意义的;但是如果你传递的是null,这种行为就有违直觉了。理解本谜题的关键在于在测试哪一个方法或构造器最精确时,这些测试没有使用实际的参数:即出现在调用中的参数。这些参数只是被用来确定哪一个重载版本是可应用的。一旦编译器确定了哪些重载版本是可获得且可应用的,它就会选择最精确的一个重载版本,而此时使用的仅仅是形式参数:即出现在声明中的参数。 要想用一个null参数来调用Confusing(Object)构造器,你需要这样写代码:new Confusing((Object)null)。这可以确保只有Confusing(Object)是可应用的。更一般地讲,要想强制要求编译器选择一个精确的重载版本,需要将实际的参数转型为形式参数所声明的类型。 以这种方式来在多个重载版本中进行选择是相当令人不快的。在你的API中,应该确保不会让客户端走这种极端。理想状态下,你应该避免使用重载:为不同的方法取不同的名称。当然,有时候这无法实现,例如,构造器就没有名称,因而也就无法被赋予不同的名称。然而,你可以通过将构造器设置为私有的并提供公有的静态工厂,以此来缓解这个问题[EJ Item 1]。如果构造器有许多参数,你可以用Builder模式[Gamma95]来减少对重载版本的需求量。 如果你确实进行了重载,那么请确保所有的重载版本所接受的参数类型都互不兼容,这样,任何两个重载版本都不会同时是可应用的。如果做不到这一点,那么就请确保所有可应用的重载版本都具有相同的行为[EJ Item 26]。 总之,重载版本的解析可能会产生混淆。应该尽可能地避免重载,如果你必须进行重载,那么你必须遵守上述方针,以最小化这种混淆。如果一个设计糟糕的API强制你在不同的重载版本之间进行选择,那么请将实际的参数转型为你希望调用的重载版本的形式参数所具有的类型。 谜题47:啊呀!我的猫变成狗了下面的程序使用了一个Counter类来跟踪每一种家庭宠物叫唤的次数。那么该程序会打印出什么呢? class Counter { private static int count = 0; public static final synchronized void increment() { count++; } public static final synchronized int getCount() { return count; } } class Dog extends Counter { public Dog() { } public void woof() { increment(); } } class Cat extends Counter { public Cat() { } public void meow() { increment(); } } public class Ruckus { public static void main(String[] args) { Dog dogs[] = { new Dog(), new Dog() }; for (int i = 0; i < dogs.length; i++) dogs[i].woof(); Cat cats[] = { new Cat(), new Cat(), new Cat() }; for (int i = 0; i < cats.length; i++) cats[i].meow(); System.out.print(Dog.getCount() + " woofs and "); System.out.println(Cat.getCount() + " meows"); } } 我们听到两声狗叫和三声猫叫——肯定是好一阵喧闹——因此,程序应该打印2 woofs and 3 meows,不是吗?不:它打印的是5 woofs and 5 meows。所有这些多出来的吵闹声是从哪里来的?我们做些什么才能够阻止它? 该程序打印出的犬吠声和猫叫声的数量之和是10,它是实际总数的两倍。问题在于Dog和Cat都从其共同的超类那里继承了count域,而count又是一个静态域。每一个静态域在声明它的类及其所有子类中共享一份单一的拷贝,因此Dog和Cat使用的是相同的count域。每一个对woof或meow的调用都在递增这个域,因此它被递增了5次。该程序分别通过调用Dog.getCount和Cat.getCount读取了这个域两次,在每一次读取时,都返回并打印了5。 在设计一个类的时候,如果该类构建于另一个类的行为之上,那么你有两种选择:一种是继承,即一个类扩展另一个类;另一种是组合,即在一个类中包含另一个类的一个实例。选择的依据是,一个类的每一个实例都是另一个类的一个实例,还是都有另一个类的一个实例。在第一种情况应该使用继承,而第二种情况应该使用组合。当你拿不准时,优选组合而不是继承[EJ Item 14]。 一条狗或是一只猫都不是一种计数器,因此使用继承是错误的。Dog和Cat不应该扩展Counter,而是应该都包含一个计数器域。每一种宠物都需要有一个计数器,但并非每一只宠物都需要有一个计数器,因此,这些计数器域应该是静态的。我们不必为Counter类而感到烦恼;一个int域就足够了。 下面是我们重新设计过的程序,它会打印出我们所期望的2 woofs, 3 meows: class Dog { private static int woofCounter; public Dog() { } public static int woofCount() { return woofCounter; }; public void woof() { woofCounter++; } } class Cat { private static int meowCounter; public Cat() { } public static int meowCount() { return meowCounter; }; public void meow() { meowCounter++; } } Ruckus类除了两行语句之外没有其它的变化,这两行语句被修改为使用新的方法名来访问计数器: System.out.print(Dog.woofCount() + " woofs and "); System.out.println(Cat.meowCount() + " meows"); 总之,静态域由声明它的类及其所有子类所共享。如果你需要让每一个子类都具有某个域的单独拷贝,那么你必须在每一个子类中声明一个单独的静态域。如果每一个实例都需要一个单独的拷贝,那么你可以在基类中声明一个非静态域。还有就是,要优选组合而不是继承,除非导出类真的需要被当作是某一种基类来看待。 谜题48:我所得到的都是静态的下面的程序对巴辛吉小鬣狗和其它狗之间的行为差异进行了建模。如果你不知道什么是巴辛吉小鬣狗,那么我告诉你,这是一种产自非洲的小型卷尾狗,它们从来都不叫唤。那么,这个程序将打印出什么呢? class Dog { public static void bark() { System.out.print("woof "); } } class Basenji extends Dog { public static void bark() { } } public class Bark { public static void main(String args[]) { Dog woofer = new Dog(); Dog nipper = new Basenji(); woofer.bark(); nipper.bark(); } } 随意地看一看,好像该程序应该只打印一个woof。毕竟,Basenji扩展自Dog,并且它的bark方法定义为什么也不做。main方法调用了bark方法,第一次是在Dog类型的woofer上调用,第二次是在Basenji类型的nipper上调用。巴辛吉小鬣狗并不会叫唤,但是很显然,这一只会。如果你运行该程序,就会发现它打印的是woof woof。这只可怜的小家伙到底出什么问题了? 问题在于bark是一个静态方法,而对静态方法的调用不存在任何动态的分派机制[JLS 15.12.4.4]。当一个程序调用了一个静态方法时,要被调用的方法都是在编译时刻被选定的,而这种选定是基于修饰符的编译期类型而做出的,修饰符的编译期类型就是我们给出的方法调用表达式中圆点左边部分的名字。在本案中,两个方法调用的修饰符分别是变量woofer和nipper,它们都被声明为Dog类型。因为它们具有相同的编译期类型,所以编译器使得它们调用的是相同的方法:Dog.bark。这也就解释了为什么程序打印出woof woof。尽管nipper的运行期类型是Basenji,但是编译器只会考虑其编译器类型。 要订正这个程序,直接从两个bark方法定义中移除掉static修饰符即可。这样,Basenji中的bark方法将覆写而不是隐藏Dog中的bark方法,而该程序也将会打印出woof,而不是woof woof。通过覆写,你可以获得动态的分派;而通过隐藏,你却得不到这种特性。 当你调用了一个静态方法时,通常都是用一个类而不是表达式来标识它:例如,Dog.bark或Basenji.bark。当你在阅读一个Java程序时,你会期望类被用作为静态方法的修饰符,这些静态方法都是被静态分派的,而表达式被用作为实例方法的修饰符,这些实例方法都是被动态分派的。通过耦合类和变量的不同的命名规范,我们可以提供一个很强的可视化线索,用来表明一个给定的方法调用是动态的还是静态的。本谜题的程序使用了一个表达式作为静态方法调用的修饰符,这就误导了我们。千万不要用一个表达式来标识一个静态方法调用。 覆写的使用与上述的混乱局面搅到了一起。Basenji中的bark方法与Dog中的bark方法具有相同的方法签名,这正是覆写的惯用方式,预示着要进行动态的分派。然而在本案中,该方法被声明为是static的,而静态方法是不能被覆写的;它们只能被隐藏,而这仅仅是因为你没有表达出你应该表达的意思。为了避免这样的混乱,千万不要隐藏静态方法。即便在子类中重用了超类中的静态方法的名称,也不会给你带来任何新的东西,但是却会丧失很多东西。 对语言设计者的教训是:对类和实例方法的调用彼此之间看起来应该具有明显的差异。第一种实现此目标的方式是不允许使用表达式作为静态方法的修饰符;第二种区分静态方法和实例方法调用的方式是使用不同的操作符,就像C++那样;第三种方式是通过完全抛弃静态方法这一概念来解决此问题,就像Smalltalk那样。 总之,要用类名来修饰静态方法的调用,或者当你在静态方法所属的类中去调用它们时,压根不去修饰这些方法,但是千万不要用一个表达式去修饰它们。还有就是要避免隐藏静态方法。所有这些原则合起来就可以帮助我们去消除那些容易令人误解的覆写,这些覆写需要对静态方法进行动态分派。 谜题49:比生命更大假如小报是可信的,那么摇滚之王“猫王”就会直到今天仍然在世。下面的程序用来估算猫王当前的腰带尺寸,方法是根据在公开演出中所观察到的他的体态发展趋势来进行投射。该程序中使用了Calendar.getInstance().get(Calendar.YEAR)这个惯用法,它返回当前的日历年份。那么,该程序会打印出什么呢? public class Elvis { public static final Elvis INSTANCE = new Elvis(); private final int beltSize; private static final int CURRENT_YEAR = Calendar.getInstance().get(Calendar.YEAR); private Elvis() { beltSize = CURRENT_YEAR - 1930; } public int beltSize() { return beltSize; } public static void main(String[] args) { System.out.println("Elvis wears a size " + INSTANCE.beltSize() + " belt."); } } 第一眼看去,这个程序是在计算当前的年份减去1930的值。如果它是正确的,那么在2006年,该程序将打印出Elvis wears a size 76 belt。如果你尝试着去运行该程序,你就会了解到小报是错误的,这证明你不能相信在报纸到读到的任何东西。该程序将打印出Elvis wears a size -1930 belt。也许猫王已经在反物质的宇宙中定居了。 该程序所遇到的问题是由类初始化顺序中的循环而引起的[JLS 12.4]。让我们来看看其细节。Elvis类的初始化是由虚拟机对其main方法的调用而触发的。首先,其静态域被设置为缺省值[JLS 4.12.5],其中INSTANCE域被设置为null,CURRENT_YEAR被设置为0。接下来,静态域初始器按照其出现的顺序执行。第一个静态域是INSTANCE,它的值是通过调用Elvis()构造器而计算出来的。 这个构造器会用一个涉及静态域CURRENT_YEAR的表达式来初始化beltSize。通常,读取一个静态域是会引起一个类被初始化的事件之一,但是我们已经在初始化Elvis类了。递归的初始化尝试会直接被忽略掉[JLS 12.4.2, 第3步]。因此,CURRENT_YEAR的值仍旧是其缺省值0。这就是为什么Elvis的腰带尺寸变成了-1930的原因。 最后,从构造器返回以完成Elvis类的初始化,假设我们是在2006年运行该程序,那么我们就将静态域CURRENT_YEAR初始化成了2006。遗憾的是,这个域现在所具有的正确值对于向Elvis.INSTANCE.beltSize的计算施加影响来说已经太晚了,beltSize的值已经是-1930了。这正是后续所有对Elvis.INSTANCE.beltSize()的调用将返回的值。 该程序表明,在final类型的静态域被初始化之前,存在着读取它的值的可能,而此时该静态域包含的还只是其所属类型的缺省值。这是与直觉相违背的,因为我们通常会将final类型的域看作是常量。final类型的域只有在其初始化表达式是常量表达式时才是常量[JLS 15.28]。 由类初始化中的循环所引发的问题是难以诊断的,但是一旦被诊断到,通常是很容易订正的。要想订正一个类初始化循环,需要重新对静态域的初始器进行排序,使得每一个初始器都出现在任何依赖于它的初始器之前。在这个程序中,CURRENT_YEAR的声明属于在INSTANCE声明之前的情况,因为Elvis实例的创建需要CURRENT_YEAR被初始化。一旦CURRENT_YEAR的声明被移走,Elvis就真的比生命更大了。 某些通用的设计模式本质上就是初始化循环的,特别是本谜题所展示的单例模式(Singleton)[Gamma95]和服务提供者框架(Service Provider Framework)[EJ Item 1]。类型安全的枚举模式(Typesafe Enum pattern)[EJ Item 21]也会引起类初始化的循环。5.0版本添加了对这种使用枚举类型的模式的语言级支持。为了减少问题发生的可能性,对枚举类型的静态初始器做了一些限制[JLS 16.5, 8.9]。 总之,要当心类初始化循环。最简单的循环只涉及到一个单一的类,但是它们也可能涉及多个类。类初始化循环也并非总是坏事,但是它们可能会导致在静态域被初始化之前就调用构造器。静态域,甚至是final类型的静态域,可能会在它们被初始化之前,被读走其缺省值。 谜题50:不是你的类型本谜题要测试你对Java的两个最经典的操作符:instanceof和转型的理解程度。下面的三个程序每一个都会做些什么呢? public class Type1 { public static void main(String[] args) { String s = null; System.out.println(s instanceof String); } } public class Type2 { public static void main(String[] args) { System.out.println(new Type2() instanceof String); } } public class Type3 { public static void main(String args[]) { Type3 t3 = (Type3) new Object(); } } 第一个程序,Type1,展示了instanceof操作符应用于一个空对象引用时的行为。尽管null对于每一个引用类型来说都是其子类型,但是instanceof操作符被定义为在其左操作数为null时返回false。因此,Type1将打印false。这被证明是实践中非常有用的行为。如果instanceof告诉你一个对象引用是某个特定类型的实例,那么你就可以将其转型为该类型,并调用该类型的方法,而不用担心会抛出ClassCastException或NullPointerException异常。 第二个程序,Type2,展示了instanceof操作符在测试一个类的实例,以查看它是否是某个不相关的类的实例时所表现出来的行为。你可能会期望该程序打印出false。毕竟,Type2的实例不是String的实例,因此该测试应该失败,对吗?不,instanceof测试在编译时刻就失败了,我们只能得到下面这样的出错消息: Type2.java:3: inconvertible types found : Type2, required: java.lang.String System.out.println(new Type2() instanceof String); ^ 该程序编译失败是因为instanceof操作符有这样的要求:如果两个操作数的类型都是类,其中一个必须是另一个的子类型[JLS 15.20.2, 15.16, 5.5]。Type2和String彼此都不是对方的子类型,所以instanceof测试将导致编译期错误。这个错误有助于让你警惕instanceof测试,它们可能并没有去做你希望它们做的事情。 第三个程序,Type3,展示了当要被转型的表达式的静态类型是转型类型的超类时,转型操作符的行为。与instanceof操作相同,如果在一个转型操作中的两种类型都是类,那么其中一个必须是另一个的子类型。尽管对我们来说,这个转型很显然会失败,但是类型系统还没有强大到能够洞悉表达式new Object()的运行期类型不可能是Type3的一个子类型。因此,该程序将在运行期抛出ClassCastException异常。这有一点违背直觉:第二个程序完全具有实际意义,但是却不能编译;而这个程序没有任何实际意义,但是却可以编译。 总之,第一个程序展示了instanceof运行期行为的一个很有用的冷僻案例。第二个程序展示了其编译期行为的一个很有用的冷僻案例。第三个程序展示了转型操作符的行为的一个冷僻案例,在此案例中,编译器并不能将你从你所做荒唐的事中搭救出来,只能靠VM在运行期来帮你绷紧这根弦。 谜题51:那个点是什么?下面这个程序有两个不可变的值类(value class),值类即其实例表示值的类。第一个类用整数坐标来表示平面上的一个点,第二个类在此基础上添加了一点颜色。主程序将创建和打印第二个类的一个实例。那么,下面的程序将打印出什么呢? class Point { protected final int x, y; private final String name; // Cached at construction time Point(int x, int y) { this.x = x; this.y = y; name = makeName(); } protected String makeName() { return "[" + x + "," + y + "]"; } public final String toString() { return name; } } public class ColorPoint extends Point { private final String color; ColorPoint(int x, int y, String color) { super(x, y); this.color = color; } protected String makeName() { return super.makeName() + ":" + color; } public static void main(String[] args) { System.out.println(new ColorPoint(4, 2, "purple")); } } main方法创建并打印了一个ColorPoint实例。println方法调用了该ColorPoint实例的toString方法,这个方法是在Point中定义的。toString方法将直接返回name域的值,这个值是通过调用makeName方法在Point的构造器中被初始化的。对于一个Point实例来说,makeName方法将返回[x,y]形式的字符串。对于一个ColorPoint实例来说,makeName方法被覆写为返回[x,y]:color形式的字符串。在本例中,x是4,y是2,color的purple,因此程序将打印[4,2]:purple,对吗?不,如果你运行该程序,就会发现它打印的是[4,2]:null。这个程序出什么问题了呢? 这个程序遭遇了实例初始化顺序这一问题。要理解该程序,我们就需要详细跟踪该程序的执行过程。下面是该程序注释过的版本的列表,用来引导我们了解其执行顺序: class Point { protected final int x, y; private final String name; // Cached at construction time Point(int x, int y) { this.x = x; this.y = y; name = makeName(); // 3. Invoke subclass method } protected String makeName() { return "[" + x + "," + y + "]"; } public final String toString() { return name; } } public class ColorPoint extends Point { private final String color; ColorPoint(int x, int y, String color) { super(x, y); // 2. Chain to Point constructor this.color = color; // 5. Initialize blank final-Too late } protected String makeName() { // 4. Executes before subclass constructor body! return super.makeName() + ":" + color; } public static void main(String[] args) { // 1. Invoke subclass constructor System.out.println(new ColorPoint(4, 2, "purple")); } } 在下面的解释中,括号中的数字引用的就是在上述注释版本的列表中的注释标号。首先,程序通过调用ColorPoint构造器创建了一个ColorPoint实例(1)。这个构造器以链接调用其超类构造器开始,就像所有构造器所做的那样(2)。超类构造器在构造过程中对该对象的x域赋值为4,对y域赋值为2。然后该超类构造器调用makeName,该方法被子类覆写了(3)。 ColorPoint中的makeName方法(4)是在ColorPoint构造器的程序体之前执行的,这就是问题的核心所在。makeName方法首先调用super.makeName,它将返回我们所期望的[4,2],然后该方法在此基础上追加字符串“:”和由color域的值所转换成的字符串。但是此刻color域的值是什么呢?由于它仍处于待初始化状态,所以它的值仍旧是缺省值null。因此,makeName方法返回的是字符串“[4,2]:null”。超类构造器将这个值赋给name域(3),然后将控制流返回给子类的构造器。 这之后子类构造器才将“purple”赋予color域(5),但是此刻已经为时过晚了。color域已经在超类中被用来初始化name域了,并且产生了不正确的值。之后,子类构造器返回,新创建的ColorPoint实例被传递给println方法,它适时地调用了该实例的toString方法,这个方法返回的是该实例的name域的内容,即“[4,2]:null”,这也就成为了程序要打印的东西。 本谜题说明:在一个final类型的实例域被赋值之前,存在着取用其值的可能,而此时它包含的仍旧是其所属类型的缺省值。在某种意义上,本谜题是谜题49在实例方面的相似物,谜题49是在final类型的静态域被赋值之前,取用了它的值。在这两种情况中,谜题都是因初始化的循环而产生的,在谜题49中,是类的初始化;而在本谜题中,是实例初始化。两种情况都存在着产生极大的混乱的可能性,但是它们之间有一个重要的差别:循环的类初始化是无法避免的灾难,但是循环的实例初始化总是可以且总是应该避免的。 无论何时,只要一个构造器调用了一个已经被其子类覆写了的方法,那么该问题就会出现,因为以这种方式被调用的方法总是在实例被初始化之前执行。要想避免这个问题,就千万不要在构造器中调用可覆写的方法,直接调用或间接调用都不行[EJ Item 15]。这项禁令应该扩展至实例初始器和伪构造器(pseudoconstructors)readObject与clone。(这些方法之所以被称为伪构造器,是因为它们可以在不调用构造器的情况下创建对象。) 你可以通过惰性初始化name域来订正该问题,即当它第一次被使用时初始化,以此取代积极初始化,即当Point实例被创建时初始化。 通过这种修改,该程序就可以打印出我们期望的[4,2]:purple。 class Point { protected final int x, y; private String name; // Lazily initialized Point(int x, int y) { this.x = x; this.y = y; // name initialization removed } protected String makeName() { return "[" + x + "," + y + "]"; } // Lazily computers and caches name on first use public final synchronized String toString() { if (name == null) name = makeName(); return name; } } 尽管惰性加载可以订正这个问题,但是对于让一个值类去扩展另一个值类,并且在其中添加一个会对euqals比较方法产生影响的域的这种做法仍旧不是一个好主意。你无法在超类和子类上都提供一个基于值的equals方法,而同时又不违反Object.equals方法的通用约定,或者是不消除在超类和子类之间进行有实际意义的比较操作的可能性[EJ Item 7]。 循环实例初始化问题对语言设计者来说是问题成堆的地方。C++是通过在构造阶段将对象的类型从超类类型改变为子类类型来解决这个问题的。如果采用这种解决方法,本谜题中最开始的程序将打印[4,2]。我们发现没有任何一种流行的语言能够令人满意地解决这个问题。也许,我们值得去考虑,当超类构造器调用子类方法时,通过抛出一个不受检查的异常使循环实例初始化非法。 总之,在任何情况下,你都务必要记住:不要在构造器中调用可覆 写的方法。在实例初始化中产生的循环将是致命的。该问题的解决方案就是惰性初始化[EJ Items 13,48]。 谜题52:合计数的玩笑下面的程序在一个类中计算并缓存了一个合计数,并且在另一个类中打印了这个合计数。那么,这个程序将打印出什么呢?这里给一点提示:你可能已经回忆起来了,在代数学中我们曾经学到过,从1到n的整数总和是n(n+1)/2。 class Cache { static { initializeIfNecessary(); } private static int sum; public static int getSum() { initializeIfNecessary(); return sum; } private static boolean initialized = false; private static synchronized void initializeIfNecessary() { if (!initialized) { for (int i = 0; i < 100; i++) sum += i; initialized = true; } } } public class Client { public static void main(String[] args) { System.out.println(Cache.getSum()); } } 草草地看一遍,你可能会认为这个程序从1加到了100,但实际上它并没有这么做。再稍微仔细地看一看那个循环,它是一个典型的半开循环,因此它将从0循环到99。有了这个印象之后,你可能会认为这个程序打印的是从0到99的整数总和。用前面提示中给出的公式,我们知道这个总和是99×100/2,即4,950。但是,这个程序可不这么想,它打印的是9900,是我们所预期值的整整两倍。是什么导致它如此热情地翻倍计算了这个总和呢? 该程序的作者显然在确保sum在被使用前就已经在初始化这个问题上,经历了众多的麻烦。该程序结合了惰性初始化和积极初始化,甚至还用上了同步,以确保缓存在多线程环境下也能工作。看起来这个程序已经把所有的问题都考虑到了,但是它仍然不能正常工作。它到底出了什么问题呢? 与谜题49中的程序一样,该程序受到了类初始化顺序问题的影响。为了理解其行为,我们来跟踪其执行过程。在可以调用Client.main之前,VM必须初始化Client类。这项初始化工作异常简单,我们就不多说什么了。Client.main方法调用了Cache.getsum方法,在getsum方法可以被执行之前,VM必须初始化Cache类。 回想一下,类初始化是按照静态初始器在源代码中出现的顺序去执行这些初始器的。Cache类有两个静态初始器:在类顶端的一个static语句块,以及静态域initialized的初始化。静态语句块是先出现的,它调用了方法initializeIfNecessary,该方法将测试initialized域。因为该域还没有被赋予任何值,所以它具有缺省的布尔值false。与此类似,sum具有缺省的int值0。因此,initializeIfNecessary方法执行的正是你所期望的行为,将4,950添加到了sum上,并将initialized设置为true。 在静态语句块执行之后,initialized域的静态初始器将其设置回false,从而完成Cache的类初始化。遗憾的是,sum现在包含的是正确的缓存值,但是initialized包含的却是false:Cache类的两个关键状态并未同步。 此后,Client类的main方法调用Cache.getSum方法,它将再次调用initializeIfNecessary方法。因为initialized标志是false,所以initializeIfNecessary方法将进入其循环,该循环将把另一个4,950添加到sum上,从而使其值增加到了9,900。getSum方法返回的就是这个值,而程序打印的也是它。 很明显,该程序的作者认为Cache类的初始化不会以这种顺序发生。由于不能在惰性初始化和积极初始化之间作出抉择,所以作者同时运用这二者,结果产生了大麻烦。要么使用积极初始化,要么使用惰性初始化,但是千万不要同时使用二者。 如果初始化一个域的时间和空间代价比较低,或者该域在程序的每一次执行中都需要用到时,那么使用积极初始化是恰当的。如果其代价比较高,或者该域在某些执行中并不会被用到,那么惰性初始化可能是更好的选择[EJ Item 48]。另外,惰性初始化对于打破类或实例初始化中的循环也可能是必需的(谜题51)。 通过重排静态初始化的顺序,使得initialized域在sum被初始化之后不被复位到false,或者通过移除initialized域的显式静态初始化操作,Cache类就可以得到修复。尽管这样所产生的程序可以工作,但是它们仍旧是混乱的和病构的。Cache类应该被重写为使用积极初始化,这样产生的版本很明显是正确的,而且比最初的版本更加简单。 使用这个版本的Cache类,程序就可以打印出我们所期望的4950: class Cache { private static final int sum = computeSum(); private static int computeSum() { int result = 0; for (int i = 0; i < 100; i++) result += i; return result; } public static int getSum() { return sum; } } 请注意,我们使用了一个助手方法来初始化sum。助手方法通常都优于静态语句块,因为它让你可以对计算命名。只有在极少的情况下,你才必须使用一个静态语句块来初始化一个静态域,此时请将该语句块紧随该域声明之后放置。这提高了程序的清晰度,并且消除了像最初的程序中出现的静态初始化与静态语句块互相竞争的可能性。 总之,请考虑类初始化的顺序,特别是当初始化显得很重要时更是如此。请你执行测试,以确保类初始化序列的简洁。请使用积极初始化,除非你有某种很好的理由要使用惰性初始化,例如性能方面的因素,或者需要打破初始化循环。 谜题53:按你的意愿行事现在该轮到你写一些代码了。假设你有一个称为Thing的库类,它唯一的构造器将接受一个int参数: public class Thing { public Thing(int i) { ... } ... } Thing实例没有提供任何可以获取其构造器参数的值的途径。因为Thing是一个库类,所以你不具有访问其内部的权限,因此你不能修改它。 假设你想编写一个称为MyThing的子类,其构造器将通过调用SomeOtherClass.func()方法来计算超类构造器的参数。这个方法返回的值被一个个的调用以不可预知的方式所修改。最后,假设你想将这个曾经传递给超类构造器的值存储到子类的一个final实例域中,以供将来使用。那么下面就是你自然会写出的代码: public class MyThing extends Thing { private final int arg; public MyThing() { super(arg = SomeOtherClass.func()); ... } } 遗憾的是,这个程序是非法的。如果你尝试着去编译它,那么你将得到一条像下面这样的错误消息: MyThing.java: can't reference arg before supertype constructor has been called super(arg = SomeOtherClass.func()); ^ 你怎样才能重写MyThing以实现想要的效果呢?MyThing()构造器必须是线程安全的:多个线程可能会并发地调用它。 这个解决方案内在地就是线程安全的和优雅的,它涉及对MyThing中第二个私有的构造器的运用: public class MyThing extends Thing { private final int arg; public MyThing() { this(SomeOtherClass.func()); } private MyThing(int i) { super(i); arg = i; } } 这个解决方案使用了交替构造器调用机制(alternate constructor invocation)[JLS 8.8.7.1]。这个特征允许一个类中的某个构造器链接调用同一个类中的另一个构造器。在本例中,MyThing()链接调用了私有构造器MyThing(int),它执行了所需的实例初始化。在这个私有构造器中,表达式SomeOtherClass.func()的值已经被捕获到了变量i中,并且它可以在超类构造器返回之后存储到final类型的域param中。 通过本谜题所展示的私有构造器捕获(Private Constructor Capture)惯用法是一种非常有用的模式,你应该把它添加到你的技巧库中。我们已经看到了某些真的是很丑陋的代码,它们本来是可以通过使用本模式而避免如此丑陋的。 谜题54:Null与Void 下面仍然是经典的Hello World程序的另一个变种。那么,这个变种将打印什么呢? public class Null { public static void greet() { System.out.println("Hello world!"); } public static void main(String[] args) { ((Null) null).greet(); } } 这个程序看起来似乎应该抛出NullPointerExceptioin异常,因为其main方法是在常量null上调用greet方法,而你是不可以在null上调用方法的,对吗?嗯,某些时候是可以的。如果你运次该程序,就会发现它打印出了“Hello World!” 理解本谜题的关键是Null.greet是一个静态方法。正如你在谜题48中所看到的,在静态方法的调用中,使用表达式作为其限定符并非是一个好主意,而这也正是问题之所在。不仅表达式的值所引用的对象的运行期类型在确定哪一个方法将被调用时并不起任何作用,而且如果对象有标识的话,其标识也不起任何作用。在本例中,没有任何对象,但是这并不会造成任何区别。静态方法调用的限定表达式是可以计算的,但是它的值将被忽略。没有任何要求其值为非空的限制。 要想消除该程序中的混乱,你可以用它的类作为限定符来调用greet方法: public static void main(String[] args) { Null.greet(); } 然而更好的方式是完全消除限定符: public static void main(String[] args) { greet(); } 总之,本谜题的教训与谜题48的完全相同:要么用某种类型来限定静态方法调用,要么就压根不要限定它们。对语言设计者来说,应该不允许用表达式来污染静态方法调用的可能性存在,因为它们只会产生混乱。 谜题55:特创论 某些时候,对于一个类来说,跟踪其创建出来的实例个数会非常用有,其典型实现是通过让它的构造器递增一个私有静态域来完成的。在下面的程序中,Creature类展示了这种技巧,而Creator类对其进行了操练,将打印出已经创建的Creature实例的数量。那么,这个程序会打印出什么呢? public class Creator { public static void main(String[] args) { for (int i = 0; i < 100; i++) Creature creature = new Creature(); System.out.println(Creature.numCreated()); } } class Creature { private static long numCreated = 0; public Creature() { numCreated++; } public static long numCreated() { return numCreated; } } 这是一个捉弄人的问题。该程序看起来似乎应该打印100,但是它没有打印任何东西,因为它根本就不能编译。如果你尝试着去编译它,你就会发现编译器的诊断信息基本没什么用处。下面就是javac打印的东西: Creator.java:4: not a statement Creature creature = new Creature(); ^ Creator.java:4: ';' expected Creature creature = new Creature(); ^ 一个本地变量声明看起来像是一条语句,但是从技术上说,它不是;它应该是一个本地变量声明语句(local variable declaration statement)[JLS 14.4]。Java语言规范不允许一个本地变量声明语句作为一条语句在for、while或do循环中重复执行[JLS 14.12-14]。一个本地变量声明作为一条语句只能直接出现在一个语句块中。(一个语句块是由一对花括号以及包含在这对花括展中的语句和声明构成的。) 有两种方式可以订正这个问题。最显而易见的方式是将这个声明至于一个语句块中: for (int i = 0; i < 100; i++) { Creature creature = new Creature(); } 然而,请注意,该程序没有使用本地变量creature。因此,将该声明用一个无任何修饰的构造器调用来替代将更具实际意义,这样可以强调对新创建对象的引用正在被丢弃: for (int i = 0; i < 100; i++) new Creature(); 无论我们做出了上面的哪种修改,该程序都将打印出我们所期望的100。 请注意,用于跟踪Creature实例个数的变量(numCreated)是long类型而不是int类型的。我们很容易想象到,一个程序创建出的某个类的实例可能会多余int数值的最大值,但是它不会多于long数值的最大值。 int数值的最大值是231-1,即大约2.1×109,而long数值的最大值是263-1,即大约9.2×1018。当前,每秒钟创建108个对象是可能的,这意味着一个程序在long类型的对象计数器溢出之前,不得不运行大约三千年。即使是面对硬件速度的提升,long类型的对象计数器也应该足以应付可预见的未来。 还要注意的是,本谜题中的创建计数策略并不是线程安全的。如果多个线程可以并行地创建对象,那么递增计数器的代码和读取计数器的代码都应该被同步: // Thread-safe creation counter class Creature { private static long numCreated; public Creature() { synchronized (Creature.class) { numCreated++; } } public static synchronized long numCreated() { return numCreated; } } 或者,如果你使用的是5.0或更新的版本,你可以使用一个AtomicLong实例,它在面临并发时可以绕过对同步的需求。 // Thread-safe creation counter using AtomicLong; import java.util.concurrent.atomic.AtomicLong; class Creature { private static AtomicLong numCreated = new AtomicLong(); public Creature() { numCreated.incrementAndGet(); } public static long numCreated() { return numCreated.get(); } } 请注意,把numCreated声明为瞬时的是不足以解决问题的,因为volatile修饰符可以保证其他线程将看到最近赋予该域的值,但是它不能进行原子性的递增操作。 总之,一个本地变量声明不能被用作for、while或do循环中的重复执行语句,它作为一条语句只能出现在一个语句块中。另外,在使用一个变量来对实例的创建进行计数时,要使用long类型而不是int类型的变量,以防止溢出。最后,如果你打算在多线程中创建实例,要么将对实例计数器的访问进行同步,要么使用一个AtomicLong类型的计数器。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值