Ce petit texte a été écrit avant l'adoption de C++ 17, qui offre std::optional<T>. Je vous recommande donc fortement de préférer std::optional<T>, qui est de qualité industrielle, à une version maison comme celle présentée ci-dessous (évidemment, si vous n'avez pas accès à un compilateur C++ 17 dans l'exercice de vos fonctions, vous pouvez prendre ce qui suit et l'adapter à vos fins).
Il arrive que l'on souhaite représenter une donnée qui peut être présente ou non (qui est peut-être là), sans toutefois vouloir recourir à de l'allocation dynamique de mémoire. Ce besoin est bien connu des tenants de la programmation fonctionnelle, où il arrive souvent que l'on enchaîne des fonctions de telle sorte qu'il devienne important de formaliser le concept de « cette fonction n'a rien retourné » pour que les autres puissent le comprendre sans recourir à du cas par cas. Des algorithmes de la forme suivante :
calcul_complique(x)
y ← f(x)
si (y < 0)
erreur // quoi faire?
retourner y
... soulèvent la question de « quoi faire » dans le cas d'une erreur. Lever une exception peut être convenable dans un appel isolé, mais si calcul_complique() est appelé dans une répétitive sur une vaste gamme de valeurs, il peut être préférable de ne pas interrompre le cacul et de constater a posteriori que le calcul a échoué dans certains cas, de manière repérable.
Les pointeurs résolvent de facto ce problème de « là, pas là » : la convention veut que le pointeur nul représente le concept de « n'est pas là », comme dans l'exemple de code à droite. En pratique, je remplacerais ceci :
... par cela :
... et ce serait plus élégant, sans changer le sens du code. |
|
En généralisant la question aux itérateurs représentant, par paire, un intervalle à demi-ouvert où le début est inclus et la fin est exclue, on arrive au même résultat, au sens où retourner la fin signifie donner au code client un signal que rien n'a été trouvé. |
|
Imaginons toutefois une structure de données exotique et contenant des T. Si nous voulons offrir des services spécialisés pour retrouver un T dans un conteneur, et s'il on doit parfois signaler que ce T n'a pas été trouvé, alors quelques options classiques s'offrent à nous. Nous pourrions retourner un pointeur potentiellement nul. À l'usage, on aurait :
Le risque ici est de donner au code client l'adresse d'un élément dont le conteneur est responsable. Sans ce que ne soit illégal en soi, il s'agit d'une pratique qui demande du doigté. |
|
Nous pourrions retourner un pair<bool,T> dont l'attribut first indiquerait la pertinence de l'attribut second. À l'usage, on aurait :
Ceci ressemble à l'approche suivie par Go, qui permet de multiples types de retour par fonction et utilise une combinaison de valeur de retour et de code d'erreur, tout en permettant de ne pas tenir compte de l'un ou de l'autre. Depuis C++ 17, il est possible d'améliorer le code client dans cet exemple par un recours aux Structured Bindings et aux if-init, comme le montre l'extrait suivant :
|
|
Nous pourrions lever une exception dans le cas où la valeur cherchée ne se trouve pas dans le conteneur. À l'usage, on aurait :
Ceci serait quelque peu suspect, cependant, à moins que le fait de ne pas trouver ce que l'on cherche soit une situation véritablement exceptionnelle. Dans la plupart des cas, ne pas trouver ce qu'on cherche est une situation normale, après tout. |
|
L'option que nous examinerons ici sera celle de définir un type maybe<T> qui aura les caractéristiques suivantes :
L'implémentation proposée va comme suit.
Un maybe<T> sera constitué de deux états :
C'est la présence de ce booléen qui fait que :
Ce que nous allons faire est administrer buf en y plaçant un T au besoin, à l'aide du new positionnel (le Placement New), et nous allons tenir à jour vide pour savoir comment buf est administré. Notez l'importance de respecter, pour buf, l'alignement d'un T si cette zone mémoire est destinée à entreposer un T en pratique. Ceci explique le recours à std::aligned_storage ici. Une écriture alternative et équivalente aurait été :
|
|
Trois services privés seront utilisés pour mentir (de manière contextuelle et contrôlée, évidemment) sur la nature de buf, soit :
Ceci n'est pas à strictement parler nécessaire, car il est peu probable que quelqu'un spécialise l'opérateur new de manière à prendre un char* en tant que deuxième paramètre, mais pourquoi courir le risque?
Techniquement, nous trichons ici, mais je ne veux pas m'étaler sur le sujet |
|
La gestion du concept « être vide » est une clé du bon fonctionnement de notre type maybe<T>, l'autre étant la saine gestion du T encapsulé par le maybe<T> quand celui-ci n'est pas vide. La méthode empty() d'un maybe<T> permettra au code client de vérifier s'il est bel et bien vide. L'opérateur de conversion en bool aura le même effet. J'ai fait du constructeur par défaut et du constructeur acceptant nullptr deux manières distinctes de créer un maybe<T> vide. Ceci permet diverses manières de construire un maybe<T> vide, incluant celles-ci :
L'affectation d'un nullptr à un maybe<T> fait de ce dernier un objet « vide ». |
|
Initialiser un maybe<T> avec un T est acceptable, évidemment. Les constructeurs présentés à droite permettent d'entreposer un T accepté par copie ou par mouvement. Dans les deux cas, le T est entreposé dans buf par voie de new positionnel, et l'attribut vide témoigne du fait que le maybe<T> n'est pas vide. Ces constructeurs sont Exception-Safe; si la construction positionnelle échoue, le maybe<T> n'a pas été construit et aucune ressource ne fuit. L'affectation d'un T à un maybe<T> telle que proposée ici n'est pas Exception-Safe, car si la construction de copie d'un T échoue, nous avons détruit l'original. |
|
La Sainte-Trinité est évidemment importante ici, du fait que nous construisons un type dont la principale raison d'être est « être correctement ». C'est ces fonctions qui sont les plus importantes pour notre type. L'affectation proposée ici n'est pas Exception-Safe, et il serait ardu de l'amener à ce niveau sans avoir recours à de l'allocation dynamique de mémoire. Étant donné que l'un de nos objectifs initiaux était d'éviter ce recours, nous gagnons en vitesse mais nous perdons en sécurité. |
|
L'une des fonctionnalités importantes d'un maybe<T> est l'accès au T qu'il encapsule. Nous offrons ici deux services de conversion explicite en T& et en const T&. Ces services sont somme toute simples. mais les offrir est un atout. |
|
De la même manière, offrir un accès aux membres du T encapsulé par un maybe<T> est une fonctionnalité très utile, que nous obtenons ici par l'implémentation de l'opérateur ->. |
|
Pour faciliter le débogage, nous exposons un opérateur de projection sur un flux quelque peu simpliste. Vous pouvez le raffiner pour tenir compte du cas où m.empty() si cela rejoint vos besoins. |
|
Comment utilise-t-on un maybe<T>? Si nous reprenons l'idée d'une structure de données exotique et contenant des T, exploitée plus haut, et si nous voulons offrir des services spécialisés pour retrouver un T dans un conteneur, devant parfois signaler que ce T n'a pas été trouvé, maybe<T> devient une option intéressante : il se comporte syntaxiquement comme un pointeur, testable pour voir s'il est utilisable, mais ne requiert pas d'allocation dynamique de mémoire.
À l'usage, on aurait :
Le code client obtenu est identique à celui que nous avions avec des pointeurs, mais sans les risques et les coûts qui leur sont associés. |
|
Voilà!
À titre illustratif, voici une autre implémentation possible, avec code de test (simpliste). Je vous laisse explorer les similitudes et les différences avec la proposition précédente :
template <class T>
class maybe {
alignas(T) char buf[sizeof(T)];
T *p {};
public:
maybe() = default;
maybe(const T &obj) {
p = new(static_cast<void*>(buf)) T(obj);
}
maybe(T &&obj) {
p = new(static_cast<void*>(buf)) T(std::move(obj));
}
maybe& operator=(const T &obj) {
if (this == &obj) return *this;
if (p) p->~T();
p = new(static_cast<void*>(buf)) T(obj);
return *this;
}
maybe& operator=(T &&obj) {
if (p) p->~T();
p = new(static_cast<void*>(buf)) T(std::move(obj));
return *this;
}
~maybe() {
if (p) p->~T();
}
maybe(const maybe &autre) : maybe() {
if (autre)
p = new(static_cast<void*>(buf)) T(autre.value());
}
maybe& operator=(const maybe &autre) {
if (this != &autre) {
if (p) p->~T();
p = new(static_cast<void*>(buf)) T(autre.value());
}
return *this;
}
operator bool() const noexcept {
return !!p;
}
T & value() noexcept {
return *p;
}
const T & value() const noexcept {
return *p;
}
};
maybe<int> div_ent(int num, int denom) {
if (!denom) return{};
return{ num / denom };
}
#include <iostream>
using namespace std;
int main() {
if (int n, d; cin >> n >> d) {
if (auto res = div_ent(n, d); res)
cout << res.value() << endl;
}
}
Pas mal, non?
Quelques liens pour enrichir le propos.