12.5. Sobrecargar la asignación

Una causa común de confusión para los nuevos programadores de C++ es la asignación. De esto no hay duda porque el signo = es una operación fundamental en la programación, directamente hasta copiar un registro en el nivel de máquina. Además, el constructor de copia (descrito en el capítulo 11) [FIXME:referencia] es invocado cuando el signo = se usa así:

MyType b;
    MyType a = b;
    a = b;

En la segunda línea, se define el objeto a. Se crea un nuevo objeto donde no existía ninguno. Dado que ya sabe hasta que punto es quisquilloso el compilador de C++ respecto a la inicialización de objetos, sabrá que cuando se define un objeto, siempre se invoca un constructor. Pero ¿qué constructor?, a se crea desde un objeto existente MyType (b, en el lado derecho del signo de igualdad), así que solo hay una opción: el constructor de copia. Incluso aunque el signo de igualdad esté involucrado, se llama al constructor de copia.

En la tercera línea, las cosas son diferentes. En la parte izquierda del signo igual, hay un objeto previamente inicializado. Claramente, no se invoca un constructor para un objeto que ya ha sido creado. En este caso MyType::operator= se llama para a, tomando como argumento lo que sea que aparezca en la parte derecha. (Puede tener varias funciones operator= que tomen diferentes argumentos en la parte derecha).

Este comportamiento no está restringido al constructor de copia. Cada vez que inicializa un objeto usando un signo = en lugar de la forma usual de llamada al constructor, el compilador buscará un constructor que acepte lo que sea que haya en la parte derecha:

//: C12:CopyingVsInitialization.cpp
class Fi {
public:
  Fi() {}
};

class Fee {
public:
  Fee(int) {}
  Fee(const Fi&) {}
};

int main() {
  Fee fee = 1; // Fee(int)
  Fi fi;
  Fee fum = fi; // Fee(Fi)
} ///:~

Listado 12.13. C12/CopyingVsInitialization.cpp


Cuando se trata con el signo =, es importante mantener la diferencia en mente: Si el objeto no ha sido creado todavía, se requiere una inicialización; en otro caso se usa el operador de asignación =.

Es incluso mejor evitar escribir código que usa = para la inicialización; en cambio, use siempre la manera del constructor explícito. Las dos construcciones con el signo igual se convierten en:

Fee fee(1);
    Fee fum(fi);

De esta manera, evitará confundir a sus lectores.

12.5.1. Comportamiento del operador =

En Integer.h y en Byte.h vimos que el operador = sólo puede ser una función miembro. Está íntimamente ligado al objeto que hay en la parte izquierda del =. Si fuese posible definir operator= de forma global, entonces podría intentar redefinir el signo = del lenguaje:

int operator=(int, MyType);   // Global = !No permitido!

El compilador evita esta situación obligandole a hacer un método operator=.

Cuando cree un operator=, debe copiar toda la información necesaria desde el objeto de la parte derecha al objeto actual (es decir, el objeto para el que operator= está siendo llamado) para realizar lo que sea que considere «asignación» para su clase. Para objetos simples, esto es trivial:

//: C12:SimpleAssignment.cpp
// Simple operator=()
#include <iostream>
using namespace std;

class Value {
  int a, b;
  float c;
public:
  Value(int aa = 0, int bb = 0, float cc = 0.0)
    : a(aa), b(bb), c(cc) {}
  Value& operator=(const Value& rv) {
    a = rv.a;
    b = rv.b;
    c = rv.c;
    return *this;
  }
  friend ostream&
  operator<<(ostream& os, const Value& rv) {
    return os << "a = " << rv.a << ", b = "
      << rv.b << ", c = " << rv.c;
  }
};

int main() {
  Value a, b(1, 2, 3.3);
  cout << "a: " << a << endl;
  cout << "b: " << b << endl;
  a = b;
  cout << "a after assignment: " << a << endl;
} ///:~

Listado 12.14. C12/SimpleAssignment.cpp


Aquí, el objeto de la parte izquierda del igual copia todos los elementos del objeto de la parte derecha, y entonces devuelve una referencia a sí mismo, lo que permite crear expresiones más complejas.

Este ejemplo incluye un error comón. Cuando asignane dos objetos del mismo tipo, siempre debería comprobar primero la auto-asignación: ¿Está asignado el objeto a sí mismo?. En algunos casos como éste, es inofensivo si realiza la operación de asignación en todo caso, pero si se realizan cambios a la implementación de la clase, puede ser importante y si no lo toma con una cuestión de costumbre, puede olvidarlo y provocar errores difíciles de encontrar.

Punteros en clases

¿Qué ocurre si el objeto no es tan simple?. Por ejemplo, ¿qué pasa si el objeto contiene punteros a otros objetos?. Sólo copiar el puntero significa que obtendrá dos objetos que apuntan a la misma localización de memoria. En situaciones como ésta, necesita hacer algo de contabilidad.

Hay dos aproximaciones a este problema. La técnica más simple es copiar lo que quiera que apunta el puntero cuando realiza una asignación o una construcción de copia. Esto es sencillo:

//: C12:CopyingWithPointers.cpp
// Solving the pointer aliasing problem by
// duplicating what is pointed to during 
// assignment and copy-construction.
#include "../require.h"
#include <string>
#include <iostream>
using namespace std;

class Dog {
  string nm;
public:
  Dog(const string& name) : nm(name) {
    cout << "Creating Dog: " << *this << endl;
  }
  // Synthesized copy-constructor & operator= 
  // are correct.
  // Create a Dog from a Dog pointer:
  Dog(const Dog* dp, const string& msg) 
    : nm(dp->nm + msg) {
    cout << "Copied dog " << *this << " from "
         << *dp << endl;
  }
  ~Dog() { 
    cout << "Deleting Dog: " << *this << endl;
  }
  void rename(const string& newName) {
    nm = newName;
    cout << "Dog renamed to: " << *this << endl;
  }
  friend ostream&
  operator<<(ostream& os, const Dog& d) {
    return os << "[" << d.nm << "]";
  }
};

class DogHouse {
  Dog* p;
  string houseName;
public:
  DogHouse(Dog* dog, const string& house)
   : p(dog), houseName(house) {}
  DogHouse(const DogHouse& dh)
    : p(new Dog(dh.p, " copy-constructed")),
      houseName(dh.houseName 
        + " copy-constructed") {}
  DogHouse& operator=(const DogHouse& dh) {
    // Check for self-assignment:
    if(&dh != this) {
      p = new Dog(dh.p, " assigned");
      houseName = dh.houseName + " assigned";
    }
    return *this;
  }
  void renameHouse(const string& newName) {
    houseName = newName;
  }
  Dog* getDog() const { return p; }
  ~DogHouse() { delete p; }
  friend ostream&
  operator<<(ostream& os, const DogHouse& dh) {
    return os << "[" << dh.houseName 
      << "] contains " << *dh.p;
  }
}; 

int main() {
  DogHouse fidos(new Dog("Fido"), "FidoHouse");
  cout << fidos << endl;
  DogHouse fidos2 = fidos; // Copy construction
  cout << fidos2 << endl;
  fidos2.getDog()->rename("Spot");
  fidos2.renameHouse("SpotHouse");
  cout << fidos2 << endl;
  fidos = fidos2; // Assignment
  cout << fidos << endl;
  fidos.getDog()->rename("Max");
  fidos2.renameHouse("MaxHouse");
} ///:~

Listado 12.15. C12/CopyingWithPointers.cpp


Dog es una clase simple que contiene solo una cadena con el nombre del perro. Sin embargo, generalmente sabrá cuando le sucede algo al perro porque los constructores y destructores imprimen información cuando se invocan. Fíjese que el segundo constructor es un poco como un constructor de copia excepto que toma un puntero a Dog en vez de una referencia, y tiene un segundo argumento que es un mensaje a ser concatenado con el nombre del perro. Esto se hace así para ayudar a rastrear el comportamiento del programa.

Puede ver que cuando un método imprime información, no accede a esa información directamente sino que manda *this a cout. Éste a su vez llama a ostream operator<<. Es aconsejable hacer esto así dado que si quiere reformatear la manera en la que información del perro es mostrada (como hice añadiendo el «[» y el «]») solo necesita hacerlo en un lugar.

Una DogHouse contiene un Dog* y demuestra las cuatro funciones que siempre necesitará definir cuando sus clases contengan punteros: todos los constructores necesarios usuales, el constructor de copia, operator= (se define o se deshabilita) y un destructor. Operator= comprueba la auto-asignación como una cuestión de estilo, incluso aunque no es estrictamente necesario aquí. Esto virtualmente elimina la posibilidad de que olvide comprobar la auto-asignación si cambia el código.

Contabilidad de referencias

En el ejemplo de arriba, el constructor de copia y el operador = realizan una copia de lo que apunta el puntero, y el destructor lo borra. Sin embargo, si su objeto requiere una gran cantidad de memoria o una gran inicialización fija, a lo mejor puede querer evitar esta copia. Una aproximación común a este problema se llama conteo de referencias. Se le da inteligencia al objeto que está siendo apuntado de tal forma que sabe cuántos objetos le están apuntado. Entonces la construcción por copia o la asignación consiste en añadir otro puntero a un objeto existente e incrementar la cuenta de referencias. La destrucción consiste en reducir esta cuenta de referencias y destruir el objeto si la cuenta llega a cero.

¿Pero que pasa si quiere escribir el objeto(Dog en el ejemplo anterior)?. Más de un objeto puede estar usando este Dog luego podría estar modificando el perro de alguien más a la vez que el suyo, lo cual no parece ser muy amigable. Para resolver este problema de «solapamiento» se usa una técnica adicional llamada copia-en-escritura. Antes de escribir un bloque de memoria, debe asegurarse que nadie más lo está usando. Si la cuenta de referencia es superior a uno, debe realizar una copia personal del bloque antes de escribirlo, de tal manera que no moleste el espacio de otro. He aquí un ejemplo simple de conteo de referencias y copia-en-escritura:

//: C12:ReferenceCounting.cpp
// Reference count, copy-on-write
#include "../require.h"
#include <string>
#include <iostream>
using namespace std;

class Dog {
  string nm;
  int refcount;
  Dog(const string& name) 
    : nm(name), refcount(1) {
    cout << "Creating Dog: " << *this << endl;
  }
  // Prevent assignment:
  Dog& operator=(const Dog& rv);
public:
  // Dogs can only be created on the heap:
  static Dog* make(const string& name) {
    return new Dog(name);
  }
  Dog(const Dog& d) 
    : nm(d.nm + " copy"), refcount(1) {
    cout << "Dog copy-constructor: " 
         << *this << endl;
  }
  ~Dog() { 
    cout << "Deleting Dog: " << *this << endl;
  }
  void attach() { 
    ++refcount;
    cout << "Attached Dog: " << *this << endl;
  }
  void detach() {
    require(refcount != 0);
    cout << "Detaching Dog: " << *this << endl;
    // Destroy object if no one is using it:
    if(--refcount == 0) delete this;
  }
  // Conditionally copy this Dog.
  // Call before modifying the Dog, assign
  // resulting pointer to your Dog*.
  Dog* unalias() {
    cout << "Unaliasing Dog: " << *this << endl;
    // Don't duplicate if not aliased:
    if(refcount == 1) return this;
    --refcount;
    // Use copy-constructor to duplicate:
    return new Dog(*this);
  }
  void rename(const string& newName) {
    nm = newName;
    cout << "Dog renamed to: " << *this << endl;
  }
  friend ostream&
  operator<<(ostream& os, const Dog& d) {
    return os << "[" << d.nm << "], rc = " 
      << d.refcount;
  }
};

class DogHouse {
  Dog* p;
  string houseName;
public:
  DogHouse(Dog* dog, const string& house)
   : p(dog), houseName(house) {
    cout << "Created DogHouse: "<< *this << endl;
  }
  DogHouse(const DogHouse& dh)
    : p(dh.p),
      houseName("copy-constructed " + 
        dh.houseName) {
    p->attach();
    cout << "DogHouse copy-constructor: "
         << *this << endl;
  }
  DogHouse& operator=(const DogHouse& dh) {
    // Check for self-assignment:
    if(&dh != this) {
      houseName = dh.houseName + " assigned";
      // Clean up what you're using first:
      p->detach();
      p = dh.p; // Like copy-constructor
      p->attach();
    }
    cout << "DogHouse operator= : "
         << *this << endl;
    return *this;
  }
  // Decrement refcount, conditionally destroy
  ~DogHouse() {
    cout << "DogHouse destructor: " 
         << *this << endl;
    p->detach(); 
  }
  void renameHouse(const string& newName) {
    houseName = newName;
  }
  void unalias() { p = p->unalias(); }
  // Copy-on-write. Anytime you modify the 
  // contents of the pointer you must 
  // first unalias it:
  void renameDog(const string& newName) {
    unalias();
    p->rename(newName);
  }
  // ... or when you allow someone else access:
  Dog* getDog() {
    unalias();
    return p; 
  }
  friend ostream&
  operator<<(ostream& os, const DogHouse& dh) {
    return os << "[" << dh.houseName 
      << "] contains " << *dh.p;
  }
}; 

int main() {
  DogHouse 
    fidos(Dog::make("Fido"), "FidoHouse"),
    spots(Dog::make("Spot"), "SpotHouse");
  cout << "Entering copy-construction" << endl;
  DogHouse bobs(fidos);
  cout << "After copy-constructing bobs" << endl;
  cout << "fidos:" << fidos << endl;
  cout << "spots:" << spots << endl;
  cout << "bobs:" << bobs << endl;
  cout << "Entering spots = fidos" << endl;
  spots = fidos;
  cout << "After spots = fidos" << endl;
  cout << "spots:" << spots << endl;
  cout << "Entering self-assignment" << endl;
  bobs = bobs;
  cout << "After self-assignment" << endl;
  cout << "bobs:" << bobs << endl;
  // Comment out the following lines:
  cout << "Entering rename(\"Bob\")" << endl;
  bobs.getDog()->rename("Bob");
  cout << "After rename(\"Bob\")" << endl;
} ///:~

Listado 12.16. C12/ReferenceCounting.cpp


La clase Dog es el objeto apuntado por DogHouse. Contiene una cuenta de referencias y métodos para controlar y leer la cuenta de referencias. Hay un constructor de copia de modo que puede crear un nuevo Dog a partir de uno existente.

La función attach() incrementa la cuenta de referencias de un Dog para indicar que hay otro objeto usándolo. La función detach() decrementa la cuenta de referencias. Si llega a cero, entonces nadie mas lo está usando, así que el método destruye su propio objeto llamando a delete this.

Antes de que haga cualquier modificación (como renombrar un Dog), debería asegurarse de que no está cambiando un Dog que algún otro objeto está usando. Hágalo llamando a DogHouse::unalias() , que llama a Dog::unalias(). Esta última función devolverá el puntero a Dog existente si la cuenta de referencias es uno (lo que significa que nadie mas está usando ese Dog), pero duplicará Dog si esa cuenta es mayor que uno.

El constructor de copia, además de pedir su propia memoria, asigna Dog al Dog del objeto fuente. Entonces, dado que ahora hay un objeto más usando ese bloque de memoria, incrementa la cuenta de referencias llamando a Dog::attach().

El operador = trata con un objeto que ha sido creado en la parte izquierda del =, así que primero debe limpiarlo llamando a detach() para ese Dog, lo que destruirá el Dog viejo si nadie más lo está usando. Entonces operator= repite el comportamiento del constructor de copia. Advierta que primero realiza comprobaciones para detectar cuando está asignando el objeto a sí mismo.

El destructor llama a detach() para destruir condicionalmente el Dog.

Para implementar la copia-en-escritura, debe controlar todas las operaciones que escriben en su bloque de memoria. Por ejemplo, el método renameDog() le permite cambiar valores en el bloque de memoria. Pero primero, llama a unalias() para evitar la modificación de un Dog solapado (un Dog con más de un objeto DogHouse apuntándole). Y si necesita crear un puntero a un Dog desde un DogHouse debe llamar primero a unalias() para ese puntero.

La función main() comprueba varias funciones que deben funcionar correctamente para implementar la cuenta de referencias: el constructor, el constructor de copia, operator= y el destructor. También comprueba la copia-en-escritura llamando a renameDog().

He aquí la salida (después de un poco de reformateo):

Creando Dog: [Fido],  rc = 1
    CreadoDogHouse: [FidoHouse]
    contiene [Fido],  rc = 1
    Creando Dog: [Spot],  rc = 1
    CreadoDogHouse: [SpotHouse]
    contiene [Spot],  rc = 1
    Entrando en el constructor de copia
    Dog añadido:[Fido],  rc = 2
    DogHouse constructor de copia
    [construido por copia FidoHouse]
    contiene [Fido],  rc = 2
    Despues de la construcción por copia de Bobs
    fidos:[FidoHouse] contiene [Fido],  rc = 2
    spots:[SpotHouse] contiene [Spot],  rc = 1
    bobs:[construido por copia FidoHouse]
    contiene[Fido],  rc = 2
    Entrando spots = fidos
    Eliminando perro: [Spot],  rc = 1
    Borrando Perro: [Spot],  rc = 0
    Añadido Dog: [Fido],  rc = 3
    DogHouse operador= : [FidoHouse asignado]
    contiene[Fido],  rc = 3
    Despues de  spots = fidos
    spots:[FidoHouse asignado] contiene [Fido], rc = 3
    Entrando en la auto asignación
    DogHouse operador= : [construido por copia FidoHouse]
    contiene [Fido],  rc = 3
    Despues de la auto asignación
    bobs:[construido por copia FidoHouse]
    contiene [Fido],  rc = 3
    Entando rename("Bob")
    Despues de rename("Bob")
    DogHouse destructor: [construido por copia FidoHouse]
    contiene [Fido],  rc = 3
    Eliminando perro: [Fido],  rc = 3
    DogHouse destructor: [FidoHouse asignado]
    contiene [Fido],  rc = 2
    Eliminando perro: [Fido],  rc = 2
    DogHouse destructor: [FidoHouse]
    contiene [Fido],  rc = 1
    Eliminando perro: [Fido],  rc = 1
    Borrando perro: [Fido],  rc = 0

Estudiando la salida, rastreando el código fuente y experimentando con el programa, podrá ahondar en la comprensión de estas técnicas.

Creación automática del operador =

Dado que asignar un objeto a otro del mismo tipo es una operación que la mayoría de la gente espera que sea posible, el compilador automáticamente creará un type::operator=(type) si usted el programador no proporciona uno. El comportamiento de este operador imita el del constructor de copia creado automáticamente; si la clase contiene objetos (o se deriva de otra clase), se llama recursivamente a operator= para esos objetos. A esto se le llama asignación miembro a miembro. Por ejemplo:

//: C12:AutomaticOperatorEquals.cpp
#include <iostream>
using namespace std;

class Cargo {
public:
  Cargo& operator=(const Cargo&) {
    cout << "inside Cargo::operator=()" << endl;
    return *this;
  }
};

class Truck {
  Cargo b;
};

int main() {
  Truck a, b;
  a = b; // Prints: "inside Cargo::operator=()"
} ///:~

Listado 12.17. C12/AutomaticOperatorEquals.cpp


El operador= generado automáticamente para Truck llama a Cargo::operator=.

En general, no querrá que el compilador haga esto por usted. Con clases de cualquier sofisticación (¡Especialmente si contienen punteros!) querrá crear de forma explicita un operator=. Si realmente no quiere que la gente realice asignaciones, declare operator= como una método privado. (No necesita definirla a menos que la esté usando dentro de la clase).