Java泛型 - 通配符以及原始类型(Raw Type)

参考 & 推荐

  • Effective Java(2nd Edition)
    December, 2017 马上就要出版第三版了, 这本书真的非常经典, 强烈推荐!
  • Time To Really Learn Generics: A Java 8 Perspective
  • 张拭心 - 深入理解 Java 泛型

推荐阅读:

  • Going wild with generics, Part 1

如何理解List

根据定义, List list指的是list引用可以指向声明为List<继承于Number>的实例(不一定要是直接父类, 祖先有Number即可).
比如说以下都是合法的,

List listOfNumbers = new ArrayList();
List listOfIntegers = new ArrayList();
List listOfDoubles = new ArrayList();

但是我们却不能向上面任何一个容器加入数据.

List listOfIntegers = new ArrayList();
listOfIntegers.add(100);  // 错误, 不允许添加

用呆杰的话来理解就是,
现在给了你一个List的引用listOfNumber, 他可能是任何继承于Number的List<>. 如果允许往其中加入数据的话很显然是不安全的, 比如说调用list.add(1.4), 但是list实际上指向的是List类型, 这样很显然是不允许的.
List用处是什么? 一个常见的用例就是作为函数参数类型, 因为虽然我们不能对List的引用进行写操作, 但是我们可以读内容. 因为能传进来的List的泛型类型都是继承于Number类的, 所以总是能将其元素安全地转换为Number类. 比如下面这个例子:

private static double sumList(List list) {
    return list.stream()
            .mapToDouble(Number::doubleValue) // returns DoubleStream
            .sum();
}

public static void main(String[] args) {
    List ints = Arrays.asList(1, 2, 3, 4, 5);
    List doubles = Arrays.asList(1.0, 2.0, 3.0, 4.0, 5.0);
    List bigDecimals = Arrays.asList(
        new BigDecimal("1.0"),
        new BigDecimal("2.0"),
        new BigDecimal("3.0"),
        new BigDecimal("4.0"),
        new BigDecimal("5.0")
    );

    System.out.printf("ints sum is        %s%n", sumList(ints));
    System.out.printf("doubles sum is     %s%n", sumList(doubles));
    System.out.printf("bigdecimals sum is %s%n", sumList(bigDecimals));
}

可能我们会有疑问, 为什么不直接把参数类型定义为List呢?为什么非得加上? extends看起来如此复杂的声明? 答案是, ListList其实没有任何关系, 并没有ListList的子类的意思. 其实, 因为泛型擦除的原因, 这两个类最终都是List. 从下面的例子可以看出ListList并没有什么关系. 所以Java中的泛型是不协变的, 即A是B的父类, 但是List和List并没有关系.
更多协变内容: Treant - Java中的逆变与协变
下面代码表明了Java的泛型不是协变的.

    class People{

    }

    class Man extends People{

    }

    class Boy extends Man{

    }

    public void test(){
        List peopleList = new ArrayList();
        List manList = new ArrayList();
        peopleList = (List) manList; // 错误, 不能转换类型
    }

原文中给出的例子:

List strings = new ArrayList<>();
String s = "abc";
Object o = s;      // allowed
// strings.add(o); // not allowed

// List moreObjects = strings; // also not allowed, but pretend it was
// moreObjects.add(new Date());
// String s = moreObjects.get(0); // uh oh
// 感觉按照下面的解释, 这里应该是 String s = strings.get(0);
 
 

Since String is a subclass of Object, you can assign a String reference to an Object reference. You can’t however, add an Object reference to a List, which feels strange. The problem is that List is NOT a subclass of List. When declaring a type, the only instances you can add to it are of the declared type. That’s it. No sub- or superclass instances allowed. We say that the parameterized type is invariant.

The commented out section shows why List is not a subclass of List. Say you could assign a list of strings to a reference to a list of objects. Then, using the list of objects reference, you could add something that wasn’t a string to the list, which would cause a cast exception when you tried to retrieve it using the original reference to the list of strings. The compiler wouldn’t know any better.

这段话主要说明了ListList是没有什么关系的. 如果List能转型为List那么我们就可以往List里面加入其他类型的对象, 这显然是不正确的, 所以泛型类并不是协变的.


如何理解

List list 表明list引用可以指向元素类型为Number或者Number的超类的List, 比如说ListList.
实例:

public void numsUpTo(Integer num, List output) {
    IntStream.rangeClosed(1, num)
        .forEach(output::add);
}

ArrayList integerList = new ArrayList<>();
ArrayList numberList = new ArrayList<>();
ArrayList objectList = new ArrayList<>();

numsUpTo(5, integerList);
numsUpTo(5, numberList);
numsUpTo(5, objectList);
 
 

因为所以, 往容器加Integer是绝对安全的, 因为实际的List要么是Integer要么是Integer的父类, 所以Integer引用一定能转型为Integer或者Integer的父类引用.

实例2(Collections类的max方法):

public static  T max(Collection coll, Comparator comp) {
        if (comp==null)
            return (T)max((Collection) coll);

        Iterator i = coll.iterator();
        T candidate = i.next();

        while (i.hasNext()) {
            T next = i.next();
            if (comp.compare(next, candidate) > 0)
                candidate = next;
        }
        return candidate;
    }

注意Comparator comp部分, 在Comparator的泛型参数中使用了super, 表明可以使用T的父类的比较方法.
注意对比以下几个方法声明:

public static  T max(Collection collection, Comparator comparator){
        return null;
}

public static  T max2(Collection collection, Comparator comparator){
        return null;
}

然后有Father和Son类:

static class Father{

}

static class Son extends Father{

}

测试:

public static void main(String[] args) {
    List sons = new ArrayList();
    Collections.max(sons, new Comparator(){
        @Override
        public int compare(Father o1, Father o2) {
            return 0;
        }
    });

    max(sons, new Comparator(){
        @Override
        public int compare(Father o1, Father o2) {
            return 0;
        }
    });

//    不能这样调用max2
//    max2(sons, new Comparator(){
//        @Override
//        public int compare(Father o1, Father o2) {
//            return 0;
//        }
//    });
}

其中max2的调用是错误的, 因为max2的参数表明该容器存放的类型必须实现了跟自己比较的Comparator.
但是为什么max(Collection collection, Comparator comparator)Comparator的参数没用super但是例子中调用却是合法的呢? 这是因为对于调用max(sons, new Comparator(){...}), Java推断了类型参数Father, 而Collection表明是可以传入存放Son类型的容器的.


如何合理使用通配符

PECS - Producer - Extends, Consumer - Super, 这个词来来源Effective Java一书.

  • Producer
    这里生产者的意思是, 你要从某个参数中获取某个类型的数据, 那么声明这个参数类型为. 比如说List list, 表明list是一个生产者, 你可以从list中取出Number对象.
  • Consumer
    这里消费者的意思是, 这个参数将消费(使用)到某个类型的数据, 那么应该将参数声明为. 比如说Collection coll, 表明coll可以消费E类型的数据.
  • 即要消费又要生产
    那么就不使用通配符.

java 官方文档也有关于使用通配符的建议.

下面是一些例子, 多数来自Effective Java (2nd Edition).

实例

static  E reduce(List list, Function f, E iniVal);  // #1

list仅仅用于produce类型为E的数据, 所以符合producer的角色, 所以应该将其声明为List. 而Function f既要消费E又会产生E, 所以直接使用具体类型. 修改后的声明如下:

static  E reduce(List list, Function f, E iniVal); // #2

那么上面两者有什么区别呢? 对于#1, 当Function fFunction的时候, 对于List listOfIntegers来说, 是传不进去的, 只能是List. 但是#2, 因为listList, 所以此处可以传入listOfIntegers.

  1. 类型推导
public static  Set  union(Set s1, Set s2);

s1和s2都是producer, 所以修改为以下声明

public static  Set  union(Set s1, Set s2);

注意返回值仍然是Set, 而不是Set. 如果改成后者, 那么用户代码也必须使用通配符, 这是一个不好的决定.
类型推导的规则十分复杂, 在[JLS, 15.12.2.7-8]中有整整16页描述. 虽然大多数情况下, 用户无需指定类型参数, 但是对于有些情况, 则必须由用户指定边界的类型到底是什么.

Set integers = ...;
Set doubles = ...;
Set numbers = union(integers, doubles);

感觉上Java应该推断E为Number, 但是在不指定具体类型参数的时候, 却会报错.

注意: 在我个人实验这段代码的时候, 编译器已经正确推导出了类型.(Java 1.8), 因为Effective Java (2nd Edition)出版于2008年, 所以应该是老版本编译器的问题.

显式指定类型参数:
注意以下几点:

  • class后面的类型参数是无法被静态方法使用的, 静态方法必须自己重新定义类型参数
    这个和class的类型参数不能用于静态方法一样, 因为静态属性和方法都是整个类共有的, 如果有其他地方传入了两种不同的类型, 那么静态属性或者方法不可能同时拥有两种类型, 所以这是不被允许的. 可见我的另一篇文章Java 泛型使用限制.
  • 显式指定类型参数的静态泛型方法的调用格式:
    ClassName.methodName();
    <>后和方法名之间不用再加.
public class TypeInference {

    public static  Set union(Set s1, Set s2){
        Set result = new HashSet<>();
        result.addAll(s1);
        result.addAll(s2);
        return result;
    }

    public static void main(String[] args) {
        Set integerSet = new HashSet<>();
        Set doubleSet = new HashSet<>();
        Set numberSet = union(integerSet, doubleSet);
        // 显式指定
        numberSet = TypeInference.union(integerSet, doubleSet);
    }
}
  1. max方法的声明
public static > T max(List list);  // #1

修改过后

public static > T max(List list);  // #2

那么#2的优点在哪呢? 首先对于Comparable表明, 可以用其父类的比较函数来比较子类. 对于list参数, 是对PECS的应用, 但是我个人认为list在这个语境下即使被定义为List list也能有同样的效果.

  1. List 和 List
public static  void swap(List list,   int i,  int j); // #1
public static void swap(List list,   int i,  int j);  // #2

对于#1, list能够get也能add, 对于#2只能取出Object, 而且只有null能作为add的参数.
基于#2的交换代码:

public static void swap(List list,   int i,  int j){
  list.set(i, list.set(j, list.get(i)));
}

我们会发现, 编译器不会通过这段代码, 看起来很违背直觉. 从同一个列表拿出的元素竟然不能放回去. 这是因为, 编译知道list中的元素是某个具体类型, 但是因为是?, 所以并不知道具体是哪个类型. 所以从listget方法中拿出的数据, 只能是Object引用, 这样才安全. 对于set()方法, 除了null, 编译器不会允许我们放入任何其他东西, 因为编译器无法判断我们要添加的东西到底是不是?的那个类型, 所以就会阻止我们这么做.
而使用#1的声明, 这一操作就可以执行了, 因为编译器知道list中的元素可以安全的转换为E, 而add方法由于现在有了E, 也知道, 可以安全的放入E对象, 所以swap就可以正常工作.

书上提到会比看起来是更好的API声明, 像swap函数, 对外的声明仍然是#2形式, 然后内部实现采用#1的私有函数来做. 这里也涉及到一个概念叫capture, 有些编译错误中会有capture, 实际是指的是编译器为不确定的?类型定义了一个名字而已. 详细可见capture.


与 Raw Type

stackoverflow上有一篇讨论raw type的提问, 里面讲到了Raw Type和的区别.
what-is-a-raw-type-and-why-shouldnt-we-use-it
使用了的话, compiler会进行类型检查, 所以不能够通过一个List的引用, 往List实例中添加任何元素(null除外, 因为null可以赋值给任何引用对象), 因为根本无法确定List到底指向了什么类型的List, 所以无法保证类型安全, 所以不能通过这种引用添加元素.

static void appendNewObject(List list) {
    list.add(new Object()); // compilation error!
}

但是, 如果参数是List这种Raw Type, 那么添加任何元素都是可以的:

List list = new ArrayList();
list.add(0);
list.add("what");

上面这段代码是可以运行的, 但是compiler会给出警告.
在引入泛型以后, 使用Raw Type是不被推荐的, 使用Raw Type只是为了兼容性问题!
例外情况, 因为Java泛型擦除的关系, List.class是错误的, 因为Java泛型没有生成新的class, 所以当需要引用List这个class的时候, 必须使用List.class, 同理使用instanceof操作符的时候, 也只能用o instanceof Set而不能够o instanceof Set.

List, List, List 区别
    public static void testFunction(List integerList){
        // do nothing
    }

    public static void main(String[] args) {
        List rawList = new ArrayList();
        List wildcardList = new ArrayList<>();
        List objectList = new ArrayList<>();

        // 编译器不会阻止我们添加任何对象, 但是会给出一个警告
        rawList.add("we can add anything to rawList");

        // 编译器直接拒绝这个操作
        // wildcardList.add("we cannot add anything, because the compiler don't know what the exact type for ? is");

        // 可以添加任何对象
        objectList.add("we can add anything too, because everything is derived from Object");

        // 同样是warning, 但是允许传入, 这很不安全, 但是raw type就是可以这样做
        // 所以raw type可作为任何List的参数
        testFunction(rawList);

        // 编译器不允许
        // testFunction(wildcardList);

        // 也是不允许的
        // testFunction(objectList);

        // 所以使用RawType确实是很危险的, 因为编译器只会给警告, 而不会阻止我们做一些潜在危险的事情
    }
 

                            
                        
                    
                    
                    

你可能感兴趣的:(Java泛型 - 通配符以及原始类型(Raw Type))