编者注:在从Java in a Nutshell,5th edith中摘录的两部部分中的
第一部分,David Flanagan描述了如何使用泛型。这部分,David Flanagan将具体告诉你如何创建自己的泛型和泛型方法,并且以Java核心API很多重要的泛型作为结束总结。
创建泛型和泛型方法
创建一个简单的泛型是非常容易的。首先,在一对尖括号(< >)中声明类型变量,以逗号间隔变量名列表。在类的实例变量和方法中,可以在任何类型的地方使用那些类型变量。切记,类型变量仅在编译时存在,所以不能使用instanceof和new这类运行时操作符来操作类型变量。
让我们以一个简单的例子来开始这部分的学习,而后将精简这个例子。这段代码定义了一个树形数据结构,使用类型变量V代表存储在各个树结点中的值。
import java.util.*;
/**
* A tree is a data structure that holds values of type V.
* Each tree has a single value of type V and can have any number of
* branches, each of which is itself a Tree.
*/
public class Tree {
// The value of the tree is of type V.
V value;
// A Tree can have branches, each of which is also a Tree
List<tree branches = new ArrayList<tree();
// Here's the constructor. Note the use of the type variable V.
public Tree(V value) { this.value = value; }
// These are instance methods for manipulating the node value and branches.
// Note the use of the type variable V in the arguments or return types.
V getValue() { return value; }
void setValue(V value) { this.value = value; }
int getNumBranches() { return branches.size(); }
Tree getBranch(int n) { return branches.get(n); }
void addBranch(Tree branch) { branches.add(branch); }
}
正如你所看到的,命名一个类型变量习惯于一个大写字母。使用一个字母可以同现实中那些具有描述性的,长的实际变量名有所区别。使用大写字母要同变量命名规则一致,并且要区别于局部变量,方法参数,成员变量,而这些变量常常使用一个小写字母。集合类中,比如java.util中常常使用类型变量E代表“Element type”。T和S常常用来表示范型变量名(好像使用i和j作为循环变量一样)。
注意到,当一个变量被声明为泛型时,只能被实例变量和方法调用(还有内嵌类型)而不能被静态变量和方法调用。原因很简单,参数化的泛型是一些实例。静态成员是被类的实例和参数化的类所共享的,所以静态成员不应该有类型参数和他们关联。方法,包括静态方法,可以声明和使用他们自己的类型参数,但是,调用这样一个方法,可以被不同地参数化。这些内容将在本章后面谈到。
类型变量绑定
上面例子中的Tree
中的类型变量V是不受约束的:Tree可以被参数化为任何类型。以前我们常常会设置一些约束条件在需要使用的类型上:也许我们需要强制一个类型参数实现一个或多个接口,或是一个特定类的子类。这可以通过指明类型绑定来完成。我们已经看到了统配符的上界,而且使用简单的语法可以指定一般类型变量的上界。后面的代码,还是使用Tree这个例子,并且通过实现Serializable和Comparable来重写。为了做到这点,例子中使用类型变量绑定来确保值类型的Serializable和Comparable。
import java.io.Serializable;
import java.util.*;
public class Tree>
implements Serializable, Comparable<tree
{
V value;
List<tree branches = new ArrayList<tree();
public Tree(V value) { this.value = value; }
// Instance methods
V getValue() { return value; }
void setValue(V value) { this.value = value; }
int getNumBranches() { return branches.size(); }
Tree getBranch(int n) { return branches.get(n); }
void addBranch(Tree branch) { branches.add(branch); }
// This method is a nonrecursive implementation of Comparable<tree
// It only compares the value of this node and ignores branches.
public int compareTo(Tree that) {
if (this.value == null && that.value == null) return 0;
if (this.value == null) return -1;
if (that.value == null) return 1;
return this.value.compareTo(that.value);
}
// javac -Xlint warns us if we omit this field in a Serializable class
private static final long serialVersionUID = 833546143621133467L;
}
一个类型变量的绑定是通过extends后的名字和一个类型列表(这可以是参数化的,就像Comparable一样)表达的。注意当有不止一个绑定时,就像上面例子中的,绑定的类型要用&作为分隔符,而不是使用逗号。都后用来分隔类型变量,如果用来分隔类型变量绑定,就会模棱两可。一个类型变量可以有任何数量的绑定,包括任何数量的借口和至多一个类。
范型中的通配符
上一章的例子中我们看到了通配符和控制参数化类型的通配符绑定。这些在范型中同样非常有用。当前设计的Tree要求每个节点有相同类型的值,V。也许这样太严格了,也许我们应该让Tree的branches能够存放V的子类而不全是V。这个版本的Tree(删除了Comparable和Serializable接口的实现)这样做会更灵活。
public class Tree {
// These fields hold the value and the branches
V value;
List<tree<? extends V>> branches = new ArrayList<tree<? extends V>>();
// Here's a constructor
public Tree(V value) { this.value = value; }
// These are instance methods for manipulating value and branches
V getValue() { return value; }
void setValue(V value) { this.value = value; }
int getNumBranches() { return branches.size(); }
Tree<? extends V> getBranch(int n) { return branches.get(n); }
void addBranch(Tree<? extends V> branch) { branches.add(branch); }
}
通配符绑定允许我们在枝节点上增加一个Tree,比如,一个树枝Tree:
Tree t = new Tree(0); // Note autoboxing
t.addBranch(new Tree(1)); // int 1 autoboxed to Integer
通过getBranch()查询树枝,而树枝的返回类型不知道,所以必须使用统配符来表达。接下来的两个是合法的,但第三个不是:
Tree<? extends Number> b = t.getBranch(0);
Tree<?> b2 = t.getBranch(0);
Tree b3 = t.getBranch(0); // compilation error
当我们这样来查询一个树枝时,不能精确确定它的返回类型,但是存在类型的上限,所以,我们可以这样做:
Tree<? extends Number> b = t.getBranch(0);
Number value = b.getValue();
那我们不能做什么呢?设定树枝的值,或者在原有的树枝上添加新的树枝。早前章节解释的,上界的存在不会改变返回值的类型不可知,编译器没有足够的信息让我们安全的给setValue()或者一个树枝(包括值类型)的addBranch()传递一个值。下面的两行代码都是非法的:
b.setValue(3.0); // Illegal, value type is unknown
b.addBranch(new Tree(Math.PI));
这个例子在设计时找到了一个平衡点:使用绑定通配符使得数据结构更加灵活,但是减少了安全使用其中方法的可能。这个设计是好是坏就要根据上下文联系了。通常,好的范型设计是非常困难的。幸运的是,大多我们要使用的已经在java.util包中设计好了,而不用我们自己再去设计。
范型方法
正如前面说的,范型只能被实例成员调用,而不是静态成员。同实例方法一样,静态方法也可以使用通配符。尽管静态方法不能使用包含他们的类中的类型变量,但是他们可以声明自己的类型变量。当一个方法声明了自己的类型变量,就叫做范型方法。
这里有一个要添加到Tree中的静态方法。他不是一个范型方法,但是使用了绑定的通配符,就好像先前我们看到的sumList()一样:
/** Recursively compute the sum of the values of all nodes on the tree */
public static double sum(Tree<? extends Number> t) {
double total = t.value.doubleValue();
for(Tree<? extends Number> b : t.branches) total += sum(b);
return total;
}
通过通配符的上界绑定,声明自己的类型变量来重写这个方法:
public static double sum(Tree t) {
N value = t.value;
double total = value.doubleValue();
for(Tree<? extends N> b : t.branches) total += sum(b);
return total;
}
范型的sum()不比通配符版本的简单,而且声明变量并没有让我们获得什么。这种情况下,通配符方案要比范型方法更有效,当一个类型变量用来表达两个参数之间或者参数和返回值之间的关系时,范型方法才是需要的。请看下面的例子:
// This method returns the largest of two trees, where tree size
// is computed by the sum() method. The type variable ensures that
// both trees have the same value type and that both can be passed to sum().
public static Tree max(Tree t, Tree u) {
double ts = sum(t);
double us = sum(u);
if (ts > us) return t;
else return u;
}
这个方法使用类型变量N来约束参数和返回值有相同类型,并且参数是Number或者他的子类。
使得参数具有相同类型也许是有争议的,应该让我们能调用max()不论是Tree或者Tree。一种方法是使用两个不相干的类型变量来表示两个不相干的值类型。注意,我们不能在方法的返回时使用变量而必须使用通配符:
public static
Tree<? extends Number> max(Tree t, Tree u) {...}
既然两个类型变量N和M没有任何联系,而且每个仅在签名的时候使用,他们没有提供比通配符更多的好处,这种方法最好这样写:
public static Tree<? extends Number> max(Tree<? extends Number> t,
Tree<? extends Number> u) {...}
所有在这里的范型方法都是静态的,这并不是必须的,实例方法也可以声明自己的类型变量。
调用范型方法
当你使用范型时,必须指定实际类型参数来代替相应的类型变量。但这些对范型方法有些不同:编译器总是能计算出基于你所传递的参数的相应范型方法参数。考虑一下上面定义的max(),作为例子:
public static Tree max(Tree t, Tree u) {...}
当你调用这个方法时,不需要指明N,因为N是隐含地由t和u指明。在后面的代码中,编译器决定N为Integer:
Tree x = new Tree(1);
Tree y = new Tree(2);
Tree z = Tree.max(x, y);
编译器判断范型方法的参数类型称为类型推断。类型推断是相对于知觉推断的。而实际编译器的实现方法是一种非常复杂的过程,超过了这本书的讨论范围。更多的细节在The Java Language Specification, Third Edition的第十五章。
让我们看一个更加复杂的类型推断,考虑一下这个方法:
public class Util {
/** Set all elements of a to the value v; return a. */
public static T[] fill(T[] a, T v) {
for(int i = 0; i < a.length; i++) a[i] = v;
return a;
}
}
这里有两个该方法的调用:
Boolean[] booleans = Util.fill(new Boolean[100], Boolean.TRUE);
Object o = Util.fill(new Number[5], new Integer(42));
在第一个例子中,编译器可以轻松的推断出T是Boolean类型,第二个例子中,编译器判断T是Number。
在非常罕见的情况下,你可能会显示的指明范型方法的参数类型。有时候这是必要的,比如,当范型方法不需要参数时。考虑一下java.util.Collections.emptySet():返回一个空集合,但是不同于Collections.singleton()(可以在参考部分察看),他不带任何参数,但需要指明返回类型。通过在方法名前的<>中,可以显示的指明参数类型:
Set empty = Collections.emptySet();
类型参数不能同没有限制的方法名结合使用:他们必须跟随在一个.后或者在关键字new后,或者在关键字this前,或者构造函数的super前。
可以证明,如果如果你将Collections.emptySet()的返回值赋给一个变量,就像我们上边通过类型推断机制推断基于变量类型的参数类型。尽管显示的类型说明可以更加清楚,但这不是必要的,可以像下面一样重写:
Set empty = Collections.emptySet();
在方法调用表达式中,显示的说明emptySet()的返回值类型是必要的。比如,假设你要调用一个名为printWords()的方法,该方法仅需一个Set的参数,如果你想传递一个空的集合给该方法,就要像下面一样写:
printWords(Collections.emptySet());
这种情况下,显示的类型说明是必要的。
范型方法和数组
早先我们看到,编译器不允许创建一个类型参数化的数组。但是对于范型的使用会是不同的。考虑一下前面定义的Util.fill(),它得以第一个参数和返回值类型都是T[]。而方法体内不必创建任何参数为T的数组,所以这个方法是合法的。
如果你创建一个方法使用varargs(参见第二章的2.6.4)和类型变量,记住调用varargs隐含创建一个数组,请看下面的例子:
/** Return the largest of the specified values or null if there are none */
public static > T max(T... values) { ... }
你可以使用一个Integer类型来调用这个方法,因为编译器会在调用的时候插入必要的数组创建代码。但是你不能将参数转换为Comparable来调用这个方法,因为创建一个Comparable[]是不合法的。
参数化异常
异常是在运行时抛出和捕获的。没有办法让编译器完成类型检查,来保证在catch块中抛出的未知的类型匹配异常。由于这个原因,catch块很可能不包含类型变量和通配符。既然不可能保证在运行时捕获一个编译器时类型参数完整性异常,所以不允许创建任何Throwable类型的子类。参数化异常是不允许的。
但是你可以使用类型变量在throw块里的方法签名中。看看下面的例子:
public interface Command {
public void doit(String arg) throws X;
}
这个接口描述了一个“command”:一块代码只有一个String类型的参数,没有返回值。代码可能抛出一个类型为X的异常。这里有一个例子使用这个接口:
Command save = new Command() {
public void doit(String filename) throws IOException {
PrintWriter out = new PrintWriter(new FileWriter(filename));
out.println("hello world");
out.close();
}
};
try { save.doit("/tmp/foo"); }
catch(IOException e) { System.out.println(e); }
范型个案研究:比较和枚举
Java1.5引入的范型新特性,在1.5的API中有使用,特别多的是在java.util包中,但是在java.lang,java.lang.reflect和java.util.concurrent中也有。这些API都是经过仔细的斟酌创建的,通过学习这些API我们可以学到很多好的设计方法。
java.util中的范形是比较简单的:因为大多都是集合类,类型变量也是代表集合中的元素。java.lang中的几个重要范型是比较难以理解的,他们不是集合,而且第一眼很不容易理解为什么设计成范型。学习这些范型可以让我们更深层次的理解范形的工作机制,并且介绍一些我们没有提到的概念。特别的,我们要检查Comparable接口和Enum类(枚举类型的超类,后面一张讲解)并且学习一些重要但是很少使用的范型特性,比如通配符下界。
在java1.5中,Comparable接口被修改为范型的。大多数的类都实现了这个接口,考虑一下Integer:
public final class Integer extends Number implements Comparable
原先的Comparable接口在类型安全方面是有问题的。两个继承了Comparable接口的对象可能不能相互比较。JDK5.0前,非范形的Comparable接口是非常有用但是不安全的,而现在的接口,捕获了我们需要的信息:他告诉我们一个对象是可比较的,并且可以同什么比较。
现在,考虑一下comparable类的子类。Integer是final的,所以不能有子类,那么让我们看看java.math.BigInteger:
public class BigInteger extends Number implements Comparable
如果我们实现一个BiggerInteger类是BigInteger的子类,他从父类那里继承了Comparable接口,但是注意继承的是Comparable而不是Comparable。这意味着BigInteger和BiggerInteger是可以相互比较的,这是非常好的。BiggerInteger可以重载compareTo(),但是不允许实现一个不同的参数化的Comparable。这就是说BiggerInteger不能同时继承BigInteger和实现Comparable。
当你使用可比较的对象时(当写排序算法的时候)记住两点。首先,使用原生类型是不够充分的:考虑到类型安全,必须指明同什么比较。接下来,类型是不允许同自己比较的:有时候他会同他的祖先比较。为了具体说明,考虑java.util.Collections.max():
这是一个冗长而且复杂的方法标签,我们来一步步考虑:
方法中包含一个类型变量T,并且有复杂的绑定,稍后我们返回来讨论。
方法的返回值类型是T。
方法名是max()。
方法的参数是一个集合。元素的类型指定为绑定的通配符。我们并不知道元素的确切类型,但直到有一个上限T。所以我们知道元素的类型要么为T,要么是T的子类。集合的任何元素都可以作为返回值使用。
这些是比较简单的,本章我们已经看到了通配符上界,我们再来看看max()中的类型变量声明:
>
要说明的第一点,T必须实现了Comparable接口。(范型的语法使用关键字extends来代表类型绑定,不论是类或接口)这是期望的,因为这个方法是找到集合中最大的元素。但是观察这个参数化的Comparable接口,这是一个通配符,但是这个通过关键字super来绑定,而不是extends。这是下界绑定。? extends T是我们熟悉的上界绑定:这意味着T或者其子类。? super T比较少用:这意味着T或者他的超类。
总结一下,类型变量声明表明:“T是一个实现了Comparable接口或者他的父类实现了该接口的类型。”Collections.min()和Collections.binarySearch()有着相同的声明。
对其他的下界通配符(对于Comparable接口没有作用)的例子,Collections中的addAll(),copy(),和fill()。观察addAll()的声明:
public static boolean addAll(Collection<? super T> c, T... a)
这是一个varargs方法,接受任意数量的参数,并且传递给他们一个T[],命名为a。他将a中的所有元素都赋给集合c。集合的元素类型虽然不知道,但是有一个下界:元素均为T或者T的超类。不论类型是什么,我们可以确定数组的元素都是类型的实例,所以将数组的元素添加到集合中是合法的。
返回到我们先前讨论的上界通配符,如果有一个集合的元素是上界通配符,那么都是只读的。考虑List<? extends Serializable>。我们知道,所有的元素都是Serializable,所以像get()这样的方法返回一个Serializable类型的返回值。编译器不允许我们调用add()这样的方法,因为实际的元素类型是不可知的。不能够添加绝对的Serializable对象到list中,因为实现他们的类可能不是正确的类型。
既然上界统配符的结果是只读的,所以你可能会期望下界通配符来实现只写的集合。实际并不是这样,假设这里有一个List<? extends Integer>。元素的实际类型是不知道的,但是可能性是Integer或者他的祖先类Number和Object。无论实际类型是什么,将Integer类型(而不是Number和Object对象)的元素添加到list中是安全的。无论实际类型是什么,list中所有元素都是Object对象的实例,所以list中像get()一样的方法返回Object。
最后,让我们把注意力放到java.lang.Enum类。Enum是所有枚举类型的父类,它实现了Comparable接口,但是有一个让人迷惑的范型声明方法:
public class Enum> implements Comparable, Serializable
第一眼,类型变量E的声明在一个循环中。再仔细的看一看:声明真正说明了,Enum必须是一个本身就是Enum类型的类型。这种表面上的循环是很显然的,如果我们看到了implements子句。正如我们看到的,Comparable类通常被定义为可以同自己比较的。而且他们的子类也可以同他们的父类比较。从另一个方面将,Enum实现了Comparable接口不是为了他本身,而是为了他的子类E。
资源:
·Onjava.com:Onjava.com
·Matrix-Java开发者社区:http://www.matrix.org.cn/
Java, java, J2SE, j2se, J2EE, j2ee, J2ME, j2me, ejb, ejb3, JBOSS, jboss, spring, hibernate, jdo, struts, webwork, ajax, AJAX, mysql, MySQL, Oracle, Weblogic, Websphere, scjp, scjd
Java中的泛型 第二部分