欢迎光临
我们一直在努力

从零开始学Java:第15章 数组和 List:从固定容器到动态数据集合

第15章 数组和 List:从固定容器到动态数据集合

上一章我们写了一个图书借阅模型。为了不提前引入集合框架,Library 里用的是数组:

private final Book[] books;
private int bookCount;

这种写法能让你看清对象引用和下标管理,但它也暴露了一个非常现实的问题:真实程序几乎总是在处理一批数据,而一批数据的数量往往不是固定的。

图书馆今天有 100 本书,明天可能有 101 本;联系人管理器今天有 3 个联系人,过几天可能有 300 个;订单系统每分钟都在新增订单。你不可能每次都提前准确知道数组应该开多大。

这一章开始进入第三阶段:集合、泛型和常用工具类。

我们先从数组讲起,再过渡到 List 和 ArrayList。不要急着背 API。你要先明白:

  • 数组到底好在哪里。
  • 数组为什么在业务程序里会吃力。
  • List 解决的是什么问题。
  • ArrayList 的增删改查怎么写。
  • 为什么 List<Book> 比 Book[] 更适合表示动态数据。
  • 删除元素时为什么容易出错。
  • 如何把上一章的图书馆改成集合版本。

一、数组仍然重要,不是学了 List 就不用数组

很多人学到集合后会觉得数组过时了,这是错误理解。

数组仍然是 Java 里非常基础的数据结构。它有几个特点:

  • 长度固定。
  • 按下标访问。
  • 同一种数组只能放同一种类型的数据。
  • 访问速度直接。
  • 适合数量明确、结构稳定的数据。

比如保存一周的温度:

double[] temperatures = new double[7];

一周就是 7 天,这个长度天然固定。数组很合适。

再比如保存棋盘:

char[][] board = new char[3][3];

井字棋就是 3 行 3 列。数组也很合适。

再比如方法接收命令行参数:

public static void main(String[] args) {
}

args 本身就是字符串数组。

所以问题不是“数组不好”,而是“数组适不适合当前场景”。如果数据数量固定,数组简单直接;如果数据数量会变化,集合通常更自然。

二、数组的核心概念再讲透

创建一个整数数组:

int[] scores = new int[3];

它在内存里大概像这样:

下标: 0 1 2
值: 0 0 0

int 数组默认值是 0。

赋值:

scores[0] = 90;
scores[1] = 75;
scores[2] = 88;

现在是:

下标: 0 1 2
值: 90 75 88

读取:

System.out.println(scores[1]); // 75

数组长度:

System.out.println(scores.length); // 3

注意,数组长度是字段,不是方法,所以写 scores.length,不是 scores.length()。

遍历:

for (int i = 0; i < scores.length; i++) {
System.out.println(scores[i]);
}

增强 for:

for (int score : scores) {
System.out.println(score);
}

如果你只需要读取每个元素,增强 for 很舒服。如果你需要下标,或者要根据下标修改元素,用普通 for 更合适。

三、引用类型数组的默认值是 null

看这个数组:

String[] names = new String[3];

它不是:

["", "", ""]

而是:

[null, null, null]

所以这段代码会报错:

String[] names = new String[3];
System.out.println(names[0].length());

报错:

NullPointerException

因为 names[0] 不是空字符串,而是 null。null 表示没有对象,不能调用方法。

正确做法:

String[] names = new String[3];
names[0] = "Tom";

if (names[0] != null) {
System.out.println(names[0].length());
}

对象数组也是一样:

Book[] books = new Book[100];

刚创建时,100 个位置都是 null。只有你写入:

books[0] = new Book("001", "Java入门", "作者A", 3);

第 0 个位置才真正指向一个 Book 对象。

这就是上一章为什么必须有 bookCount。

for (int i = 0; i < bookCount; i++) {
System.out.println(books[i].getTitle());
}

不能直接遍历到 books.length,因为后面很多位置还没有对象。

四、数组新增元素:你其实在手动维护 size

假设我们要保存学生姓名,最多 5 个:

String[] names = new String[5];
int size = 0;

添加第一个:

names[size] = "Tom";
size++;

添加第二个:

names[size] = "Lucy";
size++;

现在:

数组长度 length = 5
实际元素 size = 2

下标: 0 1 2 3 4
值: Tom Lucy null null null

length 是数组容量,size 是实际数量。

这两个概念必须分清。

如果你用 length 当实际数量,就会读到一堆 null。

如果你忘记更新 size,新元素可能覆盖旧元素。

错误写法:

names[size] = "Tom";
names[size] = "Lucy";

因为没有 size++,两次都写到同一个位置。

正确封装成方法:

public static int addName(String[] names, int size, String name) {
if (size >= names.length) {
throw new IllegalStateException("数组已满");
}
names[size] = name;
return size + 1;
}

调用:

String[] names = new String[5];
int size = 0;

size = addName(names, size, "Tom");
size = addName(names, size, "Lucy");

你会发现,数组一旦用来表示“动态增长的一批数据”,就必须自己管理容量、数量、下标和越界。

五、数组查找元素:本质是循环

根据名字查找:

public static int findNameIndex(String[] names, int size, String target) {
for (int i = 0; i < size; i++) {
if (names[i].equals(target)) {
return i;
}
}
return 1;
}

返回下标是常见写法。找到了返回 0、1、2 等下标;没找到返回 -1。

为什么不是返回 0 表示没找到?

因为 0 是合法下标。第一个元素的下标就是 0。

查找对象也是一样:

public static Book findBookByIsbn(Book[] books, int bookCount, String isbn) {
for (int i = 0; i < bookCount; i++) {
if (books[i].getIsbn().equals(isbn)) {
return books[i];
}
}
return null;
}

这里返回 Book 对象。找不到时返回 null。

这两种方式都常见:

  • 想知道位置,返回下标。
  • 想直接使用对象,返回对象。

六、数组删除元素:不是把位置设成 null 就完事

假设数组里有 4 个名字:

下标: 0 1 2 3 4
值: Tom Lucy Jack Anna null
size = 4

现在删除下标 1 的 Lucy。

如果只是:

names[1] = null;
size;

数组会变成:

下标: 0 1 2 3 4
值: Tom null Jack Anna null
size = 3

这样中间出现了空洞。后续遍历 0 到 size – 1 时,会读到 names[1] == null,很容易空指针。

更常见的做法是把后面的元素往前移:

public static int removeAt(String[] names, int size, int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("删除下标非法:" + index);
}

for (int i = index; i < size 1; i++) {
names[i] = names[i + 1];
}

names[size 1] = null;
return size 1;
}

删除下标 1 后:

下标: 0 1 2 3 4
值: Tom Jack Anna null null
size = 3

这就是数组删除麻烦的地方。你要自己移动元素,自己清理最后一个位置,自己维护 size。

ArrayList 的价值之一,就是把这些重复细节封装起来。

七、List 是什么

List 是 Java 集合框架里的一个接口,表示“有顺序、可以重复、可以按下标访问的一组元素”。

先记住这句话:

List 像一个长度可以变化的数组。

创建一个 ArrayList:

import java.util.ArrayList;

public class ListDemo {
public static void main(String[] args) {
ArrayList<String> names = new ArrayList<>();

names.add("Tom");
names.add("Lucy");
names.add("Jack");

System.out.println(names.size()); // 3
System.out.println(names.get(0)); // Tom
}
}

这里有几个新东西。

第一,必须导入:

import java.util.ArrayList;

ArrayList 不在 java.lang 包里,所以不能像 String 那样直接用。

第二,ArrayList<String> 表示这个列表里放的是字符串。

如果你写:

ArrayList<String> names = new ArrayList<>();

就不能往里面放整数:

names.add(123); // 编译错误

这就是泛型的第一层作用:让容器知道自己应该放什么类型的数据。泛型后面会专门讲,这一章先会用即可。

第三,size() 是方法。

数组长度写:

namesArray.length

列表数量写:

names.size()

数组用字段,集合用方法。

八、用 List 接口接住 ArrayList 对象

更常见的写法是:

import java.util.ArrayList;
import java.util.List;

List<String> names = new ArrayList<>();

左边是 List<String>,右边是 new ArrayList<>()。

这和前面学过的接口、多态有关。

List 是接口,它规定“列表”应该有什么能力,比如:

  • 添加元素。
  • 根据下标获取元素。
  • 删除元素。
  • 查询数量。
  • 遍历元素。

ArrayList 是具体实现。它真的把数据存起来。

用接口接住实现类的好处是:你的代码主要依赖“列表能力”,不死绑某个具体实现。

现阶段你可以先写:

List<String> names = new ArrayList<>();

不用急着深究所有集合实现。下一章还会见到 Set 和 Map。

九、List 的增删改查

1. 添加元素

List<String> names = new ArrayList<>();

names.add("Tom");
names.add("Lucy");
names.add("Jack");

列表会按添加顺序保存元素:

0 -> Tom
1 -> Lucy
2 -> Jack

也可以插入到指定位置:

names.add(1, "Anna");

结果:

0 -> Tom
1 -> Anna
2 -> Lucy
3 -> Jack

插入不是替换。原来下标 1 及后面的元素会往后移动。

2. 获取元素

String first = names.get(0);
System.out.println(first);

下标仍然从 0 开始。

如果下标越界:

names.get(99);

会报:

IndexOutOfBoundsException

集合越界和数组越界很像,只是异常类型通常是 IndexOutOfBoundsException。

3. 修改元素

names.set(1, "Lily");

set 是替换指定下标的元素。

注意区分:

names.add(1, "Anna"); // 插入,列表变长
names.set(1, "Anna"); // 替换,列表长度不变

4. 删除元素

按下标删除:

names.remove(0);

按对象删除:

names.remove("Lucy");

如果有多个相同元素,按对象删除通常只删除第一个匹配项。

例如:

List<String> names = new ArrayList<>();
names.add("Tom");
names.add("Lucy");
names.add("Lucy");

names.remove("Lucy");

System.out.println(names); // [Tom, Lucy]

5. 查询数量和是否为空

System.out.println(names.size());
System.out.println(names.isEmpty());

不要写:

if (names == null || names.size() == 0) {
}

如果你自己创建列表,尽量让列表永远不是 null:

List<String> names = new ArrayList<>();

没有数据时就是空列表,不要用 null 表示空列表。

if (names.isEmpty()) {
System.out.println("暂无数据");
}

这样调用者少很多空指针风险。

十、遍历 List

1. 普通 for:需要下标时使用

for (int i = 0; i < names.size(); i++) {
System.out.println(i + ": " + names.get(i));
}

适合场景:

  • 需要打印序号。
  • 需要根据下标修改元素。
  • 需要从后往前删除。

2. 增强 for:只读遍历最舒服

for (String name : names) {
System.out.println(name);
}

适合场景:

  • 只关心元素本身。
  • 不需要下标。
  • 不在遍历过程中删除元素。

3. while:需要更自由控制时使用

int index = 0;
while (index < names.size()) {
System.out.println(names.get(index));
index++;
}

while 在集合里不如 for 常见,但当循环步长不是简单 +1 时会有用。

十一、删除元素时最容易踩坑

先看一个错误例子:删除所有不及格成绩。

List<Integer> scores = new ArrayList<>();
scores.add(90);
scores.add(45);
scores.add(50);
scores.add(80);

for (int i = 0; i < scores.size(); i++) {
if (scores.get(i) < 60) {
scores.remove(i);
}
}

System.out.println(scores);

你可能以为结果是:

[90, 80]

但实际可能是:

[90, 50, 80]

为什么 50 没删掉?

过程是这样的:

原始: [90, 45, 50, 80]
i = 1,删除 45
列表变成: [90, 50, 80]
i++ 变成 2
此时下标 1 的 50 被跳过了

正向遍历删除时,后面的元素会往前移,而 i 又继续增加,所以会跳过元素。

常见解决办法是从后往前删:

for (int i = scores.size() 1; i >= 0; i) {
if (scores.get(i) < 60) {
scores.remove(i);
}
}

从后往前删,前面的下标不会受影响。

还有一种写法是手动控制 i:

for (int i = 0; i < scores.size(); ) {
if (scores.get(i) < 60) {
scores.remove(i);
} else {
i++;
}
}

删除后不递增,保留后再递增。

不要在增强 for 里直接删除:

for (Integer score : scores) {
if (score < 60) {
scores.remove(score); // 容易出错
}
}

这可能触发:

ConcurrentModificationException

这个异常后面讲 Iterator 时会细说。现阶段先记住:增强 for 适合读,不适合一边遍历一边删除。

十二、ArrayList 的底层直觉

ArrayList 名字里有 Array。它底层主要就是数组。

你可以把它理解成一个帮你管理数组的对象:

ArrayList
内部数组 elementData
实际数量 size
add 时自动放到 size 位置
容量不够时自动扩容

当你写:

names.add("Tom");

ArrayList 大概做了这些事:

检查内部数组是否还有位置
如果有,放到 size 下标
size + 1
如果没位置,创建更大的数组,把旧元素复制过去,再添加

所以 ArrayList 不是魔法,它只是把你上一节手写的那些细节封装好了。

这也解释了两个性能直觉。

第一,按下标读取很快:

names.get(100);

因为底层数组可以直接定位下标。

第二,在中间插入或删除可能比较慢:

names.add(0, "First");
names.remove(0);

因为很多元素需要移动。

对初学阶段来说,不需要过度纠结性能。你只要先形成判断:

  • 经常按顺序保存、按下标读取,用 ArrayList 很合适。
  • 经常在中间大量插入删除,要留意成本。
  • 查找某个编号是否存在,后面会用 HashMap 或 HashSet 更合适。

十三、数组和 List 的对比

对比点数组List / ArrayList
长度 创建后固定 可以动态增长
访问方式 arr[i] list.get(i)
数量 arr.length 是容量 list.size() 是实际数量
新增元素 自己维护下标 list.add(value)
删除元素 自己移动元素 list.remove(index)
基本类型 可以直接放 int[] 不能放 List<int>,要用 List<Integer>
适合场景 固定长度、底层结构 动态业务数据

有一个点要注意:泛型里不能写基本类型。

错误:

List<int> scores = new ArrayList<>();

正确:

List<Integer> scores = new ArrayList<>();

Integer 是 int 的包装类型。包装类型后面会在常用类里详细讲。现在先知道:集合里放基本类型数据时,要用对应的包装类型。

常见对应关系:

基本类型包装类型
int Integer
long Long
double Double
boolean Boolean
char Character

十四、用 List 改写成绩统计器

第一阶段做过成绩统计器。数组版大概是:

int[] scores = new int[count];

如果改成 List<Integer>,我们不需要提前知道学生数量:

import java.util.ArrayList;
import java.util.List;
import java.util.Scanner;

public class ScoreListApp {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
List<Integer> scores = new ArrayList<>();

while (true) {
System.out.print("请输入成绩,输入-1结束:");
int score = scanner.nextInt();

if (score == 1) {
break;
}
if (score < 0 || score > 100) {
System.out.println("成绩必须在0到100之间");
continue;
}

scores.add(score);
}

if (scores.isEmpty()) {
System.out.println("没有输入成绩");
return;
}

int max = scores.get(0);
int min = scores.get(0);
int sum = 0;
int passedCount = 0;

for (int score : scores) {
if (score > max) {
max = score;
}
if (score < min) {
min = score;
}
if (score >= 60) {
passedCount++;
}
sum += score;
}

double average = sum * 1.0 / scores.size();

System.out.println("人数:" + scores.size());
System.out.println("最高分:" + max);
System.out.println("最低分:" + min);
System.out.println("平均分:" + average);
System.out.println("及格人数:" + passedCount);
}
}

这段代码和数组版相比,最大的变化是:

scores.add(score);

我们不需要维护 size,也不需要担心数组满了。

但是统计逻辑没有消失。最高分、最低分、求和、及格人数依然要靠循环完成。集合解决的是“容器管理”问题,不会替你理解业务。

十五、把上一章图书馆改成 List 版

上一章 Library 的字段是:

private final Book[] books;
private final Reader[] readers;
private final BorrowRecord[] records;
private int bookCount;
private int readerCount;
private int recordCount;

用 List 后,可以变成:

private final List<Book> books;
private final List<Reader> readers;
private final List<BorrowRecord> records;

不再需要 bookCount、readerCount、recordCount。因为列表自己知道实际数量。

下面是改写后的 LibraryWithList。它复用上一章的 Book、Reader、BorrowRecord 类。

import java.util.ArrayList;
import java.util.List;

public class LibraryWithList {
private final String name;
private final List<Book> books;
private final List<Reader> readers;
private final List<BorrowRecord> records;
private int nextRecordNumber;

public LibraryWithList(String name) {
if (isBlank(name)) {
throw new IllegalArgumentException("图书馆名称不能为空");
}

this.name = name;
this.books = new ArrayList<>();
this.readers = new ArrayList<>();
this.records = new ArrayList<>();
this.nextRecordNumber = 1;
}

public void addBook(Book book) {
if (book == null) {
throw new IllegalArgumentException("图书不能为空");
}
if (findBookByIsbn(book.getIsbn()) != null) {
throw new IllegalArgumentException("图书已经存在,ISBN:" + book.getIsbn());
}

books.add(book);
}

public void registerReader(Reader reader) {
if (reader == null) {
throw new IllegalArgumentException("读者不能为空");
}
if (findReaderById(reader.getReaderId()) != null) {
throw new IllegalArgumentException("读者已经存在,编号:" + reader.getReaderId());
}

readers.add(reader);
}

public BorrowRecord borrowBook(String isbn, String readerId, String borrowDate) {
if (isBlank(isbn)) {
throw new IllegalArgumentException("ISBN不能为空");
}
if (isBlank(readerId)) {
throw new IllegalArgumentException("读者编号不能为空");
}
if (isBlank(borrowDate)) {
throw new IllegalArgumentException("借出日期不能为空");
}

Book book = findBookByIsbn(isbn);
if (book == null) {
throw new IllegalArgumentException("图书不存在,ISBN:" + isbn);
}

Reader reader = findReaderById(readerId);
if (reader == null) {
throw new IllegalArgumentException("读者不存在,编号:" + readerId);
}
if (!reader.isActive()) {
throw new IllegalStateException("读者账号已停用,不能借书:" + reader.getName());
}
if (!book.isAvailable()) {
throw new IllegalStateException("图书暂无可借副本:" + book.getTitle());
}
if (findActiveRecord(isbn, readerId) != null) {
throw new IllegalStateException("同一读者不能重复借同一本未归还图书");
}

book.borrowOneCopy();

BorrowRecord record = new BorrowRecord(generateRecordId(), book, reader, borrowDate);
records.add(record);
return record;
}

public void returnBook(String isbn, String readerId, String returnDate) {
if (isBlank(isbn)) {
throw new IllegalArgumentException("ISBN不能为空");
}
if (isBlank(readerId)) {
throw new IllegalArgumentException("读者编号不能为空");
}
if (isBlank(returnDate)) {
throw new IllegalArgumentException("归还日期不能为空");
}

BorrowRecord record = findActiveRecord(isbn, readerId);
if (record == null) {
throw new IllegalStateException("没有找到未归还的借阅记录");
}

record.markReturned(returnDate);
record.getBook().returnOneCopy();
}

public Book findBookByIsbn(String isbn) {
for (Book book : books) {
if (book.getIsbn().equals(isbn)) {
return book;
}
}
return null;
}

public Reader findReaderById(String readerId) {
for (Reader reader : readers) {
if (reader.getReaderId().equals(readerId)) {
return reader;
}
}
return null;
}

public BorrowRecord findActiveRecord(String isbn, String readerId) {
for (BorrowRecord record : records) {
boolean sameBook = record.getBook().getIsbn().equals(isbn);
boolean sameReader = record.getReader().getReaderId().equals(readerId);
if (sameBook && sameReader && !record.isReturned()) {
return record;
}
}
return null;
}

public void printStatus() {
System.out.println("========== " + name + " ==========");
printBooks();
printReaders();
printRecords();
}

private void printBooks() {
System.out.println("– 图书列表 –");
if (books.isEmpty()) {
System.out.println("暂无图书");
return;
}
for (Book book : books) {
System.out.println(book.getStatusText());
}
}

private void printReaders() {
System.out.println("– 读者列表 –");
if (readers.isEmpty()) {
System.out.println("暂无读者");
return;
}
for (Reader reader : readers) {
System.out.println(reader.getStatusText());
}
}

private void printRecords() {
System.out.println("– 借阅记录 –");
if (records.isEmpty()) {
System.out.println("暂无借阅记录");
return;
}
for (BorrowRecord record : records) {
System.out.println(record.getStatusText());
}
}

private String generateRecordId() {
String recordId = "BR" + nextRecordNumber;
nextRecordNumber++;
return recordId;
}

private boolean isBlank(String text) {
return text == null || text.trim().isEmpty();
}
}

对比数组版,你会发现业务逻辑几乎没变,变化主要集中在容器操作上。

数组版添加图书:

books[bookCount] = book;
bookCount++;

List 版:

books.add(book);

数组版判断空:

if (bookCount == 0) {
}

List 版:

if (books.isEmpty()) {
}

数组版遍历:

for (int i = 0; i < bookCount; i++) {
Book book = books[i];
}

List 版:

for (Book book : books) {
}

集合不是让业务消失,而是让你少写很多容器管理细节。

十六、为什么 findBookByIsbn 还是循环

有人会疑惑:用了 List,为什么查找图书还是要循环?

因为 List 的核心能力是按顺序保存元素。它没有自动知道“ISBN 是唯一编号”。对 List<Book> 来说,每个元素只是一个 Book 对象。

所以你要根据业务字段查找,仍然需要遍历:

for (Book book : books) {
if (book.getIsbn().equals(isbn)) {
return book;
}
}

如果图书很多,这种查找会越来越慢。因为最坏情况下要从头找到尾。

下一章会讲 Map。到时候可以用:

Map<String, Book> bookMap = new HashMap<>();

这样通过 ISBN 查找图书会更直接。

现阶段先把 List 用熟。不要一上来把 List、Set、Map 全混在一起。

十七、对象放进 List 后,修改的是同一个对象

看这段代码:

List<Book> books = new ArrayList<>();
Book book = new Book("001", "Java入门", "作者A", 2);

books.add(book);
book.borrowOneCopy();

System.out.println(books.get(0).getAvailableCopies());

输出是:

1

为什么?

因为 books.add(book) 放进去的是对象引用。列表里保存的不是复制品,而是指向同一个 Book 对象的引用。

可以想象成:

#mermaid-svg-orrvEjJqPla810HM{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-orrvEjJqPla810HM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-orrvEjJqPla810HM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-orrvEjJqPla810HM .error-icon{fill:#552222;}#mermaid-svg-orrvEjJqPla810HM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-orrvEjJqPla810HM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-orrvEjJqPla810HM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-orrvEjJqPla810HM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-orrvEjJqPla810HM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-orrvEjJqPla810HM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-orrvEjJqPla810HM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-orrvEjJqPla810HM .marker.cross{stroke:#333333;}#mermaid-svg-orrvEjJqPla810HM svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-orrvEjJqPla810HM p{margin:0;}#mermaid-svg-orrvEjJqPla810HM .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster-label text{fill:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster-label span{color:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster-label span p{background-color:transparent;}#mermaid-svg-orrvEjJqPla810HM .label text,#mermaid-svg-orrvEjJqPla810HM span{fill:#333;color:#333;}#mermaid-svg-orrvEjJqPla810HM .node rect,#mermaid-svg-orrvEjJqPla810HM .node circle,#mermaid-svg-orrvEjJqPla810HM .node ellipse,#mermaid-svg-orrvEjJqPla810HM .node polygon,#mermaid-svg-orrvEjJqPla810HM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .rough-node .label text,#mermaid-svg-orrvEjJqPla810HM .node .label text,#mermaid-svg-orrvEjJqPla810HM .image-shape .label,#mermaid-svg-orrvEjJqPla810HM .icon-shape .label{text-anchor:middle;}#mermaid-svg-orrvEjJqPla810HM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .rough-node .label,#mermaid-svg-orrvEjJqPla810HM .node .label,#mermaid-svg-orrvEjJqPla810HM .image-shape .label,#mermaid-svg-orrvEjJqPla810HM .icon-shape .label{text-align:center;}#mermaid-svg-orrvEjJqPla810HM .node.clickable{cursor:pointer;}#mermaid-svg-orrvEjJqPla810HM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-orrvEjJqPla810HM .arrowheadPath{fill:#333333;}#mermaid-svg-orrvEjJqPla810HM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-orrvEjJqPla810HM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-orrvEjJqPla810HM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-orrvEjJqPla810HM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-orrvEjJqPla810HM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-orrvEjJqPla810HM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-orrvEjJqPla810HM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-orrvEjJqPla810HM .cluster text{fill:#333;}#mermaid-svg-orrvEjJqPla810HM .cluster span{color:#333;}#mermaid-svg-orrvEjJqPla810HM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-orrvEjJqPla810HM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-orrvEjJqPla810HM rect.text{fill:none;stroke-width:0;}#mermaid-svg-orrvEjJqPla810HM .icon-shape,#mermaid-svg-orrvEjJqPla810HM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-orrvEjJqPla810HM .icon-shape p,#mermaid-svg-orrvEjJqPla810HM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-orrvEjJqPla810HM .icon-shape .label rect,#mermaid-svg-orrvEjJqPla810HM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-orrvEjJqPla810HM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-orrvEjJqPla810HM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-orrvEjJqPla810HM :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

变量 book

Book对象

books.get(0)

所以通过变量改对象,列表里看到的也是改后的对象;通过列表取出对象再改,变量看到的也是同一个对象。

例如:

books.get(0).returnOneCopy();
System.out.println(book.getAvailableCopies());

这里 book 的可借数量也会变化。

理解这一点很重要。集合不是对象仓库的复印机,它保存的是对象引用。

十八、List 里可以放重复元素

List 允许重复:

List<String> names = new ArrayList<>();
names.add("Tom");
names.add("Tom");
names.add("Tom");

System.out.println(names.size()); // 3

这对有些业务是合理的。比如考试成绩:

List<Integer> scores = new ArrayList<>();
scores.add(90);
scores.add(90);

两个学生都考 90 分,当然可以重复。

但对图书 ISBN 来说,重复可能不合理。注意,这不是 List 能自动判断的,它是业务规则。你必须在添加图书时自己检查:

if (findBookByIsbn(book.getIsbn()) != null) {
throw new IllegalArgumentException("图书已经存在");
}

集合提供通用能力,业务规则仍然要你写。

下一章会讲 Set,它天然不希望元素重复。但即使用 Set,也不代表所有业务重复问题都自动解决。对象是否重复,和 equals、hashCode 又有关,那是后面的重点。

十九、List 变量不要随便设成 null

不推荐:

private List<Book> books;

然后某些时候才初始化:

if (needBooks) {
books = new ArrayList<>();
}

这样其他方法里就要处处判断:

if (books != null && !books.isEmpty()) {
}

更好的做法是创建对象时就初始化:

private final List<Book> books = new ArrayList<>();

或者在构造方法里初始化:

public LibraryWithList(String name) {
this.books = new ArrayList<>();
}

没有图书时,books 是空列表,不是 null。

空列表表示:有一个列表,只是里面没有元素。
null 表示:连列表对象都没有。

在大多数业务代码里,空列表比 null 好处理。

二十、List 和封装

如果类内部有一个列表,不要轻易直接返回它。

错误设计:

public class LibraryWithList {
private final List<Book> books = new ArrayList<>();

public List<Book> getBooks() {
return books;
}
}

外部拿到列表后可以随便改:

library.getBooks().clear();
library.getBooks().add(null);

这样就绕过了 addBook 的校验规则。

更稳的设计是:先不给这个 getter,只提供明确业务方法:

public void addBook(Book book)
public Book findBookByIsbn(String isbn)
public void printStatus()

如果以后确实需要返回图书列表,也要谨慎。后面学到更多工具类后,可以返回不可修改视图,或者返回副本。现在先记住原则:类内部的可变集合是内部状态,不要随便暴露出去。

二十一、ArrayList 和 LinkedList 先怎么理解

你可能会看到另外一个列表实现:

LinkedList<String> names = new LinkedList<>();

它也是 List 的实现。

现阶段你先这样理解:

  • ArrayList 底层更像数组。
  • LinkedList 底层更像一串节点。
  • 大多数普通业务列表,先用 ArrayList。

不要在初学阶段过早纠结“到底哪个性能好”。很多情况下,ArrayList 就是默认选择。等你真的遇到大量中间插入删除、队列结构、性能瓶颈,再专门分析。

初学者更需要先写对业务规则,而不是提前优化容器。

二十二、一个更完整的小例子:任务清单

现在写一个任务清单,练习 List 的增删改查。

任务类:

public class TodoItem {
private final String title;
private boolean done;

public TodoItem(String title) {
if (title == null || title.trim().isEmpty()) {
throw new IllegalArgumentException("任务标题不能为空");
}
this.title = title;
}

public String getTitle() {
return title;
}

public boolean isDone() {
return done;
}

public void markDone() {
this.done = true;
}

public String getStatusText() {
String status = done ? "已完成" : "未完成";
return title + " – " + status;
}
}

任务清单:

import java.util.ArrayList;
import java.util.List;

public class TodoList {
private final List<TodoItem> items = new ArrayList<>();

public void add(String title) {
items.add(new TodoItem(title));
}

public void markDone(int index) {
if (index < 0 || index >= items.size()) {
throw new IllegalArgumentException("任务下标不存在:" + index);
}
items.get(index).markDone();
}

public void removeDoneItems() {
for (int i = items.size() 1; i >= 0; i) {
if (items.get(i).isDone()) {
items.remove(i);
}
}
}

public void printAll() {
if (items.isEmpty()) {
System.out.println("暂无任务");
return;
}

for (int i = 0; i < items.size(); i++) {
System.out.println(i + ". " + items.get(i).getStatusText());
}
}
}

运行:

public class TodoApp {
public static void main(String[] args) {
TodoList todoList = new TodoList();

todoList.add("学习数组");
todoList.add("学习ArrayList");
todoList.add("重写图书馆项目");

todoList.markDone(0);
todoList.printAll();

todoList.removeDoneItems();
todoList.printAll();
}
}

这个例子包含了几个重要动作:

  • items.add(…) 添加任务。
  • items.get(index) 获取任务。
  • items.size() 判断下标是否合法。
  • 从后往前删除已完成任务。
  • TodoList 不把 items 直接暴露出去。

它比单纯演示 List<String> 更接近真实项目,因为真实程序的集合里更多放的是对象,而不是简单字符串。

二十三、常见错误

1. 把数组 length 和 List size 混用

错误:

for (int i = 0; i < names.length; i++) {
}

如果 names 是 List<String>,没有 length。

正确:

for (int i = 0; i < names.size(); i++) {
}

2. 用 get 访问不存在的下标

错误:

List<String> names = new ArrayList<>();
names.add("Tom");

System.out.println(names.get(1));

只有一个元素时,合法下标只有 0。

3. 忘记导包

错误:

List<String> names = new ArrayList<>();

但文件开头没有:

import java.util.List;
import java.util.ArrayList;

编译器会提示找不到符号。

4. 泛型类型不匹配

错误:

List<String> names = new ArrayList<>();
names.add(100);

names 只能放字符串。

正确:

List<Integer> scores = new ArrayList<>();
scores.add(100);

5. 误以为 remove 一定按内容删除

对 List<String>:

names.remove("Tom");

是按对象删除。

对下标:

names.remove(0);

是删除第 0 个。

对 List<Integer> 有一个容易混淆的地方:

List<Integer> numbers = new ArrayList<>();
numbers.add(1);
numbers.add(2);
numbers.add(3);

numbers.remove(1);

这会删除下标 1 的元素,也就是删除 2,不是删除数字 1。

如果你要删除数字 1,可以写:

numbers.remove(Integer.valueOf(1));

这个细节后面讲包装类型时会更清楚。

6. 在增强 for 中删除

错误:

for (String name : names) {
if (name.startsWith("A")) {
names.remove(name);
}
}

先用倒序普通 for:

for (int i = names.size() 1; i >= 0; i) {
if (names.get(i).startsWith("A")) {
names.remove(i);
}
}

后面会学习 Iterator 和更现代的删除方式。

二十四、练习

练习 1:把姓名数组改成 List

写一个程序,循环输入姓名,输入 exit 结束。用 List<String> 保存所有姓名,最后输出总人数和所有姓名。

要求:

  • 空字符串不允许添加。
  • 姓名可以重复。
  • 使用增强 for 输出。

练习 2:删除所有低于 60 的成绩

创建 List<Integer>:

90, 45, 50, 80, 59, 100

删除所有低于 60 的成绩,最后输出列表。

要求:

  • 先故意用正向 for 删除,观察错误结果。
  • 再改成倒序 for。
  • 写出为什么正向删除会跳过元素。

练习 3:任务清单增加按标题查找

给 TodoList 增加方法:

public TodoItem findByTitle(String title)

要求:

  • 找到返回任务对象。
  • 找不到返回 null。
  • 标题比较使用 equals。

练习 4:图书馆增加打印可借图书

给 LibraryWithList 增加:

public void printAvailableBooks()

只输出 book.isAvailable() 为 true 的图书。

思考:如果没有任何可借图书,应该输出什么?

练习 5:限制读者最多借 3 本书

在 LibraryWithList 中实现:

private int countActiveRecordsByReader(String readerId)

然后在 borrowBook 中判断:

if (countActiveRecordsByReader(readerId) >= 3) {
throw new IllegalStateException("读者最多只能同时借3本书");
}

这个练习会强化“集合遍历 + 业务规则”的组合能力。

二十五、本章小结

这一章你从数组走到了 List。

数组让你理解了容器最基本的事情:下标、长度、默认值、遍历、查找、删除和移动元素。ArrayList 并不是突然出现的魔法,它是在数组思想上封装出来的动态列表。

你现在应该掌握:

  • 数组长度固定,适合固定规模数据。
  • 对象数组默认值是 null。
  • 用数组保存动态数据时,需要额外维护实际数量。
  • 删除数组元素时要移动后续元素。
  • List 表示有顺序、可重复、可按下标访问的一组数据。
  • ArrayList 是最常用的 List 实现。
  • add、get、set、remove、size、isEmpty 是必须熟练的基本方法。
  • 删除列表元素时,正向遍历容易跳过元素,倒序删除更稳。
  • 集合里保存对象引用,不是复制对象。
  • 不要随便把内部可变列表暴露给外部。

下一章会讲 Set 和 Map。如果说 List 适合保存“按顺序排列的一批数据”,那么 Set 更关注“不能重复”,Map 更关注“根据 key 快速找到 value”。到那一章,你会把图书馆里的 ISBN 查找改得更自然。

赞(0)
未经允许不得转载:171主机测评 » 从零开始学Java:第15章 数组和 List:从固定容器到动态数据集合
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址