Traits como parámetros: impl Trait, bounds y valores de retorno
Cómo pedir a una función una capacidad en vez de un tipo concreto: la sintaxis impl Trait en los argumentos, su forma explícita con trait bounds genéricos, y devolver impl Trait para ocultar un tipo que no se puede nombrar.
Un trait cobra su verdadero valor cuando lo usas para abstraer una firma. En vez de escribir una función que acepta un Articulo, escribes una que acepta cualquier cosa que sea Resumible. El tipo concreto deja de importar; lo que importa es la capacidad. Rust te da tres formas de expresar esto —impl Trait en el argumento, el trait bound genérico y impl Trait en el retorno—, y detrás de las tres hay una única idea con dos caras opuestas.
- Recibir un parámetro por capacidad con
fn f(x: impl Trait). - Escribir la forma equivalente con un trait bound genérico
<T: Trait>. - Devolver un tipo opaco con
-> impl Traity saber cuándo es imprescindible. - Entender la dualidad: quién elige el tipo en el argumento y quién lo elige en el retorno.
impl Trait en la posición de argumento
La forma más directa de pedir una capacidad es anteponer impl al trait en el tipo del parámetro:
use std::fmt::Display;
fn anunciar(x: impl Display) {
println!(">> {x}");
}
anunciar no acepta un tipo concreto: acepta cualquier valor cuyo tipo implemente Display. La función razona solo con la capacidad —“esto se puede mostrar”— y se olvida de con qué la cumples:
anunciar(42); // el tipo anónimo es i32
anunciar("hola"); // ahora es &str
anunciar(3.14); // ahora f64
Cada llamada puede usar un tipo distinto, y todas son válidas mientras cumplan el bound.
La forma explícita: trait bounds genéricos
impl Trait en el argumento es azúcar sintáctico para un parámetro de tipo genérico con una restricción. Esta función es equivalente a la anterior:
fn anunciar_generico<T: Display>(x: T) {
println!(">> {x}");
}
El <T: Display> se lee “para todo tipo T que implemente Display”. La parte : Display es el trait bound: la restricción que el parámetro debe satisfacer. Ambas formas generan el mismo código, pero la diferencia se vuelve tangible con dos parámetros. Con un único parámetro de tipo, ambos argumentos quedan atados al mismo tipo concreto:
fn par_igual<T: Display>(a: T, b: T) {
println!("{a} y {b}");
}
par_igual(1, 2); // OK: ambos son i32
// par_igual(1, "dos"); // ERROR: T no puede ser i32 y &str a la vez
Con impl Display en los dos parámetros, en cambio, cada uno introduce su propio tipo anónimo y podrían diferir. La genérica es más verbosa, pero tiene dos ventajas que la forma con impl Trait no ofrece:
Ligar dos argumentos al mismo tipo
Con <T: Display>(a: T, b: T) obligas a que a y b sean del mismo tipo. Con (a: impl Display, b: impl Display) pueden ser tipos distintos, porque cada impl Trait introduce un parámetro anónimo independiente.
Nombrar el tipo con turbofish
Un parámetro con nombre T se puede fijar en la llamada con funcion::<u32>(...). Un impl Trait es anónimo: no hay nombre que fijar.
Usa impl Trait cuando la firma es simple y no necesitas referirte al tipo; pasa a la forma <T: ...> en cuanto quieras ligar varios argumentos o mencionar el parámetro.
Devolver impl Trait
impl Trait también sirve en la posición de retorno, y ahí resuelve un problema que de otro modo sería intratable: devolver un valor cuyo tipo no se puede escribir.
fn contador_par(desde: u32) -> impl Iterator<Item = u32> {
(desde..).step_by(2)
}
fn multiplicador(factor: i32) -> impl Fn(i32) -> i32 {
move |x| x * factor
}
El tipo real que devuelve contador_par es algo como StepBy<RangeFrom<u32>>, y el de multiplicador es un closure cuyo nombre el compilador genera y no expone: es inescribible. -> impl Iterator y -> impl Fn(...) te dejan prometer solo la capacidad —“esto itera”, “esto se puede llamar”— y ocultar el tipo concreto. Quien recibe el valor puede iterarlo o llamarlo, pero no conoce ni depende de su tipo exacto.
El caso más frecuente es devolver una cadena de adaptadores de iterador, cuyo tipo se hincha a cada eslabón:
fn pares_al_cuadrado(hasta: u32) -> impl Iterator<Item = u32> {
(0..hasta).filter(|n| n % 2 == 0).map(|n| n * n)
}
El tipo verdadero anida un Map sobre un Filter sobre un Range, con un parámetro por cada closure: prácticamente imposible de escribir a mano y sujeto a cambiar en cuanto añadas un eslabón. -> impl Iterator te libera de nombrarlo; prometes la capacidad y el compilador lleva la cuenta del tipo exacto por ti.
El consumidor, al otro lado, opera solo por la capacidad, sin nombrar tipo alguno:
let cuadrados: Vec<u32> = pares_al_cuadrado(6).take(3).collect(); // [0, 4, 16]
let doblar = multiplicador(2);
assert_eq!(doblar(21), 42); // se invoca como Fn, sin conocer su tipo real
Una función que devuelve -> impl Trait debe devolver siempre el mismo tipo concreto en todas sus ramas. Esto no compila: un if que devuelve un iterador Map en una rama y un Filter en otra, aunque ambos sean Iterator. La firma oculta el tipo al llamador, pero internamente sigue siendo un tipo fijo elegido en compilación. Cuando de verdad necesites devolver tipos distintos según el caso, el camino es el despacho dinámico con Box<dyn Trait>, que verás más adelante: paga una indirección en ejecución a cambio de heterogeneidad.
No confundas -> impl Trait con -> Box<dyn Trait>. El primero es despacho estático: un único tipo concreto, resuelto y especializado en compilación, con coste cero. El segundo es despacho dinámico: una tabla de métodos consultada en ejecución que, a cambio de esa indirección, admite tipos distintos tras la misma firma. Todo este nivel vive en el mundo estático; el dyn y su vtable llegan en un nivel posterior. La regla práctica: usa impl Trait siempre que puedas y reserva dyn Trait para cuando necesites heterogeneidad real.
flowchart LR arg[impl Trait como parametro] --> q1[el llamador elige el tipo concreto] ret[impl Trait como retorno] --> q2[la funcion elige y oculta el tipo concreto] q1 --> mono[monomorfizacion genera una copia especializada por tipo] q2 --> mono mono --> zero[coste cero en ejecucion]
La dualidad: universal frente a existencial
Las dos posiciones de impl Trait parecen la misma sintaxis, pero significan cosas opuestas, y entenderlo separa a quien copia patrones de quien los domina.
Fíjate en quién tiene la libertad de elegir el tipo concreto en cada caso. En la posición de argumento, fn f(x: impl Trait) significa “para todo tipo que cumpla Trait, esta función lo acepta”: la elección es del llamador, y la función debe funcionar sea cual sea el tipo que le pasen. Es una promesa universal, la misma que en lógica escribes con “para todo”. En la posición de retorno, fn g() -> impl Trait significa “existe algún tipo que cumple Trait, y yo lo devuelvo, pero no te diré cuál”: la elección es de la función, que fija un tipo y lo esconde tras la capacidad. Es una promesa existencial, un “existe” de la lógica. Esta dualidad universal/existencial no es una analogía decorativa: es literalmente el contenido de teoría de tipos de impl Trait, y explica todas sus reglas. Explica por qué en el argumento puedes pasar tipos distintos en cada llamada (el “para todo” abarca a todos) mientras que en el retorno todas las ramas deben coincidir en un único tipo (el “existe” fija uno concreto). Explica por qué -> impl Trait es la única forma de devolver un closure: el tipo del closure es inescribible, y un existencial es precisamente “un tipo que existe pero no se nombra”. Y explica por qué todo esto es de coste cero: la resolución ocurre por monomorfización —el compilador estampa una versión especializada de la función por cada tipo concreto que la atraviesa—, de modo que en el binario final no queda ninguna abstracción, ninguna tabla de métodos, ninguna indirección. Un genérico con trait bounds no es una interfaz consultada en ejecución como en Java: es una plantilla que el compilador especializa y luego borra. La abstracción existe solo en tu código fuente; en la máquina se evapora.
fn f(x: impl Trait) recibe cualquier valor con esa capacidad; es azúcar de <T: Trait>, que además permite ligar argumentos al mismo tipo y nombrarlo. -> impl Trait devuelve un tipo opaco, imprescindible para closures e iteradores inescribibles, con la regla de un único tipo concreto por retorno. En el argumento manda el llamador (universal, “para todo”); en el retorno manda la función (existencial, “existe uno oculto”). Todo se resuelve por monomorfización, sin coste en ejecución.
- Escribe
fn describir(x: impl std::fmt::Display)y llámala con un entero, un flotante y un&str. - Reescríbela como
fn describir<T: Display>(x: T)y explica qué caso nuevo habilita ligar dos argumentos al mismoT. - Escribe una función que devuelva
impl Iterator<Item = i32>construida con(0..).map(|x| x * x)y consúmela con untake(5). - Escribe una función que devuelva
impl Fn(i32) -> i32capturando una variable conmove; explica por qué su tipo es inescribible. - Intenta devolver
impl Iteratorcon unifque dé unmapen una rama y unfilteren otra; lee el error y razónalo con la idea de “un único tipo existencial”.