4.13.3 Objetos-puntero
§1 Preámbulo
En el capítulo dedicado a punteros (
4.2) hemos visto que estos objetos están
definidos de forma nativa en el lenguaje (en el mismo sentido en que lo están
los tipos int o float), y cómo pueden definirse punteros a
cualquier tipo de entidad incluyendo funciones. Sin embargo, existen
ocasiones en que resulta útil construir tipos abstractos que
funcionen como punteros. Nos referimos a clases cuyas instancias puedan
ser utilizadas como si fuesen punteros a otros objetos.
Observe el lector que no nos referimos a clases cuyos miembros sean punteros, caso este del que hemos visto múltiples ejemplos a lo largo de este tutorial, sino a objetos que puedan ser utilizados como punteros. Observe también que decimos: "utilizados como punteros". La razón es que evidentemente, desde el punto de vista formal tales objetos no son punteros, sino del tipo definido por la clase. Lo aclararemos con un ejemplo; supongamos:
int x = 10;
int* pint = &x;
cout << *pint; // -> 10
En este caso *pint es equivalente a x, y el tipo de pint es puntero-a-int (int*). Lo que deseamos es una clase ClaseP tal que:
class Clase1; // una clase cualquiera
Clase1 obj; // un objeto
class ClaseP; // una clase-puntero
ClaseP opt; // objeto-puntero
...
// algún mecanismo para que opt pueda señalar a obj
*opt; // debe ser equivalente a obj
Pretendemos que las instancias de ClaseP puedan ser utilizadas como punteros, pero observe que en estos casos el
tipo de obj es Clase1, y el de opt es ClaseP (no puntero-a-Clase1).
§2 Objetos-puntero
A estas entidades las denominaremos objetos-puntero, y queremos aplicarles el álgebra de los punteros-a-clase
( 4.2.1f). Es decir, suponiendo que
x y fun sean respectivamente una propiedad y un método de obj, deberíamos poder escribir:
(*opt).x; // Ok. equivalente a obj.x
opt->x; // Ídem.
opt->fun() // Ok. invocación de obj.fun
También deben estar permitidas las operaciones de asignación con estos objetos:
ClaseP opt2 = opt;
opt2->fun(); // Ok. invocación de obj.fun
§2.1 Ejemplo
A continuación se muestra un ejemplo de lo que podría ser el diseño de una de estas clases-puntero. Para evidenciar el paralelismo de comportamiento con los punteros-a-clase primitivos, se define también uno de estos punteros. A continuación comprobamos como las operaciones que pueden hacerse con uno de ellos pueden realizarse también con las instancias de la nueva clase.
#include <iostream> // Ejemplo-1 objeto-puntero
using namespace std;
struct Cls { // una clase cualquiera
int x;
void fe() { cout << "Cls x = " << x << endl; }
Cls(int n=1) : x(n) {}
};
Cls* eptr; // puntero-a-clase tradicional
class ClsPtr { // clase puntero-a-Ext
public:
Cls* ptr;
// L.13:
Cls operator*() { return *ptr; } // L.14
Cls* operator->() { return ptr; } // L.15
ClsPtr() { ptr = NULL; } // constructor por defecto
ClsPtr(Cls& obj) { ptr = &obj; } // constructor
};
int main() { // =================
Cls obj; // M.1: Una instancia de Cls
eptr = &obj; // M.2:
(*eptr).fe(); // M.3:
eptr->fe();
cout << "Valor Cls x = " << (*eptr).x << endl;
ClsPtr op1(obj); // M.7:
(*op1).fe(); // M.8:
op1->fe();
cout << "Valor Cls x = " << (*op1).x << endl;
ClsPtr op2 = op1; // M.12
cout << "Valor Cls x = " << (*op2).x << endl;
return 0;
}
Salidas:
Cls x = 1
Cls x = 1
Valor Cls x = 1
Cls x = 1
Cls x = 1
Valor Cls x = 1
Valor Cls x = 1
Comentario:
La clase Cls (todos sus miembros son públicos) con una propiedad y un método, sirve de testigo. A continuación definimos eptr; un puntero tradicional a Cls que servirá de término de comparación.
La clase-puntero ClsPtr es diseñada ex profeso para que sus objetos sirvan como punteros-a-Cls. El secreto
estriba principalmente en el miembro ptr (L.13) que es un puntero-a-Cls. Para utilizar con estos objetos los
operadores de indirección *
( 4.9.11)
y selector indirecto de miembro ->
(
4.9.16),
ha sido necesario sobrecargarlos para los miembros de la clase (sentencias L.14 y L.15). El diseño se completa con sendos
constructores. El primero es por defecto; el segundo acepta una referencia-a-Cls y es utilizado para iniciar el puntero
ptr del objeto creado con el valor correspondiente.
Las sentencias M.1 y M.2 se limitan a crear una instancia obj de la clase-testigo y a iniciar el puntero eptr
con la dirección del objeto. M.3 y M.4 invocan el método fe() de obj utilizando diversas notaciones. Finalmente M.5
muestra el valor de la propiedad x utilizando el operador de indirección
* y el selector directo de miembro
. (
4.9.16).
Este grupo de sentencias es responsable de las tres primeras salidas.
El grupo M.7-M.10, responsables de las salidas 4 a 6, es análogo al anterior, pero utilizando un objeto-puntero op1. Observe que las sentencias M.8-M.10 son paralelas a M.3-M.5. Sin embargo, la inicialización del objeto op1 no puede realizarse igual que con eptr. En efecto, un intento de construir una sentencia paralela a M.2:
ClsPtr op1 = &obj; // M.7bis Error!!
provocaría un error de compilación: Cannot convert 'Cls *' to 'ClsPtr' in..
. La razón es que evidentemente,
el tipo del Rvalue; puntero-a-Cls (Cls*) no puede ser convertido al tipo del Lvalue (ClsPtr). Por esta razón,
la inicialización de este tipo de objetos es encomendada al constructor, al que se pasa el argumento adecuado.
Finalmente, en M.12 se crea una nueva instancia de ClsPtr utilizando el constructor por defecto. A continuación, el operador de asignación proporcionado por defecto por el compilador, inicia el nuevo objeto con los valores del anterior. La sentencia M.13, responsable de la última salida, es comprobación de que esta asignación miembro a miembro es suficiente en este caso.
§3 Un diseño más general
El diseño anterior adolece de un inconveniente: la clase-puntero, debe ser construida específicamente para servir de puntero a objetos de otra clase (en nuestro ejemplo, ClsPtr solo sirve para señalar objetos de tipo Cls). Sin embargo, es posible un diseño más general recurriendo al mecanismo de plantillas:
template<class T> class ClPt { // clase-puntero genérica
public:
T* ptr;
T operator*() { return *ptr; }
T* operator->() { return ptr; }
ClPt() { ptr = NULL; } // constructor
ClPt(T& obj) { ptr = &obj; } // constructor
};
Este diseño permite que la clase ClPt pueda ser utilizada como puntero a objetos de cualquier tipo. Por ejemplo, siguiendo con el supuesto anterior:
ClPt<Cls> op1(obj); // instanciar un objeto-puntero
op1->fe(); // -> Cls x = 1
cout << (*op1).x; // -> 1
§4 Algunos refinamientos adicionales
Es frecuente que los objetos-puntero sean utilizados para acceder objetos creados con new
( 4.9.20), para lo que se recurre a una
invocación implícita o explícita del constructor con los argumentos adecuados. Por ejemplo:
ClPt<Cls> op1(new Cls); // invocación implícita
ClPt<Cls> op1 = ClPt<Cls>(new Cls); // invocación explícita
Las sentencias anteriores nos obligan a definir un nuevo constructor de ClPt que acepte un puntero-a-T como
argumento, ya que este es el valor devuelto por new T. En consecuencia el nuevo diseño queda como sigue:
template<class T> class ClPt { // clase- puntero genérica
public:
T* ptr;
T operator*() { return *ptr; }
T* operator->() { return ptr; }
ClPt() { ptr = NULL; } // constructor
ClPt(T& obj) { ptr = &obj; } // constructor
ClPt(T* pt) { ptr = pt; } // constructor
};
§4.1 El hecho de que los objetos creados con new sean
referenciados mediante un objeto-puntero, en vez de un puntero básico, puede
resolver uno de los principales problemas de los objetos persistentes: que un
olvido o circunstancia imprevista origine una pérdida de memoria porque no se
invoque la destrucción con delete. Es la situación del siguiente esquema:
func () {
Cls* eptr = new Cls;
...
}
Si olvidamos liberar el espacio previamente asignado con new, al salir eptr de ámbito, tendremos una pérdida permanente de memoria en el montón. Sin embargo, una construcción semejante con un objeto-puntero podría evitarla:
func () {
ClPt<Cls> op1(new Cls);
...
}
Para ello, aprovechando que el compilador invoca automáticamente el destructor del objeto op1 al salir este de ámbito [1], añadimos a la clase-puntero un destructor explícito que sea capaz de destruir el objeto referenciado:
~ClPt() { delete ptr; } // destructor
A continuación se muestra un ejemplo compilable, junto con las salidas obtenidas. Observe que en el destructor hemos
incluido un mecanismo de control para el caso de que el objeto sea un puntero nulo:
#include <iostream> // Ejemplo-2 objeto-puntero
using namespace std;
struct Cls { // una clase cualquiera
int x;
};
template<class T> class ClPt { // clase puntero generica
public:
T* ptr; // L.13:
T operator*() { return *ptr; }
T* operator->() { return ptr; }
ClPt() { ptr = NULL; } // constructor
ClPt(T& obj) { ptr = &obj; } // constructor
ClPt(T* pt) { ptr = pt; } // constructor
~ClPt() {
// destructor
if (ptr != NULL) {
delete ptr;
cout << "Destruido objeto persistente" << endl;
}
else
cout << "Puntero nulo. No destruir objeto" << endl;
}
};
int main() { // =================
{
ClPt<Cls> op1(new Cls);
ClPt<Cls> op2; // M.3:
}
return 0;
}
Salida:
Puntero nulo. No destruir objeto
Destruido objeto persistente
Comentario: Observe que los objetos son destruidos en orden inverso al de creación.
§4.2 Incluir la propiedad del objeto
El diseño anterior adolece todavía de un grave problema: varios objetos-puntero pueden señalar a la misma entidad; al salir de ámbito el primero la destruye, pero el intento de los demás de destruir un objeto inexistente producirá un error de runtime. Para comprobarlo basta modificar la sentencia M.3 del ejemplo anterior:
ClPt<Cls> op2 = op1; // M.3bis:
Esta modificación produce un error fatal al ejecutar el programa. La salida de ámbito de op2 destruye el objeto creado con new y recupera la memoria asignada, pero el intento posterior de hacer lo mismo por parte de op1 produce un error.
Una forma de evitar este inconveniente, es conseguir que distintos objetos-puntero no puedan señalar simultáneamente a una misma entidad; para esto se utiliza el concepto de "propiedad": si un objeto-puntero señala a un objeto detenta la "propiedad" del mismo, y ningún otro puede señalarlo. Cualquier intento de reasignación origina la pérdida de propiedad del primero en favor del segundo [2].
La forma práctica de conseguirlo es añadir a la clase-puntero un constructor-copia
( 4.11.2d4) que realice el trabajo
correspondiente. En nuestro caso tendría el siguiente aspecto:
ClPt(ClPt& obj) {
// constructor-copia
if (obj.ptr == NULL) {
ptr = NULL;
}
else {
ptr = obj.ptr;
obj.ptr = NULL;
}
}
Podemos comprobar que si el objeto copiado es un puntero nulo, el nuevo puntero también lo es nulo, y ninguno detenta ninguna propiedad. Sería el caso de las sentencias:
ClPt<Cls> op1;
ClPt<Cls> op2 = op1;
En cambio, si el objeto copiado es propietario de un objeto, lo perdería en favor del segundo transformándose en un puntero nulo. Sería el caso de:
ClPt<Cls> op1(new Cls);
ClPt<Cls> op2 = op1;
Generalmente, los objetos-puntero se relacionan con el control de recursos (

A continuación se muestra el nuevo diseño en condiciones reales, junto con el resultado obtenido con las sentencias que antes producían error:
#include <iostream> // Ejemplo-3 objeto-puntero
using namespace std;
struct Cls { // una clase cualquiera
int x;
};
template<class T> class ClPt { // clase puntero generica
public:
T* ptr; // L.13:
T operator*() { return *ptr; }
T* operator->() { return ptr; }
ClPt() { ptr = NULL; } // constructor por defecto
ClPt(T& obj) { ptr = &obj; } // constructor
ClPt(T* pt) { ptr = pt; } // constructor
ClPt(ClPt& obj) {
// constructor-copia
if (obj.ptr == NULL) {
ptr = NULL;
}
else {
ptr = obj.ptr;
obj.ptr = NULL;
}
}
~ClPt() {
// destructor
if (ptr != NULL) {
delete ptr;
cout << "Destruido objeto
persistente\n";
}
else cout << "Puntero nulo. No destruir
objeto\n";
}
};
int main() { // =================
{
ClPt<Cls> op1(new Cls);
ClPt<Cls> op2 = op1;
}
return 0;
}
Salida:
Destruido objeto persistente
Puntero nulo. No destruir objeto
Comentario:
Aunque la destrucción de objetos sigue realizándose en orden inverso al de creación, ahora op2 señala a un objeto, mientras que op1 ha pasado a ser un puntero nulo al perder la propiedad del objeto creado con new.
§4.3 Aunque el diseño anterior garantiza la propiedad del puntero para los objetos de nueva creación,
todavía pueden presentarse duplicidades si utilizamos el operador de asignación proporcionado por el compilador. Podemos
comprobarlo incluyendo una sentencia de asignación después de M3:
{
ClPt<Cls> op1(new Cls);
ClPt<Cls> op2 = op1;
op1 = op2;
}
return 0;
Este nuevo diseño de main produce igualmente un error de ejecución. La causa es que el operador de asignación
= proporcionado por el compilador (operador = por defecto) no garantiza unicidad
en la propiedad del puntero. Para evitar el inconveniente sobrecargamos el referido operador para los objetos de la clase
ClPt de forma adecuada ( 4.9.18a).
El nuevo método operator=() presenta el aspecto siguiente:
ClPt& operator= (ClPt& obj) { // Operador de asignacion
if (ptr != NULL) delete ptr; // L.1:
if (obj.ptr == NULL) {
ptr = NULL;
}
else {
ptr = obj.ptr;
obj.ptr = NULL;
}
return *this;
}
Como primera medida (L.1) si el objeto actual es propietario de un objeto, se procede a su destrucción. A continuación vienen las precauciones habituales: si el Rvalue es un puntero nulo, el Lvalue también lo será. En caso contrario, el Lvalue se hace con la propiedad y el Rvalue queda reducido a un puntero nulo.
Observe que esta definición de operator=() se aparta de la convencional, en la que se pasa una referencia constante:
ClaseX& operator= (const ClaseX& obj);
La razón es que en este caso, una asignación del tipo obj1 = obj2; además de una modificación del Lvalue, puede producir también una alteración del Rvalue.
§5 Corolario
Por razón de su comportamiento, los objetos-puntero así definidos, son denominados también punteros inteligentes. La
Librería Estándar C++ presenta una versión directamente utilizable bajo la forma de la clase genérica auto_ptr
( 4.12.2b1), que incluye algunos
refinamientos adicionales sobre los señalados.
[1] También es invocado el destructor si el objeto es desalojado de la pila porque se ha producido una
excepción ("Stack unwinding"
1.6).
[2] Otra forma de conseguirlo se denomina contador de referencias ("Reference counting"). En esta técnica se ha eliminado el concepto de propiedad, de forma que varios objetos-puntero pueden señalar al mismo objeto persistente. Para evitar que cuando uno de estos objetos salga de ámbito destruya el objeto, si existen otros que todavía lo señalan (lo que produciría punteros descolgados), el objeto referenciado mantiene un contador de los punteros que lo referencian. Cuando un objeto-puntero sale de ámbito, su destructor consulta el contador y lo disminuye en una unidad. Solo si el contador llega a cero, el objeto referenciado es destruido. El sistema es aplicado en muchos otros ámbitos. Por ejemplo, los SO Windows mantiene un mecanismo análogo para saber si una .DLL puede ser eliminada al eliminar una aplicación que la utiliza.