程序员总是希望自己的代码能够尽可能得被复用,也就是说,我们希望能够有一种方式来编写通用的代码。多态性虽然在一定程度上可以做到这一点,比如有一个容器类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泛型的历史
- Java 2:增加Collections API,它利用多态性实现通用方法。
- Java 5: JSR 14 Java正式支持泛型。
- Java 6: JDK-6182950修复了方法冲突算法不应该依赖于返回类型的bug。这是bug,不是很多书籍讲的feature。
- Java 7: JSR 201增加diamond operator。
- Java 8:更全面的类型推导。
- 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。这里有两种解决方法:
- 通过Runtime类型的注解,来传递类型信息。这个很容易理解,不多说。
- 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。
感谢阅读