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

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.

Lección 4 de 4 del módulo
Al terminar esta lección podrás
  • 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
  1. ¿Qué es una plantilla?
  2. ¿Para qué sirve la programación genérica?
  3. Cómo funciona por dentro: instanciación
  4. Sintaxis: parámetros, typename y especialización
  5. Ejemplo básico: una función para muchos tipos
  6. Ejemplo intermedio: una clase plantilla
  7. Caso práctico: variádicas y concepts
  8. Visualización: qué genera el compilador
  9. Errores comunes
  10. Buenas prácticas
  11. Ejercicios
  12. Soluciones explicadas
  13. Resumen
  14. 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 ordenar para todos los tipos comparables, en lugar de una copia por tipo.
  • Usar la biblioteca estándar: vector, map, sort, find_if y accumulate son plantillas; entenderlas es entender la STL.
  • Escribir contenedores propios: una matriz, una cola o un buffer funcionan igual con float, int o 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.
EnfoqueCómo evita repetirCoste o límite
Sobrecarga manualUna función por tipo, todas explícitasMucho código y mantenimiento
Macro o void*Se escribe una vezPierde el tipo: errores tardíos y conversiones inseguras
PlantillaEl compilador genera una versión por tipoMás código máquina y errores de compilación largos
Herencia virtualUna implementación para todosIndirecció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).

CPP sintaxis.cpp
#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.

CPP especializacion.cpp
#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

CPP maximo.cpp
#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;
}
  1. template <typename T> abre el molde: todo lo que sigue puede usar T como si fuera un tipo conocido.
  2. T maximo(const T& a, const T& b) recibe dos referencias constantes y devuelve una copia del mayor. La referencia evita copiar objetos grandes.
  3. En maximo(3, 7) el compilador deduce T = int y genera maximo<int>; en maximo(2.5, 1.5) genera maximo<double>.
  4. La llamada con cadenas necesita maximo<std::string> porque dos literales son const 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.

CPP caja.cpp
#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.

CPP variadica.cpp
#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.

CPP concepts.cpp
#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>.

Ejecución visual ·Una plantilla, dos instancias
1 / 6
1 #include <iostream>
2
3 template <typename T>
4 T doble(T x) {
5 return x + x;
6 }
7
8 int main() {
9 int a = doble(4);
10 double b = doble(1.5);
11 std::cout << a << ' ' << b << '\n';
12 }
El compilador registra el molde doble<T>. No genera código todavía porque no conoce T.
Variables

Sin variables en este paso.

Salida
—

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

Errores comunes

  • Tipos deducidos en conflicto: menor(3, 4.5) produce deduced conflicting types for parameter 'const T&'. Solución: convertir un argumento o indicar menor<double>(3, 4.5).
  • Falta typename: T::value_type x; en una plantilla da need 'typename' before 'T::value_type' because 'T' is a dependent scope. Escribe typename 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 reporta undefined reference.
  • Leer solo el primer error: el mensaje real suele estar en la última línea de una cadena larga; busca required from here para encontrar tu llamada.
  • Restricciones implícitas: si la plantilla usa a + b y le pasas un tipo sin operator+, 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 constexpr o concepts cuando puedas.
El error clásico de deducción
Incorrecto
PYTHON Incorrecto: dos tipos distintos para el mismo T
template <typename T>
T menor(const T& a, const T& b);

menor(3, 4.5);   // T = int e double a la vez
Correcto
PYTHON Correcto: un solo tipo
menor(3.0, 4.5);          // T = double
menor<double>(3, 4.5);    // conversion explicita

La 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: T para un tipo genérico, Iter para un iterador, Pred para 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 inline si 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.
Genérico en Python y en C++
Python
PYTHON generico.py
def doble(x):
    return x + x

print(doble(4))     # 8
print(doble("ab"))  # abab
C++
CPP generico.cpp
template <typename T>
T doble(T x) { return x + x; }

std::cout << doble(4);                  // 8
std::cout << doble(std::string("ab"));  // abab

Python 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

Ejercicio 1

Sumar los elementos de un vector

Escribe una plantilla 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.

Ejercicio 2

Un mínimo restringido con concepts

Define un concept 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.

Completar código Completa la plantilla
 <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 T y class T son equivalentes en la lista de parámetros; typename tambié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

Evaluación 1

¿Cuándo se genera el código de template <typename T> T doble(T x)?

Evaluación 2

maximo(3, 4.5) no compila. ¿Cuál es la corrección más directa?

Evaluación 3

¿Para qué sirve typename en typename T::value_type x;?

Tus notas

¿Ya lo entendiste?

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