Step 01: Creating Streams using Stream of method and for Arrays #

Key Objectives: #

  1. Learn how to create streams directly using Stream.of().

  2. Understand the difference between streams created from lists and those created from arrays.

  3. Explore the benefits of using primitive streams for performance efficiency.

  4. Practice common operations like sum, average, min, and max on both wrapper class and primitive streams.

Introduction #

In this lecture, we focus on how to create streams directly without needing to rely on pre-existing lists. There are multiple options in Java for creating streams, including Stream.of() and Arrays.stream().

These options provide flexibility in how we create and manipulate streams, particularly when working with primitive data types, which can offer performance benefits.

Creating Streams using Stream.of() #

  • To begin, we explore how to create a stream directly from a set of values using the Stream.of() method.
  • This allows us to quickly initialize a stream with specific elements without first needing to create a list.

Code: #

Stream<Integer> numberStream = Stream.of(12, 9, 13, 4, 6, 2, 4, 12, 15);
System.out.println(numberStream.count());

This code snippet creates a stream from a series of integers and then counts the number of elements in the stream. The output will be:

9

Summing Elements with Reduce #

  • In addition to counting, we can perform operations on the stream.
  • For example, using the reduce() method, we can calculate the sum of the stream’s elements. The reduce() method applies a binary operator to combine all elements into a single result.

Code: #

int sum = Stream.of(12, 9, 13, 4, 6, 2, 4, 12, 15)
    .reduce(0, Integer::sum);
System.out.println(sum);

This code sums all the elements in the stream, and the output will be:

77

Wrapper Classes in Streams #

  • One important thing to note is that streams created using Stream.of() store elements as instances of wrapper classes (e.g., Integer for int values).
  • This can lead to boxing and unboxing during operations, which can be inefficient when dealing with large datasets.
  • Boxing: Converting a primitive type (e.g., int) into a wrapper class object (e.g., Integer).
  • Unboxing: Converting a wrapper class object back into a primitive type.
  • While Stream.of() is simple to use, there are more efficient ways to create streams of primitive types that avoid boxing and unboxing overhead.

Creating Streams from Arrays #

  • To avoid boxing and unboxing inefficiencies, Java provides an option to create streams of primitive values directly from arrays using Arrays.stream().
  • This creates streams that work with primitive types (e.g., int, double, long) and allows for more efficient operations.

Let’s look at how to create a stream from an array of primitive int values.

Code: #

int[] numberArray = {12, 9, 13, 4, 6, 2, 4, 12, 15};
IntStream intStream = Arrays.stream(numberArray);
System.out.println(intStream.sum());

This code creates a stream from an array of integers and calculates the sum of the elements.

The output will be:

77

Primitive vs. Wrapper Class Streams #

A key distinction between streams created from arrays and those created from Stream.of() is the type of stream generated:

  • Streams created from arrays (e.g., IntStream) are primitive streams that directly hold primitive values (int, long, etc.).
  • Streams created from Stream.of() are streams of wrapper classes (e.g., Stream<Integer>).
  • Primitive streams (like IntStream) are more efficient because they avoid the overhead of boxing and unboxing.

Performing Operations on Primitive Streams #

  • Primitive streams, such as IntStream, provide several useful operations like sum(), average(), min(), and max() directly, without needing extra conversions.
  • These operations return results wrapped in optional types to handle cases where there might not be any values in the stream.

Code: #

int[] numberArray = {12, 9, 13, 4, 6, 2, 4, 12, 15};
IntStream intStream = Arrays.stream(numberArray);

// Perform common operations
int sum = intStream.sum();
OptionalDouble average = Arrays.stream(numberArray).average();
OptionalInt min = Arrays.stream(numberArray).min();
OptionalInt max = Arrays.stream(numberArray).max();

System.out.println("Sum: " + sum);            // Sum: 77
System.out.println("Average: " + average);    // Average: OptionalDouble[8.555555555555555]
System.out.println("Min: " + min);            // Min: OptionalInt[2]
System.out.println("Max: " + max);            // Max: OptionalInt[15]

The output will show the sum, average, minimum, and maximum values:

Sum: 77
Average: OptionalDouble[8.555555555555555]
Min: OptionalInt[2]
Max: OptionalInt[15]

Step 02: Creating Streams for First 100 Numbers, Squares of Numbers and More #

Key Objectives: #

  1. Learn how to create streams of primitive values dynamically using IntStream.

  2. Understand the difference between range(), rangeClosed(), and iterate() methods for generating sequences.

  3. Explore the use of peek() for inspecting stream elements.

  4. Perform advanced exercises, such as generating even numbers and powers of two.

  5. Learn how to convert primitive streams to collections using the boxed() method.

Introduction #

In this lecture, we explore how to create dynamic streams of primitive values using IntStream. We will cover techniques for generating streams of numbers, performing operations on those streams, and converting primitive streams to more general collections like lists.

This approach is useful for generating sequences like the first 100 numbers, even numbers, odd numbers, and powers of two.

Creating Streams with IntStream.range() #

  • To generate a sequence of numbers, we can use the IntStream.range() method, which allows us to create a stream of numbers within a specified range.
  • This method is useful when you want to generate a range of sequential integers.
int sum = IntStream.range(1, 10).sum();
System.out.println(sum);
  • IntStream.range(1, 10) generates a stream of integers from 1 to 9 (excluding 10).
  • The sum() method adds all the elements in the stream.

Output: #

45

Using rangeClosed() to Include the Last Element #

  • If you want to include the last element in the range, you can use IntStream.rangeClosed().
  • This method is similar to range() but includes the last number in the sequence.
int sum = IntStream.rangeClosed(1, 10).sum();
System.out.println(sum);
  • IntStream.rangeClosed(1, 10) generates a stream of integers from 1 to 10.

Output: #

55

Creating Dynamic Streams with IntStream.iterate() #

  • Sometimes, we need to generate numbers based on a specific algorithm rather than a sequential range.
  • The IntStream.iterate() method allows you to specify a starting value and a function to generate subsequent values.
  • This is particularly useful for generating even numbers, odd numbers, or any other pattern.

Example: Generating an Infinite Stream of Odd Numbers

IntStream.iterate(1, n -> n + 2)
    .limit(10)
    .forEach(System.out::println);
  • IntStream.iterate(1, n -> n + 2) starts with 1 and increments by 2, generating odd numbers.
  • The limit(10) method limits the stream to the first 10 odd numbers.

Output: #

1
3
5
7
9
11
13
15
17
19

Using peek() to Inspect Stream Elements #

  • The peek() method allows you to inspect the elements of a stream without modifying the stream itself.
  • It’s useful for debugging or for understanding what values are being processed in the stream.

Example: Printing the Elements of a Stream Using peek()

IntStream.iterate(1, n -> n + 2)
    .limit(10)
    .peek(System.out::println)
    .sum();
  • peek(System.out::println) prints the elements in the stream before performing the sum operation.
  • The sum is calculated, but it is secondary to the peek output, which is the same list of odd numbers as before:
1
3
5
7
9
11
13
15
17
19

Exercise: Printing the First 10 Even Numbers #

  • As an exercise, let’s generate the first 10 even numbers by modifying the IntStream.iterate() method.

Solution:

IntStream.iterate(2, n -> n + 2)
    .limit(10)
    .peek(System.out::println)
    .sum();
  • This stream starts at 2 and increments by 2, producing even numbers.

Output: #

2
4
6
8
10
12
14
16
18
20

Exercise: Calculating the Powers of 2 #

  • Another exercise involves generating the first 10 powers of 2 (2^1, 2^2, 2^3, etc.).

Solution:

IntStream.iterate(2, n -> n * 2)
    .limit(10)
    .peek(System.out::println)
    .sum();
  • The stream starts at 2 and multiplies each number by 2, producing powers of 2.

Output: #

2
4
8
16
32
64
128
256
512
1024

Converting Primitive Streams to Lists Using boxed() #

  • While working with primitive streams (IntStream, DoubleStream, LongStream), it is important to note that you cannot directly collect primitive streams into a List.
  • To do this, you need to convert the primitive stream into a stream of wrapper objects (e.g., Integer for IntStream). This can be done using the boxed() method.

Example: Collecting Powers of 2 into a List

List<Integer> powersOfTwo = IntStream.iterate(2, n -> n * 2)
    .limit(10)
    .boxed()
    .collect(Collectors.toList());
System.out.println(powersOfTwo);
  • The boxed() method converts the primitive IntStream into a Stream<Integer>, which can then be collected into a List.

Output: #

[2, 4, 8, 16, 32, 64, 128, 256, 512, 1024]

Step 03: Doing Big Number calculations with BigInteger #

Key Objectives: #

  1. Understand the limitations of primitive numeric types like int and long in Java.

  2. Learn how to perform calculations involving extremely large values using BigInteger.

  3. Explore how to use BigInteger in conjunction with Java Streams to calculate large factorials and other big number operations.

Introduction #

In this lecture, we explore how to handle calculations involving very large numbers that exceed the limits of Java’s primitive numeric types such as int and long.

Java provides the BigInteger class for such cases, allowing us to perform arithmetic operations on arbitrarily large integers.

Understanding the Limits of int and long #

  • Primitive types like int and long have predefined maximum values:
  • Integer.MAX_VALUE: The largest value that an int can hold.
  • Long.MAX_VALUE: The largest value that a long can hold.

Example: Checking Integer and Long Limits #

System.out.println("Max value of Integer: " + Integer.MAX_VALUE);
System.out.println("Max value of Long: " + Long.MAX_VALUE);

Output: #

Max value of Integer: 2147483647
Max value of Long: 9223372036854775807
  • These limits restrict the range of calculations that can be performed with int and long.
  • If calculations exceed these limits, the results can wrap around and become incorrect, such as returning negative values for large numbers.

Calculating Factorials and Exceeding Limits #

  • Factorials grow very large quickly.
  • For example, calculating 50! (50 factorial) involves multiplying numbers from 1 to 50.
  • When using int or long, the result will overflow the maximum value and produce incorrect results.

Example: Factorial Calculation Using intStream.rangeClosed #

int factorial = IntStream.rangeClosed(1, 50)
    .reduce(1, (x, y) -> x * y);
System.out.println(factorial);

Output: #

0
  • In this case, the result is 0 because the product exceeds the int limit, resulting in an overflow. Similarly, using LongStream will also overflow at high values.

Introducing BigInteger for Large Calculations #

  • To handle large number calculations like factorials, Java provides the BigInteger class.
  • Unlike int and long, BigInteger can handle arbitrarily large values without overflow.
  • It also provides methods for common arithmetic operations like addition, multiplication, etc.

Calculating Large Factorials with BigInteger #

  • We can solve the factorial problem by converting the stream of numbers into BigInteger objects and using BigInteger for the multiplication.
  • Step-by-Step: Using BigInteger to Calculate 50 Factorial
  • Convert Each Number to BigInteger: Use BigInteger``.valueOf() to convert each int into a BigInteger.
  • Perform Multiplication Using BigInteger: Use the BigInteger.multiply() method to multiply the numbers.

Example: Calculating 50 Factorial with BigInteger #

BigInteger factorial50 = IntStream.rangeClosed(1, 50)
    .mapToObj(BigInteger::valueOf)
    .reduce(BigInteger.ONE, BigInteger::multiply);
System.out.println(factorial50);
  • IntStream.rangeClosed(1, 50): Generates a stream of integers from 1 to 50.
  • mapToObj(BigInteger::valueOf): Converts each integer into a BigInteger.
  • reduce(BigInteger.ONE, BigInteger::multiply): Multiplies all the BigInteger values, starting from BigInteger.ONE.

Output: #

30414093201713378043612608166064768844377641568960512000000000000
  • This produces the correct value for 50! without any overflow issues.

Playing further with Java Functional Programming #

Step 01: Joining Strings with joining and Playing with flapMap #

Key Objectives: #

  1. Learn how to join a list of strings into a single string using the Collectors.joining() method.

  2. Understand the purpose and use of flatMap() to flatten streams of complex data structures.

  3. Explore practical examples of using flatMap() for operations like splitting strings into characters and creating tuples from two lists.

Introduction #

In this lecture, we focus on two powerful stream operations in Java:

  • Collectors.joining(): Used for joining multiple strings with a separator into a single string.
  • flatMap(): A versatile method for flattening nested streams, useful when dealing with arrays or complex collections.

Joining Strings Using Collectors.joining() #

  • To start, we explore how to combine a list of course names into a single string, where each course name is separated by a comma.
  • This is easily achieved using the Collectors.joining() method.

Example: Joining a List of Course Names #

List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices", "AWS", "PCF");
String joinedCourses = courses.stream()
    .collect(Collectors.joining(", "));
System.out.println(joinedCourses);
  • Collectors.joining(", "): Joins all elements in the stream, separated by a comma and space.

Output: #

Spring, Spring Boot, API, Microservices, AWS, PCF
  • The joining() method allows you to specify any separator (e.g., commas, spaces) to combine the strings.

Working with flatMap() #

  • Next, we introduce flatMap(), a method that is useful when dealing with nested structures such as arrays or lists of lists. flatMap() helps flatten these structures into a single stream.
  • In the first example, we want to split each course name into its individual characters.
  • A direct use of map() would return a stream of arrays, but flatMap() allows us to flatten the arrays into a stream of characters.

Example: Flattening a List of Strings into Characters #

List<String> courses = List.of("Spring", "Spring Boot", "API");
List<String> characters = courses.stream()
    .map(course -> course.split(""))
    .flatMap(Arrays::stream)
    .collect(Collectors.toList());
System.out.println(characters);
  • map(course -> course.split("")): Splits each course name into an array of characters.
  • flatMap(Arrays::stream): Converts the array of characters into a flattened stream of individual characters.

Output: #

[S, p, r, i, n, g, S, p, r, i, n, g, B, o, o, t, A, P, I]

Using flatMap() to Remove Duplicates #

  • We can further manipulate the stream by removing duplicate characters using the distinct() method.

Example: Finding Distinct Characters #

List<String> distinctCharacters = courses.stream()
    .map(course -> course.split(""))
    .flatMap(Arrays::stream)
    .distinct()
    .collect(Collectors.toList());
System.out.println(distinctCharacters);
  • distinct(): Removes duplicate characters from the stream.

Output: #

[S, p, r, i, n, g, B, o, t, A, P, I]

Advanced Use of flatMap(): Generating Tuples #

  • In more advanced scenarios, flatMap() can be used to generate combinations of elements from two lists.
  • For example, we can use flatMap() to create pairs (tuples) of course names that have the same number of characters.

Example: Creating Tuples from Two Lists #

List<String> courses = List.of("Spring", "API", "AWS", "PCF", "Docker");
List<String> courses2 = List.of("Spring", "API", "AWS", "PCF", "Docker");

List<List<String>> coursePairs = courses.stream()
    .flatMap(course -> courses2.stream()
        .filter(course2 -> course.length() == course2.length() && !course.equals(course2))
        .map(course2 -> List.of(course, course2)))
    .collect(Collectors.toList());

System.out.println(coursePairs);
  • flatMap(): For each course in the first list, flatMap() generates a stream of courses from the second list.
  • filter(course2 -> course.length() == course2.length()): Filters the second list to match courses with the same length.
  • List.of(course, course2): Creates a pair (tuple) of matching courses.

Output: #

[[API, AWS], [API, PCF], [AWS, API], [AWS, PCF], [PCF, API], [PCF, AWS]]

Explanation of flatMap() Use Cases #

  • The flatMap() method can be used in a variety of scenarios, especially when:
  • You need to flatten nested collections (e.g., a stream of lists into a single stream of elements).
  • You want to combine multiple sources of data into a single stream.
  • In the examples above, we used flatMap() to flatten arrays of characters and to generate combinations (pairs) of matching courses.

Step 02: Creating Higher Order Functions #

Key Objectives: #

  1. Understand the concept of Higher Order Functions (HOFs) in functional programming.

  2. Learn how to create and use higher-order functions in Java by returning functions from methods.

  3. Explore practical examples of using HOFs, including the use of predicates.

Introduction #

In this lecture, we explore the concept of Higher Order Functions (HOFs), an important feature in functional programming.

A Higher Order Function is a function that either takes one or more functions as parameters or returns a function as its result.

In this case, we will focus on creating functions that return other functions, allowing us to generate reusable logic dynamically.

What is a Higher Order Function? #

A Higher Order Function in programming is a function that either:

  • Takes a function as a parameter, or
  • Returns a function as a result.

In this lecture, the focus is on functions that return functions. This is useful when you want to generate customized logic dynamically at runtime, such as creating predicates with varying conditions.

Creating Predicates with Different Cutoff Values #

Let's start by revisiting some predicates that filter courses based on their review scores. Initially, we define two separate predicates that check if a course has a review score greater than 90 or 95.

Example: Predicates with Different Cutoff Values #

Predicate<Course> reviewScoreGreaterThan95 = course -> course.getReviewScore() > 95;
Predicate<Course> reviewScoreGreaterThan90 = course -> course.getReviewScore() > 90;
  • These predicates work, but they are quite repetitive. The only difference between them is the cutoff value (90 or 95).
  • Instead of hardcoding separate predicates, we can make this more flexible by creating a method that returns a predicate with any cutoff value.

Refactoring to Use Higher Order Functions #

  • We will refactor the code to create a higher-order function that takes the cutoff value as a parameter and returns a predicate based on that value.
  • This approach eliminates repetition and allows us to generate predicates dynamically.

Example: Creating a Higher Order Function to Return a Predicate #

public static Predicate<Course> createPredicateWithCutoffReviewScore(int cutoffReviewScore) {
    return course -> course.getReviewScore() > cutoffReviewScore;
}
  • createPredicateWithCutoffReviewScore(int cutoffReviewScore): This method accepts a cutoff value and returns a predicate that checks if a course's review score is greater than the cutoff.
  • The method returns a function (a Predicate<Course>) based on the cutoff value.
  • Now, we can generate predicates for any cutoff dynamically:
Predicate<Course> reviewScoreGreaterThan90 = createPredicateWithCutoffReviewScore(90);
Predicate<Course> reviewScoreGreaterThan95 = createPredicateWithCutoffReviewScore(95);

Applying the Higher Order Function #

  • Once we have created our higher-order function, we can use it to generate predicates and apply them to our courses.
  • For example, we can filter courses with a review score greater than 90 or 95 using the dynamically generated predicates.

Example: Using the Generated Predicates #

List<Course> courses = // Assume this is a list of courses
List<Course> highRatedCourses = courses.stream()
    .filter(createPredicateWithCutoffReviewScore(95))
    .collect(Collectors.toList());

System.out.println(highRatedCourses);
  • We generate the predicate using the createPredicateWithCutoffReviewScore(95) method.
  • The filter() method applies the generated predicate to filter out courses with a review score greater than 95.
  • This dynamic approach allows for flexibility, making it easier to reuse the logic for different cutoff values.

Higher Order Functions in Functional Programming #

  • The use of higher-order functions like the one we created is a hallmark of functional programming.
  • They allow functions to be treated like first-class citizens:
  • Functions as data: We can pass functions around as arguments, return them from methods, or store them in variables just like any other data.
  • Dynamic logic generation: Higher-order functions allow us to create custom logic on the fly, reducing code duplication and making the program more flexible.

In functional programming, you can store methods as local variables, pass methods as parameters, and return methods from other methods—allowing greater control over the behavior of your code.


Step 03: FP and Performance - Intermediate Stream Operations are Lazy #

Key Objectives: #

  1. Understand the concept of laziness in intermediate stream operations.

  2. Learn how Java Streams execute operations efficiently by delaying execution until a terminal operation is invoked.

  3. Explore how functional programming in Java helps improve performance, particularly with operations like filtering and mapping.

Introduction #

In this lecture, we explore the performance advantages of functional programming in Java, particularly focusing on the laziness of intermediate stream operations.

Laziness means that intermediate operations like filter(), map(), and peek() are not executed until a terminal operation (such as findFirst() or collect()) is invoked.

This allows Java to execute operations more efficiently by only doing the minimum amount of work required to get the result.

Functional Programming and Performance #

  • Functional programming has existed for over 50 years, but its performance benefits have gained more attention recently, especially in the context of multicore processors.
  • Java Streams, introduced in Java 8, enable efficient parallelization of code, making it easier to write performant programs.
  • One key aspect of this performance improvement is how intermediate operations in Java Streams are lazy.
  • Instead of processing the entire stream eagerly, intermediate operations are only evaluated when a terminal operation is called.

Example of Stream Operations #

  • Let's start with an example where we apply a series of operations on a list of course names.
  • The goal is to filter out courses with names longer than 11 characters, convert them to uppercase, and return the first match.

Example: Filter, Map, and Find First #

List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices", "AWS");
String result = courses.stream()
    .filter(course -> course.length() > 11)
    .map(String::toUpperCase)
    .findFirst()
    .orElse("No match");
System.out.println(result);
  • filter(): Filters the courses based on the length of their names.
  • map(): Converts the course names to uppercase.
  • findFirst(): Finds the first course that satisfies the conditions.

Output: #

MICROSERVICES

Understanding Laziness in Intermediate Operations #

  • The key point about streams is that intermediate operations (like filter() and map()) are lazy.
  • They are not executed immediately when encountered. Instead, Java "chains" these operations and waits for a terminal operation (like findFirst()) to trigger their execution.

Demonstrating Lazy Execution with peek() #

  • To understand the lazy nature of intermediate operations, we can use the peek() method to see when each step of the stream is executed.
  • The peek() method allows us to inspect the elements as they flow through the pipeline without modifying them.

Example: Using peek() to Observe Stream Execution #

String result = courses.stream()
    .peek(System.out::println)  // Inspect the courses before filtering
    .filter(course -> course.length() > 11)
    .peek(System.out::println)  // Inspect the courses after filtering
    .map(String::toUpperCase)
    .peek(System.out::println)  // Inspect the courses after mapping to uppercase
    .findFirst()
    .orElse("No match");
System.out.println("Result: " + result);
  • The first peek() will print all the course names before they are filtered.
  • The second peek() will only print the course names that pass the filter.
  • The third peek() will print the course names after they are converted to uppercase.

Output: #

Spring
Spring Boot
API
Microservices
Microservices
MICROSERVICES
Result: MICROSERVICES
  • As soon as the findFirst() operation finds the first course that meets the conditions ("Microservices"), the stream processing stops.
  • The remaining courses ("AWS") are not even evaluated, demonstrating how efficient functional programming can be.

Laziness of Intermediate Operations #

  • The intermediate operations (filter(), map(), peek()) in the stream pipeline are lazy:
  • They don’t execute immediately: Java doesn’t process these operations until a terminal operation (like findFirst() or collect()) is called.
  • Short-circuiting: As shown in the example, once findFirst() finds a match, the rest of the stream is not processed, saving computational resources.
  • This lazy evaluation ensures that Java only performs the minimum amount of work necessary to get the result, which leads to performance optimizations.

Understanding Terminal Operations #

  • A terminal operation triggers the execution of the intermediate operations in the stream pipeline.

Examples of terminal operations include:

  • findFirst(): Returns the first element that matches the conditions.
  • collect(): Collects the results into a collection like a list or set.
  • forEach(): Applies an action to each element in the stream.

Once a terminal operation is invoked, Java evaluates the stream pipeline just enough to fulfill the terminal operation.

Example: Removing the Terminal Operation #

  • If we remove the terminal operation (findFirst()), the intermediate operations will not be executed.
Stream<String> courseStream = courses.stream()
    .filter(course -> course.length() > 11)
    .map(String::toUpperCase);
  • No terminal operation is called, so nothing is printed or processed. The stream operations are only defined, but not executed.

Step 04: Improving Performance with Parallelization of Streams #

Key Objectives: #

  1. Understand how to leverage multicore processors to improve the performance of functional code.

  2. Learn how to parallelize streams in Java with the parallel() method.

  3. Explore the benefits and mechanisms of parallelizing stream operations and how Java efficiently handles them.

Introduction #

  • Modern laptops and computers are equipped with multicore processors, allowing programs to execute tasks in parallel and improve performance.
  • In functional programming, parallelization is a straightforward and effective way to take advantage of this.
  • In this lecture, we explore how to parallelize streams in Java and measure the performance improvements.

Setting Up a Sequential Stream Operation #

  • To begin, let’s create a basic example where we sum a large range of numbers using Java Streams.
  • First, we perform this operation sequentially (without parallelization).

Example: Summing a Range of Numbers #

LongStream.range(0, 1000000000L).sum();
  • This creates a stream of long values from 0 to 999,999,999 and calculates the sum. While this works, it can take time to execute.

Measuring the Performance of Sequential Streams #

  • To measure how long it takes to sum these numbers, we can use the System.currentTimeMillis() method to track the start and end times.

Example: Measuring Execution Time #

long startTime = System.currentTimeMillis();
long result = LongStream.range(0, 1000000000L).sum();
long endTime = System.currentTimeMillis();

System.out.println("Sum: " + result);
System.out.println("Time taken: " + (endTime - startTime) + " ms");
  • System.currentTimeMillis(): Captures the current system time in milliseconds.
  • We calculate the sum and then measure the total time taken by subtracting the start time from the end time.

Introducing Parallelization with Parallel Streams #

  • Java allows us to easily parallelize stream operations with the parallel() method.
  • This enables Java to split the stream into multiple chunks and process them concurrently across multiple cores, speeding up operations.

Example: Parallelizing the Stream #

long startTime = System.currentTimeMillis();
long result = LongStream.range(0, 1000000000L)
    .parallel()
    .sum();
long endTime = System.currentTimeMillis();

System.out.println("Sum: " + result);
System.out.println("Time taken with parallelization: " + (endTime - startTime) + " ms");
  • parallel(): This method tells Java to process the stream in parallel, allowing the operation to take advantage of multiple cores.
  • Java will split the stream into chunks and process each chunk on different cores, improving the overall execution time.

Comparing Sequential vs. Parallel Execution #

  • When we run both the sequential and parallel versions of the code, we will typically see a performance improvement in the parallel version.
  • This is because Java can distribute the workload across multiple processor cores, reducing the total time required to complete the task.

Understanding Parallel Stream Mechanics #

  • Parallel streams work by splitting the stream into smaller parts, processing each part separately, and then merging the results.
  • This is possible because of the stateless nature of functional operations, which do not depend on shared data between iterations.

Key Concepts: #

  • Stateful Operations: Operations that rely on changing or maintaining a state (like a loop variable) are difficult to parallelize because they require synchronization.
  • Stateless Functional Programming: In contrast, functional programming emphasizes stateless operations, making it easier to split the work across multiple cores.

Why Functional Programming is Easy to Parallelize #

  • In traditional structured code, loops and stateful operations make parallelization difficult because multiple threads would need to coordinate access to shared variables (e.g., a sum variable in a loop).
  • In functional programming, however, streams are stateless. Instead of manually controlling how each element is processed, we describe what operations to apply, and Java handles the parallelization automatically.

The functional approach allows Java to: #

  • Divide the stream into chunks.
  • Process each chunk independently across multiple cores.
  • Merge the results from each chunk at the end.
  • This makes functional code naturally parallelizable and more efficient.

Functional Programming makes Java Easy #

Step 01: Modifying lists with replaceAll and removeIf #

Key Objectives: #

  1. Learn how to modify elements in a list using the replaceAll() method.

  2. Understand how to remove elements from a list based on a condition using the removeIf() method.

  3. Explore the advantages of using functional programming to simplify list modifications in Java.

Introduction #

Functional programming in Java introduces new methods that make list manipulation much easier and more expressive.

In this lecture, we explore two important methods for modifying lists: replaceAll() and removeIf().

These methods allow us to update and filter list elements by passing functions directly to them, reducing the need for manual loops and enhancing code readability.

Modifying List Elements Using replaceAll() #

  • Traditionally, to modify all elements in a list, we would loop through the list and update each element manually.
  • However, with the replaceAll() method, we can easily modify all the elements in a list by passing a function that specifies how each element should be updated.

Example: Converting All Elements to Uppercase #

  • Suppose we have a list of course names, and we want to convert all the course names to uppercase.
List<String> courses = List.of("Spring", "Spring Boot", "API", "Microservices", "AWS");
courses.replaceAll(str -> str.toUpperCase());
  • replaceAll(): This method applies the specified function (in this case, str.toUpperCase()) to each element of the list.
  • However, because List.of() creates an immutable list, this operation will throw an error. Immutable lists cannot be modified.

Handling Immutable Lists #

  • To perform modifications, we need to work with a modifiable list.
  • One way to achieve this is by creating a new ArrayList from the original list.

Example: Creating a Modifiable List #

List<String> modifiableCourses = new ArrayList<>(courses);
modifiableCourses.replaceAll(str -> str.toUpperCase());
System.out.println(modifiableCourses);
  • new ArrayList<>(courses): Creates a new modifiable list from the original courses list.
  • replaceAll(str -> str.toUpperCase()): Converts each course name to uppercase.

Output: #

[SPRING, SPRING BOOT, API, MICROSERVICES, AWS]
  • Now, the list is successfully modified, and all course names are converted to uppercase.

Removing Elements from a List Using removeIf() #

  • Another powerful method introduced in Java is removeIf(), which allows us to remove elements from a list based on a condition.
  • Instead of looping through the list manually and removing elements, we can pass a predicate (a condition) directly to the removeIf() method.

Example: Removing Courses with Short Names #

  • Suppose we want to remove all courses from the list that have names with fewer than six characters.
modifiableCourses.removeIf(course -> course.length() < 6);
System.out.println(modifiableCourses);
  • removeIf(): This method takes a predicate (a condition) and removes all elements from the list that satisfy the condition.
  • In this case, course.length() < 6 removes all course names with fewer than six characters.

Output: #

[SPRING, SPRING BOOT, MICROSERVICES]
  • In this example, "API" and "AWS" are removed from the list because their names have fewer than six characters.

Benefits of Functional Programming in List Manipulation #

Using methods like replaceAll() and removeIf() makes list manipulation in Java much simpler and more expressive:

  • Cleaner code: We can apply transformations and filters directly, without the need for explicit loops.
  • Higher readability: The logic is easier to understand since it focuses on what needs to be done rather than how to do it.
  • Functional approach: By passing functions (or predicates) directly to these methods, we can encapsulate logic in a more reusable and declarative way.

Step 02: Playing with Files using Functional Programming #

Key Objectives: #

  1. Learn how to read and manipulate files using functional programming constructs in Java.

  2. Understand how to process the contents of a file using streams, including operations like reading lines, finding unique words, and sorting.

  3. Explore how to list and filter directories using functional programming.

Introduction #

Functional programming in Java has made file handling more intuitive and efficient.

In this lecture, we will explore how to perform common file operations using streams and functional programming constructs.

By the end of the lecture, you will know how to read file contents, manipulate text, and work with directories—all in a functional and streamlined way.

Reading a File with Files.lines() #

  • To start, we will learn how to read the contents of a text file using the Files.lines() method.
  • This method allows us to read all lines in a file and process them as a stream.

Example: Reading and Printing File Contents #

Path path = Paths.get("file.txt");
Files.lines(path)
    .forEach(System.out::println);
  • Files.lines(path): Reads the file line by line and returns a Stream<String>.
  • forEach(System.out::println): Prints each line of the file to the console.
  • This makes reading files simple and efficient
  • However, you must handle exceptions (like IOException), so be sure to include proper error handling or a throws Exception clause in your method.

Manipulating File Content #

  • Now that we can read the file, let's go one step further. Suppose you want to find the unique words in the file.
  • To achieve this, we can split each line into words, flatten the results, and filter for distinct words.

Example: Finding Unique Words in a File #

Files.lines(path)
    .flatMap(line -> Arrays.stream(line.split(" ")))  // Split lines into words
    .distinct()                                       // Find distinct words
    .forEach(System.out::println);                    // Print unique words
  • split(" "): Splits each line into words based on spaces.
  • flatMap(): Flattens the stream of arrays into a stream of words.
  • distinct(): Filters out duplicate words to show only unique ones.

Sorting Unique Words #

  • Once we have the unique words, we may want to sort them in alphabetical order.
  • We can easily achieve this by adding the sorted() method to our stream.

Example: Sorting Unique Words Alphabetically #

Files.lines(path)
    .flatMap(line -> Arrays.stream(line.split(" ")))  // Split lines into words
    .distinct()                                       // Find distinct words
    .sorted()                                         // Sort words alphabetically
    .forEach(System.out::println);                    // Print sorted words
  • sorted(): Sorts the words in natural (alphabetical) order.
  • The sorted result will list all the unique words in alphabetical order, with uppercase words coming before lowercase ones.

Listing Files in a Directory #

  • Next, we explore how to list the files in a directory.
  • Using Files.list(), we can get a stream of files and directories in a given path.

Example: Listing Files and Directories #

Files.list(Paths.get("."))
    .forEach(System.out::println);
  • Files.list(Paths.get(".")): Lists all files and directories in the current project root.
  • forEach(System.out::println): Prints the names of all files and directories.
  • This method is useful when you want to inspect the contents of a directory programmatically.

Filtering Directories #

  • Sometimes, we may want to filter out only directories from the list of files.
  • This can be done easily using the filter() method and the Files.isDirectory() function.

Example: Filtering Only Directories #

Files.list(Paths.get("."))
    .filter(Files::isDirectory)    // Filter only directories
    .forEach(System.out::println);  // Print directory names
  • filter(Files::isDirectory): Filters the stream to only include directories.
  • This filters out non-directory files, leaving only the directories in the list.

Other File Operations #

  • Java’s Files API offers many other methods that can be used in combination with streams for more specific operations.

For example:

  • Files.isRegularFile(): Check if a path is a regular file.
  • Files.isReadable(): Check if a file is readable.
  • Files.isExecutable(): Check if a file is executable.
  • Files.isHidden(): Check if a file is hidden.

These methods allow you to fine-tune your file operations based on different conditions.


Step 03: Playing with Threads using Functional Programming #

Key Objectives: #

  1. Learn how to create and manage threads in Java using both traditional and functional approaches.

  2. Understand how functional programming simplifies the creation of threads by using lambda expressions.

  3. Explore how functional programming improves readability and reduces boilerplate code when working with concurrency in Java.

Introduction #

In this lecture, we explore how functional programming simplifies working with threads in Java.

Traditionally, creating threads required implementing the Runnable interface or extending the Thread class.

With functional programming, we can streamline this process using lambda expressions, making the code more concise and easier to understand.

Creating Threads with the Traditional Approach #

  • In Java, a common way to create a thread is by implementing the Runnable interface.
  • This involves defining the run() method, which contains the logic that will be executed by the thread. We then pass this Runnable to a Thread object and start the thread.

Example: Creating Threads Using Runnable #

Runnable runnable = new Runnable() {
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            System.out.println(Thread.currentThread().getId() + ": " + i);
        }
    }
};

Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);

thread1.start();
thread2.start();
  • Runnable interface: This defines a task to be executed in a separate thread. We implement the run() method to specify the task.
  • Thread class: We create new Thread objects, passing in the Runnable, and start them using thread1.start() and thread2.start().
  • The code will print the thread ID and the counter from 0 to 9999 for each thread running in parallel.

Simplifying Thread Creation with Functional Programming #

  • In Java, functional interfaces are interfaces that contain exactly one abstract method.
  • The Runnable interface is a functional interface because it has only one method: run().
  • This means we can use lambda expressions to provide an implementation, making the code shorter and more readable.

Example: Using Lambda Expressions to Create Threads #

Runnable runnable2 = () -> {
    for (int i = 0; i < 10000; i++) {
        System.out.println(Thread.currentThread().getId() + ": " + i);
    }
};

Thread thread3 = new Thread(runnable2);
Thread thread4 = new Thread(runnable2);

thread3.start();
thread4.start();
  • Lambda Expression: The runnable2 variable is defined as a lambda expression, which provides the implementation of the run() method without needing to explicitly define the method inside an anonymous class.
  • Simplification: By using the lambda syntax, we eliminate the need for boilerplate code like the Runnable interface implementation and focus directly on the logic to be executed in the thread.

Understanding the Benefits of the Functional Approach #

Using lambda expressions in functional programming simplifies thread management by:

  • Reducing boilerplate: We no longer need to write out the entire Runnable implementation, making the code more concise.
  • Improving readability: The code is easier to understand because it focuses on the core logic rather than the ceremony of implementing an interface.
  • Leveraging functional interfaces: Since Runnable is a functional interface, we can provide its implementation with a lambda expression, simplifying how we define tasks for threads.

Step 04: Using Functional Programming in Java Applications #

Key Objectives: #

  1. Understand how functional programming fits into Java's object-oriented structure.

  2. Learn where to apply functional programming constructs like streams, lambdas, and functional interfaces in real-world applications.

  3. Recognize the challenges teams face when adopting functional programming and strategies to overcome them.

Introduction #

Java is primarily an object-oriented programming (OOP) language, where applications are built by creating classes, objects, and managing interactions between these objects.

However, with the introduction of functional programming (FP) features in Java, such as streams, lambda expressions, and method references, developers can now blend object-oriented and functional paradigms to write more concise, readable, and maintainable code.

Integrating Functional Programming into Java Applications #

  • While Java remains object-oriented, functional programming can be highly useful in specific contexts.
  • The best place to start incorporating functional programming is wherever you are processing collections of data, such as lists, sets, or maps.

Example: Using Streams to Process Collections #

List<String> courses = List.of("Spring", "Java", "Kubernetes", "Microservices");

// Using functional programming to filter and process the list
courses.stream()
       .filter(course -> course.length() > 6)
       .map(String::toUpperCase)
       .forEach(System.out::println);
  • Streams: Functional programming makes it easier to process collections with declarative operations like filter(), map(), and forEach().
  • Lambdas and Method References: We use lambda expressions and method references to apply operations without explicitly defining loops.
  • Functional programming fits perfectly into these contexts, where collections and data processing tasks are common.

Functional Programming as a Complement to Object-Oriented Programming #

  • Functional programming does not replace object-oriented programming in Java. Instead, it complements OOP by simplifying specific tasks like:
  • Data manipulation and transformations.
  • Working with large collections using streams.
  • Writing cleaner and more readable code when performing repetitive tasks.
  • For instance, when you need to perform operations on collections (filtering, sorting, mapping), functional programming is a powerful tool.
  • However, Java still relies on OOP principles for class design, object relationships, and larger application architecture.

Challenges in Adopting Functional Programming #

  • One of the biggest challenges when introducing functional programming into existing Java projects is the learning curve for developers.
  • Not all developers may be familiar with functional programming constructs like lambda expressions, method references, and stream operations.

Example: Complex Functional Code #

List<String> courses = List.of("Spring", "Java", "Kubernetes", "Microservices");

courses.stream()
       .filter(course -> course.startsWith("S"))
       .map(course -> course.length())
       .forEach(System.out::println);
  • For developers unfamiliar with functional programming, this code can be difficult to understand at first.

The Paradigm Shift in Problem Solving #

  • The transition from structured programming to functional programming requires a paradigm shift.
  • While structured programming focuses on explicit loops, iteration, and state management, functional programming focuses on declarative problem solving:
  • Declarative programming: You describe what you want to achieve (e.g., filtering a list) without detailing how it should be done (e.g., using a for loop).
  • Immutability and statelessness: Functional programming encourages avoiding state mutation, reducing the risk of bugs and making code easier to reason about.\

The Benefits of Functional Programming in Java #

  • Despite the learning curve, functional programming provides several benefits:
  • Concise and readable code: It reduces boilerplate code, allowing you to write more expressive and declarative operations.
  • Easier data processing: Functional constructs like streams make it easier to manipulate data, especially large datasets.
  • Parallelism: Functional programming simplifies parallel processing by removing the need for manual thread management.
  • By understanding these benefits, teams can slowly adopt functional programming in their daily Java development workflow.