Trước khi đi sâu vào các ví dụ về Stream API trong Java 8, hãy cùng tìm hiểu xem tại sao tính năng này lại cần thiết như vậy.
Giả sử ta muốn duyệt qua một danh sách số nguyên và tìm tổng của tất cả các số lớn hơn 10. Ở các phiên bản trước Java 8, cách tiếp cận thông thường sẽ là:
private static int sumIterator(List<Integer> list) {
Iterator<Integer> it = list.iterator();
int sum = 0;
while (it.hasNext()) {
int num = it.next();
if (num > 10) {
sum += num;
}
}
return sum;
}
Cách làm trên có ba vấn đề lớn:
- Ta chỉ muốn biết tổng của các số nguyên, nhưng lại phải tự định nghĩa cách thức duyệt qua danh sách. Cách làm này được gọi là lặp ngoài (external iteration), vì logic duyệt lặp được xử lý bởi chương trình của người dùng.
- Chương trình chạy một cách tuần tự, không có cách nào để thực thi song song một cách dễ dàng.
- Cần rất nhiều code chỉ để thực hiện một tác vụ đơn giản.
Để giải quyết các nhược điểm trên, Java 8 đã thêm Stream API. Ta có thể dùng nó để triển khai lặp trong (internal iteration), một phương pháp tốt hơn vì Java framework sẽ kiểm soát việc lặp.
Lặp trong cung cấp nhiều tính năng như thực thi tuần tự và song song, lọc dựa trên tiêu chí cho trước, mapping (ánh xạ), v.v. Hầu hết các tham số phương thức trong Stream API của Java 8 đều là interface hàm (functional), vì vậy biểu thức lambda hoạt động rất hiệu quả với chúng.
Hãy xem cách ta có thể viết lại code trên chỉ bằng một dòng duy nhất sử dụng Java Stream.
private static int sumStream(List<Integer> list) {
return list.stream().filter(i -> i > 10).mapToInt(i -> i).sum();
}
Lưu ý rằng chương trình trên tận dụng cơ chế lặp, các phương thức lọc và mapping của Java để giúp tăng độ hiệu quả.
Trước hết, ta sẽ tìm hiểu các khái niệm cốt lõi của Stream API trong Java 8, sau đó sẽ đi qua một số ví dụ để hiểu rõ hơn về các phương thức thường dùng.
Collection và Stream
Collection là một cấu trúc dữ liệu trong bộ nhớ dùng để chứa các giá trị. Trước khi bắt đầu sử dụng collection, tất cả các giá trị phải được đưa vào trước. Trong khi đó, Stream là một cấu trúc dữ liệu được tính toán theo yêu cầu.
Stream không lưu trữ dữ liệu. Nó hoạt động trên cấu trúc dữ liệu nguồn (collection và array) và tạo ra dữ liệu dạng pipeline (dữ liệu được xử lý theo chuỗi giai đoạn) để ta có thể sử dụng và thực hiện các thao tác cụ thể. Chẳng hạn, ta có thể tạo một stream từ một list và lọc nó dựa trên một điều kiện.
Các thao tác của Stream sử dụng interface hàm, rất phù hợp với lập trình hàm sử dụng biểu thức lambda. Như bạn có thể thấy trong ví dụ trên, việc sử dụng biểu thức lambda giúp code của chúng ta dễ đọc và ngắn gọn hơn.
Nguyên tắc lặp trong của Stream giúp đạt được cơ chế tìm kiếm lười (lazy-seeking, hoãn việc thực thi đến lúc cần kết quả) trong một số thao tác stream. Ví dụ, các thao tác lọc, mapping, hoặc loại bỏ phần tử trùng lặp có thể được triển khai một cách “lười”, cho phép hiệu suất cao hơn và có nhiều cơ hội để tối ưu hóa.
Stream trong Java chỉ có thể được sử dụng một lần (consumable), vì vậy không có cách nào để tạo một tham chiếu đến stream để dùng lại trong tương lai. Dữ liệu được xử lý theo yêu cầu và không thể tái sử dụng cùng một stream nhiều lần.
Stream hỗ trợ cả xử lý tuần tự lẫn song song. Xử lý song song có thể rất hữu ích để đạt hiệu suất cao đối với các collection lớn.
Tất cả các interface và class của Java Stream API đều nằm trong gói java.util.stream
. Vì ta có thể sử dụng các kiểu dữ liệu nguyên thủy như int
, long
trong các collection thông qua auto-boxing (tự động chuyển đổi kiểu nguyên thủy thành đối tượng) và các thao tác này có thể tốn nhiều thời gian, nên API này cung cấp các class chuyên biệt cho các kiểu nguyên thủy: IntStream
, LongStream
, và DoubleStream
.
Interface hàm trong Stream API
Một số interface hàm thường được sử dụng trong các phương thức của Stream API là:
- Function và BiFunction: Đại diện cho một hàm nhận một loại đối số và trả về một loại khác.
Function<T, R>
là dạng generic (chung), trong đóT
là kiểu đầu vào của hàm vàR
là kiểu kết quả trả về. Để xử lý các kiểu nguyên thủy, có cácFunction
interface chuyên biệt:ToIntFunction
,ToLongFunction
,ToDoubleFunction
,IntFunction
,LongFunction
,DoubleFunction
,IntToLongFunction
,IntToDoubleFunction
,LongToIntFunction
, v.v. Một số phương thức của Stream sử dụngFunction
hoặc các phiên bản chuyên biệt cho kiểu nguyên thủy của nó là:<R> Stream<R> map(Function<? super T, ? extends R> mapper)
IntStream mapToInt(ToIntFunction<? super T> mapper)
: tương tự cholong
vàdouble
trả về stream chuyên biệt cho kiểu nguyên thủy.IntStream flatMapToInt(Function<? super T, ? extends IntStream> mapper)
: tương tự cholong
vàdouble
.<A> A[] toArray(IntFunction<A[]> generator)
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner)
- Predicate và BiPredicate: Đại diện cho một mệnh đề dùng để kiểm tra các phần tử của stream.
Predicate
được sử dụng để lọc các phần tử từ stream. Giống nhưFunction
, có các interface chuyên biệt choint
,long
vàdouble
. Một số phương thức của Stream sử dụngPredicate
hoặc các phiên bản chuyên biệt của nó là:Stream<T> filter(Predicate<? super T> predicate)
boolean anyMatch(Predicate<? super T> predicate)
boolean allMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)
- Consumer và BiConsumer: Đại diện cho một thao tác nhận một đối số đầu vào duy nhất và không trả về kết quả.
Consumer
có thể được sử dụng để thực hiện một hành động nào đó trên tất cả các phần tử của Java stream. Một số phương thức của Stream API sử dụngConsumer
,BiConsumer
hoặc các interface chuyên biệt cho kiểu nguyên thủy của nó là:Stream<T> peek(Consumer<? super T> action)
void forEach(Consumer<? super T> action)
void forEachOrdered(Consumer<? super T> action)
- Supplier: Đại diện cho một thao tác mà qua đó ta có thể tạo ra các giá trị mới trong stream. Một số phương thức trong Stream nhận tham số
Supplier
là:public static<T> Stream<T> generate(Supplier<T> s)
<R> R collect(Supplier<R> supplier,BiConsumer<R, ? super T> accumulator,BiConsumer<R, R> combiner)
java.util.Optional
Optional là một đối tượng chứa (container), có thể chứa hoặc không chứa một giá trị không phải null. Nếu có giá trị, isPresent()
sẽ trả về true
và get()
sẽ trả về giá trị đó. Các tiến trình cuối của Stream sẽ trả về đối tượng Optional. Một vài trong số các phương thức đó là:
Optional<T> reduce(BinaryOperator<T> accumulator)
Optional<T> min(Comparator<? super T> comparator)
Optional<T> max(Comparator<? super T> comparator)
Optional<T> findFirst()
Optional<T> findAny()
java.util.Spliterator
Để hỗ trợ thực thi song song trong Stream API, ta sử dụng interface Spliterator. Phương thức trySplit()
trả về một Spliterator mới để quản lý một tập hợp con các phần tử của Spliterator gốc.
Các thao tác trung gian và cuối (terminal) trong Stream
Các thao tác trong Stream API trả về một Stream mới được gọi là thao tác trung gian. Hầu hết các thao tác này thường có tính chất “lười” (lazy). Chúng tạo ra các phần tử stream mới và gửi đến thao tác tiếp theo. Thao tác trung gian không bao giờ là thao tác tạo ra kết quả cuối cùng. Các thao tác trung gian thường dùng là filter()
và map()
.
Các thao tác trong Stream API trả về kết quả hoặc tạo ra một hiệu ứng phụ (side effect, là các tác động thêm ngoài giá trị trả về) được gọi là thao tác terminal (cuối). Một khi phương thức terminal được gọi trên một stream, nó sẽ sử dụng hết stream đó và sau đó ta không thể dùng lại stream được nữa.
Thao tác terminal có tính chất chủ động. Chúng xử lý tất cả các phần tử trong stream trước khi trả về kết quả. Các thao tác terminal thường dùng là collect()
, forEach()
, reduce()
, count()
, findFirst()
, anyMatch()
, v.v. Bạn có thể xác định các thao tác terminal dựa trên kiểu trả về của chúng. Chúng sẽ không bao giờ trả về một Stream.
Thao tác short circuiting trong Stream
Một thao tác trung gian được gọi là short circuiting (ngắn mạch) nếu nó có thể tạo ra một stream hữu hạn từ một stream vô hạn. Ví dụ, limit()
và skip()
là hai thao tác trung gian kiểu short circuiting.
Một thao tác terminal được gọi là short circuiting nếu nó có thể kết thúc trong thời gian hữu hạn đối với một stream vô hạn. Ví dụ, anyMatch()
, allMatch()
, noneMatch()
, findFirst()
và findAny()
là các thao tác terminal dạng short circuiting.
Ví dụ dùng Java Stream
Tôi đã trình bày gần như tất cả các phần quan trọng của Java 8 Stream API. Thật thú vị khi sử dụng các tính năng API mới này, hãy cùng xem chúng hoạt động qua một số ví dụ về Java stream.
Tạo Stream
Có nhiều cách để tạo một Stream từ array và collection. Hãy xem qua các cách này với các ví dụ đơn giản.
Ta có thể dùng Stream.of()
để tạo một Stream từ các dữ liệu cùng loại. Ví dụ, ta có thể tạo một Java stream các số nguyên từ một nhóm các đối tượng int
hoặc Integer
.
Stream<Integer> stream = Stream.of(1,2,3,4);
Ta có thể dùng Stream.of()
với một mảng các đối tượng để trả về stream. Lưu ý rằng nó không hỗ trợ auto-boxing, vì vậy ta không thể truyền vào một mảng kiểu nguyên thủy.
Stream<Integer> stream = Stream.of(new Integer[]{1,2,3,4});
//works fine
Stream<Integer> stream1 = Stream.of(new int[]{1,2,3,4});
//Compile time error, Type mismatch: cannot convert from Stream<int[]> to Stream<Integer>
Ta có thể dùng phương thức stream()
của Collection để tạo stream tuần tự và parallelStream()
để tạo stream song song.
List<Integer> myList = new ArrayList<>();
for(int i=0; i<100; i++) myList.add(i);
//sequential stream
Stream<Integer> sequentialStream = myList.stream();
//parallel stream
Stream<Integer> parallelStream = myList.parallelStream();
Ta có thể dùng các phương thức Stream.generate()
và Stream.iterate()
để tạo Stream.
Stream<String> stream1 = Stream.generate(() -> {return "abc";});
Stream<String> stream2 = Stream.iterate("abc", (i) -> i);
Sử dụng các phương thức Arrays.stream()
và String.chars()
.
LongStream is = Arrays.stream(new long[]{1,2,3,4});
IntStream is2 = "abc".chars();
Chuyển đổi Stream thành Collection hoặc Array
Có nhiều cách để lấy một Collection hoặc Array từ một Java Stream.
Ta có thể dùng phương thức collect()
của Java Stream để lấy List
, Map
hoặc Set
từ stream.
Stream<Integer> intStream = Stream.of(1,2,3,4);
List<Integer> intList = intStream.collect(Collectors.toList());
System.out.println(intList); //prints [1, 2, 3, 4]
intStream = Stream.of(1,2,3,4); //stream is closed, so we need to create it again
Map<Integer,Integer> intMap = intStream.collect(Collectors.toMap(i -> i, i -> i+10));
System.out.println(intMap); //prints {1=11, 2=12, 3=13, 4=14}
Ta có thể dùng phương thức toArray()
của stream để tạo một mảng từ stream.
Stream<Integer> intStream = Stream.of(1,2,3,4);
Integer[] intArray = intStream.toArray(Integer[]::new);
System.out.println(Arrays.toString(intArray)); //prints [1, 2, 3, 4]
Các thao tác trung gian của Stream
Hãy xem ví dụ về các thao tác trung gian thường dùng của Stream.
filter(): Ta có thể dùng phương thức filter()
để kiểm tra các phần tử của stream theo một điều kiện và tạo ra một danh sách đã được lọc.
List<Integer> myList = new ArrayList<>();
for(int i=0; i<100; i++) myList.add(i);
Stream<Integer> sequentialStream = myList.stream();
Stream<Integer> highNums = sequentialStream.filter(p -> p > 90); //filter numbers greater than 90
System.out.print("High Nums greater than 90=");
highNums.forEach(p -> System.out.print(p+" "));
//prints "High Nums greater than 90=91 92 93 94 95 96 97 98 99 "
map(): Ta có thể dùng map()
để áp dụng các hàm lên một Stream. Hãy xem cách ta có thể dùng nó để chuyển đổi một danh sách các chuỗi thành chữ hoa ở ví dụ dưới.
Stream<String> names = Stream.of("aBc", "d", "ef");
System.out.println(names.map(s -> {
return s.toUpperCase();
}).collect(Collectors.toList()));
//prints [ABC, D, EF]
sorted(): Ta có thể dùng sorted()
để sắp xếp các phần tử của stream bằng cách truyền vào một Comparator.
Stream<String> names2 = Stream.of("aBc", "d", "ef", "123456");
List<String> reverseSorted = names2.sorted(Comparator.reverseOrder()).collect(Collectors.toList());
System.out.println(reverseSorted); // [ef, d, aBc, 123456]
Stream<String> names3 = Stream.of("aBc", "d", "ef", "123456");
List<String> naturalSorted = names3.sorted().collect(Collectors.toList());
System.out.println(naturalSorted); //[123456, aBc, d, ef]
flatMap(): Ta có thể dùng flatMap()
để tạo ra một stream từ một stream của các list. Hãy xem một ví dụ đơn giản sau.
Stream<List<String>> namesOriginalList = Stream.of(
Arrays.asList("Pankaj"),
Arrays.asList("David", "Lisa"),
Arrays.asList("Amit"));
//flat the stream from List<String> to String stream
Stream<String> flatStream = namesOriginalList
.flatMap(strList -> strList.stream());
flatStream.forEach(System.out::println);
Các thao tác terminal của Java Stream
Hãy xem một số ví dụ về các thao tác terminal của Java Stream.
reduce(): Ta có thể dùng reduce()
để thực hiện một thao tác rút gọn trên các phần tử của stream, sử dụng một hàm tích lũy có tính kết hợp, và trả về một đối tượng Optional. Hãy xem cách ta có thể dùng nó để nhân các số nguyên trong một stream.
Stream<Integer> numbers = Stream.of(1,2,3,4,5);
Optional<Integer> intOptional = numbers.reduce((i,j) -> {return i*j;});
if(intOptional.isPresent()) System.out.println("Multiplication = "+intOptional.get()); //120
count(): Ta có thể dùng thao tác terminal này để đếm số lượng phần tử trong stream.
Stream<Integer> numbers1 = Stream.of(1,2,3,4,5);
System.out.println("Number of elements in stream="+numbers1.count()); //5
forEach(): Thao tác này có thể được dùng để duyệt qua stream. Ta có thể dùng nó thay cho iterator. Ở dưới đây là ví dụ cách dùng nó để in ra tất cả các phần tử của stream.
Stream<Integer> numbers2 = Stream.of(1,2,3,4,5);
numbers2.forEach(i -> System.out.print(i+",")); //1,2,3,4,5,
match(): Hãy xem một số ví dụ về các phương thức match trong Stream API.
Stream<Integer> numbers3 = Stream.of(1,2,3,4,5);
System.out.println("Stream contains 4? "+numbers3.anyMatch(i -> i==4));
//Stream contains 4? true
Stream<Integer> numbers4 = Stream.of(1,2,3,4,5);
System.out.println("Stream contains all elements less than 10? "+numbers4.allMatch(i -> i<10));
//Stream contains all elements less than 10? true
Stream<Integer> numbers5 = Stream.of(1,2,3,4,5);
System.out.println("Stream doesn't contain 10? "+numbers5.noneMatch(i -> i==10));
//Stream doesn't contain 10? true
findFirst(): Đây là một thao tác terminal dạng short-circuiting. Đây là ví dụ cách ta có thể dùng nó để tìm chuỗi đầu tiên bắt đầu bằng ‘D’ từ một Stream.
Stream<String> names4 = Stream.of("Pankaj","Amit","David", "Lisa");
Optional<String> firstNameWithD = names4.filter(i -> i.startsWith("D")).findFirst();
if(firstNameWithD.isPresent()){
System.out.println("First Name starting with D="+firstNameWithD.get()); //David
}
Hạn chế của Stream API
Tuy Stream API trong Java 8 mang lại rất nhiều điều mới mẻ để làm việc với list và array, nó cũng có một số hạn chế.
- Biểu thức lambda phi trạng thái (stateless): Việc sử dụng Stream song song và các biểu thức lambda là có trạng thái (stateful) có thể dẫn đến các kết quả ngẫu nhiên. Hãy xem qua một chương trình đơn giản sau.
package com.journaldev.java8.stream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
public class StatefulParallelStream {
public static void main(String[] args) {
List<Integer> ss = Arrays.asList(1,2,3,4,5,6,7,8,9,10,11,12,13,14,15);
List<Integer> result = new ArrayList<Integer>();
Stream<Integer> stream = ss.parallelStream();
stream.map(s -> {
synchronized (result) {
if (result.size() < 10) {
result.add(s);
}
}
return s;
}).forEach( e -> {});
System.out.println(result);
}
}
Nếu chạy chương trình trên, bạn sẽ nhận được các kết quả khác nhau vì nó phụ thuộc vào cách Stream được duyệt và ta không có thứ tự nào cho việc xử lý song song. Nếu ta sử dụng Stream tuần tự, vấn đề này sẽ không xảy ra.
- Một khi Stream đã được sử dụng, nó không thể được dùng lại. Như bạn có thể thấy trong các ví dụ trên, mỗi lần chúng ta đều phải tạo một Stream mới.
- Có rất nhiều phương thức trong Stream API và phần khó hiểu nhất là các phương thức ghi đè (overloaded). Điều này khiến cho việc làm quen mất nhiều thời gian cho người mới.
Tổng kết
Hy vọng qua bài viết này, bạn đã nắm được cách sử dụng Stream API trong Java 8 để xử lý dữ liệu hiệu quả hơn trong các chương trình của mình. Bạn cũng có thể tìm hiểu sâu thêm chủ đề này bằng cách tham khảo tài liệu chính thức của Java. Nếu còn thắc mắc hay muốn trao đổi thêm, đừng ngần ngại để lại bình luận bên dưới nhé.