11.3.2. Construcción por copia

El problema se produce debido a que el compilador hace una suposición sobre cómo crear un nuevo objeto a partir de de otro existente. Cuando se pasa un objeto por valor, se crea un nuevo objeto, que estará dentro del ámbito de la función, a partir del objeto original existente fuera del ámbito de la función. Esto también se puede aplicar a menudo cuando una función retorna un objeto. En la expresión

HowMany h2 = f(h);

h2, un objeto que no estaba creado anteriormente, se crea a partir del valor que retorna f(); por tanto también se crea un nuevo objeto a partir de otro existente.

El compilador supone que la creación ha de hacerse con una copia bit a bit, lo que en muchos casos funciona bien, pero en HowMany no funciona porque la inicialización va más allá de una simple copia. Otro ejemplo muy común ocurre cuando la clase contiene punteros pues, ¿a qué deben apuntar? ¿debería copiar sólo los punteros o debería asignar memoria nueva y que apuntaran a ella?

Afortunadamente, puede intervenir en este proceso e impedir que el compilador haga una copia bit a bit. Se soluciona definiendo su propia función cuando el compilador necesite crear un nuevo objeto a partir de otro. Lógicamente, está creando un nuevo objeto, por lo que esta función es un constructor, y el único argumento del constructor tiene que ver con el objeto del que se pretende partir para crear el nuevo. Pero no puede pasar ese objeto por valor al constructor porque está intentando definir la función que maneja el paso por valor, y, por otro lado, sintácticamente no tiene sentido pasar un puntero porque, después de todo, está creando un objeto a partir de de otro. Aquí es cuando las referencias vienen al rescate, y puede utilizar la referencia del objeto origen. Esta función se llama constructor de copia, que también se lo puede encontrar como X(X&), que es el constructor de copia de una clase llamada X.

Si crea un constructor de copia, el compilador no realizará una copia bit a bit cuando cree un nuevo objeto a partir de otro. El compilador siempre llamará al constructor de copia. Si no crea el constructor de copia, el compilador intentará hacer algo razonable, pero usted tiene la opción de tener control total del proceso.

Ahora es posible solucionar el problema en HowMany.cpp:

//: C11:HowMany2.cpp
// The copy-constructor
#include <fstream>
#include <string>
using namespace std;
ofstream out("HowMany2.out");

class HowMany2 {
  string name; // Object identifier
  static int objectCount;
public:
  HowMany2(const string& id = "") : name(id) {
    ++objectCount;
    print("HowMany2()");
  }
  ~HowMany2() {
    --objectCount;
    print("~HowMany2()");
  }
  // The copy-constructor:
  HowMany2(const HowMany2& h) : name(h.name) {
    name += " copy";
    ++objectCount;
    print("HowMany2(const HowMany2&)");
  }
  void print(const string& msg = "") const {
    if(msg.size() != 0) 
      out << msg << endl;
    out << '\t' << name << ": "
        << "objectCount = "
        << objectCount << endl;
  }
};

int HowMany2::objectCount = 0;

// Pass and return BY VALUE:
HowMany2 f(HowMany2 x) {
  x.print("x argument inside f()");
  out << "Returning from f()" << endl;
  return x;
}

int main() {
  HowMany2 h("h");
  out << "Entering f()" << endl;
  HowMany2 h2 = f(h);
  h2.print("h2 after call to f()");
  out << "Call f(), no return value" << endl;
  f(h);
  out << "After call to f()" << endl;
} ///:~

Listado 11.7. C11/HowMany2.cpp


Hay unas cuantas cosas nuevas para que pueda hacerse una idea mejor de lo que pasa. Primeramente, el string name hace de identificador de objeto cuando se imprima en la salida. Puede poner un identificador (normalmente el nombre del objeto) en el constructor para que se copie en name utilizando el constructor con un string como argumento. Por defecto se crea un string vacío. El constructor incrementa objectCount y el destructor lo disminuye, igual que en el ejemplo anterior.

Lo siguiente es el constructor de copia, HowMany2(const HowMany2&). El constructor de copia simplemente crea un objeto a partir de otro existente, así que copia en name el identificador del objeto origen, seguido de la palabra «copy», y así puede ver de dónde procede. Si mira atentamente, verá que la llamada name(h.name) en la lista de inicializadores del constructor está llamando al constructor de copia de la clase string.

Dentro del constructor de copia, se incrementa el contador igual que en el constructor normal. Esto quiere decir que obtendrá un contador de objetos preciso cuando pase y retorne por valor.

La función print() se ha modificado para imprimir en la salida un mensaje, el identificador del objeto y el contador de objetos. Como ahora accede al atributo name de un objeto concreto, ya no puede ser un método estático.

Dentro de main() puede ver que hay una segunda llamada a f(). Sin embargo esta llamada utiliza la característica de C para ignorar el valor de retorno. Pero ahora que sabe cómo se retorna el valor (es decir, código dentro de la función que maneja el proceso de retorno poniendo el resultado en un lugar cuya dirección se pasa como un argumento escondido), podría preguntarse qué ocurre cuando se ignora el valor de retorno. La salida del programa mostrará alguna luz sobre el asunto.

Pero antes de mostrar la salida, he aquí un pequeño programa que utiliza iostreams para añadir números de línea a cualquier archivo:

//: C11:Linenum.cpp
//{T} Linenum.cpp
// Add line numbers
#include "../require.h"
#include <vector>
#include <string>
#include <fstream>
#include <iostream>
#include <cmath>
using namespace std;

int main(int argc, char* argv[]) {
  requireArgs(argc, 1, "Usage: linenum file\n"
    "Adds line numbers to file");
  ifstream in(argv[1]);
  assure(in, argv[1]);
  string line;
  vector<string> lines;
  while(getline(in, line)) // Read in entire file
    lines.push_back(line);
  if(lines.size() == 0) return 0;
  int num = 0;
  // Number of lines in file determines width:
  const int width = 
    int(log10((double)lines.size())) + 1;
  for(int i = 0; i < lines.size(); i++) {
    cout.setf(ios::right, ios::adjustfield);
    cout.width(width);
    cout << ++num << ") " << lines[i] << endl;
  }
} ///:~

Listado 11.8. C11/Linenum.cpp


El archivo se pasa a un vector<string>, utilizando el mismo código fuente que ha visto anteriormente en este libro. Cuando se ponen los números de línea, nos gustaría que todas las líneas estuvieran alineadas, y esto necesita conocer el número de líneas en el archivo para que sea coherente. Se puede conocer el número de líneas con vector::size(), pero lo que realmente necesitamos es conocer si hay más líneas de 10, 100, 1000, etc. Si se utiliza el logaritmo en base 10 sobre el número de líneas en el archivo, se trunca a un entero y se añade uno al valor resultante, eso determinará el ancho máximo en dígitos que un número de línea puede tener.

Nótese que hay un par de llamadas extrañas dentro del bucle for: setf() y width(). Hay llamadas de ostream que permiten controlar, en este caso, la justificación y anchura de la salida. Sin embargo se debe llamar cada vez que se imprime línea y por eso están dentro del bucle for. El Volumen 2 de este libro tiene un capítulo entero que explica los iostreams y que cuenta más sobre estas llamadas así como otras formas de controlar los iostreams.

Cuando se aplica Linenum.cpp a HowMany2.out, resulta:

1) HowMany2()
2)   h: objectCount = 1
3) Entering f()
4) HowMany2(const HowMany2&)
5)   h copy: objectCount = 2
6) x argument inside f()
7)   h copy: objectCount = 2
8) Returning from f()
9) HowMany2(const HowMany2&)
10)   h copy copy: objectCount = 3
11) ~HowMany2()
12)   h copy: objectCount = 2
13) h2 after call to f()
14)   h copy copy: objectCount = 2
15) Call f(), no return value
16) HowMany2(const HowMany2&)
17)   h copy: objectCount = 3
18) x argument inside f()
19)   h copy: objectCount = 3
20) Returning from f()
21) HowMany2(const HowMany2&)
22)   h copy copy: objectCount = 4
23) ~HowMany2()
24)   h copy: objectCount = 3
25) ~HowMany2()
26)   h copy copy: objectCount = 2
27) After call to f()
28) ~HowMany2()
29)   h copy copy: objectCount = 1
30) ~HowMany2()
31)   h: objectCount = 0

Como se esperaba, la primera cosa que ocurre es que para h se llama al constructor normal, el cual incrementa el contador de objetos a uno. Pero entonces, mientras se entra en f(), el compilador llama silenciosamente al constructor de copia para hacer el paso por valor. Se crea un nuevo objeto, que es copia de h (y por tanto tendrá el identificador «h copy») dentro del ámbito de la función f(). Así pues, el contador de objetos se incrementa a dos, por cortesía del constructor de copia.

La línea ocho indica el principio del retorno de f(). Pero antes de que se destruya la variable local «h copy» (pues sale de ámbito al final de la función), se debe copiar al valor de retorno, que es h2. Por tanto h2, que no estaba creado previamente, se crea de un objeto ya existente (la variable local dentro de f()) y el constructor de copia vuelve a utilizarse en la línea 9. Ahora el identificador de h2 es «h copy copy» porque copió el identificador de la variable local de f(). Cuando se devuelve el objeto, pero antes de que la función termine, el contador de objetos se incrementa temporalmente a tres, pero la variable local con identificador «h copy» se destruye, disminuyendo a dos. Después de que se complete la llamada a f() en la línea 13, sólo hay dos objetos, h y h2, y puede comprobar, de hecho, que h2 terminó con el identificador «h copy copy».

Objetos temporales

En la línea 15 se empieza la llamada a f(h), y esta vez ignora el valor de retorno. Puede ver que se invoca el constructor de copia en la línea 16, igual que antes, para pasar el argumento. Y también, igual que antes, en la línea 21 se llama al constructor de copia para el valor de retorno. Pero el constructor de copia necesita una dirección para utilizar como destino (es decir, para trabajar con el puntero this). ¿De dónde procede esta dirección?

Esto prueba que el compilador puede crear un objeto temporal cuando lo necesita para evaluar adecuadamente una expresión. En este caso, crea uno que ni siquiera se le ve actuar como destino para el valor ignorado retornado por f(). El tiempo de vida de este objeto temporal es tan corto como sea posible para que el programa no se llene de objetos temporales esperando a ser destruidos, lo cual provocaría la utilización ineficaz de recursos valiosos. En algunos casos, el objeto temporal podría pasarse inmediatamente a otra función, pero en este caso no se necesita después de la llamada a la función, así que en cuanto la función termina, llamando al destructor del objeto local (líneas 23 y 24), el objeto temporal también se destruye (líneas 25 y 26).

Finalmente, de la línea 28 a la línea 31, se destruye el objeto h2, seguido de h y el contador de objetos vuelve a cero.