10 oct. 2011

Dangers et pièges des systèmes de suivi des références

On conçoit aisément qu'il est très difficile de bien concevoir une application ou une libraire : cela demande des connaissances pointues en design ainsi qu'une imagination débordante. Par contre, il est très facile de mal faire : il suffit de se laisser appeler par les sirènes des différents pièges qui, nonchalamment, s'installent sur notre route.

Ce billet traite de l'un de ces pièges : la notion de propriété des objets dans un programme.

Je sais, vous avez déjà lu cette introduction récemment...

Plutôt que d'aborder le sujet sous la forme problème / solution, pas forcément adaptée au sujet, je vais le présenter sous une forme question / réponse qui vont articuler une discussion. N'hésitez pas à me dire si la forme est convaincante (sans même parler du fond ; ce n'est pas parce que la forme est convaincante que le fond l'est, est vice-versa).

Bon, avant de commencer réellement, posons d'abord la question 0.

Question 0 : qu'est-ce qu'un système de suivi des références ?

Il s'agit d'un système implémenté sous la forme d'une librairie dans certains langages ou directement intégré dans le langage pour d'autre qui permet à la machine s'assurer le suivi des références sur des instances d'objet de manière automatique, pour - par exemple - en faire la collecte lorsqu'aucune référence n'est plus utilisée. La plupart des implémentations existantes sont basées sur un principe de comptage des références actives. On peut citer par exemple :

  • C++, avec notablement la librairie de pointeurs intelligents de Boost, intégrée sous une forme légèrement modifiée au TR1, et réintégrée dans le nom d'espace standard (std) dans la norme C++11.
  • Le suivi des références de C# ou Java, qui permettent l'utilisation d'un ramasse-miettes.

Toutes les implémentations existantes ne sont pas de qualité équivalentes. L'intégration directe au langage offre une facilité d'utilisation qui ne peut être égalée par une implémentation sous la forme d'une librairie. Dans le cas du C++, l'implémentation proposée par les librairies standard offrent au regard du développeur une certaine élégance qu'il convient pourtant de mettre de coté afin de vois si, vraiment, son utilisation a un intérêt quelconque[1].

Question 1: qu'est-ce qui justifie l'utilisation d'un système de suivi des références ?

Très franchement, pas grand chose. L'utilisation d'un système de suivi des références est justifiée uniquement par deux besoins :

  • Un code plus simple vis-à-vis de la gestion de la mémoire.

Cette première raison est tout à fait légitime : une fois supprimé tout le code qui fait référence à la gestion de la mémoire, on supprime du même coup plusieurs bogues potentiels (rappelez vous que notre métier, c'est principalement de créer des bogues, ce qui nous permet entre autre de justifier de notre immense talent lorsque vient le temps de les corriger (et donc, par la même occasion, d'en créer d'autres)). Le transfert du contrôle de la mémoire au compilateur[2] permet l'écriture d'un code plus simple, donc à priori plus résistant. Le prix à payer est double : une perte de contrôle sur le code lui-même - mais c'est voulu, puisqu'on le transfert sciemment au compilateur -, et un risque accru en cas de défaillance - risque contrebalancé par le fait que toute la gestion de la mémoire est localisée dans un seul sous-système largement éprouvé.

  • Le besoin de ne pas se soucier des questions de propriété des objets.

Si la première raison n'a strictement aucun impact sur l'architecture logicielle d'une application, on ne peut pas en dire autant de celle-ci. Mais avant d'aller plus loin, qu'est-ce que j'entends par "la question de la propriété des objets" ?

Dans les langages (orientés) objets, il existe trois relations fondamentales entre les classes:

  • la relation is-a, qui permet de définir un lien d'héritage entre deux classes ;
  • la relation has-a, qui permet d'établir un lien de composition entre deux classes.
  • la relation uses-a, qui n'a de sens qu'au niveau dynamique et qui permet de définir les collaborateurs d'une classe.

La relation is-a a été étudiée par Barbara Liskov, qui en a tiré le principe du même nom. Un exemple parmi d'autres : le fauteuil de bureau dans lequel je suis assis [3] est fondamentalement une chaise. La relation has-a est utilisée lorsqu'un lien fort de composition existe entre deux objets (par exemple, la chaise a un dossier). La relation "uses-a" est plus souple, puisqu'elle fait référence au fait qu'un objet utilise un autre objet pour effectuer une tâche spécifique - si un jour je me considère comme un objet[4], alors j'utilise mon fauteuil, m'appuyant avec délice sur le dossier, lorsque je m'assois devant mon ordinateur.

Il existe deux chemins différents pour implémenter ces relations : tout ce que j'utilise m'appartiens, qui s'oppose dans une certaine mesure à j'utilise des objets qui ne m'appartiennent pas nécessairement. Dans ce dernier cas, on souhaite respecter la notion de propriété : le dossier appartient à ma chaise, et je l'utilise en utilisant ma chaise. C'est une vision intuitive du monde réel. Dans le premier cas, on se moque de la notion de propriété : puisque j'utilise le dossier de ma chaise, alors celui-ci m'appartiens. Mais puisque qu'il est sur la chaise et non pas collé à mon dos, je ne peux posséder qu'une référence sur lui (d'où les shared_ptr<> en C++). Fort heureusement, l'extrémisme de la proposition n'échappe à personne, si bien que bien souvent, on utilisera une voix médiane : j'aime bien posséder les objets des autres, mais point trop n'en faut quand même. Ce qui est tout aussi mal, on le verra par la suite.

Question 2: à quel(s) risque(s) potentiel(s) est-ce que je m'expose en utilisant un système de suivi des références ?

On rentre dans le dur du sujet.

Si le seul besoin est d'avoir un code plus simple, la question ne se pose pas en ces termes. La véritable question est en fait : pourquoi est-ce que j'utilises un langage qui me force à avoir un contrôle complet sur la mémoire alors que je ne le souhaites pas (par exemple, vous écrivez un programme en C++ alors que vous ne souhaitez pas avoir à gérer la mémoire). Dans ce cas, la bonne réponse au problème est d'utiliser un langage dit "de plus haut niveau"[5], par exemple C# ou Java.

Dans le second cas - celui où c'est la question de la propriété qu'on veut éluder -, les problèmes sont bien plus nombreux, et certainement plus retord.

  • Destruction asynchrone : tant que j'ai quelque part une référence sur un objet, celui-ci existe. Si la raison d'exister de l'objet disparaît à un moment donné, mon objet va continuer d'exister, ce qui risque de provoquer de graves dysfonctionnements.
  • Destruction incomplète : un objet A est possédé par plusieurs autres objets B ainsi que par un objet C, inconnu de B. L'un de ces B décide qu'il faut détruire l'objet A, et parvient à prévenir tous les autres objets B. Mais ne connaissant pas l'objet C, il ne peut pas le prévenir, et l'objet A reste donc vivant. L'objet C va donc continuer à l'utiliser. Le résultat final est très variable : cela peut aller de la fuite de ressource jusqu'au crash complet du système, selon la gravité de la situation. Dans la plupart des cas, on va se retrouver avec du code exécuté qui va faire des choses qui ne devraient plus être faites (par exemple répondre à une entrée utilisateur alors même qu'on a quitter le mode où une telle entrée utilisateur devait être prise en compte).
  • Référence circulaire : A tient une référence sur B, qui tient une référence sur A. Cette référence n'est pas nécessairement directe, ce qui peut rendre sa détection difficile, voire impossible. Dans tous les cas, on se retrouve dans une situation très gênante où l'on croit avoir détruit A, ce qui es censé avoir détruit B, mais puisque B garde une référence sur A, A n'est pas détruit, donc B non plus. On a une perte de ressource, ce qui peut - dans certains contextes - être un problème critique. Qui plus est, étant donné que la notion de propriété n'est pas claire dans l'architecture, il sera très difficile de trouver une parade à ce problème, car il sera très difficile de savoir où placer le code nécessaire à sa correction. Un cas typique, bien que relativement simple à corriger dans le cas général, est l'utilisation du motif de conception observeur, où l'observeur espionne plusieurs objets, et un de ces objets doit être détruit.
  • Contamination : si vous éludez à un endroit la question de la propriété des objets, alors vous vous rendrez vite compte que vous allez devoir le faire en beaucoup d'autres endroits, jusqu'à la totalité de votre application dans certaines situations catastrophiques.
  • Solution incomplète : dans la plupart des implémentations, il existe des cas particuliers qui nécessite une gestion explicite du système de suivi des références. L'irruption dans le code de ces quelques lignes nécessaires peut provoquer des problèmes très importants (est-ce que le traitement est atomique ? Que se passe-t-il s'il échoue ?), sans compter que c'est une source de bugs particulièrement retors qui peuvent nécessiter un long travail d'expertise avant de pouvoir être corrigé (lorsqu'ils peuvent être corrigés).

Ce point est motivé par quelques lectures récentes que j'ai peu faire ici et là sur le net, qui se résument peu ou prou à "moi, je trouve les smart pointers tellement bien que j'ai décidé d'en mettre partout". C'est votre choix, je le respecte. Je m'autorise tout de même à vous dire que c'est irresponsable et dangereux, et que ces smart pointers étant infiniment moins intelligents que vous, je vous suggère de les éliminer au mieux de vos possibilités plutôt que de les laisser se multiplier sans surveillance. Je rappelle en tout état de cause que la célèbre phrase de Butler Lampson (We can solve any problem, by introducing an extra level of indirection[6]) est avant tout sarcastique : elle signifie entre autres que c'est le "niveau d'indirection" (par exemple un stagiaire) qui va résoudre le problème, pas nous.

Les conséquences de ces problèmes sont elles aussi nombreuses, et on trouve parmi celles-ci (on retrouve de vieux amis dont j'ai déjà eu l'occasion de parler) :

  • Fragilité du code : si le propriétaire d'une instance d'objet n'est pas identifié, alors toute action sur cet objet peut avoir des répercussions qui peuvent être difficiles à prévoir. Le code peut ne pas fonctionner correctement dans des zones qui ne sont à priori pas liée à la modification effectuée.
  • Immobilité du code : sans savoir ce qui se passe à un instant donné - c'est à dire qui est responsable de quoi, finalement - modifier le code devient une gageure. Il est préférable d'éviter d'y toucher.

Petit à petit, une architecture qui n'est pas basée sur le principe de l'unicité du propriétaire d'une instance pourrit, invariablement et inévitablement.

Il existe d'autres problèmes liés à l'utilisation de pointeurs intelligents en C++ dans un environnement multithreadé (ces problèmes sont bien souvent réglés à la base dans les langages supportant un système de suivi des références natif). Des primitives de synchronisations sont nécessaires[7] pour permettre un comptage exact des références, ce qui se traduit par des attentes inattendues ou un code moins efficace dans le cas où le programme n'est pas multithreadé (boost, par exemple, utilise un spinlock pour implémenter une pseudo-fonction de comparaison/échange atomique).

Question 3: mais pourquoi en dire du mal, alors que c'est à la base de tant d'autres langages ? (Java, C#, ...)

Outre l'erreur de logique implicite dans la question (ce n'est pas parce que ces langages le font que c'est une bonne chose ; en réthorique, vous utilisez un argument d'autorité), je ne peux que reconnaître la véracité de ce point : plusieurs langages de nouvelle génération utilisent de manière qui vous est cachée un système de suivi des références - qui va généralement de pair avec l'existence dans ces langages d'un "ramasse miette".

Le but n'est toutefois pas de vous permettre de jouer avec la notion de propriété. Que vous perdiez celle-ci de vue, et vous allez voir rapidement que ces langages offrent d'énormes et intéressantes possibilités de créer des applications dont le fonctionnement n'est ni démontrable, ni assuré. Le but avoué de ces technologies est de vous éloigner au maximum des questions de gestion de la mémoire (c'est à dire d'éliminer une problématique liée au code), pas de vous permettre d'oublier quels sont les vos obligations en matière de gestion de la propriété (qui est une problématique liée à l'architecture logicielle).

Question 4: OK, j'ai compris. Mais bon, il arrive des cas où je ne suis pas maître de la durée de vie d'un objet...

C'est justement la le noeud du problème. Vous n'en êtes pas maître parce que vous n'arrivez pas à décider qui en est le propriétaire. La détermination d'une telle chose peut demander d'avoir une vue plus abstraite sur le système qu'on est en train de créer, mais une telle vue rendra de manière systématique l'architecture plus ouverte, plus fiable, et plus élégante - ce n'est donc pas un mal. Dans le cas où il est difficile de voir cette notion de propriété, il devient très rapidement urgent de prendre le temps d'y réfléchir. Sans cette réflexion, l'architecture résultante ne peut être que bancale et mal adaptée au problème qu'on souhaite résoudre, d'un type qui posera des problèmes de maintenance ou d'évolution.

Question 5: du coup, je fais quoi, moi ?

Et bien, cher lecteur, il pourrait être utile de revérifier l'utilisation que tu fais du système de référence proposé par l'environnement de développement que tu utilises. Si tu es fan de 'smart pointers' en C++, abandonne les - rapidement. Ils te poserons, à terme, plus de problèmes qu'ils ne t'offrent de possibilités. Si tu souhaites mettre en place RAII pour la gestion des pointeurs alloués (et donc ne pas gérer explicitement la mémoire) alors std::auto_ptr<> te fournira la plus grande partie des outils dont tu as besoin. std::shared_ptr<> et consors ne seront alors utiles que dans les cas exceptionnels où std::auto_ptr<> est connu pour ne pas fonctionner (par exemple, le stockage dans un vecteur). Mais à tout moment, tu dois pouvoir dire "tel objet appartient à untel" de manière sûre.

La présence de multiples instances de pointeurs intelligent dans un code C++ est un code smell. Dans un langage supportant le suivi de référence de manière native, il est plus compliqué de repérer un mauvais usage du système, car celui-ci n'est souvent visible qu'au runtime, à la découverte d'effets de bord inquiétants (perte de mémoire, ...).

Conclusion

Ne lisez pas là ce que je n'ai pas dit. Lorsqu'on considère les différents systèmes de suivi des références, il y en a certains qui sont meilleurs que d'autres - c'est notamment le cas lorsqu'ils sont intégrés au langage et transparent pour l'utilisateur. Mais leur utilisation n'a pas pour but de vous libérer d'une partie de votre travail - concevoir une application sûre, sur laquelle vous exercez une contrôle complet. Et ceci ne peut se faire que si vous avez une vision claire sur la question de la propriété des ressources.

Notes

[1] oui, je sous entends que l'intégration des pointeurs intelligent dans la norme C++ n'est pas une décision très intelligente. Je ne remets pas en cause les qualités de ceux qui ont décidé de cette intégration, mais il leur est déjà arrivé de se tromper par le passé sur des questions de design logiciel. Je pense en particulier à la classe spécialisée std::vector<bool>, à la librairie stream proposée par le standard, ainsi qu'à d'autres petites choses ici et là. Quelques fois, l'intérêt légitime du programmeur pour un morceau de bravoure technique semble primer devant l'intérêt de l'architecte qui souhaiterait que jamais, jamais, quelqu'un ait un jour pensé à écrire ce code magnifique mais dangereux au possible.

[2] au sens large ; dans le cas du C++, on transfert le contrôle à une librairie tierce qui se base sur les informations fournies par le runtime et par le compilateur pour effectuer les tâches liées à la gestion de la mémoire ; c'est relativement similaire, puisque cette librairie est bien souvent prise comme une boite noire rajoutant quelques éléments de syntaxe, sous la forme de classes ou de fonctions à appeler.

[3] acheté chez Ikea, pas cher, très confortable.

[4] mais puis-je vraiment faire autrement ? C'est un long débat philisophique qui s'ouvre là...

[5] expression galvaudée dans ce cas spécifique. Je pourrais y revenir un jour, si le coeur vous en dit

[6] On peut résoudre tous les problèmes, en ajoutant un niveau d'indirection

[7] modulo l'utilisation de primitives atomiques, qui peuvent ne pas être disponibles sur certaines architectures matérielles

Commentaires

1. Le mardi, octobre 11 2011, 15:16 par Aszarsha

C'est un sujet très intéressant et qui me tient particulièrement à cœur.
Les raisonnements apportés ici me semblent cohérents, mais malheureusement les conclusions se basent sur des prémisses fausses.

Les smart-pointers C++ n'ont jamais eu vocation à être des entités unitaires, mais bien des paires d'entités.
std::shared_ptr n'a pour but à n'être utilisé que dans la relation de possession directe, là où std::weak_ptr existe pour exprimer une relation d'utilisation.
Il est est de même avec la relation std::unique_ptr et les raw-pointers.
Le premier couple dans le cas où la relation de possession est réellement partagée, le second couple où elle est unique.
(Voir les récentes présentation de Herb Sutter sur le sujet)

Il me semble qu'avec les quatre types de pointeurs évoqués ci-dessus, toutes les relations communes de références peuvent être exprimées, et j'ai donc largement la tendence inverse qui est de conseiller au maximum leur utilisation judicieuse. En effet, là où un simple pointeur exprimais auparavant soit une possession, soit une utilisation, les deux étant désormais séparés, le code est plus expressif.

On peut regretter le fait d'utiliser des raw-pointers en couple avec des unique_ptr, mais rien n'empêche de créer un type par dessus avec un nom explicite.

Enfin, conseiller l'utilisation de std::auto_ptr me semble, au mieux, malheureux. std::unique_ptr en a tous les avantages sans les inconvénients.

Je suis parfaitement en accord avec le fait qu'aucun de ces outils ne doit être une excuse à une réflexion hâtive (ça ne résout pas magiquement le cas de références circulaires, par exemple), il me semble cependant que vous cherchez en eux une utilisation et des avantages qui sont autres que ceux qu'ils apportent.
En revanche, utilisés comme ils se doivent, les avantages avancés se trouvent être bien présents de mon expérience.

2. Le mardi, octobre 11 2011, 17:28 par Emmanuel Deloget

Il me semblait que std::shared_ptr<> portait fort bien son nom : l'objet contenu est prévu pour être partagé entre plusieurs entités, de manière à ce qu'aucune ne soit vraiment le propriétaire de l'objet. Il est vrai qu'il s'utilise communément avec std::weak_ptr<> (qui marque une relation d'utilisation, et non plus une relation de possession), mais celà ne change pas vraiment fondamentalement la bête : shared_ptr<> partage :)

Il est vrai que je n'ai pas abordé unique_ptr<> - et pour cause : c'est un auto_ptr<> glorifié (la seule différence importante étant la présence d'un move ctor, ce qui est effectivement mieux que le copy ctor avec une sémantique move de auto_ptr, mais pas fondamentalement différent dans l'utilisation). unique_ptr<> est nécessairement associé à une notion de propriété unique, puisque seul un objet peut tenir à un moment donné un unique_ptr qui pointe vers une instance particulière. Du coup, il n'est pas vraiment utile de le citer (sauf, effectivement, pour remplacer la référence peu heureuse à auto_ptr). Dans l'esprit, unique_ptr<> est effectivement un plus par rapport à un pointeur nu, pour la dichotomie qu'il impose dans la sémantique de son utilisation (comme vous l'avez remarqué : le propriétaire possède l'unique_ptr, et les utilisateurs utilisent le pointeur nu stocké dans l'unique_ptr ; on sait donc, dès qu'on voit un pointeur, que ce n'est qu'une utilisation. Les delete sont censé disparaitre complètement du code, sans pour autant perdre la moindre parcelle de contrôle sur la gestion de la mémoire, et le tout de manière plus sécurisée qu'avec auto_ptr). Ce n'est pas quelque chose qu'il faut regretter :)

Sur la remarque unique_ptr vs. auto_ptr, je vais corriger l'article, parce que c'est vrai que mon "conseil" est maladroit.

Par contre, je reste sur ma conclusion : les systèmes de suivi de référence ne doivent pas servir à contourner la notion de propriété, même si, pour certains d'entre eux, c'est leur raison d'être (c'est en tout cas celle de shared_ptr<> ; par conséquent, il est préférable de ne pas l'utiliser). shared_ptr<> est un morceau de bravoure en termes de micro-design et de code, mais son utilisation nuit fortement au design d'une application ou d'une librairie - ce n'est pas la première fois que des designers très pointus se trompent lourdement : cf. std::vector<bool> :)) Ses avantages sont justement de permettre au programmeur de ne pas se soucier de la notion de propriété - mais ce n'est qu'un avantage à court terme.

3. Le mercredi, octobre 12 2011, 00:26 par Florian Blanchet

Bonsoir, très beau billet !

Pour la partie sur la sémantique de "strict ownership" renforcé par std::auto_ptr/std::unique_ptr je suis totalement d'accord, de toute facon ce n'est, après tout, qu'une application direct du RAII.

Par contre pour les pointeurs intelligents je trouve votre position un peu forte, je suis d'accord qu'il ne faut absolument pas se priver de la réflexion sur la propriété des objets, mais ne peut-on pas envisager une situation où une semantique de "shared ownership" serait clairement justifiée ? Un système où l'on peut dire : cette ensemble d'entités (bien définie) partage la responsabilté de cette autre entité, aucune ne la possède plus qu'une autre (*). Ainsi l'utilisation d'un pointeur intelligent se révèle utile. Je n'ai pas approfondie la question plus que ca (**), mais une telle situation dans un système bien pensé est irréaliste ?

Après la question de la pertinance d'un tel outil dans la bibliothèque standard est une autre question ... à partir "quel point" (dans le sens fréquence d'utilisation dans une bonne architecture), un élément doit être intégré ?

(*) Tant qu'un élément de cet ensemble (éléments de types hétérogène) existe, la ressource possèder doit vivre (l'existance d'un élément de l'ensemble est une raison d'être de la ressource), et vice-versa.

(**) Si un exemple me vient j'éditerais.

4. Le mercredi, octobre 12 2011, 01:06 par Loïc Joly

Premières réflexions sur cet article (j'espère avoir le temps d'y revenir plus en détails plus tard) :

Sur unique_ptr, pour moi son plus gros avantage sur auto_ptr n'est pas qu'il fourni un move constructor, mais bien qu'il ne fourni plus de copy constructor.

Pour ce qui est de shared_ptr, a mon avis, il ne faut pas l'utiliser comme moyen d'éviter une réflexion sur la propriété, mais comme réponse technique quand la conclusion de cette réflexion est : Je ne vois pas de propriétaire unique naturel à cet objet.

A la question : pourquoi est-ce que j'utilises un langage qui me force à avoir un contrôle complet sur la mémoire alors que je ne le souhaites pas j'ai une autre réponse possible : Parce que j'ai envie de géré la mémoire manuellement dans 10% des cas, et de ne pas m'en préoccuper dans les 90% restant, et que mixer plusieurs langages apporte aussi des problèmes. Il y a bien entendu d'autres arguments possible.

5. Le jeudi, octobre 13 2011, 00:38 par Gbdivers

Si tu es fan de 'smart pointers' en C++, abandonne les - rapidement.
Si tu écris des choses comme ça, je vais devoir arrêter de conseiller ton blog aux débutants qui ne connaissent pas les bases de l'architecture orientée objet ;)

Plus sérieusement, c'est amusant parce que cela va à contresens de ce que l'on conseille régulièrement (moi en premier) sur les forums de Developpez.
A mon avis, la démarche pédagogique pour apprendre le C++ que l'on doit adopter face à un débutant est de séparer (peut être artificiellement) les difficultés du langage. Celui qui débuterait en essayant d'assimiler en même temps la syntaxe de base du langage + les principes d'architectures orientées objet + la gestion de la mémoire + la méta-programmation finirait probablement dans le mur. Au final, recommander l'utilisation des pointeurs intelligents est juste un moyen de faciliter son apprentissage en lui permettant de ne pas avoir à gérer la mémoire et d'avoir un code plus sur. D'un certain côté, je pense que la démarche est identique à celle qui consiste à conseiller d'utiliser les conteneurs standards de la STL au lieu des tableaux dynamiques. Je suis d'accord sur le fait que les pointeurs intelligents sont une solution technique à une difficulté conceptuelle (et donc peuvent être le signe d'un manque de réflexion sur l'architecture) mais c'est ce qui est voulue pour un débutant. Mon impression est qu'il est plus facile de se focaliser sur le code dans un premier temps de son apprentissage et de passer aux problèmes conceptuels que lorsque l'on a les bases nécessaires.
Ce qui revient à poser la question : a quel moment de son apprentissage doit-on s'intéresser aux principes d'architectures ?

6. Le jeudi, octobre 13 2011, 11:00 par Emmanuel Deloget

Premièrement, merci pour ces commentaires très constructifs.

@Florian Blanchet
Par contre pour les pointeurs intelligents je trouve votre position un peu forte
Honnêtement, moi aussi :)

Plus sérieusement, et comme je le dis plus tard dans cette multi-réponse, j'ai des doutes sur la validité de mon raisonnement. S'il est valide, alors je pense que j'ai raison d'être aussi dur dans ma position. Le point faible, s'il existe, tient justement dans le fait que peut-être qu'il existe des cas où la propriété est naturellement partagée entre deux entités différentes.

Un exemple sur lequel j'ai réfléchi récemment : un système de cache de fichier dans un environnement multithreadé. A partir du moment où le cache a la responsabilité de vider les fichiers non utilisés, il faut qu'ils sachent s'ils sont ou non utilisés. Cela peut s'implémenter directement avec des shared_ptr en C++. En Java ou C#, une telle solution est inutile puisque le système de suivi des références garanti qu'une référence utilisée existe toujours en mémoire (c'est le fonctionnement que va émuler shared_ptr dans ce cas précis). Par contre, est-ce la bonne solution ? Son coût technique est faible, son coût sémantique est par contre élevé puisqu'on transforme une relation "j'utilise" (un thread utilise le fichier qui est stocké dans le cache) en une relation "je possède" (le thread et le cache possède le même objet). La solution que je préconise dans ce cas est de pouvoir "locker" la ressource, c'est à dire que (par exemple) j'ajouterais un couple de méthode lock()/unlock() à la classe de ressource pour effectuer un comptage explicite des références (l'utlisation de la classe std::scoped_lock de C++11 devrait permettre d'avoir une gestion transparente de la resource).

Stroustrup donne un autre exemple dans sa FAQ C++11. Je n'ai pas encore réfléchi à la pertinence de son exemple, et peut-être que cet exemple est l'épine dans le pied de mon raisonnement.

Pour résumer, je fais moi-même le comptage de référence, de manière explicite, pour ne pas utiliser shared_ptr, qui fait le comptage de référence de manière implicite. Ca peut sembler contradictoire (et dans une certaine mesure ça l'est au niveau du code), mais ça évite de mélanger du fenouil et du saumon.

Il faut bien comprendre que l'article ne dit pas que le comptage de référence, c'est mal. Il dit que l'utiliser pour éviter de penser à la notion de propriété, c'est mal.

Après la question de la pertinance d'un tel outil dans la bibliothèque standard est une autre question ... à partir "quel point" (dans le sens fréquence d'utilisation dans une bonne architecture), un élément doit être intégré ?
Je sens que je ne vais pas me faire aimer, mais je le dis quand même : un des problèmes ici viens du fait que boost est regardé (avec raison, dans beaucoup de cas) comme étant une librairie qui est très bien architecturée. Personnellement et très sincèrement, je pense qu'elle est émaillée de petites erreurs ici et là, principalement parce qu'elle a été réalisée non pas par des architectes, mais par des programmeurs qui ont des connaissances en architecture. Certains des composants de boost sont des réponses par le code à des problèmes d'architecture qui ne devrait pas exister ; ils donnent l'impression d'exister non pas parce qu'ils sont vraiment utiles, mais parce qu'il était possible de les écrire et parce que ça représentait un réel défi technologique.

Pour autant, tout n'est pas à jeter, lon de là : boost reste une très bonne librairie, et il y a de grandes chances que de nombreuses autres parties de boost soient peu à peu intégrées aux futurs TR.

@Loïc Joly
Pour ce qui est de shared_ptr, a mon avis, il ne faut pas l'utiliser comme moyen d'éviter une réflexion sur la propriété, mais comme réponse technique quand la conclusion de cette réflexion est : Je ne vois pas de propriétaire unique naturel à cet objet.
C'est là que le bat blesse : j'ai l'intuition que la notion de propriété partagée est bancale et dangereuse. Je n'ai pas de démonstration sous la main (sinon je n’appellerais pas ça une intuition), mais je sens distinctement les picotement dans mon cou qui me disent "il y a quelque chose qui cloche".

A la question : pourquoi est-ce que j'utilises un langage qui me force à avoir un contrôle complet sur la mémoire alors que je ne le souhaites pas j'ai une autre réponse possible : Parce que j'ai envie de géré la mémoire manuellement dans 10% des cas, et de ne pas m'en préoccuper dans les 90% restant, et que mixer plusieurs langages apporte aussi des problèmes. Il y a bien entendu d'autres arguments possible.
100% d'accord :)

@Gbdivers
Si tu écris des choses comme ça, je vais devoir arrêter de conseiller ton blog aux débutants qui ne connaissent pas les bases de l'architecture orientée objet ;)
Non, faut pas :) Mon blog est un peu comme moi : des fois, il s'adresse aux débutants, et d'autre fois non (pour prendre un exemple, SFINAE n'est pas un concept très débutant-friendly). Mais les débutants ne sont pas oubliés.

Plus sérieusement, c'est amusant parce que cela va à contresens de ce que l'on conseille régulièrement (moi en premier) sur les forums de Developpez.
Je sais. Ca m'ennuie d'ailleurs, parce que (visiblement) il y a un fossé entre ce qu'on tente de donner comme conseils aux débutants et ce que l'analyse que je fais des outils proposés me renvoie. Il a fallut que je réflechisse longtemps pour écrire cet article (ça ne se vois pas, mais il y a un bon mois de réflexion, et deux semaines entre la fin de son écriture et la mise en ligne). Lorsque j'ai commencé la refléxion, je pensais que la conclusion allait être "les smart pointers, c'est bien, utilisez-les". Sauf qu'en cours de route, j'ai vu le problème qu'ils posent à la notion de propriété.

Mon problème devient assez simple après ça : est-ce que mon analyse est valide ? Est-ce que je ne me trompe pas lourdement ? C'est tout à fait possible, même si j'ai essayé de minimiser les risques de ce coté là.

unique_ptr est bien - c'est une excellente base de travail. shared_ptr est plus dangereux, même s'il est omniprésent dans le code qu'on trouve sur le net, et on encourage très régulièrement son utilisation. Pourtant, on sait qu'il est à même de poser de grave problèmes très difficile à résoudre (le moindre d'entre eux étant le problème de la référence circulaire). Donc je vote pour conseiller unique_ptr autant que possible, et pour essayer de revoir le design des parties qui semblent nécessiter shared_ptr.

Le problème n'est d'ailleurs même pas de savoir s'il faut utiliser shared_ptr ou pas : ce n'est qu'un outil après tout, et cet outil rends des services. Le problème est : est-ce qu'il faut l'utiliser pour faire ce pour quoi il a été conçu ? Là, ma réponse est non. Mais si on restreint son utilisation (par exemple en tant que unique_ptr glorifié, c'est à dire qu'on ne crée jamais deux objets qui pointent sur le même pointeur (on va donc utiliser weak_ptr plus souvent qu'à son tour, ou en tant que scoped_ptr hyperpuissant), alors l'outil est certes surdimensionné, mais tout à fait utilisable.

Ce qui revient à poser la question : a quel moment de son apprentissage doit-on s'intéresser aux principes d'architectures ?
Selon moi, avant même de savoir coder. C'est la première chose qu'on doit apprendre lorsqu'on apprends la POO, le reste n'étant que du sucre syntaxique. En fait, on devrait tous commencer par "programmer" en UML, puis apprendre comment transformer nos diagrammes en code qui fonctionne (quel que soit le langage utilisé).

7. Le lundi, octobre 17 2011, 20:34 par Frederic Jardon

Dés que l'on utilise les containers de la STL il faut utiliser des shared_ptr afin de pouvoir écrire du code sûr pour les exceptions. On peut lire l'Item 7 de "Effective STL" pour une discussion sur ce sujet. Le shared ownership provient dans ce cas de la possibilité pour un pointeur d'être à la fois dans deux conteneurs, de façon transitoire, au moment où survient l'exception. Il y a donc alors bien deux "propriétaires" de l'objet, même si à la sortie et à l'entrée de la fonction il n'y en a qu'un...

8. Le mercredi, octobre 19 2011, 12:13 par Emmanuel Deloget

@Frederic Jardon

Dés que l'on utilise les containers de la STL il faut utiliser des shared_ptr afin de pouvoir écrire du code sûr pour les exceptions. On peut lire l'Item 7 de "Effective STL" pour une discussion sur ce sujet. Le shared ownership provient dans ce cas de la possibilité pour un pointeur d'être à la fois dans deux conteneurs, de façon transitoire, au moment où survient l'exception. Il y a donc alors bien deux "propriétaires" de l'objet, même si à la sortie et à l'entrée de la fonction il n'y en a qu'un...

Très juste. On retrouve ce cas où un std::vector<> grandit suite, par exemple, à un push_bask() : il y a un risque de levée d'exception, ainsi que la nécessité de faire en sorte que le pointeur protégé soit possédé par deux conteneurs en même temps (l'original et la copie de taille supérieure ; l'exception peut se produire pendant le transfert des éléments).

C'est encore vrai dans le cas de C++03, mais l'introduction dans C++11 de std::unique_ptr<> change la donne : il est maintenant préférable de l'utiliser en lieu et place de std::shared_ptr<> pour résoudre ce problème très spécifique.

C'est toutefois un corner-case intéressant ; le problème qu'on résout ici est un pur problème de code ou au mieux de micro-design (comment faire en sorte que RAII fonctionne dans ce cas précis) et non pas un problème d'architecture. La solution technique apportée ne réponds pas à un problème de gestion de la propriété, mais permet à un objet d'avoir plusieurs propriétaires transitoires afin de protéger celui-ci de la destruction (et donc au final de garantir que sa durée de vie est bien celle prévue par l'architecte, tout en permettant l'utilisation de RAII).

9. Le dimanche, octobre 30 2011, 12:22 par Loïc Joly

Je pense que le problème de base est une question de philosophie : Tu vois la propriété partagée comme quelque-chose de bancal, alors que je la vois comme le cas général par défaut, dont la propriété unique ne serait qu'un cas particulier. Et dans ma vision des choses, il y a de nombreux cas où je n'ai pas envie de vérifier que je suis dans ce cas particulier, non seulement à l'instant de la réflexion, mais aussi que j'y resterai quand le logiciel aura légitimement évolué.

Je me demande si l'impression que la propriété unique est plus "pure" et plus "naturelle" ne découle pas en partie de notre société capitaliste, et si un indien d’Amazonie vivant de manière communautaire ou un communiste ne considèrerait pas unique_ptr comme une abomination. :)

Ce qui me gêne dans la propriété unique, c'est que souvent elle oblige à avoir de manière qui me semble un peu artificielle un... propriétaire unique. Prenons un exemple, pour moins discuter dans le vide. Un programme faisant de la 3D, devant associer de la texture à des objets. Un système de cache permet d'éviter de charger 10 fois les mêmes textures.

  1. Solution propriété partagée 1 : Les objets possèdent leurs textures, le système de cache aussi (cette solution aura une partie des mêmes inconvénients que la solution 3).
  2. Solution propriété partagée 2 : Les objets possèdent leurs textures, le cache non (weak_ptr).
  3. Solution propriété unique : Le système de cache possède les textures, les objets les référencent uniquement.

La dernière solution peut sembler plus simple, sauf que l'on a d'un certain point de vue perverti le système de cache. Il n'assure plus uniquement le rôle d’accélérer le chargement, mais aussi de dépositaire unique de toutes les textures existantes. Ce qui signifie par exemple :

  • Que même si on sait que l'on a tout chargé les objets en mémoire, on ne peut pas détruire le système de cache.
  • Qu'une texture restera chargée en mémoire indéfiniment, même si plus aucun objet ne la référence (à moins de mettre en œuvre un mécanisme permettant à un objet de déréférencer une texture auprès du cache, ce qui revient à dire d'une autre manière que l'objet a une responsabilité sur l’existence des textures, et donc que la propriété est quand même partagée, même si c'est par une autre technique que des shared_ptr).
  • Que le cache doit se souvenir de toutes les textures, et pas uniquement des n dernières, des n plus courantes... On s'est fermé des possibilités d'implémentation du cache.

Finalement, forcer la propriété unique a eu des impacts non négligeables. Et encore, on était dans un cas où un objet préexistait qui pouvait à peu de chose près assumer le rôle de propriétaire. Dans la plupart des codes que j'ai vu de gens voulant éviter la propriété partagés, on voyait fleurir des classes XxxGestionnaire, XxxManager... qui n'auraient pas eu lieu d'être dans un design avec propriété partagée. Et en plus, ces classes "Manager" sont très souvent des singletons, car qui dit propriété unique implique souvent dans ce genre de situation l'existence d'un propriétaire globalement unique. Je ne savais pas que tu militais en faveur de ce design pattern ;)

Attention, je ne dis pas que ces designs sont mauvais, dans certains cas, ce sont eux que je choisirais moi aussi, je voulais juste souligner qu'ils avaient un impact. La solution 2 a aussi un inconvénient de taille, qui est que l'accès aux textures d'un objet présente un surcoût. Mais c'est avant tout un inconvénient d'implémentation, pas de design. En terme de design pur, je la considère comme supérieure à la solution 3 qui est celle que je vois le plus souvent utilisée.

10. Le dimanche, novembre 6 2011, 16:28 par Emmanuel Deloget

Je pense que le problème de base est une question de philosophie : Tu vois la propriété partagée comme quelque-chose de bancal, alors que je la vois comme le cas général par défaut, dont la propriété unique ne serait qu'un cas particulier.

Dans l'histoire de l'architecture OO, la notion de propriété partagée n'est arrivée que très récemment : le lien de propriété (qui a été l'un des premier mis en évidence : has-a) implique un propriétaire unique de nombreuses méthodologies pré-OMT. Il a fallu OMT et sa notation simple mais très générique, qui a ensuite inspiré UML, pour qu'on commence a vraiment s'intéresser à la propriété partagée d'instances.

Peut-être qu'il y a maintenant un décalage de paradigme, et qu'on ne doit plus voir la propriété partagée comme étant un cas à la marge - mon ponit de vue étant alors qu'il faut se restreindre à ne pas l'utiliser, en raison des dangers qui l'accompagnent.

Je me demande si l'impression que la propriété unique est plus "pure" et plus "naturelle" ne découle pas en partie de notre société capitaliste, et si un indien d’Amazonie vivant de manière communautaire ou un communiste ne considèrerait pas unique_ptr comme une abomination. :)

:)

On peut peut-être lui demander, en effet.

Plus sérieusement : il est évident que la notion de propriété unique est plus naturelle, même à un indien communautariste : un arbre "possède" ses feuilles, un homme possède ses doigts, la porte d'une hutte est liée à une seule hutte, etc. Dans la vie de tous les jours, les exemples d'objets dont on peut dire qu'ils sont possédés de manière partagée par plusieurs autres objets sont extrêmement rares, et encore dans la plupart des cas cette vision peut être transformée en une vision à propriétaire unique (par exemple, une porte qui lie deux pièces peut être vue comme appartenant aux deux pièces, ce qu'on peut transformer en une pièce maître avec une porte de sortie, et une pièce esclave qui utilise la porte de sortie comme porte d'entrée ; la vision est peut être moins naturelle, mais elle reste tout à fait valide en terme d'architecture ; en cas de destruction de la pièce maître, la propriété de la porte est transférée à l'autre pièce).

Ce qui me gêne dans la propriété unique, c'est que souvent elle oblige à avoir de manière qui me semble un peu artificielle un... propriétaire unique.

C'est justement là que ça devient intéressant : mon opinion, qui peut être fausse, est que pour tout système qui implique une notion de propriété partagée, il existe un système de complexité équivalente, voire plus simple, où on peut exprimer l'intégralité des liens de propriété en se passant de la notion de propriété partagée ; et que, cette transformation effectuée, le système obtenu sera plus simple à maintenir, à tester et à faire évoluer.

Pour ce faire, il peut être utile de remonter d'un niveau d'abstraction, et, ce faisant, on obtiendra peut-être quelque chose qui est plus "articifiel" - mais la valeur ajoutée d'une telle transformation est réelle.

Finalement, forcer la propriété unique a eu des impacts non négligeables. Et encore, on était dans un cas où un objet préexistait qui pouvait à peu de chose près assumer le rôle de propriétaire. Dans la plupart des codes que j'ai vu de gens voulant éviter la propriété partagés, on voyait fleurir des classes XxxGestionnaire, XxxManager... qui n'auraient pas eu lieu d'être dans un design avec propriété partagée. Et en plus, ces classes "Manager" sont très souvent des singletons, car qui dit propriété unique implique souvent dans ce genre de situation l'existence d'un propriétaire globalement unique. Je ne savais pas que tu militais en faveur de ce design pattern ;)

Dans un travail d'architecte logiciel, l'imagination a un rôle prépondérant : il faut pouvoir imaginer de nouvelles solutions, en plus de connaître les solutions classiques à un problème. Tu sais que je sais que les gestionnaires sont une horrible chose (et je sais que tu sais que je le sais, et tu sais que je sais que tu sais que je le sais :) ; je sais aussi qu'il y a une forte pointe d'humour dans ta phrase, du coup, j'ai mis une contre-pointe). Le fait est qu'on peut tout a fait s'en passer : identifier le propriétaire naturel d'un objet n'est pas une chose si complexe que ça ; si c'est une chose complexe, c'est que l'architecture est dans de sales draps avant même d'en arriver à ce point.

L'exemple particulier que tu prends est un peu biaisé, parce que dans un jeu vidéo, il est plus que probable que le cache ne serve pas exactement de la manière dont tu l'entends (c'est à dire que certaines textures pourraient devoir être rechargées plus tard). Dans la grande majorité des cas, toutes les textures d'un niveau de jeu sont chargées, et référencées par les objets utilisateur. Puisque ces objets ont la même durée de vie que le niveau lui-même, on sait quand il faudra les décharger de la mémoire. Le cache ne sert alors qu'à assurer la persistance des textures qui sont utilisées dans plusieurs niveaux différents, ce qui accélère d'autant le chargement de ces niveaux. C'est le cas dans à peux près tous les FPS. Dans le cas des environnements ouverts, le nombre de textures chargées est plus important, mais on peut appliquer le même principe (c'est, si je ne me trompe pas, ce que font Morrowind et Oblivion ; pour Dungeon Siege, je ne me prononcerait pas). Finalement, pour un jeu basé sur un système de mega-texture, la question ne se pose pas vraiment. Dans tous les cas, ce qu'on veut éviter, c'est un rechargement intempestif de textures pendant les phases de jeu, car un tel chargement peut être long si les textures sont par exemples placées sur un DVD (ce qui arrive dans le cas des jeux sur console). Et ça, c'est sans même compter que les textures sont bien souvent placées dans un fichier de packaging, et que ce fichier est lu (en partie ou en totalité) d'une traite, de manière à avoir l'ensemble des informations disponibles après une unique lecture. L'astuce consiste dès lors à bien ordonner les textures dans le fichier de packaging, quitte à les y mettre plusieurs fois pour accélérer les chargements.

Si on met de coté ces quelques objections pour vraiment s'intéresser à un système de cache, on s’aperçoit rapidement que la définition même d'un cache fait que l'utilisateur qui a demandé l'instance de la ressource cachée est censé en être le propriétaire unique. En fait, l'instance gardée par le cache lui appartient, et il en donne une copie à l'utilisateur. La durée de vie de l'instance présente dans le cache n'est pas la même que la durée de vie de l'instance utilisée hors du cache. Le principe accélérant vient du fait que si plusieurs utilisateurs souhaitent utiliser la même ressource alors celle-ci, déjà présente dans le cache, n'aura pas besoin d'être rechargée. Si la version cachée est détruite, les autres copies existent encore.

Le fait de ne garder en mémoire qu'une seule copie des données liées à la ressource est une optimisation mémoire que ne peut se faire que dans le cas où les ressources ne sont pas modifiées par le système. C'est, je te l'accorde, assez régulièrement le cas. Ce n'est toutefois pas une propriété intrinsèque d'un cache (cf. le cache de fichiers de Windows, qui garde en RAM les fichiers récents les plus utilisés ; tout utilisateur va se voir remettre une copie de ces fichiers cachés).

Bien évidemment, l'optimisation peut être implémentée grâce à un système de suivi des références. C'est même une implémentation sensée.

Ceci dit, l'argument principal de mon article est exactement celui-ci : car un tel système de cache, si tu le mets en place, te privera du contrôle que tu peux effectuer sur la durée de vie de tes ressources (avec les risques potentiels que j'ai cité). Que tu l'ais choisi, comme dans ce cas précis, ou subi, parce que a utilisé massivement des pointeurs intelligents sans vraiment penser à l'implication de cette utilisation massive ne change pas la conclusion, ça change juste ton degré de contrôle sur le code : dès lors que tu fais le choix raisonné d'utiliser une technologie, quelque soit son niveau de dangerosité, afin d'en obtenir un gain que tu sais mesurer, alors tu sais que la partie de ton code où se trouve cette utilisation est une partie sensible du code, qui sera plus surveillée que d'autres. A la base, toute technologie est neutre dès lors qu'elle est utilisée correctement et avec raison (c'est le cas notamment de goto ; sans goto, je code du kernel linux serait 2 fois plus important, et probablement moins efficace ; avec goto, le code est peut être moins beau à voir, mais les parties où ce mot-clef sont utilisées sont vérifiées et contrôlées de manière aussi sérieuse que les utilisations pourtant bien plus complexes et bien plus dangereuses du système RCU).

Comme je l'ai dit, mon raisonnement souffre d'un problème assez aigu : je n'arrive pas à trouver un contre-argument fort à mon raisonnement, mais je n'arrive pas non plus à exclure l'existence d'un tel contre-argument. L'outil existe, je sais qu'il a été créé pour de mauvaises raisons, qu'il est utilisé à tord et à travers dans de très nombreux cas, et qu'il existe des cas où une telle utilisation est, du moins en apparence, légitime. Du coup, je suis bien forcé de dire que ce que je prêche n'est pour l'instant qu'une opinion, certes forte, qui est basée sur quelque chose que je pressens mais que je ne formalise pas encore complètement. Une telle formalisation va de toute façon me demander des années, vu mon rythme de réflexion :)

11. Le mercredi, janvier 18 2012, 14:50 par jblecanard

Bonjour

Les réactions que j'ai eues en lisant l'article ont déjà été évoquées par les autres lecteurs. Néanmoins, j'ai résolu le problème de la manière suivante : j'ai appris comment était implémenté shared_ptr et ses amis, et par conséquent, je m'approprie cette implémentation pour répondre à mes besoins. Peu m'importe ce que voulait en faire le développeur original : je sais ce que l'outil fait, comment, et je m'en sers à ma guise en me basant sur cette connaissance.

Bien sûr, on ne peut pas faire ça avec tous les outils existants. Mais les shared_ptr sont répandus et relativement simples, cela vaut donc la peine.

12. Le dimanche, février 24 2013, 15:43 par germinolegrand

Bonjour !
Je viens lire ce billet avec 2 ans de retard, mais je dois dire qu'il est toujours d'actualité (sauf pour le auto_ptr qui m'arrache le cœur :D).

Dans le contexte d'un développement en C++11, je dois dire que je suis arrivé aux mêmes conclusions. Les unique_ptr sont très solides à l'utilisation, et fournissent un code propre. Pour ce qui est des shared_ptr, je ne leur trouve pas d'utilisation convaincante (sans parler qu'elle rend le code plus incompréhensible qu'elle ne le clarifie).

Il est je trouve préférable de créer ses propres holders RAII (contenant souvent en interne un unique_ptr) adaptés au besoin réel et particulier quand on se trouve dans une situation particulière.

La seule chose intéressante dans les shared_ptr du standard c'est qu'ils sont atomiques (si je ne me trompe pas)...

Ajouter un commentaire

Les commentaires peuvent être formatés en utilisant une syntaxe wiki simplifiée.

Fil des commentaires de ce billet