数组为什么特殊
Java中有大量其他的方式可以持有对象,那么到底是什么使数组变得与众不同呢?
数组与其他种类的容器之同的区別有三方面:效率、类型和保存基本类型的能力。在Java中数组是一种效率最高的存储和随机访问对象引用序列的方式。数组就是一个简单的线性序列,这使得元素访问非常快速。但是为这种速度所付出的代价是数组对象的大小被固定,并且在其生命周期中不可改变。你可能会建议使用ArrayList,它可以通过创建一个新实例,然后把旧实例中所有的引用移到新实例中,从而实现更多空间的自动分配。尽管通常应该首选ArrayList而不是数组,但是这种弾性需要开销,因此ArrayList的效率比数组低很多。
数组和容器都可以保证你不能滥用它们。无论你是使用数组还是容器,如果越界都会得到一个表示程序员错误的RuntimeException异常。
在泛型之前,其他的容器类在处理对象时,都将它们视作没有任何具体类型。也就是说,他们将这些对象都当作Java中所有类的根类Object处理。数组之所以优于泛型之前的容器,就是因为你可以创建一个数组去持有某种具体类型。这意味着你可以通过编译期检査,来防止插入错误类型和抽取不当类型。当然无论在编译时还是运行时,Java都会阻止你向对象发送不恰当的消息。所以并不是说哪种方法更不安全,只是如果编译时就能够指出错误,会显得更加优雅,也减少了程序的使用者被异常吓着的可能性。
数组可以持有基本类型,而泛型之前的容器则不能。但是有了泛型,容器就可以指定并检査它们所持有对象的类型,并且有了自动包装机制,容器看起来还能够持有基本类型。
这两种持有对象的方式都是类型检査型的,并且唯一明显的差异就是数组使用[]来访问元素,而List使用的是add()和get()这样的方法。数组和ArrayList之间的相似性是有意设计的,这使得从概念上讲,这两者之间的切换是很容易的。但是容器比数组明显具有更多的功能。
随着自动包装机制的出现,容器已经可以与数组几乎一样方便地用于基本类型中了。数组硕果仅存的优点就是数率。然而,如果要解决更一般化的问题,那数组就可能会受到过多的限制,因此在这些情形下你还是会使用容器。
数组是第一级对象
无论使用哪种类型的数组,数组标识符其实只是一个引用,指向在堆中创建的一个真实对象,这个(数组)对象用以保存指向其他对象的引用。可以作为数组初始化语法的一部分隐式地创建此对象,或者用new表达式显式地创建。只读成员length,是数组对象的一部分(事实上这是唯一一个可以访问的字段或方法),表示此数组对象可以存储多少元素。“[]”语法是访问数组对象唯一的方式。
下例总结了初始化数组的各种方式,以及如何对指向数组的引用成值,使之指向另一个数组对象。此例也说明,对象数组和基本类型数组在使用上几乎是相同的;唯一的区别就是对象数组保存的是引用,基本类型数组直接保存基本类型的值。
class Bery{}
public class Test2 {
public static void main(String[] args) {
Bery[] a;
Bery[] b = new Bery[5];
System.out.println(b.length);
for(int i = 0;i< b.length;i++){
if(b[i] == null){
b[i] = new Bery();
}
}
Bery[] c = {new Bery(),new Bery(),new Bery()};
a = new Bery[]{new Bery(),new Bery(),new Bery()};
int[] a1;
int[] b1 = new int[5];
for(int i = 0;i< b1.length;i++){
b1[i] = i;
}
int[] c1 = {1,2,3,4};
a1 = new int[]{1,2,3,4};
}
//输出:
//5
//5
}
数组a是一个尚未初始化的局部变量,在你对它正确地初始化之前,编译器不允许用此引用做任何事情。数组b初始化为指向一个Bery引用的数组,但其实并没有Bery对象置入数组中。然而仍然可以询问数组的大小,因为b指向一个合法的对象。这样做有一个小缺点:你无法知道在此数组中确切地有多少元素,因为length只表示数组能够容纳多少元素。也就是说,length是数组的大小,而不是实际保存的元素个数。新生成一个数组对象时,其中所有的引用被自动初始化为null,所以检査其中的引用是否为null,即可知道数组的某个位置是否存有对象。同样,基本类型的数组如果是数值型的,就被自动初始化为0;如果是字符型(char)的,就被自动初始化为(char)0;如果是布尔型(boolean),就被自动初始化为false。
数组b表明,数组对象在创建之后,随即将数组的各个位置都成值为Bery对象。数组c表明使用“聚集初始化”语法创建数组对象(隐式地使用new在堆中创建,就像数组b一样),并且以Bery对象将其初始化的过程,这些操作只用了一条语句。
下一个数组初始化可以看作是“动态的聚集初始化”。数组c采用的聚集初始化操作必须在定义c的位置使用,但若使用第二种语法法,可以在任意位置创建和初始化数组对象。
多维数组
创建多维数组很方便。对于基本类型的多维数组,可以通过使用花括号将每个向量分隔开:
public class Test2 {
public static void main(String[] args) {
int[][] a = {
{1,2,3},
{4,5,6}
};
System.out.println(Arrays.deepToString(a));
}
//输出:
//[[1, 2, 3], [4, 5, 6]]
}
数组中构成矩阵的每个向量都可以具有任意的长度(这被称为粗糙数组)
数组与泛型
通常,数组与泛型不能很好地结合。你不能实例化具有参数化类型的数组,擦除会移除参数类型信息,而数组必须知道它们所持有的确切类型,以强制保证类型安全。
但是,你可以参数化数组本身的类型:
class Bery<T>{
T[] f(T[] arg){
return arg;
}
}
public class Test2 {
public static void main(String[] args) {
Integer[] ints = {1,2,3};
Integer[] is = new Bery<Integer>().f(ints);
}
}
注意,使用参数化方法而不使用参数化类的方便之处在于:你不必为需要应用的每种不同的类型都使用一个参数去实例化这个类,并且你可以将其定义为静态的。当然你不能总是选择使用参数化方法而不是参数化类,但是它应该成为首选。
正如上例所证明的那样,不能创建泛型数组这一说法并不十分准确。诚然,编译器确实不让你实例化泛型数组,但是它允许你创建对这种数组的引用。例如:
List<String> ls;
这条语旬可以顺利地通过编译器而不报任何错错误。而且尽管你不能创建实际的持有泛型的数组对象,但是你可以创建非泛型的数组,然后将其转型:
public class Test2 {
public static void main(String[] args) {
List<String>[] ls;
List[] la = new List[10];
ls = la;
}
}
一般而言,你会发现泛型在类或方法的边界处很有效,而在类或方法的内部,擦除通常会使泛型变得不适用。例如,你不能创建泛型数组:
class Bery<T>{
T[] t;
public Bery(int s) {
//t = new T[s]; complied error;
t = (T[]) new Object[s];
}
}
擦除再次成为了障碍——本例试图创建的类型已被擦除,因而是类型未知的数组。注意,你可以创建Object数组,然后将其转型。也就是说,如果我创建一个String[],Java在编译期和运行时都会强制要求我只能将String对象置于该数组中。但是,如果创建的是Object[],那么我就可以将除基本类型之外的任何对象置于该数组中。
Arrays实用功能
在java.util类库中可以找到Arrays类,它有一套用于数组的static实用方法,其中有六个基本方法:equals()用于比较两个数组是否相等(deepEquals()用于多维数组);fill()(用同一个值填充各个位置,而针对对象而言,就是复制同一个引用进行填充。);sort()用于对数组排序;binarySearch()用于在已经排序的数组中査找元素,toString()产生数组的String表示;hashCode()产生数组的散列码。所有这些方法对各种基本类型和Object类而重载过。此外Arrays.asList()接受任意的序列或数组作为其参数,并将其转变为List容器。
在讨论Arrays的方法之前,我们先看看另一个不属于Arrays但很有用的方法。
复制数组
Java标准类库提供有static方法System.arraycopy(),用它复制数组比用for循坏复制要快很多。System.arraycopy()针对所有类型做了重载。下面的例子就是用来处理int数组的:
public class Test2 {
public static void main(String[] args) {
int[] i = new int[7];
int[] j = new int[10];
Arrays.fill(i,47);
Arrays.fill(j,99);
System.out.println("i[]:"+Arrays.toString(i));
System.arraycopy(i, 0,j,0, i.length);
System.out.println("j[]:"+Arrays.toString(j));
}
//输出:
//i[]:[47, 47, 47, 47, 47, 47, 47]
//j[]:[47, 47, 47, 47, 47, 47, 47, 99, 99, 99]
}
arraycopy()需要的参数:源数组,表示从源数组中的什么位置开始复制的偏移量,表示从目标数组的什么位置开始复制的偏移量,以及需要复制的元素数。当然,对数组的任何越界操作都会导致异常。
这个例子说明基本类型数组与对象数组都可以复制。然而,如果复制对象数组,那么只是复制了对象的引用,而不是对象本身的持贝。这被称作浅复制(shallow copy)。
System.arraycopy()不会执行自动包装和自动拆包,两个数组必须具有相同的确切类型。
数组比较
Arrays类提供了重载后的equals()方法,用来比较整个数组。同样,此方法针对所有基本类型与Object都做了重载。数组相等的条件是元素个数必须相等,并且对应位置的元素也相等,这可以通过对每一个元素使用equals()作比较来判断。(对于基本类型,需要使用基本类型的包装器类的equals()方法,例如对于int类型使用Integer.equals()作比较)见下例:
public class Test2 {
public static void main(String[] args) {
int[] a1 = new int[10];
int[] a2 = new int[10];
Arrays.fill(a1,47);
Arrays.fill(a2,47);
System.out.println(Arrays.equals(a1, a2));
a2[3] = 11;
System.out.println(Arrays.equals(a1, a2));
String[] s1 = new String[3];
Arrays.fill(s1, "Hi");
String[] s2 = {new String("Hi"),new String("Hi"),new String("Hi")};
System.out.println(Arrays.equals(s1, s2));
}
//输出:
//true
//false
//true
}
后一个例子中,s1的所有元素都指向同一个对象,而数组s2包含五个相互独立的对象。然而,数组相等是基于内容的(通过Object.equals()比较),所以结果为true。
数组元素的比较与排序
排序必须根据对象的实际类型执行比较操作。一种自然的解决方案是为每种不同的类型各编写一个不同的排序方法,但是这样的代码难以被新的类型所复用。
程序设计的基本目标是“将保持不变的事物与会发生改变的事物相分离”,而这里不变的是通用的排序算法,变化的是各种对象相互比较的方式。因此,不是将进行比较的代码与编写成不同的子程序,而是使用策略设计模式。通过使用策略,可以将“会发生变化的代码封装在单独的类中(策略对象),你可以将策略对象传进给总是相同的代码,这些代码将使用策略来完成其算法。通过这种方式,你能够用不同的对象来表示不同的比较方式,然后将它们传递给相同的排序代码。
Java有两种方式来提供比较功能。第一种是实现java.lang.Comparable接口,使你的类具有天生的比较能力。此接口很简单,只有compareTo()一个方法。此方法接收另一个Object为参数,如果当前对象小于参数则返回负值,如果相等则返回零,如果当前对象大于参数则返回正值。例:
class CompType implements Comparable<CompType>{
int i;
int j;
private static int count = 1;
public CompType(int i,int j) {
this.i = i;
this.j = j;
}
@Override
public String toString() {
String str = "[i="+i+",j="+j+"]";
if(count%3 ==0){
str += "\n";
}
return str;
}
@Override
public int compareTo(CompType arg0) {
return (i < arg0.i ? -1 :(i == arg0.i?0:1));
}
}
public class Test2 {
public static void main(String[] args) {
CompType[] cs = new CompType[3];
cs[0] = new CompType(1, 3);
cs[1] = new CompType(4, 2);
cs[2] = new CompType(2, 1);
Arrays.sort(cs);
System.out.println(Arrays.toString(cs));
}
//输出:
//[[i=1,j=3], [i=2,j=1], [i=4,j=2]]
}
在已排序的数组中查找
如果数组已经排好序了,就可以使用Array.binarySearch()执行快速査找。如果要对未排序的数组使用binarySearch(),那么将产生不可预料的结果。例:
class CompType implements Comparable<CompType>{
int i;
int j;
private static int count = 1;
public CompType(int i,int j) {
this.i = i;
this.j = j;
}
@Override
public String toString() {
String str = "[i="+i+",j="+j+"]";
if(count%3 ==0){
str += "\n";
}
return str;
}
@Override
public int compareTo(CompType arg0) {
return (i < arg0.i ? -1 :(i == arg0.i?0:1));
}
}
public class Test2 {
public static void main(String[] args) {
CompType[] cs = new CompType[3];
CompType c1 = new CompType(1, 3);
CompType c2 = new CompType(4, 2);
CompType c3 = new CompType(2, 1);
cs[0] = c1;
cs[1] = c2;
cs[2] = c3;
Arrays.sort(cs);
System.out.println(Arrays.toString(cs));
int i = Arrays.binarySearch(cs, c2);
System.out.println(i);
}
//输出:[[i=1,j=3], [i=2,j=1], [i=4,j=2]]
//2
}
如果找到了目标,Arrays.binarySearch()产生的返回值等于或大于0。否则,它产生负返回值,表示若要保持数组的排序状态此目标元素所应该插入的位置。这个负值的计算方式是:
-(插入点) - 1
“插入点”是指,第一个大于査找对象的元素在数组中的位置,如果数组中所有的元素都小于要査找的对象,“括入点”就等于cs.size()。
如果数组包含重复的元素,则无法保证找到的是这些副本中的哪一个。搜素算法确实不是专为包含重复元素的数组而设计的,不过仍然可用。如果需要对没有重复元素的数组排序,可以使用TreeSet(保持排序顺序),或者LinkedHashSet(保持插入顺序)。这些类会自动处理所有的细节。除非它们成为程序性能的瓶颈,否则不需要自己维护数组。
如果使用Comparator排序了某个对象数组(基本类型数组无法使用Comparator进行排序),在使用binarySearch()时必须提供同样的Comparator(使用binarySearch()方法的重载版本)。
- 本文来源《Java编程思想(第四版)》