Clases abstractas e interfaces en C++
Aprende clases abstractas e interfaces en C++ usando funciones virtuales puras para crear diseños flexibles.
En C++, las clases abstractas son aquellas que contienen al menos una función virtual pura y no pueden ser instanciadas directamente. El lenguaje C++ no tiene una palabra clave separada para “interfaz”; en su lugar, una clase abstracta compuesta solo por funciones virtuales puras actúa como una interfaz. En este artículo, exploraremos el diseño de clases abstractas e interfaces, la implementación de múltiples interfaces, el Principio de Inversión de Dependencias (DIP) y un ejemplo práctico con la clase Book.
1) Función virtual pura y clase abstracta
Una función virtual marcada con = 0 es una función virtual pura.
Una clase con al menos una de estas funciones se convierte en una clase abstracta
y no puede instanciarse directamente.
struct IPrintable {
virtual ~IPrintable() = default; // Buenas prácticas: destructor virtual en interfaces
virtual void Print() const = 0; // virtual pura → contrato de interfaz
};
struct IEntity {
virtual ~IEntity() = default;
virtual std::string Key() const = 0;
};
Estas dos clases ahora actúan como “interfaces”.
Las clases que las implementen deben definir Print() y Key().
2) Clase base Book: clase abstracta
Expresemos el contrato común mediante una clase abstracta BookBase.
Las clases derivadas definirán el cálculo del precio.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
struct BookBase : IPrintable, IEntity {
protected:
string title, author;
public:
BookBase(string t, string a) : title(move(t)), author(move(a)) {}
virtual ~BookBase() = default;
// Virtual pura: las clases derivadas calculan el precio
virtual double FinalPrice() const = 0;
// Contrato de IPrintable
void Print() const override {
cout << title << " - " << author
<< " | " << FinalPrice() << " €" << endl;
}
// Contrato de IEntity
string Key() const override { return title + ":" + author; }
};
BookBase no puede instanciarse directamente con new porque FinalPrice() es una función virtual pura.
Sin embargo, podemos proporcionar implementaciones predeterminadas para Print() y Key().
3) Clases que implementan la interfaz (EBook / PrintedBook)
Creamos dos tipos diferentes de libros que cumplen con el contrato FinalPrice().
struct EBook : BookBase {
double base, vat;
EBook(string t, string a, double b, double v)
: BookBase(move(t), move(a)), base(b), vat(v) {}
double FinalPrice() const override { return base * (1.0 + vat); }
};
enum class Cover { Soft, Hard };
struct PrintedBook : BookBase {
double base, shipping; int pages; Cover cover;
PrintedBook(string t, string a, double b, double s, int p, Cover c)
: BookBase(move(t), move(a)), base(b), shipping(s), pages(p), cover(c) {}
double FinalPrice() const override {
double cost = base + shipping + 0.04 * pages;
if (cover == Cover::Hard) cost *= 1.1;
return cost;
}
};
Ambas clases cumplen con el contrato BookBase, por lo que pueden usarse de forma polimórfica.
4) Implementación de múltiples interfaces (Segregación de interfaces)
Diseñar interfaces pequeñas y enfocadas en lugar de una grande reduce los costos de mantenimiento.
Por ejemplo, una interfaz IDiscountable puede definir la política de descuentos.
struct IDiscountable {
virtual ~IDiscountable() = default;
virtual double ApplyDiscount(double rate) const = 0; // 0.10 → 10 %
};
struct DiscountedEBook : EBook, IDiscountable {
using EBook::EBook; // hereda los constructores
double ApplyDiscount(double rate) const override {
return FinalPrice() * (1.0 - rate);
}
};
Con este diseño, DiscountedEBook implementa tanto el contrato de BookBase como el de descuento.
5) Inversión de dependencias (DIP) y capacidad de prueba
Las clases deben depender de abstracciones (interfaces) y no de tipos concretos.
Esto permite un acoplamiento débil y facilita las pruebas unitarias.
A continuación, un “motor de precios” utiliza solo la interfaz BookBase.
struct PricingEngine {
double Total(const vector<shared_ptr<BookBase>>& items) const {
double sum = 0.0;
for (const auto& b : items) sum += b->FinalPrice();
return sum;
}
};
int main() {
vector<shared_ptr<BookBase>> items;
items.push_back(make_shared<EBook>("Modern C++", "A. Dev", 100, 0.1));
items.push_back(make_shared<PrintedBook>("Clean Code", "R. Martin", 120, 20, 464, Cover::Hard));
PricingEngine eng;
cout << "Total: " << eng.Total(items) << " €" << endl;
for (const auto& b : items) b->Print(); // IPrintable
}
PricingEngine no depende de clases concretas; solo trabaja con la interfaz BookBase.
Por tanto, puede usar un MockBook en las pruebas unitarias.
6) Interfaces con herencia múltiple
C++ admite la herencia múltiple; en el caso de las interfaces, esto suele ser seguro ya que no contienen estado. Sin embargo, usar herencia múltiple con clases con estado puede causar el problema del diamante. En tales casos, use herencia virtual o, preferiblemente, composición.
struct IStorable { virtual ~IStorable()=default; virtual void Save() const =0; };
struct ISearchable{ virtual ~ISearchable()=default; virtual bool Matches(string) const =0; };
struct SearchableBook : BookBase, IStorable, ISearchable {
using BookBase::BookBase;
double FinalPrice() const override { return 0.0; } // ejemplo simple
void Save() const override { /* guardar en archivo... */ }
bool Matches(string q) const override {
return title.find(q)!=string::npos || author.find(q)!=string::npos;
}
};
7) Ejemplo de fábrica (Factory) con interfaz
Para separar la lógica de creación de las clases concretas, podemos usar una interfaz de fábrica.
struct IBookFactory {
virtual ~IBookFactory() = default;
virtual shared_ptr<BookBase> MakeEBook(string t,string a,double b,double v)=0;
virtual shared_ptr<BookBase> MakePrinted(string t,string a,double b,double s,int p,Cover c)=0;
};
struct DefaultBookFactory : IBookFactory {
shared_ptr<BookBase> MakeEBook(string t,string a,double b,double v) override {
return make_shared<EBook>(move(t), move(a), b, v);
}
shared_ptr<BookBase> MakePrinted(string t,string a,double b,double s,int p,Cover c) override {
return make_shared<PrintedBook>(move(t), move(a), b, s, p, c);
}
};
Los módulos de alto nivel dependen solo de IBookFactory;
la implementación concreta puede reemplazarse fácilmente o simularse en pruebas.
8) Consejos de diseño
- Defina siempre un destructor virtual en las interfaces.
- Mantenga las interfaces pequeñas y enfocadas (Principio de Segregación de Interfaces).
- Dependa de abstracciones, no de clases concretas (DIP).
- Use punteros o referencias para el polimorfismo; copiar por valor provoca slicing.
- Use herencia múltiple solo para comportamiento (interfaces); para compartir estado, prefiera la composición.
9) Errores comunes
- Olvidar el destructor virtual en una clase abstracta.
- Diseñar una interfaz demasiado grande (forzando métodos vacíos en las implementaciones).
- Depender directamente de clases concretas (reduce la capacidad de prueba y flexibilidad).
- Combinar clases base con estado en herencia múltiple → problema del diamante.
10) TL;DR
- En C++, una “interfaz” = una clase abstracta compuesta solo por funciones virtuales puras.
- Las clases abstractas no pueden instanciarse; las clases derivadas se concretan al sobrescribir el contrato.
- Prefiera interfaces pequeñas y coherentes (ISP) y dependa de abstracciones (DIP).
- Use punteros o referencias para el polimorfismo; no olvide el destructor virtual.
- Use herencia múltiple para interfaces; para compartir estado, prefiera la composición.
- Todos los ejemplos funcionan en Visual Studio 2022 y GCC 11+.