Rust
Tutorial Rust: Generics
Aprende a usar generics en Rust para crear funciones y estructuras genéricas, incluyendo const generics, con ejemplos prácticos y explicación detallada.
Aprende Rust y certifícateFunciones genéricas
Las funciones genéricas son una de las herramientas más potentes que Rust ofrece para escribir código reutilizable. Permiten definir funciones que operan sobre diferentes tipos de datos sin necesidad de duplicar código, manteniendo al mismo tiempo la seguridad y el rendimiento característicos de Rust.
Imagina que necesitas escribir una función que encuentre el valor mayor entre dos elementos. Sin genéricos, tendrías que implementar versiones separadas para enteros, flotantes, cadenas y cualquier otro tipo comparable:
fn mayor_i32(a: i32, b: i32) -> i32 {
if a > b { a } else { b }
}
fn mayor_f64(a: f64, b: f64) -> f64 {
if a > b { a } else { b }
}
// Y así sucesivamente para cada tipo...
Esta duplicación de código es tediosa y propensa a errores. Aquí es donde los genéricos brillan.
Sintaxis básica
Para definir una función genérica en Rust, utilizamos parámetros de tipo entre corchetes angulares <>
después del nombre de la función:
fn mayor<T>(a: T, b: T) -> T {
if a > b { a } else { b }
}
En este ejemplo, T
es un parámetro de tipo que representa cualquier tipo que pueda utilizarse con el operador >
. La letra T
es una convención (por "Type"), pero podríamos usar cualquier identificador válido.
Sin embargo, este código no compilará todavía. Rust necesita saber que T
soporta comparaciones. Esto lo veremos en profundidad en la siguiente lección, pero por ahora podemos añadir una restricción básica:
fn mayor<T: std::cmp::PartialOrd>(a: T, b: T) -> T {
if a > b { a } else { b }
}
Ahora la función aceptará cualquier tipo T
que implemente el trait PartialOrd
, que proporciona el operador de comparación.
Múltiples parámetros de tipo
Una función genérica puede tener múltiples parámetros de tipo, separados por comas:
fn intercambiar<T, U>(a: T, b: U) -> (U, T) {
(b, a)
}
Esta función toma dos valores de tipos potencialmente diferentes y devuelve una tupla con los valores intercambiados.
Inferencia de tipos
Rust puede inferir los tipos genéricos en muchos casos, lo que hace que el código sea más limpio:
fn imprimir<T: std::fmt::Display>(valor: T) {
println!("El valor es: {}", valor);
}
// Uso:
imprimir(42); // T se infiere como i32
imprimir("hola"); // T se infiere como &str
imprimir(3.14159); // T se infiere como f64
El compilador determina automáticamente el tipo concreto de T
basándose en los argumentos proporcionados.
Ejemplo práctico: una función de utilidad
Veamos un ejemplo más práctico. Supongamos que queremos una función que tome un vector de cualquier tipo y devuelva el primer elemento, si existe:
fn primer_elemento<T>(vector: &[T]) -> Option<&T> {
if vector.is_empty() {
None
} else {
Some(&vector[0])
}
}
// Uso:
let numeros = vec![1, 2, 3, 4, 5];
let primero = primer_elemento(&numeros);
println!("Primer número: {:?}", primero); // Imprime: Primer número: Some(1)
let cadenas = vec!["hola", "mundo"];
let primera = primer_elemento(&cadenas);
println!("Primera cadena: {:?}", primera); // Imprime: Primera cadena: Some("hola")
Esta función trabaja con cualquier tipo T
, devolviendo una referencia al primer elemento envuelta en un Option
(que será None
si el vector está vacío).
Monomorphization: el secreto del rendimiento
Una característica fundamental de los genéricos en Rust es la monomorphization. Durante la compilación, Rust genera versiones específicas de cada función genérica para cada tipo concreto con el que se utiliza.
Por ejemplo, si usamos mayor<T>
con i32
y String
, el compilador generará efectivamente:
// Generado internamente por el compilador
fn mayor_i32(a: i32, b: i32) -> i32 {
if a > b { a } else { b }
}
fn mayor_string(a: String, b: String) -> String {
if a > b { a } else { b }
}
Esto significa que los genéricos en Rust no tienen ningún costo en tiempo de ejecución comparado con funciones específicas para cada tipo. Esta es una diferencia importante respecto a otros lenguajes donde los genéricos pueden implicar indirecciones o conversiones de tipo en tiempo de ejecución.
Funciones genéricas en métodos
Los genéricos también pueden utilizarse en métodos de implementación:
struct Punto<T> {
x: T,
y: T,
}
impl<T> Punto<T> {
fn nuevo(x: T, y: T) -> Self {
Punto { x, y }
}
fn get_x(&self) -> &T {
&self.x
}
}
Aquí, nuevo
y get_x
son métodos genéricos que operan sobre la estructura genérica Punto<T>
.
Funciones genéricas con valores de retorno inferidos
Rust puede inferir el tipo de retorno de una función genérica basándose en su implementación:
fn crear_par<T>(valor: T) -> (T, T) {
(valor, valor)
}
En este caso, el tipo de retorno (T, T)
se deduce directamente del parámetro de tipo T
.
Las funciones genéricas son la base de muchas abstracciones en Rust, permitiéndonos escribir código flexible y reutilizable sin sacrificar la seguridad ni el rendimiento. En la siguiente lección, profundizaremos en cómo restringir los genéricos a tipos que implementan comportamientos específicos mediante trait bounds más complejos.
Estructuras genéricas
Las estructuras genéricas en Rust nos permiten definir tipos de datos que pueden contener o trabajar con diferentes tipos sin necesidad de duplicar código. Así como las funciones genéricas nos permiten reutilizar lógica, las estructuras genéricas nos permiten reutilizar diseños de datos.
La sintaxis para definir una estructura genérica utiliza los mismos corchetes angulares <>
que vimos en las funciones genéricas:
struct Contenedor<T> {
contenido: T,
}
En este ejemplo, Contenedor
es una estructura que puede almacenar un valor de cualquier tipo. El parámetro de tipo T
actúa como un marcador de posición que será reemplazado por un tipo concreto cuando se instancie la estructura.
Instanciando estructuras genéricas
Para crear una instancia de una estructura genérica, especificamos el tipo concreto que queremos usar:
// Contenedor que almacena un entero
let entero = Contenedor { contenido: 42 };
// Contenedor que almacena una cadena
let texto = Contenedor { contenido: String::from("Hola Rust") };
// Contenedor que almacena un booleano
let booleano = Contenedor { contenido: true };
En cada caso, Rust infiere el tipo específico de T
basándose en el valor proporcionado. También podemos especificar el tipo explícitamente:
let entero_explicito: Contenedor<i32> = Contenedor { contenido: 42 };
let texto_explicito: Contenedor<String> = Contenedor { contenido: String::from("Hola Rust") };
Múltiples parámetros de tipo
Una estructura puede tener múltiples parámetros de tipo, lo que nos permite crear diseños más flexibles:
struct Par<T, U> {
primero: T,
segundo: U,
}
// Uso con diferentes combinaciones de tipos
let entero_y_texto = Par {
primero: 1,
segundo: String::from("uno"),
};
let booleano_y_flotante = Par {
primero: true,
segundo: 3.14,
};
Esta estructura Par
puede contener dos valores de tipos potencialmente diferentes, lo que la hace muy versátil.
Implementando métodos para estructuras genéricas
Para añadir métodos a una estructura genérica, usamos un bloque impl
con los mismos parámetros de tipo:
struct Caja<T> {
contenido: T,
}
impl<T> Caja<T> {
// Constructor
fn nuevo(contenido: T) -> Self {
Caja { contenido }
}
// Getter
fn obtener(&self) -> &T {
&self.contenido
}
// Setter
fn establecer(&mut self, nuevo_contenido: T) {
self.contenido = nuevo_contenido;
}
// Método que consume la caja y devuelve su contenido
fn extraer(self) -> T {
self.contenido
}
}
Observa cómo declaramos impl<T> Caja<T>
para indicar que estamos implementando métodos para Caja<T>
con cualquier tipo T
.
Implementaciones específicas para ciertos tipos
También podemos implementar métodos que solo están disponibles para instancias de la estructura con tipos específicos:
// Métodos disponibles para cualquier Caja<T>
impl<T> Caja<T> {
fn tipo_generico(&self) {
println!("Soy una caja con contenido genérico");
}
}
// Métodos disponibles solo para Caja<String>
impl Caja<String> {
fn longitud(&self) -> usize {
self.contenido.len()
}
}
En este ejemplo, el método longitud()
solo está disponible para instancias de Caja<String>
, mientras que tipo_generico()
está disponible para cualquier Caja<T>
.
Estructuras genéricas anidadas
Las estructuras genéricas pueden componerse, creando tipos de datos más complejos:
struct Nodo<T> {
valor: T,
siguiente: Option<Box<Nodo<T>>>,
}
Este es un ejemplo de una estructura de lista enlazada simple, donde cada nodo contiene un valor de tipo T
y una referencia opcional al siguiente nodo (que también contiene un valor de tipo T
).
Ejemplo práctico: un contenedor de resultados
Veamos un ejemplo más completo de una estructura genérica que podría ser útil en aplicaciones reales:
enum Resultado<T, E> {
Ok(T),
Error(E),
}
struct Operacion<T, E> {
descripcion: String,
resultado: Resultado<T, E>,
}
impl<T, E> Operacion<T, E> {
fn nuevo(descripcion: String, resultado: Resultado<T, E>) -> Self {
Operacion { descripcion, resultado }
}
fn es_exitosa(&self) -> bool {
match self.resultado {
Resultado::Ok(_) => true,
Resultado::Error(_) => false,
}
}
fn describir(&self) -> String {
match &self.resultado {
Resultado::Ok(_) => format!("Operación '{}': Exitosa", self.descripcion),
Resultado::Error(_) => format!("Operación '{}': Fallida", self.descripcion),
}
}
}
// Uso:
let op1 = Operacion::nuevo(
String::from("Cálculo"),
Resultado::Ok(42)
);
let op2 = Operacion::nuevo(
String::from("Conexión"),
Resultado::Error("Tiempo de espera agotado")
);
println!("{}", op1.describir()); // Imprime: Operación 'Cálculo': Exitosa
println!("{}", op2.describir()); // Imprime: Operación 'Conexión': Fallida
Este ejemplo muestra cómo podemos crear estructuras genéricas que modelan conceptos abstractos como operaciones con resultados variables.
Monomorphization en estructuras genéricas
Al igual que con las funciones genéricas, Rust utiliza monomorphization para las estructuras genéricas. Esto significa que el compilador genera versiones específicas de cada estructura para cada combinación de tipos concretos utilizada en el programa.
Por ejemplo, si usamos Contenedor<i32>
y Contenedor<String>
, el compilador generará internamente algo equivalente a:
// Generado internamente por el compilador
struct Contenedor_i32 {
contenido: i32,
}
struct Contenedor_String {
contenido: String,
}
Esta característica garantiza que no hay penalización de rendimiento por usar genéricos, ya que en tiempo de ejecución, el código es tan eficiente como si hubiéramos escrito manualmente versiones específicas para cada tipo.
Estructuras genéricas en la biblioteca estándar
La biblioteca estándar de Rust hace un uso extensivo de estructuras genéricas. Algunos ejemplos notables incluyen:
Vec<T>
: un vector que puede contener elementos de cualquier tipoOption<T>
: representa un valor opcional que puede serSome(T)
oNone
Result<T, E>
: representa un resultado que puede serOk(T)
oErr(E)
HashMap<K, V>
: un mapa hash que asocia claves de tipoK
con valores de tipoV
Estas estructuras genéricas forman la base de muchas abstracciones en Rust y demuestran el poder y la flexibilidad que ofrecen los genéricos.
Las estructuras genéricas son una herramienta fundamental en Rust para crear abstracciones seguras y eficientes. Nos permiten escribir código reutilizable que funciona con múltiples tipos mientras mantenemos la seguridad de tipos y el rendimiento que caracteriza a Rust.
Const generics
Los const generics representan una evolución significativa en el sistema de tipos de Rust, permitiéndonos parametrizar tipos no solo con otros tipos, sino también con valores constantes. Esta característica, completamente implementada en versiones recientes de Rust, nos permite crear abstracciones más precisas y eficientes para estructuras de datos que dependen de valores numéricos constantes.
Mientras que los genéricos tradicionales nos permiten escribir código que funciona con diferentes tipos, los const generics nos permiten trabajar con diferentes valores constantes. Esto es particularmente útil para estructuras de datos cuyo comportamiento o representación depende de valores numéricos conocidos en tiempo de compilación.
Sintaxis básica
La sintaxis para definir un parámetro genérico constante utiliza la palabra clave const
dentro de los corchetes angulares:
struct Vector<T, const N: usize> {
elementos: [T; N],
}
En este ejemplo, Vector
está parametrizado por un tipo T
y un valor constante N
de tipo usize
. Esto nos permite crear vectores de tamaño fijo conocido en tiempo de compilación:
// Vector de 3 enteros
let v1: Vector<i32, 3> = Vector { elementos: [1, 2, 3] };
// Vector de 5 cadenas
let v2: Vector<&str, 5> = Vector {
elementos: ["uno", "dos", "tres", "cuatro", "cinco"]
};
Implementando métodos con const generics
Podemos implementar métodos para estructuras parametrizadas con const generics de manera similar a los genéricos tradicionales:
impl<T, const N: usize> Vector<T, N> {
fn nuevo(elementos: [T; N]) -> Self {
Vector { elementos }
}
fn tamanio(&self) -> usize {
N
}
fn obtener(&self, indice: usize) -> Option<&T> {
if indice < N {
Some(&self.elementos[indice])
} else {
None
}
}
}
Observa cómo podemos usar el valor constante N
directamente en la implementación, tanto para verificar límites como para devolver el tamaño del vector.
Trabajando con arrays de tamaño genérico
Uno de los casos de uso más comunes para const generics es trabajar con arrays de diferentes tamaños sin perder información de tipo:
fn suma_array<const N: usize>(arr: [i32; N]) -> i32 {
let mut total = 0;
for i in 0..N {
total += arr[i];
}
total
}
// Uso con arrays de diferentes tamaños
let a = [1, 2, 3];
let b = [4, 5, 6, 7, 8];
println!("Suma de a: {}", suma_array(a)); // Suma de a: 6
println!("Suma de b: {}", suma_array(b)); // Suma de b: 30
Antes de const generics, esto habría requerido crear funciones separadas para cada tamaño de array o usar slices (&[i32]
), perdiendo la información sobre el tamaño en tiempo de compilación.
Restricciones en const generics
Podemos aplicar restricciones a los parámetros const generics, similar a cómo aplicamos trait bounds a los parámetros de tipo:
struct Matriz<const FILAS: usize, const COLUMNAS: usize>
where
[(); FILAS * COLUMNAS]: Sized,
{
datos: [[f64; COLUMNAS]; FILAS],
}
En este ejemplo, la restricción [(); FILAS * COLUMNAS]: Sized
asegura que el producto de filas y columnas es un tamaño válido para un array.
Ejemplo práctico: Matrices de dimensiones fijas
Veamos un ejemplo más completo de cómo los const generics pueden ayudarnos a implementar operaciones matemáticas con matrices de tamaño fijo:
#[derive(Debug)]
struct Matriz<const FILAS: usize, const COLUMNAS: usize> {
datos: [[f64; COLUMNAS]; FILAS],
}
impl<const FILAS: usize, const COLUMNAS: usize> Matriz<FILAS, COLUMNAS> {
fn nuevo(datos: [[f64; COLUMNAS]; FILAS]) -> Self {
Matriz { datos }
}
fn obtener(&self, fila: usize, columna: usize) -> Option<f64> {
if fila < FILAS && columna < COLUMNAS {
Some(self.datos[fila][columna])
} else {
None
}
}
fn transpuesta(&self) -> Matriz<COLUMNAS, FILAS> {
let mut resultado = [[0.0; FILAS]; COLUMNAS];
for i in 0..FILAS {
for j in 0..COLUMNAS {
resultado[j][i] = self.datos[i][j];
}
}
Matriz { datos: resultado }
}
}
// Uso:
let m1 = Matriz::nuevo([
[1.0, 2.0, 3.0],
[4.0, 5.0, 6.0]
]);
let m2 = m1.transpuesta();
println!("Matriz original: {:?}", m1);
println!("Matriz transpuesta: {:?}", m2);
Observa cómo la operación de transposición cambia los parámetros constantes en el tipo de retorno: de Matriz<FILAS, COLUMNAS>
a Matriz<COLUMNAS, FILAS>
. Esto garantiza que el tipo refleje con precisión las dimensiones de la matriz resultante.
Ventajas de const generics
Los const generics ofrecen varias ventajas significativas:
- Seguridad de tipos: Las operaciones que dependen de dimensiones o tamaños se verifican en tiempo de compilación.
- Rendimiento: Al conocer los tamaños en tiempo de compilación, el compilador puede generar código más eficiente.
- Expresividad: Podemos modelar con precisión estructuras de datos cuyo comportamiento depende de valores constantes.
- Reutilización de código: Evitamos duplicar código para diferentes tamaños o dimensiones.
Monomorphization con const generics
Al igual que con los genéricos tradicionales, Rust utiliza monomorphization para los const generics. Esto significa que el compilador genera versiones específicas para cada combinación de tipos y valores constantes utilizados.
Por ejemplo, si usamos Vector<i32, 3>
y Vector<i32, 5>
, el compilador generará internamente algo equivalente a:
// Generado internamente por el compilador
struct Vector_i32_3 {
elementos: [i32; 3],
}
struct Vector_i32_5 {
elementos: [i32; 5],
}
Esta característica garantiza que no hay penalización de rendimiento por usar const generics, manteniendo el principio de Rust de abstracciones de costo cero.
Aplicaciones prácticas
Los const generics son especialmente útiles en varios escenarios:
- Estructuras de datos de tamaño fijo: Arrays, matrices, buffers circulares.
- Algoritmos numéricos: Operaciones con vectores y matrices de dimensiones conocidas.
- Sistemas embebidos: Donde conocer los tamaños exactos en tiempo de compilación es crucial.
- Procesamiento de señales: Filtros y transformaciones con tamaños de ventana fijos.
// Buffer circular de tamaño fijo
struct BufferCircular<T, const N: usize> {
datos: [T; N],
posicion: usize,
}
impl<T: Default + Copy, const N: usize> BufferCircular<T, N> {
fn nuevo() -> Self {
BufferCircular {
datos: [T::default(); N],
posicion: 0,
}
}
fn agregar(&mut self, valor: T) {
self.datos[self.posicion] = valor;
self.posicion = (self.posicion + 1) % N;
}
fn ultimo(&self) -> &T {
let indice = if self.posicion == 0 { N - 1 } else { self.posicion - 1 };
&self.datos[indice]
}
}
Este ejemplo muestra cómo implementar un buffer circular de tamaño fijo utilizando const generics, donde el tamaño del buffer se conoce en tiempo de compilación.
Los const generics amplían significativamente las capacidades del sistema de tipos de Rust, permitiéndonos crear abstracciones más precisas y eficientes. En la siguiente lección, veremos cómo combinar genéricos con traits para crear abstracciones aún más poderosas y flexibles.
Otras lecciones de Rust
Accede a todas las lecciones de Rust y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.
Introducción A Rust
Introducción Y Entorno
Primer Programa
Introducción Y Entorno
Instalación Del Entorno
Introducción Y Entorno
Funciones
Sintaxis
Operadores
Sintaxis
Estructuras De Control Condicional
Sintaxis
Arrays Y Strings
Sintaxis
Manejo De Errores Panic
Sintaxis
Variables Y Tipos Básicos
Sintaxis
Estructuras De Control Iterativo
Sintaxis
Colecciones Estándar
Estructuras De Datos
Option Y Result
Estructuras De Datos
Pattern Matching
Estructuras De Datos
Estructuras (Structs)
Estructuras De Datos
Enumeraciones Enums
Estructuras De Datos
El Concepto De Ownership
Ownership
Lifetimes Básicos
Ownership
Slices Y Referencias Parciales
Ownership
References Y Borrowing
Ownership
Funciones Anónimas Closures
Abstracción
Traits De La Biblioteca Estándar
Abstracción
Traits
Abstracción
Generics
Abstracción
Channels Y Paso De Mensajes
Concurrencia
Memoria Compartida Segura
Concurrencia
Threads Y Sincronización Básica
Concurrencia
Introducción A Tokio
Asincronía
Fundamentos Asíncronos Y Futures
Asincronía
Async/await
Asincronía
Ejercicios de programación de Rust
Evalúa tus conocimientos de esta lección Generics con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.
En esta lección
Objetivos de aprendizaje de esta lección
- Comprender la sintaxis y uso de funciones genéricas en Rust.
- Aprender a definir y utilizar estructuras genéricas con múltiples parámetros de tipo.
- Entender el concepto de monomorphization y su impacto en el rendimiento.
- Explorar el uso de const generics para parametrizar tipos con valores constantes.
- Aplicar genéricos y const generics en ejemplos prácticos y casos reales.