- Fundamentos de la programación orientada a objetos: Abstracción
- Fundamentos de la programación orientada a objetos: Encapsulación
- Fundamentos de la programación orientada a objetos: Herencia
- Fundamentos de la programación orientada a objetos: Polimorfismo
- Fundamentos de la programación orientada a objetos: Composición (diseño)
- Definición de clases
- Derechos de acceso
- Constructores: por defecto, paramétricos y de copia
- Destructores
- Autorreferencia: esto
- Herencia
- Creación dinámica de objetos: nuevo y suprimir
- Punteros
- Métodos virtuales (virtual y override)
- Miembros estáticos
- Tipos anidados, espacios de nombres y operador de contexto '::'
- Dividir definición y declaración de clase
- Clases abstractas e interfaces
- Sobrecarga de operadores
- Conversión de tipos de objeto: dynamic_cast y puntero void *
- Punteros, referencias y const
- Gestión de la herencia: final y delete
Métodos virtuales (virtual y override)
Las clases están pensadas para describir interfaces de programación externas y proporcionar su implementación interna. Dado que la funcionalidad de nuestro programa de prueba es dibujar varias formas, hemos descrito varias variables en la clase Shape y sus descendientes para una futura implementación, y también hemos reservado el método draw para la interfaz.
En la clase base Shape no debe ni puede hacer nada porque Shape no es una forma concreta: convertiremos Shape en una clase abstracta más adelante (hablaremos más sobre interfaces y clases abstractas más adelante).
Vamos a redefinir el método draw en las clases Rectangle, Ellipse y otras derivadas (Shapes3.mq5). Ello implica copiar el método y modificar su contenido en consonancia. Aunque muchos se refieren a este proceso como «overriding», vamos a distinguir entre los dos términos, reservando «overriding» exclusivamente para los métodos virtuales, que se abordarán más adelante.
En sentido estricto, redefinir un método sólo requiere que el nombre del método coincida. Sin embargo, para garantizar un uso coherente en todo el código, es esencial mantener la misma lista de parámetros y el mismo tipo de retorno.
class Rectangle : public Shape
|
Como aún no sabemos cómo dibujar en la pantalla, simplemente enviaremos el mensaje al registro.
Es importante señalar que, al proporcionar una nueva implementación del método en la clase derivada, obtenemos 2 versiones del método: una hace referencia al objeto base integrado (Shape interno) y la otra, al derivado (Rectangle externo).
La primera será invocada para una variable de tipo Shape y la segunda, para una variable de tipo Rectangle.
En una cadena de herencia más larga, un método puede redefinirse y propagarse todavía más veces.
Puede cambiar el tipo de acceso de un nuevo método; por ejemplo, hacerlo público si estaba protegido, o viceversa. Pero en este caso hemos dejado el método draw en la sección pública.
Si es necesario, el programador puede llamar a la implementación del método de cualquiera de las clases progenitoras: para ello, se utiliza un operador de resolución de contexto especial: dos signos de dos puntos '::'. En concreto, podríamos invocar la implementación draw de la clase Rectangle desde el método draw de la clase Square: para ello, especificamos el nombre de la clase deseada, '::' y el nombre del método, como por ejemplo Rectangle::draw(). Llamar a draw sin especificar el contexto implica que es un método de la clase actual, y por tanto, si lo hace desde el propio método draw, obtendrá una recursión infinita y, en última instancia, un desbordamiento de pila y una caída del programa.
class Square : public Rectangle
|
Entonces, al invocar draw en el objeto Square se registrarían dos líneas:
Square s(100, 200, 50, clrGreen);
|
Vincular un método a una clase en la cual está declarado proporciona el envío estático (o vinculación estática): el compilador decide qué método invocar en la fase de compilación y «programa» la coincidencia encontrada en el código binario.
Durante el proceso de decisión, el compilador busca el método que se va a invocar en el objeto de la clase para el que se realiza la desreferenciación ('.'). Si el método está presente, es invocado, y si no, el compilador comprueba la clase progenitora para la presencia del método, y así sucesivamente, a lo largo de la cadena de herencia hasta que se encuentra el método. Si el método no se encuentra en ninguna de las clases de la cadena, se producirá un error de compilación «identificador no declarado».
En concreto, el siguiente código llama al método setColor en el objeto Rectangle:
Rectangle r(100, 200, 75, 50, clrBlue);
|
No obstante, este método se define sólo en la clase base Shape y se integra una vez en todas las clases descendientes, por lo que se ejecutará aquí.
Vamos a intentar empezar a dibujar formas arbitrarias desde un array en la función OnStart (recuerde que hemos duplicado y modificado el método draw en todas las clases descendientes).
for(int i = 0; i < 10; ++i)
|
Curiosamente, no sale nada en el registro. Esto sucede porque el programa llama al método draw de la clase Shape.
Aquí hay un gran inconveniente del envío estático: cuando usamos un puntero a una clase base para almacenar un objeto de una clase derivada, el compilador elige un método basándose en el tipo del puntero, no del objeto. El hecho es que, en la fase de compilación, aún no se sabe a qué objeto de clase apuntará durante la ejecución del programa.
Así, es necesario un enfoque más flexible: un envío dinámico (o vinculación), que aplazaría la elección de un método (de entre todas las versiones sobrescritas (overridden) del método en la cadena descendiente) hasta el tiempo de ejecución. La elección debe basarse en el análisis de la clase real del objeto en el puntero. Es la expedición dinámica la que proporciona el principio de polimorfismo.
Este enfoque se implementa en MQL5 utilizando métodos virtuales. En la descripción de un método de este tipo debe añadirse la palabra clave virtual al principio del encabezamiento.
Vamos a declarar el método draw de la clase Shape (Shapes4.mq5) como virtual. Esto hará automáticamente que todas las versiones de la misma en las clases derivadas también sean virtuales.
class Shape
|
Una vez que un método se virtualiza, modificarlo en las clases derivadas se denomina sobrescritura (overriding) en lugar de redefinición. La sobrescritura requiere que el nombre, los tipos de parámetros y el valor de retorno del método coincidan (teniendo en cuenta la presencia o ausencia de modificadores const).
Tenga en cuenta que sobrescribir funciones virtuales es diferente a la sobrecarga de funciones. La sobrecarga utiliza el mismo nombre de función, pero con parámetros diferentes (en concreto, vimos la posibilidad de sobrecargar un constructor en el ejemplo de las estructuras; véase Constructores y destructores), y la sobrescritura requiere la coincidencia completa de las firmas de las funciones.
Las funciones sobrescritas (overridden) deben definirse en clases diferentes que estén relacionadas por relaciones de herencia. Las funciones sobrecargadas deben pertenecer a la misma clase; de lo contrario, no se tratará de una sobrecarga sino, muy probablemente, de una redefinición (y funcionará de forma diferente; véase un análisis más detallado del ejemplo OverrideVsOverload.mq5).
Si ejecuta un nuevo script, en el registro aparecerán las líneas esperadas, señalando las llamadas a versiones específicas del método draw en cada una de las clases.
Drawing square
|
En las clases derivadas en las que se sobrescribe (override) un método virtual, se recomienda añadir la palabra clave override a su cabecera (aunque no es obligatorio).
class Rectangle : public Shape
|
Esto le permite al compilador saber que estamos sobrescribiendo el método a propósito. Si en el futuro la API de la clase base cambia repentinamente y el método sobrescrito (overridden) deja de ser virtual (o simplemente se elimina), el compilador generará un mensaje de error: «el método se declara con el especificador 'override', pero no sobrescribe ningún método de la clase base». Tenga en cuenta que hasta la adición o eliminación del modificador const de un método cambia su firma, y la sobrescritura (overriding) puede romperse debido a ello.
La palabra clave virtual antes de un método anulado también está permitida, pero no es obligatoria.
Para que el envío dinámico funcione, el compilador genera una tabla de funciones virtuales para cada clase. Se añade un campo implícito a cada objeto con un enlace a la tabla dada de su clase. El compilador rellena la tabla basándose en la información sobre todos los métodos virtuales y sus versiones sobrescritas (overridden) a lo largo de la cadena de herencia de una clase concreta.
La llamada a un método virtual se codifica en la imagen binaria del programa de una forma especial: primero se consulta la tabla en busca de una versión para una clase de un objeto concreto (situada en el puntero) y, a continuación, se pasa a la función adecuada.
Como resultado, el envío dinámico es más lento que el estático.
En MQL5, las clases siempre contienen una tabla de funciones virtuales, independientemente de la presencia de métodos virtuales.
Si un método virtual devuelve un puntero a una clase, cuando se sobrescribe, es posible cambiar (hacerlo más específico, altamente especializado) el tipo de objeto del valor devuelto. En otras palabras: el tipo del puntero no sólo puede ser el mismo que en la declaración inicial del método virtual, sino también cualquiera de sus sucesores. Estos tipos se denominan «covariantes» o intercambiables.
Por ejemplo, si hiciéramos virtual el método setColor en la clase Shape,
class Shape
|
podríamos sobrescribirlo (override) en la clase Rectangle de esta manera (sólo a modo de demostración de la tecnología):
class Rectangle : public Shape
|
Observe que el tipo de retorno es un puntero a Rectangle en lugar de Shape.
Tiene sentido utilizar un truco similar si la versión sobrescrita (overridden) del método cambia algo en esa parte del objeto que no pertenece a la clase base, de modo que el objeto, de hecho, ya no corresponde al estado permitido (invariante) de la clase base.
Nuestro ejemplo con formas de dibujo está casi listo; queda por llenar los métodos virtuales draw con contenidos reales. Lo haremos en el capítulo Gráficos (véase el ejemplo ObjectShapesDraw.mq5), pero lo mejoraremos después de estudiar los recursos gráficos.
Teniendo en cuenta el concepto de herencia, el procedimiento por el que el compilador elige el método adecuado parece un poco confuso. A partir del nombre del método y de la lista específica de argumentos (sus tipos) en la instrucción de llamada, se compila una lista de todos los métodos candidatos disponibles.
Para los métodos no virtuales, al principio sólo se analizan los métodos de la clase actual. Si ninguno de ellos coincide, el compilador continuará buscando en la clase base (y luego en los ancestros más lejanos hasta que encuentre una coincidencia). Si entre los métodos de la clase actual hay alguno adecuado (incluso si es necesaria la conversión implícita de los tipos de argumento), será elegido. Si la clase base tuviera un método con tipos de argumento más apropiados (sin conversión o con menos conversiones), el compilador seguiría sin llegar a él. En otras palabras: los métodos no virtuales se analizan partiendo de la clase del objeto actual hacia los ancestros, hasta llegar a la primera coincidencia que «funcione».
En el caso de los métodos virtuales, el compilador busca primero el método requerido por su nombre en la clase del puntero y, a continuación, selecciona la implementación en la tabla de funciones virtuales de la clase que tiene más instancias (descendiente más lejana) en la que este método se sobrescribe (override) en la cadena entre el tipo de puntero y el tipo de objeto. En este caso se puede utilizar también la conversión implícita de argumentos si no hay coincidencia exacta entre los tipos de los argumentos.
Veamos el siguiente ejemplo (OverrideVsOverload.mq5). Hay 4 clases encadenadas: Base, Derived, Concrete y Special. Todas ellas contienen métodos con argumentos de tipo int y float. En la función OnStart, las variables entera i y real f se utilizan como argumentos para todas las llamadas a métodos.
class Base
|
En primer lugar, creamos un objeto de la clase Concrete y un puntero al mismo Base *ptr. Luego invocamos métodos virtuales y no virtuales para ellos. En la segunda parte, los métodos del objeto Special se invocan por medio de los punteros de clase Base y Derived.
void OnStart()
|
A continuación se muestra la salida del registro.
void Base::nonvirtual(float) 1.0
|
La llamada a ptr.nonvirtual(i) se realiza mediante enlace estático, y el número entero i se convierte de forma preliminar al tipo de parámetro, float.
La llamada c.nonvirtual(i) también es estática, y como no hay ningún método void nonvirtual(int) en la clase Concrete, el compilador encuentra dicho método en la clase padre Derived.
Invocar la función del mismo nombre sobre el mismo objeto con un valor del tipo float lleva al compilador al método Base::nonvirtual(float), ya que Derived::nonvirtual(int) no es adecuado (la conversión provocaría una pérdida de precisión). Por el camino, el compilador emite un aviso «comportamiento obsoleto, llamada a método oculto».
Los métodos sobrecargados pueden parecerse a los sobreescritos (overridden) (tienen el mismo nombre pero parámetros diferentes) pero son diferentes porque se encuentran en clases diferentes. Cuando un método de una clase derivada sobrescribe (override) un método de una clase progenitora, sustituye el comportamiento del método de la clase progenitora, lo que a veces puede tener efectos inesperados. El programador podría esperar que el compilador eligiera otro método adecuado (como en la sobrecarga), pero en lugar de ello se invoca la subclase.
Para evitar posibles avisos, si la implementación de la clase progenitora es necesaria, debe escribirse exactamente como la misma función en la clase derivada, y la clase base debe ser invocada desde ella.
class Derived : public Base
|
Volvamos a las pruebas en OnStart.
La llamada a ptr.process(i) demuestra la confusión entre sobrecarga y sobrescritura (override) descrita anteriormente. La clase Base tiene un método virtual process(float) y la clase Derived añade un nuevo método virtual process(int), que no es sobrescritura (overriding) en este caso porque los tipos de parámetros son diferentes. El compilador selecciona un método por su nombre en la clase base y comprueba en la tabla de funciones virtuales si hay sobrescrituras (overrides) en la cadena de herencia hasta la clase Concrete (incluida; ésta es la clase objeto por puntero). Como no se encontró ninguna sobrescritura (override), el compilador tomó Base::process(float) y aplicó la conversión de tipo del argumento al parámetro (int a float).
Si siguiéramos la regla de escribir siempre la palabra override, que lleva implícita la redefinición, y la añadiéramos a Derived, obtendríamos un error:
class Derived : public Base
|
El compilador notificará «Método 'Derived::process' declarado con el especificador 'override', pero no sobrescribe (override) ningún método de la clase base». Esto serviría como pista para solucionar el problema.
La llamada a process(i) en el objeto Concrete se realiza con Derived::process(int). Aunque tenemos una redefinición aún más extensa en la clase Special, es irrelevante porque se hace en la cadena de herencia después de la clase Concrete.
Cuando el puntero se asigna posteriormente al objeto ptr, las llamadas a Special y process(i) son resueltas por el compilador como process(f) porque Special::process(float) anula a Special. La elección del parámetro float se produce por la misma razón descrita anteriormente: el método Base::process(float) es sobrescrito (overridden) por Special.
Si aplicamos el puntero d de tipo Derived, obtendremos finalmente la llamada esperada Special::process(int) para la cadena d.process(i). La cuestión es que process(int) está definido en Derived, y entra dentro del ámbito de búsqueda del compilador.
Observe que la clase Special no sólo sobrescribe (override) los métodos virtuales heredados, sino que también sobrecarga dos métodos en la clase en sí.
No llame a una función virtual desde un constructor o destructor Aunque técnicamente es posible, el comportamiento virtual en el constructor y destructor se pierde por completo y podría obtener resultados inesperados. Deben evitarse no sólo las llamadas explícitas, sino también las indirectas (por ejemplo, cuando se llama a un método simple desde un constructor, que a su vez llama a uno virtual).
Analicemos la situación con más detalle utilizando el ejemplo de un constructor. El hecho es que, en el momento en que funciona el constructor, el objeto aún no está completamente ensamblado a lo largo de toda la cadena de herencia, sino sólo hasta la clase actual. Todavía hay que «terminar» toda la parte derivada alrededor del núcleo existente. Por lo tanto, todas las sobrescrituras (overrides) de métodos virtuales posteriores (si las hay) aún no están disponibles en este punto. Como resultado, la versión actual del método será invocada desde el constructor.