Tutoriel pour développer un Switch/Case en programmation fonctionnelle avec Java 8

François-Xavier Robin

Java 12 est sorti le 20/03/19, apportant une nouvelle façon d'écrire des structures de contrôle switch/case. Cela m'a donné une idée, certes un peu étrange, de revoir le traditionnel switch/case d'un point de vue programmation fonctionnelle en s'appuyant sur des lambdas et une petite classe Switch, le tout en JAVA 8 !

Attention toutefois, il est certain que cette approche est beaucoup moins performante qu'un switch/case classique, mais je ne renonce pas à la beauté du geste.

Pour vos suggestions et commentaires, un espace est créé sur le forum : Commentez

- Titre : Tutoriel pour développer un Switch/Case en programmation fonctionnelle avec Java 8

- Auteur : François-Xavier Robin

- Parution : 23 janvier 2020

- Licence : Copyright ® 2019 François-Xavier Robin. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Mise en jambe

- II. Usage

- III. Les interfaces techniques SwitchDefaultCase et SwitchRule

- IV. La classe Switch

- V. Pour aller encore plus loin, petite optimisation

- VI. Fin de l'histoire

- VII. Remerciements

I - Mise en jambe

Avec l'arrivée de Java 12, voici comment un nouveau bloc switch/case peut s'écrire :

1. int initialValue = ... ; // cet entier contient une valeur arbitraire

3. String returnedValue;

4. switch (initialValue)

5. {

6. case 1 -> returnedValue = "Too small!";

7. case 2, 3, 4, 5 -> returnedValue = "Good value!";

8. case 6 -> returnedValue = "Too big!";

9. default -> returnedValue = "Not applicable!";

10. }

Le principal problème, et c'est malheureusement bien dommage que cela n'ait pas été pris en compte dans la JEP 325, ce sont des plages de valeurs.

Typiquement, on aurait bien aimé quelque chose dans ce genre dans l'exemple précédent sur la plage de valeur 2..5 :

1. switch (initialValue)

2. {

3. case 1 -> returnedValue = "Too small!";

4. case 2..5 -> returnedValue = "Good value!";

5. case 6 -> returnedValue = "Too big!";

6. default -> returnedValue = "Not applicable!";

7. }

Ne cherchez pas à compiler le code ci-dessus ! Il est syntaxiquement incorrect.

Je me suis alors fait la réflexion suivante …

« En vrai, un switch/case c'est globalement :

- une valeur à tester ;

- un ensemble de prédicats (simples ou complexes) et une fonction associée à chacun d'entre eux ;

- un cas par défaut. »

Let's code it in a functional way!

II - Usage

Je suis parti de ce que je voulais obtenir côté « utilisateur/développeur » avec quelque chose de simple :

1. String result = Switch.of(initialValue, String.class)

2. .defaultCase(value -> value + " : no case!")

3. .single(10, value -> "10 is the best value!")

4. .single(3, value -> "3 is an exception!")

5. .resolve();

Dans les points clés :

- obligation de spécifier un cas par défaut, donc on commence par lui ;

- ajout simple de « matching values » en associant une function<T,R> : T étant le type de la valeur testée, ici Integer (int autoboxé) et R le type de retour, ici String ;

- un enchaînement infini avec la méthode single et donc du method-chaining à la mode ;

- la méthode terminale resolve() qui déclenche l'exécution globale du Switch.

Le type de retour est complètement générique. Dans cet exemple il s'agit d'une instance de la classe String.

Avec un usage plus avancé, qui permet de répondre à mon besoin de plage de valeurs, mais pas seulement :

1. String result = Switch.of(initialValue, String.class)

2. .defaultCase(value -> value + " : no case!")

3. .predicate(value -> value > 10 && value < 15, value -> "superior to 10!")

4. .predicate(value -> value >= 0 && value <= 10, value -> value + " is between 0 and 10")

5. .single(10, value -> "10 is the best value!")

6. .single(3, value -> "3 is an exception!")

7. .resolve();

Revenons un peu sur cette ligne :

.predicate(value -> value > 10 && value < 15, value -> "superior to 10!")

Elle est composée :

- du prédicat en premier argument, ici exprimé sous forme d'expression lambda value -> value > 10 && value < 15 ;

- puis de la fonction à exécuter le cas échéant, toujours exprimée avec une lambda value -> "superior to 10!".

III - Les interfaces techniques SwitchDefaultCase et SwitchRule

Pour définir une belle API fluent, qui impose un ordre dans l'enchaînement des méthodes, voire qui en rend obligatoire certaines, il faut passer par la définition d'interfaces techniques qui restreignent les appels possibles en fonction du dernier appel de méthode.

Hein ? Mais qu'est-ce qu'il dit ?

Par exemple, la méthode statique of(...) sera le point d'entrée et on ne pourra chaîner que la méthode defaultCase que l'on souhaite obligatoire. La méthode of(...) doit, par conséquent, retourner un ensemble restreint des méthodes autorisées.

Il en va de même pour les méthodes defaultCase(...), predicate(...) et single(...). On ne pourra pas enchaîner les single(...) ou predicate(...) tant que l'on n'a pas utilisé defaultCase(...). De plus, on ne pourra pas déclencher resolve() tant que defaultCase(...) n'a pas été utilisé non plus. Cela assure un bon usage de la classe Switch, ce qui est le principal intérêt du method-chaining.

OK merci c'est plus clair maintenant !

Voici donc la première interface technique qui autorise uniquement la méthode defaultCase(...) :

1. package fr.fxjavadevblog.fs;

3. import java.util.function.Function;

4. /**

5. * technical interface to restrict method chaining to legal operation order.

6. *

7. * @author F.X. Robin

8. *

9. * @param <T>

10. * @param <R>

11. */

12. public interface SwitchDefaultCase <T,R>

13. {

14. /**

15. * set the default function that will be executed if no single value nor predicate matches

16. * the current value of the switch instance.

17. *

18. * @param function

19. *    called function when o single value nor predicates matches the current value.

20. * @return

21. *    current instance of the switch which allows method chaining.

22. */

23. SwitchStep<T, R> defaultCase(Function<T, R> function);

24. }

Et voici la seconde interface technique qui autorise exclusivement les méthodes :

- single(...)

- predicate(...)

- resolve(...)

1. package fr.fxjavadevblog.fs;

3. import java.util.function.Function;

4. import java.util.function.Predicate;

7. /**

8. * technical interface to restrict method chaining to use the appropriate operation order.

9. *

10. * @author F.X. Robin

11. *

12. * @param <T>

13. * @param <R>

14. */

15. public interface SwitchStep <T,R>

16. {

17. /**

18. * binds a value with a function to execute.

19. *

20. * @param value

21. *    value to test.

22. * @param function

23. *    function to run if the test succeeds.

24. * @return

25. *    current instance of the switch which allows method chaining.

26. */

27. SwitchStep<T, R> single(T value, Function<T, R> function);

29. /**

30. * appends a predicate mapped with a function.

31. *

32. * @param predicate

33. *    predicate that will be evaluated with the value of the current switch.

34. * @param function

35. *    function that will be executed if the predicate returns true.

36. * @return

37. *    current instance of the switch which allows method chaining.

38. */

39. SwitchStep<T, R> predicate(Predicate<T> predicate, Function<T, R> function);

40. /**

41. * last operation of the switch method chaining which executes the flow

42. * of the rules looking for a matching single value, then the list of predicates, then the

43. * default function.

44. *

45. * @return

46. the result of the switch flow.

47. */

48. R resolve();

49. }

Notez les types de retour des méthodes qui assurent le chaînage correct pour le “method-chaining”.

IV - La classe Switch

La classe Switch est assez classique :

- elle masque son constructeur pour empêcher l'instanciation. Seule la méthode of(...) est le point d'entrée ;

- elle conserve dans un attribut private T value la valeur à tester ;

- elle détient une Map<T, Function <T,R>> pour associer les valeurs simples de type T à des fonctions qui retourneront un résultat ;

- elle détient une liste de tuples Predicate<T>, Function<T,R> pour gérer les cas complexes comme des plages de valeurs ;

- elle détient une référence vers une fonction pour le cas par défaut : private Function<T, R> defaultCase ;

- et enfin elle implémente bien évidemment les deux interfaces techniques SwithDefaultCase<T, R> et SwitchStep<T, R> décrites au paragraphe précédent.

1. package fr.fxjavadevblog.fs;

3. import java.util.AbstractMap.SimpleEntry;

4. import java.util.HashMap;

5. import java.util.LinkedList;

6. import java.util.List;

7. import java.util.Map;

8. import java.util.Map.Entry;

9. import java.util.function.Function;

10. import java.util.function.Predicate;

12. /**

13. * an implementation of a "switch-like" structure which can return a value and

14. * allows functional calls. The switch flow is built through method chaining.

15. *

16. * @author F.X. Robin

17. *

18. * @param <T>

19. *          type of the tested value

20. * @param <R>

21. *          type of the returned value

22. */

23. public final class Switch<T, R> implements SwitchDefaultCase<T, R>, SwitchStep<T, R>

24. {

25. /**

26. * function executed when no value has been found.

27. */

28. private Function<T, R> defaultCase;

30. /**

31. * value to evaluate.

32. */

33. private T value;

35. /**

36. * map of functions keyed by the matching value.

37. * Chosen implementation is LinkedHashMap in order to preserve insertion order while iterating over the entries.

38. */

39. private Map<T, Function<T, R>> singleValuefunctions = new LinkedHashMap<>();

41. /**

42. * map of functions keyed by predicates. All the predicates are tested.

43. */

44. private List<Entry<Predicate<T>, Function<T, R>>> predicates = new LinkedList<>();

46. /**

47. * hidden constructor. the "of" method is the only starting point for building

48. * an instance.

49. */

50. private Switch()

51. {

53. }

55. /**

56. * initiates the switch flow with the value to test and the returning type.

57. *

58. * @param value

59. *          value to test

60. * @param clazz

61. *          returning type

62. * @return a new instance of the switch which allows method chaining

63. */

64. public static <T, R> SwitchDefaultCase<T, R> of(T value, Class<R> clazz)

65. {

66. Switch<T, R> switchExpression = new Switch<>();

67. switchExpression.value = value;

68. return switchExpression;

69. }

71. /**

72. * @see {@link SwitchDefaultCase#defaultCase(Function)}

73. */

74. @Override

75. public SwitchStep<T, R> defaultCase(Function<T, R> function)

76. {

77. this.defaultCase = function;

78. return this;

79. }

81. /**

82. * @see {@link SwitchStep#resolve()}

83. */

84. @Override

85. public R resolve()

86. {

87. return singleValuefunctions.containsKey(value) ? singleValuefunctions.get(value).apply(value) : findAndApplyFirstPredicate();

88. }

89. // only to reduce complexity in this class.

90. private R findAndApplyFirstPredicate()

91. {

92. return predicates.stream()

93. .filter(p -> p.getKey().test(value))

94. .map(p -> p.getValue().apply(value))

95. .findFirst()

96. .orElse(this.defaultCase.apply(value));

97. }

98. /**

99. * @see {@link SwitchStep#single(Object, Function)}

100. */

101. @Override

102. public SwitchStep<T, R> single(T value, Function<T, R> function)

103. {

104. singleValuefunctions.put(value, function);

105. return this;

106. }

107. /**

108. * @see {@link SwitchStep#predicate(Predicate, Function)}

109. */

110. @Override

111. public SwitchStep<T, R> predicate(Predicate<T> predicate, Function<T, R> function)

112. {

113. SimpleEntry<Predicate<T>, Function<T, R>> simpleEntry = new SimpleEntry<>(predicate, function);

114. predicates.add(simpleEntry);

115. return this;

116. }

117. }

Notez ici l'usage de l'interface Entry ainsi que de la classe SimpleEntry de l'API Collections de Java pour obtenir une liste de tuples.

Voici la description de l'algorithme interne de la classe Switch :

1. Une recherche dans la Map parmi les valeurs simples ;

2. Si rien n'est résolu, une recherche parmi la liste des prédicats ;

3. Si toujours rien n'est résolu, déclenchement de la fonction par défaut référencée par le champ defaultCase.

Cela fonctionne avec des prédicats bien plus évolués que des plages de valeurs, ce qui rend l'ensemble bien plus ouvert qu'un switch/case Java 12.

V - Pour aller encore plus loin, petite optimisation

En l'état, c'est assez satisfaisant, mais le coût de création du Switch à chaque appel peu être très élevé.

Généralement, les différents cas et prédicats sont assez stables et évoluent assez peu au Runtime.

Il serait donc intéressant de pouvoir :

- construire une instance de Switch, sans valeur particulière ;

- conserver une référence de cette instance en static par exemple ;

- déclencher le flow au moyen d'une nouvelle méthode resolve(T value) qui prendra en argument la valeur à tester.

C'est parti ! Let's have fun!

Première étape, on introduit une nouvelle interface qui permet de faire uniquement un resolve(T value). Initialement resolve() n'avait pas besoin de valeur puisque celle-ci était fournie à l'appel de la méthode of(...). Cette interface, je décide de l'appeler SwitchExpression <T, R>.

1. package fr.fxjavadevblog.fs;

2. /**

3. * represents a fully constructed Switch instance which can resolve a specific value.

4. *

5. * @author F.X. Robin

6. *

7. * @param <T>

8. * @param <R>

9. */

10. public interface SwitchExpression <T, R>

11. {

12. /**

13. * last operation of the switch method chaining which executes the flow

14. * of the rules looking for a matching single value, then the list of predicates, then the

15. * default function.

16. *

17. * @param value

18. *          value to test

19. * @return

20. *          result of the Switch flow.

21. */

22. R resolve(T value);

23. }

Ensuite, au sein de classe Switch, il nous faut une nouvelle méthode de « démarrage » statique en complément de of(...). En manque d'inspiration, je la nomme start().

1. public static <T, R> SwitchDefaultCase<T, R> start()

2. {

3. return new Switch<T, R>();

4. }

De plus, toujours dans la classe Switch, il nous faut maintenant une méthode terminale de method-chaining qui retournera l'instance actuelle du Switch, sous forme de SwitchExpression. Cela imposera l'usage unique de resolve(T value). J'appelle cette méthode build() :

1. @Override

2. public SwitchExpression<T, R> build()

3. {

4. return this;

5. }

Et enfin il faut implémenter la méthode resolve(T value) dans la classe Switch puisqu'elle implémente maintenant l'interface SwitchExpression <T, R>. Évidemment, je réutilise la méthode resolve() qui existe déjà :

1. /**

2. * @see {@link SwitchExpression#resolve(T)}

3. */

4. @Override

5. public R resolve(T value)

6. {

7. this.value = value;

8. return resolve();

9. }

Tout est prêt pour quelques tests unitaires JUnit 5 :

1. public class SwitchTest

2. {

3. public static SwitchExpression<Integer, String> localSwitch;

4. @BeforeAll

5. public static void init()

6. {

7. localSwitch = Switch.<Integer, String> start()

8. .defaultCase(value -> value + " : no case!")

9. .predicate(value -> value > 10 && value < 15, value -> "superior to 10!")

10. .predicate(value -> value >= 0 && value <= 10, value -> value + " is between 0 and 10")

11. .single(10, value -> "10 is the best value!")

12. .single(3, value -> "3 is an exception!")

13. .build();

14. }

15. @Test

16. public void staticTest3()

17. {

18. assertEquals("3 is an exception!", localSwitch.resolve(3));

19. }

20. @Test

21. public void staticTest5()

22. {

23. assertEquals("5 is between 0 and 10", localSwitch.resolve(5));

24. }

25. }

Cette fois-ci le Switch n'est construit qu'une seule fois et peut être déclenché autant de fois que nécessaire avec une valeur différente à chaque appel de resolve(...).

VI - Fin de l'histoire

Vous pouvez récupérer le code source de cet article ici : https://github.com/fxrobin/functional-switch

Rien à ajouter, sinon que je me suis (encore) bien amusé et qu'il s'agit de la « fin de l'histoire ».

VII - Remerciements

Cet article a été publié avec l'aimable autorisation de François-Xavier Robin.

Nous tenons à remercier Claude Leloup pour sa relecture orthographique attentive de cet article puis WinJerome et Mickael Baron pour la mise au gabarit du billet original.