Es fácil imaginar lo que sucede cuando hay herencia y se redefinen algunas de las funciones virtuales. El compilador crea una nueva VTABLE para la nueva clase, e inserta las nuevas direcciones de las funciones usando además las direcciones de las funciones de la clase base para aquellas funciones virtuales que no se hayan redefinido. De un modo u otro, para todos los objetos que se puedan crear (es decir, aquellos cuya clase no tenga funciones virtuales puras) existe un conjunto completo de direcciones de funciones en la VTABLE, por lo que será imposible hacer llamadas a una dirección que no esté en la VTABLE (lo cual sería desastroso).
Pero ¿qué ocurre cuando se hereda y se añaden nuevas funciones virtuales en la clase derivada? Aquí hay un sencillo ejemplo:
//: C15:AddingVirtuals.cpp // Adding virtuals in derivation #include <iostream> #include <string> using namespace std; class Pet { string pname; public: Pet(const string& petName) : pname(petName) {} virtual string name() const { return pname; } virtual string speak() const { return ""; } }; class Dog : public Pet { string name; public: Dog(const string& petName) : Pet(petName) {} // New virtual function in the Dog class: virtual string sit() const { return Pet::name() + " sits"; } string speak() const { // Override return Pet::name() + " says 'Bark!'"; } }; int main() { Pet* p[] = {new Pet("generic"),new Dog("bob")}; cout << "p[0]->speak() = " << p[0]->speak() << endl; cout << "p[1]->speak() = " << p[1]->speak() << endl; //! cout << "p[1]->sit() = " //! << p[1]->sit() << endl; // Illegal } ///:~
Listado 15.8. C15/AddingVirtuals.cpp
La clase Pet tiene dos funciones virtuales: speak() y name(). Dog añade una tercera función virtual llamada sit(), y redefine el significado de speak(). Un diagrama ayuda a visualizar qué está ocurriendo. Se muestran las VTABLEs creadas por el compilador para Pet y Dog:
Hay que hacer notar que el compilador coloca la dirección de speak() en exactamente el mismo lugar tanto en la VTABLE de Dog como en la de Pet. De igual forma, si una clase Pug heredara de Dog, su versión de sit() ocuparía su lugar en la VTABLE en la misma posición que en Dog. Esto es debido a que el compilador genera un código que usa un simple desplazamiento numérico en la VTABLE para seleccionar una función virtual, como se vio con el ejemplo en lenguaje ensamblador. Sea cual sea el subtipo concreto al que pertenezca el objeto, su VTABLE tiene la misma disposición, por lo que las llamadas a las funciones virtuales se harán siempre del mismo modo.
En este caso, sin embargo, el compilador está trabajando sólo con un puntero a un objeto de la clase base. La clase base tiene únicamente las funciones speak() y name(), por lo que son las únicas funciones a las que el compilador permitirá llamar. ¿Cómo podría saber que se está trabajando con un objeto Dog si sólo tiene un puntero a un objeto de la clase base? El puntero podría apuntar a algún otro tipo, que no tenga una función sit(). Puede que tenga o no otra dirección de función en esa posición de la VTABLE, pero en cualquiera de los casos, hacer una llamada virtual a esa dirección de la VTABLE no es lo que se desea hacer. De modo que el compilador hace su trabajo impidiendo hacer llamadas virtuales a funciones que sólo existen en las clases derivadas.
Hay algunos casos poco comunes en los cuales se sabe que el puntero apunta realmente a un objeto de una subclase específica. Si se quiere hacer una llamada a una función que sólo exista en esa subclase, entonces hay que hacer un molde (cast) del puntero. Se puede quitar el mensaje de error producido por el anterior programa con:
((Dog *) p[1])->sit()
Aquí se da la circunstancia de que se sabe que p[1] apunta a un objeto Dog, pero en general no se sabe. Si el problema está planteado de forma que hay que conocer el tipo exacto de todos los objetos, habría que replantearlo, porque probablemente no se estén usando las funciones virtuales de forma apropiada. Sin embargo, hay algunas situaciones en las cuales el diseño funciona mejor (o no hay otra elección) si se conoce el tipo exacto de todos los objetos guardados en un contenedor genérico. Éste es el problema de la run time type identification o RTTI (identificación de tipos en tiempo de ejecución).
RTTI sirve para moldear un puntero de una clase base y «bajarlo» a un puntero de una clase derivada («arriba» y «abajo», en inglés «up» y «down» respectivamente, se refieren al típico diagrama de clases, con la clase base arriba). Hacer el molde hacia arriba (upcast) funciona de forma automática, sin coacciones, debido a que es completamente seguro. Hacer el molde en sentido descendente (downcast) es inseguro porque no hay información en tiempo de compilación sobre los tipos reales, por lo que hay que saber exactamente de qué tipo es el objeto. Si se hace un molde al tipo equivocado habrá problemas.
RTTI se describe posteriormente en este capítulo, y el Volumen 2 de este libro tiene un capítulo dedicado al tema.
Existe una gran diferencia entre pasar las direcciones de los objetos y pasar los objetos por valor cuando se usa el polimorfismo. Todos los ejemplos que se han visto, y prácticamente todos los ejemplos que se verán, pasan direcciones y no valores. Esto se debe a que todas las direcciones tienen el mismo tamaño[83], por lo que pasar la dirección de un objeto de un tipo derivado (que normalmente será un objeto más grande) es lo mismo que pasar la dirección de un objeto del tipo base (que es normalmente más pequeño). Como se explicó anteriormente, éste es el objetivo cuando se usa el polimorfismo - el código que maneja un tipo base puede también manejar objetos derivados de forma transparente.
Si se hace un upcast a un objeto en vez de a un puntero o una referencia, pasará algo que puede resultar sorprendente: el objeto es «truncado», recortado, hasta que lo único que queda es el subobjeto que corresponde al tipo destino del molde. En el siguiente ejemplo se puede ver qué ocurre cuando un objeto es truncado (object slicing):
//: C15:ObjectSlicing.cpp #include <iostream> #include <string> using namespace std; class Pet { string pname; public: Pet(const string& name) : pname(name) {} virtual string name() const { return pname; } virtual string description() const { return "This is " + pname; } }; class Dog : public Pet { string favoriteActivity; public: Dog(const string& name, const string& activity) : Pet(name), favoriteActivity(activity) {} string description() const { return Pet::name() + " likes to " + favoriteActivity; } }; void describe(Pet p) { // Slices the object cout << p.description() << endl; } int main() { Pet p("Alfred"); Dog d("Fluffy", "sleep"); describe(p); describe(d); } ///:~
Listado 15.9. C15/ObjectSlicing.cpp
La función describe() recibe un objeto de tipo Pet por valor. Después llama a la función virtual description() del objeto Pet. En el main(), se puede esperar que la primera llamada produzca «This is Alfred», y que la segunda produzca «Fluffy likes to sleep». En realidad, ambas usan la versión de description() de la clase base.
En este programa están sucediendo dos cosas. Primero, debido a que describe() acepta un objeto Pet (en vez de un puntero o una referencia), cualquier llamada a describe() creará un objeto del tamaño de Pet que será puesto en la pila y posteriormente limpiado cuando acabe la llamada. Esto significa que si se pasa a describe() un objeto de una clase heredada de Pet, el compilador lo acepta, pero copia únicamente el fragmento del objeto que corresponda a una Pet. Se desecha el fragmento derivado del objeto:
Ahora queda la cuestión de la llamada a la función virtual. Dog::description() hace uso de trozos de Pet (que todavía existe) y de Dog, ¡que ya no existe porque fue truncado! Entonces, ¿qué ocurre cuando se llama a la función virtual?
Se evita el desastre porque el objeto se pasa por valor. Debido a esto, el compilador conoce el tipo exacto del objeto porque el objeto derivado ha sido forzado a transformarse en un objeto de la clase base. Cuando se pasa por valor, se usa el constructor de copia del objeto Pet, que se encarga de inicializar el VPTR a la VTABLE de Pet y copia sólo las partes del objeto que correspondan a Pet. En el ejemplo no hay un constructor de copia explícito por lo que el compilador genera uno. Se mire como se mire, el objeto se convierte realmente en una Pet durante el truncado.
El truncado de objetos elimina realmente parte del objeto existente al copiarlo en el nuevo objeto, en vez de simplemente cambiar el significado de una dirección como ocurre cuando se usa un puntero o una referencia. Debido a esto, el upcasting a un objeto no se usa a menudo; de hecho, normalmente es algo que hay que vigilar y evitar. Hay que resaltar que en este ejemplo, si description() fuera una función virtual pura en la clase base (lo cual es bastante razonable debido a que realmente no hace nada en la clase base), entonces el compilador impediría el truncado de objetos, debido a que no permitiría «crear» un objeto del tipo base (que es lo que sucede cuando se hace un upcast por valor). Éste podría ser el valor más importante de las funciones virtuales puras: evitar el truncado de objetos generando un mensaje de error en tiempo de compilación si alguien lo intenta hacer.
[83] En realidad, no todos los punteros tienen el mismo tamaño en todas las máquinas. Sin embargo, en el contexto de esta discusión se pueden considerar iguales.