12.5. Sobrecargar la asignación

Una causa común de confusión para los nuevos programadores de C++ es la asignación. Sin duda se debe a que el signo = es una operación fundamental en la programación, que llega hasta la copia de un registro a nivel de máquina. Además, a veces también se invoca el constructor de copia (descrito en el Capítulo 11) cuando se usa el signo =:

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 qué 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 operator=.

Es incluso mejor evitar escribir código que use = para la inicialización; en cambio, use siempre la forma explícita del constructor. 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 de operator=

En Integer.h y en Byte.h vio 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 obligándole a que operator= sea un método.

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 asigna dos objetos del mismo tipo, siempre debería comprobar primero la autoasignación: ¿se está asignando el objeto a sí mismo? En algunos casos, como éste, es inofensivo realizar de todos modos las operaciones de asignación, pero si se realizan cambios en la implementación de la clase, puede ser importante, y si no lo hace por 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? Copiar simplemente el puntero significa que obtendrá dos objetos que apuntan a la misma localización de memoria. En situaciones como ésta, necesita llevar la contabilidad por su cuenta.

Hay dos enfoques habituales para este problema. La técnica más simple es copiar aquello a lo 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á cuándo le sucede algo al perro porque los constructores y destructores imprimen información cuando se invocan. Fíjese en 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 que se concatena 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 se muestra la información del perro (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 normales necesarios, el constructor de copia, operator= (se define o se deshabilita) y un destructor. operator= comprueba la autoasignación por norma, aunque no sea estrictamente necesario aquí. Esto elimina prácticamente la posibilidad de que olvide comprobar la autoasignación si cambia el código de forma que sí importe.

Conteo 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 un alto coste de inicialización, puede que quiera evitar esta copia. Un enfoque 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 apuntando. 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 qué pasa si quiere escribir en el objeto (el Dog del ejemplo anterior)? Más de un objeto puede estar usando este Dog, así que 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 de que nadie más lo está usando. Si la cuenta de referencias es superior a uno, debe realizar una copia personal del bloque antes de escribirlo, para no invadir el terreno de nadie. 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 más lo está usando, así que el método destruye su propio objeto mediante 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 más está usando ese Dog), pero duplicará el Dog si esa cuenta es mayor que uno.

El constructor de copia, en lugar de crear 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 ya 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 si 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 de operator=

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 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 operator= 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 explícita un operator=. Si realmente no quiere que la gente realice asignaciones, declare operator= como un método privado. (No necesita definirlo a menos que lo esté usando dentro de la clase).