泛型


一般的类和方法,只能使用具体的类型:要么是基本类型,要么是自定义的类。如果要编写可以应用于多种类型的代码,这种刻板的限制对代码的東缚就会很大。

有时候拘泥于单继承体系,也会使程序受限太多。如果方法的参数是一个接口,而不是一个类,这种限制就放松了许多。可是有的时候即便使用了接口,对程序的约束也还是太强了。因为一旦指明了接口,它就要求你的代码必须使用特定的接口。而我们希望达到的目的是编写更通用的代码,要使代码能够应用与,“某种不具体的类型”,而不是一个具体的接口或类。

这就是Java SE5的重大变化之一:泛型的概念。泛型实现了参数化类型的概念,使代码可以应用于多种类型。“泛型”这个术语的意思是:“适用于许多许多的类型”。泛型在编程语言中出现时,其最初的目的是希望类或方法能够具备最广泛的表达能力。如何做到这一点呢,正是通过解耦类或方法与所使用的类型之间的约束。实际上,你可能会质疑,Java中的术语“泛型”是否适合用来描述这一功能。

简单泛型

有许多原因促成了泛型的出现,而最引人注目的一个原因,就是为了创造容器类。有些情况下,我们确实希望容器能够同时持有多种类型的对象。但是,通常而言我们只会使用容器来存储一种类型的对象。泛型的主要目的之一就是用来指定容器要持有什么类型的,因此与其使用Object,我们更喜欢暂时不指定类型,而是稍后再決定具体使用什么类型。要达到这个目的,需要使用类型参数,用尖括号括住,放在类名后面。然后在使用这个类的时候,再用实际的类型替换此类型参数。在下面的例子中,T就是类型参数:

class Tt{}
public class Holder<T> {
	private T a;
	public Holder(T a){this.a = a;}
	public T get(){return a;}
	public static void main(String[] args) {
		Tt t = new Tt();
		Holder<Tt> h = new Holder<Tt>(t);
		h.get();
	}
}

现在,当你创建Holder对象时,必须指明想持有什么类型的对象,将其置于尖括号内。然后,你就只能在Holder中存入该类型(或其子类,因为多态与泛型不冲突)的对象了。并且,在你从Holder中取出它持有的对象时,自动地就是正确的类型。

这就是Java泛型的核心概念:告诉编译器想使用什么类型,然后编译器帮你处理一切细节。

一个元组类库

仅一次方法调用就能返回多个对象,你应该经常需要这样的功能吧。可是return语句只允许返回单个对象,因此解决办法就是创建一个对象,用它来持有想要返回的多个对象。现在我们有了泛型,就能够一次性地解决该问题,以后再也不用在这个问题上浪费时间了。同时,我们在编译器就能确保类型安全。

这个概念称为元组(tuple),它是将一组对象直接打包存储于其中的一个单一对象。这个容器对象允许读取其中元素,但是不允许向其中存放新的对象。(这个概念也称为数据传道对象,或信使。)

通常,元组可以具有任意长度。我们也可以利用继承机制实现长度更长的元组。从下面的例子中可以看到,增加类型参数是件很简单的事情:

class TwoTuple<A,B>{
	public A a;
	public B b;
	public TwoTuple(A a,B b) {
		this.a = a;
		this.b = b;
	}
}
public class ThreeTuple<A,B,C> extends TwoTuple<A,B> {
	public C c;
	public ThreeTuple(A a,B b,C c) {
		super(a,b);
		this.c = c;
	}
}

泛型接口

泛型也可以应用于接口。例如生成器(generator),这是一种专门负责创建对象的类。实际上这是工厂方法设计模式的一种应用。不过,当使用生成器创建新的对象时,它不需要任何参数数,而工厂方法一般需要参数。也就是说生成器无需额外的信息就知道如何创建新对象。一般而言,一个生成器只定义一个方法,该方法用以产生新的对象。

public interface Generator<T>{
	T next();
}

方法next()的返回类型是参数化的T。正如你所见到的,接口使用泛型与类使用泛型没什么区别。

泛型方法

到目前为止,我们看到的泛型都是应用于整个类上。但同样可以在类中包含参数化方法,而这个方法所在的类可以是泛型类,也可以不是泛型类。也就是说,是否拥有泛型方法与其所在的类是否是泛型没有关系。

泛型方法使得该方法能够独立于类而产生变化。以下是一个基本的指导原则:无论何时,只要你能做到,你就应该尽量使用泛型方法。也就是说,如果使用泛型方法可以取代将整个类泛型化,那么就应该只使用泛型方法,因为它可以使事情更清楚明白。另外,对于一个static的方法而言,无法访问泛型类的类型参数。所以,如果static方法需要使用泛型能力,就必须使其成为泛型方法。

要定义泛型方法只需将泛型参数列表置于返回值之前,就像下面这样:

public class Test {
	public <T> void f(T x){
		System.out.println(x.getClass().getName());
	}
}

注意,当使用泛型类时,必须在创建对象的时候指定类型参数的值,而使用泛型方法的时候,通常不必指明参数类型,因为编译器会为我们找出具体的类型。这称为类型参数推断(type argument inference)。因此,我们可以像调用普通方法一样调用f(),而且就好像是f()被无限次地
重载过。如果调用f()时传入基本类型,自动打包机制就会介入其中,将基本类型的使包装为对应的对象。事实上,泛型方法与自动打包避免了许多以前我们不得不自己编写出来的代码。

杠杆利用类型参数推断

类型推断只对赋值操作有效,其他时候并不起作用。如果你将一个泛型方法调用的结果作为参数传递给另一个方法,这时编译器并不会执行类型推断。在这种情况下,编译器认为:调用泛型方法后其返回値被赋给一个Objeet类型的变量。

显式的类型说明

在泛型方法中,可以显式地指明类型,不过这种语法很少使用。要显式地指明类型,必须在点操作符与方法名之间插入尖括号,然后把类型置于尖括号内。如果是在定义该方法的类的内部,必须在点操作符之前使用this关键字,如果是使用static的方法,必须在点操作符之前加上类名。例:

public class Test {
	public <T> void f(T x){
		System.out.println(x.getClass().getName());
	}
	static <T> Test g(){
		return new Test();
	}
	static  void h(Test t){
	}
	public static void main(String[] args) {
		h(Test.<Test>g());
	}
}

可变参数类型与泛型
泛型方法与可变参数列表能够很好地共存。

public class Test {
	public static <T> List<T> makeList(T... ts){
		List<T> list = new LinkedList<T>();
		for(T t:ts){
			list.add(t);
		}
		return list;
	}
	public static void main(String[] args) {
		List<String> list = makeList("1","2","3");
		for(String str :list){
			System.out.print(str+",");
		}
	}
	//输出:1,2,3,
}

匿名内部类

泛型还可以应用于内部类以及匿名内部类,例:

public interface Generator<T>{
	T next();
}
public class Test {
	class Customer{}
	public static Generator<Customer> generator(){
		return new Generator<Test.Customer>() {
			@Override
			public Customer next() {
				return null;
			}
		};
	}
}

擦除的神秘之处

当你开始更深入地钻研泛型时,会发现有大量的东西初看起来是没有意义的。例如,尽管可以声明ArrayList.class,但是不能声明ArrayList<Integer>.class。请考虑下面的情况:

public class Test {
	public static void main(String[] args) {
		Class c1 = new ArrayList<Integer>().getClass();
		Class c2 = new ArrayList<String>().getClass();
		System.out.println(c1 == c2);
	}
	//输出:
	//true
}

ArrayList<String>和ArrayList<Integer>很容易被认为是不同的类型。不同的类型在行为方面肯定不同,例如如果尝试着将一个Integer放入ArrayList<String>所得到的行为(将失败),与把一个Integer放入ArrayList<Integer>(将成功)所得到的行为完全不同。但是上面的程序会认为它们是相同的类型。

根据JDK文档的描述,Class.getTepeParameters()将“返回一个TypeVariable对象数组,表示有泛型声明所声明的类型参数……”这好像是在暗示你可能发现参数类型的信息,但是,正如你从输出中所看到的,你能够发现的只是用作参数占位符的标识符,这并非有用的信息。因此残酷的现实是:

在泛型代码内部,无法获得何有关泛型参数类型的信息。

因此,你可以知道诸如类型参数标识符和泛型类型边界这类的信息——你却无法知道用来创建某个特定实例的实际的类型参数。在使用Java泛型工作时它是必须处理的最基本的问题。

Java泛型是使用擦除来实现的,这意味着当你在使用泛型时,任何具体的类型信息都被擦除了,你唯一知道的就是你在使用一个对象。因此List<String>和List<Integer>在运行时事实上是相同的类型。这两种形式都被擦除成它们的“原生”类型,即List。请看下面的示例:

class HasF{
	public void f(){
		System.out.println("hasF.f()");
	}
}
class Maninpulator<T>{
	private T obj;
	public Maninpulator(T t){
		this.obj = t;
	}
	public void manipulate(){
		obj.f();//Error!
	}
}

由于有了擦除,Java编译器无法将manipulate()必须能够在obj上调用f()这一需求映射到HasF拥有f()这一事实上。为了调用f(),我们必须协助泛型类,给定泛型类的边界,以此告知编译器只能接受遵循这个边界的类型。这里重用了extends关键字。由于有了边界,下面的代码就可以编译了:

class Maninpulator2<T extends HasF>{
	private T obj;
	public Maninpulator2(T t){
		this.obj = t;
	}
	public void manipulate(){
		obj.f();
	}
}

边界<T extends HasF>声明T必须具有类型HasF或者从HasF导出的类型。如果情况确实如此,那么就可以安全地在obj上调用f()了。

我们说泛型类型参数将擦除到它的第一个边界(它可能会有多个边界),我们还提到了类型参数的擦除。编译器实际上会把类型参数替换为它的擦除,就像上面的示例一样。T擦除到了HasF,就好像在类的声明中用HasF替换了T一样。

你可能已经正确地观察到,在上例Maninpulator2类中,泛型没有贡献任何好处。只需很容易地自己去执行擦除,就可以创建出没有泛型的类。这提出了很重要的一点:只有当你希望使用的类型参数比某个具体类型(以及它的所有子类型)更加“泛化”时——也就是说,当你希望代码能够跨多个类工作时,使用泛型才有所帮助。因此,类型参数和它们在有用的泛型代码中的应用,通常比简单的类替换要更复杂。但是,不能因此而认为<T extends HasF>形式的任何东西而都是有缺陷的。例如,如果某个类有一个返回T的方法,那么泛型就有所帮助,因为它们之后将返回确切的类型:

class Maninpulator2<T extends HasF>{
	private T obj;
	public Maninpulator2(T t){
		this.obj = t;
	}
	public T get(){
		return obj;
	}
}

必须査看所有的代码,并确定它是否“足够复杂”到必须使用泛型的程度。

迁移兼容性

为了减少潜在的关于擦除的混淆,你必须清楚地认识到这不是一个语言特性。它是Java的泛型实现中的一种折中,因为泛型不是Java语言出现时就有的组成部分,所以这种折中是必需的。这种折中会使你痛苦,因此你需要习惯它并了解为什么它会是这样。

如果泛型在Java 1.0中就已经是其一部分了,那么这个特性将不会使用擦除来实现——它将使用具体化,使类型参数保持为第一类实体,因此你就能够在类型参数上执行基于类型的语言操作和反射操作。擦除减少了泛型的泛化性,泛型在Java中仍旧是有用的,只是不如它们本来设想的那么有用,而原因就是擦除。

在基于擦除的实现中,泛型类型被当作第二类类型处理,即不能在某些重要的上下文环境中使用的类型。泛型类型只有在静态类型检査期间才出现,在此之后,程序中的所有泛型类型都将被擦除,替换为它们的非泛型上界。例如,诸如List<T>这样的类型注解将被擦除为List,而普通的类型变量在未指定边界的情况下将被擦除为Object。

擦除的核心动机是它使得泛化的客户端可以用非泛化的类库来使用,反之亦然,这经常被称为“迁移兼容性”。在理想情况下,当所有事物都可以同时被泛化时,我们就可以专注于此。在现实中,即使程序员只编写泛型代码,他们也必须处理在Java SE5之前编写的非泛型类库。那些类库的作者可能从没有想过要泛化它们的代码,或者可能刚刚开始接触泛型。

因此Java泛型不仅必须支持向后兼容性,即现有的代码和类文件仍旧合法,并且继续保持其之前的含义;而且还要支持迁移兼容性,使得类库按照它们自己的步调变为泛型的,并且当某个类库变为泛型时,不会破坏依赖于它的代码和应用程序。在决定这就是目标之后,Java设计者们和从事此同题相关工作的各个团队决策认为擦除是唯一可行的解决方案。通过允许非泛型代码与泛型代码共存,擦除使得这种向着泛型的迁移成为可能。

例如,假设某个应用程序具有两个类库X和Y,并且Y还要使用类库Z。随着Java SE5的出现,这个应用程序和这些类库的创建者最终可能希望迁移到泛型上。但是,当进行这种迁移时,他们有着不同动机和限制。为了实现迁移兼容性,每个类库和应用程序都必须与其他所有的部分是否使用了泛型无关。这样,它们必须不具备探测其他类库是否使用了泛型的能力。因此,某个特定的类库使用了泛型这样的证据必须被“擦除”。

如果没有某种类型的迁移途径,所有已经构建了很长时间的类库就需要与希望迁移到Java 泛型上的开发者们说再见了。但是,类库是编程语言无可争议的一部分,它们对生产效率会产生最重要的影响,因此这不是一种可以接受的代价。擦除是否是最佳的或者唯一的迁移途径,还需要时间来证明。

擦除的问题

因此,擦除主要的正当理由是从非泛化代码到泛化代码的转变过程,以及在不破坏现有类库的情况下,将泛型融入Java语言。擦除使得现有的非泛型客户端代码能够在不改变的情况下继续使用,直至客户端准备好用泛型重写这些代码。这是一个崇高的动机,因为它不会突然间破坏所有现有的代码。

擦除的代价是显著的。泛型不能用于显式地引用运行时类型的操作之中,例如转型、instanceof操作和new表达式。因为所有关于参数的类型信息都丢失了,无论何时,当你在编写泛型代码时,必须时刻提醒自己,你只是看起来好像拥有有关参数的类型信息而已。因此,如果你编写了下面这样的代码段:

class Foo<T>{
	T var;
}

那么,看起来当你在创建Foo的实例时:

Foo<Cat> f = new Foo<Cat>();

class Foo中的代码应该知道现在工作于Cat之上,而泛型语法也在强烈暗示:在整个类中的各个地方,类型T都在被替换。但是事实并非如此,无论何时,当你在编写这个类的代码时,必须提醒自己:“不,它只是一个Object。”

边界处的动作

正是因为有了擦除,我发现泛型最令人困惑的方面源自这样一个事实,即可以表示没有任何意义的事物。

因为擦除在方法体中移除了类型信息,所以在运行时的问题就是边界:即对象进入和离开方法的地点。这些正是编译器在编译期执行类型检査并插入转型代码的地点。所以在泛型中的所有动作都发生在边界处——对传递进来的值进行额外的编译期检査,并插入对传递出去的值的转型。这有助于澄清对擦除的混淆,记住,“边界就是发生动作的地方。”

擦除的补偿

正如我们看到的,擦除丢失了在泛型代码中执行某些操作的能力。任何在运行时需要知道确切类型信息的操作都将无法工作:

public class Test<T> {
	private final int SIZE = 100;
	public static void f(Object arg){
		if (arg instanceof T) {}//Error;
		T v = new T();//Error
	}
}

偶尔可以绕过这些问题来编程,但是有时必须通过引入类型标签来对擦除进行补偿。这意味着你需要显式地传递你的类型的Class对象,以便你可以在类型表达式中使用它。

在前面示例中对使用instanceof的尝试最终失败了,因为其类型信息已经被擦除了。如果引入类型标签,就可以转而使用动态的isInstance():

class Building{}
public class Test<T> {
	Class<T> kind;
	public Test(Class<T> kind) {
		this.kind = kind;
	}
	public boolean f(Object obj){
		return this.kind.isInstance(obj);
	}
	public static void main(String[] args) {
		Building b = new Building();
		Test<Building> t = new Test<Building>(Building.class);
		System.out.println(t.f(b));
	}
	//输出:
	//true
}

编译器将确保类型标签可以匹配泛型参数。

创建类型实例

在上例中对创建一个new T()的尝试将无法实现,部分原因是因为擦除,而另一部分原因是因为编译器不能验证T具有默认(无参)构造器。解决方案就是使用显示的工厂,并将限制其类型。例:

interface Factory<T>{
	T create();
}
class Foo2<T>{
	private T x;
	public <F extends Factory<T>> Foo2(F f){
		x = f.create();
	}
}
class IntegerFactory implements Factory<Integer>{
	@Override
	public Integer create() {
		return new Integer(0);
	}
}
public class Test<T> {
	public static void main(String[] args) {
		new Foo2<Integer>(new IntegerFactory());
	}
}

另一种方式是模版方法设计模式。在下面的示例中,get()是模版方法,而create()是在子类中定义的,用来产生子类类型的对象(注:原文并没有发现get()方法,我这里没搞明白…):

abstract class GeneriWithCerate<T>{
	final T element;
	GeneriWithCerate() { 
		element = create();
	}
	abstract T create();
}
class X {}
class Creator extends GeneriWithCerate<X>{
	@Override
	X create() {
		return new X();
	}
	void f(){
		System.out.println(element.getClass().getName());
	}
}

public class Test{
	public static void main(String[] args) {
		Creator c = new Creator();
		c.f();
	}
	//输出:
	//X
}

泛型数组

有时,你希望创建泛型类型的数组,有趣的是,可以按照编译器喜欢的方式来定义一个引用,例如:

class Generic<T>{}
public class Test{
	static Generic<Integer>[] gia;
}

编译器将接受这个程序,而不会产生任何警告。但是永远都不能创建这个确切类型的数组(包括类型参数),因此这有一点令人困惑。既然所有数组无论它们持有的类型如何,都具有相同的结构(每个数组槽位的尺寸和数组的布局),那么看起来你应该能够创建一个Object数组,并将其转型为所希望的数组类型。事实上这可以编译,但是不能运行,它将产生ClassCaseException。

class Generic<T>{}
public class Test{
	static final int SIZE = 100;
	static Generic<Integer>[] gia;
	public static void main(String[] args) {
		//!gia = (Generic<Integer>[])new Object[SIZE]; 运行后这句将会抛出异常
		gia = (Generic<Integer>[])new Generic[SIZE];
		System.out.println(gia.getClass().getName());
	}
}

问题在于数组将跟踪它们的实际类型,而这个类型是在数组被创建时确定的,因此即使gia已经被转型为Generic<Integer>[],但是这个信息只存在于编译期。在运行时,它仍旧是Object数组,而这将引发问题。成功创建泛型数组的唯一方式就是创建一个被擦除类型的新数组,然后对其转型。例:

public class Test<T>{
	private T[] array;
	public Test(int sz) {
		array = (T[]) new Object[sz];
	}
	private T[] rep(){return array;}
	public static void main(String[] args) {
		Test<Integer> a = new Test<Integer>(10);
		//Integer[] is = a.rep(); Error
	}
}

与前面相同,我们并不能声明T[] array= new T[sz],因此我们创建了一个对象数组,然后将其转型 。

rep()方法将返回T[],它在main()中将用于gai,因此应该是Integer[],但是如果调用它,并尝试着将结果作为Integer[]引用来捕获,就会得到ClassCastException,这还是因为实际的运行时类型是Object[]。

因为有了擦除,数组的运行时类型就只能是Object[]。如果我们立即将其转型为T[],那么在编译期该数组的实际类型就将丢失,而编译器可能会错过某些潜在的错误检査。正因为这样,最好是在集合内部使用0bject[],然后当你使用数组元素时,添加一个对T的转型。例:

public class Test<T>{
	private Object[] array;
	public Test(int sz) {
		array = new Object[sz];
	}
	@SuppressWarnings("unchecked")
	public T[] rep(){return (T[]) array;}
	public void put(int i,T t){
		array[i] = t;
	}
	public T get(int i){
		return (T) array[i];
	}
	public static void main(String[] args) {
		Test<Integer> a = new Test<Integer>(10);
		for(int i = 0;i<10;i++){
			a.put(i, i);
		}
		for(int i = 0;i<10;i++){
			System.out.print(a.get(i));
		}
		Integer[] is = a.rep();
	}
}

初看起来,这好像没多大变化,只是转型挪了地方。但是,现在的内部表示是Object[]而不是T[]。当get()被调用时,它将对象转型为T,这实际上是正确的类型,因此这是安全的。然而,如果你调用一rep(),它还是尝试着将Object[]转型为T[],这仍旧是不正确的,将在编译期产生警告,在运行时产生异常。因此,没有任何方式可以推翻底层的数组类型,它只能是Object[]。在内部将array当作Object[]而不是T[]处理的优势是:我们不太可能忘记这个数组的运行时类型,从而意外地引入缺陷(尽管大多数也可能是所有这类缺陷都可以在运行时快速地探测到) 。

public class Test<T>{
	private T[] array;
	public Test(Class<T> type,int sz) {
		array = (T[]) Array.newInstance(type, sz);
	}
	@SuppressWarnings("unchecked")
	public T[] rep(){return (T[]) array;}
	public void put(int i,T t){
		array[i] = t;
	}
	public T get(int i){
		return (T) array[i];
	}
	public static void main(String[] args) {
		Test<Integer> a = new Test<Integer>(Integer.class,10);
		for(int i = 0;i<10;i++){
			a.put(i, i);
		}
		for(int i = 0;i<10;i++){
			System.out.print(a.get(i));
		}
		Integer[] is = a.rep();
	}
	//输出:
	//0123456789
}

类型标记Class<T>被传递到构造器中,以便从擦除中恢复,使得我们可以创建需要的实际类型的数组,一旦我们获得了实际类型,就可以返回它,并获得想要的结果,就像在main()中看到的那样。该数组的运行时类型型是确切类型T[]。

边界

边界使得你可以在用于泛型的参数类型上设置限制条件。尽管这使得你可以强制规定泛型可以应用的类型,但是其潜在的一个更重要的效果是你可以按照自己的边界类型来调用方法。

因为擦除移除了类型信息,所以,可以用无界泛型参数调用的方法只是那些可以用Object调用的方法。但是,如果能够将这个参数限制为某个类型子集,那么你就可以用这些类型子集来调用方法。为了执行这种限制,Java泛型重用了extends关键字。对你来说有一点很重要,即要理解extends关键字在泛型边界上下文环境中和在普通情况下所具有的意义是完全不同的,下面的示例展示了边界的基本要素:

interface HasColor{Color color();}
class Colored<T extends HasColor>{
	T item;
	public Colored(T item) {
		this.item = item;
	}
	public T get(){
		return item;
	}
	public Color color(){
		return item.color();
	}
}
class X{public int x,y,z;}
class ColoredDimension<T extends X & HasColor>{
	T item;
	public ColoredDimension(T item) {
		this.item = item;
	}
	T getT(){
		return item;
	}
	int getX(){
		return item.x;
	}
}
public class Test{
	public static void main(String[] args) {
		X x = new X();
		ColoredDimension c = new ColoredDimension(x);
		System.out.println(c.getX());
		System.out.println(c.getT());
	}
	//输出:
	//0
	//thinkinjava.X@6c267f18
}

下面可以看到如何在继承的每个层次上添加边界限制

class HoldItem<T>{
	T item;
	public HoldItem(T item) {
		this.item = item;
	}
	T get(){return item;}
}
class Colored2<T extends HasColor> extends HoldItem<T>{
	public Colored2(T item) {
		super(item);
	}
	public Color color(){
		return item.color();
	}
}
class ColorDimension2<T extends X & HasColor> extends Colored2<T>{
	public ColorDimension2(T item) {
		super(item);
	}
	int getX(){
		return item.x;
	}
}
public class Test2 {
	public static void main(String[] args) {
		X x = new X();
		ColorDimension2 c = new ColorDimension2(x);
		c.get();
		c.getX();
	}
}

通配符

你应该看到过一些使用通配符的案例——在泛型参数表达式中的问号。

现在我们思考一个问题:有一个Apple数组,我们将它赋给一个Fruit数组引用,Apple继承于Fruit。这是有意义的,因为Apple也是一种Fruit,因此Apple数组应该也是一个Fruit数组。

但是,如果实际的数组类型是Apple[],你应该只能在其中设置Apple或Apple的子类型,这在编译期和运行时都可以工作。但是请注意,编译器允许你将Fruit放置到这个数组中,这对于编译器来说是有意义的,因为它有一个Fruit[]引用——它有什么理由不允许将Fruit对象或者任何从Fruit继承出来的对象(例如Orange), 放置到这个数组中呢?因此,在编译期,这是允许的。但是,运行时的数组机制知道它处理的是Apple[],因此会在向数组中放置异构类型时抛出异常 。

实际上,向上转型不合适用在这里。你真正做的是将一个数组赋值给另一个数组。数组的行为应该是它可以持有其他对象,这里只是因为我们能够向上转型而已,所以很明显数组对象可以保留有关它们包含的对象类型的规则。就好像数组对它们持有的对象是有意识的,因此在编译期检査和运行时检査之间,你不能滥用它们。

对数组的这种赋值并不是那么可怕,因为在运行时可以发现你已经插入了不正确的类型。但是泛型的主要目标之一是将这种错误检测移入到编译期。因此当我们试图使用泛型容器来代替数组时,会发生什么呢?我们可能会认为:“不能将一个Apple容器赋值给一个Fruit容器” 。别忘了,泛型不仅和容器相关正确的说法是:“不能把一个涉及Apple的泛型赋值给一个涉及Fruit的泛型”。如果就像在数组的情況中一样,编译器对代码的了解足够多,可以确定所涉及到的容器,那么它可能会留下一些余地。但是它不知道任何有关这方面的信息,因此它拒绝向上转型。然而实际上这根本不是向上转型——Apple的List不是Fruit的List。Apple的List将持有Apple和Apple的子类型,而Fruit的List将持有任何类型的Fruit,诚然,这包括Apple在内,但是它不是一个Apple的List,它仍旧是Fruit的List。Apple的List在类型上不等价于Fruit的List,即使Apple是一种Fruit类型。

真正的问题是我们在谈论容器的类型,而不是容器持有的类型。与数组不同,泛型没有内建的协变类型。这是因为数组在语言中是完全定义的,因此可以内建了编译期和运行时的检査,但是在使用泛型时,编译器和运行时系统都不知道你想用类型做些什么,以及应该来用什么样的规则。

但是,有时你想要在两个类型之间建立某种类型的向上转型关系,这正是通配符所允许的:

class Fruit{}
class Apple extends Fruit{}
public class Test2 {
	public static void main(String[] args) {
		List<? extends Fruit> flist = new LinkedList<Fruit>();
		list.add(null);
		//compile error
		//list.add(new Apple());
	}
}

flist类型现在是List<? extends Fruit>,你可以将其读作“具有任何从Fruit继承的类型的列表”。但是,这实际上并不意味着这个List将持有任何类型的Fruit。通配符引用的是明确的类型,因此它意味着“某种flist引用没有指定的具体类型”。因此这个被赋值的List必须持有诸如Fruit或Apple这样的某种指定类型,但是为了向上转型为flist,这个类型是什么并没有人关心。

如果唯一的限制是这个List要持有某种具体的Fruit或Fruit的子类型,但是你实际上并不关心它是什么,那么你能用这样的List做什么呢?如果不知道List持有什么类型,那么你怎样才能安全地向其中添加对象呢?你不能,除非编译器而不是运行时系统可以阻止这种操作的发生。你很快就会发现这一问题。

你可能会认为,事情变得有点走极端了,因为现在你甚至不能向刚刚声明过将持有Apple对象的List中放置一个Apple对象了。是的,但是编译器并不知道这一点。 List<? extends Fruit>可以合法地指向一个List<Orange>。一旦执行这种类型的向上转型你就将丢失掉向其中传递任何对象的能力,甚至是传递Object也不行。

另一方面,如果你调用一个返回Fruit的方法,则是安全的,因为你知道在这个List中的任何对象至少具有Fruit类型,因此编译器将允许这么做。

编译器有多聪明

现在,你可能会猜想自己被阻止去调用任何接受参数的方法,但是请考虑下面的程序:

class Fruit{}
class Apple extends Fruit{}
public class Test2 {
	public static void main(String[] args) {
		List<? extends Fruit> flist = Arrays.asList(new Apple());
		flist.indexOf(new Apple());
		flist.contains(new Apple());
		flist.get(0);
	}
}

你可以看到,对contains()和indexOf()的调用,这两个方法都接受Apple对象作为参数,而这些调用都可以正常执行这是否意味着编译器实际上将检査代码,以査看是否有某个特定的方法修改了它的对象?

通过査看ArrayList的文档,我们可以发现,编译器并没有这么聪明。尽管add()将接受一个具有泛型参数类型的参数,但是contains()和indexOf()将接受Object类型的参数。因此当你指定一个ArrayList<? extends Fruit>时,add()的参数就变成了“? Extends Fruit”。从这个描述中,编译器并不能了解这里需要Fruit的哪个具体子类型,因此它不会接受任何类型的Fruit。如果先将Apple向上转型为Fruit,也无关紧要——编译器将直接拒绝对参数列表中涉及通配符的方法(例如add())的调用。

在使用contains()和indexOf()时,参数类型是Object,因此不涉及任何通配符,而编译器也将允许这个调用。这意味着将由泛型类的设计者来决定哪些调用是“安全的”,并使用Object类型作为其参数类型。为了在类型中使用了通配符的情况下禁止这类调用,我们需要在参数列表中使用类型参数。

逆变

还可以走另外一条路,即使用超类型通配符。这里,可以声明通配符是由某个特定类的任何基类来界定的,方法是指定<? super MyClass>,甚至或者使用类型参数: <? super T>(尽管你不能对泛型参数给出一个超类型边界;即不能声明<T super MyClass>)。这使得你可以安全地传递一个类型对象到泛型类型中。因此,有了超类型通配符,就可以这样写:

class Fruit{}
class Apple extends Fruit{}
public class Test2 {
	static void writeTo(List<? super Apple> list){
		list.add(new Apple());
		list.add(new Fruit());
	}
}

参数Apple是Apple的某种基类型的List,这样你就知道向其中添加Apple或Apple的子类型是安全的。但是,既然Apple是下界,那么你可以知道向这样的List中添加Fruit是不安全的,因为这将使这个List敞开口子,从而可以向其中添加非Apple类型的对象,而这是通反静态类型安全的。

因此你可能会根据如何能够向一个泛型类型“写入”(传递给一个方法),以及如何能够从一个泛型类型中“读取”(从一个方法中返回),来着手思考子类型和超类型边界。

超类型边界放松了在可以向方法传递的参数上所作的限制:

class Fruit{}
class Apple extends Fruit{}
public class Test2 {
	static <T> void writeExact(List<T> list, T item){
		list.add(item);
	}
	static <T> void writeExact2(List<? super T> list, T item){
		list.add(item);
	}
	static List<Fruit> fruit = new ArrayList<Fruit>();
	static List<Apple> apples = new ArrayList<Apple>();
	public static void main(String[] args) {
		writeExact(apples, new Apple());
		writeExact(fruit, new Apple());
		writeExact2(apples, new Apple());
		writeExact2(fruit, new Apple());
	}
}

在writeExact2()中,其参数现在是List<? super T>,因此这个List将持有从T导出的某种具体类型,这样就可以安全地将一个T类型的对象或者从T导出的任何对象作为参数传递给List的方法。在main()中可以看到这一点,在这个方法中我们仍旧可以像前面那样,将Apple放置到List<Apple>中,但是现在我们还可以如你所期望的那样,将Apple放置到List<Fruit>中。(注:但是我发现运行上面的的代码并不报错…)

无边界通配符

无界通配符<?>看起来意味着“任何事物”,因此使用无界通配符好像等价于使用原生类型。事实上编译器初看起来是支持这种判断的。

编译器很少关心使用的是原生类型还是<?>。 通常<?>可以被认为是一种装饰,但是它仍旧是很有价值的,因为实际上,它是在声明:“我是想用Java的泛型来编写这段代码,我在这里并不是要用原生类型,但是在当前这种情况下, 泛型参数可以持有任何类型。”

下面示例展示了无界通配符的一个重要应用。当你在处理多个泛型参数时,有时允许一个参数可以是任何类型,同时为其他参数确定某种特定类型的这种能力会显得很重要:

public class Test2 {
	static Map map1;
	static Map<?,?> map2;
	static Map<String,?> map3;
	static void assign1(Map map){map1 = map;}
	static void assign2(Map<?,?> map){map2 = map;}
	static void assign3(Map<String,?> map){map3 = map;}
	public static void main(String[] args) {
		assign1(new HashMap());
		assign2(new HashMap());
		assign3(new HashMap());
		assign1(new HashMap<String,Integer>());
		assign2(new HashMap<String,Integer>());
		assign3(new HashMap<String,Integer>());
	}
}

但是,当你相有的全都是无界通配符时,就像在Map<?,?>中看到的那样,编译器看起来就无法将其与原生Map区分开了。另外,上例展示了编译器处理List<?>和List<? extends Object>时是不同的。

令人困惑的是,编译器并非总是关注像List和List<?>之间的这种差异,因此它们看起来就像是相同的事物。因为,事实上,由于泛型参数将擦除到它的第一个边界,因此List<?>看起来等价于List<Object>,而List实际上也是List<Object>——除非这些语句都不为真。List实际上表示“持有任何Object类型的原生List,而List<?>表示“具有某种特定类型的非原生List,只是我们不知道那种类型是什么。

编译器何时才会关注原生类型和涉及无界通配符的类型之间的差异呢?下面的示例使用了前面定义的Holder类, t包合接受Holder作为参数的各种方法,但是它们具有不同的形式:

作为原生类型,具有具体的类型参数以及具有无界通配符参数:

class Holder<T>{
	private T a;
	public Holder(T a){this.a = a;}
	public T get(){return a;}
	public void set(T t){
		this.a = t;
	}
}
public class Test2 {
	static void rawArgs(Holder h,Object o){
		h.set(o);//Warning
		Object obj  = h.get();
	}
	static void unboundedArg(Holder<?> h,Object o){
		//h.set(o);Error
		Object obj  = h.get();
	}
	static <T> T exact1(Holder<T> h){
		T t = h.get();
		return t;
	}
	static <T> T exact2(Holder<T> h,T arg){
		h.set(arg);
		T t = h.get();
		return t;
	}
	static <T> T wildSubtype(Holder<? extends T> h,T arg){
		//h.set(arg);	Error
		T t = h.get();
		return t;
	}
	static <T> void wildSupertype(Holder<? super T> h,T arg){
		h.set(arg);
		//T t = h.get();Error
		Object obj = h.get();
	}
}

在rawArgs()中,编译器知道Holder是一个泛型类型,因此即使它在这里被表示成一个原生类型,编译器仍旧知道向set()传递一个object是不安全的。由于它是原生类型,你可以将任何类型的对象传递给set(),而这个对象将被向上转型为Object。因此,无论何时;只要使用了原生类型,都会放弃编译期检査。对get()的调用说明了相同的问题:没有任何T类型的对象,因此结果只能是一个Object。

人们很自然地会开始考虑原生Holder与Holder<?>是大致相同的事物。但是unboundedArg()强调它们是不同的——揭示了相同的问题,但是它将这些问题作为错误而不是警告报告,因为原生Holder将持有任何类型的组合,而Holder<?>将持有具有某种具体类型的同构集合,因此不能只是向其中传递Object。

在exact1()和exact2()中,你可以看到使用了确切的泛型参数——没有任何通配符。你将看到,exact1()与exact2()具有不同的限制,因为它有额外的参数。

在wildSubtype()中,在Holder类型上的限制被放松为包招持有任何扩展自T的对象的Holder。这还是意味着如果T是Fruit那么holder可以是Holder<Apple>,这是合法的。为了防止将Orange放置到Holder<APple>中,对set()的调用(或者对任何接受这个类型参数为参数的方法的调用)都是不允许的。但是你仍旧知道任何来自Holder<? extends Fruit>的对象至少是Fruit,因此get()(或者任何将产生具有这个类型基数的返回值的方法)都是允许的。

wildSupertype()展示了超类型通配符,这个方法展示了与wildSubtype()相反的行为:h可以是持有任何T的基类型的容器。因此set()可以接受T,因为任何可以工作于基类的对象都可以多态地作用于导出类(这里就是T)。但是尝试着调用get()是没有用的,因为由h持有的类型可以是任何超类型,因此唯一安全的类型就是Object。

这个示例还展示了对于在unbounded()中使用无界通配符能够做什么不能做什么所做出的限制。对于迁移兼容性,rawArgs()将接受所有Holder的不同变体,而不会产生警告。unboundedArg()方法也可以接受相同的所有类型,尽管如前所述,它在方法体内部处理这些类型的方式并不相同。

如果向按受“确切”泛型类型(没有通配符)的方法传递一个原生Holder引用,就会得到一个警告,因为确切的参数期望得到在原生类型中并不存在的信息。如果向exact1()传递一个无界引用,就不会有任何可以确定返回类型的类型信息。

可以看到,exact2()具有最多的限制,因为它希望精确地得到一个Holder<T>,以及一个具有类型T的参数,正由于此,它将产生错误或警告,除非提供确切的参数。有时这样做很好但是如果它过于受限,那么就可以使用通配符,这取决于是否想要从泛型参数中返回类型确定的返回值(就像在wildSubtype()中看到的那样),或者是否想要向泛型参数传递类型确定的参数(就像在wildSupertype()中看到的那样)。

因此,使用确切类型来替代通配符类型的好处是,可以用泛型参数来做更多的事,但是使用通配符使得你必须接受范围更宽的参数化类型作为参数。因此,必须逐个情况地权衡利弊,找到更适合你的需求的方法。

捕获转换

有一种情况特别需要使用<?>而不是原生类型。如果向一个使用<?>的方法传递原生类型,那么对编译器来说,可能会推断出实际的类型参数,使得这个方法可以回转并调用另一个使用这个确切类型的方法。下面的示例演示了这种技术,它被称为捕获转换,因为未指定的通配符类型被捕获,并被转换为确切类型。这里有关警告的注释只有在@SuppressWarnings注解被移除之后才能起作用:

class Holder<T>{
	private T a;
	public Holder(){}
	public Holder(T a){this.a = a;}
	public T get(){return a;}
	public void set(T t){
		this.a = t;
	}
}
public class Test2 {
	static <T> void f1(Holder<T> h){
		T t = h.get();
		System.out.println(t.getClass().getSimpleName());
	}
	static <T> void f2(Holder<T> h){
		f1(h);
	}
	public static void main(String[] args) {
		Holder raw = new Holder<Integer>(1);
		f2(raw);
		Holder rawBasic = new Holder();
		rawBasic.set(new Object());
		f2(rawBasic);
	}
	//输出:
	//Integer
	//Object
}

f1()中的类型参数都是确切的,没有通配符或边界。在f2()中,Holder参数是一个无界通配符,因此它看起来是未知的。但是在f2()中f1()被调用,而f()需要一个已知参数。这里所发生的是:参数类型在调用f2()的过程中被捕获,因此它可以在对f1()的调用中被使用。

你可能想知道,这项技术是否可以用于写入,但是这要求要在传递<Holder ?>时同时传递一个具体类型。捕获转换只有在这样的情况下可以工作:即在方法内部,你需要使用确切的类型。注意不能从f2()中返回T,因为T对于f2()来说是未知的。捕获转换十分有趣,但是非常受限。

问题

这里将闻述在使用Java泛型时会出现的各类问题

任何基本类型都不能作为类型参数

正如早先提到过的,你将在Java泛型中发现的限制之一是,不能将基本类型用作类型参数。因此,不能创建ArrayList<int>之类的东西。

解决之道是使用基本类型的包装器类以及Java SE5的自动包装机制。如果创建一个ArrayList<Integer>,并将基本类型int应用于这个容器,那么你将发现自动包装机制将自动地实现int到Integer的双向转换——因此,这几乎就像是有一个ArrayList<int>一样:

public class Test2 {
	public static void main(String[] args) {
		List<Integer> list = new ArrayList<Integer>();
		for(int i = 0;i< 5 ;i++){
			list.add(i);
		}
		for (Integer integer : list) {
			System.out.print(integer+",");
		}
	}
	//输出:0,1,2,3,4,
}

实现参数化接口

一个类不能实现同一个泛型接口的两种变体,由于擦除的原因,这两个变体会成为相同的接口。下面是产生这种冲突的情况:

interface Payable<T> {}
class Employee implements Payable<Employee>{}
class Hourly extends Employee implements Payable<Hourly>{}

这将不能编译,因为擦除会将Payable<Employee>和Payable<Hourly>简化为相同的类Payable,这样上面的代码就意味着在重复两次地实现相同的接口。十分有趣的是,如果从Payable的两种用法中都移除掉泛型参数(就像编译器在擦除阶段所做的那样)这段代码就可以编译。

转型和警告

使用带有泛型类型参数的转型或instanceof不会有任何效果。下面的容器在内部将各个值存储为Object,并在获取这些值时,再将它们转型回T:

class FixedSizeStack<T>{
	private int index = 0;
	private Object[] storage;
	public FixedSizeStack(int s) {
		this.storage = new Object[s];
	}
	public void push(T item){
		storage[index++] =item;
	}
	@SuppressWarnings("unchecked")
	public T pop(){
		return (T)storage[--index];
	}
}
public class Test2 {
	public static final int SIZE = 10;
	public static void main(String[] args) {
		FixedSizeStack<String> strings = new FixedSizeStack<String>(SIZE);
		for(String s:"A B C D E F G H I J".split(" ")){
			strings.push(s);
		}
		for(int i = 0;i < SIZE ;i++){
			String s = strings.pop();
			System.out.print(s+" ");
		}
	}
	//输出:
	//J I H G F E D C B A 
}

如果没有@SuppressWarnings注解,编译器将对pop()产生“unchecked cast”警告。由于擦除的原因,编译器无法知道这个转型是否是安全的,并且pop()方法实际上并没有执行任何转型。这是因为,T被擦除到它的第一个边界,默认情况下是Object,因此pop()实际上只是将Object转型为Object。

重载

下面的程序是不能编译的,即使编译它是一种合理的尝试:

class UseList<W,T>{
	void f(List<W> v){}
	void f(List<T> v){}
}

由于擦除的原因,重裁方法将产生相同的类型签名。与此不同的是,当被擦除的参数不能产生唯一的参数列表时,必须提供明显有区别的方法名。

幸运的是,这类问题题可以由编译器探测到。

基类劫持了接口

假设你有一个Pet类,它可以与其他的Pet对象进行比较(实现了Comparable接口):

class ComparablePet implements Comparable<ComparablePet>{
	@Override
	public int compareTo(ComparablePet o) {
		return 0;
	}
}

对可以与ComParablePet的子类比较的类型进行窄化是有意义的,例如,一个cat对象就只能与其他的Cat对象比较:

class Cat extends ComparablePet implements Comparable<Cat>{}
//complie Error

遗憾的是,这不能工作。一旦为Comparable确定了ComparablePet参数,那么其他任何实现类都不能与ComparablePet之外的任何对象象比较。

自限定的类型

在Java泛型中,有一个好像是经常性出现的惯用法,它相当令人费解:

class SelfBounded<T extends SelfBounded<T>>{}

这就像两面镜子彼此照向对方所引起的目眩效果一样,是一种无限反射。SelfBounded类接受泛型参数T,而T由一个边界类限定,这个边界就是拥有T作为其参数的SelfBounded。

当你首次看到它时,很难去解析它,它强调的是当extends关键字用于边界与用来创建子类明显是不同的。

古怪的循环泛型

为了理解自限定类型的含义,我们从这个惯用法的一个简单版本入手,它没有自限定的边界。不能直接继承一个泛型参数,但是可以继承在其自己的定义中使用这个泛型参数的类。也就是说,可以声明:

class GenericType<T>{}
public class CuriouslyRecurringGeneric 
	extends GenericType<CuriouslyRecurringGeneric>{}

这可以按照Jim Coplien在C++中的古怪的循环模版模式的命名方式,称为古怪的循环泛型(CRG)。“古怪的循环”是指类相当古怪地出现在它自己的基类中这一事实。

为了理解其含义,努力大声说:“我在创建一个新类,它继承自一个泛型类型,这个泛型类型接受我的类的名字作为其参数。”当给出导出类的名字时,这个泛型基类能够实现什么呢?好吧,Java中的泛型关乎参数和返回类型,因此它能够产生使用导出类作为其参数和返回类型的基类。它还能将导出类型用作其域类型,甚至那些将被擦除为Object的类型。

下面的例子是一个普通的泛型类型,它的一些方法将接受和产生具有其参数类型的对象,还有一个方法将在其存储的域上执行操作(尽管只是在这个域上执行Object操作)。

class BasicHolder<T>{
	T element;
	void set(T t){
		this.element = t;
	}
	T get(){
		return element;
	}
	void f(){
		System.out.println(element.getClass().getSimpleName());
	}
}
class Subtype extends BasicHolder<Subtype>{}
public class Test2 {
	public static void main(String[] args) {
		Subtype s1 = new Subtype(),s2 = new Subtype();
		s1.set(s2);
		Subtype s3 = s1.get();
		s1.f();
	}
	//输出:
	//Subtype
}

注意,这里有些东西很重要:新类Subtype接受的参数和返回的值具有Subtype类型而不仅仅是基类BasicHolder类型。这就是CRG的本质:基类用导出类替代其参数。这意味着泛型基类变成了一种其所有导出类的公共功能的模版,但是这些功能对于其所有参数和返回值,将使用
导出类型。也就是说,在所产生的类中将使用确切类型而不是基类型。因此,在Subtype中,传递给能set()的参数和从gett()返回的类型都是确切的Subtype。

自限定

BasicHolder可以使用任何类型作为其泛型参数,就像上面看到的那样。

自限定将采取额外的步骤,强制泛型当作其自己的边界参数来使用。观察所产生的类可以如何使用以及不可以如何使用:

class SelfBounded<T extends SelfBounded<T>>{
	T t;
	SelfBounded<T> set(T t) {
		this.t = t;
		return this;
	}
	T get(){return t;}
}
class A extends SelfBounded<A>{}
class B extends SelfBounded<B>{}

class C extends SelfBounded<C>{
	C setAndGet(C c){
		set(c);
		return get();
	}
}
class D{}
//class E extends SelfBounded<D>{} complie Error
class F extends SelfBounded{}
public class Test2 {
	public static void main(String[] args) {
		A a = new A();
		a.set(new A());
		a = a.set(new A()).get();
		a = a.get();
		C c = new C();
		c = c.setAndGet(new C());
	}
}

自限定所做的,就是要求在继承关系中,像下面这样使用这个类

class A extends SelfBounded<A> {}

这会强制要求将正在定义的类当作参数传递给基类。

自限定的参数有何意义呢?它可以保证类型参数必须与正在被定义的类相同。正如你在B类的定义中所看到的,还可以从使用了另一个SelfBounded参数的SelfBounded中导出,尽管在A类看到的用法看起来是主要的用法。对定义E的尝试说明不能使用不是SelfBounded的类型参数。

遗憾的是,F可以编译,不会有任何警告,因此自限定惯用法不是可强制执行的。如果它确实很重要,可以要求一个外部工具来确保不会使用原生类型来替代参数化类型。

注意,可以移除自限定这个限制,这样所有的类仍旧是可以编译的,但是E也会因此面变得可编译。因此很明显,自限定限制只能强制作用于继承关系。如果使用自限定,就应该了解这个类所用的类型参数将与使用这个参数的类具有相同的基类型。这会强制要求使用这个类的每个人解遵循这种形式。

还可以将自限定用于泛型方法:

class SelfBounded<T extends SelfBounded<T>>{
	T t;
	SelfBounded<T> set(T t) {
		this.t = t;
		return this;
	}
	T get(){return t;}
}
class SelfBoundingMethod{
	static <T extends SelfBounded<T>> T f(T t){
		return t.set(t).get();
	}
}

这可以防止这个方法被应用于除上述形式的自限定参数之外的任何事物上。

参数协变

自限定类型的价值在于它们可以产生协变参数类型——方法参数类型会随子类而变化。尽管自限定类型还可以产生T子类类型相同的返回类型,但是这并不十分重要,因为协变返回类型是在Java SE5中引入的

自限定泛型事实上将产生确切的导出类型作为其返回值,就像在get()中所看到的一样:

interface GenericGetter<T extends GenericGetter<T>>{
	T get();
}
interface Getter extends GenericGetter<Getter>{}
class Gen{
	void test(Getter g){
		Getter r = g.get();
		GenericGetter gg = g.get();
	}
}

注意,这段代码不能编译,除非是使用囊括了协变返回类型的Java SE5。然而在非泛型代码中,参数类型不能随子类型发生变化。

注:到这里泛型基本讲完了,但是在原书中这章后面还有占大篇幅的潜在类型机制内容。


  1. 本文来源《Java编程思想(第四版)》
  • 5
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值