Saltar al contenido principal

Tutorial Básico de Rust

Documento escrito por: Oussama Osman

Puedes ver todos los documentos de este autor aquí.

Rust es un lenguaje de sistemas muy rápido, eficiente en el manejo de memoria, confiable (tipado estático), da errores útiles y con su propio administrador de paquetes: Cargo.

¿Qué se puede hacer con Rust?

  • Crear apps de línea de comandos(CLI)
  • Compilar a webAssembly
  • Networking (servicios en red)
  • IoT (internet of things) y cosas embebidas

¿Quién lo usa?

  • NPM
  • Mozilla
  • Github
  • Microsoft
  • Amazon
  • Node

¿Por qué lo debería aprender?

Rust lleva más de 5 años seguidos siendo el lenguaje más amado por los desarrolladores (Encuesta de StackOverflow)

Administración de código con el sistema de módulos de Rust

El sistema está compuesto por:

  • Crates : Unidad de compilación, es decir, el fragmento más pequeño que Rust es capaz de compilar.
  • Módulos : Ayudan a organizar el programa.
  • Rutas : Para dar nombre a los elementos de código.

Uso de crates y bibliotecas de Rust

La biblioteca estándar de Rust, std, contiene código reutilizable para las definiciones y operaciones fundamentales de los programas de Rust.

Ejecución y compilación

Para ejecutar código de rust, puedes hacerlo de dos formas:

  • rustc : Ejecutando rustc seguido del archivo con extensión .rs. Esto creará un ejecutable con el nombre del archivo
  • corgo build : Aunque para este es necesario haber creado un "proyecto" con cargo. Para eso, se ejecuta el comando cargo new seguido del nombre del proyecto. Esto generará una carpeta con un archivo .toml con cierta configuración y un directorio src con un main.rs dentro. Ahí estará el grueso de nuestro código.

Macros

  • todo : para indicar algo no acabado. Desencadena una error.

    fn main() {
    todo!("Aún queda acabar esto!")
    }
  • println : espera uno o más argumentos de entrada y los visualiza.

    • fn main() {
      println!("Hola Mundo")
      }
    • fn main() {
      println!("Hola {}!","Mundo")
      //Hola Mundo!
      }

Variables

La variables se declaran con `let, teniendo cada una de estas un nombre único. A estas, se les puede asignar un valor al declararlas o hacer esta asignación más tarde:

let numero;

o

let numero = 10;

Aparte, en Rust, las variables son inmutables. Para poder reasignar el valor de las variables usamos mut:

let mut numero = 10;
numero = 5;
// numero vale 5

Aparte de esa mutación, existen las variables shadowo sombra, que se nombran igual que una variable anterior pero que "oscurecen" o "desvanecen" la "vieja variable".Veamos un ejemplo:

let numero = 2;
let numero = numero * 2;
// ahora numero vale 4
let numero = numero + 2;
// ahora numero vale 6

Tipos de datos

En Rust, hay distintos tipos de datos, entre los que se encuentran :

  • Números enteros
  • Decimales
  • Boleanos
  • Caracteres

Números

Los números se identifican según los bits, que van desde 8 hasta 128 bits (8-16-32-64-128).Aparte, dentro de estos hay 2 categorías:

  • signed : identificados con un i y seguidos de los bits.Estos tienen signo.
  • unsigned : identificados con un u y seguidos de los bits. Estos carecen de signo (deben ser todos positivos).

Los decimales son algo más simples, ya que, por defecto usan el tipo f64, aunque también se puede usar el f32 que es menos preciso.

Los primitivos (números "sueltos", fuera de una variable) aceptan operaciones matemáticas, pero, hace falta ponerles el tipo.Veamos como:

println!("1 + 2 = {}, 8 - 5 = {} y 15 * 3 = {}",1u32 + 2, 8i32 - 5, 15 * 3 );
//1 + 2 = 3, 8 -5 = 3 y 15 * 3 = 45

De igual forma con los decimales :

println!("9 / 2 = {} pero 9.0 / 2.0 = {}", 9u32 / 2, 9.0 / 2.0);
//9 / 2 = 4 pero 9.0 / 2.0 = 4.5

Boleanos

Rust tiene boleanos y, al igual que en otros lenguajes de programación, son:

  • true
  • false

Estos, se representan con el tipo bool.

Aparte de igualar una variable a true o false, también se pueden poner comparaciones.Mira el siguiente ejemplo:

let es_mayor = 2 > 3;
println!("¿Es 2 mayor que 3? {}", es_mayor);
//¿Es 2 mayor que 3? false

Caracteres

Rust soporta dos tipos básicos de cadenas de texto:

  • character : Un solo carácter, letra o representación UTF-8, rodeada de comillas simples ('').Su tipo es char.
  • string : Una serie de caracteres o letras, así como palabras, frases, etc... rodeado de comillas dobles ("").Su tipo es String (aunque la mayor parte del tiempo se use `&str que es una referencia inmutable.Veremos esto más adelante).

Tipos simples y complejos

Aparte, estos tipos anteriormente mencionados están agrupados por dos grandes conjuntos: tipos simples y tipos complejos. Vamos a listar los que pertenecen a cada conjunto e iremos detallando algunos más adelante:

  • Tipos simples :
    • iX: enteros de X bits
    • uX : enteros de X bits sin signo
    • fX : decimales de X bits
    • bool : boleanos
    • char : carácter Unicode
    • &str : referencia a una cadena de caracteres
  • Tipos complejos
    • Vec<T> : vector
    • String : cadena de caracteres
    • HashMap<K,V> : mapa hash
    • Option<T> : contenedor con o sin valor
    • Result<T, E> : contenedor con valor o error
    • Box<T> : puntero a un valor
    • Rc<T> : puntero de contabilidad de referencias

Tuplas

Las tuplas son diferentes valores que pueden ser de distintos tipos que se almacenan en un solo valor compuesto.Los valores individuales de estas tuplas son llamados elementos.

Las tuplas se crean poniendo los valores entre paréntesis () y separándoles individualmente con comas ,:

let tupla: (&str, char, i32, f64) = ("Messi",'D',10,34.6);

Para acceder a los datos de la tupla, se pone el índice del elemento (se empieza a contar en 0):

let tupla = ("Messi",'D',10,34.6);
println!("El dorsal de {} es: {}", tupla.0,tupla.2);
//El dorsal de Messi es: 10

Estructuras

Una estructura o estruct es un tipo compuesto por otros tipos llamados campos (fields). Son parecidos a las tuplas, solo que en los structs puedes nombrar cada campo.

Para crear un struct, debes declararlo con la palabra clave struct seguido de un nombre capitalizado (iniciado en mayúscula) Hay tres tipos distintos de structs:

  • Clásicos (parecidos a los de C) : Cada campo tiene un nombre y el tipo de dato.

    struct Estudiante {nombre: String,curso: &str, edad: u8, todo_aprobado: bool}
  • Tupla : Parecidos a los clásicos, solo que los campos no están nombrados.

    struct Estudiante (String, &str, u8, bool);
  • Unitarios : Solo son declarados y se usan como marcadores.

    struct Estudiante;

Ahora que sabemos como declararlos, vamos a instanciarlos. Un ejemplo con la estructura o struct clásico:

let sexto_estudiante: Estudiante = Estudiante {
nombre: String::from("Manuel"),
curso: "1 de Bachillerato",
edad: 16,
todo_aprobado: true
};

Enumeración

La enumeración, contenedor o enums son tipos de datos que representan los un conjunto de valores posibles para una variable (una de varias variantes). Sirven para crear tipos de datos personalizados que tienen un número limitado de variantes.

Se utiliza la palabra clave enum para crear un enum. Veamos un ejemplo:

enum Forma {
Linea,
//struct unitario
Circulo( f64 ),
//struct tupla
Cuadrado { lado: f64 },
Rectangulo { ancho : f64, alto: f64 }
//structs clásicos
}

También se puede declarar con structs:

struct Rectangle { ancho: f64, alto: f64 }

struct Square { lado: f64 }

enum Forma {
Linea,
Circulo( f64 ),
Cuadrado(Square),
Rectangulo(Rectangle)
}
Ten en cuenta que ...

Es importante saber, que si usas un enum, se deben aceptar todas sus variantes y no solo una, es decir, tiene que existir la posibilidad de que se puedan usar todas aunque en distintos momentos se use una u otra.

Ahora, vamos a instanciar un enum :

let forma_b = Forma::Cuadrado { lado: 3.14 }

Veamos un ejemplo completo con los enums y structs:

fn main() {
#[derive(Debug)]
struct Jugador {
nombre: String,
dorsal: u8,
}

#[derive(Debug)]
enum Player {
DATOS(Jugador),
ACTIVO(bool),
}
let jug_datos = Jugador {
nombre: String::from("Messi"),
dorsal: 10,
};
print!("{} con dorsal {}. Todos sus ", jug_datos.nombre, jug_datos.dorsal);
let activo = Player::ACTIVO(true);
let jugador = Player::DATOS(jug_datos);
print!("{:#?},{:#?}", jugador, activo)
}
//Messi con dorsal 10. Todos sus DATOS(
// Jugador {
// nombre: "Messi",
// dorsal: 10,
// },
//),ACTIVO(
// true,
//)
Ten en cuenta que ...

El #[derive(Debug) nos permite ver ciertos valores en la ejecución del código, y es necesario para los enums

Funciones

Las funciones son la forma principal de ejecutar código en rust (con la función main). Vamos a ver como se definen funciones:

  1. Para definir una función, se usa la palabra clave fn seguida del nombre de la función.
  2. Después van unos paréntesis con los argumentos de la función separados por ,.
  3. Después de esto van las llaves {}, que le indican al compilador el inicio y fin de dicha función.
  4. Dentro de estas llaves donde va el código.
fn despedida () {
println!("Adiós!");
}

Para llamar a una función se necesita el nombre y sus argumentos de entrada si los hubiera:

fn main () {
println("Despedida");
despedida();
}

Algo a tener en cuenta, es que al ser Rust un lenguaje compilado, no le importa en que lugar se definan las funciones, ya que puede ser antes o después de ser llamadas. La única condición es que sí estén definidas.

Argumentos

Las funciones pueden o no tener argumentos de entrada, si lo tuviera, hay que seguir estás reglas:

  • Nombre : nombre de cada argumento.
  • Tipo : tipo de dato del argumento.

Un ejemplo sería el siguiente:

fn despedida (nombre: &str) {
println!("Adiós {}", nombre);
}

Ahora, al llamarla en la función main le podemos pasar distintos argumentos:

fn main () {
let amigo_uno: &str = "Juan";
let amigo_dos: &str = "Pedro";
despedida (amigo_uno);
// Adiós Juan
despedida (amigo_dos);
// Adiós Pedro
}

Devolver un valor

Algunas veces, querremos que cierta función devuelva un valor dependiendo de los argumentos pasados. Esto se haría de la siguiente forma:

  • Declaramos la función normal : `fn funcion (argumento: i32) { argumento/5 }
  • Antes de la llave de apertura {, ponemos una "flecha" : ->
  • Después de la flecha, indicamos el tipo del valor que devuelva : i32

Quedaría así :

fn suma (num1: u32, num2: u32) -> u32 {
num1 + num2;
}
Consejo

También se puede usar la palabra clave return para devolver un valor antes de la finalización de la ejecución de la función. Pero esto es más común con condicionales y lo abarcaremos en ese apartado.

Al hacer la llamada a la función, sería algo tal que así:

fn main () {
let num_uno: u32 = 20;
let num_dos: u32 = 31;

println!("Sumar {} a {} da {}.", num_uno, num_dos, suma(num_uno, num_dos));
// Sumar 20 a 31 da 51.
}

Matrices

Una matriz es una colección de objetos del mismo tipo con una longitud fija, almacenados de manera secuencial (uno detrás de otro) en memoria.

Definir una matriz

Hay dos formas distintas de definir una matriz:

  • Un valor seguido de otro, separados por comas:

    let dias = ["lunes","martes","miércoles","jueves","viernes","sábado","domingo"];
  • Un valor inicial, ; y la longitud que tendrá dicha matriz:

    let bytes = [0; 5];

Indexar una matriz

Los elementos de una matriz van enumerados, empezando pro el 0:

let dias = ["lunes","martes","miércoles","jueves","viernes","sábado","domingo"];

let primer_dia = dias[0];// lunes

Si intentamos acceder a un indice mayor a la longitud de la matriz nos da error

let ultimo_dia = dias[7];
// index out of bounds: the length is 7 but the index is 7

Vectores

Un vector es muy parecido a una matriz, con la diferencia de que un vector si puede cambiar su longitud a lo largo del programa.

Definir un vector

Para definir un vector se usa la macro vec! seguido de corchetes [] y los datos dentro.Al igual que las matrices, los vectores se pueden definir de dos formas distintas :

  • Un valor seguido de otro, separados por comas:

    let numeros = vec![10,12,15];
  • Un valor inicial, ; y la longitud que tendrá dicha matriz:

    let ceros = vec![0; 5];
  • Una variable mutable usando Vec::new y añadiendo los datos posteriormente:

    let mut frutas = Vec::new();
    frutas.push("Manzana");
    frutas.push("Pera");
    frutas.push("Naranja");

Push y Pop

Al crear un vector con el tercer método, necesitaremos añadir los valores posteriormente. Para ello tenemos los métodos push y pop

  • Push : agrega el objeto al final del vector:

    frutas.push("Plátano");
  • Pop : quita el objeto final del vector:

    fruit.pop();

Indexar un vector

El indice de los vectores es igual al de las matrices, comienza en 0:

let primera_fruta = frutas[0]; // Manzana

Pero, al ser los valores del vector mutables, podemos cambiar un valor accediendo a su indice:

frutas[1] = "Kiwi";
println!("Mi fruta favorita es: {}", frutas[1]);
// Mi fruta favorita es: Kiwi
¡Cuidado!

Al igual que con las matrices, si intentamos acceder a un elemento fuera de indice, da error.

If/else

Para crear condiciones, podemos usar las palabras clave if y else, que sirven para probar valores y hacer acciones basadas en el resultado de dicha prueba.

Definición de una condición

Para definir una condición, veamos un ejemplo con igualdad de números:

if 1 == 1 {
println!("1 es igual a 1");
} else {
println!("1 no es igual a 1");
}

// 1 es igual a 1

En este caso, la condición se cumple y devuelve un valor true, el cual ejecuta el bloque de código dentro del if y no sigue evaluando dicha condición.

También se puede añadir estas condiciones al valor de una variable, siempre y cuándo todos los valores posibles sean del mismo tipo:

let formal = true;

let saludo;
if formal {
saludo = String::from("Buenos días caballero");
} else {
saludo = String::from("Hola colega!");
}

println!("{}", saludo);
// Buenos días caballero

Aparte, también se pueden probar más valores que un solo true/false, para ello usaremos la palabra clave else if:

let nota: u8 = 6;

let resultado: String;

if nota < 5 {
resultado = String::from("Estás suspenso");
} else if nota == 5 {
resultado = String::from("Has aprobado por la mínima");
} else {
resultado = String::from("Estás aprobado");
}

Mapas Hash

El mapa hash es usado en otros lenguajes de programación para elementos de datos como objetos, diccionarios o tablas hash. Este mapa hash consta de una clave (V) y un valor (V). Estos se pueden aumentar y su tipo es HashMap<K,V>

Definición de mapas hash

Para definir un mapa hash, aparte de su propia definición, hace falta importarlo de las colecciones de la biblioteca estándar (std):

use std::collections::HasMap;

Una vez importado, vamos a definir un mapa hash:

let mut colors_hex: HashMap<String, String> = HashMap::new();

Ahora, con el método insert, añadimos un valor:

colors_hex.insert(String::from("Green"),String::from("#0f0"));

Obtener un valor

Para obtener un valor, usamos el método get():

let verde = "Green";
println!("El hexadecimal del color {} es: {:?}", verde, colors_hex.get(verde));
//El hexadecimal del color Green es: Some("#0f0")
Ten en cuenta ...

El método get devuelve un Option<&Value> y Rust encapsula el resultado con la notación "Some()"

Eliminar un par clave-valor

Para eliminar un par, se usa el método remove():

colors_hex.remove(verde);

Si intentáramos acceder de nuevo con get(), este nos daría de resultado None (ninguno)

Bucles

Los bucles son bloques de código que deben repetirse.En Rust, existen 3 expresiones distintas para realizar esta función:

  • loop: detención manual.
  • while: mientra la condición se cumpla.
  • for: para todos los valores de una recopilación.

loop

La expresión loop crea un bucle infinito hasta que se realice alguna acción directa para pararlo:

loop {
println!("Bucle infinito!");
}

La única forma de detener este bucle sería "a mano", parando el programa. Una manera común de parar esta instrucción es usando la palabra clave break:

loop {
println!("Bucle infinito!");
break;
}

Un ejemplo más práctico sería este:

let mut contador = 1;

let stop_loop = loop {
contador *=2;
if contador > 100 {
break contador;
}
};

println!("El contador ha parado en {}",stop_loop);
//El contador ha parado en 128

while

El bucle while se repite mientras la condición sea true. Veamos un ejemplo:

while contador < 5 {
println!("Seguimos el bucle");
contador = contador + 1;
}

for

El bucle for usa un iterador y una variable temporal para procesar una colección de elementos, como matrices, vectores o mapas hash:

let frutas = ["Pera","Frambuesa","Mandarina"];
for fruta in frutas.iter() {
println!("La {} es una fruta.", fruta);
}
//La Pera es una fruta.
//La Frambuesa es una fruta.
//La Mandarina es una fruta.

Otra manera de usar un iterador es usando la notación de intervalo a...b. EL iterador comienza en a, incrementa de uno en uno hasta b, pero sin llegar al valor de b:

for num in 0..10 {
println!("{}", num * 3);
}

Ese código imprimiría la tabla de multiplicar del 3, sin llegar a 30: el último valor es 27.

Control de errores

panic!

La macro panic! es el mecanismo más sencillo para controlar errores. Emite una alerta de pánico para el subproceso actual:

  • Emite el mensaje de error
  • Libera recursos
  • Sale del programa

Veamos un ejemplo:

panic!("¡Cuidado!");
//thread 'main' panicked at 'Farewell!'

Aparte, Rust entra en pánico (panic!) en algunas operaciones como dividir por 0 o acceder a un índice mayor al debido.

option

Option<T> Se usa mucho en Rust y es útil para trabajar con valores que pueden existir o estar vacíos. Seria similar al null de otros lenguajes de programación.

Si por ejemplo tenemos un tipo String, este solo podrá ser String. Si queremos que sea tanto String como vacío, usaríamos el Option<String>. Option por defecto viene representado de la siguiente manera:

enum Option<T> {
None,
Some(T),
}

Si recordamos los mapas hash, cuando mostrábamos un valor venía encapsulado en Some(). Pues ese Some() es el de Option.

Tanto Nonecomo Some son variantes del tipo Option<T>.

Un ejemplo sería, que si tenemos un vector e intentamos acceder a un indice mayor o igual a la longitud del vector, el programa entraría en pánico:

let colores = vec!["Azul","Morado","Amarillo"];

let uno = colores[0];
println!("{}",uno);
let dos = colores[4];
println!("{}",dos);

//Azul
//thread 'main' panicked at 'index out of bounds: the len is 3 but the index is 4'

Sin embargo, si usamos el método get(), este nos devolverá un tipo Option:

let colores = vec!["Azul","Morado","Amarillo"];

let uno = colores.get(0);
println!("{:?}",uno);
let dos = colores.get(4);
println!("{:?}",dos);

//Some("Azul")
//None

Detección de patrones

Ahora, ya podemos acceder a indices aún no creados sin entrar en modo pánico pero,¿cómo accedemos a los datos que viene dentro de Some()?.

Podemos utilizar el operador match, que cuando encuentra un patrón de coincidencia ejecuta el código que se proporciona con ese patrón.

Veamos un ejemplo:

for &indice in [0,4].iter(){
match colores.get(indice) {
Some(nombre_color) => println!("El color es {}",nombre_color),
None => println!("Este color no existe"),
}
}

//El color es Azul
//Este color no existe

También podríamos añadir un nuevo patrón:

for &indice in [0,1,2,3,4].iter(){
match colores.get(indice) {
Some(&"Morado") => println!("El Morado es mi color favorito!"),
Some(nombre_color) => println!("El color es {}",nombre_color),
None => println!("Este color no existe"),
}
}

//El color es Azul
//El Morado es mi color favorito!
//El color es Amarillo
//Este color no existe
//Este color no existe

También, existe el patrón comodín, que ejecuta el código para "Cualquier otra cosa". Su sintaxis es así:

let un_numero = Some(8);
match un_numero {
Some(8) => println!("Es mi número de la suerte"),
_ => {},
}

//Es mi número de la suerte

if let

if let se usa puede usar en el último caso visto del match para reducir el código. Este operador compara un patrón con una expresión usando solo el patrón que más nos interesa:


let un_numero = Some(8);
if let Some(8) = un_numero {
println!("Es mi número de la suerte");
}
//Es mi número de la suerte

unwrap, expect y unwrap_or

También podemos acceder al valor interno de un tipo Option con unwrap, pero si la variante en vez de ser Some() es None emitirá una alerta:

let gift = Some("candy");
assert_eq!(gift.unwrap(), "candy");

let empty_gift: Option<&str> = None;
assert_eq!(empty_gift.unwrap(), "candy"); // This will panic!

//thread 'main' panicked at 'called `Option::unwrap()` on a `None` value'

Otro método, expect, funciona similar solo que este proporciona un mensaje de pánico personalizado como argumento:

let gift = Some("candy");
assert_eq!(gift.expect("fruits are healthy"), "candy");

let empty_gift: Option<&str> = None;
empty_gift.expect("fruits are healthy"); // panics with `fruits are healthy`

//thread 'main' panicked at 'fruits are healthy'

El método unwrap_or es similar a los anteriores, solo que en vez de lanzar un alerta de pánico, puede devolver un valor si la variante es None:

let gift = Some("candy");
assert_eq!(gift.unwrap_or("apple"), "candy");

let empty_gift: Option<&str> = None;
assert_eq!(empty_gift.unwrap_or("apple"), "apple");

Result

Result es una enumeración (enum) al igual que Option. Result<T, E> sirve para devolver y propagar errores.

Resultse define así:

enum Result<T, E> {
Ok(T):,
Err(E),
}

La variante Ok(T) representa un acierto y la variante Err(E) representa un error. Este tipo resulta más adecuado que Option si se pueden producir errores.

AL igual que Option,también tiene los métodos unwrap y expect:

  • Devuelven un valor dentro de la variante Ok si es el caso.
  • Ocasionan alertas de pánico en el programa si la variante es Err.

Veamos un ejemplo completo con Result:

#[derive(Debug)]
struct DivisionPorZeroError;

fn div_segura(dividendo: f64, divisor: f64) -> Result<f64, DivisionPorZeroError> {
if divisor == 0.0 {
Err(DivisionPorZeroError)
} else {
Ok(dividendo / divisor)
}
}

fn main() {
println!("{:?}", div_segura(9.0, 3.0));
println!("{:?}", div_segura(4.0, 0.0));
println!("{:?}", div_segura(0.0, 2.0));

//Ok(3.0)
//Err(DivisionByZeroError)
//Ok(0.0)
}

Un Result con variante Ok hará la división, si se trata de la variante Err llevara a la estructura `DivisionPorZeroError', que indica una división incorrecta.

Administración de memoria

Reglas de ámbito

En Rust las variables solo son válidas dentro de un cierto ámbito, que son normalmente indicados con las llaves {} como if, else o match:

{
let nombre = String::from("Rodrigo");
// en este ámbito, nombre existe y se puede usar
}
println!("Mi nombre es {}", nombre);
//cannot find value `nombre` in this scope
//No existe el valor de nombre

Una vez fuera del ámbito, el objeto, o en este caso, la variable nombre se "descarta" y se elimina la memoria que posee el String.

Semántica y transferencia de recursos

A veces, no se quiere que los elementos asociados a una variable se anulen al final del ámbito, sino que se quiere transferir la propiedad de un elemento de un enlace a otro:

{
let nombre = String::from("Rodrigo");
let rodri = nombre;
}

Algo que hay que tener en cuenta es que una vez transferida la propiedad, la variable antigua ya no vale y no se puede usar. Est es conocido como "mover": Se mueve el valor de la variable antigua a la nueva.

Si hacemos esta transferencia de propiedad e intentamos usar la variable antigua nos saltará un error:

{
let nombre = String::from("Rodrigo");
let rodri = nombre;
println!("Mi nombre es {}", rodri);
println!("Mi nombre es {}", nombre);
}
//borrow of moved value: `nombre`

Este error se conoce como uso después de movimiento, y se debe a que en Rust, solo un elemento puede poseer un fragmento de dato a la vez ya que utiliza un sistema de prestamos y propiedad muy riguroso para evitar conflictos y problemas de seguridad evitando así que dos partes del código intente acceder al mismo valor al mismo tiempo.

Propiedad en las funciones

Lo mismo pasa en las funciones, si se le pasa una cadena como argumento, esta se mueve a la función:

fn proceso (hacer: String) {}

fn llamada () {
let entrada = String::from("Detener el proceso 5");
proceso(entrada);
proceso(entrada);
}

//use of moved value: `entrada`
// --> src/main.rs:7:17
//5 | let entrada = String::from("Detener el proceso 5");
// | ------- move occurs because `entrada` has type `String`, which does not implement the `Copy` trait
//6 | proceso(entrada);
// | ------- value moved here
//7 | proceso(entrada);
// | ^^^^^^^ value used here after move

Al hacer la primera llamada a proceso(), se transfiere la propiedad de la variable entrada y en la segunda llamada a proceso ese valor está movido con lo cual salta el error.

Copy

En los mensajes de error de "uso después d movimiento", sale el rasgo Copy:

//move occurs because `entrada` has type `String`, which does not implement the `Copy` trait

Hay ciertos valores que si implementan el rasgo Copy y otros que no. Con este rasgo, en vez de mover el valor como en los ejemplos anteriores, simplemente se copia.

Un ejemplo de esto son los tipos simples, como los números, que si tienen el rasgo Copy:

fn proceso(input: i8) {}
fn llamada() {
let entrada = 1;
proceso(entrada);
proceso(entrada);
}

Esto pasa porque la copia de tipos simples es muy económica, mientras que los tipos complejos como cadenas o vectors puede costar más y en su lugar se mueven.

Si queremos hacer una copia de un tipo complejo en vez de moverlo, debemos clonarlo. Esto se hace con el método clone(), que duplica la memoria y genera un nuevo valor. Este nuevo valor es el que se mueve, lo que hace que aún se pueda usar el valor anterior:

fn proceso (hacer: String) {}

fn llamada () {
let entrada = String::from("Detener el proceso 5");
proceso(entrada.clone());
proceso(entrada);
}

Un pequeño inconveniente de este método es que ralentiza el código al estar copiando los datos de un tipo complejo lo cual es costoso.

Referencias

Una forma de solucionar el último inconveniente que nos ha surgido por culpa del método clone es usando "referencias", que permite a las funciones y variables "tomar prestado" los valores sin convertirse en propietario de ellos:

let nombre = String::from("Julio");
let ref_nombre = &nombre;
println!("Felicidades {}", nombre);

Usando la referencia, no se mueve el valor, por lo que la variable sigue teniéndolo y podemos usarla sin que salte ningún error. Lo mismo pasaría con las funciones :

fn proceso (hacer: &String) {
println!{"Hay que: {}",hacer};
}

fn llamada () {
let entrada = String::from("Detener el proceso 5");
proceso(&entrada);
proceso(&entrada);
}

Este préstamo también tiene algunos inconvenientes ya que, la propiedad del valor no es nuestra no se pueden hacer con el ciertas cosas, como mutarlo :

fn proceso (hacer: &String) {
hacer.push_str("Hecho!")
}

fn llamada () {
let entrada = String::from("Detener el proceso 5");
proceso(&entrada);
}

//cannot borrow `*hacer` as mutable, as it is behind a `&` reference
// --> src/main.rs:3:9
//2 | fn proceso(hacer: &String) {
// | ------- help: consider changing this to be a mutable reference: `&mut String`
//3 | hacer.push_str("Hecho!")
// | ^^^^^^^^^^^^^^^^^^^^^^^^ `hacer` is a `&` reference, so the data it refers to cannot be borrowed as mutable

El error nos sugiere un cambio: pasar de &String a &mut String:

fn proceso(hacer: &mut String) {
hacer.push_str("Hecho!")
}
fn llamada() {
let mut entrada = String::from("Detener el proceso 5");
proceso(&mut entrada);
}

Esto es debido a que los préstamos & son inmutables, mientras que los &mut si son mutables: los datos se pueden leer y escribir.

referencias mutables e inmutables

Como hemos dicho antes, cuando se produce un préstamo este puede ser mutable (&mut) o inmutable (&). Pues el código debe implementar siempre una de las dos, no las dos a la vez. Aparte, solo puede haber una referencia mutable, mientras que para las inmutables "no hay límite":

let mut valor = String::from("Hola!");

let ref1 = &mut valor;
let ref2 = &mut valor;

println!("{}, {}", ref1, ref2);

//cannot borrow `valor` as mutable more than once at a time
// --> src/main.rs:5:16
//4 | let ref1 = &mut valor;
// | ---------- first mutable borrow occurs here
//5 | let ref2 = &mut valor;
// | ^^^^^^^^^^ second mutable borrow occurs here
//6 |
//7 | println!("{}, {}", ref1, ref2);
// | ---- first borrow later used here

Tampoco se podría usar ambas:

let mut valor = String::from("Hola!");

let ref1 = &mut valor;
let ref2 = &valor;

println!("{}, {}", ref1, ref2);

//cannot borrow `valor` as immutable because it is also borrowed as mutable
// --> src/main.rs:5:16
//4 | let ref1 = &mut valor;
// | ---------- mutable borrow occurs here
//5 | let ref2 = &valor;
// | ^^^^^^ immutable borrow occurs here
//6 |
//7 | println!("{}, {}", ref1, ref2);
// | ---- mutable borrow later used here

Duraciones

Para no usar una referencia de un valor que ya no existe o no es válido Rust usa las duraciones. Estas duraciones representan el tiempo durante el cual se espera que la referencia siga siendo válida. Veamos un ejemplo:

fn main() {
let x;
{
let y = 42;
x = &y;
// Guardamos la referencia de `y` en `x` pero `y` está apunto de dejar de ser válido.
}
println!("x: {}", x); // `x` se refiere a `y` pero `y` ya no es válido
}

//`y` does not live long enough
// --> src/main.rs:5:13
// |
//5 | x = &y;
// | ^^ borrowed value does not live long enough
//6 | // Guardamos la referencia de `y` en `x` pero `y` está apunto de dejar de ser válido.
//7 | }
// | - `y` dropped here while still borrowed
//8 | println!("x: {}", x); // `x` se refiere a `y` pero `y` ya no es válido
// | - borrow later used here

Esto pasa porque y solo es válido en el ámbito de las llaves {}, y por la tanto, tiene menor recorrido que la variable x, que es válida en todo el código.

Para solucionar esto, se usa la anotación de las duraciones, lo que ayuda al compilador de Rust a entender los tiempos.

Un ejemplo sería el siguiente:

fn main() {
let magic1 = String::from("abracadabra!");
let magic2 = String::from("shazam!");

let result = longest_word(&magic1, &magic2);
println!("The longest magic word is {}", result);
}

fn longest_word(x: &String, y: &String) -> &String {
if x.len() > y.len() {
x
} else {
y
}
}

//error[E0106]: missing lifetime specifier
// --> src/main.rs:9:38
// |
// 9 | fn longest_word(x: &String, y: &String) -> &String {
// | ---- ---- ^ expected named lifetime parameter
// |
// = help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
// help: consider introducing a named lifetime parameter
// |
// 9 | fn longest_word<'a>(x: &'a String, y: &'a String) -> &'a String {
// | ^^^^ ^^^^^^^ ^^^^^^^ ^^^

Aquí da error porque no puede hacer el seguimiento correctamente, y nos sugiere las anotaciones :

...

fn longest_word<'a>(x: &'a String, y: &'a String) -> &'a String {
if x.len() > y.len() {
x
} else {
y
}
}

Hemos puesto 'a de forma arbitraria, podría ser también 'b o 'durar.

También podemos poner estas anotaciones en las declaraciones de tipos:

#[derive(Debug)]
struct Highlight<'document>(&'document str);

fn main() {
let text = String::from("The quick brown fox jumps over the lazy dog.");
let fox = Highlight(&text[4..19]);
let dog = Highlight(&text[35..43]);
println!("{:?}", fox);
println!("{:?}", dog);
}
//Highlight("quick brown fox")
//Highlight("lazy dog")

Tipos y rasgos genéricos

Un tipo de dato genérico es un tipo que se define en términos de otros tipos parcialmente desconocidos:

  • Option<T> es genérico respecto al tipo T
  • Result<T, E> es genérico respecto al tipo T y al error E
  • Vec<T>
  • matriz [T; n]
  • HasMap<K, V>
  • etc...

Los tipos genéricos sirven para especificar la operación deseada sin especificar el tipo. Podemos implementar un tipo genérico de la siguiente forma:

struct Punto<T> {
x: T,
y: T,
}

Y luego podemos usarlo :

let booleano = Punto { x: true, y: false};
let enteros = Punto {x: 1, y: 5};
let decimales = Punto {x: 33.3, y: 6.9};
let texto = Punto {x: "Punto x",y: "Punto y"};

La única restricción que hay es que, según lo hemos definido, tanto y como x son del mismo tipo T, así que, x e y deben coincidir en el tipo. Si quisiéramos que pudieran ser de distinto tipo, declararíamos Punto de esta forma:

struct Punto<T, U> {
x: T,
y: U,
}

Comportamiento compartido con rasgos

Un rasgo es una interfaz común que puede implementar un grupo de tipos. Un ejemplo sería crear un rasgo para el área:

trait Area {
fn area(&self) -> f64;
}

La palabra clave trait sirve para declarar un rasgo, en este caso llamado Area.¡, el cual, tiene dentro la función area, que devuelve un número decimal (f64). Si creamos tipos que implementen este nuevo rasgo se verá mejor:

struct Rectangulo {
ancho: f64,
alto: f64,
}
impl Area for Rectangulo {
fn area(&self) -> f64 {
self.ancho * self.alto
}
}

Después de declarar la estructura del rectángulo, implementamos (impl) el rasgo Area para (for) este rectángulo (Rectangulo). Una vez implementado este rasgo, podemos llamar al método en instancias de Rectangulo:

let rect = Rectangulo {
ancho: 1,
alto: 1.5,
};

println!("El area del rectángulo es: {}",rect.area());

Rasgo de derivación

Si creamos una estructura e intentamos hacer ciertas operaciones con ella, es probable que salte un error:

struct Punto {
x: i8,
y: i8,
}

fn main () {
let p1 = Punto { x: 1, y: 6};
let p2 = Punto { x: -5, y: 3};

if p1 == p2 {
println!("Son dos puntos iguales");
} else {
println!("Son dos puntos distintos");
}

println!("El punto 1 es {}", p1);
println!("El punto 2 es {:?}", p2);
}

Nos da los siguientes errores:

binary operation `==` cannot be applied to type `Punto`
--> src/main.rs:10:11
|
10 | if p1 == p2 {
| -- ^^ -- Punto
| |
| Punto
|
note: an implementation of `PartialEq<_>` might be missing for `Punto`
--> src/main.rs:1:1
|
1 | struct Punto {
| ^^^^^^^^^^^^ must implement `PartialEq<_>`
help: consider annotating `Punto` with `#[derive(PartialEq)]`
|
1 | #[derive(PartialEq)]

error[E0277]: `Punto` doesn't implement `std::fmt::Display`
--> src/main.rs:16:34
|
16 | println!("El punto 1 es {}", p1);
| ^^ `Punto` cannot be formatted with the default formatter
|
= help: the trait `std::fmt::Display` is not implemented for `Punto`
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

error[E0277]: `Punto` doesn't implement `Debug`
--> src/main.rs:17:36
|
17 | println!("El punto 2 es {:?}", p2);
| ^^ `Punto` cannot be formatted using `{:?}`
|
= help: the trait `Debug` is not implemented for `Punto`
= note: add `#[derive(Debug)]` to `Punto` or manually `impl Debug for Punto`
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider annotating `Punto` with `#[derive(Debug)]`
|
1 | #[derive(Debug)]

Los errores vienen porque el tipo Punto no implementa los rasgos debug (dar formato mediante {:?}), Display (dar formato mediante {}) y PartialEq (comparar la igualdad).

Estos problemas los solucionamos con el atributo derive (#[derive(Trait)]) que implementa automáticamente los rasgos Debug y PartialEq:

#[derive(Debug, PartialEq)]
struct Punto {
x: i8,
y: i8,
}

Con esa línea de código habríamos solucionado 2 de los 3 errores anteriores, ya que, aún queda implementar el rasgo Display ya que este no se implementa automáticamente porque está pensado para usuarios finales. Para implementar ese rasgo para nuestro tipo Punto, debemos hacer lo siguiente:

use std::fmt;

impl fmt::Display for Punto {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}

Una vez implementado todo, el código final sería así:

use std::fmt;

#[derive(Debug, PartialEq)]
struct Punto {
x: i8,
y: i8,
}

impl fmt::Display for Punto {
fn fmt (&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}

fn main() {
let p1 = Punto { x: 1, y: 6 };
let p2 = Punto { x: -5, y: 3 };

if p1 == p2 {
println!("Son dos puntos iguales");
} else {
println!("Son dos puntos distintos");
}

println!("El punto 1 es {}", p1);
println!("El punto 2 es {:?}", p2);
}

//Son dos puntos distintos
//El punto 1 es (1, 6)
//El punto 2 es Punto { x: -5, y: 3 }

Límites de rasgos y funciones genéricas

Teniendo en cuenta lo que ya sabemos de los rasgos, podemos declarar argumentos de función como un parámetro de tipo anónimo, que tenga los límites declarados por el parámetro de tipo anónimo. Vamos a verlo con un ejemplo.

Supongamos que tenemos una aplicación web, y queremos pasar ciertos datos a formato JSON, para ello escribimos el siguiente rasgo:

trait FormatoJson {
fn formato_json (&self) -> String
}

También usaremos la función que es la que hará el trabajo duro y aparte comprobará si el argumento está implementado con el rasgo FormatoJson:

fn datos_a_json<T: FormatoJson>(valor: &T) {
println!("Enviando datos al servidor");
println!(" -> {}", valor.formato_json());
println!("Trabajo hecho!");
}

Una hecho esto, debemos crear la estructura o estructuras que lo implementarán:

struct Persona {
nombre: String,
edad: i8,
estudia: bool,
}

struct Mascota {
nombre: String,
especie: String,
edad: i8,
}

impl FormatoJson for Persona {
fn formato_json (&self) {
format!(
r#"{{"tipo":"persona", "nombre": "{}", "edad": "{}", "estudia":"{}"}}"#,
self.nombre, self.edad, self.estudia
)
}
}

impl FormatoJson for Mascota {
fn formato_json (&self) {
format!(
r#"{{"tipo":"mascota", "nombre": "{}", "especie": "{}", "edad":"{}"}}"#,
self.nombre, self.especie, self.edad
)
}
}

Una vez implementados ambos, ya se pueden usar como parámetros de entrada:

fn main () {
let maria = Person {
nombre: String::from("María"),
edad: 19,
estudia: false,
};

let boby = Mascota {
nombre: String::from("Boby"),
especie: String::from("Perro"),
edad: 3,
};

datos_a_json(&laura);
datos_a_json(&boby);
}

Y la salida que obtendremos es la siguiente:

Enviando los datos
-> { "tipo": "persona", "nombre": "María","edad": "19", "estudia": "false"}
Trabajo hecho!
Enviando los datos
-> { "tipo": "mascota", "nombre": "Boby", "especie": "Perro","edad": "3"}
Trabajo hecho!

Si creamos un tipo nuevo, sin implementarlo al FormatoJson, nos daría un error, ya que solo los tipos que están implementados en este rasgo se pueden pasar como argumento a datos_a_json.

Iteradores

Ya hemos iterado tipos de colección con el bucle for, ahora vamos a ver como Rust controla la iteración de forma más detallada.

Todos los iteradores implementan el rasgo Iterator, que tiene este aspecto:

trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}

Al devolver un Option, seguirá mientras devuelva Some() y cuando devuelva None, se detendrá. Aparte, devuelve el tipo Item, que es un tipo asociado que se debe definir, ya que será el tipo devuelto.

Para ver une ejemplo de todo esto, vamos a crear nuestro propio iterador que se llamará Contador:

#[derive(Debug)]
struct Contador {
tope: usize,
cuenta: usize,
}

impl Contador {
fn nuevo(tope: usize) -> Contador {
Contador {
cuenta: 0,
tope: tope,
}
}
}

Ya hemos creado la estructura del contador e implementado el método nuevo para controlar dicha estructura. Ahora vamos con el iterador:

impl Iterator for Contador {
type Item = usize;
fn next (&mut self) -> Option<Self::Item> {
self.cuenta += 1;
if self.cuenta <= self.tope {
Some(self.cuenta)
} else {
None
}
}
}

Ahora, podemos comprobar que funciona llamando a la función next():

fn main () {
let mut cont = Contador::nuevo(3);
println!("Contador creado: {:?}, cont");
//Contador creado: Contador { tope: 3, cuenta: 0 }

assert_eq!(cont.next(),Some(1));
assert_eq!(cont.next(),Some(2));
assert_eq!(cont.next(),Some(3));
assert_eq!(cont.next(),None);

println!("Contador parado: {:?}", cont)
//Contador parado: Contador { tope: 3, cuenta: 4 }
}

Aunque también podemos hacerlo con un bucle for:

fn main () {
for num in Contador::nuevo(3) {
println!("{}",num);
}
}

//1
//2
//3

Organización del código

En Rust, el código se organiza de la siguiente forma:

  1. Paquete: contiene uno o más contenedores e información sobre como crearlos en el archivo Cargo.toml.
  2. Contenedor: representa una unidad de compilación y al compilar genera un archivo ejecutable o biblioteca (lib).
  3. Módulo: unidad de organización de código dentro de un contenedor pudiendo tener definiciones que abarcan módulos adicionales.

Paquete

Una paquete se crea cuando ejecutamos el siguiente comando:

cargo new nombre-proyecto

Con esto se generará la siguiente estructura:

nombre-proyecto
├── src
│ └── main.rs
└── Cargo.toml

Este "paquete inicial" solo contará con un src/main.rs, es decir, un solo contenedor binario con el nombre del paquete. Si tuviera un archivo src/lib.rs sería una biblioteca con el nombre del paquete,

Para crear una biblioteca, solo agregaríamos --lib al comando:

cargo new --lib nombre-proyecto

Y generaría la siguiente estructura:

nombre-proyecto
├── src
│ └── lib.rs
└── Cargo.toml

Módulos

Los módulos se pueden usar para dividir jerárquicamente en unidades lógicas. Un módulo es una colección de los siguientes elementos :

  • Constantes
  • Alias de tipo
  • Funciones
  • Estructuras
  • Enumeraciones
  • Rasgos
  • Bloques impl
  • Otros módulos

Un ejemplo de un módulo sería el siguiente:

mod mates {
type Complex = (f64, f64);
pub fn sin(f: f64) -> f64 {
...
}
pub fn cos(f: f64) -> f64 {
...
}
pub fn tan(f: f64) -> f64 {
...
}
}

println!("El coseno de 45 es {}", mates::cos(45.0));

Aparte, los módulos controlan la privacidad de un elemento, el cual por defecto suele ser privado e inaccesible por el exterior. Si quisiéramos cambiar esto, debemos hacer el elemento público con la palabra clave pub:

struct Algo;

pub struct Bar {
nombre: String,
}

Un ejemplo donde estos módulos y su privacidad podrían ser útiles es en una API de autenticación.

Pongamos que tenemos el siguiente código:

mod authentication {
pub struct User {
username: String,
password_hash: u64,
}

impl User {
pub fn new(username: &str, password: &str) -> User {
User {
username: username.to_string(),
password_hash: hash_password(password),
}
}
}
fn hash_password(input: &str) -> u64 { /*...*/ }
}
fn main() {

let user = authentication::User::new("Federico", "super-secret");

println!("La contraseña del usuario {} es: {}", user.username, user.password_hash);

}

Si intentamos ejecutar este código, nos saldrá un error ya que no tenemos acceso al user.username ni al user.password_hash. Esto es un arma de doble filo, ya que nos da la seguridad de que nadie puede acceder a estos datos, el problema es que nosotros tampoco...

Esto se puede solucionar añadiendo dos funciones públicas, una para leer el username t otra para sobre escribir el password_hash:

mod authentication {

// ...

impl User {

// ...

pub fn get_username(&self) -> &String {
&self.username
}
pub fn set_password(&mut self, new_password: &str) {
self.password_hash = hash_password(new_password)
}
}
}

Ahora, la lectura y escritura de estos parámetros está más controlada con el módulo de authentication.

Una vez esto, puede que nuestro módulo quede muy grande en conjunto con el resto del archivo. Para remediar esto, podemos moverlo a un archivo externo, llamado igual que el módulo, en este caso será: authentication.rs dentro de la propia carpeta src.

Una vez todo el código siguiente en el archivo src/authentication.rs:

pub struct User {
username: String,
password_hash: u64,
}

impl User {
pub fn new(username: &str, password: &str) -> User {
User {
username: username.to_string(),
password_hash: hash_password(&password.to_owned()),
}
}

pub fn get_username(&self) -> &String {
&self.username
}

pub fn set_password(&mut self, new_password: &str) {
self.password_hash = hash_password(&new_password.to_owned())
}
}

fn hash_password<T: Hash>(t: &T) -> u64 {/* ... */}

Lo vamos a referenciar en el archivo principal, el src/main.rs de la siguiente forma:

mod authentication;

fn main() {
let user = authentication::User::new("Federico", "super-secret");
println!("La contraseña del usuario {} es: {}", user.username, user.password_hash);
}

Es importante poner el ; después del mod authentication en vez de código para que importe correctamente el fichero y sus datos.

Contenedores de terceros

Aparte de la biblioteca estándar de Rust, podemos usar algunas externas, creados por terceros. Todos los paquetes de terceros se encuentran en crates.io.

Vamos a hacer un ejemplo breve con uno de estos paquetes, estas acciones servirían para cualquier otro paquete.

Vamos a usar un módulo para expresiones regulares (regexp), por tanto, agregamos el contenedor llamado regex a las dependencias del del proyecto. Estas están en el archivo Cargo.toml:

[dependencies]
regex = "1.4.2"

En este bloque de dependencias, agregamos el nombre del contenedor (regex en este caso), y luego indicamos que versión deseamos de tal contenedor (1.4.2 en este caso). Si quisiéramos agregar más dependencias, las añadiríamos seguidas, una debajo de otro y siguiendo la estructura siguiente:

nombre = "version"

Una vez agregada la dependencia, guardamos el archivo y lanzamos el siguiente comando:

cargo build

Este comando descargará el contenedor con la versión que le hemos indicado.

Ahora que tendríamos todo, solo falta incluirlo. Para ello usaremos la palabra reservada use y ya podríamos usarlo:

use regex::Regex;

fn main() {
let re = Regex::new(r"^\d{3}-\d{2}-\d{2}$").unwrap();
println!("¿Coinciden las fechas? {}", re.is_match("2023-01-02"));
}

Una vez hayamos hecho todo lo que queríamos, compilamos con cargo run y tendremos la salida que esperamos:

cargo run
Running `target/nombre-proyecto`
¿Coinciden las fechas? true

Testing en Rust

Pruebas Unitarias

Las pruebas unitarias en Rust son funciones simples marcadas con el atributo #[test]. Estas funciones ejecutan el código que se quiere probar comprobando los resultados mediante las macros assert!yassert_eq!`. Veamos un ejemplo:

fn suma (a: i8, b: i8) -> i8{
a+b
}

#[test]
fn suma_prueba() {
assert_eq!(suma(1,2), 3);
assert_eq!(suma(5,3), 8);
assert_eq!(suma(-2,7), 5);
}

Para ejecutar y ver si se pasan los tests, tenemos que ejecutar el comando siguiente:

cargo test

running 1 test
test suma_prueba ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Si añadimos una prueba errónea, podremos ver que falla:

#[test]
fn suma_prueba_err() {
assert_eq!(suma(1, 2), 4);
}

Y el fallo que nos da es el siguiente:

running 2 tests
test suma_prueba ... ok
test suma_prueba_err ... FAILED

failures:

---- suma_prueba_err stdout ----
thread 'suma_prueba_err' panicked at 'assertion failed: `(left == right)`
left: `3`,
right: `4`', src/main.rs:14:5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


failures:
suma_prueba_err

test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Ahora, pongamos que queremos probar que realmente tenga esos errores, como podría ser que queremos que nuestro programa falle en ciertos resultados. Para ello le podemos agregar el atributo #[should_panic]. Con este atributo, si la prueba da fallo, la prueba será exitosa, pero si la prueba no da fallo, nos mostrará error, puesto que con este atributo lo que esperamos es que el programa falle:

#[test]
#[should_panic]
fn suma_prueba_err() {
assert_eq!(suma(1, 2), 4);
}

Y esto pasa el test:

running 2 tests
test suma_prueba ... ok
test suma_prueba_err - should panic ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

También tenemos otro atributo más que podemos usar, el #[ignore], este sirve para ignorar la prueba que se le indique, hasta se puede poner un mensaje específico en este atributo:

#[test]
#[ignore = "Aún en desarrollo"]
fn suma_negativa(){
assert_eq!(suma(-2,-4), -8);
}

La salida que nos da esto al hacer el cargo test es la siguiente:

running 3 tests
test suma_negativa ... ignored, Aún en desarrollo
test suma_prueba ... ok
test suma_prueba_err - should panic ... ok

test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.00s

Por lo general, estas pruebas unitarias no suelen estar "sueltas" en el código, si no que se suelen recoger un un módulo con el atributo #[cfg(test)]:

#[cfg(test)]
mod testing_suma {
use super::*;
#[test]
fn suma_prueba() {
assert_eq!(suma(1, 2), 3);
assert_eq!(suma(5, 3), 8);
assert_eq!(suma(-2, 7), 5);
}

#[test]
#[should_panic]
fn suma_prueba_err() {
assert_eq!(suma(1, 2), 4);
}

#[test]
#[ignore = "Aún en desarrollo"]
fn suma_negativa() {
assert_eq!(suma(-2, -4), -8);
}
}

El atributo cfg hace compilación condicional, es decir, solo cuando lo que lleva entre paréntesis es cierto se compila. En este caso, solo cuando hacemos cargo test. Luego, la declaración use super::*; ses necesaria para tener acceso a la función suma.

Pruebas de documentación

En las bibliotecas, las pruebas unitarias se llaman pruebas de documentación y se hacen de una forma distinta a las normales, aunque con los mismos conceptos. Veamos un ejemplo

Primero, creamos nuestra plantilla con el comando cargo new --lib mates_basic y nos situamos en esa carpeta generada. Nos vamos a editar el archivo src/lib.rs con lo siguiente:

/// La primera línea es una descripción breve de su función principal
///
/// Las siguientes líneas son de documentación más detallada.
/// Los bloques de código empiezan con 3 comillas inversas. El código tienen un `fn main()` implícito dentro y un
/// `extern crate <nombre>`, lo que quiere decir que puedes empezar a escribir código.
///
/// ```
/// let resultado = mates_basic::add(2, 3);
/// assert_eq!(result, 5);
/// ```
pub fn add(a: i32, b: i32) -> i32 {
a + b
}

Si ejecutamos ahora un cargo test, obtendremos la siguiente salida:

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests mates_basic

running 1 test
test src/lib.rs - add (line 7) ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.96s

Test de integración

Aparte de querer probar partes del código, sobre todo en las bibliotecas, puede que queramos probar el conjunto de nuestro contenedor. Para ello están las pruebas de integración, que se hacen en un directorio destinado para ello, separadas del código. Veamos un ejemplo:

Primero, vamos a crear una nueva biblioteca:

cargo new --lib rusty_pizza
cd rusty_pizza

Y creamos una estructura de Pizzay la implementamos. Todo esto usando métodos públicos y privados:

pub struct Pizza {
pub ingredientes: String,
pub pulgadas: u8,
}

impl Pizza {
pub fn pepperoni(pulgadas: u8) -> Self {
Pizza::hacer("pepperoni", pulgadas)
}

pub fn mozzarella(pulgadas: u8) -> Self {
Pizza::hacer("mozzarella", pulgadas)
}

fn hacer(ingredientes: &str, pulgadas: u8) -> Self {
Pizza {
ingredientes: String::from(ingredientes),
pulgadas,
}
}
}

Este código tiene la estructura de Pizza con dos métodos públicos: Pizza::pepperoni y Pizza::mozzarella, que son del método privado Pizza::hacer que prepara las pizzas.

Una vez entendido lo anterior, vamos a crear una carpeta llamada tests junto al directorio src, dentro de este nuevo directorio creamos el archivo pizzas.rs donde escribiremos lo siguiente:

use rusty_pizza::Pizza;

#[test]
fn hacer_pizza_con_pepperoni() {
let pizza = Pizza::pepperoni(12);
assert_eq!(pizza.ingredientes, "pepperoni");
assert_eq!(pizza.pulgadas, 12);
}

#[test]
fn hacer_pizza_con_mozzarella() {
let pizza = Pizza::mozzarella(16);
assert_eq!(pizza.ingredientes, "mozzarella");
assert_eq!(pizza.pulgadas, 16);
}

Una vez añadidos los test, lanzamos el cargo test y nos da este resultado:

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Running tests/pizzas.rs (target/debug/deps/pizzas-6f9bec51c21904bc)

running 2 tests
test hacer_pizza_con_mozzarella ... ok
test hacer_pizza_con_pepperoni ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Doc-tests rusty_pizza

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Vemos que primero muestra los resultados de las pruebas unitarias y luego de la integración.

Estas pruebas de integración solo se pueden hacer con bibliotecas, ya que los binarios no exponen un funcionalidad que usen otros contenedores.