viernes, 7 de febrero de 2014

Polimorfismo en C++

El uso del polimorfismo en C++ es uno de los temas que más complicaciones les trae a los estudiantes cuando están haciendo sus primeras armas en C++ y Programación Orientada a Objetos. Aclaro que estoy hablando de clases abstractas y métodos virtuales, porque algunos materiales cuentan la sobrecarga como un tipo de polimorfismo. Tal vez se deba a que para utilizarlo hay que tener claras las ideas de la POO y el diseño de clases por un lado (algo muy difícil si se tiene poca experiencia), y por otro lado los conceptos del manejo de memoria en C++, ya que involucra punteros y conversiones implícitas de tipo. Es decir, ni el diseño ni la sintaxis son triviales. Entonces es lógico que les resulte complejo. El problema es que culpa de esto, le esquivan todo lo posible, y terminan no utilizándolo.

Se puede hacer todo tipo de cosas en C++ sin utilizar polimorfismo, es posible evitarlo, o emularlo. Pero en muchísimos casos, hacerlo de esa manera implica más código, clases más complejas, implementaciones más rebuscadas, más dependencias entre objetos, o el uso de otros trucos tanto o más complicados que el mismo polimorfismo que estaban tratando de evitar. Voy a comentar en este artículo rápidamente de qué se trata, cuales son los mecanismos del compilador que hay por detrás, y qué usos le podemos dar, para que sirva de introducción a otros artículos donde comentaré casos particulares y de interés en PSeInt y en ZinjaI.

La idea básica está bien expresada en Wikipedia: "el polimorfismo se refiere a la propiedad por la que es posible enviar mensajes sintácticamente iguales a objetos de tipos distintos". En C++, el "mensaje" es una llamáda a un "método (de una clase)", y lo de "sintácticamente iguales" quiere decir" mismo nombre y mismos argumentos". Dos clases entonces pueden tener métodos con iguales prototipos, pero implementaciones diferentes. El ejemplo de libro sueler ser geométrico, las clases Rectángulo y Triángulo por ejemplo, tendrán ambas un método CalcularArea(), que hará diferentes operaciones en cada caso, pero simpre devolviendo un flotante al final. Para que esto sea posible: (1) ambas clases tienen que heredar de una base común (2), en la que ese método esté declarado como virtual, y (3) hacer la llamada mediantes punteros a esa clase base:

    class Forma {
        ...
        virtual float CalcularArea()=0;
    };
    class Rectángulo : public Forma {
        ...
        float CalcularArea() { .... }
    };
    int main() {
        ...
        Forma *p = new Rectangulo(...);
        cout<<p->CalcularArea();
        ...
    }

Vemos rápidamente porqué son necesarias estas 3 cosas. La idea es que a la llamada polimórfica la resuelva el compilador, que no tengamos que hacer un if ni nada de eso. Entonces, el elemento no será tratado como un Rectángulo, ni como un Triángulo, sino como una Forma genérica, y el compilador se encargará de averiguar qué hay en realidad, por eso (1). Pero para poder hacer una llamada a un método a partir de un objeto o de un puntero a objeto, necesitamos que la clase en cuestión tenga definido ese método. Por esto hay que definir el método en la clase base (aunque no lo implementemos ahí), pero además hay que hacerlo con el calificativo de "virtual". El compilador normalmente decide en tiempo de compilación a cual método llamar simplemente mirando el tipo del objeto o puntero (que en este ejemplo sería Base y no Rectángulo). La palabra "virtual" le avisa que no haga eso, que la decisión será delegada al tiempo de ejecución en base a lo que realmente se apunte. Por esto es (2).

Para que exista una decisión que tomar tiene que haber alguna mezcla de tipos, pues si los tipos están claros no hay dudas de cual llamar. La mezcla solo es posible gracias a los punteros. Si asigno un Rectángulo a una Forma, solo se copia en Forma la parte de rectángulo que hereda de Forma (pues en una Forma no entra más que eso, no le entra el "extra" de Rectángulo). Pero si asigno un puntero a Rectángulo en un puntero a Forma, no pierdo nada, porque son sólo direcciones de memoria. Y es válido, porque la primer parte de un Rectángulo se ve exactamente como una Forma (una objeto generado por herencia organiza en memoria al principio la parte heredada y al final lo propio). Por esto (3). Pero teniendo un puntero de tipo Forma, solo puedo invocar a los métodos definidos en Forma, no importa que apunte a otra cosa, porque el control de qué se invoca sí se hace en tiempo de compilación, y nuevamente por eso (2).

Entonces, tengo un puntero que apunta a algo más grande (Triángulo o Rectángulo) de lo que parece (Forma). El compilador deberá darse cuenta de esto y llamar a un método de ese algo más grande, y no de lo que parece. Si no ponemos virtual, el compilador hace lo que parece, decide al momento de compilar a cual llamar (al de Forma), y listo. Esta es la llamada más eficiente posible. Delegar la decisión para la ejecución implica una pequeña sobrecarga en la ejecución. Muchos creen que esta sobrecarga es costosa, y que por eso el polimorfismo hace que perdamos eficiencia. Esto no es cierto en la mayoría de los casos, la sobrecarga es generalmente muy muy barata y normalmente no la notaremos.

¿Cómo hace entonces? La solución es simple, me sorprendió la primera vez que lo leí: por cada clase con métodos virtuales (no objeto, sino tipo) hay una tabla en algún lugar (estática) con punteros a métodos. Es decir, la clase Forma tendrá un atributo más, invisible, que es un puntero a una tabla. En esa tabla hay un puntero a "su" implementación de CalcularArea (si es que la tiene). Cuando creamos un Rectángulo, por ejemplo, el rectángulo reemplaza el puntero a la tabla (que heredó de Forma) por otra dirección, la de su propia tabla (común a todos los rectángulos), donde está la dirección de su implementación de CalcularArea. Entonces, cuando llamamos a p->CalcularArea(), en realidad se va a la tabla (cuyo puntero está en Forma, pero que el constructor de Rectángulo ha hecho apuntar a "su" tabla), y se invoca al método que dice la tabla que toque. Toda la sobrecarga es un nivel de indirección, es buscar una posición fija en una tabla, es invocar al método a travéz de un puntero en lugar de hacerlo directamente, son operaciones simples, es un tiempo constante y muy muy bajo. Todas las tablas son estáticas, y solo existen para clases con métodos virtuales, si no no se usan.

Esta sería (casi) una forma de implementarlo, donde las tmv son las Tablas de Métodos Virtuales, y ptr_oculto es el atributo implícito que añaden las clases con métodos virtuales. Lo que está en rojo sería lo que el compilador añade para hacerlo posible.

Veamos casos de aplicación en la vida real, empezando por los ejemplos más clásicos. Uno sería un juego, donde una clase Juego controla el bucle principal, y en cada iteración debe actualizar y dibujar un montón de Actores (personajes u objetos del juego) que habrá en el escenario. Usando polimorfismo, el juego tienen un arreglo de punteros a Actores, y en cada vuelta llama a los métodos Actualizar y Dibujar de cada uno, tratándolos así a todos por igual. El polimorfismo se encarga de elegir qué corresponde hacer para cada tipo de actor (no se mueve igual un Gnomo que un Caballero, o no atacan igual un Dragón y una Princesa). El único punto del código de Juego donde el tipo es importante, es cuando se crean los actores, el new será diferente según la clase de actor. Entonces si agrego un nuevo tipo de personaje tendré probablemente que agregar un nuevo if en la inicialización del juego para poder hacer el new de ese nuevo tipo. Pero el bucle principal no cambiará. Con polimorfismo entonces, que tengamos 2 o 100 tipos de actores no hace casi diferencia, no hay que andar agregando ifs por todos lados para separar los distintos tipos.

Otro ejemplo de libro es el caso de las GUIs, de las bibliotecas para hacer ventanas con botones, cuadros de texto, barras de scroll, etc. La clase Ventana tendrá una lista de punteros a controles genéricos, y al momento de dibujarse llamará al método para dibujar de cada control, o al momento de procesar eventos, probará delegar el evento a cada control. Si los métodos para esto son polimorficos, a la implementación de Ventana no le importa cuales son en particular los controles, ni cuantos tipos hay, mientras hereden todos de la misma clase Control.

Pero hay otro uso muy interesante que descubrí leyendo a Stroustroup. Un problema común en C++ son los tiempos de compilación. Para poder compilar un cpp que usa una clase (digamos Util), necesitamos la definición completa de la clase, lo cual suele ser un "#include <Util.h>". Si es una clase muy usada, ese include estará por todos lados, y entonces cambiar algo en esa clase, aunque sea algo privado que nadie de afuera puede ver, obligará a recompilar una gran cantidad de cpps. La solución es armar una interfaz que separe; por ejemplo una clase InterfazUtil. Que los cpps cliente hagan el #include de esa interfaz. Esa interfaz solo tendrá los métodos visibles (públicos) de la clase Util, como virtuales, y así los cpps podrán llamar a los reales gracias al polimorfismo. Pero dejará entonces los detalles de implementación para clase hija Util, que se compilará por separado, y que agregará todos los atributos y métodos privados que quiera sin que se note afuera. Así, solo será necesario recompilar todo cuando realmente cambie la interfaz pública, pero no si cambian por ejemplo los atributos privados, o si se necesita agregar algun método auxiliar privado.

Esto es en general válido para clases grandes, pero no para cosas chicas, ya que nos obliga por un lado a trabajar con punteros, alocar dinámicamente las instancias, y además le impide al compilador hacer optimizaciones como el inlining de los métodos. Entonces, no lo usaría para la clase Punto, ni para Triángulo o Complejo; tal vez sí para Juego, para LaGranBaseDeDatos, o para VentanaPrincipal. Pero siguiendo con el ejemplo de Util, hay otra ventaja de uso más común. Pensemos que en el programa tendríamos un puntero de tipo InterfazUtil apuntando a una instancia de Util. Podemos cambiar la instancia de Util por otra de una seguna clase Util2 que herede de la misma interfaz sin que el resto de el programa se vea afectado. Hasta lo podemos hacer en tiempo de ejecución. Entonces, en un juego por ejemplo, podemos modelar la interfaz del motor de renderizado, y "enchufar" mediante el new adecuado uno de un conjunto de posibles motores gráficos, teniendo por ejemplo una herencia para OpenGL, una para DirectX, una para SDL, etc.

Como vemos, el polimorfismo es muy útil para hacer código prolijo y sobre todo flexible. Es también útil para otros trucos como los de los últimos párrafos. Una vez que uno entiende cómo usarlo ve que no es tan difícil y le pierde el miedo, y una vez que uno entiendo cómo lo implementa el compilador ve que no es nada ineficiente. Pueden descargar un apunte más extenso, con una especie de paso a paso, que armé para mis alumnos hace un tiempo. En próximos posts comentaré qué uso le doy en PSeInt y en ZinjaI.

3 comentarios:

  1. Es por eso que se inventó Java...

    ResponderEliminar
    Respuestas
    1. No se puede comparar Java con C++ más allá de la sintaxis. Son lenguajes completamente diferentes, basados en principios muy diferentes, y por eso lo que para los usuarios de uno es una ventaja para los del otro es una gran contra.

      Las clases funcionando de interfaces (con solo metodos publicos virtuales) se parecen (creo) a las interfases de java, con las que supuestamente elimina el problema de la herencia múltiple, pero no estoy seguro de que estén hablando del mismo problema.

      Eliminar
  2. Sólo hablaba de la sintaxis (si dudas los propositos y la arquitectura es completamente diferente).
    En Java todas las instancias son punteros/referencias, todos los métodos son virtuales, no hay herencia múltiple (no se elimina con interfaces, se usan soluciones alternativas basadas en interfaces). En Java:

    class Forma {
    ...
    public float CalcularArea(){...}
    }

    class Rectángulo extends Forma {
    ...
    public float CalcularArea() { .... }
    }

    class Test {
    public static void main() {
    ...
    Forma p = new Rectangulo(...);
    System.out.println(p.CalcularArea());
    ...
    }
    }

    Saludos

    ResponderEliminar