I. Mise en jambe▲
Avec l'arrivée de Java 12, voici comment un nouveau bloc switch/case peut s'écrire :
2.
3.
4.
5.
6.
7.
8.
9.
10.
int
initialValue =
... ; // cet entier contient une valeur arbitraire
String returnedValue;
switch
(
initialValue)
{
case
1
->
returnedValue =
"Too small!"
;
case
2
, 3
, 4
, 5
->
returnedValue =
"Good value!"
;
case
6
->
returnedValue =
"Too big!"
;
default
->
returnedValue =
"Not applicable!"
;
}
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
:
2.
3.
4.
5.
6.
7.
switch
(
initialValue)
{
case
1
->
returnedValue =
"Too small!"
;
case
2..5
->
returnedValue =
"Good value!"
;
case
6
->
returnedValue =
"Too big!"
;
default
->
returnedValue =
"Not applicable!"
;
}
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 :
2.
3.
4.
5.
String result =
Switch.of
(
initialValue, String.class
)
.defaultCase
(
value ->
value +
" : no case!"
)
.single
(
10
, value ->
"10 is the best value!"
)
.single
(
3
, value ->
"3 is an exception!"
)
.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 :
2.
3.
4.
5.
6.
7.
String result =
Switch.of
(
initialValue, String.class
)
.defaultCase
(
value ->
value +
" : no case!"
)
.predicate
(
value ->
value >
10
&&
value <
15
, value ->
"superior to 10!"
)
.predicate
(
value ->
value >=
0
&&
value <=
10
, value ->
value +
" is between 0 and 10"
)
.single
(
10
, value ->
"10 is the best value!"
)
.single
(
3
, value ->
"3 is an exception!"
)
.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
(
...) :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
package
fr.fxjavadevblog.fs;
import
java.util.function.Function;
/**
* technical interface to restrict method chaining to legal operation order.
*
*
@author
F.X. Robin
*
*
@param
<T>
*
@param
<R>
*/
public
interface
SwitchDefaultCase <
T,R>
{
/**
* set the default function that will be executed if no single value nor predicate matches
* the current value of the switch instance.
*
*
@param
function
* called function when o single value nor predicates matches the current value.
*
@return
* current instance of the switch which allows method chaining.
*/
SwitchStep<
T, R>
defaultCase
(
Function<
T, R>
function);
}
Et voici la seconde interface technique qui autorise exclusivement les méthodes :
single
(
...)predicate
(
...)resolve
(
...)
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
package
fr.fxjavadevblog.fs;
import
java.util.function.Function;
import
java.util.function.Predicate;
/**
* technical interface to restrict method chaining to use the appropriate operation order.
*
*
@author
F.X. Robin
*
*
@param
<T>
*
@param
<R>
*/
public
interface
SwitchStep <
T,R>
{
/**
* binds a value with a function to execute.
*
*
@param
value
* value to test.
*
@param
function
* function to run if the test succeeds.
*
@return
* current instance of the switch which allows method chaining.
*/
SwitchStep<
T, R>
single
(
T value, Function<
T, R>
function);
/**
* appends a predicate mapped with a function.
*
*
@param
predicate
* predicate that will be evaluated with the value of the current switch.
*
@param
function
* function that will be executed if the predicate returns true.
*
@return
* current instance of the switch which allows method chaining.
*/
SwitchStep<
T, R>
predicate
(
Predicate<
T>
predicate, Function<
T, R>
function);
/**
* last operation of the switch method chaining which executes the flow
* of the rules looking for a matching single value, then the list of predicates, then the
* default function.
*
*
@return
the result of the switch flow.
*/
R resolve
(
);
}
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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
31.
32.
33.
34.
35.
36.
37.
38.
39.
40.
41.
42.
43.
44.
45.
46.
47.
48.
49.
50.
51.
52.
53.
54.
55.
56.
57.
58.
59.
60.
61.
62.
63.
64.
65.
66.
67.
68.
69.
70.
71.
72.
73.
74.
75.
76.
77.
78.
79.
80.
81.
82.
83.
84.
85.
86.
87.
88.
89.
90.
91.
92.
93.
94.
95.
96.
97.
98.
99.
100.
101.
102.
103.
104.
105.
106.
107.
108.
109.
110.
111.
112.
113.
114.
115.
116.
117.
package
fr.fxjavadevblog.fs;
import
java.util.AbstractMap.SimpleEntry;
import
java.util.HashMap;
import
java.util.LinkedList;
import
java.util.List;
import
java.util.Map;
import
java.util.Map.Entry;
import
java.util.function.Function;
import
java.util.function.Predicate;
/**
* an implementation of a "switch-like" structure which can return a value and
* allows functional calls. The switch flow is built through method chaining.
*
*
@author
F.X. Robin
*
*
@param
<T>
* type of the tested value
*
@param
<R>
* type of the returned value
*/
public
final
class
Switch<
T, R>
implements
SwitchDefaultCase<
T, R>
, SwitchStep<
T, R>
{
/**
* function executed when no value has been found.
*/
private
Function<
T, R>
defaultCase;
/**
* value to evaluate.
*/
private
T value;
/**
* map of functions keyed by the matching value.
* Chosen implementation is LinkedHashMap in order to preserve insertion order while iterating over the entries.
*/
private
Map<
T, Function<
T, R>>
singleValuefunctions =
new
LinkedHashMap<>(
);
/**
* map of functions keyed by predicates. All the predicates are tested.
*/
private
List<
Entry<
Predicate<
T>
, Function<
T, R>>>
predicates =
new
LinkedList<>(
);
/**
* hidden constructor. the "of" method is the only starting point for building
* an instance.
*/
private
Switch
(
)
{
}
/**
* initiates the switch flow with the value to test and the returning type.
*
*
@param
value
* value to test
*
@param
clazz
* returning type
*
@return
a new instance of the switch which allows method chaining
*/
public
static
<
T, R>
SwitchDefaultCase<
T, R>
of
(
T value, Class<
R>
clazz)
{
Switch<
T, R>
switchExpression =
new
Switch<>(
);
switchExpression.value =
value;
return
switchExpression;
}
/**
*
@see
{@link
SwitchDefaultCase#defaultCase(Function)
}
*/
@Override
public
SwitchStep<
T, R>
defaultCase
(
Function<
T, R>
function)
{
this
.defaultCase =
function;
return
this
;
}
/**
*
@see
{@link
SwitchStep#resolve()
}
*/
@Override
public
R resolve
(
)
{
return
singleValuefunctions.containsKey
(
value) ? singleValuefunctions.get
(
value).apply
(
value) : findAndApplyFirstPredicate
(
);
}
// only to reduce complexity in this class.
private
R findAndApplyFirstPredicate
(
)
{
return
predicates.stream
(
)
.filter
(
p ->
p.getKey
(
).test
(
value))
.map
(
p ->
p.getValue
(
).apply
(
value))
.findFirst
(
)
.orElse
(
this
.defaultCase.apply
(
value));
}
/**
*
@see
{@link
SwitchStep#single(Object, Function)
}
*/
@Override
public
SwitchStep<
T, R>
single
(
T value, Function<
T, R>
function)
{
singleValuefunctions.put
(
value, function);
return
this
;
}
/**
*
@see
{@link
SwitchStep#predicate(Predicate, Function)
}
*/
@Override
public
SwitchStep<
T, R>
predicate
(
Predicate<
T>
predicate, Function<
T, R>
function)
{
SimpleEntry<
Predicate<
T>
, Function<
T, R>>
simpleEntry =
new
SimpleEntry<>(
predicate, function);
predicates.add
(
simpleEntry);
return
this
;
}
}
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 :
- Une recherche dans la Map parmi les valeurs simples ;
- Si rien n'est résolu, une recherche parmi la liste des prédicats ;
- 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>
.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
package
fr.fxjavadevblog.fs;
/**
* represents a fully constructed Switch instance which can resolve a specific value.
*
*
@author
F.X. Robin
*
*
@param
<T>
*
@param
<R>
*/
public
interface
SwitchExpression <
T, R>
{
/**
* last operation of the switch method chaining which executes the flow
* of the rules looking for a matching single value, then the list of predicates, then the
* default function.
*
*
@param
value
* value to test
*
@return
* result of the Switch flow.
*/
R resolve
(
T value);
}
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
(
).
2.
3.
4.
public
static
<
T, R>
SwitchDefaultCase<
T, R>
start
(
)
{
return
new
Switch<
T, R>(
);
}
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
(
) :
2.
3.
4.
5.
@Override
public
SwitchExpression<
T, R>
build
(
)
{
return
this
;
}
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à :
2.
3.
4.
5.
6.
7.
8.
9.
/**
*
@see
{@link
SwitchExpression#resolve(T)
}
*/
@Override
public
R resolve
(
T value)
{
this
.value =
value;
return
resolve
(
);
}
Tout est prêt pour quelques tests unitaires JUnit 5 :
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
public
class
SwitchTest
{
public
static
SwitchExpression<
Integer, String>
localSwitch;
@BeforeAll
public
static
void
init
(
)
{
localSwitch =
Switch.<
Integer, String>
start
(
)
.defaultCase
(
value ->
value +
" : no case!"
)
.predicate
(
value ->
value >
10
&&
value <
15
, value ->
"superior to 10!"
)
.predicate
(
value ->
value >=
0
&&
value <=
10
, value ->
value +
" is between 0 and 10"
)
.single
(
10
, value ->
"10 is the best value!"
)
.single
(
3
, value ->
"3 is an exception!"
)
.build
(
);
}
@Test
public
void
staticTest3
(
)
{
assertEquals
(
"3 is an exception!"
, localSwitch.resolve
(
3
));
}
@Test
public
void
staticTest5
(
)
{
assertEquals
(
"5 is between 0 and 10"
, localSwitch.resolve
(
5
));
}
}
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.