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.
- 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
- ¿Qué es la herencia pública?
- ¿Para qué sirve el polimorfismo?
- ¿Cómo funciona por dentro? La vtable
- Sintaxis
- Ejemplo básico: figuras a través de la base
- Ejemplo intermedio: activaciones intercambiables
- Caso práctico: un pipeline compuesto de transformadores
- Visualización: despacho virtual y slicing
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- 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.
| Concepto | Qué es | Cuándo se resuelve |
|---|---|---|
| Tipo estático | El declarado: Figura*, const Base& | En compilación |
| Tipo dinámico | El del objeto real al que apunta | En ejecución |
| Llamada no virtual | Salto directo a la función de la base | En compilación |
| Llamada virtual | Indirección a través de la vtable | En 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
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.
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()) # 9class 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
#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;
}Figuradeclara dos métodos puros, así que es abstracta:Figura f;no compila.CirculoyRectanguloheredan públicamente y sobrescriben ambos métodos conoverride.- El vector guarda
std::unique_ptr<Figura>: posee objetos de tipos distintos a través de un puntero común. 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&.
#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.
#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.
#include <iostream>#include <string>struct Base { virtual ~Base() = default; virtual std::string nombre() const { return "Base"; }};struct Derivada : Base { std::string nombre() const override { return "Derivada"; }};int main() { Derivada d; Base& ref = d; Base copia = d; std::cout << ref.nombre() << '\n'; std::cout << copia.nombre() << '\n'; return 0;}d. Su vptr apunta a la vtable de Derivada, donde la entrada de nombre es la versión derivada.—
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
| Error | Causa | Solución |
|---|---|---|
cannot instantiate abstract class | La clase tiene métodos puros o falta implementarlos en la derivada | Implementa todos los métodos puros o usa la base solo por referencia |
marked 'override', but does not override | La firma no coincide: falta const, cambia un parámetro o el nombre | Copia la firma exacta de la base y revisa los cualificadores |
| Fuga al borrar por puntero a la base | El destructor de la base no es virtual | Declara virtual ~Base() = default; |
| La función imprime el nombre de la base | Recibiste el parámetro por valor y sufriste object slicing | Usa const Base& o const std::unique_ptr<Base>& |
| La llamada virtual del constructor no llega a la derivada | Durante la construcción de la base, la derivada aún no existe | No 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.
#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;
}#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
overridey usafinalsi 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 yBase¶ observar. - Antes de heredar, pregúntate si la relación es realmente «es un».
Ejercicios
Tres figuras y la de mayor área
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.
Métricas MAE y RMSE tras 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.
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.
virtualdecide la implementación en ejecución mediante la vtable; sin él, se usa el tipo estático.overrideconvierte 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
¿Por qué una clase base polimórfica necesita un destructor virtual?
Con Derivada d; Base& ref = d; y nombre() virtual, ¿qué devuelve ref.nombre()?
¿Qué ocurre al pasar un objeto Derivada a una función que recibe Base por valor?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.