Java泛型简介

前言

今天要说的是Java中的泛型,其实在C++中我们早就接触到了泛型,也感受到了泛型的强大。使用了泛型,我们可以不再为了不同的类型写多个相同的函数。比如说下面就是一个泛型的函数。

template <typename T, typename U>
auto add(const T& t, const U& u) -> decltype(u + t) {
    return u + t;
}

通过上面的函数,我们就可以写出一个add函数,作用是将两个参数相加,但是这两个参数可以是任何的类型的。那么Java中有没有相似的泛型技术呢?其实是有的,在JDK5之后,Java也引进了泛型的技术。其实我们是经常见到泛型的,比如说

List<String> list = new ArrayList<>();

其中<String>就是一个泛型,写法是和C++一致的(很有可能就是抄袭C++的,毕竟C++在泛型编程这一块是非常的强大的,最伟大的泛型编程的作品就是C++的标准模板库,也就是我们经常说的STL)。

那么下面我们就来介绍一下Java中的泛型吧。

泛型方法

和C++一样,我们先来接触一下泛型的方法。当我们需要声明一个泛型方法时,我们需要在修饰符之后(public static),返回类型之前写一个类型参数。语法如下。

public static <T> void swap(T[] array, int i, int j) {
    T temp = array[i];
    array[i] = array[j];
    array[j] = temp;
}

这样我们就声明出了交换数组中某两个位置的函数了(这里为了简单没有检查范围)。

但是问题却出现了。

public static void main(String[] args) {
    int[] arr1 = {1, 2, 3, 4};
    swap(arr1, 1, 2);
    System.out.println(arr1);
}

上面的代码出错了,是什么没有符合这个类型的函数。其实这个问题我们之前也说过了,当我们使用泛型的时候,只可以使用对象类实现。比如我们只能使用List<Integer>而不可以使用List<int>。但是我们可以放int值进入啊,为什么这儿报错了呢?确实基本数据类型有装箱机制,但是int[]是没法转变成为Integer[]的。

public static void main(String[] args) {
    Integer[] arr1 = {1, 2, 3, 4};
    swap(arr1, 1, 2);
    System.out.println(Arrays.toString(arr1));

    String[] arr2 = {"hello", "world", "sher"};
    swap(arr2, 1, 2);
    System.out.println(Arrays.toString(arr2));
}

我们可以使用上面的方式调用这个函数。但是,我就是想要int[]也可以用这个函数,有没有办法呢?其实是有办法的。

int[]是一个数组,那么Java中的数组是基本数据类型吗?不是,数组其实一个对象,泛型可以用于对象上面,那么当然是可以用于int[]上面的。但是,我们就需要之前学的反射的知识了。

public static <T> void swapPlus(T arr, int i, int j) throws Exception {
    Class<?> clazz = arr.getClass();
    if (!clazz.isArray()) {
        return;
    }
    Object temp = Array.get(arr, i);
    Array.set(arr, i, Array.get(arr, j));
    Array.set(arr, j, temp);
}

这里我们使用的不是T[] 而是T,这样int[]就可以作为Object传入函数了。此时我们就可以通过反射的方式操作这个函数了。这个函数也是适用与其他的数组的,是一个通用的方法。

泛型类

之前我们看的ArrayListHashMap都是泛型类,甚至那个Entry也是一个泛型类。我们使用的时候都是这样子用的。HashMap<K, V>因为我们并不知道需要放入的键值的类型,只好声明为K V。等到我们使用的时候指定需要的类型是什么。比如说Map<String, Integer> map = new HashMap<>();后面的<>中可以省略类型声明,毕竟前面都已经写过一遍了。泛型类和泛型方法不一致的是,使用泛型类的时候需要我们指定类型。而泛型方法不需要我们提供类型,这一点和C++也是类似的。

泛型类的声明方式是在类名之后加上类型参数。

class A<E> {
    private E element;

    A(E e) {
        this.element = e;
    }

    public E getElement() {
        return element;
    }

    public void setElement(E element) {
        this.element = element;
    }

    @Override
    public String toString() {
        return "A{" +
                "element=" + element +
                '}';
    }
}

通过上面的方式我们就声明一个简单的泛型类,其中有一个私有的泛型的属性。

类中的类型参数类中的方法都是可以使用的,但是如果我们需要在类中声明额外的泛型方法,这当然也是可以的。我们可以将这个类型参数声明和泛型类的类型参数一致(不建议这么脑残),此时优先使用的是泛型方法的类型参数。

类型限定

有时候我们需要对泛型的类型做一些限定,比如说这个类型必须要实现了Comparable接口才可以,此时我们可以这么写。

public static <T extends Comparable> void func(T t1, T t2) {
    if (t1.compareTo(t2) > 0) {
        System.out.println("The fromer is bigger");
    } else {
        System.out.println("The Later is bigger");
    }
}

@Test
public void test2() {
    String str1 = "Hello";
    String str2 = "World";
    func(str1, str2);
}

此时调用func的参数必须要实现Comparable这个接口,如果没有实现这个接口是没法调用的。不过也可以有多个限定。

public static <T extends Comparable & Serializable> void func(T t1, T t2) {
    if (t1.compareTo(t2) > 0) {
        System.out.println("The fromer is bigger");
    } else {
        System.out.println("The Later is bigger");
    }
}

使用&符号相连,就想捕捉异常那样子一样。

通配符

如果我们需要实现一个方法,用来处理Employee类的子类组成的数组,我们只需要讲参数声明为Employee[]就行了。比如说ManagerEmployee的一个子类,此时Manager[]也是Employee的子类,因为Java中数组具有协变性。但是如果我们使用的不是原生数组,而是动态数组ArrayList呢?此时如果我们使用ArrayList<Employee>是没有用的。

@Test
public void test4() {
    List<Employee> employeeList = null;
    List<Manager> managerList = null;

    employeeList = managerList; // Error
    employeeList.add(new Employee());
}

上面的代码告诉我们,其实List<Manager>不是List<Employee>的子类,是不可以进行赋值。如果第六行代码可以通过编译的话,那么第七行代码就是可以执行的,此时可以将一个普通的员工加入经理的数组中,这显然是不合适的。所以Java中禁止了这样的操作。Java中的数组虽然是具有协变性,但是如果我们将Employee对象放入到Manager数组中,也是会抛出异常的,所以说具有协变性非常的方便但是也有缺点。那么我们该如何解决这个问题呢?我们需要找到他们共同的父类,那是List<Object>吗?闭着眼睛想都不可能是的。如果是的话那就真的乱套了,不仅普通员工可以加入到经理的列表中,甚至什么老鼠猴子都是可以的。Java中提供了通配符?帮助我们解决这个问题。

List<?>就是任何的List<xxx>的父类,他自然也都是List<Employee>List<Manager>的父类,不过这个题目中我们说的是所有的Employer子类,我们就应该使用List<? extends Employee>

不过我们使用add操作的时候,却发现了问题。编译器不允许我们进行添加数据。毕竟?可以是Employee的任何子类,所以我们是没有办法进行添加数据的。但是我们可以进行读的操作。因为只要是Employee的子类都是可以赋值给Employee的,不过也有一个例外的,我们可以想数组中添加null,比较任何对象都可以是null,添加null是肯定没有任何问题的,但是添加null真的有意义吗?

@Test
public void test4() {
    List<Manager> managerList = new ArrayList<>();
    managerList.add(new Manager());
    List<? extends Employee> list = null;

    list = managerList;
    Employee employee = list.get(0);
    System.out.println(employee.getClass());
}

取出来的是Employee但是我们也可以将其强转为Manager,毕竟我们通过getClass可以知道其实这是一个Manager对象。

上面说的是子类型的通配符,其实还有一种父类型的通配符。上面的是有下界,这里是有上界。不过这两种通配符都是可以匹配自己的。比如说? extends Employee,此时Employee也是在这个范围之内的。? super Manager如是。

List<? super Manager>就代表着List中是Manager的父类,使用的方式比较相似。

@Test
public void test5() {
    List<Employee> employeeList = new ArrayList<>();
    employeeList.add(new Employee());

    List<? super Manager> list = employeeList;

    list.add(new Manager());
    list.add(new BigMan());
    System.out.println(list);

    Object object = list.get(1);
    System.out.println(object.getClass());
}

不过需要注意的是,此时我们是可以进行读写两种操作的。? super Manager表示其中肯定是Manager的父类的类型。子类是可以赋值给父类的,此时我们就可以添加任何的Manager以及其子类类型的对象。(上面的BigManManager的子类)。至于获取,我们并不知道我们将要获取到一个什么样类型的对象,但是既然是一个对象我们就可以将其赋值给Object,所以对于? super Manager,我们是可以对其进行读写两种操作的。

? super xx这种用法是经常用于函数式对象的参数。比如我们需要根据给定的属性打印出员工的姓名。

public static void printAll(Employ[] emps, Predicate<Employee> filter) {
    for (Employ e : emps) {
        if (filter.test(e)) {
            System.out.println(e.getName());
        }
    }
}

当然我们也可以使用一个lambda表达式来传递这个参数。

printAll(emps, e -> e.getSalary() > 10000);

不过有时候我们想要使用一个Predicate<Object>来替代他。

printAll(emps, e -> e.toString().length() % 2 == 0);

这个没有任何的问题,因为toString方法其实是Object和他的子类共有的一个方法。不过Predicate<Object>Predicate<Employee>是毫无关系的两个类。此时我们就需要使用Predicate<? super Employee>

public static void printAll(Employ[] emps, Predicate<? super Employee> filter) {
    for (Employ e : emps) {
        if (filter.test(e)) {
            System.out.println(e.getName());
        }
    }
}

此时e是Employee的父类,他可以使用的方法,Employee也是可以使用的。

一般情况下,当给方法指定一个泛型函数式接口的时候,我们应当使用super通配符。

小提示:

很多的程序员喜欢使用PECS的方法来帮助直接来记忆。Productor生产者使用extendsConsumer消费者使用super。当我们从ArrayList中读取数据的时候,我们需要使用extends,此时是生产者。如果要将值传递给Predicate用来测试的时候,此时是消费者。

​ —— 以上的部分内容来自《Core Java for the Impatient》

通配符捕获

我们尝试使用通配符定义一个swap方法。

public static void swap(ArrayList<?> list, int i, int j) {
    Object temp = list.get(i);
    list.set(i, list.get(j)); // Error
    list.set(j, temp); // Error
}

之前我们也说过了,这个时候我们是可以从数组得到数据,但是我们不可以对数据进行修改。那么我们到底该怎么做呢?

public static void swap(List<?> list, int i, int j) {
    swapHelper(list, i, j);
}

private static <T> void swapHelper(List<T> list, int i, int j) {
    T temp = list.get(i);
    list.set(i, list.get(j));
    list.set(j, temp);
}

上面就是通配符匹配I的例子。编译器虽然是不知道?到底是个什么玩意,但是知道它代表是某种类型,所以说调用一个泛型方法是没有问题的。也就是说swapHelper中的T捕获了,这个?,此时我们就可以使用T来声明变量。

但是这样做,我们得到了什么呢?直接使用swapHelper这个泛型函数不是更好。。。其实我也不是很能理解官方的说辞。说什么List<?>这样的API更加的友好,泛型函数不好。我倒是感觉反过来了。没学过?是什么含义的,估计死都不会用,但是看到泛型方法倒是很容易理解。

总结

上面简单的介绍了Java中泛型方法、泛型类,以及通配符的使用。不过泛型还有很多需要注意的地方没有说。下面我们将会更加深入的Java中的泛型。


一枚小菜鸡