虽然 Java 中允许在运行时确定数组的大小。
int size = ...;
String[] staff = new String[size];
但是并没有完全解决运行时动态更改数组的问题。
一旦确定了数组的大小,就不能很容易地改变它。在 Java 中,解决这个问题最简单的方法是使用 Java 中另外一个类,名为 ArrayList。ArrayList 类类似于数组,但在添加或删除元素时,它能够自动地调整数组容量,而不要为此编写任何代码。
ArrayList 是一个有类型参数(type parameter) 的泛型类(generic class)。为了指定数组列表保存的元素对象的类型,需要用一对尖括号将类名括起来追加到 ArrayList 后面,例如 ArrayList<String>
。
1. 声明数组列表
声明和构造一个保存 String 对象的数组列表:
ArrayList<String> list = new ArrayList<String>();
在 Java 10 中,最好使用 var 关键字以避免重复写类名:
var list = new ArrayList<String>();
如果没有使用 var 关键字,可以省略右边的类型参数:
ArrayList<String> list = new ArrayList<>();
这称为 “菱形” 语法,因为空尖括号 <>
就像是一个菱形。可以结合 new 操作符使用菱形语法。编译器会检查新值要什么。如果赋值给一个变量,或传递到某个方法,或者从某个方法返回,编译器会检査这个变量、参数或方法的泛型类型,然后将这个类型放在 <>
中。在这个例子中,new ArrayList<>()
将赋至一个类型为 ArrayList<String>
的变量,所以泛型类型为 String
警告: 如果使用 var 声明 ArrayList,就不能使用菱形语法。以下声明:
var list = new ArrayList<>();
会生成一个 ArrayList<Object>
。
注释: Java 5 以前的版本没有提供泛型类,而是有一个保存 Object 类型元素的 ArrayList 类,它是一个“自适应大小”(one-size-fits-all)的集合。仍然可以使用没有后缀 <...>
的 ArrayList,这将被认为是删去了类型参数的一个“原始”类型。
注释: 在 Java 的老版本中,程序员使用 Vector 类实现动态数组。不过,ArrayList 类更加高效,没有任何理由再使用 Vector 类。
使用 add 方法可以将元素添加到数组列表中。例如,下面展示了如何将 String 对象添加到一个数组列表中:
ArrayList<String> staff = new ArrayList<String>();
staff.add(new String("Harry Hacker"));
staff.add(new String("Tommy Tester"));
数组列表管理着一个内部的对象引用数组。最终,数组的全部空间有可能被用尽。这时就显现出数组列表的魅力了:如果调用 add 且内部数组已经满了,数组列表就会自动地创建一个更大的数组,并将所有的对象从较小的数组中拷贝到较大的数组中。
如果已经清楚或能够估计出数组可能存储的元素数量,就可以在填充数组之前调用 ensureCapacity 方法:
list.ensuteCapacity(100);
这个方法调用将分配一个包含 100 个对象的内部数组。这样一来,前 100 次 add 调用不会带来开销很大的重新分配空间。
还可以把初始容量传递给 ArrayList 构造器:
ArrayList<String> list = new ArrayList<>(100);
警告: 如下分配数组列表:
new ArrayList<>(100) // capacity is 100
这与分配一个新数字有所不同:
new String[100] // size is 100
数组列表的容量与数组的大小有一个非常重要的区别。如果分配一个有 100 个元素的数组。数组就有 100 个空位置(槽)可以使用。而容量为 100 个元素的数组列表只是可能保存 100 个元素(实际上也可以超过 100,不过要以重新分配空间为代价),但是在最初,甚至完成初始化构造之后,数组列表不包含任何元素。
size 方法将返回数组列表中包含的实际元素个数。
list.size()
将返回 staff 数组列表的当前元素个数,它等价于数组 a 的 a.length。
一旦能够确认数组列表的大小将保持恒定,不再发生变化,就可以调用 trimToSize 方法。这个方法将存储块的大小调整为保存当前元素数量所需要的存储空间。垃圾回收器将回收多余的存储空间。
一旦消减了数组列表的大小,添加新元素就需要花时间再次移动存储块,所以应该在确认不会再向数组列表添加任何元素时再调用 trimToSize。
java.util.ArrayList<E>
1.2
-
ArrayList<E>
构造一个空数组列表。
- boolean add(E obj)
在数组列表的末尾追加一个元素。永远返回 true。
- int size()
返回当前存储在数组列表中元素个数。(当然,这个值永远不会大于数组列表的容量)
- void ensureCapacity()
确保数组列表不重新分配内部存储数组的情况下有足够的容量存储给定数量的元素。
- void trimToSize()
将数组列表的存储容量削减到当前大小。
2. 访问数组列表元素
数组列表自动扩展容量的便利增加了访问元素语法的复杂程度。其原因是 ArrayList 类并不是 Java 程序设计语言的一部分;它只是由某个人编写并在标准库中提供的一个实用工具类。
数组列表使用 get 和 set 方法访问或改变数组列表的元素。
警告: 只有当前数组列表的大小大于 i 时,才能够调用 list.set(i, x)
。例如这段代码时错误的:
var list = new ArrayList<String>(100); // capacity 100, size 0
list.set(0, x);
要使用 add 方法为数组添加新元素,而不是 set 方法,set 方法只是用来替换数组列表中已经加入的元素。
要得到一个数组列表的元素,使用 get 方法:
String srt = list.get(i);
注释: 没有泛型时,原始的 ArrayList 类提供的 get 方法别无选择,只能返回 Object,因此,get 方法的调用者必须对返回值进行强制类型转换:
String str = (String) list.get(i);
原始的 ArrayList 还存在一定的危险性。它的 add 和 set 方法接受任意类型的对象。对于下面这个调用:
list.set(i, new Date());
它能正常编译而不会给出任何警告,只有在检索对象并试图对它进行强制类型转换时,才会发现有问题。如果使用 ArrayList<String>
,编译器就会检测到这个错误。
这个技巧可以一举两得,即可以灵活地扩展数组,又可以方便地访问数组列表。首先,创建一个数组列表,并添加所有的元素。
var list = new ArrayList<X>();
while (...) {
x = ...;
list.add(x);
}
执行完上述操作后,使用 toArray 方法将数组元素拷贝到一个数组中。
var a = new X[list.size()];
list.toArray(a);
有时需要在数组列表的中间插入元素,为此可以使用 add 方法并提供一个索引参数。
var str = "";
int n = staff.size() / 2;
list.add(n, str);
位置 n 及以后的元素都要向后移动一个位置,为新元素留出空间。插入新元素后,如果数组列表新的大小超过了容量,数组列表就会重新分配它的存储数组。
可以从数组列表中间删除一个元素:
String srt = list.remove(n);
位于这个位置之后的所有元素都向前移动一位,并且数组的大小减 1。
插入和删除元素的操作效率很低。对于较小的数组列表来说,不必担心这个问题。但如果存储的元素比较多,又经常需要在中间插入、删除元素,就应该考虑使用链表了。
可以使用 “for each” 循环遍历数组列表的内容:
for (String str : list) {
do something with str
}
这个循环和下列代码具有相同的效果:
for (int i = 0; i < list.size(); i++) {
String str = list.get(i);
do something with str
}
使用数组列表情注意下面的变化:
- 不必指定数组列表的大小。
- 使用 add 将任意多的元素添加到数组列表中。
- 使用 size() 而不是 length 统计元素个数。
- 使用 list.get(i) 而不是 list[i] 来访问元素。
java.util.ArrayList<E> 1.2
- E set(int index, E obj)
将值 obj 放置在数组列表的指定索引位置,返回之前的内容。
- E get(i)
得到指定索引位置存储的值。
- void add(int index, E obj)
后移元素从而将 obj 插入到指定索引位置。
- E remove(int index)
删除指定索引位置的元素,并将后面的所有元素前移。返回所删除的元素。
3. 类型化与原始数组列表的兼容性
假设有下面这个遗留下来的类:
public class EmployeeDB
{
public void update(ArrayList list) { . . . }
public ArrayList find(String query) { . . . }
}
public class Employee {
private String name;
private double salary;
private LocalDate hireDay;
public Employee(String name, double salary, int year, int month, int day) {
this.name = name;
this.salary = salary;
hireDay = LocalDate.of(year, month, day);
}
}
可以将一个类型化的数组列表传递给 update 方法,而并不需要进行任何类型转换。
EmployeeDB employeeDB = new employeeDB();
ArrayList<Employee> list = . . .;
employeeDB.update(list);
可以将 list 对象传递给 update方法。
相反地,将一个原始 ArrayList 赋给一个类型化 ArrayList 会得到一个警告。
ArrayList<String> result = employeeDB.find(query); // yields warning
注释: 为了能够看到警告的文字信息,要将编译选项置为 -Xlint:unchecked
。
使用强制类型转换并不能避免出现警告。
ArrayList<String> result = (ArrayList<Employee>) employeeDB.find(query);
// yields another warning
这样将会得到另外一个警告信息,指出类型转换有误。
警告: 尽管编译器没有给出任何错误信息或警告,但是这样调用并不太安全。在 update 方法中,添加到数组列表中的元素可能不是 Employee 类型。访问这些元素时就会出现异常。 听起来似乎很吓人,但思考一下就会发现,这种行为与 Java 中引入泛型之前是一样的,虚拟机的完整性并没有受到威胁。在这种情形下,既没有降低安全性,也没有受益于编译时的检查。
这就是 Java 中不尽如人意的泛型类型限制所带来的结果。出于兼容性的考虑,编译器检查到没有发现违反规则的现象之后,就将所有的类型化参数列表转换成原始 ArrayList 对象。在程序运行时,所有的数组列表都是一样的,即虚拟机中没有类型参数。因此,强制类型转换(ArrayList)和(ArrayList<Employee>
)将执行相同的运行时检查。
在这种情形下,你并不能做什么。在与遗留的代码交互时,要研究编译器的警告,确保这些警告不太严重就行了。
一旦确保问题不太严重,可以用 @SuppressWamings("unchecked") 注解来标记接受强制类型转换的变量,如下所示:
@SuppressWarnings("unchecked") ArrayList<String> result =
(ArrayList<String>) employeeDB.find(query); // yields another warning