Making Functional Programming Prettier in Java
Introduction
Back in 2014, Java 8 has been released and it introduced one of the biggest language update it ever had: first-class functional programming (FP) support via @FunctionalInterface
and its lambda syntax using Stream
, Optional
, and other data types. With this, numerous functional programming practices and feature are now enabled in the Java programming language.
Lambda is made possible with interfaces with a single abstract method. These types of interfaces are called functional interfaces which are commonly declared with @FunctionalInterface
annotation. In other words, when a method expects an argument with a type of a functional interface, lambda is possible to be used on it.
Before Java 8, this is how Java programmers use functional interfaces and pass functions to methods that need it.
The problem with anonymous classes is that the programmer is required to implement all the abstract methods of the interface or abstract class. This is why Java 8 introduced the @FunctionalInterface
annotation. The interface annotated with it will throw a compiler error when there is more than 1 abstract methods in it. When Java 8 released, existing functional interfaces are then annotated with @FunctionalInterface
.
One of the most used FP features in Java is method chaining. Even if method chaining existed far before FP is introduced in Java, it just became better with lambda.
Since Java 8, new APIs are introduced that enables method chaining, too. These are Stream
and Optional
. What makes these APIs FP is that their method accepts lambda, or an anonymous function, as argument. This made the code easier to read and made the code less complex due to the elimination of deep scoping.
In the example, there are 3 methods shown that accepts a lambda as an argument. The argument with the arrow or ->
operator is the lambda syntax. The left side of the arrow is the lambda parameters and the right side is its logic. Since the lambda has only one line of code, curly braces, semi-colon, and return
keyword is not needed.
The 3 methods in the example are:
.filter()
– This method requires aPredicate
or a function that accepts an argument of typeT
(orEmployee
in the example) and evaluates function using the given then returns aboolean
result. When the predicate function has returnedtrue
, the current element of the stream will proceed in the chain..map()
– This requires aFunction
. The function argument accepts an argument of typeT
(orEmployee
in the example) and transforms its given argument to return a typeR
(orString
from.getFullName()
.).forEach()
– This is a terminal stream operation. It requires aConsumer
function that accepts an argument of typeT
(orString
in the example) and does something to the given, this returns nothing..forEach()
method acts just like the Java'sfor
-each syntax in the first, non-FP example.
In spirit of the Java language, the number of repeated words in a single lambda statement is present. There are multiple ways to reduce the code without sacrificing its readability.
Additional FP Concepts
Side Effects
In FP, functions should have a maximum of 1 output. When a function has more than 1 result or does something outside its scope, such as object mutation or database calls, it is called a side effect.
Side effects are usually discouraged in an FP code since function calls should be self-verifiable. A simple glance at the method's name or content should easily reveal everything it does. When there is a need to perform a side effect, the code that does it is usually isolated in a single class or layer or 3rd-party API.
Effectively Final Variables
Simply saying, these are variables that are treated as final
. In Java, lambdas cannot re-assign values to variables that is outside its scope. Basically, variables outside the lambda scope is read-only. This is not entirely true, it is still possible to mutate the object (using its setters or collection manipulation if a collection) but never possible to re-assign a value to a variable.
Again, it is still possible to mutate the state of an object using its methods inside a lambda. There are no errors thrown because there are no re-assigning operations in the lambda.
Method Reference
In Java, it is possible to use existing methods as functional interface to replace lambda. To to this the method's reference can be passed as argument.
To get the method reference of a method, double-colon, the method reference operator is used (::
). However, this is only possible when the signature of the method being referred matches the functional interface required.
Here are some examples:
| Type | Example method | Method Reference | Functional Interface |
|:-----|:-----|:-----|:-----|:-----|
| Static method | Integer.valueOf(String)
| Integer::valueOf
| Function<String, Integer>
|
| Instance method | String.split(String)
| str::split
| Function<String, String[]>
|
| Instance boolean method | Object.equals(Object)
| obj::equals
| Predicate<Object>
|
| Instance method from given | Path.toString()
| Path::toString
| Function<Path, String>
|
| void
method | System.out.println(String)
| System.out::println
| Consumer<String>
|
| Constructor | BigDecimal(String)
| BigDecimal::new
| Function<String, BigDecimal>
|
With these, the example printInternNames()
method above can be refactored to:
public void printInternNames(List<Employee> employees) {
employees.stream()
.filter(employee -> EmployeeType.INTERN == employee.getType())
.map(Employee::getFullName)
.forEach(System.out::println);
}
As we recall from above, .map()
method requires a function. Passing the method reference Employee::getFullName
is same as passing an instance of Function<Employee, String>
.
Next is .forEach()
. It requires a Consumer<String>
because the previous method in the chain returns a String
. In the example, the method reference System.out::println
is passed because .println()
can accept a String
.
Now with method reference, the code looks shorter and other FP concept has been enforced: No side effects.
Currying
Currying is when a function that takes multiple arguments is refactored to a function that composes a function that has less argument. The goal is to create a function that has 1 argument (or 0 or 2, but usually 1– depending on the consuming method.)
For example, the function f(x, y)
can be refactored to h(x)(y)
. Where h(x)
is a function that produces a function that accepts y
.
Currying is done since most of the operation on Stream
and Optional
accepts only functions with one argument.
Here is another example of currying applied to Orika mappers:
Other classes involved:
In the first function in the .map()
call, notice the method mapper.map()
has 2 arguments: the object to map, employee
, and the the type to be mapped to, EmployeeJsonResource.class
. Since the Optional.map()
argument needs only a function that requires 1 argument. This can be achieved by currying:
An alternate approach to this is to create a private method to do the mapping. This private method will then be passed to the .map()
call.
Another alternative approach is to create a mapping private method and use that method's reference as .map()
argument:
Next is the .orElseThrow()
call. In this case, it is possible to compose a function that requires the id
to make the exception object:
After applying both method reference and currying to our example controller method, it is now simplier and has less symbols in the source code:
Composing Functions
Another benefit of currying functions is it enables programmers to use the composing functions offered by the standard Java functions API.
Consider this method:
Notice in the example above that the checking of employee type is done multiple times and in multiple ways. This pattern of usage invites the usage of private methods:
Since the method employeeHasType
has 2 parameters, it is possible to curry it to create a function with one parameter:
Looking at the example, the method printInternNames()
has no problem currying since its predicate has only 1 test needed in its .filter()
call. However, the method printRegularEmployeeNames()
has used the Predicate.test()
method since it needs 2 tests to be done.
The proper way to work with this is to compose the 2 tests, byType(REGULAR)
and byType(ADMIN)
into a single Predicate
function. Good thing that the Predicate
API provides some composing methods to make function composing easier. In the example, the composing method Predicate.or()
is needed:
Now the filter looks shorter and better and the private method byType()
has been properly reused.
Here are some composing methods available in the standard Java functions API:
Method | What it does? | Usage | Definition |
---|---|---|---|
Predicate.and(Predicate) |
ANDs 2 predicates | p.and(q) |
x -> p(x) && q(x) |
Predicate.or(Predicate) |
ORs 2 predicates | p.or(q) |
`x -> p(x) |
Predicate.negate() |
Negates the predicate | p.negate() |
x -> !p(x) |
Function.compose(Function) |
Performs an operation using the result of the given function | f.compose(g) |
x -> f(g(x)) |
Function.andThen(Function) |
Performs an operation, then another operation | f.andThen(g) |
x -> g(f(x)) |
Consumer.andThen(Consumer) |
Consumes an object, then consumes the object again | f.andThen(g) |
x -> f(x); g(x); |
When there is a need for more complex composite functions it is always possible to create own compositions – for example joining different filter rules with database calls, etc... If it is too complex, creating a function procedural-style would be better. Remember that this is done to make the code more readable.
On the additional note, the Stream
is a powerful API that supports complex compositions. The Stream
has 2 sets of methods (or operations as they call it) – intermediate and terminal. Intermediate operations will not work unless there is a terminal operation in the end. Under the hood, intermediate operations composes a function that will be finally used by the terminal operation.
Avoid Using Optional
as Null Check Replacement
While the Optional
API is easy to use, keep in mind that it is also easy to misuse it. The intended use of Optional
is to show that a method might return a value or not (return null
).
When creating a public
API or method, and the result might be null
, it is better to return Optional.empty()
instead of null
to represent the non-value result. This will enable its usage to gracefully ignore the empty response and avoid extra null checks.
When working with an external API or method that returns null
on no response, avoid using Optional.ofNullable()
as a null check replacement. Using the classic if ( variable == null )
statement is more appropriate and better performant.
Conclusion
On the release of Java 8, powerful functional programming or FP features has arrived in the language. Just like any other language features, they are powerful but easy to misuse and create dirty code with it.
Just like any programming language that supports FP features such as method references, compositions, and currying, it is possible to write elegant FP constructs that greatly helps the maintainability to the codebase. Doing such constructs will help implementing code abstraction and lessens the burden of knowledge to a class, which in turn make maintenance easier. Remember that code is read more than it was written; code is usually written once. This is why code readability is one of the best qualities to improve in a codebase.
Even if Java now supports FP programming paradigm, it is still an OOP language. Do not forget to apply OOP principles and best practices. OOP and FP paradigms are not exclusive with other and they can be used together in a single codebase elegantly.
Also, beware of over-using these features. Learn when to use these FP features or not. There are times where the classic syntax like if
/ else
and for
loop works better.