1. Imperative vs functional style
Let’s start with a simple question: why do we even need this “functional style”? How is it better than the usual, familiar loop-based approach? And what does “functional style” mean in Java at all?
Imperative style
The imperative style is when you tell the computer how to do something, step by step. For example, if you need to get a list of string lengths from a list of strings, keep only odd lengths, and sort them in descending order, you might write something like this:
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
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]
Here we explicitly create an intermediate list, add elements manually, and sort — all step by step.
Functional style
The functional style is when you describe what you want to get, not how it’s done. In Java this is implemented via the Stream API:
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> result = words.stream()
.map(String::length)
.filter(len -> len % 2 != 0)
.sorted(Comparator.reverseOrder())
.toList();
System.out.println(result); // [7, 5, 3]
Here we’re building a data-processing “conveyor”: first we turn words into their lengths (map), then filter the odd ones (filter), then sort (sorted). All of this happens in a single chain, without explicit intermediate collections and loops.
Comparison: iterative vs functional style
In the familiar imperative approach, we write a loop in which we explain to the computer, step by step, what to do: iterate over each element, check a condition, add to a new list or print on the screen. The code works, but it takes more lines, and the more complex the task, the harder it is to read and maintain.
The functional style lets you describe not the process itself but the result you want. Instead of a long loop, we build a chain of operations: filter, transform, and collect. It’s shorter, clearer, and reduces the risk of errors because there’s less “manual work” with mutable collections.
There’s a flip side, too. For a beginner, such a chain can look confusing: several lambdas in a row can be harder to read than a simple loop. So the functional style wins in brevity and expressiveness, but it does require practice and familiarity.
2. Core Stream API operations
The Stream API is not just a “new kind of loop,” but a whole toolkit for processing collections in a functional style. Let’s go over the core operations.
How do you get a Stream?
List<String> list = List.of("a", "bb", "ccc");
Stream<String> stream = list.stream();
Intermediate operations
- map — transforms stream elements
- filter — filters elements by a predicate
- flatMap — turns each element into a stream and “flattens” them
- sorted — sorting
- distinct — removes duplicates
- limit / skip — limit/skip elements
Terminal operations
- forEach — perform an action for each element
- collect — collect the result into a collection
- reduce — reduce the stream to a single value (for example, a sum)
- count — count the number of elements
- anyMatch, allMatch, noneMatch — condition checks
Example: a processing chain
List<String> names = List.of("Anna", "Boris", "Victoria", "Glen", "Daria");
List<String> filtered = names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(filtered); // [ANNA, BORIS, DASHA, GLEB, VIKA]
Visual “conveyor” scheme:
[Anna, Boris, Victoria, Glen, Daria]
| filter (length>3)
[Anna, Boris, Victoria, Glen, Daria]
| map (toUpperCase)
[ANNA, BORIS, VIKA, GLEB, DASHA]
| sorted
[ANNA, BORIS, DASHA, GLEB, VIKA]
| toList
Each operation does not change the original collection — a new stream is created.
3. Stream execution and laziness
Intermediate vs terminal operations
Streams have two kinds of operations. First, intermediate ones such as map, filter, or sorted. They return a new stream and look like they promise to do something, but in fact nothing has been executed yet. Second, terminal ones such as forEach, collect, or reduce. These actually kick off the processing. The key point is that until you call a terminal operation, the stream remains “lazy” — no computation starts.
Example:
Stream<String> stream = List.of("a", "bb", "ccc").stream()
.map(s -> {
System.out.println("map: " + s);
return s.toUpperCase();
});
System.out.println("Before forEach");
stream.forEach(System.out::println);
Output:
Before forEach
map: a
A
map: bb
BB
map: ccc
CCC
You can see that map does not execute until forEach starts.
Why is this great?
This approach lets you build arbitrarily long transformation chains without extra cost. The stream will start working only when it’s actually needed. Thanks to this, you can easily process huge amounts of data or even infinite sequences. Laziness also helps save memory and execute computations more efficiently.
4. Practice: “Strings → lengths → odd → descending”
Let’s solve the task step by step: “From a list of strings, get a list of string lengths, keep only odd lengths, and sort in descending order.”
Imperative solution
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
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]
Functional solution with the Stream API
List<String> words = Arrays.asList("cat", "elephant", "rhino", "tiger", "mouse");
List<Integer> result = words.stream()
.map(String::length) // convert strings to their lengths
.filter(len -> len % 2 != 0) // keep only odd lengths
.sorted(Comparator.reverseOrder()) // sort in descending order
.toList(); // collect into a List (Java 16+)
System.out.println(result); // [7, 5, 3]
Explanations:
- map(String::length) — for each string, take its length.
- filter(len -> len % 2 != 0) — keep only odd lengths.
- sorted(Comparator.reverseOrder()) — sort in descending order.
- toList() — collect the stream into a new list.
Analogy
It’s like building a factory conveyor belt: at each stage the parts are processed in a new way, and only at the very end everything is packed into a box.
5. More examples: map, filter, forEach, collect
Example 1: Filtering and printing
List<String> names = List.of("Anna", "Boris", "Victoria", "Glen", "Daria");
names.stream()
.filter(name -> name.contains("a"))
.forEach(System.out::println);
// Will print: Anna, Daria, Victoria
Example 2: Transform and collect into a Set
Set<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
System.out.println(upperNames); // [ANNA, BORIS, DASHA, GLEB, VIKA]
Example 3: Getting the sum of all string lengths
int totalLength = names.stream()
.mapToInt(String::length)
.sum();
System.out.println("Total length: " + totalLength);
Example 4: Using Predicate and Function
Predicate<String> longName = name -> name.length() > 4;
Function<String, String> greet = name -> "Hello, " + name + "!";
names.stream()
.filter(longName)
.map(greet)
.forEach(System.out::println);
// Hello, Boris!
// Hello, Daria!
6. Traits of the functional style with the Stream API
No mutable state
The Stream API style encourages “pure” functions — without side effects. This means it’s better not to mutate external variables inside lambdas.
Bad:
List<String> result = new ArrayList<>();
names.stream()
.filter(name -> name.startsWith("A"))
.forEach(result::add); // side-effect!
Better:
List<String> result = names.stream()
.filter(name -> name.startsWith("A"))
.toList();
Composition of operations
You can build very long chains by combining map, filter, sorted, and other methods. The main thing is not to overdo it: if the chain is longer than the screen, consider splitting it into parts.
Lazy evaluation
The Stream API does nothing until it reaches a terminal operation. This helps save resources and build efficient data-processing pipelines.
Immutability of the original collections
A stream does not modify the original collection. All transformations return a new stream/collection.
7. When to use the Stream API
The Stream API is great when:
- You need to quickly process a collection (filtering, transformation, sorting).
- You want concise, readable code.
- You don’t want to manually create intermediate collections.
- You need to add parallelism easily (via parallelStream()).
The imperative style can be preferable when:
- You need complex logic with multiple nested loops and conditions.
- You need maximum performance in critical sections (the Stream API can sometimes be slightly slower).
- You need to work with mutable state (for example, updating elements “in place”).
8. Common mistakes when working with the Stream API
Mistake #1: Using forEach to build a collection. Many beginners use forEach to add elements to a new collection. That’s not a functional style. Instead, use collect or toList().
Mistake #2: Premature optimization. Don’t try to use parallelStream() right away — parallelism is only needed for really large collections and CPU-intensive tasks.
Mistake #3: Mixing the Stream API with mutable collections. The Stream API implies working with immutable data. Don’t mutate collection elements inside lambdas.
Mistake #4: “Losing” the result. If you forget to call a terminal operation, nothing will happen.
Mistake #5: Lambdas that are too complex. If a lambda expression grows beyond one or two lines, extract it into a separate method with a clear name.
GO TO FULL VERSION