从零起步,真正理解集合与泛型

零、前言

List<E>的尖括号是什么意思?
什么是泛型?如何去理解它?
泛型有什么优点?

以上问题可以在本文中找到答案。

一、基础知识

在所有编程语言中,都有这么一种东西,叫做“数组”,比如C++中就有数组

但是呢,传统数组最大的缺点就是:它的长度是固定的,如果不知道一个数组需要容纳多大的数据量,编写程序时就会很困难,数组建的太大了,就会浪费内存资源,数组太小了,容量不够就会报错。

所以,前人发明了各种各样的大小可变的数组,也就是“集合”,Collection

Java集合类型一览(选自《HeadFirstJava》):

Scan - 2020-04-11 17_05_04.jpg

正因为集合没有固定的大小,并且有着许多很方便的API,因此集合被广泛使用。(从此以后,原始的数组就只出现在大学的C++课堂之中了)

众多的集合中,较为常见的就是ArrayList,我们就拿它来举例。

假如,需要一个集合来储存一些字符串,然后遍历输出它们:

public static void main(String[] args) {
    // new对象,初始化
    ArrayList<String> array = new ArrayList<String>();
    
    // 加入元素
    array.add("ABC");
    array.add("DEF");
    array.add("Test");
    array.add("Debug");
    
    //遍历输出
    for (String i: array) {
        System.out.println(i);
    }
}

那么问题来了,ArrayList<String>中的<String>有什么作用呢?

如果把刚才的<String>去掉或者删掉,会发生什么呢?

  • 把尖括号里面改成Double的包装类,但传入的还是String:
public static void main(String[] args) {
    // 此处的集合是Double类型
    ArrayList<Double> array = new ArrayList<Double>();
    array.add("ABC");
    array.add("DEF");
    array.add("Test");
    array.add("Debug");
    for (String i: array) {
        System.out.println(i);
    }
}

答案是:无法通过编译,传入的类型与规定类型不匹配。

方法 Collection.add(Double)不适用
  (参数不匹配; String无法转换为Double)
  • 把尖括号里面去掉,但传入的还是String:
public static void main(String[] args) {
    // 此处的集合没有声明类型
    ArrayList array = new ArrayList();
    array.add("ABC");
    array.add("DEF");
    array.add("Test");
    array.add("Debug");
    for (String i: array) {
        System.out.println(i);
    }
}

答案是:仍然无法通过编译,没有进行传入类型的安全检查

我们可以从中看出一些规律:尖括号<>里面的内容,似乎是对这个集合的类型做了规定。

那为什么没有了这个尖括号,还是不能通过编译呢?
理论上,没有条件的约束,应该可以储存任何类型的数据啊?

就像这样:

public static void main(String[] args) {
    // 没有类型约束
    ArrayList array = new ArrayList();
    // 字符串
    array.add("ABC");
    // 整数型
    array.add(123);
    // 浮点数
    array.add(123.456);
    // 对象
    array.add(new Object());
    
    for (String i: array) {
        System.out.println(i);
    }
}

当然,上面的代码是错的,100%不能通过编译。

假设能编译,那么问题又出现了:在不知道某个元素的类型的情况下,怎么使用统一的方法去处理它呢?比如Java内置的Print可以输出字符串,但怎么用System.out.println(i)来打印一个对象的内容呢?

所以这种约束条件,最大的好处就是增加了安全性,就好比不同口径的瓶子,只能装合适的物品,String类型的瓶子只能装String类型的对象

这就是今天的话题,泛型

二、初识泛型

泛型是什么?从字面意思解释:“泛”是广泛的,“型”是一种特定类型,连起来就是“广泛的特定类型的对象”。

说它广泛,无论是什么对象,是只要符合规定的类型,集合(ArrayList)就可以处理它;
说它特定,是因为它必须严格符合约定的类型,否则就会被编译器拦截下来,无法通过编译。

如果说集合是各种瓶子,那么泛型就是对于瓶子的约束。这种约束是为了安全,如果没有了泛型的约束,集合就可以容纳任何类型的对象,啥都可以装进去,就像把绵羊放进老虎的集合中。

那么,怎么使用泛型呢?

首先,不同于方法的参数,参数是针对于某个方法来说的,而泛型是针对一个类或对象来说的。
再解释一下就是:把一个参数传给某个方法,这个方法接收到参数之后,就可以处理它;把一个泛型传给某个类,这个类接收到这个泛型之后,就可以new出一个只能处理这个泛型的对象。

所以泛型参数有相似之处,关键点就是在尖括号<>里加上

泛型可以用在集合中:

  • new一个对象:
new ArrayList<String>
  • new对象并声明变量:
// 声明stringList变量是字符串类型的集合
// 并且链接到一个new出来的对象上
ArrayList<String> stringList = new ArrayList<String>;

泛型还能用在方法的参数中:

// 此方法接收的参数是List集合,
// 但必须是字符串类型的集合才可以
public void printString (List<String> list) {
    ...
}

为一个集合添加数据:

// 添加: add
// 删除: remove
// 其他用法请参考源码,写的很清楚

array.add("ABC");

三、深入泛型——原理

基本的用法说完了,那么就来看看,泛型的世界里有什么规律。

在之前的《从零起步,真正理解Javascript回调函数》中说到:

程序 = 数据结构 + 算法

如果执行一个写死的程序(比如输出HelloWorld),那么无论怎样运行,结果都是一样的。这样的程序是没有意义的。

那么,如果想让某个方法发挥作用,就要让它是可以“变化”的,如果把输出的数据单独拿出来,作为函数的参数,而方法不变,那么就可以根据不同的参数,用同样的方法输出不同的结果。
这就是函数。

反之,如果待处理的数据不变,而处理数据的方法改变,把方法作为参数,就有了回调函数。

以上两种变化都是对于函数的,而泛型是对于来说的。

我们看一看ArrayList的源码(部分):

public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, Serializable {
    
    public E get(int index) {
        Objects.checkIndex(index, this.size);
        return this.elementData(index);
    }

    public E set(int index, E element) {
        Objects.checkIndex(index, this.size);
        E oldValue = this.elementData(index);
        this.elementData[index] = element;
        return oldValue;
    }

}

可以看到,这个类的许多方法里面,都有一个E,这个E是就是泛型,它不是一个具体的类型,而是在new对象的时候,传入什么泛型,就是用什么泛型。
在类定义的时候,有:

ArrayList<E>

既然定义的时候是E,下面的方法里也是E,那么,在初始化ArrayList的时候,传入什么泛型,下面的E就会变化成什么泛型,比如

ArrayList<String> stringList = new ArrayList<String>;

此时传入的是String,那么上面的代码,等同于发生了如下变化:

// 所有的 E 都被替换为特定的类型

public class ArrayList<String> extends AbstractList<String> implements List<String>, RandomAccess, Cloneable, Serializable {

    public String get(int index) {
        Objects.checkIndex(index, this.size);
        return this.elementData(index);
    }

    public String set(int index, String element) {
        Objects.checkIndex(index, this.size);
        String oldValue = this.elementData(index);
        this.elementData[index] = element;
        return oldValue;
    }

}

此时,这个被new出来的集合对象,就只能处理字符串类型的数据了。

(有没有感觉像函数重载?泛型就可以想象成类的重载,而且不用手动写重载代码)

知道了泛型的原理,也就很容易明白一个道理:
泛型并非只能用于数组和集合中,我们也可以创建使用泛型的类,只不过由于Java的API很丰富,没有必要再去自己写了。

四、最终篇 集合套娃

已经讲了很多知识,接下来来实践一下,如果能明白这个实例,就真正理解泛型了。

有这么一道面试题:

// 要求:给变量赋值

List<Map<String,List<String>>> list = newArrayList<Map<String,List<String>>>();

要理解这道题,我们还需要一些知识的补充。

Java集合类型一览(选自《HeadFirstJava》):

Scan - 2020-04-11 17_05_04.jpg

集合有这么多种,但是有些是,有些是接口
众所众知,接口是不能New的,所以题目中的List和Map都是不能new的,但我们可以new它们的实现类,然后赋值给这个类型的变量。

接着看题,变量名是list,类型是List集合,泛型是<Map<String,List<String>>>

那么怎么理解呢?就像洋葱“一样一层一层的剥开它的心”。

图片.png

  • 最外层,是一个List,它的泛型是<Map>
  • 第二层,是一个Map,它的泛型是<String, List>
  • 最里层,又是一个List,它的泛型是<String>

Map为什么有两个泛型呢?
请参考函数的两个参数,Map就相当于两个参数的函数
由于Map具有特殊性,它是“键值对”,Map的每一个“”都必须有一个唯一的“”,所以要写成 Map<键,值>

知道了它的组成,就可以去给它赋值了,只不过赋值的过程相反,是从里到外的,先创建里面,再逐层创建外面。

第一步,创建最里面的List:

// new一个ArrayList对象
List inside = new ArrayList<String> ();

// 添加一个String元素
inside.add("This is test String");

第二步,把这个有值的List,放到一个Map中

// new一个HashMap对象
Map middle = new HashMap<String, List<String>> ();

// 添加第一步的List元素
middle.add("key", inside);

这样就得到了一个有值的Map。
第三步,把这个Map赋值给外层的List。

// new一个ArrayList对象,这就是题目中的list变量
List list = new ArrayList< Map< String, List<String> > >();

// 添加第二步的Map元素
outside.add(middle);

至此,一个集合套娃已完成。

总结

见到泛型别烦恼,
这个功能非常好。
泛型若想用得好,
关键在于尖括号。

如果用函数来类比:

  • 函数是使用相同的方法,通过改变数据来产生不同结果。
  • 回调是使用相同的数据,通过改变方法来产生不同的结果。
  • 泛型是使用相同的类,通过改变对象的类型来产生不同的结果。
阅读 1.2k

推荐阅读
目录