Course Content
Java Data Structures
Java Data Structures
Stream API
There are various ways to process data in Java – loops, methods, and different algorithms. However, in Java 8, a very powerful tool was introduced – the Stream API.
In simple terms, the Stream API is a way to work quickly and easily with a stream of information. In our case, this stream of information is represented by collections. The Stream API has some concepts. Here are the main ones.
Main concepts
-
Stream: Represents a sequence of data elements that can be processed;
-
Intermediate Operations: Operations that create a new stream after their execution. Examples:
filter
,map
,distinct
,sorted
; -
Terminal Operations: Operations that complete the processing of the stream and return a result. Examples:
collect
,forEach
,count
,reduce
; -
Parallel Streams: Allow parallel processing of data. Methods
parallel()
andparallelStream()
are used to create parallel streams.
Enough talking about theory, let's start coding!
Declaring a stream is done by using a method on the collection we want to turn into a stream:
main
List<String> strings = Arrays.asList("a", "b", "c"); Stream<String> stream = strings.stream();
With the stream()
method, we obtained a stream of strings. But to start working with the stream, we need to understand what lambda expressions are, as stream methods mainly work with them.
Lambda Expressions
Lambda expressions were introduced in Java 8, and they represent a simplified form of creating anonymous functions in Java. We haven't covered anonymous functions before as they were not highly necessary, but now we'll get acquainted with them through lambda expressions.
Lambda Expression Syntax:
The general syntax for lambda expressions in Java looks like this:
example
(parameters) -> expression // or (parameters) -> { statements; }
-
Parameters: This is a parameter list that can be empty or contain one or more parameters;
-
Arrow: Represented by the symbol
->
, which separates the parameters from the body of the lambda expression; -
Expression or Statements: This is the body of the function, containing an expression or a block of statements;
Here's an example of a lambda expression representing a simple function that adds two numbers:
example
// Traditional way MathOperation addition = new MathOperation() { @Override public int operate(int a, int b) { return a + b; } }; // Using a lambda expression MathOperation addition = (int a, int b) -> a + b;
Let's take a closer look at what exactly is happening in the code above and how we use lambda expressions:
main
package com.example; // Functional interface with a single abstract method interface MyMathOperation { int operate(int a, int b); } public class Main { public static void main(String[] args) { // Using a lambda expression to implement the interface MyMathOperation addition = (a, b) -> a + b; System.out.println("Sum: " + addition.operate(5, 3)); } }
Explanation:
- Created a functional interface
MyMathOperation
with a single abstract methodoperate
; - Used a lambda expression to implement this method, performing the addition of two numbers;
- Printed the result of the addition.
I understand that it might be challenging to grasp what is happening in this code for now, but let's move on back to the Stream API, where lambda expressions are frequently used, and try to understand how to use them in practice.
As you remember, earlier, we created a stream of strings from a list of strings. Now, let's use stream methods to make each string in this stream uppercase:
main
package com.example; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> strings = Arrays.asList("a", "b", "c"); Stream<String> stream = strings.stream(); stream.map(e -> e.toUpperCase()).toList(); } }
In the code above, we used a lambda expression and two methods: map()
and toList()
. If it's clear what the toList()
method does, the map()
method changes each element in the stream according to the provided lambda expression.
Let's take a closer look at how the lambda expression works here:
The map()
method applies the toUpperCase()
method to each element of the stream. We defined the element of this stream as e
and, using the lambda expression, instructed the program to apply this method to each element.
But this is not the end yet because we applied an intermediate operation. This means that operations on the stream are not yet completed. To complete the work on the stream, we need to apply a terminal operation, which will finish the operations on the stream and return a specific value. For example, we can use the toList()
method, and the modified stream will be converted into a list.
For example:
main
package com.example; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> strings = Arrays.asList("a", "b", "c"); Stream<String> stream = strings.stream(); List<String> list = stream.map(e -> e.toUpperCase()).toList(); System.out.println(list); } }
Note
Notice that after using the terminal operation, we can no longer use stream methods. In our case, after the terminal operation
toList()
, our stream was converted into a list, so we cannot use stream methods on the list.
Let's take a closer look at possible intermediate operations in the stream.
Intermediate operations
- The
map()
method - you are already familiar with this method; it performs operations specified by the lambda expression on each element in the stream.
For example, let's use the substring()
method on each element in the stream of strings:
main
package com.example; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> strings = Arrays.asList("Unlock", "Infinity", "with", "Codefinity"); System.out.println("List of strings: " + strings); Stream<String> stream = strings.stream(); List<String> list = stream.map(e -> e.substring(1, 4)).toList(); System.out.println("Modified list: " + list); } }
- The
filter()
method takes a lambda expression with a condition based on which the stream will be filtered. In other words, all elements that meet the condition will remain in the stream, and elements that do not meet the condition will be removed from the stream. Let's modify the stream to keep only the elements whose length is greater than 5:
main
package com.example; import java.util.Arrays; import java.util.List; import java.util.stream.Stream; public class Main { public static void main(String[] args) { List<String> strings = Arrays.asList("Unlock", "Infinity", "with", "Codefinity"); System.out.println("List of strings: " + strings); Stream<String> stream = strings.stream(); stream = stream.filter(e -> e.length() > 5); List<String> list = stream.map(e -> e.substring(1, 4)).toList(); System.out.println("Modified list: " + list); } }
Using the filter()
method, we remove the string "with" from the stream because this word is less than 5 characters.
You can also use intermediate operations multiple times in a row.
For example, we can slightly simplify the above code:
main
package com.example; import java.util.Arrays; import java.util.List; public class Main { public static void main(String[] args) { List<String> strings = Arrays.asList("Unlock", "Infinity", "with", "Codefinity"); System.out.println("List of strings: " + strings); List<String> list = strings.stream() .filter(e -> e.length() > 5) .map(e -> e.substring(1, 4)) .toList(); System.out.println("Modified list: " + list); } }
When chaining multiple stream methods together, it is recommended to put each method on a new line to significantly improve code readability.
- The
flatMap()
method transforms each element of a stream into a new stream and combines the results into a single stream. In other words, with this method, we can split the stream into streams, and then they will be merged into one stream. For example, we have a list of strings where each string may contain more than one word, such as a list of first and last names. And we need to capitalize the first letter of each of these words:
main
package com.example; import java.util.Arrays; import java.util.List; public class Main { public static void main(String[] args) { List<String> users = Arrays.asList("Ethan Johnson", "Olivia smith", "mason davis", "Ava taylor", "logan brown", "Emma Anderson", "jackson miller"); System.out.println("List of users: " + users); List<String> list = users.stream() .flatMap(e -> Arrays.stream(e.split(" "))) .map(e -> capitalizeFirstLetter(e)) .toList(); System.out.println("List with capitalized names and surnames: " + list); } private static String capitalizeFirstLetter(String word) { if (word == null || word.isEmpty()) { return word; } return Character.toUpperCase(word.charAt(0)) + word.substring(1); } }
In the code above, we wrote a separate private method that capitalizes the first letter in a word and used this method in the map()
method along with a lambda expression.
Note that using the flatMap
method, we split each element of the stream into different streams using the Arrays.stream(e.split(" "))
method. Because the split()
method returns an array, we need to use the Arrays.stream()
method to split this array into streams.
Then, all these streams are merged into one stream, after which we use the method we wrote. Voila, now we have all the names and surnames of users with the first letter capitalized.
You know what would be cool?
If we put these names and surnames into a HashMap
, where the key is the surname and the value is the first name.
Let's implement this in the code:
main
package com.example; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; public class Main { public static void main(String[] args) { List<String> users = Arrays.asList("Ethan Johnson", "Olivia smith", "mason davis", "Ava taylor", "logan brown", "Emma Anderson", "jackson miller"); System.out.println("List of users: " + users); List<String> list = users.stream() .flatMap(e -> Arrays.stream(e.split(" "))) .map(e -> capitalizeFirstLetter(e)) .toList(); System.out.println("List with capitalized names and surnames: " + list); Map<String, String> usersKeyValue = new HashMap<>(); for (int i = 0; i < list.size() - 1; i+=2) { String name = list.get(i); String surname = list.get(i + 1); usersKeyValue.put(surname, name); } System.out.println("Map with surnames as keys and names as values: " + usersKeyValue); } private static String capitalizeFirstLetter(String word) { if (word == null || word.isEmpty()) { return word; } return Character.toUpperCase(word.charAt(0)) + word.substring(1); } }
With a simple loop, we stored the first name and last name in variables and then in the map. Note how the loop works. We increment the variable i
by 2 in each iteration because we need to skip the last name once we've already recorded it.
Note
This method of filtering data is quite risky because data can be recorded in the wrong order, but in our case, it doesn't play a significant role.
- The
distinct()
method removes duplicates from the stream. In general, this can be useful if you need unique elements in the stream or if you want to quickly eliminate duplicates from a list. You can easily achieve this with the following construct:
-
The
sorted
method sorts all elements in the stream in natural order, from the smallest to the largest number or in alphabetical order. This can also be useful if you need a sorted stream or if you need to quickly sort a list; -
The
skip(n)
method skips the firstn
elements of the stream. This can be useful when working with text files, where the first n lines might be, for example, metadata or a file description. It's also worth mentioning thelimit(n)
method, which generally limits the number of elements in the stream. Even if we create a stream with 1000 elements and then uselimit(200)
, the stream will contain only the first 200 elements.
main
package com.example; import java.util.Arrays; import java.util.List; public class Main { public static void main(String[] args) { List<Integer> example = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); example = example.stream().skip(3).limit(5).toList(); System.out.println("List: " + example); } }
These are the main intermediate methods you'll need to use. You can explore the rest of the methods by referring to the link to the official Java documentation. Let's move on to terminal methods.
Terminal Methods
-
The terminal method you're already familiar with is
toList()
. It converts the stream into a list and returns it. In other words, we can directly assign this stream with methods to a list. This method was introduced in Java 17 and serves as a replacement for the more complex constructcollect(Collectors.toList())
; -
The
collect()
method also converts the stream into a specific data structure. It uses, as a parameter, a method from theCollectors
interface. This interface has methods such astoList()
,toSet()
, andtoCollection()
. For example:
main
package com.example; import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.stream.Collectors; public class Main { public static void main(String[] args) { List<Integer> example = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); Set<Integer> integerSet = example.stream().collect(Collectors.toSet()); System.out.println("List: " + example); System.out.println("Set: " + integerSet); } }
The forEach()
method takes a lambda expression and performs a specific action for each element in the stream.
For example:
main
package com.example; import java.util.Arrays; import java.util.List; public class Main { public static void main(String[] args) { List<Integer> example = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); example.stream().forEach(e -> System.out.println(e + 1)); } }
The difference between this method and the map
method is that this method is terminal, and after it, you cannot call other methods.
These are all the basic methods for working with streams. It's a complex topic, and you may not grasp it immediately. However, it's a subject that is mastered through practice. In the upcoming practice chapters with streams, you will have plenty of opportunities to work with them, as it's a very convenient and practical way to manipulate lists and arrays of data!
Thanks for your feedback!