Java谜题9-高级谜题

谜题86:有毒的括号垃圾

你能否举出这样一个合法的Java表达式,只要对它的某个子表达式加上括号就可以使其成为不合法的表达式,而添加的括号只是为了注解未加括号时赋值的顺序? 
插入一对用来注解现有赋值顺序的括号对程序的合法性似乎是应该没有任何影响的。事实上,绝大多数情况下确实是没有影响的。但是,在两种情况下,插入一对看上去没有影响的括号可能会令合法的Java程序变得不合法。这种奇怪的情况是由于数值的二进制补码的不对称性引起的,就像在谜题33和谜题64中所讨论的那样。 你可能会联想到,最小的int型负数其绝对值比最大的int型正数大1:Integer.MIN_VALUE是-231,即-2,147,483,648,而Integer.MAX_VALUE是231-1,即2,147,483,647。 
Java不支持负的十进制字面常量;int和long类型的负数常量都是由正数十进制字面常量前加一元负操作符(-)构成。这种构成方式是由一条特殊的语言规则所决定的:在int类型的十进制字面常量中,最大的是2147483648。而从0到2147483647的所有十进制字面常量都可以在任何能够使用int类型字面常量的地方出现,但是字面常量2147483648只能作为一元负操作符的操作数来使用[JLS 3.10.1]。 
一旦你知道了这个规则,这个谜题就很容易了。符号-2147483648构成了一个合法的Java表达式,它由一元负操作符加上一个int型字面常量2147483648组成。通过添加一对括号来注解(很不重要的)赋值顺序,即写成-(2147483648),就会破坏这条规则。信不信由你,下面这个程序肯定会出现一个编译期错误,如果去掉了括号,那么错误也就没有了: 
public class PoisonParen {
    int i = -(2147483648);
}
类似地,上述情况也适用于long型字面常量。下面这个程序也会产生一个编译期错误,并且如果你去掉括号错误也会消失: 
public class PoisonParen {
long j = -(9223372036854774808L);
} 

谜题87:紧张的关系

在数学中,等号(=)定义了一种真实的数之间的等价关系(equivalence relation)。这种等价关系将一个集合分成许多等价类(equivalence class),每个等价类由所有相互相等的值组成。其他的等价关系包括有所有三角形集合上的“全等”关系和所有书的集合上的“有相同页数”的关系等。事实上,关系 ~ 是一种等价关系,当且仅当它是自反的、传递的和对称的。这些性质定义如下: 
•	自反性:对于所有x,x ~ x。也就是说,每个值与其自身存在关系 ~ 。 
•	传递性:如果x ~ y 并且y ~ z,那么x ~ z。也就是说,如果第一个值与第二个值存在关系 ~,并且第二个值与第三个值存在关系 ~ ,那么第一个值与第三个值也存在关系 ~ 。 
•	对称性:如果x ~ y,那么y ~ x。也就是说,如果第一个值和第二个值存在关系 ~ ,那么第二个值与第一个值也存在关系 ~ 。 
如果你看了谜题29,便可以知道操作符 == 不是自反的,因为表达式( Double.NaN == Double.NaN )值为false,表达式( Float.NaN == Float.NaN )也是如此。但是操作符 == 是否还违反了对称性和传递性呢?事实上它并不违反对称性:对于所有x和y的值,( x == y )意味着( y == x )。 传递性则完全是另一回事。 谜题35为操作符 == 作用于原始类型的数值时不符合传递性的原因提供了线索。当比较两个原始类型数值时,操作符 == 首先进行二进制数据类型提升(binary numeric promotion)[JLS 5.6.2]。这会导致这两个数值中有一个会进行拓宽原始类型转换(widening primitive conversion)。大部分拓宽原始类型转换是不会有问题的,但有三个值得注意的异常情况:将int或long值转换成float值,或long值转换成double值时,均会导致精度丢失。这种精度丢失可以证明 == 操作符的不可传递性。 
实现这种不可传递性的窍门就是利用上述三种数值比较中的两种去丢失精度,然后就可以得到与事实相反的结果。可以这样构造例子:选择两个较大的但不相同的long型数值赋给x和z,将一个与前面两个long型数值相近的double型数值赋给y。下面的程序就是其代码,它打印的结果是true true false,这显然证明了操作符 == 作用于原始类型时具有不可传递性。 
public class Transitive {
    public static void main(String[] args) throws Exception {
        long x = Long.MAX_VALUE;
        double y = (double) Long.MAX_VALUE;
        long z = Long.MAX_VALUE - 1;
        System.out.print((x == y) + “ “); // Imprecise!
        System.out.print((y == z) + “ “); // Imprecise!
        System.out.println(x == z);        // Precise!
    }
}
本谜题的教训是:要警惕到float和double类型的拓宽原始类型转换所造成的损失。它们是悄无声息的,但却是致命的。它们会违反你的直觉,并且可以造成非常微妙的错误(见谜题34)。更一般地说,要警惕那些混合类型的运算(谜题5、8、24和31)。本谜题给语言设计者的教训和谜题34一样:悄无声息的精度损失把程序员们搞糊涂了。 

谜题88:原生类型的处理

下面的程序由一个单一的类构成,该类表示一对类型相似的对象。它大量使用了5.0版的特性,包括泛型、自动包装、变长参数(varargs)和for-each循环。关于这些特性的介绍,请查看http://java.sun.com/j2se/5.0/docs/guide/language[Java-5.0]。这个程序的main方法只是执行这个类。那么它会打印什么呢? 
import java.util.*;
public class Pair<T> {
    private final T first;
    private final T second;

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T first() {
        return first;
    }
    public T second() {
        return second;
    }
    public List<String> stringList() {
        return Arrays.asList(String.valueOf(first),
                             String.valueOf(second));
    }

    public static void main(String[] args) {
        Pair p = new Pair<Object> (23, "skidoo");
        System.out.println(p.first() + " " + p.second());
        for (String s : p.stringList())
            System.out.print(s + " ");
    }
}
这段程序看上去似乎相当简单。它创建了一个对象对,其中第一个元素是一个表示23的Integer对象,第二个元素是一个字符串“skidoo”,然后这段程序将打印这个对象对的第一个和第二个元素,并用一个空格隔开。最后它循环迭代这些元素的string表示,并且再次打印它们,所以这段程序应该打印23 skidoo两次。然而可惜的是,它根本不能通过编译。更糟的是,编译器的错误消息更是另人困惑: 
Pair.java:26: incompatible types;
found: Object, required: String
            for (String s : p.stringList())
                               ^
如果Pair.stringList是声明返回List<Object>的话,那么这个错误消息还是可以明白的,但是事实是它返回的是List<String>。究竟是怎么回事呢? 
这个十分奇怪的现象是因为程序使用了原生类型(raw type)而引起的。一个原生类型就是一个没有任何类型参数的泛型类或泛型接口的名字。例如,List<E> 是一个泛型接口,List<String> 是一个参数化的类型,而List 就是一个原生类型。在我们的程序中,唯一用到原生类型的地方就是在main方法中对局部变量p的声明: 
Pair p = new Pair<Object> (23, "skidoo"); 
一个原生类型很像其对应的参数化类型,但是它的所有实例成员都要被替换掉,而替换物就是这些实例成员被擦除掉对应部分之后剩下的东西。具体地说,在一个实例方法声明中出现的每个参数化的类型都要被其对应的原生部分所取代[JLS 4.8]。我们程序中的变量p是属于原生类型Pair的,所以它的所有实例方法都要执行这种擦除。这也包括声明返回List<String>的方法stringList。编译器会将这个方法解释为返回原生类型List。 
当List<String>实现了参数化类型Iterable<String>时,List也实现了原生类型Iterable。Iterable<String>有一个iterator方法返回参数化类型Iterator<String>,相应地,Iterable也有一个iterator方法返回原生类型Iterator。当Iterator<String>的next方法返回String时,Iterator的next方法返回Object。因此,循环迭代p.stringList()需要一个Object类型的循环变量,这就解释了编译器的那个奇怪的错误消息的由来。这种现象令人想不通的原因在于参数化类型List<String>虽然是方法stringList的返回类型,但它与Pair的类型参数没有关系,事实上最后它被擦除了。 
你可以尝试通过将循环变量类型从String改成Object这一做法来解决这个问题: 
// Don’t do this; it doesn’t really fix the problem!
    for (Object s : p.stringList())
        System.out.print(s + " "); 
这样确实令程序输出了满意的结果,但是它并没有真正解决这个问题。你会失去泛型带来的所有优点,并且如果该循环在s上调用了任何String方法,那么程序甚至不能通过编译。正确解决这个问题的方法是为局部变量p提供一个合适的参数化的声明: 
    Pair<Object> p = new Pair<Object>(23, "skidoo"); 
以下是要点强调:原生类型List和参数化类型List<Object>是不一样的。如果使用了原生类型,编译器不会知道在list允许接受的元素类型上是否有任何限制,它会允许你添加任何类型的元素到list中。这不是类型安全的:如果你添加了一个错误类型的对象,那么在程序接下来的执行中的某个时刻,你会得到一个ClassCastException异常。如果使用了参数化类型List<Object>,编译器便会明白这个list可以包含任何类型的元素,所以你添加任何对象都是安全的。 
还有第三种与以上两种类型密切相关的类型:List<?>是一种特殊的参数化类型,被称为通配符类型(wildcard type)。像原生类型List一样,编译器也不会知道它接受哪种类型的元素,但是因为List<?>是一个参数化类型,从语言上来说需要更强的类型检查。为了避免出现ClassCastException异常,编译器不允许你添加除null以外的任何元素到一个类型为List<?>的list中。 
原生类型是为兼容5.0版以前的已有代码而设计的,因为它们不能使用泛型。5.0版中的许多核心库类,如collections,已经利用泛型做了改变,但是使用这些类的已有程序的行为仍然与在以前的版本上运行一样。这些原生类型及其成员的行为被设计成可以镜像映射到5.0之前的Java语言上,从而保持了兼容性。 
这个Pair程序的真正问题在于编程者没有决定究竟使用哪种Java版本。尽管程序中大部分使用了泛型,而变量p却被声明成原生类型。为了避免被编译错误所迷惑,请避免在打算用5.0或更新的版本来运行的代码中编写原生类型。如果一个已有的库方法返回了一个原生类型,那么请将它的结果存储在一个恰当的参数化类型的变量中。然而,最好的办法还是尽量将该库升级到使用泛型的版本上。虽然Java提供了原生类型和参数化类型间的良好的互用性,但是原生类型的局限性会妨碍泛型的使用。 
实际上,这种问题在用getAnnotation方法在运行期读取Class的注解(annotations)的情况下也会发生,该方法是在5.0版中新添加到Class类中的。每次调用getAnnotation方法时都会涉及到两个Class对象:一个是在其上调用该方法的对象,另一个是作为传递参数指出需要哪个类的注解的对象。在一个典型的调用中,前者是通过反射获得的,而后者是一个类名称字面常量,如下例所示: 
Author a = Class.forName(name).getAnnotation(Author.class);
你不必把getAnnotation的返回值转型为Author。以下两种机制保证了这种做法可以正常工作:(1)getAnnotation方法是泛型的。它是通过它的参数类型来确定返回类型的。具体地说,它接受一个Class<T>类型的参数,返回一个T类型的值。(2)类名称字面常量提供了泛型信息。例如,Author.class的类型是Class<Author>。类名称字面常量可以传递运行时和编译时的类型信息。以这种方式使用的类名称字面常量被称作类型符号(type token)[Bracha04]。 
与类名称字面常量不同的是,通过反射获得的Class对象不能提供完整的泛型类型信息:Class.forName的返回类型是通配类型Class<?>。在调用getAnnotation方法的表达式中,使用的是通配类型而不是原生类型Class,这一点很重要。如果你采用了原生类型,返回的注解具有的就是编译期的Annotation类型而不是通过类名称字面常量指示的类型了。下面的程序片断错误地使用了原生类型,和本谜题中最初的程序一样不能通过编译,其原因也一样: 
Class c = Class.forName(name);           // Raw type!
Author a = c.getAnnotation(Author.class);    // Type mismatch
总之,原生类型的成员被擦掉,是为了模拟泛型被添加到语言中之前的那些类型的行为。如果你将原生类型和参数化类型混合使用,那么便无法获得使用泛型的所有好处,而且有可能产生让你困惑的编译错误。另外,原生类型和以Object为类型参数的参数化类型也不相同。最后,如果你想重构现有的代码以利用泛型的优点,那么最好的方法是一次只重构一个API,并且保证新的代码中绝不使用原生类型。 

谜题89:泛型迷药

和前一个谜题一样,本谜题也大量使用了泛型。我们从前面的错误中吸取教训,这次不再使用原生类型了。这个程序实现了一个简单的链表数据结构。main程序构建了一个包含2个元素的list,然后输出它的内容。那么,这个程序会打印出什么呢? 
public class LinkedList<E> {
    private Node<E> head = null;

    private class Node<E> {
        E value;
        Node<E> next;

        // Node constructor links the node as a new head
        Node(E value) {
            this.value = value;
            this.next = head;
            head = this;
        }
    }

    public void add(E e) {
        new Node<E>(e);
        // Link node as new head
    }

    public void dump() {
        for (Node<E> n = head; n != null; n = n.next)
            System.out.println(n.value + " ");
    }

    public static void main(String[] args) {
        LinkedList<String> list = new LinkedList<String>();
        list.add("world");
        list.add("Hello");
        list.dump();
    }
}
又是一个看上去相当简单的程序。新元素被添加到链表的表头,而dump方法也是从表头开始打印list。因此,元素的打印顺序正好和它们被添加到链表中的顺序相反。在本例中,程序先添加了“world”然后添加了“Hello”,所以总体来看它似乎就是一个复杂化的Hello World程序。遗憾的是,如果你尝试着编译它,就会发现它不能通过编译。编译器的错误消息是令人完全无法理解的: 
LinkedList.java:11: incompatible types
found : LinkedList<E>.Node<E>
required: LinkedList<E>.Node<E>
              this.next = head;
                            ^
LinkedList.java:12: incompatible types
found : LinkedList<E>.Node<E>
required: LinkedList<E>.Node<E>
              head = this;
                      ^
编译器试图告诉我们,这个程序太过复杂了。一个泛型类的内部类可以访问到它的外围类的类型参数。而编程者的意图很明显,即一个Node的类型参数应该和它外围的LinkedList类的类型参数一样,所以Node完全不需要有自己的类型参数。要订正这个程序,只需要去掉内部类的类型参数即可: 
// 修复后的代码,可以继续修改得更好
public class LinkedList<E> {
    private Node head = null;

    private class Node {
        E value;
        Node next;

        //Node的构造器,将node链接到链表上作为新的表头
        Node(E value) {
            this.value = value;
            this.next = head;
            head = this;
        }
    }

    public void add(E e) {
        new Node(e);
        //将node链接到链表上作为新的表头
    }

    public void dump() {
        for (Node n = head; n != null; n = n.next)
            System.out.print(n.value + " ");
    }
}
以上是解决问题的最简单的修改方案,但不是最优的。最初的程序所使用的内部类并不是必需的。正如谜题80中提到的,你应该优先使用静态成员类而不是非静态成员类[EJ Item 18]。LinkedList.Node的一个实例不仅含有value和next域,还有一个隐藏的域,它包含了对外围的LinkedList实例的引用。虽然外部类的实例在构造阶段会被用来读取和修改head,但是一旦构造完成,它就变成了一个甩不掉的包袱。更糟的是,这样使得构造器中被置入了修改head的负作用,从而使程序变得难以读懂。应该只在一个类自己的方法中修改该类的实例域。 
因此,一个更好的修改方案是将最初的那个程序中对head的操作移到LinkedList.add方法中,这将会使Node成为一个静态嵌套类而不是真正的内部类。静态嵌套类不能访问它的外围类的类型参数,所以现在Node就必须有自己的类型参数了。修改后的程序既简单清楚又正确无误: 
class LinkedList<E> {
    private Node<E> head = null;
    private static class Node<T> {
        T value; Node<T> next;
        Node(T value, Node<T> next) {
            this.value = value;
            this.next = next;
        }
    }
    public void add(E e) {
        head = new Node<E>(e, head);
    }
    public void dump() {
        for (Node<E> n = head; n != null; n = n.next)
            System.out.print(n.value + " ");
    }
} 
总之,泛型类的内部类可以访问到其外围类的类型参数,这可能会使得程序模糊难懂。本谜题所阐述的误解对于初学泛型的程序员来说是普遍存在的。在一个泛型类中设置一个内部类并不是必错的,但是很少用到这种情况,而且你应该考虑重构你的代码来避免这种情况。当你在一个泛型类中嵌套另一个泛型类时,最好为它们的类型参数设置不同的名字,即使那个嵌套类是静态的也应如此。对于语言设计者来说,或许应该考虑禁止类型参数的遮蔽机制,同样的,局部变量的遮蔽机制也应该被禁止。这样的规则就可以捕获到本谜题中的错误了。 

谜题90:荒谬痛苦的超类

下面的程序实际上不会做任何事情。更糟的是,它连编译也通不过。为什么呢?又怎么来订正它呢? 
public class Outer {
    class Inner1 extends Outer{}
    class Inner2 extends Inner1{}
}
这个程序看上去简单得不可能有错误,但是如果你尝试编译它,就会得到下面这个有用的错误消息: 
Outer.java:3: cannot reference this before supertype constructor has been called
    class Inner2 extends Inner1{}
    ^ 
好吧,可能这个消息不那么有用,但是我们还是从此入手。问题在于编译器产生的缺省的Inner2的构造器为它的super调用找不到合适的外部类实例。让我们来看看显式地包含了构造器的该程序: 
public class Outer {
    public Outer() {}
    class Inner1 extends Outer{
        public Inner1() {
            super();    // 调用Object()构造器
        }
    }
    class Inner2 extends Inner1{
        public Inner2() {
            super();    // 调用Inner1()构造器
        }
    }
}
现在错误消息就会显示出多一点的信息了: 
Outer.java:12: cannot reference this before
                 supertype constructor has been called
        super();    // 调用Inner1()构造器 
        ^
因为Inner2的超类本身也是一个内部类,一个晦涩的语言规则登场了。正如大家知道的,要想实例化一个内部类,如类Inner1,需要提供一个外部类的实例给构造器。一般情况下,它是隐式地传递给构造器的,但是它也可以以expression.super(args)的方式通过超类构造器调用(superclass constructor invovation)显式地传递[JLS 8.8.7]。如果外部类实例是隐式传递的,编译器会自动产生表达式:它使用this来指代最内部的其超类是一个成员变量的外部类。这确实有点绕口,但是这就是编译器所作的事情。在本例中,那个超类就是Inner1。因为当前类Inner2间接扩展了Outer类,Inner1便是它的一个继承而来的成员。因此,超类构造器的限定表达式直接就是this。编译器提供外部类实例,将super重写成this.super。 解释到这里,编译错误所含的意思可扩展为: 
Outer.java:12: cannot reference this before
                        supertype constructor has been called
         this.super();    
         ^
现在问题就清楚了:缺省的Inner2的构造器试图在超类构造器被调用前访问this,这是一个非法的操作[JLS 8.8.7.1]。解决这个问题的蛮力方法是显式地传递合理的外部类实例: 
public class Outer {
     class Inner1 extends Outer {}
     class Inner2 extends Inner1{
         public Inner2() {
             Outer.this.super();
         }
     }
}
这样可以通过编译,但是它太复杂了。这里有一个更好的解决方案:无论何时你写了一个成员类,都要问问你自己,是否这个成员类真的需要使用它的外部类实例?如果答案是否定的,那么应该把它设为静态成员类。内部类有时是非常有用的,但是它们很容易增加程序的复杂性,从而使程序难以被理解。它们和泛型(谜题89)、反射(谜题80)以及继承(本谜题)都有着复杂的交互方式。在本例中,如果你将Inner1设为静态的便可以解决问题了。如果你将Inner2也设为静态的,你就会真正明白这个程序做了什么:确实是一个相当好的意外收获。 
总之,这种一个类既是外部类又是其他类的超类的方式是很不合理的。更一般地讲,扩展一个内部类的方式是很不恰当的;如果必须这样做的话,你也要好好考虑其外部类实例的问题。另外,尽量用静态嵌套类而少用非静态的[EJ Item 18]。大部分成员类可以并且应该被声明为静态的。 

谜题91:序列杀手

这个程序创建了一个对象并且检查它是否遵从某个类的不变规则(invariant)。然后该程序序列化这个对象,之后将其反序列化,然后再次检查反序列化得到的副本是否也遵从这个规则。它会遵从这个规则吗?如果不是的话,又是为什么呢? 
import java.util.*;
import java.io.*;

public class SerialKiller {
    public static void main(String[] args) {
        Sub sub = new Sub(666); 
        sub.checkInvariant();

        Sub copy = (Sub) deepCopy(sub);
        copy.checkInvariant();
    }

    // Copies its argument via serialization (See Puzzle 80)
    static public Object deepCopy(Object obj) {
        try {
            ByteArrayOutputStream bos = 
                new ByteArrayOutputStream();
            new ObjectOutputStream(bos).writeObject(obj);
            ByteArrayInputStream bin =
                new ByteArrayInputStream(bos.toByteArray());
            return new ObjectInputStream(bin).readObject(); 
        } catch(Exception e) {
            throw new IllegalArgumentException(e); 
        }
    }
}

class Super implements Serializable {
    final Set<Super> set = new HashSet<Super>();
} 

final class Sub extends Super {
    private int id;
    public Sub(int id) {
        this.id = id;
        set.add(this); // Establish invariant
    }

    public void checkInvariant() {
        if (!set.contains(this))
            throw new AssertionError("invariant violated");
    }

    public int hashCode() {
        return id;
    }

    public boolean equals(Object o) {
        return (o instanceof Sub) && (id == ((Sub)o).id);
    }
}
程序中除了使用了序列化之外,看起来是很简单的。子类Sub覆写了hashCode方法和equals方法。这些覆写过的方法符合了相关的一般规约[EJ Item 7,8]。Sub的构造器建立了这个类的不变规则,而在它这么做的时候没有调用到可覆写的方法(谜题51)。Super类有一个单独的Set<Super>类型的域,Sub类添加了另外一个int类型的域。Super和Sub都不需要定制的序列化形式。那么什么东西会出错呢? 
其实有很多。对于5.0版本,运行该程序会得到如下的“堆轨迹”(stack trace): 
Exception in thread “main” AssertionError
    at Sub.checkInvariant(SerialKiller.java:41)
    at SerialKiller.main(SerialKiller.java:10)
序列化和反序列化一个Sub实例会产生一个被破坏的副本。为什么呢?阅读程序并不会帮助你找出原因,因为真正引起问题的代码在其他地方。错误是由HashSet的readObject方法引起的。在某些情况下,这个方法会间接地调用某个未初始化对象的被覆写的方法。为了组装(populate)正在被反序列化的散列集合,HashSet.readObject调用了HashMap.put方法,而它会去调用每个键(key)的hashCode方法。由于整个对象图(object graph)正在被反序列化,并没有什么可以保证每个键在它的hashCode方法被调用的时候已经被完全初始化了。实际上,这很少会成为一个问题,但是有时候它会造成绝对的混乱。这个缺陷会在正在被反序列化的对象图的某些循环中出现。 
为了更具体一些,让我们看看程序中在反序列化Sub实例的时候发生了什么。首先,序列化系统会反序列化Sub实例中Super的域。唯一的这样的域就是set,它包含了一个对HashSet的引用。在内部,每个HashSet实例包含一个对HashMap的引用,HashMap的键是该散列集合的元素。HashSet类有一个readObject方法,它创建一个空的HashMap,并且使用HashMap的put方法,针对集合中的每个元素在HashMap中插入一个键-值对。put方法会调用键的hashCode方法以确定它所在的单元格(bucket)。在我们的程序中,散列映射表中唯一的键就是Sub的实例,而它的set域正在被反序列化。这个实例的子类域(subclass field),即id,尚未被初始化,所以它的值为0,即所有int域的缺省初始值。不幸的是,Sub的hashCode方法将返回这个值,而不是最后保存在这个域中的值666。因为hashCode返回了错误的值,相应的键-值对条目将会放入错误的单元格中。当id域被初始化为666时,一切都太迟了。当Sub实例在HashMap中的时候,改变这个域的值就会破坏这个域,进而破坏HashSet,破坏Sub实例。程序检测到了这个情况,就报告出了相应的错误。 
这个程序说明,包含了HashMap的readObject方法的序列化系统总体上违背了不能从类的构造器或伪构造器(pseudoconstructor)中调用其可覆写方法的规则[EJ Item 15]。Super类的(缺省的)readObject方法调用了HashSet的(显式的)readObject方法,该方法进而调用了它内部的HashMap的put方法,put方法又调用了Sub实例的hashCode方法,而该实例正处在创建的过程中。现在我们遇到大麻烦了:Super类中,从Object类继承而来的hashCode方法在Sub中被覆写了,但是这个被覆写的方法在Sub的域被初始化之前就被调用了,而该方法需要依赖于Sub的域。 
这个问题和谜题51中的那个本质上几乎是完全相同的。唯一真正不同的是在这个谜题中,readObject伪构造器错误地替代了构造器。HashMap和Hashtable的readObject方法受到的影响是类似的。 
对于平台的实现者来说,也许可以通过牺牲一点性能来订正HashSet、HashMap和HashTable中的这个问题。当针对HashSet时,订正的策略可以是重写readObject方法使其在反序列化期间,将集合的元素保存到一个数组中,而不是将它们放入散列集合中。这样,当被反序列化的散列集合的公共方法首次被调用的时候,数组中的元素将在方法执行之前被插入到集合中。 
这种方法的代价是它需要在与散列集合的每个公共方法相对应的条目上检查是否要组装散列集合。由于HashSet、HashMap以及HashTable都是性能临界(performance-critical)的,所以这个方法看起来是不可取的。更不幸的是,所有的用户都要付出这种代价,甚至当他们不对这些集合(collection)进行序列化时也是如此。这就违背了这样一个原则:你绝不应该为你不使用的功能而付出代价。 
另外一个可能的方法是让HashSet.readObject方法调用ObjectInputStream.registerValidation方法,用以将散列集合的组装延迟到validateObject方法回调时再进行。这个方法看起来更吸引人,因为它仅仅增加了反序列化的开销,但是它会破坏任何在“包含流”(containing stream)的反序列化过程中试图使用HashSet实例的代码。 
上述的2个方法是否可行还有待研究。但是现在,我们必须接受这些类的这种行为。幸运的是,有一个工作区(workaround):如果一个HashSet、Hashtable或HashMap被序列化,那么请确认它们的内容没有直接或间接地引用到它们自身。这里的内容(content),指的是元素、键和值。 
这里也有一个教训送给那些使用可序列化类型的开发者们:在readObject或readResolve方法中,请避免直接或间接地在正在进行反序列化的对象上调用任何方法。如果你必须在某个类型C的readObject或readResolve方法中违背这条建议,请确定没有C的实例会出现在正在被反序列化的对象图的某个循环内。不幸的是,这不是一个本地的属性:一般说来,你需要考虑到整个系统来验证这一点。 
总之,Java的序列化系统是很脆弱的。为了正确而且高效地序列化大量的类,你必须编写readObject或readResolve方法[EJ Items 55-57]。这个谜题说明了,为了避免破坏反序列化的实例,你必须小心翼翼地编写这些方法。HashSet、HashMap和Hashtable的readObject方法很容易产生这种错误。对于平台设计者来说,如果你决定提供序列化系统,请不要提供如此脆弱的东西。健壮的序列化系统是很难设计的。 

谜题92:双绞线

下面这个程序使用一个匿名类执行了一个并不自然的动作。它会打印出什么呢? 
public class Twisted {
    private final String name;
    Twisted(String name) {
        this.name = name;
    }
    private String name() {
        return name;
    }
    private void reproduce() {
        new Twisted("reproduce") {
            void printName() {
                System.out.println(name());
            }
        }.printName();
    }
    public static void main(String[] args) {
        new Twisted("main").reproduce();
    }
}
根据一个肤浅的分析会判断该程序不能通过编译。reproduce方法中的匿名类试图调用Twisted类中的私有方法name。一个类不能调用另一个类的私有方法,是吗?如果你试图编译这个程序,你会发现它可以成功地通过编译。在顶层的类型(top-level type)中,即本例中的Twisted类,所有的本地的、内部的、嵌套的和匿名的类都可以毫无限制地访问彼此的成员[JLS 6.6.1]。这是一个欢乐的大家庭。 
在了解了这些之后,你可能会希望程序打印出reproduce,因为它在new Twisted(“reproduce”)实例上调用了printName方法,这个实例将字符串”reproduce”传给其超类的构造器使其存储到它的name域中。printName方法调用name方法,name方法返回了name域的内容。但是如果你运行这个程序,你会发现它打印的是main。现在的问题是它为什么会做出这样的事情呢? 
这种行为背后的原因是私有成员不会被继承[JLS 8.2]。在这个程序中,name方法并没有被继承到reproduce方法中的匿名类中。所以,匿名类中对于printName方法的调用必须关联到外围(“main”)实例而不是当前(“reproduce”)实例。这就是含有正确名称的方法的最小外围范围(enclosing scope)(谜题 71和79)。 
这个程序违反了谜题90中的建议:在”reproduce”中的匿名类即是Twisted类的内部类又扩展了它。单独这一点就足以使程序难以阅读。再加上调用超类的私有方法的复杂度,这个程序就成了纯粹的冗长的废话。这个谜题可以用来强调谜题6中的教训:如果你不能通过阅读代码来分辨程序会做什么,那么它很可能不会做你想让它做的事。要尽量争取程序的清晰。 

谜题93:类的战争

下面这个谜题测试了你关于二进制兼容性(binary compatibility)的知识:当你改变了某个类所依赖的另外一个类时,第一个类的行为会发生什么改变呢?更特殊的是,假设你编译的是如下的2个类。第一个作为一个客户端,第二个作为一个库类,会怎么样呢: 
public class PrintWords {
    public static void main(String[] args) {
        System.out.println(Words.FIRST  + " " + 
                           Words.SECOND + " " +
                           Words.THIRD);
    }
}

public class Words {
    private Words() { };  // Uninstantiable

    public static final String FIRST  = "the";
    public static final String SECOND = null;
    public static final String THIRD  = "set";
} 
现在假设你像下面这样改变了那个库类并且重编译了这个类,但并不重编译客户端的程序: 
public class Words {
    private Words() { };  // Uninstantiable

    public static final String FIRST  = "physics";
    public static final String SECOND = "chemistry";
    public static final String THIRD  = "biology";
}
此时,客户端的程序会打印出什么呢? 
简单地看看程序,你会觉得它应该打印 physics chemistry biology;毕竟Java是在运行期对类进行装载的,所以它总是会访问到最新版本的类。但是更深入一点的分析会得出不同的结论。对于常量域的引用会在编译期被转化为它们所表示的常量的值[JLS 13.1]。这样的域从技术上讲,被称作常量变量(constant variables),这可能在修辞上显得有点矛盾。一个常量变量的定义是:一个在编译期被常量表达式初始化的final的原始类型或String类型的变量[JLS 4.12.4]。在知道了这些知识之后,我们有理由认为客户端程序会将初始值Words.FIRST, Words.SECOND, Words.THIRD编译进class文件,然后无论Words类是否被改变,客户端都会打印the null set。 
这种分析可能是有道理的,但是却是不对的。如果你运行了程序,你会发现它打印的是the chemistry set。这看起来确实太奇怪的了。它为什么会做出这种事情呢?答案可以在编译期常量表达式(compile-time constant expression)[JLS 15.28]的精确定义中找到。它的定义太长了,就不在这里写出来了,但是理解这个程序的行为的关键是null不是一个编译期常量表达式。 
由于常量域将会编译进客户端,API的设计者在设计一个常量域之前应该深思熟虑。如果一个域表示的是一个真实的常量,例如π或者一周之内的天数,那么将这个域设为常量域没有任何坏处。但是如果你想让客户端程序感知并适应这个域的变化,那么就不能让这个域成为一个常量。有一个简单的方法可以做到这一点:如果你使用了一个非常量的表达式去初始化一个域,甚至是一个final域,那么这个域就不是一个常量。你可以通过将一个常量表达式传给一个方法使得它变成一个非常量,该方法将直接返回其输入参数。 
如果我们使用这种方法来修改Word类,在Words类被重新修改和编译之后,PrintWords类将打印出physics chemistry biology: 
public class Words {
private Words() {};  // Uninstantiable

    public static final String FIRST   = ident("the");
    public static final String SECOND  = ident(null);
    public static final String THIRD   = ident("set");

    private static String ident(String s) {
        return s;
    }
} 
在5.0版本中引入的枚举常量(enum constants),虽然有这样一个名字,但是它们并不是常量变量。你可以在枚举类型中加入枚举常量,对它们重新排序,甚至可以移除没有用的枚举常量,而且并不需要重新编译客户端。 
总之,常量变量将会被编译进那些引用它们的类中。一个常量变量就是任何被常量表达式初始化的原始类型或字符串变量。令人惊讶的是,null不是一个常量表达式。 
对于语言设计者来说,在一个动态链接的语言中,将常量表达式编译进客户端可能并不是一个好主意。这让很多程序员大吃一惊,并且很容易产生一些难以查出的缺陷:当缺陷被侦测出来的时候,那些定义常量的源代码可能已经不存在了。另外一方面,将常量表达式编译进客户端使得我们可以使用if语句来模拟条件编译(conditional compilation)[JLS 14.21]。为了正当目的可以不择手段的做法是需要每个人自己来判断的。

谜题94:迷失在混乱中

下面的shuffle方法声称它将公平的打乱它的输入数组的次序。换句话说,假设其使用的伪随机数发生器是公正的,它将会以均等的概率产生各种排列的数组。它真的兑现了它的诺言吗?如果没有,你将如何订正它呢? 
import java.util.Random;
public class Shuffle {
    private static Random rnd = new Random();
    public static void shuffle(Object[] a) {
        for(int i = 0; i < a.length; i++)
            swap(a, i, rnd.nextInt(a.length));
    }
    private static void swap(Object[] a, int i, int j) {
        Object tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    }
}
看看这个shuffle方法,它并没有什么明显的错误。它遍历了整个数组,将随机抽取的元素互换位置。这会公平地将数组打乱,对吗?不对。“它没有明显的错误”和“它明显没有错误”,这2种说法是很不同的。在这里,有很严重的错误,但是它并不明显,除非你专门研究算法。 
如果你使用一个长度为n的数组作为参数去调用shuffle方法,这个循环体会执行n次。在每次执行中,这个方法会选取从0到n-1这n个整数中的一个。所以,该方法就有nn 种不同的执行动作。我们假设随机数发生器是公平的,那么每一种执行动作出现的概率是相等的。每一种执行动作都产生数组的一种排列。但是,这里就有一个小问题:对于一个长度为n的数组来说,只有n!种不同的排列。(在n之后的感叹号表示了阶乘(factorial)操作:n的阶乘定义为n×(n-1) ×(n-2) ×…×1。)问题在于,对于任何大于2的n,nn 都无法被n!整除,因为n!包含了从2到n的所有质数因子,而nn 只包含了n所包含的质数因子。这就毫无疑问的证明了shuffle方法将会更多地产生某些排列。 
为了使这个问题更具体一些,让我们来考虑一个包含了字符串”a”,”b”,”c”的长度为3的数组。此时shuffle方法就有33 = 27种执行动作。这些动作出现机率相同,并且都会产生某个排列。数组有3! = 6种不同的排列:{“a”,”b”,”c”},{“a”,”c”,”b”},{“b”,”a”,”c”},{“b”,”c”,”a”},{“c”,”a”,”b”}和{“c”,”b”,”a”}。由于27不能被6整除,比起其他的排列,某些排列肯定会被更多的执行动作所产生,所以shuffle方法并不是公平的。 
这里的一个问题就是,上述的证明只是证明了shuffle方法确实存在偏差,而并没有提供任何这种偏差的感性材料。有时候深入了解的最好办法就是动手实验。我们让该方法操作“恒等数组”(identity array,即满足a[i]=i的数组a),然后测试程序将计算每个位置上的元素的期望值(expected value)。宽松的说,这个期望值,就是在重复运行shuffle方法的时候,你在数组的某个位置上看到的所有数值的平均值。如果shuffle方法是公平的,那么每个位置的元素的期望值应该是相等的:((n-1)/2)。图10.1显示了在一个长度为9的数组中各个元素的期望值。请注意这张图特殊的形状:开始的时候比较低,然后增长超过了公平值(4),然后在最后一个元素下降到公平值。 
为什么这张图会有这种形状呢?我们不知道具体的细节,但是我们会有一些直觉上的认识。让我们把注意力集中到数组的第一个元素上。当循环体第一次执行之后,它会有正确的期望值(n-1)/2。然而在第2次执行中,有n分之1的可能性,随机数发生器会返回0且数组第一个元素的值会被设为1或0。也就是说,第2次执行系统地减少了第一个元素的期望值。在第3次执行中,也会有n分之1的可能性,第一个元素的值会被设为2、1或者0,然后就这么继续下去。在循环的前n/2次执行中,第一个元素的期望值是减少的。在后n/2次执行中,它的期望值是增加的,但是再也达不到它的公平值了。请注意,数组的最后一个元素肯定会有正确的期望值,因为在方法执行的最后一步,就是在数组的所有元素中为其选择一个值。 
好了,我们的shuffle方法是坏掉了。我们怎么修复它呢?使用类库中提供的shuffle方法: 
import java.util.*;
public static void shuffle(Object[] a) {
    Collections.shuffle(Arrays.asList(a));
}
如果库中有可以满足你需要的方法,请务必使用它[EJ Item 30]。一般来说,库提供了高效的解决方案,并且可以让你付出最小的努力。 
另外,在你忍受了所有这些数学的东西之后,如果不告诉你如何修复这个坏掉的shuffle方法是不公平的。修复方法是非常直接的。在循环体中,将当前的元素和某个在当前元素与数组末尾元素之间的所有元素中随机选择出来的元素进行互换。不要去碰那些你已经进行过值互换的元素。这本质上也就是库中的方法所使用的算法: 
public static void shuffle(Object[] a) {
    for(int i = 0; i < a.length; i++)
           swap(a, i, i + rnd.nextInt(a.length - i));
}
使用归纳法很容易证明这个方法是公平的。最基础情况,让我们观察长度为0的数组,这显然是公平的。根据归纳法的步骤,如果你将这个方法用在一个长度n>0的数组上,它会为这个数组的0位置上的元素随机选择一个值。然后,它会遍历数组剩下的元素:在每个位置上,它会在“子数组”中随机选择一个元素,这个子数组从当前位置开始到原数组的末尾。对于从位置1到原数组末尾的这个长度为n-1的子数组来说,如果将该方法作用在这个子数组上,它实际上也是在做上述的事。这就完成了证明。它同时也提供了shuffle方法的递归形式,它的细节就留给读者作为练习了。 
你可能会认为到此为止就是故事的全部内容了,但却还有一部分内容。你设想过这个经过修复的shuffle方法会等概率的产生一个表示52张牌的52个元素的数组的所有排列吗?毕竟我们只是证明了它是公平的。在这里你可能不会很惊讶地发现答案很显然是“不”。这里的问题是,在谜题的开始,我们做出了“使用的伪随机数发生器是公平的”这一假设。但是它不是。 
这个随机数发生器,java.util.Random,使用的是一个64位的种子,而它产生的随机数完全是由这个种子决定的。52张牌有52!种排列,而种子却只有264个。它能够覆盖的排列占所有排列的多少呢?你相信是百分之2.3×10-47吗?这只是委婉地表示了“实际上就没怎么覆盖”。如果你使用java.security.SecureRandom代替java.util.Random,你会得到一个160位的种子,但是它给你带来的东西少得惊人:对于元素个数大于40的数组,这个shuffle方法仍然不能返回它的某些排列(因为40!>2160) 。对于一个52个元素的数组,你只能获得所有可能的排列的百分之1.8×10-18 。 
这难道意味着你在洗牌的时候不能相信这些伪随机数发生器吗?这要看情况。它们确实只能产生所有可能排列的微不足道的一部分,但是它们没有我们前面所看到的那种系统性的偏差。公平地讲,这些发生器在非正式的场景中已经足够好用了。如果你需要一个尖端的随机数发生器,那你就需要到别的什么地方去寻找了。总之,像很多算法一样,打乱一个数组是需要慎重对待的。这么做很容易犯错并且很难发现错误。在其他条件相似的情况下,你应该优先使用类库而不是手写的代码。如果你想学习更多的关于本谜题的论题的内容,请参见[Knuth98 3.4.2]。 

谜题95:只是些甜点

本章的大多数谜题都是颇具挑战性的。但是这个不是。下面这个程序会打印出什么呢?如果你相信的话,前2个程序被报告为系统的缺陷[Bug 4157460 4763901]: 
public class ApplePie {
    public static void main(String[] args) {
        int count = 0;
        for(int i = 0; i < 100; i++); {
            count++;
        }
        System.out.println(count);
    }
}

import java.util.*;
public class BananaBread {
    public static void main(String[] args) {
        Integer[] array = { 3, 1, 4, 1, 5, 9 };
        Arrays.sort(array, new Comparator<Integer>() {
            public int compare(Integer i1, Integer i2) {
                return i1 < i2 ? -1 : (i2 > i1 ? 1 : 0);
            }
        });
       System.out.println(Arrays.toString(array));
  }
}

public class ChocolateCake {
    public static void main(String[] args) {
         System.out.println(true?false:true == true?false:true);
    }
}
如果你受够这些东西了,那么你不需要知道这些愚蠢谜题的详细解释,所以让我们把它们变得又短又甜: 
•	这个程序会打印出1。这是由多余的标号造成的。(分号的恶习?) 
•	这个程序在我们所知道的所有平台实现上都会打印出[3, 1, 4, 1, 5, 9]。从技术上说,程序的输出是未被定义的。它的比较器(comparator)承受着“是头我赢,是尾你输”的综合症。 
•	这个程序会打印出false。它书写的布局和它的操作符的优先级并不匹配。加一些括号可以解决问题。 
这个谜题的教训,也是整本书的教训,就是:不要像我的兄弟那样编码。 







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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值