14 Vectores
Hasta ahora hemos trabajado con tipos cuyo tamaño es conocido en tiempo de compilación: enteros, números en coma flotante, valores booleanos, caracteres, tuplas, struct y arrays. En el caso de los arrays, además, el número de elementos debe fijarse al definir la variable.
En muchos programas esta restricción resulta poco realista. Si se reserva espacio para pocos elementos, puede no ser suficiente durante la ejecución. Si se reserva para demasiados, se desperdicia memoria.
Para resolver este problema, la biblioteca estándar de Rust proporciona varias colecciones. Una colección es un tipo capaz de almacenar un número variable de elementos, reservando memoria dinámicamente cuando es necesario.
En estos apuntes estudiaremos las tres colecciones más utilizadas:
Vec<T>, que almacena una secuencia ordenada de elementos del mismo tipo. Les dedicaremos el presente capítulo.String, utilizada para almacenar y manipular cadenas de texto UTF-8 de longitud variable. Las trataremos en el Capítulo 15.HashMap<K, V>, que almacena pares clave-valor y permite acceder rápidamente a un valor a partir de su clave. Las trataremos en el Capítulo 16
Las colecciones forman parte de la biblioteca estándar. Basta con utilizarlas directamente o, en el caso de HashMap, importar el tipo desde std::collections.
14.1 Introducción a los vectores (Vec<T>)
En muchos lenguajes de programación, como Pascal, C++, Java, C# o MATLAB, el término vector se utiliza con frecuencia, de manera formal o informal, como sinónimo de array. En Rust, sin embargo, ambos conceptos son distintos.
Como vimos en la Sección 6.3, un array es una secuencia de elementos homogéneos, con tamaño prefijado en tiempo de compilación, no puede cambiar durante la ejecución.
Un vector (
Vec<T>) es una colección que almacena una secuencia de elementos homogéneos. La diferencia es que su longitud puede variar durante la ejecución: es posible añadir o eliminar elementos según sea necesario.
El tipo de un vector se escribe como Vec<T>, donde T representa el tipo de los elementos almacenados. Por ejemplo, Vec<i32> es un vector de enteros, mientras que Vec<String> es un vector de cadenas de texto.
Los vectores son probablemente la colección más utilizada en Rust. La biblioteca estándar proporciona numerosos métodos para crearlos, recorrerlos, modificarlos y consultar su contenido.
14.2 Uso básico de vectores
El siguiente ejemplo muestra las operaciones más habituales con un vector: crearlo, añadir elementos y acceder a ellos mediante un índice.
fn main() {
let mut numeros = Vec::new();
numeros.push(10);
numeros.push(20);
numeros.push(30);
println!("Número de elementos: {}", numeros.len());
println!("Primer elemento: {}", numeros[0]);
println!("Segundo elemento: {}", numeros[1]);
println!("{:?}", numeros);
}
En este ejemplo:
Se crea un vector vacío mediante
Vec::new(). Como el vector va a modificarse posteriormente, se declara conmut.Los elementos se añaden mediante el método
push, que incorpora cada nuevo valor al final del vector. Todos los elementos deben ser del mismo tipo; en este caso, el compilador deduce que se trata de unVec<i32>.Los elementos pueden accederse mediante un índice, comenzando por el cero, igual que en un array.
Veamos otro programa sencillo:
fn main() {
let numeros = vec![10, 20, 30, 40];
println!("Número de elementos: {}", numeros.len());
for n in &numeros { // Recorremos los elementos, sin índice
println!("{}", n);
}
for i in 0..numeros.len() { // Ahora usando el índice
println!("Elemento {}: {}", i, numeros[i]);
}
}
Observaciones:
Cuando los valores del vector son conocidos en tiempo de compilación, puede resultar más cómodo utilizar la macro
vec!(en minúsculas), que crea e inicializa el vector con los valores proporcionados, entre corchetes y separados por comas. El compilador deduce automáticamente el tipo de los elementos, en este caso,Vec<i32>(en mayúsculas).En la Sección 8.2.3 vimos que hay dos formas de recorrer un array: usando el índice o sin usarlo. Con los vectores sucede lo mismo.
Lo más sencillo es recorrer todos los elementos con un bucle
for n in &numerosHemos añadido el
¶ que el bucle itere sobre una referencia anumeros. De lo contrario, elforconsumiría el vector y no podría volver a usarse.Si necesitamos conocer el índice, usaremos el operador
.., que devuelve un rango. El primer operando es el límite inferior, el segundo el límite superior. El límite inferior está incluido, el superior, no. Esta es la forma típica de usarlo:for i in 0..numeros.len()El método
len()devuelve el número de elementos almacenados en el vector. En este caso, 4. Por tanto el rango irá desde 0, incluido, hasta 4, excluido. Esto es, desde 0 hasta 3.
14.3 El operador [] con índices fuera de rango
En lenguajes como C o C++, acceder a un elemento situado fuera de los límites de un array constituye un error especialmente peligroso. El lenguaje no realiza ninguna comprobación automática y el programa puede terminar leyendo o escribiendo memoria que no le pertenece. Las consecuencias van desde resultados incorrectos y fallos intermitentes hasta vulnerabilidades de seguridad que pueden ser aprovechadas por un atacante.
Durante décadas, este tipo de errores ha sido una de las principales causas de fallos de software. Diversos estudios de la industria atribuyen a los errores relacionados con la gestión de memoria una parte muy importante de las vulnerabilidades de seguridad descubiertas cada año, con un coste económico global que asciende a miles de millones de dólares entre pérdidas, interrupciones del servicio y tareas de mantenimiento.
Uno de los principales objetivos de diseño de Rust es minimizar este tipo de problemas. Como hemos visto en la Sección 14.2, Rust permite acceder a los elementos de un vector mediante el operador []. Si el índice está fuera de rango, el programa provoca un panic, muestra un mensaje de diagnóstico y finaliza inmediatamente. De este modo, se evita que el programa continúe ejecutándose tras acceder a memoria que no le pertenece, como puede ocurrir en lenguajes como C o C++.
fn main() {
let protocolos = vec!["tcp", "ip"];
println!("{}", protocolos[0]); // tcp
println!("{}", protocolos[1]); // ip
println!("{}", protocolos[2]); // ¡Error!
}El programa mostrará los dos primeros elementos del vector y, al intentar acceder al tercero, finalizará con un panic.
14.4 El método get()
Además del operador [], Rust también permite acceder a los elementos de un vector mediante el método get(), que devuelve un valor de tipo Option (que estudiamos en la Sección 12.4). Si el índice es válido, el resultado es Some(&T); en caso contrario, None. Esto permite al programa detectar y tratar explícitamente la situación sin necesidad de finalizar su ejecución.
El siguiente programa intenta acceder al tercer elemento del vector. Como no existe, get() devuelve None, permitiendo que el programa trate esta situación sin finalizar su ejecución:
fn main() {
let protocolos = vec!["tcp", "ip"];
let indice = 2;
match protocolos.get(indice) {
Some(nombre) => println!("Protocolo: {}", nombre),
None => println!("Índice fuera de rango"),
}
}El método get() devuelve un valor de tipo Option<&str>. La sentencia match distingue los dos casos posibles:
Si el índice es válido,
get()devuelveSome(nombre), dondenombrees una referencia al elemento del vector.Si el índice está fuera de rango, devuelve
None, permitiendo que el programa continúe ejecutándose y decida cómo actuar.
En general, el operador [] resulta adecuado cuando sabemos con certeza que el índice es válido. En cambio, get() es preferible cuando el índice procede de datos externos o existe la posibilidad de que esté fuera de rango.
14.5 Modificación de elementos
Los elementos de un vector pueden modificarse mediante el operador [], siempre que el vector haya sido declarado como mutable.
Como en cualquier asignación en Rust, el compilador comprobará que el nuevo valor tenga el tipo de los elementos del vector.
Naturalmente, este acceso mediante
[]está sujeto a las mismas comprobaciones que al leer un elemento. Si el índice está fuera de rango, el programa provocará un panic y finalizará inmediatamente, tal y como vimos en la Sección 14.3 .
En este ejemplo, el vector contiene cadenas de texto (&str). El programa sustituye el primer elemento por un nuevo valor:
fn main() {
let mut protocolos = vec!["tcp", "ip"];
protocolos[0] = "udp";
println!("{:?}", protocolos);
}14.6 Operaciones habituales
La biblioteca estándar proporciona numerosos métodos para manipular vectores. La Tabla 14.1 tabla resume algunos de los más utilizados:
| Método | Descripción |
|---|---|
push(valor) |
Añade un elemento al final del vector. |
pop() |
Elimina y devuelve el último elemento del vector. |
len() |
Devuelve el número de elementos almacenados. |
is_empty() |
Indica si el vector está vacío. |
clear() |
Elimina todos los elementos del vector. |
insert(pos, valor) |
Inserta un elemento en la posición indicada. |
remove(pos) |
Elimina el elemento situado en la posición indicada. |
contains(&valor) |
Indica si el vector contiene un determinado valor. |
El siguiente programa muestra el uso de todos estos métodos:
fn main() {
let mut protocolos = vec!["HTTP", "HTTPS", "FTP"];
protocolos.push("SSH");
println!("{:?}", protocolos);
protocolos.insert(1, "SMTP");
println!("{:?}", protocolos);
protocolos.remove(2);
println!("{:?}", protocolos);
println!("Número de protocolos: {}", protocolos.len());
println!("¿Contiene HTTPS? {}", protocolos.contains(&"HTTPS"));
let ultimo = protocolos.pop();
println!("Extraemos {:?}", ultimo);
println!("Queda {:?}", protocolos);
protocolos.clear();
println!("¿Vector vacío? {}", protocolos.is_empty());
} Obsérvese que pop() tiene dos efectos:
- Devuelve el último elemento del vector. En este caso un
Optioncon el valorSome("SSH"). - Elimina este elemento del vector.
En la práctica, lo habitual es procesar el valor devuelto mediante una sentencia match, distinguiendo los casos Some y None, como muestra el siguiente ejemplo:
fn main() {
let mut direcciones = vec!["192.168.1.1"];
println!("Direcciones: {:?}", direcciones);
match direcciones.pop() {
Some(d) => println!("Extraemos: {}", d),
None => println!("No quedan direcciones que extraer"),
}
println!("Direcciones: {:?}", direcciones);
match direcciones.pop() {
Some(d) => println!("Extraemos: {}", d),
None => println!("No quedan direcciones que extraer"),
}
println!("Direcciones: {:?}", direcciones);
}La documentación oficial de Rust describe muchos otros métodos para ordenar, buscar, dividir o transformar vectores. Puede consultarse en:
14.7 Resumen
En este capítulo hemos introducido el tipo Vec<T>, la colección dinámica más utilizada en Rust.
Las ideas principales son las siguientes:
- Un vector almacena una secuencia de elementos del mismo tipo y puede aumentar o disminuir de tamaño durante la ejecución del programa.
- Los vectores se crean habitualmente mediante
Vec::new()o la macrovec!. - Los elementos pueden añadirse y eliminarse con métodos como
push()ypop(). - El acceso mediante el operador
[]es directo, pero provoca unpanic!si el índice está fuera de rango. - El método
get()ofrece una alternativa segura al devolver un valor de tipoOption<&T>. - Los vectores pueden recorrerse de forma sencilla mediante bucles
for, tanto por referencia como por referencia mutable. - La biblioteca estándar proporciona numerosos métodos para buscar, modificar y transformar el contenido de un vector.
Los vectores son una de las estructuras de datos más importantes de Rust y aparecen en una gran cantidad de programas. En el siguiente capítulo estudiaremos el tipo String, que internamente utiliza un Vec<u8> para almacenar sus caracteres codificados en UTF-8.