[Note] This article is translated from: Introduction To Pragmatic Functional Java-DZone Java
Pragmatic Funcational Java is a modern, very concise but readable Java coding style based on functional programming concepts.
Practical Functional Java (PFJ) attempts to define a new idiomatic Java coding style. The coding style will fully utilize all the features of the current and upcoming Java versions, and involve a compiler to help write concise but reliable and readable code.
Although this style can even be used in Java 8, it looks more concise and concise in Java 11. It becomes more expressive in Java 17, and benefits from every new Java language feature.
But PFJ is not a free lunch, it requires a major change in the habits and methods of developers. Changing habits is not easy, and traditional imperative habits are especially difficult to solve.
Is it worth it? really! The PFJ code is concise, expressive, and reliable. It is easy to read and maintain, and in most cases, if the code can be compiled-it can work!
Elements of practical functional Java
PFJ is derived from a wonderful Effective Java book, which contains some additional concepts and conventions, especially from functional programming (FP: Functional Programming). Note that despite the use of FP concepts, PFJ does not attempt to enforce FP-specific terminology. (Although for those who are interested in further exploring these concepts, we also provide references).
PFJ focuses on:
- Reduce the psychological burden.
- Improve code reliability.
- Improve long-term maintainability.
- Use a compiler to help write the correct code.
- Make it easy and natural to write correct code. Although it is still possible to write incorrect code, it should require effort.
Despite the ambitious goals, there are only two key PFJ rules:
null
as much as possible.- There are no business exceptions.
Below, each key rule is discussed in more detail:
Avoid null as much as possible (ANAMAP rule)
The nullability of variables is one of the special states. They are well-known sources of runtime errors and boilerplate code. In order to eliminate these problems and indicate possible missing values, PFJ uses the Option<T>
container. This covers all situations where such a value may appear-return value, input parameter or field.
In some cases, such as for performance or compatibility with existing frameworks, the class may use null
internally. These situations must be clearly documented and not visible to class users, that is, all class APIs should use Option<T>
.
This method 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 assignments between them.
- Eliminate all templates required
null
No business exception (NBE rule)
PFJ only uses exceptions to indicate fatal, unrecoverable (technical) failure conditions. Such exceptions may only be intercepted for the purpose of logging and/or gracefully closing the application. Discourage and avoid all other exceptions and their interception as much as possible.
Business abnormality is another case of a special state. In order to propagate and handle business-level errors, PFJ uses the Result<T>
container. Again, this covers all situations where errors can occur-return values, input parameters, or fields. Practice shows that very few (if any) fields need to use this container.
There is no legitimate situation to use business-level exceptions. Interact with existing Java libraries and legacy code through dedicated packaging methods. Result<T>
container contains the implementation of these packaging methods. no business 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, check the source code, or analyze the call tree to check which exceptions can be thrown and under what conditions.
- The compiler enforces correct error handling and propagation.
- There are few examples of error handling and propagation.
- We can happy day scene, and handle the error at the most convenient point-the original intent of the exception, which has actually never been realized.
- The code remains composable, easy to read and reason, and there are no hidden interruptions or unexpected transitions in the execution flow- what you read is the that will be executed.
Convert legacy code to PFJ style code
Okay, the key rules look good and useful, but what will the real code look like?
Let's start with a very typical 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);
}
}
The interface at the beginning of the example is provided for clarity of context. The main point of interest is the getUserWithProfile
method. Let's analyze it step by step.
- The first statement retrieves the
user
variable from the user repository. - Since the user may not exist in the repository, the
user
variable may benull
. The followingnull
checks to verify whether this is the case, and if it is, a business exception is thrown. - The next step is to retrieve the user profile details. Lack of details is not considered an error. Conversely, when detailed information is missing, the configuration file will use default values.
There are several problems with the above code. First of all, if the value does not exist in the storage, the return is null
, which is not obvious from the interface. We need to check the documentation, study 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);
}
No guesswork is needed now-the API clearly informs that there may not be a return value.
Now let us look at the getUserWithProfile
method again. The second thing to note is that the method may return a value or may raise an exception. This is a business exception, so we can apply the rule. The main goal of the change-to clarify the fact that the or
public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
Okay, now that we have cleaned up the API, we can start changing the code. The first change is returned userRepository
Option<User>
by 0618d05cc21133:
public Result<UserWithProfile> getUserWithProfile(User.Id userId) {
Option<User> user = userRepository.findById(userId);
}
Now we need to check whether the user exists, and if not, return an error. Using the traditional imperative approach, the code should 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"));
}
}
The code doesn't look very attractive, but it's not worse than the original one, so keep it as it is for the time being.
The next step is to try to convert the remaining part 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);
}
Option<T>
comes the problem: detailed information and users are stored in the 0618d05cc211dc container, so to assemble UserWithProfile
, we need to extract the value in some way. There may be different methods, for example, use the Option.fold()
method. The generated code will certainly not be very beautiful, and it is likely to violate the rules.
There is another way-use Option<T>
is a container with special properties.
In particular, the Option.map()
and Option.flatMap()
methods can be used to convert the value in Option<T>
In addition, we know that the details
will be provided by the repository or replaced with the default value. For this, we can use the Option.or()
method to extract detailed information 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));
}
Now we need to write the last step-convert the userWithProfile
container from Option<T>
to Result<T>
:
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(""));
}
We temporarily return
statement blank, and then check the code again.
We can easily find a problem: we must know that userWithProfile
always exists-when user
does not exist, the above has already dealt with this situation. How can we solve this problem?
Please note that we can call user.map()
without checking whether the user exists. The user
exists, otherwise it will be ignored. In this way, we can eliminate the if(user.isEmpty())
check. Let us User
of details
user.map()
in the lambda passed to 0618d05cc213df and convert it to UserWithProfile
:
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(""));
}
Now you need to change the last line, because userWithProfile
may be missing. The error will be the same as the previous version, because only when userRepository.findById(userId)
the return of the missing values, userWithProfile
will be 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"));
}
Finally, we can inline details
and userWithProfile
because they are only used 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"));
}
Note how indentation helps group code into logically linked parts.
Let's analyze the resulting code:
- The code is more concise,
happy day scene, there is no clear error or
null
check, no interference with business logic - There is no easy way to skip or avoid errors or
null
checks. Writing correct and reliable code is straightforward and natural.
Less obvious observations:
- All types are derived automatically. This simplifies refactoring and eliminates unnecessary confusion. If needed, you can still add types.
- If at some point the repository will start to return
Result<T>
instead ofOption<T>
, the code will remain unchanged, except for the last transition (toResult
) will be deleted. - Except for replacing the ternary operator with the
Option.or()
method, the resulting code looks a lot like if we moved the codereturn
map()
method.
The last observation is very useful for starting to easily write (reading is usually not a problem) PFJ style code. It can be rewritten as the following rule of thumb: look for the value on the right. Compare:
User user = userRepository.findById(userId); // <-- 值在表达式左边
with
return userRepository.findById(userId)
.map(user -> ...); // <-- 值在表达式右边
This useful observation facilitates the transition from legacy imperative coding style to PFJ.
Interact with legacy code
Needless to say, the existing code does not follow the PFJ method. It throws an exception, returns null
and so on. Sometimes this code can be rewritten to make it compatible with PFJ, but usually this is not the case. This is especially true for external libraries and frameworks.
Call legacy code
There are two main problems with legacy code calls. Each of them is related to the violation of the corresponding PFJ rules:
Handling business exceptions
Result<T>
includes an lift()
, which covers most use cases. The method signature looks like this:
static <R> Result<R> lift(FN1<? extends Cause, ? super Throwable> exceptionMapper, ThrowingSupplier<R> supplier)
The first parameter is a function that converts the exception to an Cause
(in turn, it is used to create Result<T>
instance of 0618d05cc21990 in case of failure). The second parameter is lambda, which encapsulates the call to the actual code that needs to be compatible with PFJ.
The simplest function is provided in the Causesutility
class, which converts the 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));
}
Handle null value return
This situation is fairly simple-if the API can return null
, just use the Option.option()
method to pack it into Option<T>
.
Provide legacy API
Sometimes it is necessary to allow legacy code to call code written in PFJ style. In particular, this usually happens when some smaller subsystems are converted to 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 way is to split the implementation into two parts-PFJ style API and adapter, which only adapt the new API to the old API. This can be a very useful simple helper method, as shown below:
public static <T> T unwrap(Result<T> value) {
return value.fold(
cause -> { throw new IllegalStateException(cause.message()); },
content -> content
);
}
Result<T>
is no readily available auxiliary method in 0618d05cc21ac1 for the following reasons:
- There may be different use cases, and different types of exceptions (checked and unchecked) can be thrown.
- The
Cause
to different specific exceptions largely depends on the specific use case.
Manage variable scope
This section will specifically introduce various practical cases when writing PFJ style code.
The example below assumes that Result<T>
used, but this is largely irrelevant because all considerations also apply to Option<T>
. In addition, the example assumes that the function called in the example is converted to return Result<T>
instead of throwing an exception.
Nested scope
The functional style code uses lambda extensively to perform the calculation and conversion of the values in the Option<T>
and Result<T>
Every lambda implicitly creates a scope 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 very unusual and may feel inconvenient at first. Fortunately, there is a simple technique that can overcome the perceived inconvenience.
Let's take a look at the following imperative code:
var value1 = function1(...); // function1()
可能抛出异常
var value2 = function2(value1, ...); // function2() 可能抛出异常
var value3 = function3(value1, value2, ...); // function3() 可能抛出异常
The variable value1
should be accessible to call function2()
and function3(). This does mean that direct conversion to PFJ style will not work:
function1(...)
.flatMap(value1 -> function2(value1, ...))
.flatMap(value2 -> function3(value1, value2, ...)); // <-- 错, value1 不可访问
In order to maintain the accessibility of the value, we need to use nested scope, that is, the nested call is as follows:
function1(...)
.flatMap(value1 -> function2(value1, ...)
.flatMap(value2 -> function3(value1, value2, ...)));
The second call to flatMap()
is for function2
of the value returned by the first flatMap()
. In this way, we keep value1
in range and make it accessible to function3
Although you can create nested scopes of any depth, multiple nested scopes are often more difficult to read and follow. In this case, it is strongly recommended to extract the deeper range into a dedicated function.
Parallel scope
Another frequently observed situation is the need to calculate/retrieve several independent values, and then make a call or construct an object. Let's look at the following example:
var value1 = function1(...); // function1() 可能抛出异常
var value2 = function2(...); // function2() 可能抛出异常
var value3 = function3(...); // function3() 可能抛出异常
return new MyObject(value1, value2, value3);
At first glance, the conversion to the PFJ style can be exactly the same as the nested scope. The visibility of each value will be the same as the imperative code. Unfortunately, this makes the scope deeply nested, especially if you need to get many values.
For this case, Option<T>
and Result<T>
provide a set of all()
methods. These methods perform a "parallel" calculation of all values and return a dedicated version of the MapperX<...>
This interface has only three methods- id()
, map()
and flatMap()
. map()
and flatMap()
methods work Option<T>
and Result<T>
, except that they accept lambdas with different numbers of parameters. Let's take a look at how it works in practice and convert the imperative code above into PFJ style:
return Result.all(
function1(...),
function2(...),
function3(...)
).map(MyObject::new);
In addition to compactness and flat , this method has some advantages. First, it clearly expresses the intent-to calculate all values before use. The imperative code performs this operation sequentially, hiding the original intent. The second advantage-the calculation of each value is independent and will 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 situation is that we need to retrieve a value, but if it is not available, then we use an alternative source for that value. When multiple alternatives are available, the frequency of this situation is even lower, and it is more painful when it comes to error handling.
Let's take a 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
}
}
}
The code is artificially designed because nested cases are usually hidden in other methods. Nevertheless, 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 keeps the initial intention-choosing the first available alternative-hidden in error handling.
The transition to PFJ style makes the intent very clear:
var value = Result.any(
function1(...),
function2(...),
function3(...)
);
Unfortunately, there is an important difference: the original imperative code only calculates the second and subsequent alternatives when necessary. In some cases, this is not a problem, but in many cases, it is very undesirable. Fortunately, Result.any()
has a lazy version. Using it, we can rewrite the code as follows:
var value = Result.any(
function1(...),
() -> function2(...),
() -> function3(...)
);
Now, the converted code behaves exactly like its imperative counterpart.
Brief technical overview of Option<T> and Result<T>
These two containers are monads in functional programming terms.Option<T>
is a direct implementation Option/Optional/Maybe monad
Result<T>
is Either<L,R>
: the left type is fixed and the Cause interface should be implemented. Specialization makes the API Option<T>
, and eliminates many unnecessary inputs at the expense of generality.
This particular implementation focuses on two things:
- Interoperability with existing JDK classes (such as
Optional<T>
andStream<T>
- API for clear intention expression
The last sentence deserves a deeper explanation.
Each container has several core methods:
- Factory method
map()
conversion method, convert the value but do not change the special state:present Option<T>
keeppresent,success Result<T>
keepsuccess
.flatMap()
conversion method, in addition to conversion, you can also change the special status: convertOption<T> present
toempty
orResult<T> success
tofailure
.fold()
method, which handles two situations at the same time (Option<T>
ofpresent/empty
andResult<T>
ofsuccess/failure
).
In addition to the core methods, there are a bunch of auxiliary methods, which are useful in frequently observed use cases.
Among these methods, there is a set of methods that are explicitly designed to produce as a .Option<T>
has the following side effects and methods:
Option<T> whenPresent(Consumer<? super T> consumer);
Option<T> whenEmpty(Runnable action);
Option<T> apply(Runnable emptyValConsumer, Consumer<? super T> nonEmptyValConsumer);
Result<T>
has the following side effects and methods:
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);
These methods provide readers with hints that the code handles side effects rather than conversions.
Other useful tools
In addition to Option<T>
and Result<T>
, PFJ also uses some other general classes. Below, each method will be described in more detail.
Functions
JDK provides many useful functional interfaces. Unfortunately, the functional interface of universal functions is limited to two versions: single parameter Function<T, R>
and two parameters BiFunction<T, U, R>
.
Obviously, this is not enough in many practical situations. In addition, 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, and in the function declaration, it is defined first.
PFJ uses a consistent set of functional interfaces for functions with 1 to 9 parameters. For brevity, they are called FN1…FN9
. So far, there are no more parameter function use cases (usually this is a code smell). But if necessary, the list can be further expanded.
Tuples
A tuple is a special container that can be used to store multiple values of different types in a single variable. Unlike a class or record, the value stored in it has no name. This makes them an indispensable tool for capturing arbitrary sets of values while preserving types. A good example of this use case is Result.all()
and Option.all()
method sets.
In a sense, the tuple can be thought of as a function call to prepare a set of parameters frozen . From this perspective, the decision to make the internal values of the tuple accessible only through the map() method sounds reasonable. However, tuples with 2 parameters have additional accessors, and Tuple2<T1,T2>
can be used as an alternative to Pair<T1,T2>
PFJ is implemented using a consistent set of tuples, with values from 0 to 9. Provide tuples with 0 and 1 values to maintain consistency.
in conclusion
Practical functional Java is a modern, very concise but readable Java coding style based on functional programming concepts. Compared with the traditional idiomatic Java coding style, it provides many benefits:
PFJ uses the Java compiler to help write reliable code:
- The compiled code is usually valid
- Many errors are transferred from runtime to compile time
- Certain types of errors, such as
NullPointerException
or unhandled exceptions, have actually been eliminated
- PFJ significantly reduces the amount of boilerplate code related to error propagation and handling and
null
- PFJ focuses on expressing intentions clearly and reducing psychological burden
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。