泛型(Generics)

泛型(Generics)

任何非平凡的软件项目中,bugs都是存在的。虽然仔细谨慎地规划、编程、测试可以帮你减少bugs,但是不知怎么地,bugs都会存在于你的代码之中。当项目引入新功能,代码变庞大、复杂时,这种情况尤其明显。

幸运的是,有些bugs十分容易侦测。如编译时期(compile-time)的bugs可以在编译时就发现,此时你根据编译错误信息就可以发现并修复问题。但是运行时期(runtime)的bugs就没那么容易诊断出问题了(有些bugs隐藏得很深)。

泛型通过使bugs更具检测性的方式,让你的代码更加健壮。完成这些课程后,你也许想进一步了解泛型

为何使用泛型?

简单来说,泛型能在定义类、方法、接口的时候让类型(类和接口)参数化。这很像方法声明中的形参(formal parameters),即类型参数可以接受不同的入参,以达到代码复用的目的。它们的区别是,传递给方法形参的是值,而传递给类型参数(type parameters)的是类型。

泛型的代码要比非泛型的要有益得多:

  • 编译时期类型检查强度更强。如果代码与安全相违背,则Java编译器会执行强类型检查,并暴露问题。由于运行时错误难以找到,因此解决编译时期错误比解决运行时期错误要简单得多。
  • 无须造型(elimination casts)。下面代码就需要造型:
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);   // no cast
  • 程序员可以实现(implements)泛型算法(generic algorithms)。你可以将泛型算法作用到不同的数据类型集合上,也可以定制化泛型算法,它不仅类型安全还易于阅读。

泛型类型(Generic Types)

参数化的泛型类或者接口称为泛型类型。以下Box类将会被修改以阐述泛型类型概念。

一个简单的Box类

以非泛型Box类起头,代码如下:

//可以接收任意类型,因为Object是所有类的根类
public class Box {
    private Object object;

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

因为Box方法接收的参数类型是Object,因此你可以传递任意类型的参数并且无法校验其参数合法性。假如有一个人传递一个Integer,并期望取出来的也是一个Integer,如果有另一个人错误地将一个String传入,则抛出运行时异常。

泛型化的Box类

定义泛型类的格式如下:

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

用尖角括号(<>)将泛型参数限定起来,紧接着的是类名称。泛型参数指定了类型参数(也称为类型变量),如T1,T2,…,Tn。为了将Box类升级为泛型类,需要将public class Box变成public class Box<T>,升级后的Box为:

/**
 * Generic version of the Box class.
 * @param <T> the type of the value being boxed
 */
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。类型变量可以为任意非原始数据类型:任意类类型,任意接口类型,任意数组类型等。

同理,泛型接口也可定义。

类型参数命名规范(Type parameter Naming Conventions)

按照约定,类型参数名称是单一的(single),大写的。这和变量的命名有很大的区别,假如不这样做的话,那么你就无法区分出类或接口名称和一个类型变量的区别了。

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

  • E - Element(在Java集合框架中被广泛使用)
  • K - key
  • N - Number
  • T - Type
  • V - Value
  • S,U,V等 - 2nd,3rd,4th类型

调用以及实例化一个泛型类型

为了引用Box类,你必须调用它,即用具体的值替代T,如Integer:

//你可以那么理解,即调用泛型类型和调用方法类似,只不过你传递的不是方法的参数,而是类型参数(Type parameter)Integer
Box<Integer> integerBox;

Type Parameter and Type Argument 术语,它们意思都是类型参数,但是它们是有区别的,即TFoo<T> 内是type parameter,而 String in Foo<String>是 type argument。本章节都遵循这种定义。


和其他变量的声明一样,上面这段代码并没有实际创建出一个Box类型的对象,它仅仅声明integerBox将会持有一个”Box of Integer“引用,这也是 Box<Integer>的读法。

钻石(Diamond)

在Java SE 7及其之后,只要编译器可以推断出,在调用泛型构造函数时,你都可以用尖角括号来替代Type arguments。这个尖角括号(<>)称为钻石。如:

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

多类型参数

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

Pair<String, Integer> p1 = new OrderedPair<String, Integer>("Even", 8);
Pair<String, String>  p2 = new OrderedPair<String, String>("hello", "world");
未加工类型(Raw Types)

未加工的类型是泛型类或泛型接口不具备任何type arguments,如:

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

//正常实例化泛型化类
Box<Integer> intBox = new Box<>();

//省略,你会创建一个未经加工的Box<T>
Box intBox = new Box();

因此, Box 是泛型类型 Box<T>的raw Type。注意一个非泛型的类或者接口则不是一个raw Type。

原始类型出现在遗留代码中(Legacy code),因为jdk 5.0之前的很多API,如Collections,是不支持泛型的。泛型支持向后兼容,即:

//示例一:向后兼容
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)

未检查的错误信息(Unchecked Error Messages)

和之前举例的代码一样,当遗留代码和泛型代码混合使用时会发出警告信息,如:

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

用-Xlint:unchecked来重新编译以下代码,会发生警告

public class Main {
    public static void main(String[] args) {
        FooBar<Integer> f;
        f = new FooBar();//mixing legacy code with generic code,will encounter warning messages
    }
}
class FooBar<T>{

}

警告为:

E:\source\c>javac -Xlint:unchecked Main.java
Main.java:9: 警告: [unchecked] 未经检查的转换
                f = new FooBar();//mixing legacy code with generic code,will encounter warning messages
                    ^
  需要: FooBar<Integer>
  找到:    FooBar
1 个警告

泛型方法(Generic Methods)

引入type parameters的方法称为泛型方法。这和声明泛型类型相似,只是type parameters的作用范围被限制在方法里面了。

泛型方法的语法包括在尖角括号内的type parameters,它出现在返回类型前面,如:

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);
The type has been explicitly provided, as shown in bold. Generally, this can be left out and the compiler will infer the type that is needed:
//通常情况下,可以让编译器自动推断类型,如下所示
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)

有时候,你想在参数化类型时限制类型参数(arguments)。例如操作数字的方法仅仅想接收Number或者Number子类型,这就是首先的类型参数(parameters)。

声明受限的类型参数(parameters)时,先列出类型参数(parameters)的名称,紧接的是extends关键字(对于接口是用implements),最后是类型参数(parameters)的上限,本例中的类型参数(parameters)上限是Number

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

由于String并不是Number或Number子类型故编译会报错:

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

受限制的类型参数(parameters)允许你调用受限的方法,如:

  public class NaturalNumber<T extends Integer> {

    private T n;

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

    public boolean isEven() {
        return n.intValue() % 2 == 0;//intValue()是Integer内的方法
    }

    // ...
}

多界限(Multiple Bounds)

语法如下:

<T extends B1 & B2 & B3>

多限制的类型变量是界限的子类型,如果其中一个界限是类,那么它必须先被指定,否则编译错误。

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

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

//如果写成这样
class D <T extends B & A & C> { /* ... */ }  // compile-time error

泛型方法和受限的参数类型(parameters)

public static <T> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    for (T e : anArray)
       // compiler error,因为类型参数(parameters)不是原始数据类型
        if (e > elem) 
            ++count;
    return count;
}
//解决如下
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;
}

泛型、继承、子类型(Generics, Inheritance, and Subtypes)

子类型可以赋值给父类型,如Integer对象赋值给Object变量:

Object someObject = new Object();
Integer someInteger = new Integer(10);
// OK,只要符合is a 标准,即someInteger is a someObject
someObject = someInteger;   

现在考虑一下代码:

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

boxTest()接收什么参数?能传递Box类型进去么?

这里写图片描述


虽说Integer是Number的子类,但是Box和Box并无什么关系,唯一有共同的地方就是它们的父类都是Object。


泛型类和子类型

你可以用extends或者implements为泛型类创建出开一个子类型。extends和implements关空间自决定了泛型类或者泛型接口之间的关系。如ArrayList<E> implements List<E>, and List<E> extends Collection<E>.因此ArrayList<String>List<String>的子类型, List<String>Collection<String>的子类型,如下图所示

这里写图片描述

再来一个例子:

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

PayloadList<String,String>
PayloadList<String,Integer>
PayloadList<String,Exception>

这里写图片描述

类型推理(Type Inference)

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

如上述代码,T可以是任意类型,pick方法调用时传入了String和ArrayList类型的,查看String和ArrayList源码,我们发现它们都是Serializable的子类型,所以以上代码并不会报错。

根据方法调用的方式以及相应方法的声明推断出类型参数(arguments),以使方法调用合适的能力称为类型推理。

上面的例子中,Java编译器推断出T类型是Serializable兼顾了所有的方法参数类型。

类型推理与泛型方法

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);
  }
}
The following is the output from this example:

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

BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);中的<Integer>指明了类型参数(parameter),称之为类型哨兵( type witness);

你也可以省略它,如BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);

类型推理与实例化泛型类

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

//jdk 7 及其之后
Map<String, List<String>> myMap = new HashMap<>();

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

类型推理与泛型化的构造函数

class MyClass<X> {
  <T> MyClass(T t) {
    // ...
  }
}
//实例化
new MyClass<Integer>("")

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

目标类型(Target Type)

//声明
static <T> List<T> emptyList();
//赋值语句
List<String> listOne = Collections.emptyList();
//也可以添加 type witness,即<String>
List<String> listOne = Collections.<String>emptyList();

赋值语句中,编译器期望listOne接收的类型是List<String>,这个数据类型便是目标类型

但是在另一种上下文(Context)添加type witness是必要的,如:

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

//报错的调用方式,运行时异常:List<Object> cannot be converted to List<String>,jdk 8之后,编译通过,Java编译器根据方法声明可以推断出期望的入参是List<String>
processStringList(Collections.emptyList());

//正确调用方式
rocessStringList(Collections.<String>emptyList());

通配符(Wildcards)

在泛型代码中字符<?>称为通配符,表示为止的类型。通配符可以在很多地方使用:类型参数(parameter),字段或者局部变量、返回类型(不建议,建议用更加具体的类型)。通配符决不能用在泛型方法的调用,泛型类实例化或者子类型。

通配符的上界(Upper Bounded Wildcards)

你可以使用通配符的上界来放松变量的限制。例如你想编一个入参为List<Integer>, List<Double>, 和 List<Number>的方法,你可以使用通配符上界来实现,如 List<? extends Number>

无界限的通配符(Unbounded wildCards)

字符?即是无界限,如List<?>称为不知道类型的list。以下列举了2个使用场景:

  1. 如果方法的实现可以使用Object类内包含的方法实现。
  2. 如果方法的功能不依赖于类型参数(parameters)。如List.size,List.clear等。
//功能是打印任意类型的list
//不能打印List<Integer>等,因为它不是List<Object>的子类型
public static void printList(List<Object> list) {
    for (Object elem : list)
        System.out.println(elem + " ");
    System.out.println();
}
//修改为通配符
public static void printList(List<?> list) {
    for (Object elem: list)
        System.out.print(elem + " ");
    System.out.println();
}
//使用,因为?是任意类型,所以可以这样使用:
List<Integer> li = Arrays.asList(1, 2, 3);
List<String>  ls = Arrays.asList("one", "two", "three");
printList(li);
printList(ls);

List<Object>List<?>是不一样的,你可以在List<Object>集合内添加任意Object或Object子类的对象,但你只能往List<?>集合内添加null。

通配符的下限(Lower Bounded Wildcards)

通配符的下限是限制类型为一个具体的类型,或者具体类型的父类。如你想入参可以接收List<Integer>, List<Number>, 和 List<Object>,那么你可以这样声明:

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

通配符和子类型(Wildcards and Subtyping)

List<Integer>List<Number>没有关系,是因为它们仅仅在类型参数上有关联。然而你可以使用通配符来创建泛型类或者泛型接口之间的关系。

class A { /* ... */ }
class B extends A { /* ... */ }
//正确赋值
B b = new B();
A a = b;

//错误赋值
List<B> lb = new ArrayList<>();
List<A> la = lb;   // compile-time error

如下图,List<Number>List<Integer>的公共父亲是List

List<? extends Integer> intList = new ArrayList<>();
List<? extends Number>  numList = intList;  // OK. List<? extends Integer> is a subtype of List<? extends Number>

通配符捕获以及帮助方法(Wildcard Capture and Helper Methods)

在某些例子中,编译器会推断出通配符的类型。例如List<?> a = new ArrayList<Integer>(),编译器会根据代码推断出类型。这就是所熟知的通配符捕获。

import java.util.List;

public class WildcardError {

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

当执行到List.set(int ,E)时,会报错,因为编译器无法推断出放到list集合中的对象类型,因为编译器相信你操作错误了。错误信息如下:

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

为了解决这个错误,你可以在类内部添加一个私有的帮助方法(帮助捕获通配符),如:

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

}

按照约定,帮助类的命名规则为originalMethodNameHelper

通配符使用的指导方针

泛型编程的其中一个疑惑就是什么时候使用通配符下限,什么时候使用通配符上限。先看以下两个概念:

  1. in变量。in变量为代码提供数据。如拷贝方法copy(src,dest)中,src提供了拷贝的数据来源,所以是in变量。
  2. out变量。一个out变量用在其它地方。如拷贝方法中dest接收了数据,所以是out变量。

当决定是否使用通配符或者用何种类型的通配符时,你可以使用in,out变量的原理来进行判断:


  1. in变量使用通配符的上界,即使用extends关键字。
  2. out变量使用通配符下界,使用super关键字。
  3. 当in变量通过Object的方法可以访问到的话,使用没有界限的通配符。
  4. 即是in又是out变量的地方不要使用通配符。

上面的指导方针并不适用与返回值。应该避免在方法返回值处使用通配符,因为这会强制编程者处理通配符。

类型擦除(Type Erasure)

在编译时期,为了提供更加紧凑的类型检查(type check)以及泛型编程引入了泛型。为了实现泛型,Java编译器提供类型擦除来:

  • 用限制类型(bounds)或者Object(通配符无限制)来替代泛型类型。因此生成的字节码只包含普通类、接口和方法。
  • 必要的时候插入造型以维持类型安全。
  • 在继承泛型类型中,生成桥接方法(bridge methods)以维持多态。

类型擦除保证参数化类型时不会创建新类。通常情况下,泛型不会产生额外开销。

泛型类型擦除(Erasure of Generic Types)

类型擦除执行期间,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; }
    // ...
}
//因为类型参数(parameter)T是无界限的,所有用Object来替代所有的T
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类使用一个受限的类型参数(parameters)

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

//编译器会用个Comparable来替代所有的T
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; }
    // ...
}

泛型方法的擦除(Erasure of Generic Methods)

在泛型方法中,java编译器也会擦除类型参数(parameters)。

// Counts the number of occurrences of elem in anArray.
//
public static <T> int count(T[] anArray, T elem) {
    int cnt = 0;
    for (T e : anArray)
        if (e.equals(elem))
            ++cnt;
        return cnt;
}
//因为T是非受限的,故用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) { /* ... */ }
//编译器户用Shape来替代所有的T
public static void draw(Shape shape) { /* ... */ }

类型擦除和桥接方法的影响(Effects of Type Erasure and Bridge Methods)

有时候,即使你不参与其中,类型擦除也会导致一些后果。

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; // A raw type - compiler throws an unchecked warning
n.setData("Hello");     
Integer x = mn.data;// Causes a ClassCastException to be thrown.

//类型擦除后
MyNode mn = new MyNode(5);
// A raw type - compiler throws an unchecked warning
Node n = (MyNode)mn;
n.setData("Hello");
// Causes a ClassCastException to be thrown.
Integer x = (String)mn.data; 

执行流程如下:

  1. n.setData(“Hello”);调用从父类Node中继承而来的方法。
  2. 1执行完后,n内的字段data将持有”Hello”的引用,”Hello”是一个字符串。
  3. mn的字段data,认为它所持有的数据类型是Integer。
  4. 将mn.data赋值给Integer后将会报造型异常。

桥接方法(Bridge Methods)

当编译器编译继承自泛型类或者实现自泛型接口的类时,编译器可能会创建出一个合成方法,称之为桥接方法作为类型擦除的一部分。你不用担心桥接方法,但是当它出现在错误的堆栈信息中时,你可能会感到疑惑。

类型擦除后,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);
    }
}

类型擦除后,方法签名不会匹配上了。因此MyNode里面的setData方法不会重写Node里面的setData方法。

为了解决这个问题,维持类型擦除后的多态性,Java编译器产生了一个桥接方法来保证子类型具有和所期望的一样的功能。对于MyNode类,编译器会生成如下桥接方法:

class MyNode extends Node {

    // Bridge method generated by the compiler
    //方法签名和父类一样,使用代理调用了父类方法
    public void setData(Object data) {
        setData((Integer) data);
    }

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

    // ...
}

不可具体化的类型(Non-Reifiable Types)

可变形参类型不确定的方法总会伴随类型擦除。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值