Je vais essayer d'énoncer les différents modes viables de passage de pointeurs vers des objets dont la mémoire est gérée par une instance de l'outil de gestion de la mémoire. std::unique_ptr
Il s'applique également à l'ancien modèle de classe de la Commission européenne. std::auto_ptr
(qui, selon moi, permet toutes les utilisations du pointeur unique, mais pour lequel, en outre, les valeurs l modifiables seront acceptées là où les valeurs r sont attendues, sans avoir à invoquer le modèle de classe std::move
), et dans une certaine mesure aussi à std::shared_ptr
.
Comme exemple concret pour la discussion, je vais considérer le type de liste simple suivant
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Les instances d'une telle liste (qui ne peuvent pas être autorisées à partager des parties avec d'autres instances ou à être circulaires) sont entièrement détenues par celui qui détient le nom initial de la liste. list
pointeur. Si le code client sait que la liste qu'il stocke ne sera jamais vide, il peut également choisir de stocker le premier node
directement plutôt qu'un list
. Pas de destructeur pour node
doit être définie : puisque les destructeurs de ses champs sont automatiquement appelés, la liste entière sera récursivement supprimée par le destructeur de pointeur intelligent une fois que la durée de vie du pointeur ou du noeud initial se termine.
Ce type récursif donne l'occasion de discuter de certains cas qui sont moins visibles dans le cas d'un pointeur intelligent vers des données simples. De plus, les fonctions elles-mêmes fournissent occasionnellement (de manière récursive) un exemple de code client. Le typedef pour list
est bien sûr biaisé en faveur de unique_ptr
mais la définition pourrait être modifiée pour utiliser auto_ptr
o shared_ptr
à la place, sans qu'il soit nécessaire de changer grand chose à ce qui est dit ci-dessous (notamment en ce qui concerne la sécurité des exceptions qui est assurée sans avoir besoin d'écrire des destructeurs).
Modes de transmission des pointeurs intelligents
Mode 0 : passer un pointeur ou une référence en argument au lieu d'un pointeur intelligent.
Si votre fonction n'est pas concernée par la propriété, c'est la méthode préférée : ne lui faites pas du tout prendre un pointeur intelligent. Dans ce cas, votre fonction n'a pas besoin de se soucier de la propriété. qui est propriétaire de l'objet pointé, ou par quel moyen cette propriété est gérée, donc passer un pointeur brut est à la fois parfaitement sûr, et la forme la plus flexible, puisque indépendamment de la propriété, un client peut toujours produire un pointeur brut (soit en appelant la fonction get
ou de l'opérateur d'adresse &
).
Par exemple, la fonction permettant de calculer la longueur d'une telle liste ne devrait pas recevoir un nom de fichier de type list
mais un pointeur brut :
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
Un client qui détient une variable list head
peut appeler cette fonction comme length(head.get())
, alors qu'un client qui a choisi de stocker une node n
représentant une liste non vide peut appeler length(&n)
.
Si le pointeur est garanti non nul (ce qui n'est pas le cas ici puisque les listes peuvent être vides), on peut préférer passer une référence plutôt qu'un pointeur. Il peut s'agir d'un pointeur ou d'une référence à un objet non nul. const
si la fonction doit mettre à jour le contenu du ou des nœuds, sans en ajouter ou en supprimer (ce qui impliquerait la propriété).
Un cas intéressant qui tombe dans la catégorie du mode 0 est la réalisation d'une copie (profonde) de la liste ; alors qu'une fonction faisant cela doit bien sûr transférer la propriété de la copie qu'elle crée, elle n'est pas concernée par la propriété de la liste qu'elle copie. On pourrait donc la définir comme suit :
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
Ce code mérite un examen attentif, à la fois pour la question de savoir pourquoi il compile (le résultat de l'appel récursif à copy
dans la liste d'initialisation se lie à l'argument de référence rvalue dans le constructeur move de unique_ptr<node>
aussi connu sous le nom de list
lors de l'initialisation de la next
du champ généré node
), et pour la question de savoir pourquoi elle est à l'abri des exceptions (si, au cours du processus d'allocation récursive, la mémoire vient à manquer et qu'un appel de la commande new
jette std::bad_alloc
alors, à ce moment-là, un pointeur vers la liste partiellement construite est conservé anonymement dans un fichier temporaire de type list
créé pour la liste d'initialisation, et son destructeur nettoiera cette liste partielle). Au fait, il faut résister à la tentation de remplacer (comme je l'ai fait initialement) le deuxième élément de la liste des initialisateurs par un élément de la liste des initialisateurs. nullptr
por p
qui, après tout, est connu pour être nul à ce moment-là : on ne peut pas construire un pointeur intelligent à partir d'un pointeur (brut). à la constante même s'il est connu pour être nul.
Mode 1 : passer un pointeur intelligent par valeur
Une fonction qui prend une valeur de pointeur intelligent comme argument prend immédiatement possession de l'objet pointé : le pointeur intelligent que l'appelant détenait (que ce soit dans une variable nommée ou un temporaire anonyme) est copié dans la valeur de l'argument à l'entrée de la fonction et le pointeur de l'appelant est devenu nul (dans le cas d'un temporaire, la copie peut avoir été élidée, mais dans tous les cas l'appelant a perdu l'accès à l'objet pointé). Je voudrais appeler ce mode appel en espèces L'appelant paie d'avance pour le service appelé, et ne peut avoir aucune illusion sur la propriété après l'appel. Pour que cela soit clair, les règles de langage exigent que l'appelant enveloppe l'argument dans std::move
si le pointeur intelligent est maintenu dans une variable (techniquement, si l'argument est une lvalue) ; dans ce cas (mais pas pour le mode 3 ci-dessous) cette fonction fait ce que son nom suggère, à savoir déplacer la valeur de la variable vers un temporaire, laissant la variable nulle.
Pour les cas où la fonction appelée prend inconditionnellement la propriété de l'objet pointé (pilfers), ce mode est utilisé avec std::unique_ptr
o std::auto_ptr
est un bon moyen de transmettre un pointeur avec sa propriété, ce qui évite tout risque de fuite de mémoire. Néanmoins, je pense qu'il n'y a que très peu de situations où le mode 3 ci-dessous n'est pas à préférer (un tant soit peu) au mode 1. Pour cette raison, je ne fournirai aucun exemple d'utilisation de ce mode. (Mais voyez le reversed
exemple du mode 3 ci-dessous, où l'on remarque que le mode 1 ferait au moins aussi bien l'affaire). Si la fonction prend plus d'arguments que juste ce pointeur, il peut arriver qu'il y ait en plus un pointeur raison technique pour éviter le mode 1 (avec std::unique_ptr
o std::auto_ptr
) : puisqu'une opération de déplacement réelle a lieu lors du passage d'une variable pointeur p
par l'expression std::move(p)
on ne peut pas supposer que p
conserve une valeur utile pendant qu'il évalue les autres arguments (l'ordre d'évaluation n'étant pas spécifié), ce qui pourrait conduire à des erreurs subtiles ; en revanche, l'utilisation du mode 3 garantit qu'aucun déplacement de la fonction p
a lieu avant l'appel de la fonction, de sorte que les autres arguments peuvent accéder en toute sécurité à une valeur par le biais de la fonction p
.
Lorsqu'il est utilisé avec std::shared_ptr
ce mode est intéressant dans la mesure où, avec une seule définition de fonction, il permet à l'appelant de choisissez s'il faut conserver une copie partagée du pointeur pour lui-même tout en créant une nouvelle copie partagée qui sera utilisée par la fonction (ceci se produit lorsqu'un argument lvalue est fourni ; le constructeur de copie pour les pointeurs partagés utilisés à l'appel augmente le nombre de références), ou s'il faut simplement donner à la fonction une copie du pointeur sans en conserver une ni toucher au nombre de références (ceci se produit lorsqu'un argument rvalue est fourni, éventuellement un lvalue enveloppé dans un appel de std::move
). Par exemple
void f(std::shared_ptr<X> x) // call by shared cash
{ container.insert(std::move(x)); } // store shared pointer in container
void client()
{ std::shared_ptr<X> p = std::make_shared<X>(args);
f(p); // lvalue argument; store pointer in container but keep a copy
f(std::make_shared<X>(args)); // prvalue argument; fresh pointer is just stored away
f(std::move(p)); // xvalue argument; p is transferred to container and left null
}
La même chose pourrait être réalisée en définissant séparément void f(const std::shared_ptr<X>& x)
(pour le cas de la lvalue) et void f(std::shared_ptr<X>&& x)
(pour le cas de rvalue), les corps de fonction ne différant que par le fait que la première version invoque la sémantique de la copie (en utilisant la construction/affectation de la copie lorsque l'on utilise la fonction x
) mais la deuxième version déplace la sémantique (écriture std::move(x)
à la place, comme dans l'exemple de code). Ainsi, pour les pointeurs partagés, le mode 1 peut être utile pour éviter une certaine duplication du code.
Mode 2 : passer un pointeur intelligent par référence à une valeur l (modifiable)
Ici, la fonction demande simplement d'avoir une référence modifiable au pointeur intelligent, mais ne donne aucune indication sur ce qu'elle va en faire. Je voudrais appeler cette méthode appel par carte : l'appelant assure le paiement en donnant un numéro de carte de crédit. La référence puede peut être utilisé pour prendre possession de l'objet pointé, mais ce n'est pas obligatoire. Ce mode nécessite de fournir un argument lvalue modifiable, correspondant au fait que l'effet souhaité de la fonction peut inclure de laisser une valeur utile dans la variable argument. Un appelant avec une expression rvalue qu'il souhaite passer à une telle fonction serait obligé de la stocker dans une variable nommée pour pouvoir faire l'appel, puisque le langage ne fournit qu'une conversion implicite vers une variable rvalue. constant référence à une lvalue (se référant à un temporaire) à partir d'une rvalue. (Contrairement à la situation inverse gérée par std::move
, un casting de Y&&
a Y&
avec Y
le type de pointeur intelligent, n'est pas possible ; néanmoins, cette conversion pourrait être obtenue par une simple fonction de modèle si on le souhaitait vraiment ; cf. https://stackoverflow.com/a/24868376/1436796 ). Pour le cas où la fonction appelée a l'intention de prendre inconditionnellement possession de l'objet, en volant l'argument, l'obligation de fournir un argument lvalue donne un mauvais signal : la variable n'aura aucune valeur utile après l'appel. C'est pourquoi le mode 3, qui donne des possibilités identiques à l'intérieur de notre fonction mais demande aux appelants de fournir une rvalue, doit être préféré pour une telle utilisation.
Cependant, il existe un cas d'utilisation valide pour le mode 2, à savoir les fonctions qui peuvent modifier le pointeur, ou l'objet pointé d'une manière qui implique la propriété . Par exemple, une fonction qui préfixe un nœud à un list
fournit un exemple d'une telle utilisation :
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
Il est clair qu'il ne serait pas souhaitable ici de forcer les appelants à utiliser std::move
puisque leur pointeur intelligent possède toujours une liste bien définie et non vide après l'appel, bien qu'une liste différente de celle d'avant.
Encore une fois, il est intéressant d'observer ce qui se passe si la prepend
L'appel échoue par manque de mémoire libre. Ensuite, le new
l'appel lancera std::bad_alloc
à l'heure actuelle, puisqu'aucune node
a pu être allouée, il est certain que la référence de la valeur r (mode 3) passée de std::move(l)
ne peut pas encore avoir été chapardé, car cela serait fait pour construire la next
du champ de la node
qui n'ont pas été attribués. Donc le pointeur intelligent original l
contient toujours la liste originale lorsque l'erreur est levée ; cette liste sera soit correctement détruite par le destructeur de pointeur intelligent, soit, dans le cas où l
devrait survivre grâce à une mise en place suffisamment précoce catch
la liste d'origine sera conservée.
C'était un exemple constructif, avec un clin d'œil à cette question on peut également donner l'exemple plus destructif de la suppression du premier nœud contenant une valeur donnée, le cas échéant :
void remove_first(int x, list& l)
{ list* p = &l;
while ((*p).get()!=nullptr and (*p)->entry!=x)
p = &(*p)->next;
if ((*p).get()!=nullptr)
(*p).reset((*p)->next.release()); // or equivalent: *p = std::move((*p)->next);
}
Encore une fois, l'exactitude est assez subtile ici. Notamment, dans l'énoncé final, le pointeur (*p)->next
détenu à l'intérieur du nœud à supprimer est délié (par release
qui renvoie le pointeur mais rend l'original nul). avant reset
détruit (implicitement) ce noeud (lorsqu'il détruit l'ancienne valeur détenue par le noeud p
), en veillant à ce qu'un et seulement un est détruit à ce moment-là. (Dans la forme alternative mentionnée dans le commentaire, cette synchronisation serait laissée aux internes de l'implémentation de l'opérateur de déplacement-affectation de la fonction std::unique_ptr
instance list
; la norme dit 20.7.1.2.3;2 que cet opérateur doit agir "comme si en appelant reset(u.release())
", d'où le timing devrait être sûr ici aussi).
Notez que prepend
y remove_first
ne peut pas être appelé par les clients qui stockent un node
pour une liste toujours non vide, et ce à juste titre puisque les implémentations données ne pouvaient pas fonctionner pour de tels cas.
Mode 3 : passage d'un pointeur intelligent par une référence rvalue (modifiable)
C'est le mode à utiliser de préférence lorsqu'il s'agit simplement de prendre possession du pointeur. Je voudrais appeler cette méthode appel par chèque L'appelant doit accepter de renoncer à la propriété, comme s'il fournissait de l'argent, en signant le chèque, mais le retrait réel est reporté jusqu'à ce que la fonction appelée vole effectivement le pointeur (exactement comme elle le ferait en utilisant le mode 2). La "signature du chèque" signifie concrètement que les appelants doivent emballer un argument dans std::move
(comme dans le mode 1) s'il s'agit d'une lvalue (si c'est une rvalue, la partie "abandon de propriété" est évidente et ne nécessite pas de code séparé).
Notez que techniquement le mode 3 se comporte exactement comme le mode 2, donc la fonction appelée ne doit pas assumer la propriété ; cependant, j'insiste sur le fait qu'en cas d'incertitude quant au transfert de propriété (dans le cadre d'une utilisation normale), le mode 2 devrait être préféré au mode 3, de sorte que l'utilisation du mode 3 signale implicitement aux appelants qu'ils sont renoncer à la propriété. On pourrait rétorquer que seul le passage d'arguments en mode 1 signale vraiment une perte forcée de propriété aux appelants. Mais si un client a le moindre doute sur les intentions de la fonction appelée, il est censé connaître les spécifications de la fonction appelée, ce qui devrait lever tout doute.
Il est étonnamment difficile de trouver un exemple typique impliquant notre list
qui utilise le passage d'arguments en mode 3. Déplacement d'une liste b
à la fin d'une autre liste a
est un exemple typique ; cependant a
(qui survit et conserve le résultat de l'opération) est mieux passé en utilisant le mode 2 :
void append (list& a, list&& b)
{ list* p=&a;
while ((*p).get()!=nullptr) // find end of list a
p=&(*p)->next;
*p = std::move(b); // attach b; the variable b relinquishes ownership here
}
Un exemple pur de passage d'argument en mode 3 est le suivant qui prend une liste (et sa propriété), et retourne une liste contenant les noeuds identiques dans l'ordre inverse.
list reversed (list&& l) noexcept // pilfering reversal of list
{ list p(l.release()); // move list into temporary for traversal
list result(nullptr);
while (p.get()!=nullptr)
{ // permute: result --> p->next --> p --> (cycle to result)
result.swap(p->next);
result.swap(p);
}
return result;
}
Cette fonction peut être appelée comme dans l = reversed(std::move(l));
pour inverser la liste en elle-même, mais la liste inversée peut aussi être utilisée différemment.
Ici, l'argument est immédiatement déplacé vers une variable locale pour des raisons d'efficacité (on aurait pu utiliser le paramètre l
directement à la place de p
mais y accéder à chaque fois impliquerait un niveau supplémentaire d'indirection) ; donc la différence avec le mode 1 de passage d'argument est minime. En fait, en utilisant ce mode, l'argument aurait pu servir directement de variable locale, évitant ainsi ce déplacement initial ; c'est juste un exemple du principe général selon lequel si un argument passé par référence ne sert qu'à initialiser une variable locale, on peut tout aussi bien le passer par valeur à la place et utiliser le paramètre comme variable locale.
L'utilisation du mode 3 semble être préconisée par la norme, comme en témoigne le fait que toutes les fonctions de bibliothèque fournies qui transfèrent la propriété des pointeurs intelligents utilisent le mode 3. Un cas particulièrement convaincant est celui du constructeur std::shared_ptr<T>(auto_ptr<T>&& p)
. Ce constructeur utilisé (dans std::tr1
) pour prendre un lvalue (tout comme la référence auto_ptr<T>&
), et pourrait donc être appelé avec un constructeur de copie auto_ptr<T>
lvalue p
dans le cas de std::shared_ptr<T> q(p)
après quoi p
a été remis à zéro. En raison du passage du mode 2 au mode 3 pour le passage des arguments, cet ancien code doit maintenant être réécrit en std::shared_ptr<T> q(std::move(p))
et continuera ensuite à travailler. Je comprends que le comité n'ait pas aimé le mode 2 ici, mais il avait la possibilité de passer au mode 1, en définissant std::shared_ptr<T>(auto_ptr<T> p)
Au lieu de cela, ils auraient pu s'assurer que l'ancien code fonctionne sans modification, car (contrairement aux pointeurs uniques) les auto-pointeurs peuvent être déréférencés silencieusement vers une valeur (l'objet pointeur lui-même étant réinitialisé à null dans le processus). Apparemment, le comité a tellement préféré défendre le mode 3 plutôt que le mode 1, qu'il a choisi de casser activement le code existant plutôt que d'utiliser le mode 1 même pour un usage déjà déprécié.
Quand préférer le mode 3 au mode 1
Le mode 1 est parfaitement utilisable dans de nombreux cas, et pourrait être préféré au mode 3 dans les cas où la prise en charge de la propriété prendrait autrement la forme d'un déplacement du pointeur intelligent vers une variable locale, comme dans l'exemple de l'article reversed
exemple ci-dessus. Cependant, je vois deux raisons de préférer le mode 3 dans le cas plus général :
-
Il est légèrement plus efficace de passer une référence que de créer un pointeur temporaire et de supprimer l'ancien pointeur (la gestion de l'argent liquide est quelque peu laborieuse) ; dans certains scénarios, le pointeur peut être passé plusieurs fois inchangé à une autre fonction avant d'être réellement chapardé. Un tel passage nécessitera généralement d'écrire std::move
(à moins que le mode 2 ne soit utilisé), mais notez qu'il s'agit juste d'un cast qui ne fait rien (en particulier pas de déréférencement), donc il n'a aucun coût attaché.
-
S'il est concevable que quelque chose lève une exception entre le début de l'appel de la fonction et le moment où celle-ci (ou un appel contenu) déplace effectivement l'objet pointé dans une autre structure de données (et que cette exception n'est pas déjà prise en compte dans la fonction elle-même), alors, en utilisant le mode 1, l'objet auquel se réfère le pointeur intelligent sera détruit avant qu'une fonction catch
clause peut gérer l'exception (parce que le paramètre de la fonction a été détruit pendant le déroulement de la pile), mais pas lorsqu'on utilise le mode 3. Ce dernier donne à l'appelant la possibilité de récupérer les données de l'objet dans ce cas (en attrapant l'exception). Notez que le mode 1 ici ne provoque pas de fuite de mémoire mais peut conduire à une perte irrécupérable de données pour le programme, ce qui peut également être indésirable.
Retourner un pointeur intelligent : toujours par valeur
Pour conclure, un mot sur en retournant sur un pointeur intelligent, pointant vraisemblablement vers un objet créé pour être utilisé par l'appelant. Ce n'est pas vraiment un cas comparable au passage de pointeurs dans des fonctions, mais pour être complet, je voudrais insister sur le fait que dans de tels cas toujours retourner par valeur (et n'utilisez pas std::move
dans le return
déclaration). Personne ne veut recevoir un référence à un pointeur qui vient probablement d'être annulé.