Slices: referencias con longitud a una porción
Un slice es una ventana a una parte contigua de un Vec o un array: una referencia gorda que lleva puntero y longitud. Qué es un slice de tipo referencia a corte de T, cómo funciona la aritmética de rangos semiabiertos, y por qué son referencias con longitud y no un tipo aparte.
Un &str no era más que un caso particular de una idea más general y poderosa: el slice. Un slice es una ventana a una porción contigua de una secuencia —un Vec, un array, otro slice— que no la posee, solo la mira. Es una referencia con una peculiaridad: además del puntero, lleva grabada una longitud. Comprender los slices desbloquea de golpe media biblioteca estándar, porque &[T] es el tipo con el que Rust dice “dame una secuencia de lectura y no me importa quién la posea”.
- Definir un slice
&[T]como vista a una porción contigua de unVeco array. - Dominar la aritmética de rangos semiabiertos y sus cuatro formas.
- Entender por qué
[T]ystrno tienen tamaño y por eso viven tras una referencia. - Ver cómo un parámetro
&[T]desacopla la función del contenedor concreto.
Una ventana a una porción contigua
Un slice se construye indizando con un rango en lugar de con un índice suelto. &v[1..4] no extrae elementos ni copia nada: fabrica una vista a los elementos 1, 2 y 3 del original.
fn main() {
let numeros = vec![10, 20, 30, 40, 50];
let ventana: &[i32] = &numeros[1..4]; // vista a [20, 30, 40], sin copiar
println!("{ventana:?}"); // [20, 30, 40]
println!("mide {} elementos", ventana.len());
} // `numeros` sigue siendo el único dueño; `ventana` solo miraba
Igual que un &str es un puntero gordo de dos palabras, un &[i32] lo es: puntero al primer elemento de la porción y número de elementos. No hay capacidad porque no posee el buffer; no puede crecer ni encoger el original. Y como toda referencia, el borrow checker garantiza que la ventana nunca sobreviva al Vec del que sale: un slice no puede colgar.
La conexión con la lección anterior es literal: str es a [u8] lo que un slice cualquiera es a su elemento, con una garantía extra. Un &str es esencialmente un &[u8] cuyos bytes se prometen UTF-8 válido. Por eso todo lo que aprendas aquí sobre &[T] —rangos, longitud, puntero gordo, no-propiedad— vale palabra por palabra para las cadenas. &str no era un tipo especial: era un slice con un invariante.
Un slice también puede ser mutable. Un &mut [T] es una vista que deja modificar los elementos en su sitio —reordenarlos, reemplazarlos— pero nunca cambiar cuántos hay: no puede crecer ni encoger, porque el número de elementos es potestad del dueño. Con él, operaciones como ordenar o intercambiar trabajan sobre la porción sin copiarla a ningún lado.
fn main() {
let mut v = vec![30, 10, 20, 50, 40];
let medio: &mut [i32] = &mut v[1..4]; // vista mutable de [10, 20, 50]
medio.sort(); // ordena EN EL SITIO: [10, 20, 50]
medio.swap(0, 2); // intercambia extremos: [50, 20, 10]
println!("{v:?}"); // [30, 50, 20, 10, 40]
}
Aritmética de rangos: intervalos semiabiertos
Los rangos de Rust son semiabiertos: a..b incluye a y excluye b. Esta convención no es capricho: hace que la longitud del slice sea exactamente b - a, que dos rangos contiguos a..b y b..c no se solapen ni dejen hueco, y que 0..len cubra la colección entera sin un +1 ni un -1 a la vista. Es la misma economía por la que se cuenta desde cero.
let v = [0, 1, 2, 3, 4];
let a = &v[1..4]; // [1, 2, 3] longitud 4 - 1 = 3
let b = &v[..3]; // [0, 1, 2] desde el principio
let c = &v[2..]; // [2, 3, 4] hasta el final
let d = &v[..]; // [0, 1, 2, 3, 4] la colección entera como slice
Omitir el extremo izquierdo asume 0; omitir el derecho asume len. El índice final puede ser igual a la longitud —&v[5..5] es un slice vacío perfectamente válido— pero no mayor: pedir &v[..6] sobre cinco elementos es un panic en ejecución con “range end index 6 out of range for slice of length 5”. Aquí Rust elige la seguridad: en C, v + 6 sería aritmética de punteros silenciosa hacia memoria ajena; en Rust, un corte fuera de límites se detecta y detiene el programa en vez de corromperlo.
flowchart LR subgraph Vec numeros e0[10] --- e1[20] --- e2[30] --- e3[40] --- e4[50] end slice[slice de indices 1..4] -->|ptr| e1 slice -->|len igual a 3| e3
Muchas operaciones frecuentes evitan escribir el rango a mano y dejan la aritmética al método, más legible y sin riesgo de un índice mal calculado:
let v = [10, 20, 30, 40, 50];
let (izq, der) = v.split_at(2); // (&[10, 20], &[30, 40, 50]) en un solo corte
let primero = v.first(); // Option<&i32>: Some(&10)
let ultimo = v.last(); // Option<&i32>: Some(&50)
for _par in v.windows(2) {} // ventanas solapadas de 2: [10,20],[20,30]...
for _trozo in v.chunks(2) {} // trozos disjuntos de 2: [10,20],[30,40],[50]
Por qué son referencias con longitud
Aquí está la razón profunda de que un slice sea siempre &[T] y nunca un [T] suelto. El tipo [T] es un tipo sin tamaño (dynamically sized type, DST): su tamaño no se conoce en compilación, porque un [i32] podría tener tres elementos o tres millones. Y el compilador necesita tamaños fijos para colocar valores en el stack, pasarlos por registro o calcular offsets. Por eso no puedes tener una variable de tipo [i32]: no cabe la pregunta “¿cuánto ocupa?”.
La solución es acceder al DST detrás de un puntero que lleva el tamaño consigo. Ahí está la clave del puntero gordo: &[T] mide dos palabras precisamente porque la segunda —la longitud— es la información de tamaño que a [T] le faltaba. El puntero delgado dice dónde; la longitud dice cuánto. Juntos reconstruyen, en ejecución, lo que el tipo por sí solo no podía fijar en compilación.
// let mal: [i32] = ...; // ERROR E0277: [i32] no tiene tamaño conocido en compilación
let bien: &[i32] = &[1, 2, 3]; // OK: la referencia gorda añade la longitud que faltaba
Lo mismo ocurre con str frente a &str: str es un DST (una secuencia de bytes UTF-8 de longitud desconocida) y solo lo manejas tras una referencia que carga su longitud. Cuando en la lección anterior dijimos que &str mide dos palabras, esta es la explicación de fondo: la segunda palabra existe porque str, por sí mismo, no tiene tamaño.
Un slice desacopla la función del contenedor
La utilidad práctica del slice es que funciona con cualquier secuencia contigua. Un array [i32; 3], un Vec<i32> y otro &[i32] se convierten todos a &[i32] sin coste. Una función que pide &[T] sirve a los tres de una vez, exactamente como &str servía a literales y a String.
fn suma(datos: &[i32]) -> i32 { // acepta cualquier secuencia contigua de i32
datos.iter().sum()
}
fn main() {
let array = [1, 2, 3];
let vector = vec![4, 5, 6];
println!("{}", suma(&array)); // [i32; 3] -> &[i32]
println!("{}", suma(&vector)); // Vec<i32> -> &[i32] por coerción
println!("{}", suma(&vector[1..])); // un sub-slice también encaja
}
Escribir fn suma(datos: &Vec<i32>) sería el error simétrico al &String de la lección anterior: rechazaría arrays y sub-slices sin ganar nada. La firma idiomática pide siempre la vista más general, &[T], y deja que la coerción haga el resto.
El patrón se extiende a los valores de retorno. Métodos como trim, split o strip_prefix no copian ni reservan: devuelven &str que apuntan dentro del original, sub-slices cuya vida el compilador ata a la del dato de entrada. Rebanar en Rust es, casi siempre, gratis: mueves punteros y longitudes, nunca bytes.
let linea = " clave=valor ";
let limpia = linea.trim(); // &str hacia el interior de `linea`, sin copiar
let (k, v) = limpia.split_once('=').unwrap(); // dos &str, ambos sub-slices de `linea`
println!("[{k}] [{v}]"); // [clave] [valor]
Detente en lo que acabas de ganar. En C, cuando una función necesita leer un array, recibe dos argumentos sueltos: un puntero y una longitud, void procesa(int* datos, size_t n). Nada en el lenguaje ata ese puntero a esa longitud, así que nada impide pasar la longitud equivocada y leer más allá del final: es la fuente de una legión de desbordamientos de búfer, entre ellos algunos de los CVE más caros de la historia. El slice de Rust toma esa pareja (puntero, longitud) —la convención informal de C— y la cosifica en un solo tipo cuyas dos mitades no se pueden separar ni desincronizar, y sobre el cual el borrow checker demuestra además que apunta a memoria viva del dueño correcto durante todo su lifetime. De un plumazo desaparecen tres clases enteras de bug: el puntero sin su longitud, la longitud mentida, y el puntero que sobrevive a su buffer. Y el coste en ejecución es exactamente el mismo que en C —dos palabras, sin indirección extra, sin comprobaciones salvo en el corte—, porque toda la seguridad se paga en compilación. Por eso &[T] es mucho más que un detalle de sintaxis: es la respuesta de Rust a “¿cómo se lee una secuencia sin poseerla y sin abrir un agujero de memoria?”, y esa respuesta —empaquetar puntero y longitud en un tipo con lifetime verificado— es una de las ideas que justifican la existencia entera del lenguaje.
- Toma un
Vecde cinco elementos y produce, con rangos, un slice de los tres centrales; verifica su longitud. - Explica por qué
&v[5..5]sobre cinco elementos es válido y vacío, pero&v[..6]hacepanic. - Intenta declarar
let x: [i32];y lee el error E0277; luego arréglalo con&[i32]y explica qué palabra añadiste. - Escribe
fn maximo(datos: &[i32]) -> Option<i32>y llámala con un array, unVecy un sub-slice. - Razona en una frase por qué
&stres un caso particular de&[u8]y qué le añade.