AulaML Python · C++
C++ avanzado · 50 min · Programación orientada a objetos

Herencia, virtual y polimorfismo

Una interfaz, muchas implementaciones y la decisión que se toma en tiempo de ejecución

La herencia permite tratar objetos distintos a través de una interfaz común, y `virtual` decide en tiempo de ejecución qué implementación se ejecuta. Aquí ves la vtable por dentro, por qué el destructor debe ser virtual, cómo evitar el object slicing y cuándo conviene componer en lugar de heredar.

Lección 2 de 4 del módulo
Al terminar esta lección podrás
  • Derivar clases con herencia pública y sobrescribir métodos con override
  • Explicar cómo la vtable resuelve una llamada virtual en ejecución
  • Declarar clases abstractas con métodos puros y usarlas mediante referencias o unique_ptr
  • Justificar el destructor virtual y reconocer un object slicing
  • Elegir entre herencia y composición según la relación del dominio

Antes necesitas: Clases, constructores y encapsulamiento · RAII y punteros inteligentes

En esta lección
  1. ¿Qué es la herencia pública?
  2. ¿Para qué sirve el polimorfismo?
  3. ¿Cómo funciona por dentro? La vtable
  4. Sintaxis
  5. Ejemplo básico: figuras a través de la base
  6. Ejemplo intermedio: activaciones intercambiables
  7. Caso práctico: un pipeline compuesto de transformadores
  8. Visualización: despacho virtual y slicing
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. Evaluación

Hay problemas donde varios tipos comparten un contrato: toda figura sabe calcular su área, toda métrica sabe comparar dos vectores, toda activación transforma un número. La herencia permite declarar ese contrato una vez y que cada tipo lo cumpla a su manera. El polimorfismo es la capacidad de escribir código que llama a ese contrato sin saber qué tipo concreto tiene delante.

La diferencia con una función sobrecargada es cuándo se decide. En la sobrecarga, el compilador elige la versión mirando los tipos estáticos. Con virtual, la elección ocurre en ejecución, según el tipo real del objeto. Esa es la única forma de recorrer un std::vector con objetos de clases distintas y que cada uno ejecute su propio código.

¿Qué es la herencia pública?

Una clase derivada declara que es un caso particular de la base: class Circulo : public Figura significa que un Circulo puede usarse en cualquier lugar donde se espere una Figura. Hereda sus miembros públicos y protegidos, y puede añadir los suyos o redefinir el comportamiento de los métodos virtuales.

  • Herencia pública (: public Base): mantiene la relación «es un» y es la única habitual en diseño.
  • Herencia privada o protegida: implementa en términos de la base sin exponer la conversión; se usa muy poco.
  • Sobrescritura: la derivada declara un método con la misma firma y lo marca override.
  • Método virtual puro (= 0): no tiene implementación en la base y obliga a las derivadas a darla.
  • Clase abstracta: tiene al menos un método puro y no se puede instanciar.

¿Para qué sirve el polimorfismo?

  • Extender sin tocar: añadir una figura nueva no obliga a modificar el bucle que suma áreas.
  • Desacoplar: el código cliente depende de Metrica, no de MAE ni de RMSE.
  • Modelar variantes: activaciones, optimizadores, capas o plugins comparten interfaz y cambian el comportamiento.
  • Probar con dobles: una implementación falsa de la interfaz sustituye a la real en un test.
  • Evitar cadenas de if: en lugar de preguntar el tipo y ramificar, cada clase hace lo suyo.

La contrapartida es el coste y la complejidad: cada llamada virtual es un salto indirecto que el compilador no siempre puede predecir, y una jerarquía mal diseñada acopla todas las derivadas a los detalles de la base. Por eso la última sección trata de cuándo conviene no heredar.

¿Cómo funciona por dentro? La vtable

Cuando una clase tiene métodos virtuales, el compilador crea una tabla virtual (vtable): un array de punteros a función, uno por método virtual, con la implementación que corresponde a esa clase. Cada objeto guarda, en su primer campo oculto, un puntero a la vtable de su tipo dinámico, el vptr.

Una llamada f->area() se convierte en: leer el vptr del objeto, buscar la entrada de area en la vtable y saltar a esa dirección. De ahí vienen dos consecuencias. Primera, el objeto crece: un int de tamaño 4 puede pasar a ocupar 16 con el vptr y la alineación. Segunda, la decisión se toma con el tipo dinámico, el del objeto real, no con el tipo del puntero o de la referencia.

ConceptoQué esCuándo se resuelve
Tipo estáticoEl declarado: Figura*, const Base&En compilación
Tipo dinámicoEl del objeto real al que apuntaEn ejecución
Llamada no virtualSalto directo a la función de la baseEn compilación
Llamada virtualIndirección a través de la vtableEn ejecución

El vptr se escribe en el constructor, y por eso una llamada virtual dentro de un constructor ejecuta la versión de la clase que se está construyendo, no la de la derivada: en ese momento la derivada aún no existe. Es un error sutil que el compilador no señala como tal.

Sintaxis

CPP base abstracta y derivada
class Base {
public:
    virtual ~Base() = default;                 // destructor virtual
    virtual double evaluar(double x) const = 0; // pura: obliga a implementar
};

class Derivada : public Base {
public:
    double evaluar(double x) const override;   // misma firma, misma const
};

override no es obligatorio, pero sí imprescindible en la práctica: si la firma no coincide exactamente —por ejemplo, si olvidas el const— el compilador avisa con marked override, but does not override en lugar de crear silenciosamente un método nuevo que nunca se llama. La firma incluye nombre, parámetros y cualificadores; el tipo de retorno solo puede ser covariante.

La clase derivada inicializa la base en su lista de inicialización: Circulo(double r) : Figura(), radio_(r) {}. Si la base no tiene constructor por defecto, hay que llamar explícitamente a uno con parámetros. El orden de construcción es siempre base primero, derivada después, y la destrucción al revés.

La misma interfaz en los dos lenguajes
Python
PYTHON figuras.py
from abc import ABC, abstractmethod

class Figura(ABC):
    @abstractmethod
    def area(self): ...

class Cuadrado(Figura):
    def __init__(self, lado):
        self.lado = lado
    def area(self):
        return self.lado ** 2

print(Cuadrado(3).area())  # 9
C++
CPP figuras.cpp
class Figura {
public:
    virtual ~Figura() = default;
    virtual double area() const = 0;
};

class Cuadrado : public Figura {
public:
    explicit Cuadrado(double lado) : lado_(lado) {}
    double area() const override { return lado_ * lado_; }
private:
    double lado_;
};

Python marca la abstracción con `@abstractmethod` y comprueba en tiempo de ejecución; C++ la marca con `= 0` y comprueba en compilación. En ambos casos la clase base no se puede instanciar y las derivadas deben implementar el método.

Ejemplo básico: figuras a través de la base

CPP figuras.cpp
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Figura {
public:
    virtual ~Figura() = default;
    virtual double area() const = 0;
    virtual std::string nombre() const = 0;
};

class Circulo : public Figura {
public:
    explicit Circulo(double radio) : radio_(radio) {}
    double area() const override { return 3.141592653589793 * radio_ * radio_; }
    std::string nombre() const override { return "circulo"; }

private:
    double radio_;
};

class Rectangulo : public Figura {
public:
    Rectangulo(double ancho, double alto) : ancho_(ancho), alto_(alto) {}
    double area() const override { return ancho_ * alto_; }
    std::string nombre() const override { return "rectangulo"; }

private:
    double ancho_;
    double alto_;
};

int main() {
    std::vector<std::unique_ptr<Figura>> figuras;
    figuras.push_back(std::make_unique<Circulo>(2.0));
    figuras.push_back(std::make_unique<Rectangulo>(3.0, 4.0));

    double total = 0.0;
    for (const auto& f : figuras) {
        std::cout << f->nombre() << ": " << f->area() << '\n';
        total += f->area();
    }
    std::cout << "total: " << total << '\n';
    return 0;
}
  1. Figura declara dos métodos puros, así que es abstracta: Figura f; no compila.
  2. Circulo y Rectangulo heredan públicamente y sobrescriben ambos métodos con override.
  3. El vector guarda std::unique_ptr<Figura>: posee objetos de tipos distintos a través de un puntero común.
  4. f->area() se resuelve por la vtable de cada objeto; el bucle no sabe ni le importa qué tipo concreto es.

La salida es circulo: 12.5664, rectangulo: 12 y total: 24.5664. Si añadieras un Triangulo, bastaría con crear la clase y añadirla al vector: ni el bucle ni la suma cambian. Esa es la ganancia real del polimorfismo, y también su trampa: el código cliente ya no sabe qué tipos existen.

Ejemplo intermedio: activaciones intercambiables

En una red neuronal las capas aplican activaciones distintas sobre los mismos datos. La interfaz Activacion fija el contrato y el bucle que evalúa solo ve const Activacion&.

CPP activaciones.cpp
#include <cmath>
#include <iomanip>
#include <iostream>
#include <memory>
#include <string>
#include <vector>

class Activacion {
public:
    virtual ~Activacion() = default;
    virtual double aplicar(double x) const = 0;
    virtual std::string nombre() const = 0;
};

class ReLU : public Activacion {
public:
    double aplicar(double x) const override { return x > 0.0 ? x : 0.0; }
    std::string nombre() const override { return "relu"; }
};

class Sigmoide : public Activacion {
public:
    double aplicar(double x) const override { return 1.0 / (1.0 + std::exp(-x)); }
    std::string nombre() const override { return "sigmoide"; }
};

int main() {
    std::vector<std::unique_ptr<Activacion>> capa;
    capa.push_back(std::make_unique<ReLU>());
    capa.push_back(std::make_unique<Sigmoide>());

    std::cout << std::fixed << std::setprecision(3);
    for (const auto& act : capa) {
        std::cout << act->nombre() << "(-2) = " << act->aplicar(-2.0) << '\n';
    }
    return 0;
}

La salida es relu(-2) = 0.000 y sigmoide(-2) = 0.119. La misma llamada aplicar(-2.0) ejecuta dos fórmulas distintas porque el objeto real es distinto. ReLU no necesita estado y su constructor implícito basta; Sigmoide tampoco guarda nada, pero podría hacerlo sin cambiar la interfaz.

Fíjate en override en ambas derivadas y en el destructor virtual de la base. Sin ese destructor, borrar un Sigmoide a través de un unique_ptr<Activacion> sería comportamiento indefinido: el compilador llamaría solo al destructor de la base.

Caso práctico: un pipeline compuesto de transformadores

La herencia resuelve la variación; la composición resuelve la estructura. Un Pipeline tiene una secuencia de transformadores y los aplica en orden. Cada paso es polimórfico, pero el pipeline no hereda de nadie: contiene.

CPP pipeline.cpp
#include <algorithm>
#include <iostream>
#include <memory>
#include <utility>
#include <vector>

class Transformador {
public:
    virtual ~Transformador() = default;
    virtual double aplicar(double x) const = 0;
};

class Escalar : public Transformador {
public:
    explicit Escalar(double factor) : factor_(factor) {}
    double aplicar(double x) const override { return x * factor_; }

private:
    double factor_;
};

class Recortar : public Transformador {
public:
    explicit Recortar(double limite) : limite_(limite) {}
    double aplicar(double x) const override { return std::min(x, limite_); }

private:
    double limite_;
};

class Pipeline {
public:
    void agregar(std::unique_ptr<Transformador> paso) {
        pasos_.push_back(std::move(paso));
    }

    double ejecutar(double x) const {
        for (const auto& paso : pasos_) {
            x = paso->aplicar(x);
        }
        return x;
    }

private:
    std::vector<std::unique_ptr<Transformador>> pasos_;
};

int main() {
    Pipeline p;
    p.agregar(std::make_unique<Escalar>(2.0));
    p.agregar(std::make_unique<Recortar>(5.0));
    std::cout << p.ejecutar(4.0) << '\n';
    return 0;
}

La salida es 5. El valor 4 se multiplica por 2 y da 8; después Recortar lo limita a 5. El pipeline no conoce las clases concretas, solo Transformador, y agregar toma posesión del paso con std::move, de modo que el unique_ptr del llamador queda vacío y sin riesgo de doble liberación.

Visualización: despacho virtual y slicing

La traza compara dos accesos al mismo objeto derivado. Por una referencia, la llamada llega a la implementación de Derivada; por una copia en un Base, la parte derivada se perdió antes de la llamada. Observa en qué paso cambia el tipo dinámico.

Ejecución visual ·Un objeto, dos caminos
1 / 6
1 #include <iostream>
2 #include <string>
3
4 struct Base {
5 virtual ~Base() = default;
6 virtual std::string nombre() const { return "Base"; }
7 };
8
9 struct Derivada : Base {
10 std::string nombre() const override { return "Derivada"; }
11 };
12
13 int main() {
14 Derivada d;
15 Base& ref = d;
16 Base copia = d;
17 std::cout << ref.nombre() << '\n';
18 std::cout << copia.nombre() << '\n';
19 return 0;
20 }
Se construye d. Su vptr apunta a la vtable de Derivada, donde la entrada de nombre es la versión derivada.
Variables
d = Derivada
ref = —
copia = —
Salida
—

Avanza paso a paso: observa la línea resaltada, las variables y la salida.

El paso tres es el que hay que recordar: una copia por valor de un objeto polimórfico no conserva el tipo. Si necesitas copiar objetos de una jerarquía, hazlo a través de un método virtual clonar() que devuelva std::unique_ptr<Base>, o evita la copia y usa referencias y punteros.

Errores comunes

ErrorCausaSolución
cannot instantiate abstract classLa clase tiene métodos puros o falta implementarlos en la derivadaImplementa todos los métodos puros o usa la base solo por referencia
marked 'override', but does not overrideLa firma no coincide: falta const, cambia un parámetro o el nombreCopia la firma exacta de la base y revisa los cualificadores
Fuga al borrar por puntero a la baseEl destructor de la base no es virtualDeclara virtual ~Base() = default;
La función imprime el nombre de la baseRecibiste el parámetro por valor y sufriste object slicingUsa const Base& o const std::unique_ptr<Base>&
La llamada virtual del constructor no llega a la derivadaDurante la construcción de la base, la derivada aún no existeNo llames a métodos virtuales desde constructores ni destructores

El tercer error es el más peligroso porque el programa compila y a menudo «funciona»: el destructor de la base se ejecuta, la parte derivada no se destruye y la memoria que poseía queda perdida. Con -Wall -Wextra puede aparecer delete-non-virtual-dtor, pero solo si el compilador puede ver el tipo.

Pasar por valor recorta el objeto
Incorrecto
PYTHON Incorrecto: parámetro por valor
#include <iostream>
#include <string>

struct Base {
    virtual std::string nombre() const { return "Base"; }
};

struct Derivada : Base {
    std::string nombre() const override { return "Derivada"; }
};

void imprimir(Base b) {
    std::cout << b.nombre() << '\n';
}

int main() {
    Derivada d;
    imprimir(d);
    return 0;
}
Correcto
PYTHON Correcto: referencia a la base
#include <iostream>
#include <string>

struct Base {
    virtual std::string nombre() const { return "Base"; }
};

struct Derivada : Base {
    std::string nombre() const override { return "Derivada"; }
};

void imprimir(const Base& b) {
    std::cout << b.nombre() << '\n';
}

int main() {
    Derivada d;
    imprimir(d);
    return 0;
}

La primera versión imprime `Base`: al pasar por valor se construye un `Base` nuevo y el tipo dinámico se pierde. La segunda imprime `Derivada`: la referencia conserva el objeto original y el despacho virtual lo encuentra. El diagnóstico es invisible en el código de llamada, por eso conviene que las funciones polimórficas reciban siempre referencias.

Buenas prácticas

  • Declara virtual ~Base() = default; en toda clase pensada para ser base.
  • Marca cada sobrescritura con override y usa final si no quieres más derivadas.
  • Recibe los objetos polimórficos por const& o por puntero inteligente, nunca por valor.
  • Prefiere interfaces pequeñas: un método puro por responsabilidad real.
  • No llames a métodos virtuales desde constructores ni destructores.
  • Usa std::unique_ptr<Base> para poseer y Base& para observar.
  • Antes de heredar, pregúntate si la relación es realmente «es un».

Ejercicios

Ejercicio 1

Tres figuras y la de mayor área

Define una clase abstracta Figura con area() y nombre() puros y destructor virtual. Deriva Circulo (radio 1), Rectangulo (2x3) y Triangulo (base 4, altura 5). Guárdalas en un std::vector<std::unique_ptr<Figura>>, imprime cada área con dos decimales, suma el total y muestra el nombre de la figura con mayor área usando std::max_element.
Pista

El comparador de max_element recibe dos const std::unique_ptr<Figura>& y compara a->area() < b->area(). Para imprimir con dos decimales usa std::fixed y std::setprecision.

Ejercicio 2

Métricas MAE y RMSE tras una interfaz

Declara una interfaz Metrica con calcular(real, pred) puro y nombre() puro. Implementa MAE y RMSE para los vectores {3, 5} y {2, 7}. Recorre un std::vector<std::unique_ptr<Metrica>> e imprime cada métrica con dos decimales. Calcula a mano los valores esperados antes de ejecutar.
Pista

Los errores son 1 y 2. MAE promedia sus valores absolutos; RMSE promedia los cuadrados y aplica std::sqrt.

Completar código Completa la jerarquía
class Base {
public:
     ~Base() = default;
    virtual double f() const = 0;
};

class D : public Base {
public:
    double f() const  { return 1.0; }
};

El destructor de una base polimórfica debe permitir el borrado correcto; la derivada declara que sobrescribe.

Soluciones explicadas

En el ejercicio de figuras, el punto delicado es max_element: recibe dos unique_ptr y el comparador desreferencia para comparar áreas. Si compararas los punteros directamente, ordenarías por dirección de memoria, un resultado sin sentido que cambia entre ejecuciones. El total se acumula en el mismo bucle que imprime, así que no hay que recorrer el vector dos veces.

En las métricas, ambas implementaciones tienen la misma firma y el override garantiza que así sea. La interfaz no impone cómo se calcula, solo qué se recibe y qué se devuelve. Y el destructor virtual no es decorativo: el vector posee MAE y RMSE a través de Metrica, así que el borrado ocurre con el tipo estático de la base.

Resumen

  • La herencia pública expresa una relación «es un» y permite usar la derivada como base.
  • virtual decide la implementación en ejecución mediante la vtable; sin él, se usa el tipo estático.
  • override convierte una firma equivocada en un error de compilación.
  • Una clase con métodos puros es abstracta y no se puede instanciar.
  • El destructor de una base polimórfica debe ser virtual.
  • Las funciones que reciben objetos polimórficos usan const& o punteros inteligentes para evitar slicing.
  • La composición suele ser más flexible: hereda contratos, compón implementaciones.

Evaluación

Evaluación 1

¿Por qué una clase base polimórfica necesita un destructor virtual?

Evaluación 2

Con Derivada d; Base& ref = d; y nombre() virtual, ¿qué devuelve ref.nombre()?

Evaluación 3

¿Qué ocurre al pasar un objeto Derivada a una función que recibe Base por valor?

Tus notas

¿Ya lo entendiste?

Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.