欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java 之路 (十五) -- 泛型上(泛型类、泛型方法、有界类型参数、泛型与继承、类型推断)

程序员文章站 2022-05-07 10:54:01
...

Thinking in Java 中关于泛型的讲解篇幅实在过长,前后尝试阅读这一章,但总是觉得找不到要点,很迷。于是放弃 Thinking in Java 泛型一章的阅读,转而官方教程,本章可以算作官方教程的中文版。

放上官方教程地址:

The Java™ Tutorials - Generics


1.为什么使用泛型

简单来说,泛型使类型在定义类、接口和方法时成为参数。就像在方法声明中使用形式参数一样,类型参数提供了一种使用不同输入重用相同代码的方法。不同之处在于形式参数的输入是值,而类型参数的输入是类型。

使用泛型的代码比非泛型代码有许多好处:

  • 在编译时进行更强大的类型检查。
    Java编译器将强类型检查应用于通用代码,并在代码违反类型安全时发出错误。修复编译时错误比修复运行时错误更容易,后者很难发现错误源头。

  • 消除转型

    以下没有泛型的代码片段需要强制转换:

    List list = new ArrayList();
    list.add("hello");
    String s = (String)list.get(0);

    使用泛型时,不需要类型转换:

    List <String> list = new ArrayList<String>();
    list.add("hello");
    String s = list.get(0); //没有转型
  • 使程序员能够实现通用算法。
    通过使用泛型,程序员可以实现通用算法,这些算法可以处理不同类型的集合,可以自定义,并且类型安全且易于阅读。


2. 泛型类

泛型类是对类型进行参数化的类或接口。 下面一步步展示该概念。

2.1 简单的 Box 类

如果我们想在一个类中存放任何类型的对象,怎么做呢?没错,使用 Object 即可。

下面展示一个可对任何类对象进行操作的非泛型 Box 类:

public class Box {
    private Object object;

    public void set(Object object) { this.object = object; }
    public Object get() { return object; }
}

由于它的方法接受或返回一个Object ,所以你可以*地传入任何你想要的东西。 在编译时无法验证类的使用方式。 代码的一部分可能会放置一个Integer ,并期望从中获取Integer ,而代码的另一部分可能会错误地传入String ,从而导致运行时错误。

2.2 Box 类的泛型版本

上面提到,通过 Object 存储,不存在任何类型信息,这可能导致使用时类型错误。于是泛型发挥作用了。

泛型类定义格式如下:

class name<T1, T2, ..., Tn> { /* ... */ }

用尖括号将类型参数包起来,并跟在类名后面。

于是2.1中的代码修改之后如下:

public class Box<T> {
    // T stands for "Type"
    private T t;

    public void set(T t) { this.t = t; }
    public T get() { return t; }
}

如代码所示,所有 Object 都被 T 替换。类型变量可以是制定的任何非基本类型:类、接口、数组或者其他类型变量。且类型变量 T 可以在类的任何位置使用。

同样,也适用于将泛型应用于接口,如下:

interface Box<T> { /*...*/}

2.3 类型参数命名约定

按照惯例,类型参数名称是单个大写字母 。

最常用的类型参数(标识符)名称是:

  • E - Element(Java Collections Framework广泛使用)
  • K - key
  • N - number
  • T - 类(类型)
  • V - value
  • S,U,V等 - 第2,第3,第4类型

2.4 调用和实例化泛型类

将 T 替换为某些具体类即可,例如 Integer:

 Box<Integer> integerBox = new Box<Integer>();

//在 Java SE 7 及更高版本中,只要编译期可以从上下文中确定或推断类型参数,就可以用一组空的类型参数“<>” 替换调用泛型类的构造函数所需的类型参数
//如下:
 Box<Integer> integerBox = new Box<>();

泛型类的调用通常称为参数化类型

2.5 多种类型参数

泛型类可以有多个类型参数,如下展示一个通用的 OrderPair 类,实现了 Pair 接口:

public interface Pair<K, V> {
    public K getKey();
    public V getValue();
}

public class OrderedPair<K, V> implements Pair<K, V> {

    private K key;
    private V value;

    public OrderedPair(K key, V value) {
    this.key = key;
    this.value = value;
    }

    public K getKey()   { return key; }
    public V getValue() { return value; }
}

以下语句创建两个 OrderPair 类的实例:

Pair<String,Integer> p1 = new OrderedPair<String,Integer>("Even"8);
Pair<String,String> p2 = new OrderedPair <String,String>("hello","world");

//或如下
Pair<String,Integer> p1 = new OrderedPair<>("Even"8);
Pair<String,String> p2 = new OrderedPair <>("hello","world");

可以看到,分别将 K、V 实例化为 String、Integer 和 String、String,由于自动装箱机制,这里传入的基本数据类型会自动包装为其对应值的对象。

基本数据类型不能作为参数类型,之所以可以传入基本类型参数,是因为自动装箱机制会将其转化为对应值的对象。

2.6 参数化类型

参数化类型(如 List<String>)耶尔可以作为类型参数,如:

OrderedPair<String, Box<Integer>>> p = new OrderedPair<>("primes"new Box<Integer>(...));

2.7 “原生”类型

原生类型(Raw type)是没有任何类型参数的泛型类/接口的名称,即原生类型的概念只针对泛型而言。

例如,给定泛型 Box 类:

public class Box<T> {
    public void set(T t) { /* ... */ }
    // ...
}

在创建参数化类型的 Box<T>,需要传入实际类型参数,如:

Box <Integer> intBox = new Box<>();

但是,如果不指定类型参数,那么则会创建一个原生类型 Box:

Box rawBox = new Box();

Box 是泛型 Box<T> 的原生类型。

换个更熟悉的例子,List<String> 的 原生类型是 List,即原生类型可以理解为去掉了泛型类型信息。

原生类型主要存在于历史遗留代码中(JDK 5.0 以前),因为许多类 在 JDK 5.0 以前是不支持泛型的,所以为了向后兼容,令原生类型默认提供 Object,然后允许将参数化类型赋值给原始类型:

Box <String> stringBox = new Box<>();
Box rawBox = stringBox;  // 这是没问题的

但是当将原生类型赋值给参数化类型,或者原生类型调用泛型类型中定义的方形方法,都会收到警告:

 Box rawBox = new Box();  // rawBox是Box<T>的原始类型
 Box <Integer> intBox = rawBox;  //warning: unchecked conversion

 Box <String> stringBox = new Box<>();
 Box rawBox = stringBox;
 rawBox.set(8);  //warning: unchecked invocation to set(T)

上述原生类型会绕过泛型类型检查,这会导致捕获不安全的代码推迟到运行时,因此应该避免使用原生类型


3. 泛型方法

泛型方法是引入其自己的类型参数的方法。 这类似于声明泛型类型,但类型参数的范围仅限于声明它的方法。 允许使用静态和非静态泛型方法,以及泛型类构造函数。

泛型方法的语法包括位于尖括号内部的类型参数列表,它置于方法返回类型之前。

[权限修饰词] <T1,T2,...,Tn> methods(/*...*/) {/*...*/}

下面举个例子:

public class Util {
    public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
        return p1.getKey().equals(p2.getKey()) &&
               p1.getValue().equals(p2.getValue());
    }
}

public class Pair<K, V> {

    private K key;
    private V value;

    public Pair(K key, V value) {
        this.key = key;
        this.value = value;
    }

    public void setKey(K key) { this.key = key; }
    public void setValue(V value) { this.value = value; }
    public K getKey()   { return key; }
    public V getValue() { return value; }
}

Util 类包含一个泛型方法 compare,用以比较两个 Pair 对象。

调用此方法的完整语法如下:

Pair <Integer,String> p1 = new Pair<>(1,"apple");
Pair <Integer,String> p2 = new Pair<>(2,"pear");
boolean same = Util.<Integer,String> compare(p1,p2);

由于已明确提供该类型,通常,可以省略参数类型,编译期将推断所需的类型:

Pair <Integer,String> p1 = new Pair<>(1,"apple");
Pair <Integer,String> p2 = new Pair<>(2,"pear");
boolean same = Util.compare(p1,p2);

此功能称为类型推断,允许将泛型方法作为普通方法来调用,而无需在尖括号之间指定类型。


4. 有界类型参数

有时,我们希望可以限制类型参数的类型。例如,对数字操作的方法可能只想接收 Number 或其子类的对象。这种情况下,有界类型参数就发挥作用了。

声明有界类型参数,需要指定类型参数的名称,然后是 extends 关键字,后接其上限。

<T extends SomeType> 

注意此情景下的 extends 包含了通常意义的 extends(在类中) 和 implements(在接口中)

例子如下:

public class Box<T> {

    private T t;          

    public void set(T t) {
        this.t = t;
    }

    public T get() {
        return t;
    }

    public <U extends Number> void inspect(U u){
        System.out.println("T: " + t.getClass().getName());
        System.out.println("U: " + u.getClass().getName());
    }

    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<Integer>();
        integerBox.set(new Integer(10));
        integerBox.inspect("some text"); // error: this is still String!
    }
}

这里我们指定接收 Number 及其子类型对象,于是当我们想 inspect 方法传递一个 String 对象时,会发生错误。

上述限制类型只是有界类型参数的作用之一,其实潜在的更重要的功能时,有界类型参数允许我们调用边界中定义的方法:

public class NaturalNumber<T extends Integer> {

    private T n;

    public NaturalNumber(T n)  { this.n = n; }

    public boolean isEven() {
        return n.intValue() % 2 == 0;
    }

    // ...
}

isEven() 方法通过 n 调用 Integer 类中定义的 intValue 方法。

4.1 多个边界

实际上类型参数可以有多个边界:

<T extends B1 & B2 & B3>

具有多个边界时,类型变量是指定的所有类型的子类型。

注意:边界中必须将 类Class 放在 接口interface 之前,否则会出错:

Class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }

class D <T extends A & B & C> { /* ... */ }

4.2 泛型方法与有界类型参数

有界类型参数往往是通用算法实现的关键。考虑以下方法,该方法计算数组 T[] 中大于指定元素 elem 的元素数量。

public static <T> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
        if (e > elem)  // compiler error
            ++count;
    return count;
}

看起来方法很简单,但是编译会失败,这是因为 ”>“ 仅适用于基本类型,不能用与对象比较。

解决此方法,可以考虑使用由 Comparable<T> 接口限定的类型参数:

public interface Comparable<T> {
    public int compareTo(T o);
}

修改后的代码如下:

public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
        if (e.compareTo(elem) > 0)
            ++count;
    return count;
}

说实话,这里有些蒙蔽。。

经搜索后,<T extends Comparable<T>> 这种写法就是相当于 <T>,但是 T 要 implements Comparable<T>,所以如果传入基本类型都是可以的,因为基本类型都是实现了 Comparable<T>接口的


5. 泛型与继承

通常,只要类型兼容(继承),就可以将一个类型的对象转换为另一个类型的对象。如下:

Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger;  // Object 是 Integer 的父类

同时 Integer 也是一种 Number(面向对象中继承表示 ”is-a“ 关系),所以下面代码也是有效的:

public void someMethod(Number n) { /* ... */ }

someMethod(new Integer(10));   // OK
someMethod(new Double(10.1));   // OK

泛型也是如此,可以执行泛型类型的调用,将 Number 作为其类型参数传递,如果参数与 Number 兼容,则允许任何调用:

Box<Number> box = new Box<Number>();
box.add(new Integer(10));   // OK
box.add(new Double(10.1));  // OK

但是,世事无绝对。考虑以下方法:

public void boxTest(Box<Number> n) { /* ... */ }

**你可能会因为它可以接受一个类型为 Box<Number> 的参数,按照上面的结论,就以为向其传递 Box<Integer> 或者 Box<Double> ?这里需要强调,后者是不能传递的。因为 Box<Integer>Box<Double> 并不是 Box<Number> 的子类型。

Java 之路 (十五) -- 泛型上(泛型类、泛型方法、有界类型参数、泛型与继承、类型推断)

注意:给定具体类型 A、B,MyClass<A>MyClass<B> 无关,二者唯一的交集是公共父类为 Object。

5.1 泛型类和子类型

一个类或接口的类型参数与另一个类或接口的类型参数之间的关系由 extends 和 implements 子句确定。

举个例子:Collection 类,ArrayList <E>实现List <E>List <E>扩展Collection <E> 。 因此ArrayList <String>List <String>的子类型,它是Collection <String>的子类型。 只要不改变类型参数,就会在类型之间保留子类型关系。

Java 之路 (十五) -- 泛型上(泛型类、泛型方法、有界类型参数、泛型与继承、类型推断)

此时,假设我们要定义自己的 List 接口 PayloadList ,它将 泛型 P 与每个元素绑在一起,它的声明如下:

interface PayloadList<E,P> extends List<E> {
  void setPayload(int index, P val);
  ...
}

此时PayloadList的以下参数化是List <String>的子类型:

  • PayloadList <字符串,字符串>
  • PayloadList <字符串,整数>
  • PayloadList <字符串,异常>

Java 之路 (十五) -- 泛型上(泛型类、泛型方法、有界类型参数、泛型与继承、类型推断)


6. 类型推断

类型推断是 Java 编译器查看每个方法调用和相应声明,根据类型参数(或参数)进行合适的方法调用;类型推断会尝试查找适用于所有参数的最具体类型。

6.1 类型推断与泛型方法

在 3.泛型方法 中介绍了类型推断,它使得你能够向调用普通方法一样调用泛型方法,而无需在尖括号之间指定类型。

通常,Java 编译期可以推断泛型方法调用的类型参数,因此多数情况下,不必指定。

依旧是前面的例子,两种调用方式:

官方教程中将完整写法称作 类型见证(type witness)

boolean same = Util.<Integer,String> compare(p1,p2);//指定类型 
boolean same = Util.compare(p1,p2);//不指定类型,Java 编译期会自动推断类型参数是Integer 和 String

6.2 类型推断和泛型类的实例化

在 2.4 调用和实例化泛型类中也提到过,只要编译器能够从杀昂下文中推断出类型参数,就可以用一组空的类型参数(<>) 替换调用泛型类的构造函数所需的类型参数。

例如,对以下变量声明:

Map<String, List<String>> myMap = new HashMap<String, List<String>>();

可以写成:

Map<String, List<String>> myMap = new HashMap<>();

6.3 类型推断 与 泛型/非泛型类的泛型构造方法

首先明确一点,泛型类和泛型方法没什么关系,一个类是不是泛型类与其中是否包含泛型方法无关。

构造函数在泛型和非泛型类中都可以是泛型的,换句话说,它们可以具有自己的类型参数:

class MyClass<X> {
  <T> MyClass(T t) {
    // ...
  }
}

考虑 MyClass 类的实例化:

new MyClass<Integer>("");

该语句将泛型类的类型参数 X 指定为 Integer,泛型构造方法的类型参数T 指定为 String,因此实际上该构造函数的实际参数是 String 对象。

Java SE7之前,编译期能够推断泛型构造参数的类型参数。Java SE7 之后,使用 <> 使编译期推断正在实例化的泛型类的类型参数:

MyClass<Integer> myObject = new MyClass<>("");

此例中,编译期将 泛型类 MyClass<X> 的类型参数X 推断为 Integer,同时推断出泛型类的构造函数的类型参数 T 的类型为 String。

6.4 目标类型

Java编译器利用目标类型来推断泛型方法调用的类型参数。 表达式的目标类型是Java编译器所期望的数据类型,具体取决于表达式的显示位置。 考虑方法Collections.emptyList ,声明如下:

static <T> List<T> emptyList();

对于以下赋值语句:

List<String> listOne = Collections.emptyList();

此语句创建 List<String> 的实例,此数据类型即为目标类型;而 emptyList() 方法返回 List<T> 类型对象,所以编译期推断类型参数 T 必须是 String。这适用于 Java SE 7以上。当然,我们可以用完整写法:(这个场景下不是必须的)

List<String> listOne = Collections.<String>emptyList();

下面给出一个必须使用完整写法(类型见证)的例子,考虑以下写法:

void processStringList(List<String> stringList) {
    // process stringList
}

如果我们需要向 processStringList 中传入 emptyList() 方法作为参数,如以下语句:

processStringList(Collections.emptyList());

上述语句在Java SE 7 中编译失败,必须指定类型参数;但是在 Java SE 8 中可以编译成功。