Programmation fonctionnelle en Java

1. Introduction

Dans ce didacticiel, nous allons comprendre les principes de base du paradigme de programmation fonctionnelle et comment les mettre en pratique dans le langage de programmation Java. Nous aborderons également certaines des techniques avancées de programmation fonctionnelle.

Cela nous permettra également d'évaluer les avantages que nous tirons de la programmation fonctionnelle, notamment en Java.

2. Qu'est-ce que la programmation fonctionnelle

Fondamentalement, la programmation fonctionnelle est un style d'écriture de programmes informatiques qui traitent les calculs comme l'évaluation de fonctions mathématiques . Alors, qu'est-ce qu'une fonction en mathématiques?

Une fonction est une expression qui relie un ensemble d'entrées à un ensemble de sorties.

Surtout, la sortie d'une fonction dépend uniquement de son entrée. Plus intéressant encore, nous pouvons composer deux ou plusieurs fonctions ensemble pour obtenir une nouvelle fonction.

2.1. Calcul Lambda

Pour comprendre pourquoi ces définitions et propriétés des fonctions mathématiques sont importantes en programmation, il faudra remonter un peu dans le temps. Dans les années 1930, le mathématicien Alonzo Chruch a développé un système formel pour exprimer des calculs basés sur l'abstraction de fonctions . Ce modèle universel de calcul est devenu connu sous le nom de Lambda Calculus.

Le calcul Lambda a eu un impact considérable sur le développement de la théorie des langages de programmation, en particulier des langages de programmation fonctionnels. En règle générale, les langages de programmation fonctionnels implémentent le calcul lambda.

Comme le calcul lambda se concentre sur la composition de fonctions, les langages de programmation fonctionnels fournissent des moyens expressifs de composer des logiciels dans la composition de fonctions.

2.2. Catégorisation des paradigmes de programmation

Bien entendu, la programmation fonctionnelle n'est pas le seul style de programmation en pratique. De manière générale, les styles de programmation peuvent être classés en paradigmes de programmation impératifs et déclaratifs:

L' approche impérative définit un programme comme une séquence d'instructions qui modifient l'état du programme jusqu'à ce qu'il atteigne l'état final. La programmation procédurale est un type de programmation impérative où nous construisons des programmes en utilisant des procédures ou des sous-programmes. L'un des paradigmes de programmation populaires connus sous le nom de programmation orientée objet (POO) étend les concepts de programmation procédurale.

En revanche, l' approche déclarative exprime la logique d'un calcul sans décrire son flux de contrôle en termes d'une séquence d'instructions. En termes simples, l'objectif de l'approche déclarative est de définir ce que le programme doit réaliser plutôt que la manière dont il devrait y parvenir. La programmation fonctionnelle est un sous-ensemble des langages de programmation déclaratifs.

Ces catégories ont d'autres sous-catégories, et la taxonomie devient assez complexe, mais nous n'entrerons pas dans cela pour ce tutoriel.

2.3. Catégorisation des langages de programmation

Toute tentative de catégoriser formellement les langages de programmation aujourd'hui est un effort académique en soi! Cependant, nous essaierons de comprendre comment les langages de programmation sont divisés en fonction de leur prise en charge de la programmation fonctionnelle pour nos besoins.

Les langages fonctionnels purs, comme Haskell, n'autorisent que des programmes fonctionnels purs.

D'autres langages, cependant, permettent à la fois des programmes fonctionnels et procéduraux et sont considérés comme des langages fonctionnels impurs. De nombreux langages entrent dans cette catégorie, notamment Scala, Kotlin et Java.

Il est important de comprendre que la plupart des langages de programmation populaires aujourd'hui sont des langages à usage général, et qu'ils ont donc tendance à prendre en charge plusieurs paradigmes de programmation.

3. Principes et concepts fondamentaux

Cette section couvrira certains des principes de base de la programmation fonctionnelle et comment les adopter en Java. Veuillez noter que de nombreuses fonctionnalités que nous utiliserons n'ont pas toujours fait partie de Java, et il est conseillé d'utiliser Java 8 ou une version ultérieure pour exercer efficacement la programmation fonctionnelle .

3.1. Fonctions de première classe et d'ordre supérieur

On dit qu'un langage de programmation a des fonctions de premier ordre s'il traite les fonctions comme des citoyens de premier ordre. Fondamentalement, cela signifie que les fonctions sont autorisées à prendre en charge toutes les opérations généralement disponibles pour d'autres entités . Celles-ci incluent l'affectation de fonctions à des variables, leur transmission en tant qu'arguments à d'autres fonctions et leur retour en tant que valeurs d'autres fonctions.

Cette propriété permet de définir des fonctions d'ordre supérieur en programmation fonctionnelle. Les fonctions d'ordre supérieur sont capables de recevoir une fonction en tant qu'arguments et de renvoyer une fonction en conséquence . Cela permet en outre plusieurs techniques de programmation fonctionnelle comme la composition de fonctions et le currying.

Traditionnellement, il n'était possible de transmettre des fonctions en Java qu'en utilisant des constructions telles que des interfaces fonctionnelles ou des classes internes anonymes. Les interfaces fonctionnelles ont exactement une méthode abstraite et sont également appelées interfaces SAM (Single Abstract Method).

Disons que nous devons fournir un comparateur personnalisé à la méthode Collections.sort :

Collections.sort(numbers, new Comparator() { @Override public int compare(Integer n1, Integer n2) { return n1.compareTo(n2); } });

Comme nous pouvons le voir, c'est une technique fastidieuse et verbeuse - certainement pas quelque chose qui encourage les développeurs à adopter la programmation fonctionnelle. Heureusement, Java 8 a apporté de nombreuses nouvelles fonctionnalités pour faciliter le processus, telles que les expressions lambda, les références de méthodes et les interfaces fonctionnelles prédéfinies .

Voyons comment une expression lambda peut nous aider dans la même tâche:

Collections.sort(numbers, (n1, n2) -> n1.compareTo(n2));

Certainement, c'est plus concis et compréhensible. Cependant, veuillez noter que si cela peut nous donner l'impression d'utiliser des fonctions en tant que citoyens de premier ordre en Java, ce n'est pas le cas.

Derrière le sucre syntaxique des expressions lambda, Java les enveloppe toujours dans des interfaces fonctionnelles. Par conséquent, Java traite une expression lambda comme un objet , qui est, en fait, le véritable citoyen de première classe en Java.

3.2. Fonctions pures

La définition de la fonction pure souligne qu'une fonction pure doit renvoyer une valeur basée uniquement sur les arguments et ne doit avoir aucun effet secondaire . Maintenant, cela peut sembler tout à fait contraire à toutes les meilleures pratiques de Java.

Java, being an object-oriented language, recommends encapsulation as a core programming practice. It encourages hiding an object's internal state and exposing only necessary methods to access and modify it. Hence, these methods aren't strictly pure functions.

Of course, encapsulation and other object-oriented principles are only recommendations and not binding in Java. In fact, developers have recently started to realize the value of defining immutable states and methods without side-effects.

Let's say we want to find the sum of all the numbers we've just sorted:

Integer sum(List numbers) { return numbers.stream().collect(Collectors.summingInt(Integer::intValue)); }

Now, this method depends only on the arguments it receives, hence, it's deterministic. Moreover, it doesn't produce any side effects.

Side effects can be anything apart from the intended behavior of the method. For instance, side-effects can be as simple as updating a local or global state or saving to a database before returning a value. Purists also treat logging as a side effect, but we all have our own boundaries to set!

We may, however, reason about how we deal with legitimate side effects. For instance, we may need to save the result in a database for genuine reasons. Well, there are techniques in functional programming to handle side effects while retaining pure functions.

We'll discuss some of them in later sections.

3.3. Immutability

Immutability is one of the core principles of functional programming, and it refers to the property that an entity can't be modified after being instantiated. Now in a functional programming language, this is supported by design at the language level. But, in Java, we have to make our own decision to create immutable data structures.

Please note that Java itself provides several built-in immutable types, for instance, String. This is primarily for security reasons, as we heavily use String in class loading and as keys in hash-based data structures. There are several other built-in immutable types like primitive wrappers and math types.

But what about the data structures we create in Java? Of course, they are not immutable by default, and we have to make a few changes to achieve immutability. The use of the final keyword is one of them, but it doesn't stop there:

public class ImmutableData { private final String someData; private final AnotherImmutableData anotherImmutableData; public ImmutableData(final String someData, final AnotherImmutableData anotherImmutableData) { this.someData = someData; this.anotherImmutableData = anotherImmutableData; } public String getSomeData() { return someData; } public AnotherImmutableData getAnotherImmutableData() { return anotherImmutableData; } } public class AnotherImmutableData { private final Integer someOtherData; public AnotherImmutableData(final Integer someData) { this.someOtherData = someData; } public Integer getSomeOtherData() { return someOtherData; } }

Note that we have to observe a few rules diligently:

  • All fields of an immutable data structure must be immutable
  • This must apply to all the nested types and collections (including what they contain) as well
  • There should be one or more constructors for initialization as needed
  • There should only be accessor methods, possibly with no side-effects

It's not easy to get it completely right every time, especially when the data structures start to get complex. However, several external libraries can make working with immutable data in Java easier. For instance, Immutables and Project Lombok provide ready-to-use frameworks for defining immutable data structures in Java.

3.4. Referential Transparency

Referential transparency is perhaps one of the more difficult principles of functional programming to understand. The concept is pretty simple, though. We call an expression referentially transparent if replacing it with its corresponding value has no impact on the program's behavior.

This enables some powerful techniques in functional programming like higher-order functions and lazy evaluation. To understand this better, let's take an example:

public class SimpleData { private Logger logger = Logger.getGlobal(); private String data; public String getData() { logger.log(Level.INFO, "Get data called for SimpleData"); return data; } public SimpleData setData(String data) { logger.log(Level.INFO, "Set data called for SimpleData"); this.data = data; return this; } }

This is a typical POJO class in Java, but we're interested in finding if this provides referential transparency. Let's observe the following statements:

String data = new SimpleData().setData("Baeldung").getData(); logger.log(Level.INFO, new SimpleData().setData("Baeldung").getData()); logger.log(Level.INFO, data); logger.log(Level.INFO, "Baeldung");

The three calls to logger are semantically equivalent but not referentially transparent. The first call is not referentially transparent as it produces a side-effect. If we replace this call with its value as in the third call, we'll miss the logs.

The second call is also not referentially transparent as SimpleData is mutable. A call to data.setData anywhere in the program would make it difficult for it to be replaced with its value.

So basically, for referential transparency, we need our functions to be pure and immutable. These are the two preconditions we've already discussed earlier. As an interesting outcome of referential transparency, we produce context-free code. In other words, we can execute them in any order and context, which leads to different optimization possibilities.

4. Functional Programming Techniques

The functional programming principles that we discussed earlier enable us to use several techniques to benefit from functional programming. In this section, we'll cover some of these popular techniques and understand how we can implement them in Java.

4.1. Function Composition

Function composition refers to composing complex functions by combining simpler functions. This is primarily achieved in Java using functional interfaces, which are, in fact, target types for lambda expressions and method references.

Typically, any interface with a single abstract method can serve as a functional interface. Hence, we can define a functional interface quite easily. However, Java 8 provides us many functional interfaces by default for different use cases under the package java.util.function.

Many of these functional interfaces provide support for function composition in terms of default and static methods. Let's pick the Function interface to understand this better. Function is a simple and generic functional interface that accepts one argument and produces a result.

It also provides two default methods, compose and andThen, which will help us in function composition:

Function log = (value) -> Math.log(value); Function sqrt = (value) -> Math.sqrt(value); Function logThenSqrt = sqrt.compose(log); logger.log(Level.INFO, String.valueOf(logThenSqrt.apply(3.14))); // Output: 1.06 Function sqrtThenLog = sqrt.andThen(log); logger.log(Level.INFO, String.valueOf(sqrtThenLog.apply(3.14))); // Output: 0.57

Both these methods allow us to compose multiple functions into a single function but offer different semantics. While compose applies the function passed in the argument first and then the function on which it's invoked, andThen does the same in reverse.

Several other functional interfaces have interesting methods to use in function composition, such as the default methods and, or, and negate in the Predicate interface. While these functional interfaces accept a single argument, there are two-arity specializations, like BiFunction and BiPredicate.

4.2. Monads

Many of the functional programming concepts derive from Category Theory, which is a general theory of functions in mathematics. It presents several concepts of categories like functors and natural transformations. For us, it's only important to know that this is the basis of using monads in functional programming.

Formally, a monad is an abstraction that allows structuring programs generically. So basically, a monad allows us to wrap a value, apply a set of transformations, and get the value back with all transformations applied. Of course, there are three laws that any monad needs to follow – left identity, right identity, and associativity – but we'll not get into the details.

In Java, there are a few monads that we use quite often, like Optional and Stream:

Optional.of(2).flatMap(f -> Optional.of(3).flatMap(s -> Optional.of(f + s)))

Now, why do we call Optional a monad? Here, Optional allows us to wrap a value using the method of and apply a series of transformations. We're applying the transformation of adding another wrapped value using the method flatMap.

If we want, we can show that Optional follows the three laws of monads. However, critics will be quick to point out that an Optional does break the monad laws under some circumstances. But, for most practical situations, it should be good enough for us.

If we understand monads' basics, we'll soon realize that there are many other examples in Java, like Stream and CompletableFuture. They help us achieve different objectives, but they all have a standard composition in which context manipulation or transformation is handled.

Of course, we can define our own monad types in Java to achieve different objectives like log monad, report monad, or audit monad. Remember how we discussed handling side-effects in functional programming? Well, as it appears, the monad is one of the functional programming techniques to achieve that.

4.3. Currying

Currying is a mathematical technique of converting a function that takes multiple arguments into a sequence of functions that take a single argument. But, why do we need them in functional programming? It gives us a powerful composition technique where we do not need to call a function with all its arguments.

Moreover, a curried function does not realize its effect until it receives all the arguments.

In pure functional programming languages like Haskell, currying is well supported. In fact, all functions are curried by default. However, in Java, it's not that straightforward:

Function
    
      weight = mass -> gravity -> mass * gravity; Function weightOnEarth = weight.apply(9.81); logger.log(Level.INFO, "My weight on Earth: " + weightOnEarth.apply(60.0)); Function weightOnMars = weight.apply(3.75); logger.log(Level.INFO, "My weight on Mars: " + weightOnMars.apply(60.0));
    

Here, we've defined a function to calculate our weight on a planet. While our mass remains the same, gravity varies by the planet we're on. We can partially apply the function by passing just the gravity to define a function for a specific planet. Moreover, we can pass this partially applied function around as an argument or return value for arbitrary composition.

Currying depends upon the language to provide two fundamental features: lambda expressions and closures. Lambda expressions are anonymous functions that help us to treat code as data. We've seen earlier how to implement them using functional interfaces.

Now, a lambda expression may close upon its lexical scope, which we define as its closure. Let's see an example:

private static Function weightOnEarth() { final double gravity = 9.81; return mass -> mass * gravity; }

Please note how the lambda expression, which we return in the method above, depends on the enclosing variable, which we call closure. Unlike other functional programming languages, Java has a limitation that the enclosing scope has to be final or effectively final.

As an interesting outcome, currying also allows us to create a functional interface in Java of arbitrary arity.

4.4. Recursion

Recursion is another powerful technique in functional programming that allows us to break down a problem into smaller pieces. The main benefit of recursion is that it helps us eliminate the side effects, which is typical of any imperative style looping.

Let's see how we calculate the factorial of a number using recursion:

Integer factorial(Integer number) { return (number == 1) ? 1 : number * factorial(number - 1); }

Here, we call the same function recursively until we reach the base case and then start to calculate our result. Notice that we're making the recursive call before calculating the result at each step or in words at the head of the calculation. Hence, this style of recursion is also known as head recursion.

A drawback of this type of recursion is that every step has to hold the state of all previous steps until we reach the base case. This is not really a problem for small numbers, but holding the state for large numbers can be inefficient.

A solution is a slightly different implementation of the recursion known as tail recursion. Here, we ensure that the recursive call is the last call a function makes. Let's see how we can rewrite the above function to use tail recursion:

Integer factorial(Integer number, Integer result) { return (number == 1) ? result : factorial(number - 1, result * number); }

Notice the use of an accumulator in the function, eliminating the need to hold the state at every step of recursion. The real benefit of this style is to leverage compiler optimizations where the compiler can decide to let go of the current function's stack frame, a technique known as tail-call elimination.

While many languages like Scala supports tail-call elimination, Java still does not have support for this. This is part of the backlog for Java and will perhaps come in some shape as part of larger changes proposed under Project Loom.

5. Why Functional Programming Matters?

After going through the tutorial so far, we must wonder why we even want to take this much effort. For someone coming from a Java background, the shift that functional programming demands are not trivial. So, there should be some really promising advantages for adopting functional programming in Java.

The biggest advantage of adopting functional programming in any language, including Java, is pure functions and immutable states. If we think in retrospect, most of the programming challenges are rooted in the side-effects and mutable state one way or the other. Simply getting rid of them makes our program easier to read, reason about, test, and maintain.

Declarative programming, as such, leads to very concise and readable programs. Functional programming, being a subset of declarative programming, offers several constructs like higher-order functions, function composition, and function chaining. Think of the benefits that Stream API has brought into Java 8 for handling data manipulations.

But don't get tempted to switch over unless completely ready. Please note that functional programming is not a simple design pattern that we can immediately use and benefit from. Functional programming is more of a change in how we reason about problems and their solutions and how to structure the algorithm.

So, before we start using functional programming, we must train ourselves to think about our programs in terms of functions.

6. Is Java a Suitable Fit?

While it's difficult to deny functional programming benefits, we cannot help but ask ourselves if Java is a suitable choice for it. Historically, Java evolved as a general-purpose programming language more suitable for object-oriented programming. Even thinking of using functional programming before Java 8 was tedious! But things have definitely changed after Java 8.

The very fact that there are no true function types in Java goes against functional programming's basic principles. The functional interfaces in the disguise of lambda expressions make up for it largely, at least syntactically. Then, the fact that types in Java are inherently mutable and we have to write so much boilerplate to create immutable types does not help.

We expect other things from a functional programming language that are missing or difficult in Java. For instance, the default evaluation strategy for arguments in Java is eager. But, lazy evaluation is a more efficient and recommended way in functional programming.

We can still achieve lazy evaluation in Java using operator short-circuiting and functional interfaces, but it's more involved.

The list is certainly not complete and can include generics support with type-erasure, missing support for tail-call optimization, and other things. However, we get a broad idea. Java is definitely not suitable for starting a program from scratch in functional programming.

But what if we already have an existing program written in Java, probably in object-oriented programming? Nothing stops us from getting some of the benefits of functional programming, especially with Java 8.

This is where most of the benefits of functional programming lie for a Java developer. A combination of object-oriented programming with the benefits of functional programming can go a long way.

7. Conclusion

In this tutorial, we went through the basics of functional programming. We covered the fundamental principles and how we can adopt them in Java. Further, we discussed some popular techniques in functional programming with examples in Java.

Finally, we covered some of the benefits of adopting functional programming and answered if Java is suitable for the same.

Le code source de l'article est disponible à l'adresse over sur GitHub.