This is the fourth day of my participation in the November Gwen Challenge. See details: The last Gwen Challenge 2021.

[Note]Introduction To Pragmatic Functional Java – DZone Java

Pragmatic Funcational Java is a modern, very concise, yet readable Java coding style based on the concepts of functional programming.

Functional Java (PFJ) is an attempt to define a new idiomatic Java coding style. The coding style will take full advantage of all the features of current and upcoming versions of Java and involve compilers to help write concise yet reliable and readable code. While this style can be used even in Java 8, it looks much cleaner and more concise in Java 11. It has become more expressive in Java 17 and benefits from every new Java language feature. But PFJ is not a free lunch, and it requires a major change in developer habits and approaches. Changing habits isn’t easy, especially traditional imperative habits. Is it worth it? Indeed! PFJ code is concise, expressive, and reliable. It’s easy to read and maintain, and in most cases, if the code can compile – it works!

Utility elements for functional Java

PFJ comes from an excellent Effective Java book, which contains some additional concepts and conventions, particularly from FP: Functional Programming. Note that despite the use of the FP concept, PFJ does not attempt to enforce FP-specific terminology. (Although references are also provided for those interested in exploring these concepts further). PFJ focuses on:

  • Reduce psychological burden.
  • Improve code reliability.
  • Improve long-term maintainability.
  • Use a compiler to help you write correct code.
  • Making it easy and natural to write correct code, while it is still possible to write incorrect code, should require effort.

Despite the ambitious goals, there are only two key PFJ rules:

  • Avoid as much as possiblenull.
  • No service is abnormal.

Below, each key rule is explored in more detail:

Avoid NULL whenever possible (ANAMAP rule)

The nullability of a variable is one of the special states. They are well-known sources of runtime errors and boilerplate code. To eliminate these problems and represent possible missing values, PFJ uses the Option

container. This covers all the situations in which such values can occur – return values, input parameters, or fields. In some cases, classes may use NULL internally, for performance or compatibility with existing frameworks, for example. These conditions must be clearly documented and invisible to class users, that is, Option

should be used for all class apis. This approach has several advantages:

  • Nullable variables are immediately visible in the code. No need to read documentation, check source code, or rely on comments.
  • The compiler distinguishes between nullable and non-nullable variables and prevents incorrect assignment between them.
  • To eliminate thenullCheck all templates required.

No service exception (NBE rule)

PFJ uses exceptions only to represent cases of fatal, unrecoverable (technical) failure. Such exceptions may be intercepted only for the purpose of logging and/or shutting down the application normally. All other exceptions and their interception are discouraged and avoided if possible. A business exception is another case of a special state. To propagate and handle business-level errors, PFJ uses the Result

container. Again, this covers everything that can go wrong – return values, input parameters, or fields. Practice has shown that few, if any, fields need to use this container. There is no legitimate case for using business-level exceptions. Interacts with existing Java libraries and legacy code through specialized wrappers. The Result

container contains implementations of these wrapping methods. The no service exception rule has the following advantages:

  • Methods that can return errors are immediately visible in the code. There is no need to read the documentation, examine the source code, or analyze the call tree to see what exceptions can be thrown and under what conditions.
  • The compiler enforces proper error handling and propagation.
  • There is almost no boilerplate for error handling and propagation.
  • We can doHappy daysScenarios write code and handle the original intent of the error-exception at the most convenient point, which is never actually implemented.
  • The code remains composable, easy to read, and deducible, with no hidden interrupts or unexpected transitions in the execution flowWhat you read is what will be carried out.

Transform legacy code into PFJ-style code

Ok, the key rules look good and useful, but what does the actual code look like? Let’s start with a very typical example of back-end code:

public interface UserRepository {
    User findById(User.Id userId);
}

public interface UserProfileRepository {
    UserProfile findById(User.Id userId);
}

public class UserService {
    private final UserRepository userRepository;
    private final UserProfileRepository userProfileRepository;

    public UserWithProfile getUserWithProfile(User.Id userId) {
        User user = userRepository.findById(userId);
        if (user == null) {
            throw UserNotFoundException("User with ID " + userId + " not found");
        }
        UserProfile details = userProfileRepository.findById(userId);
        return UserWithProfile.of(user, details == null? UserProfile.defaultDetails() : details); }}Copy the code

The interface at the beginning of the example is provided for context clarity. The main point of interest is the getUserWithProfile method. Let’s do it step by step.

  • The first statement is retrieved from the user repositoryuserThe variable.
  • Since the user may not exist in the repositoryuserThe variable may benull. The followingnullCheck to verify if this is the case and throw a business exception if it is.
  • The next step is to retrieve the user profile details. Lack of detail is not considered a mistake. In contrast, when details are missing, the default values are used in the configuration file.

There are several problems with the code above. First, if no value exists in the repository, returning null is not obvious from the interface. We need to examine the documentation, explore the implementation, or guess how these repositories work. Annotations are sometimes used to provide hints, but this still does not guarantee the behavior of the API. To solve this problem, let’s apply the rule to the repository:

public interface UserRepository {
    Option<User> findById(User.Id userId);
}

public interface UserProfileRepository {
    Option<UserProfile> findById(User.Id userId);
}
Copy the code

No guesswork at this point – the API explicitly tells you that there may be no return value. Now let’s look at the getUserWithProfile method again. The second thing to note is that this method may return a value or may throw an exception. This is a business exception, so we can apply this rule. Primary goal of change – To clarify the fact that methods may return values or errors:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
Copy the code

Okay, now that we’ve cleaned up the API, we’re ready to change the code. The first change is caused by userRepository now returning Option

:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
}
Copy the code

Now we need to check if the user exists and return an error if not. Using the traditional imperative approach, the code would look like this:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }
Copy the code

The} code doesn’t look very attractive, but it’s no worse than the original, so leave it as it is for now. The next step is to try to convert the rest of the code:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    Option<UserProfile> details = userProfileRepository.findById(userId);
   
}
Copy the code

Here’s the problem: the details and users are stored in the Option

container, so to assemble the UserWithProfile, we need to extract the values somehow. There might be different methods here, for example, using the option.fold () method. The resulting code will certainly not be pretty, and it will probably break the rules. There is another approach – use the fact that Option

is a container with special attributes. In particular, the option.map () and option.flatmap () methods can be used to convert values in Option

. In addition, we know that the Details value will be supplied by the repository or replaced with the default value. To do this, we can use the option.or () method to extract the details from the container. Let’s try these methods:


public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
   
    Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));
   
}
Copy the code

Now we need to write the final step – convert the userWithProfile container from Option

to Result

:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<User> user = userRepository.findById(userId);
   
    if (user.isEmpty()) {
        return Result.failure(Causes.cause("User with ID " + userId + " not found"));
    }

    UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());

    Option<UserWithProfile> userWithProfile =  user.map(userValue -> UserWithProfile.of(userValue, details));

    return userWithProfile.toResult(Cause.cause(""));
}
Copy the code

Let’s leave the reason for the error in the return statement blank for the moment and look at the code again. We can easily spot a problem: we must know that the userWithProfile always exists – when the user does not exist, this situation has been dealt with above. How can we solve this problem? Note that we can call user.map() without checking if the user exists. The transformation is applied only if the user exists, otherwise it is ignored. In this way, we can eliminate the if(user.isempty ()) check. Let’s move the user details retrieval and conversion to UserWithProfile in the lambda passed to user.map() :

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
        return UserWithProfile.of(userValue, details);
    });
   
    return userWithProfile.toResult(Cause.cause(""));
}
Copy the code

Now you need to change the last line because userWithProfile might be missing. This error will be the same as in previous versions, because userWithProfile is missing only if the value returned by userRepository.findById(userId) is missing:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    Option<UserWithProfile> userWithProfile = userRepository.findById(userId).map(userValue -> {
        UserProfile details = userProfileRepository.findById(userId).or(UserProfile.defaultDetails());
        return UserWithProfile.of(userValue, details);
    });
   
    return userWithProfile.toResult(Causes.cause("User with ID " + userId + " not found"));
}
Copy the code

Finally, we can inline details and userWithProfile, since they are used only once immediately after creation:

public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
    return userRepository.findById(userId)
        .map(userValue -> UserWithProfile.of(userValue, userProfileRepository.findById(userId)
                                                                             .or(UserProfile.defaultDetails())))
        .toResult(Causes.cause("User with ID " + userId + " not found"));
}
Copy the code

Notice how indentation helps group code into logically linked sections. Let’s examine the resulting code:

  • The code is more concise, forHappy daysScenarios are written without explicit errors ornullCheck that there is no interference with business logic
  • There is no easy way to skip or avoid errors ornullChecking to write correct and reliable code is straightforward and natural.

A less obvious observation:

  • All types are automatically derived. This simplifies refactoring and eliminates unnecessary clutter. You can still add types if you want.
  • If at some point the repository will start returningResult<T>Rather thanOption<T>, the code remains the same, except for the last conversion (toResult) will be deleted.
  • In addition to usingOption.or()Method replaces the ternary operator except that the resulting code looks a lot like the original if we would pass it inside the lambdareturnThe code in the statement moves tomap()Methods.

One last observation is useful to start writing PFJ-style code easily (reading is usually not a problem). It can be rewritten as the following rule of thumb: Look for values on the right. Compare:

User user = userRepository.findById(userId); // <-- the value is on the left of the expression
Copy the code

and

returnuserRepository.findById(userId) .map(user -> ...) ;// <-- the value is on the right side of the expression
Copy the code

This useful observation helps in the transition from legacy imperative code styles to PFJ.

Interact with legacy code

Needless to say, the existing code does not follow the PFJ approach. It throws exceptions, returns NULL, and so on. Sometimes this code can be rewritten to make it PFJ compatible, but usually this is not the case. This is especially true for external libraries and frameworks.

Calling legacy code

There are two main problems with legacy code calls. Each of them is associated with a violation of the corresponding PFJ rule:

Handling Service Exceptions

Result

contains a helper method called lift(), which covers most use cases. Method signatures look like this:

static <R> Result<R> lift(FN1<? extends Cause, ? super Throwable> exceptionMapper, ThrowingSupplier<R> supplier)
Copy the code

The first argument is a function that converts the exception to a Cause instance (which, in turn, is used to create a Result

instance in the event of a failure). The second argument is lambda, which encapsulates the call to the actual code that needs to be compatible with PFJ. The Causesutility class provides the simplest function that converts an exception to an instance of Cause: fromThrowable(). They can be used with result.lift (), as follows:

public static Result<URI> createURI(String uri) {
    return Result.lift(Causes::fromThrowable, () -> URI.create(uri));
}
Copy the code

Handle null value returns

The case is fairly simple – if the API can return NULL, just wrap it in Option

using the option.option () method.

Providing legacy apis

Sometimes you need to allow legacy code to call code written in PFJ style. In particular, this often happens when some smaller subsystems are converted to the PFJ style, but the rest of the system is still written in the old style and the API needs to be preserved. The most convenient approach is to split the implementation into two parts — a PFJ-style API and an adapter, which only ADAPTS the new API to the old ONE. This can be a very useful simple helper, as shown below:

public static <T> T unwrap(Result<T> value) {
    return value.fold(
        cause -> { throw new IllegalStateException(cause.message()); },
        content -> content
    );
}
Copy the code

No ready-to-use helper method is provided in Result

for the following reasons:

  • There may be different use cases, and different types of exceptions (checked and unchecked) can be thrown.
  • willCauseThe conversion to different specific exceptions depends largely on the specific use case.

Manage variable scope

This section focuses on a variety of practical cases that arise when writing PFJ-style code. The following example assumes the use of Result

, but this is largely irrelevant because all considerations also apply to Option

. In addition, the example assumes that the function called in the example is converted to return Result

instead of throwing an exception.


Nested scopes

Functional-style code makes heavy use of lambda to perform calculations and conversions of values in the Option

and Result

containers. Each lambda implicitly creates scopes for its parameters — they can be accessed inside the lambda body, but not outside it. This is usually a useful attribute, but for traditional imperative code, it is unusual and may feel inconvenient at first. Fortunately, there is a simple technique to overcome the perceived inconvenience. Let’s look at the following imperative code:

varvalue1 = function1(...) ;// function1()May throw an exceptionvarvalue2 = function2(value1, ...) ;// Function2 () may throw an exception
varvalue3 = function3(value1, value2, ...) ;// function3() may throw an exception
Copy the code

The variable value1 should be accessible to call function2() and function3(). This does mean that converting directly to PFJ style will not work:

function1(...) .flatMap(value1 -> function2(value1, ...) ) .flatMap(value2 -> function3(value1, value2, ...) );// <-- no, value1 is not accessible
Copy the code

To keep values accessible, we need to use nested scopes, i.e., nested calls like this:

function1(...) .flatMap(value1 -> function2(value1, ...) .flatMap(value2 -> function3(value1, value2, ...) ));Copy the code

The second call to flatMap() is for the value returned by function2 rather than the first flatMap(). In this way, we keep Value1 in scope and make function3 accessible. Although you can create nested scopes of any depth, often multiple nested scopes are more difficult to read and follow. In this case, it is highly recommended to extract deeper scopes into specialized functions.

Parallel scope

Another commonly observed situation is the need to evaluate/retrieve several independent values and then make a call or build an object. Let’s look at the following example:

varvalue1 = function1(...) ;// Function1 () may throw an exception
varvalue2 = function2(...) ;// Function2 () may throw an exception
varvalue3 = function3(...) ;// function3() may throw an exception
return new MyObject(value1, value2, value3);
Copy the code

At first glance, converting to PFJ style can be exactly the same as nested scopes. The visibility of each value will be the same as the imperative code. Unfortunately, this can make scopes very nested, especially if you need to fetch many values. For this case, Option

and Result

provide a set of all() methods. These methods perform a “parallel” calculation of all values and return MapperX<… > A dedicated version of the interface. This interface has only three methods — id(), map(), and flatMap(). The map() and flatMap() methods work exactly the same as their counterparts in Option

and Result

, except that they accept lambdas with different numbers of arguments. Let’s see how it works in practice and convert the above imperative code to PFJ style:



returnResult.all( function1(...) , function2(...) , function3(...) ).map(MyObject::new);
Copy the code

In addition to being compact and flat, this approach has some advantages. First, it makes the intent clear — all values are computed before they are used. Imperative code performs this sequentially, hiding the original intent. Second advantage – each value is evaluated independently and does not bring unnecessary values into the range. This reduces the context required to understand and reason about each function call.

Substitution scope

A less common but still important case is where we need to retrieve a value, but if it is not available, then we use an alternative source for that value. This is even less frequent when multiple alternatives are available, and more painful when error handling is involved. Let’s look at the following imperative code:

MyType value;

try{ value = function1(...) ; }catch (MyException e1) {
    try{ value = function2(...) ; }catch(MyException e2) {
        try{ value = function3(...) ; }catch(MyException e3) {
            ... // repeat as many times as there are alternatives}}}Copy the code

The code is contrived because nested cases are often hidden in other methods. Still, the overall logic is not simple, mainly because in addition to selecting values, we also need to deal with errors. Error handling messes up the code and leaves the initial intent — to choose the first available alternative — hidden in error handling. The switch to PFJ style makes the intent very clear:

varvalue = Result.any( function1(...) , function2(...) , function3(...) );Copy the code

Unfortunately, there is an important difference: the original imperative code only evaluates the second and subsequent alternatives when necessary. In some cases this is not a problem, but in many cases it is highly undesirable. Fortunately, result.any () has an inert version. Using it, we can rewrite the code as follows:

varvalue = Result.any( function1(...) , () -> function2(...) , () -> function3(...) );Copy the code

Now, the transformed code behaves exactly like its imperative counterpart.

A brief technical overview of Option<T> and Result<T>

These two containers are monads in functional programming terms. Option

is a direct implementation of Option/Optional/Maybe monad. Result

is a deliberately simplified and specialized version of Either

: the left type is fixed and should implement the Cause interface. Specialization makes the API very similar to Option

and eliminates many unnecessary inputs at the expense of generality. This particular implementation focuses on two things:

,r>

  • With existing JDK classes (e.gOptional<T>Stream<T>Interoperability between
  • Apis for explicit intent expressions

The last sentence deserves further explanation. Each container has several core methods:

  • The factory method
  • map()A method of converting a value without changing a particular state:present Option<T>keepThe present, success Result < T >keepsuccess.
  • flatMap()The transformation method, in addition to the transformation, can also change the special state: willOption<T> presentconvertemptyOr willResult<T> successconvertfailure.
  • fold()Method, which handles both cases (Option<T>present/emptyResult<T>success/failure).

In addition to the core methods, there are a bunch of helper methods that are useful in frequently observed use cases. Among these approaches, there is a group that is explicitly designed to produce side effects. Option

has the following side effects:

Option<T> whenPresent(Consumer<? super T> consumer);
Option<T> whenEmpty(Runnable action);
Option<T> apply(Runnable emptyValConsumer, Consumer<? super T> nonEmptyValConsumer);
Copy the code

Result

has the following side effects:

Result<T> onSuccess(Consumer<T> consumer);
Result<T> onSuccessDo(Runnable action);
Result<T> onFailure(Consumer<? super Cause> consumer);
Result<T> onFailureDo(Runnable action);
Result<T> apply(Consumer<? super Cause> failureConsumer, Consumer<? super T> successConsumer);
Copy the code

These methods provide the reader with hints that the code handles side effects rather than transformations.

Other useful tools

In addition to Option

and Result

, PFJ uses some other generic classes. Each method is described in more detail below.

Functions (Functions)

The JDK provides a number of useful functional interfaces. Unfortunately, the functional interface for generic functions is limited to two versions: single-parameter Function

and two-parameter BiFunction

. Obviously, this is not enough in many practical situations. Also, for some reason, the type parameters of these functions are the opposite of how functions are declared in Java: the result type is listed last, whereas in function declarations it is defined first. PFJ uses a consistent set of function interfaces for functions with 1 to 9 parameters. For brevity, they are called FN1… FN9. So far, there are no function use cases with more arguments (this is usually code smell). But the list can be extended further if necessary.
,>
,>

Tuples

A tuple is a special container for storing multiple different types of values in a single variable. Unlike a class or record, the value stored in it has no name. This makes them indispensable tools for capturing arbitrary sets of values while preserving types. A good example of this use case is the implementation of the result.all () and option.all () method sets. In a sense, a tuple can be thought of as a frozen set of parameters for a function call. From this perspective, the decision to make the internal values of a tuple accessible only through the map() method sounds reasonable. However, tuples with 2 arguments have additional accessors and can use Tuple2

as an alternative to various Pair

implementations. PFJ is implemented using a consistent set of tuples with 0 to 9 values. Provide tuples with 0 and 1 values for consistency.
,t2>
,t2>

conclusion

Functional Java is a modern, very concise, yet readable Java coding style based on functional programming concepts. It offers a number of benefits over the traditional idiomatic Java coding style:

  • PFJ uses the Java compiler to help write reliable code:
  • Compiled code is usually valid
  • Many errors migrate from run time to compile time
  • Certain types of errors, for exampleNullPointerExceptionOr an unhandled exception that has actually been eliminated
  • PFJ significantly reduces error propagation and handling as wellnullCheck the amount of boilerplate code involved
  • PFJ focuses on articulating intention clearly and reducing psychological burden