你所不知道的五件事情--Java集合框架API(第二部分)
-- 小心可变性
这是Ted Neward在IBM developerWorks中5 things系列文章中的一篇,仍然讲述了关于Java集合框架的一些应用窍门,值得大家学习。(2010.05.08最后更新)
概要:你可以在任何地方使用Java集合框架,但不要想当然地使用它们。集合框架有神秘之处,如果你不能正确地对待它,它就会为你惹麻烦。Ted Neward探索了Java集合框架API中复杂且可变的部分,还给出了一些帮助你更好地利用Iterable,HashMap和SortedSet的窍门,这些窍门将会使你的代码不会产生Bug。
设计java.util包中集合框架类的目的就是帮助,也即替代数组,这也就提高了Java的能力。如你在上一篇文章中所学习到的,它们仍具可塑性,它们希望以不同的途径,好的方式,整洁的代码去进行定制和扩展。
集合框架仍然强大,但它是可变的:要小心使用之,若滥用之则会使你陷入危机中。
1. List不同于数组
Java开发者经常错误地猜想ArrayList只是Java数组的替代品。集合框架的背后就是数组,这就使得在集合对象中随机地查找元素时能有好的性能。另外,如同数组那样,集合对象使用整数序数去获取特定元素。即便如此,集合对象仍不是数组的简易替代品。
将集合对象与数组区分开来的技巧就是要知道顺序与位置之间的区别。例如,List是一个接口,它为置入集合中的元素维护了顺序,如清单1所示:
清单1. Mutable keys
import java.util.*;
public class OrderAndPosition
{
public static <T> void dumpArray(T[] array)
{
System.out.println("=============");
for (int i=0; i<array.length; i++)
System.out.println("Position " + i + ": " + array[i]);
}
public static <T> void dumpList(List<T> list)
{
System.out.println("=============");
for (int i=0; i<list.size(); i++)
System.out.println("Ordinal " + i + ": " + list.get(i));
}
public static void main(String[] args)
{
List<String> argList = new ArrayList<String>(Arrays.asList(args));
dumpArray(args);
args[1] = null;
dumpArray(args);
dumpList(argList);
argList.remove(1);
dumpList(argList);
}
}
当删除上面List中的第三个元素时,该元素"下面"的其它元素会向上移动以填补空位。很清楚,集合对象的行为不同于数组。(事实上,从数组中删除一个元素与从List中删除一个元素大为不同--从数组中"删除"一个元素就是用一个新的引用变量或null去覆盖该元素所处的位置。)
2. 迭代器,令我大为吃惊!
毫无疑问,Java开发者喜欢Java集合框架中的Iterator,但你最后一次看到Iterator接口是在什么时候呢?可以这么说,多数时候,我们只是将Iterator置入for循环或改进的for循环中。
但对于那些善于挖掘的人,Iterator内藏两大惊人之处:
第一,通过调用Iterator本身的remove()方法,Iterator拥有了从来源集合对象中安全地删除元素的能力。此处的关键点在于避免了 ConcurrentModifiedException,顾名思意:当迭代器正在遍历集合对象时,又正在修改该集合。一些集合对象不会让你向正在被遍历的集合中删除或添加元素,但调用Iterator的remove()方法是一个安全的实践方式。
第二,Iterator支持派生出的(且功能更强大的)兄弟。ListIterator,它只存在于List实例中,支持在遍历过程中向List中添加和删除元素,并且能双向滚动(bidirectional scrolling)List对象。
双向滚动(bidirectional scrolling)在某些场景下有特别强大的功能,例如无处不在的"结果集滑动",即,从数据库或其它集合对象的众多结果中展示其中的10个。它还可以被用于"向后遍历"一个集合或列表,而不用试图从前向后地访问每个元素。使用ListIterator要比利用向下计数的整数参数的List.get() 方法去"向后遍历"一个List容易得多。
3. 并不是所有的Iterable实例都来自于集合对象
Ruby和Groovy开发者喜欢炫耀他们怎样使用一行代码就遍历了整篇文本,并将其中的内容打印到控制台上。多数时候,他们会说,使用Java来做同样的事情需要编写许多代码:打开一个FileReader,再创建一个BufferedReader,然后创建一个while()循环去调用 getLine()方法,直到返回null为止。当然,你还必须得在一个try/catch/finally语句块中做上述事情,这个语句块用于处理异常且在结束时关闭文件句柄。
看起来这像是一个微不足道,学究式的争论,但它还是有些意义的。
他们(包括一些Java开发者)不知道并不是所有Iterable实例都要来自于集合对象。相反地,一个Iterable实例可以创建一个 Iterator实例,这个Iterator知道如何去凭空地造出下一个元素,而不是在一个预先已存在集合对象的内部默默地进行处理。
清单2 Iterating a file
// FileUtils.java
import java.io.*;
import java.util.*;
public class FileUtils
{
public static Iterable<String> readlines(String filename)
throws IOException
{
final FileReader fr = new FileReader(filename);
final BufferedReader br = new BufferedReader(fr);
return new Iterable<String>() {
public <code>Iterator</code><String> iterator() {
return new <code>Iterator</code><String>() {
public boolean hasNext() {
return line != null;
}
public String next() {
String retval = line;
line = getLine();
return retval;
}
public void remove() {
throw new UnsupportedOperationException();
}
String getLine() {
String line = null;
try {
line = br.readLine();
}
catch (IOException ioEx) {
line = null;
}
return line;
}
String line = getLine();
};
}
};
}
}
//DumpApp.java
import java.util.*;
public class DumpApp
{
public static void main(String[] args)
throws Exception
{
for (String line : FileUtils.readlines(args[0]))
System.out.println(line);
}
}
该方法的优点在于不需要在内存中处理整个文件的内容,但有一个告诫,如上面所编写的代码,它不能关闭下层的文件句柄。(当readLing()方法返回 null时就关闭文件句柄,通过该方法可以修正这一问题,但当Iterator未能遍历完整个文件时,该方法也解决不了这个问题。)
4. 意识到可变的hashCode()方法
Map是很好的集合对象,它带给我们只有在其它编程语言,如Perl,中才能体会到的键-值对集合的乐趣。并且JDK为我们提供了一个很棒的Map实现,HashMap,该实现在内部使用散列表,这使得快速地通过键来查找对应的值。但在那儿就会出现一个细微的问题:支持散列码的键会依赖内容可变的字段,这很容易就产生Bug。即使对那些最有耐心的Java开发者,这样的Bug也会使他们发疯。
想像清单3中的Person对象,它有一个典型的hashCode()方法(该方法使用firstName,lastName和age字段--所有的字段都不是final的--去计算散列码),调用Map的get()方法将可能失败并返回null。
清单3 可变的hashCode()使人犯错
// Person.java
import java.util.*;
public class Person
implements Iterable<Person>
{
public Person(String fn, String ln, int a, Person kids)
{
this.firstName = fn; this.lastName = ln; this.age = a;
for (Person kid : kids)
children.add(kid);
}
//
public void setFirstName(String value) { this.firstName = value; }
public void setLastName(String value) { this.lastName = value; }
public void setAge(int value) { this.age = value; }
public int hashCode() {
return firstName.hashCode() & lastName.hashCode() & age;
}
//
private String firstName;
private String lastName;
private int age;
private List<Person> children = new ArrayList<Person>();
}
// MissingHash.java
import java.util.*;
public class MissingHash
{
public static void main(String[] args)
{
Person p1 = new Person("Ted", "Neward", 39);
Person p2 = new Person("Charlotte", "Neward", 38);
System.out.println(p1.hashCode());
Map<Person, Person> map = new HashMap<Person, Person>();
map.put(p1, p2);
p1.setLastName("Finkelstein");
System.out.println(p1.hashCode());
System.out.println(map.get(p1));
}
}
更明确地说,上述方法令人痛楚,但解决方法却很简单:HashMap的键永远不要使用可变对象。
5. equals() vs Comparable
浏览Javadoc时,Java开发者们常会遇到SortedSet类型(在JDK中,它的唯一实现是TreeSet)。因为SortedSet是 java.util包中唯一提供了某种指定排序行为的集合类,所以开发者们在一开始使用它时并没有仔细地考究其中的细节。清单4证明了这一点:
清单4 SortedSet,很高兴发现你
import java.util.*;
public class UsingSortedSet
{
public static void main(String[] args)
{
List<Person> persons = Arrays.asList(
new Person("Ted", "Neward", 39),
new Person("Ron", "Reynolds", 39),
new Person("Charlotte", "Neward", 38),
new Person("Matthew", "McCullough", 18)
);
SortedSet ss = new TreeSet(new Comparator<Person>() {
public int compare(Person lhs, Person rhs) {
return lhs.getLastName().compareTo(rhs.getLastName());
}
});
ss.addAll(perons);
System.out.println(ss);
}
}
在用了上述代码一段时间之后,你可能会发现Set的核心特性之一:它不允许重复。这一特性在Set的Javadoc中有明确的描述。Set是"不包含重复元素的集合"。更准确地说,对于元素e1和e2,如果有e1.eqauls(e2),那么Set就不能同时包含它们,并且最多只能包含一个null元素。
但这似乎不是实际情况--虽然清单4没有Person对象是相等的(根据Person所实现的equals()方法),但当打印该TreeSet时,只展示了三个Person对象。
与Set的天然状态相反,TreeSet要求对象要么实现Comparable接口,要么向构造器中直接传入一个Comparator实现,不用 equals()方法相比较对象;而是使用Comparator/Comparable中的compare/comparaTo方法。
存储在Set中的对象有两种潜在的方法来判定相等性:期望中的equals()方法;Comparable/Comparator方法,这依赖于调用这些方法的上下文。
更糟的是,如此简单的描述还不足以表明这二者是不同的,因为以排序为目的的比较不同于以等价性为目的的比较:当按姓氏进行排序时,某两个Person对象是相等的,但它们的内容却是不等的。
总是要明确equals()与Comparable.compareTo()方法的区别--当实现Set时,返回零必须是清晰的。甚至于,应该在你的文档中清晰地描述这一区别。
结论
Java集合框架遍布有用之物,只要知道它们,就能使你的生活更简单也更富有成效。然而,挖掘出的这些有用之物经常伴随着一定的复杂度,例如,你会发现只要不在键中使用可变对象,就可以按你自己的方式去使用HashMap。
到目前为止,我们已经对集合框架进行了深入挖掘,但我们还未触及这其中的"金矿":由Java 5引入的并发集合。本系列的后5个窍门将关注包java.util.concurrent。
请关注你所不知道的五件事情--Java集合框架API(第一部分)