Java——泛型

【泛型】Java中的泛型,泛型类,泛型接口,泛型方法,泛型擦除 这篇文章总结一下Java中的泛型相关知识点,包括:泛型概念、泛型优缺点、泛型通配符、泛型上下界、泛型使用(泛型类、泛型接口、泛型方法)、泛型擦除等内容。 一、泛型概念 (1)什么是泛型 什么是泛型??? 泛型(Generic),是一种参数化数据类型,它允许我们在编写程序代码的时候不用具体指定需要什么数据类型,而是等到具体使用的时候,将数据类型以参数的形式传递给程序,这就是泛型程序设计。 (2)为什么需要泛型 我们来看看下面这个栗子 假设现在有个需求,通过集合保存元素,需要保存String字 阅读详情

1、基本概念和原理

1.1、一个简单泛型类

1.1.1、基本概念
public class Pair<T> {
    T first;
    T second;

    public Pair(T first, T second) {
        this.first = first;
        this.second = second;
    }

    public T getFirst() {
        return first;
    }

    public T getSecond() {
        return second;
    }
}

Pair就是一个泛型类,与普通类的区别体现在:

  1. 类名后面多了一个<T>
  2. first和second的类型都是T

T是什么呢?T表示类型参数,泛型就是类型参数化,处理的数据类型不是固定的,而是可以作为参数传入。怎么用这个泛型类,并传递类型参数呢?看代码:

    public static void main(String[] args) {
        Pair<Integer> minmax = new Pair<>(1, 100);
        Integer min = minmax.getFirst();
        Integer max = minmax.getSecond();
    }

Pair中的Integer就是传递的实际类型参数。Pair类的代码和它处理的数据类型不是绑定的,具体类型可以变化。上面是Integer,也可以是String,比如:

Pair<String> kv = new Pair<String>("name", "老马");

类型参数可以有多个,Pair类中的first和second可以是不同的类型,多个类型之间以逗号分隔,来看改进后的Pair类定义:

public class Pair<U, V> {

    U first;
    V second;

    public Pair(U first, V second) {
        this.first = first;
        this.second = second;
    }

    public U getFirst() {
        return first;
    }

    public V getSecond() {
        return second;
    }
}

可以这样使用:

Pair<String, Integer> pair = new Pair<String, Integer>("老马",100);

<String, Integer>既出现在了声明变量时,也出现在了new后面,比较烦琐,从Java 7开始,支持省略后面的类型参数,可以如下使用:

Pair<String, Integer> pair = new Pair<>("老马",100);
1.1.2、基本原理

泛型类型参数到底是什么呢?为什么一定要定义类型参数呢?定义普通类,直接使用Object不就行了吗?比如,Pair类可以写为:

public class Pair {

    Object first;
    Object second;

    public Pair(Object first, Object second) {
        this.first = first;
        this.second = second;
    }

    public Object getFirst() {
        return first;
    }

    public Object getSecond() {
        return second;
    }
}

使用Pair的代码可以为:

    public static void main(String[] args) {
        Pair minmax = new Pair(1, 100);
        Integer min = (Integer)minmax.getFirst();
        Integer max = (Integer)minmax.getSecond();
        Pair kv = new Pair("name", "tom");
        String key = (String)kv.getFirst();
        String value = (String)kv.getSecond();
    }

这样是可以的。实际上,Java泛型的内部原理就是这样的。

我们知道,Java有Java编译器和Java虚拟机,编译器将Java源代码转换为.class文件,虚拟机加载并运行.class文件。对于泛型类,Java编译器会将泛型代码转换为普通的非泛型代码,就像上面的普通Pair类代码及其使用代码一样,将类型参数T擦除,替换为Object,插入必要的强制类型转换。Java虚拟机实际执行的时候,它是不知道泛型这回事的,只知道普通的类及代码。

Java为什么要这么设计呢?泛型是Java 5以后才支持的,这么设计是为了兼容性而不得已的一个选择。

1.1.3、泛型的好处

既然只使用普通类和Object就可以,而且泛型最后也转换为了普通类,那为什么还要用泛型呢?或者说,泛型到底有什么好处呢?泛型主要有两个好处:

  • 更好的安全性。
  • 更好的可读性。

语言和程序设计的一个重要目标是将bug尽量消灭在摇篮里,能消灭在写代码的时候,就不要等到代码写完程序运行的时候。只使用Object,代码写错的时候,开发环境和编译器不能帮我们发现问题,看代码:

Pair pair = new Pair("老马",1);
Integer id = (Integer)pair.getFirst();
String name = (String)pair.getSecond();

看出问题了吗?写代码时不小心把类型弄错了,不过,代码编译时是没有任何问题的,但运行时程序抛出了类型转换异常ClassCastException。如果使用泛型,则不可能犯这个错误,比如下面的代码:

Pair<String, Integer> pair = new Pair<>("老马",1);
Integer id = pair.getFirst(); //有编译错误
String name = pair.getSecond(); //有编译错误

开发环境(如Eclipse)会提示类型错误,即使没有好的开发环境,编译时Java编译器也会提示。这称之为类型安全,也就是说,通过使用泛型,开发环境和编译器能确保不会用错类型,为程序多设置一道安全防护网。使用泛型,还可以省去烦琐的强制类型转换,再加上明确的类型信息,代码可读性也会更好。

1.2、容器类

泛型类最常见的用途是作为容器类。所谓容器类,简单地说,就是容纳并管理多项数据的类。数组就是用来管理多项数据的,但数组有很多限制,比如,长度固定,插入、删除操作效率比较低。

我们来实现一个简单的动态数组容器。所谓动态数组,就是长度可变的数组。底层数组的长度当然是不可变的,但我们提供一个类,对这个类的使用者而言,好像就是一个长度可变的数组。Java容器中有一个对应的类ArrayList,我们来实现一个简化版,如代码所示。

public class DynamicArray<E> {
    private static final int DEFAULT_CAPACITY = 10;//默认容量
    private int size;//元素个数
    private Object[] elementData;//底层数组

    //默认构造
    public DynamicArray() {
        this.elementData = new Object[DEFAULT_CAPACITY];
    }
    
    //新增元素前的容量检查
    private void ensureCapacity(int minCapacity) {
        //当前元素个数
        int oldCapacity = elementData.length;
        //容量够用
        if(oldCapacity >= minCapacity) {
            return;
        }
        //不够,扩容一倍
        int newCapacity = oldCapacity * 2;
        //新容量小于指定容量则使用指定容量
        if(newCapacity < minCapacity) {
            newCapacity = minCapacity;
        }
        //移动旧元素,返回新的底层数组
        elementData = Arrays.copyOf(elementData, newCapacity);
    }
    
    //尾部新增元素
    public void add(E e) {
        ensureCapacity(size + 1);
        elementData[size++] = e;
    }
    
    //根据索引获取元素
    public E get(int index) {
        return (E)elementData[index];
    }
    
    //获取元素个数
    public int size() {
        return size;
    }
    
    //根据索引设置元素
    public E set(int index, E element) {
        E oldValue = get(index);
        elementData[index] = element;
        return oldValue;
    }
}

DynamicArray就是一个动态数组,内部代码与我们之前分析过的StringBuilder类似,通过ensureCapacity方法来根据需要扩展数组。作为一个容器类,它容纳的数据类型是作为参数传递过来的,比如,存放Double类型:

    public static void main(String[] args) {
        DynamicArray<Double> arr = new DynamicArray<>();
        Random random = new Random();
        int size = 1 + random.nextInt(100);
        for (int i = 0; i < size; i++) {
            arr.add(Math.random());
        }
        Double d = arr.get(random.nextInt(size));
    }

这就是一个简单的容器类,适用于各种数据类型,且类型安全。后文还会以Dynamic-Array为例进行扩展,以解释泛型概念。

具体的类型还可以是一个泛型类,比如,可以这样写:

DynamicArray<Pair<Integer, String>> arr = new DynamicArray<>()

arr表示一个动态数组,每个元素是Pair<Integer, String>类型。

1.3、泛型方法

除了泛型类,方法也可以是泛型的,而且,一个方法是不是泛型的,与它所在的类是不是泛型没有什么关系。我们看个例子:

public static <T> int indexOf(T[] arr, T elm){
    for(int i=0; i<arr.length; i++){
        if(arr[i].equals(elm)){
            return i;
        }
    }
    return -1;
}

这个方法就是一个泛型方法,类型参数为T,放在返回值前面,它可以如下调用:

indexOf(new Integer[]{1,3,5}, 10)
//也可以如下调用
indexOf(new String[]{"hello", "老马", "编程"}, "老马")

indexOf表示一个算法,在给定数组中寻找某个元素,这个算法的基本过程与具体数据类型没有什么关系,通过泛型,它可以方便地应用于各种数据类型,且由编译器保证类型安全。

与泛型类一样,类型参数可以有多个,以逗号分隔,比如:

public static <U, V> Pair<U, V> makePair(U first, V second){
    Pair<U, V> pair = new Pair<>(first, second);
    return pair;
}

与泛型类不同,调用方法时一般并不需要特意指定类型参数的实际类型,比如调用make-Pair:

makePair(1, "老马");

并不需要告诉编译器U的类型是Integer, V的类型是String, Java编译器可以自动推断出来。

1.4、泛型接口

接口也可以是泛型的,我们之前介绍过的Comparable和Comparator接口都是泛型的,它们的代码如下:

public interface Comparable<T> {
   public int compareTo(T o);
}
public interface Comparator<T> {
   int compare(T o1, T o2);
   boolean equals(Object obj);
}

与前面一样,T是类型参数。实现接口时,应该指定具体的类型,比如,对Integer类,实现代码是:

public final class Integer extends Number implements Comparable<Integer>{
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }
    //其他代码
}

通过implements Comparable<Integer>, Integer实现了Comparable接口,指定了实际类型参数为Integer,表示Integer只能与Integer对象进行比较。

再看Comparator的一个例子,String类内部一个Comparator的接口实现为:

private static class CaseInsensitiveComparator
        implements Comparator<String> {
    public int compare(String s1, String s2) {
        //省略主体代码
    }
}

这里,指定了实际类型参数为String。

1.5、类型参数的限定

1.5.1、上界为某个具体类

比如,上面的Pair类,可以定义一个子类NumberPair,限定两个类型参数必须为Number,代码如下:

public class NumberPair<U extends Number, V extends Number>
   extends Pair<U, V> {
   public NumberPair(U first, V second) {
       super(first, second);
   }
}

限定类型后,就可以使用该类型的方法了。比如,对于NumberPair类,first和second变量就可以当作Number进行处理了。比如可以定义一个求和方法,如下所示:

public double sum(){
    return getFirst().doubleValue() +getSecond().doubleValue();
}

可以这么用:

NumberPair<Integer, Double> pair = new NumberPair<>(10, 12.34);
double sum = pair.sum();

限定类型后,如果类型使用错误,编译器会提示。指定边界后,类型擦除时就不会转换为Object了,而是会转换为它的边界类型,这也是容易理解的。

1.5.2、上界为某个接口

在泛型方法中,一种常见的场景是限定类型必须实现Comparable接口,我们来看代码:

    public static <T extends Comparable> T max(T[] arr) {
        T max = arr[0];
        for (int i = 1; i < arr.length; i++) {
            if(arr[i].compareTo(max) > 0) {
                max = arr[i];
            }
        }
        return max;
    }

max方法计算一个泛型数组中的最大值。计算最大值需要进行元素之间的比较,要求元素实现Comparable接口,所以给类型参数设置了一个上边界Comparable, T必须实现Comparable接口。

不过,直接这么编写代码,Java中会给一个警告信息,因为Comparable是一个泛型接口,它也需要一个类型参数,所以完整的方法声明应该是:

public static <T extends Comparable<T>> T max(T[] arr){
    //主体代码
}

<T extends Comparable<T>>是一种令人费解的语法形式,这种形式称为递归类型限制,可以这么解读:T表示一种数据类型,必须实现Comparable接口,且必须可以与相同类型的元素进行比较。

1.5.3、上界为其他类型参数

上面的限定都是指定了一个明确的类或接口,Java支持一个类型参数以另一个类型参数作为上界。为什么需要这个呢?我们看个例子,给上面的DynamicArray类增加一个实例方法addAll,这个方法将参数容器中的所有元素都添加到当前容器里来,直觉上,代码可以如下书写:

    //将另一个容器的所有对象添加进来
    public void addAll(DynamicArray<E> c) {
        for (int i = 0; i < c.size; i++) {
            add(c.get(i));
        }
    }

但这么写有一些局限性,我们看使用它的代码:

DynamicArray<Number> numbers = new DynamicArray<>();
DynamicArray<Integer> ints = new DynamicArray<>();
ints.add(100);
ints.add(34);
numbers.addAll(ints);//编译错误

numbers是一个Number类型的容器,ints是一个Integer类型的容器,我们希望将ints添加到numbers中,因为Integer是Number的子类,应该说,这是一个合理的需求和操作。

但Java会在numbers.addAll(ints)这行代码上提示编译错误:addAll需要的参数类型为DynamicArray<Number>,而传递过来的参数类型为DynamicArray<Integer>,不适用。Integer是Number的子类,怎么会不适用呢?

事实就是这样,确实不适用,而且是很有道理的,假设适用,我们看下会发生什么。

 DynamicArray<Integer> ints = new DynamicArray<>();
 DynamicArray<Number> numbers = ints; //假设这行是合法的
 numbers.add(new Double(12.34));

那最后一行就是合法的,这时,DynamicArray中就会出现Double类型的值,而这显然破坏了Java泛型关于类型安全的保证。

我们强调一下,虽然Integer是Number的子类,但DynamicArray并不是DynamicArray的子类,DynamicArray的对象也不能赋值给Dynamic-Array的变量,这一点初看上去是违反直觉的,但这是事实,必须要理解这一点。

不过,我们的需求是合理的,将Integer添加到Number容器中并没有问题。这个问题可以通过类型限定来解决:

public <T extends E> void addAll(DynamicArray<T> c) {
    for(int i=0; i<c.size; i++){
        add(c.get(i));
    }
}

E是DynamicArray的类型参数,T是addAll的类型参数,T的上界限定为E,这样,下面的代码就没有问题了:

public static void main(String[] args) {
    DynamicArray<Number> numbers = new DynamicArray<>();
    DynamicArray<Integer> ints = new DynamicArray<>();
    ints.add(100);
    ints.add(34);
    numbers.addAll(ints);//正确
    for (int i = 0; i < numbers.size; i++) {
        System.out.print(numbers.get(i) + " ");
    }
}

对于这个例子,这种写法有点烦琐,后面会介绍一种简化的方式。

2、解析通配符

2.1、更简洁的参数类型限定

为了将Integer对象添加到Number容器中,我们的类型参数使用了其他类型参数作为上界,我们提到,这种写法有点烦琐,它可以替换为更为简洁的通配符形式:

public void addAll(DynamicArray<? extends E> c) {
    for (int i = 0; i < c.size; i++) {
        add(c.get(i));
    } 
}

这个方法没有定义类型参数,c的类型是DynamicArray<?extends E>, ?表示通配符,<? extends E>表示有限定通配符,匹配E或E的某个子类型,具体什么子类型是未知的。使用这个方法的代码不需要做任何改动,还可以是:

 DynamicArray<Number> numbers = new DynamicArray<>();
 DynamicArray<Integer> ints = new DynamicArray<>();
 ints.add(100);
 ints.add(34);
 numbers.addAll(ints);

这里,E是Number类型,DynamicArray<? extends E>可以匹配DynamicArray<Integer>。

那么问题来了,同样是extends关键字,同样应用于泛型,<T extends E>和<? extends E>到底有什么关系?它们用的地方不一样,我们解释一下:

  1. <T extends E>用于定义类型参数,它声明了一个类型参数T,可放在泛型类定义中类名后面、泛型方法返回值前面。
  2. <? extends E>用于实例化类型参数,它用于实例化泛型变量中的类型参数,只是这个具体类型是未知的,只知道它是E或E的某个子类型。

虽然它们不一样,但两种写法经常可以达成相同目标,比如,前面例子中,下面两种写法都可以:

public void addAll(DynamicArray<? extends E> c)
public <T extends E> void addAll(DynamicArray<T> c)

那么,到底应该用哪种形式呢?我们先进一步理解通配符,然后再解释。

2.2、理解通配符

除了有限定通配符,还有一种通配符,形如DynamicArray<? >,称为无限定通配符。我们来看个例子,在DynamicArray中查找指定元素,代码如下:

public static int indexOf(DynamicArray<?> arr, Object elm) {
    for (int i = 0; i < arr.size(); i++) {
        if (arr.get(i).equals(elm)) {
            return i;
        }
    }
    return -1;
}

其实,这种无限定通配符形式也可以改为使用类型参数。也就是说,下面的写法:

public static int indexOf(DynamicArray<? > arr, Object elm)

可以改为:

public static <T> int indexOf(DynamicArray<T> arr, Object elm)

不过,通配符形式更为简洁。虽然通配符形式更为简洁,但上面两种通配符都有一个重要的限制:只能读,不能写。怎么理解呢?看下面的例子:

DynamicArray<Integer> ints = new DynamicArray<>();
DynamicArray<? extends Number> numbers = ints;
Integer a = 200;
numbers.add(a); //错误!
numbers.add((Number)a); //错误!
numbers.add((Object)a); //错误!

三种add方法都是非法的,无论是Integer,还是Number或Object,编译器都会报错。为什么呢?问号就是表示类型安全无知,? extends Number表示是Number的某个子类型,但不知道具体子类型,如果允许写入,Java就无法确保类型安全性,所以干脆禁止。我们来看个例子,看看如果允许写入会发生什么:

 DynamicArray<Integer> ints = new DynamicArray<>();
 DynamicArray<? extends Number> numbers = ints;
 Number n = new Double(23.0);
 Object o = new String("hello world");
 numbers.add(n);
 numbers.add(o);

如果允许写入Object或Number类型,则最后两行编译就是正确的,也就是说,Java将允许把Double或String对象放入Integer容器,这显然违背了Java关于类型安全的承诺。

大部分情况下,这种限制是好的,但这使得一些理应正确的基本操作无法完成,比如交换两个元素的位置,看如下代码:

public static void swap(DynamicArray<? > arr, int i, int j){
    Object tmp = arr.get(i);
    arr.set(i, arr.get(j));
    arr.set(j, tmp);
}

这个代码看上去应该是正确的,但Java会提示编译错误,两行set语句都是非法的。不过,借助带类型参数的泛型方法,这个问题可以如下解决:

private static <T> void swapInternal(DynamicArray<T> arr, int i, int j){
    T tmp = arr.get(i);
    arr.set(i, arr.get(j));
    arr.set(j, tmp);
}
public static void swap(DynamicArray<? > arr, int i, int j){
    swapInternal(arr, i, j);
}

swap可以调用swapInternal,而带类型参数的swapInternal可以写入。Java容器类中就有类似这样的用法,公共的API是通配符形式,形式更简单,但内部调用带类型参数的方法。

除了这种需要写的场合,如果参数类型之间有依赖关系,也只能用类型参数,比如,将src容器中的内容复制到dest中:

public static <D, S extends D> void copy(DynamicArray<D> dest,
        DynamicArray<S> src){
    for(int i=0; i<src.size(); i++){
        dest.add(src.get(i));
    }
}

S和D有依赖关系,要么相同,要么S是D的子类,否则类型不兼容,有编译错误。不过,上面的声明可以使用通配符简化,两个参数可以简化为一个,如下所示:

public static <D> void copy(DynamicArray<D> dest, DynamicArray<? extends D> src) {
	for(int i = 0; i < src.size(); i++) {
		dest.add(src.get(i));
	}
}

如果返回值依赖于类型参数,也不能用通配符,比如,计算动态数组中的最大值,如下所示:

public static <T extends Comparable<T>> T max(DynamicArray<T> arr){
    T max = arr.get(0);
    for(int i=1; i<arr.size(); i++){
        if(arr.get(i).compareTo(max)>0){
            max = arr.get(i);
        }
    }
    return max;
}

上面的代码就难以用通配符代替。

现在我们再来看泛型方法到底应该用通配符的形式还是加类型参数。两者到底有什么关系?我们总结如下。

  1. 通配符形式都可以用类型参数的形式来替代,通配符能做的,用类型参数都能做。
  2. 通配符形式可以减少类型参数,形式上往往更为简单,可读性也更好,所以,能用通配符的就用通配符。
  3. 如果类型参数之间有依赖关系,或者返回值依赖类型参数,或者需要写操作,则只能用类型参数。
  4. 通配符形式和类型参数往往配合使用,比如,上面的copy方法,定义必要的类型参数,使用通配符表达依赖,并接受更广泛的数据类型。

2.3、超类型通配符

还有一种通配符,与形式<? extends E>正好相反,它的形式为<? super E>,称为超类型通配符,表示E的某个父类型。它有什么用呢?有了它,我们就可以更灵活地写入了。

如果没有这种语法,写入会有一些限制。来看个例子,我们给DynamicArray添加一个方法:

public void copyTo(DynamicArray<E> dest){
    for(int i=0; i<size; i++){
        dest.add(get(i));
    }
}

这个方法也很简单,将当前容器中的元素添加到传入的目标容器中。我们可能希望这么使用:

DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
DynamicArray<Number> numbers = new DynamicArray<Number>();
ints.copyTo(numbers);

Integer是Number的子类,将Integer对象拷贝入Number容器,这种用法应该是合情合理的,但Java会提示编译错误,理由我们之前也说过了,期望的参数类型是Dynamic-Array<Integer>, DynamicArray<Number>并不适用。

如之前所说,一般而言,不能将DynamicArray看作DynamicArray,但我们这里的用法是没有问题的,Java解决这个问题的方法就是超类型通配符,可以将copyTo代码改为:

public void copyTo(DynamicArray<? super E> dest){
    for(int i=0; i<size; i++){
        dest.add(get(i));
    }
}

这样,就没有问题了。

超类型通配符另一个常用的场合是Comparable/Comparator接口。同样,我们先来看下如果不使用会有什么限制。以前面计算最大值的方法为例,它的方法声明是:

public static <T extends Comparable<T>> T max(DynamicArray<T> arr)

这个声明有什么限制呢?举个简单的例子,有两个类Base和Child, Base的代码是:

class Base implements Comparable<Base>{
   private int sortOrder;
   public Base(int sortOrder) {
       this.sortOrder = sortOrder;
   }
   @Override
   public int compareTo(Base o) {
       if(sortOrder < o.sortOrder){
           return -1;
       }else if(sortOrder > o.sortOrder){
           return 1;
		}else{
		   return 0;
		}
	}
}

Base代码很简单,实现了Comparable接口,根据实例变量sortOrder进行比较。Child代码是:

class Child extends Base {
    public Child(int sortOrder) {
        super(sortOrder);
    }
}

这里,Child非常简单,只是继承了Base。注意:Child没有重新实现Comparable接口,因为Child的比较规则和Base是一样的。我们可能希望使用前面的max方法操作Child容器,如下所示:

DynamicArray<Child> childs = new DynamicArray<Child>();
childs.add(new Child(20));
childs.add(new Child(80));
Child maxChild = max(childs);

遗憾的是,Java会提示编译错误,类型不匹配。为什么不匹配呢?我们可能会认为,Java会将max方法的类型参数T推断为Child类型,但类型T的要求是extends Comparable<T>,而Child并没有实现Comparable<Child>,它实现的是Comparable<Base>。

但我们的需求是合理的,Base类的代码已经有了关于比 较所需要的全部数据,它应该可以用于比较Child对象。解决这个问题的方法,就是修改max的方法声明,使用超类型通配符,如下所示:

public static <T extends Comparable<? super T>> T max(DynamicArray<T> arr)

这么修改一下就可以了,这种写法比较抽象,将T替换为Child,就是:

Child extends Comparable<? super Child>

<? super Child>可以匹配Base,所以整体就是匹配的。

我们比较一下类型参数限定与超类型通配符,类型参数限定只有extends形式,没有super形式,比如,前面的copyTo方法的通配符形式的声明为:

public void copyTo(DynamicArray<? super E> dest)

如果类型参数限定支持super形式,则应该是:

public <T super E> void copyTo(DynamicArray<T> dest)

前面我们说过,对于有限定的通配符形式<? extendsE>,可以用类型参数限定替代,但是对于类似上面的超类型通配符,则无法用类型参数替代。

2.4、通配符比较

泛型中的三种通配符形式<? >、<? super E>和<? extends E>,并分析了与类型参数形式的区别和联系,它们比较容易混淆,我们总结比较如下:

1.它们的目的都是为了使方法接口更为灵活,可以接受更为广泛的类型。
2. <? super E>用于灵活写入或比较,使得对象可以写入父类型的容器,使得父类型的比较方法可以应用于子类对象,它不能被类型参数形式替代。
3. <? >和<? extends E>用于灵活读取,使得方法可以读取E或E的任意子类型的容器对象,它们可以用类型参数的形式替代,但通配符形式更为简洁。

Java容器类的实现中,有很多使用通配符的例子,比如,类Collections中就有如下方法:

public static <T extends Comparable<? super T>> void sort(List<T> list)
public static <T> void sort(List<T> list, Comparator<? super T> c)
public static <T> void copy(List<? super T> dest, List<? extends T> src)
public static <T> T max(Collection<? extends T> coll,
    Comparator<? super T> comp)
通配符原因
<? extents T>安全读取
<? super T>安全写入
<T>即读又写

3、细节问题

3.1、使用泛型类、方法和接口

在使用泛型类、方法和接口时,有一些值得注意的地方,比如:

  • 基本类型不能用于实例化类型参数。
  • 运行时类型信息不适用于泛型。
  • 类型擦除可能会引发一些冲突。

我们逐个来看下。Java中,因为类型参数会被替换为Object,所以Java泛型中不能使用基本数据类型,也就是说,类似下面的写法是不合法的:

Pair<int> minmax = new Pair<int>(1,100);

解决方法是使用基本类型对应的包装类。

在内存中每个类都有一份类型信息,而每个对象也都保存着其对应类型信息的引用。在Java中,这个类型信息也是一个对象,它的类型为Class, Class本身也是一个泛型类,每个类的类型对象可以通过<类名>.class的方式引用,比如String. class、Integer.class。这个类型对象也可以通过对象的getClass()方法获得,比如:

Class<? > cls = "hello".getClass();

这个类型对象只有一份,与泛型无关,所以Java不支持类似如下写法:

Pair<Integer>.class

一个泛型对象的getClass方法的返回值与原始类型对象也是相同的,比如,下面代码的输出都是true:

Pair<Integer> p1 = new Pair<Integer>(1,100);
Pair<String> p2 = new Pair<String>("hello", "world");
System.out.println(Pair.class==p1.getClass()); //true
System.out.println(Pair.class==p2.getClass()); //true

之前,我们介绍过instanceof关键字,instanceof后面是接口或类名,instanceof是运行时判断,也与泛型无关,所以,Java也不支持类似如下写法:

if(p1 instanceof Pair<Integer>)

不过,Java支持如下写法:

        if(p1 instanceof Pair<? >)

由于类型擦除,可能会引发一些编译冲突,这些冲突初看上去并不容易理解,有两个类Base和Child, Base的声明为:

class Base implements Comparable<Base>

Child的声明为:

class Child extends Base

Child没有专门实现Comparable接口,Base类已经有了比较所需的全部信息,所以Child没有必要实现,可是如果Child希望自定义这个比较方法呢?直觉上,可以这样修改Child类:

class Child extends Base implements Comparable<Child>{
    //主体代码
}

遗憾的是,Java编译器会提示错误,Comparable接口不能被实现两次,且两次实现的类型参数还不同,一次是Comparable<Base>,一次是Comparable<Child>。为什么不允许呢?因为类型擦除后,实际上只能有一个。

那Child有什么办法修改比较方法呢?只能是重写Base类的实现,如下所示:

class Child extends Base {
    @Override
    public int compareTo(Base o) {
        if(! (o instanceof Child)){
            throw new IllegalArgumentException();
        }
        Child c = (Child)o;
        //比较代码
        return 0;
    }
    //其他代码
}

另外,你可能认为可以如下定义重载方法:

public static void test(DynamicArray<Integer> intArr)
public static void test(DynamicArray<String> strArr)

虽然参数都是DynamicArray,但实例化类型不同,一个是DynamicArray,另一个是DynamicArray,同样,遗憾的是,Java不允许这种写法,理由同样是类型擦除后它们的声明是一样的。

3.2、定义泛型类、方法和接口

在定义泛型类、方法和接口时,也有一些需要注意的地方,比如:

  • 不能通过类型参数创建对象。
  • 泛型类类型参数不能用于静态变量和方法。
  • 了解多个类型限定的语法。

不能通过类型参数创建对象,比如,T是类型参数,下面的写法都是非法的:

T elm = new T();
T[] arr = new T[10];

为什么非法呢?因为如果允许,那么用户会以为创建的就是对应类型的对象,但由于类型擦除,Java只能创建Object类型的对象,而无法创建T类型的对象,容易引起误解,所以Java干脆禁止这么做。

那如果确实希望根据类型创建对象呢?需要设计API接受类型对象,即Class对象,并使用Java中的反射机制。如果类型有默认构造方法,可以调用Class的newInstance方法构建对象,类似这样:

public static <T> T create(Class<T> type){
    try {
        return type.newInstance();
    } catch (Exception e) {
        return null;
    }
}

比如:

Date date = create(Date.class);
StringBuilder sb = create(StringBuilder.class);

对于泛型类声明的类型参数,可以在实例变量和方法中使用,但在静态变量和静态方法中是不能使用的。类似下面这种写法是非法的:

public class Singleton<T> {
    private static T instance;
    public synchronized static T getInstance(){
        if(instance==null){
              //创建实例
        }
        return instance;
    }
}

如果合法,那么对于每种实例化类型,都需要有一个对应的静态变量和方法。但由于类型擦除,Singleton类型只有一份,静态变量和方法都是类型的属性,且与类型参数无关,所以不能使用泛型类类型参数。

不过,对于静态方法,它可以是泛型方法,可以声明自己的类型参数,这个参数与泛型类的类型参数是没有关系的。

之前介绍类型参数限定的时候,我们提到上界可以为某个类、某个接口或者其他类型参数,但上界都是只有一个,Java中还支持多个上界,多个上界之间以&分隔,类似这样:

T extends Base & Comparable & Serializable

Base为上界类,Comparable和Serializable为上界接口。如果有上界类,类应该放在第一个,类型擦除时,会用第一个上界替换。

3.3、泛型与数组

引入泛型后,一个令人惊讶的事实是,不能创建泛型数组。比如,我们可能想这样创建一个Pair的泛型数组:

Pair<Object, Integer>[] options = new Pair<Object, Integer>[]{
        new Pair("1元",7), new Pair("2元", 2), new Pair("10元", 1)
};

Java会提示编译错误,不能创建泛型数组。这是为什么呢?我们先来进一步理解一下数组。

前面我们解释过,类型参数之间有继承关系的容器之间是没有关系的,比如,一个DynamicArray<Integer>对象不能赋值给一个DynamicArray<Number>变量。不过,数组是可以的,看代码:

Integer[] ints = new Integer[10];
Number[] numbers = ints;
Object[] objs = ints;

后面两种赋值都是允许的。数组为什么可以呢?数组是Java直接支持的概念,它知道数组元素的实际类型,知道Object和Number都是Integer的父类型,所以这个操作是允许的。

虽然Java允许这种转换,但如果使用不当,可能会引起运行时异常,比如:

Integer[] ints = new Integer[10];
Object[] objs = ints;
objs[0] = "hello";

编译是没有问题的,运行时会抛出ArrayStoreException,因为Java知道实际的类型是Integer,所以写入String会抛出异常。

理解了数组的这个行为,我们再来看泛型数组。如果Java允许创建泛型数组,则会发生非常严重的问题,我们看看具体会发生什么:

Pair<Object, Integer>[] options = new Pair<Object, Integer>[3];
Object[] objs = options;
objs[0] = new Pair<Double, String>(12.34, "hello");

如果可以创建泛型数组options,那它就可以赋值给其他类型的数组objs,而最后一行明显错误的赋值操作,则既不会引起编译错误,也不会触发运行时异常,因为Pair<Double, String>的运行时类型是Pair,和objs的运行时类型Pair[​]是匹配的。但我们知道,它的实际类型是不匹配的,在程序的其他地方,当把objs[0]作为Pair<Object, Integer>进行处理的时候,一定会触发异常。

也就是说,如果允许创建泛型数组,那就可能会有上面这种错误操作,它既不会引起编译错误,也不会立即触发运行时异常,却相当于埋下了一颗炸弹,不定什么时候爆发,为避免这种情况,Java干脆就禁止创建泛型数组。

但现实需要能够存放泛型对象的容器,怎么办呢?可以使用原始类型的数组,比如:

Pair[] options = new Pair[]{
      new Pair<String, Integer>("1元",7),
      new Pair<String, Integer>("2元", 2),
      new Pair<String, Integer>("10元", 1)};

有时,我们希望转换泛型容器为一个数组,比如,对于DynamicArray,我们可能希望它有这么一个方法:

public E[] toArray()

而希望可以这么用:

DynamicArray<Integer> ints = new DynamicArray<Integer>();
ints.add(100);
ints.add(34);
Integer[] arr = ints.toArray();

先使用动态容器收集一些数据,然后转换为一个固定数组,这也是一个常见的合理需求,怎么来实现这个toArray方法呢?可能想先这样:

E[] arr = new E[size];

遗憾的是,如之前所述,这是不合法的。Java运行时根本不知道E是什么,也就无法做到创建E类型的数组。另一种想法是这样:

public E[] toArray(){
    Object[] copy = new Object[size];
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}

或者使用之前介绍的Arrays方法:

public E[] toArray(){
    return (E[])Arrays.copyOf(elementData, size);
}

结果都是一样的,没有编译错误了,但运行时会抛出ClassCastException异常,原因是Object类型的数组不能转换为Integer类型的数组。

那怎么办呢?可以利用Java中的运行时类型信息和反射机制,这些概念我们后续章节再详细介绍。这里我们简要介绍下。Java必须在运行时知道要转换成的数组类型,类型可以作为参数传递给toArray方法,比如:

public E[] toArray(Class<E> type){
    Object copy = Array.newInstance(type, size);
    System.arraycopy(elementData, 0, copy, 0, size);
    return (E[])copy;
}

Class<E>表示要转换成的数组类型信息,有了这个类型信息,Array类的newInstance方法就可以创建出真正类型的数组对象。调用toArray方法时,需要传递需要的类型,比如,可以这样:

Integer[] arr = ints.toArray(Integer.class);

我们来稍微总结下泛型与数组的关系:

  • Java不支持创建泛型数组。
  • 如果要存放泛型对象,可以使用原始类型的数组,或者使用泛型容器。
  • 泛型容器内部使用Object数组,如果要转换泛型容器为对应类型的数组,需要使用反射。
FPGA之全数字锁相环(DPLL) 1. 数字锁相环的基本原理和组成 1.1 锁相环(PLL)的简介 锁相环最开始并不是用在数字电路中,而是模拟短路中。对于模拟电路中的锁相环,大家可以参考这篇文章稍微看一下,数字和模拟只是实现的方法不同,但是原理、步骤还是共同的。关于模拟PLL。可以参考一片文章: http://www.sohu.com/a/190569594_464086 首先在学习数字锁相环之前,我们应该先了解什么是锁相环?锁相... 阅读详情

相关推荐

K-L展开 (Karhunen-Loeve expansion)

K-L变换

susan_wang1的博客 2万+

Java之泛型

一、什么是泛型 1.背景: JAVA推出泛型以前,程序员可以构建一个元素类型为Object的集合,该集合能够存储任意的数据类型对象,而在使用该集合的过程中,需要程序员明确知道存储每个元素的数据类型,否则很容易引发ClassCastException异常。 2.概念: Java泛型(generics)是JDK5中引入的一个新特性,泛型提供了编译时类型安全监测机制,该机制允许我们在编译时检测到非法的类型数据结构。泛型的本质就是参数化类型,也就是所操作的数据类型被指定为一个参数。 3.好处: 类型安全 消

Beyondczn的博客 9373

保姆级教程:手把手教你用记事本修改DNF私服PVF,打造毕业级自定义装备

本文提供了一份详细的DNF私服PVF文件修改教程,教你如何使用记事本轻松打造毕业级自定义装备。从PVF文件结构解析到基础属性修改,再到高级特效设置,手把手教你实现装备属性的全方位定制,无需编程基础即可完成。

weixin_42525385的博客 1202

Java泛型详解:Java泛型集合、泛型类、泛型方法、泛型的使用

在 Java 1.5 之前没有泛型,通常需要使用强制类型转换的方式将一种数据类型转换为另一种数据类型,这种转换要求开发者对实际参数的类型具有可预知性。对于强制类型转换错误的情况,编译器可能不会提示错误,但是在运行时会出现异常,这是一个安全隐患。 为了解决这一隐患,从 Java 1.5 开始提供了泛型。泛型可以在编译的时候检查类型安全,并且所有的强制转换都是自动和隐式的,提高了代码的重用率。本节将详...

jijiangpeng的博客 7447

聚焦112Gb/s SerDes芯片的AN/LT端口自协商和链路学习,评估验证高速链路的信号质量并分析调优(400/800G高速以太网互联接口,AI加速卡网络RDMA性能测试,交换背板接口性能评估)

在112G SerDes高速通道下的400G/800G高速以太网端口下,如何保障通信速率,验证SerDes的数据完整性和链路余量,自动协商(AN)和链路训练(LT)功能显得尤其重要,其在DAC、ACC、AEC铜缆连接或高速背板通信上,自动协商(AN)和链路训练(LT)的工作与否将决定高速链路传输质量的优劣。本文将系统的讨论介绍自动协商(AN)和链路训练(LT)的测试流程,其可以独立使用,也可以与teledynelecroy SierraNet M1288配合使用。

专注有线以太网性能测试--丹麦信雅纳网络(Xena Networks) 9406

Windows 10/11 零基础配置 SillyTavern 连接 Claude API 完整指南(含 Node.js 环境搭建)

本文提供Windows 10/11系统下零基础配置SillyTavern连接Claude API的完整教程,涵盖Node.js环境搭建、SillyTavern初始化及API连接配置等关键步骤。通过详细的操作指南和实用技巧,帮助用户快速搭建AI聊天界面并优化对话体验,实现与Claude AI的高效交互。

weixin_30642561的博客 134

Java泛型——为何要有泛型

对于String类类型来说,它是继承自Object的,而Integer的直接父类是Number,因此会有Integer无法转String的错误。泛型类是在实例化类的时候指明泛型的具体类型,那么泛型方法就是在调用方法时指明泛型的具体类型。在实现传入泛型实参的泛型接口的实现类定义中,实现类即使不声明泛型,也不会编译错误。在实现未传入泛型实参的泛型接口的实现类定义中,实现类要声明泛型,否则会编译错误。为了避免这样可能的错误,引入泛型,使编译器在编译阶段就提示不匹配的类类型。方法返回了一个更加特定的返回类型。

xiaovie的博客 1734

Hopf振荡器在机器人步态控制里到底怎么用?一个CPG仿生案例说清楚

本文深入解析了Hopf振荡器在机器人步态控制中的实际应用,通过CPG仿生案例详细展示了从数学方程到关节指令的完整流程。重点探讨了Hopf振荡器的极限环稳定性、参数调节技巧及自适应控制策略,为机器人运动控制提供了高效解决方案。

weixin_29284657的博客 202

java泛型——泛型类、泛型方法、泛型接口

泛型Java 泛型Java 泛型是什么泛型类泛型类的格式泛型类举例泛型方法泛型方法的格式泛型方法举例泛型接口泛型接口的格式泛型接口举例 Java 泛型 Java 泛型是什么 官方定义: 泛型是Java SE 1.5的新特性,泛型的本质就是参数化类型,也就是说所操作的数据类型被指定为一个参数 这种参数类型可以用在类、接口和方法的创建中,分别成为泛型类、泛型接口、泛型方法 优缺点: ​ 优点:可以不用再类型转换了 ​ 缺点:降低了代码的可读性 泛型类 泛型类:当一个类要操作的引用数据类型不确定的时候,可

一个小小的码农 6600

Habitat安装全网最详细教程,手把手带你跑通example.py(全网唯一)

全网最详细具身智能仿真平台habitat的安装教程

shannanshouyin的博客 2758

开关电源环路学习笔记(1)-为啥要知道传递函数?

做硬件都会接触到开关电源,或多或少会接触到环路补偿。很多兄弟们也提出来让我说说这个,说明大家也都很关心,也说明这个好像有点难。 就我个人而言,我也想搞明白环路这一块,也查了一些资料,视频,不过说实话,一直都还是懵的,感觉很困难。 具体感觉就是,看资料或视频的时候,感觉好像是那么回事。但是撇开之后,就又云里雾里的感觉,总有一些想法没有找到答案。 问题 比如下面我想过的问题: 开关电源有开关,根本就不是一个线性系统,传递函数是咋弄出来的? 系统环路框图的输入量为什么不是Vin,而是参考电压V

weixin_42005993的博客 1万+

Java泛型

是同时存在的,可是如果是常规的两个方法,他们的方法签名是一样的,也就是说虚拟机根本不能分别这两个方法。如果是我们自己编写Java代码,这样的代码是无法通过编译器的检查的,但是虚拟机却是允许这样做的,因为虚拟机通过参数类型和返回类型来确定一个方法,所以编译器为了实现泛型的多态允许自己做这个看起来“不合法”的事情,然后交给虚拟器去区别。如果是重载,那么子类中两个setValue方法,一个是参数Object类型,一个是Date类型,可是我们发现,根本就没有这样的一个子类继承自父类的Object类型参数的方法。

YLXCA的博客 1316

Silly Tavern(酒馆)AI 角色扮演下载与安装教程(Windows本地部署)

SillyTavern是一款开源AI角色扮演前端工具,支持沉浸式对话和长期角色互动。本文详细介绍了在Windows系统F盘部署SillyTavern的完整流程,包括下载项目、安装Node.js和Git环境、配置路径、启动服务等关键步骤,并特别说明非系统盘路径可能遇到的npm安装问题及解决方法。成功启动后,用户可通过浏览器访问本地地址使用该工具进行AI角色扮演互动。

Tan3851的博客 1万+

Java 泛型

Java 泛型 Java 泛型(generics)是 JDK 5 中引入的一个新特性,泛型提供了编译时类型安全检测机制,该机制允许程序在编译时检测到非法的类型。 泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。泛型信息不会进入到运行时阶段。编译器有效 泛型的三种方式:泛型类、泛型接口、泛型方法 泛型类 定义格式: 修饰符 class 类名<代表泛型的变量> { } 例如,API中的ArrayList集合: class ArrayList<E>{ p

JustToFaith 2955

STM32F407中文手册

STM32f407中文手册 本参考手册面向应用开发人员,提供有关使用 STM32F405xx/07xx、STM32F415xx/17xx、 STM32F42xxx 和 STM32F43xxx 微控制器存储器与外设的完整信息。STM32F405xx/07xx、STM32F415xx/17xx、STM32F42xxx 和 STM32F43xxx 构成一个微 控制器系列,各产品具有不同的存储器大小、封装和外设。有关订购信息以及器件的机械与电气特性,请参见数据手册。有关 ARM Cortex™-M4F 内核的信息,请参见《Cortex™-M4F 技术参考手册》。

Camera2Demo

在Camera2 的基础上进行封装,使得Camera2一堆初始化逻辑与Activity分离,降低耦合度,完成预览拍照并保存的操作

【 java 泛型】详解java泛型

java泛型可以提高java程序的类型安全,提高程序的可靠性,并且在开发中也较常用,因此还是要简单学习一下!

阿牛的博客 2069

蓝牙模块HM-10手册

蓝牙模块HM-10手册,该手册对蓝牙模块HM-10进行了详细的介绍和使用方法以及一些AT指令。

Java之泛型,什么是泛型?为什么用泛型?(泛型详解)

1.为什么会有泛型呢? 早期的Object类型可以接受任意的对象类型,但是在实际使用中,会有类型转化的问题,存在隐患,所以Java提供泛型解决这个安全问题 2.什么是泛型? 泛型,即“参数化类型”。一提到参数,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。那么参数化类型怎么理解呢?顾名思义,就是将类型由原来的具体的类型参数化,类似于方法中的变量参数,此时类型也定义成参数形式(可以称之为类...

围巢 4004

VC中sqlite的使用

在VC中对sqlite的使用,实现数据库大批数据的快速插入,读取数据库中的数据在ListCtrl中显示

Java泛型的其他应用——泛型接口、泛型方法、泛型数组以及泛型的嵌套设置

学习目标 掌握泛型接口的使用 掌握泛型方法的定义的及使用 掌握泛型数组的使用 掌握泛型的嵌套设置 之前所有的操作都是在类中直接使用泛型操作的,那么,对于Java来说,也可以直接在接口中定义及使用泛型。 定义泛型接口 在JDK1.5之后,不仅仅可以声明泛型类,也可以声明泛型接口,声明泛型接口和声明泛型类的语法类似,也是在接口名称后面加上,如下格式所示: [访问权限]

1万+

java 泛型详解(普通泛型、 通配符、 泛型接口,泛型数组,泛型方法

Java代码Java代码Java代码Java代码Java代码Java代码Java代码。

2401_84619530的博客 1543

java泛型

文章目录1. 泛型简述2. 泛型类别2.1 泛型类2.1.1 某种实际类型,重用性不好,Holder只能拥有A类型2.1.2 Object类型,类型太泛,可以容纳所有类型2.1.3 在创建的时候可以指定只可以容纳某种类型,此后只能容纳这种类型注意:基础类型无法作为类型参数,其封装类型可以2.2 泛型接口2.3 泛型方法3. 泛型擦除3.1 泛型只在编译期有效3.2 泛型内部无法获得泛型参数的有关信...

登峰小蚁 3065

Java【泛型】

Java的泛型说明

在下的博客 1468

java 泛型的默认值_java泛型

1.什么是泛型泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。泛型:泛型类 泛型接口 泛型方法作用:提高程序健壮性,简化代码泛型的默认值是Objectjdk 要1.5以上2.泛型定义/***泛型类*首先定义一个person的实体类 Person其中A代表的就是一个泛型,调用时可以自定义数据类型*/package com.temp;public class Per...

weixin_34246474的博客 1833

java中的泛型是什么_Java中泛型是什么?Java泛型的详细介绍

本篇文章给大家带来的内容是关于Java中泛型是什么?Java泛型的详细介绍,有一定的参考价值,有需要的朋友可以参考一下,希望对你有所帮助。一、什么是泛型?Java泛型设计原则:只要在编译时期没有出现警告,那么运行时期就不会出现ClassCastException异常.泛型:把类型明确的工作推迟到创建对象或调用方法的时候才去明确的特殊的类型参数化类型:ArrayList中的E称为类型参数变量Arra...

weixin_29174013的博客 3474

【Java】关于Java泛型,为你做最全面的泛型介绍

泛型(Generics)是Java语言中的一个重要特性,它提供了类型安全和代码重用的机制。泛型起到的作用包括类型安全、代码重用、程序设计抽象、集合框架的增强、减少强制类型转换。在面向对象编程和设计模式中有广泛的应用。

✈ 正在努力 の 寒江(StudiousTiger) ✌ 呐 ✈ 1072

【Java】泛型深入

在刚刚,我们仅仅是使用泛型,那我能不能自己写一个类似于ArrayList带有泛型的类让别人去用呢,当然这必须是可以的。泛型可以写在类的后面,也可以写在方法上,还可以写在接口后。如果将泛型写在类的后面,那么这个类叫做泛型类在类名后面定义泛型,创建该类对象的时候,确定类型如果将泛型写在方法的后面,那么这个类叫做泛型方法在修饰符后面定义方法,调用该方法的时候,确定类型如果将泛型写在接口的后面,那么这个类叫做泛型接口在接口名后面定义泛型,有两种用法:① 实现类确定类型;② 实现类延续泛型。

qq_39921135的博客 1074

Java 泛型-泛型类、泛型方法、泛型接口、通配符、上下限

泛型:         一种程序设计语言的新特性,于Java而言,在JDK 1.5开始引入。泛型就是在设计程序的时候定义一些可变部分,在具体使用的时候再给可变部分指定具体的类型。在Java中泛型主要体现在泛型类、泛型方法和泛型接口中。 泛型类:         当一个类要操作的引用数据类型不确定的时候,可以给该类定义一个形参。用到这个类的时候,通过传递类型参数的形式,来确定要操作的具体的对象

蔡先生的专栏 1万+
上一篇: Java——随机
下一篇: Java——ArrayList
吴声子夜歌
博客等级 码龄9年 2688粉丝 2270原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值