Catto / Mapa de Temas · Informática Electrónica I 4° año
Informática Electrónica I · 72 h · Eje 3 de 3

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.

Programación Clases y objetos Herencia Polimorfismo Drivers

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.

AgregadoPara qué sirve¿Cuesta recursos?
Clases y objetosAgrupar datos y las funciones que los manipulan.No
Constructores / destructoresGarantizar que un objeto nazca inicializado y libere lo que tomó.No
SobrecargaVarias funciones con el mismo nombre y distintos parámetros.No
Referencias (&)Paso por referencia más legible que con punteros.No
HerenciaReutilizar y especializar código existente.No
Funciones virtualesPolimorfismo: elegir la implementación en tiempo de ejecución.Poco
Plantillas (templates)Código genérico para cualquier tipo.Poco
ExcepcionesManejo de errores separado del flujo normal.
Biblioteca estándar (STL)Contenedores y algoritmos ya hechos.
«No pagás por lo que no usá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();
Encapsulamiento

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.

La diferencia con un struct de C

Un 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

Constructor

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.

Destructor

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
RAII: el patrón más útil de C++

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) { ... }
Con moderación

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».

Sensor virtual float leer() = 0; clase abstracta · interfaz SensorTemp LM35 por A/D float leer() override SensorLuz LDR en divisor float leer() override SensorHum DHT22 por 1-Wire float leer() override «es un»
Figura 1. Jerarquía de sensores. La clase base define qué sabe hacer todo sensor; cada derivada define cómo lo hace.
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;
    }
};
VisibilidadQuién puede acceder
privateSólo la propia clase. Es lo predeterminado.
protectedLa propia clase y sus derivadas.
publicCualquiera.

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
Cómo funciona por dentro

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.

El destructor de una clase base debe ser virtual

Si 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");
}
Por qué esto importa en electrónica

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();
}
En microcontroladores se suelen desactivar

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

Usar sin problema
  • Clases, encapsulamiento, métodos.
  • Constructores y destructores (RAII).
  • Sobrecarga de funciones y operadores.
  • Referencias en vez de punteros.
  • const y constexpr.
  • Herencia simple y funciones virtuales, con criterio.
  • Plantillas, si no explotan en cantidad de instancias.
  • enum class para estados y modos.
Evitar
  • Excepciones (-fno-exceptions).
  • RTTI y dynamic_cast (-fno-rtti).
  • new y delete: misma razón que malloc, la RAM se fragmenta.
  • std::vector, std::string y en general la STL con memoria dinámica.
  • Herencia múltiple y jerarquías profundas.
  • iostream: agrega decenas de KB.
Arduino es C++

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

Práctica 1 · Primera clase

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.

Práctica 2 · Constructor, destructor y RAII

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.

Práctica 3 · Herencia y polimorfismo

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.

Práctica 4 · Interfaz de display

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

ErrorConsecuencia
Olvidar el ; después de la llave de cierre de una claseCascada de errores de compilación incomprensibles en las líneas siguientes.
Destructor de la base no virtualAl 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órficoSe 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 constructorBasura. Conviene usar la lista de inicialización (: pin(p), estado(false)).
Usar new sin deleteFuga de memoria. En un micro con 2 KB de RAM, se agota en minutos.
Compilar con la STL en un microcontrolador chicoEl programa no entra en la flash, o entra y falla al agotarse la RAM.
Sobrecargar operadores de forma poco intuitivaCódigo imposible de leer. La sobrecarga se justifica sólo si el significado es obvio.
Poner todo publicSe 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++.

Desarrollo del eje «El lenguaje C++» de Informática Electrónica I (4° año), según la «Propuesta Curricular – Segundo Ciclo de la Modalidad Técnico Profesional, Educación Secundaria – Electrónica», Ministerio de Educación de la Provincia de Córdoba, DGETyFP. Volver al Mapa de Temas · catto.ar