Rust
Tutorial Rust: Slices y referencias parciales
Aprende a usar slices y referencias parciales en Rust para manipular colecciones con seguridad y eficiencia en memoria.
Aprende Rust y certifícateVistas de colecciones
En Rust, las colecciones como strings, arrays y vectores almacenan múltiples valores en una estructura de datos. Sin embargo, a menudo necesitamos trabajar solo con una porción de estos datos sin tomar posesión de ellos. Aquí es donde entran en juego los slices.
Un slice es una vista parcial de una colección que no posee los datos subyacentes. Funciona como una especie de "ventana" que permite acceder y manipular una secuencia contigua de elementos sin necesidad de copiarlos. Esta característica es fundamental en el modelo de ownership de Rust, ya que permite referencias temporales a partes de una colección sin transferir la propiedad.
Anatomía de un slice
Un slice en Rust consiste en dos componentes esenciales:
- Un puntero al primer elemento de la sección
- La longitud de la sección (número de elementos)
Esta estructura permite que Rust mantenga un control preciso sobre qué parte de la colección estamos referenciando:
// Representación conceptual interna de un slice
struct Slice<T> {
ptr: *const T, // Puntero al primer elemento
len: usize, // Número de elementos
}
Creando slices
La sintaxis para crear un slice utiliza corchetes con un rango:
let vector = vec![1, 2, 3, 4, 5];
let slice = &vector[1..4]; // Slice que contiene [2, 3, 4]
Rust ofrece varias formas de especificar los rangos:
let array = [10, 20, 30, 40, 50];
// Diferentes formas de crear slices
let completo = &array[..]; // Todos los elementos: [10, 20, 30, 40, 50]
let desde_inicio = &array[..3]; // Desde el inicio hasta índice 3 (exclusivo): [10, 20, 30]
let hasta_final = &array[2..]; // Desde índice 2 hasta el final: [30, 40, 50]
let porcion = &array[1..4]; // Desde índice 1 hasta 4 (exclusivo): [20, 30, 40]
Es importante notar que los rangos en Rust son generalmente inclusivos en el inicio y exclusivos en el final ([inicio..fin]
incluye inicio
pero excluye fin
).
Slices y seguridad de memoria
Los slices son una herramienta fundamental para la seguridad de memoria en Rust. El compilador verifica estáticamente que:
- Un slice nunca sobreviva a la colección original
- Los índices estén dentro de los límites válidos
- No se modifique la colección mientras existan slices inmutables
Esto previene errores comunes como:
fn main() {
let mut v = vec![1, 2, 3];
let slice = &v[0..2];
// Esto causaría un error de compilación:
// v.push(4); // Error: no se puede modificar mientras existe un préstamo inmutable
println!("Slice: {:?}", slice);
// Ahora podemos modificar v porque slice ya no se usa después
v.push(4);
}
Operaciones con slices
Los slices heredan muchos de los métodos disponibles en las colecciones originales:
let numeros = [10, 20, 30, 40, 50];
let slice = &numeros[1..4];
// Operaciones básicas
println!("Longitud: {}", slice.len()); // 3
println!("Está vacío: {}", slice.is_empty()); // false
println!("Primer elemento: {}", slice[0]); // 20
// Iteración
for numero in slice {
println!("{}", numero); // Imprime 20, 30, 40
}
// Métodos útiles
if let Some(valor) = slice.first() {
println!("Primer valor: {}", valor); // 20
}
if let Some(indice) = slice.iter().position(|&x| x == 30) {
println!("30 está en la posición: {}", indice); // 1
}
Slices mutables
También podemos crear slices mutables que permiten modificar los elementos de la colección original:
let mut array = [1, 2, 3, 4, 5];
let slice_mut = &mut array[1..4];
// Modificamos los elementos a través del slice
slice_mut[0] = 20; // Cambia el segundo elemento del array original
slice_mut[1] = 30; // Cambia el tercer elemento del array original
println!("Array modificado: {:?}", array); // [1, 20, 30, 4, 5]
Las reglas de préstamo de Rust se aplican rigurosamente: no puede haber otros préstamos (mutables o inmutables) mientras existe un slice mutable.
Ventajas de los slices en APIs
Los slices son extremadamente útiles para diseñar APIs flexibles. Permiten que las funciones acepten referencias a porciones de datos sin importar si provienen de un array, un vector o cualquier otra colección:
// Esta función acepta cualquier slice de enteros
fn suma_elementos(numeros: &[i32]) -> i32 {
numeros.iter().sum()
}
fn main() {
let array = [1, 2, 3, 4, 5];
let vector = vec![6, 7, 8, 9, 10];
// Podemos pasar slices de diferentes colecciones
println!("Suma array: {}", suma_elementos(&array[1..4])); // 2+3+4 = 9
println!("Suma vector: {}", suma_elementos(&vector[..])); // 6+7+8+9+10 = 40
// También podemos pasar la colección completa como slice
println!("Suma array completo: {}", suma_elementos(&array)); // 1+2+3+4+5 = 15
}
Esta flexibilidad permite escribir código más genérico y reutilizable, ya que una sola implementación puede trabajar con diferentes tipos de colecciones.
Slices y colecciones dinámicas
Cuando trabajamos con colecciones dinámicas como Vec<T>
, los slices nos permiten compartir acceso a los datos sin transferir la propiedad:
fn procesar_datos(datos: &mut Vec<i32>) {
// Obtenemos un slice de la primera mitad
let primera_mitad = &datos[..datos.len()/2];
// Calculamos la suma de la primera mitad
let suma = primera_mitad.iter().sum::<i32>();
// Modificamos la segunda mitad basándonos en la suma
for elemento in &mut datos[datos.len()/2..] {
*elemento += suma;
}
}
fn main() {
let mut numeros = vec![1, 2, 3, 4, 5, 6];
procesar_datos(&mut numeros);
println!("{:?}", numeros); // [1, 2, 3, 10, 11, 12]
}
Es importante recordar que el compilador de Rust garantiza que los slices nunca sobrevivirán a la colección original, evitando así referencias colgantes. El sistema de lifetimes de Rust (que veremos en la siguiente lección) se encarga de esta verificación.
&str y &[T]
En Rust, dos de los tipos de slice más utilizados son &str
y &[T]
, que representan vistas parciales de strings y colecciones genéricas respectivamente. Estos tipos son fundamentales en el ecosistema de Rust y forman parte del núcleo del lenguaje.
String slices (&str)
El tipo &str
es un slice de string que apunta a una secuencia UTF-8 válida almacenada en algún otro lugar. A diferencia del tipo String
(que posee sus datos y puede modificarlos), &str
es simplemente una referencia inmutable a datos de texto existentes:
let mensaje: String = String::from("Hola, mundo!");
let saludo: &str = &mensaje[0..5]; // "Hola,"
println!("{}", saludo); // Imprime: "Hola,"
El tipo &str
es increíblemente común en Rust por varias razones:
- Es el tipo de los literales de string en Rust:
let literal: &str = "Esto es un literal de string";
- Permite operaciones de vista sin copiar datos:
fn primera_palabra(s: &str) -> &str {
match s.find(' ') {
Some(pos) => &s[0..pos],
None => s,
}
}
let texto = "programación en Rust";
let primera = primera_palabra(texto);
println!("Primera palabra: {}", primera); // "programación"
- Facilita APIs flexibles que pueden aceptar tanto
String
como&str
:
// Esta función acepta cualquier tipo que pueda convertirse a &str
fn saludar(nombre: &str) {
println!("¡Hola, {}!", nombre);
}
let nombre_owned = String::from("Ana");
let nombre_literal = "Carlos";
// Ambas llamadas funcionan sin problemas
saludar(&nombre_owned); // Convertimos String a &str con &
saludar(nombre_literal); // Los literales ya son &str
Slices genéricos (&[T])
El tipo &[T]
representa un slice de cualquier colección que contenga elementos de tipo T
. Funciona de manera similar a &str
, pero para cualquier tipo de datos:
let numeros: Vec<i32> = vec![10, 20, 30, 40, 50];
let slice: &[i32] = &numeros[1..4]; // [20, 30, 40]
println!("{:?}", slice); // Imprime: [20, 30, 40]
Los slices genéricos son extremadamente versátiles:
- Permiten trabajar con porciones de arrays o vectores:
let array = [1, 2, 3, 4, 5];
let vector = vec![6, 7, 8, 9, 10];
// Ambos se convierten al mismo tipo de slice
let slice_array: &[i32] = &array[1..3]; // [2, 3]
let slice_vector: &[i32] = &vector[2..]; // [8, 9, 10]
- Facilitan funciones genéricas que pueden operar sobre cualquier colección:
fn encontrar_maximo(numeros: &[i32]) -> Option<i32> {
if numeros.is_empty() {
return None;
}
let mut max = numeros[0];
for &num in &numeros[1..] {
if num > max {
max = num;
}
}
Some(max)
}
// Funciona con arrays
let array = [3, 1, 5, 2];
println!("Máximo en array: {:?}", encontrar_maximo(&array)); // Some(5)
// Funciona con vectores
let vector = vec![7, 2, 9, 4];
println!("Máximo en vector: {:?}", encontrar_maximo(&vector)); // Some(9)
// Funciona con slices de estas colecciones
println!("Máximo en slice: {:?}", encontrar_maximo(&vector[1..3])); // Some(9)
Seguridad de UTF-8 con &str
Una ventaja crucial de &str
sobre operaciones directas con índices en strings es la seguridad de UTF-8. Los caracteres en UTF-8 pueden ocupar múltiples bytes, lo que hace que el indexado por byte sea peligroso:
let texto = "Привет"; // "Hola" en ruso
// println!("{}", texto[2]); // ¡ERROR! No podemos indexar directamente
// En su lugar, podemos iterar sobre caracteres
for c in texto.chars() {
println!("{}", c);
}
// O crear slices en límites de caracteres válidos
if let Some(primera_letra) = texto.chars().next() {
println!("Primera letra: {}", primera_letra); // П
}
Rust previene errores comunes en otros lenguajes donde la indexación incorrecta puede cortar caracteres UTF-8 por la mitad, causando comportamientos indefinidos o corrupción de datos.
Métodos específicos de &str
El tipo &str
ofrece métodos especializados para manipulación de texto:
let texto = " Rust es increíble ";
// Eliminación de espacios
let recortado = texto.trim();
println!("Recortado: '{}'", recortado); // "Rust es increíble"
// División en palabras
let palabras: Vec<&str> = recortado.split(' ').collect();
println!("Palabras: {:?}", palabras); // ["Rust", "es", "increíble"]
// Reemplazo (crea un nuevo String)
let reemplazado = recortado.replace("increíble", "fantástico");
println!("Reemplazado: {}", reemplazado); // "Rust es fantástico"
// Comprobación de contenido
println!("¿Comienza con 'Rust'? {}", recortado.starts_with("Rust")); // true
println!("¿Contiene 'es'? {}", recortado.contains("es")); // true
Métodos específicos de &[T]
De manera similar, &[T]
proporciona métodos útiles para trabajar con colecciones:
let numeros = [10, 20, 30, 40, 50];
let slice = &numeros[1..4]; // [20, 30, 40]
// Acceso a elementos
println!("Primero: {:?}", slice.first()); // Some(20)
println!("Último: {:?}", slice.last()); // Some(40)
// Partición
let (izquierda, derecha) = slice.split_at(1);
println!("Izquierda: {:?}, Derecha: {:?}", izquierda, derecha); // [20], [30, 40]
// Búsqueda
let posicion = slice.iter().position(|&x| x == 30);
println!("Posición de 30: {:?}", posicion); // Some(1)
// Ordenamiento (solo en slices mutables)
let mut numeros_mut = [3, 1, 5, 2, 4];
let slice_mut = &mut numeros_mut[..];
slice_mut.sort();
println!("Ordenado: {:?}", slice_mut); // [1, 2, 3, 4, 5]
Conversión entre tipos de colección y slices
Rust facilita la conversión fluida entre tipos de colección y sus respectivos slices:
// De String a &str
let string = String::from("Hola");
let str_slice: &str = &string;
// También funciona implícitamente en llamadas a funciones
// De Vec<T> a &[T]
let vector = vec![1, 2, 3];
let slice: &[i32] = &vector;
// De array a &[T]
let array = [4, 5, 6];
let array_slice: &[i32] = &array;
// De &[T] a Vec<T> (crea una copia)
let nuevo_vector: Vec<i32> = slice.to_vec();
// De &str a String (crea una copia)
let nuevo_string: String = str_slice.to_string();
// Alternativa: String::from(str_slice)
Estas conversiones son esenciales para escribir código flexible que pueda trabajar con diferentes tipos de colecciones de manera uniforme.
Slices y el operador de deref coercion
Rust proporciona una característica llamada deref coercion que permite que los tipos que poseen datos (como String
y Vec<T>
) se conviertan automáticamente a sus tipos de slice correspondientes cuando se pasan por referencia:
fn procesar_texto(s: &str) {
println!("Procesando: {}", s);
}
let texto_owned = String::from("Hola mundo");
procesar_texto(&texto_owned); // &String se convierte automáticamente a &str
fn procesar_numeros(nums: &[i32]) {
println!("Suma: {}", nums.iter().sum::<i32>());
}
let vector = vec![1, 2, 3];
procesar_numeros(&vector); // &Vec<i32> se convierte automáticamente a &[i32]
Esta característica hace que las APIs de Rust sean más ergonómicas y consistentes, permitiendo que las funciones acepten tanto tipos que poseen datos como sus vistas correspondientes.
Slice patterns
Los slice patterns son una característica de Rust que permite realizar pattern matching sobre slices, facilitando la descomposición y extracción de elementos de manera elegante y expresiva. Esta funcionalidad amplía el poder del sistema de patrones de Rust, permitiéndonos trabajar con colecciones de forma más declarativa.
En esencia, los slice patterns nos permiten hacer coincidencia de patrones directamente sobre slices, especificando la estructura que esperamos encontrar en ellos. Esto resulta particularmente útil cuando queremos extraer elementos específicos o verificar la forma de un slice en una sola operación.
Sintaxis básica
La sintaxis de los slice patterns utiliza corchetes []
para representar el patrón que queremos hacer coincidir:
let numeros = [1, 2, 3, 4, 5];
match numeros {
[primero, segundo, ..] => {
println!("Primeros dos elementos: {} y {}", primero, segundo);
}
_ => unreachable!(), // Este caso nunca ocurrirá para un array de 5 elementos
}
En este ejemplo, extraemos los dos primeros elementos del array y usamos ..
para indicar que no nos interesan los elementos restantes.
Patrones con resto
El operador ..
(rest pattern) es fundamental en los slice patterns, ya que nos permite hacer coincidir partes de un slice sin necesidad de especificar todos sus elementos:
let valores = [10, 20, 30, 40, 50];
match valores {
// Extraer primer y último elemento
[primero, .., ultimo] => {
println!("Primero: {}, Último: {}", primero, ultimo);
}
_ => unreachable!(),
}
El operador ..
puede aparecer solo una vez en el patrón y puede estar en cualquier posición:
let numeros = [1, 2, 3, 4, 5];
// Diferentes formas de usar el operador de resto
match numeros {
[primero, segundo, ..] => {
// Coincide con los dos primeros elementos
println!("Inicio: {} {}", primero, segundo);
}
_ => unreachable!(),
}
match numeros {
[.., penultimo, ultimo] => {
// Coincide con los dos últimos elementos
println!("Final: {} {}", penultimo, ultimo);
}
_ => unreachable!(),
}
match numeros {
[primero, .., ultimo] => {
// Coincide con el primero y último elemento
println!("Extremos: {} {}", primero, ultimo);
}
_ => unreachable!(),
}
Patrones con longitud específica
Podemos verificar la longitud exacta de un slice especificando todos sus elementos:
fn analizar_slice(slice: &[i32]) {
match slice {
[] => println!("Slice vacío"),
[unico] => println!("Slice con un solo elemento: {}", unico),
[a, b] => println!("Slice con dos elementos: {} y {}", a, b),
[a, b, c] => println!("Slice con tres elementos: {}, {} y {}", a, b, c),
_ => println!("Slice con más de tres elementos"),
}
}
analizar_slice(&[]); // "Slice vacío"
analizar_slice(&[42]); // "Slice con un solo elemento: 42"
analizar_slice(&[1, 2]); // "Slice con dos elementos: 1 y 2"
analizar_slice(&[1, 2, 3]); // "Slice con tres elementos: 1, 2 y 3"
analizar_slice(&[1, 2, 3, 4]); // "Slice con más de tres elementos"
Combinación con otros patrones
Los slice patterns se pueden combinar con otros tipos de patrones para crear coincidencias más complejas:
let datos = [1, 2, 3, 4, 5];
match datos {
// Usando patrones de rango para el primer elemento
[1..=3, segundo, ..] => {
println!("Primer elemento entre 1 y 3, segundo es {}", segundo);
}
// Usando patrones OR para el último elemento
[.., ultimo @ (5 | 10)] => {
println!("Último elemento es {} (5 o 10)", ultimo);
}
_ => println!("No coincide con los patrones anteriores"),
}
También podemos usar patrones anidados para estructuras más complejas:
let matriz = [[1, 2], [3, 4], [5, 6]];
match matriz {
[[a, b], [c, d], ..] => {
println!("Primeros cuatro elementos: {}, {}, {}, {}", a, b, c, d);
}
_ => println!("Matriz con formato diferente"),
}
Uso en declaraciones if let y while let
Los slice patterns no están limitados a las expresiones match
; también funcionan con if let
y while let
:
let numeros = [1, 2, 3, 4, 5];
// Usando if let con slice pattern
if let [primero, segundo, ..] = numeros {
println!("Los dos primeros números son {} y {}", primero, segundo);
}
// Ejemplo con while let (consumiendo un iterador)
let mut iter = numeros.chunks(2);
while let Some([a, b]) = iter.next() {
println!("Par encontrado: {} y {}", a, b);
}
Patrones con subslices
Una característica poderosa es la capacidad de capturar subslices completos usando el operador @
:
let valores = [10, 20, 30, 40, 50];
match valores {
// Capturamos los tres primeros elementos como un subslice
[inicio @ .., 40, 50] => {
println!("Termina con [40, 50], inicio: {:?}", inicio);
}
// Capturamos los tres últimos elementos como un subslice
[10, 20, resto @ ..] => {
println!("Comienza con [10, 20], resto: {:?}", resto);
}
_ => println!("No coincide"),
}
Aplicaciones prácticas
Los slice patterns son especialmente útiles para procesamiento de datos y análisis de secuencias:
fn analizar_tendencia(precios: &[f64]) -> String {
match precios {
[] => "No hay datos suficientes".to_string(),
[_] => "Se necesitan al menos dos puntos de datos".to_string(),
[primero, .., ultimo] if primero < ultimo =>
format!("Tendencia alcista: {:.2} a {:.2}", primero, ultimo),
[primero, .., ultimo] if primero > ultimo =>
format!("Tendencia bajista: {:.2} a {:.2}", primero, ultimo),
_ => "Precio estable".to_string(),
}
}
println!("{}", analizar_tendencia(&[10.5, 11.2, 12.0])); // "Tendencia alcista: 10.50 a 12.00"
println!("{}", analizar_tendencia(&[15.0, 14.2, 13.8])); // "Tendencia bajista: 15.00 a 13.80"
También son excelentes para parsear estructuras de datos con formato específico:
fn interpretar_comando(tokens: &[&str]) -> String {
match tokens {
["ayuda"] => "Comandos disponibles: ayuda, salir, sumar".to_string(),
["salir"] => "Saliendo del programa...".to_string(),
["sumar", numeros @ ..] => {
// Convertimos los strings a números y los sumamos
let suma: i32 = numeros
.iter()
.filter_map(|s| s.parse::<i32>().ok())
.sum();
format!("La suma es: {}", suma)
}
[comando, ..] => format!("Comando desconocido: {}", comando),
[] => "Por favor ingrese un comando".to_string(),
}
}
println!("{}", interpretar_comando(&["ayuda"])); // "Comandos disponibles: ayuda, salir, sumar"
println!("{}", interpretar_comando(&["sumar", "10", "20", "30"])); // "La suma es: 60"
Slice patterns y APIs flexibles
Los slice patterns complementan perfectamente el diseño de APIs flexibles en Rust. Permiten que las funciones procesen datos de manera declarativa, independientemente de si provienen de un array, un vector o cualquier otra colección:
fn procesar_secuencia(datos: &[i32]) -> i32 {
match datos {
[] => 0,
[unico] => *unico,
[primero, segundo] => primero + segundo,
[primero, medio @ .., ultimo] => {
// Suma el primero, el último y el promedio de los elementos del medio
let suma_medio: i32 = medio.iter().sum();
let promedio_medio = if !medio.is_empty() { suma_medio / medio.len() as i32 } else { 0 };
primero + ultimo + promedio_medio
}
}
}
// Funciona con cualquier tipo que pueda convertirse a slice
let array = [1, 2, 3, 4, 5];
let vector = vec![10, 20, 30];
println!("Resultado array: {}", procesar_secuencia(&array)); // 9 (1 + 5 + (2+3+4)/3)
println!("Resultado vector: {}", procesar_secuencia(&vector)); // 50 (10 + 30 + 20)
Consideraciones de rendimiento
Los slice patterns son eficientes porque no realizan copias de los datos. Solo crean referencias a las partes del slice original, manteniendo el enfoque de Rust en el rendimiento y la seguridad de memoria:
let datos = vec![100, 200, 300, 400, 500];
// No se crea ninguna copia de los datos
if let [primero, segundo, resto @ ..] = &datos[..] {
println!("Primero: {}, Segundo: {}", primero, segundo);
println!("Resto: {:?} (longitud: {})", resto, resto.len());
// resto es solo una vista de la parte correspondiente de datos
assert_eq!(resto, &[300, 400, 500]);
// Verificamos que no se haya realizado ninguna copia
assert_eq!(resto.as_ptr().offset(2), &datos[4] as *const i32);
}
Es importante recordar que, como con cualquier slice, el compilador garantiza que los patrones de slice no sobrevivirán a los datos originales, evitando así referencias colgantes. El sistema de lifetimes de Rust se encarga de esta verificación, algo que veremos con más detalle en la siguiente lección.
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 Slices y referencias parciales 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 qué son los slices y cómo funcionan como vistas parciales de colecciones en Rust.
- Aprender a crear y manipular slices inmutables y mutables.
- Entender la seguridad de memoria que proporcionan los slices y cómo Rust la garantiza.
- Conocer los tipos específicos &str y &[T] y sus métodos asociados.
- Aplicar slice patterns para realizar pattern matching sobre slices y diseñar APIs flexibles y eficientes.