Functional Programming in Java

Avicsebooks
21 min readAug 2, 2023

--

Higher-order functions:

A function that accepts one or more functions as arguments and/or returns a function as its output is referred to as a higher-order function.

In Java 8, Higher-Order Functions (HOFs) refer to functions that can accept other functions as arguments or return functions as results. This functional programming concept allows you to treat functions as first-class citizens, enabling more flexible and concise code.

To demonstrate Higher-Order Functions in Java 8, we’ll use the java.util.function package, which provides functional interfaces representing various types of functions.

Let’s create an example that uses Higher-Order Functions to implement a simple mathematical operation:

import java.util.function.IntBinaryOperator;
public class HigherOrderFunctionExample {
// Higher-Order Function that takes a binary operation function as an argument
public static int applyOperation(int a, int b, IntBinaryOperator operation) {
return operation.applyAsInt(a, b);
}
public static void main(String[] args) {
// Implementing different operations using lambda expressions
IntBinaryOperator add = (a, b) -> a + b;
IntBinaryOperator subtract = (a, b) -> a - b;
IntBinaryOperator multiply = (a, b) -> a * b;
int x = 10;
int y = 5;
// Using Higher-Order Function to perform different operations
int resultAdd = applyOperation(x, y, add);
int resultSubtract = applyOperation(x, y, subtract);
int resultMultiply = applyOperation(x, y, multiply);
System.out.println("Addition: " + resultAdd); // Output: Addition: 15
System.out.println("Subtraction: " + resultSubtract); // Output: Subtraction: 5
System.out.println("Multiplication: " + resultMultiply); // Output: Multiplication: 50
}
}

In this example, we define a HigherOrderFunctionExample class with a method applyOperation, which is a Higher-Order Function. It takes two integers and an IntBinaryOperator as arguments. IntBinaryOperator is a functional interface representing an operation that takes two integer arguments and produces an integer result.

In the main method, we create three different lambda expressions (add, subtract, and multiply), each representing a specific binary operation. Then, we use the applyOperation Higher-Order Function to apply these operations on two integer variables x and y, obtaining different results for each operation.

Using Higher-Order Functions like this allows you to encapsulate behavior and pass it as an argument, making the code more modular and reusable. This functional programming approach is particularly powerful when combined with other functional interfaces and concepts like lambda expressions, streams, and method references in Java 8.

First-order functions

First-order functions: First-order functions refer to all functions other than higher-order functions.

Function composition

Function composition in Java 8 allows you to combine multiple functions to create a new function. It enables you to perform a sequence of operations on a given input, where the output of one function becomes the input to the next function in the sequence.

Java 8 introduced the Function interface (from the java.util.function package), which represents a function that takes one argument and produces a result. The Function interface has a method andThen that facilitates function composition.

Let’s see an example of function composition in Java 8:

import java.util.function.Function;
public class FunctionCompositionExample {
public static void main(String[] args) {
// Create functions for adding 2 and multiplying by 3
Function<Integer, Integer> addTwo = x -> x + 2;
Function<Integer, Integer> multiplyByThree = x -> x * 3;
// Function composition using "andThen"
Function<Integer, Integer> addAndMultiply = addTwo.andThen(multiplyByThree);
// Apply the composed function to a value
int result = addAndMultiply.apply(5);
System.out.println("Result: " + result); // Output: Result: 21
}
}

In this example, we define two functions: addTwo and multiplyByThree. Each of these functions takes an integer as input and performs a specific operation on it.

We then use the andThen method of the Function interface to create a new function addAndMultiply, which is a composition of addTwo followed by multiplyByThree. The andThen method creates a new function that first applies the addTwo function to the input and then passes the result to the multiplyByThree function.

When we apply the composed function addAndMultiply to the value 5, it first adds 2 to 5, resulting in 7, and then multiplies the result by 3, giving us the final output of 21.

Function composition is an elegant way to combine simple functions into more complex ones, making your code more modular, readable, and maintainable. It allows you to build sophisticated data transformation pipelines in a clean and declarative manner. Function composition is a powerful technique when combined with other functional programming concepts like lambda expressions, streams, and higher-order functions.

Currying

Currying is a functional programming technique where a function that takes multiple arguments is transformed into a sequence of functions, each taking a single argument. In other words, instead of providing all the arguments at once, currying allows you to apply the function partially, one argument at a time, producing a new function with each application until all arguments are provided, and the final result is obtained.

In Java 8, you can simulate currying using lambda expressions and functional interfaces. Let’s see an example to demonstrate currying in Java 8:

import java.util.function.Function;

public class CurryingExample {
public static void main(String[] args) {
// Define a function that takes two arguments and returns their sum
Function<Integer, Function<Integer, Integer>> add = x -> y -> x + y;
// Apply currying to the "add" function
Function<Integer, Integer> add5 = add.apply(5);
// Apply the partially applied function
int result = add5.apply(3);
System.out.println("Result: " + result); // Output: Result: 8
}
}

In this example, we start by defining a function add that takes two integers (x and y) and returns their sum. However, instead of returning the result directly, the function returns another function (a closure) that takes the second argument (y) and performs the addition with the provided x.

We then use apply on add to provide the first argument (x) and create a new function add5 that expects only the second argument (y). This process is called partial application. Now, add5 is a curried function that adds 5 to any integer provided as an argument.

Finally, we apply the curried function add5 to the value 3, resulting in 8, which is the sum of 5 and 3.

Currying allows you to create reusable functions and provides more flexibility when dealing with partial inputs. It can be especially helpful in functional programming paradigms, enabling you to build powerful and expressive code. However, Java doesn’t have built-in support for currying, so it might require a bit more boilerplate code compared to languages that directly support currying, like Haskell or Scala.

Functor

A Functor is a functional programming concept that represents a type that can be mapped over, i.e., you can apply a function to the values inside the type while preserving the structure of the type.

In Java, we can simulate Functor using interfaces and lambda expressions. Let’s define a simple Functor interface and demonstrate its usage:

import java.util.function.Function;

// Functor interface with a "map" method
interface Functor<T> {
<R> Functor<R> map(Function<T, R> function);
}
// Example Functor implementation for a Box that holds a value
class Box<T> implements Functor<T> {
private final T value;
Box(T value) {
this.value = value;
}
@Override
public <R> Functor<R> map(Function<T, R> function) {
R result = function.apply(value);
return new Box<>(result);
}
public T getValue() {
return value;
}
}
public class FunctorExample {
public static void main(String[] args) {
// Create a Box with an Integer value
Box<Integer> integerBox = new Box<>(5);
// Define a function to double the value
Function<Integer, Integer> doubleFunction = x -> x * 2;
// Map the function over the Box
Functor<Integer> doubledBox = integerBox.map(doubleFunction);
// Get the result from the mapped Box
int result = doubledBox.getValue();
System.out.println("Doubled value: " + result); // Output: Doubled value: 10
}
}

In this example, we start by defining the Functor interface, which has a map method that takes a function (Function<T, R>) and returns a new Functor containing the result of applying the function to the value inside the Functor.

Next, we create an example implementation of a Functor, represented by the Box class. The Box holds a value of type T, and it implements the map method by applying the provided function to the value and returning a new Box containing the result.

In the main method, we create a Box with an Integer value of 5. Then, we define a function doubleFunction that doubles an integer value. We use the map method to apply the doubleFunction to the integerBox, resulting in a new Box with the doubled value. Finally, we retrieve the value from the mapped Box and print it.

In this way, we simulate Functor behavior in Java, where we can apply a function to the value inside the Functor while preserving its structure. Functor is a powerful concept in functional programming and is widely used in languages that support functional programming paradigms.

Monad

Monads are a type of structure used to represent computations that are described as a series of stages. The linking and sequencing of operations, the derivation of characteristics based on the operations and their sequence, and the mechanism to a chain that type with other similar kinds are all defined by a type (a class in the Java sense) representing a monad structure.

How to implement monads in Java 8

In Java 8, functional programming concepts, including monads, can be implemented using the new features like lambda expressions and the java.util.function package. While Java doesn't have native support for monads like languages such as Haskell, we can simulate monadic behavior using interfaces and classes.

Let’s implement a basic Maybe (or Optional) monad, which represents a value that may or may not be present.

Step 1: Define the Monad Interface

import java.util.function.Function;
public interface Monad<T> {
<R> Monad<R> bind(Function<T, Monad<R>> mapper);
}

The Monad interface defines a bind method that takes a Function as an argument and returns a new Monad with the mapped value. The bind method is the essential operation that allows monads to be chained together.

Step 2: Implement the Maybe Monad

import java.util.function.Function;
public class Maybe<T> implements Monad<T> {
private final T value;
private Maybe(T value) {
this.value = value;
}
public static <T> Maybe<T> just(T value) {
return new Maybe<>(value);
}
public static <T> Maybe<T> nothing() {
return new Maybe<>(null);
}
@Override
public <R> Maybe<R> bind(Function<T, Monad<R>> mapper) {
if (value == null) {
return Maybe.nothing();
} else {
return (Maybe<R>) mapper.apply(value);
}
}
public T getValue() {
return value;
}
}

In this example, we implement the Maybe monad. The Maybe class has two static factory methods: just, which creates a Maybe with a value, and nothing, which creates an empty Maybe.

The bind method is implemented to handle both the presence of a value (just) and the absence of a value (nothing). When calling bind on a Maybe with a value, the function is applied, and the result is returned as a new Maybe. If the Maybe is empty (nothing), then bind returns an empty Maybe.

Step 3: Using the Maybe Monad Now, let’s use the Maybe monad to demonstrate how it can chain operations:

import java.util.Optional;
import java.util.function.Function;
public class Main {
public static void main(String[] args) {
Maybe<Integer> maybeValue = Maybe.just(10);
Maybe<Integer> result = maybeValue
.bind(value -> Maybe.just(value * 2))
.bind(value -> Maybe.just(value + 5));
System.out.println("Result: " + result.getValue()); // Output: Result: 25
Maybe<Integer> emptyMaybe = Maybe.nothing();
Maybe<Integer> emptyResult = emptyMaybe
.bind(value -> Maybe.just(value * 2))
.bind(value -> Maybe.just(value + 5));
System.out.println("Empty Result: " + emptyResult.getValue()); // Output: Empty Result: null
}
}

In this example, we first create a Maybe with a value of 10 using Maybe.just(10). Then, we chain two bind operations, where the first one multiplies the value by 2 and the second one adds 5. The final result is 25.

Next, we create an empty Maybe using Maybe.nothing() and try to chain operations on it. Since there is no value, all the operations will be skipped, and the final result will be null.

Please note that this is a simple demonstration of how to implement monads in Java using Java 8 features. In practical scenarios, you can use existing libraries like Vavr or functional java that provide more extensive functional programming support, including monads.

Method References

Lambda expressions are used to express the body of a function, but if the function or method has already been created or defined, you can use it directly as a method reference anywhere lambda expressions might be appropriate. The only difference between lambda expressions and methods is that the former are named, whereas the latter are anonymous. Therefore, method references enable us to use methods by name instead of anonymous lambda expressions when a method has already been created.

According to Oracle documentation, method references come in four different forms:

  1. ContainsClass::staticMethodName is a reference to a static method.
  2. Reference to a certain object’s instance method: containingObject::instanceMethodName
  3. Reference to a certain type’s instance method for any arbitrary object ContainingType::methodName
  4. Constructor reference: ClassName::new

public class MyMethodRef {
public void print(String s) {
System.out.println(“Hello the input string was ”,s));
}
public static void printStatic(String s) {
System.out.println(“Hello the input string from static method”,s);
}
public void test() {
List<String> list = Arrays.asList(“Avinash”, “Bikash”, “Chotu”);
list.forEach(this::print);
list.forEach(MyMethodRef::printStatic);
list.forEach(String::toUpperCase);
list.forEach(String::new);
}
}

list.forEach(this::print)

This is an illustration of a method reference to a specific object’s instance method. If you use a lambda expression in place of this method reference, you should be aware of one difference: When using a lambda expression, you can only access fields that are final or almost final; however, with this method reference, you can access any field.

list.forEach(MyMethodRef::printStatic)

An illustration of a method reference to a static method is list.forEach(MyMethodRef::printStatic);

list.forEach(String::toUpperCase).

An example of a method reference to an instance method of an arbitrary object of a certain type is list.forEach(String::toUpperCase).

list.forEach(String::new)

An example of a method reference to a constructor is list.forEach(String::new).

Functional Interfaces

Let us now have a look at Functional Interface in Java 8.

1. An interface in Java known as a functional interface only has one abstract method.
2. Sometimes referred to as SAM (Single-Abstract-Method) types, these interfaces
3. Only functional interfaces are compatible with lambda expressions. Therefore, we can utilize a lambda expression (as well as method references) whenever a function interface is required.
4. As an object-oriented platform, Java is able to handle functional programming (by acting as a type wrapper for a function) and deal with functions thanks to a crucial component known as functional interfaces.

5. Such interfaces can also be marked with the @FunctionalInterface annotation, which enables the compiler to issue warnings or errors in the event that a functional interface is defined inconsistently.

6. Functional interfaces are a means of representing functions, and Java offers a variety of functional interfaces for various function types, which are housed within the java.util.function package. Be aware that functional interfaces can accept generic arguments, allowing the definition of input/output types for any function.

7. The most common primitive types, such as double, int, and long, as well as their combinations of argument and return types, have their own versions of the Functional Interfaces since a primitive type cannot be a generic type argument.

Lambda Expressions

The core of Java functional programming is now revealed. The main construct in Java that introduces a new syntax into the language and enables us to write functions naturally is called a lambda expression.

  1. The application of functional programming principles to an object-oriented framework is made possible via lambda expressions.
  2. From a language syntax perspective, lambda expressions are used to express a function directly in code without the assistance of object-oriented wrappers. It can be thought of as an anonymous method in terms of methods.
  3. Lambdas benefit from all the parallelism and concurrency advantages that may be gained from immutable, consistent functions because they obey the semantics of a function, as in a functional programming language.
  4. Because lambda expressions can be used in code whenever a functional interface is required, lambdas are truly first-class language constructs because they implement functional interfaces internally. They can be provided as an argument, assigned to storage, etc.
  5. Anonymous classes can be replaced by lambdas. The anonymous class can be substituted with a lambda, which would produce clearer and less verbose code, if it is an implementation of an interface with only one method (in such circumstances, the purpose actually is that you are trying to use a function as an argument).

Now let us contrast Java lambda expressions with lambda calculus or functional programming constructs:

  1. Lambdas expressions and functions both have an anonymous nature.

() →System.out.println(“Welcome lambda”)

2.Lambda expressions follow the same closure requirements as anonymous/local classes, which means they can only access final or almost final variables. Functions can not modify state.

  1. Functions can be returned from other functions as outputs or utilized as inputs.
  2. The syntax of a lambda expression, xxx2, in lambda calculus is x.x2. Here, x represents the argument portion, dot the arrow, and expr body the body of the lambda expression.

Syntax for Lambda Expression

Let us examine the syntax of the language that is used to express various functions in code.

  1. Type of parameter is optional, and if there is only one parameter, parenthesis can be omitted.

() →System.out.println(“Hello world from lambda”);

2. Use the arrow operator to divide the body and the parameters list

x →x+2;

3. If there is only one statement, the function body, parentheses, and return can be removed.

(Double x, Double y) →x+y;

(x,y) → {
System.out.println(“Hello world from lambda”);

return x+y;
}

OOP way vs Functional lambdas way

OOP way

Thread t = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(“ I am a thread from OOP”);
}
});

Lambda way

Thread t = new Thread(() -> System.out.println(“Thread from Lambda”));

Best practices for Lambda

  • Always extract complex lambdas into named functions that you reference using ::. You should NEVER write -> {.
  • Avoid excessive call chaining — break them up using explanatory methods and variables, especially if the return type varies across these calls.
  • Whenever null annoys you, think about using the Optional. Twist your mind — you will have to apply functions to the magic box.
  • Realize when the variable thing is a function, and you work with that explicitly, pass a function to another function.
  • Loan Pattern means to have the function you give as the parameter that you use for a resource managed by the ‘host’ function. This leads to a conceptually lighter, loosely coupled, and easy-to-test design
  • Sometimes, you might want to have some arbitrary code to execute around another function. If that is the case, pass that code to the function as a parameter.
  • You can hook type-specific logic to your enums using method references to make sure each enum value is associated with a corresponding bit of logic.

Streams

A pipeline of actions that can change input data into the desired form can be defined using Java streams. Any data source that can grant access to its elements as needed by the Streams API can be operated on using streams. In Java, it would typically be any Collection (? extends Collection). Java 8’s collection was enhanced to support streams. Other than that, one can build a stream-compatible data source using any of the factory or generator methods.

Using streams, it is possible to process data declaratively as opposed to imperatively. The filter-map-reduce pattern, which is available in the majority of languages that support functional programming, may also be applied to collections using streams. Using streams, actions can be executed sequentially and concurrently with ease.

Stream is a declarative description of operations that would be carried out on a data source; it is not a data structure or a collection.

Streams are essentially Monads in the language of functional programming, which means that they are a type of structure that expresses computations that are described as collections of steps. The linking and sequencing of operations, the derivation of characteristics based on the operations and their sequence, and a mechanism to chain that type with other similar kinds are all defined by a type (class, in a Java sense) representing a monad structure.

To use an analogy from programming, a stream’s execution flow can be compared to an iterator in that values flow by and become unusable after they are consumed (once the stream is run though a terminal action).

Three tiers make up a Stream pipeline:

  1. Source: Identifies the data source from which a process receives its data pieces.

Example :

  1. Collection.stream()
  2. Stream.of(T…)
  3. Stream.of(T[])
  4. Stream.empty()
  5. IntStream.range(lower, upper)
  6. IntStream.rangeClosed(lower, upper)

2. Intermediate Operations: We can chain numerous intermediate operations together since intermediate operations return a stream. Laziness is a crucial aspect of intermediate activities. Only when a terminal operation is present will an intermediate operation be carried out.

Example

  • filter(Predicate<T>)
  • map(Function<T, U>)
  • flatMap(Function<T, Stream<U>>
  • distinct()
  • sorted()
  • Sorted(Comparator<T>)
  • limit(long)
  • skip(long)

3. Termination Operation: Either void or a non-stream result is returned by terminal operations. They activate the stream pipeline, which then executes all the applied operations and returns the results.

Example

  • forEach(Consumer<T> action)
  • toArray()
  • reduce(…)
  • collect(…)
  • min(Comparator<T>)
  • max(Comparator<T>)
  • count()
  • {any,all,none}Match(Predicate<T>)
  • findFirst()
  • findAny()

Stream.of(“Avinash”, “Avisha”, “Susmita”, “Bikash”, “Indu”) // source
.filter(s -> s.contains(“a”)) // intermediate operation
.map(String::length) // intermediate operation
.sorted() // intermediate operation
.forEach(System.out::println); // terminal operation

Interface Default and Static methods

The addition of default and static methods is permitted by interfaces; these methods do not have to be implemented in the classes that implement the interface. To designate a default method, the default keyword is added to the method signature. In this context, default denotes a default implementation that implementing classes may override. Defender methods and virtual extension methods are other names for default methods. Static methods cannot be used with implementation class objects since they are a component of the interface.

Using default methods in JDK:

  1. The Map interface now has several default methods, including replaceAll(), putIfAbsent(Key k, Value v), and others.
  2. The Iterable interface now has a forEach default function.
  3. The Collection interface now has the spliterator, parallelStream, and stream functions.

Optional

Optional enables the use of a formal typed solution in place of null references to represent optional values. A component of the java.util package is optional. Null checks and runtime null pointer errors can be prevented by utilizing Optional.

Advantages

  1. A codebase that uses Optional tends to be cleaner, readable, and more expressive than one that uses null references and tests.

2. One can avoid having bloated code with null tests and other things, as well as runtime surprises brought on by NPEs, if Optional’s API is handled properly.

3. It acts as a warning to the developer that the field you are accessing might have a value that is optional so that they are aware up front that certain processing is necessary to correctly access that field when they see an Optional type.

4. It enables us to write more clear and effective APIs, allowing users of your API to immediately understand by looking at the method signature that an Optional will be returned, and that they will need to appropriately unwrap and handle that Optional.

Traditional vs Optional way of dealing null

Traditional coding in Java often involves using null references to represent the absence of a value. This approach can lead to NullPointerExceptions (NPE) if not handled properly. Java 8 introduced the Optional class to address this issue and provide a more concise and safer way to handle potentially absent values.

Let’s see a comparison between traditional coding using null references and how using Optional can improve the code:

Traditional Coding with Null References:

public class Person {
private String name;
private Integer age;
public Person(String name, Integer age) {
this.name = name;
this.age = age;
}
// Getter methods
public String getName() {
return name;
}
public Integer getAge() {
return age;
}
public static void main(String[] args) {
Person person = new Person("John Doe", null);
// Traditional null check before accessing a field
if (person.getAge() != null) {
System.out.println("Age: " + person.getAge());
} else {
System.out.println("Age not available.");
}
}
}

Using Optional in Java 8:

import java.util.Optional;

public class Person {
private String name;
private Optional<Integer> age;
public Person(String name, Integer age) {
this.name = name;
this.age = Optional.ofNullable(age);
}
// Getter methods
public String getName() {
return name;
}
public Optional<Integer> getAge() {
return age;
}
public static void main(String[] args) {
Person person = new Person("John Doe", null);
// Using Optional to handle potentially absent value
person.getAge().ifPresentOrElse(
age -> System.out.println("Age: " + age),
() -> System.out.println("Age not available.")
);
}
}

In the traditional approach, we use a null check before accessing the age field. If age is null, we print that the age is not available. This kind of manual null check and handling can lead to boilerplate code and might be prone to mistakes if you forget to perform the null check.

In contrast, using Optional, we wrap the age field with Optional.ofNullable(age) when creating the Person object. Then, when we want to access the age value, we use the ifPresentOrElse method to handle the presence or absence of the value. If the value is present, the lambda expression inside ifPresent will be executed, and if it's absent, the lambda expression inside orElse will be executed. This approach allows for more concise and safer code by avoiding explicit null checks.

Using Optional not only reduces the chances of getting NPEs but also makes the code more expressive and readable, as it forces the developer to handle the possibility of missing values in a more explicit and structured way.

It is vital to keep in mind that the Optional.get() method should only be used in extremely specific circumstances. It is not advised to use the Optional API directly by calling Optional.get(). It should always be contained inside of a check for Optional.isPresent().If it were used directly, it might raise a NoSuchElementException if the value was missing; as a result, it has the same issue as null references. even when Optional is properly used.This method is not advised because it functions the same way as the null checks and can be nested with Optional.isPresent.

CompletableFuture

CompletableFuture is a class introduced in Java 8 as part of the java.util.concurrent package. It represents a promise of a future result that can be completed asynchronously. With CompletableFuture, you can perform asynchronous computations and compose them in a declarative and more readable way. It enables you to write non-blocking, concurrent, and parallel code in a much simpler manner compared to traditional approaches.

Pros of using CompletableFuture:

  1. Asynchronous and Non-blocking: CompletableFuture allows you to perform asynchronous tasks without blocking the main thread. This helps improve the responsiveness of your application, especially in scenarios where tasks may take some time to complete.
  2. Composability: You can easily compose multiple CompletableFuture instances to create complex asynchronous workflows. This makes it easy to express dependencies and relationships between different asynchronous tasks.
  3. Error Handling: CompletableFuture provides built-in mechanisms for handling errors and exceptions that may occur during the computation. You can handle exceptions gracefully without causing the application to crash.
  4. Combining Multiple Futures: You can combine multiple CompletableFuture instances to wait for all of them to complete, or any one of them to complete, or even run a computation when either of them completes.
  5. Timeout and Cancellation: CompletableFuture allows you to set timeouts for futures, which means you can specify how long you are willing to wait for the result. You can also cancel a CompletableFuture if needed.

Now, let’s see an example of using CompletableFuture to demonstrate some of its features:

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class CompletableFutureExample {
public static void main(String[] args) {
// Create a CompletableFuture that will complete with a result after 2 seconds
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "Result from future1";
});
// Create another CompletableFuture that will complete with a result immediately
CompletableFuture<String> future2 = CompletableFuture.completedFuture("Result from future2");
// Chain the CompletableFuture instances together using thenCombine
CompletableFuture<String> combinedFuture = future1
.thenCombine(future2, (result1, result2) -> result1 + " and " + result2);
// Handle the result of the combined future
combinedFuture.thenAccept(System.out::println);
// Wait for the combined future to complete
try {
combinedFuture.get();
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
}

In this example, we create two CompletableFuture instances, future1 and future2. future1 is set to complete after 2 seconds, simulating a long-running computation. future2 is immediately completed with a result.

Then, we chain these two futures together using the thenCombine method, which allows us to combine the results of both futures. In this case, we concatenate the results with the "and" string.

Finally, we wait for the combined future to complete using the get method, which blocks the main thread until the future's result is available.

Keep in mind that CompletableFuture provides many other methods for different types of combinations, transformations, and error handling. It is a powerful tool for writing asynchronous and concurrent code in Java. However, it's essential to use it judiciously as too many chained futures or improperly handled exceptions can lead to complex and hard-to-maintain code.

Annotation in Java 8

In Java 8, annotations were enhanced with some new features that make them more powerful and flexible. Annotations are used to provide metadata about elements in the Java code, such as classes, methods, fields, or even other annotations. They allow you to embed additional information that can be processed at compile time or runtime. To create a custom annotation, you’ll need to define an annotation type using the @interface keyword.

Here’s how you can define a custom annotation in Java:

import java.lang.annotation.*;

// Define the custom annotation type
@Retention(RetentionPolicy.RUNTIME) // Indicates that this annotation will be available at runtime
@Target(ElementType.METHOD) // Specifies that this annotation can only be applied to methods
public @interface MyCustomAnnotation {
// Define annotation elements (parameters)
String value(); // An annotation element named "value" of type String
int count() default 1; // An annotation element named "count" of type int with a default value of 1
}

In this example, we defined a custom annotation @MyCustomAnnotation with two elements: value and count. The value element is of type String, and the count element is of type int with a default value of 1.

Now let’s apply this custom annotation to a method:

public class MyClass {

@MyCustomAnnotation(value = "Hello, World!", count = 3)
public void myAnnotatedMethod() {
System.out.println("Inside the annotated method.");
}
public void nonAnnotatedMethod() {
System.out.println("Inside the non-annotated method.");
}
}

In the above example, we have a class MyClass with two methods: myAnnotatedMethod and nonAnnotatedMethod. We applied the @MyCustomAnnotation to the myAnnotatedMethod and provided values for its elements.

Now, to access the annotation and its values at runtime, we can use reflection:

import java.lang.reflect.Method;
public class Main {
public static void main(String[] args) {
MyClass myClass = new MyClass();
// Get the class object
Class<?> cls = myClass.getClass();
// Get the method object
Method method = null;
try {
method = cls.getMethod("myAnnotatedMethod");
} catch (NoSuchMethodException e) {
e.printStackTrace();
}
// Check if the method has the custom annotation
if (method != null && method.isAnnotationPresent(MyCustomAnnotation.class)) {
// Get the annotation instance
MyCustomAnnotation annotation = method.getAnnotation(MyCustomAnnotation.class);
// Access the annotation elements
String value = annotation.value();
int count = annotation.count();
// Use the annotation elements
System.out.println("Annotation value: " + value);
System.out.println("Annotation count: " + count);
} else {
System.out.println("Method is not annotated with @MyCustomAnnotation.");
}
}
}

The Main class uses reflection to get the Method object for myAnnotatedMethod and checks if it has the @MyCustomAnnotation applied. If it does, it extracts the values of the annotation elements and prints them.

Remember that annotations can be processed in various ways, such as code generation, configuration, or validation, depending on your specific use case. Java frameworks like Spring and Hibernate extensively use annotations to provide configuration and enhance functionality.

--

--