Aunque la sobrecarga de operadores en C++ es un tema bastante básico y aparece en cualquier libro, hay algunos tipos de sobrecarga (la de operadores globales y la del cast) que descubrí bastante tarde y que me parecieron bastante útiles. Por este motivo escribo este tutorial, aunque posiblemente no encuentres en él nada que no puedas encontrar en un buen libro de C++ (como por ejemplo, "Programación Orientada a Objetos con C++", de Francisco Javier Ceballos).
Los temas que vamos a tratar son:
Para nuestro ejemplo vamos a hacer parte de una clase ComplejoC que representa un número complejo. Únicamente redefiniremos los operadores de suma, de cast a double y para sacarlo por pantalla con cout .
¿Qué es la sobrecarga de operadores?
En cualquier lenguaje de programación, y C++ no es menos, se puden hacer operaciones con los tipos básicos del lenguaje: se pueden sumar enteros, compararlos, etc. Lo siguiente es perfectamente válido en C++
int a=3;
int b=4;
int c;
c = a + b;
Si estos tipos no son los básicos del lenguaje, no se puede hacer sumas con ellos. Por ejemplo, si ClaseC es una clase que tengo definida en C++, el siguiente código dará error.
ClaseC a;
ClaseC b;
ClaseC c;
c = a + b;
C++ permite que hagamos estas cosas si definimos en algún sitio cómo se suman esas clases. Definiendo cómo se hacen las operaciones, podremos escribir las operaciones con nuestras clases de la misma forma que si se trataran de tipos básicos del lenguaje.
De hecho podemos definir cualquier operador que nos dé C++, desde algunos muy normales como suma, resta, multiplicación, mayor qué, etc, a otros un poco más raros como el operador () o el [], de forma que ClaseC[i] o ClaseC(i,j) pueden devolver lo que nosostros queramos.
Si tenemos una clase ComplejoC que representa un complejo, para poder sumar dos de estas clases simplemente poniendo un +, como con cualquier tipo básico de C++, debemos sobrecargar el operador +, darle una nueva funcionalidad.
La sobrecarga de un operador suma se hace de la siguiente manera
class ComplejoC
{
public:
// Permite sumar un ComplejoC
con un double
ComplejoC operator + (double
sumando);
// Permite sumar un ComplejoC
con un array
ComplejoC operator + (const double
sumando[]);
// Permite sumar dos ComplejoC
entre sí.
ComplejoC operator +
(const ComplejoC &sumando);
};
Aquí estamos redefiniendo tres operadores suma para que nos permita sumar a nuestra clase ComplejoC cosas como un double, un array de double (que supondremos contiene dos double, aunque no tenemos forma de comprobarlo) y otra clase ComplejoC .
En estas funciones hemos puesto const en los parámetros para no poder cambiarlos dentro del código. En la tercera función pasamos ComplejoC por referencia (el &), para evitar que se hagan copias innecesarias. En la primera función no es necesario nada de esto, puesto que es un simple double. En la segunda, ponemos const para no poder modificar el array, pero no es necesaria la referencia, puesto que los arrays son punteros.
A la hora de implementar debemos tener cuidado con los const que hemos puesto. Por ejemplo, en el tercer operador +, recibimos un sumando que es const. El código que implementemos dentro no puede modificar dicho sumando, ni puede llamar a ningún método de ese sumando que no esté declarado como const. Si lo intentamos, el compilador dará error. Supongamos que ComplejoC tiene un atributo double x (parte real) y un método dameX() para obtener dicho atributo, este método debe estar declarado const para poder llamarlo desde nuestro operator +. Más o menos esto:
class ComplejoC
{
public:
// Atención al const
del final. Sin el no podemos llamar a sumando.dameX().
double dameX() const;
};
de forma que en el operator + podemos llamar a sumando.dameX().
Una vez implementados estos métodos, podemos hacer operaciones como
ComplejoC a;
ComplejoC b;
// Aprovechando la primera sobrecarga
b = a + 1.0;
// Aprovechando la segunda sobrecarga
double c[] = {1.0, 2.0};
b = a + c;
// Aprovechando la tercera sobrecarga
b = a + b;
Cuando el compilador lee a + 1.0, lo interpreta como a.operator + (1.0), es decir, la llamada al operador suma al que se le pasa como parámetro un double . De la misma forma sucede con los otros dos operadores suma.
Sin embargo, esto nos da un pequeño problema. ¿Qué pasa si ponemos 1.0 + a?. Debería ser lo mismo, pero al compilador le da un pequeño problema. Intenta llamar al método operator + de 1.0, que no existe, puesto que 1.0 ni es una clase ni tiene métodos. Para solucionar este problema tenemos la sobrecarga de operadores globales.
Un operador global es una función global que no pertenece a ninguna clase y que nos indica cómo operar con uno o dos parámetros (depende del tipo de operador).
En nuestro ejemplo de las sumas, para poder poner los sumandos al revés, deberíamos definir las siguientes funciones globales:
ComplejoC operator + (double sumando1, const ComplejoC &sumando2);
ComplejoC operator + (double sumando1[], const ComplejoC &sumando2);
Estas funciones le indican al compilador cómo debe sumar los dos parámetros y qué devuelve. Con ellas definidas e implementadas, ya podemos hacer
b = 1.0 + a;
// c era un array de double
b = c + a;
Esta sobrecarga es especialmente útil cuando tratamos con una clase ya hecha y que no podemos modificar. Por ejemplo, cout es de la clase ostream y no podemos modificarla, sin embargo nos sería de utilidad sobrecargar el operador << de ostream de forma que pueda escribir nuestros números complejos. La siguiente llamada nos dará error mientras no redefinamos el operator << de ostream .
cout << a << endl; // a es un ComplejoC
Con la sobrecarga de operadores globales podemos definir la función
ostream &operator << (ostream &salida, const ComplejoC &valor);
Con esta función definida, el complejo se escribirá en pantalla como indique dicha función. Esta función deberá escribir la parte real e imaginaria del complejo en algún formato, utilizando algo como
salida << valor.dameX() << " + " << valor.dameY() << "j";
El operador devuelve un ostream, que será un return cout. De esta forma se pueden encadenar las llamadas a cout de la forma habitual.
cout << a << " + " << b << endl;
Primero se evalúa operator << (cout, a), que escribe a en pantalla y devuelve un cout, con lo que la expresión anterior quedaría, después de evaluar esto
cout << " + " << b << endl;
y así consecutivamente.
Hay que tener en cuenta que estos operadores globales no son de la clase, así que sólo pueden acceder a métodos y atributos públicos de la misma.
Un operador interesante es el operador "cast". En C++, si tenemos dos tipos básicos distintos, podemos pasar de uno a otro haciendo un cast (siempre que sean compatibles de alguna forma). Por ejemplo
int a;
double b;
a = (int)b;
El cast consiste en poner delante, entre paréntesis, el tipo que queramos que sea. En algunos casos, como en el de este ejemplo, el cast se hace automáticamente y no es necesario ponerlo. Puede que de un "warning" en el compilador avisando de que perderemos los decimales.
En principio, con las clases no se puede hacer cast a otros tipos, pero es posible declarar operadores que lo hagan. La sintaxis sería:
class ComplejoC
{
public:
// Permite hacer un cast de ComplejoC
a double
operator double ();
}
Con este podemos hacer cast de nuestra clase a un double . Es nuestro problema decidir cómo se hace ese cast. En el código de ejemplo que hay más abajo se ha definido como la operación módulo del número complejo.
double a;
ComplejoC b;
a = (double)b;
En el operator cast se pone operator seguido del tipo al que se quiere hacer el cast. No se pone el tipo del valor devuelto, puesto que ya está claro. Si ponemos operator double, hay que devolver un double .
En el operator cast no se pone parámetro, puesto que el parámetro recibido será una instancia de la clase.
Conviene tener cuidado con definir muchos operadores cast, puesto que el compilador los tendrá todos presentes y será capaz, encadenando unos con otros, de hacer cast entre tipos que no tienen nada que ver. Por ejemplo, si sumamos un ComplejoC con un DibujoC (que no tienen nada que ver) y ambos tienen cast e int, es posible que el compilador los transforme ambos en int y luego los sume como enteros.
¿Cómo hacemos un cast al revés?. Es decir, ¿Cómo podemos convertir un double a ComplejoC?. El asunto es sencillo, basta hacer un constructor que admite un double como parámetro.
class ComplejoC
{
public:
ComplejoC (double valor);
};
Este constructor sirve para lo que ya sabemos
ComplejoC a(3.0);
o podemos usarlo para hacer un cast de double a ComplejoC
ComplejoC a;
a = (ComplejoC)3.0;
El operator = es como los demás. Simplemente un pequeño detalle. C++ por defecto tiene el operador igual definido para clases del mismo tipo. Por ejemplo, sin necesidad de redefinir nada, podemos hacer
ComplejoC a;
ComplejoC b;
a = b;
Este igual por defecto lo único que hace es copiar el contenido del uno sobre el otro, como si fueran bytes, sin saber qué atributos está copiando ni qué significan. Para clases sencillas, que solo tienen atributos que no son punteros, esto es más que suficiente.
Si embargo, si algún atributo es un puntero, tenemos que tener mucho cuidado con lo que hacemos. Supongamos que ClaseC tiene un atributo Atributo que es un puntero. Supongamos también que en el constructor de la clase, se hace new del puntero para que tenga algo y en el destructor se hace el delete correspondiente.
ClaseC
{
public:
// Se crea un array de tres
enteros
ClaseC () {
Atributo =
new int[3];
}
// Se libera el array.
~ClaseC () {
delete
[] Atributo;
}
protected:
int *Atributo;
};
Si ahora hacemos esto
// a tiene ahora un array de 3 enteros en su interior
ClaseC *a = new ClaseC();
// b tiena ahora otro array de 3 enteros en su interior
ClaseC *b = new ClaseC();
/* Se copia el Atributo de b sobre el de a, es decir, ahora a->Atributo
apunta al mismo sitio que b->Atributo */
*a = *b;
// Ahora si que la hemos liado.
delete b;
Cuando hacemos a=b, con el operador igual por defecto de C++, se hace que el puntero Atributo de a apunte al mismo sitio que el de b. El array original de a->Atributo lo hemos perdido, sigue ocupando memoria y no tenemos ningún puntero a él para liberarlo.
Cuando hacemos delete b, el destructor de b se encarga de liberar su array. Sin embargo, al puntero a->Atributo nadie le avisa de esta liberación, se queda apuntando a una zona de memoria que ya no es válida. Cuando intentemos usar a->Atributo más adelante, puede pasar cualquier cosa (cambios aleatorios de variables, caidas del programa, etc).
En el tema de punteros tienes un poco más detallado todo esto. Allí se habla de estructuras, pero también se aplica a clases.
La forma de solucionar esto, es definiendo nosotros un operator = que haga una copia real del array, liberando previamente el nuestro o copiando encima los datos.
ClaseC
{
public:
ClaseC &operator =
(const ClaseC &original)
{
int i;
/* Damos por supuesto que ambos arrays existen y son de tamaño 3
*/
for
(i=0;i<3;i++)
Atributo[i] = original.Atributo[i];
}
};
Otro operador interesante de sobrecargar es el new y el delete. Si los sobrecargamos dentro de la clase, cada vez que hagamos un new a nuestra clase se llamará a nuestro operador.
Más interesante es la sobrecarga de los operadores new y delete globales. Sobrecargando estos operadores se llamará a nuestras funciones cada vez que hagamos un new o un delete de cualquier cosa (clases o variables). Esta característica nos permite hacer contabilidad de punteros, para ver si liberamos todo lo que reservamos o liberamos lo mismo más veces de la cuenta.
Con esto, aunque no he entrado mucho en detalles, está todo más o menos dicho: Se pueden sobrecargar operadores en una clase, operadores globales fuera de las clases y algunos operadores interesantes/curiosos como los operadores de cast, new o delete .
En el siguiente código de ejemplo se implementa la clase ComplejoC, pero sólo los operadores de suma indicados, algunos constructores, operador para cout y el módulo.
En Complejo.h y Complejo.cc tienes la clase. En Prueba.cc tienes el programa principal que hace varias cosas con la clase. Para compilarlo todo, tienes el Makefile. Puedes descargar los cuatro ficheros, quitarles la extensión .txt y compilar con make .