wandres.dev
GENÉRICOS · código para cualquier tipo

Funciones genéricas: parametrizar el código sobre el tipo

Una función genérica como `fn max<T: PartialOrd>(a: T, b: T) -> T` se escribe una vez y sirve para infinitos tipos. El parámetro de tipo, y por qué el trait bound que le exige capacidades no es una restricción, sino un contrato.

⏱ 16 min

Escribiste max para i32. Luego lo necesitas para f64, y copias el cuerpo cambiando un tipo. Después para char, y vuelves a copiar. El algoritmo —comparar dos valores y devolver el mayor— es idéntico; lo único que cambia es el tipo sobre el que opera. Los genéricos son la herramienta de Rust para escribir ese algoritmo una sola vez, abstrayéndolo sobre el tipo. Pero con una condición que revela media filosofía del lenguaje: si tu función va a servir para cualquier tipo, debe declarar por adelantado qué capacidades le exige a ese tipo.

🎯 Al terminar esta lección sabrás
  • Declarar un parámetro de tipo con fn nombre<T> y usarlo en la firma.
  • Entender por qué, sin un bound, no puedes hacer casi nada con un valor de tipo T.
  • Leer un trait bound como la lista de capacidades que la función pide.
  • Guiar la inferencia con el turbofish ::<T> cuando el tipo sea ambiguo.

El mismo algoritmo, un tipo distinto

Sin genéricos, abstraer sobre el tipo es imposible y solo queda copiar:

fn max_i32(a: i32, b: i32) -> i32 { if a > b { a } else { b } }
fn max_f64(a: f64, b: f64) -> f64 { if a > b { a } else { b } }
fn max_char(a: char, b: char) -> char { if a > b { a } else { b } }

Tres funciones con el mismo cuerpo, byte a byte. Cada tipo nuevo que quieras comparar exige otra copia. Es duplicación pura: un bug en la lógica hay que arreglarlo tres veces, la API se hincha sin aportar nada, y ningún tipo definido después de estas funciones podrá usarlas jamás.

📋

Una función por tipo

El cuerpo se repite idéntico. La duplicación crece con cada tipo nuevo y ninguna función futura la reutiliza.

🧬

Una función genérica

El algoritmo se escribe una vez. Vale para todo tipo presente y futuro que cumpla el bound, sin tocar nada.

Un parámetro de tipo: fn max

Un parámetro de tipo es una variable que, en lugar de contener un valor, representa un tipo que se decidirá en cada llamada. Se declara entre ángulos tras el nombre de la función:

fn max<T: PartialOrd>(a: T, b: T) -> T {
    if a > b { a } else { b }
}

Lee la firma con cuidado. El <T> tras max declara el parámetro; a partir de ahí, T es un tipo como cualquier otro dentro de la función. Que a y b sean ambos T obliga a quien llama a pasar dos valores del mismo tipo, y que el retorno sea T promete devolver ese mismo tipo. T es una convención (de Type), pero podrías llamarlo Elem o Clave; lo que importa es la posición, no el nombre.

let a = max(3, 9);            // T = i32, inferido
let b = max(2.5, 1.5);        // T = f64
let c = max('z', 'a');        // T = char
// let malo = max(3, 2.5);    // ERROR: i32 y f64 no son el mismo T

El bound: qué puede hacer T

Aquí está la lección profunda. Prueba a quitar el : PartialOrd:

fn max<T>(a: T, b: T) -> T {
    if a > b { a } else { b }   // ERROR E0369: `>` no está definido para `T`
}

El compilador rechaza el >. ¿Por qué? Porque un T sin restricciones es un tipo totalmente opaco: podría ser i32, un String, un Vec, o un struct que no sabe compararse. Dentro de la función solo puedes hacer con a y b lo que todo tipo posible sabe hacer: moverlos, pasarlos, devolverlos. Comparar con > no está garantizado para todos, así que Rust lo prohíbe de raíz.

El trait bound T: PartialOrd es la solución: le dice al compilador “solo acepto tipos T que implementen PartialOrd”, y a cambio dentro de la función puedes usar >, <, >=. El bound es a la vez una exigencia a quien llama (tu tipo debe saber compararse) y una promesa al cuerpo (aquí dentro, comparar es legal).

ℹ️
El bound viaja en la firma, no en el cuerpo

El compilador verifica cada llamada solo contra la firma: si T: PartialOrd aparece ahí, comprueba que el tipo concreto lo cumpla, sin mirar el cuerpo. Y verifica el cuerpo solo contra los bounds declarados: dentro de max puede usar > porque la firma lo prometió. Esta separación es lo que vuelve componibles los genéricos: cada lado razona contra el contrato, nunca contra la implementación del otro.

El bound que necesitas lo dicta el cuerpo, no tu intuición: cada capacidad que ejerces sobre T se traduce en un bound. Si tu función conserva un valor prestado y a la vez devuelve algo derivado de él, tendrá que clonarlo, y eso exige T: Clone:

// Devuelve una copia y conserva el original prestado: necesita Clone
fn eco<T: Clone>(x: &T) -> T {
    x.clone()
}

La regla es simétrica: pides en la firma exactamente lo que el cuerpo usa, ni más (restringirías de más) ni menos (no compilaría). Y el contrato vale también para tus tipos, no solo para los primitivos. En cuanto un tipo cumple el bound —aquí, derivando PartialOrd—, la función genérica lo acepta como a cualquier i32:

#[derive(PartialEq, PartialOrd)]
struct Version(u32, u32);

let v = max(Version(1, 2), Version(1, 5));   // Version cumple el bound: funciona

Que un tipo satisfaga el contrato sin que la función supiera siquiera de su existencia es lo que separa a los genéricos de una lista cerrada de sobrecargas: el conjunto de tipos válidos queda abierto para siempre.

Por último, una función puede declarar varios parámetros de tipo, independientes entre sí y elegidos por separado en cada llamada:

fn empareja<A, B>(a: A, b: B) -> (A, B) {
    (a, b)
}

let p = empareja(1, "uno");   // A = i32, B = &str

Turbofish e inferencia

Casi siempre, Rust infiere T a partir de los argumentos: en max(3, 9) deduce T = i32 solo. Pero cuando no hay argumentos que lo delaten —típicamente en parse o collect—, debes decírselo tú. La sintaxis es el turbofish ::<T>:

let n = "42".parse::<u32>().unwrap();     // sin turbofish, ¿parsear a qué?
let v = (1..=5).collect::<Vec<i32>>();    // ¿colectar en qué colección?

También puedes fijar el tipo por el lado del destino y dejar que la inferencia trabaje hacia atrás: let n: u32 = "42".parse().unwrap();. Ambas vías son equivalentes; el turbofish es útil cuando no hay un binding con anotación donde apoyarse.

El turbofish desambigua también collect, que del mismo iterador puede producir colecciones distintas:

use std::collections::HashMap;

let pares = [(1, 'a'), (2, 'b')];
let mapa = pares.into_iter().collect::<HashMap<i32, char>>();

Sin el turbofish (o una anotación en mapa), el compilador no sabría si quieres un HashMap, un BTreeMap u otra colección: el tipo destino es información que solo tú tienes.

flowchart TB
call[Llamada max de 3 y 9] --> inf[La inferencia mira los argumentos]
inf --> bind[T queda ligado a i32]
bind --> chk[Se comprueba que i32 cumple PartialOrd]
chk -->|si| ok[Compila]
chk -->|no| err[Error en el punto de llamada]
style bind fill:#cba6f7,color:#11111b
style ok fill:#a6e3a1,color:#11111b
style err fill:#f38ba8,color:#11111b
Un genérico es una afirmación universal con condiciones

fn max<T: PartialOrd> no es “una función que acepta muchos tipos”; es una afirmación lógica: para todo tipo T que sepa compararse, existe esta función. El parámetro de tipo es un cuantificador universal —el “para todo” de la lógica— y el bound es la condición que acota ese universo. Esto explica por qué Rust exige el bound por adelantado en vez de deducirlo del cuerpo, como hacen los templates de C++. En C++, un template comprueba su validez cuando se instancia, y un tipo que no sabe compararse produce un muro de errores incomprensibles apuntando a las tripas de la biblioteca. En Rust, el bound es parte del contrato antes de cualquier instanciación: la firma sola te dice qué exige la función y qué garantiza, el compilador la verifica de forma aislada, y un tipo que no cumple falla en el punto de llamada con un mensaje claro. La diferencia no es cosmética. Significa que puedes razonar sobre una función genérica leyendo únicamente su firma, y que quien escribe un tipo sabe exactamente qué debe implementar para usarla. El bound convierte “para todo tipo” de una promesa vaga en un contrato verificable. Esa es la idea que sostiene todo el sistema de genéricos y traits de Rust.

📝
Lo esencial de la función genérica

Declaras el parámetro con <T> tras el nombre; lo usas en argumentos y retorno como un tipo más. Sin bound, T es opaco y apenas puedes moverlo. El bound T: Trait exige capacidades a quien llama y las concede al cuerpo. Y cuando la inferencia no basta, el turbofish ::<T> fija el tipo a mano.

⚔️ Escribe tu primera abstracción sobre el tipo
  1. Escribe fn menor<T: PartialOrd>(a: T, b: T) -> T que devuelva el menor de dos valores, y pruébala con i32, f64 y char.
  2. Quita el bound : PartialOrd y lee el error E0369 completo: identifica qué operación deja de estar permitida y por qué.
  3. Intenta menor(3, 2.5) y explica, a partir de la firma, por qué mezclar i32 y f64 no compila.
  4. Usa "255".parse::<u8>() y luego "256".parse::<u8>(); observa el turbofish y razona qué devuelve cada uno.
  5. Reescribe la llamada del punto 4 fijando el tipo con una anotación en el binding, sin turbofish, y comprueba que compila igual.