Step 01: Getting Started with Functional Programming with Java #
- Functional programming is introduced as a different approach from traditional programming.
Key Objectives #
- To understand how functional programming differs from traditional programming.
- Introduction to basic functional programming concepts:
- Streams
- Lambda Expressions
- Method References
Hands-On Approach #
The course adopts a hands-on approach, starting with the creation of a new Java project in IntelliJ suitable for functional programming.
Setting Up the Java Project #
-
Open Eclipse and select or create a new workspace.
-
Create a New Java Project:
- Navigate to
File > New > Java Project. - Name the project
functional-programming-with-java. - Ensure the Java version is set to Java 9 or above (Java 9, 10, 11, 12 are compatible).
- Navigate to
Writing the First Code: #
Problem Statement: #
- Objective: Print a set of numbers, each on a separate line.
Steps to Solve the Problem: #
-
Create a New Java Class:
- Go to
File > New > Class. - Name the class
FP01Structured. - Package name:
programming. - Include a
mainmethod by checking the appropriate box.
public class FP01Structured { public static void main(String[] args) { printAllNumbersInListStructured(List.of(12, 9, 13, 4, 6, 2, 4, 12, 15)); } } - Go to
-
Method to Print Numbers:
- Define a method printAllNumbersInListStructured(List
<Integer>numbers).
private static void printAllNumbersInListStructured(List`<Integer>` numbers) { for (int number : numbers) { System.out.println(number); } } - Define a method printAllNumbersInListStructured(List
-
Run the Program:
- Right-click on the file in Eclipse.
- Select Run As > Java Application.
- Check the console for output.
The traditional approach focuses on "how" to achieve tasks, like looping through numbers.
Step 02: Writing Your First Java Functional Program #
Key Objectives: #
- Transition from a traditional programming approach to a functional approach in Java.
- Learn how to print a list of numbers using streams and method references in functional programming.
- Understand the functional programming focus on "what to do" rather than "how to do it".
Problem Statement: #
Objective: Print a set of numbers from a list using a functional programming approach.
We previously solved this problem using a structured loop. Now, we will refactor the solution using functional programming.
1. Refactoring the Code to Use Streams #
- In functional programming, we focus on "what to do" rather than "how to do it".
- Instead of explicitly looping through each element in the list, we can leverage streams.
Refactoring the Method: #
private static void printAllNumbersInList(List`<Integer>` numbers) {
numbers.stream()
.forEach(FP01Functional::print); // Method reference
}
numbers.stream(): Converts the list of numbers into a stream, which is a sequence of elements..forEach(): Applies the specified action (in this case, printing) to each element in the stream.FP01Functional::print: This is a method reference to the print method, which will be used to print each number.
2. Creating the print Method #
- The print method is a simple helper method that prints each number passed to it.
private static void print(int number) {
System.out.println(number);
}
- This method takes an integer and prints it to the console using
System.out.println(). - The method is referenced inside the stream's
forEach()method to print each number from the list.
Understanding Key Functional Programming Concepts #
Streams: A stream is a sequence of elements that supports various operations to process data in a declarative way. In this case, we convert the list of numbers into a stream to process each element without manually looping.
Method References (::): Instead of passing a full lambda expression or explicitly calling a method inside forEach(), we use a method reference (FP01Functional::print). This is a shorthand for telling Java to execute the print method for each element in the stream.
> When the program is run, it will print each number in the list as output. #
Full Code Example: #
public class FP01Functional {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
printAllNumbersInList(numbers);
}
private static void printAllNumbersInList(List<Integer> numbers) {
numbers.stream() // Convert the list to a stream
.forEach(FP01Functional::print); // Print each number using method reference
}
private static void print(int number) {
System.out.println(number); // Print the number
}
}
Output: #
12
9
13
4
6
2
4
12
15
Step 03: Improving Java Functional Program with filter #
Key Objectives #
- Understand how to simplify the existing functional code.
- Learn how to filter data in a functional programming approach using streams.
- Compare the structured approach and functional approach for filtering even numbers from a list.
Problem Statement: #
Objective: Print only the even numbers from a list of numbers using both structured and functional programming approaches.
We will improve upon the functional program by adding a filter to select only even numbers.
1. Simplifying the Existing Functional Code #
- In the previous lecture, we used a custom method (
print) to print each number in the list. - We can simplify this approach further by directly using
System.out::println.
Updated Functional Code: #
numbers.stream()
.forEach(System.out::println); // Simplified method reference
- Instead of defining a separate
printmethod, we can use Java’s built-in method referenceSystem.out::println. - This reduces complexity and makes the code more concise.
2. Problem - Printing Even Numbers in Structured Approach #
- Let's go back to the structured approach in the
FP01Structuredclass and modify it to print only even numbers.
Structured Code: #
private static void printEvenNumbersInListStructured(List<Integer> numbers) {
for (int number : numbers) {
if (number % 2 == 0) {
System.out.println(number);
}
}
}
- The condition
number % 2 == 0checks whether a number is even by dividing the number by 2 and checking if the remainder is 0. - If the number is even, it gets printed.
- When this structured approach runs, the output will only display even numbers from the list.
3. Printing Even Numbers in the Functional Approach #
- Now, let's implement the same functionality using a functional approach with streams.
Refactoring to Use a Filter: #
private static void printEvenNumbersInListFunctional(List<Integer> numbers) {
numbers.stream() // Convert the list to a stream
.filter(FP01Functional::isEven) // Filter to select even numbers
.forEach(System.out::println); // Print each even number
}
numbers.stream(): This converts the list of numbers into a stream.filter(FP01Functional::isEven): This applies a filter to the stream, only allowing numbers that pass the isEven condition.forEach(System.out::println): After filtering, the remaining even numbers are printed.
4. Creating the isEven Method #
- We need a method to check if a number is even, which will be used in the
filter()function.
Method to Check Even Numbers: #
private static boolean isEven(int number) {
return number % 2 == 0;
}
- The method checks if a number is divisible by 2 without a remainder.
- If the condition is true, it returns
true, meaning the number is even.
Method Reference: #
In the functional approach, we pass the method reference FP01Functional::isEven into the filter() method, allowing the stream to filter numbers based on this condition.
5. Full Functional Code #
public class FP01Functional {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
printEvenNumbersInListFunctional(numbers);
}
private static void printEvenNumbersInListFunctional(List<Integer> numbers) {
numbers.stream() // Convert the list to a stream
.filter(FP01Functional::isEven) // Filter only even numbers
.forEach(System.out::println); // Print each number
}
private static boolean isEven(int number) {
return number % 2 == 0; // Check if the number is even
}
}
Output: #
12
4
6
2
4
12
- The list is filtered to contain only even numbers, which are then printed.
Structured vs Functional Approach: #
Structured Approach: #
- We used a traditional
forloop and anifstatement to manually check each number and print the even numbers.
Functional Approach: #
- We used a stream, applied a filter to select only even numbers, and then printed them using
forEach()with a method reference toSystem.out::println.
Step 04: Using Lambda Expression to enhance your Functional Program #
Key Objectives #
- Learn about Lambda Expressions and how they simplify functional programming in Java.
- Replace the method reference (
isEven) with a Lambda Expression. - Understand how Lambda Expressions allow you to define logic directly within functional constructs.
Introduction to Lambda Expressions #
- In the previous step, we used a separate method (
isEven) to filter even numbers from the list. - While this works, it introduces unnecessary complexity since the logic is very simple.
- In this step, we’ll learn how to simplify this process using a Lambda Expression, a feature in Java that allows you to write the logic of a method in a more concise way, without needing to define a separate method.
1. Refactoring the filter to Use a Lambda Expression #
- Instead of using a method reference like
FP01Functional::isEven, we will define the logic for filtering even numbers directly inside thefilter()method using a Lambda Expression.
Lambda Expression Syntax: #
numbers.stream()
.filter(number -> number % 2 == 0) // Lambda Expression
.forEach(System.out::println); // Print the even numbers
number ->: This is the Lambda Expression syntax. It means we are passing number as an argument to the function.number % 2 == 0: The logic checks whether the number is even (i.e., divisible by 2).filter(): Filters out only the even numbers.forEach(System.out::println): Prints the filtered numbers.- Instead of defining a separate method (isEven), we now define the entire logic inside the
filter()method. - The expression
number -> number % 2 == 0takes each number, checks if it is even, and returns true or false. - If the number is even (true), it passes through the filter and gets printed. If not, it is ignored.
2. Replacing the isEven Method #
- Previously, we had a method called
isEventhat checked whether a number was divisible by 2. - Now, this method is no longer needed, as the logic is directly handled by the Lambda Expression.
Old Code with isEven Method: #
private static boolean isEven(int number) {
return number % 2 == 0;
}
- This method can now be commented out or removed, as the Lambda Expression handles the same logic with fewer lines of code.
3. Full Functional Code with Lambda Expression #
- Here is the full code that uses a Lambda Expression instead of a method reference:
public class FP01Functional {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
printEvenNumbersInListFunctional(numbers);
}
private static void printEvenNumbersInListFunctional(List<Integer> numbers) {
numbers.stream() // Convert the list to a stream
.filter(number -> number % 2 == 0) // Lambda Expression to filter even numbers
.forEach(System.out::println); // Print each even number
}
}
Output: #
12
4
6
2
4
12
- The Lambda Expression
number -> number % 2 == 0is used to filter even numbers. - This makes the code more concise and eliminates the need for an external method like
isEven.
Method Reference vs Lambda Expression #
Method Reference (FP01Functional::isEven): #
- Refers to an existing method defined elsewhere. This is useful when you want to reuse logic defined in a separate method.
Lambda Expression (number -> number % 2 == 0): #
- Defines the logic inline, making the code shorter and eliminating the need for separate methods.
Step 05: Do Functional Programming Exercises with Streams, Filters and Lambdas #
Key Objectives #
- Consolidate functional programming concepts learned so far through a series of exercises.
- Practice using streams, filters, and lambda expressions to solve problems in a functional style.
Exercises Overview #
In this lesson, we will perform the following exercises using functional programming techniques:
-
Print only the odd numbers from a list of integers.
-
Print all courses individually from a list of course names.
-
Print only the courses that contain the word Spring.
-
Print courses whose names have at least four letters.
Exercise 1 - Printing Odd Numbers from the List #
- The first exercise is to modify the method that prints even numbers to print odd numbers instead.
Refactor the Code: #
- We can use a similar logic as for even numbers, but this time we will check if a number is odd by modifying the condition.
Code: #
private static void printOddNumbersInListFunctional(List<Integer> numbers) {
numbers.stream()
.filter(number -> number % 2 != 0) // Filter odd numbers
.forEach(System.out::println); // Print each odd number
}
- The
filter(number -> number % 2 != 0)lambda expression checks if the number is odd. - If the number is odd, it gets printed using
forEach(System.out::println).
Output: #
9
13
15
- The output contains only the odd numbers from the list.
Exercise 2 - Printing All Courses Individually #
- In this exercise, we will work with a list of course names instead of numbers. The task is to print each course name on its own line.
Code: #
List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices",
"AWS", "PCF", "Azure", "Docker", "Kubernetes");
courses.stream()
.forEach(System.out::println); // Print each course name
- We convert the list of courses to a stream using
courses.stream(). - We print each course using
forEach(System.out::println).
Output: #
Spring
Spring Boot
API
Microservices
AWS
PCF
Azure
Docker
Kubernetes
Exercise 3 - Printing Courses That Contain the Word "Spring" #
- For this exercise, the goal is to filter the list of courses and print only those that contain the word Spring.
Code: #
courses.stream()
.filter(course -> course.contains("Spring")) // Filter courses containing "Spring"
.forEach(System.out::println); // Print each filtered course
- The
filter(course -> course.contains("Spring"))lambda expression checks if a course name contains the word "Spring". - Only the courses that pass this condition are printed.
Output: #
Spring
Spring Boot
Exercise 4 - Printing Courses with at Least Four Letters #
- In this final exercise, we need to filter the courses so that only those with four or more letters are printed.
Code: #
courses.stream()
.filter(course -> course.length() >= 4) // Filter courses with at least 4 characters
.forEach(System.out::println); // Print each filtered course
- The
filter(course -> course.length() >= 4)lambda expression checks if the length of the course name is at least four. - Only the courses that have names of four or more characters are printed.
Output: #
Spring
Spring Boot
API
Microservices
Azure
Docker
Kubernetes
Full Code: #
public class FP01Exercises {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
printOddNumbersInListFunctional(numbers);
List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices",
"AWS", "PCF", "Azure", "Docker", "Kubernetes");
// Exercise 2: Print all courses
courses.stream()
.forEach(System.out::println);
// Exercise 3: Print courses that contain the word "Spring"
courses.stream()
.filter(course -> course.contains("Spring"))
.forEach(System.out::println);
// Exercise 4: Print courses with at least four letters
courses.stream()
.filter(course -> course.length() >= 4)
.forEach(System.out::println);
}
private static void printOddNumbersInListFunctional(List<Integer> numbers) {
numbers.stream()
.filter(number -> number % 2 != 0) // Filter odd numbers
.forEach(System.out::println); // Print each odd number
}
}
Step 06: Using map in Functional Programs - with Exercises #
Key Objectives #
- Learn how to use the map() function in Java streams.
- Understand how to transform data using lambda expressions and
map(). - Perform exercises to print squares, cubes, and the lengths of strings.
Introduction to Mapping in Functional Programming #
- In this lesson, we will learn how to transform elements in a stream using the map() function.
- The
map()function allows us to apply a function to each element in a stream, transforming the data in some way. - We’ll start by printing the squares of even numbers and then move on to other exercises.
Printing Squares of Even Numbers #
- The task is to print the squares of all even numbers in the list.
- Instead of simply printing the numbers, we will use the map() function to transform each even number into its square.
Code: #
private static void printSquaresOfEvenNumbersInListFunctional(List<Integer> numbers) {
numbers.stream()
.filter(number -> number % 2 == 0) // Filter only even numbers
.map(number -> number * number) // Map each number to its square
.forEach(System.out::println); // Print each squared number
}
filter(number -> number % 2 == 0): Filters only the even numbers.map(number -> number * number): Maps each even number to its square.forEach(System.out::println): Prints each squared number.
Output: #
144
16
36
4
144
Exercise 5 - Printing Cubes of Odd Numbers #
- Now, let’s extend the logic to print the cubes of the odd numbers.
- A cube is calculated by multiplying a number by itself three times
(number * number * number).
Code: #
private static void printCubesOfOddNumbersInListFunctional(List<Integer> numbers) {
numbers.stream()
.filter(number -> number % 2 != 0) // Filter only odd numbers
.map(number -> number * number * number) // Map each odd number to its cube
.forEach(System.out::println); // Print each cubed number
}
filter(number -> number % 2 != 0): Filters only the odd numbers.map(number -> number * number * number): Maps each odd number to its cube.forEach(System.out::println): Prints each cubed number.
Output: #
729
2197
3375
Exercise 6 - Printing the Number of Characters in Course Names #
- In this exercise, we will print the length of each course name in a list of courses.
- We will use the
map()function to transform each course name into its length (number of characters).
Code: #
List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices",
"AWS", "PCF", "Azure", "Docker", "Kubernetes");
courses.stream()
.map(course -> course.length()) // Map each course to its length
.forEach(System.out::println); // Print the length of each course
map(course -> course.length()): Maps each course name to the number of characters in the name.forEach(System.out::println): Prints the length of each course name.
Output: #
6
11
3
13
3
3
5
6
10
Printing Course Names with Their Lengths #
- To make it more informative, let’s modify the code to print both the course name and its length, side by side.
Code: #
courses.stream()
.map(course -> course + " " + course.length()) // Map to "course name + length"
.forEach(System.out::println); // Print the course and its length
- The
map(course -> course + " " + course.length())expression maps each course to a string that combines the course name and its length, separated by a space. - This will print both the name of the course and the number of characters in the name.
Output: #
Spring 6
Spring Boot 11
API 3
Microservices 13
AWS 3
PCF 3
Azure 5
Docker 6
Kubernetes 10
Full Code #
public class FP01Exercises {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
// Printing squares of even numbers
printSquaresOfEvenNumbersInListFunctional(numbers);
// Printing cubes of odd numbers
printCubesOfOddNumbersInListFunctional(numbers);
// Printing number of characters in each course name
List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices",
"AWS", "PCF", "Azure", "Docker", "Kubernetes");
// Printing the length of each course
courses.stream()
.map(course -> course.length())
.forEach(System.out::println);
// Printing both course name and length
courses.stream()
.map(course -> course + " " + course.length())
.forEach(System.out::println);
}
private static void printSquaresOfEvenNumbersInListFunctional(List<Integer> numbers) {
numbers.stream()
.filter(number -> number % 2 == 0) // Filter only even numbers
.map(number -> number * number) // Map to square of each number
.forEach(System.out::println); // Print each squared number
}
private static void printCubesOfOddNumbersInListFunctional(List<Integer> numbers) {
numbers.stream()
.filter(number -> number % 2 != 0) // Filter only odd numbers
.map(number -> number * number * number) // Map to cube of each number
.forEach(System.out::println); // Print each cubed number
}
}
Step 07: Quick Review of Functional Programming Basics #
Key Objectives #
- Review the foundational concepts of functional programming learned in the previous lessons.
- Understand the key functional programming constructs like streams, filter, forEach, map, lambda expressions, and method references.
1. Streams in Functional Programming #
- In functional programming, we focus on specifying what to do rather than how to do it. The first step is to convert a List into a stream, which is a sequence of elements that can be processed functionally.
Example: #
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2);
numbers.stream() // Convert List to Stream
.filter(number -> number % 2 != 0) // Allow only odd numbers
.forEach(System.out::println); // Print each odd number
- The stream represents the sequence of elements from the list.
- The functional operations such as filter and forEach specify what should be done with each element in the stream.
2. The filter() Method #
- The
filter()method is used to allow only elements that meet a certain condition to pass through. All other elements are filtered out.
Example: #
numbers.stream()
.filter(number -> number % 2 == 0) // Allow only even numbers
.forEach(System.out::println);
- In this example, we use
filter()to allow only even numbers through the stream by checking ifnumber % 2 == 0. - If the condition is true, the number is passed to the next stage of the stream pipeline.
3. The forEach() Method #
- The
forEach()method is used to consume each element in the stream. This is typically where side effects occur, such as printing elements to the console.
Example: #
numbers.stream()
.forEach(System.out::println); // Print each number
forEach()applies an operation (like printing) to each element in the stream.- It is a terminal operation, meaning that it processes all elements in the stream and doesn’t return a new stream.
4. The map() Method #
- The
map()method is used to transform each element in the stream. It allows you to convert one value into another.
Example: #
numbers.stream()
.map(number -> number * number) // Square each number
.forEach(System.out::println);
map()takes each element in the stream and applies a transformation (in this case, squaring the number).- The transformed values are then passed to the next stage of the stream pipeline.
5. Lambda Expressions #
- A Lambda Expression is a simplified way of defining a method in a functional programming style. It provides a concise way to express a method using a shorter syntax.
Example: #
numbers.stream()
.filter(number -> number % 2 == 0) // Lambda expression to check even numbers
.map(number -> number * number) // Lambda expression to square the numbers
.forEach(System.out::println);
number -> number % 2 == 0is a lambda expression that checks if a number is even.number -> number * numberis a lambda expression that squares each number.- Lambda expressions allow us to define functional logic inline without writing separate methods.
Simplifying Methods with Lambdas: #
- Instead of writing a full method, like:
private static boolean isEven(int number) {
return number % 2 == 0;
}
- We can replace it with a simpler lambda expression:
number -> number % 2 == 0
6. Method References #
- A Method Reference is a shorthand notation for referring to methods in Java, particularly when using functional programming.
- Method references allow you to call a method directly without explicitly using a lambda expression.
Example: #
numbers.stream()
.forEach(System.out::println); // Method reference to print each number
System.out::printlnis a method reference that points to the println method, allowing us to print each element of the stream.- We also used custom method references like
ClassName::methodNameto call static methods, such asFP01Functional::isEven.
Custom Method Reference: #
numbers.stream()
.filter(FP01Functional::isEven) // Method reference to custom static method
.forEach(System.out::println);
Playing with Streams #
Step 01: Learning Stream Operations - Calculate Sum using reduce #
- In this section, we explore different ways to perform operations on streams in Java, specifically focusing on calculating the sum of a list of numbers.
- We will look at both a traditional structured approach and a functional programming approach using the Stream API and the
reducemethod.
1. Summing Numbers with the Traditional (Structured) Approach #
- The structured approach involves manually looping through the list of numbers and accumulating the result in a temporary variable.
Key Objectives: #
- Understand how to sum numbers in a list using a loop.
- Learn how to initialize a sum variable and use an enhanced
forloop to iterate through the list. - Return the final sum after processing all numbers.
Code: Traditional Structured Approach #
import java.util.List;
public class FP02Structured {
public static void main(String[] args) {
// Step 1: Create a list of numbers
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
// Step 2: Calculate the sum using the structured approach
System.out.println(addListStructured(numbers)); // Output: 77
}
// Step 3: Define a method to calculate the sum
private static int addListStructured(List<Integer> numbers) {
int sum = 0; // Initialize sum to 0
// Loop over each number in the list and add it to sum
for (int number : numbers) {
sum += number;
}
// Return the final sum
return sum;
}
}
- A list of numbers is created using
List.of(...). - The
addListStructuredmethod loops through the list, adding each number to the sum variable initialized at 0. - After the loop finishes, the final sum is returned and printed. In this example, the sum is
77.
2. Functional Programming Approach: Using Streams and reduce #
- Java’s Stream API offers a more functional and declarative way to process data.
- In this approach, we use the reduce method to calculate the sum of the numbers in the list.
Key Objectives: #
- Learn how to create a stream from a list of numbers.
- Use the reduce method to combine elements into a single result.
- Understand how the reduce method works with an identity value and an accumulator function.
Code: Functional Approach Using reduce #
import java.util.List;
public class FP02Functional {
public static void main(String[] args) {
// Step 1: Create a list of numbers
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
// Step 2: Calculate the sum using the functional approach
System.out.println(addListFunctional(numbers)); // Output: 77
}
// Step 3: Define a method to calculate the sum using streams
private static int addListFunctional(List<Integer> numbers) {
return numbers.stream() // Convert the list to a stream
.reduce(0, Integer::sum); // Use reduce to sum the elements
}
}
- A list of numbers is converted into a stream using
stream(). - The reduce method is used to combine the numbers into a sum. The first argument (0) is the identity value, and
Integer::sumis the method reference used to add the numbers. - The final result, which is the sum of the numbers, is returned and printed. The output is
77.
3. Understanding the reduce Method #
- The reduce method is a terminal operation in Java Streams that allows you to combine elements into a single result.
Key Objectives: #
- Understand the role of the identity value in the reduce method.
- Learn how the accumulator function works to combine elements in the stream.
- Apply reduce to sum numbers, but also recognize that it can be used for other operations such as finding the maximum, minimum, etc.
Key Concepts: #
- Identity: The initial value of the reduction operation. In this case, 0 is the identity for summing numbers.
- Accumulator: A function that defines how to combine two values. In this case, Integer::sum adds two numbers.
- Result: The final result after processing all elements in the stream.
Example:
return numbers.stream()
.reduce(0, Integer::sum); // Reduce the numbers to their sum
In this code: #
- The stream is reduced to a single value (sum) starting from 0 as the identity value.
Integer::sumacts as the accumulator function to combine each pair of numbers.
4. Parallel Streams (Theoretical Concept) #
- Java Streams can also be processed in parallel, enabling multi-threaded execution for performance improvement on large datasets.
Key Objectives: #
- Understand how parallel streams distribute data across multiple threads.
- Learn how changing
stream()toparallelStream()can improve performance for large datasets.
Example of Using Parallel Stream: #
private static int addListParallel(List<Integer> numbers) {
return numbers.parallelStream()
.reduce(0, Integer::sum); // Sum numbers using parallel streams
}
- The
parallelStream()method divides the list into smaller parts, processing them in parallel across multiple threads. - The reduce operation remains the same but is now executed concurrently, which can improve performance for larger datasets.
Step 02: Playing with reduce Stream Operation #
- In this section, we dive deeper into the
reducemethod in Java Streams and explore what happens in the background when performing a reduction operation. - We look at how values are aggregated step-by-step and how we can replace custom functions with lambda expressions or method references.
Key Objectives: #
- Understand how the
reducemethod works internally. - Explore how values are combined during the reduction process.
- Learn to replace custom functions with lambda expressions.
- Use method references as a more concise way to perform operations like summing.
1. Understanding How reduce Works Internally #
- The
reducemethod aggregates a stream of elements into a single result by combining elements one by one. - In this case, we are summing numbers from a list. To better understand how
reduceworks, we can print out the values being combined during the reduction process.
Key Objectives: #
- Print out the intermediate values during the reduction.
- Understand how the aggregate value and the next number are combined at each step.
Code: Printing Intermediate Values in reduce #
import java.util.List;
public class FP02DebuggingReduce {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
// Step 1: Use a custom sum function with a sysout to print the values being passed to a and b
int sum = numbers.stream()
.reduce(0, (a, b) -> {
System.out.println("a: " + a + ", b: " + b); // Print current values of a and b
return a + b; // Return the sum of a and b
});
System.out.println("Sum: " + sum); // Output the final sum
}
}
- The reduce method takes an initial value (0 in this case) and combines each element in the stream with an accumulator function.
- We print out the values of a (the aggregate) and b (the next number in the list) during each step of the reduction process.
- The output shows how values are accumulated step-by-step.
Output: #
a: 0, b: 12
a: 12, b: 9
a: 21, b: 13
a: 34, b: 4
a: 38, b: 6
a: 44, b: 2
a: 46, b: 4
a: 50, b: 12
a: 62, b: 15
Sum: 77
- The reduction starts with an initial value of 0 (the identity).
- Each number from the list is added to the current aggregate value.
For example: #
0 + 12 = 12
12 + 9 = 21
21 + 13 = 34, and so on.
- The final result is the sum of all numbers: 77.
2. Using Lambda Expressions in reduce #
- Instead of using a custom function, we can simplify the code with lambda expressions.
- A lambda expression allows you to define the logic of the reduction in a more concise way.
Key Objectives: #
- Replace custom logic with lambda expressions.
- Simplify the reduce operation by specifying how two values (x and y) should be combined.
Code: Using Lambda Expressions #
import java.util.List;
public class FP02LambdaReduce {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
// Step 1: Use a lambda expression to sum the numbers
int sum = numbers.stream()
.reduce(0, (x, y) -> x + y); // Lambda to add two numbers
System.out.println("Sum: " + sum); // Output the final sum
}
}
- The lambda expression
(x, y) -> x + yreplaces the custom sum logic. - Here, x represents the aggregate (current sum) and y represents the next number in the list.
- The reduce method combines x and y by adding them together, just like in the previous step, but with less code.
Output: #
Sum: 77
3. Using Method References in reduce #
- Java provides predefined methods for common operations like addition, which can be used with method references to make the code even simpler.
- In this case, we can use
Integer::suminstead of writing our own sum logic.
Key Objectives: #
- Replace the lambda expression with a method reference for predefined operations.
- Use
Integer::sumto add numbers instead of manually specifying the addition.
Code: Using Method References #
import java.util.List;
public class FP02MethodReferenceReduce {
public static void main(String[] args) {
List<Integer> numbers = List.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
// Step 1: Use method reference Integer::sum to perform the reduction
int sum = numbers.stream()
.reduce(0, Integer::sum); // Method reference to sum numbers
System.out.println("Sum: " + sum); // Output the final sum
}
}
Integer::sumis a method reference that points to the predefined sum method in the Integer class, which takes two integers and returns their sum.- The method reference simplifies the reduce operation even further, making the code cleaner and easier to understand.
Output: #
Sum: 77
Benefits of Using Method References: #
- Concise: Reduces the amount of code written.
- Readability: Improves code readability by directly using predefined methods for common operations.
Step 03: Exploring Streams with Puzzles in JShell #
- In this lecture, we will explore the powerful Stream API in Java, focusing on how to use the
reducemethod to aggregate values from a stream. - We will also learn how to use JShell for interactive coding, allowing us to quickly test and understand how streams work.
Key Objectives #
- Understand how to use JShell for experimenting with Java code.
- Learn how the
reducemethod works for aggregating values. - Discover how to find the maximum and minimum values in a list using streams.
- Practice using lambda expressions to manipulate stream operations.
- Develop hands-on experience with interactive coding using JShell.
1. Introduction to JShell #
- JShell is a tool introduced in Java 9 that allows you to experiment with Java code interactively.
- This is especially useful for learners who want to see the results of their code without setting up an entire project.
- With JShell, you can type in and execute Java code line by line. This makes learning Java easier because you don't need to write a complete program just to test a small piece of code.
- You can immediately see the results of what you’ve written, helping you understand concepts more quickly.
- For example: If you want to print a simple message, you can do it directly in JShell:
Code: #
System.out.println("Hello, JShell!");
Output: #
Hello, JShell!
- This is a quick way to test your Java code without the need for an IDE or a complete Java project.
Key Objectives: #
- Learn how to launch and use JShell for quick Java experiments.
- Understand the benefits of interactive coding for immediate feedback.
2. Creating a List #
- Next, we create a list of integers. This list will be used to perform various stream operations throughout the lecture.
- In Java, you can use the
List.of(...)method to create a list of values. - This method is simple and efficient for creating immutable lists. In our case, we create a list of integers which we will then manipulate using stream operations.
Code: #
List<Integer> numbers = List.of(12, 9, 13, 4, 6);
- This line creates a list with the numbers 12, 9, 13, 4, and 6.
- This list will be used in our reduce operations later on.
Key Objectives: #
- Understand how to create a list of integers in Java.
Use
List.of(...)to create an immutable list for stream operations.
3. Understanding the Reduce Method #
- The reduce method in streams is a powerful operation that allows us to aggregate values in a list or stream.
- It takes two parameters: an initial value and a function that defines how to combine elements.
Example 1: Summing Values #
- In this example, we use reduce to sum the numbers in our list.
Code: #
int sum = numbers.stream().reduce(0, (x, y) -> x + y);
- Identity value: The first argument 0 is the initial value, which serves as the starting point for the summation.
- Lambda expression: The second argument
(x, y) -> x + yis a lambda expression that specifies how to combine each element with the current result. - In this case, it adds the two numbers together.
Output: #
Sum: 77
- This result shows that the sum of the numbers in the list is 77.
Key Objectives: #
- Understand the purpose of the initial value (identity) in the reduce method.
- Learn how to use a lambda expression to combine values in a stream.
- Explore how the reduce method aggregates all elements in the list into a single result.
4. Exploring Different Reduce Operations #
- Now that we understand the basic use of reduce, let's explore different ways we can manipulate the reduce operation to see how the results change.
Example 2: Returning Zero #
- In this example, we change the reduce operation to always return the initial value, which is 0.
Code: #
int result = numbers.stream().reduce(0, (x, y) -> x);
- In this case, the lambda expression
(x, y) -> xalways returns the value of x (the current aggregate). - Since the initial value is 0 and the function keeps returning x, the result will be:
Output: #
Result: 0
- This is because the lambda expression never updates the value of x, so the result remains the initial value of 0.
Example 3: Returning the Second Number #
- Next, we modify the lambda expression to return the second argument (y) instead of the first.
Key Objectives: #
- Understand how the return values in the lambda expression affect the output of the reduce method.
- Experiment with different lambda expressions to see how they change the result of the reduction.
Code: #
int result = numbers.stream().reduce(0, (x, y) -> y);
- Here, the lambda expression
(x, y) -> yreturns the second argument (y), which is the current element from the list. - This means that the last element processed by the stream will be the final result. The output will be:
Output: #
Result: 15
- This is because 15 is the last number in the list.
5. Finding Maximum Values #
- Now, let's find the maximum value in the list using the reduce method.
Key Objectives: #
- Learn how to find the maximum value in a list using the reduce method.
- Understand why using
Integer.MIN_VALUEis important when dealing with negative numbers.
Code: #
int max = numbers.stream().reduce(0, (x, y) -> x > y ? x : y);
- In this case, we compare two numbers at a time and return the larger one using the condition
x > y ? x : y. This ensures that as we iterate through the list, we keep track of the largest number we've seen so far. - However, this approach has a limitation: it won't work properly if the list contains negative numbers.
- Since we are using 0 as the initial value, it will always be considered larger than any negative numbers in the list.
Suggested Improvement #
- To handle negative numbers, we can use
Integer.MIN_VALUEas the starting value. - This ensures that any number in the list will be larger than the initial value.
Code: #
int max = numbers.stream().reduce(Integer.MIN_VALUE, (x, y) -> x > y ? x : y);
6. Finding Minimum Values #
- Just like finding the maximum value, we can use the reduce method to find the minimum value in the list.
Key Objectives: #
- Learn how to find the minimum value in a list using the reduce method.
- Understand how using Integer.MAX_VALUE helps in finding the smallest number in a list.
Code: #
int min = numbers.stream().reduce(Integer.MAX_VALUE, (x, y) -> x < y ? x : y);
- Here, we start with
Integer.MAX_VALUE, which is the largest possible integer value. As we compare each element in the list, the lambda expressionx < y ? x : yensures that the smaller of the two values is returned.
Output: #
Minimum Value: 2
- This confirms that the smallest value in the list is 2.
Step 04: Do Functional Programming Exercises with Streams and reduce #
- In this lecture, we will work through several exercises related to Java Streams and the
reducefunction. - We will focus on practical applications of functional programming by squaring numbers, cubing numbers, and filtering for odd numbers. This hands-on approach will solidify our understanding of streams and their operations.
Key Objectives #
- Understand how to use the
mapfunction to transform data in streams. - Learn to use the
reducemethod for aggregating results. - Apply filtering techniques to isolate specific elements in a stream.
- Practice using functional programming concepts in Java.
Exercises #
- Exercise 7: Square each number in a given list and find the total sum of these squares.
- Exercise 8: Cube each number in the list and determine the total sum of these cubes.
- Exercise 9: Identify and sum only the odd numbers from the list.
Exercise 7: Sum of Squares #
Key Objectives #
- Learn how to use the
mapfunction to transform elements in a stream. - Understand the
reducemethod and how it can aggregate results from a stream. - Let's start with Exercise 7, which requires us to square every number in the list and find the sum of these squares.
Steps #
-
Create a stream from the list of numbers.
-
Use
mapto square each number. -
Use
reduceto sum up the squared values.
Code: #
int sumOfSquares = numbers.stream().map(x -> x * x).reduce(0, Integer::sum);
- The
map(x -> x * x)transformation applies the squaring operation to each number in the list. - The
reduce(0, Integer::sum)operation takes the squared values and sums them up, starting from an initial value of 0.
Output: #
Sum of Squares: 835
Exercise 8: Sum of Cubes #
Key Objectives #
- Understand how to modify the mapping operation to perform cubing.
- Practice using reduce for aggregation with different mathematical operations.
Steps #
- Use map to cube each number.
- Use reduce to sum the cubed values.
Code: #
int sumOfCubes = numbers.stream().map(x -> x * x * x).reduce(0, Integer::sum);
- Similar to Exercise 7, we apply the cubing operation with
map(x -> x * x * x). - Again, we use reduce to aggregate the results into a single sum.
Key Objectives: #
- Practice using the map function for transformations in streams.
- Understand the flexibility of the reduce method for different operations.
Exercise 9: Sum of Odd Numbers #
Key Objectives #
- Learn how to filter elements in a stream based on specific criteria.
- Understand how to combine filtering with reduction to achieve the desired result.
Steps #
- Use filter to isolate odd numbers.
- Use reduce to sum these filtered values.
Code: #
int sumOfOddNumbers = numbers.stream().filter(x -> x % 2 == 1).reduce(0, Integer::sum);
- The
filter(x -> x % 2 == 1)condition checks each number to see if it is odd (i.e., it has a remainder of 1 when divided by 2). - After filtering, we use reduce to sum the remaining numbers.
Output: #
Sum of Odd Numbers: 37
Step 05: Learn Stream Operations - distinct and sorted #
- In this lecture, we will explore two important stream operations in Java, distinct and sorted.
- These operations allow us to manipulate collections of data effectively by filtering out duplicates and arranging elements in a specific order.
Key Objectives #
- Understand the purpose of the
distinctmethod in streams. - Learn how to use the
sortedmethod to order elements. - Explore the combination of
distinctandsortedfor more refined data manipulation.
1. Introduction to Stream Operations #
Key Objectives #
- Familiarize yourself with basic stream operations such as
distinctandsorted. - In the previous step, we learned about the
reducemethod. Now, we will look at two additional methods that are essential for stream manipulation:distinctandsorted.
2. Using the Distinct Method #
Key Objectives #
- Learn how to eliminate duplicate values from a stream using the
distinctmethod. - Understand how to print distinct elements from a stream.
Let's say we have a stream of numbers. To get only the distinct numbers from this stream, we can use the following approach:
Code: #
numbers.stream().distinct().forEach(System.out::println);
- The
distinct()method filters the stream to only include unique elements. - The
forEach(System.out::println)prints each distinct number to the console. - When executed, this code will print only the unique numbers from the list, omitting any duplicates.
- For instance, if the list contains duplicates of 4 and 12, those numbers will appear only once in the output.
3. Using the Sorted Method #
Key Objectives #
- Understand how the sorted method can be used to arrange elements in ascending order.
- Explore how sorted values retain duplicates.
- To sort the numbers in a stream, we can use the
sorted()method:
Code: #
numbers.stream().sorted().forEach(System.out::println);
- The
sorted()method sorts the elements of the stream in their natural order (ascending). - The duplicates are retained, meaning if a number appears multiple times, it will be printed each time.
- For example, if the list includes 2, 4, 4, 6, 9, 12, 12, 13, 15, the sorted output will be:
2
4
4
6
9
12
12
13
15
4. Combining Distinct and Sorted #
Key Objectives #
- Learn how to use both distinct and sorted together for more refined output. If we want to get distinct values and have them sorted, we can combine both methods:
Code: #
numbers.stream().distinct().sorted().forEach(System.out::println);
- This combination first filters out duplicates with
distinct()and then sorts the unique numbers withsorted(). - The final output will be a list of distinct numbers in ascending order.
- For the same input list, the output will be:
2
4
6
9
12
13
15
5. Sorting Strings in Streams #
Key Objectives #
- Understand how the sorted method can also be applied to collections of strings.
- In addition to numbers, we can also use the sorted method on a list of strings.
Code: #
List<String> courses = List.of("Java", "Python", "C++", "JavaScript");
courses.stream().sorted().forEach(System.out::println);
- This code sorts the list of course names in alphabetical order.
- The
sorted()method arranges the strings, andforEach(System.out::println)prints each one.
Output: #
C++
Java
JavaScript
Python
Step 06: Using Comparators to Sort Streams with sorted #
- In this lecture, we will explore how to use Comparators in Java Streams to sort data according to custom criteria. We'll learn how to sort items in natural order, reverse order, and even by specific attributes such as string length.
Key Objectives #
- Understand how to use the
sortedmethod with natural order. - Learn to apply
Comparatorto sort items in reverse order. - Create custom comparators for sorting based on specific criteria, such as string length.
- Practice using lambda expressions with comparators.
1. Introduction to Sorting with Comparators #
Key Objectives #
- Familiarize yourself with the concept of natural order sorting using streams.
- In the previous lecture, we discussed how to sort a list of courses alphabetically. Now, we will explore how to define a custom sorting algorithm.
To sort the courses, we can use the following code:
Code: #
courses.stream().sorted().forEach(System.out::println);
- The
sorted()method sorts the elements of the stream in their natural order. For strings, this means sorting alphabetically.
2. Sorting in Reverse Order #
Key Objectives #
- Learn how to sort items in reverse order using Comparator.
- If we want to sort the courses in reverse alphabetical order, we can use a comparator:
Code: #
courses.stream()
.sorted(Comparator.naturalOrder().reversed())
.forEach(System.out::println);
- Here,
Comparator.naturalOrder()specifies the natural order for sorting, whilereversed()modifies this order to be descending. - When executed, this will print the course names in reverse order, allowing you to see the courses sorted from Z to A.
- For example, if the original list is
["Java", "Python", "C++", "JavaScript", "Spring Boot"], the output will be:
Spring Boot
Python
JavaScript
Java
C++
3. Creating Custom Comparators #
Key Objectives #
- Understand how to define a custom comparator using lambda expressions.
- Sometimes, you may want to sort items based on specific criteria. For instance, let’s sort the courses by the length of their names.
Code: #
courses.stream()
.sorted(Comparator.comparing(str -> str.length()))
.forEach(System.out::println);
- In this example,
Comparator.comparing(str -> str.length())creates a comparator that sorts the strings based on their lengths. - This approach allows for flexible sorting based on different attributes.
- If the courses are
["Java", "Python", "C++", "JavaScript", "Spring Boot"], the output will be:
C++
Java
Python
JavaScript
Spring Boot
Step 07: Collecting Stream Elements to List using collect #
- In this lecture, we will explore how to collect elements from a stream into a list using the
collectmethod. We will see how to transform data, create new lists based on existing lists, and filter elements during the collection process.
Key Objectives #
- Understand how to use the
collectmethod to gather stream elements into a list. - Learn how to apply mapping to transform elements in a stream.
- Explore filtering techniques to create new lists based on specific criteria.
- Practice using lambda expressions in stream operations.
1. Introduction to Collecting Elements #
Key Objectives #
- Familiarize yourself with stream manipulations and how to create new lists.
- Until now, we have manipulated elements of a list by performing operations like doubling, squaring, and cubing. We also learned how to use the
reduce()function to produce a single result. - Now, we will focus on creating new lists based on existing data.
Example Scenario #
Let’s say we have a list of numbers and we want to create a new list that contains the squares of those numbers.
2. Creating a Method to Double Values #
Key Objectives #
- Learn how to define a method that utilizes streams for element transformation.
- We will start by creating a method called
doubleListthat takes a list of numbers and returns a new list containing their squares.
Code: #
List<Integer> squaredNumbers = doubleList(numbers);
- Here,
doubleListis a method we will define to process the input list of numbers. - Focusing on the
doubleListMethod - Inside the
doubleListmethod, we will implement the following steps:
Convert the list to a stream. #
- Use map to transform each number by squaring it.
- Collect the results into a new list.
Code: #
public List<Integer> doubleList(List<Integer> numbers) {
return numbers.stream()
.map(number -> number * number) // Square each number
.collect(Collectors.toList()); // Collect to a list
}
- The map method applies the squaring operation, transforming each number to its square.
- The
collect(Collectors.toList())method gathers the transformed elements into a new list. - When this method is executed with a list of numbers, it returns a new list containing the squares.
3. Filtering Even Numbers #
Key Objectives #
- Understand how to filter elements in a stream based on specific criteria. Now, let’s create a list containing only the even numbers from the original list.
Code: #
List<Integer> evenNumbersOnly = numbers.stream().filter(x -> x % 2 == 0).collect(Collectors.toList());
- The
filter(x -> x % 2 == 0)condition checks each number to see if it is even. - The filtered results are then collected into a new list.
- Running this code will yield a list containing only the even numbers from the original list.
4. Creating a List of Lengths of Course Titles #
Key Objectives #
- Learn how to transform strings and collect their lengths into a list. In the next exercise, we will create a list that contains the lengths of all course titles.
Code: #
List<Integer> courseLengths = courses.stream().map(course -> course.length()).collect(Collectors.toList());
- The
map(course -> course.length())operation transforms each course title into its length. - The lengths are then collected into a new list.
- When executed, this code provides a list of integers representing the lengths of each course title.
Step 08: Reviewing Streams - Intermediate and Stream Operations #
- In this lecture, we will review various operations related to streams in Java, specifically focusing on intermediate and terminal operations.
- Understanding these concepts is crucial for effectively working with streams and manipulating data collections.
Key Objectives #
- Distinguish between intermediate and terminal operations in Java streams.
- Understand how intermediate operations transform streams and return new streams.
- Learn about terminal operations that produce a final result from a stream.
- Review practical examples of stream operations.
1. Introduction to Stream Operations #
Key Objectives #
- Familiarize yourself with the different types of stream operations.
- So far in this course, we have performed numerous manipulations on lists, including sorting, filtering, and transforming elements.
- Now, let's categorize these operations into two main types: intermediate operations and terminal operations.
2. Intermediate Operations #
Key Objectives #
- Learn about intermediate operations and their characteristics.
- Intermediate operations are those that transform a stream into another stream. They do not modify the original stream but instead return a new stream.
Some common intermediate operations include: #
- distinct(): Filters out duplicate values from the stream.
- sorted(): Sorts the elements in natural order.
- map(): Transforms each element in the stream into a new value.
- filter(): Retains only those elements that match a specified condition.
Code: #
List<Integer> distinctNumbers = numbers.stream().distinct().collect(Collectors.toList());
List<Integer> sortedNumbers = numbers.stream().sorted().collect(Collectors.toList());
List<Integer> squaredNumbers = numbers.stream().map(n -> n * n).collect(Collectors.toList());
List<Integer> evenNumbers = numbers.stream().filter(n -> n % 2 == 0).collect(Collectors.toList());
- Each of these operations takes the input stream and performs a transformation, resulting in a new stream.
- For example,
distinct()returns a stream containing only unique elements, while map() applies a function to each element and returns a stream of modified values.
3. Terminal Operations #
Key Objectives #
- Understand terminal operations and their role in stream processing.
- Terminal operations are the final operations in a stream processing pipeline. Unlike intermediate operations, they do not return a stream; instead, they produce a result or a side effect.
Common terminal operations include: #
forEach(): Iterates over each element in the stream and performs an action.collect(): Gathers the elements of a stream into a collection, such as a list, set, or map.reduce(): Combines the elements of a stream into a single result, based on a specified function.
Code: #
numbers.stream().forEach(System.out::println);
List<Integer> collectedNumbers = numbers.stream().collect(Collectors.toList());
int sum = numbers.stream().reduce(0, Integer::sum);
forEach()consumes each element of the stream without returning anything (void).collect()transforms the stream into a collection type, such as a list or set.reduce()aggregates the stream's elements into a single value, like summing all the numbers.- Terminal operations mark the end of the stream processing.
- Once a terminal operation is invoked, the stream is consumed, and no further operations can be performed on it.
Exploring Java Functional Interfaces and Lambdas #
Step 01: Getting Started with Functional Interfaces - Predicate, Consumer and Function #
Key Objectives #
- Understand what functional interfaces are and how they enable passing logic to methods.
- Learn about three key functional interfaces in Java: Predicate, Consumer, and Function.
- Explore how lambda expressions work under the hood and how functional interfaces simplify method logic.
- Refactor lambda expressions into explicit functional interface instances for better code clarity.
Introduction to Functional Interfaces #
Objective: Understand the role of functional interfaces in Java's functional programming.
- Functional interfaces are a core component of Java's approach to functional programming, introduced in Java 8. They allow us to pass logic to methods using lambda expressions.
- A functional interface is defined as an interface that contains exactly one abstract method. Java provides several built-in functional interfaces, such as
Predicate,Function, andConsumer, which we will explore in this lecture.
Refactoring with Lambda Expressions #
Objective: Transition from using raw logic inside stream operations to assigning them to functional interfaces.
- Begin by looking at a common example using lambda expressions within stream operations to filter, map, and print numbers.
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
numbers.stream()
.filter(x -> x % 2 == 0) // Filter even numbers
.map(x -> x * x) // Square the numbers
.forEach(System.out::println); // Print each number
Here, three operations are performed:
- Filter: Filters even numbers using a lambda expression
x -> x % 2 == 0. - Map: Maps each number to its square with
x -> x * x. - ForEach: Prints the squared numbers using method reference
System.out::println. - In the background, each of these operations is associated with a functional interface (explored below).
Introduction to Predicate Interface #
- A Predicate is a functional interface that tests a condition on input and returns a boolean value.
- In the example above, the filtering logic is
x -> x % 2 == 0, which checks if a number is even. - This lambda expression can be rewritten using the Predicate interface:
Predicate<Integer> isEvenPredicate = x -> x % 2 == 0;
- You can refactor the filter operation using the Predicate:
numbers.stream()
.filter(isEvenPredicate) // Use Predicate to filter even numbers
.map(x -> x * x)
.forEach(System.out::println);
- In essence, the Predicate interface provides a structure for filtering collections based on conditions.
Behind the Scenes: #
A lambda expression like x -> x % 2 == 0 is internally equivalent to the following:
Predicate<Integer> isEvenPredicate2 = new Predicate<>() {
@Override
public boolean test(Integer x) {
return x % 2 == 0;
}
};
- This shows how the lambda is actually turned into an object that implements the Predicate interface.
Introduction to Function Interface #
Objective: Understand how the Function interface handles input-output transformation logic.
- The Function interface represents a function that accepts one argument and returns a result. It is used to map one input to a corresponding output.
- In the stream example, the map operation squares each number, represented by the lambda
x -> x * x. - We can refactor this lambda into a Function:
Function<Integer, Integer> squareFunction = x -> x * x;
- Now, you can refactor the map operation using this Function:
numbers.stream()
.filter(isEvenPredicate)
.map(squareFunction) // Use Function to map numbers to their squares
.forEach(System.out::println);
Behind the Scenes: #
The lambda x -> x * x is transformed into an object of the Function interface:
Function<Integer, Integer> squareFunction2 = new Function<>() {
@Override
public Integer apply(Integer x) {
return x * x;
}
};
- The Function interface is essential for operations where we take an input and return an output, such as mapping elements in a stream.
Introduction to Consumer Interface #
Objective: Learn how the Consumer interface is used for operations that take input but return no result.
- A Consumer is a functional interface that accepts a single input and performs an action without returning any result.
- In our example, the method reference
System.out::printlnis a Consumer that prints each number to the console. - You can explicitly declare the Consumer for printing numbers:
Consumer<Integer> sysoutConsumer = System.out::println;
- Refactor the forEach operation using the Consumer:
numbers.stream()
.filter(isEvenPredicate)
.map(squareFunction)
.forEach(sysoutConsumer); // Use Consumer to print each number
Behind the Scenes: #
The method reference System.out::println is internally represented as an implementation of the Consumer interface:
Consumer<Integer> sysoutConsumer2 = new Consumer<>() {
@Override
public void accept(Integer x) {
System.out.println(x);
}
};
- The Consumer interface is widely used when you want to perform operations on data without returning a result (e.g., logging or saving data).
Full Code #
import java.util.List;
import java.util.function.Predicate;
import java.util.function.Function;
import java.util.function.Consumer;
public class FP03FunctionalInterfaces {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
Predicate<Integer> isEvenPredicate = x -> x % 2 == 0;
Function<Integer, Integer> squareFunction = x -> x * x;
Consumer<Integer> sysoutConsumer = System.out::println;
numbers.stream()
.filter(isEvenPredicate) // Apply Predicate to filter even numbers
.map(squareFunction) // Apply Function to map numbers to squares
.forEach(sysoutConsumer); // Apply Consumer to print each number
}
}
Step 02: Do Exercises with Functional Interfaces - BinaryOperator #
Key Objectives #
- Understand how to use the
BinaryOperatorfunctional interface in Java. - Learn how method references like
Integer::sumwork behind the scenes. - Implement and refactor a
BinaryOperatorto perform binary operations on two numbers. - Explore the relationship between
BinaryOperatorandBiFunctioninterfaces.
Exercise #
Objective: Explore how method references work and implement the corresponding functional interface.
- In this exercise, we will take a closer look at the following code snippet, which uses a method reference:
int sum = numbers.stream()
.reduce(0, Integer::sum);
- This code reduces a list of numbers to their sum using
Integer::sum. - Our goal is to determine the functional interface behind this method reference (
Integer::sum) and create a manual implementation of this interface.
Identifying the Functional Interface Behind Integer::sum #
Objective: Understand how Integer::sum is linked to the BinaryOperator functional interface.
- The method reference
Integer::sumrefers to a method that adds two integers. This is an example of a binary operation, where two values are combined to produce one result. - The functional interface behind this is BinaryOperator
<T>, a specialized version of the BiFunction<T, T, T>interface where both the arguments and the result are of the same type (Integer in this case).
BinaryOperator<Integer> sumBinaryOperator = Integer::sum;
In this code: #
BinaryOperator<Integer>is a functional interface that operates on two Integer values and returns an Integer.Integer::sumis a method reference that implements this binary operation.
Behind the Scenes: #
- The method reference
Integer::sumis shorthand for a lambda expression like this:
BinaryOperator<Integer> sumBinaryOperator = (a, b) -> a + b;
- This lambda expression performs the same addition operation, taking two integers as input and returning their sum.
Implementing the BinaryOperator Interface #
Objective: Create a manual implementation of the BinaryOperator interface and understand its structure.
- We can manually implement the BinaryOperator interface using the following approach:
BinaryOperator<Integer> sumBinaryOperator2 = new BinaryOperator<>() {
@Override
public Integer apply(Integer a, Integer b) {
return a + b;
}
};
In this code: #
- The apply method defines the logic for combining two Integer values, in this case, adding them.
- The result type is also Integer, which matches the input types.
- This manual implementation achieves the same result as
Integer::sumbut without using a method reference or lambda expression. It illustrates the structure of the functional interface behind the method reference.
Exploring BinaryOperator and BiFunction Interfaces #
Objective: Understand the relationship between BinaryOperator and BiFunction.
- The
BinaryOperator<T>interface is a specialized version of theBiFunction<T, T, R>interface where both input arguments and the return type are of the same type (T). - The apply method of BinaryOperator takes two parameters and returns a result of the same type:
@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> {
T apply(T t1, T t2);
}
In the example we are working with: #
- T is Integer.
- The method apply takes two integers as input (a and b) and returns their sum.
- This explains why
Integer::sumcan be used as aBinaryOperator<Integer>because it adds two integers and returns the result, all of type Integer.
Refactoring BinaryOperator to a Lambda Expression #
Objective: Refactor the BinaryOperator implementation into a lambda expression for conciseness.
- We can simplify the implementation of the BinaryOperator using a lambda expression, which is more concise and readable:
BinaryOperator<Integer> sumBinaryOperatorLambda = (a, b) -> a + b;
- This lambda expression is equivalent to both the method reference (
Integer::sum) and the manual implementation using the apply method.
Explanation of Lambda Expression: #
- (a, b) represents two input integers.
- a + b is the operation that adds these integers.
- The result is returned directly from the lambda expression.
- By using lambda expressions, we achieve a more succinct way to express binary operations while adhering to the BinaryOperator interface.
Finding the Maximum: #
BinaryOperator<Integer> maxBinaryOperator = (a, b) -> a > b ? a : b;
Finding the Minimum: #
BinaryOperator<Integer> minBinaryOperator = (a, b) -> a < b ? a : b;
Multiplying Two Numbers: #
BinaryOperator<Integer> multiplyBinaryOperator = (a, b) -> a * b;
- These operations demonstrate the versatility of the BinaryOperator interface for performing various types of operations on pairs of values.
Full Code: #
import java.util.List;
import java.util.function.BinaryOperator;
public class BinaryOperatorExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
// Using BinaryOperator with Integer::sum
BinaryOperator<Integer> sumBinaryOperator = Integer::sum;
int sum = numbers.stream()
.reduce(0, sumBinaryOperator);
System.out.println("Sum: " + sum);
// Using BinaryOperator as a lambda expression
BinaryOperator<Integer> sumLambda = (a, b) -> a + b;
int sum2 = numbers.stream()
.reduce(0, sumLambda);
System.out.println("Sum using Lambda: " + sum2);
}
}
Step 03: Doing Behavior Parameterization with Functional Programming #
Key Objectives #
- Understand the concept of behavior parameterization in functional programming.
- Learn how to pass behavior (logic) as an argument to methods.
- Implement a flexible method to filter and print numbers based on varying criteria.
- Explore how this technique enhances code reusability and maintainability.
Introduction to Behavior Parameterization #
Objective: Understand what behavior parameterization means and how it applies to functional programming.
- In this section, we are introduced to behavior parameterization, a technique that allows us to pass the logic (or behavior) of a method as an argument to another method.
- This enables us to write more flexible and reusable code.
- For instance, the expression
x % 2 == 0defines the behavior for determining if a number is even. By extracting this logic, we can avoid repetition in our code and allow for easy modifications in the future. - This approach is significant because it promotes the idea of treating behaviors as first-class citizens in programming. Instead of just passing data, we are passing logic, which opens up new possibilities for dynamic code execution.
Initial Implementation of Filtering Numbers #
Objective: Start with a basic implementation to filter even and odd numbers.
- Lets begin with a simple implementation that prints out even and odd numbers from a list:
numbers.stream()
.filter(x -> x % 2 == 0) // Filter for even numbers
.forEach(System.out::println); // Print each even number
numbers.stream()
.filter(x -> x % 2 != 0) // Filter for odd numbers
.forEach(System.out::println); // Print each odd number
In this code: #
- We use the filter method to separate even and odd numbers based on the provided logic.
- The first block filters for even numbers, while the second block filters for odd numbers.
- However, there is a clear problem, both blocks of code are nearly identical, differing only in the filtering condition.
- This duplication not only makes the code harder to maintain but also suggests that we can refactor it for greater clarity and efficiency.
Identifying Code Duplication #
Objective: Recognize the duplication in the existing code and prepare for refactoring.
- We recognize significant duplication, as both blocks perform similar operations with different filtering logic.
- The only difference lies in the condition used to filter numbers. This realization prompts us to consider a more efficient way to manage this behavior.
Extracting Predicate Logic #
To address this issue, we can create predicates that encapsulate the filtering logic for even and odd numbers:
Predicate<Integer> evenPredicate = x -> x % 2 == 0; // Predicate for even numbers
Predicate<Integer> oddPredicate = x -> x % 2 != 0; // Predicate for odd numbers
- By defining these predicates, we can refer to the logic in a more meaningful way and reduce code repetition.
- This refactoring is a fundamental practice in programming that enhances readability and maintainability.
Creating a Generic Filter and Print Method #
Objective: Develop a method that accepts a predicate for filtering numbers.
- Next, we create a generic method called
filterAndPrintthat accepts a list of numbers and a predicate as parameters. - This allows us to filter the numbers based on any condition we choose:
public void filterAndPrint(List<Integer> numbers, Predicate<Integer> predicate) {
numbers.stream()
.filter(predicate) // Use the passed predicate for filtering
.forEach(System.out::println); // Print each filtered number
}
- Now, we can use this method to filter even or odd numbers, significantly simplifying our code:
filterAndPrint(numbers, evenPredicate); // Filter and print even numbers
filterAndPrint(numbers, oddPredicate); // Filter and print odd numbers
- This approach eliminates duplication and makes the code cleaner and more modular. By externalizing the logic, we are not only enhancing readability but also making our code more adaptable to changes in requirements.
Enhancing Flexibility with Behavior Parameterization #
Objective: Explore the benefits of passing behavior as parameters.
- One of the most exciting aspects of this method is that we can pass different filtering logic to filterAndPrint.
- For example: To filter multiples of 3, we can define a new predicate:
Predicate<Integer> multipleOfThreePredicate = x -> x % 3 == 0; // Predicate for multiples of 3
filterAndPrint(numbers, multipleOfThreePredicate); // Filter and print multiples of 3
- This ability to change the filtering logic dynamically is powerful.
- It means that instead of hardcoding behaviors into our methods, we can now customize them at runtime based on the needs of our application.
- By passing behavior as parameters, we externalize the logic and give our methods the flexibility to adapt. This leads to cleaner code that is easier to maintain and extend.
Full Code: #
import java.util.List;
import java.util.function.Predicate;
public class FP03BehaviorParameterization {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
// Using behavior parameterization to filter and print numbers
filterAndPrint(numbers, x -> x % 2 == 0); // Print even numbers
filterAndPrint(numbers, x -> x % 2 != 0); // Print odd numbers
filterAndPrint(numbers, x -> x % 3 == 0); // Print multiples of 3
}
public static void filterAndPrint(List<Integer> numbers, Predicate<Integer> predicate) {
numbers.stream()
.filter(predicate) // Use the passed predicate for filtering
.forEach(System.out::println); // Print each filtered number
}
}
Step 04: Do Exercise with Behavior Parameterization #
Key Objectives: #
- Understand how to implement behavior parameterization in Java.
- Learn to create flexible methods that can apply different mathematical operations on a list of numbers.
- Explore the concept of first-class functions and their significance in Java programming.
Exercise #
Objective: Get familiar with behavior parameterization through a practical exercise.
- In this exercise, we will focus on behavior parameterization by creating a method that can handle different mathematical operations like squaring, cubing, doubling, or tripling numbers in a list.
- This enables us to pass the desired behavior as an argument, allowing for greater flexibility in our code.
- Previously, we created a list of squared numbers using the following code:
numbers.stream()
.map(x -> x * x) // Mapping x to its square
.collect(Collectors.toList()); // Collecting the results into a list
- Now, the goal is to refactor this code so that we can easily switch between different operations by passing different functions as parameters.
Refactoring for Behavior Parameterization #
Objective: Refactor the existing code to introduce behavior parameterization.
- To start, we can define a mappingFunction that encapsulates the behavior we want to apply to each number in the list. For example, to square the numbers:
Function<Integer, Integer> mappingFunction = x -> x * x; // Function to square numbers
- Next, we will extract this logic into a method that takes both the list of numbers and the mapping function as parameters:
public List<Integer> mapAndCreateNewList(List<Integer> numbers, Function<Integer, Integer> mappingFunction) {
return numbers.stream()
.map(mappingFunction) // Using the passed mapping function
.collect(Collectors.toList()); // Collecting the results
}
- By extracting this method, we allow for any transformation logic to be passed in, making the method reusable for various operations.
Implementing Different Operations #
Objective: Use the new method to create lists of squared, cubed, and doubled numbers.
- With our
mapAndCreateNewListmethod in place, we can now easily apply different mathematical transformations to our list of numbers. - Here’s how we can use the method for squaring, cubing, and doubling:
// Squaring numbers
List<Integer> squaredNumbers = mapAndCreateNewList(numbers, x -> x * x);
// Cubing numbers
List<Integer> cubedNumbers = mapAndCreateNewList(numbers, x -> x * x * x);
// Doubling numbers
List<Integer> doubledNumbers = mapAndCreateNewList(numbers, x -> x + x);
- This flexibility allows us to use the same method with different logic just by changing the function we pass in.
- For example: To print the doubled numbers, we can do:
System.out.println("Doubled Numbers: " + doubledNumbers);
- When we run this code, we see that we can seamlessly switch between different operations without duplicating code.
The Concept of First-Class Functions #
Objective: Understand the significance of first-class functions in Java.
- During this exercise, we observe that functions now have a first-class status in Java.
- This means that we can treat functions like any other variable, allowing us to:
- Pass functions as parameters to other methods.
- Store functions in variables.
- Return functions from other methods.
For instance, we can create a variable that holds a function: #
Function<Integer, Integer> square = x -> x * x; // Function to square a number
- This flexibility is essential for writing more modular and reusable code. It allows us to externalize behavior and apply it dynamically, leading to clearer and more maintainable applications.
Full Code: #
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
public class BehaviorParameterizationExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
// Using behavior parameterization to create lists of squared, cubed, and doubled numbers
List<Integer> squaredNumbers = mapAndCreateNewList(numbers, x -> x * x);
List<Integer> cubedNumbers = mapAndCreateNewList(numbers, x -> x * x * x);
List<Integer> doubledNumbers = mapAndCreateNewList(numbers, x -> x + x);
// Printing results
System.out.println("Squared Numbers: " + squaredNumbers);
System.out.println("Cubed Numbers: " + cubedNumbers);
System.out.println("Doubled Numbers: " + doubledNumbers);
}
public static List<Integer> mapAndCreateNewList(List<Integer> numbers, Function<Integer, Integer> mappingFunction) {
return numbers.stream()
.map(mappingFunction) // Using the passed mapping function
.collect(Collectors.toList()); // Collecting the results
}
}
Step 05: Exploring Supplier and UnaryOperator Functional Interfaces #
Key Objectives: #
- Understand the Supplier functional interface and its applications.
- Learn about the UnaryOperator functional interface and how it operates.
- Explore the significance of functional interfaces in Java and their role in functional programming.
Introduction to Functional Interfaces #
- In prior lessons, we explored several key functional interfaces:
- Predicate: Represents a boolean condition based on an input.
- Function: Takes an input and produces an output, potentially of a different type.
- Consumer: Accepts an input but does not return any output.
- Now, we will expand our knowledge by introducing two more functional interfaces: Supplier and UnaryOperator.
Understanding the Supplier Interface #
Objective: Learn about the Supplier interface, its purpose, and its usage.
- The Supplier interface provides a mechanism to generate or supply values without requiring any input. This makes it distinct from other interfaces we've discussed.
- A Supplier is useful in scenarios such as factory patterns, where you want to create objects or generate values without needing any external input.
Here's how we can define a Supplier of integers:
Supplier<Integer> randomIntegerSupplier = () -> 2; // A simple supplier returning the number 2
However, a more practical use case would be to return a random number:
Supplier<Integer> randomIntegerSupplier = () -> {
Random random = new Random();
return random.nextInt(10000); // Returns a random integer less than 10000
};
- In this example, we create a Supplier that returns a random integer between 0 and 9999. This is executed using:
System.out.println(randomIntegerSupplier.get());
This allows us to retrieve random numbers each time we call get(),
- illustrating how Suppliers work in practice.
Characteristics of Suppliers #
- The Supplier interface is straightforward; it has one abstract method,
get(), which does not take any parameters. - This makes it ideal for scenarios where you want to create values or objects on demand without requiring any input.
- For instance, in a game, you might use a Supplier to generate random events, enemies, or power-ups without the need for specific parameters each time.
Understanding the UnaryOperator Interface #
- The UnaryOperator interface is a specialization of the Function interface. It represents a function that takes a single input and returns a result of the same type.
- Essentially, it operates on one element and produces a result of the same type.
Here’s an example of a UnaryOperator that triples an integer: #
UnaryOperator<Integer> tripleOperator = x -> 3 * x; // Takes an integer and returns its triple
We can then use this operator to transform an integer:
System.out.println(tripleOperator.apply(10)); // Outputs 30
- This showcases how UnaryOperators can be utilized to transform values while maintaining the same data type.
Key Features of UnaryOperator #
- The UnaryOperator interface is valuable because it simplifies operations on single values while ensuring that the input and output types remain consistent.
- It is particularly useful in scenarios where you need to apply transformations to data within streams or collections.
For example: When working with Java streams, you can easily apply UnaryOperators to modify data as it flows through the stream pipeline:
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
List<Integer> tripledNumbers = numbers.stream()
.map(tripleOperator)
.collect(Collectors.toList());
- This makes it easy to transform collections with clean and readable code.
Exploring Java's Functional Interfaces Package #
- Both Supplier and UnaryOperator, along with other functional interfaces, are found in the
java.util.function package. - This package includes various interfaces designed to work with Java's functional programming features, such as:
- BiPredicate: A predicate that takes two inputs.
- BiFunction: A function that takes two inputs and produces an output.
- BiConsumer: A consumer that takes two inputs and does not return anything.
- These interfaces allow for more complex operations and are helpful when dealing with multiple inputs in a functional style.
Full Code: #
import java.util.Random;
import java.util.function.Supplier;
import java.util.function.UnaryOperator;
public class FunctionalInterfacesExample {
public static void main(String[] args) {
// Supplier example: generates random integers
Supplier<Integer> randomIntegerSupplier = () -> {
Random random = new Random();
return random.nextInt(10000); // Returns a random integer less than 10000
};
// Testing the supplier
System.out.println("Random Number: " + randomIntegerSupplier.get());
// UnaryOperator example: triples a number
UnaryOperator<Integer> tripleOperator = x -> 3 * x;
// Testing the unary operator
System.out.println("Triple of 10: " + tripleOperator.apply(10)); // Outputs 30
}
}
Step 06: Exploring BiPredicate, BiFunction, BiConsumer and Primitive Functional Interfaces #
Key Objectives: #
- Understand the concepts and usage of BiPredicate, BiFunction, and BiConsumer functional interfaces.
- Learn about primitive functional interfaces and their advantages in Java.
- Explore the significance of using primitive types for improved performance.
Introduction to BiPredicate #
Objective: Explore the BiPredicate interface and its functionality.
- The BiPredicate interface is a specialized version of the Predicate interface that accepts two inputs instead of one.
- The result is still a boolean value, which allows it to evaluate conditions based on two different parameters.
For example: #
BiPredicate<Integer, String> biPredicate = (number, str) -> number < 10 && str.length() > 5;
- In this example, we define a BiPredicate that checks if a number is less than 10 and if the length of a given string is greater than 5.
- We can easily test this BiPredicate with various inputs:
System.out.println(biPredicate.test(5, "in28minutes")); // Prints true
System.out.println(biPredicate.test(15, "test")); // Prints false
- This output illustrates how BiPredicate allows us to evaluate more complex conditions involving two variables, showcasing its flexibility in functional programming.
Understanding BiFunction #
- The BiFunction interface takes two inputs and produces a single output.
- Unlike BiPredicate, which always returns a boolean, BiFunction can return any type of output.
- This makes it useful for various scenarios where two inputs are combined into a single result.
For example: #
BiFunction<Integer, String, String> biFunction = (number, str) -> number + " " + str;
- In this case, the BiFunction concatenates an integer and a string into a single string.
- We can apply this BiFunction like so:
System.out.println(biFunction.apply(15, "in28minutes")); // Prints "15 in28minutes"
- This demonstrates how BiFunction can be used to manipulate or combine multiple inputs into a meaningful output, providing flexibility in handling data transformations.
Exploring BiConsumer #
- The BiConsumer interface is similar to Consumer but accepts two inputs and does not return a result.
- It is typically used for operations where you want to process two inputs, such as logging or printing.
For example: #
BiConsumer<Integer, String> biConsumer = (s1, s2) -> {
System.out.println(s1);
System.out.println(s2);
};
- This BiConsumer simply prints both inputs.
- To use the BiConsumer:
biConsumer.accept(15, "in28minutes"); // Prints 15 and then in28minutes
- This shows that BiConsumer is helpful in scenarios where you want to perform an action on multiple inputs without returning any value.
Primitive Functional Interfaces #
Java also provides primitive versions of functional interfaces, such as:
- IntPredicate
- IntFunction
- IntConsumer
- IntBinaryOperator
These interfaces are designed specifically to operate with primitive types, allowing for more efficient processing without the overhead of boxing and unboxing associated with wrapper classes.
For example, instead of using: #
BinaryOperator<Integer> intBinaryOperator = (x, y) -> x + y;
We can use:
IntBinaryOperator intBinaryOperator = (x, y) -> x + y;
- This approach eliminates the need for boxing and improves performance, especially in scenarios involving large datasets or performance-critical applications.
Advantages of Primitive Functional Interfaces #
Using primitive functional interfaces allows for:
- Improved Performance: Eliminates boxing and unboxing, reducing memory overhead and improving speed.
- Simpler Code: Facilitates the handling of primitive data types directly, making the code cleaner and easier to read.
- For instance, using an IntBinaryOperator directly with primitive int types avoids unnecessary conversion, which can lead to more efficient code execution.
Full Code: #
import java.util.function.BiPredicate;
import java.util.function.BiFunction;
import java.util.function.BiConsumer;
public class FunctionalInterfacesExample {
public static void main(String[] args) {
// BiPredicate example
BiPredicate<Integer, String> biPredicate = (number, str) -> number < 10 && str.length() > 5;
System.out.println(biPredicate.test(5, "in28minutes")); // Prints true
// BiFunction example
BiFunction<Integer, String, String> biFunction = (number, str) -> number + " " + str;
System.out.println(biFunction.apply(15, "in28minutes")); // Prints "15 in28minutes"
// BiConsumer example
BiConsumer<Integer, String> biConsumer = (s1, s2) -> {
System.out.println(s1);
System.out.println(s2);
};
biConsumer.accept(15, "in28minutes"); // Prints 15 and in28minutes
}
}
Step 07: Playing Puzzles with Functional Interfaces and Lambdas #
Key Objectives: #
- Understand the syntax and compilation rules for functional interfaces in Java.
- Learn how to work with lambda expressions, including type inference and multi-statement expressions.
- Explore practical examples to clarify common misconceptions related to functional interfaces.
Introduction to Functional Interfaces #
Objective: Review the concepts of functional interfaces and introduce puzzles for exploration.
- In this session, we will engage with functional interfaces and lambda expressions through a series of coding puzzles. We'll focus on how these concepts behave during compilation and execution.
- This hands-on approach will help solidify our understanding of these fundamental aspects of functional programming in Java.
Exploring Consumer Functional Interface #
Objective: Analyze the Consumer interface and its syntax.
Begin with the Consumer interface, which represents an operation that takes a single input and returns no result. For instance, let's define a Consumer for a string:
Consumer<String> consumer = str -> {};
- Empty Parameters: If you try to define a Consumer without parameters, it will not compile because the Consumer requires one input.
- Lambda Expression: You can omit parentheses for a single parameter. However, if there are no statements in the body, it will compile successfully even if the body is empty.
Now, if we replace the empty body with a print statement:
Consumer<String> consumer = str -> System.out.println(str);
- This compiles correctly and will print the string passed to it.
Working with BiConsumer #
- Next, we will examine the BiConsumer interface, which accepts two inputs. Here’s how we define it:
BiConsumer<String, String> biConsumer = (s1, s2) -> {
System.out.println(s1);
System.out.println(s2);
};
- You must specify both parameters. If you only define one, it will not compile.
- BiConsumer allows us to ignore one of the parameters while still meeting the method signature requirements.
Testing the BiConsumer: #
biConsumer.accept("Hello", "World"); // Prints "Hello" and "World"
- This demonstrates how BiConsumer can process two inputs, similar to Consumer but with added flexibility.
Exploring Supplier Interface #
The Supplier interface is defined as:
Supplier<String> supplier = () -> "Ranga"; // Returns the string "Ranga"
If you add braces to create a block:
Supplier<String> supplier = () -> {
return "Ranga"; // Explicit return needed in a block
};
- When using braces, an explicit return statement is required.
- If you attempt to define a Supplier without parameters, it should compile as long as you adhere to its method signature (i.e., no input parameters).
Understanding Multi-Statement Lambda Expressions #
If you want to execute multiple statements within a lambda expression, you must encapsulate them in braces:
Consumer<String> consumer = str -> {
System.out.println(str);
System.out.println("Another line");
};
- Multi-statement lambdas require braces and a return statement if there’s a return type involved. This makes it essential to understand the structure of lambda expressions when creating more complex operations.
Type Inference in Lambda Expressions #
In Java, type inference automatically determines the types of parameters based on the context:
BinaryOperator<Integer> sum = (x, y) -> x + y; // Type is inferred from the context
You can also specify types explicitly:
BinaryOperator<Integer> sum = (Integer x, Integer y) -> x + y; // Explicit types
- If you specify the type for one parameter, you must do so for all parameters. If you provide an incorrect type, the compiler will throw an error.
For instance: #
// This will cause a compilation error
BinaryOperator<Integer> incorrectSum = (String x, Integer y) -> x + y;
- This highlights the importance of matching types correctly when using lambda expressions.
Full Code: #
import java.util.function.Consumer;
import java.util.function.BiConsumer;
import java.util.function.Supplier;
import java.util.function.BinaryOperator;
public class FunctionalInterfacesPuzzles {
public static void main(String[] args) {
// Consumer example
Consumer<String> consumer = str -> System.out.println(str);
consumer.accept("Hello, Consumer!");
// BiConsumer example
BiConsumer<String, String> biConsumer = (s1, s2) -> {
System.out.println(s1);
System.out.println(s2);
};
biConsumer.accept("Hello", "World");
// Supplier example
Supplier<String> supplier = () -> "Ranga";
System.out.println(supplier.get());
// BinaryOperator example
BinaryOperator<Integer> sum = (x, y) -> x + y;
System.out.println("Sum: " + sum.apply(10, 20)); // Outputs "Sum: 30"
}
}
Step 08: Exploring Method References with Java #
Key Objectives: #
- Understand the concept of method references in Java.
- Learn how to use method references for static methods, instance methods, and constructors.
- Explore practical examples to solidify knowledge of method references.
Introduction to Method References #
- In this session, we will explore method references, a powerful feature in Java that allows us to refer to methods without executing them.
- This provides a more concise and readable way to write code, especially when working with functional interfaces.
Using Method References with Streams #
- Let’s start by examining a basic example using a list of courses. We will create a stream and apply transformations using method references.
Here’s how it looks: #
courses.stream()
.map(str -> str.toUpperCase()) // Using a lambda expression
.forEach(System.out::println); // Printing each course name
In this code: #
maptransforms each course name to uppercase.forEachprints the uppercase course names.- Replacing Lambda with Method Reference
We can simplify the map operation using a method reference:
map(String::toUpperCase);
This change makes our code cleaner. Method references point directly to existing methods without needing a lambda expression for simple cases.
Using Static Method References #
Next, we can create a static method in our class for printing.
Here’s how to define and use it: #
private static void print(String str) {
System.out.println(str);
}
forEach(FP03MethodReferences::print);// Method reference to static print method- This code showcases how we can use method references for static methods.
This method will be called for each element in the stream, similar to how we used a lambda expression earlier.
Using Instance Method References #
We can also call instance methods using method references. For example:
map(str -> str.toUpperCase());
can be replaced with:
map(String::toUpperCase);
Here, we refer directly to the instance method toUpperCase() of the String class.
This indicates that the method should be called on each string instance in the stream.
Constructor References #
Constructor references allow us to create new objects without the need for a lambda expression.
For example: If we want to create a Supplier for strings:
Supplier<String> stringSupplier = () -> new String(); // Using a lambda
We can simplify this using a constructor reference:
Supplier<String> stringSupplier = String::new; // Constructor reference
This creates a new instance of the String class whenever get() is called on the supplier.
Constructor references provide a clear and concise way to instantiate objects.
In this lecture, we covered the following types of method references:
- Static Method References: Referring to static methods of a class.
- Instance Method References: Referring to instance methods of an object.
- Constructor References: Referring to constructors for creating new objects.
These references enhance code readability and maintainability by reducing boilerplate code.
They also integrate seamlessly with Java’s functional programming capabilities, allowing for cleaner functional style code.
Full Code: #
import java.util.List;
import java.util.function.Supplier;
public class FP03MethodReferences {
public static void main(String[] args) {
List<String> courses = List.of("Java", "Python", "JavaScript");
// Using method references with streams
courses.stream()
.map(String::toUpperCase) // Method reference for instance method
.forEach(System.out::println); // Method reference for static method
// Using a Supplier with constructor reference
Supplier<String> stringSupplier = String::new; // Constructor reference
System.out.println(stringSupplier.get()); // Prints an empty string
}
private static void print(String str) {
System.out.println(str); // Static method for printing
}
}
Java Functional Programming with Custom Classes #
Step 01: Creating Custom Class Course with some Test Data #
Key Objectives: #
-
Understand the importance of creating custom classes in Java.
-
Learn how to define class attributes and methods.
-
Explore how to instantiate objects and manage them in a list.
-
Familiarize with using the
toString()method for object representation. -
Prepare for advanced operations using streams in future sessions.
Introduction #
Lets begins by transitioning from basic data structures to creating a custom class called Course. This class will help organize and manage course-related data effectively.
The focus will be on defining the class, its attributes, and how to create multiple objects from this class.
Creating the Custom Class #
- A new class named
Courseis created, which will encapsulate all relevant details about a specific course. This practice is fundamental in object-oriented programming, as it helps structure code and data into manageable entities.
Defining Class Attributes #
Inside the Course class, several attributes are defined:
- A
Stringvariable namednameis added to hold the name of the course. - A
Stringvariable for the course'scategoryindicates the area it belongs to. - An
intvariable calledreviewScoreis introduced to represent the course's review score, which can range from 0 to 100. - An
intvariable fornumberOfStudentsis included to track how many students are enrolled in the course.
Code: #
private String name; // Course name
private String category; // Course category
private int reviewScore; // Review score (0 to 100)
private int numberOfStudents; // Number of students enrolled
Generating Getters and Setters #
- Next, getters and setters are generated for the attributes of the Course class.
- This allows easy access and modification of the class properties. Getters provide read access, while setters allow changes to the property values.
Code: #
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
// Repeat for other attributes
Adding a Constructor #
- A constructor is added to the Course class, which initializes the attributes of the course when a new object is created. The constructor takes parameters for each attribute, ensuring that every Course object is properly initialized with relevant data.
Code: #
public Course(String name, String category, int reviewScore, int numberOfStudents) {
this.name = name;
this.category = category;
this.reviewScore = reviewScore;
this.numberOfStudents = numberOfStudents;
}
Overriding the toString Method #
- The
toString()method is overridden to provide a meaningful string representation of the Course objects. - This method is crucial for displaying information about a course in a human-readable format when an object is printed.
Code: #
@Override
public String toString() {
return name + " - Students: " + numberOfStudents + ", Review Score: " + reviewScore;
}
Instantiating Course Objects #
- With the Course class defined, several instances of the Course class are created using the
List.of()method. - This allows for the management of multiple course objects in a list format, making it easier to manipulate and access course data.
Code: #
List<Course> courses = List.of(
new Course("Spring", "Framework", 98, 20000),
new Course("Spring Boot", "Framework", 95, 18000),
new Course("Microservices", "Microservices", 97, 22000),
new Course("FullStack", "FullStack", 96, 25000),
new Course("AWS", "Cloud", 92, 21000),
new Course("Azure", "Cloud", 91, 14000),
new Course("Docker", "Cloud", 90, 20000),
new Course("Kubernetes", "Cloud", 89, 20000)
);
- After creating the list of course objects, the details of each course can be accessed and manipulated easily.
Step 02: Playing with allMatch, noneMatch and anyMatch #
Key Objectives: #
-
Understand the functionality of
allMatch(),noneMatch(), andanyMatch()in Java Streams. -
Learn how to create predicates to check specific conditions on course data.
-
Explore practical examples to illustrate the use of these stream methods.
Introduction #
In this lecture, the focus is on using Java Stream methods: allMatch(), noneMatch(), and anyMatch().
These methods allow for checking conditions across a collection of objects, specifically, the list of courses created in the previous lecture.
Understanding allMatch() #
- The first method explored is
allMatch(). This method checks whether all elements in a stream match a given predicate. - The goal is to find out if all courses have a review score greater than 90.
To implement this: #
- The stream of courses is initiated using
courses.stream(). - A predicate is defined to check if the review score of each course is greater than 90 using
course.getReviewScore() > 90. - The result is printed to the console.
Code: #
boolean allGreaterThan90 = courses.stream().allMatch(course -> course.getReviewScore() > 90);
System.out.println(allGreaterThan90); // Expected output: true
- When executed, the output confirms that all courses meet this criterion.
- Next, the check is modified to see if all courses have a review score greater than 95.
Code: #
boolean allGreaterThan95 = courses.stream().allMatch(course -> course.getReviewScore() > 95);
System.out.println(allGreaterThan95); // Expected output: false
The output is false, indicating that not all courses have a review score exceeding 95.
Creating Predicates #
To make the code cleaner and more maintainable, predicates for the review score conditions are created:
reviewScoreGreaterThan90Predicate: Checks if the review score is greater than 90.reviewScoreGreaterThan95Predicate: Checks if the review score is greater than 95.
Code: #
Predicate<Course> reviewScoreGreaterThan90Predicate = course -> course.getReviewScore() > 90;
Predicate<Course> reviewScoreGreaterThan95Predicate = course -> course.getReviewScore() > 95;
These predicates can then be reused in the stream methods for better readability.
Understanding noneMatch() #
- The next method covered is
noneMatch(), which checks if no elements in the stream match a given condition. - The implementation tests whether none of the courses have a review score greater than 95.
Code: #
boolean noneGreaterThan95 = courses.stream().noneMatch(reviewScoreGreaterThan95Predicate);
System.out.println(noneGreaterThan95); // Expected output: false
The output is false, indicating that at least one course has a review score greater than 95.
- To demonstrate a case where
noneMatch()returns true, a new predicate is created to check if the review score is less than 90.
Code: #
Predicate<Course> reviewScoreLessThan90Predicate = course -> course.getReviewScore() < 90;
boolean noneLessThan90 = courses.stream().noneMatch(reviewScoreLessThan90Predicate);
System.out.println(noneLessThan90); // Expected output: true
This output is true, confirming that no courses have a review score below 90.
Understanding anyMatch() #
Finally, the method anyMatch() is explored. This method checks if at least one element in the stream matches the specified predicate.
A test is performed to see if any courses have a review score greater than 90.
Code: #
boolean anyGreaterThan90 = courses.stream().anyMatch(reviewScoreGreaterThan90Predicate);
System.out.println(anyGreaterThan90); // Expected output: true
This returns true because all courses meet this criterion. Similarly, checking against the reviewScoreGreaterThan95Predicate also yields true since some courses meet the condition.
Code: #
boolean anyGreaterThan95 = courses.stream().anyMatch(reviewScoreGreaterThan95Predicate);
System.out.println(anyGreaterThan95); // Expected output: true
allMatch()returns true if every element in the stream matches the condition.noneMatch()returns true if no elements match the specified condition.anyMatch()returns true if at least one element matches the condition.
An exercise is suggested for learners to create additional predicates based on the number of students and practice using allMatch(), noneMatch(), and anyMatch() with these new conditions.
Step 03: Sorting courses with sorted and creating Comparators #
Key Objectives: #
-
Understand how to sort a list of objects using Java Streams.
-
Learn to create custom comparators to define sorting criteria.
-
Explore sorting with multiple criteria, including handling primitive data types efficiently.
Introduction #
In this lecture, the focus is on sorting a list of courses using predefined criteria.
The primary method utilized for sorting is the sorted() method from Java Streams, which requires a comparator to define how the sorting should be done.
Sorting Courses #
- To start sorting the courses, the
courses.stream().sorted()method is called. This method will sort the list based on a provided comparator, which needs to be defined.
Code: #
courses.stream().sorted(/* comparator here */);
Creating a Comparator by Number of Students #
- The next step involves defining a comparator that sorts the courses based on the number of students enrolled. The Comparator interface from
java.utilis used to facilitate this. - A new comparator is created and named comparingByNumberOfStudents.
- The comparator is set up to compare courses by their number of students using the
Comparator.comparing()method.
Code: #
Comparator<Course> comparingByNumberOfStudents = Comparator.comparing(Course::getNumberOfStudents);
- Once defined, this comparator is passed to the
sorted()method to sort the courses.
Code: #
List<Course> sortedCourses = courses.stream()
.sorted(comparingByNumberOfStudents)
.collect(Collectors.toList());
- After running the code, the courses are displayed in increasing order based on the number of students enrolled, providing a clear output for verification.
Sorting in Decreasing Order #
- To sort the courses in decreasing order, the previous comparator is modified by using the
reversed()method. - A new comparator named
comparingByNumberOfStudentsDecreasingis created for this purpose.
Code: #
Comparator<Course> comparingByNumberOfStudentsDecreasing = comparingByNumberOfStudents.reversed();
- The same sorting logic is applied, and running the code now displays the courses in decreasing order of the number of students.
Sorting with Multiple Criteria #
- When courses have the same number of students, a secondary sorting criterion is introduced: the review score.
- A new comparator is created that first compares by the number of students and then by the review score.
Code: #
Comparator<Course> comparingByNumberOfStudentsAndReviews =
Comparator.comparing(Course::getNumberOfStudents)
.thenComparing(Course::getReviewScore);
- By using
thenComparing(), the courses are now sorted by the number of students, and in cases where there are ties, they are further sorted by the review score in ascending order. - After running this code, courses with the same number of students are displayed with the one having the higher review score appearing first.
Efficiently Handling Primitive Types #
While sorting, it’s noted that when dealing with primitive types (like int), it’s more efficient to use specialized comparator methods such as comparingInt().
This avoids the overhead of boxing and unboxing.
Code: #
Comparator<Course> comparingByNumberOfStudentsInt = Comparator.comparingInt(Course::getNumberOfStudents);
- The same efficiency can be applied to
thenComparing()for secondary criteria as well.
Step 04: Playing with skip, limit, takeWhile and dropWhile #
Key Objectives: #
-
Understand the utility methods
skip(),limit(),takeWhile(), anddropWhile()in Java Streams. -
Learn how to filter and manipulate streams based on specific criteria.
-
Explore practical examples of using these methods to manage lists of data.
Introduction #
In this lecture, we will explore additional utility methods available in Java Streams: skip, limit, takeWhile, and dropWhile.
These methods allow us to control which elements we process from a stream based on defined criteria, making it easier to handle large datasets efficiently.
Using the limit() Method #
- Lets begins by introducing the
limit()method. This method restricts the number of elements returned from a stream. For example, if we only want to focus on the top five courses after sorting them, we can utilizelimit()to narrow down our results. - By applying
limit(5), we can effectively filter out any unnecessary data and concentrate on the most relevant entries. - This is particularly useful in scenarios where we want to display only a subset of results, such as in a user interface where displaying all courses might overwhelm the user.
Code: #
List<Course> topFiveCourses = courses.stream()
.sorted(comparingByNumberOfStudentsAndReviews)
.limit(5)
.collect(Collectors.toList());
System.out.println(topFiveCourses);
When executed, this will output the top five courses based on the sorting criteria. For instance:
Output: #
[Microservices:25000:96, API:22000:97, Azure:21000:92, AWS:21000:92, Spring:20000:98]
Using the skip() Method #
- Next, we explore the
skip()method. This method allows us to skip a specified number of elements from the beginning of the stream. - For instance, if we want to ignore the top three results, we can easily achieve this with
skip(3). - This is useful in cases where we might want to bypass certain entries, such as when displaying results that follow a pagination structure, where the first few items have already been viewed.
Code: #
List<Course> skippedCourses = courses.stream()
.skip(3)
.collect(Collectors.toList());
System.out.println(skippedCourses);
When this is run, the output will show that the first three courses are skipped:
Output: #
[AWS:21000:92, Spring:20000:98, Docker:20000:92, Kubernetes:20000:91, Spring boot:18000:95, FullStack:14000:91]
Combining skip() and limit() #
- It is also possible to combine
skip()andlimit()to create more complex queries. - For instance, if you want to skip the first three courses and then limit the results to the next five, you can chain these methods together seamlessly.
- This combination provides a powerful way to control exactly which elements are included in your final output, giving you greater flexibility in how you present data.
Code: #
List<Course> nextFiveAfterSkipping = courses.stream()
.skip(3)
.limit(5)
.collect(Collectors.toList());
System.out.println(nextFiveAfterSkipping);
The output shows the next five courses starting from the fourth course:
Output: #
[AWS:21000:92, Spring:20000:98, Docker:20000:92, Kubernetes:20000:91, Spring boot:18000:95]
Using takeWhile() Method #
- Moving on, the
takeWhile()method is introduced. This method allows you to take elements from the stream as long as a specified condition is true. - For example: We might want to take courses until we find one with a review score less than 95.
- The utility of
takeWhile()lies in its ability to stop processing as soon as a condition is no longer met. - This can significantly improve performance by avoiding unnecessary checks after the first non-matching element is found.
Code: #
List<Course> coursesWithHighReviews = courses.stream()
.takeWhile(course -> course.getReviewScore() >= 95)
.collect(Collectors.toList());
System.out.println(coursesWithHighReviews);
When executed, this outputs the courses with review scores above 95, stopping at the first course that does not meet this condition:
output: #
[Spring:20000:98, Spring boot:18000:95, API:22000:97, Microservices:25000:96]
Using dropWhile() Method #
- The lecture then covers the
dropWhile()method, which is the opposite of takeWhile(). It skips elements as long as the specified condition is true and takes all subsequent elements. - This method is particularly useful when you want to ignore initial entries that do not meet certain criteria.
- For example: If you want to ignore all courses with a review score above 95 and start processing from the first course that meets this criterion,
dropWhile()will help you achieve that.
Code: #
List<Course> droppedCourses = courses.stream()
.dropWhile(course -> course.getReviewScore() >= 95)
.collect(Collectors.toList());
System.out.println(droppedCourses);
When this is run, it will skip courses until it finds one that does not meet the condition and then return all following courses:
Output: #
[FullStack:14000:91, AWS:21000:92, Azure:21000:99, Docker:20000:92, Kubernetes:20000:91]
limit(n): Restricts the stream to the first n elements, allowing for a focused subset of data.skip(n): Skips the first n elements of the stream, useful for pagination or excluding certain entries.takeWhile(predicate): Takes elements as long as the predicate is true, stopping as soon as a condition is not met.dropWhile(predicate): Drops elements as long as the predicate is true, returning all subsequent elements thereafter.
These methods can be very useful for managing collections of data effectively, especially when working with large datasets or when performance is a concern.
Step 05: Finding top, max and min courses with max, min, findFirst and findAny #
Key Objectives: #
-
Understand how to use
max(),min(),findFirst(), andfindAny()methods in Java Streams. -
Learn about the significance of the
Optionaltype and how to handle the absence of values. -
Explore practical examples of finding specific courses based on defined criteria.
Introduction #
In this lecture, the focus shifts to retrieving individual elements from a list of courses. We will explore methods such as max(), min(), findFirst(), and findAny() to identify top courses based on specific criteria, such as the number of students or review scores.
Understanding how to leverage these methods can greatly enhance data retrieval and analysis in Java applications.
Finding the Maximum Course #
- Lets begins by discussing how to find the course with the maximum value based on defined criteria. The approach starts with invoking the
stream()method on the list of courses. - To find the maximum course, the
max()method is utilized, which requires a comparator that defines how to evaluate the courses. - The comparator will compare courses based on both the number of students and review scores.
Code: #
Optional<Course> maxCourse = courses.stream()
.max(comparingByNumberOfStudentsAndReviews);
System.out.println(maxCourse);
When executed, this code identifies the course with the maximum values according to the defined criteria.
Output: #
Optional[FullStack:14000:91]
This indicates that FullStack is the course with the highest values according to the comparator used.
- It's important to note that the result of the
max()operation is wrapped in an Optional. - This is a container object which may or may not contain a value, providing a way to avoid NullPointerExceptions.
- If no course meets the criteria,
Optional.emptyis returned.
Finding the Minimum Course #
Next, lets explore how to find the course with the minimum value using the min() method. Similar to max(), the min() method also requires a comparator.
Code: #
Optional<Course> minCourse = courses.stream()
.min(comparingByNumberOfStudentsAndReviews);
System.out.println(minCourse);
When executed, this will return the course with the lowest values:
Optional[Microservices:25000:96]
In this case, Microservices is the course with the minimum value based on the defined criteria.
Handling Empty Results with Optional #
To demonstrate the handling of an empty result, a predicate that filters out courses with review scores less than 90 is applied.
If no courses meet this condition, the min() operation returns Optional.empty.
Code: #
Optional<Course> noMinCourse = courses.stream()
.filter(course -> course.getReviewScore() < 90)
.min(comparingByNumberOfStudentsAndReviews);
System.out.println(noMinCourse); // Output: Optional.empty
In this case, the output confirms that there are no courses with a review score below 90:
Output: #
Optional.empty
Providing Default Values #
- To address cases where a value may not be present, you can specify a default value using the
orElse()method. - This allows you to define what should be returned if no valid result is found.
Code: #
Course defaultCourse = new Course("Default Course", "N/A", 0, 0);
Course resultCourse = noMinCourse.orElse(defaultCourse);
System.out.println(resultCourse); // Output: Default Course
If no minimum course is found, the default course is returned instead.
Finding the First Element #
The findFirst() method is introduced as a way to retrieve the first element from a stream that matches a specific criterion.
For instance, if you want to find the first course with a review score below 90, you would use this method.
Code: #
Optional<Course> firstLowScoreCourse = courses.stream()
.filter(course -> course.getReviewScore() < 90)
.findFirst();
System.out.println(firstLowScoreCourse); // Output: Optional.empty
This operation returns an Optional containing the first course that meets the criterion or Optional.empty if none exists.
Using findAny() #
Finally, the findAny() method is discussed. This method returns any element from the stream that matches a specified condition.
Unlike findFirst(), which always returns the first match, findAny() may return any qualifying element in a non-deterministic manner, especially when working with parallel streams.
Code: #
Optional<Course> anyHighScoreCourse = courses.stream()
.filter(course -> course.getReviewScore() > 95)
.findAny();
System.out.println(anyHighScoreCourse); // Output: Optional[Spring:21000:96]
This demonstrates that findAny() can return any course that matches the criteria, providing flexibility in how you retrieve data.
max(): Finds the maximum element according to a comparator.min(): Finds the minimum element according to a comparator.findFirst(): Retrieves the first element that matches a given predicate.findAny(): Returns any element that matches the criteria in a potentially non-deterministic manner.
Understanding the use of Optional is crucial for robust programming, helping to avoid null-related errors.
Step 06: Playing with sum, average and count #
Key Objectives: #
-
Learn how to use
sum(),average(), andcount()methods in Java Streams. -
Understand how to aggregate data from a collection of objects.
-
Explore the use of primitive mapping methods (
mapToInt) for efficient performance. -
Learn how to handle optional results when calculating averages.
Introduction #
In this lecture, we explore methods that return single aggregate results from a stream of data.
Specifically, we will look at how to calculate the sum, average, and count of students in courses that meet a specific condition—courses with a review score greater than 95.
Calculating the Total Number of Students with sum() #
- The first operation demonstrated is how to calculate the total number of students in courses that have a review score greater than 95.
- To achieve this, the process begins by filtering the courses using a predicate, and then mapping the filtered courses to the number of students using the
mapToInt()method. - This method is chosen because it efficiently handles primitive data types without boxing or unboxing.
Once the courses are mapped to the number of students, the sum() method is applied to get the total number of students.
Code: #
int totalStudents = courses.stream()
.filter(course -> course.getReviewScore() > 95)
.mapToInt(Course::getNumberOfStudents)
.sum();
System.out.println(totalStudents);
This code returns the total number of students in the courses that meet the condition:
Output: #
88000
Finding the Average Number of Students with average() #
- Next, the
average()method is used to find the average number of students in courses with a review score greater than 95. - Since
average()might not always return a result (e.g., if there are no courses that meet the condition), it returns anOptionalDouble - This helps avoid
NullPointerExceptionand provides a way to handle missing values.
Code: #
OptionalDouble averageStudents = courses.stream()
.filter(course -> course.getReviewScore() > 95)
.mapToInt(Course::getNumberOfStudents)
.average();
System.out.println(averageStudents);
The output shows the average number of students per course:
Output: #
OptionalDouble[22000.0]
Handling Optional Results #
If there are no matching courses, average() would return an Optional.empty.
This can be handled using methods like orElse() to provide a default value in such cases:
double avgStudents = averageStudents.orElse(0.0);
System.out.println(avgStudents);
Counting the Number of Courses with count() #
- To find out how many courses meet the condition of having a review score greater than 95, the
count()method is used. - This method is straightforward and returns the total number of elements that match the criteria.
Code: #
long count = courses.stream()
.filter(course -> course.getReviewScore() > 95)
.count();
System.out.println(count);
The output shows the number of courses that meet the condition:
Output: #
4
Finding Maximum and Minimum Number of Students #
- The lecture continues by showing how to find the course with the maximum and minimum number of students using
max()andmin(). - These methods return the maximum and minimum values as
OptionalInt.
To find the course with the highest number of students in courses with a review score greater than 95, the max() method is used:
OptionalInt maxStudents = courses.stream()
.filter(course -> course.getReviewScore() > 95)
.mapToInt(Course::getNumberOfStudents)
.max();
System.out.println(maxStudents);
The output will show the maximum number of students in such courses:
Output: #
OptionalInt[25000]
Similarly, the min() method can be used to find the course with the minimum number of students:
OptionalInt minStudents = courses.stream()
.filter(course -> course.getReviewScore() > 95)
.mapToInt(Course::getNumberOfStudents)
.min();
System.out.println(minStudents);
Flexibility with Stream Methods #
- With Java Streams, it's possible to apply various filters, map data to any attribute (such as category or review score), and retrieve aggregate results like sum, average, and count.
- For example, instead of mapping to the number of students, we could map the stream to review scores and calculate their sum, average, or count. The possibilities are endless when working with streams and custom filters.
sum(): Calculates the total of a mapped stream of numbers.average(): Returns the average of the numbers in the stream as an OptionalDouble.count(): Returns the number of elements in the stream that meet a certain condition.max()andmin(): Return the maximum and minimum values in the stream asOptionalInt.
Understanding and applying these methods allows for powerful and flexible data analysis in Java.
Step 07: Grouping courses into Map using groupingBy #
Key Objectives: #
-
Learn how to group a list of objects into a
Mapusing theCollectors.groupingBy()method in Java Streams. -
Understand how to perform advanced grouping operations such as counting and finding the maximum value within each group.
-
Explore how to map specific properties (like course names) rather than entire objects to the grouped results.
Introduction #
In this lecture, we explore how to group a list of courses into a Map based on specific criteria using the Collectors.groupingBy() method.
Grouping allows us to organize data into categories and perform various aggregate operations on each group, such as counting the number of items or finding the maximum value.
Grouping Courses by Category #
- The first task is to group courses by their category.
- For example, we want to group all courses related to "Framework," "Microservices," "Cloud," etc., and store them in a
HashMapwhere the category is the key and the list of courses under that category is the value.
To do this, we use the groupingBy() method in the Collectors utility class.
Code: #
Map<String, List<Course>> coursesByCategory = courses.stream()
.collect(Collectors.groupingBy(Course::getCategory));
System.out.println(coursesByCategory);
This code groups courses by category, and when executed, the output will show something like:
Output: #
{
Cloud=[AWS:21000:92, Azure:21000:99, Docker:20000:92, Kubernetes:20000:91],
FullStack=[FullStack:14000:91],
Microservices=[API:22000:97, Microservices:25000:96],
Framework=[Spring:20000:98, Spring boot:18000:95]
}
Counting Courses in Each Category #
- Next, instead of listing all the courses under each category, we want to count how many courses belong to each category.
- The
Collectors.counting()method can be used to count the number of courses for each key (category) in the map.
Code: #
Map<String, Long> courseCountByCategory = courses.stream()
.collect(Collectors.groupingBy(Course::getCategory, Collectors.counting()));
System.out.println(courseCountByCategory);
The output will show the number of courses in each category:
{
Cloud=4,
FullStack=1,
Microservices=2,
Framework=2
}
Finding the Course with the Highest Review Score in Each Category #
- The next step is to find the course with the highest review score within each category. Instead of counting, we use `Collectors.maxBy() to identify the course with the maximum review score in each group.
- We need to specify a comparator that compares courses based on their review scores.
Code: #
Map<String, Optional<Course>> highestRatedCourseByCategory = courses.stream()
.collect(Collectors.groupingBy(
Course::getCategory,
Collectors.maxBy(Comparator.comparing(Course::getReviewScore))
));
System.out.println(highestRatedCourseByCategory);
When executed, the output will display the course with the highest review score for each category:
{
Cloud=Optional[Azure:99],
FullStack=Optional[FullStack:91],
Microservices=Optional[API:97],
Framework=Optional[Spring:98]
}
Extracting Only the Course Names #
- Rather than returning the entire Course object, we might only want the name of the highest-rated course for each category.
- This can be achieved using
Collectors.mapping(), which allows us to transform the grouped elements before collecting them. Here, we map the courses to their names.
Code: #
Map<String, List<String>> courseNamesByCategory = courses.stream()
.collect(Collectors.groupingBy(
Course::getCategory,
Collectors.mapping(Course::getName, Collectors.toList())
));
System.out.println(courseNamesByCategory);
The output will show the course names grouped by category:
{
Cloud=[AWS, Azure, Docker, Kubernetes],
FullStack=[FullStack],
Microservices=[API, Microservices],
Framework=[Spring, Spring Boot]
}
Mapping Highest Rated Course Name by Category #
- To refine the previous example, we can combine
mapping()andmaxBy()to return only the names of the highest-rated courses in each category. - This example shows how to chain different collectors to get specific results.
Code: #
Map<String, Optional<String>> highestRatedCourseNameByCategory = courses.stream()
.collect(Collectors.groupingBy(
Course::getCategory,
Collectors.mapping(Course::getName,
Collectors.maxBy(Comparator.comparing(Course::getReviewScore))
)
));
System.out.println(highestRatedCourseNameByCategory);
The output will show only the names of the highest-rated courses by category:
{
Cloud=Optional[Azure],
FullStack=Optional[FullStack],
Microservices=Optional[API],
Framework=Optional[Spring]
}