A menudo, en un diseño, se quiere que la clase base presente sólo una interfaz para sus clases derivadas. Es decir, no se quiere que nadie cree realmente un objeto de la clase base, sino sólo que se haga upcast hacia ella para poder usar su interfaz. Se consigue haciendo que la clase sea abstracta (abstract), lo que ocurre si se le pone como mínimo una función virtual pura. Se puede reconocer una función virtual pura porque usa la palabra reservada virtual y va seguida de =0. Si alguien intenta hacer un objeto de una clase abstracta, el compilador lo impide. Es una herramienta que permite imponer un diseño concreto.
Cuando se hereda una clase abstracta, hay que implementar todas las funciones virtuales puras, o la clase que hereda se convierte también en abstracta. Crear una función virtual pura permite poner una función miembro en una interfaz sin verse obligado a proporcionar un cuerpo de código posiblemente sin sentido para esa función miembro. Al mismo tiempo, una función virtual pura obliga a las clases que heredan a proporcionar una definición para ella.
En todos los ejemplos de los instrumentos, las funciones en la clase base Instrument eran siempre funciones «ficticias». Si alguna vez se llama a esas funciones, es que algo va mal. Esto es porque la intención de la clase Instrument es crear una interfaz común para todas las clases que deriven de ella.
La única razón para establecer una interfaz común es que después se pueda expresar de forma diferente en cada subtipo. Se crea una forma básica que determina lo que tienen en común todas las clases derivadas y nada más. Por esto, Instrument es un candidato adecuado para ser una clase abstracta. Se crea una clase abstracta cuando sólo se quiere manipular un conjunto de clases a través de una interfaz común, pero la interfaz común no necesita tener una implementación (o al menos, no necesita una implementación completa).
Si se tiene un concepto como Instrument que funciona como clase abstracta, los objetos de esa clase casi nunca tendrán sentido. Es decir, Instrument sirve solamente para expresar la interfaz, y no una implementación particular, por lo que crear un objeto que sea únicamente un Instrument no tiene sentido, y probablemente se quiera impedir que el usuario lo haga. Se puede conseguir haciendo que todas las funciones virtuales de Instrument muestren mensajes de error, pero eso retrasa la aparición de la información del error hasta el tiempo de ejecución y requiere que el usuario realice pruebas exhaustivas y fiables. Es mucho mejor cazar el problema en tiempo de compilación.
Ésta es la sintaxis usada para declarar una función virtual pura:
virtual void f() = 0;
Haciendo esto, se indica al compilador que reserve un hueco para una función en la VTABLE, pero que no ponga una dirección en ese hueco. Incluso aunque sólo una función en una clase sea declarada como virtual pura, la VTABLE estará incompleta.
Si la VTABLE de una clase está incompleta, ¿qué se supone que debe hacer el compilador cuando alguien intente crear un objeto de esa clase? No puede crear de forma segura un objeto de una clase abstracta, por lo que se obtiene un mensaje de error del compilador. Así, el compilador garantiza la pureza de una clase abstracta. Hacer clases abstractas asegura que el programador cliente no pueda hacer mal uso de ellas.
He aquí Instrument4.cpp modificado para usar funciones virtuales puras. Debido a que la clase no tiene más que funciones virtuales puras, se la llama clase abstracta pura:
//: C15:Instrument5.cpp // Pure abstract base classes #include <iostream> using namespace std; enum note { middleC, Csharp, Cflat }; // Etc. class Instrument { public: // Pure virtual functions: virtual void play(note) const = 0; virtual char* what() const = 0; // Assume this will modify the object: virtual void adjust(int) = 0; }; // Rest of the file is the same ... class Wind : public Instrument { public: void play(note) const { cout << "Wind::play" << endl; } char* what() const { return "Wind"; } void adjust(int) {} }; class Percussion : public Instrument { public: void play(note) const { cout << "Percussion::play" << endl; } char* what() const { return "Percussion"; } void adjust(int) {} }; class Stringed : public Instrument { public: void play(note) const { cout << "Stringed::play" << endl; } char* what() const { return "Stringed"; } void adjust(int) {} }; class Brass : public Wind { public: void play(note) const { cout << "Brass::play" << endl; } char* what() const { return "Brass"; } }; class Woodwind : public Wind { public: void play(note) const { cout << "Woodwind::play" << endl; } char* what() const { return "Woodwind"; } }; // Identical function from before: void tune(Instrument& i) { // ... i.play(middleC); } // New function: void f(Instrument& i) { i.adjust(1); } int main() { Wind flute; Percussion drum; Stringed violin; Brass flugelhorn; Woodwind recorder; tune(flute); tune(drum); tune(violin); tune(flugelhorn); tune(recorder); f(flugelhorn); } ///:~
Listado 15.6. C15/Instrument5.cpp
Las funciones virtuales puras son útiles porque hacen explícita la abstracción de una clase e indican al usuario y al compilador cómo se pretendía que se usara.
Hay que hacer notar que las funciones virtuales puras impiden que una clase abstracta se pase a una función por valor, por lo que son también una manera de evitar el truncado de objetos (object slicing), que se describirá en breve. Convertir una clase en abstracta también permite garantizar que se use siempre un puntero o una referencia cuando se haga upcasting a esa clase.
El hecho de que una función virtual pura impida que la VTABLE esté completa no significa que no se quieran cuerpos de función para algunas de las otras funciones. A menudo se querrá llamar a la versión de la función que esté en la clase base, incluso aunque ésta sea virtual. Es una buena idea poner siempre el código común tan cerca como sea posible de la raíz de la jerarquía. No sólo ahorra código, sino que permite fácilmente la propagación de cambios.
Es posible proporcionar una definición para una función virtual pura en la clase base. Se le sigue diciendo al compilador que no permita crear objetos de esa clase base abstracta, y las funciones virtuales puras siguen teniendo que definirse en las clases derivadas para poder crear objetos. Sin embargo, puede haber un fragmento de código común al que se quiera llamar desde algunas o todas las definiciones de las clases derivadas en vez de duplicar ese código en cada función.
Éste es el aspecto de una definición virtual pura:
//: C15:PureVirtualDefinitions.cpp // Pure virtual base definitions #include <iostream> using namespace std; class Pet { public: virtual void speak() const = 0; virtual void eat() const = 0; // Inline pure virtual definitions illegal: //! virtual void sleep() const = 0 {} }; // OK, not defined inline void Pet::eat() const { cout << "Pet::eat()" << endl; } void Pet::speak() const { cout << "Pet::speak()" << endl; } class Dog : public Pet { public: // Use the common Pet code: void speak() const { Pet::speak(); } void eat() const { Pet::eat(); } }; int main() { Dog simba; // Richard's dog simba.speak(); simba.eat(); } ///:~
Listado 15.7. C15/PureVirtualDefinitions.cpp
El hueco en la VTABLE de Pet todavía está vacío, pero resulta que hay una función con ese nombre a la que se puede llamar desde la clase derivada.
Otra ventaja de esta característica es que permite cambiar de una función virtual corriente a una virtual pura sin alterar el código existente (es una forma de localizar las clases que no redefinen esa función virtual).