Cuando se manejan jerarquías de tipos, se suele tratar un objeto no como el tipo específico que es sino como su tipo base. Esto le permite escribir código que no depende de los tipos específicos. En el ejemplo de la figura, las funciones manipulan figuras genéricas sin preocuparse de si son círculos, cuadrados, triángulos, etc. Todas las figuras se pueden dibujar, borrar y mover, así que estas funciones simplemente envían un mensaje a un objeto figura, sin preocuparse de cómo se las arregla el objeto con cada mensaje.
Semejante código no se ve afectado por la adición de nuevos tipos, y añadir nuevos tipos es la forma más común de extender un programa orientado a objetos para tratar nuevas situaciones. Por ejemplo, puede derivar un nuevo subtipo de figura llamado pentágono sin modificar las funciones que tratan sólo con figuras genéricas. Esta capacidad para extender un programa fácilmente derivando nuevos subtipos es importante porque mejora enormemente los diseños al mismo tiempo que reduce el coste del mantenimiento del software.
Hay un problema, no obstante, con intentar tratar objetos de un tipo derivado como sus tipos base genéricos (círculos como figuras, bicicletas como vehículos, cormoranes como pájaros, etc). Si una función va a indicar a una figura genérica que se dibuje a sí misma, o a un vehículo genérico que gire, o a un pájaro genérico que se mueva, el compilador en el momento de la compilación no sabe con precisión qué pieza del código será ejecutada. Éste es el punto clave - cuando el mensaje se envía, el programador no quiere saber qué pieza de código será ejecutada; la función draw() se puede aplicar a un círculo, un cuadrado, o un triángulo, y el objeto ejecutará el código correcto dependiendo de su tipo específico. Si no tiene que saber qué pieza de código se ejecutará, cuando añada un nuevo subtipo el código que éste ejecute podrá ser diferente sin necesidad de cambiar la llamada a la función. Por tanto, el compilador no puede saber con precisión qué pieza de código se ejecuta; entonces, ¿qué hace? Por ejemplo, en el siguiente diagrama el objeto ControladorDePájaro trabaja sólo con objetos genéricos Pájaro, y no sabe de qué tipo son exactamente. Esto es conveniente desde la perspectiva del ControladorDePájaro, porque no tiene que escribir código especial para determinar el tipo exacto de Pájaro con el que está trabajando, o el comportamiento del Pájaro. Entonces, ¿cómo es posible que, cuando se invoca mover() ignorando el tipo específico de Pájaro, se produzca el comportamiento correcto (un Ganso corre, vuela, o nada, y un Pingüino corre o nada)?
La respuesta es el giro fundamental de la programación orientada a objetos: el compilador no puede hacer una llamada a la función en el sentido tradicional. La llamada a función generada por un compilador no-OO provoca lo que se llama una ligadura temprana (early binding), un término que quizá no haya oído antes porque nunca ha pensado en que hubiera ninguna otra forma. Significa que el compilador genera una llamada al nombre de la función específica, y el enlazador resuelve esta llamada con la dirección absoluta del código que se ejecutará. En POO, el programa no puede determinar la dirección del código hasta el momento de la ejecución, de modo que se necesita algún otro esquema cuando se envía un mensaje a un objeto genérico.
Para resolver el problema, los lenguajes orientados a objetos usan el concepto de ligadura tardía (late binding). Cuando envía un mensaje a un objeto, el código invocado no está determinado hasta el momento de la ejecución. El compilador se asegura de que la función existe y realiza una comprobación de tipo de los argumentos y el valor de retorno (el lenguaje que no realiza esta comprobación se dice que es débilmente tipado), pero no sabe el código exacto a ejecutar.
Para llevar a cabo la ligadura tardía, el compilador de C++ inserta un trozo especial de código en lugar de la llamada absoluta. Este código calcula la dirección del cuerpo de la función, usando información almacenada en el objeto (este proceso se trata con detalle en el Capítulo 15). De este modo, cualquier objeto se puede comportar de forma diferente de acuerdo con el contenido de este trozo especial de código. Cuando envía un mensaje a un objeto, el objeto comprende realmente qué hacer con el mensaje.
Es posible disponer de una función que tenga la flexibilidad de las propiedades de la ligadura tardía usando la palabra reservada virtual. No necesita entender el mecanismo de virtual para usarla, pero sin ella no puede hacer programación orientada a objetos en C++. En C++, debe recordar añadir la palabra reservada virtual porque, por defecto, los métodos no se enlazan dinámicamente. Los métodos virtuales le permiten expresar las diferencias de comportamiento en clases de la misma familia. Estas diferencias son las que causan comportamientos polimórficos.
Considere el ejemplo de la figura. El diagrama de la familia de clases (todas basadas en la misma interfaz uniforme) apareció antes en este capítulo. Para demostrar el polimorfismo, se quiere escribir una única pieza de código que ignore los detalles específicos de tipo y hable sólo con la clase base. Este código está desacoplado de la información del tipo específico, y de esa manera es más simple de escribir y más fácil de entender. Y si se añade mediante herencia un nuevo tipo (un Hexagon, por ejemplo), el código que escriba funcionará igual de bien para el nuevo tipo de Shape que para los tipos anteriores. De esta manera, el programa es extensible.
Si escribe una función C++ (podrá aprender dentro de poco cómo hacerlo):
void doStuff(Shape& s) { s.erase(); // ... s.draw(); }
Esta función se puede aplicar a cualquier Shape, de modo que es independiente del tipo específico del objeto que se dibuja y borra (el «&» significa «toma la dirección del objeto que se pasa a doStuff()», pero no es importante que entienda los detalles ahora). Si en alguna otra parte del programa se usa la función doStuff():
Circle c; Triangle t; Line l; doStuff(c); doStuff(t); doStuff(l);
Las llamadas a doStuff() funcionan bien automáticamente, independientemente del tipo concreto del objeto.
En realidad es un truco bastante asombroso. Considere la línea:
doStuff(c);
Lo que está ocurriendo aquí es que está pasando un Circle a una función que espera una Shape. Como un Circle es una Shape se puede tratar como tal por parte de doStuff(). Es decir, cualquier mensaje que pueda enviar doStuff() a una Shape, un Circle puede aceptarlo. Por eso, es algo completamente lógico y seguro.
A este proceso de tratar un tipo derivado como si fuera su tipo base se le llama upcasting (moldeado hacia arriba[18]). El nombre cast (molde) se usa en el sentido de adaptar a un molde y es hacia arriba por la forma en que se dibujan los diagramas de clases para indicar la herencia, con el tipo base en la parte superior y las clases derivadas colgando debajo. De esta manera, moldear hacia un tipo base es moverse hacia arriba por el diagrama de herencias: «upcasting».
Todo programa orientado a objetos tiene algún upcasting en alguna parte, porque así es como se despreocupa de tener que conocer el tipo exacto con el que está trabajando. Mire el código de doStuff():
s.erase(); // ... s.draw();
Observe que no dice «Si es un Circle, haz esto, si es un Square, haz esto otro, etc.». Si escribe ese tipo de código, que comprueba todos los tipos que puede ser realmente una Shape, resultará sucio y tendrá que cambiarlo cada vez que añada un nuevo tipo de Shape. Aquí, sólo dice «Eres una figura; sé que puedes borrarte con erase() y dibujarte con draw(), así que hazlo y ocúpate de los detalles».
Lo impresionante del código en doStuff() es que, de alguna manera, funciona bien. Llamar a draw() para un Circle ejecuta un código diferente que cuando se llama a draw() para un Square o una Line, pero cuando se envía el mensaje draw() a una Shape anónima, se produce el comportamiento correcto según el tipo real de la Shape. Esto es asombroso porque, como se mencionó anteriormente, cuando el compilador C++ está compilando el código para doStuff(), no sabe exactamente qué tipos está manipulando. Por eso normalmente, es de esperar que acabe invocando la versión de erase() y draw() para Shape, y no para el Circle, Square, o Line específico. Y aún así ocurre del modo correcto a causa del polimorfismo. El compilador y el sistema en tiempo de ejecución se encargan de los detalles; todo lo que necesita saber es que esto ocurre y lo que es más importante, cómo utilizarlo en sus diseños. Si un método es virtual, entonces cuando envíe el mensaje a un objeto, el objeto hará lo correcto, incluso cuando esté involucrado el upcasting.
[18] N. de T: En el libro se utilizará el término original en inglés debido a su uso común, incluso en la literatura en castellano.