Java 中的泛型 – 第 I 部分






4.94/5 (22投票s)
本文介绍了泛型的概念,并展示了如何使用它的示例。
摘要
Java 5 (JDK 1.5) 引入了泛型或参数化类型的概念。在本文中,我将介绍泛型的概念并向您展示如何使用它的示例。在第二部分中,我们将探讨 Java 中泛型的实际实现方式以及使用泛型时遇到的一些问题。
类型安全问题
Java 是一种强类型语言。使用 Java 编程时,在编译时,您期望知道是否将错误类型的参数传递给了方法。例如,如果您定义
Dog aDog = aBookReference; // ERROR
其中,aBookReference
是类型为 Book
的引用,与 Dog
无关,您将收到编译错误。
但不幸的是,Java 推出时,这一点并未完全应用到 *集合* 库中。因此,例如,您可以编写
Vector vec = new Vector();
vec.add("hello");
vec.add(new Dog());
…
无法控制将什么类型的对象放入 Vector
中。请看以下示例
package com.agiledeveloper;
import java.util.ArrayList;
import java.util.Iterator;
public class Test
{
public static void main(String[] args)
{
ArrayList list = new ArrayList();
populateNumbers(list);
int total = 0;
Iterator iter = list.iterator();
while(iter.hasNext())
{
total += ((Integer) (iter.next())).intValue();
}
System.out.println(total);
}
private static void populateNumbers(ArrayList list)
{
list.add(new Integer(1));
list.add(new Integer(2));
}
}
在上面的程序中,我创建了一个 ArrayList
,用一些 Integer
值填充它,然后通过从 ArrayList
中提取 Integer
来计算总和。
上面程序的输出是 3,正如您所期望的那样。
现在,如果我将 populateNumbers()
方法更改为如下所示
private static void populateNumbers(ArrayList list)
{
list.add(new Integer(1));
list.add(new Integer(2));
list.add("hello");
}
我不会收到任何编译错误。但是,程序将无法正确执行。我们将收到以下运行时错误
Exception in thread "main" java.lang.ClassCastException:
java.lang.String at com.agiledeveloper.Test.main(Test.java:17)…
在 Java 5 之前的集合中,我们并没有完全实现这种类型安全。
什么是泛型?
回想我过去用 C++ 编程的日子,我很喜欢 C++ 中一个很酷的功能——模板。模板在提供类型安全的同时,允许您编写通用的代码,也就是说,它不针对任何特定类型。虽然 C++ 模板是一个非常强大的概念,但它也有一些缺点。首先,并非所有编译器都能很好地支持它。其次,它相当复杂,需要付出很大的努力才能熟练使用它。最后,它在使用方式上存在许多特有之处,当您用它进行花哨的操作时(这通常可以适用于 C++,但这又是另一回事),会让您头疼不已。Java 问世时,C++ 中许多复杂的功能,如模板和运算符重载,都被避免了。
在 Java 5 中,最终决定引入泛型。虽然泛型——编写通用代码、独立于特定类型——在概念上与 C++ 模板相似,但也有一些区别。首先,与 C++ 中为每个参数化类型生成不同的类不同,在 Java 中,每个泛型类只有一个,无论您用多少不同的类型实例化它。当然,Java 泛型也存在一些问题,这将在第二部分中讨论。在第一部分中,我们将重点关注好的方面。
Java 中的泛型工作起源于一个名为 GJ1 (Generic Java) 的项目,该项目最初是作为语言扩展。这个想法随后通过 Java 社区流程 (JCP) 作为 Java 规范请求 (JSR) 142。
泛型类型安全
让我们从前面看到的非泛型示例开始,看看我们如何从泛型中受益。让我们将上面的代码转换为使用泛型。修改后的代码如下所示
package com.agiledeveloper;
import java.util.ArrayList;
import java.util.Iterator;
public class Test
{
public static void main(String[] args)
{
ArrayList<Integer> list = new ArrayList<Integer>();
populateNumbers(list);
int total = 0;
for(Integer val : list)
{
total = total + val;
}
System.out.println(total);
}
private static void populateNumbers(ArrayList<Integer> list)
{
list.add(new Integer(1));
list.add(new Integer(2));
list.add("hello");
}
}
我使用的是 ArrayList<Integer>
而不是 ArrayList
。现在,如果我编译代码,我会得到一个编译错误
Test.java:26: cannot find symbol
symbol : method add(java.lang.String)
location: class java.util.ArrayList<java.lang.Integer>
list.add("hello");
^
1 error
ArrayList
的参数化类型提供了类型安全。“*让 Java 更易于输入,也更易于输入*”是 Java 泛型贡献者的口号。
命名约定
为了避免泛型参数与代码中的实际类型之间混淆,您必须遵循良好的命名约定。如果您遵循良好的 Java 约定和软件开发实践,您可能不会用单个字母命名您的类。您还将使用混合大小写来命名类,以大写字母开头。以下是一些用于泛型的约定
- 使用字母 E 作为集合元素,例如在定义中
public class PriorityQueue<E> {…}
编写泛型类
编写泛型类的语法非常简单。这是一个泛型类的示例
package com.agiledeveloper;
public class Pair<E>
{
private E obj1;
private E obj2;
public Pair(E element1, E element2)
{
obj1 = element1;
obj2 = element2;
}
public E getFirstObject() { return obj1; }
public E getSecondObject() { return obj2; }
}
此类表示一对某种泛型类型 E
的值。让我们看一些该类的用法示例
// Good usage
Pair<Double> aPair = new Pair<Double>(new Double(1), new Double(2.2));
如果我们尝试创建类型不匹配的对象,我们将收到编译错误。例如,考虑以下示例
// Wrong usage
Pair<Double> anotherPair = new Pair<Double>(new Integer(1), new Double(2.2));
在这里,我试图将 Integer
实例和 Double
实例传递给 Pair
实例。但是,这会导致编译错误。
泛型和可替换性
泛型遵守 Liskov 的可替换性原则4。让我用一个例子来解释。假设我有一个水果篮。我可以向其中添加橘子、香蕉、葡萄等。现在,让我们创建一个香蕉篮。对此,我应该只能添加香蕉。它应该禁止添加其他类型的水果。香蕉是水果,即香蕉继承自水果。香蕉篮是否应该继承自水果篮,如下图所示?
如果香蕉篮继承自水果篮,那么您可能会得到一个类型为水果篮的引用来指向香蕉篮的实例。然后,使用此引用,您可以将香蕉添加到篮子中,但您也可以添加橘子。虽然将香蕉添加到香蕉篮中是可以的,但添加橘子则不行。充其量,这会导致运行时异常。然而,使用水果篮的代码可能不知道如何处理这种情况。香蕉篮不能在水果篮被使用的地方进行替换。
泛型遵守此原则。让我们看这个例子
Pair<Object> objectPair = new Pair<Integer>(new Integer(1), new Integer(2));
此代码将产生编译时错误
Error: line (9) incompatible types found :
com.agiledeveloper.Pair<java.lang.Integer> required:
com.agiledeveloper.Pair<java.lang.Object>
现在,如果您想将不同类型的 Pair
作为一种类型来处理,该怎么办?我们将在“*通配符*”部分稍后讨论。
在我们结束这个话题之前,让我们来看一个奇怪的行为。虽然
Pair<Object> objectPair = new Pair<Integer>(new Integer(1), new Integer(2));
不允许,但以下内容是允许的,不过
Pair objectPair = new Pair<Integer>(new Integer(1), new Integer(2));
没有参数化类型的 Pair
是 Pair
类的非泛型形式。每个泛型类还有一个非泛型形式,以便可以从非泛型代码访问它。这允许与现有代码或尚未移植到使用泛型的代码向后兼容。虽然这种兼容性具有一定的优势,但此功能可能导致一些混淆以及类型安全问题。
泛型方法
除了类之外,方法也可以参数化。
考虑以下示例
public static <T> void filter(Collection<T> in, Collection<T> out)
{
boolean flag = true;
for(T obj : in)
{
if(flag)
{
out.add(obj);
}
flag = !flag;
}
}
filter()
方法将 in
集合中的偶数元素复制到 out
集合中。void
前面的 <T>
表示该方法是泛型方法,其中 <T>
是参数化类型。让我们看一个该泛型方法的用法
ArrayList<Integer> lst1 = new ArrayList<Integer>();
lst1.add(1);
lst1.add(2);
lst1.add(3);
ArrayList<Integer> lst2 = new ArrayList<Integer>();
filter(lst1, lst2);
System.out.println(lst2.size());
我们用三个值填充一个 ArrayList
lst1
,然后过滤(复制)其内容到另一个 ArrayList
lst2
。调用 filter()
方法后 lst2
的大小为 2。
现在,让我们看一个略有不同的调用
ArrayList<Double> dblLst = new ArrayList<Double>();
filter(lst1, dblLst);
在这里,我收到一个编译错误
Error:
line (34) <T>filter(java.util.Collection<T>,java.util.Collection<T>)
in com.agiledeveloper.Test cannot be applied to
(java.util.ArrayList<java.lang.Integer>,
java.util.ArrayList<java.lang.Double>)
错误表示无法将不同类型的 ArrayList
发送到此方法。这很好。但是,让我们尝试以下操作
ArrayList<Integer> lst3 = new ArrayList<Integer>();
ArrayList lst = new ArrayList();
lst.add("hello");
filter(lst, lst3);
System.out.println(lst3.size());
无论您是否喜欢,此代码都可以编译而不会出错,并且调用 lst3.size()
返回 1。首先,为什么它会编译以及发生了什么?编译器会尽力适应对泛型方法的调用(如果可能)。在这种情况下,通过将 lst3
视为一个简单的 ArrayList
,没有任何参数化类型(请参阅上面“泛型和可替换性”部分中的最后一段),它就可以调用 filter
方法。
现在,这可能导致一些问题。让我们在上面的示例中添加另一条语句。当开始输入时,IDE(我使用的是 IntelliJ IDEA)会通过代码提示帮助我,如下所示
它说调用 get()
方法需要一个索引并返回一个 Integer
。这是完整的代码
ArrayList<Integer> lst3 = new ArrayList<Integer>();
ArrayList lst = new ArrayList();
lst.add("hello");
filter(lst, lst3);
System.out.println(lst3.size());
System.out.println(lst3.get(0));
那么,您认为运行此代码时会发生什么?也许是运行时异常?好吧,您猜错了!我们得到以下代码段的输出
1
hello
为什么会这样?答案在于实际编译的内容(我们将在本文的第二部分中更详细地讨论)。目前简短的答案是,即使代码完成提示返回 Integer
,实际上返回类型也是 Object
。因此,字符串 "hello" 能够无误地通过。
现在,如果我们添加以下代码会怎样
for(Integer val: lst3)
{
System.out.println(val);
}
在这里,显然,我正在从集合中请求一个 Integer
。此代码将引发 ClassCastException
。虽然泛型旨在使我们的代码类型安全,但这个例子表明,我们可以轻易地有意或无意地绕过它,充其量会导致运行时异常,或者在最坏的情况下,代码会悄无声息地出错。现在,足够多的问题了。我们将在第二部分中进一步探讨其中一些陷阱。让我们在第一部分中继续讨论目前有效的内容。
上界
假设我们想编写一个简单的泛型方法来确定两个参数的最大值。方法原型如下所示
public static <T> T max(T obj1, T obj2)
我将如下使用它
System.out.println(max(new Integer(1), new Integer(2)));
现在,问题是如何完成 max()
方法的实现?让我们尝试一下
public static <T> T max(T obj1, T obj2)
{
if (obj1 > obj2) // ERROR
{
return obj1;
}
return obj2;
}
这行不通。>
运算符未在引用上定义。嗯,我该如何比较这两个对象呢?Comparable
接口浮现在脑海中。那么,为什么不使用 Comparable 接口来完成工作呢?
public static <T> T max(T obj1, T obj2)
{
// Not elegant code
Comparable c1 = (Comparable) obj1;
Comparable c2 = (Comparable) obj2;
if (c1.compareTo(c2) > 0)
{
return obj1;
}
return obj2;
}
虽然这段代码可能有效,但有两个问题。首先,它很丑陋。其次,我们必须考虑强制转换为 Comparable
失败的情况。由于我们如此严重地依赖实现此接口的类型,为什么不要求编译器强制执行呢?这正是*上界*的作用。代码如下
public static <T extends Comparable> T max(T obj1, T obj2)
{
if (obj1.compareTo(obj2) > 0)
{
return obj1;
}
return obj2;
}
编译器将检查以确保调用此方法时提供的参数化类型实现了 Comparable
接口。如果您尝试使用未实现 Comparable
接口的某些类型的实例调用 max()
,您将收到一个严格的编译错误。
通配符
到目前为止我们进展顺利,您可能急于深入研究泛型的一些更有趣的概念。让我们考虑这个例子
public abstract class Animal
{
public void playWith(Collection<Animal> playGroup)
{
}
}
public class Dog extends Animal
{
public void playWith(Collection<Animal> playGroup)
{
}
}
Animal
类有一个 playWith()
方法,该方法接受 Animal
的 Collection
。Dog
,它扩展了 Animal
,重写了此方法。让我们尝试在示例中使用 Dog
类
Collection<Dog> dogs = new ArrayList<Dog>();
Dog aDog = new Dog();
aDog.playWith(dogs); //ERROR
在这里,我创建了一个 Dog
实例并将 Dog
的 Collection
发送到其 playWith()
方法。我们收到一个编译错误
Error: line (29) cannot find symbol
method playWith(java.util.Collection<com.agiledeveloper.Dog>)
这是因为 Dog
的 Collection
不能被视为 playWith()
方法所期望的 Animal
的 Collection
(请参阅上面“泛型和可替换性”部分)。但是,能够将 Dog
的 Collection
发送到此方法是有意义的,不是吗?我们该怎么做?这就是*通配符*或*未知*类型发挥作用的地方。
我们将 playMethod()
方法(在 Animal
和 Dog
中)都修改如下
public void playWith(Collection<?> playGroup)
Collection
的类型不是 Animal
。而是*未知类型*(?)。未知类型不是 Object
,它只是未知或未指定。
现在,代码
aDog.playWith(dogs);
可以编译而不会出错。
但是,有一个问题。我们也可以写
ArrayList<Integer> numbers = new ArrayList<Integer>();
aDog.playWith(numbers);
我所做的更改,允许将 Dog
的 Collection
发送到 playWith()
方法,现在也允许将 Integer
的 Collection
发送。如果我们允许这样做,那将是一只奇怪的狗。我们如何告诉编译器允许 Animal
的 Collection
或任何扩展 Animal
的类型的 Collection
,而不是任何其他类型的 Collection
?这可以通过使用上界来实现,如下所示
public void playWith(Collection<? extends Animal> playGroup)
使用通配符的一个限制是,您可以从 Collection<?>
中获取元素,但不能向此类集合添加元素——编译器不知道它正在处理什么类型。
下界
让我们考虑最后一个例子。假设我们要将元素从一个集合复制到另一个集合。这是我进行此操作的第一个代码尝试
public static <T> void copy(Collection<T> from, Collection<T> to) {…}
让我们尝试使用此方法
ArrayList<Dog> dogList1 = new ArrayList<Dog>();
ArrayList<Dog> dogList2 = new ArrayList<Dog>();
//…
copy(dogList1, dogList2);
在此代码中,我们将 Dog
从一个 Dog ArrayList
复制到另一个。
由于 Dog
是 Animal
,Dog
可能同时存在于 Dog
的 ArrayList
和 Animal
的 ArrayList
中,不是吗?所以,这是将 Dog
的 ArrayList
复制到 Animal
的 ArrayList
的代码。
ArrayList<Animal> animalList = new ArrayList<Animal>();
copy(dogList1, animalList);
但是,此代码将因以下错误而编译失败
Error:
line (36) <T>copy(java.util.Collection<T>,java.util.Collection<T>)
in com.agiledeveloper.Test cannot be applied
to (java.util.ArrayList<com.agiledeveloper.Dog>,
java.util.ArrayList<com.agiledeveloper.Animal>)
如何使其工作?这就是下界发挥作用的地方。我们对 Copy
的第二个参数的意图是,它的类型为 T
或 T
的任何基类型。代码如下
public static <T> void copy(Collection<T> from, Collection<? super T> to)
在这里,我们说第二个集合接受的类型与 T
相同,或者它是 T
的超类型。
我们现在在哪里?
我已通过示例展示了 Java 泛型的强大功能。但是,Java 中使用泛型也存在一些问题。我将把这些讨论推迟到本文的第二部分。在第二部分中,我们将讨论泛型的某些限制、Java 中泛型的实现方式、类型擦除的影响、对 Java 类库的修改以适应泛型、将非泛型代码转换为泛型代码的问题,以及最后,泛型的一些陷阱或缺点。
结论
在本第一部分中,我们讨论了 Java 中的泛型以及如何使用它。泛型提供类型安全。泛型的实现方式是它提供了与非泛型代码的向后兼容性。它们比 C++ 中的模板更简单,而且编译时也没有代码膨胀。在第二部分中,我们将讨论使用泛型时遇到的问题。