Expressions Lambda et interfaces fonctionnelles: conseils et bonnes pratiques

1. Vue d'ensemble

Maintenant que Java 8 a atteint une large utilisation, des modèles et des meilleures pratiques ont commencé à émerger pour certaines de ses fonctionnalités en tête d'affiche. Dans ce didacticiel, nous examinerons de plus près les interfaces fonctionnelles et les expressions lambda.

2. Préférez les interfaces fonctionnelles standard

Les interfaces fonctionnelles, rassemblées dans le package java.util.function , satisfont la plupart des besoins des développeurs en fournissant des types de cibles pour les expressions lambda et les références de méthodes. Chacune de ces interfaces est générale et abstraite, ce qui les rend faciles à adapter à presque toutes les expressions lambda. Les développeurs doivent explorer ce package avant de créer de nouvelles interfaces fonctionnelles.

Considérons une interface Foo :

@FunctionalInterface public interface Foo { String method(String string); }

et une méthode add () dans une classe UseFoo , qui prend cette interface comme paramètre:

public String add(String string, Foo foo) { return foo.method(string); }

Pour l'exécuter, vous écririez:

Foo foo = parameter -> parameter + " from lambda"; String result = useFoo.add("Message ", foo);

Regardez de plus près et vous verrez que Foo n'est rien de plus qu'une fonction qui accepte un argument et produit un résultat. Java 8 fournit déjà une telle interface dans Function à partir du package java.util.function.

Maintenant, nous pouvons supprimer complètement l' interface Foo et changer notre code en:

public String add(String string, Function fn) { return fn.apply(string); }

Pour exécuter cela, nous pouvons écrire:

Function fn = parameter -> parameter + " from lambda"; String result = useFoo.add("Message ", fn);

3. Utilisez l' annotation @FunctionalInterface

Annotez vos interfaces fonctionnelles avec @FunctionalInterface. Au début, cette annotation semble inutile. Même sans cela, votre interface sera traitée comme fonctionnelle tant qu'elle ne comportera qu'une seule méthode abstraite.

Mais imaginez un grand projet avec plusieurs interfaces - il est difficile de tout contrôler manuellement. Une interface, qui a été conçue pour être fonctionnelle, pourrait être accidentellement modifiée en ajoutant d'autres méthodes abstraites, la rendant inutilisable en tant qu'interface fonctionnelle.

Mais en utilisant l' annotation @FunctionalInterface , le compilateur déclenchera une erreur en réponse à toute tentative de rupture de la structure prédéfinie d'une interface fonctionnelle. C'est également un outil très pratique pour rendre l'architecture de votre application plus facile à comprendre pour les autres développeurs.

Alors, utilisez ceci:

@FunctionalInterface public interface Foo { String method(); }

au lieu de simplement:

public interface Foo { String method(); }

4. Ne pas abuser des méthodes par défaut dans les interfaces fonctionnelles

Nous pouvons facilement ajouter des méthodes par défaut à l'interface fonctionnelle. Ceci est acceptable pour le contrat d'interface fonctionnelle tant qu'il n'y a qu'une seule déclaration de méthode abstraite:

@FunctionalInterface public interface Foo { String method(String string); default void defaultMethod() {} }

Les interfaces fonctionnelles peuvent être étendues par d'autres interfaces fonctionnelles si leurs méthodes abstraites ont la même signature.

Par exemple:

@FunctionalInterface public interface FooExtended extends Baz, Bar {} @FunctionalInterface public interface Baz { String method(String string); default String defaultBaz() {} } @FunctionalInterface public interface Bar { String method(String string); default String defaultBar() {} }

Tout comme avec les interfaces régulières, l' extension de différentes interfaces fonctionnelles avec la même méthode par défaut peut être problématique .

Par exemple, ajoutons la méthode defaultCommon () aux interfaces Bar et Baz :

@FunctionalInterface public interface Baz { String method(String string); default String defaultBaz() {} default String defaultCommon(){} } @FunctionalInterface public interface Bar { String method(String string); default String defaultBar() {} default String defaultCommon() {} }

Dans ce cas, nous obtiendrons une erreur de compilation:

interface FooExtended inherits unrelated defaults for defaultCommon() from types Baz and Bar...

Pour résoudre ce problème, la méthode defaultCommon () doit être remplacée dans l' interface FooExtended . Nous pouvons, bien entendu, fournir une implémentation personnalisée de cette méthode. Cependant, nous pouvons également réutiliser l'implémentation depuis l'interface parent :

@FunctionalInterface public interface FooExtended extends Baz, Bar { @Override default String defaultCommon() { return Bar.super.defaultCommon(); } }

Mais il faut être prudent. Ajouter trop de méthodes par défaut à l'interface n'est pas une très bonne décision architecturale. Cela doit être considéré comme un compromis, à n'utiliser qu'en cas de besoin, pour mettre à niveau les interfaces existantes sans rompre la compatibilité descendante.

5. Instancier des interfaces fonctionnelles avec des expressions Lambda

Le compilateur vous permettra d'utiliser une classe interne pour instancier une interface fonctionnelle. Cependant, cela peut conduire à un code très détaillé. Vous devriez préférer les expressions lambda:

Foo foo = parameter -> parameter + " from Foo";

sur une classe interne:

Foo fooByIC = new Foo() { @Override public String method(String string) { return string + " from Foo"; } }; 

L'approche d'expression lambda peut être utilisée pour toute interface appropriée à partir d'anciennes bibliothèques. Il est utilisable pour des interfaces telles que Runnable , Comparator , etc. Cependant, cela ne signifie pas que vous devriez revoir toute votre ancienne base de code et tout changer.

6. Évitez de surcharger les méthodes avec des interfaces fonctionnelles comme paramètres

Utilisez des méthodes avec des noms différents pour éviter les collisions; regardons un exemple:

public interface Processor { String process(Callable c) throws Exception; String process(Supplier s); } public class ProcessorImpl implements Processor { @Override public String process(Callable c) throws Exception { // implementation details } @Override public String process(Supplier s) { // implementation details } }

À première vue, cela semble raisonnable. Mais toute tentative d'exécuter l'une des méthodes de ProcessorImpl :

String result = processor.process(() -> "abc");

se termine par une erreur avec le message suivant:

reference to process is ambiguous both method process(java.util.concurrent.Callable) in com.baeldung.java8.lambda.tips.ProcessorImpl and method process(java.util.function.Supplier) in com.baeldung.java8.lambda.tips.ProcessorImpl match

Pour résoudre ce problème, nous avons deux options. La première consiste à utiliser des méthodes avec des noms différents:

String processWithCallable(Callable c) throws Exception; String processWithSupplier(Supplier s);

The second is to perform casting manually. This is not preferred.

String result = processor.process((Supplier) () -> "abc");

7. Don’t Treat Lambda Expressions as Inner Classes

Despite our previous example, where we essentially substituted inner class by a lambda expression, the two concepts are different in an important way: scope.

When you use an inner class, it creates a new scope. You can hide local variables from the enclosing scope by instantiating new local variables with the same names. You can also use the keyword this inside your inner class as a reference to its instance.

However, lambda expressions work with enclosing scope. You can’t hide variables from the enclosing scope inside the lambda’s body. In this case, the keyword this is a reference to an enclosing instance.

For example, in the class UseFoo you have an instance variable value:

private String value = "Enclosing scope value";

Then in some method of this class place the following code and execute this method.

public String scopeExperiment() { Foo fooIC = new Foo() { String value = "Inner class value"; @Override public String method(String string) { return this.value; } }; String resultIC = fooIC.method(""); Foo fooLambda = parameter -> { String value = "Lambda value"; return this.value; }; String resultLambda = fooLambda.method(""); return "Results: resultIC = " + resultIC + ", resultLambda = " + resultLambda; }

If you execute the scopeExperiment() method, you will get the following result: Results: resultIC = Inner class value, resultLambda = Enclosing scope value

As you can see, by calling this.value in IC, you can access a local variable from its instance. But in the case of the lambda, this.value call gives you access to the variable value which is defined in the UseFoo class, but not to the variable value defined inside the lambda's body.

8. Keep Lambda Expressions Short and Self-explanatory

If possible, use one line constructions instead of a large block of code. Remember lambdas should be anexpression, not a narrative. Despite its concise syntax, lambdas should precisely express the functionality they provide.

This is mainly stylistic advice, as performance will not change drastically. In general, however, it is much easier to understand and to work with such code.

This can be achieved in many ways – let's have a closer look.

8.1. Avoid Blocks of Code in Lambda's Body

In an ideal situation, lambdas should be written in one line of code. With this approach, the lambda is a self-explanatory construction, which declares what action should be executed with what data (in the case of lambdas with parameters).

If you have a large block of code, the lambda's functionality is not immediately clear.

With this in mind, do the following:

Foo foo = parameter -> buildString(parameter);
private String buildString(String parameter) { String result = "Something " + parameter; //many lines of code return result; }

instead of:

Foo foo = parameter -> { String result = "Something " + parameter; //many lines of code return result; };

However, please don't use this “one-line lambda” rule as dogma. If you have two or three lines in lambda's definition, it may not be valuable to extract that code into another method.

8.2. Avoid Specifying Parameter Types

A compiler in most cases is able to resolve the type of lambda parameters with the help of type inference. Therefore, adding a type to the parameters is optional and can be omitted.

Do this:

(a, b) -> a.toLowerCase() + b.toLowerCase();

instead of this:

(String a, String b) -> a.toLowerCase() + b.toLowerCase();

8.3. Avoid Parentheses Around a Single Parameter

Lambda syntax requires parentheses only around more than one parameter or when there is no parameter at all. That is why it is safe to make your code a little bit shorter and to exclude parentheses when there is only one parameter.

So, do this:

a -> a.toLowerCase();

instead of this:

(a) -> a.toLowerCase();

8.4. Avoid Return Statement and Braces

Braces and return statements are optional in one-line lambda bodies. This means, that they can be omitted for clarity and conciseness.

Do this:

a -> a.toLowerCase();

instead of this:

a -> {return a.toLowerCase()};

8.5. Use Method References

Very often, even in our previous examples, lambda expressions just call methods which are already implemented elsewhere. In this situation, it is very useful to use another Java 8 feature: method references.

So, the lambda expression:

a -> a.toLowerCase();

could be substituted by:

String::toLowerCase;

This is not always shorter, but it makes the code more readable.

9. Use “Effectively Final” Variables

Accessing a non-final variable inside lambda expressions will cause the compile-time error. But it doesn’t mean that you should mark every target variable as final.

According to the “effectively final” concept, a compiler treats every variable as final, as long as it is assigned only once.

It is safe to use such variables inside lambdas because the compiler will control their state and trigger a compile-time error immediately after any attempt to change them.

For example, the following code will not compile:

public void method() { String localVariable = "Local"; Foo foo = parameter -> { String localVariable = parameter; return localVariable; }; }

The compiler will inform you that:

Variable 'localVariable' is already defined in the scope.

This approach should simplify the process of making lambda execution thread-safe.

10. Protect Object Variables from Mutation

One of the main purposes of lambdas is use in parallel computing – which means that they're really helpful when it comes to thread-safety.

The “effectively final” paradigm helps a lot here, but not in every case. Lambdas can't change a value of an object from enclosing scope. But in the case of mutable object variables, a state could be changed inside lambda expressions.

Consider the following code:

int[] total = new int[1]; Runnable r = () -> total[0]++; r.run();

This code is legal, as total variable remains “effectively final”. But will the object it references to have the same state after execution of the lambda? No!

Keep this example as a reminder to avoid code that can cause unexpected mutations.

11. Conclusion

Dans ce didacticiel, nous avons vu quelques bonnes pratiques et pièges dans les expressions lambda et les interfaces fonctionnelles de Java 8. Malgré l'utilité et la puissance de ces nouvelles fonctionnalités, ce ne sont que des outils. Chaque développeur doit faire attention lors de leur utilisation.

Le code source complet de l'exemple est disponible dans ce projet GitHub - il s'agit d'un projet Maven et Eclipse, il peut donc être importé et utilisé tel quel.