【Java原理系列】 泛型官方示例详解

java 泛型

为什么使用泛型?

在简单来说,泛型允许在定义类、接口和方法时将类型(类和接口)作为参数。类似于方法声明中使用的形式参数,类型参数为您提供了一种重用相同代码但使用不同输入的方式。不同之处在于,形式参数的输入是值,而类型参数的输入是类型。

使用泛型的代码相较于非泛型代码具有许多优势:

  • 编译时更强的类型检查。
    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);   // 无需转换
  • 可以实现泛型算法。
    使用泛型,程序员可以实现适用于不同类型集合的通用算法,这些算法可以进行定制,并且具有类型安全性和更易读的特点。

以上是使用泛型的一些好处。通过利用泛型,可以使代码更加类型安全、简洁且易于维护。

Generic Types(泛型)

泛型类型是参数化类型的通用类或接口。下面的Box类将被修改以演示这个概念。

一个简单的Box类

首先,看一下操作任何类型对象的非泛型Box类。它只需要提供两个方法:set,用于向盒子中添加对象;get,用于获取对象:

public class Box {
    private Object object;

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

由于其方法接受或返回Object,你可以自由地传入任何你想要的内容,只要不是原始类型之一。在编译时,没有办法验证类的使用方式。代码的一部分可能会将Integer放入盒子中,并期望从中获取到整数,而代码的另一部分可能错误地传入了一个String,导致运行时错误。

Box类的泛型版本

泛型类的定义格式如下:

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

类型参数部分由尖括号(<>)包围,紧跟在类名后面。它指定了类型参数(也称为类型变量)T1、T2、…和Tn。

要将Box类更新为使用泛型,只需将代码"public class Box"更改为"public class Box",创建一个泛型类型声明。这引入了类型变量T,在类内部的任何地方都可以使用。

有了这个改变,Box类变成了:

/**
 * Box类的泛型版本。
 * @param <T> 被封装值的类型
 */
public class Box<T> {
    // T代表“Type”
    private T t;

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

可以看到,所有Object的出现都被替换为T。类型变量可以是你指定的任何非原始类型:任何类类型、任何接口类型、任何数组类型,甚至是另一个类型变量。

这种技术同样适用于创建泛型接口。

类型参数命名约定

按照惯例,类型参数的名称是单个大写字母。这与你已经了解的变量命名约定形成鲜明对比,并且有充分的理由:如果没有这个约定,很难区分类型变量和普通的类或接口名称。

最常用的类型参数名称有:

  • E - 元素(Java集合框架广泛使用)
  • K - 键
  • N - 数字
  • T - 类型
  • V - 值
  • S, U, V等 - 第2个、第3个、第4个类型

你会在Java SE API以及本课程的其余部分中看到这些名称的使用。

调用和实例化泛型类型

要在代码中引用泛型Box类,必须进行泛型类型调用,将T替换为某个具体的值,例如Integer:

Box<Integer> integerBox;

可以将泛型类型调用视为普通方法调用的类比,但是不是将参数传递给方法,而是将类型参数(在本例中为Integer)传递给Box类本身。

类型参数和类型参数的术语:许多开发人员在使用过程中将“类型参数”和“类型参数”这两个术语互换使用,但这些术语并不相同。在编码时,通过提供类型参数来创建参数化类型。因此,Foo中的T是类型参数,Foo f中的String是类型参数。本课程在使用这些术语时遵守这个定义。

与任何其他变量声明一样,这段代码实际上并没有创建一个新的Box对象。它只是声明integerBox将保存对"Box of Integer"的引用,这是如何读取Box的含义。

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

要实例化这个类,像平常一样使用new关键字,但是在类名和括号之间加上:

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

菱形操作符

在Java SE 7及更高版本中,你可以用一组空的类型参数(<>)替代调用泛型类构造函数所需的类型参数,只要编译器能够从上下文中确定或推断出这些类型参数。这对尖括号(<>)被非正式地称为菱形。例如,你可以使用以下语句创建Box的实例:

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

有关菱形符号和类型推断的更多信息,请参见类型推断。

多个类型参数

如前所述,泛型类可以有多个类型参数。例如,泛型OrderedPair类实现了泛型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; }
}

下面的语句创建了两个OrderedPair类的实例:

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

代码new OrderedPair<String, Integer>将K实例化为String,V实例化为Integer。因此,OrderedPair的构造函数的参数类型分别是String和Integer。由于自动装箱,将String和int传递给这个类是有效的。

如菱形符号中所述,由于Java编译器可以从声明OrderedPair<String, Integer>中推断出K和V的类型,可以使用菱形符号简化这些语句:

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

要创建一个泛型接口,遵循与创建泛型类相同的约定。

参数化类型

你还可以用参数化类型(例如List)替换类型参数(即K或V)。例如,使用OrderedPair<K, V>示例:

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

原始类型(Raw Types)

原始类型是没有任何类型参数的泛型类或接口的名称。例如,给定泛型Box类:

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

要创建Box的参数化类型,你需要为形式类型参数T提供实际类型参数:

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

如果省略实际类型参数,就会创建Box的原始类型:

Box rawBox = new Box();

因此,Box是泛型类型Box的原始类型。然而,非泛型的类或接口类型不是原始类型。

原始类型在旧代码中出现,因为许多API类(如Collections类)在JDK 5.0之前都不是泛型的。当使用原始类型时,实际上获得的是泛型之前的行为——一个Box会给你Objects。出于向后兼容性的考虑,将参数化类型赋值给其原始类型是被允许的:

Box<String> stringBox = new Box<>();
Box rawBox = stringBox;               // OK

但是,如果将原始类型赋值给参数化类型,则会得到警告:

Box rawBox = new Box();           // rawBox is a raw type of 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)

这个警告显示原始类型绕过了泛型类型检查,将不安全的代码检查推迟到运行时。因此,应该避免使用原始类型。

“类型擦除”一节中详细介绍了Java编译器如何使用原始类型。

未检查的错误消息

正如前面提到的,在将旧代码与泛型代码混合使用时,你可能会遇到类似以下的警告消息:

Note: Example.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

当使用操作原始类型的旧API时,就会出现这种情况,如下面的例子所示:

public class WarningDemo {
    public static void main(String[] args){
        Box<Integer> bi;
        bi = createBox();
    }

    static Box createBox(){
        return new Box();
    }
}

术语"unchecked"表示编译器没有足够的类型信息来执行所有必要的类型检查,以确保类型安全性。默认情况下,“unchecked”警告被禁用,但编译器给出了提示。要查看所有的“unchecked”警告,请使用-Xlint:unchecked重新编译。

使用-Xlint:unchecked重新编译前面的示例,会显示以下附加信息:

WarningDemo.java:4: warning: [unchecked] unchecked conversion
found   : Box
required: Box<java.lang.Integer>
        bi = createBox();
                      ^
1 warning

要完全禁用未检查的警告,请使用-Xlint:-unchecked标志。@SuppressWarnings(“unchecked”)注解可以抑制未检查的警告。如果对@SuppressWarnings语法不熟悉,请参阅注解。

泛型方法(Generic Methods)

泛型方法是引入自己的类型参数的方法。这类似于声明一个泛型类型,但类型参数的范围仅限于声明它的方法内部。静态和非静态的泛型方法都是允许的,还可以有泛型类构造函数。

泛型方法的语法包括一个类型参数列表,在方法的返回类型之前以尖括号内的形式出现。对于静态的泛型方法,类型参数部分必须出现在方法的返回类型之前。

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

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

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

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

这个特性被称为类型推断,它允许你将泛型方法调用作为普通方法调用,而不需要在尖括号之间指定类型。这个主题在下一节“类型推断”中进一步讨论。

有界类型参数(Bounded Type Parameters)

在某些情况下,你可能希望限制可以用作参数化类型的类型参数的类型。例如,一个操作数字的方法可能只想接受Number类或其子类的实例。这就是有界类型参数的用途。

要声明一个有界类型参数,列出类型参数的名称,后跟extends关键字,后跟它的上界,在本例中是Number。请注意,在这个上下文中,extends一词在一般意义上使用,既表示“扩展”(如类)也表示“实现”(如接口)。

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!
    }
}

通过修改我们的泛型方法以包括这个有界类型参数,编译将会失败,因为我们调用inspect仍然包含一个字符串:

Box.java:21: <U>inspect(U) in Box<java.lang.Integer> cannot
  be applied to (java.lang.String)
                        integerBox.inspect("10");
                                  ^
1 error

除了限制实例化泛型类型时可以使用的类型之外,有界类型参数还允许你调用在上界中定义的方法:

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方法。

多个边界

前面的示例演示了带有单个边界的类型参数的用法,但是类型参数可以具有多个边界:

<T extends B1 & B2 & B3>

具有多个边界的类型变量是所有边界中列出的类型的子类型。如果其中一个边界是类,则必须首先指定它。例如:

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

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

如果未首先指定A作为边界,就会得到编译时错误:

class D <T extends B & A & C> { /* ... */ }  // compile-time error

泛型方法和有界类型参数

有界类型参数对于实现通用算法非常重要。考虑下面的方法,它计算数组T[]中大于指定元素elem的元素数量。

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

这个方法的实现很简单,但它无法编译,因为大于运算符(>)只适用于原始类型,如short、int、double、long、float、byte和char。你不能使用大于运算符来比较对象。为了解决这个问题,可以使用一个有界于Comparable接口的类型参数:

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

现在,我们可以使用compareTo方法来比较对象,并根据比较结果计数大于指定元素的元素数量。

泛型、继承和子类型

正如你已经知道的,可以将一个类型的对象赋值给另一个类型的对象,前提是这些类型是兼容的。例如,可以将一个Integer赋值给一个Object,因为Object是Integer的超类型之一:

Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger;   // OK

在面向对象的术语中,这被称为“is a”关系。由于Integer是Object的一种类型,所以赋值是允许的。但是Integer也是Number的一种类型,所以下面的代码也是有效的:

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

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

泛型也是同样的道理。可以进行泛型类型调用,将Number作为其类型参数,并且如果参数与Number兼容,那么后续的add调用都是允许的:

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的参数。但是这是什么意思?是否允许传递Box或Box,如你所期望的那样?答案是“不行”,因为Box和Box不是Box的子类型。

这是在使用泛型编程时常见的误解,但它是一个重要的概念需要学习。

图示展示了Box不是Box的子类型,尽管Integer是Number的子类型。
注意:给定两个具体类型A和B(例如,Number和Integer),MyClass与MyClass没有关系,无论A和B是否有关系。MyClass和MyClass的共同父类是Object。

关于如何在两个泛型类之间创建类似子类型的关系,当类型参数相关时,可以参考通配符和子类型。

泛型类和子类型

可以通过扩展或实现一个泛型类或接口来创建其子类型。一个类或接口的类型参数与另一个类或接口的类型参数之间的关系由extends和implements子句决定。

以Collections类为例,ArrayList实现了List,而List扩展了Collection。所以ArrayList是List的子类型,List是Collection的子类型。只要不改变类型参数,类型之间的子类型关系将被保留。

下面是我们想要定义自己的列表接口PayloadList的情况,该接口将一个可选的值P与每个元素相关联。它的声明可能如下:

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

以下PayloadList的参数化类型是List的子类型:

  • PayloadList<String,String>

  • PayloadList<String,Integer>

  • PayloadList<String,Exception>

类型推断

类型推断是Java编译器根据每个方法调用和相应的声明来确定使调用适用的类型参数(或参数)。推断算法确定参数的类型,并且如果可用,确定结果被分配或返回的类型。最后,推断算法尝试找到适用于所有参数的最具体的类型。

为了说明最后一点,在下面的示例中,推断确定传递给pick方法的第二个参数的类型为Serializable:

static <T> T pick(T a1, T a2) { return a2; }
Serializable s = pick("d", new ArrayList<String>());

类型推断和泛型方法

在泛型方法中,你已经了解到了类型推断,它使你能够像调用普通方法一样调用泛型方法,而无需在尖括号之间指定类型。考虑以下示例BoxDemo,需要Box类:

public class BoxDemo {

  public static <U> void addBox(U u, 
      java.util.List<Box<U>> boxes) {
    Box<U> box = new Box<>();
    box.set(u);
    boxes.add(box);
  }

  public static <U> void outputBoxes(java.util.List<Box<U>> boxes) {
    int counter = 0;
    for (Box<U> box: boxes) {
      U boxContents = box.get();
      System.out.println("Box #" + counter + " contains [" +
             boxContents.toString() + "]");
      counter++;
    }
  }

  public static void main(String[] args) {
    java.util.ArrayList<Box<Integer>> listOfIntegerBoxes =
      new java.util.ArrayList<>();
    BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);
    BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);
    BoxDemo.addBox(Integer.valueOf(30), listOfIntegerBoxes);
    BoxDemo.outputBoxes(listOfIntegerBoxes);
  }
}

以下是此示例的输出:

Box #0 contains [10]
Box #1 contains [20]
Box #2 contains [30]

泛型方法addBox定义了一个名为U的类型参数。通常,Java编译器可以推断出泛型方法调用的类型参数。因此,在大多数情况下,你不必指定它们。例如,要调用泛型方法addBox,可以使用类型实参作为类型证明来指定类型参数,如下所示:

BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);

或者,如果省略类型实参,Java编译器会自动推断出类型参数为Integer(根据方法的参数):

BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);

类型推断和实例化泛型类

可以使用一组空的类型参数(<>)替换调用泛型类构造函数所需的类型实参,只要编译器可以从上下文中推断出类型实参。这对应的一对尖括号被非正式地称为“钻石”。

例如,考虑以下变量声明:

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

可以使用空的类型参数(<>)替换构造函数的参数化类型:

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

注意,在实例化泛型类时利用类型推断,必须使用钻石。在以下示例中,编译器生成了未经检查的转换警告,因为HashMap()构造函数引用了HashMap原始类型,而不是Map<String, List>类型:

Map<String, List<String>> myMap = new HashMap(); // unchecked conversion warning

类型推断和泛型和非泛型类的泛型构造函数

请注意,构造函数可以是泛型(也就是说,声明自己的形式类型参数),既可以是泛型类也可以是非泛型类。考虑以下示例:

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

考虑对MyClass进行实例化:

new MyClass<Integer>("")

此语句创建了一个参数化类型MyClass的实例;该语句显式指定了泛型类MyClass的形式类型参数X的类型为Integer。请注意,此泛型类的构造函数包含一个形式类型参数T。编译器将String类型推断为此泛型类构造函数的形式类型参数T的类型(因为该构造函数的实际参数是一个String对象)。

Java SE 7之前的编译器能够推断出泛型构造函数的实际类型参数,类似于泛型方法。然而,在Java SE 7及更高版本的编译器中,如果使用钻石(<>),它们可以推断出正在实例化的泛型类的实际类型参数。考虑以下示例:

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

在此示例中,编译器将Integer类型推断为泛型类MyClass的形式类型参数X的值。它将String类型推断为该泛型类构造函数的形式类型参数T的类型。

注意:重要的是要注意推断算法仅使用调用参数、目标类型和可能的明显预期返回类型来推断类型。推断算法不使用程序后面的结果。

通配符

在泛型代码中,问号(?)被称为通配符,表示未知类型。通配符可以在多种情况下使用:作为参数、字段或局部变量的类型;有时作为返回类型(尽管最好更具体地指定)。通配符永远不会用作泛型方法调用、泛型类实例创建或超类型的类型参数。

以下各节详细讨论了通配符,包括上界通配符、下界通配符和通配符捕获。

上界通配符

你可以使用上界通配符来放宽变量的限制。例如,假设你想编写一个可以处理List、List和List的方法,你可以通过使用上界通配符来实现这一点。

要声明一个上界通配符,使用通配符字符(‘?’),后跟extends关键字,后跟其上界。请注意,在这个上下文中,extends被泛指为“扩展”(如类)或“实现”(如接口)。

要编写适用于Number及其子类型(如Integer、Double和Float)的列表的方法,你可以指定List<? extends Number>。List这个术语比List<? extends Number>更具限制性,因为前者仅匹配类型为Number的列表,而后者匹配类型为Number或任何其子类的列表。

考虑以下process方法:

public static void process(List<? extends Foo> list) { /* ... */ }

上界通配符<? extends Foo>,其中Foo是任意类型,匹配Foo和Foo的任何子类型。process方法可以将列表元素访问为类型Foo:

public static void process(List<? extends Foo> list) {
    for (Foo elem : list) {
        // ...
    }
}

在foreach循环中,elem变量遍历列表中的每个元素。现在可以在elem上使用Foo类中定义的任何方法。

sumOfList方法返回列表中数字的总和:

public static double sumOfList(List<? extends Number> list) {
    double s = 0.0;
    for (Number n : list)
        s += n.doubleValue();
    return s;
}

下面的代码使用整数对象列表打印出sum = 6.0:

List<Integer> li = Arrays.asList(1, 2, 3);
System.out.println("sum = " + sumOfList(li));

双精度值列表可以使用相同的sumOfList方法。下面的代码打印出sum = 7.0:

List<Double> ld = Arrays.asList(1.2, 2.3, 3.5);
System.out.println("sum = " + sumOfList(ld));

无界通配符

无界通配符类型是使用通配符字符(?)指定的,例如List<?>。这被称为未知类型的列表。有两种情况下,无界通配符是一种有用的方法:

  • 如果你正在编写一个可以使用Object类提供的功能来实现的方法
  • 当代码使用泛型类中不依赖于类型参数的方法时。例如,List.size或List.clear。事实上,Class<?>经常被使用,因为Class中的大多数方法都不依赖于T。

考虑以下printList方法:

public static void printList(List<Object> list) {
    for (Object elem : list)
        System.out.println(elem + " ");
    System.out.println();
}

printList的目标是打印任何类型的列表,但它无法实现这个目标 - 它只能打印Object实例的列表;它不能打印List、List、List等,因为它们不是List的子类型。要编写一个通用的printList方法,使用List<?>:

public static void printList(List<?> list) {
    for (Object elem: list)
        System.out.print(elem + " ");
    System.out.println();
}

因为对于任何具体类型A,List是List<?>的子类型,所以可以使用printList来打印任何类型的列表:

List<Integer> li = Arrays.asList(1, 2, 3);
List<String>  ls = Arrays.asList("one", "two", "three");
printList(li);
printList(ls);

注意:在本课程的示例中,使用了Arrays.asList方法。这个静态工厂方法将指定的数组转换并返回一个固定大小的列表。

重要的是要注意,**List和List<?>不是相同的**。你可以将一个Object或任何Object的子类型插入到List中。但是,你只能将null插入到List<?>中。关于何时在特定情况下使用什么样的通配符(如果有的话),请参考“通配符使用指南”部分获取更多信息。

下界通配符

在“上界通配符”一节中,我们看到上界通配符限制了未知类型必须是特定类型或该类型的子类型,并使用extends关键字表示。类似地,下界通配符限制了未知类型必须是特定类型或该类型的超类型。

下界通配符使用通配符字符(‘?’),后跟super关键字,后跟其下界来表示:<? super A>。

注意:可以为通配符指定上界,也可以指定下界,但不能同时指定两者。

假设你想编写一个方法将Integer对象放入一个列表中。为了最大限度地提高灵活性,你希望该方法适用于List、List和List - 任何可以容纳Integer值的类型。

要编写适用于整数列表及其超类型(如Integer、Number和Object)的方法,可以指定List<? super Integer>。List这个术语比List<? super Integer>更具限制性,因为前者仅匹配类型为Integer的列表,而后者匹配任何Integer的超类型的列表。

以下代码将数字1到10添加到列表的末尾:

public static void addNumbers(List<? super Integer> list) {
    for (int i = 1; i <= 10; i++) {
        list.add(i);
    }
}

“通配符使用指南”部分提供了关于何时使用上界通配符和何时使用下界通配符的指导。

通配符和子类型

如《泛型、继承和子类型》中所述,仅仅因为它们的类型之间存在关系,并不意味着泛型类或接口之间有关联。然而,可以使用通配符来创建泛型类或接口之间的关系。

给定以下两个常规(非泛型)类:

class A { /* ... */ }
class B extends A { /* ... */ }

下面的代码是合理的:

B b = new B();
A a = b;

这个示例展示了常规类的继承遵循子类型规则:如果B扩展了A,则B是A的子类型。但是,这个规则不适用于泛型类型:

List<B> lb = new ArrayList<>();
List<A> la = lb;   // 编译时错误

考虑到Integer是Number的子类型,那么List和List之间有什么关系?

  • Integer是Number的子类型,但是List不是List的子类型,实际上,这两种类型没有关联。List和List的共同父类型是List<?>。

为了在代码中通过List的元素访问Number的方法,可以使用上界通配符来创建这些类之间的关系:

List<? extends Integer> intList = new ArrayList<>();
List<? extends Number> numList = intList;  // OK. List<? extends Integer>是List<? extends Number>的子类型

因为Integer是Number的子类型,而numList是一个Number对象的列表,现在intList(一个Integer对象的列表)和numList之间存在关系。下面的图示展示了使用上界和下界通配符声明的几个List类之间的关系。

  • List是List<? extends Integer>和List<? super Integer>的子类型。
  • List<? extends Integer>是List<? extends Number>的子类型,而后者又是List<?>的子类型。
  • List是List<? super Number>和List>? extends Number>的子类型。
  • List<? super Number>是List<? super Integer>的子类型,而后者又是List<?>的子类型。

这些通配符的使用细节请参考《通配符使用指南》部分。

通配符捕获和辅助方法

在某些情况下,编译器会推断出通配符的类型。例如,一个列表可以被定义为List<?>,但是当评估表达式时,编译器会从代码中推断出特定的类型。这种情况被称为通配符捕获。

大多数情况下,你不需要担心通配符捕获,除非你看到一个包含"capture of"短语的错误消息。

在以下WildcardError示例中,在编译时会产生捕获错误:

import java.util.List;

public class WildcardError {

    void foo(List<?> i) {
        i.set(0, i.get(0));
    }
}

在这个示例中,编译器将i输入参数处理为Object类型。当foo方法调用List.set(int, E)时,编译器无法确认要插入到列表中的对象的类型,因此会产生错误。当出现这种类型的错误时,通常意味着编译器认为你正在将错误的类型赋值给变量。泛型的目的就是为了在编译时强制执行类型安全。

在Oracle JDK 7的javac实现中,WildcardError示例生成以下错误:

WildcardError.java:6: error: method set in interface List<E> cannot be applied to given types;
    i.set(0, i.get(0));
     ^
  required: int,CAP#1
  found: int,Object
  reason: actual argument Object cannot be converted to CAP#1 by method invocation conversion
  where E is a type-variable:
    E extends Object declared in interface List
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
1 error

在这个示例中,代码尝试执行一个安全操作,那么你该如何解决编译器错误呢?你可以通过编写一个私有的辅助方法来捕获通配符来解决这个问题。在这种情况下,你可以创建私有的辅助方法fooHelper,如下所示:

public class WildcardFixed {

    void foo(List<?> i) {
        fooHelper(i);
    }


    // Helper method created so that the wildcard can be captured
    // through type inference.
    private <T> void fooHelper(List<T> l) {
        l.set(0, l.get(0));
    }

}

由于有了辅助方法,编译器使用推断确定T是CAP#1(捕获变量)在调用中的类型。这个示例现在可以成功编译。

按照惯例,辅助方法通常被命名为originalMethodNameHelper。

现在考虑一个更复杂的示例,WildcardErrorBad:

import java.util.List;

public class WildcardErrorBad {

    void swapFirst(List<? extends Number> l1, List<? extends Number> l2) {
      Number temp = l1.get(0);
      l1.set(0, l2.get(0)); // expected a CAP#1 extends Number,
                            // got a CAP#2 extends Number;
                            // same bound, but different types
      l2.set(0, temp);	    // expected a CAP#1 extends Number,
                            // got a Number
    }
}

在这个示例中,代码尝试执行一个不安全的操作。例如,考虑swapFirst方法的以下调用:

List<Integer> li = Arrays.asList(1, 2, 3);
List<Double>  ld = Arrays.asList(10.10, 20.20, 30.30);
swapFirst(li, ld);

虽然List和List都满足List<? extends Number>的条件,但是从一个整数值列表中取出一个项目并尝试将其放入一个双精度值列表中是明显错误的。

使用Oracle JDK的javac编译器编译代码会产生以下错误:

WildcardErrorBad.java:7: error: method set in interface List<E> cannot be applied to given types;
      l1.set(0, l2.get(0)); // expected a CAP#1 extends Number,
        ^
  required: int,CAP#1
  found: int,Number
  reason: actual argument Number cannot be converted to CAP#1 by method invocation conversion
  where E is a type-variable:
    E extends Object declared in interface List
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
WildcardErrorBad.java:10: error: method set in interface List<E> cannot be applied to given types;
      l2.set(0, temp);      // expected a CAP#1 extends Number,
        ^
  required: int,CAP#1
  found: int,Number
  reason: actual argument Number cannot be converted to CAP#1 by method invocation conversion
  where E is a type-variable:
    E extends Object declared in interface List
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Number from capture of ? extends Number
WildcardErrorBad.java:15: error: method set in interface List<E> cannot be applied to given types;
        i.set(0, i.get(0));
         ^
  required: int,CAP#1
  found: int,Object
  reason: actual argument Object cannot be converted to CAP#1 by method invocation conversion
  where E is a type-variable:
    E extends Object declared in interface List
  where CAP#1 is a fresh type-variable:
    CAP#1 extends Object from capture of ?
3 errors

这个示例没有辅助方法来解决问题,因为代码本身就是错误的:从一个整数值列表中取出一个项目并尝试将其放入一个双精度值列表中是明显错误的。

通配符使用指南

在学习使用泛型编程时,确定何时使用上界通配符和何时使用下界通配符是比较困惑的方面之一。本文提供了一些设计代码时要遵循的准则。

为了讨论方便,将变量视为提供以下两个功能之一:

  • “In” 变量:一个 “in” 变量向代码提供数据。想象一个带有两个参数的拷贝方法:copy(src, dest)。src 参数提供要拷贝的数据,所以它是 “in” 参数。
  • “Out” 变量:一个 “out” 变量保存数据供其他地方使用。在拷贝示例中,copy(src, dest),dest 参数接受数据,因此它是 “out” 参数。
    当然,有些变量既用于 “in” 目的,也用于 “out” 目的 —— 这种情况也在准则中进行了说明。

在决定是否使用通配符以及使用什么类型的通配符时,可以使用 “in” 和 “out” 原则。以下列表提供了应遵循的准则:

  • 如果变量是 “in” 变量,使用上界通配符,并使用 extends 关键字。
  • 如果变量是 “out” 变量,使用下界通配符,并使用 super 关键字。
  • 如果可以通过在 Object 类中定义的方法来访问 “in” 变量,请使用无界通配符。
  • 如果代码需要将变量同时用作 “in” 和 “out” 变量,请不要使用通配符。

这些准则不适用于方法的返回类型。应避免将通配符用作返回类型,因为这会强制使用该代码的程序员处理通配符。

通过 List<? extends …> 定义的列表可以非正式地视为只读的,但这并不是严格的保证。假设有以下两个类:

class NaturalNumber {

    private int i;

    public NaturalNumber(int i) { this.i = i; }
    // ...
}

class EvenNumber extends NaturalNumber {

    public EvenNumber(int i) { super(i); }
    // ...
}

考虑以下代码:

List<EvenNumber> le = new ArrayList<>();
List<? extends NaturalNumber> ln = le;
ln.add(new NaturalNumber(35));  // 编译时错误

因为List是List<? extends NaturalNumber>的子类型,所以可以将le赋值给ln。但是不能使用ln将一个自然数添加到一个偶数列表中。对列表进行以下操作是可能的:

  • 可以添加null。
  • 可以调用clear方法。
  • 可以获取迭代器并调用remove方法。
  • 可以捕获通配符并写入从列表中读取的元素。

你可以看到,通过List<? extends NaturalNumber>定义的列表在严格意义上并不是只读的,但是你可以认为它是只读的,因为你不能在列表中存储新元素或更改现有元素。

类型擦除

泛型是为了在编译时提供更严格的类型检查并支持泛型编程而引入到Java语言中的。为了实现泛型,Java编译器对以下内容进行了类型擦除:

  • 将泛型类型中的所有类型参数替换为它们的边界或Object(如果类型参数未指定边界)。因此,生成的字节码仅包含普通的类、接口和方法。
  • 在必要时插入类型转换以保持类型安全。
  • 生成桥接方法以保持扩展的泛型类型的多态性。

类型擦除确保不会为参数化类型创建新的类;因此,泛型不会产生运行时开销。

泛型类型的擦除

在类型擦除过程中,Java编译器会擦除所有类型参数,并用它们的第一个边界(如果类型参数有边界)或Object(如果类型参数没有边界)进行替换。

考虑以下表示单向链表中节点的泛型类:

public class Node<T> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

因为类型参数T没有指定边界,所以Java编译器将其替换为Object:

public class Node {

    private Object data;
    private Node next;

    public Node(Object data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Object getData() { return data; }
    // ...
}

在下面的示例中,泛型Node类使用了有界的类型参数:

public class Node<T extends Comparable<T>> {

    private T data;
    private Node<T> next;

    public Node(T data, Node<T> next) {
        this.data = data;
        this.next = next;
    }

    public T getData() { return data; }
    // ...
}

Java编译器将有界的类型参数T替换为第一个边界类Comparable:

public class Node {

    private Comparable data;
    private Node next;

    public Node(Comparable data, Node next) {
        this.data = data;
        this.next = next;
    }

    public Comparable getData() { return data; }
    // ...
}

注意,泛型类型的擦除会导致在运行时无法访问具体的类型参数信息。

泛型方法的擦除

Java编译器还会擦除泛型方法中的类型参数。考虑以下泛型方法:

// 计算anArray中elem出现的次数。
public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
    return cnt;
}

因为T没有指定边界,所以Java编译器将其替换为Object:

public static int count(Object[] anArray, Object elem) {
    int cnt = 0;
    for (Object e : anArray)
        if (e.equals(elem))
            ++cnt;
    return cnt;
}

假设定义了以下类:

class Shape { /* ... */ }
class Circle extends Shape { /* ... */ }
class Rectangle extends Shape { /* ... */ }

可以编写一个泛型方法来绘制不同的形状:

public static <T extends Shape> void draw(T shape) { /* ... */ }

Java编译器将T替换为Shape:

public static void draw(Shape shape) { /* ... */ }

需要注意的是,泛型方法的擦除也会导致在运行时无法访问具体的类型参数信息。

类型擦除和桥接方法的影响

有时类型擦除会导致意外的情况发生。下面的示例展示了这种情况,它展示了编译器在类型擦除过程中有时会创建一个合成方法,称为桥接方法。

给定以下两个类:

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {
    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

考虑以下代码:

MyNode mn = new MyNode(5);
Node n = mn;            // 一个原始类型 - 编译器抛出未经检查的警告
n.setData("Hello");     // 抛出ClassCastException异常。
Integer x = mn.data;    

在类型擦除之后,该代码变成了:

MyNode mn = new MyNode(5);
Node n = mn;            // 一个原始类型 - 编译器抛出未经检查的警告
                        // 注意:这个语句也可以是:
                        //     Node n = (Node)mn;
                        // 但是,编译器没有生成转换操作,因为它不是必需的。
n.setData("Hello");     // 抛出ClassCastException异常。
Integer x = (Integer)mn.data; 

下一节将解释为什么在n.setData(“Hello”);语句中会抛出ClassCastException异常。

桥接方法
在编译一个继承了参数化类或实现了参数化接口的类或接口时,编译器可能需要创建一个合成方法,称为桥接方法,作为类型擦除过程的一部分。通常情况下,你不需要担心桥接方法,但如果在堆栈跟踪中出现了一个桥接方法,你可能会感到困惑。

在类型擦除之后,Node和MyNode类变成了:

public class Node {

    public Object data;

    public Node(Object data) { this.data = data; }

    public void setData(Object data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node {

    public MyNode(Integer data) { super(data); }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

在类型擦除之后,方法的签名不再匹配;Node.setData(T)方法变成了Node.setData(Object)。因此,MyNode.setData(Integer)方法不会覆盖Node.setData(Object)方法。

为了解决这个问题并在类型擦除之后保持泛型类型的多态性,Java编译器生成了一个桥接方法来确保子类型工作正常。

对于MyNode类,编译器为setData生成了以下桥接方法:

class MyNode extends Node {

    // 编译器生成的桥接方法
    //
    public void setData(Object data) {
        setData((Integer) data);
    }

    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }

    // ...
}

桥接方法MyNode.setData(object)委托给原始的MyNode.setData(Integer)方法。因此,n.setData(“Hello”);语句调用了方法MyNode.setData(Object),抛出ClassCastException异常,因为"Hello"无法转换为Integer类型。

非可具体化类型

非可具体化类型是在编译时通过类型擦除而丢失了相关类型参数和类型参数的信息。非可具体化类型不会在运行时完全提供其所有信息。非可具体化类型包括List和List等类型,虚拟机无法在运行时区分这些类型。正如在《限制泛型》中所示,某些情况下无法使用非可具体化类型,例如在instanceof表达式中或作为数组的元素。

堆污染(Heap Pollution)
当参数化类型的变量引用一个不属于该参数化类型的对象时,就发生了堆污染。这种情况发生在程序执行某些操作时,在编译时会生成未检查的警告。如果在编译时(在编译时类型检查规则的范围内)或运行时无法验证涉及参数化类型(例如强制转换或方法调用)的操作的正确性,则会生成未检查的警告。例如,当混合使用原始类型和参数化类型,或进行未检查的强制转换时,就会发生堆污染。

在正常情况下,当所有代码同时编译时,编译器会发出一个未检查的警告,以引起你对潜在的堆污染的注意。如果你将代码分开编译,很难检测到潜在的堆污染风险。如果确保代码编译时没有警告,那么就不会发生堆污染。

具有非可具体化形式参数的可变参数方法的潜在漏洞
包含可变参数输入参数的泛型方法可能会导致堆污染。

考虑以下ArrayBuilder类:

public class ArrayBuilder {

  public static <T> void addToList (List<T> listArg, T... elements) {
    for (T x : elements) {
      listArg.add(x);
    }
  }

  public static void faultyMethod(List<String>... l) {
    Object[] objectArray = l;     // 合法
    objectArray[0] = Arrays.asList(42);
    String s = l[0].get(0);       // 在此处抛出ClassCastException异常
  }

}

下面的示例使用了ArrayBuilder类:

public class HeapPollutionExample {

  public static void main(String[] args) {

    List<String> stringListA = new ArrayList<String>();
    List<String> stringListB = new ArrayList<String>();

    ArrayBuilder.addToList(stringListA, "Seven", "Eight", "Nine");
    ArrayBuilder.addToList(stringListB, "Ten", "Eleven", "Twelve");
    List<List<String>> listOfStringLists =
      new ArrayList<List<String>>();
    ArrayBuilder.addToList(listOfStringLists,
      stringListA, stringListB);

    ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));
  }
}

在编译时,ArrayBuilder.addToList方法的定义会产生以下警告:

warning: [varargs] Possible heap pollution from parameterized vararg type T

当编译器遇到可变参数方法时,它将可变参数形式参数转换为数组。然而,Java编程语言不允许创建参数化类型的数组。在ArrayBuilder.addToList方法中,编译器将可变参数形式参数T… elements转换为形式参数T[] elements,一个数组。然而,由于类型擦除,编译器将可变参数形式参数转换为Object[] elements。因此,可能会发生堆污染。

下面的语句将可变参数形式参数l赋值给对象数组objectArgs:

Object[] objectArray = l;

这个语句有可能引入堆污染。可以将与可变参数形式参数l的参数化类型不匹配的值分配给变量objectArray,并因此可以分配给l。然而,编译器不会在此语句生成未检查的警告。编译器在将可变参数形式参数List… l转换为形式参数List[] l时已经生成了警告。该语句是合法的;变量l的类型是List[],是Object[]的子类型。

因此,如果将任何类型的List对象分配给objectArray数组的任何数组组件,编译器不会发出警告或错误,如下所示:

objectArray[0] = Arrays.asList(42);

这个语句将objectArray数组的第一个数组组件分配为包含一个Integer类型对象的List对象。

假设使用以下语句调用ArrayBuilder.faultyMethod:

ArrayBuilder.faultyMethod(Arrays.asList("Hello!"), Arrays.asList("World!"));

在运行时,JVM会在以下语句处抛出ClassCastException异常:

// 在此处抛出ClassCastException异常
String s = l[0].get(0);

存储在变量l的第一个数组组件中的对象的类型是List,但这个语句期望的是一个类型为List的对象。

防止具有非可具体化形式参数的可变参数方法生成警告
如果声明了一个具有参数化类型参数的可变参数方法,并且确保该方法的主体不会由于对可变参数形式参数的错误处理而抛出ClassCastException或其他类似异常,则可以通过在静态和非构造方法声明中添加以下注解来防止编译器为这种类型的可变参数方法生成警告:

@SafeVarargs

@SafeVarargs注解是方法契约的一部分;该注解断言该方法的实现不会对可变参数形式参数进行错误处理。

也可以使用以下方式抑制此类警告,但这种方法不太理想:

@SuppressWarnings({"unchecked", "varargs"})

然而,这种方法无法抑制从方法调用点生成的警告。如果不熟悉@SuppressWarnings语法,请参阅注解文档。

泛型的限制

为了有效地使用Java泛型,必须考虑以下限制:

  1. 无法使用原始类型实例化泛型类型
  2. 无法创建类型参数的实例
  3. 无法声明其类型为类型参数的静态字段
  4. 无法在参数化类型上使用强制转换或instanceof运算符
  5. 无法创建参数化类型的数组
  6. 无法创建、捕获或抛出参数化类型的对象
  7. 无法重载方法,其中每个重载的形式参数类型在类型擦除后变为相同的原始类型

无法使用原始类型实例化泛型类型
考虑以下参数化类型:

class Pair<K, V> {

    private K key;
    private V value;

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

    // ...
}

在创建Pair对象时,无法将原始类型替换为类型参数K或V:

Pair<int, char> p = new Pair<>(8, 'a');  // 编译错误

只能使用非原始类型替代类型参数K和V:

Pair<Integer, Character> p = new Pair<>(8, 'a');

需要注意的是,Java编译器会自动对8进行装箱,即将其转换为Integer.valueOf(8),并将’a’转换为Character(‘a’):

Pair<Integer, Character> p = new Pair<>(Integer.valueOf(8), new Character('a'));

无法创建类型参数的实例
无法创建类型参数的实例。例如,下面的代码会导致编译错误:

public static <E> void append(List<E> list) {
    E elem = new E();  // 编译错误
    list.add(elem);
}

作为一种解决方法,可以通过反射来创建类型参数的对象:

public static <E> void append(List<E> list, Class<E> cls) throws Exception {
    E elem = cls.newInstance();   // 可行
    list.add(elem);
}

可以按照以下方式调用append方法:

List<String> ls = new ArrayList<>();
append(ls, String.class);

无法声明其类型为类型参数的静态字段
类的静态字段是该类的所有非静态对象共享的类级别变量。因此,不允许使用类型参数的静态字段。考虑以下类:

public class MobileDevice<T> {
    private static T os;

    // ...
}

如果允许类型参数的静态字段,则以下代码将产生混淆:

MobileDevice<Smartphone> phone = new MobileDevice<>();
MobileDevice<Pager> pager = new MobileDevice<>();
MobileDevice<TabletPC> pc = new MobileDevice<>();

由于os静态字段被phone、pager和pc共享,那么os的实际类型是什么?它不能同时是Smartphone、Pager和TabletPC。因此,无法创建类型参数的静态字段。

无法在参数化类型上使用强制转换或instanceof运算符
由于Java编译器会对泛型代码中的所有类型参数进行擦除,所以无法在运行时验证泛型类型的参数化类型。例如:

public static <E> void rtti(List<E> list) {
    if (list instanceof ArrayList<Integer>) {  // 编译错误
        // ...
    }
}

传递给rtti方法的参数化类型集合为:

S = { ArrayList, ArrayList LinkedList, … }

运行时不保留类型参数的信息,因此无法区分ArrayList和ArrayList。最多可以使用无限定通配符来验证列表是否是ArrayList:

public static void rtti(List<?> list) {
    if (list instanceof ArrayList<?>) {  // 可行;instanceof需要一个可具体化的类型
        // ...
    }
}

通常情况下,无法将类型强制转换为参数化类型,除非它是通过无界通配符参数化的。例如:

List<Integer> li = new ArrayList<>();
List<Number> ln = (List<Number>) li;  // 编译错误

但在某些情况下,编译器知道类型参数始终有效,并允许进行强制转换。例如:

List<String> l1 = ...;
ArrayList<String> l2 = (ArrayList<String>)l1;  // 可行

无法创建参数化类型的数组
无法创建参数化类型的数组。例如,以下代码无法编译:

List<Integer>[] arrayOfLists = new List<Integer>[2];  // 编译错误

以下代码说明了在数组中插入不同类型时会发生的情况:

Object[] strings = new String[2];
strings[0] = "hi";   // 可行
strings[1] = 100;    // 抛出ArrayStoreException异常

如果尝试在泛型列表上执行相同的操作,就会出现问题:

Object[] stringLists = new List<String>[2];  // 编译错误,但假装允许
stringLists[0] = new ArrayList<String>();   // 可行
stringLists[1] = new ArrayList<Integer>();  // 不应该抛出ArrayStoreException异常,
                                            // 但运行时无法检测到

如果允许创建参数化列表的数组,前面的代码将无法抛出所期望的ArrayStoreException异常。

无法创建、捕获或抛出参数化类型的对象
泛型类不能直接或间接地扩展Throwable类。例如,以下类无法编译:

// 间接地扩展Throwable
class MathException<T> extends Exception { /* ... */ }    // 编译错误

// 直接地扩展Throwable
class QueueFullException<T> extends Throwable { /* ... */  // 编译错误

方法不能捕获类型参数的实例:

public static <T extends Exception, J> void execute(List<J> jobs) {
    try {
        for (J job : jobs)
            // ...
    } catch (T e) {   // 编译错误
        // ...
    }
}

但是,可以在throws子句中使用类型参数:

class Parser<T extends Exception> {
    public void parse(File file) throws T {     // 可行
        // ...
    }
}

无法重载方法,其中每个重载的形式参数类型在类型擦除后变为相同的原始类型
一个类不能有两个在类型擦除后具有相同签名的重载方法。

public class Example {
    public void print(Set<String> strSet) { }
    public void print(Set<Integer> intSet) { }
}

在类型擦除后,这些重载会共享相同的类文件表示,并且会生成编译时错误。

参考链接

https://docs.oracle.com/javase/tutorial/java/generics/why.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

BigDataMLApplication

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值