Objetivos
Material requerido.
|
|
Mucha paz interior |
Las Clases en Arduino
Antes de que empecemos a hablar sobre Clases y Objetos, es importante insistir en que, la OOP no es tanto un lenguaje de programación diferente, sino más bien, una manera diferente de organizar tus programas y tus ideas, de acuerdo con unos principios guía que nos permiten modelar nuestro código de un modo distinto a como lo hemos hecho hasta ahora.
La OOP consiste en organizar tus programas de otra forma, que nos evite los problemas que mencionábamos en la sesión anterior, pero seguimos usando C++ con algunos añadidos.
Para definir las Clases, existen una serie de reglas y de nuevas instrucciones, pero por lo demás el lenguaje sigue siendo el de siempre.
La diferencia, es que ahora vamos a empezar definiendo unos entes abstractos que llamamos Clases y que son la base de la OOP.
En esta sesión daremos los primeros pasos con las Clases y su terminología. Veremos cómo definir Clases y Objetos y veremos cómo acceder a las propiedades o variables miembros de la Clase y sus métodos o funciones miembros.
Escribiremos un par de programas completos que involucren Clases y veremos cómo usarlas, pero al final, como dice el refrán “Cortando co**nes se aprende a capar” y es que no hay otra.
Así que poneros cómodos, sujetad el temblor de rodillas y vamos a lio.
Nuestro primer programa con clases
Hay que empezar por algún sitio y para ello nada mejor que con un pequeño programa de muestra como un contador (Que parece que se ha convertido en una norma general).
¿Cómo harías un contador general, en programación estructurada?
Pues una variable global que ponemos a 0 y que vamos incrementando en función de la necesidad.
Pero esto ilustra bastante bien el problema de que si quiero 6 contadores voy a necesitar 6 variables globales, con el riesgo que supone que algún memo nos las borre en el momento más inoportuno por cualquier razón estúpida que solo el comprende, así que ni hablar de esto.
La idea en OOP es crear una Clase que nos permita definir Objetos tipo Contador que se pueda reutilizar y que nos permita mezclar diferentes contadores en un mismo programa sin riesgo de catástrofe.
Una de las ideas básicas tras la OOP es encapsular los datos y las funciones (O propiedades y métodos) de nuestro programa en un contenedor común, y más importante aún, aplicamos el principio de: “Esconder los datos y mostrar los métodos o funciones”.
Iremos hablando más de esto, pero de entrada conviene destacar que si escondemos los datos, pero proporcionamos las funciones que trabajan con ellos vamos a reducir drásticamente la posibilidad de que alguien nos la líe.
Por eso cuando definimos Clases, veremos que hay partes que son públicas y otras que son privadas (Y si no se especifica lo contrario son privadas. Volveremos a esto)
La sintaxis para definir la Clase contador que nos ocupa:
class Contador { private: ………… public: …………. } ;
Debajo de la cláusula “private:” viene las variables y funciones ocultas al exterior. Solo pueden ser invocadas desde el interior de la clase, es decir no se pueden ejecutar por una llamada exterior. Y lo contrario ocurre con lo que definamos tras la cláusula “public:”
Vamos a definir una variable privada llamada N, que llevará la situación del contador, y después necesitaremos los métodos necesarios para trabajar con ella.
En principio vamos a definir tres funciones públicas: Una que ponga el contador a un valor dado antes de nada, Otra que sirva para incrementar el contador, y otra tercera para que nos entregue el valor del contador en un momento dado.
Nuestra clase podría ser algo así: (Fijaros en el“;” al final)
class Contador { private: int N ; public: void SetContador( int n) { N = n ; } void Incrementar() { N++ ; } int GetCont() { return (N) ;} } ;
Dentro de las llaves de la Clase definimos las funciones y variables que necesitamos como hasta ahora, y en este caso son de lo más simples. La variable N se comporta como si fuera una variable global pero solo dentro del ámbito de la Clase (Encapsulación), y como está definida como prívate es inaccesible desde el exterior (Cualquier intento de llegar a ella causará un ladrido del compilador)
Pues eso es todo, no era para tanto después de todo ¿No?
¿Y cómo se usa nuestra flamante primera Clase? Pues como otras que habéis usado antes. Primero se instancian tantas ocurrencias de la clase como queramos. Vamos a usar dos:
Contador C1,C2 ;
Podemos montar un programa que sea algo así: Contador_1
void loop() { C1.SetContador(0); C1.Incrementar() ; Serial.print("C1 = ") ; Serial.println( C1.GetCont() ) ; C2.SetContador(0); C2.Incrementar() ; C2.Incrementar() ; C2.Incrementar() ; Serial.print("C2 = ") ; Serial.println( C2.GetCont() ) ; Serial.flush(); exit(0); // Abandonar el programa }
Usamos el «.» para referir la funcion a la que queremos llamar, con el Objeto al que se le aplica, como hemos visto antes en otros programas aunque sin entrar en muchos detalles.
Una vez creados un par de contadores, lo primero que hacemos es ponerlos a 0 (Con SetContador()), después hacemos un par de llamadas a Incrementar y cuando nos hartemos imprimimos el valor del contador. Aquí tenéis el resultado:
No ganaremos premios con este programa, pero a cambio ilustra muy bien algunos conceptos básicos de la programación con Clases.
El primero es que una cosa es la definición de la Clase y otra distinta la instanciación. La clase es Contador pero el compilador no asigna memoria hasta que creamos par de instancias de la misma: C1 y C2. Ahora si que se crean los objetos.
Una Clase, pero tantas ocurrencias como sean precisas, que no se mezclan, son distintos objetos.
Hemos escondido las variables miembros, pero proporcionamos las funciones o métodos necesarios para manejar los objetos, y va a ser difícil que alguien enrede las variables globales porque no existen. ¿Qué te parece?
Podemos crear tantos contadores independientes como queramos, con absoluta certeza de que cada uno está aislado delos demás.
Refinando nuestra Clase: Constructores
La Clase anterior esconde una bomba de relojería, porque el que la use tiene que ser consciente de que por cada instancia que creemos de Contador, necesitamos una instrucción que la inicialice:
Contador C1 ; C1.SetContador(0);
Y esto no solo es un asco, sino que además es peligroso, ya que un despiste de no inicializar, y el valor de N es impredecible. Sería conveniente que el contador se pusiese a 0 al crearse, ya que la mayor parte de los contadores empiezan en 0 y nos despreocupamos de olvidos.
Podemos definir una función que se ejecuta siempre que se crea un objeto, y es tan habitual que tiene nombre. Se le llama Constructor de la Clase, y para ello basta con llamarla igual que la Clase (Sin tipo):
class Contador { private: int N ; public: Contador( ) // Constructor { N = 0 ; } void SetContador( int n) { N = n ; } void Incrementar() { N++ ; } int GetCont() { return (N) ;} } ;
Una peculiaridad de los constructores es que no tienen un tipo definido, otra de las razones por las que el compilador sabe que es un constructor. Si intentas esto de abajo, el compilador protestará amargamente:
public: void Contador( ) // Constructor
Usando el constructor, podemos reescribir el programa anterior así, sin problemas: Contador_2
void loop() { Serial.println( C1.GetCont() ) ; Serial.println("..............."); C1.Incrementar() ; Serial.print("C1 = ") ; Serial.println( C1.GetCont() ) ; C2.Incrementar() ; C2.Incrementar() ; C2.Incrementar() ; Serial.print("C2 = ") ; Serial.println( C2.GetCont() ) ; Serial.flush(); exit(0); }
Aquí está el resultado:
Como veis el constructor inicializa a 0 el valor interno al crear el objeto y nos podemos olvidar tranquilamente de obligaciones.
Ya que estamos, es interesante destacar que podemos hacer esto, ya que son objetos del mismo tipo:
C1 = C2 ; Serial.print("C1 = ") ; Serial.println( C1.GetCont() ) ;
Con este resultado:
Por ultimo, me gustaría hablar de la forma de inicializar el Constructor, que está escrito de una forma de lo más sencilla y comprensible, así que alguien tenía que complicarlo un poco (A mí no me miréis) y para darle mayor prestancia es muy habitual escribirlo de otra forma:
public: Contador( ) : N(0) { } // Constructor
Donde N(0) es el nombre de la variable miembro a inicializar y el numero entre paréntesis, es el valor deseado. En el caso de que fueran varias las variables internas a las que queremos pasar valores cuando el objeto se crea, la sintaxis a usar es:
public: Contador( ) : N(0) , M(4) , P(44) // Constructor {}
Se me escapan las razones por las que algo así ha llegado a ser lo habitual, pero os hartareis a verlo si revisáis las librerías de Arduino, así que yo con informaros cumplo.
Definiendo fuera las funciones miembros
Cuando las clases y las funciones miembro son tan pequeñas y sencillas como en este caso, la forma que hemos visto de definirlas puede valer, pero en seguida se quedará corta.
Por eso podemos declarar las funciones y variables miembros en la declaración de Clase, y definirlas fuera para mayor comodidad y evitar errores de sintaxis complicados de detectar.
Vamos a reescribir la clase Contador así:
class Contador { private: int N ; public: Contador( ) ; // Constructor void SetContador( int n) ; // Declaracion de funcion externa void Incrementar() ; // Declaracion de funcion externa int GetCont() ; // Declaracion de funcion externa } ; // ---------------------------------------- void Contador::SetContador( int n) { N = n ; } void Contador::Incrementar() { N++ ; } int Contador::GetCont() { return (N) ;}
Declaramos las funciones miembros dentro de la Clase (Para informar al compilador), pero no incluimos su código aquí, porque sería muy confuso en cuanto crezcan de tamaño (Pero fijaros que ahora hay un punto y coma al final de las declaraciones que antes no había)
En cualquier otro lugar podemos definir esas funciones sin más que hacer referencia a la Clase a la que pertenecen usando el operador ‘::’ (Scope Operator u Operador Ámbito) y el compilador entiende que son miembros de la clase que precede al operador.
Este operador le indica al compilador, que estas funciones o variables son miembros de la clase, y solo pueden invocarse de acuerdo a las condiciones que se especifican en la declaración de la Clase (Que debe coincidir con esta claro está).
Si editáis cualquiera de las librerías de Arduino, encontrareis que ésta es la forma habitual de programar las clases y librerías (Pero mucho ojo, con cambiar nada por la cuenta que os tiene)
Clases, Objetos y uso de memoria
Con lo que hemos visto hasta ahora, parece que aunque la definición de la Clase es única. Cuando instanciamos los objetos de esa clase, cada uno recibe una asignación de memoria suficiente para contener todas las variables y funciones miembros.
Me gusta que penséis así porque ayuda conceptualizar los objetos, pero me temo que en la realidad las cosas son un poco diferentes, aunque no mucho. Es verdad que cuando creamos un objeto el compilador asigna memoria para contener todas las propiedades de ese objeto en concreto, ya que es lo que diferencia a un objeto de otro de la misma Clase.
Pero no es verdad que se asignen copias de los métodos de la clase a cada instancia. Y el motivo es que no hace falta, ya que el compilador sabe que las funciones miembros son comunes y esto no causa ningún problema y además nos permite ahorra memoria que es algo a tener en cuenta.
El motivo de hacer este comentario, no es tanto volveros locos (Que siempre mola) como presentar otra posible directiva a tener en cuenta cuando definimos una Clase que ya conocemos: static.
Si recordáis, cuando en una función definíamos una variable como static, se creaba una sola vez y persistía disponible, por muchas veces que entráramos en la función, a diferencia de las variables normales que se creaba y destruían cada vez que ejecutamos la función.
Cuando declaramos una propiedad miembro de una clase como static, el compilador crea una única variable para ella, que es compartida por todas las instancias de los objetos de esa Clase, rompiendo así la regla de que cada instancia tiene su propio juego de memoria y propiedades.
¿Y porque vamos a querer hacer algo tan extraño?
Bueno la vida es complicada y a veces hacen falta excepciones. Por ejemplo, si por algún motivo necesitamos saber cuántas instancias de un objeto se han creada en un momento dentro del programa, podemos usar una variable static para interrogar a cualquier objeto de la clase, ya que comparten el campo static y eso es algo que sería imposible de hacer de ninguna otra manera que se nos ocurra.
Veamos un pequeño ejemplo: Contador_5
class Contador { private: int N ; static int Num ; public: Contador( ) ; // Constructor void SetContador( int n) ; // Declaracion de funcion externa void Incrementar() ; // Declaracion de funcion externa int GetCont() ; // Declaracion de funcion externa } ;
Añadimos una variable static llamada Num que llevara la cuenta del numero de contadores que vamos a crear. He modificado ligeramente las funciones miembros :
Contador::Contador( ) // Constructor { N = 0 ; ++Num ; } void Contador::SetContador( int n) { N = n ; ++Num ; } void Contador::Incrementar() { N++ ; } int Contador::GetCont() { return (N) ;} int Contador::Num_Objetos() { return(Num) ; }
Básicamente he modifica el Constructor del objeto para que incremente la variable static Num, incrementándola cada vez que se ejecute (O sea cada vez que se cree un objeto de esta Clase) y añadido un nuevo método, Num_Objetos(), que nos devuelve el valor de Num.
Si usamos un programa como este:
void loop() { Serial.println(C1.Num_Objetos()); Serial.flush(); exit(0); }
Vamos a tener una sorpresa en la salida:
Contador_5.cpp.o: In function `Contador::Contador()': C:\Program Files (x86)\Arduino/Contador_5.ino:16: undefined reference to `Contador::Num' Contador_5.cpp.o: In function `loop': C:\Program Files (x86)\Arduino/Contador_5.ino:38: undefined reference to `Contador::Num' Contador_5.cpp.o:C:\Program Files (x86)\Arduino/Contador_5.ino:38: more undefined references to `Contador::Num' follow collect2.exe: error: ld returned 1 exit status Error de compilación
El motivo está en que Num no ha sido incializado en ningún sitio y para corregirlo podemos hacer:
int Contador::Num = 0
Hay que tener un poco cuidado cuando definimos una variable static asociada a una clase, ya que hay que asignarla solo una vez, y fuera de las funciones miembros porque de lo contrario podemos encontrarnos con comportamientos extraños.
Haciendo resumen
Bueno yo creo que para esta primera sesión sobre objetos puede valer ya. He procurado mostrar con el ejemplo más sencillo que se me ha ocurrido, que programar con objetos es conceptualmente distinto del modo procedural, pero que tampoco es para tanto.
En lugar de resolver problemas pensando en funciones, buscamos un modelo a partir de objetos a los que vamos definiendo métodos y propiedades, de una manera muy parecida a como lo haríamos a base de funciones estructuradas.
La peculiaridad es que encapsulamos esas funciones y propiedades en un objeto abstracto que las contiene y aísla del exterior.
Para quienes podéis pensar que es una manera extraña y más trabajosa de hacer lo mismo, me gustaría haceros alguna consideración.
En primer lugar, ciertamente puede haber algo más de trabajo en planificar y diseñar las Clases, cuando el programa a desarrollar es pequeño, pero en cuanto el programa crece la ventaja se invierte, porque defino la clase una vez y la utilizo las veces que requiera.
En un ejemplo en el que el número de instancias de un objeto crezca, la ventaja a favor de la OOP es abismal. Menor código, mejor encapsulado, disminución de errores.
Está también la cuestión de la reutilización del código, que con una clase es automática, mientras que con una colección de funciones hay que andar con tiento.
Los objetos se parece mucho a la forma en como pensamos en nuestro cerebro y eso nos ayuda a desarrollar mejores programas y más seguros.
Para programas muy pequeños quizás no compense, pero a medida que la complejidad crece, es más seguro dedicar un tiempo a esa planificación a la que tan reacios somos los amigos del “Tu dispara y pregunta luego”.
Resumen de la sesión