Mỗi lần bạn viết for (Order o : orders), Java đang dùng Iterator pattern mà bạn không nhìn thấy. Pattern này cho phép duyệt qua các phần tử của một tập hợp mà không lộ cách tập hợp lưu trữ chúng. Từ Java 8, Iterator pattern có thêm hai lớp trừu tượng xây trên cùng ý tưởng là Spliterator cho duyệt song song và Stream cho xử lý khai báo. Bài này đi từ Iterator pattern thuần, qua Iterable và vòng lặp for-each, đến cách Spliterator và Stream mở rộng pattern đó.
Bài thuộc series Java & Design Pattern, nối tiếp State pattern và máy trạng thái cho đơn hàng và Chain of Responsibility: filter chain trong Servlet và Spring Security. Code đã chạy thật trên JDK 21 (OpenJDK 21), máy Apple M4, 16 GB RAM, và các đoạn output trong bài được dán nguyên từ lần chạy đó.
Vấn đề mà Iterator pattern giải quyết
Giả sử hệ thống bán hàng có cây danh mục sản phẩm, danh sách đơn hàng trong bộ nhớ, và danh sách đơn hàng lấy từ một API phân trang. Ba nguồn dữ liệu này lưu theo ba cách khác nhau: cây, mảng, các trang tải dần. Nếu mỗi nơi dùng dữ liệu phải tự biết cách đi qua từng cấu trúc, logic duyệt sẽ trộn lẫn vào nghiệp vụ và lặp lại ở nhiều chỗ.
Theo refactoring.guru, Iterator pattern cho phép duyệt các phần tử của một tập hợp mà không phơi bày biểu diễn bên dưới. Việc duyệt được tách khỏi nơi lưu: tập hợp chỉ cần trả về một iterator, còn mã khách chỉ làm việc qua hai interface. Mỗi iterator trong Iterator pattern giữ tiến độ riêng nên nhiều iterator có thể cùng duyệt một tập hợp độc lập.
Cấu trúc của Iterator pattern trong Java
| Vai trò (GoF) | Trong JDK | Trong ví dụ bài này |
|---|---|---|
| Iterator | java.util.Iterator | Iterator ẩn danh trong CategoryTree |
| Concrete iterator | Lớp lồng trong ArrayList, HashMap... | Duyệt DFS, duyệt BFS, PagedIterator |
| Aggregate (Collection) | java.lang.Iterable | CategoryTree implements Iterable |
| Client | Vòng lặp for-each, forEach | main |
Theo Javadoc của Iterator, interface có hai phương thức bắt buộc là hasNext() và next(), cùng hai phương thức mặc định remove() (tuỳ chọn, mặc định ném UnsupportedOperationException) và forEachRemaining. Tài liệu cũng cho biết Iterator thay thế Enumeration, với hai khác biệt: cho phép xoá phần tử khi duyệt và tên phương thức rõ hơn. Còn Iterable chỉ có một phương thức trừu tượng là iterator() (cùng hai phương thức mặc định forEach và spliterator()), và chính sự có mặt của nó cho phép đối tượng đứng bên phải dấu hai chấm trong vòng for-each.
Cài đặt Iterator pattern cho cây danh mục
Để cài Iterator pattern, ví dụ đầu tiên là cây danh mục sản phẩm. CategoryTree cài Iterable, trả về iterator duyệt theo chiều sâu (DFS). Thay vì đệ quy, iterator giữ một ngăn xếp Deque, điều này giúp nó dừng và tiếp tục được giữa các lần gọi next(), và không gây StackOverflowError với cây rất sâu. Thêm vào đó có một iterator thứ hai duyệt theo chiều rộng (BFS) trên cùng cây.
/** Collection: trả về iterator, không lộ cấu trúc bên trong. */
static class CategoryTree implements Iterable<Category> {
private final Category root;
CategoryTree(Category root) { this.root = root; }
/** Mặc định: duyệt theo chiều sâu, dùng Deque tự quản thay vì đệ quy. */
@Override public Iterator<Category> iterator() {
return new Iterator<>() {
private final Deque<Category> stack = new ArrayDeque<>(List.of(root));
public boolean hasNext() { return !stack.isEmpty(); }
public Category next() {
if (stack.isEmpty()) throw new NoSuchElementException();
Category c = stack.pop();
for (int i = c.children.size() - 1; i >= 0; i--) stack.push(c.children.get(i));
return c;
}
};
}
/** Iterator thứ hai trên cùng một cây: duyệt theo chiều rộng. */
Iterable<Category> breadthFirst() {
return () -> new Iterator<>() {
private final Deque<Category> queue = new ArrayDeque<>(List.of(root));
public boolean hasNext() { return !queue.isEmpty(); }
public Category next() {
Category c = queue.remove(); // ném NoSuchElementException khi rỗng
queue.addAll(c.children);
return c;
}
};
}
}Hàm main minh hoạ Iterator pattern: duyệt bằng for-each, duyệt bằng BFS, kiểm tra tính độc lập của hai iterator và cuối cùng chuyển sang Stream. Output khi chạy java TreeIterators.java cho Iterator pattern này:
DFS (for-each): Điện tử | Điện thoại | Android | iPhone | Laptop | Gaming | Văn phòng | Phụ kiện |
BFS : Điện tử | Điện thoại | Laptop | Phụ kiện | Android | iPhone | Gaming | Văn phòng |
a gọi next() lần 3 -> Android, b mới gọi next() lần 1 -> Điện tử
Danh mục lá : Android, iPhone, Gaming, Văn phòng, Phụ kiệnCó ba điều cần rút ra. Thứ nhất, DFS và BFS cho hai thứ tự khác nhau trên cùng cấu trúc mà mã khách không đổi, đây chính là lợi ích tách duyệt khỏi lưu trữ. Thứ hai, hai iterator a và b giữ trạng thái riêng: a đã gọi next() hai lần và lần thứ ba trả về "Android", trong khi b mới gọi lần đầu và nhận "Điện tử". Thứ ba, dòng cuối cho thấy Iterator pattern ghép với Stream chỉ bằng StreamSupport.stream(tree.spliterator(), false), vì Iterable có phương thức mặc định spliterator() bọc iterator của bạn.
Iterator pattern có một hợp đồng cần tuân thủ cho next(): khi hết phần tử phải ném NoSuchElementException, không trả về null. Ở iterator BFS, Deque.remove() đã làm việc này. Còn ở iterator DFS, ta kiểm tra tường minh.
Sửa tập hợp khi đang duyệt: fail-fast và các cách đúng
Lỗi phổ biến nhất khi dùng Iterator pattern là sửa tập hợp trong lúc duyệt. Theo Javadoc của ConcurrentModificationException, iterator của các tập hợp trong JDK là fail-fast: phát hiện sửa đổi không được phép và ném ngoại lệ thay vì tiếp tục với kết quả không xác định. Tài liệu nhấn mạnh hành vi này chỉ là best-effort, không được bảo đảm, và chỉ nên dùng để phát hiện bug, đừng dựa vào nó để chương trình đúng. Đoạn code dưới thử năm tình huống:
public class FailFast {
public static void main(String[] args) {
// 1. Sai: xoá trực tiếp trong for-each
List<String> a = new ArrayList<>(List.of("A", "B", "C", "D"));
try { for (String s : a) if (s.equals("B")) a.remove(s); }
catch (ConcurrentModificationException e) { System.out.println("1. for-each + remove -> " + e.getClass().getSimpleName()); }
// 2. Mánh khoé nguy hiểm: xoá phần tử kế cuối thì KHÔNG báo lỗi (fail-fast chỉ là best-effort)
List<String> b = new ArrayList<>(List.of("A", "B", "C", "D"));
for (String s : b) if (s.equals("C")) b.remove(s);
System.out.println("2. xoá phần tử kế cuối -> không lỗi, nhưng D bị bỏ qua. Còn lại: " + b);
// 3. Đúng: xoá qua iterator
List<String> c = new ArrayList<>(List.of("A", "B", "C", "D"));
for (Iterator<String> it = c.iterator(); it.hasNext();) if (it.next().equals("B")) it.remove();
System.out.println("3. iterator.remove -> " + c);
// 4. Gọn hơn: removeIf
List<String> d = new ArrayList<>(List.of("A", "B", "C", "D"));
d.removeIf(s -> s.equals("B") || s.equals("C"));
System.out.println("4. removeIf -> " + d);
// 5. CopyOnWriteArrayList: iterator là ảnh chụp, không ném lỗi nhưng không thấy thay đổi
List<String> e = new CopyOnWriteArrayList<>(List.of("A", "B", "C", "D"));
StringBuilder seen = new StringBuilder();
for (String s : e) { seen.append(s); if (s.equals("A")) e.add("E"); }
System.out.println("5. CopyOnWrite -> đã duyệt " + seen + ", danh sách cuối " + e);
}
}Output thật:
1. for-each + remove -> ConcurrentModificationException
2. xoá phần tử kế cuối -> không lỗi, nhưng D bị bỏ qua. Còn lại: [A, B, D]
3. iterator.remove -> [A, C, D]
4. removeIf -> [A, D]
5. CopyOnWrite -> đã duyệt ABCD, danh sách cuối [A, B, C, D, E]Tình huống 2 là cái bẫy đáng nhớ nhất của Iterator pattern trong Java. Xoá "C" (phần tử kế cuối) trong vòng for-each không ném lỗi, vì sau khi xoá, hasNext() so sánh con trỏ với kích thước mới và trả về false, nên vòng lặp kết thúc trước khi next() kịp kiểm tra số lần sửa. Hệ quả là phần tử "D" bị bỏ qua âm thầm. Đây đúng là điều tài liệu cảnh báo: không có ngoại lệ không có nghĩa là code đúng. Cách sửa chuẩn là Iterator.remove() (tình huống 3) hoặc removeIf (tình huống 4). Tình huống 5 dùng CopyOnWriteArrayList: iterator duyệt trên ảnh chụp tại thời điểm tạo, nên không ném lỗi nhưng cũng không thấy "E" vừa thêm, và mỗi lần ghi phải sao chép cả mảng.
Iterator lười: duyệt dữ liệu phân trang
Sức mạnh của Iterator pattern lộ rõ khi dữ liệu không có sẵn trong bộ nhớ. PagedIterator dưới đây giả lập một API phân trang trả 25 đơn, mỗi trang 10 đơn, và chỉ gọi trang kế tiếp khi hasNext() cần. Nhờ Iterator pattern, mã khách duyệt như một danh sách bình thường, không biết có phân trang.
static class PagedIterator implements Iterator<String> {
private int page = 0;
private Iterator<String> current = List.<String>of().iterator();
private boolean exhausted = false;
public boolean hasNext() {
while (!current.hasNext() && !exhausted) {
List<String> batch = fetchPage(page++);
if (batch.isEmpty()) exhausted = true; else current = batch.iterator();
}
return current.hasNext();
}
public String next() {
if (!hasNext()) throw new NoSuchElementException();
return current.next();
}
}
static Stream<String> orders() {
return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(new PagedIterator(), Spliterator.ORDERED | Spliterator.NONNULL), false);
}Bọc iterator này thành Stream rồi thử ba kịch bản:
1. Lấy 7 đơn đầu bằng limit(7):
[API] GET /orders?page=0
-> [DH-1000, DH-1001, DH-1002, DH-1003, DH-1004, DH-1005, DH-1006], số lần gọi API = 1
2. Duyệt toàn bộ và đếm:
[API] GET /orders?page=0
[API] GET /orders?page=1
[API] GET /orders?page=2
[API] GET /orders?page=3
-> 25 đơn, số lần gọi API = 4 (trang 3 trả về rỗng để biết đã hết)
3. Chưa gọi thao tác kết thúc thì không có lời gọi API nào:
-> số lần gọi API = 0Kết quả minh hoạ đúng đặc tính lười của Stream xây trên Iterator pattern. Theo tài liệu gói java.util.stream, các thao tác trung gian như filter và map luôn lười, việc duyệt nguồn chỉ bắt đầu khi có thao tác kết thúc. Kịch bản 3 chứng minh điều đó: không có thao tác kết thúc nên không có lời gọi API nào. Kịch bản 1 cho thấy limit(7) chỉ cần một trang, tiết kiệm ba lần gọi so với kịch bản 2. Lưu ý kịch bản 2 gọi bốn lần vì phải tải thêm trang thứ tư (rỗng) mới biết đã hết, một chi tiết dễ bỏ qua khi tính hạn mức API.
Spliterator: Iterator pattern cho xử lý song song
Iterator pattern cổ điển chỉ đi tuần tự. Javadoc của Spliterator mô tả nó là đối tượng "để duyệt và chia nhỏ các phần tử của một nguồn". Tài liệu nêu rõ API này được thiết kế để hỗ trợ duyệt song song hiệu quả nhờ phân rã, có chi phí trên mỗi phần tử thấp hơn Iterator, và tránh điều kiện tranh chấp vốn có khi tách hasNext() và next() thành hai phương thức. Bốn phương thức then chốt:
tryAdvance(action): gộp "còn không" và "lấy phần tử" thành một lời gọi, trả vềfalsekhi hết.trySplit(): tách một phần phần tử cho luồng khác; có thể trả vềnullnếu không chia được. Với nguồnORDERED, phần trả về phải là tiền tố của dãy.estimateSize(): ước lượng số phần tử còn lại, trả vềLong.MAX_VALUEnếu không biết.characteristics(): tập cờ nhưORDERED,SIZED,SUBSIZED,IMMUTABLE,DISTINCT,SORTEDgiúp thư viện Stream chọn chiến lược.
Để hiểu cơ chế, ta tự viết một Spliterator trên đoạn số nguyên. trySplit chia đôi: trả về nửa đầu và giữ lại nửa sau, đúng hợp đồng tiền tố cho nguồn có thứ tự.
private final int to; // không bao gồm
static final ConcurrentHashMap<String, Integer> THREAD_ITEMS = new ConcurrentHashMap<>();
RangeSpliterator(int from, int to) { this.from = from; this.to = to; }
@Override public boolean tryAdvance(Consumer<? super Integer> action) {
if (from >= to) return false;
action.accept(from++);
return true;
}
/** Chia đôi: trả về nửa đầu (prefix) cho luồng khác, giữ nửa sau. */
@Override public Spliterator<Integer> trySplit() {
int size = to - from;
if (size < 4) return null;
int mid = from + size / 2;
RangeSpliterator prefix = new RangeSpliterator(from, mid);
from = mid;
return prefix;
}
@Override public long estimateSize() { return to - from; }
@Override public int characteristics() { return ORDERED | SIZED | SUBSIZED | IMMUTABLE | NONNULL | DISTINCT; }Phần main thử chia tay, so sánh tuần tự với song song, đếm số luồng tham gia, và đối chiếu với Spliterator dựng từ Iterator. Dưới đây là phần rút gọn của main (khối 1 và khối 4); toàn bộ nằm trong RangeSpliterator.java, chạy bằng java RangeSpliterator.java. Output:
public static void main(String[] args) {
// 1. Quan sát việc chia
RangeSpliterator s = new RangeSpliterator(0, 16);
System.out.println("Gốc: size=" + s.estimateSize() + ", exact=" + s.getExactSizeIfKnown());
Spliterator<Integer> a = s.trySplit();
Spliterator<Integer> b = s.trySplit();
System.out.println("Sau 2 lần split: a=" + a.estimateSize() + ", b=" + b.estimateSize() + ", còn lại=" + s.estimateSize());
System.out.println("hasCharacteristics(SIZED)=" + s.hasCharacteristics(Spliterator.SIZED)
+ ", SORTED=" + s.hasCharacteristics(Spliterator.SORTED));
// ... khối 2 và 3: so sánh tuần tự/song song, đếm luồng (xem file đầy đủ)
// 4. So sánh: spliterator dựng từ Iterator (không biết kích thước) chia thế nào
java.util.List<Integer> src = new java.util.ArrayList<>();
for (int i = 0; i < 3000; i++) src.add(i);
Spliterator<Integer> unk = java.util.Spliterators.spliteratorUnknownSize(src.iterator(), ORDERED);
System.out.println("Từ Iterator: estimateSize=" + unk.estimateSize() + " (Long.MAX_VALUE = không biết), exact=" + unk.getExactSizeIfKnown());
Spliterator<Integer> half = unk.trySplit();
int[] cnt = {0};
half.forEachRemaining(x -> cnt[0]++);
System.out.println("trySplit lần 1 trả về một lô cố định " + cnt[0] + " phần tử trong tổng 3000, không phải một nửa");
// ... khối 5: in đặc tính của ArrayList, HashSet, TreeSet
}Gốc: size=16, exact=16
Sau 2 lần split: a=8, b=4, còn lại=4
hasCharacteristics(SIZED)=true, SORTED=false
Tổng 0..999999: tuần tự=499999500000, song song=499999500000
Số luồng tham gia > 1 : true, tổng phần tử xử lý=1000000
Từ Iterator: estimateSize=9223372036854775807 (Long.MAX_VALUE = không biết), exact=-1
trySplit lần 1 trả về một lô cố định 1024 phần tử trong tổng 3000, không phải một nửa
ArrayList: ORDERED SIZED SUBSIZED
HashSet : DISTINCT SIZED
TreeSet : ORDERED DISTINCT SORTED SIZEDMỗi dòng cho một bài học về Iterator pattern phiên bản song song. Riêng dòng "Số luồng tham gia" phụ thuộc số lõi CPU của máy chạy: máy một lõi có thể in false. Dòng đầu cho thấy hai lần split chia 16 phần tử thành 8, 4 và 4 còn lại. Tổng tuần tự và song song bằng nhau (499999500000), và nhiều luồng thực sự cùng làm việc, nhờ ba cờ ORDERED, SIZED và SUBSIZED cho phép Stream chia đều.
Dòng "Từ Iterator" là điểm mấu chốt: Spliterator dựng từ Iterator bằng spliteratorUnknownSize không biết kích thước (Long.MAX_VALUE, exact=-1) và trySplit chỉ cắt ra một lô cố định (ở lần chạy này là 1024 phần tử trên 3000, đây là chi tiết cài đặt của JDK, đừng dựa vào con số cụ thể). Tài liệu gói stream cũng nói rõ điều này: Spliterator tạo từ iterator vẫn dùng được nhưng mất thông tin kích thước và chia kém, còn Spliterator chất lượng cao cho các lần chia cân bằng với kích thước biết trước.
Bảng đặc tính ở các tập hợp chuẩn cũng khớp với kỳ vọng: ArrayList là ORDERED SIZED SUBSIZED, HashSet là DISTINCT SIZED (không có thứ tự), TreeSet thêm SORTED. Đây là lý do list.parallelStream() chia rất tốt còn stream dựng từ Iterator tuỳ ý thì khó tăng tốc.
Khi nào dùng Iterator pattern, Spliterator hay Stream
| Nhu cầu | Công cụ nên dùng |
|---|---|
| Cho lớp của bạn dùng được trong for-each | Cài Iterable với iterator() |
| Xoá phần tử khi duyệt | Iterator.remove() hoặc removeIf |
| Biến đổi, lọc, gom dữ liệu | Stream (nguồn từ stream() hay StreamSupport) |
| Nguồn dữ liệu tải dần (API phân trang, file lớn) | Iterator lười bọc thành Stream |
| Cần song song hoá nguồn tự viết | Spliterator với SIZED và trySplit cân bằng |
Với phần lớn ứng dụng nghiệp vụ, Iterator pattern có sẵn trong JDK là đủ và bạn không cần tự viết Spliterator. Chỉ cài khi nguồn dữ liệu tự viết đủ lớn và việc xử lý mỗi phần tử đủ nặng để song song hoá có ích. Với tác vụ nhỏ, chi phí chia việc và gộp kết quả lớn hơn lợi ích. Hãy đo bằng JMH trước khi kết luận trong code thật.
Khi nào không nên tự viết Iterator pattern
Theo refactoring.guru, Iterator có thể là quá mức cần thiết nếu ứng dụng chỉ dùng các tập hợp đơn giản, và có thể kém hiệu quả hơn truy cập trực tiếp vào tập hợp chuyên biệt. Với ArrayList, vòng for-each đã dùng iterator của JDK, nên tự viết thêm lớp bọc cho Iterator pattern không đem lại gì. Hãy tự cài Iterator pattern khi cấu trúc có cách duyệt không hiển nhiên (cây, đồ thị, dữ liệu tải dần), khi cần nhiều thứ tự duyệt, hoặc khi muốn che cấu trúc nội bộ khỏi mã khách.
Với cây đối tượng đồng nhất, hãy kết hợp với Composite pattern: xử lý cây đối tượng đồng nhất: Composite lo cấu trúc, Iterator lo cách đi qua nó. Refactoring.guru cũng ghi nhận cặp đôi này, cũng như việc kết hợp Iterator với Visitor để chạy một thao tác trên các phần tử thuộc nhiều lớp khác nhau.
Lỗi thường gặp với Iterator pattern
Các lỗi sau gặp thường xuyên nhất khi dùng hoặc tự cài Iterator pattern.
- Sửa tập hợp trong for-each khi dùng Iterator pattern: gây
ConcurrentModificationExceptionhoặc, tệ hơn, bỏ sót phần tử như tình huống 2 ở trên. next()không kiểm tra hết: trả vềnullthay vì némNoSuchElementExceptionlàm hỏng hợp đồng.- Tái sử dụng Stream: theo tài liệu, Stream chỉ duyệt được một lần, gọi thao tác kết thúc lần hai ném
IllegalStateException. Tạo Stream mới từ nguồn. hasNext()có tác dụng phụ không lặp lại được: trongPagedIterator,hasNext()có thể gọi API. Bảo đảm gọi nhiều lần liên tiếp vẫn không tải lặp trang, như vòngwhileở code trên.- Chia sẻ iterator giữa các luồng: iterator và Spliterator không an toàn luồng. Tài liệu Spliterator nói hành vi không xác định nếu hai luồng cùng thao tác trên một spliterator.
- Quên đóng tài nguyên: iterator đọc file hoặc kết nối cần đóng; với Stream từ
Files.lineshãy dùng try-with-resources.
Checklist cho Iterator pattern
- Lớp tập hợp theo Iterator pattern cài
Iterableđể dùng được trong for-each và để cóspliterator()miễn phí. - Mỗi lần gọi
iterator()trả về một iterator mới, độc lập. next()némNoSuchElementExceptionkhi hết; chỉ càiremove()nếu hỗ trợ thật.- Xoá khi duyệt bằng
Iterator.remove()hoặcremoveIf, không bao giờ qua tập hợp gốc. - Với nguồn tải dần, trì hoãn việc tải tới khi cần và nêu rõ chi phí mỗi lần
hasNext(). - Muốn song song, khai báo
SIZEDvà càitrySplitchia cân bằng; đo trước khi chọn song song.
Chạy lại toàn bộ ví dụ bằng runall.sh trong thư mục code của bài (JDK 21, chạy trực tiếp từng file bằng java File.java).
Bài tiếp theo
Iterator pattern cho ta đi qua một cấu trúc dữ liệu từng phần tử mà không biết bên trong nó ra sao. Sau Iterator pattern, bài kế tiếp xét hai pattern về phối hợp thành phần và lưu lại trạng thái để khôi phục: Mediator pattern và Memento: điều phối thành phần và lưu trạng thái.



