Java 8 Features

Java 8 introduced some desired features that significantly enhanced the Java programming language.

The code examples in this blog are available on GitHub.

In this article, we’ll dive into some of these key features.

Bear in mind, each feature has a lot more into it than what I go into in this article.

Here are some of the Java 8 features we’ll talk about in this articles:

  • Lambda Expressions
  • Stream API
  • Default Methods
  • Functional Interfaces
  • Method References
  • Optional Class
  • Date and Time API
  • Java I/O Improvements
  • forEach() Method
  • Collections API Improvements

Lambda Expressions

Enable functional programming by allowing the representation of anonymous functions as method arguments.

They provide a concise syntax for writing inline functions, making code more expressive and readable.

In the example below, we have a test method named calculate_area that demonstrates the usage of lambda expressions.

The lambda expression is defined using a functional interface named ShapeFunctionalInterface, which has a single abstract method.
The lambda expression multiplies the given number by itself to calculate_area of the square.

package systems.loreto.java8;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class ShapeLambdaTest {

    @Test
    void calculate_area() {
        ShapeFunctionalInterface square = (int number) -> number * number;

        int result = square.calculate(3);

        assertEquals(9, result);
    }

    @FunctionalInterface
    interface ShapeFunctionalInterface {
        int calculate(int number);
    }
}

Stream API

The Stream API introduced a new way of working with collections and performing operations on data in a declarative and functional style.

Streams provide powerful methods for filtering, mapping, reducing, and parallel processing of data.

Below, we have a test method named square_numbers_from_a_list that demonstrates the usage of Stream API.

We start by creating a list of integers named numbers. Then, using the stream() method, we obtain a stream of elements from the list. We chain the map() operation to square each element of the stream, and finally, we collect the squared numbers into a new list using the collect() method.

package systems.loreto.java8;

import org.junit.jupiter.api.Test;

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.assertEquals;

class StreamApiTest {

    @Test
    void square_numbers_from_a_list() {
        List<Integer> numbers =
                Arrays.asList(1, 2, 3, 4, 5, 6);

        List<Integer> squaredNumbers = numbers.stream()
                .map(number -> number * number)
                .collect(Collectors.toList());

        assertEquals(
                Arrays.asList(1, 4, 9, 16, 25, 36),
                squaredNumbers);
    }
}

Default Methods

Java 8 introduced the concept of default methods in interfaces, enabling interfaces to have concrete method implementations.

This feature allows for backward compatibility when adding new methods to existing interfaces without breaking the implementing classes.

We have an example below where we have an interface named Calculator with two methods: add() and subtract().

The add() method is not a default method, so it must be implemented in the classes that implement the interface. On the other hand, the subtract() method is a default method that provides a default implementation in the interface itself.

We then have a class named BasicCalculator that implements the Calculator interface and provides an implementation for the add() method. Since the subtract() method has a default implementation in the interface, there’s no need to explicitly implement it in the class.

In the functionality() test method, we create an instance of BasicCalculator and test both the add() and subtract() methods.

package systems.loreto.java8.defaultMethods;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

class BasicCalculatorTest {

    @Test
    public void functionality() {
        BasicCalculator calculator = new BasicCalculator();

        int sum = calculator.add(4, 3);
        assertEquals(7, sum);

        int difference = calculator.subtract(8, 3);
        assertEquals(5, difference);
    }
}
package systems.loreto.java8.defaultMethods;

interface Calculator {
    int add(int a, int b);

    default int subtract(int a, int b) {
        return a - b;
    }
}
package systems.loreto.java8.defaultMethods;

class BasicCalculator implements Calculator {
    @Override
    public int add(int a, int b) {
        return a + b;
    }
}

Functional Interfaces

These are interfaces that have a single abstract method.

Java 8 introduced the @FunctionalInterface annotation to explicitly mark interfaces as functional interfaces. This enables better support for lambda expressions and method references.

Below, we have a functional interface named CalculatorOperation with a single abstract method calculate(), representing a calculator operation. We then have a Calculator class that has a performOperation() method, which takes two operands and a CalculatorOperation instance to perform the calculation.

In the testCalculatorFunctionality() test method, we create an instance of Calculator and test two different operations.

Firstly, we test addition using a lambda expression as the CalculatorOperation to perform the calculation. We pass in the lambda expression (a, b) -> a + b to calculate the sum of two operands.

Secondly, we test subtraction using a lambda expression assigned to a variable of type CalculatorOperation, and then pass that variable to the performOperation() method.

package systems.loreto.java8.functionalInterfaces;

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

    @Test
    public void testCalculatorFunctionality() {
        Calculator calculator = new Calculator();
        int sum = calculator.performOperation(5, 3, (a, b) -> a + b);
        assertEquals(8, sum);

        CalculatorOperation subtraction = (a, b) -> a - b;
        int difference = calculator.performOperation(10, 4, subtraction);
        assertEquals(6, difference);
    }
}
package systems.loreto.java8.functionalInterfaces;

class Calculator {
    public int performOperation(int a, int b, CalculatorOperation operation) {
        return operation.calculate(a, b);
    }
}

@FunctionalInterface
interface CalculatorOperation {
    int calculate(int a, int b);
}

Method References

Method references provide a shorthand syntax for referring to methods by their names.

They allow developers to pass methods as arguments or assign them to functional interfaces without explicitly writing lambda expressions.

We have a Calculator class with a static method add() that performs addition of two integers. We then have a CalculatorTest class where we define a test method named functionality().

Within the test method, we create a BiFunction named addition that takes two integers as input and uses the method reference Calculator::add to reference the add() method of the Calculator class. This method reference allows us to use the add() method as a functional interface implementation.

We then use the apply() method of the BiFunction to pass in the operands 5 and 3, which results in the addition operation being performed using the add() method.

package systems.loreto.java8.methodReferences;

import org.junit.jupiter.api.Test;
import java.util.function.BiFunction;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class CalculatorTest {

    @Test
    public void functionality() {
        BiFunction<Integer, Integer, Integer> addition = Calculator::add;
        int sum = addition.apply(5, 3);
        assertEquals(8, sum);
    }
}
package systems.loreto.java8.methodReferences;

class Calculator {

    public static int add(int a, int b) {
        return a + b;
    }
}

Optional Class

The Optional class is a container object that may or may not contain a non-null value.

It helps in handling null values and reduces the risk of NullPointerExceptions or others, as the context may vary, by enforcing explicit checks and handling for potential null values.

In the example below, we have a Calculator class with a divide() method that is responsible for performing the division operation and returning an Optional<Integer>.

In the CalculatorTest class, we create an instance of Calculator in each test method and invoke the divide() method accordingly.

On divide_valid_input() method, we test valid inputs. In this case 10 and 2, are divisable. Then, an Optional<Integer> is returned with the correct value.

On divide_by_zero() method, we test when we are dividing an integer by zero, we return an Optional.empty() avoiding an ArithmeticException.

package systems.loreto.java8.optionalClass;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.assertFalse;

import org.junit.jupiter.api.Test;

import java.util.Optional;

class CalculatorTest {

    @Test
    public void divide_valid_input() {
        Calculator calculator = new Calculator();
        Optional<Integer> result = calculator.divide(10, 2);

        assertTrue(result.isPresent());
        assertEquals(5, result.get());
    }

    @Test
    public void divide_by_zero() {
        Calculator calculator = new Calculator();
        Optional<Integer> result = calculator.divide(10, 0);

        assertFalse(result.isPresent());
    }
}
package systems.loreto.java8.optionalClass;

import java.util.Optional;

class Calculator {

    public Optional<Integer> divide(int a, int b) {
        return (b == 0)
                ? Optional.empty()
                : Optional.of(a / b);
    }
}

Date and Time API

Java 8 introduced the java.time package, which provides a comprehensive set of classes for working with dates, times, durations, and time zones. The new Date and Time API address the limitations and design flaws of the legacy java.util.Date and java.util.Calendar classes.

Below, we have two test methods within the DateAndTimeApiTest class.

package systems.loreto.java8.dateAndTimeApi;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

import java.time.LocalDate;
import java.time.Month;
import java.time.Period;

public class DateAndTimeApiTest {

    @Test
    public void period_between() {
        LocalDate startDate = LocalDate.of(2023, Month.JANUARY, 1);
        LocalDate endDate = LocalDate.of(2023, Month.JUNE, 3);

        Period period = Period.between(startDate, endDate);

        assertEquals(5, period.getMonths());
        assertEquals(0, period.getYears());
        assertEquals(2, period.getDays());
    }

    @Test
    public void local_date_manipulation() {
        LocalDate currentDate = LocalDate.of(2023, Month.JANUARY, 1);

        LocalDate nextMonth = currentDate.plusMonths(1);
        LocalDate nextYear = currentDate.plusYears(1);
        LocalDate previousWeek = currentDate.minusWeeks(1);

        assertEquals(2, nextMonth.getMonthValue());
        assertEquals(2024, nextYear.getYear());
        assertEquals(2022, previousWeek.getYear());
    }
}

The first method, period_between(), demonstrates the usage of the Period.between() method to calculate the period between two LocalDate objects.

We create a start date of January 1, 2023, and an end date of June 3, 2023. By calling Period.between(startDate, endDate), we obtain a Period object representing the difference between the two dates.

The assertions then verify that the period consists of 5 months, 0 years, and 2 days.

The second method, local_date_manipulation(), showcases the manipulation capabilities of LocalDate.

We start with a current date of January 1, 2023, and use the plusMonths(), plusYears(), and minusWeeks() methods to calculate different dates.

The assertions confirm that the resulting dates match the expected values.

In these 2 tests, we can see the usage of some of the Date and Time API features introduced in Java 8.

Java I/O Improvements

Java 8 introduced several enhancements to the Java I/O API, including new classes like Files and Paths for improved file handling and path manipulation. It also introduced the BufferedReader.lines() method, which allows reading lines from a file in a more streamlined manner.

In the test class below, we count the lines in the file we created.

package systems.loreto.java8.ioImprovements;

import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Test;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;

import static org.junit.jupiter.api.Assertions.assertEquals;

class IOImprovementsTest {

    private static final String TEST_TXT = "src/test/resources/test.txt";

    @Test
    void read_lines_from_file() throws IOException {
        Path filePath = Path.of(TEST_TXT);
        Files.write(filePath, "Hello\nWorld\nJava 8".getBytes());

        Stream<String> lines = Files.lines(filePath);
        long count = lines.count();

        assertEquals(3, count);
    }

    @AfterAll
    static void delete_files_create_for_test_purposes() throws IOException {
       Files.delete(Path.of(TEST_TXT));
    }
}

First, we create a Path object representing the file path where we want to write our test data. We then use the Files.write() method to write the content “Hello\nWorld\nJava 8” to the file.

Next, we use a Java I/O improvement feature by calling the Files.lines() method, which returns a stream of lines from the specified file. We assign this stream to the lines variable.

To validate the functionality, we use the count() method on the lines stream to count the number of lines in the file. The assertion checks that the count is equal to the expected value of 3.

We then proceed to delete the file created for this test by having a delete_files_create_for_test_purposes() static method with the @AfterAll annotations.

As most of us are familiar with this annotations, this method will be called by junit after all the tests have been executed in this test class.

forEach() Method

The forEach() method was added to the Iterable interface, allowing for more concise and expressive iteration over collections. It provides a convenient way to perform an action for each element in a collection without the need for explicit iteration.

In this example, we have a test method called for_each() within the ForEachMethodTest class.

package systems.loreto.java8.forEachMethod;

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

public class ForEachMethodTest {

    @Test
    public void for_each() {
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Orange");

        StringBuilder result = new StringBuilder();

        fruits.forEach(fruit -> result.append(fruit).append(" "));

        assertEquals("Apple Banana Orange ", result.toString());
    }
}

First, we create an ArrayList called fruits and add three fruit names to it: “Apple”, “Banana”, and “Orange”.

Next, we create a StringBuilder called result to store the concatenated fruit names.

Using the forEach() method introduced in Java 8, we iterate over each element in the fruits list. The lambda expression

fruit -> result.append(fruit).append(" ")

is used as the action to be performed for each element. It appends the current fruit name followed by a space to the result StringBuilder.

Finally, we assert that the final value of the result StringBuilder is equal to the expected value “Apple Banana Orange “. This verifies that the forEach() method correctly iterated over the list and executed the provided action on each element.

Collection API Improvements

Java 8 introduced several enhancements to the Collection API.

Notable additions include the stream() and parallelStream() methods, which allow performing functional-style operations on collections.

The List interface gained the sort() method for in-place sorting, and the Map interface gained the computeIfAbsent() and computeIfPresent() methods for more efficient handling of key-value mappings.

In the CollectionAPIImprovementsTest class below, we have three test methods. Each one demonstrates a different aspect of the Collections API improvements introduced in Java 8.

package systems.loreto.java8;

import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.junit.jupiter.api.Assertions.assertEquals;

class CollectionAPIImprovementsTest {

    @Test
    public void stream_and_parallel_stream_methods() {
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Orange");

        List<String> upperCaseFruits = fruits.stream()
                .map(String::toUpperCase)
                .collect(Collectors.toList());

        assertEquals("APPLE", upperCaseFruits.get(0));
        assertEquals("BANANA", upperCaseFruits.get(1));
        assertEquals("ORANGE", upperCaseFruits.get(2));
    }

    @Test
    public void sort_method() {
        List<String> fruits = new ArrayList<>();
        fruits.add("Banana");
        fruits.add("Orange");
        fruits.add("Apple");

        fruits.sort(String::compareToIgnoreCase);

        assertEquals("Apple", fruits.get(0));
        assertEquals("Banana", fruits.get(1));
        assertEquals("Orange", fruits.get(2));
    }

    @Test
    public void compute_if_absent_and_compute_if_present_methods() {
        Map<String, Integer> scores = new HashMap<>();
        scores.put("John", 90);

        scores.computeIfAbsent("Alice", key -> 80);
        scores.computeIfPresent("John", (key, value) -> value + 10);

        assertEquals(100, scores.get("John"));
        assertEquals(80, scores.get("Alice"));
    }
}

The stream_and_parallel_stream_methods() method demonstrates the usage of the stream() method on a List. It creates a list of fruits, converts each fruit to uppercase using the map() operation, and collects the results into a new list. The assertions validate that the uppercase fruits are obtained correctly.

The sort_method() method showcases the sort() method introduced in Java 8. It adds three fruits to a list in a random order and then sorts them using the sort() method with a case-insensitive comparator. The assertions ensure that the fruits are sorted correctly.

The compute_if_absent_and_compute_if_present_methods() method shows the use of the computeIfAbsent() and computeIfPresent() methods on a Map. It creates a map of scores, adds an entry for “John” with a score of 90, and then applies transformations to the values using these methods. The assertions verify that the values are computed correctly.

Conclusion

These are some of the most widely used features introduced in Java 8.

Each of the features mentioned in this article, could easily become article on it’s own.

This release brought many other enhancements, such as improved annotations, improved type inference, parallel sorting, concurrency and more efficient handling of large amounts of data, to name a few more.

Java 8 played a crucial role in modernising the Java language and laying the foundation for subsequent Java releases.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *