Java IAQ:很少被回答的问题

Q什么是很少被回答的问题?

一个问题如果被回答地很少,有可能是因为知道答案的人很少,亦或是因为问题本身模糊不清、微不足道(但对你来讲可能很关键)。我似乎发明了一个术语,但是它在一个信息量很大的叫做About.com Urban legends 网站里也被提到了。Java相关的常见问题非常之多,但接下来我要讲的是Java不常问到的问题(不常见问题列表就没那么多了,其中包括了一些对C语言的冷嘲热讽。)

Qfinally 语句内的代码一定会被执行,对吧?

嗯,大部分时候是的。但也存在一些特例,比如:不管choice的值是什么,下面代码finally中的语句就不会被执行。

try 
{
    if (choice) 
    	while (true) ;
    else
      System.exit(1);
}
finally
{
    code.to.cleanup();
}

Q:在类C的一个方法m中调用this.getClass()是不是永远返回C

不。有时候对象x可以是一个c的子类c1,要么c1.m()这个方法不存在,要么x中某些方法调用了super.m()。无论上述那种情况,this.getClass()都会返回c1,而不是C.m()中的c。不过如果C是被final修饰的,那每次都会返回c是成立的。

Q:我自定义了一个equals方法,但是Hashtable忽略了它,为什么?

想要完全理解equals函数实际上是很难的。首先看下面几方面:

 1、你定义了一个错误的equals方法。比如你这样写:

public class C 
{
    public boolean equals(C that) 
    { 
    	return id(this) == id(that); 
    }
}

但为了让table.get(c)能正常工作,你需要为equals方法设置一个Object类型参数,而不是C类型的参数:

public class C 
{
	public boolean equals(Object that) 
	{
		return (that instanceof C) && id(this) == id((C)that);
 	}
}

为什么?Hashtable.get方法大概这样:

public class Hashtable 
{
	public Object get(Object key) 
	{
		Object entry;

    	//...

    	if (entry.equals(key)) //...
  	}
}

现在,entry.equals(key) 触发的方法取决于实际运行时的对象引用entry,以及声明的编译时变量key的类型。所以,当你调用table.get(new C(…))时,this会在C类中寻找参数为Object的equals方法。如果恰巧你有一个参数定义为为C的equals方法,那并没有任何关系。它会忽略,并继续寻找函数签名为equals(Object)的函数,最终找到equals(Object)。如果你想重写一个方法,你需要将它们的参数类型也匹配上。有些情况下,你可能想要两种方法,这样可以在类型已知的情况下避免由类型转换带来的额外开销:

public class C
{
	public boolean equals(Object that) 
	{
		return (this == that) || ((that instanceof C) && this.equals((C)that));
	}

	public boolean equals(C that)
	{
		return id(this) == id(that); // Or whatever is appropriate for class C
	}
}

2、你实现的equals方法并不是绝对等价的:equals方法必须是对称的、传递的和自反的。对称性是指a.equals(b)的值必须与b.equals(a)一致。(大多数人会把这一点搞混。)传递性是指如果a.equals(b)为真且b.equals(c)也为真那么a.equals(c)必须为真。自反性是指a.equals(a)必须为真,并且这也是为什么要有上述(this == that)这个条件测试(这是比较好的做法,因为这会提高效率:利用==测试要比跟踪一个对象进行测试快很多,并且一定程度上屏蔽了循环指针链的递归问题)。

3、你忘记了hashCode方法。任何时候你定义了一个equals方法,那么就应该同时定义一个hashCode方法。你必须保证两个相等的对象有着同样的hashCode,并且如果你想追求更好的hashtable性能,应该尝试着把最不相等的对象设置成不同的hashCodes。一些类将hashCodes进行了缓存,所以它们仅被计算一次。如果是这样的话,你在equals方法中加一句if (this.hashSlot != that.hashSlot) return false会节省不少时间。

4、你没有处理好继承。首先,考虑到如果来自两个不同类的对象可以相等的话。在你说“不!一定不会!”之前,想想下面这种情况:一个Rectangle类中有width和height两个字段,另一个Box类除了上述两个字段外还有一个depth字段。那么,如果depth==0,这时的Box是否与Rectangle等价呢?你也许会赞成这个观点。如果你所处理的类不是被final修饰的,那么它有可能成为其它类的父类,此时作为一个良民,你会想要善待你的子类。特别的情况下,你可能想允许C类的子类利用super调用C.equals(),就像这样:

public class C2 extends C 
{
	int newField = 0;
	public boolean equals(Object that) 
	{
		if (this == that) 
			return true;
		else if (!(that instanceof C2)) 
			return false;
		else 
			return this.newField == ((C2)that).newField && super.equals(that);
	}
}

为了能实现上述功能,你需要在C.euqals的定义中对类谨慎地处理。例如,检查类型时用that instanceof C而不是that.getClass() == C.class。具体原因参看前面IAQ。如果你确定两个对象的父类一样的时候是相等的,那就可以使用this.getClass() == that.getClass() 。

5、你没有处理好循环引用,比如像这样:

public class LinkedList 
{
	Object contents;
	LinkedList next = null;

	public boolean equals(Object that) 
	{
		return (this == that) || ((that instanceof LinkedList) && this.equals((LinkedList)that));
	}

	public boolean equals(LinkedList that) 
	{
		// Buggy!
		return 	Util.equals(this.contents, that.contents) &&
				Util.equals(this.next, that.next);
	}
}

这里我假设有一个Util类可以做如下工作:

public static boolean equals(Object x, Object y)
{
	return (x == y) || (x != null && x.equals(y));
}

我想把这个方法放到Object内部;如果没有它,那你不得不在测试的时候抛出null指针的异常。总之,LinkedList.equals 这个方法如果用来检测两个循环引用的链表,那它永远不会返回(链表中一个元素指向另一个元素)。至于如何在线性时间内,仅使用两个字的额外存储空间完成这件事,请参看Common Lisp的list-length函数的描述。(我怕你们想自己搞清楚它,所以这里就不剧透答案了。)

Q我尝试向super传一个方法,但有时候它不正常工作。为什么?

下面是针对上述问题的一段简化后的代码示例:

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/
public class HashtableWithPlurals extends Hashtable
{
	/** Make the table map both key and key + "s" to value. **/
	public Object put(Object key, Object value) 
	{
		super.put(key + "s", value);
		return super.put(key, value);
	}
}

你需要在调用super的时候非常小心,并且一定要清楚super的方法究竟会做什么。在这个例子中,Hashtable.put 的职责是将key和value的映射关系记录到表中。然而,如果hashtable太满了,那Hashtable.put 会为表分配一个更大的数组,将所有旧的对象拷贝过去,然后再次递归调用table.put(key, value)。因为Java是根据目标运行时的类别解析方法的,在这个例子中,代码中Hashtable递归调用将会调用HashtableWithPlurals.put(key, value)。最终的结果就是:有时候(当table的容量在错误的时间溢出时),你在得到“dogs”和“dog”的同时,也得到一个“dogss”。任何文档提到过put递归调用这种现象有时会发生么?没有。在这种情况下,查看JDK的源码是非常有帮助的。

Q为什么在我使用get时,Properties对象总是忽略默认值?

你不应该对Properties对象调用get方法;而应该调用getProperty方法。许多人认为二者的区别是getProperty声明了返回值为String类型,而get声明的返回值类型为Object。但实际上二者之间有更大的区别:getProperty会查看默认值。get是继承自Hashtable的方法,它会忽视默认值,所以get的职责就像Hashtable文档中描述的一样,但是这种方式可能会跟你想象中的不一样。其它继承自Hashtable的方法也会忽略默认值(如isEmpty和toString方法),举个例子:

Properties defaults = new Properties();
defaults.put("color", "black");
Properties props = new Properties(defaults);
System.out.println(props.get("color") + ", " +
props.getProperty(color));
// This prints "null, black"

这点在文档中有描述么?可能吧。Hashtable的文档中提到了table的实体,同时提到了如果你认为默认值不是表中实体的话,那么Properties的行为是与Hashtable一致的。如果出于某些原因,你认为默认值是表中的实体(正如你会以为能得到与getProperty一样的效果)那你就晕了。

Q承看起来很容易出错。有什么办法能防止犯错么?

前两个问题都表示出了一个观点,那就是程序员需要在继承类的时候特别小心,并且在使用其它类的子类时也同样要小心。上述两个问题让John Outsterhout发表了如下言论“实现继承导致了代码之间纠缠不清,变得更为脆弱,这正如goto语句被滥用时发现的问题一样。最终,这导致面向对象系统经常饱受复杂度和缺乏代码重用的困扰” (Scripting, IEEE Computer, March 1998)。与此同时,据说Edsger Dijkstra说过“面向对象编程有时也并不尽如人意,这极有可能起源于加利福尼亚”(来自一些签名的文件)。

我认为没有可以保证一定安全的方法,但是下面是一些可以加以考虑的事情:

  • 继承一些没有源码的类是很有风险的;在你不能预见的某些情况下,文档可能是不完整的
  • 调用super方法一般会导致不可预料的问题。
  • 对于不需要重写的方法,你需要花与重写方法同样多的精力来处理。这是利用面向对象的继承机制的一大缺点。继承的确可以让你少些一些代码,但你为此也不得不考虑一下那些没有用到的代码。
  • 如果你在子类中违背了父类中的任何方法的约定,亦或是违背了整个父类的约定,那你就是自讨苦吃了。约定何时被改变很难说,因为契约是非正式的(正式的部分包括了类型签名,但是剩下的部分仅在注释里体现而已)。在Properties例子中,很难说契约到底有没有被打破,因为并没有明确指定默认值是否被考虑为table的实体。

Q除了继承,还有其它类似的做法

委托是继承的一种替代品。委托的意思就是可以将其它类的实例以实例变量的方式添加到一个类中,并将参数传递给这个实例变量。通常来讲,这要比继承更加安全,因为由于实例变量是一个已知类,而不是一个新类,所以这么做的话会迫使你深思熟虑每次要传递的参数与此同时,这么做也不会强制你接受父类的所有方法:你可以仅使用其中一些需要的方法。另一方面,这会使你写更多的代码,也就导致了其很难复用(因为它不是一个子类)。

在HashtableWithPlurals例子中,利用代理的方式可以这样写注意:在JDK1.2版本中,Dictionary是不推荐使用的;可以使用Map替代):

/** A version of Hashtable that lets you do
 * table.put("dog", "canine");, and then have
 * table.get("dogs") return "canine". **/
public class HashtableWithPlurals extends Dictionary
{
	Hashtable table = new Hashtable();
	/** Make the table map both key and key + "s" to value. **/
	public Object put(Object key, Object value) 
	{
		table.put(key + "s", value);
		return table.put(key, value);
	}
	//...  Need to implement other methods as well
}

在Properties例子中,如果你想强调默认值是实体这种解释的话,那最好使用代理。为什么Properties还用继承处理呢?因为Java的实现团队追求简洁的代码,而且他们太匆忙了。

Q为什么Java里没有全局变量?

由于一些原因,并不推荐大家使用全局变量:

  • 添加全局变量打破了引用透明的原则(你永远不再可能通过单一语句或表达式明白它们各自的含义了:你需要要结合它们在上下文中设置的各种全局变量来进行理解)。
  • 全局变量会使程序变得低内聚:你需要了解更多的信息来理解代码是怎么运行的。面向对象编程的一大主要特点就是将全局的变量打散,使其变成更容易理解的局部变量
  • 当你添加一个全局变量时,你的程序就被限制成只能运行一个实例了。你眼中的全局别人看来可能认为是局部的:他们可能想同时运行两个程序。

出于上述原因,Java决定废弃全局变量。

Q我还是很怀念全局变量。我能做点什么?

那要看你想做什么了。无论哪种情况,你都需要确定以下两件事:认清这个所谓的全局变量一共需要多少个副本?以及放在哪里比较合适?以下是一些常见的解决方案:

如果你真的只是想在用户首次启动JVM的时候,在程序中保留一个副本的话,那你也许可以使用一个静态实例变量。比如,在你的应用中有一个叫做MainWindow的类,并且你想记录下用户打开窗口的数量,并在用户关闭最后一个窗口时初始化“真要退出吗?”这个对话框。如此一来,你可以这样做:

// One variable per class (per JVM)

public Class MainWindow {

  static int numWindows = 0;

  ...

  // when opening: MainWindow.numWindows++;

  // when closing: MainWindow.numWindows--;

}

大多数情况下,你需要的只是一个类的实例变量。比如,假设你在写一个网页浏览器并且想将访问历史记录当做全局变量那么在Java中,如果将其设置成一个Browser类内的实例变量会更好。这样的话,用户完全可以在同一个JVM中同时运行两个浏览器,之间也不会相互影响。

// One variable per instance

public class Browser {

  HistoryList history = new HistoryList();

  ...

  // Make entries in this.history

}

现在假设你完成了浏览器的大部分设计与实现,这时候你发现想要在Http类内的Cookies类里面打印出一些错误信息但是不知道在哪里展示这些信息。你可以简单在Browser类中添加一个实例变量,用它来记录待输出的流或帧,但目前你还没有将当前的Browser对象中的实例传递给Cookies类的方法。你并不希望在传递Browser对象的时候修改大部分的函数签名。你也不能用一个静态变量来解决,因为可能有多个Browser对象同时运行。然而,如果你可以保证每个线程中只有一个Browser对象(尽管每个Browser对象可能会有多个线程),那么有一种比较好的解决方法:在Browser类中存储一个静态表,保存线程与Browser对象之间的映射关系,然后根据当前所在线程查找正确的Browser对象(这里就是要找到待显示错误信息的Browser对象)。

// One "variable" per thread

public class Browser {

  static Hashtable browsers = new Hashtable();

  public Browser() { // Constructor

    browsers.put(Thread.currentThread(), this);

  }

  ...

  public void reportError(String message) {

    Thread t = Thread.currentThread();

    ((Browser)Browser.browsers.get(t))

      .show(message)

  }

}

最后,如果你想要一个全局变量在JVM期间一直存在,亦或是想让其在多个JVM之间通过网络互相共享。那么你大概需要一个通过JDBC访问的数据库,或者将数据序列化,然后将它存成文件的形式。

Q我可以将Math.sin(x)写成sin(x)吗?

长话短说:Java1.5之前的版本不可以。Java1.5之后的版本可以通过引用static imports实现;你现在可以这样写:import static java.lang.Math.*然后直接可以调用sin(x)。但是要注意来自Sun的警告“:你什么时候应该使用静态导入?一定要谨慎!”

下面是一些针对Java1.5之前版本的解决方案:

 

如果你只是想用Math中的一小部分方法,那你可以将它们封装到你自己的类中:

public static double sin(double x) 
{
	return Math.sin(x); 
}

public static double cos(double x) 
{
	return Math.cos(x); 
}

//...

sin(x)

静态方法需要一个目标(就是点符号左面的东西),这个目标要么是一个类的名字,要么是一个与具体取值无关的某个类的对象,但一定要声明成正确的类。所以你可以为每次调用少敲三个字符,就像这样:

// Can't instantiate Math, so it must be null.
Math m = null;
//...
m.sin(x)

java.lang.Math是被final修饰的类,所以不能被继承,但如果你有一些自己的静态函数,并且想在自己的各个类之间互相共享使用的话,那你可以把它们打包起来,然后再使用的时候继承它们

public abstract class MyStaticMethods
{
	public static double mysin(double x) 
	{
		//...
	}
}

public class MyClass1 extends MyStaticMethods 
{
	//...
	mysin(x)
}

Just Java的作者Peter van der Linden在他的FAQ中反对上述最后两种做法。大多数情况下,我也认为Math m = null 是一种糟糕的做法,但我不认同MyStaticMethods 的例子是一种“为了使用可有可无的缩写(不如直接用类别层级的方式进行表示)而导致缺乏面向对象风格的继承做法”。首先,说缩写不重要是一种旁观者的想法;缩写可能是极其重要的(参看这个例子来了解我是如何利用这种做法来达到理想效果的)。其次,倒不如他自以为是的说这是一种糟糕的面向对象风格。对于Java来讲,你可以说这事一种糟糕的风格,但是对于具有多继承机制的语言来讲,我这种用法更容易被接受。

另一种考虑这个问题的点是:Java的某些特性(对每个语言来讲)会有一些无可避免的权衡,并且其中还混杂着各种问题。我同意MyClass1继承MyStaticMethods这种做法会误导用户以为MyClass1继承了一些来自MyStaticMethods的方法,并且我也赞同这样做会无法继承真正需要的类,这也是不好的。但对Java而言,类一般是封装和编译(大部分时候)和一些命名空间的单元。MyStaticMethods这种方法在继承机制面前有负面效果,但是在命名空间这方面有正面作用。如果你认为继承更重要,那我不会与你争论了。但你真的认为一个类同时做多件事要比只做一件事好吗?你真的认为风格的规定一定比权衡更重要吗?

Qnull是Object类型么?

当然不是。我这里的否定是指null instanceof Object 会返回false。

下面是一些你需要了解的与null相关的事情:

1、你不能对null调用方法:当x是null且m是非静态方法时,调用x.m()是错误的。(当m时静态方法时候是合法的,但那是跟x的类相关,与x这个对象本身的值并无关系。)

2、null只有一个,并不是每个类都有一个自己的null。例如,((String) null == (Hashtable) null)这样会返回true。

3、可以将null当做参数传给一个方法,前提是这个方法支持这种做法。要注意的是,有些方法支持这样做,有些方法不支持。比如,System.out.println(null)这样写没有问题,但是string.compareTo(null)这样就不行了。所以除非参数本身是显而易见的,否则你写方法的时候应该在javadoc里说明null这种参数是否是合法的。

4、JDK1.1到1.1.5版本中,将null当做参数直接传给匿名内部类的构造函数如new SomeClass(null){…})会导致编译错误。但传入一个结果是null的表达式是没有问题的,或者传入强制类型转换的null也可以,如new SomeClass( (String)null){…}

5、Null通常来讲至少有三种不同的含义:

  • 未初始化。变量或内存地址尚未被赋值。
  • 不存在/不可用。比如,在二叉树中,一般会将普通节点的子节点的指针设为null,以此来表示一个叶节点。
  • 空。比如,你可能会用null表示一棵空树。注意,虽然有些人会混淆,但这与上一点有些许的不同。不同之处在于null是否为一个可以接受的树节点,还是一个表示不是树节点的特殊值。对比下列三种不同的二叉树中序周游的实现:
// null means not applicable

// There is no empty tree.

class Node {

  Object data;

  Node left, right;

  void print() {

    if (left != null)

      left.print();

    System.out.println(data);

    if (right != null)

      right.print();

  }

}
// null means empty tree

// Note static, non-static methods

class Node {

  Object data;

  Node left, right;

  void static print(Node node) {

    if (node != null) node.print();

  }

  void print() {

    print(left);

    System.out.println(data);

    print(right);

  }

}
// Separate class for Empty

// null is never used

interface Node { void print(); }

class DataNode implements Node{

  Object data;

  Node left, right;

  void print() {

    left.print();

    System.out.println(data);

    right.print();

  }

}

class EmptyNode implements Node {

  void print() { }

}

QObject究竟有多大?为什么没有sizeof?

C语言有sizeof运算符,这是必须要有的,因为用户需要管理malloc的调用,同时也是因为一些原生的类型(如long)的大小并没有一个统一的标准。Java并不需要sizeof,但是如果有这个运算符的话当然会方便很多。如果想在Java里得到类似sizeof的效果,你可以这样做:

static Runtime runtime = Runtime.getRuntime();

...

long start, end;

Object obj;

runtime.gc();

start = runtime.freememory();

obj = new Object(); // Or whatever you want to look at

end =  runtime.freememory();

System.out.println("That took " + (start-end) + "

bytes.");

这个方法并不总是奏效,因为垃圾回收可能发生在你代码正在进行检测的时候,那样就会丢掉字节的计数。并且,如果你使用的是JIT类的编译器,那么生成代码也会产生一些额外的字节。

在Sun 的JDK VM中,你也许会感到很吃惊,一个Object会占用16字节,或是4字大小。其中的内容是这样的:头信息占用了两个字大小,一个字指向了对象所属的类,另一个字指向了实例的变量。即使Object没有实例变量,Java也会为其分配一字大小的空间。最后,还有一个“handle”,这是一个指向两字大小的头信息的指针。Sun声称这一额外的间接层使垃圾回收过程变得更为简单。(而近15年以来,高性能Lisp和Smalltalk两种语言却不使用间接层的垃圾回收器。我也听说微软的JVM并没有这种额外的间接层,这点尚未被我证实。)

一个空的new String()占用40字节,或是10字:3个字来存储头信息,3个字来存储实例变量(开始索引结束索引以及字符数组),和4个字来存储空的字符数组。从一个已有的字符串建立一个字串仅需6个字的空间,因为字符数组是共享的。将Integer类型的key/value键值对存入Hashtable需要64字节(这其中包含了预先给Hashtable中的数组所分配的4字节):我会让你明白这是为什么。

Q初始化代码的执行顺序是怎样的?我应该怎么安排它们?

在一个类中,实例变量的初始化代码可以出现在3个地方:

在类(或父类)的实例变量初始化器中。

class C 
{
    String var = "val";}
在类(或父类)的构造函数中。

public C() { var = "val"; }
类的初始化代码块中。这是Java1.1中新加入的功能;这类似于静态初始化代码块,但是不用static关键字修饰。

{ var = "val"; }

当你写下new C()时,初始化的顺序是这样的(不考虑内存不够的情况):

1、调用C父类的构造函数(除非C是Object这个类,因为Object没有父类)。大多数情况都会调用无参数构造函数,除非程序员在构造函数最开始的时候显式地写下了super(…)。

2、一旦父类的构造函数返回了,接下来实例变量初始化器和对象初始化器会按照文字顺序(从左到右)执行。不要被javadoc和javap用字母顺序所迷惑,在这里并不重要。

3、现在会执行构造函数中余下的代码。这里可以设置实例变量,或者做任何其它事情。

实际上你对上述三种初始化方式有很大的自主选择权。我推荐的是使用实例变量初始化器,这样一来,如果这个变量的值与所用的构造函数无关,则可以不必为每个构造函数都写一遍初始化代码了。仅在初始化情况非常复杂(比如需要用到循环)的时候再去使用代码块初始化,这样可以避免在多个构造函数中重复初始化同样的东西。剩下的就可以让构造函数去完成了。

下面是一个例子:

Program:

class A {

    String a1 = ABC.echo(" 1: a1");

    String a2 = ABC.echo(" 2: a2");

    public A() {ABC.echo(" 3: A()");}

}

class B extends A {

    String b1 = ABC.echo(" 4: b1");

    String b2;

    public B() {

        ABC.echo(" 5: B()");

        b1 = ABC.echo(" 6: b1 reset");

        a2 = ABC.echo(" 7: a2 reset");

    }

}

class C extends B {

    String c1;

    { c1 = ABC.echo(" 8: c1"); }

    String c2;

    String c3 = ABC.echo(" 9: c3");

    public C() {

        ABC.echo("10: C()");

        c2 = ABC.echo("11: c2");

        b2 = ABC.echo("12: b2");

    }

}

public class ABC {

    static String echo(String arg) {

        System.out.println(arg);

        return arg;

    }

    public static void main(String[] args) {

        new C();

    }

}

 

输出:

1: a1

 2: a2

 3: b1

 4: B()

 5: b1 reset

 6: a2 reset

 7: c1

 8: c3

 9: C()

10: c2

11: b2

Q谈谈类的初始化?

从实例创建中区分出类的初始化是很重要的一点。实例在你利用new来调用构造函数时被创建。一个类C,是在第一次被激活使用的时候初始化的。在这个过程中,这个类的初始化代码会以文本顺序运行。一共有两种类初始化代码:静态初始化代码块(static {…})和类的变量初始化(static String var = …)。

以下是对激活使用(active use)的一些定义,当你第一次进行如下任何一种操作时,就出发了激活使用这个条件:

1、通过调用构造函数创建了一个C的实例。

2、调用了C中定义的的静态方法(不是继承来的)。

3、对C中定义的静态变量(不是继承来的)进行读写。如果静态变量是被常量表达式(比如一些只用到了原始操作符的表达式(如+或者||)、常量以及被static final所修饰的变量)那么不会算数,因为这些是在编译的时候被初始化的。

下面是一个例子:

Program:

class A {

    static String a1 = ABC.echo(" 1: a1");

    static String a2 = ABC.echo(" 2: a2");

}

class B extends A {

    static String b1 = ABC.echo(" 3: b1");

    static String b2;

    static {

        ABC.echo(" 4: B()");

        b1 = ABC.echo(" 5: b1 reset");

        a2 = ABC.echo(" 6: a2 reset");

    }

}

class C extends B {

    static String c1;

    static { c1 = ABC.echo(" 7: c1"); }

    static String c2;

    static String c3 = ABC.echo(" 8: c3");

    static {

        ABC.echo(" 9: C()");

        c2 = ABC.echo("10: c2");

        b2 = ABC.echo("11: b2");

    }

}

public class ABC {

    static String echo(String arg) {

        System.out.println(arg);

        return arg;

    }

    public static void main(String[] args) {

        new C();

    }

}

输出:

1: a1

 2: a2

 3: b1

 4: B()

 5: b1 reset

 6: a2 reset

 7: c1

 8: c3

 9: C()

10: c2

11: b2

Q我有一个类,内有6个实例变量,每一个变量都可以选择初始化或者不初始化。那么我是应该写64个构造函数么?

你当然不需要写(26)个构造函数。假设你有一个类叫C,它的定义如下:

public class C { int a,b,c,d,e,f; }

你可以为构造函数做如下几件事:

1、对极有可能需要的几种变量组合进行猜测,并且为之提供构造函数。赞成的观点认为:这是惯用的做法。反对的观点认为:很难完全猜对;会产生大量冗余代码。

2、定义可串联的setter方法,因为它们会返回this。如此一来,为每个实例变量定义一个setter,然后调用默认构造函数之后调用它们

public C setA(int val) { a = val; return this; }

...

new C().setA(1).setC(3).setE(5);

赞成:这是一种相当简洁且高效的方法。一些类似的观点在Bjarne Stroustrop的The Design and Evolution of C++一书中第156页被讨论过了。反对:你需要实现所有的setter,这并不遵从JavaBean规则(因为它们返回this而不是void),并且如果两个值之间需要交互的话那这种方法也不适用了。

3、在默认的构造函数中利用非静态的初始化代码块对匿名子类进行初始化:

new C() {{ a = 1; c = 3; e = 5; }}

赞成:十分简洁,没有使用setter那么凌乱;反对:实例变量不能是私有的,处理子类需要额外的间接成本,而这个对象可能根本就不是C这个类(虽然它是C的一个实例)。这仅在你对实例变量有访问权限的时候才管用,然而包括经验丰富的Java程序员在内的大多数人都不会明白。

实际上很简单:定义一个新的没有命名的(匿名的)C的子类,而这个子类没有新添任何方法或变量,但初始化代码块初始化了ac和e。如此定义这个类的话,你就相当于在创建一个实例。当我把这展示给Guy Steele看得时候,他说“哈哈!这太酷了,好吧,但我可能不会提倡这么做……”。和平时一样,Guy是对的(对了,你还可以用这种方法创建并初始化向量。你要知道能如此创建并初始化是非常给力的一件事儿,想想看,new String[] {“one”, “two”, “three”}就可以初始化一个String数组了。 曾经你以为必须用赋值语句对vector进行初始化的工作,现在也可以用类似的方法解决了new Vector(3) {{add(“one”); add(“two”); add(“three”)}})。

4、你可以换一种支持选择性初始化部分变量的语言。比如,C++就支持默认参数。所以你可以这么写:

class C {

public: C(int a=1, int b=2, int c=3, int d=4, int e=5);

}

...

new C(10); // Construct an instance with defaults for b,c,d,e

Common Lisp和Python都有关键字参数,也支持默认参数,所以你可以这么写:

C(a=10, c=30, e=50)            # Construct an instance; use defaults for b and d.

Q何时调用构造函数,何时调用其它法呢?

最直观的回答就是在你想new一个对象的时候调用构造函数;这是new这个关键字的用途。而我的回答是:构造函数往往被滥用了,调用它们和它们所做的工作两方面都被滥用了。下面是一些需要考虑的问题:

  • Setter方法:正如我们在之前的问题中所看到的,有些人会写很多构造函数。而通常来讲,最好要控制住构造函数的数量,然后提供一些setter方法,让他们它们做剩余的初始化工作。如果这些方法返回this,那你可以通过一个表达式就完成对象的创建;否则,创建一个对象需要多条语句。善用setter方法是件好事,因为在创建对象时需要修改的变量往往之后也可能要修改,所以为什么要在构造函数和setter方法里写一样的代码呢?
  • 工厂:有时候你想创建某个类或某个接口的实例,但你并不关心到底是那个子类创建的,亦或你想推迟到运行时再做决定。比如,你正在写一个计算器程序,你可能会想调用new Number(string)如果string是浮点型格式的话希望它返回Double,如果string是整数格式的话希望它返回Long。但出于以下两点,你无法实现上述功能:Number是一个抽象类,你不能直接调用它的构造函数,并且每一次调用构造函数都会返回所属类的实例,而并不是它子类的实例。
  • 一种可以像构造函数一样返回对象且对如何构造有更大选择余地(也可以指定其类型)的方法被称为工厂。Java没有自带对工厂模式的支持,但是你仍可以自己动手写一个工厂模式。
  • 缓存与回收:构造函数一定会创建一个新的对象。但是创建一个新的对象消耗非常大。像现实世界中一样,你可以以循环利用的方法来降低垃圾回收的代价。比如,new Boolean(x)会创建一个Boolean对象,但你最好优先循环使用已有的值(x ? Boolean.TRUE : Boolean.FALSE),而不是浪费资源去申请一个新的。如果Java提倡使用上述的机制而不是单一的提倡使用构造函数就完美了。Boolean只是一个例子;你应该也考虑其它不可变,诸如CharacterInteger也许还包括一些你自定义的类。下面是一个有关Number的回收工厂的例子。如果我有选择权的话,我想调用Number.make,但是很显然我没法向Number类添加方法,所以我只能用别的方法了:
public Number numberFactory(String str) throws NumberFormatException {

    try {

      long l = Long.parseLong(str);

      if (l >= 0 && l < cachedLongs.length) {

        int i = (int)l;

        if (cachedLongs[i] != null) return cachedLongs[i];

        else return cachedLongs[i] = new Long(str);

      } else {

        return new Long(l);

      }

    } catch (NumberFormatException e) {

      double d = Double.parseDouble(str);

      return d == 0.0 ? ZERO : d == 1.0 ? ONE : new Double(d);

    }

  }

  private Long[] cachedLongs = new Long[100];

  private Double ZERO = new Double(0.0);

  private Double ONE = new Double(1.0);

可以看出new的功能很有用,但是工厂的回收机制同样很有用。Java之所以仅支持new,是因为这是最简单最有效的方法,并且Java的宗旨也是尽量保持语言自身的简洁。但这并不意味着你自己的类库需要按照这一低标准来约束自己。(而且这并不意味着内置的库也需要这种约束条件,但是很可惜,他们还是这么做了。)

Q我的代码会在创建对象或在GC开始之前时被杀掉吗?

假设应用程序不得不操纵许多3D几何点。很明显,依Java的风格来做就是去写一个Point类,内含3个double变量x、y、z坐标。但是为大量点进行申请和回收的确会导致性能上的问题。而你可以自己建立资源池对存储进行管理。你可以在程序运行之初申请一大批Point对象,并将其存入数组中,而不是每次用到时才去申请。得到的数组(封装在一个类中)就像Point的工厂一样,但它是上下文感知的(socially-concious)回收工厂。调用pool.point(x,y,z) 时会返回数组中第一个未被使用的Point对象,将其3个变量设置为指定的值,并把它标记为已使用。而作为一个程序员来讲,当这些对象不再使用时,将它们放回资源池中便成了你的责任。

完成这点的方法有很多。如果你确定所申请的Point对象在使用一段时间之后会被丢弃的话,那最简单的方法就是这样做:利用int pos = pool.mark() 来标识当前资源池的位置。当你用完了之后,可以调用pool.restore(pos) 将原来位置的标志位重置。如果你想同时使用多个Point对象,那从不同的资源池里申请吧。资源池节省了垃圾回收时的开销(如果你有一个好的处理对象回收的模型)但是你仍然躲不开初始化对象时候的开销。你可以选择用“Fortran式”的方法来解决这个问题:用三个数组来存储x、y和z坐标,而不是用Point对象。你可以一个管理一批Point的类,而不必为单个点定义Point类。下面是一个资源池类的例子:

public class PointPool {

  /** Allocate a pool of n Points. **/

  public PointPool(int n) {

    x = new double[n];

    y = new double[n];

    z = new double[n];

    next = 0;

  }

  public double x[], y[], z[];

  /** Initialize the next point, represented as in integer index. **/

  int point(double x1, double y1, double z1) {

    x[next] = x1; y[next] = y1; z[next] = z1;

    return next++;

  }

  /** Initialize the next point, initilized to zeros. **/

  int point() { return point(0.0, 0.0, 0.0); }

  /** Initialize the next point as a copy of a point in some pool. **/

  int point(PointPool pool, int p) {

    return point(pool.x[p], pool.y[p], pool.z[p]);

  }

  public int next;

}

你可以这样使用它:

PointPool pool = new PointPool(1000000);

PointPool results = new PointPool(100);

...

int pos = pool.next;

doComplexCalculation(...);

pool.next = pos;

...

void doComplexCalculation(...) {

  ...

  int p1 = pool.point(x, y, z);

  int p2 = pool.point(p, q, r);

  double diff = pool.x[p1] - pool.x[p2];

  ...

  int p_final = results.point(pool,p1);

  ...

}

用PointPool 的方法申请100万个点花了半秒钟,而用Point类直接申请100万个点的方法需要6秒钟,所以相当于提速了12倍。

把p1,p2和p_final直接当做Point来声明远比当做int来声明好的多吧?在C/C++中,你可以用typedef int Point命令,但是Java不允许这样做。如果你想冒险一下,可以自己设置一下makefile,让文件在Java编译器运行之前先过一遍C语言的预处理器,然后你就可以这样写了:#define Point int.

Q我在循环中有一个复杂的表达式。为了保证效率,我想让这个计算仅做一次。但是为了可读性,我想让留在循环里被调用的地方。我该怎么办?

我们假设有这样一个例子,match是一个正则表达式的模式匹配函数,compile将一个字符串编译成一个有限状态机以供match调用:

for(;;) {

  ...

  String str = ...

  match(str, compile("a*b*c*"));

  ...

}

由于Java没有宏定义,随着时间的推移,你也许会需要一些控制,但你的选择很有限。其中一种可行的选择是,使用带有变量初始化的内部接口,这虽然不优雅但是是一种可行的方法。

for(;;) {

  ...

  String str = ...

  interface P1 {FSA f = compile("a*b*c*);}

  match(str, P1.f);

  ...

}

P1.f会在第一次使用P1时进行初始化,并且不会再改变,因为接口中的变量是隐式的static final的。如果你不想这么做,那可以换一种可以提供更多控制选择的语言。在Common Lisp中,字符序列#.表示其紧随在后的表达式会在读(编译)时计算,而不是在运行时。所以你可以这样写:

(loop

  ...

  (match str #.(compile "a*b*c*"))

  ...)

Q有哪些其它操作出奇的慢?

我该从何说起?下面是一些最该知道的东西。我在一个循环里写了一个计时功能,用来报告每秒钟千次迭代速度(K/sec)和每次迭代所需微秒数(uSecs)。整个测试在Sparc 20上完成,JDK版本为1.1.4,编译器为JIT。随后我注意到了如下信息:

  • 这些实验是在1998年完成的。编译器已经有所变化了。
  • 递减计数要比递增计数快两倍:我的机器可以在一秒钟内递减计数1.44亿次,但递增计数只能完成7200万次。
  • 调用Math.max(a,b)要比(a > b) ? a : b慢七倍,这是由于函数调用引起的。
  • 数组要比Vectors快15到30倍。Hashtable要比Vector快2/3倍。
  • Bitset.get(i)要比bits & 1 << i慢60倍。这大部分时候是因为函数的同步调用造成的。当然,如果超过了64位,这个测试可能就不准了。下面是对一些数据结构进行读写操作的时间耗费表:

  K/sec     uSecs          Code           Operation

=========  ======= ====================  ===========

  147,058    0.007 a = a & 0×100;        get element of int bits

      314    3.180 bitset.get(3);        get element of Bitset

   20,000    0.050 obj = objs[1];        get element of Array

    5,263    0.190 str.charAt(5);        get element of String

      361    2.770 buf.charAt(5);        get element of StringBuffer

      337    2.960 objs2.elementAt(1);   get element of Vector

      241    4.140 hash.get(“a”);        get element of Hashtable

      336    2.970 bitset.set(3);        set element of Bitset

    5,555    0.180 objs[1] = obj;        set element of Array

      355    2.810 buf.setCharAt(5,’ ‘)  set element of StringBuffer

      308    3.240 objs2.setElementAt(1  set element of Vector

      237    4.210 hash.put(“a”, obj);   set element of Hashtable

  • Java编译器在循环中能检测出常量这件事做得并不好。C/Java的for循环是个比较糟糕的概念,因为它大部分时候会重复计算结束条件。比如,for(int i=0; i<str.length(); i++) 要比int len = str.length(); for(int i=0; i<len; i++)慢三倍。

Q我能从书本中获得一些关于Java的好建议么?

Java相关的书籍有很多,大概可以分为三个等级:

糟糕的。大部分Java书籍都是由那些找不到Java相关工作的人写出来的(因为编程几乎总是比出书更挣钱;我都干过,所以我敢这么说)。这种书漏洞百出,会有不好的建议以及糟糕的程序。这些书对于初学者来说很危险,但是对其它语言稍有经验的编程人员是可以很容易认出并拒绝的。

非常好的。Java类的好书数量并不多。我更偏向于官方说明文档Arnlod和GoslingMarty HallPeter van der Linden这些作者写的书。作为参考,我喜欢Java in a Nutshell系列书籍,以及Sun的在线参考(我将javadoc API语言规范以及它们的变更都拷到了本地硬盘中。此外我将它们添加到了我浏览器的书签里,这样我总是可以快速访问它们了。)

不确定的。在上述两种极端之间存在着一些中等质量的书籍,一般这些书是由不够了解Java的人写出来的他们要么没花时间研究Java究竟是如何工作的,或者是想快速出版太过仓促。举一个例子,来自Rise and Resurrection of the American Programmer的 Edward Yourdon的Java and the new Internet programming paradigm一书。下面是Yourdon眼中与众不同的Java:

  • “函数被废止了”Java中并没有“function”这种字眼,所以这么说并没有错。Java称之为method (Perl称之为subroutines, Scheme称之为procedure, 但你并不能说这些语言中没有函数)。你尽可以说Java中没有全局函数,但我认为这样说更精确:Java中全局作用域的函数是存在的,只不过它们必须被定义到一个类里,并且称之为“静态方法C.f”而不是“函数f”。
  • 自动数据类型转换被废止了。虽然强制两类转换是限制的,但是远不到被废止那个份上。你仍然可以写(1.0 + 2),2会自动被转换成double类型。或者你也可以直接写(“one” + 2)2会自动转为string。
  • 指针和指针相关操作被废止了。显示的对指针进行操作被废止了是没错的(可算解脱了)。但是指针还是被保留下来了;事实上,每一个对象的引用都是指针。(这就是为什么我们会遇到NullPointerExceptioin。)对于一个有竞争力的Java程序员来讲,这应该是必须知道的内容。每个Java程序员都应该知道,当你这样做的时候:
int[] a = {0, 1, 2};
int[] b = a;
b[0] = 99;

那a[0]也会变成99,因为a和b都是同一个对象的指针(或引用)。

  • “因为结构体不存在了,并且数组和字符串都以对象方式表示,所以对于指针的需求也大大降低了”。这句话也是有误导性的,首先,结构体并没有消失,只不过被叫做“类”了。不复存在的是程序员失去了对结构体/类分配在堆或栈中的选择权。在Java中,所有对象都被分配到堆中,这就是为什么指针不需要语法标记符(如*)——在Java中,如果它是一个对象的引用,那它就是指针。Yourdon提到,在C和汇编语言中,字符串或是数组中使用指针是一种好的习惯用法(有人也在C++中这样做),这种说法并没有错,但是其它语言并没有打算这样做,也并不支持。
  • Yourdon也犯了一些小错误,比如数组的length()方法(而不是length 字段),以及可修改的字符串用StingClass表示(而不是StringBuffer)。这些东西很烦人,但是倒没有犯那种低级错误致命。
原文链接: norvig 翻译: ImportNew.com - exlsunshine
译文链接: http://www.importnew.com/16583.html
[ 转载请保留原文出处、译者和译文链接。]



相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部