URL: https://linuxfr.org/users/guillaum/journaux/gestion-de-l-erreur-c-std-optional Title: Gestion de l'erreur - C++ - std::optional Authors: Guillaum Date: 2016年09月03日T16:36:22+02:00 License: CC By-SA Tags: c++, c++14 et c++17 Score: 39 # Introduction Ce journal discute de la gestion d'erreur lors des appels de fonction. Dans la première partie, nous discuterons plusieurs solutions de gestion d'erreur classiques rencontrée dans de nombreux languages comme C, C++, Python, etc. Dans la seconde partie, nous présenterons `std::optional`, une nouvelle classe de la librairie standard C++17 et nous discuterons comment celle-ci change la gestion d'erreur. Pour finir je donnerai un avis personnel sur l'API de cette classe que je n'aime pas, et je proposerai une solution alternative inspirée de ce qui se fait dans d'autres langages, comme swift, rust, ocaml, haskell, etc. Ce journal s'adresse au développeur débutant dans sa première partie. Le développeur débrouillé pourra être intéressé par la présentation de `std::optional` de la seconde partie. Enfin la partie finale sera sûrement le point de départ de nombreux débats (constructifs) et pourra intéresser tout le monde, mais surtout le développeur confirmé. Ce journal est clairement MON avis sur la question, et de nombreux points sont largement subjectifs, ils sont issus de mon expérience personnelle et je vous invite à m'incendier dans les commentaires si vous n'êtes pas d'accord ;) Note : je m'intéresse uniquement au traitement des optionals, donc j'ignore certains détails de bonnes pratiques de programmation ou d'optimisation ou de cas particuliers, ainsi inutile de troller sur le fait que certains de mes exemples n'exploitent pas la move sémantique et copient inutilement des chaînes, je sais ;) # Fonctions partielles et fonction totales Il existe de nombreuses fonctions qui peuvent échouer, on appelle cela des fonctions *partielles*. Quelques exemples que nous utiliserons au cours de ce document. - Une fonction `maximum` qui donne la valeur maximum d'une liste. Que se passe-t-il si la liste est vide ? - Une fonction `recherche` qui cherche un élément dans un tableau et retourne l'indice où celui-ci se trouve. Que se passe-t-il si l'élément n'est pas dans le tableau ? - Une fonction `lireFichier` qui retourne le contenu lue dans un fichier. Que se passe-t-il si le fichier n'est pas lisible ou n'existe pas ? Pour toutes ces fonctions, il convient de mettre en place une politique de gestion d'erreur adaptée. Nous en discuterons plusieurs : - La politique du contrat. - La méthode des valeurs sentinelles - La méthode du flag de réussite. - La méthode par exception - La méthode par optional. # Méthode par contrat Cette méthode est de loin la plus simple, il suffit de placer un contrat avec l'utilisateur de la fonction disant qu'il ne doit appeler la fonction que dans un contexte où elle doit réussir. Cette approche est utilisé par exemple en C++ pour l'opérateur `operator[]` d'accès aux cases d'un tableau sur un `std::vector`. Dans le cas où on essaye d'accéder à une mauvaise case, le comportement du programme est indéfini. Cette méthode est très simple, mais force le développeur à s'assurer que le contrat est respecté avant l'appel de fonction. C'est souvent contre productif voir impossible. Dans le cas de la fonction `recherche` il faudrait parcourir une première fois la structure pour vérifier que l'élément est dedans avant d'appeler la fonction `recherche` pour savoir où il est. Dans le cas de la fonction `lireFichier`, c'est carrément impossible puisque pour savoir si on peut lire un fichier, il faut le lire, et qu'il n'y a aucune garantie qu'un fichier lue à un instant t sera lisible à l'instant t+1. Je n'apprécie pas cette méthode car elle est source de nombreux bugs difficiles à trouver. # Méthode par valeur sentinelle L'idée ici étant d'utiliser une valeur de retour particulière pour matérialiser l'erreur. C'est une approche très souvent utilisée dans de nombreuses librairies. Par exemple, en C, nous avons la fonction `fopen` `FILE *fopen(const char *path, const char *mode);` chargée d'ouvrir un fichier. En cas de réussite, la fonction retourne un pointeur vers un objet utilisé par la suite pour traiter le fichier. En cas d'échec, elle retourne un pointeur `NULL`. Dans le cas de la méthode `recherche` qui renvoie l'indice dans un tableau d'un élément recherché, on pourrait renvoyer une valeur négative, puisque les indices dans un tableau sont toujours positifs. L'usage se ferait ainsi de la manière suivante : ```cpp // prototype de la fonction int rechercher(const Collection &c, const Item item); //... int offset = rechercher(maCollection, monItem); if(offset>= 0) { std::cout << "Item trouvé à la position " << offset << std::endl; std::cout << "La valeur de l'item est " << maCollection[offset] << std::endl; } else { std::cout << "Item non trouvé". } ``` Cette méthode ne peut cependant pas s'appliquer à tous les cas de figure. Quelle valeur sentinelle pourrait renvoyer la fonction `maximum` ? Celle-ci devra être garantie de ne pas pouvoir être confondue avec une valeur qui serait le vrai résultat de la fonction. Cette méthode rend l'erreur facile, en effet, il est aisé d'oublier de tester la réussite, ainsi le code suivant, qui semble anodin, est faux : ```cpp int offset = rechercher(maCollection, monItem); std::cout << "Item trouvé à la position " << offset << std::endl; std::cout << "La valeur de l'item est " << maCollection[offset] << std::endl; ``` En effet, si l'élément n'est pas trouvé, offset vaut `-1` et `maCollection[offset]` n'a pas de sens. # Méthode par flag de réussite Ici la fonction vas renvoyer un flag, souvent un booléen pour matérialiser la réussite. La valeur de retour étant en fait passée par référence et modifiée. ```cpp // prototype de la fonction bool rechercher(const Collection &c, const Item item, int &offset); //... int offset; bool res = rechercher(maCollection, monItem, offset); if(res) { std::cout << "Item trouvé à la position " << offset << std::endl; std::cout << "La valeur de l'item est " << maCollection[offset] << std::endl; } else { std::cout << "Item non trouvé". } ``` Cette approche corrige une des limitations de la méthode par valeur sentinelle, elle peut fonctionner pour n'importe quelle fonction, puisque il n'est pas nécessaire de trouver une valeur sentinelle adaptée, la réussite étant matérialisée par le booléan. Cependant on peut toujours oublier de tester le booléan de résultat et ainsi utiliser la valeur de `offset` qui serait non initialisée (ou initialisée par défaut avec une valeur fausse). # Méthode par exception Cette méthode est plus souvent utilisée dans des langages comme Python. Par exemple, la fonction de recherche d'un élément dans une liste vas soit renvoyer l'indice de l'élément, soit va lever une exception qui va remonter la pile d'appel jusqu'à être interceptée ou jusqu'à terminer le programme. ```pyc>>> # cas qui fonctionne>>> [1,2,3].index(2) 1>>> # cas qui renvoie une exception>>> [1,2,3].index(4) Traceback (most recent call last): File "", line 1, in ValueError: 4 is not in list ``` La méthode par exception a de nombreux avantages : - Si on ne traite pas l'exception, elle finira le programme, souvent avec un message d'erreur explicite, ce qui évite les bugs dissimulés où la valeur sentinelle ou non initialisée est utilisée. - Le code n'est pas complexifié par une gestion d'erreur avec de nombreux if - La gestion d'erreur peut être repoussée à plus tard, dans une fonction appelante. La gestion de l'exception se fait, en Python, avec un bloc `try / except` : ```python try: offset = maCollection.index(monItem) print("l'element a été trouvé, il est en position", offset, "et c'est", maCollection[offset] except: # Normalement il ne faut pas attraper TOUTES les exceptions, mais c'est un tutoriel simplifié print("Élement non trouvé") ``` Le C++ gère aussi les exceptions, mais la librairie standard ne s'en sert pas. Pour différentes raisons que je n'aborderais pas, la communauté du C++ n'aime pas les exceptions et peu de librairie les utilisent correctement. Cependant, rien ne force le développeur à gérer les exceptions et rien n'indique à celui-ci qu'une fonction peut lancer une exception, si ce n'est une lecture scrupuleuse de la documentation. Certains langages, comme Java, proposent un système d'exception qui force le développeur à gérer celles-ci ou à les propager explicitement à la fonction appelante. C'est donc à mon sens une solution intéressante, mais il est trop facile d'oublier de traiter un cas exceptionnel. Heureusement, celui-ci se traduira par une exception qui finalisera le programme, mais en tant que développeur j'aurais aimé que mon compilateur m'assure lors du développement que je n'avais pas raté ce cas. # Bilan Dans l'état actuel des choses, nous avons des méthodes qui laissent beaucoup de place à l'erreur potentielle et qui forcent à bien lire la documentation de chaque fonction pour distinguer les valeurs sentinelles, comprendre le fonctionnement du flag de réussite, ou connaître la liste des exceptions pouvant apparaître. La méthode par exception est sans doute la plus propre de toutes, mais il est difficile dans les langages qui ne gèrent pas les exceptions vérifiées (Comme C++, Python, etc.) d'être certain d'avoir traité toutes les exceptions possibles. En dernier point, touts ces méthodes sont très implicites et il n'est pas évidant de repérer les oublis de gestion d'erreur lors d'une lecture de code. # C++17 et `std::optional` [std::optional](http://en.cppreference.com/w/cpp/utility/optional) vas apparaître avec C++17 et une implementation était disponible dans [Boost.Optional](http://www.boost.org/doc/libs/1_61_0/libs/optional/doc/html/index.html). Il s'agit d'une classe qui peut contenir ou pas une valeur. L'objet peut être testé avec une méthode `has_value` et la valeur peut être récupérée le cas échéant avec la méthode `value`. Vous pouvez dès à présent tester `std::optional` dans le namespace `std::experimental` dans de nombreux compilateurs. Reprenons notre exemple de la fonction rechercher : ```cpp // prototype de la fonction std::optional rechercher(const Collection &, const Item &); // ... std::optional offsetO = rechercher(maCollection, monItem); if(offsetO.has_value()) // ou tout simplement if(offset) { int offset = offsetO.value(); std::cout << "Item trouvé à la position " << offset << std::endl; std::cout << "La valeur de l'item est " << maCollection[offset] << std::endl; } else { std::cout << "Item non trouvé". } ``` Cette approche est très proche de l'approche par flag de réussite, sauf que le flag de réussite est contenu dans l'`optional` en même temps que la valeur de retour. Je trouve personnellement que cela rend l'API de la fonction plus lisible car on distingue bien en argument les entrées et en valeur de retour les sorties pouvant ne pas réussir. Comparons nos anciens prototypes : ```cpp // Méthode par valeur sentinelle, le prototype ne donne aucune // information sur les erreurs possibles, il faudra lire la documentation int rechercher(const Collection &, const Item &); // Méthode par flag de réussite, le prototype nous laisse penser qu'il // y a quelque chose à faire, d'autant que la valeur passée par référence // n'est pas const, ce qui devrait nous alarmer, mais c'est tout de même // flou. bool rechercher(const Collection &, const Item &, int &); // Méthode par exception, le prototype ne liste pas les exceptions. int rechercher(const Collection &, const Item &); // Méthode par optional, le prototype met en évidence la présence d'un // résultat qui peut ne pas être là. std::optional rechercher(const Collection &, const Item &); ``` De plus, on ne peut pas utiliser directement l'`optional` en lieu et place de la valeur contenu, il faut explicitement aller le chercher avec la méthode `value`. Cette méthode est donc attrayante à mes yeux, mais on peut encore se tromper en allant directement chercher la valeur avec `value` sans tester sa présence : ```cpp int offset = rechercher(maCollection, monItem).value(); ``` Heureusement, la fonction `value` lève une exception dans ce cas là, donc tout n'est pas perdu. Une autre limitation concerne la soupe de code qui apparaît lorsque on travaille avec de nombreuses méthodes retournant des optional. Imaginons que nous avons les méthodes `f`, `g` et `h` tel que : ``` std::optional f(Arg); std::optional g(B); D h(C); ``` et que nous voulions construire la méthode `std::optional foo(Arg)`. Notre code risque de ressembler à ceci : ```cpp std::optional foo(Arg arg) { auto fResO = f(arg); if(!fResO) { return std::optional{}; // un optional vide } auto fRes = fResO.value(); auto gResO = g(fRes); if(!gResO) { return std::optional{}; // un optional vide } auto gRes = gResO.value(); return std::optional{h(gRes)); } ``` Et encore, je vous ai épargné la cascade de if imbriqué. Ce code est lourd et fastidieux et potentiellement source d'erreur. Pourrait-on faire mieux ? # Une nouvelle API pour `std::optional` Je reproche deux choses à `std::optional` dans son état actuel : a) La libraire est source d'erreur car on peut appeler la fonction `value` alors que l'optional ne contient rien. Il faut en théorie s'assurer qu'il contient bien une valeur avant, et rien ne force cette vérification. b) Il est difficilement composable, en effet, l'utilisation de plusieurs `std::optional` est souvent source d'une cascade de if imbriqués qui font mal aux crane et qui sont sources d'erreurs. ## Sécurité Nous allons nous attaquer au premier point. Ce que je veux c'est une fonction qui, connaissant un optional, fasse un traitement si celui-ci contient une valeur et pas de traitement sinon. Dit autrement, je veux cette fonction, que j'appelle arbitrairement `optionalApply` : ``` template void optionalApply(std::optional o, const F &f) { if(o) { f(o.value()); } } ``` Simple non ? Elle effectue l'action `f` si l'optional est bon, et pas d'action sinon. `F` est un type templaté qui peut contenir au choix des fonctions, des lambdas, ou des objets fonction. On pourra donc écrire le code de recherche d'un élément dans un tableau de la façon suivante : ```cpp optionalApply(rechercher(maCollection, monItem), [&](int offset) { std::cout << "Item trouvé à la position " << offset << std::endl; std::cout << "La valeur de l'item est " << maCollection[offset] << std::endl; }); ``` En se servant des fonctions lambda de C++11. Je l'avoue, la syntaxe est particulière du fait des lambdas, mais ici on obtient un code ou il est impossible de se tromper. Par contre je n'ai pas géré le cas o ùon veuille faire quelque chose si l'optional ne contient rien. Autant faire une nouvelle fonction, `optionalCase` ```cpp template void optionalCase(std::optional o, const FOk &fOk, const FNotOk &fNotOk) { if(o) { fOk(o.value()); } else { fNotOk(); } } ``` Ce qui nous donne maintenant : ``` optionalCase(rechercher(maCollection, monItem), [&] (int offset) { std::cout << maCollection[offset] << std::endl; }, [] () { std::cout << "Item non trouvé" << std::endl; } ); ``` On pourra débattre de la lisibilité du truc, mais maintenant, en tout cas, on ne peut plus se tromper. ## Composabilité avec `map` Maintenant la question c'est comment chaîner plusieurs fonctions qui peuvent renvoyer des optionals et ce facilement, en limitant la présence de variables intermédiaires (les optionals et leur valeur) et en limitant l'erreur au maximum. Pour cela on va écrire la fonction `map`, variante de `optionalApply` qui contient un type de retour : ``` template std::optional map(std::optional o, const F &f) { if(o) { return std::optional(f(o.value())); } return std::optional(); // un optional vide } ``` Cette fonction nous permet donc d'appliquer n'importe quelle fonction de transformation de `A` vers `B` sur un `std::optional` vers un `std::optional`. Exemple d'utilisation, on veut calculer la longueur du contenu d'un fichier, sachant que on possède une fonction `std::optional readFileContent(const std::string &path)`. ```cpp // Solution sans map std::optional getFileLength(const std::string &path) { auto contentO = readFileContent(path); if(contentO) { auto content = contentO.value(); return std::optional(content.size()); } else { return std::optional(); // optional vide } } // Solution avec map std::optional getFileLength(const std::string &path) { return map(readFileContent(path), [] (std::string content) { return content.size(); }); } ``` La solution avec map est plus simple. Notons que on pourrait faire encore plus simple et plus lisible en exploitant C++14 et C++17 pour la détection automatique des type des lambdas et si `map` était une méthode de optional, on pourrait avoir quelque chose du genre : ```cpp std::optional getFileLength(const std::string &path) { return readFileContent(path).map([] (auto content) { return content.size(); }); } ``` Notons aussi que le lambda n'est pas obligatoire si la fonction à chaîner existe déjà. ## Composabilité avec `bind` Nous ne sommes toujours pas capables de traiter le cas présenté initialement avec les fonctions `f`, `g` et `h` car celles-ci ne sont pas des fonctions de `A` dans `B`, mais de `A` dans `std::optional`, ce qui complexifie un peu les choses, nous allons introduire une nouvelle fonction, `bind` qui gère ce cas. ```cpp template std::optional bind(std::optional o, const F &f) { if(o) { return f(o.value()); } return std::optional(); // un optional vide } ``` On peut donc maintenant traiter notre cas initial, je vous rappelle : ```cpp // sans map ni bind std::optional foo(Arg arg) { auto fResO = f(arg); if(!fResO) { return std::optional{}; // un optional vide } auto fRes = fResO.value(); auto gResO = g(fRes); if(!gResO) { return std::optional{}; // un optional vide } auto gRes = gResO.value(); return std::optional{h(gRes)); } // avec map et bind std::optional foo(Arg arg) { return map(bind(f(arg), g), h) } // avec map et bind en fonction membre des optionals std::optional foo(Arg arg) { return f(arg).bind(g).map(h); } ``` Voila, c'est plus simple et moins source d'erreur. Pour finir, que faire si en bout de chaîne on veux une valeur par défaut si il n'y a pas de valeur dans l'optional, et bien il existe `value_or` qui est déjà dans le standard c++17 qui permet de récupérer la valeur ou une valeur par défaut, ce qui permet d'écrire : ```cpp std::optional fooWithDefault(Arg arg, D def) { return map(bind(f(arg), g), h).value_or(def); } ``` # Conclusion Je vous ai présenté différentes méthodes de gestion des fonctions partielles. Pour ma part, mon coeur balance entre les optionals (avec `map` et `bind`) et les exceptions. Les optionals assurent une sécurité importante du code, une grande composabilité et assurent que l'erreur est traitée. Les exceptions ont moins de garantie, mais il existe des cas où on ne veut pas traiter l'erreur et où il est préférable d'avoir une exception. Ces deux approches sont utilisables en C++, en Python (mais avec moins de garantie à la compilation) et dans de nombreux langages comme Haskell (mon préféré), Rust, Swift, Ocaml, j'en oublie des milliers. L'approche par optional a une place de premier ordre dans certains langages. Le C++ propose depuis son standard C++17 une API d'optional, mais je la trouve limitée et je vous ai proposé des extensions. Les fonctions `map`, `bind`, `optionalCase` et `optionalApply` que je vous ai proposées sont utilisables comme cela, mais pour en faire une vraie librairie il faudrait prendre en compte de nombreux cas particuliers, notamment pour traiter le cas où les objets contenus dans l'optional ne peuvent pas être copiés.

AltStyle によって変換されたページ (->オリジナル) /