目录
边界
通配符
编译器的能力范畴
逆变性
无界通配符
捕获转换
本笔记参考自: 《On Java 中文版》
在泛型中,边界的作用是:在参数类型上增加限制。这么做可以强制执行应用泛型的类型规则,但还有一个更重要的潜在效果,我们可以调用边界类型上的方法了。
若一个泛型参数没有边界,那么我们只能调用其中的Object方法。
为了应用边界的限制,Java复用了extend关键字:
【例子:使用extend规定泛型边界】
interface HasColor {
java.awt.Color getColor();
}
class WithColor {
T item;
WithColor(T item) {
this.item = item;
}
T getItem() {
return item;
}
// 可以调用位于边界上的方法:
java.awt.Color color() {
return item.getColor();
}
}
class Coord {
public int x, y, z;
}
// 在规定边界时,需要将类排(Coord)在前面,接口(HasColor)排在后面\
// 因此这种写法会失败:
// class WithColorCoord {}
// 这样才能正确定义多重边界:
class WithColorCoord {
T item;
WithColorCoord(T item) {
this.item = item;
}
T getItem() {
return item;
}
java.awt.Color color() {
return item.getColor();
}
int getX() {
return item.x;
}
int getY() {
return item.y;
}
int getZ() {
return item.z;
}
}
interface Weight {
int weight();
}
// 与继承一样,只能继承一个具体类,但可以实现多个接口:
class Solid {
T item;
Solid(T item) {this.item = item;}
T getItem() {return item;}
java.awt.Color color() {return item.getColor();}
int getX() {return item.x;}
int getY() {return item.y;}
int getZ() {return item.z;}
int weight() {return item.weight();}
}
class Bounded extends Coord implements HasColor, Weight {
@Override
public java.awt.Color getColor() {
return null;
}
@Override
public int weight() {
return 0;
}
}
public class BasicBounds {
public static void main(String[] args) {
Solid solid =
new Solid<>(new Bounded());
solid.color();
solid.getY();
solid.weight();
}
}
可以通过继承去除上例中的一些冗余代码。继承也可以增加边界的限制:
【例子:使用继承简化代码】
class HoldItem {
T item;
HoldItem(T item) {
this.item = item;
}
T getItem() {
return item;
}
}
class WithColor2
extends HoldItem {
WithColor2(T item) {
super(item);
}
java.awt.Color color() {
return item.getColor();
}
}
class WithColorCoord2
extends WithColor2 {
WithColorCoord2(T item) {
super(item);
}
int getX() {
return item.x;
}
int getY() {
return item.y;
}
int getZ() {
return item.z;
}
}
class Solid2
extends WithColorCoord2 {
Solid2(T item) {
super(item);
}
int weight() {
return item.weight();
}
}
public class InheritBounds {
public static void main(String[] args) {
Solid2 solid2 =
new Solid2<>(new Bounded());
solid2.color();
solid2.getY();
solid2.weight();
}
}
Solid2变得更加简洁了。
在这里,每一层的继承都会为对应的类增加边界的限制,同时继承那些来自父类的方法。这样我们就不需要在每个类中重复定义那些代码了。
另外,创建泛型集合时需要注意,我们可以且只可以继承一个接口或类:
// 可以进行的操作:
List extends Coord> list;
List extends HasColor> list1;
// 不可行的操作:
// List < ? extends HasColor & Weight > list2;
先看一个例子,将派生类的数组赋值给基类数组的引用:
【例子:数组的特殊行为】
class Fruit {
}
class Apple extends Fruit {
}
class Jonathan extends Apple {
}
class Orange extends Fruit {
}
public class CovariantArrays {
public static void main(String[] args) {
Fruit[] fruit = new Apple[10];
// 可行的操作:
fruit[0] = new Apple();
fruit[1] = new Jonathan();
// 但运行时的类型是Apple[],而不是Fruit[]或Orange[]
try {
// 编译器允许添加Fruit(父类):
fruit[0] = new Fruit();
} catch (Exception e) { // 但这种操作却会导致ArrayStoreException异常
System.out.println(e);
}
try {
// 编译器允许添加Orange:
fruit[0] = new Orange();
} catch (Exception e) { // 但同样会发生异常
System.out.println(e);
}
}
}
程序执行的结果是:
在这个例子中,我们将派生类Apple的数组赋值给了Fruit数组:
Fruit[] fruit = new Apple[10];
这在继承结构上是合理的。
不过需要注意一点,因为实际的数组类型是Apple[],所以将基类Fruit放入其中是不合理的。而编译器允许了这个行为,因为从代码上看,这只不过是将Fruit对象赋给了Fruit数组。数组机制能够知道数组的实际类型,因此才会在运行时抛出异常。
数组可以维持其包含的对象的类型规则,这也是为什么上例这种类似“向上转型”的操作能够成功的原因。它在一定程度上能够确保我们不会乱用数组。
尽管我们能够在运行时发现这种不合理的数组赋值。但使用泛型,我们可以在编译时提前进行错误检测:
【例子:泛型的编译时检查】
import java.util.ArrayList;
import java.util.List;
public class NonCovariantGenerics {
List flist = new ArrayList();
}
编译器会在编译时发现如下的问题:
它告诉我们,我们无法将包含Apple的泛型赋值给包含Fruit的泛型。
之所以会这样,是因为编译器无法掌握足够的信息,它并不知道List
可以发现,在这里我们需要讨论的是集合自身的类型,而不是集合持有的元素类型。
与数组不同,泛型并没有内建的协变性。数组完全由语言自身定义,而泛型的定义却来自于程序员。因此,编译器和运行系统有足够的信息来检查数组,却无法对泛型做到相同的事。
若一定需要在List
【例子:使用通配符建立关系】
import java.util.ArrayList;
import java.util.List;
public class NonCovariantGenerics {
public static void main(String[] args) {
// 可以用通配符提供协变的能力:
List extends Fruit> flist = new ArrayList<>();
// 但却不能添加任何类型的数据
// flist.add(new Apple());
// flist.add(new Fruit());
// flist.add(new Object());
flist.add(null); // 可以添加null,但没什么用
// 至少能返回一个Fruit对象:
Fruit f = flist.get(0);
}
}
显然,这并不意味着flist真的会持有任何Fruit类型,因为 extends Fruit>实际上表示的是“某种继承自Fruit的类型”。这里存在着一个矛盾:
集合应该持有具体的类型,但flist只要求提供一种没有被确切指定的类型。
换言之,flist所要求的类型并不具体(这是为了能够向上转型为flist做出的牺牲)。
若一个集合并不要求所持有的类型足够具体,这个集合就会失去意义。而若我们并不知道集合持有的具体元素是什么,我们也无法安全地向其中添加元素。
因为这种限制,通配符并不适合用于传入参数的集合。但我们可以将其用于接收一个已经打包好的集合,并从中取出元素。
按照上面的说法,若使用了通配符,我们似乎无法调用一个带有参数的集合方法了。先看看这个例子:
【例子:调用泛型集合中的含参方法】
import java.util.Arrays;
import java.util.List;
public class CompilerIntelligence {
public static void main(String[] args) {
List extends Fruit> flist =
Arrays.asList(new Apple());
Apple a = (Apple) flist.get(0); // 未产生警告
// 方法中的参数是Object:
flist.contains(new Apple());
// 同样,参数也是Object:
flist.indexOf(new Apple());
}
}
程序能够顺利执行。这似乎与之前得出的结论相悖——我们可以调用含参的集合方法。这是否是编译器在其中进行调度呢?
答案是否定的,可以观察contains()和indexOf()方法的参数列表:
contains()和indexOf()的参数都是Object的,假若我们调用了flist.add()方法,则会发现:
因为此时add()方法是参数已经变成了? extends Fruit。编译器不会知道应该处理哪种具体的Fruit类型,因此不会接受任何类型。
这里体现了一种思路:作为泛型类的设计者,若我们认为某种调度是“安全的”,那么可以将Object作为其的参数。例如:
【例子:设置“安全”调度的参数】
import java.util.Objects;
public class Holder {
private T value;
public Holder() {
}
public Holder(T val) {
value = val;
}
public void set(T val) {
value = val;
}
public T get() {
return value;
}
// 使用Object作为参数
@Override
public boolean equals(Object o) {
return o instanceof Holder &&
Objects.equals(value, ((Holder) o).value);
}
@Override
public int hashCode() {
return Objects.hashCode(value);
}
public static void main(String[] args) {
Holder apple = new Holder<>(new Apple());
Apple d = apple.get();
apple.set(d);
// 不允许这种操作:
// Holder fruit = apple;
// 但允许这种操作:
Holder extends Fruit> fruit = apple;
Fruit p = fruit.get();
d = (Apple) fruit.get(); // 返回一个Object,然后再转型
try {
Orange c = (Orange) fruit.get();
} catch (Exception e) {
System.out.println(e);
}
// 无法这样调用set():
// fruit.set(new Apple());
// fruit.set(new Fruit());
System.out.println(fruit.equals(d));
}
}
程序执行的结果是:
可以看到,Holder
因为equals()方法接受Object,所以它不会受到上述的限制。
除extends之外,还可以使用超类通配符(重写了super关键字)。如果说extends关键字可以为泛型添加限制,那么super就是为通配符添加了边界限制,其中的边界限制就是某个类的基类。例如:
super MyClass>
super T> // 可以使用类型参数
// // 但无法为泛型参数设置超类边界
有了超类通配符,就可以向集合中进行写操作了:
【例子:向泛型集合中进行写操作】
import java.util.List;
public class SuperTypeWildcards {
static void writeTo(List super Apple> apples) {
apples.add(new Apple());
apples.add(new Jonathan());
// 但不可以添加基类元素:
// apples.add(new Fruit());
}
}
我们可以向apples类型中添加Apple及其的子类型。但由于apples的下界是Apple,所以我们无法安全地先这个泛型集合中添加Fruit。
【例子:总结一下通配符】
import java.util.Arrays;
import java.util.List;
public class GenericReading {
static List apples =
Arrays.asList(new Apple());
static List fruit =
Arrays.asList(new Fruit());
// 调用精确的类型:
static T readExact(List list) {
return list.get(0);
}
// 兼容各种调用的静态方法:
static void f1() {
Apple a = readExact(apples);
Fruit f = readExact(fruit);
f = readExact(apples);
}
// 类的类型会在其实例化后确定:
static class Reader {
T readExact(List list) {
return list.get(0);
}
}
static void f2() {
Reader fruitReader = new Reader<>();
Fruit f = fruitReader.readExact(fruit);
// fruitReader的参数类型是Fruit
// 因此不会接受List:
// Fruit a = fruitReader.readExact(apples);
}
// 允许协变:
static class CovariantReader {
T readCovariant(List extends T> list) {
return list.get(0);
}
}
static void f3() {
CovariantReader fruitReader =
new CovariantReader<>();
Fruit f = fruitReader.readCovariant(fruit);
Fruit a = fruitReader.readCovariant(apples);
}
public static void main(String[] args) {
f1();
f2();
f3();
}
}
f1()使用了一个静态的泛型方法readExact()。从f1()中的调用可以看出,readExact()可以兼容不同的方法调用。因此,若可以使用静态的泛型方法,则不一定需要使用到协变。
从f2()中可以看出,泛型类的对象会在被实例化时确定下来。因此fruitReader的类型参数被确定成了Fruit。
无界通配符>表示“一个泛型可以持有任何类型”,但在更多时候它是一种装饰,告诉别人我考虑过Java泛型,并确定此处的这个泛型可以适配任何类型。
【例子:无界通配符的使用】
import java.util.HashMap;
import java.util.Map;
public class UnboundedWildcards2 {
static Map map1;
static Map, ?> map2;
static Map map3;
static void assign1(Map map) {
map1 = map;
}
static void assign2(Map, ?> map) {
map2 = map;
}
static void assign3(Map map) {
map3 = map;
}
public static void main(String[] args) {
assign1(new HashMap());
assign2(new HashMap());
// 出现警告:
assign3(new HashMap());
assign1(new HashMap<>());
assign2(new HashMap<>());
assign3(new HashMap<>());
}
}
第一次调用的assign3()会会发出警告,可以在编译时添加-Xlint:unchecked来观察这个警告:
编译器似乎不会区分Map和Map, ?>。下面的例子会展示出一点区别:
【例子:无界通配符带来的区别】
import java.util.ArrayList;
import java.util.List;
public class UnboundedWildcards1 {
static List list1;
static List> list2;
static List extends Object> list3;
static void assign1(List list) {
list1 = list;
list2 = list;
// 会引发警告:
list3 = list;
}
static void assign2(List> list) {
list1 = list;
list2 = list;
list3 = list;
}
static void assign3(List extends Object> list) {
list1 = list;
list2 = list;
list3 = list;
}
public static void main(String[] args) {
assign1(new ArrayList());
assign2(new ArrayList());
// 也会引发警告:
assign3(new ArrayList());
assign1(new ArrayList<>());
assign2(new ArrayList<>());
assign3(new ArrayList<>());
// 两种定义都被List>接受
List> wildList = new ArrayList();
wildList = new ArrayList<>();
assign1(wildList);
assign2(wildList);
assign3(wildList);
}
}
这段代码也会触发一些警告:
这里体现了编译器对List>和List extends Object>在处理上的不同。
编译器似乎并不关心List和List>之间有何区别,因此对它们的处理才会如此相同。然而,尽管这二者都可以被看做List,但在细节上它们仍有区别,它们实际的指代如下:
不过,在一些情况下,编译器仍会区分二者:
【例子:区分不同的泛型】
public class Wildcards {
static void rawArgs(Holder holder, Object arg) {
// 会触发警告:
holder.set(arg);
// 当前作用域中也没有T,所以不能这样写:
// T t = holder.get();
// 可以这么写,但会丢失类型信息:
Object obj = holder.get();
}
// 与rawArgs()不同,方法会触发报错:
static void unboundedArg(Holder> holder, Object arg) {
// 发生报错:
// holder.set(arg);
// 当然,这样依旧不行:
// T t = holder.get();
// 可以,但还是会丢失类型信息:
Object obj = holder.get();
}
static T exact1(Holder holder) {
return holder.get();
}
static T exact2(Holder holder, T arg) {
holder.set(arg);
return holder.get();
}
static
T wildSubtype(Holder extends T> holder, T arg) {
// 依旧会发生报错:
// holder.set(arg);
return holder.get();
}
static
void wildSupertype(Holder extends T> holder, T arg) {
// 引发报错:
// holder.set(arg);
Object obj = holder.get();
}
public static void main(String[] args) {
Holder raw = new Holder<>();
// 这种写法也一样:
raw = new Holder();
Holder qualified = new Holder<>();
Holder> unbounded = new Holder<>();
Holder extends Long> bounded = new Holder<>();
Long lng = 1L;
rawArgs(raw, lng);
rawArgs(qualified, lng);
rawArgs(unbounded, lng);
rawArgs(bounded, args);
unboundedArg(raw, lng);
unboundedArg(qualified, lng);
unboundedArg(unbounded, lng);
unboundedArg(bounded, lng);
// 引发警告:
Object r1 = exact1(raw);
Long r2 = exact1(qualified);
Object r3 = exact1(unbounded); // 方法返回Object类型
// 引发异常:
// Long r4 = exact1(bounded);
// 引发警告
Long r5 = exact2(raw, lng);
Long r6 = exact2(qualified, lng);
// 引发报错:
// Long r7 = exact2(unbounded, lng);
// 同样会报错:
// Long r8 = exact2(bounded, lng);
// 引发警告:
Long r9 = wildSubtype(raw, lng);
Long r10 = wildSubtype(qualified, lng);
// 同样会获得Object类型
Object r11 = wildSubtype(unbounded, lng);
// 引发异常:
Long r12 = wildSubtype(bounded, lng);
// 引发警告:
wildSupertype(raw, lng);
wildSupertype(qualified, lng);
wildSupertype(bounded, lng);
}
}
先看rawArgs中的holder.set(),编译时会产生警告:
由于这里使用的是Holder的原始类型,所以任何向set()中传入的类型都会被向上转型为Object。编译器知道这种行为是不安全的,所以发出了警告。注意:使用原始类型,就意味着放弃了编译时检查。
再看unboundedArg()中的holder.set(),与原生的Holder不同,使用Holder>时编译器提示的警告级别是error:
这是因为原生的Holder可以持有任何类型的组合,而Holder>只能持有由某种具体类型组合成的单类型集合,因此我们无法传入一个Object。
除此之外,exact1()和exact2()也因为参数的不同而受到了不同的限制:
可以看到,exact2()所受的限制更大。
若向一个有“具体的”泛型类型(即无通配符)参数的方法中传入原生类型,就会产生警告。这是因为具体参数所需的信息并不存在于原生类型中。
>有一个特殊的用法:可以向一个使用了>的方法传入原生类型,编译器可能可以推断出具体的类型参数。这被称为捕获转换,通过这种方式,我们可以捕获未指定的通配符类型,将其转换成具体的类型:
【例子:捕获转换的使用例】
public class CaptureConversion {
static void f1(Holder holder) {
T t = holder.get();
System.out.println(t.getClass().getSimpleName());
}
static void f2(Holder> holder) {
f1(holder); // 捕获类型,并将具体的类型传入f1()中
}
@SuppressWarnings("unchecked")
public static void main(String[] args) {
Holder raw = new Holder<>(1);
// 若直接传入f1()中,会产生警告
f1(raw);
// 但使用f2()就不会出现警告
f2(raw);
Holder rawBasic = new Holder();
// 会产生警告:
rawBasic.set(new Object());
// 也不会出现警告
f2(rawBasic);
// 即使向上转型为Holder>,依旧可以推断出具体类型:
Holder> wildcarded = new Holder<>(1.0);
f2(wildcarded);
}
}
程序执行的结果是:
需要注意的是,捕获转换经适用于“在方法中必须使用确切类型”的情况。我们无法从f2()方法中返回T,因为对f2()而言,T是未知的(因此,若需要返回值,我们需要自己传入类型参数)。