CodeGym /Các khóa học /JAVA 25 SELF /Phương thức forEach, peek: tác dụng phụ

Phương thức forEach, peek: tác dụng phụ

JAVA 25 SELF
Mức độ , Bài học
Có sẵn

1. Phương thức forEach: hành động cuối cùng trên các phần tử

Trước khi đi sâu vào chi tiết, hãy nhắc lại sự khác nhau giữa thao tác kết thúc và thao tác trung gian của luồng.

  • Thao tác trung gian (ví dụ, filter, map, distinct, peek) — trả về một luồng mới và thường không thực thi gì cho đến khi có thao tác kết thúc.
  • Thao tác kết thúc (ví dụ, forEach, collect, count, anyMatch) — kích hoạt xử lý các phần tử của luồng và trả về kết quả (hoặc không trả về gì, như forEach).

Sau thao tác kết thúc, luồng được xem là đã đóng, và bạn không thể áp dụng các thao tác khác lên nó nữa. Giống như cố ăn nốt cây kem đã ăn hết — không thể: luồng đã “được sử dụng”.

Bây giờ cùng làm quen với hai phương thức quan trọng cho tác dụng phụ: forEachpeek.

forEach làm gì?

forEach là một thao tác kết thúc của luồng, thực hiện hành động được chỉ định cho mỗi phần tử của luồng. Thường được dùng để in ra màn hình, ghi log, thống kê và các tác dụng phụ (side effects) khác.

Chữ ký phương thức:

void forEach(Consumer<? super T> action)

Consumer<T> là functional interface nhận một đối số và không trả về gì (ví dụ, System.out::println).

Ví dụ: In tất cả phần tử của danh sách

Giả sử chúng ta có danh sách tên người dùng:

List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");

In tất cả người dùng ra màn hình bằng Stream API rất đơn giản:

users.stream().forEach(System.out::println);

Kết quả:

Anna
Boris
Alex
Alina
Dmitry

Cũng có thể dùng biểu thức lambda:

users.stream().forEach(name -> System.out.println("Người dùng: " + name));

Kết quả:

Người dùng: Anna
Người dùng: Boris
Người dùng: Alex
Người dùng: Alina
Người dùng: Dmitry

Quan trọng: forEach kết thúc luồng

Sau khi gọi forEach, luồng “đóng”. Không thể tiếp tục chuỗi:

users.stream()
     .filter(name -> name.startsWith("A"))
     .forEach(System.out::println)
     .map(String::toUpperCase); // Lỗi! Luồng đã đóng.

Thử gọi thứ gì đó sau forEach sẽ dẫn đến lỗi biên dịch: thao tác kết thúc trả về void, chứ không phải một luồng mới.

2. Phương thức peek: nhìn lén nhưng không can thiệp

peek là một thao tác trung gian. Nó cho phép thực hiện hành động cho mỗi phần tử ở một giai đoạn nhất định của quá trình xử lý, mà không thay đổi phần tử và không kết thúc luồng.

Chữ ký phương thức:

Stream<T> peek(Consumer<? super T> action)
  • peek trả về luồng mới, trong đó với mỗi phần tử sẽ thực hiện hành động action.
  • Thường dùng để gỡ lỗi, ghi log hoặc theo dõi trạng thái của luồng.

Ví dụ: Ghi log sau khi lọc

List<String> users = List.of("Anna", "Boris", "Alex", "Alina", "Dmitry");

List<Integer> nameLengths = users.stream()
    .filter(name -> name.startsWith("A"))
    .peek(name -> System.out.println("Đã qua bộ lọc: " + name))
    .map(String::length)
    .collect(Collectors.toList());

Kết quả trên console:

Đã qua bộ lọc: Anna
Đã qua bộ lọc: Alex
Đã qua bộ lọc: Alina

Nội dung của nameLengths:

[4, 4, 5]

Nên dùng peek ở đâu?

  • Để gỡ lỗi chuỗi thao tác: xem điều gì xảy ra ở mỗi bước.
  • Để thu thập thống kê (ví dụ, đếm số phần tử).
  • Để ghi log dữ liệu ở các bước trung gian.

Quan trọng: peek không nên dùng để thay đổi phần tử của luồng. Để biến đổi, hãy dùng map. peek là “nhìn lén”, chứ không phải “can thiệp”.

3. forEach vs peek: khác nhau ở đâu?

Phương thức Loại thao tác Khi áp dụng Có thể tiếp tục chuỗi không? Phù hợp cho việc gì
forEach
Kết thúc (terminal) Ở cuối cùng quá trình xử lý luồng Không Hành động cuối (in ra, ghi log, ghi vào CSDL)
peek
Trung gian (intermediate) Ở giữa chuỗi thao tác Gỡ lỗi, log trung gian, thống kê

Ví dụ: khác nhau trong cách dùng

// Ví dụ với forEach
users.stream()
    .filter(name -> name.startsWith("A"))
    .map(String::toUpperCase)
    .forEach(System.out::println); // Tại đây luồng kết thúc

// Ví dụ với peek
users.stream()
    .filter(name -> name.startsWith("A"))
    .peek(name -> System.out.println("Đã qua bộ lọc: " + name))
    .map(String::toUpperCase)
    .collect(Collectors.toList()); // Có thể tiếp tục chuỗi

Cần ghi nhớ

  • forEach là điểm không thể quay lại: sau nó bạn không thể làm gì thêm với luồng.
  • peek không đảm bảo sẽ thực thi nếu không có thao tác kết thúc. Nếu chỉ viết chuỗi toàn thao tác trung gian — sẽ không có gì xảy ra.

4. Những điểm không hiển nhiên: forEach không phải lúc nào cũng là lựa chọn tốt nhất!

Vì sao không nên dùng forEach để thay đổi collection?

Nhiều người mới học cố gắng dùng forEach để thay đổi phần tử của collection hoặc chính collection (ví dụ, xóa phần tử). Nhưng đó là thực hành tệ: Stream không được thiết kế để sửa đổi collection gốc.

Ví dụ dùng sai:

List<String> names = new ArrayList<>(List.of("Anna", "Boris", "Alex"));
names.stream().forEach(name -> {
    if (name.startsWith("A")) {
        names.remove(name); // Có thể dẫn tới ConcurrentModificationException!
    }
});

Kết quả: lỗi lúc chạy — không được phép thay đổi collection trong khi duyệt luồng (ConcurrentModificationException).

Vậy forEach nên dùng cho việc gì?

  • Để in ra màn hình (ví dụ, in báo cáo).
  • Để ghi log.
  • Để gọi dịch vụ bên ngoài (ví dụ, gửi e-mail).
  • Để thu thập thống kê (ví dụ, tăng bộ đếm).

5. Nhắc lại về peek: chỉ để gỡ lỗi!

Rất dễ muốn dùng peek để thay đổi phần tử, ví dụ tăng tuổi người dùng:

users.stream()
    .peek(user -> user.setAge(user.getAge() + 1)) // Không nên!
    .collect(Collectors.toList());

Vì sao không nên?

  • Nó phá vỡ tính khai báo và “sạch” của Stream API.
  • Mã trở nên khó bảo trì và kiểm thử.
  • Các tác dụng phụ trong thao tác trung gian có thể dẫn đến lỗi khó lường.

Tốt hơn hãy dùng map để biến đổi dữ liệu:

List<User> olderUsers = users.stream()
    .map(user -> new User(user.getName(), user.getAge() + 1))
    .collect(Collectors.toList());

Sơ đồ: khác biệt giữa forEach và peek

users.stream()
    .filter(...)         // thao tác trung gian
    .peek(...)           // thao tác trung gian, "nhìn lén"
    .map(...)            // thao tác trung gian
    .forEach(...)        // thao tác kết thúc, "thực hiện hành động"

Giải thích:
— Mọi thứ trước forEach có thể kết hợp, hoán đổi, thêm bớt.
— Sau forEach luồng đã đóng.

6. Các lỗi điển hình khi làm việc với forEach và peek

Lỗi số 1: Dùng peek để thay đổi dữ liệu. peek chỉ để quan sát, không phải để thay đổi phần tử của luồng. Để biến đổi hãy dùng map.

Lỗi số 2: Kỳ vọng peek luôn chạy. peek chỉ chạy nếu sau nó có thao tác kết thúc (collect, forEach, count, v.v.). Không có thao tác kết thúc sẽ không có gì xảy ra.

Lỗi số 3: Cố tiếp tục luồng sau forEach. forEach là thao tác kết thúc. Sau nó không thể gọi các phương thức khác của luồng.

Lỗi số 4: Sửa đổi collection bên trong forEach. Thay đổi collection gốc (xóa hoặc thêm phần tử) trong khi duyệt bằng forEach — là con đường thẳng tới ConcurrentModificationException.

Lỗi số 5: Dùng forEach thay cho collect để thu thập kết quả. Nếu muốn thu thập phần tử vào một collection mới, hãy dùng collect(Collectors.toList()), thay vì forEach với việc thêm thủ công. Làm vậy phá vỡ tính khai báo và có thể dẫn đến lỗi trong bối cảnh đa luồng.

Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION