Templates y programación genérica
Escribir una función o una clase una vez y que sirva para muchos tipos
Una plantilla es un molde con el que el compilador fabrica funciones y clases para cada tipo que uses. Aquí verás cómo se instancian, cómo restringirlas con concepts de C++20 y cómo leer los errores que producen.
- Escribir plantillas de función y de clase y reconocer cuándo se instancian
- Explicar la diferencia entre una plantilla y el código que genera
- Usar typename, especialización y plantillas variádicas sin copiar código
- Restringir una plantilla con concepts para obtener errores legibles
- Decidir cuándo una plantilla es mejor que una sobrecarga o una clase base
Antes necesitas: Regla de 0/3/5 y sobrecarga de operadores · Clases, constructores y encapsulamiento
En esta lección
- ¿Qué es una plantilla?
- ¿Para qué sirve la programación genérica?
- Cómo funciona por dentro: instanciación
- Sintaxis: parámetros, typename y especialización
- Ejemplo básico: una función para muchos tipos
- Ejemplo intermedio: una clase plantilla
- Caso práctico: variádicas y concepts
- Visualización: qué genera el compilador
- Errores comunes
- Buenas prácticas
- Ejercicios
- Soluciones explicadas
- Resumen
- Evaluación
Cuando usas std::vector<int>, std::vector<double> o std::vector<std::string>, no existe una clase distinta escrita a mano para cada uno: existe una plantilla. Una plantilla no es código ejecutable, sino un molde que el compilador rellena con el tipo concreto que le pidas.
Esa idea, la programación genérica, es la que permite que la biblioteca estándar tenga un solo std::sort que ordena enteros, cadenas o structs, y un solo std::vector que guarda cualquier cosa. En esta lección vas a escribir tus propios moldes y a entender qué genera el compilador por debajo.
¿Qué es una plantilla?
Piensa en una plantilla de repostería: el cortador tiene la forma, pero no es el bizcocho. Con template <typename T> T doble(T x) describes la forma de una función; el bizcocho aparece cuando escribes doble(4). En ese momento el compilador sustituye T por int y genera una función real.
T es un parámetro de tipo: un hueco que se rellena con int, double, std::string o incluso con otro tipo de tu programa. También puedes tener parámetros que no son tipos, como un tamaño entero, y plantillas con varios parámetros a la vez.
¿Para qué sirve la programación genérica?
- Evitar duplicación: una sola
ordenarpara todos los tipos comparables, en lugar de una copia por tipo. - Usar la biblioteca estándar:
vector,map,sort,find_ifyaccumulateson plantillas; entenderlas es entender la STL. - Escribir contenedores propios: una matriz, una cola o un buffer funcionan igual con
float,into un struct de tu dominio. - Código sin coste: el compilador conoce el tipo concreto y puede insertar las operaciones directamente, sin indirecciones.
- Bibliotecas de Machine Learning: Eigen declara
Matrix<float, 3, 3>; el tipo y el tamaño forman parte de la plantilla, lo que permite optimizar en compilación.
| Enfoque | Cómo evita repetir | Coste o límite |
|---|---|---|
| Sobrecarga manual | Una función por tipo, todas explícitas | Mucho código y mantenimiento |
Macro o void* | Se escribe una vez | Pierde el tipo: errores tardíos y conversiones inseguras |
| Plantilla | El compilador genera una versión por tipo | Más código máquina y errores de compilación largos |
| Herencia virtual | Una implementación para todos | Indirección en tiempo de ejecución y jerarquías rígidas |
Cómo funciona por dentro: instanciación
El compilador procesa una plantilla en dos momentos. Primero comprueba su sintaxis y sus nombres no dependientes, aunque nadie la use. Después, cuando encuentra una llamada como doble(4), deduce que T es int e instancia la función doble<int>, es decir, genera el código de esa versión concreta.
Ese proceso se llama monomorphization y explica dos cosas. La buena: cada versión está especializada y el optimizador trabaja con tipos conocidos. La mala: si usas la plantilla con veinte tipos, tendrás veinte copias del código; si además la plantilla vive en una cabecera, cada archivo que la usa la vuelve a instanciar.
De ahí una regla práctica: las definiciones de plantillas se escriben en la cabecera (.hpp), no en un .cpp separado. El compilador necesita ver el cuerpo completo en el punto donde deduce T; si solo ve la declaración, compila la llamada pero falla el enlazado con un undefined reference.
Sintaxis: parámetros, typename y especialización
La forma canónica empieza por template y una lista de parámetros entre ángulos. typename T y class T significan exactamente lo mismo aquí; hoy se prefiere typename porque también se usa para tipos que no son clases. El parámetro puede deducirse del argumento o indicarse de forma explícita: menor<double>(3, 4.5).
#include <iostream>
#include <string>
#include <utility>
template <typename T>
T menor(const T& a, const T& b) {
return (a < b) ? a : b;
}
template <typename T>
class Caja {
public:
explicit Caja(T valor) : valor_(std::move(valor)) {}
const T& valor() const { return valor_; }
private:
T valor_;
};
int main() {
std::cout << menor(9, 4) << '\n'; // 4
std::cout << menor(2.5, 7.5) << '\n'; // 2.5
Caja<std::string> c("peso");
std::cout << c.valor() << '\n'; // peso
return 0;
}Dentro de una plantilla, a veces necesitas decirle al compilador que un nombre colgante es un tipo. Si T es un contenedor, T::value_type podría ser un tipo o un miembro estático; el compilador no lo sabe hasta instanciar. La palabra typename resuelve la ambigüedad y es obligatoria en ese contexto.
La especialización total sustituye el molde por una implementación concreta cuando todos los parámetros están fijados. Sirve para optimizar un caso o para cambiar el comportamiento con un tipo específico, sin tocar la versión genérica.
#include <iostream>
#include <string>
template <typename T>
struct Etiqueta {
static std::string texto() { return "tipo generico"; }
};
template <>
struct Etiqueta<bool> {
static std::string texto() { return "booleano"; }
};
template <>
struct Etiqueta<std::string> {
static std::string texto() { return "texto"; }
};
int main() {
std::cout << Etiqueta<int>::texto() << '\n';
std::cout << Etiqueta<bool>::texto() << '\n';
std::cout << Etiqueta<std::string>::texto() << '\n';
return 0;
}Ejemplo básico: una función para muchos tipos
#include <iostream>
#include <string>
template <typename T>
T maximo(const T& a, const T& b) {
return (a < b) ? b : a;
}
int main() {
std::cout << maximo(3, 7) << '\n'; // 7
std::cout << maximo(2.5, 1.5) << '\n'; // 2.5
std::cout << maximo<std::string>("ana", "bea") << '\n'; // bea
return 0;
}template <typename T>abre el molde: todo lo que sigue puede usarTcomo si fuera un tipo conocido.T maximo(const T& a, const T& b)recibe dos referencias constantes y devuelve una copia del mayor. La referencia evita copiar objetos grandes.- En
maximo(3, 7)el compilador deduceT = inty generamaximo<int>; enmaximo(2.5, 1.5)generamaximo<double>. - La llamada con cadenas necesita
maximo<std::string>porque dos literales sonconst char*: sin el tipo explícito se compararían direcciones, no textos.
Ejemplo intermedio: una clase plantilla
Una clase plantilla se instancia igual que una función: cada combinación de parámetros produce un tipo distinto. Caja<int> y Caja<std::string> son clases diferentes para el compilador, con sus propios métodos y su propio sizeof.
#include <iostream>
#include <string>
#include <utility>
template <typename T>
class Caja {
public:
explicit Caja(T valor) : valor_(std::move(valor)) {}
const T& valor() const { return valor_; }
void guardar(T nuevo) { valor_ = std::move(nuevo); }
private:
T valor_;
};
int main() {
Caja<int> entero(42);
Caja<std::string> texto("modelo");
entero.guardar(7);
std::cout << entero.valor() << ' ' << texto.valor() << '\n'; // 7 modelo
Caja deducida(3.5); // C++17: deduce Caja<double>
std::cout << deducida.valor() << '\n';
return 0;
}La última línea usa deducción de argumentos de plantilla del constructor (CTAD): al no escribir <double>, el compilador lo obtiene del argumento 3.5. Funciona cuando hay un constructor que permite deducir todos los parámetros; en clases con varios parámetros puede volverse ambiguo, así que conviene usarla en casos simples.
Caso práctico: variádicas y concepts
Una plantilla variádica acepta un número arbitrario de argumentos. typename... Ts es un paquete de tipos y Ts... un paquete de parámetros; sizeof...(valores) cuenta cuántos hay. La forma clásica de procesarlos es recursiva: separas el primero y pasas el resto.
#include <iostream>
template <typename... Ts>
auto cantidad(const Ts&... valores) {
return sizeof...(valores);
}
template <typename T>
T suma(T valor) {
return valor;
}
template <typename T, typename... Resto>
T suma(T primero, Resto... resto) {
return primero + suma(resto...);
}
int main() {
std::cout << cantidad(1, 2.5, "tres") << '\n'; // 3
std::cout << suma(1, 2, 3, 4) << '\n'; // 10
return 0;
}Los concepts de C++20 permiten declarar qué operaciones debe soportar un tipo antes de instanciar la plantilla. El mensaje de error deja de ser una lista de errores internos y pasa a decir «no se cumplen las restricciones». Además documentan el contrato en la firma.
#include <concepts>
#include <iostream>
template <typename T>
concept Numerico = std::integral<T> || std::floating_point<T>;
template <Numerico T>
T promedio(T a, T b) {
return (a + b) / 2;
}
int main() {
std::cout << promedio(3, 5) << '\n'; // 4
std::cout << promedio(3.0, 4.0) << '\n'; // 3.5
return 0;
}Fíjate en que promedio(3, 5) devuelve 4 y no 4.0: con T = int, la división es entera. La plantilla no cambia las reglas del tipo que le pasas; solo escribe la operación una vez. Si quieres un resultado real, pasa double o diseña la firma con double como tipo de retorno.
Visualización: qué genera el compilador
El código de abajo tiene una sola plantilla, pero produce dos funciones. Avanza por los pasos y observa que la plantilla nunca se ejecuta: lo que se ejecuta son las instancias doble<int> y doble<double>.
#include <iostream>template <typename T>T doble(T x) { return x + x;}int main() { int a = doble(4); double b = doble(1.5); std::cout << a << ' ' << b << '\n';}doble<T>. No genera código todavía porque no conoce T.Sin variables en este paso.
—
Avanza paso a paso: observa la línea resaltada, las variables y la salida.
Errores comunes
- Tipos deducidos en conflicto:
menor(3, 4.5)producededuced conflicting types for parameter 'const T&'. Solución: convertir un argumento o indicarmenor<double>(3, 4.5). - Falta
typename:T::value_type x;en una plantilla daneed 'typename' before 'T::value_type' because 'T' is a dependent scope. Escribetypename T::value_type x;. - Definir la plantilla en un
.cpp: si el cuerpo no está en la cabecera, otras unidades de traducción no pueden instanciarla y el enlazador reportaundefined reference. - Leer solo el primer error: el mensaje real suele estar en la última línea de una cadena larga; busca
required from herepara encontrar tu llamada. - Restricciones implícitas: si la plantilla usa
a + by le pasas un tipo sinoperator+, el error aparece dentro de la plantilla, lejos de tu código. Un concept lo acerca a la llamada. - Abusar de la especialización: varias especializaciones parciales superpuestas hacen que la resolución sea difícil de predecir. Prefiere
if constexpro concepts cuando puedas.
template <typename T>
T menor(const T& a, const T& b);
menor(3, 4.5); // T = int e double a la vezmenor(3.0, 4.5); // T = double
menor<double>(3, 4.5); // conversion explicitaLa deducción exige que todos los usos de `T` lleguen al mismo tipo. No hay promoción automática entre argumentos de una plantilla, porque eso podría ocultar conversiones con pérdida. Indica el tipo o unifica los argumentos.
Buenas prácticas
- Nombra los parámetros con intención:
Tpara un tipo genérico,Iterpara un iterador,Predpara un predicado. - Pasa los argumentos grandes como
const T&y devuelve por valor; el compilador aplicará la elisión de copia. - Restringe con concepts en cuanto la plantilla dependa de una operación concreta:
<std::integral T>es más claro que un error interno. - Define plantillas en cabeceras y marca las funciones cortas como
inlinesi hace falta evitar duplicados al enlazar. - Prefiere una plantilla de función a una clase plantilla cuando no necesites guardar estado.
- No generalices por adelantado: si solo tienes dos tipos y el código es corto, dos sobrecargas se leen mejor.
- Prueba la plantilla con un tipo inesperado (por ejemplo, una cadena y un
double) para descubrir restricciones ocultas.
def doble(x):
return x + x
print(doble(4)) # 8
print(doble("ab")) # ababtemplate <typename T>
T doble(T x) { return x + x; }
std::cout << doble(4); // 8
std::cout << doble(std::string("ab")); // ababPython comprueba en tiempo de ejecución si el objeto soporta `+`; si no, falla al llamar. C++ comprueba al compilar y genera una versión por tipo. El resultado es el mismo, pero en C++ el error llega antes y el coste en ejecución es cero.
Ejercicios
Sumar los elementos de un vector
suma_vector que reciba const std::vector<T>& y devuelva la suma de sus elementos. Pruébala con {1, 2, 3} y con {1.5, 2.0, 2.5}. No uses std::accumulate.Pista
Declara T total{}; para inicializar a cero del tipo correcto y recorre con range-for. El valor de retorno debe ser T.
Un mínimo restringido con concepts
Ordenable que exija a < b convertible a bool y una función minimo restringida a él, que devuelva el menor elemento de un std::vector<T>. Si el vector está vacío, lanza std::invalid_argument. Pruébala con enteros y con cadenas.Pista
Usa requires(const T& a, const T& b) { { a < b } -> std::convertible_to<bool>; }. Recorre desde el segundo elemento comparando con el mínimo actual.
<typename T> T menor(T a, T b) { return (a < b) ? : b; } int main() { std::cout << menor(9, 4); // 4 std::cout << menor(2.5, 7.5); // 2.5 }
La palabra que abre el molde es la misma que aparece en la declaración de una plantilla de clase.
Soluciones explicadas
En el primer ejercicio, la decisión importante es inicializar con T total{} y no con 0. Si el tipo es double, un 0 literal también funcionaría por conversión, pero T{} expresa la intención y evita sorpresas con tipos definidos por el usuario. La plantilla no necesita saber si suma enteros o reales: solo necesita que += exista.
En el segundo, el concept cumple dos funciones. Para el compilador, impide instanciar la plantilla con tipos que no se pueden ordenar. Para quien lee el código, documenta el contrato sin abrir el cuerpo. Fíjate también en que la comprobación de vacío ocurre antes de front(): llamar a front() en un vector vacío es comportamiento indefinido, no una excepción.
Resumen
- Una plantilla es un molde; el compilador genera código para cada tipo con el que se usa.
- La instanciación ocurre al compilar, no al ejecutar: no hay coste de indirección.
typename Tyclass Tson equivalentes en la lista de parámetros;typenametambién marca nombres dependientes.- Las definiciones de plantillas van en cabeceras para que cualquier unidad pueda instanciarlas.
- La especialización total cambia el comportamiento para un tipo concreto.
- Las variádicas aceptan paquetes de argumentos; los concepts restringen y documentan el contrato.
- Si el código solo se usa con dos tipos y es corto, dos sobrecargas pueden ser más claras.
Evaluación
¿Cuándo se genera el código de template <typename T> T doble(T x)?
maximo(3, 4.5) no compila. ¿Cuál es la corrección más directa?
¿Para qué sirve typename en typename T::value_type x;?
¿Ya lo entendiste?
Márcala como completada para llevar tu progreso y desbloquear el siguiente paso de la ruta.