Permítanme tratar de establecer los diferentes modos viables de pasar punteros a objetos cuya memoria es administrada por una instancia de la std::unique_ptr
plantilla de clase; también se aplica a la std::auto_ptr
plantilla de clase anterior (que creo que permite todos los usos que hace un puntero único, pero para los cuales además se aceptarán valores modificables donde se esperan valores, sin tener que invocar std::move
), y en cierta medida también std::shared_ptr
.
Como ejemplo concreto para la discusión, consideraré el siguiente tipo de lista simple
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Las instancias de dicha lista (que no pueden compartir partes con otras instancias o ser circulares) son propiedad exclusiva de quien posea el list
puntero inicial . Si el código del cliente sabe que la lista que almacena nunca estará vacía, también puede optar por almacenar la primera node
directamente en lugar de a list
. No node
es necesario definir ningún destructor : dado que los destructores para sus campos se invocan automáticamente, el destructor de puntero inteligente eliminará recursivamente toda la lista una vez que finalice la vida útil del puntero o nodo inicial.
Este tipo recursivo brinda la oportunidad de analizar algunos casos que son menos visibles en el caso de un puntero inteligente para datos sin formato. Además, las funciones mismas ocasionalmente proporcionan (recursivamente) un ejemplo de código de cliente también. El typedef para, list
por supuesto, está sesgado hacia unique_ptr
, pero la definición podría cambiarse para usar auto_ptr
o en su shared_ptr
lugar sin mucha necesidad de cambiar a lo que se dice a continuación (en particular, con respecto a la seguridad de excepción garantizada sin la necesidad de escribir destructores).
Modos de pasar punteros inteligentes
Modo 0: pase un puntero o argumento de referencia en lugar de un puntero inteligente
Si su función no está relacionada con la propiedad, este es el método preferido: no haga que tome un puntero inteligente. En este caso, su función no necesita preocuparse de quién es el propietario del objeto señalado o de qué manera se gestiona la propiedad, por lo que pasar un puntero sin formato es perfectamente seguro y la forma más flexible, ya que independientemente de la propiedad, un cliente siempre puede producir un puntero sin formato (ya sea llamando al get
método o desde la dirección del operador &
).
Por ejemplo, la función para calcular la longitud de dicha lista no debe ser un list
argumento, sino un puntero sin formato:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
Un cliente que tiene una variable list head
puede llamar a esta función como length(head.get())
, mientras que un cliente que ha elegido almacenar una node n
lista que no está vacía puede llamar length(&n)
.
Si se garantiza que el puntero no es nulo (que no es el caso aquí ya que las listas pueden estar vacías), uno podría preferir pasar una referencia en lugar de un puntero. Puede ser un puntero / referencia a non- const
si la función necesita actualizar el contenido de los nodos, sin agregar o eliminar ninguno de ellos (esto último implicaría la propiedad).
Un caso interesante que cae en la categoría del modo 0 es hacer una copia (profunda) de la lista; Si bien una función que realiza esto, por supuesto, debe transferir la propiedad de la copia que crea, no le preocupa la propiedad de la lista que está copiando. Por lo tanto, podría definirse de la siguiente manera:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
Este código merece una mirada cercana, tanto para la pregunta de por qué se compila en absoluto (el resultado de la llamada recursiva a copy
en la lista de inicializador se une al argumento de referencia rvalue en el constructor de movimiento de unique_ptr<node>
, aka list
, al inicializar el next
campo del generado node
), y para la pregunta de por qué es seguro para excepciones (si durante el proceso de asignación recursiva se agota la memoria y se produce una llamada de new
lanzamiento std::bad_alloc
, en ese momento un puntero a la lista parcialmente construida se mantiene de forma anónima en un tipo temporal list
creado para la lista de inicializadores, y su destructor limpiará esa lista parcial). Por cierto, uno debe resistir la tentación de reemplazar (como lo hice inicialmente) el segundo nullptr
porp
, que después de todo se sabe que es nulo en ese punto: no se puede construir un puntero inteligente desde un puntero (en bruto) a constante , incluso cuando se sabe que es nulo.
Modo 1: pase un puntero inteligente por valor
Una función que toma un valor de puntero inteligente como argumento toma posesión del objeto señalado de inmediato: el puntero inteligente que tenía la persona que llama (ya sea en una variable nombrada o en una temporal anónima) se copia en el valor del argumento en la entrada de la función y la persona que llama el puntero se ha convertido en nulo (en el caso de un temporal, la copia podría haberse omitido, pero en cualquier caso la persona que llama ha perdido el acceso al objeto señalado). Me gustaría llamar a esta llamada de modo en efectivo : la persona que llama paga por adelantado por el servicio llamado, y no puede hacerse ilusiones sobre la propiedad después de la llamada. Para aclarar esto, las reglas del lenguaje requieren que la persona que llama ajuste el argumento enstd::move
si el puntero inteligente se mantiene en una variable (técnicamente, si el argumento es un valor l); en este caso (pero no para el modo 3 a continuación), esta función hace lo que su nombre sugiere, es decir, mover el valor de la variable a temporal, dejando la variable nula.
Para los casos en que la función llamada toma posesión incondicionalmente (roba) el objeto señalado, este modo se utiliza con std::unique_ptr
o std::auto_ptr
es una buena forma de pasar un puntero junto con su propiedad, lo que evita cualquier riesgo de pérdidas de memoria. No obstante, creo que solo hay muy pocas situaciones en las que el modo 3 a continuación no sea preferible (muy ligeramente) sobre el modo 1. Por esta razón, no proporcionaré ejemplos de uso de este modo. (Pero vea el reversed
ejemplo del modo 3 a continuación, donde se observa que el modo 1 funcionaría al menos también). Si la función toma más argumentos que solo este puntero, puede suceder que haya además una razón técnica para evitar el modo 1 (con std::unique_ptr
o std::auto_ptr
): dado que se realiza una operación de movimiento real al pasar una variable de punterop
por la expresión std::move(p)
, no se puede suponer que p
tiene un valor útil al evaluar los otros argumentos (el orden de evaluación no está especificado), lo que podría conducir a errores sutiles; por el contrario, el uso del modo 3 asegura que no p
se realice ningún movimiento antes de la llamada a la función, por lo que otros argumentos pueden acceder de forma segura a un valor p
.
Cuando se usa con std::shared_ptr
, este modo es interesante porque con una definición de función única permite que la persona que llama elija si desea guardar una copia compartida del puntero mientras crea una nueva copia compartida para que la función la use (esto sucede cuando un valor se proporciona el argumento; el constructor de copia para los punteros compartidos utilizados en la llamada aumenta el recuento de referencia), o simplemente para dar a la función una copia del puntero sin retener uno o tocar el recuento de referencia (esto sucede cuando se proporciona un argumento de valor, posiblemente un valor envuelto en una llamada de std::move
). Por ejemplo
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
}
Lo mismo podría lograrse definiendo por separado void f(const std::shared_ptr<X>& x)
(para el caso lvalue) y void f(std::shared_ptr<X>&& x)
(para el caso rvalue), con cuerpos de función que difieren solo en que la primera versión invoca semántica de copia (usando construcción / asignación de copia cuando se usa x
) pero la segunda versión mueve semántica (escribiendo en su std::move(x)
lugar, como en el código de ejemplo). Entonces, para los punteros compartidos, el modo 1 puede ser útil para evitar la duplicación de código.
Modo 2: pase un puntero inteligente por (modificable) referencia de valor
Aquí la función solo requiere tener una referencia modificable al puntero inteligente, pero no da ninguna indicación de lo que hará con él. Me gustaría llamar a este método llamar con tarjeta : la persona que llama asegura el pago dando un número de tarjeta de crédito. La referencia se puede utilizar para tomar posesión del objeto señalado, pero no es necesario. Este modo requiere proporcionar un argumento de valor variable modificable, correspondiente al hecho de que el efecto deseado de la función puede incluir dejar un valor útil en la variable del argumento. Una persona que llama con una expresión de valor que desea pasar a dicha función se vería obligada a almacenarla en una variable con nombre para poder realizar la llamada, ya que el lenguaje solo proporciona conversión implícita a una constantelvalue reference (refiriéndose a un temporal) de un rvalue. (A diferencia de la situación opuesta manejada por std::move
, no es posible una conversión de Y&&
a Y&
, con Y
el tipo de puntero inteligente; sin embargo, esta conversión podría obtenerse mediante una función de plantilla simple si realmente se desea; consulte https://stackoverflow.com/a/24868376 / 1436796 ). Para el caso en el que la función llamada tiene la intención de tomar posesión incondicionalmente del objeto, robando del argumento, la obligación de proporcionar un argumento lvalue está dando la señal incorrecta: la variable no tendrá un valor útil después de la llamada. Por lo tanto, debe preferirse el modo 3, que ofrece posibilidades idénticas dentro de nuestra función, pero pide a las personas que llaman que proporcionen un valor r, para tal uso.
Sin embargo, hay un caso de uso válido para el modo 2, es decir, funciones que pueden modificar el puntero o el objeto señalado de una manera que implica propiedad . Por ejemplo, una función que antepone un nodo a un list
proporciona un ejemplo de tal uso:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
Claramente, sería indeseable forzar el uso de las personas que llaman std::move
, ya que su puntero inteligente aún posee una lista bien definida y no vacía después de la llamada, aunque es diferente a la anterior.
Nuevamente, es interesante observar lo que sucede si la prepend
llamada falla por falta de memoria libre. Entonces la new
llamada se lanzará std::bad_alloc
; en este momento, dado que no se node
pudo asignar, es seguro que la referencia de valor pasada (modo 3) de std::move(l)
todavía no se ha podido robar, ya que eso se haría para construir el next
campo del node
que no se pudo asignar. Por lo tanto, el puntero inteligente original l
aún conserva la lista original cuando se produce el error; esa lista será destruida adecuadamente por el destructor de puntero inteligente, o en caso de l
que sobreviva gracias a una catch
cláusula suficientemente temprana , aún conservará la lista original.
Ese fue un ejemplo constructivo; Con un guiño a esta pregunta , también se puede dar el ejemplo más destructivo de eliminar el primer nodo que contiene un valor dado, si lo hay:
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);
}
Nuevamente, la corrección es bastante sutil aquí. Notablemente, en la declaración final, el puntero que se encuentra (*p)->next
dentro del nodo que se va a eliminar se desvincula (por release
, lo que devuelve el puntero pero hace que el original sea nulo) antes reset
(implícitamente) de destruir ese nodo (cuando destruye el valor anterior mantenido p
), asegurando que uno y solo un nodo se destruye en ese momento. (En la forma alternativa mencionada en el comentario, este tiempo se dejaría a los internos de la implementación del operador de asignación de movimiento de la std::unique_ptr
instancia list
; el estándar dice 20.7.1.2.3; 2 que este operador debe actuar "como si fuera llamando reset(u.release())
", por lo que el tiempo debe ser seguro aquí también.)
Tenga en cuenta que prepend
y remove_first
no puede ser llamado por los clientes que almacenan un local de node
variable para un siempre lista no vacía, y con razón, ya que las implementaciones dadas no podía trabajar para estos casos.
Modo 3: pase un puntero inteligente por referencia de valor (modificable)
Este es el modo preferido para usar cuando simplemente toma posesión del puntero. Me gustaría llamar a este método llamar con cheque : la persona que llama debe aceptar renunciar a la propiedad, como si proporcionara efectivo, al firmar el cheque, pero el retiro real se pospone hasta que la función llamada realmente robe el puntero (exactamente como lo haría al usar el modo 2 ) La "firma del cheque" significa concretamente que las personas que llaman tienen que ajustar un argumento std::move
(como en el modo 1) si es un valor l (si es un valor r, la parte "renunciar a la propiedad" es obvia y no requiere un código separado).
Tenga en cuenta que técnicamente el modo 3 se comporta exactamente como el modo 2, por lo que la función llamada no tiene que asumir la propiedad; sin embargo, insistiría en que si existe alguna incertidumbre sobre la transferencia de propiedad (en uso normal), se debe preferir el modo 2 al modo 3, de modo que el uso del modo 3 implícitamente sea una señal para quienes llaman de que están renunciando a la propiedad. Uno podría replicar que solo pasar el argumento del modo 1 realmente indica la pérdida forzada de propiedad a las personas que llaman. Pero si un cliente tiene dudas sobre las intenciones de la función llamada, se supone que debe conocer las especificaciones de la función que se llama, lo que debería eliminar cualquier duda.
Es sorprendentemente difícil encontrar un ejemplo típico de nuestro list
tipo que utilice el paso de argumentos en modo 3. Mover una lista b
al final de otra lista a
es un ejemplo típico; sin embargo a
(que sobrevive y mantiene el resultado de la operación) se pasa mejor usando el modo 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 ejemplo puro de pasar el argumento del modo 3 es el siguiente que toma una lista (y su propiedad) y devuelve una lista que contiene los nodos idénticos en orden inverso.
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;
}
Se puede llamar a esta función como l = reversed(std::move(l));
para revertir la lista en sí misma, pero la lista revertida también se puede usar de manera diferente.
Aquí el argumento se mueve inmediatamente a una variable local para la eficiencia (se podría haber usado el parámetro l
directamente en lugar de p
, pero luego acceder a él cada vez implicaría un nivel adicional de indirección); por lo tanto, la diferencia con el paso del argumento del modo 1 es mínima. De hecho, utilizando ese modo, el argumento podría haber servido directamente como variable local, evitando así ese movimiento inicial; Esta es solo una instancia del principio general de que si un argumento pasado por referencia solo sirve para inicializar una variable local, uno podría pasarlo por valor y usar el parámetro como variable local.
El uso del modo 3 parece ser defendido por el estándar, como lo demuestra el hecho de que todas las funciones de biblioteca proporcionadas que transfieren la propiedad de los punteros inteligentes usando el modo 3. Un caso convincente en particular es el constructor std::shared_ptr<T>(auto_ptr<T>&& p)
. Ese constructor solía (in std::tr1
) tomar una referencia de valor de l modificable (al igual que el auto_ptr<T>&
constructor de copia) y, por lo tanto, podría llamarse con un valor de auto_ptr<T>
l p
como in std::shared_ptr<T> q(p)
, después de lo cual p
se ha restablecido a nulo. Debido al cambio del modo 2 al 3 en el paso de argumentos, este antiguo código ahora debe reescribirse std::shared_ptr<T> q(std::move(p))
y continuará funcionando. Entiendo que al comité no le gustó el modo 2 aquí, pero tenían la opción de cambiar al modo 1, definiendostd::shared_ptr<T>(auto_ptr<T> p)
en cambio, podrían haberse asegurado de que el código antiguo funciona sin modificación, porque (a diferencia de los punteros únicos) los punteros automáticos pueden desreferenciarse silenciosamente a un valor (el objeto puntero se restablece a nulo en el proceso). Aparentemente, el comité prefirió tanto el modo de defensa 3 sobre el modo 1, que eligió romper activamente el código existente en lugar de usar el modo 1 incluso para un uso ya obsoleto.
Cuándo preferir el modo 3 sobre el modo 1
El modo 1 es perfectamente utilizable en muchos casos, y podría preferirse sobre el modo 3 en los casos en que asumir la propiedad de otra manera tomaría la forma de mover el puntero inteligente a una variable local como en el reversed
ejemplo anterior. Sin embargo, puedo ver dos razones para preferir el modo 3 en el caso más general:
Es un poco más eficiente pasar una referencia que crear un indicador temporal y rechazar el puntero anterior (manejar efectivo es algo laborioso); En algunos escenarios, el puntero se puede pasar varias veces sin cambios a otra función antes de que sea realmente robado. Dicha aprobación generalmente requerirá escritura std::move
(a menos que se use el modo 2), pero tenga en cuenta que esto es solo un reparto que en realidad no hace nada (en particular, no elimina la referencia), por lo que tiene un costo cero adjunto.
En caso de que sea concebible que algo arroje una excepción entre el inicio de la llamada a la función y el punto donde (o alguna llamada contenida) realmente mueve el objeto señalado a otra estructura de datos (y esta excepción aún no está atrapada dentro de la función en sí) ), cuando se usa el modo 1, el objeto al que hace referencia el puntero inteligente se destruirá antes de que una catch
cláusula pueda manejar la excepción (porque el parámetro de la función se destruyó durante el desenrollado de la pila), pero no así cuando se usa el modo 3. Este último proporciona el la persona que llama tiene la opción de recuperar los datos del objeto en tales casos (al detectar la excepción). Tenga en cuenta que el modo 1 aquí no causa una pérdida de memoria , pero puede conducir a una pérdida irrecuperable de datos para el programa, lo que también podría ser indeseable.
Devolver un puntero inteligente: siempre por valor
Para concluir una palabra acerca de devolver un puntero inteligente, presumiblemente apuntando a un objeto creado para ser utilizado por la persona que llama. Este no es realmente un caso comparable con pasar punteros a funciones, pero para completar, me gustaría insistir en que en tales casos siempre regrese por valor (y no lo use std::move
en la return
declaración). Nadie quiere obtener una referencia a un puntero que probablemente acaba de ser rechazado.