【Java5 特性】2.Java泛型

目录

一、泛型概念

二、泛型的应用

1、泛型类

2、泛型接口

3、泛型方法

4、泛型数组

三、泛型通配符

1、无边界通配符

2、有边界限定通配符

四、类型擦除

小结


一、泛型概念

泛型是Java5引入的一种特性,本质上是参数化类型,即把原有具体的类型当做一种参数使用,有点类似函数中的形参和实参的使用(比如定义函数使用形参,而调用函数则使用实参)。举个例子说明,比如List集合接口的定义:public interface List<E> extends Collection<E> {...},这里的List<E>可理解为一种类型形参,而我创建List集合的实例  List<String> list = new List<>(); ,这里的List<String>便可理解为一种类型实参。

泛型提供了一种在编译时的类型安全检查机制,防止在编译期间有不合法的类型存在。举个例子说明:比如Set set = new HashSet(); ,我们可以通过set.add("新年快乐!"); set.add(2021); set.add(false); 等方式往Set集合中加入任意类型的值,然后通过fori遍历时又想强转成String类型使用,这一切看起来没什么问题,启动程序运行之前也没有任何错误提示,但编译后的结果显示了 ClassCastException异常。我们把代码改成Set<String> set = new HashSet<>(); 后,在add加入上面那些内容,则会检查并提示类型不符,无法加入2021和false等内容,泛型在编译期间进行类型安全检查,并表明了操作的合法数据类型,这就是泛型存在的最直观优势了!

当然,泛型还可以自动的隐式的进行类型转换。当不指定类型时,编译器会提示你要进行强转,比如 List list = new ArrayList(); ,通过list.set(1, "新年快乐!"); 设置一个初始值,然后通过强制转换String str = (String)list.get(1); 才能通过编译。我们把代码改成List<String> list = new ArrayList();,重复上面步骤,就不需要在get时强转了。

泛型可用于接口,类,方法中,分别称为泛型接口,泛型类,泛型方法,而与接口,类,方法相比,实现泛型数组似乎特殊了点,这些实现过程中需要注意的一些问题,后面会分析到。

使用泛型时,你是否注意过这些符号,如?,E,T,U,R,K,V等等,它们被称为泛型通配符,它们本质上没什么区别,只是泛型使用的一种标记或约定。约定规则为:?表示的是不确定的或未知的Java类型,可看作是无边界通配符;E是element的简写,表示的是元素类型,用于Java集合结构 ;T是Type的简写,表示的是任意的Java类型,有时也可以用临近的U,R表示;K,V则表示的是键和值(key-value),用于Map映射结构。另外,一些通配符搭配 super 和 extends关键字使用,又可以当做有边界的通配符,后面会分析到。

最后值得一提的便是类型擦除了,擦除的概念也很好理解,就是JVM里并没有泛型的概念,只是我们在代码层面使用了泛型,当JVM启动并编译程序时,会将所有的泛型类及泛型方法的类型参数去掉,最好的验证是编写一段泛型代码去执行,然后打开生成的class文件就能发现其奥秘了,因此,类型擦除也可以看作是一种Java语法糖。

二、泛型的应用

Java5引入的泛型,早已经是Java中最基础、最重要的知识点了,像《Java编程思想》、《Java技术核心 卷I》等经典书籍都花了很多的篇幅介绍Java泛型。打开jdk源码,我们可以看到泛型普遍应用在了接口,类,方法上,尤其是Java集合。这里,我们将与泛型相关的变量称为类型变量。

1、泛型类

泛型类指的是具有一个或多个类型变量的类,通用格式为:修饰符  class  ClassName<通配符列表> {}

比如泛型类LinkedList<E>中,将E换成String或其他具体的类型,像LinkedList<String>、LinkedList<Long> 等,就可以完成泛型类型的实例化,因此可以把泛型类理解为普通类的工厂。

JDK常见的泛型类:(以Optional泛型类为例)

/** 如:Java8新增的Optional泛型类 */
public final class Optional<T> {
    /**  类型变量 */
    private final T value;

    /** Optional构造器 */
    private Optional(T value) {
        this.value = Objects.requireNonNull(value);
    }

    /** 调用Optional构造器,of()方法可实现静态创建Optional对象 */
    public static <T> Optional<T> of(T value) {
        return new Optional<>(value);
    }

    public T get() {
        if (value == null) {
            throw new NoSuchElementException("No value present");
        }
        return value;
    }

    ......
}

为了方便理解Optional泛型类,可以将该泛型类中抽象的T当作具体的类型来分析,比如把T替换成Integer。泛型类的通配符可以有多个,比如:自定义泛型类Mouse<T, U>、自定义泛型类Mouse<T, U, S>等。

2、泛型接口

泛型接口的通用格式为:修饰符  interface  InterfaceName<通配符列表> {} ,实现泛型接口一般有两种方式:一种像Character普通类实现了泛型接口,另一种像ArrayList泛型集合类实现了泛型接口,如下:

/** Character类 实现了Comparable泛型接口 */
public final class Character 
    implements java.io.Serializable, Comparable<Character> {
    ......
}

/** ArrayList类 实现了List泛型集合接口 */
public class ArrayList<E> extends AbstractList<E>
        implements List<E>, RandomAccess, Cloneable, java.io.Serializable
{
    ......
}

JDK常见的泛型接口:(以Comparable泛型接口为例)

/** 比较器Comparable泛型接口 */
public interface Comparable<T> {
    public int compareTo(T o);
}

泛型接口的通配符可以指定多个,以Java8新增的java.util.function包下的函数式接口为例:比如BiConsumer<T, U>、Function<T, R> 、BiFunction<T, U, R>等。

3、泛型方法

上面讲到的泛型类和泛型接口内都可以定义泛型方法,当然普通类与普通接口也可以定义泛型方法,泛型方法的通用格式为:修饰符  <参数类型> 泛型返回值  methodName(通配符列表) {...}

对泛型方法的调用,比如调用上面的Optional泛型类的of()泛型方法,原始写法为 Optional.<String>of("新年好啊!"); ,这样写看起来很晦涩且不美观。实际大多数情况下,调用泛型方法时可以省略<String>类型参数,编译器会根据赋值的类型 和 泛型类型推断出调用泛型方法的参数类型。

JDK常见的泛型方法:(以List泛型集合接口为例,E表示集合的元素类型)

/** List泛型集合接口 */
public interface List<E> extends Collection<E> {
    /** 以以下几个接口方法为例 */
    boolean add(E e);
    E get(int index);
    boolean addAll(Collection<? extends E> c);
    Iterator<E> iterator();
    ......
}

实际中,在创建接口实例时,通过new构造器的方式确定了E的具体类型,之后调用泛型方法add()、get()等都会按照该具体类型进行操作。而像addAll()方法的类型变量需要是一个Collection集合,且要保证该集合内元素类型不能超过E对应的类型,也就是说 ,?类型是E类型 或 E类型的子类型,否则编译阶段检查会报错。有时候,类、接口或方法中为了更好的表明类型变量的使用范围,需要对类型变量加以限定或约束,通常使用extends 或 super 来限定,限定用法在分析通配符时再作详细说明。

4、泛型数组

以上讨论的泛型大多数是在Java集合容器的使用,而泛型是怎么在数组上创建、声明及使用的呢?下面编写一个测试示例,在Test类中定义了以下测试方法:

public class Test{
    /** 测试一:创建带有?通配符的List泛型数组 */
    public static void test1(){
        List<?>[] lists = new List<?>[5];
        lists[0] = Collections.singletonList("新年快乐!");
        lists[1] = Collections.singletonList(2020);
//        lists[2] = "happy new year!"; // error
//        lists[3] = 3.1415; // error
        for (int i = 0; i < lists.length; i++) {
            System.out.println(lists[i] + " "); // [新年快乐!] [2020] null null null
            // 转换成String数组
            String[] strings = (String[]) lists[i].toArray(); // java.lang.ClassCastException
            System.out.println(strings + " ");
        }
    } 

    /** 测试二:创建具体类型的List泛型数组,会编译报错*/
    public static void test2(){
        List<String>[] lists = new List<String>[5]; // compile error
    }

    /** 测试三:强转成具体类型的List泛型数组 */
    public static void test3() {
        @SuppressWarnings("unchecked")
        List<String>[] lists = (List<String>[]) new List[5];

        lists[0] = Collections.singletonList("2020");
        lists[1] = Collections.singletonList("2021");
//        lists[2] = Collections.singletonList(2020); // error
        for (int i = 0; i < lists.length; i++) {
            System.out.print(lists[i] + " ");  // [2020] [2021] null null null

            // List<String>[]的String[] 强转成 Integer[]
            Integer[] integers = (Integer[]) lists[i].toArray(); // java.lang.ClassCastException
            System.out.println(integers + " ");
        }

        // List<String>[]的String[] 强转成 Integer[]
        Integer[] integerArr = (Integer[]) Arrays.stream(lists).toArray(); // ok
        for (int i = 0; i < integerArr.length; i++) {
            System.out.println(integerArr[i] + " ");
        }
    }

    /** 测试四:只声明具体类型的List泛型数组*/
    public static void test4(){
        List<String>[] lists = null;
        lists[0] = Collections.singletonList("新年快乐!"); // NPE
        for (int i = 0; i < lists.length; i++) {
            System.out.print(lists[i] + " ");
        }
    }
}

test1()方法使用了?通配符直接构造了List泛型数组, 而?代表不确定类型,可以给该List泛型数组赋任意类型的固定值(注意不能直接赋值!),然后去遍历该List泛型数组,但是想要强转成String[]使用,由于虚拟机在编译阶段进行了泛型的类型擦除,此时会出现类型转换错误;

test2()方法则直接构造具体类型的List泛型数组,编辑器提示了错误,说明这种方式不允许使用的,究其原因大致如下:创建一个数组时,数组必须要知道所持有对象的具体类型,而运行时泛型类型会被擦除,这显然违背了数组的要求,因此不能直接构造具体类型的泛型数组;

test3()方法是基于test2()方法进行了类型强转,强转不会报错但编辑器会出现警告信息,可以使用@SuppressWarnings("unchecked")注解消除警告,也是由于虚拟机在编译阶段进行了泛型的类型擦除,通过toArray()方法强转成目标类型均会出现类型转换错误!!

最后,test4()方法只进行了声明并没有初始化,因此不能进行后续的赋值和遍历操作,赋值一定会报NPE异常。

从上面的分析中我们可以总结出:

  1. 不能直接构造具体类型的泛型数组,但可以通过强制转换的方式构造具体类型的泛型数组,也可以只声明具体类型的泛型数组,另外,也可以通过?通配符的方式直接构造出未知类型的泛型数组;
  2. 虽然通过不同的方式构造出了泛型数组,但是JVM存在类型擦除机制 —— 会将参数类型当做原始类型去处理,因此,进行强转成目标类型数组时,总是会抛java.lang.ClassCastException异常!

上面的例子都是对List数组所持有的对象类型String进行参数化,使用过程中充满着各种危险,想一想真的是太为难自己了.....,那么我们直接对数组所持有的对象进行参数化,结果会什么样的呢?ArrayList<E>集合类中带参的toArray()方法就是一个不错的例子:

    public <T> T[] toArray(T[] a) {
        if (a.length < size)
            // Make a new array of a's runtime type, but my contents:
            return (T[]) Arrays.copyOf(elementData, size, a.getClass());
        System.arraycopy(elementData, 0, a, 0, size);
        if (a.length > size)
            a[size] = null;
        return a;
    }

该方法的功能是,返回运行时指定类型的数组,传入的T[]是数组本身类型,也就是说数组的类型在运行时就已确定了,避免了被JVM擦除类型,这样自然能通过强转的方式转换了。当然,也要注意T[]不能为null,数组的元素也要保持类型一致(直白一点就是:是String就得都是String,不能包含其他类型)。

经过上面的一系列demo测试使我深深的体会到,如果不能正确认识和理解泛型数组存在的问题,使用泛型数组编码必定会困难重重,且到处是坑。那么怎样才能正确的且安全的去构造和使用泛型数组呢?从toArray()方法带给我们的启示可知,在运行时就要确定好数组的类型,从而避免出现泛型擦除,这样才能真正的转型成功,不会出现类型转换错误

基于这种思路,我们可以利用反射机制,在运行时构造出确定类型的对象数组,这样才正确且安全,demo演示如下:

public class GenericArrayDemo<T> {
    private T[] array;

    @SuppressWarnings("unchecked")
    public GenericArrayDemo(Class<T> tClass, int size) {
        array = (T[]) Array.newInstance(tClass, size); // 通过反射机制,确定运行时的数组类型
    }

    public T[] getArray() {
        return array;
    }

    public static void main(String[] args) {
        GenericArrayDemo<String> demo = new GenericArrayDemo<>(String.class, 8);
        String[] strings = demo.getArray();
        System.out.println(strings.length); // 8

        GenericArrayDemo<Double> genericArrayDemo = new GenericArrayDemo<>(Double.class, 10);
        Double[] doubles = genericArrayDemo.getArray();
        System.out.println(doubles.length); // 8
    }
}

三、泛型通配符

1、无边界通配符

常见的泛型通配符含义前面已经解释了,通配符是作为使用泛型时的一种约定,比如?和E本质上是没啥区别的,比如List<E>集合也可以写成List<?>。然而没有规矩不成方圆,约定就是约定,还是要遵守的了,但有没有想过List<?>和原始的List有何区别呢。

经测试,自定义的List<?>泛型接口是不能添加对象的,因为?表示不确定的类型,理所当然不能有添加方法了,甚至无法定义List<?>接口.而原始的List默认拥有的元素是Object类型,可以添加任意Object类型,如下:

public class Test {
    public static void main(String[] args) {
        // 原始List集合,能添加任意Object元素 
        List list = new ArrayList();
        list.add("新年好啊!");
        list.add(3.1415926);
        list.add(false);
    }
}
/** 定义List<?>泛型接口,编辑器检查报错,不支持这种类型 */
interface List_1<?> {
    boolean add(? o);
}

因此,?表达的类型是未知的,最好不要去使用,像List<?>中的 ? 通配符被称为无边界通配符。一般地,没有对类型变量进行限定或约束的,比如List<E>、Optional<T>、Map<K, V>等中的通配符,我们也可以看作是一种无边界通配符(只是与?相比,它们标记的使用场景是具体的)。

不加限定的通配符使用起来很简单,但也容易引发一些的问题,举个例子,自定义一个泛型方法maxBy(),它使用Comparable接口的compareTo()方法实现寻找对象数组最大值功能:

public class Test{
    public static <T> T maxBy(T[] values){
        if (StringUtils.isEmpty(values))
            return null;
        // 使用Comparable接口的方法比较
        T maxValue = values[values.length - 1];
        for(int i = values.length - 2; i >= 0; i--)
            maxValue = maxValue.compareTo(values[i]);
        return maxValue;
    }

    /** 调用maxBy()获取指定数组最大值 */
    public static void main(String[] args) {
        String[] strings;
        Double[] doubles;
        LocalDateTime[] times;
        String maxStr = Test.maxBy(strings);
        Double maxDouble = Test.maxBy(doubles);
        LocalDateTime maxTime = Test.maxBy(times);
    }
}

class A {
    ......
}

这里的类型变量T 代表了任意对象类型,使用时可指明为String类、Double类、LocalDateTime类等对象类型,然而自定义一个类A,把类型变量T 换成类A的对象类型,调用maxBy()方法却会报编译错误。因为String、Double、LocalDateTime等类都实现了Comparable接口,它们的对象类型才能去替换类型变量T。因此,需要对类型变量加以限定,来表明实例化时可替换的对象类型,这里将maxBy()方法改造一下:<T extends Comparable> T maxBy(T[] values) ,这样看起来语义表达就很清晰了。

2、有边界限定通配符

Java中,在类,接口或方法上,使用 extends 或 super 限定类型变量,此时的通配符(即类型变量)就具有了有界特性,我们将 A extends BoundingType 称为上界限定(或者子类型限定,通配符A则是上界限定通配符了),将 B super BoundingType 称为下界限定(或者超类型限定,通配符B则是下界限定通配符了)。

上界限定表达形式:A extends BoundingType,其中BoundingType 为限定类型,A为 BoundingType 的子类型(包括了A 为 BoundingType本身),A 和 BoundingType可以是类,也可以接口,而使用extends 关键字则能更清晰表达出这种子类型的关系。BoundingType 可以有多个限定类型,用&隔开表示(例如:T extends RandomAccess & Serializable)。

下界限定表达形式:B super BoundingType,同样的BoundingType 为限定类型,B 为 BoundingType 的父类型(包括了B 为 BoundingType本身),使用super 关键字则能更清晰表达出这种父类型的关系。

有很多人包括我初次接触到泛型时,看到<T, U extends Comparable<? super U>>这种泛型的声明(据说目的是为了帮助程序员排除调用参数上不必要的限制),简直头皮发麻有木有,哈哈哈哈。有了上面的基础之后,这个泛型声明看起来没那么恐怖了,以Comparator接口源码中的几个泛型方法为例,分析下有界限定通配符的应用:

/** Comparator泛型接口,以其中的几个方法为例分析 */
@FunctionalInterface
public interface Comparator<T> {
    /** compare() */
    int compare(T o1, T o2);
    
    /** comparing() */
    public static <T, U extends Comparable<? super U>> Comparator<T> comparing(
            Function<? super T, ? extends U> keyExtractor)
    {
        Objects.requireNonNull(keyExtractor);
        return (Comparator<T> & Serializable)
            (c1, c2) -> keyExtractor.apply(c1).compareTo(keyExtractor.apply(c2));
    }

    /** thenComparing() */
    default <U extends Comparable<? super U>> Comparator<T> thenComparing(
            Function<? super T, ? extends U> keyExtractor)
    {
        return thenComparing(comparing(keyExtractor));
    }

    default Comparator<T> thenComparing(Comparator<? super T> other) {
        Objects.requireNonNull(other);
        return (Comparator<T> & Serializable) (c1, c2) -> {
            int res = compare(c1, c2);
            return (res != 0) ? res : other.compare(c1, c2);
        };
    }
    
    ......
}

Comparator接口用注解@FunctionalInterface被标记成了一个函数式接口,关于函数式接口可以参考我的另一篇文章:Lambda表达式与函数式接口。Java8开始,允许接口有方法的默认实现,这大大的增强了Comparator接口的功能,其中:

int compare(T o1, T o2);

  • 是一个抽象方法,Java8之前使用函数式接口进行比较,需要通过new Comparator的方式并重写该方法;

public static <T, U extends Comparable<? super U>> Comparator<T> comparing(Function<? super T, ? extends U> keyExtractor){...}

  • 是一个静态方法,有具体的逻辑实现,可直接通过 Comparator.comparing(参数) 的方式调用;
  • 先分析该方法的参数,该参数是一种Function的函数式接口,Function是有参数有返回值的,其中? super T表示的是入参参数类型(即通配符?的类型是T 或T 的父类型),? extends U表示的是返回值类型(即通配符?的类型是U 或U 的子类型);
  • 再分析该方法的返回值,返回类型的是Comparator<T>类型,该类型的泛型声明为<T, U extends Comparable<? super U>>,这个声明的意思是:比较时入参类型是T, 返回值类型U是实现了Comparable接口的类型,而Comparable接口本身也是泛型接口,继续使用? super U限定返回值类型;
  • 上面的T,U限定类型都是抽象的,通过实例化指明具体的类型后便可豁然开朗,举个例子说明 —— 自定义一个类:Milk,该类不实现任何接口,再写一个方法:Double getMilkPrice(){},最后要想比较牛奶的价格:Comparator<Milk> price =  Comparator.comparing(Milk -> Milk.getMilkPrice()); ,这里T被具化成了Milk的实例类型,U被具化成了Double对象类型,Double则是一个实现了Comparable<Double>接口的包装类。为了验证限定类型U 是否能具化为Milk的实例类型,可修改代码:Comparator<Milk> price =  Comparator.comparing(Milk -> Milk);,结果如下图,报了一个编译错误!说明上面的分析是完全正确的!

default <U extends Comparable<? super U>> Comparator<T> thenComparing(Function<? super T, ? extends U> keyExtractor) {...}

  • 是一个默认方法,有具体的逻辑实现,需要通过Comparator实例的引用去调用thenComparing(参数);
  • 有了上面静态方法的分析,这个方法中有界限定通配符相对简单了,Function接口的入参和返回值类型同理,该方法的Comparator<T>的泛型声明只声明返回值的限定<U extends Comparable<? super U>>;
  • 接着上面的例子:Comparator<Milk> nbr = price.thenComparing(Milk->Milk.getNbr()); ,T、U分别用具体的Milk实例、String对象替代了,而String是一个实现了Comparable<String>接口的类。

四、类型擦除

编写一段常见集合泛型使用的测试代码:

import java.util.*;

public class Test{
   public static void main(String[] args) {
      List<String> list = new ArrayList<>();
      Set<Integer> set = new HashSet<>();
      Map<String,Long> map = new HashMap<>();
   }
}

再打开编译后生成的class文件:

/** Test.class */
public class Test {
  public static void main(String[] paramArrayOfString) {
    ArrayList arrayList = new ArrayList();
    HashSet hashSet = new HashSet();
    HashMap hashMap = new HashMap();
  }
}

这就是所谓的类型擦除了,对于JVM而言是没有泛型的,只有普通的类、普通的方法及原始类型。在编译过程中,所有泛型的类型参数都是通过它们的限定参数来替换的,有时候为了类型的安全性,可能会使用强制类型转换。

Java泛型也是一把双刃剑,给我们带来类型安全检查、类型自动转换等优势的同时,也产生了诸多的限制和约束问题,而这些问题大多数是由类型擦除引起的,下面盘点一下这些限制:

1、泛型的类型参数不能是基本类型

如:Milk<int>会编译报错。这是因为类型擦除后,原始类型会用Object代替类型参数,而Object是不能存放基本类型值的。

2、运行时的类型的查询比较不能是泛型

如:假设类型变量m,n均为泛型类的实例引用,if(m  instanceof  n) 或 if(m == n) 会编译报错。这是因为JVM没有泛型,只有原始类型,无法进行泛型类型的查询或比较。

3、不能直接构造具体类型的泛型数组

如:Optional<String>[] strings = new Optional<String>[10]; 会编译报错,但可以声明具体类型的泛型数组,或构造时可以强转成具体类型的泛型数组。

4、不能实例化类型变量

如:new T();会编译报错,不能实例化泛型类的类型变量,但可以实例化泛型类,如Test<T> a = new Test<T>(); 是可以的。这是因为在编译期间没法确定泛型参数化类型,也就找不到对应的类字节码文件,因此不能实例化泛型类的类型变量。

5、不能使用带有类型变量的静态属性和静态方法

如:private static T data; //编译报错  public static T getData() { return data; } //编译报错。这是因为拥有类型变量的静态成员是类中所有对象共享的,且该静态成员在类中也只存在独立的一份,假如A对象需要将T替换成String类型去调用该静态成员,B对象需要将T替换成Long类型去调用该静态成员,这样看似共享,但却无法保证静态成员的唯一性。从类型擦除方面考虑的话,擦除时,进行强制转换可能会造成类型转换错误。

6、不能抛出或捕获泛型类的实例

以下均是不合法的:(可以编写代码验证测试)

  • 泛型类不能继承异常类及异常的实现类,如public class TestType<T>  extends Exception{}。
  • catch块不能捕获泛型类的实例 ,如 try{}catch(T e){}。

7、类型擦除后,泛型类的方法名称引起的冲突

如:定义一个Order类,自定义了一个泛型方法 boolean equals(T value),当类型擦除后就是 boolean equals(Object o) ,与Object的equals()发生了冲突!可以修改Order类的equals(),避免与Object的方法冲突。

8、注意Varages警告

向参数个数可变的方法传递一个泛型类型的实例,调用该方法时会出现一个Varages警告,有两个方法可以避免:一是使用注解@SuppressWarnings("unchecked") ;二是用注解@SafeVarages。

小结

最后还是做下总结吧,个人感觉深入Java泛型还是有点难度的,尤其是搞明白泛型数组的构造问题,及类型擦除带来的一系列限制问题。本文从整体上对Java泛型的知识点做了一番梳理总结,从为什么要引入泛型,怎么理解泛型,如何将泛型应用在接口、类和方法上,有哪些泛型通配符及表达的含义,为什么使用限定类型的通配符,怎么构建泛型数组及使用过程中的问题,如何正确安全的构建泛型数组,由于JVM类型擦除给泛型带来了一系列的限制分析等。这一过程着实花了不少时间去思考,分析和码字整理,算是进一步巩固了基础吧,这个过程收获还是挺多的。还是那句话,不积硅步无以至千里,点滴付出终将有所收获,共同进步吧~

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值