21 diciembre 2010

Sobrecarga de operadores en C++.

Últimamente he visto algunas dudas respecto de este tema en la sección de C/C++ de elhacker.net -sección en la cual soy moderador-. Este tipo de dudas suelen ser las que mas me gustan, porque a simple vista parecen simples pero en el fondo hay una complejidad que la mayoría de las personas que están aprendiendo ese lenguaje todavía no conocen.

No voy a extenderme demasiado pero simplemente aclarar algunos puntos que suelen ser los que mas dudas generan. No escribiré todo en una sola entrada por supuesto, probablemente tome varias. En esta comenzare con lo básico del tema.

Otro detalle es que hace mucho que no posteo ninguna entrada relacionada a la programación, y siendo que es una de las áreas que mas me gusta creo que ya viene siendo hora.




Introducción

Cuando desarrollas una aplicación y se diseña la interfaz gráfica, siempre se tiene en mente el usuario final. No hay reglas generales que sirvan para diseñar una interfaz excelente que le guste a todos los usuarios, por esa razón uno trata de hacer la interfaz lo mas intuitiva posible, lo mas accesible que se pueda, y por sobre todas las cosas, fácil de manejar y de recordar.

Cuando desarrollas una clase o un conjunto de estas, también realizas un producto para un usuario final, con la diferencia que en este caso el usuario final es a la vez un programador.
Este simple hecho suele dar la falsa noción que, al ser el usuario final un programador, este tiene que entender todas las locuras que hayamos hecho a la hora de codificar. Es decir, "A fin de cuentas es un programador o no? si es muy bueno sabrá porque hice esto y aquello." son ideas que suelen existir en muchos programadores y que influyen negativamente en el resultado final.

Al igual que con la interfaz gráfica de una aplicación, la interfaz de una clase también debe diseñarse. Esta debe ser intuitiva, accesible, fácil de recordar y de manejar. Es aquí cuando entra la sobrecarga de operadores.


Para que se utiliza la sobrecarga de operadores?

La sobrecarga de operadores es una manera sencilla de simplificar los significados de ciertas operaciones que trabajen con tipos definidos por nosotros mismos (Lo que usualmente en C++ es una clase).
El objetivo final es reducir la curva de aprendizaje de nuestra clase al proveer una interfaz intuitiva que sea fácil de utilizar y recordar.

La idea principal radica en intentar simplificar los significados de las operaciones que realizamos con nuestras clases sobrecargando los operadores que mas se relacionen con lo que realmente se quiere hacer.

Si tuviésemos una clase que contiene un entero, y quisiésemos proveer una interfaz para sumar varios objetos sin utilizar sobrecarga de operadores, tendríamos que realizar un método que sume. Por ejemplo, algo así:



  1. MiClase Resultado = sum(Clase1,Clase2);

A simple vista no parece muy complicado, pero que pasa si queremos sumar 4 objetos?


  1. MiClase Resultado = sum(sum(Clase1,Clase2),sum(Clase1,Clase2));// No muy intuitivo que digamos.
Realmente no se ve muy agradable verdad?

Ahora veamos un ejemplo sobrecargando operadores:

  1. #include <iostream>
  2. class MiClase
  3. {
  4. private:
  5.     int Num;
  6. public:
  7.     MiClase(int Cnum) { Num = Cnum; }
  8.     
  9.     friend MiClase operator+(const MiClase &a, const MiClase &b);
  10.     int ObtNum() { return Num; }
  11. };
  12. MiClase operator+(const MiClase &a, const MiClase &b)
  13. {
  14.     return MiClase(a.Num + b.Num);
  15. }
  16. int main()
  17. {
  18.     MiClase Clase1(10);
  19.     MiClase Clase2(20);

    //Intuitivo, c=a+b;
  20.     MiClase Resultado = Clase1 + Clase2 + Clase1 + Clase2;
  21.     std::cout << "El resultado es: " << Resultado .ObtNum() <<  std::endl;
  22.     return 0;
  23. }
No inquietarse si el código no se entiende a la primera. Ya iré explicando con detalles mas adelante.

Que tiene que ver esto con la seguridad informática?

Directamente nada, indirectamente mucho. La mayoría de las vulnerabilidades en las aplicaciones se dan por errores de diseño u por descuidos. Al proveer una interfaz intuitiva para tu clase, logras reducir la tasa de errores de los programadores que la utilicen, facilitando el desarrollo de aplicaciones mas seguras y estables.

Cuando utilizar la sobrecarga de operadores?

Es normal que cuando uno intente resolver un problema, analice varias soluciones posibles, y luego bajo criterio propio  elija la mas adecuada para el caso determinado. Realmente es imposible tener una regla que aplique para todos los casos, y lo que hoy es mejor para determinada aplicación, puede ser lo peor para otra, por lo tanto nunca deben tomarse reglas absolutas y siempre hay que analizar el caso en particular. Sin embargo, se pueden seguir una pauta que es clave para decidir si se debe o no sobrecargar operadores. Muchas personas usualmente me preguntan porque se utiliza la sobrecarga de operadores siendo que los beneficios que otorga no compensan el posible desorden del código. En reglas generales respondo siempre lo mismo: La sobrecarga de operadores no se utiliza para hacernos la vida mas fácil, si no para hacerle la vida mas fácil a los otros, a los usuarios de tus clases. Hacerle la vida mas fácil a los usuarios finales de tu clase es la regla principal que se debe seguir. Si se esta seguro de poder lograrlo, perfecto, pero si no se esta seguro lo mejor es volver a analizar el caso en particular ya que tal vez sobrecargar operadores no sea necesario o incluso puede volverse perjudicial.

Comenzando con la sobrecarga de operadores

Antes que nada es esencial saber lo que es un operador. Un operador es un token que le indica al compilador que se van a realizar determinadas operaciones sobre objetos u variables –los operandos-. Es decir, si tenemos:  a= b+c; donde a, b y c son enteros, podemos decir que tenemos 3 operandos  (a, b y c) y 2 operadores (= y +). El comportamiento de la expresión y su significado están definidos por el lenguaje. Ahora bien, C++ permite redefinir el comportamiento de la mayoría de los operadores para que realicen una tarea aparentemente similar a la que originalmente realizan pero con un comportamiento especifico acorde a los tipos de datos que hayamos creado, por ende podemos tener algo como: a=b+c; donde a,b y c son objetos del tipo MiClase. Particularmente los operadores que se pueden sobrecargar son:
+     -     *     /     %     ^     &
|     ~     !     =     <     >     +=
-=    *=    /=    %=    ^=    &=    |=
<<    >>    >>=   <<=   ==    !=    <=
>=    &&    ||    ++    --    ->*    ,
->    []    ()    new   new[]  delete delete[]
Los que no pueden sobrecargarse son:
  • Selector directo de componente .
  • Operador de indirección de puntero-a-miembro .*
  • Operador de acceso a ámbito ::
  • Condicional ternario ?:
  • Directivas de preprocesado #   y # #
  • sizeof ,   typeid (si, son operadores también)
Cabe destacar que al sobrecargar un operador se puede modificar su comportamiento pero no cambiar el numero de operandos, ni la asociatividad ni la precedencia del operador respectivo.

Sintaxis de la sobrecarga de operadores

El prototipo de una sobrecarga de operador puede definirse como:
  1. <tipo de retorno> operator +  (parametros) {logica} ;
Es decir, el tipo de retorno, sumado al especificador operator seguido del operador que se desea sobrecargar. Veamos de nuevo el ejemplo inicial:
  1. MiClase operator+(const MiClase &a, const MiClase &b);

Para definir la lógica del operador, en este caso basta con definirla como si fuese cualquier otra función:
  1. MiClase operator+(const MiClase &a, const MiClase &b)
  2. {
  3.     return MiClase(a.Num + b.Num);
  4. }
Cabe destacar que que al sobrecargar un operador, como mínimo uno de los operandos debe ser un tipo definido por el usuario (generalmente una clase). No se puede sobrecargar un operador para que trabaje con dos tipos primitivos. Por ejemplo, alguien puede pensar en sobrecargar el operador “=” para poder asignar una cadena char* a otra cadena char* copiando el contenido como si fuese strcpy en lugar de asignar la dirección a la que apunta el puntero. Seria una idea valida, pero C++ no lo permite y por un motivo bastante claro. Si una operación tan simple como 1+1 puede tener distintos significados,  el compilador nunca estaría seguro de lo que 1+1 significa.

Miembros de la clase o función friend

Una de las dudas mas recurrentes es si un método debe ser miembro de la clase o una función friend. Pero primero que nada, que significa el especificador friend? La razón por la cual existen métodos públicos, protegidos y privados radica en permitir al programador encapsular la implementación de su clase de la interfaz de esta. En la mayoría de las situaciones esto es esencial, pero hay casos específicos en el cual dicho esquema se torna un tanto rígido para lo que en realidad queremos hacer. Ahí es cuando entra el especificador friend, el cual permite que una función/clase tenga acceso total a otra clase de la cual no es miembro, saltando en cierta forma, el mecanismo de métodos públicos protegidos y privados. La ventaja principal de las funciones friend es que la sintaxis suele ser mas legible dado que la llamada a un miembro es a.func() mientras que una función friend es simplemente func(). Dado esto el programador debe decidir cual de las dos sintaxis es mas legible para el caso en particular y optar por ella. La sobrecarga de un operador en la mayoría de las oportunidades requiere acceso a datos privados de la clase (siempre que no haya un miembro wrapper mediante) y una sintaxis agradable a la vista. Esas son las razones principales por la cual generalmente las sobrecarga de los operadores van acompañadas del especificador de acceso friend. No obstante los permisos otorgados a una clase/función no son hereditarios, transitivos ni recíprocos, es decir, casi igual que en la vida real:
  • Si tenemos una clase A que es amiga de B, las derivadas de la clase A no necesariamente tienen que tener acceso a la clase B. Es decir, la amistad no es hereditaria
    .
  • Si tenemos una clase A que es amiga de B, y B es amiga de C, A no tiene porque ser amiga de C. Es decir, la amistad no es transitiva.
  • Si tenemos una clase A que es amiga de B, B no tiene porque ser amiga de A. Es decir, la amistad no es reciproca.
Estas 3 reglas perjudican en cierta forma el concepto de orientación a objetos, y por esa razón una de las desventajas de las funciones friend es que requieren código extra para integrarlas completamente en un diseño OO. Las funciones friend deben utilizarse como una extensión de la interfaz y no mas, no debe abusarse. Por lo tanto la regla principal es usar miembros cuando puedas y funciones friend cuando debas. Siendo que las funciones friend no son miembros de una clase, no pueden declararse virtuales, por lo tanto el enlace dinamico (dynamic binding) no es posible directamente. Recordemos que para que un diseño sea orientado a objetos realmente, las funciones virtuales son necesarias, de lo contrario estamos hablando de un diseño basado en objetos. Supongamos que queremos proveer una sobrecarga de operador que tenga la posibilidad de imprimir todo un grupo de clases. Dadas las limitaciones que vimos anteriormente, alguien podría pensar que la única forma es implementar la sobrecarga en cada clase, pero esto es un tanto incomodo, engorroso, y de seguro aumenta el costo de mantenimiento y con esto los posibles futuros fallos. Para resolver este pequeño problema, lo que se suele hacer es declarar una función friend de la clase base. Esta función simplemente delega el trabajo a otra función que es miembro que si es virtual, siendo esta ultima la que se reemplaza en cada clase derivada para realizar la tarea.
  1. class Base {
  2.   public:
  3.     friend ostream& operator << (ostream& o, const Base& b);
  4.  protected:
  5.     virtual void print(ostream& o) const
  6.     { ... }
  7. };
  8. inline std::ostream& operator<< (std::ostream& o, const Base& b)
  9. {
  10.   b.print(o); // Delega el trabajo a la función polimorfica
  11.   return o;
  12. }
  13. class Derived : public Base {
  14.   protected:
  15.     virtual void print(ostream& o) const
  16.     { ... }
  17. };
Esto se conoce como Virtual Friend Function Idiom. Por hoy dejo esto aquí para no liar a nadie, cualquier duda y demás, aquí, por mail o en el foro  Para la próxima sigo con los templates, q es otra de las causas de dolores de cabeza. Saludos!

1 comentarios:

  1. buen post! me ha quedado muy claro. me gustaira ver el tema de templates tb

    salu2

    ResponderSuprimir