WL
Java Full Stack Developer
Wassim Lagnaoui

Lesson 06: Java Streams & Lambdas (Essentials)

Transform how you process data with functional programming: use lambda expressions and stream operations to write cleaner, more expressive code.

Introduction

Imagine you have a list of 1000 students and you need to find all computer science majors with a GPA above 3.5, then get their names in alphabetical order. With traditional loops, you'd write multiple for-loops, if-statements, and sorting logic spread across many lines of code. Java Streams and Lambda expressions let you express this same complex data processing in just a few readable lines that read almost like English. Streams provide a modern, functional approach to processing collections of data, while lambda expressions give you a concise way to define small functions inline without creating separate methods. This functional programming style makes your code more readable, less error-prone, and often more efficient. Instead of focusing on how to loop through data step-by-step, you describe what transformations you want to apply to your data. Once you master streams and lambdas, you'll find yourself writing cleaner, more expressive code that's easier to understand and maintain.

Lambda Expressions

Definition

A lambda expression is a concise way to represent a function without formally declaring it as a method. It's essentially an anonymous function that you can pass around as a parameter to other methods. Lambda expressions consist of parameters, an arrow operator (->), and a body that contains the logic. They allow you to write functional-style code that's more readable and expressive than traditional anonymous classes.

Analogy

A lambda expression is like giving someone quick verbal instructions instead of writing a formal manual. Imagine you're at a coffee shop and need to tell the barista how to make your custom drink. Instead of writing out a detailed recipe with numbered steps, title, and formal instructions (like a traditional method), you simply say "take the espresso, add steamed milk, and sprinkle cinnamon on top." The barista understands exactly what to do from these concise instructions. Similarly, when you need a simple function to transform or filter data, instead of creating a full method with a name, return type, and formal declaration, you can use a lambda expression to provide quick, inline instructions. Just like the barista follows your verbal instructions immediately without needing to reference a written manual, Java can execute lambda expressions directly where they're needed without requiring a separate method definition.

Examples

Basic Lambda Syntax

This example shows the basic syntax of lambda expressions with different parameter combinations:

Lambda with single parameter:

// Takes a string, returns its length (parentheses optional for single parameter)
Function<String, Integer> getLength = name -> name.length();
System.out.println(getLength.apply("Java")); // prints: 4

Lambda with multiple parameters:

// Takes two integers, returns their sum
BinaryOperator<Integer> add = (a, b) -> a + b;
System.out.println(add.apply(5, 3)); // prints: 8

Lambda vs Traditional Approach

This example compares lambda expressions with the traditional anonymous class approach:

Traditional anonymous class (verbose):

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// Old way - lots of boilerplate code
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return a.length() - b.length(); // compare by length
    }
});

Modern lambda expression (concise):

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// New way - much shorter and cleaner
Collections.sort(names, (a, b) -> a.length() - b.length());
System.out.println(names); // [Bob, Alice, Charlie]

Lambda with Custom Objects

This example demonstrates how to use lambdas with custom objects:

Extract student names:

// Lambda to get name from student object
Function<Student, String> getName = student -> student.name;
System.out.println("First student: " + getName.apply(students.get(0)));

Check if student has high GPA:

// Lambda to test if GPA is high enough
Predicate<Student> hasHighGPA = student -> student.gpa >= 3.5;
System.out.println("Alice has high GPA: " + hasHighGPA.test(alice));

Sort students by age:

// Lambda to compare students by age
students.sort((s1, s2) -> s1.age - s2.age);
System.out.println("Sorted by age: " + students);

Streams Introduction

Definition

A Stream is a sequence of data elements that can be processed using functional-style operations. Streams don't store data themselves - they process data from a source like a collection, array, or file. You can chain multiple operations together to transform, filter, or aggregate data in a single fluent expression. Streams are lazy, meaning they only process data when a terminal operation is called, making them efficient for large datasets.

Analogy

A Stream is like an assembly line in a factory where products move through different workstations for processing. The conveyor belt doesn't store the products - it just moves them from one processing station to the next. At each station, workers might inspect products (filter), modify them (map), or count them (reduce). You can set up multiple stations in sequence: first station removes defective items, second station paints them blue, third station adds labels, and finally someone counts the finished products. The assembly line only starts moving when you actually need the final products - this is like how streams are "lazy" and don't process anything until you ask for results. Just like you can design an assembly line by specifying what happens at each station without actually running it, you can design a stream pipeline by chaining operations together without actually processing the data until you need the final result.

Examples

Creating Streams

This example shows different ways to create streams from various data sources:

Create stream from a list:

List<String> fruits = Arrays.asList("apple", "banana", "cherry");
Stream<String> fruitStream = fruits.stream(); // convert list to stream
System.out.println("Fruits: " + fruits);

Create stream from individual values:

Stream<Integer> numberStream = Stream.of(1, 2, 3, 4, 5); // create from values
System.out.println("Created number stream with 5 elements");

Traditional vs Stream Approach

This example compares the traditional imperative approach with the modern stream approach:

Traditional approach (imperative):

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// Find even numbers and double them - the old way
List<Integer> result1 = new ArrayList<>(); // create empty list
for (Integer num : numbers) { // loop through each number
    if (num % 2 == 0) { // check if even
        result1.add(num * 2); // double it and add to result
    }
}
System.out.println("Traditional result: " + result1);

Stream approach (declarative):

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// Same logic in one readable line
List<Integer> result2 = numbers.stream() // create stream from list
    .filter(num -> num % 2 == 0) // keep only even numbers
    .map(num -> num * 2) // double each number
    .collect(Collectors.toList()); // collect into new list
System.out.println("Stream result: " + result2); // [4, 8, 12, 16, 20]

Filter Operation

Definition

The filter operation allows you to select only the elements from a stream that match a given condition (predicate). It takes a lambda expression that returns true or false for each element, keeping only those elements where the condition evaluates to true. Filter is an intermediate operation, meaning it returns a new stream and can be chained with other operations. The original data remains unchanged.

Analogy

The filter operation is like a security checkpoint at an airport where only certain people are allowed through. The security guard (your lambda expression) checks each person (data element) against specific criteria - maybe checking if they have a valid boarding pass and ID. People who meet the criteria pass through to the departure gate, while those who don't are turned away. The security checkpoint doesn't change the people in any way - it simply decides who gets to continue and who doesn't. Similarly, the filter operation examines each element in your data stream and only lets through those that satisfy your specified condition. The original data remains unchanged; you just get a new stream containing only the elements that "passed the test."

Examples

Basic Filtering

This example shows simple filtering operations on different types of data:

Filter even numbers:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> evenNumbers = numbers.stream()
    .filter(num -> num % 2 == 0) // keep only even numbers
    .collect(Collectors.toList());
System.out.println("Even numbers: " + evenNumbers); // [2, 4, 6, 8, 10]

Filter numbers greater than 5:

List<Integer> largeNumbers = numbers.stream()
    .filter(num -> num > 5) // keep only numbers > 5
    .collect(Collectors.toList());
System.out.println("Numbers > 5: " + largeNumbers); // [6, 7, 8, 9, 10]

Filter long words:

List<String> words = Arrays.asList("cat", "elephant", "dog", "butterfly", "ant");

List<String> longWords = words.stream()
    .filter(word -> word.length() > 3) // keep words longer than 3 chars
    .collect(Collectors.toList());
System.out.println("Long words: " + longWords); // [elephant, butterfly]

Filter words containing specific letter:

List<String> wordsWithT = words.stream()
    .filter(word -> word.contains("t")) // keep words containing 't'
    .collect(Collectors.toList());
System.out.println("Words with 't': " + wordsWithT); // [cat, elephant, butterfly, ant]

Filtering Custom Objects

This example demonstrates filtering collections of custom objects based on their properties:

Filter expensive products:

List<Product> products = Arrays.asList(
    new Product("Laptop", 999.99, "Electronics"),
    new Product("Book", 15.99, "Education"),
    new Product("Coffee", 4.99, "Food")
);

List<Product> expensiveProducts = products.stream()
    .filter(product -> product.price > 50) // keep expensive products
    .collect(Collectors.toList());
System.out.println("Expensive products: " + expensiveProducts);

Filter by category:

List<Product> electronics = products.stream()
    .filter(product -> product.category.equals("Electronics")) // keep only electronics
    .collect(Collectors.toList());
System.out.println("Electronics: " + electronics);

Filter by name starting with letter:

List<Product> productsStartingWithC = products.stream()
    .filter(product -> product.name.startsWith("C")) // keep names starting with 'C'
    .collect(Collectors.toList());
System.out.println("Products starting with 'C': " + productsStartingWithC);

Multiple Filter Conditions

This example shows how to combine multiple filter conditions:

Chain multiple filters:

List<Employee> result1 = employees.stream()
    .filter(emp -> emp.department.equals("Engineering")) // first filter: engineering dept
    .filter(emp -> emp.salary > 70000) // second filter: high salary
    .filter(emp -> emp.age < 30) // third filter: young age
    .collect(Collectors.toList());
System.out.println("Young high-paid engineers: " + result1);

Combine conditions with logical operators:

List<Employee> result2 = employees.stream()
    .filter(emp -> emp.department.equals("Engineering") && // all conditions in one filter
                  emp.salary > 70000 &&
                  emp.age < 30)
    .collect(Collectors.toList());
System.out.println("Same result, different approach: " + result2);

Complex condition with OR logic:

List<Employee> result3 = employees.stream()
    .filter(emp -> emp.salary > 80000 || // high salary OR
                  (emp.age > 30 && emp.department.equals("Marketing"))) // senior marketing
    .collect(Collectors.toList());
System.out.println("High earners or senior marketing: " + result3);

Map Operation

Definition

The map operation transforms each element in a stream by applying a function to it, producing a new stream with the transformed elements. It's a one-to-one transformation where each input element becomes exactly one output element, though the type can change. Map is an intermediate operation that doesn't change the size of the stream but can change the type and content of elements. The original data remains unchanged.

Analogy

The map operation is like a photo editing filter that you apply to every picture in your album. Imagine you have a folder with 100 photos and you want to convert them all to black and white. You run each photo through the same black-and-white filter, and you end up with 100 transformed photos - same number of pictures, but each one has been changed in the same way. The original photos aren't modified; you get a new set of processed photos. Similarly, when you map a stream of student objects to their names, you're applying a "get name" transformation to each student object, resulting in a stream of strings. You still have the same number of elements (one name for each student), but the type and content have been transformed from Student objects to String values.

Examples

Basic Data Transformation

This example shows simple transformations on different types of data:

Square each number:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> squares = numbers.stream()
    .map(num -> num * num) // square each number
    .collect(Collectors.toList());
System.out.println("Squares: " + squares); // [1, 4, 9, 16, 25]

Convert strings to uppercase:

List<String> words = Arrays.asList("hello", "world", "java");
List<String> upperWords = words.stream()
    .map(word -> word.toUpperCase()) // convert to uppercase
    .collect(Collectors.toList());
System.out.println("Uppercase: " + upperWords); // [HELLO, WORLD, JAVA]

Get length of each word:

List<Integer> lengths = words.stream()
    .map(word -> word.length()) // get length of each word
    .collect(Collectors.toList());
System.out.println("Word lengths: " + lengths); // [5, 5, 4]

Object Property Extraction

This example demonstrates extracting specific properties from objects:

Extract first names from Person objects:

List<String> firstNames = people.stream()
    .map(person -> person.firstName) // extract first name
    .collect(Collectors.toList());
System.out.println("First names: " + firstNames); // [John, Jane, Bob]

Extract ages from Person objects:

List<Integer> ages = people.stream()
    .map(person -> person.age) // extract age
    .collect(Collectors.toList());
System.out.println("Ages: " + ages); // [25, 30, 35]

Create full names by combining first and last:

List<String> fullNames = people.stream()
    .map(person -> person.firstName + " " + person.lastName) // combine names
    .collect(Collectors.toList());
System.out.println("Full names: " + fullNames); // [John Doe, Jane Smith, Bob Johnson]

Generate email addresses from names:

List<String> emails = people.stream()
    .map(person -> person.firstName.toLowerCase() + "." +
                  person.lastName.toLowerCase() + "@company.com") // create email format
    .collect(Collectors.toList());

Type Conversion and Complex Transformations

This example shows how to convert between different types:

Convert strings to integers:

List<String> numberStrings = Arrays.asList("1", "2", "3", "4", "5");
List<Integer> numbers = numberStrings.stream()
    .map(str -> Integer.parseInt(str)) // convert string to integer
    .collect(Collectors.toList());
System.out.println("Converted numbers: " + numbers); // [1, 2, 3, 4, 5]

Calculate total value for each order:

List<Double> totalValues = orders.stream()
    .map(order -> order.getTotalValue()) // calculate total for each order
    .collect(Collectors.toList());
System.out.println("Order total values: " + totalValues);

Create formatted summary strings:

List<String> orderSummaries = orders.stream()
    .map(order -> order.product + ": $" + String.format("%.2f", order.getTotalValue()))
    .collect(Collectors.toList());

Chain multiple transformations together:

List<String> expensiveItems = orders.stream()
    .map(order -> order.getTotalValue()) // first: get total value
    .filter(total -> total > 100) // then: filter expensive ones
    .map(total -> "Expensive: $" + String.format("%.2f", total)) // format as string
    .collect(Collectors.toList());

Reduce Operation

Definition

The reduce operation combines all elements in a stream into a single result by repeatedly applying a combining function. It takes two parameters at a time, combines them according to your specified logic, and uses the result to combine with the next element, continuing until all elements are processed. Reduce is a terminal operation that produces a single value from many values, such as finding a sum, maximum, minimum, or concatenating strings.

Analogy

The reduce operation is like making a smoothie from multiple fruits. You start with a collection of different fruits - apples, bananas, strawberries, and oranges. To make a smoothie, you put the first two fruits in a blender and blend them together. Then you take that blended result and add the third fruit, blend again. You continue this process, always taking the current blended mixture and adding the next fruit, until you've processed all the fruits and end up with one final smoothie. The "blending function" defines how to combine two items (in programming, this is your lambda expression), and you apply this same combining logic repeatedly until you reduce your entire collection down to a single result. Just like the smoothie combines the flavors and nutrients of all the individual fruits, the reduce operation combines all your data elements using your specified combining logic.

Examples

Basic Mathematical Reductions

This example shows common mathematical operations using reduce:

Sum all numbers:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sum = numbers.stream()
    .reduce(0, (a, b) -> a + b); // start with 0, add each number
System.out.println("Sum: " + sum); // 55

Find maximum number:

Optional<Integer> max = numbers.stream()
    .reduce((a, b) -> a > b ? a : b); // compare and keep larger
System.out.println("Max: " + max.orElse(-1)); // 10

Find minimum number:

Optional<Integer> min = numbers.stream()
    .reduce((a, b) -> a < b ? a : b); // compare and keep smaller
System.out.println("Min: " + min.orElse(-1)); // 1

Multiply all numbers together:

int product = numbers.stream()
    .reduce(1, (a, b) -> a * b); // start with 1, multiply each
System.out.println("Product: " + product); // 3628800

Count elements (alternative to count() method):

int count = numbers.stream()
    .reduce(0, (total, num) -> total + 1); // start with 0, add 1 for each element
System.out.println("Count: " + count); // 10

String Operations with Reduce

This example demonstrates how to use reduce with strings:

Concatenate all words into a sentence:

List<String> words = Arrays.asList("Java", "is", "awesome", "for", "programming");
String sentence = words.stream()
    .reduce("", (result, word) -> result + " " + word); // start empty, add each word
System.out.println("Sentence:" + sentence); // " Java is awesome for programming"

Concatenate with proper formatting:

String properSentence = words.stream()
    .reduce("", (result, word) ->
        result.isEmpty() ? word : result + " " + word); // don't add space before first word
System.out.println("Proper sentence: " + properSentence); // "Java is awesome for programming"

Find longest word:

Optional<String> longestWord = words.stream()
    .reduce((word1, word2) ->
        word1.length() >= word2.length() ? word1 : word2); // keep longer word
System.out.println("Longest word: " + longestWord.orElse("none")); // "programming"

Create acronym from first letters:

String acronym = words.stream()
    .map(word -> word.substring(0, 1).toUpperCase()) // get first letter, uppercase
    .reduce("", (result, letter) -> result + letter); // concatenate all letters
System.out.println("Acronym: " + acronym); // "JIAFP"

Build a formatted report:

String report = words.stream()
    .reduce("Word Report:\n", (result, word) ->
        result + "- " + word + " (" + word.length() + " chars)\n");
System.out.println(report);

Complex Object Reductions

This example shows how to use reduce with custom objects:

Calculate total revenue from sales:

double totalRevenue = sales.stream()
    .map(sale -> sale.getTotalValue()) // transform to total values
    .reduce(0.0, (sum, value) -> sum + value); // sum all values
System.out.println("Total revenue: $" + String.format("%.2f", totalRevenue));

Find highest value sale:

Optional<Sale> highestSale = sales.stream()
    .reduce((sale1, sale2) ->
        sale1.getTotalValue() > sale2.getTotalValue() ? sale1 : sale2);
System.out.println("Highest sale: " + highestSale.orElse(null));

Count total items sold:

int totalItems = sales.stream()
    .map(sale -> sale.quantity) // extract quantities
    .reduce(0, (total, qty) -> total + qty); // sum all quantities
System.out.println("Total items sold: " + totalItems);

Build formatted sales summary:

String salesSummary = sales.stream()
    .map(sale -> sale.product + ": $" + String.format("%.2f", sale.getTotalValue()))
    .reduce("Sales Summary:\n", (summary, line) ->
        summary + "- " + line + "\n"); // build formatted report
System.out.println(salesSummary);

Method References

Definition

Method references are a shorthand notation for lambda expressions that call a specific method. When your lambda expression does nothing but call an existing method, you can replace it with a method reference using the `::` operator. There are four types: static method references (Class::method), instance method references on specific objects (object::method), instance method references on arbitrary objects (Class::method), and constructor references (Class::new).

Analogy

Method references are like using speed dial on your phone instead of typing out the full number every time. Imagine you frequently call your best friend, and instead of dialing their full 10-digit number each time, you program speed dial #1 to automatically call them. When you press "1" and call, the phone knows exactly which number to dial - you don't need to specify the digits again. Similarly, when you have a lambda expression that simply calls an existing method like `name -> name.toUpperCase()`, you can use the method reference shorthand `String::toUpperCase`. Both do exactly the same thing, but the method reference is more concise, just like speed dial is more convenient than typing the full number. The `::` operator is like the speed dial button that connects directly to the method you want to call.

Examples

Static Method References

This example shows how to use method references for static methods:

Parse strings to integers using lambda:

List<String> numberStrings = Arrays.asList("1", "2", "3", "4", "5");
List<Integer> numbersLambda = numberStrings.stream()
    .map(str -> Integer.parseInt(str)) // lambda calls static method
    .collect(Collectors.toList());
System.out.println("With lambda: " + numbersLambda);

Same operation using method reference:

List<Integer> numbersMethodRef = numberStrings.stream()
    .map(Integer::parseInt) // method reference to static method
    .collect(Collectors.toList());
System.out.println("With method reference: " + numbersMethodRef);

Convert numbers to strings with lambda:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<String> stringsLambda = numbers.stream()
    .map(num -> String.valueOf(num)) // lambda calls static method
    .collect(Collectors.toList());

Same using method reference:

List<String> stringsMethodRef = numbers.stream()
    .map(String::valueOf) // method reference to static method
    .collect(Collectors.toList());
System.out.println("Method ref conversion: " + stringsMethodRef);

Instance Method References

This example demonstrates instance method references on both specific objects and arbitrary objects:

Convert to uppercase with lambda:

List<String> words = Arrays.asList("hello", "world", "java", "stream");
List<String> upperLambda = words.stream()
    .map(word -> word.toUpperCase()) // lambda calls instance method
    .collect(Collectors.toList());

Same with method reference:

List<String> upperMethodRef = words.stream()
    .map(String::toUpperCase) // method reference to instance method
    .collect(Collectors.toList());
System.out.println("Method ref uppercase: " + upperMethodRef);

Get string lengths with lambda:

List<Integer> lengthsLambda = words.stream()
    .map(word -> word.length()) // lambda calls instance method
    .collect(Collectors.toList());

Same with method reference:

List<Integer> lengthsMethodRef = words.stream()
    .map(String::length) // method reference to instance method
    .collect(Collectors.toList());
System.out.println("Method ref lengths: " + lengthsMethodRef);

Print with specific object method reference:

PrintStream printer = System.out; // specific object reference
// Lambda version
words.stream().forEach(word -> printer.println(word)); // lambda calls instance method
// Method reference version
words.stream().forEach(printer::println); // method reference to specific object

Constructor References

This example shows constructor references and getter method references:

Create Person objects with lambda:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Person> personsLambda = names.stream()
    .map(name -> new Person(name)) // lambda calls constructor
    .collect(Collectors.toList());

Same with constructor reference:

List<Person> personsMethodRef = names.stream()
    .map(Person::new) // constructor reference
    .collect(Collectors.toList());
System.out.println("Constructor reference: " + personsMethodRef);

Extract names with lambda:

List<Person> people = Arrays.asList(/* person objects */);
List<String> extractedNamesLambda = people.stream()
    .map(person -> person.getName()) // lambda calls getter
    .collect(Collectors.toList());

Same with method reference:

List<String> extractedNamesMethodRef = people.stream()
    .map(Person::getName) // method reference to getter
    .collect(Collectors.toList());
System.out.println("Method ref name extraction: " + extractedNamesMethodRef);

Sort using static method reference:

List<Person> sortedPeople = new ArrayList<>(people);
sortedPeople.sort(Person::compareByAge); // static method reference for comparison
System.out.println("Sorted by age: " + sortedPeople);

Array creation with constructor reference:

// Lambda version
String[] arrayLambda = names.stream()
    .toArray(size -> new String[size]); // lambda creates array
// Method reference version
String[] arrayMethodRef = names.stream()
    .toArray(String[]::new); // constructor reference for array

Summary

Java Streams and Lambda expressions revolutionize how you work with collections and data processing by introducing functional programming concepts to Java. Lambda expressions provide a concise way to write inline functions, making your code more readable and expressive when working with operations like filtering, mapping, and reducing data. Streams offer a powerful pipeline approach where you can chain operations together to transform data in a declarative style - you describe what you want rather than how to achieve it step by step. The filter operation lets you select elements based on conditions, map transforms each element, and reduce combines all elements into a single result. Method references provide an even more concise syntax when your lambdas simply call existing methods. These concepts work together to make data processing code more maintainable, readable, and often more efficient than traditional imperative approaches.

Programming Challenge

Challenge: Student Grade Processor

Task: Create a program that processes a list of students and their grades using streams and lambda expressions.

Requirements:

  1. Create a Student class with fields: name (String), subject (String), and grade (double)
  2. Create a list of at least 8 students with different subjects (Math, Science, English, History) and grades
  3. Use streams to accomplish the following:
    • Find all students with grades above 85
    • Get a list of all unique subjects
    • Calculate the average grade for each subject
    • Find the student with the highest grade in each subject
    • Create a summary report showing: total students, average overall grade, and count by subject
  4. Use lambda expressions for all filtering and transformation operations
  5. Use method references where appropriate

Bonus: Add a method to find the top 3 students overall and display them in a formatted table.

Learning Goals: Practice chaining stream operations, using different types of lambda expressions, applying method references, and combining multiple functional programming concepts in a realistic scenario.