读《Java核心技术 卷I》有感之第8章 泛型程序设计

学无止境

  泛型存在的意义就是相对于强制转换类型的代码具有更好的安全性与可读性。

8.1 为什么要使用泛型程序设计

  就是为了让编写的代码尽可能的被很多不同类型的对象所重用。

8.1.1 类型参数的好处

  类型参数用于泛指类型,这是程序具有更好的可读性与安全性的原因。

ArrayList<String> files = new ArrayList<>();	  //类型参数在构造函数中可省略
ArrayList<String> files = new ArrayList<String>();

8.1.2 谁想成为泛型程序员

  泛型程序员的任务是预测出所用类的未来可能有的所有用途,其设计能力分为3个级别,最基本的是仅仅使用泛型类来进行编码,更高的层级即为编写泛型类。(泛型类的编写看似与常规编写无异,但是实际上设计大量的对T参数的特殊处理,即所谓的预知编写,这非常考研一个程序员对业务的理解能力)

8.2 定义简单泛型类

  泛型类即具有一个或多个类型变量的类,类型变量中通常用E表示集合的元素类型,K和V分别表示关键字与值的类型(词典中的概念),T以及相近的U和S表示“任意类型”,这些变量表示被称为通配符类型。(从表面上看,C++的template模板类与Java的泛型类写起来感觉一样)

public class Pair<T> 
{
   private T first;
   private T second;

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

   public T getFirst() { return first; }
   public T getSecond() { return second; }

   public void setFirst(T newValue) { first = newValue; }
   public void setSecond(T newValue) { second = newValue; }
}

8.3 泛型方法

  泛型方法即带有类型参数的方法,其可以定义在普通类中,也可以定义在泛型类中。在多数情况下,在调用泛型方法时,均可以省略类型参数与<>符号的撰写(万一遇到自动识别失败的情况,手动写上每个参数的确切类型即可)。

class ArrayAlg
{
	public static <T> T getMiddle(T...a)
	{
		return a[a.length / 2];
	}
}

//上面的方法与下面的方法调用无关
String middle = Array.<String>getMiddile("John", "Q.", "Public");
String middle = Array.getMiddile("John", "Q.", "Public");

  总结就是,泛型类是在实例化类的时候指明泛型的具体类型;泛型方法是在调用方法的时候指明泛型的具体类型 。

8.4 类型变量的限定

  限定是Java泛型类的特色功能,即可以限定类型参数T必须实现了XX接口,或者必须继承了XX类。这里涉及到关于类型变量限定中的一些要求:

  • 类型变量的限定通过关键字extends而非implements实现(原因在于限定包括类与接口,extends更贴近于子类型的概念);
  • 一个类型变量或通配符可以有多个限定,每个限定之间通过“&”符号来分隔,并且限定与类的继承/接口实现条件相同,即至多有一个类,但可以有多个接口,限定中有类时必须将类放置在限定列表的第一个;
  • 类型变量的限定通常只在类型变量的声明位置进行编写,即在最开始的<>符号中的声明进行编写。
class ArrayAlg
{
   public static <T extends Comparable> Pair<T> minmax(T[] a) 
   {
      if (a == null || a.length == 0) return null;
      T min = a[0];
      T max = a[0];
      for (int i = 1; i < a.length; i++)
      {
         if (min.compareTo(a[i]) > 0) min = a[i];
         if (max.compareTo(a[i]) < 0) max = a[i];
      }
      return new Pair<>(min, max);
   }
}

8.5 泛型代码与虚拟机

  对于虚拟机而言,所有的对象都是一个普通类,也就是说,泛型设计中的类型参数对于虚拟机而言不存在。

8.5.1 类型擦除

  无论何时定义一个泛型类型,都自动提供了一个原始类型。原始类型的名字就是删除类型参数后的泛型类型名。上面所述的这个过程为类型擦除,其用于擦除类型变量,并替换为限定类型(当无限定类型时替换为Object类型,当有限定类型时选择第一个限定的类型变量来替换。所以这里也衍生了限定列表的接口排序,即将标记接口尽可能放在限定列表的末尾)

public class Pair
{
   private Object first;
   private Object second;

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

   public Object getFirst() { return first; }
   public Object getSecond() { return second; }

   public void setFirst(Object newValue) { first = newValue; }
   public void setSecond(Object newValue) { second = newValue; }
}

8.5.2 翻译泛型表达式

  由于类型擦除的原因,虚拟机中所看到的代码与程序员所认为的并不一样,即类型参数对于虚拟机而言没有意义。将虚拟机的运行结果与程序员所设想的变为一致,是编译器所做的事情,比如下面的代码,buddies.getFirst()会返回一个Object类型的变量,而编译器会自动插入对于这个Object类型变量到Employee变量的强制转换。也就是说,编译器将这个Pair.getFirst()方法的调用过程变为了两条虚拟机指令

  • 对原始方法Pair.getFirst()的方法调用;
  • 将Pair.getFirst()所返回的Object类型强制转换为Employee类型。
  • (由此可见,对于Java虚拟机与编译器的联合过程而言,泛型的内部实现本质仍然是强制类型转换)
Pair<Employee> buddies = ...;
Employee buddy = buddies.getFirst();

8.5.3 翻译泛型方法

  这个地方我简单点说,假设类B是个泛型类(比如上面的Pair类),类A继承了类B类型参数为T的形式,类A在代码中对类B中的某个方法F(比如上面的Pair.setSecond()方法)以形参为T的情况实现了重写,这里姑且称类A中重写的方法叫AF,类B中本来的方法叫BF。由于在虚拟机中泛型方法也会进行类型擦除,所以AF(形参为T)实际上并没有对BF(形参为Object)实现了重写,而是实现了重载,这导致了在虚拟机中看到的类B同时存在方法AF与BF,完全违背了程序员所想表达的AF重写BF的初衷,即书中所描述的所谓的“类型擦除与多态产生了冲突”。
  如果看懂了上面我的描述,对应到下面的代码中就很好理解:

程序员写的类B如下:
public class B<T>
{
	public void F(T second)			 //即BF
	{
		...
	}
	...
}
程序员写的类A如下:
public class A extends B<LocalDate>
{
	public void F(LocalData second)  //即AF
	{
		...
	}
	...
}
虚拟机中所看到的类A:
public class A extends B<LocalDate>
{
	public void F(Object second)
	{
		...
	}
	public void F(LocalData second)
	{
		...
	}
	...
}

  为了解决这种冲突,编译器会将类A中继承得到的BF方法进行加工,即将其变为书中所谓的“桥方法”。类A中的桥方法BF的内部会调用类A中程序员自己写的方法AF,由此来间接实现类A中的方法AF对类B中的BF方法的重写,以保持多态性。

虚拟机中所看到的真正的类A:
public class A extends B<LocalDate>
{
	public void F(Object second)
	{
		F((LocalDate) second);  //由此调用下面的F方法
	}
	public void F(LocalData second)
	{
		...
	}
	...
}

  上面所说的情况是针对形参类型不同实现的,但是桥方法实际上对于返回值不同的情况也可以实现。假如将上面类A与类B中的方法F的形参类型均换为返回值的类型,那么可以得到这样的代码。下面这样的不同返回值但是其他完全相同的两个方法F在编译器中不能被允许,但是在虚拟机中却可以,因为虚拟机是通过参数类型(形参)和返回类型来确定一个方法的

(变返回值后)虚拟机中所看到的真正的类A:
public class A extends B<LocalDate>
{
	public Object F()
	{
		//调用下面的F方法,并返回下面的F方法的值,这里涉及到虚拟机的实现
	}
	public LocalData F()
	{
		...
	}
	...
}

8.5.4 调用遗留代码

  这里的主要是讲了泛型代码与遗留代码的相互操作,比如类A是老代码写的时候实现的类,类A< T >是对老代码类A实现了泛型之后的泛型类,老代码的类A变量与泛型类A< T >变量之间能够传递指针值,即实现互通。
  这种遗留代码与泛型类之间的互相的调用会导致警告,可以利用注解使其消失,比如在生成这个警告的方法前写:@SuppressWarnings("unchecked")

8.6 约束与局限性

  Java泛型使用的局限性往往都是类型擦除所引起的,总结来说Java的泛型与C++的模板差别在于:C++的模板对每一种类型参数都会创建一套代码,而Java的泛型在虚拟机中实际上只有一套类型擦除后的原始类型代码,编译器将根据不同类型参数的代码调用把的原始类型强制转换为对应的类型参数进行使用。
  (简单说几句,Java的这种方式是通过一个原始的泛型代码实现不同参数下的泛型类功能,但也正是因为这个原始导致很多代码书写上的限制,需要程序员对Java的泛型实现具有较深的理解,否则难以写出实用的泛型代码。而C++的相对简单,因为每个类型参数对应一套代码,程序员键入的类型参数对于模板中的T而言就是字符替换而已,那么就没有Java中这么多繁杂限制,但是缺点就是代码量可能比较冗余,即模板代码膨胀。)
  (Java的这种方式虽然繁琐但是实现的很精妙,C++的模板虽然有模板代码膨胀问题但是便于理解且约束较少。目前因为我没有用Java的模板实现过代码所以我没有资格评论Java的泛型类本身,但是从原理的学习过程中来看,C++的模板编程对于程序员的书写体验优于Java的这种泛型类的书写体验)

8.6.1 不能使用基本类型实例化类型参数

  简单来说就是Object类没法存储double和int这样的基本类型,所以不能像C++一样在类型参数中写基本类型,而是写基本类型的包装器类型,比如Double和Integer这样的。

8.6.2 运行时类型查询只适用于原始类型

  由于虚拟机中的对象总有一个特点的非泛型类型(也就是类型擦除之后的类型)。因此所有的类型查询都只会产生原始类型(也就是在虚拟机中对泛型类的类型查询都只会得到类型擦除之后的类型)
  所以如果想判断类A的实例a与类B的实例b是否都属于同种泛型类,通过getClass()方法即可,该方法总是会返回泛型类的原始类型(比如类A为Pair< String >,类B为Pair< Double >,两者实例的getClass()结果都为Pair.class)。

8.6.3 不能创建参数化类型的数组

  这个限定的原因是为了保证数组安全性。比如这里new出一个泛型数组table:Pair[] table = new Pair[10],table内部的类型参数String在类型擦除后变为Object:Pair< Object >[]可等效为Pair[],那么此时如果将泛型数组table转换为Objecrt形式的数组:Object[] o = table,这种情况下数组o就可以存储任意其他类型的变量,但是其引用本质为table数组,这直接导致了table数组内部存储了错误的变量,也就是破坏了数组table的安全性。

8.6.4 Varargs警告

  这个警告的产生条件通俗点来说,就是当Java虚拟机在实现参数可变的泛型方法的调用时,内部必须建立一个泛型数组,但是这个泛型数组的建立与8.6.3的原则相违背,不过因为这是虚拟机创建的而非程序员所写的,所以会得到一个Varargs警告。想要抑制这个警告需要在所调用的泛型方法声明前进行抑制:@SafeVarargs,当然也可以在调用这个泛型方法的代码前加上@SuppressWarning("unchecked")以关闭之后的代码检查功能。

8.6.5 不能实例化类型变量

  如标题所言,不能对类型变量T以及相关通配符直接进行new操作而实例化。

8.6.6 不能构建泛型数组

  这里与8.6.3的区别是,8.6.3是指不能构建形如Pair[10]这样的参数化类型数组,8.6.6则是表示不能构建形如T[]这样的数组,原因在于参数擦除后T[]这样的数组会一直保持原始类型,如Object[],与想实现的效果完全不同。

8.6.7 泛型类的静态上下文中类型变量无效

  这个必须注意抠字眼:

  • 不能在泛型类中的静态域或静态方法中引用类型变量;
  • 普通类中可以使用静态泛型方法。(因为必须在调用静态泛型方法时指明类型变量)
  • 无论普通类还是泛型类,都不能在域(成员变量)中引用类型变量。

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

  泛型类不能被throw和catch,也就是说泛型类不能被作为异常,即泛型类定义时无法extends继承Throwable类。

8.6.9 可以消除对受查的异常的检查

  这里书里讲了个骚操作,就是通过静态泛型方法的方式来讲受查异常包装成非受查异常(作者也觉得Java的所有受查异常都必须要提供一个处理器的方式很蠢哈哈),具体如下:

public class Block
{
	public static <T extends Throwable> void throwAs(Throwable e) throw T
	{
		throw (T)e;
		...
	}
	...
}

try
{
	work
}
catch (Throwable t)
{
	Block.<RuntimeException>throwAs(t);
}

8.6.10 注意擦除后的冲突

  这里讲了两个问题,其中第一个问题书本讲的很垃圾,这里把两个问题分开了讲:

  1. 假设在前面一直使用的Pair类中加入equals方法如下第一行,当对Pair类进行类型擦除后,下面的equals方法将会变为如下第二行。由于所有的类都继承自Obejct类,而Object类本身存在public boolean equals(Object XXX)方法,这导致了类型擦除后的Pair.equals方法与Object.equals方法产生了二义性冲突。这就表示了第一个问题,即“类型擦除后的方法无法对类中原有的方法实现重写”(书上的那句话翻译的很烂),解决这个问题要么是改equals方法的名字,要么直接就写如下的第二行代码实现对Object类中的equals方法的重写即可。
//原方法
public boolean equals(T value) { return first.equals(value) && second.equals(value); }
//类型擦除后的equals方法
public boolean equals(Object value) { return first.equals(value) && second.equals(value); }
  1. 由于书本上那句原则太过拗口且晦涩难懂,我这里简单概括为“由于类型擦除的原因,类在实现同泛型接口的两个或多个类型参数实例时,会导致所实现的接口方法中产生桥方法的冲突”,大意就是假设某泛型接口为C< T >,其内部有个方法叫F(T a),其两个实例分别为C< Double >和C< Integer >,类A不能同时实现接口C< Double >和C< Integer >,因为两个接口所生成桥方法都是F(Object a),从而产生了同样的二义性问题。

8.7 泛型类型的继承规则

  这一节讲了三个关键点:

  • Pair< T >与Pair< S >之间毫无关系,无法转换‘
  • 永远可以将参数化类型转换为一个原始类型(比如Pair< Employee >转换为原始类型Pair,即Pair< Object >);
  • 泛型类可以扩展或实现其他的泛型类(泛型类的各种类型参数实例中,泛型本身之间有关系可以转换,但是类型参数不同的泛型类根本毫无关联,这里可以直接用C++的模板方式理解)。

8.8 通配符类型

  通配符通俗来讲,就是对于泛型参数的一种上下界的模糊限定,而非之前的类继承或接口实现的具体限定。所以使用通配符的方法不能称为泛型方法,因为其不需要声明通配符?的含义,而泛型方法必须声明类型参数T本身。
  (也就是说,通配符型的方法和泛型方法都可以存在于泛型类与普通类中,但两者独立)

8.8.1 通配符概念

  这里先介绍的是子类型通配符,这种通配符形如Pair,其表示Pari泛型类的一种形式,不过输入的类型参数必须为Employee类或者其子类,也就是说这里类型参数设为Employee或者前面章节实现过的Manager之类的都可以。
  在实际使用中子类型通配符只能得到类型参数的上界:

  • 当子类型通配符用作方法中的形参时,由于形参属于实参的被赋值方,被赋值方需要是赋值方的同级或父级(即可以将子类变量赋值给父类变量,形参作为被赋值方需要类型参数的下界),所以无法直接通过子类型通配符确认形参的确切类型;
  • 当子类型通配符作为返回值时,由于返回值作为返回值接收者的赋值方,被赋值方需要是赋值方的同级或父级(即可以将子类变量赋值给父类变量,返回值作为赋值方需要类型参数的上界),由此可见子类型通配符作为返回值时默认选取类型参数的上界进行返回。

8.8.2 通配符的超类型限定

  这里就介绍的是超类型限定符,这种通配符形如Pair,其表示Pair泛型类的一种形式,不过输入的类型参数必须为Employee类或者其父类,也就是说这里类型参数设为Employee或者原始类型Object都可以。
  在实际使用中超类型通配符只能得到类型参数的下界:

  • 当超类型通配符用作方法中的形参时,由于形参属于实参的被赋值方,被赋值方需要是赋值方的同级或父级(即可以将子类变量赋值给父类变量,形参作为被赋值方需要类型参数的下界),由此可见超类型通配符作为形参时默认选取类型参数的下界进行操作;
  • 当超类型通配符作为返回值时,由于返回值作为返回值接收者的赋值方,被赋值方需要是赋值方的同级或父级(即可以将子类变量赋值给父类变量,返回值作为赋值方需要类型参数的上界),所以无法直接通过超类型通配符确认返回值的确切类型;

  以上总结来说,就是“带有超类型限定的通配符可以向泛型对象写入(作为形参),带有子类型限定的通配符可以从泛型对象读取(作为返回值)。
  这里需要注意的是,无论子/超类型通配符,两者在成为形参和返回值都是对应的泛型类如Pair内部本身的方法,比如setFirst and getFirst方法就会变成setFirst(? extends Employee) and getFirst(? extends Employee)方法,前面的讨论都是针对这种情况而言。但是对于Pair泛型类变量本身的引用(指针)传递没有影响,比如将Pair类型的变量传递给Pair类型的变量完全没问题,这也是通配符设计的初衷。

8.8.3 无限定通配符

  即直接使用Pair这种形式的通配符,我个人的理解是这其实就等同于Pair,属于一种子类型限定通配符,所以其无法确定形参的确切类型,但是返回值类型必为Object类型。

8.3.4 通配符捕获

  通配符捕获即类型参数可以捕获通配符中所携带的参数信息,通常是在通配符方法中调用泛型方法来实现的,即泛型方法捕获了通配符方法形参中的类型信息。
  下面的swapHelper方法就会捕获swap方法形参p中的类型参数信息。(个人感觉意义不大,因为大可将swapHelper方法直接被用,不过这里最大的作用其实是先通过通配符限制上界或下界,然后配合泛型方法进行方法调用,也就是两层限制,估计某些特殊情况需要使用)

   public static void swap(Pair<?> p) { swapHelper(p); }

   public static <T> void swapHelper(Pair<T> p)
   {
      T t = p.getFirst();
      p.setFirst(p.getSecond());
      p.setSecond(t);
   }

8.9 反射与泛型

  之前反射就没太能理解,这里先放下。

你可能感兴趣的:(Java学习,Java,Java核心技术)