《java解惑》读书笔记6——类谜题

时间:2023-02-25 22:36:02

1.方法重载:

问题:

下面的程序演示方法重载,代码如下:

public class Test{

public static void main(String[] args) {
new Test(null);
}

private Test(Object o){
System.out.println("Object");
}

private Test(double[] doubleArray){
System.out.println("Double array");
}
}
很多人看到这个程序都不能很确定输出结果,因为构造方法重载了,但是对于传递进来的null,两个构造方法都可以,所以也有人觉得程序模棱两可,不能通过编译。

程序的真实运行结果是“Double array”,即调用参数为double数组的构造方法。


原因:

java中方法重载必须以方法签名:方法名称和参数列表来区分,在源码层面方法的返回值不作为方法重载的判断(在字节码文件中方法返回值也作为重载依据),方法重载是静态早绑定,即在编译器要调用哪个方法就是已经确定的了,java的重载解析过程是以下面两个阶段运行的:

阶段一:

选取所有可获得并且可应用的方法或者构造函数。

阶段二:

在第一阶段选取的方法或构造函数中选取最精确的一个。

如果方法或者构造函数A可以接受传递给方法或者构造函数B的任何参数,那么我们就可以说方法A比方法B缺乏精确性。

上述程序中,两个构造函数都是可获得并且可应用的,构造函数Test(Object o)可以接受任何传递给Test(double[] doubleArray)的参数,因为每一个double数组都是一个Object类型,但是并不一定每个Object类型都是一个double数组,因此程序会选择最精确匹配的构造方法。


结论:

如果想让上述的程序调用Object类型参数的构造方法,只需要进行如下修改即可:

public class Test{

public static void main(String[] args) {
new Test((Object)null);
}

private Test(Object o){
System.out.println("Object");
}

private Test(double[] doubleArray){
System.out.println("Double array");
}
}
即如果想要强制要求编译器选择一个精确的重载版本,需要将实际的参数转型为目标重载方法中形式参数所声明的类型,但是这种方式选择重载版本体验非常的不好,更好的建议为:

(1).避免使用重载,为不同方法取不同名字。

(2).对于构造方法,使用静态工厂模式或者建造者模式来减少对重载版本的需求量。

(3).如果必须要使用重载,请确保所有的重载版本所接受的参数类型都互不兼容,这样任何两个重载版本都不会同时是可应用的。


2.静态变量共享问题:

问题:

我们经常使用静态变量做计数器,下面的程序使用一个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 Test{

public static void main(String[] args) {
Dog[] dogs = {new Dog(), new Dog()};
for(Dog dog : dogs){
dog.woof();
}
Cat[] cats = {new Cat(), new Cat(), new Cat()};
for(Cat cat : cats){
cat.meow();
}
System.out.print(Dog.getCount() + " woofs and ");
System.out.println(Cat.getCount() + " meows");
}
}
我们期望上述程序打印出“2 woofs and 3 meows”,但是程序真实运行结果为”5 woofs and 5 meows“。


原因:

之所以出现了计数器不对的问题是因为Dog和Cat都从共同的父类计数器Counter那里继承了count域,而count是一个静态域,每个静态域在声明它的类及其所有子类*享一份单一的拷贝,因此Dog和Cat使用的相同的count域,每一个对woof()或meow()的调用都在递增这个域,因此总共增加了5次,所以造成最后打印输出5的问题。


结论:

知道了问题的原因,就容易解决问题了,静态变量是声明它的类及其所有子类共享的,因此如果每个子类都需要一个单独的拷贝,则需要在每个类中声明自己的静态域(也可以声明非静态域),代码如下:

class Dog{
private static int count = 0;
public Dog(){}
public void woof(){
count++;
}
public static int getCount(){
return count;
}
}

class Cat{
private static int count = 0;
public Cat(){}
public void meow(){
count++;
}
public static int getCount(){
return count;
}
}
另外从面向对象程序设计来看,Dog和Cat继承Counter也不符合要求,在设计一个类的时候,如果该类构建于另一个类的行为之上,那么有两种选择:

继承:一个类扩展另一个类,适用于一个类的每一个实例都是另一个类的一个实例。

组合:在一个类中包含另一个类的一个实例,适用于一个类的每一个实例都有另一个类的一个实例。

Dog和Cat显然不是计数器,但是可以拥有计数器,在有些不确定的情况下优选组合而非继承。


3.当静态方法遇到覆盖:

问题:

下面程序企图使用方法覆盖演示面向对象的多态特性:

class Dog{
public static void bark(){
System.out.print("woof ");
}
}

class Basenji extends Dog{
public static void bark(){}
}

public class Test{

public static void main(String[] args) {
Dog woofer = new Dog();
Dog nipper = new Basenji();
woofer.bark();
nipper.bark();
}
}
很多人认为上述程序是方法覆盖,因此结果应该是一个woof,可惜程序真实运行结果是两个woof。


原因:

方法重载和静态方法从严格意义上来说都不能算做是多态,因为它们都是静态单分派的而非动态多分派。

当一个程序调用一个静态方法时,被调用的静态方法在编译时就被选定了,它的选定是基于修饰符的编译期类型而做出的,修饰符的编译器类型就是我们给出的方法调用表达式中圆点左边部分的名字。

在上述程序代码中,两个方法调用的修饰符分别是变量woofer和nipper,他们都被声明为Dog类型,因此它们具有相同的编译期类型,因此都调用了Dog类中的静态方法。


结论:

多态中的方法覆盖是针对非静态方法的,若想让上述程序只打印输出一个woof,只需要把方法变成非静态即可,代码如下:

class Dog{
public void bark(){
System.out.print("woof ");
}
}

class Basenji extends Dog{
public void bark(){}
}

public class Test{

public static void main(String[] args) {
Dog woofer = new Dog();
Dog nipper = new Basenji();
woofer.bark();
nipper.bark();
}
}

在java中,如果子类和父类具有相同签名的静态方法,称之为子类对父类静态方法的隐藏,只有非静态方法才能被覆盖。


4.类初始化的循环:

问题:

下面的程序使用单例模式计算1970年出生的人的年龄,代码如下:

public class Test{
public static final Test INSTANCE = new Test();
private final int age;
private static final int CURRENT_YEAR = Calendar.getInstance().get(Calendar.YEAR);
private Test(){
age = CURRENT_YEAR - 1970;
}
public int getAge(){
return age;
}
public static void main(String[] args) {
System.out.println("Age is " + INSTANCE.getAge());
}
}
第一眼看去,这个程序是在计算当前的年份减去1970的值,如果它是正确的,那么在2014年,该程序应该打印出Age is 44,但是程序的真实运行结果是Age is -1970。


原因:

改程序的问题是由类初始化顺序中的循环而引起的,Test类的初始化是有java虚拟机对其main()方法的调用而触发的,顺序如下:

首先,静态域被设置为缺省值,其中INSTANCE域被设置为null,CURRENT_YEAR被设置为0.

其次,静态域初始器按照其出现的顺序执行,第一个静态域是INSTANCE,它的值是通过调用Test()构造函数而计算出来的。在构造方法中会用一个涉及静态域CURRENT_YEAR的表达式来初始化age变量,通常读取一个静态域是会引起一个类被初始化的事件之一,但是我们已经在初始化Test类了,递归的初始化尝试会直接被忽略掉,因此CURRENT_YEAR的值仍旧是其缺省值0,因此年龄被计算出来为-1970.

最后,从构造函数返回以完成Test类的初始化,假设我们是在2014年运行改程序,那么我们就将静态域CURRENT_YEAR初始化成了2014,遗憾的是,这个常量现在所具有的正确值对于Test类的age计算已经太迟了,age的值已经被计算为-1970了,这正是后续INSTANCE.getAge()方法调用返回的值。


结论:

该程序表明,在final类型的静态域被初始化之前,存在着读取它的值的可能,而此时该静态域包含的还只是其所属类型的缺省值。

我们通常将final类型的域看作是常量,但是final类型的域只有在其初始化表达式是常量表达式市才是常量。

修正上述程序方法很简单,只需要重新对静态域的初始器进行排序,使的每一个初始器都出现在任何依赖域它的初始器之前,对于上述程序中CURRENT_YEAR的声明放在INSTANCE声明和初始化之前,代码如下:

public class Test{
private static final int CURRENT_YEAR = Calendar.getInstance().get(Calendar.YEAR);
public static final Test INSTANCE = new Test();
private final int age;
private Test(){
age = CURRENT_YEAR - 1970;
}
public int getAge(){
return age;
}
public static void main(String[] args) {
System.out.println("Age is " + INSTANCE.getAge());
}
}

单例设计模式,服务提供者框架和类型安全的枚举模式本质上都是初始化循环。


5.类型比较和转换:

问题:

下面的程序展示java中类型比较和转换,代码如下:

public class Test{
public static void main(String[] args) {
String s = null;
System.out.println(s instanceof String);
System.out.println(new Test() instanceof String);
Test t = (Test)new Object();
}
}

对于第一个打印语句,很多人不确定instanceof应用于一个空对象引用时的行为。

对于第二个打印语句,很多人认为毫无疑问应该输出false。

对于第三个类型转换语句,很多人认为无法通过编译。

程序真实情况是:

对于第一个打印语句,结果应为false。

对于第二个打印语句,会有一个“Incompatible conditional operand types Test and String”编译错误。

对于第三个类型转换语句,如果注释掉编译错误的第二条打印语句,运行时会报“java.lang.ClassCastException”。


原因:

对于第一个打印语句,展示了instanceof操作符应用于一个空对象引用时的行为,尽管null对于每一个引用类型来说都是其子类型,但是instanceof操作符被定义为在其左操作数为null时返回false,因此如果instanceof告诉你一个对象引用是某个特定类型的实例,那么你就可以将其转型为该类型,并调用该类型的方法,而不用担心会抛出类型转换或者空指针异常。

对于第二个打印语句,展示了instanceof操作符在测试一个类的实例,以查看它是否是某个不相关的类型时所表现出来的行为。instanceof操作符规定:如果两个操作数的类型都是类,其中一个必须是另一个的子类型,否则将会导致编译器失败。

第三个类型转换语句,展示了当要被转型的表达式的静态类型是转型类型的超类时,转型操作符的行为。与instanceof操作符类似,如果在一个转型操作中的两种类型都是类,那么其中一个必须是另一个的子类型,但是编译器在编译期无法确定表达式new Object()的运行期类型不可能是Test的一个子类型,因此可以通过编译,只有在运行期才会抛出类型转换异常。


结论:

instaceof对于左操作数为null时,一律返回false。

当instanceof操作符的两个操作数都是类时,这两个类必须是父子类型关系。


6.实例初始化顺序:

问题:

下面的程序使用不可变的值类来演示实例初始化顺序问题,代码如下:

class Point{
protected final int x, y;
private final String name;
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{
protected 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, "Blue"));
}
}
很多人认为程序的运行结果应该为:[4,2]:Blue,但是程序的真实运行结果为:[4,2]:null。


原因:

之所以出现令人意想不到的结果是因为上述代码中有个实例初始化顺序问题,详细分解上述代码的执行过程如下:

(1).执行ColorPoint类中main方法时,调用ColorPoint的构造方法。

(2).调用ColorPoint类构造方法时调用父类Point类的构造方法。

(3).调用Point类构造方法时调用其makeName方法,Point类的makeName方法调用子类ColorPoint的makeName方法.

(4).调用ColorPoint类的makeName方法时,color属性还未被赋值,因此使用默认值null,所以Point类的name属性被赋值为[4,2]:null。

(5).父类Point构造完成,返回子类ColorPoint构造方法对color属性进行赋值,但是此时赋值已经太晚,赋值Point的name属性值已经被缓存,

(6).ColorPoint实例调用父类Point的toString方法打印输出我们看到的[4,2]:null结果。

通过分析我们看到一个final实例域color在被赋值之前,存在着读取其值的可能性,而此时final域包含的仍旧是其所属类型的默认值,这种实例初始化顺序循环问题经常引起程序的混乱。


结论:

当一个构造器调用了一个已经被其子类覆盖的方法,总会引起实例初始化循环问题,因为在父类构造器中所调用的覆盖方法总是在实例被初始化之前执行。

想要避免这个问题,就千万不要在构造方法,实例初始器和伪构造器(readObject/clone)中直接或间接调用可覆盖的方法。

可以通过延迟初始化name域来修正上述程序中的问题,即当Point对象第一次被使用时初始化,以此取代积极初始化,代码如下:

class Point{
protected final int x, y;
private String name;
Point(int x, int y){
this.x = x;
this.y = y;
}

protected String makeName(){
return "[" + x + "," + y + "]";
}

public final synchronized String toString(){
if(name == null){
name = makeName();
}
return name;
}
}
尽管延迟初始化name域可以修正这个问题,但是对于让一个值类去扩展另一个值类,并且在其中添加一个会对equals方法产生影响的域仍旧不是一个好主要,因为无法在超类和子类上都提供一个基于值的equals方法,而同时又不违反Object中关于equals方法的同样约定。

因此,请千万注意不要在构造方法,实例初始器或伪构造器中调用可覆盖的方法,因为在实例初始化中产生的循环将是致命的。


7.类初始化顺序:

问题:

为了计算从0到99的整数之和,下面的程序中同时使用积极初始化和延迟初始化确保程序在任何情况下都能正常运行,代码如下:

class Cache{
static{
initializelIfNecessary();
}
private static int sum;
public static int getSum(){
initializelIfNecessary();
return sum;
}
private static boolean initialized = false;
private static synchronized void initializelIfNecessary(){
if(!initialized){
for(int i = 0; i < 100; i++){
sum += i;
}
initialized = true;
}
}
}

public class Test{
public static void main(String[] args){
System.out.println(Cache.getSum());
}
}
本程序期望打印出4950,但是程序真实运行结果为9900,是我们期望的两倍,即重复计算了一次。


原因:

我们通过对程序执行过程的分析,来找出问题的根本原因:

(1).在Test类的main方法中调用Cache.getSum()方法时,由于是静态方法,因此首先java虚拟机加载Cache类。

(2).在java虚拟机加载Cache类的时候,执行类初始化方法(静态初始化块和静态变量赋值),静态初始化块中调用initializelIfNecessary()方法时initialized域还没有被初始化,因此被赋值为默认值false,sum也没有被初始化,默认值为0,在循环中计算出sum的值为4950并且缓存在sum变量中,initialized变量被赋值为true。

(3).静态初始化块执行完毕顺序执行静态变量赋值,此时sum没有被赋值,因此缓存了4950的计算结果,而initialized又被赋值为false。

(4).Cache类加载和初始化完成之后调用getSum方法时initialized还是false,因此又执行了initializelIfNecessary中运行,sum值被累加了4940,最后打印输出9900.


结论:

由于不能在延迟初始化和积极初始化中做出选择,上述代码同时使用了二者,结果产生了初始化的顺序问题,千万不要同时使用延迟初始化和积极初始化。

我们可以使用静态初始化顺序重排和积极初始化来避免上面的重复计算问题,代码如下:

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

public class Test{
public static void main(String[] args){
System.out.println(Cache.getSum());
}
}
只有当性能方面考虑,或者需要打破初始化循环时才使用延迟初始化,否则积极初始化是更加常用的选择。

另外在类初始化时请特别注意静态变量声明引起的初始化顺序问题。