1. The problem with the imperative approach
Let’s start with a classic task. Suppose we have a list of users:
List<User> users = ...; // already populated
Assume we need to get the list of names of all adult users (18 and older). How did we do this before?
List<String> names = new ArrayList<>();
for (User user : users) {
if (user.getAge() >= 18) {
names.add(user.getName());
}
}
At first glance it seems simple, but note the “technical” steps: declare a new list for the result, iterate over all elements, check a condition, add the result.
If the task gets more complex — for example, you need an alphabetically sorted list of unique e‑mail addresses of all users whose names start with the letter “A” — the code grows quickly: filtering, field extraction, duplicate removal, sorting, possibly e‑mail format transformations, etc. As a result, instead of a short, clear piece of logic you get a pile of boilerplate that is hard to read and maintain.
The downsides in two words: lots of repeated code, a high chance of making a mistake (forgetting to sort or mishandling duplicates), and difficulty composing operations. This is called the imperative style: you describe to the computer step by step how to perform the task, rather than what you want to obtain.
2. Stream API — a declarative style
With Java 8 came the Stream API — a tool that lets you work with collections in the “what needs to be done” style rather than “how exactly to do it”. This approach is called declarative.
So what is a Stream, anyway?
It’s not a separate collection but rather a flow of data: a sequence of elements that pass through a chain of operations — filtering, mapping, sorting, collecting the result, etc. A stream doesn’t store anything; it simply pipes data through a pipeline. You can combine operations almost like LEGO bricks: assemble the chain and run it.
Example:
List<String> names = users.stream() // create a stream from the user list
.filter(u -> u.getAge() >= 18) // keep only those who are at least 18
.map(User::getName) // transform User → String (take the name)
.collect(Collectors.toList()); // collect the result into a list
And that’s it — in a single expression we described the entire task: “take users, filter by age, extract names, collect into a list”. There’s no need to write loops manually, create temporary variables, and make sure nothing is forgotten.
The Stream API works like an assembly line: you define the processing stages, and the data passes through them. It’s clean, compact, and far easier to read.
3. Advantages of the Stream API
- Conciseness and readability. Compare the two approaches — imperative and declarative — on the same task. It’s immediately clear which one is easier to read and maintain.
- Easy composition of operations. Operations are easy to “stack” into a chain: filter, map, sorted, collect — all in a single coherent sequence.
- Fewer bugs. The Stream API removes routine work: you don’t need to manage intermediate collections and loops by hand — less chance to forget a step or make an indexing mistake.
- Option for parallelism. It’s easy to scale processing across multiple cores by calling parallelStream() instead of stream(). Details — later.
- Modern programming style. Ideas from functional programming (function composition, no explicit loops) increase expressiveness and are valued in the market.
A brief history of the Stream API
The Stream API arrived in Java 8 (2014) and was a qualitative leap for the language. Before that, any non-trivial operation over collections required lots of boilerplate, while other platforms already offered declarative approaches (map, filter, reduce, etc.).
Since then, the Stream API has been the de facto standard for processing collections in Java. If you want to write modern Java code — you can’t do without it.
4. Use cases for the Stream API
- Filtering: select only the elements you need (for example, all users older than 18).
- Transformation: extract a field or create a new object (for example, a list of user names).
- Aggregation: compute sum, average, count, maximum/minimum.
- Sorting: order elements by the required criterion.
- Grouping: partition elements by category.
- Collecting results: collect into a list, set, map, string, etc.
Example tasks:
- Get the list of e‑mail addresses of all users whose name starts with “A”.
- Compute the average age of users.
- Find the first user with a specific e‑mail.
- Collect all unique cities of residence into a single set.
5. Useful details
How a stream works
[User1] --\
[User2] ---|--> [ filter ] --> [ map ] --> [ collect ] --> List<String>
[User3] --/
1. filter — passes only users that satisfy the condition.
2. map — transforms a user into, for example, an e‑mail.
3. collect — gathers e‑mails into the desired collection.
Syntax: how to create a stream
- From a collection: list.stream() — a stream of the collection’s elements.
- From an array: Arrays.stream(array)
- From individual values: Stream.of("a", "b", "c")
6. Common mistakes when moving to the Stream API
Error #1: trying to modify the collection inside a stream. The Stream API is not intended to mutate the source collection (for example, don’t call list.remove() inside a forEach). For removals, use something like removeIf on the collection before or after processing.
Error #2: confusing a collection with a stream. A stream is not a collection! A stream “flows” over the elements once and then closes. If you need to process the data again, create a new stream().
Error #3: overly complex chains. Don’t turn an expression into a “monster” with a dozen operations. If the chain becomes long, split it into several steps with intermediate variables for readability and debugging.
GO TO FULL VERSION