Joshua Bloch, l’auteur de la fameuse série « Effective Java » a présenté, lors de la conférence Devoxx 2018 « Effective Java, Third Edition Keepin’ it Effective », ses conseils concernant les deux principales nouveautés apportées par Java 8 : les Lambdas et les Streams. Inspiré de cette conférence, cet article présente 7 conseils sur l’utilisation des nouveautés de Java 8. Nous les illustrerons au travers de différents cas pratiques et détermineront si elles doivent être utilisées ou non et nous évoquerons certains pièges à éviter.

JAVA 8 : UN LANGAGE MULTI – PARADIGME

Jusqu’à Java 7, la meilleure façon de trier une liste de chaîne de caractères par longueur était d’utiliser une classe anonyme :

Collections.sort(words, new Comparator<String>() {
  @Override
  public int compare(String s1, String s2) {
    return Integer.compare(s1.length(), s2.length());
  }
});

Avec les lambdas, il est maintenant plus simple d’écrire :

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length())); 

Avec les références de méthodes et les interfaces fonctionnelles, cela devient :	
  
Collections.sort(words, Comparator.comparingInt(String::length));

En ajoutant un import static :

import static java.util.Comparator.comparingInt;

On peut maintenant écrire :

Collections.sort(words, comparingInt(String::length));

Enfin, en utilisant la fonction « sort » ajoutée en Java 8 dans l’interface List, cela devient :

words.sort(comparingInt(String::length));

Nous voyons qu’il existe maintenant de nombreuses façons d’effectuer une même opération. Parfois, il est évident d’identifier la meilleure, parfois cela dépend du contexte et des goûts du développeur.

L’INFERENCE

L’inférence est ce qui permet au compilateur d’automatiquement deviner les types des différents objets en se basant sur les types génériques. Par exemple, quand vous faites :

Collections.sort(words, (s1, s2) -> Integer.compare(s1.length(), s2.length()));

Cela revient au même que :

Collections.sort(words, (Comparator<String>) (String s1, String s2) -> Integer.compare(s1.length(), s2.length()));

Pour utiliser des lambdas, il est indispensable d’utiliser correctement les types génériques. Ainsi, pour l’ensemble des exemples ci-dessus, il faut que « words » soit déclaré comme :

List<String> words;

Si l’on déclare « words » comme une liste sans préciser son type générique comme étant String, le code ne compile plus.

PREFERER LES LAMBDAS AUX CLASSES ANONYMES

Avant Java 8, une bonne manière de représenter un ensemble fini d’opérations était :

public enum Operation {
PLUS {
    @Override
    public double apply(double x, double y) {
      return x + y;
    }
  },
MINUS {
    @Override
    public double apply(double x, double y) {
      return x - y;
    }
  },
TIMES {
    @Override
    public double apply(double x, double y) {
      return x * y;
    }
  },
DIVIDE {
    @Override
    public double apply(double x, double y) {
      return x / y;
    }
  };

public abstract double apply(double x, double y);			
}

En utilisant des lambdas, le code devient beaucoup plus concis :

public enum Operation {
  PLUS ( (x, y) -> x + y),
  MINUS ( (x, y) -> x - y),
  TIMES ( (x, y) -> x * y),
  DIVIDE ( (x, y) -> x / y);

  private Operation(DoubleBinaryOperator op) {
    this.op = op;
  }

  private final DoubleBinaryOperator op;

  public double apply(double x, double y) {
    return op.applyAsDouble(x, y);
  }
}

Les lambdas ont néanmoins deux grosses limitations :

  • Elles nécessitent une interface fonctionnelle.
  • Elles ne peuvent pas faire référence à elle-même (« this » fait référence à l’objet encapsulant la lambda).

Les lambdas ont aussi deux inconvénients :

  • Elles n’ont pas de nom ou de documentation comme une méthode ou une classe.
  • Elles sont peu lisibles si elles font plusieurs lignes.

Ces deux inconvénients font qu’il vaut mieux créer une méthode plutôt qu’avoir une lambda longue et complexe.

PREFERER LES REFERENCES DE METHODE AUX LAMBDAS

Comme nous venons de le voir, les lambdas peuvent être très succinctes. Partant d’un objet « map » de type Map<String, Integer>, nous voulons initialiser à 1 le nombre associé à la clé « MaClef » si elle n’avait pas encore de compteur ou dans le cas contraire augmenter son compteur de 1 :

map.merge("MaClef",  1, (count, incr) -> count + incr);

Dans ce cas, utiliser une référence de méthode permet d’être encore plus concis :

map.merge("MaClef", 1, Integer::sum);

Il existe néanmoins quelques cas où il est préférable d’utiliser une lambda :

  • S’il n’y a pas d’arguments et si le nom de la classe est compliqué :
    () -> action() est plus simple que : MonLongNomDeClasse::action
  • Si la lambda est très simple comme la fonction identité :
    x -> x est plus simple que : Function.identity()

Pratiquement dans tous les cas, une lambda et une référence de méthode peuvent être facilement interchangées. Donc si notre premier choix se révèle malheureux, il sera aisé de modifier le code.

PRIVILEGIER LES INTERFACES FONCTIONNELLES STANDARDS

Il n’existe pas moins de 43 interfaces fonctionnelles standards. Six d’entre elles sont des interfaces pour travailler sur des objets quelconques (les autres sont principalement utiles pour travailler sur des types primitifs) :

Java 8 - 1
Pour des raisons d’interopérabilité et pour rendre vos APIs plus facile à comprendre, il est important d’utiliser les interfaces fonctionnelles existantes plutôt que d’en inventer de nouvelles. Il existe néanmoins quelques exceptions comme Comparator. On peut citer 4 raisons qui peuvent pousser à inventer une nouvelle interface fonctionnelle alors même qu’une standard existe :

    • L’interface créée va être très utilisée.
    • Le nom de l’interface apporte beaucoup à la compréhension.
    • L’interface est associée à un contrat fort (comme la réflexivité, la symétrie et la transitivité pour l’interface Comparator).
    • L’interface apporte des méthodes par défaut.

L’interface Comparator remplie parfaitement ces 4 raisons et il aurait été évidemment dommage d’utiliser l’interface ToIntBiFunction<T, T>.

UTILISER LES STREAMS AVEC PRECAUTION

Les streams en Java permettent de facilement traiter une suite d’éléments qu’ils soient générés ou qu’ils proviennent d’une collection, d’un tableau, d’une entrée…
En plus, en utilisant un parallelStream, il est très facile de paralléliser le traitement. Néanmoins, les streams ne sont pas toujours plus concis à écrire que le code traditionnel avec des boucles. Dans certains cas, les streams vont juste complexifier la compréhension du code. Il ne faut donc pas à tout prix remplacer toutes les boucles for par des streams avec forEach.
Il faut aussi éviter d’utiliser les streams avec le type primitif char. En effet, le code suivant :

"Hello world!".chars().forEach(System.out::print);

Ne produit pas vraiment ce à quoi on pourrait s’attendre mais : 721011081081113211911111410810033
En effet, comme il n’existe pas de CharStream mais seulement un IntStream, tous les caractères sont convertis en entier avec d’être imprimés.

SE MEFIER DES PARALLEL STREAMS

En prenant l’exemple de la suite des nombres de Mersenne premier (les nombres de Mersenne sont les nombres de la forme une puissance de 2 moins 1 https://fr.wikipedia.org/wiki/Nombre_de_Mersenne_premier), nous pouvons aisément calculer les 15 premiers en utilisant un seul thread à l’aide du code suivant :

static BigInteger ONE  = new BigInteger("1");
static BigInteger TWO  = new BigInteger("2");

static Stream<BigInteger> primes() {
  return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

public static void main(String args[]) {
  primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
  // .parallel()
  .filter(mersenne -> mersenne.isProbablePrime(50))
  .limit(15)
  .forEach(System.out::println);
}

Sur un quad-core i7, le temps de calcul en seconde évolue rapidement :
java 8 - 2
Alors pourquoi ne pas paralléliser le traitement pour gagner du temps ?

Pour tester, il suffit de décommenter « .parallel() » dans l’exemple au-dessus. Malheureusement le résultat n’est pas au rendez-vous. Après plusieurs minutes, nous n’obtenons pas le moindre nombre. Même en demandant seulement 1 nombre premier de Mersenne. Au lieu de voir immédiatement apparaître le nombre 3, nous obtenons une alerte de température du processeur !
Alors que se passe t-il ? Il se trouve que la bibliothèques Streams n’arrive pas à paralléliser l’itération. Pire encore, chaque thread va calculer de nombreux éléments et la limite fixée ne va être enforcée qu’à la fin du traitement. Or chaque nombre de Mersenne prenant deux fois plus de temps à calculer que son prédécesseur, calculer plusieurs éléments supplémentaires est rédhibitoire.
En règle générale, les parallelStream ne fonctionnent pas bien avec Stream.iterate ou avec limit(n). Une mauvaise utilisation des streams pouvant coûter très chère, il faut toujours bien mesurer les performances avant de paralléliser ou non un stream. De plus, dans certains cas, le fait de paralléliser peut conduire à de faux résultats.
Il faut aussi noter que l’utilisation des parallelStream va solliciter l’ensemble des processeurs de la machine. Ce n’est donc pas forcément une bonne idée de paralléliser un traitement d’un serveur utilisé par de nombreux utilisateurs. Or, il est assez complexe de choisir de n’utiliser qu’un nombre limité de processeurs. Il faut en effet créer un ForkJoinPool comme l’indique le site : https://blog.krecan.net/2014/03/18/how-to-specify-thread-pool-for-java-8-parallel-streams/

CONCLUSION

Le langage Java est, avec sa version 8, bien plus riche et complexe qu’auparavant. Comme il existe maintenant de nombreuses façons d’écrire une même fonction, il devient nécessaire de non seulement connaître les différentes possibilités offertes par Java mais aussi de savoir dans quels cas les utiliser. Les lambdas et les streams peuvent beaucoup apporter à vos programmes mais seulement si vous les utilisez à bon escient. « Un grand pouvoir implique de grandes responsabilités »

RESSOURCES

https://www.youtube.com/watch?v=hSfylUXhpkA