泛型
1、泛型概念与目的
1.1 提高泛化能力
泛型是一种参数化类型的思想。在没有泛型的时代,一个类或者方法的参数是固定的类型,写好了一个类一个方法只能处理特定类型的入参。如果某个方法的入参是基本数据类型比如int,如果有一个入参为String类型但是逻辑与之相同的方法,就要编写两个方法(当然可以用多态来解决)。如果把需要使用的入参的类型也作为参数的一种传进来,那么可以避免上述的情况。泛型字面上可以理解为适用于很多类型。
1.2 增强容器安全检查能力
有许多原因促使泛型的出现,其中有一个很重要的原因就是为了更好的使用容器类。
在没有泛型概念的时代,创造一个容器可以往里面放任意类型的对象,当你再次从从容器取出来这些对象的时候其编译时类型为Object,需要强转成所需要的类型。这个过程有两个缺点:1、冗余,一进一出我要多写强转的代码 2、缺乏安全检查容易出错。假如我的初衷是设计一个String容器就来保存String变量,一不小心放了一个int进去,在放入容器的过程中编译器是无法发现这个错误的,只会在取出容器Cast的过程中报错。
在不声明泛型的情况下下面的代码是可以执行的,list容器里放入了两种类型的对象。
public static void main(String[] args) { LinkedList list = new LinkedList(); list.add(1); list.add("avd"); }
2、简单泛型
2.1 定义类
定义类的时候使用泛型,该泛型可以用于类的属性上,即使得类具有了存储不同类型的属性的能力。
在定义类Holder的时候属性的类型是T,T就是类型参数,需要在使用的时候传入具体的类型。在main中使用Holder的时候需要再尖括号里传入具体的类型为T赋值,这就是泛型的核心理念:在使用的时候才告诉编译器你真正想使用的类型。
public class Holder1<T> { private T a; public T getA() { return a; } public void setA(T a) { this.a = a; } public Holder1(T a) { this.a = a; } @Override public String toString() { return "Holder1{" + "a=" + a + '}'; } public static void main(String[] args) { Holder1<String> holder1 = new Holder1<>("aaa"); Holder1<Integer> holder2 = new Holder1<>(123); System.out.println(holder1); System.out.println(holder2); } }
下面是一个复杂点的例子,类型参数可以传入不止一个。相应的在使用的时候也要传入对应的类型参数。
public class TwoTuple<A,B> { private A a; private B b; public A getA() { return a; } public void setA(A a) { this.a = a; } public B getB() { return b; } public void setB(B b) { this.b = b; } public TwoTuple(A a, B b) { this.a = a; this.b = b; } public TwoTuple() { } @Override public String toString() { return "TwoTuple{" + "a=" + a + ", b=" + b + '}'; } public static void main(String[] args) { TwoTuple<String, Integer> tuple = new TwoTuple<>(); tuple.setA("aa"); tuple.setB(123); System.out.println(tuple); } }
下面是一个更复杂的结构,使用LinkedList结合泛型定义一个灵活性较高的栈结构。
既然是一个链表就一定需要一个节点Node,出于安全考虑节点会定义成内部类。 单链表中的节点除了包含存储的数据外还要包含指向下一个节点的指针。栈的泛型本质上是栈的内部类节点的泛型,在具体一点是节点内存储的数据的泛型,体现在Node里的item的泛型。为了后续调用的方便,为Node添加isEnd方法判断该节点是否为链表里的最后一个节点。
一个栈结构应该包含push和pop两种操作。
push操作新建一个节点,然后加入list中。这里采用的是头插法,头插法可以避免扫描整个链表。在开始的时候设置一个指针top指向链表的头部,每次把新建的节点Node的next指向top,然后修改让top指向新插入的节点Node。
pop操作较为简单,无论如何返回当前栈的顶部的Node的item,当栈里还有数据的时候就让top指向下一个节点。
public class LinkedStack<T> { private static class Node<U>{ U item; Node<U> next; Node() { item = null; next = null; } Node(U item, Node<U> next) { this.item = item; this.next = next; } boolean end(){ return item==null && next==null; } } private Node<T> top = new Node<>(); public void push(T item){ top = new Node<T>(item,top); } public T pop(){ T result = top.item; if(!top.end()){ top = top.next; } return result; }
当然我们的关键在泛型,这个例子体现了内部类泛型的使用。首先在定义外部类LinkedStack的时候使用了泛型T,同样在定义内部类Node的时候使用了泛型参数U。LinkedList在使push方法中使用Node,并在该方法中把LInkedStack的参数化类型变量T传给了内部类Node的参数化类型变量U。
3、泛型接口
以生成器(generator)为例子介绍接口泛型的使用。
接口中泛型的定义个类中泛型的定义类似,都是在后面加一个尖括号。相应的在实现接口的时候也要把类型作为一个参数传给T。
public interface Generator<T> { T next(); }
public class IntGenerator implements Generator<Integer> { private int[] num = {1,2,3,4,5,6,67,7,8,9,10,11,12,13}; @Override public Integer next() { Random random = new Random(); return num[random.nextInt(12)]; } public static void main(String[] args) { IntGenerator generator = new IntGenerator(); System.out.println(generator.next()); } }
4、泛型方法
泛型方法可以出现在非泛型类里。泛型方法在使用的时候不需要明确的指定泛型,编译器会自动推断出来,而泛型方法和泛型接口在使用的时候需要明确的指出传入的类。
public static <T> int indexOf(T[] arr, T ele){ for(int i=0;i<arr.length;i++){ if(arr[i] == ele){ return i; } } return -1; } public static void main(String[] args) { Integer[] arr= {1,2,3,4}; int ele = 2; int index = indexOf(arr, ele); System.out.println(index); }
5、类型参数的限定
之前泛型的使用过程中是不对泛型的具体类型加以限制的,如定义泛型类Apple<T>,可以在新建该类对象的时候为T赋予任何类型,Java支持为T设置一个界限具体的来说是一个“上界”,限定泛型的类型参数必须是某个范围。
5.1 上界为某个具体的类
定义新的类newApple限定其泛型必须继承自Number即限定了U必须是Number的子类。
public class newApple<U extends Number> { private U name; public U getName() { return name; } public void setName(U name) { this.name = name; } }
使用代码测试,传入Inter Double可以新建成功,但是传入String新建失败。
newApple<Integer> apple = new newApple<>(); newApple<Double> apple1 = new newApple<>(); newApple<String> apple2 = new newApple<String>();
编译器提示错误String不在界限内,应该继承自Number
5.2 上界为某个接口
限定传入的泛型参数类必须实现某个接口,这种场景常见的是限定传入的类必须实现了Comparable接口以用于方法内调用Comparable的相关方法。
public static <T extends Comparable> T max(T[] arr){ T max = arr[0]; for(int i=1; i<arr.length; i++){ if(arr[i].compareTo(max) > 0){ max = arr[i]; } } return max; }
5.3 上界为其他类型参数
Java支持 一个类型参数 以 另一个类型参数为上界。
自定义了一个简单的链表支持get set操作。
public class DynamicArray<E> { private static final int DEFAULT_CAPACITY = 10; private int size=0; private Object[] elementsData; public DynamicArray() { this.elementsData = new Object[DEFAULT_CAPACITY]; } public void add(E e){ elementsData[size++] = e; } public E get(int index){ return (E)elementsData[index]; } public static void main(String[] args) { DynamicArray<String> array = new DynamicArray<>(); array.add("aa"); array.add("bb"); System.out.println(array.get(0)); } }
现在想给DynamicArray添加addAll方法,该方法可以把另一个DynamicArray的所有值添加到该容器里,直觉上它应该照👇那样写,并且在需要添加的DynamicArray和原始的DynamicArray泛型参数一直的情况下是可以通过的。
public void addAll(DynamicArray<E> arr){ for (int i=0;i<arr.elementsData.length;i++){ add(arr.get(i)); } }
DynamicArray<Integer> array = new DynamicArray<>(); array.add(11); array.add(22); DynamicArray<Integer> array1 = new DynamicArray<>(); array1.addAll(array); System.out.println(array1.get(0)); System.out.println(array1.get(1));
但是这样是有一定的局限性的,加入我想把一个Interger的数组加到Number里那么编译器会报错。这就是一个很有意思(面试喜欢问)的现象,Integer是Number的子类,但是DynamicArray<Integer>却不是DynamicArray<Number>的子类,多态性在这两种数组直接是不成立的。
为什么不成立呢?采用反证法来来推测加入允许这种赋值会带来何种不安全行。假设Interger可以赋值给Number的数组,那么下面代码的前两行就可以执行,新建了一个interger数组,声明一个Number的引用,Number的引用指向了interger数组。此时我通过Number引用向Interger数组里添加了一个Double变量,这样以来interger里就有了一个Double元素,这显然和interger泛型的定义是矛盾的。
DynamicArray<Integer> array3 = new DynamicArray<>(); DynamicArray<Number> array3 = array3; array3.add(new Double(12.34));
但是这种需求是合理的,需要对传入的数组限定一个泛型参数T,要求T的上界是E,即 “一个类型参数 以 另一个类型参数为上界。”
public <T extends E> void addAll(DynamicArray<T> arr) { for (int i = 0; i < arr.elementsData.length; i++) { add(arr.get(i)); } }
6、通配符与PECS原则
类之间的继承关系在泛型里不能直接使用,ArrayList<Number>不能指向ArrayList<Interger>,类似这种需求的实现除了上述比较复杂的形式外也可以使用通配符来实现,如下所示。通配符的意义是该方法接收的参数其上界是T,通知编译器所有T类型的子类都可以当做该参数的入参。
public void addAll(DynamicArray<? extends T> arr) { for (int i = 0; i < arr.elementsData.length; i++) { add(arr.get(i)); } }
被<? extends T>修饰的参数都是只读不可写的,该参数只能被当做Producer。这种设置是处于安全性考虑,因为无法确定arr指向的是一个Interger还是Double,如果允许向arr里增加元素会出现一个指向Interger的引用加进来一个Double类型的对象。