Si se ha heredado de una clase y se proporciona una nueva definición para alguna de sus funciones miembro, existen dos posibilidades. La primera es proporcionar exactamente el mismo prototipo en la definición de la clase derivada que en la de la clase base. Esto se conoce como redefinición (redefining) en el caso de las funciones miembro ordinarias y como overriding cuando la función miembro de la clase base es virtual (las funciones virtuales son el caso normal y se tratarán en detalle en el Capítulo 15). Pero ¿qué ocurre cuando se modifican los argumentos de la función miembro o el tipo de retorno en una clase derivada? He aquí un ejemplo:
//: C14:NameHiding.cpp // Hiding overloaded names during inheritance #include <iostream> #include <string> using namespace std; class Base { public: int f() const { cout << "Base::f()\n"; return 1; } int f(string) const { return 1; } void g() {} }; class Derived1 : public Base { public: void g() const {} }; class Derived2 : public Base { public: // Redefinition: int f() const { cout << "Derived2::f()\n"; return 2; } }; class Derived3 : public Base { public: // Change return type: void f() const { cout << "Derived3::f()\n"; } }; class Derived4 : public Base { public: // Change argument list: int f(int) const { cout << "Derived4::f()\n"; return 4; } }; int main() { string s("hello"); Derived1 d1; int x = d1.f(); d1.f(s); Derived2 d2; x = d2.f(); //! d2.f(s); // string version hidden Derived3 d3; //! x = d3.f(); // return int version hidden Derived4 d4; //! x = d4.f(); // f() version hidden x = d4.f(1); } ///:~
Listado 14.8. C14/NameHiding.cpp
En Base hay una función sobrecargada f(); Derived1 no hace ningún cambio en f(), pero sí redefine g(). En main() se puede ver que las dos versiones sobrecargadas de f() están disponibles en Derived1. Sin embargo, Derived2 redefine una de las versiones sobrecargadas de f() pero no la otra, y el resultado es que la segunda forma sobrecargada no está disponible. En Derived3, el cambio del tipo de retorno oculta las dos versiones de la clase base, y Derived4 muestra que cambiar la lista de argumentos también oculta las dos versiones de la clase base. En general, se puede decir que siempre que se redefine un nombre de función sobrecargada de la clase base, todas las demás versiones quedan ocultas automáticamente en la nueva clase. En el Capítulo 15 verá que añadir la palabra reservada virtual afecta un poco más a la sobrecarga de funciones.
Si cambia la interfaz de la clase base modificando el prototipo de una función miembro de la clase base, está utilizando la clase de un modo distinto a aquél para el que normalmente está pensada la herencia. Esto no quiere decir necesariamente que lo esté haciendo mal; simplemente, el objetivo último de la herencia es dar soporte al polimorfismo, y si cambia el prototipo de la función, en realidad está cambiando la interfaz de la clase base. Si eso es lo que pretendía hacer, está utilizando la herencia principalmente para reutilizar código, y no para mantener la interfaz común de la clase base (que es un aspecto esencial del polimorfismo). En general, usar la herencia de esta forma significa tomar una clase de propósito general y especializarla para una necesidad particular, lo que normalmente, aunque no siempre, se considera terreno de la composición.
Por ejemplo, considere la clase Stack del Capítulo 9. Uno de los problemas de esa clase es que había que aplicar un molde cada vez que se obtenía un puntero del contenedor. Esto no sólo es tedioso, también es inseguro: se podría moldear el puntero a cualquier cosa.
Un enfoque que a primera vista parece mejor es especializar la clase general Stack utilizando la herencia. He aquí un ejemplo que utiliza la clase del Capítulo 9:
//: C14:InheritStack.cpp // Specializing the Stack class #include "../C09/Stack4.h" #include "../require.h" #include <iostream> #include <fstream> #include <string> using namespace std; class StringStack : public Stack { public: void push(string* str) { Stack::push(str); } string* peek() const { return (string*)Stack::peek(); } string* pop() { return (string*)Stack::pop(); } ~StringStack() { string* top = pop(); while(top) { delete top; top = pop(); } } }; int main() { ifstream in("InheritStack.cpp"); assure(in, "InheritStack.cpp"); string line; StringStack textlines; while(getline(in, line)) textlines.push(new string(line)); string* s; while((s = textlines.pop()) != 0) { // No cast! cout << *s << endl; delete s; } } ///:~
Listado 14.9. C14/InheritStack.cpp
Como todas las funciones miembro de Stack4.h son inline, no es necesario enlazar nada.
StringStack especializa Stack para que push() sólo acepte punteros a string. Antes, Stack aceptaba punteros a void, de modo que el usuario no disponía de ninguna comprobación de tipos que asegurase que se insertaban los punteros adecuados. Además, peek() y pop() ahora devuelven punteros a string en vez de punteros a void, así que no es necesario ningún molde para utilizar el puntero.
Sorprendentemente, ¡esta seguridad adicional en la comprobación de tipos es gratuita en push(), peek() y pop()! Al compilador se le proporciona información adicional sobre los tipos, que utiliza en tiempo de compilación, pero las funciones son inline y no se genera código adicional.
La ocultación de nombres entra en juego aquí porque, en concreto, la función push() tiene un prototipo diferente: la lista de argumentos es distinta. Si hubiera dos versiones de push() en la misma clase, eso sería sobrecarga, pero en este caso la sobrecarga no es lo que se desea, porque seguiría permitiendo pasar cualquier tipo de puntero a push() como void*. Afortunadamente, C++ oculta la versión push(void*) de la clase base en favor de la nueva versión definida en la clase derivada y, por tanto, sólo permite usar push() con punteros a string en un StringStack.
Como ahora se puede garantizar que se sabe exactamente qué tipo de objetos hay en el contenedor, el destructor funciona correctamente y el problema de la propiedad queda resuelto (o, al menos, se dispone de un enfoque para resolverlo). Aquí, si se hace push() de un puntero a string en el StringStack, entonces (según la semántica de StringStack) también se le está pasando la propiedad de ese puntero al StringStack. Si se hace pop() del puntero, no sólo se obtiene el puntero, sino también su propiedad. Los punteros que queden en el StringStack cuando se llame a su destructor serán eliminados por éste. Y como siempre son punteros a string y la sentencia delete trabaja con punteros a string en vez de con punteros a void, la destrucción se realiza de forma adecuada y todo funciona correctamente.
Hay un inconveniente: esta clase sólo funciona con punteros a string. Si se desea un Stack que funcione con algún otro tipo de objeto, hay que escribir una nueva versión de la clase que funcione sólo con ese nuevo tipo de objeto. Esto se vuelve tedioso rápidamente, y finalmente se resuelve utilizando plantillas, como verá en el Capítulo 16.
Se puede hacer una observación adicional sobre este ejemplo: cambia la interfaz de Stack en el proceso de herencia. Si la interfaz es diferente, entonces un StringStack no es realmente un Stack, y nunca será posible usar correctamente un StringStack como un Stack. Esto hace que aquí el uso de la herencia sea cuestionable; si no se está creando un StringStack que es-un tipo de Stack, ¿por qué heredar? Más adelante, en este mismo capítulo, se mostrará una versión más adecuada de StringStack.