List与List<?>的区别何在

2020-06-24 17:01:49 浏览数 (1)

导读

泛型是Java最基础的语法之一,不过这种语法依然有值得一说的地方:如果仅停留在泛型的基本使用上,泛型当然很简单;但如果从字节码层次来看泛型,将会发现更多泛型的本质。

泛型是Java最基础的语法之一,不过这种语法依然有值得一说的地方:如果仅停留在泛型的基本使用上,泛型当然很简单;但如果从字节码层次来看泛型,将会发现更多泛型的本质。

本文并不打算介绍泛型的基本用法,这些内容应该属于普通的使用,如果连简单的在集合类中使用泛型都不熟悉,或泛型类、泛型方法这些基础内容不熟,那么能力不足就要多读书,比如再翻翻手上的《疯狂Java讲义》。

本文讲解的是两个容易混淆的东西:List类型和List<?>之间的区别和联系。

List和List<?>的相似之处

首先要说的是:如果仅从意义上来看,List和List<?>看上去具有一定的相似之处:List代表集合元素可以是任意类型的列表;List<?>似乎也代表集合元素可以任意类型的列表!

事实上呢?并不是如此! List<?>代表集合元素无法确定的列表。

不过它们有相似的地方,由于List完全没有指定泛型,因此程序可以将泛型为任意类型的List(如List<Integer>、List<String>...等)赋值给List类型的变量;类似的,程序也可将泛型为任意类型的List(如List<Integer>、List<String>...等)赋值给List<?>类型的变量。

例如如下程序:

代码语言:javascript复制
import java.util.*;
public class GenericTest
{
  public static void main(String[] args)
  {
    List<Integer> intList = List.of(2, 3, 10);
    List<String> strList = List.of("java", "swift", "python");
    // 下面两行代码都是正确的
    List list1 = intList;
    List list2 = strList;
    // 下面两行代码也是正确的
    List<?> list3 = intList;
    List<?> list4 = strList;
  }
}

从上面代码可以看到,List<String>、List<Integer>类型的列表可以直接赋值给List、也可直接赋值给List<?>。

如果仅看上面程序,List和List<?>似乎差别不大?真的是这样吗?

原始类型擦除了泛型

首先需要说明一点:早期的Java是没有泛型的——Java 5才加入泛型,对于90后的小朋友来说,Java 5应该是一个古老的传说了。

正因为早期Java没有泛型,因此早期Java程序用List等集合类型时只能写成List,无法写成List<Integer>或List<String>!这样就造成了一个现状:虽然后来Java 5增加了泛型,但Java必须保留和早期程序的兼容,因此Java 5 必须兼容早期的写法:List不带泛型。

换句话来说,使用泛型类不带尖括号、具体类型的用法,其实是一种妥协:为了与早期程序的兼容。

也就是说:对于现在写的程序,谁要是使用泛型类时不填写具体类型,都应该打屁股哦。

注意

现在使用泛型类时,都应该为泛型指定具体的类型。

为了保持与早期程序兼容,Java允许在使用泛型类时不传入具体类型的搞法,被称为”原始类型(raw type)“。

原始类型会导致泛型擦除,这是一种非常危险的操作。例如如下程序:

代码语言:javascript复制
import java.util.*;
public class GenericErase
{
  public static void main(String[] args)
  {
    List<Integer> intList = new ArrayList<>();
    intList.add(20);
    intList.add(3);
    intList.add(5);

    // 泛型擦除
    List list = intList;  // ①
    // list是List类型,因此可以添加String类型的元素
    list.add("疯狂Java");   // ②

  }
}

上面①号代码使用了原始类型,这样就导致了泛型擦除——擦除了所有的泛型信息,因此程序可以在②号代码处向list集合添加String类型的元素。

那么问题来了,②号代码处是否可以向list集合(其实是List<Integer>集合)添加String类型的元素呢?

如果你不运行这个程序,你能得到正确答案吗?

答案是:完全可以添加进去! ——这是因为原始类型导致泛型信息完全被擦除了。

因此你完全可以在②号代码后使用如下代码来遍历该list集合。

代码语言:javascript复制
    // 使用Lambda表达式遍历list集合
    list.forEach(System.out::println);

但是,如果你试图使用如下代码来遍历intList集合就会导致错误。

代码语言:javascript复制
    for (Integer i : intList)
    {
      System.out.println(i);
    }

上面代码编译时没有任何问题——道理很简单,因为intList的类型是List<Integer>,因此编译器会认为它的集合元素都是Integer,因此程序在for循环中声明它的集合元素为Integer类型——这合情合理。

但运行该程序就会导致如下运行时错误。

代码语言:javascript复制
java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer

这就是原始类型问题:原始类型导致了泛型擦除,因此编译器不会执行泛型检查,这样就会给程序引入潜在的问题。

幸运的是,Java编译器非常智能,只要你的程序中包含了泛型擦除导致的潜在的错误,编译器就会提示unchecked警告。

那么问题来了,List<?>是否有这个问题呢?

List<?>不能添加元素

很明显,List<?>是很规范的泛型用法,因此它不会导致泛型擦除,因此将List<Integer>、List<String>赋值给List<?>类型的变量完全不会导致上面的错误。

List<?>怎么处理的呢?Java的泛型规定:List<?>不允许添加任何类型的元素!

List<?>相当于上限是Object的通配符,因此List<?>完全相当于List<? extends Object>,这种指定通配符上限的泛型只能取出元素,不能添加元素。

注意

这种指定通配符上限的用法被称为泛型协变,关于泛型协变的深入介绍可参考《疯狂Java讲义》9.3节或参考《Effective Java》。

实际上,Google推荐的Android开发语言:Kotlin在处理泛型协变时更加简单粗暴,它不再搞什么上限、下限,而是直接用in、out来修饰泛型——out代表泛型协变、泛型协变只能出不能进;in代表泛型逆变,泛型逆变只能进不能出。相比之下,Kotlin在处理泛型型变、逆变时具有更好的可读性。

备注

如需了解Kotlin的泛型型变、逆变的内容,可参考《疯狂Kotlin讲义》。

对于如下程序:

代码语言:javascript复制
import java.util.*;
public class GenericWildcard
{
  public static void main(String[] args)
  {
    List<Integer> intList = new ArrayList<>();
    intList.add(20);
    intList.add(3);
    intList.add(5);

    // 泛型通配符,此处的本质就是泛型协变
    List<?> list = intList;  // ①
    // list是List类型,因此可以添加String类型的元素
    list.add("疯狂Java");    // ②

  }
}

上面程序中①号代码将List<Integer>类型的变量赋值给List<?>变量,此时的本质就是泛型协变。

由于List<?>代表元素不确定类型的List集合,因此程序无法向 List<?>类型的集合中添加任何元素——因此Java编译器会禁止向list添加任何元素,故程序②号代码报错。

上面程序编译就会报错,这样程序就健壮多了。

List和List<?>的本质是一样的

需要说明的是,泛型类并不存在!

泛型只是一种编译时的检查,因此List和List<?>的本质是一样。

例如如下使用原始类型的程序:

代码语言:javascript复制
import java.util.*;
public class RawTypeTest
{
  public static void main(String[] args)
  {
    List<Integer> inList = new ArrayList<>();
    List rList = inList;
    
  }
}

用javap分析上面程序的字节码,可看到如下输出:

代码语言:javascript复制
public class RawTypeTest {
  public RawTypeTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: aload_1
       9: astore_2
      10: return
}

对于如下使用通配符的程序:

代码语言:javascript复制
import java.util.*;
public class WildcardTest
{
  public static void main(String[] args)
{
    List<Integer> inList = new ArrayList<>();
    List<?> wList = inList;

  }
}

用javap分析上面程序的字节码,同样可看到如下输出:

代码语言:javascript复制
public class WildcardTest {
  public WildcardTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class java/util/ArrayList
       3: dup
       4: invokespecial #3                  // Method java/util/ArrayList."<init>":()V
       7: astore_1
       8: aload_1
       9: astore_2
      10: return
}

从上面字节码可以看到,泛型检查的主要工作是在编译阶段完成,编译之后生成的字节码并没有太大的差别。

本文结束

0 人点赞