4.9.18a Sobrecarga del operador de asignación
§1 Preámbulo
Lo mismo que ocurre con el constructor-copia (
4.11.2d4), si en la definición de una clase no se sobrecarga explícitamente el
operador de asignación =
(
4.9.2), el compilador proporciona una
versión por defecto. Esta versión "de oficio" realiza una asignación miembro a miembro de los elementos de la clase. De
esta forma, aunque no se haya definido explícitamente la función operator= en la definición de UnaClase, son
posibles expresiones como:
class UnaClase { ... };
...
UnaClase c1;
c1 = c2;
Ejemplo
Veámoslo con un ejemplo en el que definimos la clase Vector, destinada a representar los vectores libres en un espacio de dos dimensiones. La clase incluye dos propiedades, x e y, que son las componentes escalares del vector respecto a un sistema ejes cartesianos.
#include <iostream>
using namespace std;
class Vector {
// definición de la clase Vector
public: float x, y;
};
void main () {
// ==================
Vector v1, v2; // M.1:
v1.x = 1.0; v1.y = 2.0; // M.2:
Vector v2 = v1; //
M.3: Uso sobrecargado del operador =
cout << "x1 = " << v1.x << " y1 = " << v1.y << endl;
cout << "x2 = " << v2.x << " y2 = " << v2.y << endl;
}
Salida:
x1 = 1 y1 = 2
x2 = 1 y2 = 2
Comentario
En ausencia de ninguna definición explícita, el compilador proporciona "de oficio" un constructor
( 4.11.2d1);
un constructor-copia (
4.11.2d4), y una versión sobrecargada del operador de asignación =
adecuados a la definición de la clase.
En M.1 se instancian sendos objetos v1 y v2, sus valores son iniciados por el constructor de oficio. En M.2 los miembros de v1 son modificados a valores determinados, y en M.3 se utiliza la versión sobrecargada "de oficio" del operador = para asignar el objeto v1 a v2.
Las salidas muestran como efectivamente, la versión oficial del operador ha realizado una asignación miembro a miembro del operando v1 sobre v2.
Observe que en M.2 se utiliza una versión del operador = para la clase de los float preconstruida de forma
"nativa" en el lenguaje. Esta versión no puede ser sobrecargada y la denominamos global. En otras palabras: el
comportamiento del operador = para objetos tipo float está predeterminado en el lenguaje y su comportamiento no puede
ser modificado. La versión global de un operador no puede
ser sobrecargada.
§2 Sinopsis
Existen ocasiones en que la versión "de oficio" del operador de asignación = no es adecuada, por lo que
el lenguaje ofrece la posibilidad de sobrecargarlo para que se adapte a un comportamiento específico. Como se ha indicado
( 4.9.18), la versión
explícita del operador de asignación se establece declarando una función miembro no estática operator=.
Ejemplo:
Vector operator= (Vector v) { ... } ; // función-operador
Cuando se sobrecarga explícitamente el operador de
asignación, el compilador establece la limitación de que esta versión sobrecargada no es heredada por las posibles clases
derivadas (
4.11.2b).
§3 Ejemplo
Veamos un ejemplo más concreto en el que a la clase Vector del ejemplo anterior
le sobrecargamos el operador de
asignación simple =, de forma que la asignación entre vectores realice al mismo tiempo una multiplicación por un escalar
[1]:
#include <iostream>
using namespace std;
class Vector {
// definición de la clase Vector
public:
float x, y;
void operator= (Vector v) { // L.6: función-operador
x = v.x * 10;
// L.7:
y = v.y * 10;
// L.8:
}
};
void main () { // ==================
Vector v1, v2, v3;
v1.x = 1.0; v1.y = 2.0;
v2 = v1;
// M.3:
v3 = v2;
// M.4:
cout << "x1 = " << v1.x << " y1 = " << v1.y << endl;
cout << "x2 = " << v2.x << " y2 = " << v2.y << endl;
cout << "x3 = " << v3.x << " y3 = " << v3.y << endl;
}
Salida:
x1 = 1 y1 = 2
x2 = 10 y2 = 20
x3 = 100 y3 = 200
Comentario
El programa compila sin dificultad y las salidas son las esperadas; confirmando que las asignaciónes M.3 y M.4 invocan la versión sobrecargada de operator= y que esta funciona correctamente.
Sin embargo, a pesar de su aparente idoneidad, si en el ejemplo anterior sustituimos las sentencias M.3, M.4 por una asignación compuesta:
v3 = v2 = v1; // M.3bis:
la nueva sentencia produce un error de compilación
[2]. La razón es que nuestra versión sobrecargada de operator= viola una de las reglas básicas de los
operadores C++ de asignación: producir un resultado adecuado además de realizar la asignación en sí misma
(
4.9.2). Así pues, la ejecución de
M.3bis, que se realiza de derecha a izquierda, sería adecuada en su primera parte: v2 = v1, pero el resultado,
void (L.6) debe ser aplicado a la siguiente: v3 = void, y el compilador no encuentra una función-operador adecuada en
la que esté definida una asignación del tipo:
<valor-devuelto> operator= (void); // declaración esperada
Comprobamos que, en este sentido, el mensaje de error del compilador Borland es quizás el más explícito.
§4 Para conseguir un funcionamiento correcto, modificamos la definición de operator=:
#include <iostream>
using namespace std;
class Vector { // definición de la clase Vector
public:
float x, y;
Vector operator = (Vector v) { // L.6: función-operador
x = v.x * 10;
y = v.y * 10;
return *this; // L.10:
}
};
void main () { // ==================
Vector v1, v2,
v3; // M.1:
v1.x = 1.0; v1.y = 2.0; // M.2:
v3 = v2 = v1; // M.3: Ok.
cout << "x1 = " << v1.x << " y1 = " << v1.y << endl;
cout << "x2 = " << v2.x << " y2 = " << v2.y << endl;
cout << "x3 = " << v3.x << " y3 = " << v3.y << endl;
}
Salida:
x1 = 1 y1 = 2
x2 = 10 y2 = 20
x3 = 100 y3 = 200
Comentario
Observe en L.10, la utilización explícita del puntero this
( 4.11.6) en la expresión del valor
devuelto por la función operator=; como la aplicación del operador de indirección
* (
4.9.11a) sobre dicho puntero, devuelve un objeto, y como este objeto es
precisamente el primer operador (Lvalue) involucrado en la expresión a = b;. Recuerde que a = b; es equivalente
a: a.operator=(b);.
Nota: se verá en el siguiente epígrafe que la invocación de una función como operator+()
del ejemplo, implica dos invocaciones al constructor-copia (
4.11.2d4), por lo que
es muy posible que algunos diseños requieran definir
explícitamente dicho constructor si se sobrecarga el operador de asignación.
§5 Una versión definitiva:
A pesar de que según hemos comprobado, la versión anterior funciona correctamente, en la práctica la versión sobrecargada del operador de asignación suele adoptar la siguiente forma genérica:
ClaseX& operator= (const ClaseX& obj) {
...
// asignaciones
return valordevuelto; // generalmente *this
}
Este diseño hace que la función reciba y devuelva sendas referencias en vez de objetos, lo que disminuye la sobrecarga inherente
a la creación de objetos temporales por el compilador. A su vez, el argumento de la función se declara const
( 3.2.1c)
para asegurar que operator= no modificará el objeto utilizado como argumento (lo que significaría modificar el Rvalue de la
asignación).
En nuestro caso, una expresión del tipo v1 = v2 equivale a la invocación:
v1.operator=(const Vector& v2);
Para justificar y poner en evidencia la economía de proceso derivada de utilizar referencias, efectuaremos un experimento.
Para ello añadimos a la versión anterior
un constructor-copia (
4.11.2d4) y un constructor por defecto
(
4.11.2d1)
explícitos [3], haciendo que nos muestren los objetos creados.
#include <iostream>
using namespace std;
class Vector {
// definición de Vector
public:
float x, y;
Vector operator = (Vector v) { // L.6: función-operador
x = v.x * 10;
y = v.y * 10;
return *this; // L.9
}
Vector(int i = 0, int j = 0) {
cout << "Creado un objeto (1)" << endl;
x = i; y = j;
}
Vector(Vector& v) { // constructor-copia
cout << "Creado un objeto (2)" << endl;
x = v.x; y = v.y;
}
};
void main () {
// ==================
Vector v1, v2, v3; // M.1:
v1.x = 1.0; v1.y = 2.0;
v3 = v2 = v1; // M.3:
cout << "x1 = " << v1.x << " y1 = " << v1.y << endl;
cout << "x2 = " << v2.x << " y2 = " << v2.y << endl;
cout << "x3 = " << v3.x << " y3 = " << v3.y << endl;
}
Salida [4]:
Creado un objeto (1)
Creado un objeto (1)
Creado un objeto (1)
Creado un objeto (2)
Creado un objeto (2)
Creado un objeto (2)
Creado un objeto (2)
x1 = 1 y1 = 2
x2 = 10 y2 = 20
x3 = 100 y3 = 200
Comentario
Las tres primeras salidas corresponden a la invocación implícita al constructor por defecto realizadas en M.1 para instanciar los objetos v1, v2 y v3. Las siguientes corresponden a sendas invocaciones al constructor-copia en M.3. Recuerde que esta última sentencia equivale a:
v2 = v1;
v3 = v2;
y que en realidad, cada una de ellas adopta la forma vL.operator=(vR) (al Lvalue y Rvalue de la asignación los
hemos designado respectivamente vL y vR ). La invocación de operator=( ) supone la creación de un objeto
v que es el argumento de la función y es local a esta (se destruye cuando la función es descargada de la pila
( 4.4.6b). Esta es la primera invocación
al constructor-copia. La sentencia de retorno (L.9) implica la creación de otro objeto temporal *this, que será
devuelto (por valor
). Esta es la segunda invocación al
constructor-copia.
Este último extremo puede comprobarse modificando el diseño de operator=():
void operator = (Vector v) { // L.6b: función-operador bis
x = v.x * 10;
y = v.y * 10;
// return *this;
}
Con esta definición solo se realiza la primera invocación al constructor-copia (aunque por supuesto el operador no pueda ser
utilizado para asignaciones en cadena ).
Si en el diseño anterior cambiamos la definición de la función-operador por una que utilice referencias:
Vector& operator = (const Vector& v) { // L.6c: función-operador
x = v.x * 10;
y = v.y * 10;
return *this;
}
Se obtienen las siguientes salidas:
Creado un objeto (1)
Creado un objeto (1)
Creado un objeto (1)
x1 = 1 y1 = 2
x2 = 10 y2 = 20
x3 = 100 y3 = 200
Como puede suponer, esta versión resulta mucho más eficiente que la anterior, ya que la creación y destrucción de cuatro objetos requiere tiempo y memoria, en especial cuando se trata de objetos grandes.
Otro ejemplo de sobrecarga del operador de asignación
(
4.9.18d1).
§6 Como resumen de lo anteriormente expuesto, incluimos un diseño de lo que sería una
versión real para la clase Vector con su correspondiente operador de asignación sobrecargado.
class Vector { // Una clase Vector
public:
float x, y;
Vector& operator= (const Vector& v) { // operador de asignación
x = v.x; y = v.y;
return *this;
}
Vector(int i = 0, int j = 0) { // constructor por defecto
x = i; y = j;
}
Vector(const Vector& v) { // constructor-copia
x = v.x; y = v.y;
}
};
[1] Que una definición de este tipo tenga o no sentido desde el punto de vista matemático es otra
cuestión (
4.9.18). En este aspecto, el lenguaje C++ es extraordinariamente permisivo y
en cualquier caso, el ejemplo propuesto es solo una muestra de su posibilidades.
[2] El mensaje es más o menos explícito, dependiendo del compilador utilizado. Por ejemplo, MS Visual
C++ 6.0: binary '=' : no operator defined which takes a right-hand operand of type 'void' (or there is no acceptable
conversion)
; Borland C++ 5.5: Could not find a match for 'Vector::operator =(void)' in function main()
,
y con Linux GCC v 2.95.3 19991030 (prerelease): No match for 'Vector & = void'
, indicándonos además que miremos
la definición de L.6.
[3] En realidad solo necesitamos el constructor-copia, pero su inclusión nos obliga a definir también explícitamente un constructor por defecto.
[4] La salida indicada corresponde al compilador MS Visual C++ 6.0. Por su parte, Borland C++ 5.5 realiza algún tipo de optimización interna y solo realiza tres invocaciones al constructor-copia en la sentencia M.3. Aunque sigue realizando dos para una asignación simple del tipo v2 = v1;.