Cas d’utilisation des ressources (référence, pointeurs)

Encore un article dans la série des pointeurs intelligents. Le but de cet article est d’explorer les différents cas d’utilisation des pointeurs intelligents. Mon propos n’est pas de donner des directives à suivre, mais d’étudier les garanties qui sont apporter par les différentes approches.

Cet article n’est pas termine, mais cela fait tellement longtemps que je l’ai écrit que je préfère le publier en l’état.

Le contexte d’utilisation

Les pointeurs et références servent à la manipulation de ressources. Le concept de ressource est a prendre au sens large : tout ce que l’on a besoin d’acquérir pour être utilisé et que l’on doit libérer lorsqu’on en a plus besoin. Une littérale chaîne de caractères n’est pas une ressource, par contre, une chaîne de caractères de type std::string est une ressource. Un fichier, une connexion réseau, une interface graphique, tout cela sont des ressources.

La bibliothèque standard du C++ fournit de nombreux outils pour gérer les ressources de façon sécurisée. Le type std::string est un bon exemple : il gère automatiquement la mémoire et évite d’avoir des fuites mémoire ou d’utiliser de la mémoire non allouée. Tout cela est transparent pour l’utilisateur. On peut également citer les fichiers (iostream), les collections (std::array, std::vector, etc.), etc.

Ces classes sont un peu particulières. Elles sont responsables de la gestion de leur ressource et de fournir les services attendus par les utilisateurs (ajouter des éléments dans une collection, connaitre la taille, réaliser des copies, etc.)

J’entends déjà des voix crier au scandale pour ce non respect flagrant du principe de responsabilité unique. Pas d’inquiétude, ces classes utilisent une classe interne qui est responsable uniquement de la gestion des ressources. Tout va bien. Voir cet article pour les détails : Exception Safety: Concepts and Techniques .

Avoir des classes dédiées pour la gestion des ressources est assez courante, c’est le meilleur moyen d’avoir du code sécurisé dans un langage à exceptions comme le C++ (voir l’article sur Ressource Acquisition Is Initialization). Pour simplifier le travail des développeurs, le C++11 a ajouté dans la bibliothèque standard des outils destinés uniquement à la gestion des ressources : les pointeurs intelligents.

(Si vous n’utilisez pas le C++11, pas d’inquiétude. Ce concept de pointeurs intelligents est antérieur au C++11, vous pouvez utiliser une bibliothèque comme Boost ou implémenter vos propres pointeurs intelligents. Par contre, utiliser directement des ressources brutes est généralement une source d’erreur importantes. A vos risques et périls).

Les pointeurs intelligents fournies par la bibliothèque standard sont :

Les garanties recherchées

Lorsque l’on manipule des ressources, il est nécessaire de respecter certaines contraintes. Vous pouvez toujours essayer de lire un fichier qui n’existe pas ou essayer de vous connecter à un site en ligne sans connexion internet, mais cela fonctionne moins bien…

Il faut donc avoir certaines garanties pour utiliser des ressources de façon sécurisée :

  • que la ressource a été acquise avant de l’utiliser ;
  • que la ressource ne soit pas libérée pendant qu’on l’utilise (en particulier en multi-threads) ;
  • que la ressource soit libérée lorsque l’on en a plus besoin.

La méthode la plus simple pour gérer une ressource est d’utiliser la portée des variables, en particulier la destruction automatique des variables locales à la fin d’un bloc. Par exemple :

{
    Ressource r;
    r.doSomething();
} // la ressource est libérée ici

Dans ce code, il est impossible d’utiliser r avant son initialisation (si on essaie d’appeler r avant de l’avoir déclaré, on obtient une erreur de compilation), r est toujours valide après sa déclaration et r sera automatiquement libéré à la fin. Le monde parfait.

On peut voir ici le lien étroit entre gestion des ressources et le RAII. Le RAII implique que les ressources utilisées en interne par r doivent être acquises dans le constructeur de Ressource, qu’elles doivent être libérées dans le destructeur de Ressource, et que le constructeur doit placer l’objet dans un état valide. La portée des variables permet de s’occuper de la gestion de r, le RAII garantie que tout ce qui se passe en interne dans r se déroule correctement.

Une erreur de conception classique est d’utiliser une fonction init et/ou une fonction release, ce qui casse les garanties apportées par le RAII.

Les pointeurs nus. Ou pas

Les pointeurs nus n’apportent aucune garantie. Rien du tout. C’est aux développeurs de s’arranger pour fournir ces garanties. L’expérience montre que ces garanties sont régulièrement mal assurées lorsqu’on utilise des pointeurs nus.

Avec l’augmentation de la taille des projets, de la taille des équipes, de la diversité de formation des intervenants, il est complètement illusoire de s’imaginer que l’on peut apporter ces garanties dans la majorité des projets si on utilise des pointeurs nus.

(A la rigueur, on peut imaginer certains projets ou sous-projets extrêmement critiques et qui recevront l’attention nécessaire pour minimiser ces risques. Mais cette « attention » nécessite beaucoup de travail, des équipes compétentes et des procédures et outils de validation adaptés, ce qui a un coût. Cela n’est pas viable sur la majorité des projets).

Allocation

Voyons pourquoi les pointeurs nus n’apportent pas les garanties attendues. Pour l’allocation :

Ressource* r = createRessource();
// ...
assert(???);
r->doSomething();

Lorsque l’on reçoit une ressource gérée par un pointeur, on doit pouvoir faire un test pour savoir si la ressource est valide ou non. Un pointeur nu ne permet pas de faire cela.

Pour contourner ce problème, il est possible d’affecter une valeur choisie d’avance comme étant invalide et de l’utiliser pour tester un pointeur nu. Cette valeur est nullptr (0 ou NULL avant le C++11).

Ressource* r = createRessource();
// ...
assert(r); // équivalent à assert(r == nullptr);
r->doSomething();

Mais pour autant, le pointeur n’apporte pas de garantie. Cela permet simplement au développeur de transmettre une information via le pointeur. Mais si le développeur « oublie » de transmettre cette information, alors cette validation n’est pas possible.

Les cas les plus courant, c’est un pointeur invalide non initialisé à nullptr ou une ressource libérée mais dont le pointeur n’est pas remis à nullptr ensuite. (Pour ceux qui se disent « moi, je ne ferais jamais ce genre d’erreurs grossières », sachez que ce problème est tellement courant qu’il a un nom, on parle de dangling pointer. Et tout le monde fini un jour ou l’autre par faire cette erreur).

Utilisation

La seconde garantie est d’éviter la libération lorsque la ressource est utilisée. Cela peut arriver dans un contexte multi-thread ou non. Hors multi-threads, cela est dû à des pointeurs dans des classes ou des globales

Ressource* r = createRessource();
RessourceHandler handler(r);
// ...
handler.useRessource(); // problème ?

Dans un contexte multi-threads, c’est encore plus compliqué, puisque même si on possède un moyen de vérifier la validé d’une ressource, celle ci peux être libérer quand même entre le moment de la vérification et le moment de l’utilisation, même ces lignes se suivent :

Ressource* r = createRessource();
std::tread t(r);
if(r) r->doSomething(); // problème ?

Le fait de vérifier si r est valide avant de l’utiliser n’est pas suffisant. Le test (if) et l’utilisation (doSomething) ne sont pas réalisé en même temps, il y a un petit délai, infime, mais le thread peut libérer la ressource pendant ce temps là. Et donc rendre invalide la ressource lors de son utilisation.

(Cette situation est en fait probablement la pire. On pourrait croire que comme la probabilité d’apparition de ce problème est faible, on ne le rencontrera jamais. En pratique, imaginez qu’un ce bug apparaît une fois sur un milliard quand cette ligne de code est appelé. Maintenant, imaginer que cette ligne de code est appelé mille fois par jour (donc une fois tous les trois minutes, c’est pas énorme), que votre programme est utilisé par des milliers de personnes (ce qui n’est pas énorme non plus). Cela veut donc dire que sur une année, un tiers de vos clients auront reproduit de bug. Si celui-ci provoque le plantage de l’application et la perte d’une quantité importante de données, c’est un bug critique, il faut le corriger. Maintenant, imaginez combien de temps il vous faudra pour reproduire ce bug sur votre propre ordinateur, pour pouvoir le corriger ? C’est typiquement le genre de bug qui est très difficile à corriger, car difficilement reproductible).

Libération

Le dernier problème est ce que l’on appelle une fuite mémoire (memory leak). Tous les codes précédant avec les pointeurs nus présentent ce problème, puisque à aucun moment, delete est appelé. Et lorsque l’on a plusieurs utilisateurs d’une ressource, plusieurs chemins d’exécution possible, il est parfois très compliqué de savoir quand et qui doit appeler delete.

Passage par valeur (copie)

Le passage d’argument par valeur réalise une copie, ce qui apporte un maximum de garantie sur les ressources. En travaillant sur une copie locale, une fonction devient l’unique propriétaire d’une ressource, ce qui évite les problèmes d’accès concurrents (multi-threads), de libration de la mémoire (la ressource est une variable locale, elle sera donc libérée a la fin de la fonction. Et on a la garantie qu’elle sera libérée qu’a ce moment la et pas pendant qu’elle est utilisée).

Cela peut sembler être un approche un peu trop gourmande en termes de ressources – surtout pour des développeurs C++ – mais c’est en fait l’approche utilisée par de nombreux langages (en particulier les langages fonctionnels). Dans ceux-ci, les objets ne sont pas mutable : par exemple, lorsque l’on ajoute un élément dans une liste, cela créé en fait une nouvelle liste contenant cet élément. C’est également comme cela que fonctionne la méta-programmation en C++ – voir par exemple boost.mpl.list. Bref, ce n’est pas forcement une approche à négliger, en particulier en situation de multi-threads.

Le second probleme avec le passage par valeur est

Critères à prendre en compte

Propriétaire unique ou multiple ?

Le propriétaire d’une ressource (ownership) est celui qui a le droit de détruire cette ressource. Un observateur est une classe ou fonction qui conserve un lien vers une ressource, mais n’est pas responsable de sa durée de vie. Il doit donc vérifier qu’une ressource est encore disponible avant de l’utiliser. Cependant, il peut devenir propriétaire temporaire de cette ressource, pendant la période où il utilise cette ressource, pour interdire la destruction de cette ressource (il est en quelque sorte un propriétaire temporaire, à distinguer du propriétaire permanent, qui possède réellement l’ownership).

Avoir plusieurs propriétaires ne permet généralement pas de contrôler correctement la durée des ressources, il est donc préférable de n’avoir qu’un seul propriétaire. La classe unique_ptr est donc souvent recommandée pour cette raison.

Pour autant, il faut considérer les autres approches permettant d’avoir un propriétaire unique. Ainsi, il est possible qu’une ressource soit gérée par un shared_ptr unique (ownership), mais que les observateurs ne possède qu’une référence sur ce shared_ptr ou un weak_ptr.

Transfert de propriété

Lorsqu’une ressource possède plusieurs propriétaires, il est assez facile de partager cette ressource avec un nouveau propriétaire.

Lorsqu’une ressource est gérée par un seul propriétaire (ce que devrait être le cas par défaut), elle pourra posséder plusieurs observateurs (qui sont potentiellement de futurs propriétaires temporaires).

Mais il est également possible que le propriétaire d’une ressource n’ait plus besoin de celle-ci. Il peut dans ce cas transmettre la propriété de la ressource à un autre propriétaire permanent (ownership).

Polymorphisme d’héritage

Lorsqu’une classe entre dans une hiérarchie par héritage, les objets instanciés peuvent être polymorphiques. Pour cela, il faut que ces objets soient manipulées via des références ou des pointeurs.

Ressource optionnelle

Dans certaines situation, il peut être intéressant d’avoir une variable qui peut contenir une ressource ou ne rien contenir. C’est même un des points qui différentié les références et les pointeurs : une référence ne peut jamais être nulle, il n’est donc pas nécessaire de la vérifier avant utilisation ; un pointeur peut être nul (nullptr), il faut donc systématiquement vérifier un pointeur avant de l’utiliser.

Dans un contexte où l’on s’attend à ce qu’un pointeur peut être nul, il convient d’utiliser un test. Sinon, une assertion.

void f(Ressource* p) {
    assert(p); // p ne devrait pas être nullptr
    p->doSomething();
}

void g(Ressource *p) {
    if(p) { // p peut être nullptr
        f(p);
    }
}

Remarque : pour le moment, seul les pointeurs permettent d’avoir des ressources optionnelles. Dans le C++17, il sera également possible d’utiliser la classe std::optional.

Attribuer une nouvelle ressource

Une référence est par nature quelque chose de constant : lorsque l’on attribue une ressource à une référence (lors de l’initialisation), il n’est plus possible ensuite d’attribuer une nouvelle ressource à cette référence.

Les pointeurs sont au contraire mutable et il est possible de changer la ressource pointée par un pointeur.

En pratique, la possibilité d’avoir une ressource optionnelle et la possibilité d’attribuer une nouvelle ressource sont des caractéristiques des pointeurs et non des références. Ces deux caractéristiques seront traitées de la même façon.

Invalidation d’une ressource

Lorsque l’on est pas propriétaire d’une ressource, celle-ci peut être libérée alors qu’on en aura besoin par la suite. Il convient donc de tester si cette ressource est valide avant utilisation. Le cas le plus courant est un dangling pointer, c’est à dire un pointeur qui pointe sur une ressource qui a été libérée, mais dont le pointeur n’a pas été remis à nullptr.

Travailler en multi-threads

Pour terminer, lorsque l’on travaille avec plusieurs threads, il faut porter une attention particulière, comme cela a été expliqué pour les pointeurs nus. Cette situation est très compliquée, puisque difficile à reproduire et donc difficile à corriger.

Analyse des différentes situations

L’ensemble des cas possibles est résumé dans le tableau suivant. Les critères « modifiable » et « optionnel » ont été fusionné, du fait qu’ils correspondent en pratique aux mêmes cas d’utilisation.

Dans ce tableau, « & » représente une référence, constante ou non.

J’ai exclue de cette analyse les « assemblages » complexes de pointeurs intelligents, comme les unique_ptr<shared_ptr<T>> ou autres équivalents.

reference

Dans tous les cas, il sera possible d’utiliser la copie (passage par valeur dans les fonctions), de façon sécurisée. Une copie signifie que celui qui reçoit la ressource devient propriétaire, qu’il est unique et que le paramètre de fonction sera détruit à la fin de celle-ci. La limite (que l’on atteint très rapidement) est lorsque la ressource est coûteuse en termes de mémoire et de temps de copie ou lorsque l’on souhaite que la fonction appelante récupère la ressource après modification.

Cas 1 : passage simple de ressources dans une fonction

Le premier cas correspond à un appel simple de fonction, avec des paramètres passés par valeur (copie) ou par référence, avec un objet non polymorphique créé sur la Pile. Le code appelant est responsable de la ressource et continue de l’être après l’appel de la fonction.

L’utilisation d’un pointeur ne se justifie a priori pas, sauf si la ressource est trop importante et dépasse les capacités de la Pile.

void useRessource(Ressource) { ... } // copie de r
// ou
void useRessource(Ressource &) { ... } // r est modifié
// ou
void useRessource(Ressource const&) // r n'est pas modifié
{ ... } 
Ressource r;
useRessource(r);
... // continue to use r

Cas 2 : transfert de ressource dans une fonction

Le second cas se distingue du premier du fait que le code appelant n’a pas besoin de conserver la ressource après l’avoir transmise à la fonction. C’est par exemple le cas pour une ressource temporaire (rvalue). C’est le cas classique d’utilisation de rvalue reference.

Il est également possible d’utiliser une référence constante, mais il faudra faire attention aux copies.

void useRessource(Ressource &&) { ... }

useRessource(Ressource());
// on ne conserve pas la ressource

 Cas 3 et 4 : objet polymorphiques

Lorsque le polymorphisme d’héritage est utilisé, il est nécessaire de passer par référence ou pointeur. Puisque les cas 3 et 4 correspondent à des ressources qui ne peuvent pas être invalides ou modifiées, une référence apporte une garantie supplémentaire au pointeurs (elle ne peut pas être nulle, il n’est donc pas nécessaire de la vérifier avant utilisation) et sera donc privilégiée.

 

Si la ressource est temporaire, il sera nécessaire d’utiliser une référence constante (qui accepte aussi bien les lvalues que les rvalues). Si la référence est une lvalue, on pourra utiliser une référence ou une référence constante, on fonction de si l’on souhaite récupérer les modifications faites dans la ressource.

(Point a confirmer) Les rvalue references ne

void foo(Ressource &) { ... } // r est modifié
// ou
void bar(Ressource const&) { ... } // r n'est pas modifié

Ressource r;
foo(r);           // ok
bar(r);           // ok
foo(Ressource()); // erreur
bar(Ressource()); // ok

Cas 5 à 8 : ressources optionnelles ou modifiables

Dans le cas ou l’on souhaite que la ressource soit optionnelle ou modifiable, cela implique que l’on doit utiliser des pointeurs (ou std::optional avec le C++17).

Dans le cas ou le « lien » n’est pas « invalidable », il n’est pas nécessaire de recourir aux shared_ptr/weak_ptr. Si la propriété est transférée, on peut directement utiliser un unique_ptr avec déplacement. Sinon, on peut utiliser une référence sur un unique_ptr.

// cas 5 et 7
void foo(unique_ptr<Ressource> &) { ... } 
void foo(unique_ptr<Ressource> const&) { ... }

void bar() {
    auto r = make_unique<Ressource>();
    foo(r);
    ... // continue to use r
}
// cas 6 et 8
void foo(unique_ptr<Ressource>) { ... } 

void bar() {
    auto r = make_unique<Ressource>();
    foo(std::move(r));
    ... // r est invalide
}

Cas 9 a 16

Par exemple, la ressource est dans un vector et l’on partage une référence ou un itérateur sur cette ressource. Ou l’on conserve un lien vers une telle ressource dans un classe ou en globale.

  • Solution unique_ptr + raw ptr => si ressource est libérée, raw pas mis à jour
  • Solution unique_ptr + unique_ptr& => référence peut être invalide
class RessourceUser {
public:
    RessourceUser(weak_ptr<Ressource>);
private:
    weak_ptr<Ressource> m_ressource;
};

auto r = make_shared<Ressource>();
RessourceUser u { r };
... // on ne sait pas quand r sera utilisé dans user

Cas 17, 19, 21 et 23

Si pas transfert de propriété, il faut garantir que la ressource n’est pas invalide dans un autre thread. Seul solution est shared_ptr/weak_ptr.

Cas 18

Avec transfert de propriété, possible d’utiliser simple un move.

Cas 20, 22 et 24

Avec transfert d’une objet qui peut être polymorphique ou optionnel/modifiable.

Cas 25 a 32

Idem que 9 a 16

Advertisements

Laisser un commentaire

Entrez vos coordonnées ci-dessous ou cliquez sur une icône pour vous connecter:

Logo WordPress.com

Vous commentez à l'aide de votre compte WordPress.com. Déconnexion / Changer )

Image Twitter

Vous commentez à l'aide de votre compte Twitter. Déconnexion / Changer )

Photo Facebook

Vous commentez à l'aide de votre compte Facebook. Déconnexion / Changer )

Photo Google+

Vous commentez à l'aide de votre compte Google+. Déconnexion / Changer )

Connexion à %s