65.9K
CodeProject 正在变化。 阅读更多。
Home

Java 中的泛型 – 第 I 部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.94/5 (22投票s)

2008 年 7 月 8 日

CPOL

12分钟阅读

viewsIcon

70413

本文介绍了泛型的概念,并展示了如何使用它的示例。

摘要

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> {…}
  • 使用字母 T、U、S 等表示通用类型。

编写泛型类

编写泛型类的语法非常简单。这是一个泛型类的示例

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));

没有参数化类型的 PairPair 类的非泛型形式。每个泛型类还有一个非泛型形式,以便可以从非泛型代码访问它。这允许与现有代码或尚未移植到使用泛型的代码向后兼容。虽然这种兼容性具有一定的优势,但此功能可能导致一些混淆以及类型安全问题。

泛型方法

除了类之外,方法也可以参数化。

考虑以下示例

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() 方法,该方法接受 AnimalCollectionDog,它扩展了 Animal,重写了此方法。让我们尝试在示例中使用 Dog

Collection<Dog> dogs = new ArrayList<Dog>();
        
Dog aDog = new Dog();
aDog.playWith(dogs); //ERROR

在这里,我创建了一个 Dog 实例并将 DogCollection 发送到其 playWith() 方法。我们收到一个编译错误

Error:  line (29) cannot find symbol 
method playWith(java.util.Collection<com.agiledeveloper.Dog>)

这是因为 DogCollection 不能被视为 playWith() 方法所期望的 AnimalCollection(请参阅上面“泛型和可替换性”部分)。但是,能够将 DogCollection 发送到此方法是有意义的,不是吗?我们该怎么做?这就是*通配符*或*未知*类型发挥作用的地方。

我们将 playMethod() 方法(在 AnimalDog 中)都修改如下

public void playWith(Collection<?> playGroup)

Collection 的类型不是 Animal。而是*未知类型*(?)。未知类型不是 Object,它只是未知或未指定。

现在,代码

aDog.playWith(dogs);

可以编译而不会出错。

但是,有一个问题。我们也可以写

ArrayList<Integer> numbers = new ArrayList<Integer>();
aDog.playWith(numbers);

我所做的更改,允许将 DogCollection 发送到 playWith() 方法,现在也允许将 IntegerCollection 发送。如果我们允许这样做,那将是一只奇怪的狗。我们如何告诉编译器允许 AnimalCollection 或任何扩展 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 复制到另一个。

由于 DogAnimalDog 可能同时存在于 DogArrayListAnimalArrayList 中,不是吗?所以,这是将 DogArrayList 复制到 AnimalArrayList 的代码。

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 的第二个参数的意图是,它的类型为 TT 的任何基类型。代码如下

public static <T> void copy(Collection<T> from, Collection<? super T> to)

在这里,我们说第二个集合接受的类型与 T 相同,或者它是 T 的超类型。

我们现在在哪里?

我已通过示例展示了 Java 泛型的强大功能。但是,Java 中使用泛型也存在一些问题。我将把这些讨论推迟到本文的第二部分。在第二部分中,我们将讨论泛型的某些限制、Java 中泛型的实现方式、类型擦除的影响、对 Java 类库的修改以适应泛型、将非泛型代码转换为泛型代码的问题,以及最后,泛型的一些陷阱或缺点。

结论

在本第一部分中,我们讨论了 Java 中的泛型以及如何使用它。泛型提供类型安全。泛型的实现方式是它提供了与非泛型代码的向后兼容性。它们比 C++ 中的模板更简单,而且编译时也没有代码膨胀。在第二部分中,我们将讨论使用泛型时遇到的问题。

参考文献

  1. GJ - 泛型 Java 语言扩展
  2. JSR 14
  3. Java SE 下载 - 以前的版本 - J2SE 5.0
  4. 面向对象设计原则
© . All rights reserved.