Permettez-moi d'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 du std::unique_ptr
modèle de classe; il s'applique également à l'ancien std::auto_ptr
modèle de classe (qui, je crois, autorise toutes les utilisations de ce pointeur unique, mais pour lequel en outre des valeurs modifiables seront acceptées là où des valeurs sont attendues, sans avoir à invoquer std::move
), et dans une certaine mesure également std::shared_ptr
.
Comme exemple concret pour la discussion, je considérerai le type de liste simple suivant
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Les instances de cette liste (qui ne peuvent pas être autorisées à partager des parties avec d'autres instances ou à être circulaires) appartiennent entièrement à la personne qui détient le list
pointeur initial . Si le code client sait que la liste qu'il stocke ne sera jamais vide, il peut également choisir de stocker la première node
directement plutôt que a list
. Aucun destructeur pour les node
besoins à définir: puisque les destructeurs pour ses champs sont automatiquement appelés, la liste entière sera supprimée récursivement par le destructeur du pointeur intelligent une fois la durée de vie du pointeur ou du nœud initial terminée.
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 (récursivement) un exemple de code client. Le typedef pour list
est bien sûr biaisé unique_ptr
, mais la définition pourrait être modifiée pour être utilisée auto_ptr
ou à la shared_ptr
place sans qu'il soit nécessaire de modifier ce qui est dit ci-dessous (notamment concernant la sécurité des exceptions étant assurée sans avoir besoin d'écrire des destructeurs).
Modes de passage des pointeurs intelligents
Mode 0: passez un pointeur ou un argument de référence 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 s'inquiéter à qui appartient l'objet pointé ou par quel moyen la propriété est gérée, donc passer un pointeur brut est à la fois parfaitement sûr et la forme la plus flexible, car indépendamment de la propriété, un client peut toujours produire un pointeur brut (soit en appelant la get
méthode, soit à partir de l'opérateur d'adresse &
).
Par exemple, la fonction pour calculer la longueur d'une telle liste, ne doit pas être un list
argument, 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 contient une variable list head
peut appeler cette fonction comme length(head.get())
, tandis qu'un client qui a choisi de stocker une node n
liste non vide peut appeler length(&n)
.
Si le pointeur est garanti non nul (ce qui n'est pas le cas ici car les listes peuvent être vides), on pourrait préférer passer une référence plutôt qu'un pointeur. Il peut s'agir d'un pointeur / d'une référence à non- const
si la fonction doit mettre à jour le contenu du ou des nœuds, sans ajouter ou supprimer aucun d'entre eux (ce dernier impliquerait la propriété).
Un cas intéressant qui tombe dans la catégorie du mode 0 est de faire une copie (approfondie) de la liste; si une fonction qui le fait doit naturellement transférer la propriété de la copie qu'elle crée, elle ne se préoccupe pas de la propriété de la liste qu'elle copie. Il pourrait donc être défini 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 de déplacement de unique_ptr<node>
, aka list
, lors de l'initialisation du next
champ de la généré node
), et pour la question de savoir pourquoi il est à l'abri des exceptions (si pendant le processus d'allocation récursive la mémoire s'épuise et un appel de new
lancers std::bad_alloc
, alors à ce moment-là, un pointeur vers la liste partiellement construite est conservé de manière anonyme dans un temporaire de type list
créé pour la liste d'initialisation, et son destructeur nettoiera cette liste partielle). Soit dit en passant, il faudrait résister à la tentation de remplacer (comme je l'ai fait initialement) le second nullptr
parp
, qui après tout est connu pour être nul à ce stade: on ne peut pas construire un pointeur intelligent à partir d'un pointeur (brut) vers constant , 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 dans un temporaire anonyme) est copié dans la valeur d'argument à l'entrée de la fonction et l'appelant le pointeur est devenu nul (dans le cas d'un temporaire, la copie peut avoir été éludée, mais dans tous les cas, l'appelant a perdu l'accès à l'objet pointé). Je voudrais appeler cet appel de mode en espèces : l'appelant paie d'avance le service appelé et ne peut se faire aucune illusion sur la propriété après l'appel. Pour que cela soit clair, les règles de langage exigent que l'appelant encapsule l'argument dansstd::move
si le pointeur intelligent est maintenu dans une variable (techniquement, si l'argument est une valeur l); 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 une valeur temporaire, en laissant la variable nulle.
Dans les cas où la fonction appelée s'approprie inconditionnellement (pilfers) l'objet pointé, ce mode utilisé avec std::unique_ptr
ou std::auto_ptr
est un bon moyen de passer 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 ne doit pas être préféré (très légèrement) au mode 1. Pour cette raison, je ne fournirai aucun exemple d'utilisation de ce mode. (Mais voir l' reversed
exemple du mode 3 ci-dessous, où l'on remarque que le mode 1 ferait au moins aussi bien.) Si la fonction prend plus d'arguments que ce pointeur, il peut arriver qu'il y ait en plus une raison technique pour éviter le mode 1 (avec std::unique_ptr
ou std::auto_ptr
): puisqu'une opération de déplacement réelle a lieu lors du passage d'une variable de pointeurp
par l'expression détient une valeur utile lors de l'évaluation des autres arguments (l'ordre d'évaluation étant non spécifié), ce qui pourrait conduire à des erreurs subtiles; en revanche, l'utilisation du mode 3 garantit qu'aucun déplacement de n'a lieu avant l'appel de fonction, de sorte que d'autres arguments peuvent accéder en toute sécurité à une valeur via .std::move(p)
, on ne peut pas supposer quep
p
p
Lorsqu'il est utilisé avec std::shared_ptr
, ce mode est intéressant car avec une seule définition de fonction, il permet à l'appelant de choisir de conserver ou non une copie de partage du pointeur tout en créant une nouvelle copie de partage à utiliser par la fonction (cela se produit lorsqu'une valeur est fourni; le constructeur de copie pour les pointeurs partagés utilisés lors de l'appel augmente le nombre de références), ou pour simplement donner à la fonction une copie du pointeur sans en conserver un ou sans toucher le nombre de références (cela se produit lorsqu'un argument rvalue est fourni, éventuellement une valeur l enveloppée 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 obtenue en définissant séparément void f(const std::shared_ptr<X>& x)
(pour le cas lvalue) et void f(std::shared_ptr<X>&& x)
(pour le cas rvalue), les corps de fonction différant uniquement en ce que la première version invoque la sémantique de copie (en utilisant la construction / affectation de copie lors de l'utilisation x
) mais la deuxième version déplace la sémantique (en écrivant à la std::move(x)
place, comme dans l'exemple de code). Ainsi, pour les pointeurs partagés, le mode 1 peut être utile pour éviter une certaine duplication de code.
Mode 2: passer un pointeur intelligent par référence de valeur (modifiable)
Ici, la fonction nécessite simplement d'avoir une référence modifiable au pointeur intelligent, mais ne donne aucune indication de ce qu'elle en fera. Je voudrais appeler cette méthode appel par carte : l'appelant assure le paiement en donnant un numéro de carte bancaire. La référence peut être utilisée pour s'approprier 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 transmettre à une telle fonction serait obligé de la stocker dans une variable nommée pour pouvoir effectuer l'appel, car le langage ne fournit qu'une conversion implicite en un constantelvalue référence (se référant à un temporaire) à partir d'une rvalue. (Contrairement à la situation opposée gérée par std::move
, un cast de Y&&
à Y&
, avec Y
le type de pointeur intelligent, n'est pas possible; néanmoins, cette conversion peut être obtenue par une simple fonction de modèle si vous le souhaitez vraiment; voir https://stackoverflow.com/a/24868376 / 1436796 ). Dans le cas où la fonction appelée entend s'approprier inconditionnellement l'objet en dérobant l'argument, l'obligation de fournir un argument lvalue donne le mauvais signal: la variable n'aura aucune valeur utile après l'appel. Par conséquent, le mode 3, qui donne des possibilités identiques à l'intérieur de notre fonction mais demande aux appelants de fournir une valeur r, devrait ê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)} ); }
De toute évidence, il ne serait pas souhaitable ici de forcer les appelants à utiliser std::move
, car leur pointeur intelligent possède toujours une liste bien définie et non vide après l'appel, bien que différente de celle d'avant.
Encore une fois, il est intéressant d'observer ce qui se passe si l' prepend
appel échoue par manque de mémoire libre. Ensuite, l' new
appel sera lancé std::bad_alloc
; à ce stade, comme aucun node
n'a pu être alloué, il est certain que la référence de valeur r passée (mode 3) de std::move(l)
ne peut pas encore être volée, car cela serait fait pour construire le next
champ de celui node
qui n'a pas été alloué. Ainsi, le pointeur intelligent d'origine l
contient toujours la liste d'origine lorsque l'erreur est levée; cette liste sera soit correctement détruite par le destructeur de pointeur intelligent, ou au cas où l
devrait survivre grâce à une catch
clause suffisamment précoce , elle contiendra toujours la liste d'origine.
C'était un exemple constructif; avec un clin d'oeil à cette question, on peut également donner l'exemple le plus destructeur de 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 la déclaration finale, le pointeur (*p)->next
détenu à l'intérieur du nœud à supprimer n'est pas lié (par release
, qui renvoie le pointeur mais rend la valeur nulle d'origine) avant reset
(implicitement) de détruire ce nœud (lorsqu'il détruit l'ancienne valeur détenue par p
), garantissant que un et un seul nœud est détruit à ce moment. (Dans la forme alternative mentionnée dans le commentaire, ce délai serait laissé aux internes de la mise en œuvre de l'opérateur d'affectation de déplacement de l' std::unique_ptr
instance list
; la norme dit 20.7.1.2.3; 2 que cet opérateur devrait agir "comme si par appeler reset(u.release())
", d'où le moment devrait être sûr ici aussi.)
Notez que prepend
et remove_first
ne peuvent pas être appelés par les clients qui stockent une node
variable locale pour une liste toujours non vide, et à juste titre car les implémentations données ne peuvent pas fonctionner dans de tels cas.
Mode 3: passer un pointeur intelligent par une référence de valeur (modifiable)
Il s'agit du mode préféré à utiliser lorsque vous prenez simplement 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 le pointeur (exactement comme lors de l'utilisation du mode 2). ). La "signature du chèque" signifie concrètement que les appelants doivent encapsuler un argument std::move
(comme dans le mode 1) s'il s'agit d'une valeur l (s'il s'agit d'une valeur r, la partie "abandonner la 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 n'a pas à assumer la propriété; Cependant, j'insiste sur le fait qu'en cas d'incertitude concernant le transfert de propriété (en utilisation normale), le mode 2 devrait être préféré au mode 3, de sorte que l'utilisation du mode 3 soit implicitement un signal aux appelants qu'ils sont renoncent à la propriété. On pourrait rétorquer que seul l'argument du mode 1 passant signale vraiment une perte forcée de propriété aux appelants. Mais si un client a des doutes 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
type qui utilise le passage d'arguments en mode 3. Déplacer une liste b
à la fin d'une autre liste a
est un exemple typique; cependant a
(qui survit et détient 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'arguments en mode 3 est le suivant qui prend une liste (et sa propriété), et retourne une liste contenant les nœuds 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 l = reversed(std::move(l));
pour inverser la liste en elle-même, mais la liste inversée peut également être utilisée différemment.
Ici, l'argument est immédiatement déplacé vers une variable locale pour l'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); par conséquent, la différence avec le passage du paramètre mode 1 est minime. En fait, en utilisant ce mode, l'argument aurait pu servir directement de variable locale, évitant ainsi ce mouvement initial; ceci est juste une instance du principe général selon lequel si un argument passé par référence ne sert qu'à initialiser une variable locale, on pourrait tout aussi bien le passer par valeur 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 en utilisant le mode 3. Un exemple particulièrement convaincant est le constructeur std::shared_ptr<T>(auto_ptr<T>&& p)
. Ce constructeur utilisait (in std::tr1
) pour prendre une référence lvalue modifiable (tout comme le auto_ptr<T>&
constructeur de copie), et pouvait donc être appelé avec une auto_ptr<T>
lvalue p
comme in std::shared_ptr<T> q(p)
, après quoi il p
a été réinitialisé à null. En raison du passage du mode 2 au mode 3 lors du passage d'arguments, cet ancien code doit maintenant être réécrit enstd::shared_ptr<T> q(std::move(p))
et continuera à fonctionner. Je comprends que le comité n'aimait pas le mode 2 ici, mais il avait la possibilité de passer au mode 1, en définissantstd::shared_ptr<T>(auto_ptr<T> p)
au lieu de cela, ils auraient pu garantir que l'ancien code fonctionne sans modification, car (contrairement aux pointeurs uniques) les pointeurs automatiques peuvent être silencieusement déréférencés à 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à obsolète.
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ù supposer que la propriété prendrait autrement la forme de déplacer le pointeur intelligent vers une variable locale comme dans l' reversed
exemple ci-dessus. Cependant, je peux voir deux raisons de préférer le mode 3 dans le cas plus général:
Il est légèrement plus efficace de transmettre une référence que de créer un temporaire et de supprimer l'ancien pointeur (la gestion de l'argent est quelque peu laborieuse); dans certains scénarios, le pointeur peut être transmis plusieurs fois sans changement à une autre fonction avant d'être réellement volé. Un tel passage nécessitera généralement l'écriture std::move
(sauf si le mode 2 est utilisé), mais notez qu'il ne s'agit que d'un cast qui ne fait rien (en particulier pas de déréférencement), donc il n'a aucun coût attaché.
Devrait-il être concevable que quelque chose lève une exception entre le début de l'appel de fonction et le point où il (ou un appel contenu) déplace réellement l'objet pointé vers une autre structure de données (et cette exception n'est pas déjà interceptée à l'intérieur de la fonction elle-même ), puis lors de l'utilisation du mode 1, l'objet référencé par le pointeur intelligent sera détruit avant qu'une catch
clause ne puisse gérer l'exception (car le paramètre de fonction a été détruit lors du déroulement de la pile), mais pas lors de l'utilisation du mode 3. Ce dernier donne la l'appelant a la possibilité de récupérer les données de l'objet dans de tels cas (en interceptant l'exception). Notez que le mode 1 ici ne provoque pas de fuite de mémoire , mais peut entraîner une perte irrécupérable de données pour le programme, ce qui peut également être indésirable.
Renvoyer un pointeur intelligent: toujours par valeur
Pour conclure un mot sur le retour d' un pointeur intelligent, pointant vraisemblablement vers un objet créé pour être utilisé par l'appelant. Ce n'est pas vraiment un cas comparable à passer des pointeurs dans des fonctions, mais pour être complet, je voudrais insister sur le fait que dans de tels cas, toujours retourner par valeur (et ne pas utiliser std::move
dans l' return
instruction). Personne ne veut obtenir une référence à un pointeur qui vient probablement d'être supprimé.