Java Array——数组

1 数组的特点

作为Java中持有对象的众多方式之一,数组可以说是“年岁已高”,但仍旧是长盛不衰,不论是在基础程序里,还是在算法设计里,都是一个出现频率很高且重点的知识内容。

直观上,对数组的基本看法也就是:“一种持有对象的方法,通过整型索引值访问数组元素,创建并初始化之后,数组大小就固定了,对于元素值的查询和修改的操作比较简单,但对于元素的删除和插入操作较链表来说要复杂”。

具体来说,数组和其他容器的区别有以下三个方面:

(1)效率

数组是一种效率最高的存储和随机访问对象引用序列的方式,因为数组就是一个简单的线性序列。但是,获得效率的代价就是,数组在创建并初始化之后,其大小就已经固定,在其生命周期内不能改变。

(2)类型

泛型出现之前,数组之所以优于其他容器类,就是因为其他容器类在处理对象的时候,都将对象视为无类型,即将对象当做Object类的对象进行处理。而数组在创建的时候则必须确定其内部元素的类型,即数组自创建之后,只能持有相应类型的对象,不能持有该类型之外的其他对象,带来的好处就是可以通过编译期的检查,尽早地发现和防止插入和获取不正确的类型对象,而不是等到程序运行起来之后遇到问题而崩溃。

当然,在泛型出现之后,其他的容器类也可以有类型地持有对象了,这是后话——泛型

(3)保存基本类型

在泛型出现之前,只有数组能够持有基本类型。

当然,在泛型出现之后,以及有了基本类型的自动包装类,如Integer/Double等,其他容器类也可以持有基本类型了。

总体来说,在泛型和自动包装类出现之前,数组集“效率、类型安全、广泛应用范围”为一身,地位之高,无人能比,但是在泛型和自动包装类出现之后,数组看似只有在“效率和性能”这一方面保有些许优势,除非证明确实是性能的问题,否则一般还是推荐使用灵活性更好的泛型容器类

2 数组是第一级对象

所谓第一级对象,即First-class Object,一般具有以下的特征:

  • 可以被存入变量或其他结构;
  • 可以被作为参数传递给其他方法;
  • 可以被作为方法的返回值;
  • 可以在执行期被创建,而无需在设计期全部写出;
  • 有固定的身份/类型,即指实体有内部表示,而不是单纯地根据名字识别。

数组的标识符只是一个引用,指向在堆中创建的一个真实对象,这个(数组)对象用于保存指向其他对象的引用。

length是数组对象的一部分,是一个只读成员,也是唯一一个数组中可以访问的字段或者方法。

[]是访问数组对象唯一的方式。

由于数组的标识符只是一个引用,所以在Java中可以返回一个数组,即相当于返回一个引用,如果该引用一直需要被使用,那么所指向的数组就会保持存在的状态,如果该引用不再使用,那么GC会将该数组所占资源回收。

(但是在C/C++中就不是这样简单了,由于不能返回一个数组,而是只能返回指向数组的指针,所以使得控制数组的生命周期变得困难,且没有GC机制,容易造成内存的泄露)

3 数组的初始化

不论是对象数组还是基本类型数组,使用上几乎相同,唯一的区别只是:对象数组保存的是引用基本类型数组保存的是基本类型的值

(1)仅声明,未初始化:int[] a;

(2)new初始化方式:int[] b = new int[3];

​ 使用new创建数组的时候,可以直接确定数组的大小。对于对象数组,初始化完成之后,数组内的元素(或者说引用)全为null;对于基本类型数组,数值型数组元素初始化为0,char型数组元素初始化为O,boolean型数组元素初始化为false

(3)聚集初始化方式:int[] c = {1,2,3};Object[] d = { new Object(), new Object(), new Object()};

(4)动态的聚集初始化方式:(先声明 Object[] e;e = new e[]{ new Object(), new Object()};

​ 动态聚集初始化的好处在于,如果一个方法需要一个数组的时候,可以不事先定义和创建好数组,在需要的时候根据当时的情况动态创建即可:needArrays(new f[]{new Object(), new Object()});

4 多维数组

多维数组可以看成是一个多维矩阵,每一维的元素其实就是一个引用,指向另一个多维数组。

多维数组的初始化,其实与一维数组是一样的,例如:

  • int[][] a;
  • int[][][] b = new int[3][4][2],如果不直接在声明的时候确定维数,那么后续初始化的时候,缺几维就需要有几个new
  • Object[][] c = { { new Object(), new Object(), new Object() }, { new Object(), new Object() } };,用花括号区分不同维度

Arrays.deepToString(array)方法,可以将多维数组转换为多个String,可以用于输出多维数组。该方法对于对象数组和基本类型数组都适用。

5 数组的常用操作

关于数组的常用操作,可以参见java.util.Arrays,其中有六个基本方法较为常用:

(1)数组的比较——array1.equals(array2)

equals()方法对所有基本类型和Object都做了重载,对于基本类型,需要使用其包装类的equals()方法。

数组与数组相等的条件是元素个数必须相等,并且对应位置的元素也相等。

(2)数组的填充——Arrays.fill(Object[] a, Object x)

Arrays.fill()是数组的静态方法,用于填充已经初始化好了的数组,默认是用一个相应类型的元素填充整个数组,也可以用元素填充一个区域:Arrays.fill(Object[] a, int begin, int end, Object x)

(3)数组元素的排序——Arrays.sort(Object[] a)

排序,必须根据对象的实际类型执行比较操作,从而得出相应的排序结果。

将保持不变的事物与会发生改变的事物相分离

排序的算法大体也就那么几种,变化不大,不同的数组元素排序,变化的只是数组元素的类型和不同类型中的比较方式,所以应该将排序算法与数组元素类型两者相分离,排序算法作为不变的部分封装起来,向外提供参数接口,而数组元素类型和相应的比较方式则封装到一个新的单独的类中,最后只需要用不同的对象代表不同的比较方式,传入已经写好排序算法的数组排序方法Arrays.sort()中即可。

Java的两种比较方式:java.lang.Comparable接口&java.util.Cpmparator接口

  • 实现Comparable接口之后,需要实现其中唯一的方法compareTo(),然后在该方法内定义比较的方法,即决定两个对象之间如何作比较、如何返回比较的结果。

  • 实现Comparator接口之后,需要实现其中的compare()方法,同样在方法中定义了对象之间比较的方法。

    除了自己实现Comparator接口之外,Collections类包含一个reverseOrder()方法,该方法可以产生一个Comparator:Collections.reverseOrder(),可以作为已经定义好的比较类的对象,作为参数传入排序方法中。

    基本类型无法使用Cpmparator进行排序。

(4)数组的字符串表示——Arrays.toString(Object[] a)

将数组转换为字符串对象。

(5)数组的散列码——array1.hashCode()

该方法用于产生数组的散列码。

产生散列码有什么用呢?主要的作用是,尽量使得不同的对象拥有不同的哈希值,使得对象能够成为HashTable的Key值或者成为HashSet的成员。

因为JDK中所有基于哈希的集合都是将值存储在数组之中,在查找元素/存储元素的时候,会先使用哈希值计算出数组的初始查找位置/存储位置,如果是单纯地查找元素,那么会调用equals()方法将给定的值和数组中存储对象的值进行比较;如果是存储元素,如果该位置上没有元素,则直接存储即可,如果已经有元素,则会调用equals()方法与新元素进行比较,相同则不存,不相同则为哈希冲突,散列表将会根据相应的处理办法,将新元素存储在适当的位置。如果所有元素的哈希值都不一样,那么将会减少哈希的碰撞概率。

为什么会使用基于哈希的集合存储元素或者对象?

集合有保证元素不重复的需要,怎么做到不重复呢?从头到尾调用equals()方法比较一遍么?效率太低!所以Java采用了哈希表的原理,根据哈希算法将数据直接指定到一个地址上,之后只需要调用hashCode()方法即可直接返回该对象的存储位置,方便快捷,不需要一点一点的调用equals()方法比较。

如此说来,一个对象在实现hashCode()方法的同时,还需要实现equals()方法,而且这两种方法的实现必须是一致的:

  • a.equals(b)返回true等价于a.hashCode() == b.hashCode()
  • 如果一个对象没有被修改过,那么该对象的hashCode()方法调用多少次,返回的结果都应该是相同的。

(6)有序数组的元素查找——Arrays.binarySearch(Object[] a, Object x)

若数组已经有序,就可以使用binarySearch()方法对数组元素进行快速查找,否则不能使用。

若在数组中找到了相应的元素,则返回该元素的数组索引(0~array1.length-1),否则返回负值。

(7)数组的容器化——Arrays.asList(Object[] a)

该方法接受任意的序列和数组作为参数,返回一个List容器。

(8)数组的复制(浅复制)——System.arraycopy()

System.arraycopy(Object[] A, int a, Object[] B, int b, int length)

该方法是Java标准类库提供的静态方法,用于将A数组从索引a开始,长度为length的一部分元素,复制移动到数组B从索引b开始、长度为length的部分,会将原有部分覆盖

该方法复制数组,较for循环的方式要快。该方法对于基本类型数组和对象数组都可以使用,但是对于对象数组使用时,复制的仅仅是对象的引用,而不是对象本身的拷贝,所以是浅复制。此外,该方法不会执行自动包装和自动拆包的操作,所以进行操作的两个数组的类型必须一致。

6 数组与泛型

(1)泛型的擦除

数组和泛型不能很好地结合,Java也不支持“泛型数组”这个东西,原因在于:数组在创建的时候必须知道内部元素的类型,而且在数组的生存周期中,数组会一直记得这个类型的信息,每一次访问数组元素都会进行类型检查。而Java泛型是通过擦除(Erasure)操作实现的,在运行的时候类型参数将会被擦除,只有在最后获取泛型内部元素的时候,才会加上一个类型转换,如下所示,运行编译器只能看到注释之后的情况,看不到泛型参数:

1
2
3
List<String> str = new ArrayList<String>(); // List str = new ArrayList();
str.add("hi"); // str.add("hi");
String s = str.get(0); // String s = (String)str.get(0);

所以,如下的泛型数组是错误的,编译器只能够看到ArrayList,而看不到类型参数String,由于无法确定类型,数组不允许初始化:

1
List<String>[] str = new ArrayList<String>[3]; // Error

(2)数组的协变

在泛型出现之前,很多代码迫切需要“泛型”的概念解决问题,即“泛化”地接受任何类型的元素作为方法的参数,实现方法的复用,而不必每一个类型的每一个方法都要重新定义一次。

所以,各种对象数组都设计成为了Object类数组的派生类,比如String[]、Integer[]等等,这样一来Object[]就能够接受所有的数组类型,即多态,在执行期间判断所引用对象的实际类型,然后调用相应的方法。

由于数组的特性:数组在创建的时候必须知道内部元素的类型,且在运行的时候会进行类型检查,所以数组设计成为“协变”没什么大问题,但是容器Collection就不能设计为“协变”了,因为Collection在运行的时候会对类型进行擦除,而不会进行类型检查,只有在获取内部元素的时候才会附加上类型的信息,如果Collection被设计成“协变”,那么不同类型之间的容器类就可以按照“向上转型”的规则传递对象,这样一来在获取内部元素的时候,取出来的就可能是别的类型,从而发生错误。

但,好像人们一直在追求容器功能的最大化,所以最终通过引入通配符(Wildcard),容器类的协变功能也实现了,通配符上界和下界的时候,让容器内元素的类型受到了严格的控制,基本不会出现之前的问题,虽然还挺复杂的…

1
List<? extends Number> a = new ArrayList<Integer>();

总的来说,数组的“协变”是上一个阶段Java发展所需的产物,是泛型出现之前的替代,由于涉及到底层Object[],现在如果想改也不行了,除非重新创造一门新语言。

附: