L’arrivée du C++17 – std::optional

Bonjour à tous ! L’article d’aujourd’hui traite d’un nouveau venu dans la bibliothèque standard avec C++17 : std::optional.

Introduction

C++17 a introduit le type std::optional<T>, qui permet de définir d’associer la notion de valeur “absente” à un type. optional est grandement inspiré de son équivalent dans Boost.

Le cas d’usage le plus évident vient des fonctions qui peuvent ne pas retourner l’objet demandé, par exemple en cas d’erreur :

Dans la fonction foo, le cas d’erreur est géré par une valeur spécifique, ici -1, qui indique a l’appelant qu’un problème est survenu. C’est une technique commune qui possède toutefois le défaut de demander à ce qu’une valeur soit choisie comme indicateur d’erreur. La fonction bar utilise quant à elle un booléen pour indiquer le problème, ce qui évite d’avoir à se passer d’une valeur dans le type de retour.

C’est là qu’intervient std::optional :

std::optional a été pensé précisément pour ce genre de cas où un type doit avoir une valeur supplémentaire représentant l’absence de valeur. Il peut s’agir d’un champ d’une classe qui n’est pas toujours présent (possiblement dans le cas d’initialisation paresseuse) ou alors lorsqu’une fonction ne peut pas toujours retourner une valeur (par exemple une lecture de base de données).

Voyons ensemble ce qu’il a dans le ventre.

Manipuler std::optional

Dans cette section, nous allons voir les différentes opérations proposées par la classe std::optional : création, test de valeur, récupération de valeur, comparaisons etc.

Création d’un std::optional

Tout comme std::any, std::optional propose la construction in-place, ainsi qu’un bon nombre de méthodes de création différentes :

Pour plus de détails sur la construction in-place, je vous renvois à l’article précédent sur std::any et à cette page de cppreferences.com. std::make_optional permet également d’utiliser la construction in-place : auto i = std::make_optional(std::in_place_type<MyType>, "AX0938", 1.0, 1.0, 345);.

Un std::optional est copiable (si le type paramétré l’est aussi bien entendu).

Tester si un optional a une valeur

std::optional propose la fonction membre has_value :

Tout optional est également implicitement convertible en bool :

Notez qu’il est également possible d’utiliser la comparaison avec std::nullopt.

Récupérer la valeur d’un std::optional

optional<T>  n’est pas directement convertible en T mais propose les fonctions membres value_or et value et surcharge les opérateurs *  et ->. La première permet de récupérer la valeur contenue ou la valeur spécifiée dans le cas ou l’objet n’a pas de valeur :

value ne nécessite pas de valeur par défaut mais jette une exception de type std::bad_optional_access si l’objet n’a pas de valeur :

Enfin, les opérateurs * et -> permettent la manipulation de la même manière qu’un pointeur :

Si l’objet ne contient aucune valeur, il s’agit d’un undefined behavior.

Divers

La fonction membre reset permet de supprimer la valeur actuelle (elle est équivalent à assigner std::nullopt) :

emplace permet de remplacer l’objet courant en construisant un nouvel objet à partir des paramètres :

Notez que emplace, reset, ou toute affectation, appelle le constructeur de l’objet actuel s’il existe.

Enfin, les opérateurs de comparaison ==, !=, <, >, <= et >= sont surchargés de manière à permettre directement la comparaison des objets contenus. Un optional vide est considéré comme inférieur à tout objet :

En plus de cela, les fonctions std::swap et std::hash ont aussi une spécialisation pour std::optional .

Performance et cas d’usage

En pratique, std::optional<T> va ajouter une valeur nulle ( std::nullopt ), à tout T. On peut légitimement se poser la question des performances, puisque cette “extension de la plage de valeur de T” va le plus souvent se traduire par le coût d’au minimum un byte.

On peut effectivement imaginer des spécialisations templates permettant d’optimiser la chose, par exemple en resservant une valeur spécifique pour certains types. Mais dans le cas général, utiliser cette valeur la rend inutilisable pour autre chose que l’absence de valeur et on peut donc imaginer que ça va à l’encontre de l’objectif pour lequel cette classe a été créée.

En pratique, le blog de Bartek rappelle que l’alignement fait que ce byte supplémentaire induit la plupart du temps un overhead équivalent à sizeof(T).

On peut également se poser la question de std::optional<std::reference_wrapper<T>> en comparaison de T*. En effet, les deux ont les mêmes propriétés lorsqu’on parle d’un pointeur sans ownership : possibilité de modifier l’objet pointé et existence d’un élément nul ( nullopt dans un cas et nullptr dans l’autre).

A mon avis, si les coding rules imposent qu’un pointeur nul n’ai aucune responsabilité sur l’objet pointé, autant directement utiliser T*. std::optional<std::reference_wrapper<T>> a l’avantage de forcer cette règle implicitement. Je vous propose ce thread pour plus d’informations sur le sujet.

Pour ma part, je vous recommande de considérer std::optional<T> dès lors que le concept de valeur nulle (ou plutôt de valeur “absente”) est adapté à vos besoin, sans trop vous préoccuper des risques de surcoût mémoire. Souvenez vous : premature optimization is evil. La majorité du temps, cette empreinte mémoire supplémentaire n’aura pas ou peu d’impact sur le programme et l’expressivité d’outils tels que std::optional mérite largement ce surcoût.

Si toutefois la consommation mémoire est effectivement un problème, rien ne vous empêche d’écrire votre propre version d' optional adaptée à vos besoin, le moment venu.

Cet article touche à sa fin, il me reste donc à vous remercier pour votre lecture et à vous dire à la prochaine !

Références et ressources

%d bloggers like this: