Free Handbook · Every example compiled & verified

Generics, Lambdas & Streams

Java generics, wildcards and type erasure, then lambdas, functional interfaces, method references and the Stream API — and when a plain loop is better.

0 / 142 lessons🔥 0 day streak
ShareXLinkedIn

Module 09 · what you'll be able to do

  • Write generic classes and methods, add bounds like <T extends Comparable<T>>, and choose ? extends or ? super using PECS
  • Explain type erasure and the limits it causes (no new T(), no instanceof List<String>)
  • Write lambdas and method references for Function, Predicate, Supplier and Consumer
  • Build Stream pipelines with filter, map, sorted, reduce and collect, including groupingBy and Optional results
  • Judge when a stream reads better than a loop, and when it does not
01

Generic classes and methods

You have used generics since Collections: List<String> is a list that the compiler knows holds only strings. Writing your own generic type works the same way. A type parameter in angle brackets — by convention a single capital letter such as T (type), E (element), K/V (key/value) — stands for a type the user chooses later.

javaMain.java
public class Main {
    public static void main(String[] args) {
        Box<String> name = new Box<>("Ada");
        Box<Integer> count = new Box<>(3);

        String s = name.get();          // no cast needed
        int n = count.get() + 1;        // unboxed automatically
        System.out.println(s + " " + n);

        Pair<String, Double> price = new Pair<>("coffee", 3.5);
        System.out.println(price.first() + " costs " + price.second());
    }
}

class Box<T> {
    private T value;
    Box(T value) { this.value = value; }
    T get() { return value; }
    void set(T value) { this.value = value; }
}

record Pair<A, B>(A first, B second) { }
Outputcompiled & run with real Java
Ada 4
coffee costs 3.5
Your turn

Try name.set(42); — the compiler stops you, because name is a Box<String>.

Before generics (Java 5) collections held plain Objects, and every read needed a cast that could fail at runtime. Generics move that check to compile time: the mistake below is caught before the program ever runs.

Error you will hit

no suitable method found for add(String)

java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> ids = new ArrayList<>();
        ids.add(101);
        ids.add("102");
        System.out.println(ids);
    }
}
Main.java:7: error: no suitable method found for add(String)
        ids.add("102");
           ^
    method List.add(Integer) is not applicable
      (argument mismatch; String cannot be converted to Integer)
    method List.add(int,Integer) is not applicable
      (actual and formal argument lists differ in length)
1 error
Why the compiler said that

The list was declared to hold Integers, and "102" is a String. Without generics this would be accepted and blow up later with a ClassCastException wherever someone read the element as a number.

The fix

Convert the data at the boundary: ids.add(Integer.parseInt("102"));.

Generic methods

A single method can be generic even inside a non-generic class. Put the type parameter before the return type: static <T> T firstOr(List<T> list, T fallback). The compiler infers T from the arguments at each call.

javaMain.java
import java.util.*;

public class Main {
    static <T> T firstOr(List<T> list, T fallback) {
        return list.isEmpty() ? fallback : list.get(0);
    }

    static <T> List<T> repeat(T item, int times) {
        List<T> out = new ArrayList<>();
        for (int i = 0; i < times; i++) out.add(item);
        return out;
    }

    public static void main(String[] args) {
        System.out.println(firstOr(List.of("x", "y"), "none"));
        System.out.println(firstOr(List.<String>of(), "none"));
        System.out.println(repeat('*', 5));
        System.out.println(repeat(List.of(1, 2), 2));
    }
}
Outputcompiled & run with real Java
x
none
[*, *, *, *, *]
[[1, 2], [1, 2]]
02

Bounded types, wildcards and PECS

Inside a generic method, T could be anything, so you can only call Object methods on it. A bound narrows what T may be and, in return, unlocks that type's methods. <T extends Comparable<T>> reads "any T that can compare itself to another T" (extends is used for interfaces too).

Error you will hit

bad operand types for binary operator '>'

java
import java.util.*;

public class Main {
    static <T> T max(List<T> items) {
        T best = items.get(0);
        for (T item : items) {
            if (item > best) best = item;
        }
        return best;
    }

    public static void main(String[] args) {
        System.out.println(max(List.of(3, 9, 4)));
    }
}
Main.java:7: error: bad operand types for binary operator '>'
            if (item > best) best = item;
                     ^
  first type:  T
  second type: T
  where T is a type-variable:
    T extends Object declared in method <T>max(List<T>)
1 error
Why the compiler said that

> only works on primitive numbers. T is an unknown object type, and the compiler has no idea how to order two arbitrary objects.

The fix

Bound T to things that know how to compare themselves, and use compareTo.

java
import java.util.*;

public class Main {
    static <T extends Comparable<T>> T max(List<T> items) {
        T best = items.get(0);
        for (T item : items) {
            if (item.compareTo(best) > 0) best = item;
        }
        return best;
    }

    public static void main(String[] args) {
        System.out.println(max(List.of(3, 9, 4)));
        System.out.println(max(List.of("pear", "apple", "plum")));
    }
}

Why List is not a List

Integer is a subtype of Number, but List<Integer> is not a subtype of List<Number>. If it were, you could pass a list of integers to a method expecting List<Number>, and that method could legally add a Double to it — corrupting the caller's list. Generics are invariant to prevent that.

Error you will hit

method sum cannot be applied to given types: List<Integer> is not List<Number>

java
import java.util.*;

public class Main {
    static double sum(List<Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        System.out.println(sum(ints));
    }
}
Main.java:12: error: method sum in class Main cannot be applied to given types;
        System.out.println(sum(ints));
                           ^
  required: List<Number>
  found:    List<Integer>
  reason: argument mismatch; List<Integer> cannot be converted to List<Number>
1 error
Why the compiler said that

The parameter type demands exactly List<Number>. A List<Integer> is a different type, for the safety reason above.

The fix

This method only reads numbers, so accept a list of "Number or any subtype": List<? extends Number>.

java
import java.util.*;

public class Main {
    static double sum(List<? extends Number> nums) {
        double total = 0;
        for (Number n : nums) total += n.doubleValue();
        return total;
    }

    public static void main(String[] args) {
        System.out.println(sum(List.of(1, 2, 3)));
        System.out.println(sum(List.of(1.5, 2.5)));
    }
}

Wildcards come in two directions, and the rule of thumb is PECS — Producer Extends, Consumer Super. If a parameter produces values you read, use ? extends T. If it consumes values you write into it, use ? super T. Collections.copy(List<? super T> dest, List<? extends T> src) uses both.

javaMain.java
import java.util.*;

public class Main {
    // src PRODUCES Ts (we read) -> extends; dest CONSUMES Ts (we write) -> super
    static <T> void copyAll(List<? extends T> src, List<? super T> dest) {
        for (T item : src) {
            dest.add(item);
        }
    }

    public static void main(String[] args) {
        List<Integer> ints = List.of(1, 2, 3);
        List<Double> doubles = List.of(0.5);
        List<Number> numbers = new ArrayList<>();
        List<Object> anything = new ArrayList<>(List.of("start"));

        copyAll(ints, numbers);       // T = Integer, Number is a super of Integer
        copyAll(doubles, numbers);    // T = Double
        copyAll(ints, anything);      // Object is a super of everything
        System.out.println(numbers);
        System.out.println(anything);
    }
}
Outputcompiled & run with real Java
[1, 2, 3, 0.5]
[start, 1, 2, 3]
DeclarationYou can read asYou can addUse when
List<T>TTYou both read and write the same exact type
List<? extends T>Tnothing (except null)The list is a producer — you only read
List<? super T>only ObjectT and its subtypesThe list is a consumer — you only write
List<?>only ObjectnothingYou only need size, clear, and so on
03

Type erasure

Generics are a compile-time feature. After checking your types, the compiler erases them: List<String> and List<Integer> both become plain List in the bytecode, T becomes Object (or its bound), and casts are inserted where you read values. This kept Java 5 compatible with older code, and it explains every "why can't I…" generics question.

javaMain.java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        List<Integer> nums = new ArrayList<>();
        System.out.println(words.getClass() == nums.getClass());
        System.out.println(words.getClass().getName());
    }
}
Outputcompiled & run with real Java
true
java.util.ArrayList

At runtime there is only one ArrayList class. The <String> is gone.

You cannot…Because at runtime…Do this instead
new T()there is no T to constructPass a Supplier<T> (ArrayList::new)
new T[10]arrays must know their element typeUse a List<T>
x instanceof List<String>the element type is not storedx instanceof List<?>, then check elements
Overload f(List<String>) and f(List<Integer>)both erase to f(List)Give them different names
List<int>erased T is an ObjectList<Integer> or IntStream
Error you will hit

unexpected type: required class, found type parameter T

java
import java.util.*;

public class Main {
    static <T> List<T> fill(int n) {
        List<T> out = new ArrayList<>();
        for (int i = 0; i < n; i++) out.add(new T());
        return out;
    }

    public static void main(String[] args) {
        System.out.println(fill(2));
    }
}
Main.java:6: error: unexpected type
        for (int i = 0; i < n; i++) out.add(new T());
                                                ^
  required: class
  found:    type parameter T
  where T is a type-variable:
    T extends Object declared in method <T>fill(int)
1 error
Why the compiler said that

After erasure the method has no idea what T is, so it cannot call a constructor on it. The compiler refuses rather than guess.

The fix

Ask the caller for a factory — a Supplier<T>. A constructor reference such as StringBuilder::new fits it perfectly.

java
import java.util.*;
import java.util.function.Supplier;

public class Main {
    static <T> List<T> fill(int n, Supplier<T> make) {
        List<T> out = new ArrayList<>();
        for (int i = 0; i < n; i++) out.add(make.get());
        return out;
    }

    public static void main(String[] args) {
        List<StringBuilder> sbs = fill(2, StringBuilder::new);
        System.out.println(sbs.size());
    }
}
04

Lambdas and functional interfaces

A lambda is a function written inline: (a, b) -> a + b. Its type is always a functional interface — an interface with exactly one abstract method. The lambda becomes the body of that method. Before Java 8 you wrote a whole anonymous class for this; a lambda is the same thing without the noise.

javaMain.java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> names = new ArrayList<>(List.of("Zoe", "al", "Bob", "christine"));

        // Old way: anonymous class implementing Comparator
        names.sort(new Comparator<String>() {
            @Override
            public int compare(String a, String b) {
                return a.length() - b.length();
            }
        });
        System.out.println(names);

        // Same thing as a lambda
        names.sort((a, b) -> a.compareToIgnoreCase(b));
        System.out.println(names);

        // Lambda with a block body
        names.removeIf(s -> {
            boolean tooShort = s.length() < 3;
            return tooShort;
        });
        System.out.println(names);
    }
}
Outputcompiled & run with real Java
[al, Zoe, Bob, christine]
[al, Bob, christine, Zoe]
[Bob, christine, Zoe]

You rarely need to invent functional interfaces: java.util.function has the common shapes. Learn these five and you can read most lambda-heavy code.

Also: UnaryOperator<T> (T to T), BinaryOperator<T> (T, T to T), and primitive versions like IntPredicate that avoid boxing.
InterfaceMethodShapeExample
Function<T,R>R apply(T)T in, R outs -> s.length()
Predicate<T>boolean test(T)T in, yes/no outn -> n % 2 == 0
Supplier<T>T get()nothing in, T out() -> new ArrayList<>()
Consumer<T>void accept(T)T in, nothing outs -> System.out.println(s)
BiFunction<T,U,R>R apply(T, U)two in, one out(a, b) -> a * b
javaMain.java
import java.util.function.*;

public class Main {
    public static void main(String[] args) {
        Function<String, Integer> length = s -> s.length();
        Function<Integer, Integer> doubled = n -> n * 2;
        Predicate<String> isEmpty = s -> s.isEmpty();
        Supplier<String> greeting = () -> "hello";
        Consumer<String> shout = s -> System.out.println(s.toUpperCase() + "!");

        System.out.println(length.apply("lambda"));
        System.out.println(length.andThen(doubled).apply("lambda"));   // compose
        System.out.println(isEmpty.negate().test(""));                 // combine
        shout.accept(greeting.get());
    }
}
Outputcompiled & run with real Java
6
12
false
HELLO!
Your turn

Build a Predicate<String> longWord for length > 5 and print longWord.and(s -> s.startsWith("l")).test("lambda").

Captured variables must be effectively final

A lambda can use local variables from the surrounding method, but only if they are never reassigned after initialisation — effectively final. The lambda may run later, even on another thread, and Java captures a copy of the value; allowing reassignment would make that copy silently stale.

Error you will hit

local variables referenced from a lambda expression must be final or effectively final

java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> prices = List.of(120, 80, 45);
        int total = 0;
        prices.forEach(p -> total += p);
        System.out.println(total);
    }
}
Main.java:7: error: local variables referenced from a lambda expression must be final or effectively final
        prices.forEach(p -> total += p);
                            ^
1 error
Why the compiler said that

total += p reassigns a captured local variable, which lambdas are not allowed to do.

The fix

Do not mutate outside state from a lambda. Let a stream compute the value and return it: int total = prices.stream().mapToInt(Integer::intValue).sum(); — or use a plain for-each loop, which has no such restriction.

java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> prices = List.of(120, 80, 45);
        int total = prices.stream().mapToInt(Integer::intValue).sum();
        System.out.println(total);
    }
}

Your own single-method interfaces work with lambdas too. Mark them @FunctionalInterface so the compiler complains if someone adds a second abstract method.

javaMain.java
public class Main {
    @FunctionalInterface
    interface DiscountRule {
        double apply(double price);
    }

    static double checkout(double price, DiscountRule rule) {
        return rule.apply(price);
    }

    public static void main(String[] args) {
        DiscountRule none = p -> p;
        DiscountRule tenPercent = p -> p * 0.9;
        DiscountRule flat50 = p -> Math.max(0, p - 50);
        System.out.println(checkout(400, none));
        System.out.println(checkout(400, tenPercent));
        System.out.println(checkout(400, flat50));
    }
}
Outputcompiled & run with real Java
400.0
360.0
350.0
05

Method references

When a lambda does nothing but call an existing method, a method reference says the same thing shorter: s -> s.length() becomes String::length. There are four kinds.

KindMethod referenceEquivalent lambda
Static methodInteger::parseInts -> Integer.parseInt(s)
Instance method of a particular objectSystem.out::printlnx -> System.out.println(x)
Instance method of an arbitrary object (first argument is the receiver)String::toUpperCases -> s.toUpperCase()
ConstructorArrayList::new() -> new ArrayList<>()
javaMain.java
import java.util.*;
import java.util.function.*;

public class Main {
    public static void main(String[] args) {
        Function<String, Integer> parse = Integer::parseInt;          // static
        Consumer<Object> print = System.out::println;                 // particular object
        UnaryOperator<String> upper = String::toUpperCase;            // arbitrary object
        BiFunction<String, String, Boolean> same = String::equalsIgnoreCase;  // receiver + arg
        Supplier<List<String>> newList = ArrayList::new;              // constructor

        print.accept(parse.apply("41") + 1);
        print.accept(upper.apply("ref"));
        print.accept(same.apply("Java", "JAVA"));

        List<String> list = newList.get();
        list.add("b"); list.add("a");
        list.sort(Comparator.naturalOrder());
        list.forEach(System.out::println);
    }
}
Outputcompiled & run with real Java
42
REF
true
a
b
Pick whichever reads better
Method references shine when the name says it all (map(String::trim)). When you need to pass extra arguments or the method name is unclear, keep the lambda: map(s -> s.substring(0, 3)) has no neat reference form.
06

The Stream API: map, filter, reduce

A stream is a pipeline over a sequence of elements: a source (list.stream(), Stream.of(...), IntStream.range(...)), zero or more intermediate operations that return a new stream (filter, map, sorted, distinct, limit), and one terminal operation that produces a result (collect, reduce, count, forEach, findFirst). Streams never change the source collection.

javaMain.java
import java.util.*;
import java.util.stream.*;

public class Main {
    record Order(String customer, String city, double total) { }

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("asha", "Pune", 1200),
            new Order("ravi", "Delhi", 300),
            new Order("meena", "Pune", 450),
            new Order("john", "Mumbai", 2200),
            new Order("asha", "Pune", 80));

        List<String> bigSpenders = orders.stream()
            .filter(o -> o.total() >= 400)          // keep some
            .map(Order::customer)                   // transform each
            .distinct()
            .sorted()
            .collect(Collectors.toList());
        System.out.println(bigSpenders);

        double revenue = orders.stream()
            .mapToDouble(Order::total)              // DoubleStream: sum/average built in
            .sum();
        System.out.println("revenue " + revenue);

        int product = Stream.of(1, 2, 3, 4)
            .reduce(1, (a, b) -> a * b);            // identity, then combine pairwise
        System.out.println("product " + product);
    }
}
Outputcompiled & run with real Java
[asha, john, meena]
revenue 4230.0
product 24
Your turn

Print the average order total with mapToDouble(Order::total).average(). Why does it return an OptionalDouble rather than a double?

Streams are lazy

Intermediate operations do nothing on their own; they only describe work. The terminal operation pulls elements through the whole pipeline one at a time, and stops as soon as it has its answer. That is why the trace below processes only three of the five names.

Visualizefilter, map and findFirst pull one element at a timeStep 1 / 7
Optional<String> first = Stream.of("ant", "bee", "cat", "dove", "eagle")
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.findFirst();
Line 1

Building the pipeline runs nothing. findFirst() starts pulling: the first element is "ant".

Variables now
element"ant"
All 7 steps as a table
StepLineWhat happenedVariables now
11Building the pipeline runs nothing. findFirst() starts pulling: the first element is "ant".element = "ant"
22"ant" has length 3, the filter rejects it. map never sees it. Pull the next one.element = "bee"
32"bee": rejected too. Next.element = "cat"
42"cat": rejected. Next.element = "dove"
52"dove" has length 4 — it passes the filter and flows on immediately.element = "dove"
63map turns it into "DOVE".element = "DOVE"
74findFirst has its answer and stops the stream. "eagle" is never read.first = Optional[DOVE]
javaMain.java
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        String result = Stream.of("ant", "bee", "cat", "dove", "eagle")
            .peek(s -> System.out.println("  read   " + s))
            .filter(s -> s.length() > 3)
            .peek(s -> System.out.println("  passed " + s))
            .map(String::toUpperCase)
            .findFirst()
            .orElse("none");
        System.out.println(result);
    }
}
Outputcompiled & run with real Java
  read   ant
  read   bee
  read   cat
  read   dove
  passed dove
DOVE

peek is a debugging aid that lets you watch elements flow. The output confirms the trace: each element goes through the whole pipeline before the next is read, and "eagle" is never touched.

Error you will hit

IllegalStateException: stream has already been operated upon or closed

java
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        Stream<String> words = Stream.of("a", "bb", "ccc");
        System.out.println(words.count());
        System.out.println(words.filter(w -> w.length() > 1).count());
    }
}
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
	at java.base/java.util.stream.AbstractPipeline.<init>(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline.<init>(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline$StatelessOp.<init>(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline$2.<init>(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline.filter(Unknown Source)
	at Main.main(Main.java:7)
Why the compiler said that

Read down to the first frame in your code: Main.java:7, the second pipeline. A stream is single-use, like an iterator. count() is a terminal operation; it consumed words, so the second pipeline has nothing to read.

The fix

Keep the source (a List) in a variable and call .stream() each time you need a new pipeline — or use a Supplier<Stream<String>>.

java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<String> words = List.of("a", "bb", "ccc");
        System.out.println(words.stream().count());
        System.out.println(words.stream().filter(w -> w.length() > 1).count());
    }
}
07

Collectors: groupingBy, toMap, joining and sorting

collect turns a stream into a container using a Collector. The Collectors class has the ones you need daily: toList() (or .toList() directly on the stream since Java 16, which returns an unmodifiable list), toSet(), joining, toMap, groupingBy and partitioningBy. This is the SQL GROUP BY of Java.

javaMain.java
import java.util.*;
import java.util.stream.*;

public class Main {
    record Sale(String region, String product, int units) { }

    public static void main(String[] args) {
        List<Sale> sales = List.of(
            new Sale("North", "laptop", 5),
            new Sale("South", "phone", 12),
            new Sale("North", "phone", 7),
            new Sale("East", "laptop", 2),
            new Sale("South", "laptop", 4));

        // region -> total units (TreeMap keeps keys sorted for printing)
        Map<String, Integer> unitsByRegion = sales.stream()
            .collect(Collectors.groupingBy(Sale::region, TreeMap::new,
                     Collectors.summingInt(Sale::units)));
        System.out.println(unitsByRegion);

        // product -> how many sale rows
        Map<String, Long> rowsByProduct = sales.stream()
            .collect(Collectors.groupingBy(Sale::product, TreeMap::new, Collectors.counting()));
        System.out.println(rowsByProduct);

        // true/false split
        Map<Boolean, List<String>> bigOrSmall = sales.stream()
            .collect(Collectors.partitioningBy(s -> s.units() >= 5,
                     Collectors.mapping(Sale::region, Collectors.toList())));
        System.out.println(bigOrSmall);

        String products = sales.stream().map(Sale::product).distinct().sorted()
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println(products);
    }
}
Outputcompiled & run with real Java
{East=2, North=12, South=16}
{laptop=3, phone=2}
{false=[East, South], true=[North, South, North]}
[laptop, phone]

Plain groupingBy(key) returns a HashMap, whose print order is unspecified. Passing TreeMap::new as the map factory gives sorted keys.

Sorting streams

sorted() uses natural order; sorted(comparator) takes any Comparator, and the Comparator.comparing(...).thenComparing(...).reversed() builders from Collections work unchanged. limit(n) after sorted gives a top-N.

javaMain.java
import java.util.*;
import java.util.stream.*;

public class Main {
    record Player(String name, int score, int age) { }

    public static void main(String[] args) {
        List<Player> players = List.of(
            new Player("Kiran", 88, 31), new Player("Leo", 95, 24),
            new Player("Mia", 88, 22), new Player("Noor", 72, 29));

        List<String> top3 = players.stream()
            .sorted(Comparator.comparingInt(Player::score).reversed()
                    .thenComparing(Player::name))
            .limit(3)
            .map(p -> p.name() + " " + p.score())
            .toList();
        System.out.println(top3);

        Map<String, Integer> ageByName = players.stream()
            .collect(Collectors.toMap(Player::name, Player::age, (a, b) -> a, TreeMap::new));
        System.out.println(ageByName);
    }
}
Outputcompiled & run with real Java
[Leo 95, Kiran 88, Mia 88]
{Kiran=31, Leo=24, Mia=22, Noor=29}
Error you will hit

IllegalStateException: Duplicate key North (Collectors.toMap)

java
import java.util.*;
import java.util.stream.*;

public class Main {
    record Sale(String region, int units) { }

    public static void main(String[] args) {
        List<Sale> sales = List.of(new Sale("North", 5), new Sale("South", 12), new Sale("North", 7));
        Map<String, Integer> byRegion = sales.stream()
            .collect(Collectors.toMap(Sale::region, Sale::units));
        System.out.println(byRegion);
    }
}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key North (attempted merging values 5 and 7)
	at java.base/java.util.stream.Collectors.duplicateKeyException(Unknown Source)
	at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$0(Unknown Source)
	at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(Unknown Source)
	at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(Unknown Source)
	at java.base/java.util.stream.AbstractPipeline.copyInto(Unknown Source)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(Unknown Source)
	at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(Unknown Source)
	at java.base/java.util.stream.AbstractPipeline.evaluate(Unknown Source)
	at java.base/java.util.stream.ReferencePipeline.collect(Unknown Source)
	at Main.main(Main.java:10)
Why the compiler said that

The two-argument toMap assumes every key is unique. "North" appears twice, and rather than silently dropping one value it throws, naming the key and both values.

The fix

Say what should happen to a clash with the third argument, a merge function: Collectors.toMap(Sale::region, Sale::units, Integer::sum) adds them up. If you really want groups, use groupingBy.

08

Optional, and when a loop is better

Optional<T> is a box that holds either one value or nothing. Stream operations that may find nothing — findFirst, max, min, reduce without an identity — return one, so the "no result" case is visible in the type instead of hidden as a null. Use it as a return type; do not use it for fields or parameters.

javaMain.java
import java.util.*;

public class Main {
    record User(String name, String email) { }

    static Optional<User> findByName(List<User> users, String name) {
        return users.stream().filter(u -> u.name().equals(name)).findFirst();
    }

    public static void main(String[] args) {
        List<User> users = List.of(new User("asha", "[email protected]"), new User("ravi", null));

        System.out.println(findByName(users, "asha").map(User::email).orElse("no email"));
        System.out.println(findByName(users, "zara").map(User::email).orElse("no such user"));

        // map skips a null result: Optional.ofNullable semantics
        System.out.println(findByName(users, "ravi").map(User::email).orElse("no email"));

        findByName(users, "asha").ifPresent(u -> System.out.println("hello " + u.name()));
        String name = findByName(users, "zara").map(User::name).orElseGet(() -> "guest");
        System.out.println(name);
    }
}
Outputcompiled & run with real Java
[email protected]
no such user
no email
hello asha
guest

orElse(x) always evaluates x; orElseGet(() -> x) only computes it when empty — use it when the fallback is expensive.

Error you will hit

NoSuchElementException: No value present

java
import java.util.*;

public class Main {
    public static void main(String[] args) {
        List<Integer> scores = List.of();
        int best = scores.stream().max(Integer::compare).get();
        System.out.println(best);
    }
}
Exception in thread "main" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.get(Unknown Source)
	at Main.main(Main.java:6)
Why the compiler said that

The list is empty, so max returned an empty Optional, and get() on an empty Optional throws. Calling get() without checking throws away everything Optional was meant to protect you from.

The fix

Say what to do when there is nothing: .orElse(0), .orElseThrow(() -> new IllegalStateException("no scores")) with a clear message, or ifPresent(...).

When a loop is better

Streams are excellent for "take this collection, filter, transform, summarise". They are not a replacement for every loop. Rewrite a loop as a stream only when the result is clearer to a reader.

Reach for a stream

  • Filter / map / group / count over a collection
  • The result is a new collection or a single summary value
  • Each element is handled independently
  • Chained steps read like a sentence

Keep the loop

  • You need the index, or to look at the previous element
  • You must break out early on a complex condition, or update several variables
  • The body throws checked exceptions (lambdas cannot throw them without wrapping)
  • The stream version needs peek, nested flatMap gymnastics or mutable state to work
javaMain.java
public class Main {
    public static void main(String[] args) {
        int[] prices = { 100, 104, 101, 110, 108, 115 };

        // Largest single-day rise: needs the previous element -> a loop is clearest
        int bestRise = 0, bestDay = -1;
        for (int i = 1; i < prices.length; i++) {
            int rise = prices[i] - prices[i - 1];
            if (rise > bestRise) {
                bestRise = rise;
                bestDay = i;
            }
        }
        System.out.println("best rise " + bestRise + " on day " + bestDay);

        // Count of days above 105: independent per element -> a stream is clearest
        long above = java.util.Arrays.stream(prices).filter(p -> p > 105).count();
        System.out.println(above + " days above 105");
    }
}
Outputcompiled & run with real Java
best rise 9 on day 3
3 days above 105
In real jobs
Code reviewers push back on clever one-line streams nobody can debug. Keep pipelines short, name intermediate results, and prefer method references. For large data volumes, the same filter/map/group ideas scale out in Spark — see the Data Engineering course.
Type parameter
A placeholder like T in class Box<T> or <T> T first(...), filled in by the user of the type.
Bounded type
<T extends X>: T must be X or a subtype, so X's methods can be called on it.
Wildcard
? in a type argument: ? extends T (read-only producer), ? super T (write-only consumer).
PECS
Producer Extends, Consumer Super — the rule for picking a wildcard direction.
Type erasure
The compiler removes generic type arguments after checking them, so they do not exist at runtime.
Lambda
An inline function (args) -> body implementing a functional interface.
Functional interface
An interface with exactly one abstract method, such as Function, Predicate, Runnable.
Method reference
Shorthand for a lambda that just calls a method: String::length, ArrayList::new.
Stream
A lazy, single-use pipeline: source, intermediate operations, one terminal operation.
Collector
A recipe for collect: toList, groupingBy, toMap, joining.
Optional
A container of zero or one value, used as a return type for "might be absent".
Quick check

A method should accept a list and add Integers to it. Which parameter type lets callers pass List<Integer>, List<Number> and List<Object>?

Quick check

What does this print? Stream.of(1, 2, 3).peek(System.out::print).filter(n -> n > 1).findFirst();

Frequently asked questions

What does PECS mean in Java generics?
Producer Extends, Consumer Super. If a generic parameter produces values that your method reads, declare it with ? extends T. If it consumes values that your method writes into it, declare it with ? super T. Collections.copy(List dest, List src) is the textbook example.
Are Java streams faster than for loops?
Usually not; for simple work over small collections a plain loop is as fast or slightly faster. Streams win on readability for filter/map/group pipelines, and parallel streams can help for large CPU-bound workloads. Choose by clarity, and measure before optimising.
Why can't I create new T() in a Java generic method?
Because of type erasure: generic type arguments are removed after compilation, so at runtime the method does not know what T is and cannot call its constructor. Pass a Supplier such as ArrayList::new, or a Class object, and create instances through it.

Finish the Java handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.