CodeGym /Các khóa học /JAVA 25 SELF /Phong cách hàm với Stream API

Phong cách hàm với Stream API

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

1. Phong cách mệnh lệnh vs phong cách hàm

Hãy bắt đầu với một câu hỏi đơn giản: tại sao chúng ta lại cần cái gọi là “phong cách hàm”? Nó tốt hơn cách tiếp cận quen thuộc dựa trên vòng lặp ở điểm nào? Và “phong cách hàm” trong Java thực sự có nghĩa là gì?

Phong cách mệnh lệnh

Phong cách mệnh lệnh — là khi bạn nói cho máy tính cách làm gì đó, từng bước một. Ví dụ, nếu cần lấy từ danh sách chuỗi ra danh sách độ dài của các chuỗi đó, giữ lại chỉ các độ dài lẻ và sắp xếp chúng theo thứ tự giảm dần, bạn sẽ viết đại khái như sau:

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> lengths = new ArrayList<>();

for (String word : words) {
    int len = word.length();
    if (len % 2 != 0) {
        lengths.add(len);
    }
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]

Ở đây chúng ta tạo rõ ràng một danh sách trung gian, thêm phần tử thủ công, sắp xếp — tất cả theo từng bước.

Phong cách hàm

Phong cách hàm — là khi bạn mô tả cái gì bạn muốn nhận được, chứ không phải cách thực hiện. Trong Java điều này được hiện thực thông qua Stream API:

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");

List<Integer> result = words.stream()
    .map(String::length)
    .filter(len -> len % 2 != 0)
    .sorted(Comparator.reverseOrder())
    .toList();

System.out.println(result); // [7, 5, 3]

Ở đây như thể chúng ta xây dựng một “dây chuyền” xử lý dữ liệu: trước hết biến đổi các từ thành độ dài của chúng (map), rồi lọc các giá trị lẻ (filter), sau đó sắp xếp (sorted). Tất cả — trong một chuỗi, không có collection trung gian và vòng lặp tường minh.

So sánh: lặp vs phong cách hàm

Trong cách tiếp cận mệnh lệnh quen thuộc, chúng ta viết vòng lặp và bên trong giải thích cho máy tính từng bước phải làm gì: duyệt từng phần tử, kiểm tra điều kiện, thêm vào danh sách mới hoặc in ra màn hình. Mã chạy được, nhưng chiếm nhiều dòng; bài toán càng phức tạp, việc đọc và bảo trì càng khó.

Phong cách hàm cho phép mô tả không phải quy trình, mà là kết quả mong muốn. Thay vì một vòng lặp dài, ta xây dựng chuỗi thao tác: lọc, biến đổi, gom kết quả. Ngắn gọn, rõ ràng hơn và giảm rủi ro lỗi vì ít “thao tác thủ công” với collection có thể thay đổi.

Cũng có mặt trái. Với người mới, chuỗi thao tác có thể rối: vài lambda liên tiếp đôi khi khó đọc hơn một vòng lặp đơn giản. Vì vậy phong cách hàm thắng về độ ngắn gọn và biểu đạt, nhưng đòi hỏi thói quen và kinh nghiệm.

2. Các thao tác chính của Stream API

Stream API không chỉ là “một kiểu vòng lặp mới”, mà là cả một bộ công cụ để xử lý collection theo phong cách hàm. Hãy xem các thao tác cơ bản.

Lấy Stream như thế nào?

List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();

Các phép toán trung gian

  • map — chuyển đổi phần tử của stream
  • filter — lọc phần tử theo điều kiện
  • flatMap — biến mỗi phần tử thành một stream và “trải phẳng” chúng
  • sorted — sắp xếp
  • distinct — loại bỏ trùng lặp
  • limit / skip — giới hạn/bỏ qua phần tử

Các phép toán kết thúc

  • forEach — thực hiện hành động cho mỗi phần tử
  • collect — thu thập kết quả vào collection
  • reduce — rút gọn stream về một giá trị (ví dụ, tổng)
  • count — đếm số phần tử
  • anyMatch, allMatch, noneMatch — kiểm tra điều kiện

Ví dụ: chuỗi xử lý

List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");

List<String> filtered = names.stream()
    .filter(name -> name.length() > 3)
    .map(String::toUpperCase)
    .sorted()
    .toList();

System.out.println(filtered); // [BORIS, VIKA, DASHA]

Sơ đồ trực quan của “dây chuyền”:

[Anna, Boris, Vika, Gleb, Dasha]
   | filter (length>3)
[Boris, Vika, Dasha]
   | map (toUpperCase)
[BORIS, VIKA, DASHA]
   | sorted
[BORIS, VIKA, DASHA]
   | toList

Mỗi thao tác không thay đổi collection gốc — một stream mới được tạo.

3. Thực thi theo luồng và tính lười (lazy evaluation)

Phép toán trung gian và phép toán kết thúc

Với Stream có hai loại thao tác. Thứ nhất — trung gian, như map, filter hoặc sorted. Chúng trả về stream mới và như thể “hứa” sẽ làm gì đó, nhưng thực tế chưa chạy. Thứ hai — kết thúc, ví dụ forEach, collect hoặc reduce. Chúng mới thực sự kích hoạt toàn bộ xử lý. Điểm mấu chốt là khi bạn chưa gọi thao tác kết thúc, stream vẫn “lười” — tính toán chưa bắt đầu.

Ví dụ:

Stream<String> stream = List.of("a", "bb", "ccc").stream()
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    });

System.out.println("Trước forEach");
stream.forEach(System.out::println);

Kết quả:

Trước forEach
map: a
A
map: bb
BB
map: ccc
CCC

Có thể thấy map không chạy cho đến khi forEach bắt đầu.

Vì sao điều này hay?

Cách tiếp cận này cho phép xây dựng các chuỗi biến đổi dài tùy ý mà không tốn kém thừa. Stream chỉ bắt đầu làm việc khi thực sự cần. Nhờ vậy bạn có thể xử lý khối dữ liệu khổng lồ hoặc thậm chí các dãy vô hạn mà không gặp vấn đề. Tính lười cũng giúp tiết kiệm bộ nhớ và thực thi hiệu quả hơn.

4. Thực hành: bài toán “Chuỗi → độ dài → số lẻ → giảm dần”

Hãy giải từng bước: “Từ danh sách chuỗi lấy danh sách độ dài, chỉ giữ độ dài lẻ, sắp xếp giảm dần.”

Giải pháp mệnh lệnh

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");
List<Integer> lengths = new ArrayList<>();

for (String word : words) {
    int len = word.length();
    if (len % 2 != 0) {
        lengths.add(len);
    }
}
lengths.sort(Comparator.reverseOrder());
System.out.println(lengths); // [7, 5, 3]

Giải pháp hàm với Stream API

List<String> words = Arrays.asList("kot", "slon", "nosorog", "tigr", "mysh’");

List<Integer> result = words.stream()
    .map(String::length) // chuyển chuỗi thành độ dài của chúng
    .filter(len -> len % 2 != 0) // chỉ giữ độ dài lẻ
    .sorted(Comparator.reverseOrder()) // sắp xếp giảm dần
    .toList(); // gom vào List (Java 16+)

System.out.println(result); // [7, 5, 3]

Giải thích:

  • map(String::length) — với mỗi chuỗi, lấy độ dài của nó.
  • filter(len -> len % 2 != 0) — chỉ giữ độ dài lẻ.
  • sorted(Comparator.reverseOrder()) — sắp xếp giảm dần.
  • toList() — gom stream thành danh sách mới.
Ẩn dụ

Giống như bạn dựng một “băng chuyền” trong nhà máy: ở mỗi bước, các chi tiết được xử lý theo cách mới, và chỉ ở cuối cùng mọi thứ mới được xếp vào hộp.

5. Ví dụ thêm: map, filter, forEach, collect

Ví dụ 1: Lọc và in

List<String> names = List.of("Anna", "Boris", "Vika", "Gleb", "Dasha");

names.stream()
    .filter(name -> name.contains("a"))
    .forEach(System.out::println);
// Sẽ in: Anna, Dasha

Ví dụ 2: Biến đổi và gom vào Set

Set<String> upperNames = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toSet());

System.out.println(upperNames); // [ANNA, BORIS, VIKA, GLEB, DASHA]

Ví dụ 3: Tính tổng độ dài của tất cả chuỗi

int totalLength = names.stream()
    .mapToInt(String::length)
    .sum();

System.out.println("Tổng độ dài: " + totalLength);

Ví dụ 4: Dùng Predicate và Function

Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Xin chào, " + name + "!";

names.stream()
    .filter(longName)
    .map(greet)
    .forEach(System.out::println);
// Xin chào, Boris!
// Xin chào, Dasha!

6. Đặc điểm của phong cách hàm với Stream API

Không có trạng thái có thể thay đổi

Phong cách Stream API khuyến khích các hàm “thuần” — không có tác dụng phụ. Điều đó có nghĩa là tốt nhất đừng thay đổi biến bên ngoài bên trong lambda.

Không nên:

List<String> result = new ArrayList<>();
names.stream()
    .filter(name -> name.startsWith("A"))
    .forEach(result::add); // side-effect!

Tốt hơn:

List<String> result = names.stream()
    .filter(name -> name.startsWith("A"))
    .toList();

Tổ hợp các thao tác

Bạn có thể xây dựng các chuỗi rất dài, kết hợp map, filter, sorted và các phương thức khác. Điều quan trọng là đừng quá đà: nếu chuỗi dài quá một màn hình, có lẽ nên tách thành các phần.

Tính lười (lazy evaluation)

Stream API không làm gì cho tới khi gặp thao tác kết thúc. Điều này cho phép tiết kiệm tài nguyên và xây dựng các pipeline xử lý hiệu quả.

Không làm thay đổi collection gốc

Stream không thay đổi collection ban đầu! Mọi biến đổi đều trả về stream/collection mới.

7. Khi nào dùng Stream API

Stream API rất phù hợp khi:

  • Cần xử lý nhanh một collection (lọc, biến đổi, sắp xếp).
  • Cần mã ngắn gọn, dễ đọc.
  • Không muốn tự tay tạo các collection trung gian.
  • Cần thêm tính song song một cách dễ dàng (qua parallelStream()).

Phong cách mệnh lệnh đôi khi phù hợp hơn nếu:

  • Cần logic phức tạp với nhiều vòng lặp lồng nhau và điều kiện.
  • Cần giữ hiệu năng tối đa ở chỗ nhạy cảm (Stream API đôi khi chậm hơn một chút).
  • Cần làm việc với trạng thái có thể thay đổi (ví dụ, cập nhật phần tử “tại chỗ”).

8. Lỗi thường gặp khi làm việc với Stream API

Lỗi #1: Dùng forEach để thu thập collection. Nhiều người mới dùng forEach để thêm phần tử vào collection mới. Đây không phải phong cách hàm! Hãy dùng collect hoặc toList() thay thế.

Lỗi #2: Tối ưu hóa quá sớm. Đừng vội dùng parallelStream() — tính song song chỉ nên dùng cho collection thực sự lớn và tác vụ nặng CPU.

Lỗi #3: Trộn Stream API với collection có thể thay đổi. Stream API hướng tới dữ liệu bất biến. Không nên thay đổi phần tử collection bên trong lambda.

Lỗi #4: “Mất” kết quả. Quên gọi thao tác kết thúc — sẽ không có gì xảy ra.

Lỗi #5: Lambda quá phức tạp. Nếu biểu thức lambda dài hơn một-hai dòng — hãy tách ra thành phương thức riêng có tên rõ ràng.

1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Đánh giá "Kho Đồ Phép thuật" trong Trò chơi Phiêu lưu Văn bản 🧙‍♂️
Đánh giá "Kho Đồ Phép thuật" trong Trò chơi Phiêu lưu Văn bản 🧙‍♂️
1
Nhiệm vụ
JAVA 25 SELF, mức độ, bài học
Đã khóa
Phân tích Các Địa Điểm cho Cuộc Thám Hiểm Bí Ẩn 🗺️
Phân tích Các Địa Điểm cho Cuộc Thám Hiểm Bí Ẩn 🗺️
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION