En esta introducción a Rust vamos a ver los rasgos principales del lenguaje.
Entorno
Esta introducción está escrita en la versión 1.41.1 de rustc en MacOS Catalina 10.15.3.
¿Qué es Rust?
Rust es un lenguaje que promete excelente rendimiento gracias a su bajo nivel, sin sacrificar en ergonomía o en fiabilidad.
Sus objetivos de diseño son:
- Concurrencia segura por defecto.
- Acceso seguro de memoria (sin punteros nulos o colgantes).
- Eficiencia sin recolector de basura.
Recursos para aprender Rust
Los mejores recursos para aprender Rust son los dos libros oficiales y los ejercicios prácticos:
- The Rust Programming Language: El libro introductorio de Rust. Explica de manera clara y lógica las características del lenguaje.
- Rust by Example: Explica las mismas características de manera más práctica.
- Ejercicios «Rustlings»: Ejercicios para asentar los conocimientos.
Yo recomiendo empezar por el primer libro The Rust Programming Language. Explica de principio a fin todo lo que tiene que ver con el lenguaje.
Instalación
En Linux o MacOs:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
En Windows:
Instalar rustup-init.exe.
Existen otros métodos de instalación en la guía de instalación.
Para comprobar que la instalación ha funcionado, revisar la versión con:
rustc --version
Si tienes problemas en MacOS, prueba a ejecutar este comando:
xcode-select --install
Plugin de Rust para IDE
Para facilitar el trabajo en Rust, existen plugins para los IDE más usados.
- Extensión Rust (rls) para VSCode
- Rust Plugin para IntelliJ IDEA.
He probado ambas y he tenido mejor resultado con InteliJ IDEA. Pero ninguna es infalible y no es capaz de autocompletar en todos los casos.
Hola Mundo
Para crear un proyecto de Rust, el comando cargo new crea un nuevo proyecto de tipo aplicación:
cargo new hello_world
Esto creará el directorio hello_world, con los archivos:
- cargo.toml: Archivo que define el proyecto. Piensa pom.xml o package.json. ¿Qué es .toml?
- src/main.rs: Archivo de entrada de la aplicación.
- .gitignore: Configura Git para ignorar la carpeta /target.
El contenido de main.rs es el siguiente, parece que nuestro trabajo está hecho:
fn main() { println!("Hello, world!"); }
Para compilar el código, usamos cargo build:
cargo build
Para comprobar que el código compila, usamos cargo check. Es más rápido que build:
cargo check
Para ejecutar la aplicación, usamos cargo run:
cargo run
Stack y Heap
Para usar Rust correctamente, debemos conocer cómo es su gestión de memoria.
En Rust, la memoria se divide en stack y heap.
Stack
Guarda la memoria en el orden en el que entra.
Esto se llama último dentro, primero fuera. Imagina una pila de platos, solo puedes quitar el último que has añadido.
Toda lo almacenado en el stack debe tener un tamaño fijo, si no sabemos el tamaño de algo, debemos guardarlo en el heap.
Heap
Almacena la memoria de una forma menos organizada.
Cuando guardamos algo en el heap, el sistema operativo busca un hueco disponible, y guarda la dirección de esa memoria en el stack.
De esta forma, tenemos acceso a una memoria de tamaño desconocido desde el stack.
Variables y Mutabilidad
Para declarar una variable, usamos let:
fn main() { let x = 1; println!("{}", x); }
Ahora, si intentamos modificar la variable:
fn main() { let x = 1; println!("{}", x); x = 2; println!("{}", x); }
Vamos a obtener un error con este aspecto:
error[E0384]: cannot assign twice to immutable variable `x` --> src/main.rs:4:5 | 2 | let x = 1; | - first assignment to `x` 3 | println!("", x); 4 | x = 2; | ^^^^^ cannot assign twice to immutable variable
Esto se debe a que en Rust las variables son inmutables por defecto.
Si queremos que una variable sea mutable, debemos usar mut.
fn main() { let mut x = 1; println!("{}", x); x = 2; println!("{}", x); }
Documentación sobre variables y mutabilidad.
Tipos primitivos
Escalares
En Rust existen muchos tipos primitivos escalares:
- Enteros con signo: i8, i16, i32, i64, i128, isize.
- Enteros sin signo: u8, u16, u32, u64, u128, usize
- Punto flotante: f32, f64.
- char para valores como ‘a’, ‘b’, ‘c’.
- bool puede ser true o false.
- El tipo unidad (), una tupla vacía. Parecido a un void en otros lenguajes.
Tuplas
Una tupla es una colección de valores con distintos tipos. Se construyen con los paréntesis.
fn main() { let tuple = (1, "string", true); let one = tuple.0; let string = tuple.1; let boolean = tuple.2; }
El compilador de Rust puede extraer los tipos en situaciones normales como esta, por eso no necesitamos especificar los tipos así:
fn main() { let tuple: (i32, &str, bool) = (1, "string", true); let one: i32 = tuple.0; let string: &str = tuple.1; let bool: bool = tuple.2; }
También podemos extraer los valores de la tupla de esta manera:
fn main() { let tuple: (i32, &str, bool) = (1, "string", true); let (one, string) = tuple; }
Arrays
Un array es una colección de elementos del mismo tipo que se van a guardar en memoria de manera continua. Se crean usando los corchetes y es necesario especificar su tamaño:
fn main() { // [tipo, tamaño] let array: [i32, 3] = [1, 2, 3]; }
En muchos casos el compilador podrá extraer el tipo del array en tiempo de compilación. Basta con:
fn main() { let array = [1, 2, 3]; }
Documentación sobre Tipos primitivos.
Structs
Con struct podemos crear tuplas con nombre:
struct Position(i32, i32); let position = Position(1, 2);
Estructuras que almacenan datos en campos nombrados:
struct Position { x: i32, y: i32, } let position = Position {x: 1, y: 2};
Si queremos que los campos sean accesibles desde fuera, usamos pub.
struct Position { pub x: i32, pub y: i32, } let position = Position {x: 1, y: 2}; let x = position.x;
Más adelante veremos cómo adherir métodos a un struct con impl.
Documentación sobre structs.
Enums
Un enum permite la creación de un tipo que puede ser una de las variantes disponibles.
El valor de un enum puede contener datos, como una tupla o un struct.
enum Action { // Sin guardar datos (). Jump, // Como un struct. Move { x: i32, y: i32 }, // Como una tupla. Speak(String), } let jump = Action::Jump; let movement = Action::Move { x: 1, y: 2 }; let speak = Action::Speak(String::from("Hello World"));
Documentación sobre enums.
El enum Option
En Rust no existe el concepto de null.
Pero sí existe un enum que representa el concepto de un valor existente o absente.
Este es Option, y está dentro de la librería estándar del lenguaje. No hace falta importarlo.
Un Option puede ser dos cosas:
- None, indica que no tiene valor real.
- Some(value), una tupla que contiene el valor real de tipo T.
// Some es genérico, puede contener cualquier tipo. let some_number = Some(1); let some_string = Some("hello world"); // Si queremos inicializar un Option a None, debemos especificar el tipo. let none_number: Option<i32> = None;
Ahora vamos a intentar hacer una operación con un Option:
let x: i32 = 10; let y: Option<i32> = Some(2); let total = x + y;
Si intentamos ejecutar este código, obtendremos el error:
error[E0277]: the trait bound `i32: std::ops::Add<std::option::Option<i32>>` is not satisfied --> | 5 | let total = x + y; | ^ no implementation for `i32 + std::option::Option<i32>` |
Esto se debe a que Option es un wrapper que contiene el valor real. Para sacar el valor podemos usar la función unwrap(). Esto nos permitirá «desenvolver» el valor real.
let x: i32 = 10; let y: Option<i32> = Some(2); let y: i32 = y.unwrap(); let total = x + y;
Pero que pasa si intentamos hacer unwrap() de un valor None:
let x: i32 = 10; let y: Option<i32> = None; let y: i32 = y.unwrap(); let total = x + y;
thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', /rustc/f3e1a954d2ead4e2fc197c7da7d71e6c61bad196/src/libcore/macros/mod.rs:15:40
Llamar directamente a unwrap() puede resultar en un error de ejecución. Una forma sencilla de solventar este error es usar unwrap_or(default), que devolverá un valor por defecto si Option es None.
let x: i32 = 10; let y: Option<i32> = None; let y: i32 = y.unwrap_or(0); let total = x + y;
Documentación sobre Option.
Pattern Matching
Rust tiene un mecanismo de control de búsqueda de patrones con el operador match.
Permite comparar un valor contra una serie de patrones y ejecutar código en el patrón que se cumpla. El compilador no nos dejará continuar si no cubrimos todas las ramas posibles.
enum Attack { Punch, Sword, Spear, Magic } let attack = Attack::Punch; let damage = match attack { Attack::Punch => 1, Attack::Sword => 5, Attack::Spear => 7, Attack::Magic => 10, _ => 0 // Si es cualquier otro valor. //En este caso no es necesario porque estamos cubriendo todas las ramas. };
Pero match no solo sirve para devolver un valor, podemos ejecutar código dentro de las ramas.
enum Attack { Punch, Sword, Spear, Magic } let attack = Attack::Punch; let damage = match attack { Attacks::Punch => { println!("Ouch!"); 1 }, Attacks::Sword => { println!("Slash Slash!"); 5 }, Attacks::Spear => { println!("Poky Poky!"); 7 }, Attacks::Magic => { println!("✨✨✨"); 10 }, _ => 0 };
Documentación sobre Pattern Matching.
Pattern Matching con Option
Otra forma de utilizar el valor real dentro de un Option es con match.
let x = 10; let y = Some(2); let total = match y { Some(y) => Some(x + y), None => None, };
También podemos utilizar match como anteriormente hemos usado unwrap_or()
let x = 10; let y = Some(2); let total = match y { Some(y) => x + y, None => x, };
Otra sintaxis disponible es if let, que nos permite contrastar un único patrón de manera más concisa.
Pero el compilador no nos obligará a cumplir todas las ramas.
let x = 10; let y = Some(2); let mut total = 0; if let Some(y) = y { total = x + y; } else { total = x; };
Funciones
En Rust las funciones se escriben con la palabra fn:
fn do_nothing() {}
Si la función tiene parámetros:
fn hello(who: String) { println!("Hello {}", who); }
Si la función devuelve un valor, el valor debe estar en la última línea sin punto y coma.
fn hello(who: String) -> String { let hello_who = format!("Hello {}", who); hello_who }
En muchas ocasiones no será necesario especificar el tipo del resultado, ya que el compilador puede interpretar la función.
Documentación sobre funciones.
Métodos
Los métodos son funciones asociadas a una estructura. En Rust se utiliza impl para crear la implementación de un tipo.
struct Position { x: i64, y: i64, } // El bloque de implementación va separado del bloque de estructura. impl Position { // Esta es una función estática o de tipo. Se las llama con tipo::nombre_funcion. // Se pueden usar como constructor. fn new(x: i64, y: i64) -> Position { Position { x, y } } } let position = Position::new(1, 2);
Si queremos crear un método que tenga acceso a la instancia del objeto actual, usamos &self:
impl Position { // &self es una referencia a la instancia actual. fn delta_to(&self, pos: Position) -> (i64, i64) { (pos.x - self.x, pos.y - self.y) } } let pos1 = Position::new(1, 2); let pos2 = Position::new(5, 4); let delta = pos1.delta_to(pos2);
Si queremos modificar un campo de la instancia usamos &mut self:
impl Position { // La firma &mut self, le dice al compilador que al llamar a esta función // se va a modificar la instancia de la estructura. // Por esto, nos obligará a marcarla como mutable al instanciarlo. fn move_to(&mut self, to: Position) { self.x = to.x; self.y = to.y; } } // Sin mut, este código no compila. let mut pos1 = Position::new(1, 2); let pos2 = Position::new(5, 4); pos1.move_to(pos2);
Documentación sobre métodos.
Closures
Una closure, también conocida como expresiones lambda, es una función que puede capturar el entorno que la rodea. Esto significa que tiene acceso a las variables del contexto.
Tiene algunas diferencias con las funciones:
- Utiliza || en lugar de () para las variables de entrada.
- El cuerpo {} es opcional en expresiones únicas.
- Puede capturar su entorno.
let x = 5; let times_x = |value: i32| value * x; let five_times_x = times_x(5);
Documentación sobre closures.
Ownership
Las reglas de ownership dominan Rust con estas normas:
- Todo valor tiene una propiedad llamada su dueño.
- Solo puede tener un dueño en todo momento.
- Cuando el dueño sale del contexto, el valor también lo es.
Estas reglas se encargan del control de la memoria en Rust sin necesitar un colector de basura.
{ // hello no es válido, aún no se ha declarado. let hello = "hello"; // hello es válido de aquí en adelante. // ... } // hello ya no es válido. Se libera la memoria.
Vamos a ver las reglas en acción.
let x = 5; let y = x;
Este código es bastante aparente. Se inicializa x a 5 y se inicializa y a x.
Es correcto presuponer que ambas variables van a tener valor 5. Pero esto se debe a que son de tipo entero, y tienen un tamaño fijo. Por lo que se guardan en el stack.
Ahora vamos a usar el tipo String, que es de un tamaño indeterminado y se guarda en el heap.
let string1 = String::from("hello"); let string2 = string1;
Parece que el funcionamiento será el mismo, pero no es así. Vamos a ver cómo se reparte el tipo String entre el stack y el heap.
Cuando se crea un String, se introducen en el stack la longitud, la capacidad y una referencia de memoria al heap, en el que se guardan el valor del String.
Cuando instanciamos string2 a partir de string1, se añade al stack una nueva variable con longitud, capacidad y la referencia apunta a la misma memoria que string1. Mientras que string1 se marca como invalido.
Si queremos mantener las dos variables válidas, debemos usar clone(). Hay que tener en cuenta que esta operación es mucho más cara que la anterior.
let string1 = String::from("hello"); let string2 = string1.clone();
Ownership en las funciones
Si ejecutamos el siguiente ejemplo:
fn main() { let s = String::from("hello"); takes_ownership(s); println!("{}", s); } fn takes_ownership(some_string: String) { println!("{}", some_string); }
Recibiremos este error.
error[E0382]: borrow of moved value: `s` --> src/main.rs:6:20 | 2 | let s = String::from("hello"); | - move occurs because `s` has type `std::string::String`, which does not implement the `Copy` trait 3 | 4 | takes_ownership(s); | - value moved here 5 | 6 | println!("{}", s); |
Esto se debe a una de las reglas que vimos anteriormente.
- Todo valor tiene un único dueño.
Cuando se pasa s por parámetro a takes_ownership, la función pasa a ser su dueño.
Y cuando la función termina y sale del contexto, s pasa a ser invalido.
Por esto no podemos acceder a s después de takes_ownership.
En el caso de que necesitemos una operación parecida a la anterior, debemos usar referencias.
Referencias
Para marcar referencias usamos &.
fn main() { let s = String::from("hello"); takes_ownership(&s); println!("{}", s); } fn takes_ownership(some_string: &String) { println!("{}", some_string); }
Si queremos modificar una referencia, usamos &mut. Es importante saber que, si queremos modificar una referencia, debemos asegurarnos que el valor también es mutable.
fn main() { let mut s = String::from("hello"); takes_ownership(&mut s); println!("{}", s); } fn takes_ownership(some_string: &mut String) { some_string.push_str(" world"); println!("{}", some_string); }
Con las referencias vienen varias reglas que debemos seguir:
- Puedes tener o una referencia mutable o múltiples referencias inmutables.
- Las referencias siempre deben ser válidas.
Documentación sobre Ownership.
Testing
Rust viene con herramientas de testing automático de serie.
fn add(val1: i32, val2: i32) -> i32 { val1 + val2 } #[cfg(test)] mod tests { // Importa todo lo que hay en el mismo fichero. use super::*; #[test] fn adds_correctly() { let given = (1, 2); let expected = 3; let result = add(given.0, given.1); assert_eq!(result, expected) } }
Para ejecutar los test, comando es cargo test:
running 1 test test tests::adds_correctly ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Documentación sobre testing.
Conclusiones
Gracias por seguir está guía introductoria a Rust. Espero que haya sido educativa.
Si quieres seguir aprendiendo, te recomiendo el libro oficial 100% gratuito The Rust Programming Language.