15.8. Herencia y la VTABLE

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:

Una nueva función virtual

Figura 15.4. Una nueva función virtual


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.

15.8.1. FIXME: Object slicing

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:

Object slicing

Figura 15.5. Object slicing


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.