El lenguaje C++
C++ agrega a C la posibilidad de modelar objetos: un LED, un sensor, un motor dejan de ser un conjunto de variables sueltas y pasan a ser entidades con sus datos y sus operaciones juntos. Es el lenguaje de Arduino y de buena parte de la industria embebida moderna.
01Qué agrega C++ sobre C
C++ es, casi por completo, un superconjunto de C: un programa en C compila en C++. Lo que suma es una forma distinta de organizar programas grandes.
| Agregado | Para qué sirve | ¿Cuesta recursos? |
|---|---|---|
| Clases y objetos | Agrupar datos y las funciones que los manipulan. | No |
| Constructores / destructores | Garantizar que un objeto nazca inicializado y libere lo que tomó. | No |
| Sobrecarga | Varias funciones con el mismo nombre y distintos parámetros. | No |
Referencias (&) | Paso por referencia más legible que con punteros. | No |
| Herencia | Reutilizar y especializar código existente. | No |
| Funciones virtuales | Polimorfismo: elegir la implementación en tiempo de ejecución. | Poco |
| Plantillas (templates) | Código genérico para cualquier tipo. | Poco |
| Excepciones | Manejo de errores separado del flujo normal. | Sí |
| Biblioteca estándar (STL) | Contenedores y algoritmos ya hechos. | Sí |
Es el principio de diseño de C++, y explica por qué se lo usa en embebidos. Las clases, la herencia simple y los constructores generan exactamente el mismo código máquina que la versión equivalente en C: son organización, no capas de ejecución. Lo que sí cuesta son las excepciones, la información de tipos en tiempo de ejecución y la STL con asignación dinámica — y esas tres se deshabilitan en la mayoría de los proyectos de microcontrolador.
02Clases y objetos
Una clase es un molde: describe qué datos tiene una cosa y qué se puede hacer con ella. Un objeto es un ejemplar concreto creado a partir de ese molde.
class Led {
private: // nadie de afuera puede tocar esto
uint8_t pin;
bool encendido;
public: // la interfaz visible
Led(uint8_t p); // constructor
void on();
void off();
void toggle();
bool estado() const; // const: no modifica el objeto
};
// --- implementación ---
Led::Led(uint8_t p) : pin(p), encendido(false) {
pinMode(pin, OUTPUT);
digitalWrite(pin, LOW);
}
void Led::on() { digitalWrite(pin, HIGH); encendido = true; }
void Led::off() { digitalWrite(pin, LOW); encendido = false; }
void Led::toggle() { encendido ? off() : on(); }
bool Led::estado() const { return encendido; }
// --- uso ---
Led rojo(13); // se crean dos objetos independientes
Led verde(12); // cada uno con su propio pin y su propio estado
rojo.on();
verde.toggle();
Los datos van en private y sólo las funciones de la clase pueden tocarlos. El resto
del programa se comunica exclusivamente por la interfaz pública. La ventaja concreta: si
mañana se cambia cómo se representa el estado internamente, nada de afuera se rompe, porque
nadie dependía de esos detalles. Y es imposible dejar el objeto en un estado incoherente desde
afuera.
struct de CUn struct agrupa datos; una clase agrupa datos y comportamiento, y además
controla el acceso. En C se escribiría led_on(&rojo) pasando la estructura como
primer parámetro; en C++ se escribe rojo.on() y ese puntero al objeto (llamado
this) lo pasa el compilador solo. Es literalmente la misma llamada, con mejor
sintaxis.
03Constructores y destructores
Se ejecuta automáticamente al crear el objeto. Tiene el mismo nombre que la clase y no devuelve nada. Su trabajo es dejar el objeto listo para usarse.
Puede haber varios (sobrecargados) con distintos parámetros.
Se ejecuta automáticamente al destruirse el objeto: al salir del ámbito o al liberarlo.
Se llama ~Clase() y no recibe parámetros.
Su trabajo es devolver lo que el objeto haya tomado: memoria, un archivo, un puerto.
class PuertoSerie {
int fd;
public:
PuertoSerie(uint32_t baudios) {
fd = abrir_uart(baudios); // toma el recurso
}
~PuertoSerie() {
cerrar_uart(fd); // lo devuelve, pase lo que pase
}
void enviar(const char *s);
};
void tarea() {
PuertoSerie p(9600); // constructor: abre
p.enviar("hola");
if (error) return; // aun saliendo por acá...
...
} // ...el destructor cierra el puerto
Resource Acquisition Is Initialization: el recurso se toma en el constructor y se libera en
el destructor. Como el destructor se ejecuta siempre al salir del ámbito —por cualquier
camino— resulta imposible olvidarse de liberar. En C, en cambio, cada return anticipado
es una oportunidad de dejar un recurso tomado. En embebidos se aplica a puertos, buses SPI o I²C, y a
secciones críticas: un objeto que deshabilita interrupciones en el constructor y las restaura en el
destructor vuelve imposible olvidarse de rehabilitarlas.
04Sobrecarga
En C++ pueden coexistir varias funciones con el mismo nombre si difieren en la cantidad o el tipo de sus parámetros. El compilador elige la correcta según cómo se la llame.
void mostrar(int v);
void mostrar(float v);
void mostrar(const char *s);
void mostrar(int v, uint8_t base);
mostrar(42); // llama a la primera
mostrar(3.14f); // a la segunda
mostrar("listo"); // a la tercera
mostrar(255, 16); // a la cuarta: en hexadecimal
En C harían falta cuatro nombres distintos (mostrar_int,
mostrar_float…). Es exactamente lo que hace Serial.print() de Arduino, que
acepta enteros, decimales y cadenas: son varias funciones sobrecargadas.
Sobrecarga de operadores
class Medicion {
int16_t mV;
public:
Medicion(int16_t v) : mV(v) {}
Medicion operator+(const Medicion &o) const {
return Medicion(mV + o.mV);
}
bool operator>(const Medicion &o) const { return mV > o.mV; }
};
Medicion a(1200), b(340);
Medicion c = a + b; // se lee como matemática
if (a > b) { ... }
Sobrecargar operadores tiene sentido cuando la operación es obvia para ese tipo: sumar dos
mediciones, comparar dos tiempos, multiplicar dos matrices. Sobrecargar + para que haga
algo inesperado vuelve el código indescifrable. La regla es: si al leer a + b alguien
puede equivocarse sobre lo que hace, no hay que sobrecargarlo.
05Herencia
Una clase puede derivar de otra: hereda sus datos y sus métodos, y agrega o modifica lo que necesite. Es la forma de expresar una relación «es un».
class Sensor { // clase base
protected: // visible para las derivadas
uint8_t pin;
public:
Sensor(uint8_t p) : pin(p) {}
virtual float leer() = 0; // = 0 : virtual PURA
virtual ~Sensor() {} // destructor virtual: obligatorio
};
class SensorTemp : public Sensor { // deriva de Sensor
public:
SensorTemp(uint8_t p) : Sensor(p) {}
float leer() override { // LM35: 10 mV por grado
return analogRead(pin) * 500.0f / 1024.0f;
}
};
| Visibilidad | Quién puede acceder |
|---|---|
private | Sólo la propia clase. Es lo predeterminado. |
protected | La propia clase y sus derivadas. |
public | Cualquiera. |
06Polimorfismo e interfaces
Es la consecuencia poderosa de la herencia: se puede escribir código que trabaje con la clase base y funcione correctamente con cualquier derivada, incluso con las que todavía no existen.
// Esta función no sabe ni le importa qué sensores hay
void registrar(Sensor *lista[], uint8_t n) {
for (uint8_t i = 0; i < n; i++) {
float v = lista[i]->leer(); // llama al leer() CORRECTO
guardar(i, v);
}
}
SensorTemp t(0);
SensorLuz l(1);
SensorHum h(2);
Sensor *todos[] = { &t, &l, &h };
registrar(todos, 3); // cada uno se lee como corresponde
Una clase con funciones virtual lleva un puntero oculto a una tabla de funciones
virtuales (vtable). Al llamar lista[i]->leer(), el programa consulta esa
tabla en tiempo de ejecución y salta a la implementación del objeto real. El costo es un puntero por
objeto y una indirección por llamada: perfectamente asumible incluso en un microcontrolador de
8 bits.
virtualSi se destruye un objeto derivado a través de un puntero a la base y el destructor no es virtual, sólo se ejecuta el destructor de la base: el de la derivada nunca corre y sus recursos quedan tomados. Es un error clásico y silencioso. La regla es simple: si una clase tiene alguna función virtual, su destructor también debe serlo.
Interfaces: clases abstractas puras
Una clase cuyos métodos son todos virtuales puros (= 0) no se puede instanciar:
define únicamente un contrato. Es el mecanismo con el que se declara «lo que un driver tiene que
saber hacer», sin decir nada sobre cómo.
class IDisplay { // interfaz: sólo el contrato
public:
virtual void limpiar() = 0;
virtual void escribir(uint8_t f, uint8_t c, const char *t) = 0;
virtual ~IDisplay() {}
};
class LCD1602 : public IDisplay { /* por 4 bits paralelo */ };
class OLED_I2C : public IDisplay { /* por bus I2C */ };
class DisplayFalso : public IDisplay { /* imprime en consola: para probar */ };
// El menú funciona con cualquiera de los tres, sin cambiar una línea
void menu(IDisplay &d) {
d.limpiar();
d.escribir(0, 0, "Temperatura");
}
Permite escribir toda la lógica del equipo antes de tener el hardware, probándola contra un driver falso. Y permite cambiar de LCD a OLED, o de un microcontrolador a otro, tocando una sola clase. Es la versión en C++ del principio de separación en drivers que se vio en el eje anterior.
07Excepciones
Permiten separar el manejo de errores del flujo normal del programa: en vez de que cada función devuelva un código de error que hay que verificar, se lanza una excepción que se captura donde corresponda.
try {
float t = sensor.leer(); // puede lanzar
procesar(t);
}
catch (const SensorDesconectado &e) {
alarma("sensor fuera de linea");
}
catch (...) { // cualquier otra
reiniciar();
}
Las excepciones exigen tablas de desenrollado de pila que ocupan varios KB de flash, y su tiempo
de propagación no es acotado, lo que las vuelve inaceptables en tiempo real. Por eso casi
todos los proyectos embebidos compilan con -fno-exceptions y manejan los errores como en
C: con códigos de retorno o con un objeto que representa «valor o error». En una PC, en cambio, las
excepciones son la forma normal y recomendada de manejar errores.
08C++ en microcontroladores
- Clases, encapsulamiento, métodos.
- Constructores y destructores (RAII).
- Sobrecarga de funciones y operadores.
- Referencias en vez de punteros.
constyconstexpr.- Herencia simple y funciones virtuales, con criterio.
- Plantillas, si no explotan en cantidad de instancias.
enum classpara estados y modos.
- Excepciones (
-fno-exceptions). - RTTI y
dynamic_cast(-fno-rtti). newydelete: misma razón quemalloc, la RAM se fragmenta.std::vector,std::stringy en general la STL con memoria dinámica.- Herencia múltiple y jerarquías profundas.
iostream: agrega decenas de KB.
Aunque no se lo presente así, el entorno de Arduino compila C++ con avr-g++. Cada
biblioteca es una clase: Serial es un objeto de la clase HardwareSerial,
Servo, LiquidCrystal y Wire son clases, y
Serial.print() está sobrecargada para todos los tipos. Los archivos .ino se
concatenan, se les agrega el main() que llama a setup() y después a
loop() en un while(1), y se compilan como C++. Reconocer eso permite pasar
de «usar Arduino» a «programar el microcontrolador».
09En el laboratorio
Escribir una clase Contador con un valor privado y métodos
incrementar(), decrementar(), reiniciar() y
valor(), con un tope máximo que se pasa por constructor. Crear tres objetos con topes
distintos y comprobar que cada uno mantiene su propio estado. Intentar acceder al dato privado desde
afuera y leer el error del compilador: eso es el encapsulamiento.
Clase Recurso cuyo constructor imprima «tomado» y cuyo destructor imprima
«liberado». Crear objetos dentro de bloques { } y en distintos puntos de una función con
varios return. Observar el orden exacto en que se ejecutan los destructores: siempre al
salir del ámbito, y en orden inverso a la creación.
Clase base abstracta Figura con virtual float area() = 0; y derivadas
Circulo, Rectangulo y Triangulo. Guardar punteros a las tres en
un arreglo de Figura* y recorrerlo sumando áreas. Después quitar la palabra
virtual y volver a ejecutar: se llama siempre al método de la base. Es la demostración
directa de para qué sirve virtual.
Definir IDisplay como en el ejemplo y escribir dos implementaciones: una que imprima
en la consola y otra que simule un LCD dibujando un recuadro de 16×2 caracteres. Escribir una función
menu(IDisplay&) y ejecutarla con las dos, sin modificarla. Es el ensayo directo de
cómo se desarrolla software de equipo antes de tener el hardware.
10Errores frecuentes
| Error | Consecuencia |
|---|---|
Olvidar el ; después de la llave de cierre de una clase | Cascada de errores de compilación incomprensibles en las líneas siguientes. |
Destructor de la base no virtual | Al destruir por puntero a la base no corre el destructor de la derivada: fuga de recursos silenciosa. |
Olvidar virtual en el método que se quiere polimórfico | Se llama siempre a la versión de la clase base. El programa compila y hace lo que no se quiere. |
| Miembros sin inicializar en el constructor | Basura. Conviene usar la lista de inicialización (: pin(p), estado(false)). |
Usar new sin delete | Fuga de memoria. En un micro con 2 KB de RAM, se agota en minutos. |
| Compilar con la STL en un microcontrolador chico | El programa no entra en la flash, o entra y falla al agotarse la RAM. |
| Sobrecargar operadores de forma poco intuitiva | Código imposible de leer. La sobrecarga se justifica sólo si el significado es obvio. |
Poner todo public | Se pierde el encapsulamiento y con él toda la ventaja de usar clases. |
11Autoevaluación
¿Cuál es la diferencia entre una clase y un objeto?
La clase es el molde: describe qué datos y qué métodos tiene una entidad. El
objeto es un ejemplar concreto creado a partir de ese molde, con sus propios valores. De una
clase Led se pueden crear muchos objetos, cada uno con su pin y su estado.
¿Qué gana un programa al declarar los datos como private?
Que nadie de afuera puede dejar el objeto en un estado incoherente, y que la representación interna se puede cambiar sin romper el resto del programa, porque nadie dependía de ella. Eso es el encapsulamiento.
¿Cuándo se ejecuta un destructor y para qué sirve?
Automáticamente, al destruirse el objeto: al salir del ámbito donde se creó, o al
hacer delete. Sirve para liberar lo que el objeto haya tomado (memoria, un puerto,
un archivo). Como se ejecuta por cualquier camino de salida, hace imposible olvidarse.
¿Qué es RAII y qué problema resuelve?
Resource Acquisition Is Initialization: tomar el recurso en el constructor y liberarlo en el destructor. Resuelve el problema de las liberaciones olvidadas, sobre todo cuando una función tiene varios puntos de salida.
¿Qué hace la palabra virtual?
Indica que el método puede ser redefinido en una clase derivada, y que la decisión de cuál ejecutar se toma en tiempo de ejecución según el tipo real del objeto, no según el tipo del puntero. Es lo que hace posible el polimorfismo.
¿Qué es una función virtual pura y qué efecto tiene sobre la clase?
Es la declarada con = 0: no tiene implementación en la base. Convierte
a la clase en abstracta, es decir que no se puede instanciar, y obliga a que toda derivada
concreta la implemente. Si todos sus métodos lo son, la clase es una interfaz.
¿Por qué el destructor de una clase base debe ser virtual?
Para que, al destruir un objeto derivado a través de un puntero a la base, se ejecute también el destructor de la derivada. Si no lo es, sólo corre el de la base y los recursos de la derivada quedan sin liberar.
¿Por qué se desactivan las excepciones en microcontroladores?
Porque requieren tablas de desenrollado de pila que ocupan varios KB de flash y
porque su tiempo de propagación no es acotado, lo que las hace incompatibles con requisitos de
tiempo real. Se compila con -fno-exceptions y se manejan errores con códigos de
retorno.
Un programa de Arduino, ¿en qué lenguaje está escrito?
En C++. El entorno concatena los archivos .ino, agrega un
main() que llama a setup() y luego a loop() dentro de un
while(1), y compila todo con avr-g++. Serial,
Servo y LiquidCrystal son clases de C++.