Tekst: Laurens van der Kooi
JAVA MAGAZINE 01 – 2024
As a Java developer, I have a real passion for working with functional programming techniques. In my day-to-day work, I often find myself immersed in Java’s monad-like container types like Optional and Stream. Recently, I’ve been exploring the concept of crafting my own container type, specifically designed for managing conditional operations in a functional manner. This endeavor led to the creation of the Conditional. Since I really enjoyed crafting this functional abstraction, this article is designed to bring you on this journey with me without diving into the practical implementation just yet!
{Monad}
First, let’s define what a monad is. A monad is a design pattern used to handle computations and manage side effects in a purely functional way. It provides a structured approach to encapsulate values, operations, and control flow within a specific context.
When explaining monads to fellow programmers, I draw inspiration from Sander Hoogendoorn’s [1] keynote at J-Fall 2021, titled: The Zen of Programming [2]. In his talk, he portrayed a monad as a box that can contain an object. Once the object is inside this box, a programmer can send functions to it. These functions are then applied to the object within the box, transforming it. Programmers have the flexibility to send as many functions as necessary to the box, ultimately retrieving the transformed object from within, see image 1. These sequences of functions are commonly referred to as functional pipelines.
Daan: deze afbeelding zat geplakt in de tekst en was dus geen los meegeleverde afbeelding. Ik heb hem opgeslagen vanuit het doc. Dit geldt ook voor de bio foto
Image 1
The Java standard library offers three functional containers that closely resemble monads:
Optional: A container that may or may not hold a non-null value;
Stream: A container containing a sequence of elements supporting sequential and parallel aggregate operations;
CompletableFuture: A container designed to handle the complexities of concurrent and asynchronous programming.
Monads have two fundamental operations. The Unit operation wraps a value into the monad (e.g. Optional.of()). Bind functions transform the object within the monad from one context to another; a common function in this category is flatMap.
Let’s now explore the definition of a new type of monad-inspired container type.
{The idea: the Conditional}
While working on a project in which I had to deal with quite some conditional business logic, I came up with the idea of creating a monad-like type that is able to organize conditional actions as a functional alternative to the traditional if-then-else-statements. The idea is basically to take a method that looks like Listing 1, and turn it into a functional pipeline that elegantly expresses which action should be executed under specific conditions.
public static BigDecimal calculateNumber(int number) {
if (number % 2 == 0) {
return BigDecimal.valueOf(number * 2.5);
}
if (number > 100) {
return BigDecimal.valueOf(number * 0.5);
}
return BigDecimal.ZERO;
}
Listing 1
Listing 1
While playing around with this idea, I started writing code to figure out what this functional pipeline would look like without having an implementation yet, see Listing 2. Drawing inspiration from Java’s Optional, I called this data type the Conditional. For the sake of readability and reusability, I opted to use separate helper methods (times, isEven and isLargerThan) instead of passing lambdas directly to the Conditional.
public static BigDecimal calculateNumber(int number) {
return Conditional.of(number)
.firstMatching(
applyIf(isEven(), times(2.5)),
applyIf(isLargerThan(100), times(0.5))
)
.map(BigDecimal::valueOf)
.orElse(BigDecimal.ZERO);
}
private static Function<Integer, Double> times(double number) {
return i -> i * number;
}
private static Predicate<Integer> isEven() {
return i -> i % 2 == 0;
}
private static Predicate<Integer> isLargerThan(int number) {
return i -> i > number;
}
Listing 2
Listing 2
The Conditional in Listing 2 can be distilled as follows:
- Take an integer and wrap it in a Conditional (of);
- Apply the first action that contains a matching condition (firstMatching):
- If the number is even, multiply the number by 2.5 (applyIf);
- If the number is greater than 100, halve it (applyIf).
- Transform the resulting value into a BigDecimal (map);
- If none of the conditions are satisfied, default to BigDecimal.ZERO (orElse).
Daan: ik heb hierboven bulletpoints gebruikt omdat dat in het originele doc ook zo was maar weet niet of dat kan
As you can read, this is the same logic as in Listing 1 but then composed in a functional manner. The applyIf within the firstMatching operation is added as syntactic sugar to improve the readability of the pipeline.
{Intermediate and terminal operations}
In Java, operations in a functional pipeline can be broadly categorized into two types:
- Intermediate operations;
- Terminal
Intermediate operations are operations that transform or filter the object/elements within a monadic type. These operations are applied to the object that is wrapped by the monad and return a new monad containing the transformed object. This allows you to chain multiple intermediate operations together to create a pipeline. Translating this into the box analogy mentioned earlier, envision a chain of intermediate operations as a set of instructions that must be applied to the object within the box.
Terminal operations, on the other hand, are operations that produce a final result or side effect. Unlike intermediate operations, a terminal operation triggers the execution of the entire pipeline. They act as the endpoint of the sequence of transformations and typically return a non-monadic result or perform an action. Returning to the box analogy, this final operation becomes essential to initiate the execution of the list of instructions and get the transformed object from the box or trigger an alternative action.
Understanding the behavior and purpose of the different types of operations yields two key takeaways:
- Intermediate operations are lazy evaluated, meaning they are only executed when a terminal operation is invoked;
- A functional pipeline can contain multiple intermediate operations, but at most one terminal operation, always positioned at the end of the pipeline.
With these insights in mind, let’s explore the functional pipeline using Optional in Listing 3. In this example, Optional.ofNullable(number)encapsulates an Integer within an Optional. Subsequently, as long as the Optional contains a value, it undergoes two types of intermediate operations:
- A filter operation (checking if the number is even and larger than 26).
- Three map operations (multiplying the number by 2, transforming from a Double to an Integer, and ultimately creating a message with the outcome).
The terminal operation, orElse, serves as the conclusion of this pipeline. It retrieves the String value from the Optional or defaults to No calculation needed if the Optional becomes empty during the process.
public static String generateOutcomeMessage(Integer number) {
return Optional.ofNullable(number)
.filter(isEven().and(isLargerThan(26)))
.map(times(2))
.map(Double::intValue)
.map(“Outcome: %d”::formatted)
.orElse(“No calculation needed”);
}
Listing 3
Listing 3
{Specifying operations for the Conditional}
Now that we understand the two types of operations in a functional pipeline, let’s apply this knowledge to identify these types in the initial setup of the Conditional as seen in Listing 2. Since orElse produces a non-monadic result (the Double eventually returned by the calculateNumber method), it serves as the terminal operation in this pipeline. As a result, both the firstMatching and the map operation can be identified as intermediate operations.
So, for intermediate operations, we aim to have the following:
- firstMatching: capable of receiving condition/function pairs and applying the function of the first pair that has a matching condition;
- map: designed to apply a transformation from one type to the next within the functional pipeline.
As mentioned, a monad commonly includes flatMap as a bind-operation, so let’s add:
- flatMap: designed to apply a function that transforms the value in the functional pipeline to another Conditional while unwrapping the outer Conditional (a concept we’ll delve into in the next article).
For terminal operations, our preference is:
- orElse: either returns the value from the Conditional, or the value supplied to orElse.
Drawing inspiration from the Optional, let’s also include:
- orElseGet: either returns the value from the Conditional, or the result of the Function supplied to orElseGet;
- orElseThrow: either returns the value from the Conditional, or throws an exception to be created by the provided supplier.
{A first setup}
Now armed with the specification for the Conditional, we can begin crafting the class by outlining its methods, leaving the implementation for later stages. Listing 4 shows the start of this.
public class Conditional<S, T> {
public static <S> Conditional<S, S> of(S value) {
throw new UnsupportedOperationException();
}
// … some methods we’ve specified
public <U> Conditional<S, U> map(Function<T, U> mapFunction) {
throw new UnsupportedOperationException();
}
public T orElse(T defaultValue) {
throw new UnsupportedOperationException();
}
// … some more methods we’ve specified
}
Listing 4
Listing 4
The Conditional class contains 2 generic types: S and T. The purpose of this is to represent a conditional operation where a value of type S is initially provided, and subsequent operations may transform it to a value of type T. The map method introduces the generic type U to facilitate the transformation of the object contained in the Conditional from one type to a new type. We’ll delve deeper into these concepts in the next article.
As demonstrated, we can already outline the class and its method signatures. This allows for writing (non-working) functional pipelines, which we can use to write tests asserting the expected behavior. This aligns with the principles of Test-Driven Development (TDD). In Listing 5, we encounter the initial pair of tests that presently fail.
@Test
@DisplayName(“a condition matches -> apply matching function”)
void conditionalWithOneConditionThatEvaluatesToTrue() {
var outcome =
Conditional.of(2)
.firstMatching(applyIf(isEven(), times(2)))
.orElse(0.00);
assertThat(outcome).isEqualTo(4.00);
}
@Test
@DisplayName(“no condition matches -> return default value”)
void conditionalWithOneConditionThatEvaluatesToFalse() {
var outcome =
Conditional.of(3)
.firstMatching(applyIf(isEven(), times(2)))
.orElse(0.00);
assertThat(outcome).isEqualTo(0.00);
}
Listing 5
Listing 5
By implementing the methods that are used in these tests to make them pass, we can seamlessly alternate between expanding tests and incorporating logic into the Conditional. This iterative approach ensures steady progress towards completing the entire implementation.
{Conclusion}
In conclusion, we’ve explored the conceptual landscape of crafting the Conditional. Armed with a detailed specification, we’ve initiated the class structure, outlining methods crucial for functional pipelines. In the next article, we’ll dive into the practical implementation of the Conditional. Along the way, we’ll explore key concepts of functional programming that are integral to understanding the implementation process.
If you’re already eager to dive into coding, feel free to start implementing the Conditional yourself. Head over to my GitHub [3] to explore the setup of Listing 4, accompanied by over 35 unit tests to facilitate Test Driven Development. Rest assured, my GitHub won’t reveal the implementation we’ll be discussing in the next article yet ;).
References
- sanderhoogendoorn.com – Tools don’t solve problems, thinking does
- https://www.youtube.com/watch?v=KSQTI8pFkrg
- https://github.com/LvdKooi/javamag2024
BIO
Laurens van der Kooi is a Java Developer at Ordina JTech. He enjoys sharing knowledge and is a big fan of the Spring Framework and Functional Programming.