Lección 7 de 8: las herramientas de C++ para escribir código general y para organizar los errores. Verás las plantillas de función y de clase y los conceptos de C++20, que dicen qué tipos acepta cada una; la herencia y las funciones virtuales, con los dos errores clásicos, el destructor no virtual y el slicing; std::variant como alternativa a la herencia, y las tres formas de informar de un fallo: excepciones, std::optional y std::expected.
Plantillas de función: escribir el código una vez
Una función que devuelve el mayor de dos int y otra que hace lo mismo con double o con std::string son iguales salvo por el tipo. Una plantilla (template) es una receta con el tipo como parámetro: el compilador genera una versión distinta para cada tipo con el que la uses, así que no se paga nada al ejecutar.
#include <print>
#include <string>
template <typename T>
T mayor(T a, T b)
{
return a > b ? a : b;
}
int main()
{
std::println("{}", mayor(3, 7)); // T = int
std::println("{}", mayor(2.5, 1.5)); // T = double
std::println("{}", mayor(std::string{"pera"}, std::string{"manzana"}));
std::println("{}", mayor<double>(3, 4.5)); // T escrito a mano
}salida7 2.5 pera 4.5
En las tres primeras llamadas el compilador deduce T a partir de los argumentos. En la cuarta hay que escribirlo, porque 3 es int y 4.5 es double, y una plantilla no convierte tipos para deducir. Si lo olvidas:
El error que te ahorra: mezclar tipos al deducir
template <typename T>
T mayor(T a, T b) { return a > b ? a : b; }
int main()
{
return mayor(3, 4.5) > 4 ? 0 : 1;
}g++: errordeduccion.cpp: In function 'int main()':
deduccion.cpp:6:17: error: no matching function for call to 'mayor(int, double)'
6 | return mayor(3, 4.5) > 4 ? 0 : 1;
| ~~~~~^~~~~~~~
deduccion.cpp:6:17: note: there is 1 candidate
deduccion.cpp:2:3: note: candidate 1: 'template<class T> T mayor(T, T)'
2 | T mayor(T a, T b) { return a > b ? a : b; }
| ^~~~~
deduccion.cpp:2:3: note: template argument deduction/substitution failed:
deduccion.cpp:6:17: note: deduced conflicting types for parameter 'T' ('int' and 'double')
6 | return mayor(3, 4.5) > 4 ? 0 : 1;
| ~~~~~^~~~~~~~«deduced conflicting types for parameter ‘T’ (‘int’ and ‘double’)»: con el primer argumento, T sería int, y con el segundo, double. Escribe mayor<double>(3, 4.5) o convierte uno de los dos.
Plantillas de clase
Lo mismo vale para clases. std::vector<int> es una plantilla de clase, y puedes escribir las tuyas. Desde C++17, al construir un objeto el compilador también puede deducir los parámetros de la plantilla a partir de los argumentos (class template argument deduction, CTAD), como en std::pair p{1, 2.5}.
#include <optional>
#include <print>
#include <string>
#include <vector>
template <typename T>
class Pila {
public:
void apilar(T valor) { datos_.push_back(std::move(valor)); }
std::optional<T> desapilar()
{
if (datos_.empty()) return std::nullopt;
T arriba = std::move(datos_.back());
datos_.pop_back();
return arriba;
}
std::size_t tam() const { return datos_.size(); }
private:
std::vector<T> datos_;
};
int main()
{
Pila<std::string> pila;
pila.apilar("uno");
pila.apilar("dos");
std::println("en la pila: {}", pila.tam());
while (auto x = pila.desapilar()) {
std::println("saco {}", *x);
}
std::pair p{1, 2.5}; // CTAD: std::pair<int, double>
std::println("{} y {}", p.first, p.second);
}salidaen la pila: 2 saco dos saco uno 1 y 2.5
El código de una plantilla tiene que estar visible allí donde se usa, porque el compilador necesita la receta completa para generar cada versión. Por eso las plantillas se escriben enteras en los archivos de cabecera (.hpp), y no separadas en .cpp, algo que verás en el proyecto de la lección 8.
Conceptos: decir qué tipos valen
Una plantilla acepta cualquier tipo… hasta que el código de dentro no compila con él, y entonces el error sale desde las tripas de la plantilla, a veces con cientos de líneas. Los conceptos (concepts), de C++20, ponen las condiciones en la propia declaración: std::integral, std::floating_point, std::totally_ordered y muchos más vienen en <concepts>, y puedes definir los tuyos con requires.
#include <concepts>
#include <print>
#include <string>
template <std::integral T> // solo enteros
bool es_par(T n) { return n % 2 == 0; }
template <typename T>
concept Medible = requires(const T& x) {
{ x.size() } -> std::convertible_to<std::size_t>;
};
template <Medible T>
void informar(const T& x) { std::println("mide {}", x.size()); }
auto doble(std::floating_point auto x) { return x * 2; } // forma abreviada
int main()
{
std::println("{} {}", es_par(10), es_par(7L));
informar(std::string{"hola"});
std::println("{}", doble(1.25));
}salidatrue false mide 4 2.5
El error que te ahorra: usar una plantilla con un tipo que no vale
#include <concepts>
template <std::integral T>
bool es_par(T n) { return n % 2 == 0; }
int main()
{
return es_par(2.5) ? 0 : 1;
}g++: errorconcepto_falla.cpp: In function 'int main()':
concepto_falla.cpp:8:18: error: no matching function for call to 'es_par(double)'
8 | return es_par(2.5) ? 0 : 1;
| ~~~~~~^~~~~
concepto_falla.cpp:8:18: note: there is 1 candidate
concepto_falla.cpp:4:6: note: candidate 1: 'template<class T> requires integral<T> bool es_par(T)'
4 | bool es_par(T n) { return n % 2 == 0; }
| ^~~~~~
concepto_falla.cpp:4:6: note: template argument deduction/substitution failed:
concepto_falla.cpp:4:6: note: constraints not satisfied
In file included from concepto_falla.cpp:1:
/usr/local/include/c++/15.3.0/concepts: In substitution of 'template<class T> requires integral<T> bool es_par(T) [with T = double]':
concepto_falla.cpp:8:18: required from here
8 | return es_par(2.5) ? 0 : 1;
| ~~~~~~^~~~~
/usr/local/include/c++/15.3.0/concepts:109:13: required for the satisfaction of 'integral<T>' [with T = double]
/usr/local/include/c++/15.3.0/concepts:109:24: note: the expression 'is_integral_v<_Tp> [with _Tp = double]' evaluated to 'false'
109 | concept integral = is_integral_v<_Tp>;
| ^~~~~~~~~~~~~~~~~~El error dice qué condición no se cumple («constraints not satisfied» y, al final, que is_integral_v con double es false) y señala la línea de la llamada, en vez de quejarse del % de dentro de la función, que no existe para double. Sin el concepto, ese otro error aparecería dentro de la plantilla, lejos de tu código.
Herencia y funciones virtuales
Con la herencia, una clase derivada recibe los miembros de una clase base y puede añadir los suyos. Lo interesante es el polimorfismo: si un método de la base es virtual, al llamarlo a través de un puntero o una referencia a la base se ejecuta la versión de la clase real del objeto. Así se puede tener una lista de figuras distintas y pedirle a cada una su área sin saber qué es.
#include <memory>
#include <numbers>
#include <print>
#include <string>
#include <vector>
class Figura {
public:
virtual ~Figura() = default; // destructor virtual: imprescindible en una base
virtual double area() const = 0; // = 0: virtual pura; Figura es abstracta
virtual std::string nombre() const { return "figura"; }
};
class Circulo : public Figura {
public:
explicit Circulo(double r) : radio_{r} {}
double area() const override { return std::numbers::pi * radio_ * radio_; }
std::string nombre() const override { return "círculo"; }
private:
double radio_;
};
class Rectangulo final : public Figura { // final: nadie puede heredar de ella
public:
Rectangulo(double an, double al) : ancho_{an}, alto_{al} {}
double area() const override { return ancho_ * alto_; }
std::string nombre() const override { return "rectángulo"; }
private:
double ancho_, 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));
for (const auto& f : figuras) {
std::println("{}: área {:.2f}", f->nombre(), f->area());
}
}salidacírculo: área 12.57 rectángulo: área 12.00
Tres palabras clave hacen el código más seguro. override le dice al compilador que esa función sustituye a una virtual de la base: si te equivocas en el nombre o en un const, en vez de crear en silencio una función nueva, da error. final impide seguir heredando o sobrescribiendo. Y = 0 declara una función virtual pura, que convierte la base en abstracta: no se pueden crear objetos Figura, solo de sus derivadas.
El destructor virtual y el slicing
Si una clase se va a usar como base polimórfica, su destructor tiene que ser virtual. Si no lo es, al destruir un objeto derivado a través de un puntero a la base solo se ejecuta el destructor de la base: lo que la derivada tenía (un std::string, un vector, un archivo abierto) no se libera. g++ avisa con -Wall:
El error que te ahorra: olvidar el destructor virtual
#include <memory>
#include <string>
struct Base {
virtual void hablar() {}
~Base() {} // no es virtual
};
struct Derivada : Base {
std::string datos = std::string(100, 'x');
void hablar() override {}
};
int main()
{
Base* p = new Derivada;
delete p; // solo se llama a ~Base: datos no se libera
}g++: avisosin_destructor_virtual.cpp: In function 'int main()': sin_destructor_virtual.cpp:17:5: warning: deleting object of polymorphic class type 'Base' which has non-virtual destructor might cause undefined behavior [-Wdelete-non-virtual-dtor] 17 | delete p; // solo se llama a ~Base: datos no se libera
Compilado con AddressSanitizer, al ejecutarlo da además un error de tipo new-delete-type-mismatch: se reservaron los bytes de una Derivada y se intentan liberar los de una Base.
salida con error==210==ERROR: AddressSanitizer: new-delete-type-mismatch on 0x7648c69e0010 in thread T0:
object passed to delete has wrong type:
size of the allocated type: 40 bytes;
size of the deallocated type: 8 bytes.
…
#1 0x0000004022a5 in main /curso/sin_destructor_virtual.cpp:17
…
#1 0x00000040225c in main /curso/sin_destructor_virtual.cpp:16
…
SUMMARY: AddressSanitizer: new-delete-type-mismatch /curso/sin_destructor_virtual.cpp:17 in mainEl slicing (rebanado) es el error contrario: copiar un objeto derivado en una variable de la clase base por valor. La copia solo tiene la parte de la base, y el polimorfismo desaparece, sin aviso alguno:
#include <print>
#include <string>
struct Animal {
virtual ~Animal() = default;
virtual std::string sonido() const { return "..."; }
};
struct Perro : Animal {
std::string sonido() const override { return "guau"; }
};
void por_valor(Animal a) { std::println("por valor: {}", a.sonido()); }
void por_referencia(const Animal& a) { std::println("por referencia: {}", a.sonido()); }
int main()
{
Perro p;
por_valor(p); // se copia solo la parte Animal
por_referencia(p);
}salidapor valor: ... por referencia: guau
La regla: los objetos polimórficos se pasan por referencia o por puntero (inteligente), nunca por valor. Una base que no deba copiarse puede borrar su constructor de copia (Animal(const Animal&) = delete;), y entonces el slicing ni siquiera compila.
std::variant: polimorfismo sin herencia
Cuando el conjunto de tipos posibles es cerrado y conocido, std::variant (C++17) es a menudo mejor que una jerarquía de clases: guarda uno de varios tipos dentro del propio objeto, sin montón ni punteros, y std::visit llama a la función adecuada para el tipo que contenga. Si te olvidas de un caso, no compila.
#include <numbers>
#include <print>
#include <variant>
#include <vector>
struct Circulo { double radio; };
struct Rectangulo { double ancho, alto; };
using Figura = std::variant<Circulo, Rectangulo>;
struct Area {
double operator()(const Circulo& c) const { return std::numbers::pi * c.radio * c.radio; }
double operator()(const Rectangulo& r) const { return r.ancho * r.alto; }
};
int main()
{
std::vector<Figura> figuras{Circulo{2.0}, Rectangulo{3.0, 4.0}};
for (const auto& f : figuras) {
std::println("índice {}: área {:.2f}", f.index(), std::visit(Area{}, f));
}
if (std::holds_alternative<Circulo>(figuras[0])) {
std::println("la primera es un círculo de radio {}", std::get<Circulo>(figuras[0]).radio);
}
}salidaíndice 0: área 12.57 índice 1: área 12.00 la primera es un círculo de radio 2
- Herencia con virtualstd::variant
- Tipos posiblesAbiertos: cualquiera puede añadir una derivadaCerrados: la lista está en el
variant - OperacionesFijas en la base; añadir una obliga a tocar todas las clasesLibres: cada
visites una operación nueva - MemoriaNormalmente en el montón, con punterosDentro del propio objeto
- Si falta un casoLo detecta
= 0en la claseNo compila elvisit
Excepciones
Cuando algo sale mal y la función que lo detecta no sabe arreglarlo, puede lanzar una excepción con throw. El programa abandona esa función y las que la llamaron, una tras otra, hasta llegar a un bloque try con un catch del tipo adecuado. Por el camino se ejecutan los destructores de todos los objetos locales, y por eso RAII es tan importante en C++: si cada recurso tiene un dueño con destructor, nada se pierde aunque salte una excepción.
#include <print>
#include <stdexcept>
#include <string>
struct Registro {
std::string nombre;
~Registro() { std::println(" (se cierra {})", nombre); }
};
double dividir(double a, double b)
{
Registro r{"dividir"};
if (b == 0) {
throw std::invalid_argument("división entre cero");
}
return a / b;
}
int main()
{
try {
std::println("{}", dividir(10, 4));
std::println("{}", dividir(1, 0));
std::println("esta línea no se ejecuta");
} catch (const std::invalid_argument& e) {
std::println("error: {}", e.what());
} catch (const std::exception& e) { // cualquier otra de la biblioteca estándar
std::println("otro error: {}", e.what());
}
std::println("el programa sigue");
}salida (se cierra dividir) 2.5 (se cierra dividir) error: división entre cero el programa sigue
Las excepciones se capturan por referencia a const (catch (const std::exception& e)), para no hacer slicing, y de la más concreta a la más general. Una función marcada noexcept promete que no lanza; si aun así una excepción intenta salir de ella, el programa no la propaga: termina en el acto.
#include <print>
#include <stdexcept>
void guardar() noexcept
{
throw std::runtime_error("disco lleno"); // rompe la promesa
}
int main()
{
try {
guardar();
} catch (const std::exception& e) {
std::println("capturado: {}", e.what()); // nunca llega aquí
}
}g++: avisonoexcept_falla.cpp: In function 'void guardar()':
noexcept_falla.cpp:6:5: warning: 'throw' will always call 'terminate' [-Wterminate]
6 | throw std::runtime_error("disco lleno"); // rompe la promesa
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~salida con errorterminate called after throwing an instance of 'std::runtime_error' what(): disco lleno
g++ avisa al compilar, como ves arriba (-Wterminate), y, al ejecutar, el catch no sirve de nada: se llama a std::terminate, que aborta el programa (código de salida 134). Marca noexcept los destructores (ya lo son por omisión), los constructores y asignaciones de movimiento y las funciones que de verdad no pueden fallar.
std::optional y std::expected: errores sin excepciones
No todo fallo es excepcional. Que un usuario no exista o que un texto no sea un número son resultados normales, y muchos equipos (y casi todo el código de videojuegos y sistemas empotrados) prefieren devolverlos como valores. std::optional<T> (C++17) guarda un T o nada; std::expected<T, E> (C++23) guarda un T o un error de tipo E que explica qué pasó.
#include <charconv>
#include <expected>
#include <optional>
#include <print>
#include <string>
#include <string_view>
std::expected<int, std::string> leer_edad(std::string_view texto)
{
int valor = 0;
auto [fin, ec] = std::from_chars(texto.data(), texto.data() + texto.size(), valor);
if (ec != std::errc{} || fin != texto.data() + texto.size()) {
return std::unexpected("«" + std::string{texto} + "» no es un número");
}
if (valor < 0 || valor > 150) {
return std::unexpected("edad fuera de rango: " + std::to_string(valor));
}
return valor;
}
std::optional<std::string> buscar_correo(std::string_view usuario)
{
if (usuario == "ana") return "ana@ejemplo.com";
return std::nullopt;
}
int main()
{
for (std::string_view t : {"42", "4x", "300"}) {
auto r = leer_edad(t);
if (r) {
std::println("edad válida: {}", *r);
} else {
std::println("error: {}", r.error());
}
}
std::println("{}", buscar_correo("ana").value_or("(sin correo)"));
std::println("{}", buscar_correo("luis").value_or("(sin correo)"));
auto doble = leer_edad("21").transform([](int e) { return e * 2; }); // encadenar sin if
std::println("el doble: {}", doble.value());
}salidaedad válida: 42 error: «4x» no es un número error: edad fuera de rango: 300 ana@ejemplo.com (sin correo) el doble: 42
- SituaciónUsaEjemplo
- Un fallo raro que el que llama no puede arreglar en el sitioUna excepciónSin memoria, archivo de configuración corrupto
- Un resultado que puede faltar, sin más explicación
std::optionalBuscar en una tabla - Un fallo esperable con su motivo
std::expectedValidar lo que escribe un usuario - Un error de programación
asserto un sanitizadorUn índice fuera de rango en tu propio código
Ojo con la seguridad: la confusión de tipos
Convertir un puntero a la base en un puntero a una derivada con static_cast no comprueba nada: si el objeto es de otra clase, el programa lee y escribe sus campos como si fueran los de la que no es. Esta confusión de tipos (type confusion) es una de las clases de fallo más explotadas en los navegadores escritos en C++. Usa dynamic_cast, que devuelve nullptr si el tipo no es el esperado, o, mejor aún, diseña para no tener que convertir: funciones virtuales o std::variant. En las pruebas, -fsanitize=undefined detecta la conversión incorrecta:
#include <print>
struct Mensaje { virtual ~Mensaje() = default; };
struct Texto : Mensaje { char buf[8] = "hola"; };
struct Orden : Mensaje { int nivel = 0; };
int main()
{
Mensaje* m = new Texto;
Orden* o = static_cast<Orden*>(m); // m no es una Orden
if (auto* seguro = dynamic_cast<Orden*>(m); seguro == nullptr) {
std::println("dynamic_cast: no es una Orden");
}
o->nivel = 9;
delete m;
}salida con errorconfusion.cpp:10:16: runtime error: downcast of address 0x00000baf02b0 which does not point to an object of type 'Orden' … confusion.cpp:14:8: runtime error: member access within address 0x00000baf02b0 which does not point to an object of type 'Orden' … dynamic_cast: no es una Orden
Retos
Reto 1. Escribe una plantilla suma(const std::vector<T>&) que solo acepte tipos numéricos (usa un concepto) y pruébala con enteros y con double.
Ver la solución
#include <concepts>
#include <print>
#include <vector>
template <typename T>
concept Numerico = std::integral<T> || std::floating_point<T>;
template <Numerico T>
T suma(const std::vector<T>& v)
{
T total{};
for (const T& x : v) total += x;
return total;
}
int main()
{
std::println("{}", suma(std::vector{1, 2, 3, 4}));
std::println("{}", suma(std::vector{0.5, 0.25}));
}salida10 0.75
Con std::vector{1, 2, 3, 4}, CTAD deduce std::vector<int>. Si pruebas con un std::vector<std::string>, el compilador dirá que std::string no cumple Numerico.
Reto 2. ¿Qué imprime? struct A { virtual void f() const { std::println("A"); } }; struct B : A { void f() { std::println("B"); } }; y luego B b; const A& r = b; r.f();
Ver la solución
#include <print>
struct A {
virtual ~A() = default;
virtual void f() const { std::println("A"); }
};
struct B : A {
void f() { std::println("B"); } // sin const: es OTRA función
};
int main()
{
B b;
const A& r = b;
r.f();
}salidaA
Escribe «A». B::f no es const, así que no sobrescribe a A::f: es una función nueva que oculta a la otra. Con -Wall, g++ ya da una pista:
g++: avisoreto2.cpp:5:18: warning: 'virtual void A::f() const' was hidden [-Woverloaded-virtual=]
5 | virtual void f() const { std::println("A"); }
| ^Y si hubieras escrito void f() override, en vez de un aviso tendrías un error:
struct A {
virtual ~A() = default;
virtual void f() const {}
};
struct B : A {
void f() override {}
};
int main() {}g++: errorreto2_override.cpp:7:10: error: 'void B::f()' marked 'override', but does not override
7 | void f() override {}
| ^Reto 3. Escribe dividir(int a, int b) que devuelva std::expected<int, std::string> con un error si b es 0, y úsala con value_or.
Ver la solución
#include <expected>
#include <print>
#include <string>
std::expected<int, std::string> dividir(int a, int b)
{
if (b == 0) return std::unexpected("división entre cero");
return a / b;
}
int main()
{
std::println("{}", dividir(10, 3).value_or(-1));
auto r = dividir(1, 0);
std::println("{}", r.has_value() ? "bien" : r.error());
std::println("{}", r.value_or(-1));
}salida3 división entre cero -1
value_or devuelve el valor si lo hay y, si no, el que le pasas. Llamar a .value() sobre un expected con error lanza una excepción std::bad_expected_access, y usar *r sin comprobar es comportamiento indefinido.
Todo el código de esta lección se compiló con GCC 15.3 (C++23, -std=c++23 -Wall -Wextra) y se ejecutó en un contenedor limpio antes de publicarla; los avisos del compilador y las salidas están copiados de esa ejecución. ¿Algo no te compila? Revisa tu versión con g++ --version: hace falta GCC 14 o más nuevo (o Clang 19 con libstdc++ 14) para std::print y el resto de C++23.
