Es fácil imaginar lo que sucede cuando hay herencia y se sobreescriben 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 sobreescrito. De un modo u otro, para todos los objetos que se puedan crear (es decir, aquellos que no tengan 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 añade una nueva función virtual 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
sobreescribe 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 mapea 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. Sin importar el subtipo en
concreto del objeto, su VTABLE está colocada de la misma forma por
lo que llamar a una función virtual se hará 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 a las únicas funciones
a las que el compilador permitirá acceder. ¿Cómo es posible saber
que se está trabajando con un objeto Dog
si
sólo hay 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()
. En este punto, puede o no tener otra
dirección a función en la VTABLE, pero en cualquiera de los casos,
hacer una llamada a una función virtual de esa 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 poco comunes casos en los cuales se sabe que el puntero actualmente apunta al 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í, parece saberse que p[1]
apunta a un
objeto Dog
, pero en general no se sabe. Si
el problema consiste en averiguar el tipo exacto de todos los
objetos, hay que volver a pensar porque posiblemente 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, por ejemplo aquellos incluidos en un contenedor
genérico. Este 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 actuales, por lo que hay que saber exactamente el tipo al que pertenece. 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 una dirección de un objeto a pasar el objeto por valor cuando se usa el polimorfismo. Todos los ejemplos que se han visto, y prácticamente todos los ejemplos que se verán, se pasan por referencia y no por valor. Esto se debe a que todas las direcciones tienen el mismo tamaño[79], por lo que pasar la dirección 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 de un objeto en vez de usar un puntero o una referencia, pasará algo que puede resultar sorprendente: el objeto es "truncado", recortado, hasta que lo que quede sea un subobjeto que corresponda al tipo destino del molde. En el siguiente ejemplo se puede ver que 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". De hecho, ambas usan la versión
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 deshecha 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
, ¡el cual no existe porque fue
truncado!. Entonces, ¿Qué ocurre cuando se llama a la función
virtual?
El desastre es evitado porque el objeto es pasado 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. Quitando interpretaciones, el objeto se convierte
realmente en una Pet
durante el
truncado.
El Object Slicing quita parte del
objeto existente y se copia en un nuevo objeto, en vez de
simplemente cambiar el significado de una dirección 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 a controlar y
prevenir. 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á el object
slicing debido a que no se puede "crear" un
objeto de la clase base (que al fin y al cabo es lo que sucede
cuando se hace un upcast por valor). ésto podría ser el valor
más importante de las funciones virtuales puras: prevenir el
object slicing generando un error
en tiempo de compilación si alguien lo intenta hacer.
[79] Actualmente, no todos los punteros tienen el mismo tamaño en todos las máquinas. Sin embargo, en el contexto de esta discusión se pueden considerar iguales.