Java泛型

193 阅读15分钟

程序员总是希望自己的代码能够尽可能得被复用,也就是说,我们希望能够有一种方式来编写通用的代码。多态性虽然在一定程度上可以做到这一点,比如有一个容器类List,可以定义专门用于储存Integer和String类型的List类,但是这两个类,除了保存的对象类型不同外,其他的代码是相同的,我们可以用Object和转型来编写通用的代码。但多态并不总是能满足我们的需求,因为多态是在运行时动态来处理的,而泛型编程中,在 编译时就能获取到具体的类型 了,这一点带来的好处是使用类型推导避免强制类型转换,和编译期的类型安全检查。

C和C++中的泛型

Java SE5为我们带来了 参数化类型 (Parameterized Type)的实现—— 泛型 (Generics Types)。在正式介绍Java平台的泛型之前,我们不妨看一看,其他语言是如何做到这一点的,相信我,这会帮助我们更好的理解Java中的泛型。

C语言

C语言也有泛型?没错,C语言也有"泛型"。

我们先写一个简单的swap函数,这段代码可以交换数组中的两个值,但是显然,这时我们的swap函数不是一个通用的函数,它只对int类型有效。

#include <stdio.h>

void swap(int *a, int *b) {
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

int main() {
    int arr[] = {9, 10};
    swap(&arr[0], &arr[1]);
    printf("%d\n", arr[0]); // out: 10
    printf("%d\n", arr[1]); // out: 9
    return 0;
}

下面我们把swap函数改成"泛型"形式。我们只需要传入类型在内存中占用的空间,然后按字节交换。通过类似的方式,我们不难写出泛型的链表,泛型的树……

#include <stdio.h>

void swap(void *a, void *b, int elemSize) {
    for (int i = 0; i < elemSize; ++i, ++a, ++b) {
        *((char *) a) ^= *((char *) b);
        *((char *) b) ^= *((char *) a);
        *((char *) a) ^= *((char *) b);
    }
}

int main() {
    int arr[] = {9, 10};
    swap(&arr[0], &arr[1], sizeof(int));
    printf("%d\n", arr[0]); // out: 10
    printf("%d\n", arr[1]); // out: 9
    return 0;
}

还可以再举一个泛型的例子,标准库中的qsort函数。

void qsort(void *base, size_t nmemb, size_t size, int(*compar)(const void *, const void *));

qsort函数第一个参数是待排序的数组,第二个参数是数组的个数,第三个参数是 每个元素占用的内存空间 ,第四个参数是指定比较规则的函数指针,这个函数支持任意类型的数组。

至此,我们不妨想一想,泛型到底是什么?

难道就是使用某种 通用的类型 来做形参,并且获得到实参所 占用的字节数 吗?

没错,至少在面向过程的语言里是这样,这种泛型函数被称作generic function,正如你所看到的,这种泛型的作用非常非常有限,那么支持面向对象的语言,比如C++呢?我们后面再说。

这里再啰嗦一句,关于free函数的实现。malloc和free是成对出现的,我们告诉malloc函数,需要动态分配内存的字节数,但是free似乎只需要void*,也就是一个基地址,就能正确的工作,那它怎么知道到底需要释放多少字节呢?其实这个问题的关键,其实不在free,而是在malloc。malloc在分配内存时会记录下分配的每个block有多大,这样free就可以根据记录下来的block大小正确的释放内存。关于malloc的更多的内容可以参考 如何实现一个malloc

C++

C++的模板同时对方法和类有效,而且非常的强大。为了简便起见,不妨再写个使用 函数模板 (function template)的swap函数作例子。这个函数对int和short类型都有效。

#include <iostream>

template<typename T>
void swap(T *a, T *b) {
    *a ^= *b;
    *b ^= *a;
    *a ^= *b;
}

int main() {
    int arr[] = {9, 10};
    swap<int>(&arr[0], &arr[1]);
    printf("%d\n", arr[0]); // out: 10
    printf("%d\n", arr[1]); // out: 9
    short arr2[] = {9, 10};
    swap(&arr2[0], &arr2[1]);
    printf("%d\n", arr[0]); // out: 10
    printf("%d\n", arr[1]); // out: 9
    return 0;
}

<int>是可以省略的,依靠类型推导来保证类型的正确(仅限于函数模板),并为我们实例化一个特定版本的函数。而且模板实例化时会进行类型检查,C语言就无法保证这一点。而且基于类型推导、迭代器和lambda表达式,C++标准库是如此的简洁和优雅。

下面这段代码展示了Java做不到的事情。除非指定界限,Java无法调用泛型类型的方法。Java也不能new T(),因为Java编译器无法验证T是否真的有一个public的无参构造函数。

#include <iostream>
#include "HasF.h"
#include "Mainpulator.h"

int main() {
    HasF hasF;
    Manipulator<HasF> manipulator;
    manipulator.manipulate();
    return 0;
}

#include <iostream>

class HasF {
public:
    void f() {
        std::cout << "HsF::f()" << std::endl;
    }
};

template<class T>
class Manipulator {
public:
    void manipulate() {
        T obj;
        obj.f();
    }
};

我不是在贬低Java,C++模板是在最初的ISO版本中就引入的,而Java的泛型是Java这本语言诞生10年后才出现的,10年产生的代码需要确保移植兼容,所以Java有自身的历史包袱,下面就让我们看看Java那吸引人的泛型,却又让人费解的泛型吧。

关于C++模板更多深入的讨论,可以参考 C++ Template 进阶指南

Java泛型的历史

  1. Java 2:增加Collections API,它利用多态性实现通用方法。
  2. Java 5: JSR 14 Java正式支持泛型。
  3. Java 6: JDK-6182950修复了方法冲突算法不应该依赖于返回类型的bug。这是bug,不是很多书籍讲的feature。
  4. Java 7: JSR 201增加diamond operator。
  5. Java 8:更全面的类型推导。
  6. Java 9:更更全面的类型推导。

泛型的使用

下面用一些例子,来介绍如何有效的使用泛型,合理的复用代码。泛型的一些简单用法请参考其他文章,这里不再赘述。

泛型类

Fibonacci类可以正确的迭代输出所有正整数的斐波拉契数。这段代码避免递归的代价,小心的实现了remove方法,并且检查了int类型溢出的问题。

public class Fibonacci implements Iterable<Integer> {

    public static void main(String[] args) {
        new Fibonacci().forEach(System.out::println);
    }

    @Override
    public Iterator<Integer> iterator() {
        // Java9以下需要指定泛型参数
        return new Iterator<>() {
            private int previous = 0, current = 0, count = 0;

            @Override
            public void remove() {
                count--;
                if (count == 1) {
                    current = 0;
                } else if (count == 0) {
                    previous = 0;
                } else if (count < 0) {
                    count = 0;
                } else {
                    int temp = current - previous;
                    current = previous;
                    previous = temp;
                }
            }

            @Override
            public boolean hasNext() {
                return previous + current >= 0;
            }

            @Override
            public Integer next() {
                count++;
                if (count == 1) {
                    return ++previous;
                } else if (count == 2) {
                    return ++current;
                } else {
                    int next = previous + current;
                    previous = current;
                    current = next;
                    return next;
                }
            }
        };
    }
}

泛型方法

泛型不仅可以作用于类上,也可以单独作用于方法上。而且在可能的情况下,我们应该优先考虑泛型方法,而不是泛型类。

下面是一个简单的例子,在Java 7没有diamond operator的之前,可以避免重复的泛型声明。

public class New {

    public static void main(String[] args) {
        Map<Integer, String> map = New.hashMap();
        List<Integer> list = New.arrayList();
        Set<Integer> set = New.treeSet();
        // java8以后,参数传递时,类型推导也同样有效
        fun(New.arrayList());
        // 否则需要显式指定类型
        fun(New.<Integer>arrayList());
    }

    static void fun(List<Integer> list) {
    }

    public static <K, V> Map<K, V> hashMap() {
        return new HashMap<>();
    }

    public static <E> List<E> arrayList() {
        return new ArrayList<>();
    }

    public static <E> Set<E> treeSet() {
        return new TreeSet<>();
    }
}

泛型方法和可变参数能够一起工作,下面这段代码等同于Arrays.asList()。

public class ArrayUtils {
    public static void main(String[] args) {
        ArrayUtils.asList(1,2,3,4).forEach(System.out::println);
    }

    @SafeVarargs
    public static <T> List<T> asList(T... a) {
        List<T> list = new ArrayList<>();
        for (T : a) {
            list.add(t);
        }
        return list;
    }
}

利用泛型,可以避免不必要的cast,简化我们的代码。下面这段代码TextView、ImageView、ButtonView继承自View。find方法,需要调用时再cast,而findView方法,就避免了这种不必要的麻烦。

public class Finder {
    public static void main(String[] args) {
        Finder finder = new Finder();
        // 需要强制类型转换
        TextView tv = (TextView) finder.find("tv");
        ImageView iv = (ImageView) finder.find("iv");
        //不需要强制类型转换
        finder.<ImageView>findView("iv").show();
        ButtonView bt = finder.findView("bt");
    }

    private View find(String id) {
        Objects.requireNonNull(id);
        switch (id) {
            case "tv":
                return new TextView();
            case "bv":
                return new ButtonView();
            case "iv":
                return new ImageView();
            default:
                return null;
        }
    }

    @SuppressWarnings("unchecked")
    private <T extends View> T findView(String id) {
        Objects.requireNonNull(id);
        switch (id) {
            case "tv":
                return (T) new TextView();
            case "bv":
                return (T) new ButtonView();
            case "iv":
                return (T) new ImageView();
            default:
                return null;
        }
    }
}

前面说到C++模板能够实例化模板类型,而Java中的解决办法是传递类型信息,并使用它来创建新的实例,利用类型推导来忽略cast。我们这里用到的叫做一种type token的技术。我们知道每个类都有个Class对象,而且已经被泛型化成了 Class<?>,比如Integer.class的类型是Class<Integer>类型。Class<?>是对泛型擦除的一种折中,使我们能够获取到实际的类型。

public class Finder {
    public static void main(String[] args) {
        Finder finder = new Finder();

        finder.finViewClass(ButtonView.class).show();
        finder.finViewClass(ImageView.class).dismiss();
    }

    @SuppressWarnings("unchecked")
    private <T extends View> T finViewClass(Class<? extends View> clazz) {
        try {
            return (T) clazz.getConstructor().newInstance();
        } catch (InstantiationException | NoSuchMethodException |
IllegalAccessException | InvocationTargetException e) {
            e.printStackTrace();
        }
        return null;
    }
}

但是,这种方式还不是很优雅!Kotlin中的reified关键字可以将泛型的T具体化(类型信息),下面这个例子介绍了两种方法来实例化View对象,我们只需要在泛型参数中携带类型信息,而不需要传递参数。

fun main(args: Array<String>) {
    findViewClass<ButtonView>().show()
    // 空安全,下面这行代码不能编译
    finder.findViewClass<null>().dismiss()
    newViewClass<ImageView>().show()
    // 只有TextView有showText方法,我们得到的是实实在在的TextView,而不是它的View父类
    newViewClass<TextView>().showText()
}

// 工厂方法创建对象
private inline fun <reified T : View> findViewClass(): View {
    return when (T::class.java) {
        ButtonView::class.java -> ButtonView()
        ImageView::class.java -> ImageView()
        else -> TextView()
    }
}

//反射创建对象
private inline fun <reified T : View> newViewClass(): T {
    return T::class.java.getConstructor().newInstance()
}

是不是很神奇,你一定想问为什么可以这样。把Kotlin代码编译到字节码,然后再反编译回Java代码,我们就能一探究竟啦😋

public final class KtFinderKt {
   public static final void main(@NotNull String[] args) {
      Intrinsics.checkParameterIsNotNull(args, "args");
      Class var1 = ButtonView.class;
      (Intrinsics.areEqual(var1, ButtonView.class) ? (View)(new ButtonView()) : 
(Intrinsics.areEqual(var1, ImageView.class) ? (View)(new ImageView()) : (View)(new TextView()))).show();
      Object var10000 = ImageView.class.getConstructor().newInstance();
      Intrinsics.checkExpressionValueIsNotNull(var10000, "T::class.java.getConstructor().newInstance()");
      ((ImageView)((View)var10000)).show();
      var10000 = TextView.class.getConstructor().newInstance();
      Intrinsics.checkExpressionValueIsNotNull(var10000, "T::class.java.getConstructor().newInstance()");
      ((TextView)((View)var10000)).showText();
   }

好吧,no magic,解糖后的代码很暴力,直接内联进来判断是否相等,没有方法调用也就没有擦除。更多关于reified的信息请参考 Kotlin Spec

关于type token,这里其实有一个问题,就是我们不能有List<String>.class和List<Integer>.class。这里有两种解决方法:

  1. 通过Runtime类型的注解,来传递类型信息。这个很容易理解,不多说。
  2. GSON的 TypeToken类展示了这个技巧:我们能获得到List<String>.class的TypeToken。有文档有注释,不多说。

SuppressWarnings

前面有些代码使用了SuppressWarnings注解,它可以消除一些难以消除的警告,但有可能出现unchecked的RuntimeException。不过不要以为使用了这个注解,代码就不健壮了,如果你看过Java集合框架的源码,比如ArrayList,不妨搜索一下这个异常,数一数出现了多少次😀。要说明的是,这不是我在贬低Java API的设计者,这是Java SE5的开发者Neal Gafter在他的博客中指出的。

@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, MODULE})
@Retention(RetentionPolicy.SOURCE)

看一下注解的声明,发现它会被保留在源代码中,但是不会被加载进虚拟机,而且它可以打在任何位置上,这就提示我们,应该控制注解的粒度,比如能打在变量上,就不要打在方法上。我猜99%的人都只会打在方法上(包括我),那什么才叫做合适的粒度呢,去看看ArrayList的代码吧。

通配符

首先要说明的是,Java 中的泛型是不型变的,这意味着 List<String> 并不是 List<Object> 的子类型。但是我们可以使用通配符来提升代码的灵活性。

让我们观察一下下面这段代码,在数组中我们可以这样声明。如果往数组中插入TextView的话,可以通过编译,虽然运行的话会抛出ArrayStoreException的RuntimeException。这也是为什么《Effective Java》中会推荐列表优于数组。

View[] views = new ImageView[10];
views[0] = new TextView();

好好的泛型,为什么要用数组来举例呢?因为数组是协变的(covariant)。那什么是协变呢,下面我们仔细说明。

我们说过,泛型是编译期的行为,可以将错误提前到编译期。在下面这段代码,如果往list中插入imageView,是不能通过编译的。List<? extends View>的语义难道不是说这里面可以放入View的子类吗?其实并不是这样, 关键不是放入,而是对外提供 ,只能保证现在的list可以指向View子类泛型的List。所以就导致很坑的情况出现了:我们现在可以往list中插入何种类型的element呢?如果你亲自去尝试一下的话,会发现只有null可以插入进去,Object都不行!这不是坑爹么。总结一下,带 extends 限定(上界)的通配符类型使得类型是 协变的 ,声明List<? Extends View>, 我们可以安全的从其中读取泛型,但是不能写入。

List<? extends View> list = new ArrayList<ImageView>();
ImageView imageView = new ImageView();
// 不能编译通过
list.add(imageView);
// 可以通过编译
list = new ArrayList<TextView>();

但总是这样坑爹吗?请看下面的例子。现在我们终于可以得到我们想要的List了,List中混合了View的子类型。

List<? extends View> list = Arrays.asList(new ImageView(), new TextView());
// 不能编译
list.add(new ButtonView());
View view = list.get(0);
// 可以这样做,但是会得到RuntimeException
ButtonView buttonView = (ButtonView) list.get(0);
// out: ImageView
System.out.println(view.getClass().getSimpleName());
// out: false
System.out.println(list.contains(new ButtonView()));
// out: 0
System.out.println(list.indexOf(view));

Ok,上面这个例子只是个小插曲,其实本不用这么麻烦,下面这段代码就可以办到,其中的list1等价于list2。List<? super View>被称作 逆协变 (contravariance),和协变相反, 逆协变的关键不是对外提供,而是放入

List<View> list1 = new ArrayList<>();
List<? super View> list2 = new ArrayList<>();
list2.add(new ImageView());
list2.add(new ButtonView());
list1.add(new ImageView());
list1.add(new ButtonView());
list1.forEach(view -> System.out.println(view.getClass().getSimpleName()));
list2.forEach(view -> System.out.println(view.getClass().getSimpleName()));

其实,List的allAll签名其实是这样声明的:

   boolean addAll(Collection<? extends E> c);

这样我们可以all所有当前泛型类型和它的子类。

总结一下,extends等于对外提供,super等于放入。Joshua Bloch 在《Effective Java》中称那些你只能从中读取的对象为生产者,并称那些你只能写入的对象为消费者。事实上Kotlin和C#在这里更进一步,使用out和in关键字来具体化协变和逆协变的语义,具体的可以参考 Kotlin泛型文档C#文档。嗯,不得不吹一下C#,语法糖很甜,文档很友好 👍 ,可惜,哎。

根据这个所谓的生产者消费者原理,我们很容易写出这个实现List拷贝功能的函数,它是类型安全的。

public <T> void copy(List<? extends T> producer, List<? super T> consumer) {
    Objects.checkIndex(producer.size(), consumer.size() + 1);
    int i = 0;
    for (T t : producer) {
        consumer.set(i++, t);
    }
}

还剩<?>,也就是无限制的通配符类型(unbounded wildcard type)没有说,有人可能会问,写List<?>、List<Object>和什么都不写,不都是一回事吗?这可能又是一个历史问题了,不写的话,在Java中被称作原生态类型(raw type),留作保证移植兼容,它不是类型安全的,新代码中会得到编译器的警告,不推荐使用,使用List<?>和List<Object>,可以提示这段代码是为泛型设计的。

List<?>和List<Object>的区别在于,前者的泛型类型是没有实例化的,在将来可以实例化成任何泛型类型,后者的泛型类型已经实例化了,在这个例子中的语义是可以表示任何对象类型的集合,请看下面的代码。

List<?> list = new ArrayList<String>();
// 编译错误:不兼容的类型,无法将String转化为Object
List<Object> list1 = new ArrayList<String>();

擦除

从Java的泛型伊始,关于擦除的讨论就从来没有停止过。在我看来,Java对擦除的选择,是历史的必然。我们知道,Java语言诞生的时候,并没有实现泛型,而是在这门语言出现了10年左右,才将这个语言特性添加进去。这时不得不考虑老代码的兼容性。这个兼容性不仅仅指文件格式的兼容性,更包含了迁移兼容性。C#实现了一套新的泛型类,破坏了迁移兼容性,这需要相当的勇气。

不管怎样,现实就是如此,我们接下来讨论一些更有趣的问题:运行时真的不能获取到泛型的类型了吗?

运行时也有机会获取到泛型的类型,能获取到已经实例化的泛型。前面有提到了GSON的 TypeToken,就是在运行时获取到泛型。之所以我们能做到,是因为Java 1.5后虚拟机能识别一个新的属性:Signature,它保存了参数化类型的信息。

public static void main(String[] args) {
        Fibonacci fibonacci = new Fibonacci();
        System.out.println(Arrays.toString(fibonacci.getClass().getGenericInterfaces()));
    }

这里用到前面的Fibonacci类,如果Fibonacci泛型实例化为Integer,就会输出Integer。

感谢阅读