I. Première partie▲
Garder de la distance permet de mettre les choses en perspective, mais il faut aussi savoir se rapprocher de ces choses pour vraiment se familiariser avec elles et les comprendre. Certaines idées me paraissaient impénétrables et étranges lorsque je jouais de loin avec elles en Haskell ou me contentais de lire des choses à leur sujet en Scala ; ces mêmes concepts constituent à mes yeux des solutions évidentes à toute une série de problèmes depuis que je me suis mis à programmer en Swift.
Prenons la gestion des erreurs, pour l'exemple concret de la division de deux nombres, qui échouera si le diviseur est zéro. Voici comment je gérerais cela en Objective-C :
NSError
*
err =
nil
;
CGFloat result =
[NMArithmetic divide:2.5
by:3.0
error:&
err];
if
(
err) {
NSLog
(
@"%@"
, err)
}
else
{
[NMArithmetic utiliserResultat:result]
}
Jusqu'à présent, cela semble presque la façon naturelle de procéder. J'oublie les sinuosités que je parcours et le fait qu'elles correspondent peu à ce que je veux vraiment que le programme fasse :
— Amène-moi quelque chose. Si tu échoues, fais-le-moi savoir pour que je gère cela.
Je passe des paramètres, déréférence des pointeurs, retourne des choses chaque fois et ignore parfois la valeur de retour. C'est vraiment du code très désorganisé, pour plusieurs raisons :
- je parle le langage de la machine — des pointeurs et des pointeurs déréférencés ;
- je dois fournir à la méthode les moyens de m'informer sur les erreurs ;
- la méthode retourne un résultat indépendamment de la réussite ou de l'échec.
Chacune de ces raisons est une source potentielle de bogues et peut être exprimée en Swift d'une manière plus systématique. La première, évidemment, ne crée aucune difficulté, car Swift fait abstraction de pointeurs. Le moyen de contourner les deux autres problèmes est l'utilisation des énumérations.
I-A. Un premier exemple▲
Les calculs susceptibles de générer des erreurs ont deux résultats possibles :
- Success(1), avec un certain type de valeur qui en résulte ;
- Failure(2), avec l'espoir d'un message expliquant l'échec.
Ceux-ci sont mutuellement exclusifs — dans notre exemple, une division par zéro conduit à une erreur et tout autre cas de figure, à une réussite. Swift conceptualise l'exclusion mutuelle en fournissant les énumérations, habituellement appelées « enums ». Voici une énumération qui définit le résultat d'un calcul potentiellement générateur d'erreurs :
enum
Result
<
T
>
{
case
Success
(
T
)
case
Failure
(
String
)
}
Une instance de ce type est soit un Success avec une valeur associée, soit un Failure avec un message décrivant la cause. Chaque case définit un constructeur : le premier prend en paramètre une instance de type T (la valeur du résultat) et le second une String (le message d'erreur). L'extrait de code Objective-C devient en Swift :
var
result
=
divide
(
2
.
5
,
by
:
3
)
switch
result
{
case
Success
(
let
quotient
):
utiliserResultat
(
quotient
)
case
Failure
(
let
errString
):
println
(
errString
)
}
Dans l'ensemble, un peu plus verbeux, mais beaucoup mieux ! L'instruction switch me permet de nommer les arguments du constructeur (quotient et errString) et d'y accéder dans mon code et je peux alors traiter le résultat en fonction de la valeur de retour. Tous les points sont abordés :
- aucun pointeur explicite et encore moins déréférencé ;
- pas besoin de passer des paramètres extrinsèques à divide() ;
- traitement forcé par le compilateur de tous les cas dans l'énumération ;
- puisque quotient et errString sont enveloppés dans l'énumération, ils ne peuvent être traités que dans leurs cas respectifs — c'est impossible de traiter un résultat dans un état d'échec.
Plus important encore, le code exprime exactement ce que je voulais : le calcul de quelque chose et la gestion des erreurs. Il correspond directement à mon concept de tâche.
I-B. Un exemple plus complexe▲
Prenons maintenant un exemple plus riche. Que se passe-t-il si je veux traiter le résultat ? Imaginons que je veuille une fonction qui utilisera le résultat d'une division pour calculer un nombre magique. Ce dernier sera défini comme étant le logarithme du plus petit facteur premier du quotient de ce nombre (il n'y a rien de magique dans le calcul, j'ai pris des opérations au hasard). Le code devrait ressembler à ceci :
func
nombreMagique
(
resultatDivision
:
Result
<
Float
>)
->
Result
<
Float
>
switch
resultatDivision
{
case
Success
(
let
quotient
):
let
plusPetitFacteurPremier
=
plusPetitFacteurPremier
(
quotient
)
let
logarithme
=
log
(
plusPetitFacteurPremier
)
return
Result
.
Success
(
logarithme
)
case
Failure
(
let
errString
):
return
Result
.
Failure
(
errString
)
}
}
Assez simple. Mais maintenant que j'ai mon nombre magique, je veux obtenir la formule magique correspondante. Alors je l'écris comme ceci :
func
formuleMagique
(
nbrMagiqueResult
:
Result
<
Float
>)
->
Result
<
String
>
switch
nbrMagiqueResult
{
case
Success
(
let
value
):
let
charmeID
=
identifiantCharme
(
value
)
let
charme
=
incantation
(
charmeID
)
return
Result
.
Success
(
charme
)
case
Failure
(
let
errString
):
return
Result
.
Failure
(
errString
)
}
}
Maintenant, cependant, j'ai une instruction switch dans chaque fonction et elles semblent identiques. De plus, ces méthodes veulent vraiment exécuter des calculs uniquement sur la valeur du succès. La gestion des erreurs d'une distraction répétitive.
Chaque fois que les choses sont répétitives, il est utile de chercher une abstraction. Encore une fois, Swift possède les outils pour cela. En Swift, les énumérations peuvent avoir des méthodes et je peux éliminer la nécessité de ces instructions switch en créant la méthode map ci-dessous dans l'énumération Result :
enum
Result
<
T
>
{
case
Success
(
T
)
case
Failure
(
String
)
func
map
<
P
>(
f
:
T
->
P
)
->
Result
<
P
>
{
switch
self
{
case
Success
(
let
value
):
return
.
Success
(
f
(
value
))
case
Failure
(
let
errString
):
return
.
Failure
(
errString
)
}
}
}
La méthode map, nommée ainsi parce qu'elle mappe les Result<T> vers des Result<P>, est simple :
- s'il existe une valeur, elle lui applique f et encapsule la nouvelle valeur dans un Result.Success ;
- s'il y a un échec, elle retourne l'échec.
Aussi simple qu'elle soit, map permet la pure sorcellerie. Parce qu'elle gère les états d'erreur, je peux réécrire nombreMagique et formuleMagique strictement comme opérations primitives :
func
nombreMagique
(
quotient
:
Float
)
->
Float
{
let
ppfp
=
plusPetitFacteurPremier
(
quotient
)
return
log
(
ppfp
)
}
func
formuleMagique
(
nombreMagique
:
Float
)
{
var
charmeID
=
identifiantCharme
(
nombreMagique
)
return
incantation
(
charmeID
)
}
Ce qui me permet d'obtenir la formule magique comme ceci :
let
laFormuleMagique
=
divide
(
2
.
5
,
by
:
3
).
map
(
nombreMagique
)
.
map
(
formuleMagique
)
Ou bien, si je n'ai pas besoin de ces méthodes en premier lieu :
let
laFormuleMagique
=
divide
(
2
.
5
,
by
:
3
).
map
(
trouverPlusPetitFacteurPremier
)
.
map
(
log
)
.
map
(
identifiantCharme
)
.
map
(
incantation
)
C'est formidable. La nécessité d'une gestion des erreurs lors des calculs est abstraite. Tout ce que je dois faire est de spécifier les calculs que je veux appliquer et map se chargera de la propagation des erreurs éventuelles.
Cela ne veut pas dire que je n'aurai plus jamais à écrire une instruction switch. À un moment donné, j'aurai besoin soit de l'erreur, soit du résultat final des calculs successifs. Mais ce ne sera qu'une seule instruction switch, à la fin du traitement et je n'aurai pas besoin d'écrire des méthodes intermédiaires qui traitent les types de Result ; celles-là n'ont à se préoccuper que des valeurs en cas de succès.
Sorcellerie, je vous le dis !
I-C. Les avantages▲
Rien de tout cela n'est académique. Il est très utile d'abstraire la gestion des erreurs lorsqu'il s'agit de transformations de données : combien de fois avons-nous demandé des données à un serveur qui renvoie une chaîne ou une erreur, ensuite vous voulez transformer la chaîne en un dictionnaire, puis mapper le dictionnaire dans un objet, qui est à son tour transmis à la couche de présentation, qui en crée les objets à afficher ? L'énumération Result permet que toutes les méthodes de transformation soient écrites comme si les données qu'elles ont reçues étaient toujours valides et que les erreurs continuaient à se propager par des appels à map.
Si vous n'avez pas vu cela auparavant, prenez une seconde pour y penser. Jouez un peu avec l'idée (une fois que le code ci-dessus compile, le compilateur ne gère toujours pas l'IR, la représentation intermédiaire, pour les énumérations génériques). Je pense que vous allez commencer à apprécier la puissance.
I-D. Un petit inconvénient dans le choix de l'exemple▲
Si vous êtes fort en math, vous avez peut-être remarqué un bogue dans l'exemple donné. Les logarithmes ne sont pas définis pour des nombres négatifs et les Float peuvent être négatifs. Par conséquent, en fait log ne retournera pas un Float, mais un Result<Float>. Son passage en paramètre à map entraînerait alors des Result imbriqués, qui briseraient notre beau plan de traiter seulement des types primitifs. Ne vous inquiétez pas, il existe une solution à cela. Vous pouvez probablement la trouver vous-même. Pour ceux qui préfèrent ne pas s'en charger, j'en parle dans le chapitre suivant.
II. Seconde partie▲
Dans le chapitre précédent, j'ai montré que la définition d'une méthode map dans l'énumération Result permet la succession d'une série de transformations sur un résultat, sans se soucier de savoir si ce résultat a été un succès ou un échec, jusqu'à ce que la valeur soit nécessaire en sortie. Cependant, à la fin j'ai souligné qu'il y avait un problème avec certains types de méthodes. Je vais aborder cette problématique ci-dessous.
II-A. Un bref récapitulatif▲
La méthode map permet l'enchaînement de toute méthode unique d'entrée/sortie sur un résultat, comme ceci :
var
finalResult
=
unResultat
.
map
(
f1
)
.
map
(
f2
)
.
map
(
f3
)
Les sorties sont ce que nous attendons :
- si unResultat est Result.Success(a), finalResult sera Result.Success(f3(f2(f1(a)))) ;
- si unResultat est Result.Failure(uneString), finalResult sera également Result.Failure(uneString).
Autrement dit, l'échec est propagé. C'est génial et cela fonctionne très bien avec des calculs sans aucune erreur, tels que des additions, multiplications, longueur du tableau, etc. Mais que se passe-t-il si l'un des calculs peut échouer ? Dans l'exemple, j'avais nonchalamment :
let
result
=
divide
(
a
,
by
:
b
)
let
logResult
=
result
.
map
(
plusPetitFacteurPremier
).
map
(
log
)
Cependant, une fonction log réaliste serait déclarée comme func log(num:Float) -> Result<Float>. Elle retournerait un Result parce que log est indéfinie pour les nombres négatifs. Donc, si je devais appliquer map comme ci-dessus, je finirais avec ce monstre comme type de retour : Result<Result<Float>>. Je pourrais alors appeler map seulement avec des fonctions qui acceptent un Result<Float> comme paramètre, brisant complètement mon abstraction.
De toute évidence, map n'est pas à la hauteur de la tâche. Il me faut une deuxième méthode, similaire à map, mais qui saura canaliser, pour ainsi dire, un Result à travers une fonction qui prend une valeur, mais renvoie un Result. J'ai besoin de ceci :
extension
Result
{
func
flatMap
(
f
:
T
->
Result
<
P
>)
->
Result
<
P
>
{
switch
self
{
case
Success
(
let
value
):
return
f
(
value
)
case
Failure
(
let
errString
):
return
Result
.
Error
(
errString
)
}
}
}
Encore une fois, une mise en œuvre très simple :
- si le résultat courant est un Success, appliquer f et retourner sa valeur de retour ;
- si le résultat courant est une Error, emballer la chaîne de caractères et la retourner.
C'est en fait encore plus simple que map, même dans un cas plus complexe. En regardant le premier exemple, si f2 peut retourner une erreur, elle se transformerait en :
var
finalResult
=
unResultat
.
map
(
f1
)
.
flatMap
(
f2
)
.
map
(
f3
)
Les résultats en sortie sont un peu plus complexes, mais ont tout autant de sens :
- si unResultat est .Failure(uneString), finalResult sera également .Failure(uneString) ;
-
si unResultat est .Success(uneValeur) :
- si f2(f1(uneValeur)) ne retourne pas un échec, finalResult sera .Success(f3(f2(f1(uneValeur)))),
- si f2(f1(uneValeur)) retourne .Failure(uneAutreString), finalResult sera .Failure(uneAutreString).
En bref, les choses seront exactement comme nous nous y attendons : l'erreur qui interrompt le calcul se propage et si aucune erreur ne survient, le calcul retourne un résultat valide. Pour référence, voici à quoi devrait ressembler réellement la formule magique du chapitre précédent :
let
laFormuleMagique
=
divide
(
2
.
5
,
by
:
3
).
map
(
trouverPlusPetitFacteurPremier
)
.
flatMap
(
log
)
.
map
(
identifiantCharme
)
.
map
(
incantation
)
Donc, je peux mélanger et assortir les appels à map et flatMap au besoin, par les fonctions que je veux appliquer et je garde toujours l'avantage de la gestion différée des erreurs.
III. Conclusions▲
Ces deux chapitres ont porté sur la gestion synchrone des erreurs. La vérité est, cependant, qu'une bonne partie du code susceptible de générer des erreurs est de nos jours asynchrone. Nous essayons de maintenir la réactivité de l'IU en lançant autant que possible des opérations dans des fils d'exécution d'arrière-plan et ce sont généralement les entrées/sorties réseau ou fichier qui cassent les choses, qui prennent généralement des blocs de rappel. Je vais jeter un œil à des approches pour faire face à cela dans un article ultérieur.
Mise à jour : j'ai changé le nom de la fonction présentée dans ce chapitre, de funnel en flatMap, après avoir discuté les avantages et les inconvénients du nom avec @cocoaphony et @lightfiend, et décidé que mon aversion envers flatMap est moins bien fondée que mon ressenti du début.
IV. Remerciements Developpez▲
Nous remercions Alexandros Salazar de nous avoir aimablement autorisés à publier son article, dont le texte original peut être trouvé sur http://nomothetis.svbtle.com. Nous remercions aussi Mishulyna pour sa traduction, LeBzul pour sa relecture technique ainsi que jacques_jean pour sa relecture orthographique.
Les commentaires et les suggestions d'amélioration sont les bienvenus, alors, après votre lecture, n'hésitez pas : commentez !