La Herencia en C++

Objetivos

 

  • Sobrecarga de operadores binarios.
  • Ejemplo de sobrecarga con operadores + y >
  • Conversión de tipos de datos.
  • Algunas muestras de conversiones automáticas. .   
  •  

    Material requerido.

    Imagen de Arduino UNO Arduino Uno o similar.

     

    Relax Mucha paz interior

    La Herencia en C++

     

    Hemos ido viendo en las sesiones previas la sintaxis para crear y trabajar con la OOP y las clases en un entorno de C++.  Pero la programación orientada a objetos es mucho más que las clases, y en esta sesión veremos otra de sus mayores ventajas: La herencia.

    Si recordáis hemos ido insistiendo en que una de las ventajas de definir  Clases en nuestros programas era aprovechar una de las características de la OOP, llamada data hiding, que significa esconder las variables globales dentro de las propias clases en la medida de lo posible para evitar que un uso atolondrado de las mismas cause problemas globales de difícil detección.

    Mientras que la programación estructurada separa por completo los datos y las funciones que los manejan, la idea básica detrás de la OOP es unir ambos conceptos en un único objeto llamado clase,

    De ese modo,  encapsulamos los datos y los métodos, dentro de las clases y mediante las instrucciones public y private, podemos limitar el acceso de ambas a programadores atolondrados y por ende, limitamos su capacidad de causar daños inadvertidos.

    Pero la OOP dispone de más medios de limitar esos daños mediante otra capacidad que se llama Herencia, de no menor importancia, y a la que vamos a dedicar esta sesión.

    Y para ello tenemos que volver a hablar del santo grial de la programación: La reusabilidad del código.

    A medida que el software se iba haciendo más complicado y los proyectos más descomunales, cualquier sistema que nos permita usar un código que ya teníamos escrito y reusarlo, redunda en una mayor rapidez en el desarrollo y por tanto en mayores beneficios (La pasta manda, como siempre)

    Pero copiar y pegar código no es una buena solución, porque al final siempre hay que modificarlo un poco para adaptarlo y la ventaja que teníamos de usar un software probado se pierde al modificarlo, ya que a la primera de cambio aparecen nuevos problemas de depuración con los que no se contaba y que rápidamente añaden horas y coste a un proyecto que parecía chupado hasta convertirlo en ruinoso.

    Por eso, los directores de equipos de programación con cierta experiencia, son alérgicos a modificar programas probados (Y depurados con sangre) y acaban forzando a procedimientos de calidad  que impidan estas prácticas, lo que está muy bien, pero al final, lo que te ahorras en errores te lo gastas en burocracia.

    Y por eso la el concepto de herencia de la OOP, es una magnifica solución a este problema y ha sido adoptada por cualquier departamento de un cierto tamaño. Porque mejora la reusabilidad del código probado, y a la vez te impide tocarlo, evitando el interminable circulo sin fin, de modificar, depurar y vuelta empezar.

    Veamos cómo.

     

    La sintaxis de la Herencia en C++

     

    Habíamos ido definiendo una clase propia, Contador, en las sesiones previas que vamos a usar para ilustrar el concepto de la herencia.

    Suponed que ya tenemos probada y depurada la clase Contador y que ahora necesitamos una Clase nueva que en vez de ir creciendo sin fin sea un descontador, para que haga cuentas a cero desde el número que le damos, como para lanzar un cohete.

    Podemos coger el código fuente de contador y modificarlo para incluir un decrementador del mismo, pero aquí nos vamos a encontrar con dos posibles problemas:

  • Uno, en el mundo Arduino, da gusto porque todo el mundo regala su trabajo, lo que nos permite disponer del código fuente para hacer esto. Pero en el mundo real, los programadores suelen querer cobrar por su trabajo (Aunque os resulte increíble) y no suelen darte el código fuente de sus programas, con lo que tenemos mal para modificarlos.
  • DOS. Aun cuando dispongas del código fuente, el jefe que dirige el proyecto en el que trabajas, tiene a otros 45 programadores a su cargo y te dejará muy claro con un par de ladridos, lo que piensa de que modifiques programas que ya funcionan y se usan en otros sitios (Normalmente a gritos).
  •  

    Motivo por el que si desear seguir cobrando tu cheque a fin de mes, te conviene buscar una solución alternativa. Y para eso está la herencia.

    El método aprobado es derivar una nueva clase de una que ya existe. Esto hace que la clase derivada herede todas las características y métodos de la Clase Base sin tocarla y ahora podamos añadir lo que nos interese, garantizando que la Clase original permanece inalterada.

  • Tened en cuenta que tocar una Clase en la que se apoyan otros programas, puede suponer un lío mayúsculo, ya que cualquier pequeña diferencia con el original puede suponer una miríada de problemas en otros programas que ya estaban probados y con los que ahora hay que volver a empezar.  
  •  

    Vamos a empezar definiendo nuestra clase Contador para después ver como derivamos una clase CountDown de ella. Empecemos definiendo una Clase de base sencilla:

    class Contador
      {   private:
            int N ;
      
          public:
             Contador( ) : N(0) {}                    // Constructor
             Contador(int k ) : N(k) {}               // Constructor
             int GetCont() ;
      } ;
    
    int Contador::GetCont()     
        { return (N) ;}
    const Contador &Contador::operator ++()      // Prefix Operator         
        {  return Contador( ++N) ;   }

    Queremos definir una nueva clase que se llame CountDown   derivada de Contador y añadirle una función de decremento. Para ello lo primero es ver como derivamos una clase de otra. La sintaxis es esta:

    Class CountDown :  public Contador          // Es una clase derivada
       {   public:
               Counter Operator –()
                   { return Counter(--N) ; 
       }

    En la primera línea declaramos una nueva clase CountDown que deriva de Counter y es de acceso público, y despues definimos un prefix operator para decrementar la variable interna. Aunque la sintaxis es buena, el compilador no tragaría con esto. ¿Adivináis porque?

    Si te fijas en la definición de Contador, hemos definido N, el contador interno, como private, y eso significa que no permitirá el acceso a ninguna función externa a la clase Contador (Incluido CountDown), lo que nos hace imposible acceder desde la nueva clase derivada.

    Pero que no cunda el pánico. Para que podamos acceder a propiedades o métodos internos desde clase derivadas (Pero no desde cualquier otro medio), necesitamos definirlo no como private, sino como protected:

    class Contador
       {  protected:                        // Aqui esta el truco
             int N ;
       
          public:
             Contador( ) : N(0) {}          // Constructor
             Contador(int k ) : N(k) {}     // Constructor
       
             int GetCont() 
                { return (N) ;      }
         
             Contador operator ++()
               {  return Contador( ++N) ;    }
       } ;

    Al definir N como protected, significa que podemos acceder a esta variable desde clases derivadas de ella, pero sigue siendo imposible acceder desde un programa externo.

  • Aquí surge un debate interminable, acerca de la seguridad de esto, ya cualquier cretino puede derivar una clase de la original y meter mano sin control a las propiedades y métodos protegidos.
  • No seré yo quien intente mediar en semejante refriega, pero es evidente que una propiedad como protected es menos segura que como private, pero las ventajas compensan el riesgo en muchas ocasiones y en la vida no hay nada perfecto.
  • Y por otro lado, si el cretino se dedica a hacer sandeces con la variable original, tiene la virtud de que el daño solo se lo hace a él y a su código, porque el de los demás que usan la clase original permanece virginal.  
  •  

    A esta capacidad de acceder a los miembros públicos o protected de una clase se le conoce genéricamente por accesibilidad.

    Así pues, aquí os dejo copia del programa modificado  con protected: CountDown_1, y si ahora hacemos:

    void loop()
       { CountDown C1 ;
         ++C1; ++C1; ++C1;
         Serial.println (C1.GetCont()) ;
    
         --C1 ; --C1 ;
         Serial.println (C1.GetCont()) ;
       }

    Obtendremos una salida similar a esta:

    COnsola Arduino

    Que requiere una cierta explicación. Definimos C1 con de la clase CountDown, lo que explica que pueda hacer el decremento mediante prefix Operator, pero… ¿Cómo se explica que pueda aplicar el prefix increment Operator ++, o el GetCont (), que no están definidos en su declaración?

    Como ya habéis adivinado, una clase derivada hereda los  métodos y propiedades, de la clase original, (Que sean public o private, claro) y podemos usarlas sin problema, lo que le confiere una potencia inusitada para definir jerarquías conceptuales.

    Pero hay otra cuestión sorprendente implícita en ese mismo programita. Y es que el compilador ha inicializado a 0 nuestra instancia C1, sin que exista un constructor en CountDown ¿Por qué?

    La respuesta vuelve a ser que el compilador ha interpretado que al no haber constructor propio sin parámetros en la clase derivada, debe aplicar el constructor de la clase base, lo cual puede ser mucho suponer y causar problemas si no estáis advertidos.

    ¿Significa eso que puedo hacer entonces algo así, Para aprovecharme del segundo constructor de la clase base?

    CountDown C1(25) ;

    Ni de coña, y mucho cuidado con esto que es fuente de múltiples dolores de cabeza:

    console 136_2

    El compilador puede usar un constructor por defecto sin parámetros, pero cualquier otro debe ser definido en la clase derivada independientemente, como por ejemplo así: CountDown_2

    class CountDown : public Contador
      {   public:
             CountDown( )  :  Contador() {}         // Constructor
             CountDown(int k )  :  Contador(k) {}
    
             Contador operator -- ()
                {  return Contador( --N) ;       }
      } ;

    Donde simplemente le especificamos al compilador que use los constructores disponibles en  la clase base (O definir unos completamente nuevos), y así podamos crear C1 con un valor especificado:

    CountDown C1(20) ;

    console 136_3

    Fijaros en la hasta ahora desconocida sintaxis de:

    CountDown( )  :  Contador() {}
    CountDown(int k )  :  Contador(k) {}

    Donde especificamos al compilador que cuando se cree una instancia de CountDown, debe invocar el constructor de la clase base que le indicamos. La primera podríamos omitirla porque ya sabemos que el compilador proporcionara un constructor por defecto, pero es buena política definirlo aquí para evitar sobresaltos.

     

    Function member Overriding

     

    Vale, hemos visto que podemos definir nuevos constructores porque el compilador no aplicará per se mas que el default constructor sin parámetros, y también  hemos visto que las funciones disponibles en la clase original están gentilmente a disposición de las clases derivadas, pero puede ocurrir que nos interese redefinir una de ellas para que funcione de otra manera en la nueva clase derivada.

    De la misma forma que podíamos sobrecargar funciones a condición de que tuvieran diferentes firmas ¿Puedo redefinir métodos con el mismo nombre?

    Y la respuesta es que naturalmente, y ni siquiera necesitamos diferente firma porque el compilador aplicará a cada instancia de una clase la función que le corresponda.

    A esta capacidad de redefinir una función miembro con el mismo nombre se le llama Function Overriding (Que no estoy muy seguro de como traducirla al cristiano, porque sería algo así como invalidar u omitir la función)

    Podemos forzar un Override de la función miembro GetCont (), en nuestra clase CountDown para hacer que nos devuelva el doble del valor interno del contador.

  • Algo que resulta bastante estúpido e inútil, pero que nos permite demostrar el concepto con el material que tenemos a mano.
  • Normalmente la función Overriding se utiliza para que una función de clases derivados se comporten de forma similar a pesar de ser diferentes, pero soy incapaz de imaginar ningún ejemplo sensato, con el programa que nos ocupa.  
  •  

    Para forzar un Override de la función miembro GetCount() podemos hacer algo así: CountDown_3

    class CountDown : public Contador
       {  public:
             CountDown( ) : Contador() {}         // Constructor
             CountDown(int k ) : Contador(k) {}
    
             Contador operator -- ()
                 {   return Contador( --N) ;    }
    
             int GetCont()
                 {   return(2*N) ; }
       } ;

    En donde simplemente creamos una nueva función miembro de CountDown con el mismo nombre y distinta ejecución.

    Y tal y como esperábamos este es el resultado

    console 136_4

    Consideraciones finales

     

    El objetivo de estos humildes tutoriales es proporcionar una introducción a los conceptos detras de la OOP y allanar el camino de los que se inician en este nuevo modo de pensar en la programación, pero no pretende ser, de ningún modo, una clase doctoral o a fondo en ningún concepto.

    Por eso nuestra intención no es ser exhaustivos, sino más bien presentar los conceptos básicos que os permitan desbrozar el camino inicial y permitiros seguir aprendiendo por vuestra cuenta.

    Y naturalmente solo hemos desvelado la punta del iceberg en lo que se refiere a la programación orientada a objetos y especialmente en las cuestiones de herencia, porque se podría seguir hablando indefinidamente sobre el tema, pero creemos que ha llegado el momento de cortar aquí el tema OOP por  ahora.

    Por supuesto que hay infinidad de cuestiones como la herencia múltiple, el polimorfismo o las funciones virtuales que darían tema para una interminable y probablemente desierta disquisición que no nos conduciría a nada por ahora.

    En mi experiencia no suele ser útil, describir soluciones a problemas que aún no habéis tenido, y antes de entrar en esos temas, deberéis trabajar y madurar los conceptos que hemos expuesto hasta aquí para desarrollar la experiencia necesaria para seguir avanzando, aquellos que decidáis seguir este camino.

    Recuerdo aquel chiste en el que  al salir de una clase de universidad, un alumno pregunta a otro que le ha parecido el profesor. Y este responde que debe ser una eminencia porque no ha entendido nada.

    Nuestra aspiración es a ayudar a aprender y no a ganar puntos de cara a no sé muy bien que otros estamentos. Nuestros amigos saben ya, hace mucho, que no tenemos remedio y a pesar de todo (Increíblemente) siguen invitándonos a cañas de vez en cuando.

    Pero sí que me gustaría aseguraros que no hay nada raro en la programación orientada a objetos que no podáis aprender. No es para tanto. Simplemente es otra manera conceptual de organizar tus programas, y con grandes ventajas si el proyecto crece.

     

    Resumen de la sesión

     

  • Hemos hecho una primera aproximación a la herencia en la OOP.
  • Presentamos su sintaxis.
  • Vimos los conceptos relativos a los constructores.
  • Conocimos el function Overriding.
  •