Empezando con Rust

etiquetas: programación, rust

Debo reconocer que me aburre el enfoque habitual con el que se intenta enseñar un lenguaje de programación. Comprendo que de alguna forma hay que presentar sus elementos y características, pero cuando uno está familiarizado con el paradigma que utiliza, es tedioso enfrentarse a explicaciones interminables de conceptos ya conocidos. Y no me entendáis mal, no es que descarte por completo ese modo de enseñar: es solo que no presto la misma atención que cuando estaba comenzando.

fn main() {
    println!("Hello, World!");
}
Siguiendo la tradición.

Igual que no aprendí a conducir viendo cómo lo hacían los demás, a programar tampoco lo hice así. Y aunque evidentemente el código de otras personas me ayudó mucho al principio, fue desarrollar mis propios proyectos lo que me puso en la buena dirección.

«Neo, tarde o temprano entenderás igual que yo, que existe diferencia entre conocer el camino y andar el camino.»

The Matrix, 1999

Por eso, cuando quiero aprender un nuevo lenguaje, lo que hago es plantear algún problema y escribir un programa que lo solucione. Es una vez finalizado cuando estudio con calma la documentación, y todas aquellas características que no tuve necesidad de utilizar. Sin pretender abarcarlo todo, consigo familiarizarme con los aspectos más básicos al tiempo que evalúo su facilidad de uso.

1. La solución a una necesidad

Me gusta trabajar con ruido de fondo, bien sea música o películas, y con tiempo y bastante esfuerzo he acumulado una colección considerable. El problema viene cuando elijo algo para poner, pues en ocasiones me lleva más tiempo del que debería ser necesario. Cuando me crucé con Rust, pensé en escribir un programa que «decidiese» por mí.

Los detalles concretos de la implementación los iré explicando según avance el artículo, pero sí quiero indicar ahora algunos temas importantes que se trataron durante el desarrollo:

  • Creación de programas.
  • Estructuras de control.
  • Estructuras de datos.
  • Control de errores.
  • Acceso a la línea de comandos.
  • Acceso al sistema de archivos.
  • Números pseudoaleatorios.
  • Ejecución de otros programas.

Como dije antes no es algo exhaustivo, pero servirá perfectamente a modo de introducción, y nos podremos familiarizar con elementos básicos del lenguaje que utilizaremos casi constantemente.

2. Un nuevo proyecto

La mejor forma de ponernos en marcha con Rust es utilizando Cargo.

$ cargo new decider --bin
He bautizado a mi pequeño proyecto como decider.

Ahora no quiero alargarme con «trivialidades», así que tendréis que confiar en mí y hacerlo como indico, pero más adelante os animo a revisar la documentación, pues allí se encuentra todo lo que hay que saber sobre Cargo. En este momento lo más importante es que ya tenemos una base de la que partir.

3. La línea de comandos

Para que el programa haga lo que queremos, vamos a tener que pasarle diversos argumentos a través de la línea de comandos, y necesitaremos saber cómo acceder a ellos más tarde.

use std::env;

fn main() {
    for argument in env::args() {
        println!("{}", argument);
    }
}
Un ejemplo muy básico traído de la documentación.

En apenas cuatro líneas se presentan diferentes aspectos del lenguaje que son de cierta relevancia:

Ya solo faltaría sustituir la macro por una estructura de decisión con la que analizar cada argumento, pero hay detalles importantes que deben ser tratados antes.

3.1. Variables en Rust

Casi todo lo concerniente a las variables nos os cogerá por sorpresa: tipado, inicialización, ámbito... Sin embargo la mutabilidad no recuerdo haberla visto en ningún otro lenguaje de los que conozco.

Resulta que cuando declaramos una variable, por defecto no se establece que ésta pueda ser modificada. Como indican en la documentación no hay una razón concreta para que esto sea así, y podemos pensar en ello como una medida de seguridad.

let x = 0;
x = 1; // Esto no va a compilar.
No se podrá cambiar el valor de la variable más adelante.

De esta forma se evita modificar accidentalmente una variable cuyo valor deba permanecer inalterado, aunque no es lo mismo que una constante. Una constante es solo un nombre que se le pone a un valor conocido ya en la fase de escritura del código, una variable «inmutable» es algo totalmente diferente: por el momento me limitaré a confiar en el criterio del diseñador de Rust.

Cuando sepamos que una variable tendrá que ser modificada durante la ejecución del programa, tendremos que declararla indicando esa necesidad, y por supuesto Rust proporciona la forma de hacerlo.

let mut x = 0;
x = 1; // Todo correcto esta vez.
Ahora ya no habrá problema en asignar nuevos valores a la variable.

Por cierto, aunque en los ejemplos que he puesto inicializo la variable con un valor fijo, no quiero confundir a nadie, así que me falta aclarar que podemos definir una variable inicializándola con el valor devuelto por una función en tiempo de ejecución.

3.2. Opciones para decider

Antes de ver cómo se procesará finalmente la línea de comandos, habría que definir lo que va a contener y lo que cada una de las opciones supone de cara al usuario:

  • ¿En qué directorio va a actuar el programa?: por defecto lo hará sobre el directorio desde el que sea llamado, como es habitual en un programa de línea de comandos, pero se facilitará una opción para poder modificar este comportamiento predefinido.
  • ¿Cuántos resultados va a mostrar?: mostrará un único resultado, pero si se lo decimos podrá mostrar hasta cinco (tampoco queremos tener dudas para elegir entre las propuestas).
  • Si se va a mostrar un solo resultado, ¿queremos pasárselo directamente a otro programa?: en lugar de imprimir en pantalla el nombre de, digamos, una película, podría ser buena idea pasarle el fichero elegido a un reproductor. De esta forma nos ahorramos hacerlo «a mano».

3.3. «Cajas» para la línea de comandos

No sé a vosotros, pero a mí no me gusta «reinventar la rueda» con cada nuevo programa, de modo que si alguien proporciona una librería que cubra mis necesidades, simplemente la utilizo. Estas librerías en Rust se llaman crates, y hay miles a nuestra disposición.

Para procesar la línea de comandos utilizaremos getopts, y lo primero que tendremos que modificar para poder acceder a sus funciones es el fichero Cargo.toml.

[dependencies]
getopts = "0.2"
Con esto, Cargo sabrá cómo compilar nuestro programa.

Ahora que ya hemos definido la dependencia hay que usarla. Si tomamos como base el primer ejemplo donde se trató con la línea de comandos, las cosas quedarían más o menos como sigue:

extern crate getopts; // Vamos a utilizar la librería getopts.

use getopts::Options; // De getopts nos interesa el módulo Options.
use std::env;

fn main() {
    let mut options = Options::new();

    options.optflag("h", "help", "prints this help screen");
    options.optopt("i", "input", "defines the input directory", "DIR");
    options.optopt("c", "count", "how many files should we get?", "COUNT");
    options.optflag("p", "play", "should we play the selected file?");

    let parsed = match options.parse(env::args()) {
        Ok(p) => {p}
        Err(e) => {panic!(e.to_string())}
    };

    // ...

}
Una pequeña parte del código que va a procesar la línea de comandos.

Se ha eliminado el bucle que teníamos, y en su lugar tenemos las funciones y estructuras que nos proporciona getopts para definir y analizar la línea de comandos. Pronto veremos cómo utilizar el resultado de todo ello.

4. Directorios y ficheros

Antes de escribir el código que realmente va a procesar la línea de comandos, sería necesario ver la forma en que se puede trabajar con el sistema de archivos. Si repasamos la librería estándar vamos a encontrar el módulo fs, que es donde está la función read_dir(). Ésta devuelve un iterador con todo lo que pueda contener el directorio que le indiquemos, sin importar que sean ficheros u otros directorios.

let paths = match fs::read_dir(directory) {
    Ok(p) => {p}
    Err(e) => {panic!(e.to_string())}
};
Ahora tenemos un iterador con TODAS las entradas del directorio indicado.

Sin embargo nosotros no queremos todo lo que en un directorio pueda haber, sino únicamente la música y los vídeos, de modo que habrá que descartar todo aquello que no nos interese. En Rust, los iteradores tienen implementadas una serie de funciones llamadas adaptadores que sirven justo para eso: vamos a crear un vector filtrando todo el contenido del iterador mediante una clausura.

let entries: Vec<_> = paths
    .filter(|entry|
        entry.as_ref().unwrap().path().is_file() && (
            entry.as_ref().unwrap().path().extension().unwrap() == "mp4"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "avi"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "mov"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "mkv"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "flv"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "wmv"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "mpg"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "mpeg" ||
            entry.as_ref().unwrap().path().extension().unwrap() == "flac" ||
            entry.as_ref().unwrap().path().extension().unwrap() == "mp3"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "wav"  ||
            entry.as_ref().unwrap().path().extension().unwrap() == "ogg"
        )
    )
    .collect();
Filtramos el iterador y nos quedamos SOLO con los ficheros multimedia.

Ya estamos cerca de nuestro objetivo: solo falta saber cuántos ficheros hay que elegir y qué hacer con ellos.

5. Gestión de errores

Dada la cantidad de veces que va a aparecer este código en el programa, me parece un buen momento para hablar brevemente sobre la gestión de errores, y lo vamos a hacer con parte del código que obtiene información de la línea de comandos.

let count = parsed.opt_str("c");
let count = match count.as_ref().map(String::as_ref) {
    Some(x) => {x}
    None => {"1"}
};

let count = match count.parse::<i32>() {
    Ok(c) => {c}
    Err(e) => {panic!(e.to_string())}
};
De esta forma conseguimos el número de ficheros que se quieren mostrar.

La gestión de errores en Rust se puede dividir en dos grandes grupos: valores de retorno y excepciones. Los primeros se utilizan para funciones que pueden devolver «algo» o no devolver nada, y las segundas serían los errores propiamente dichos.

5.1. Valores de retorno

Hay muchas funciones que devuelven un elemento de tipo Option, que se define de la siguiente manera:

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

Dentro de dicha estructura, como se puede apreciar, puede haber un elemento de tipo T o no haber nada. Para poder determinarlo con seguridad podemos utilizar la expresión match.

let count = parsed.opt_str("c");
let count = match count.as_ref().map(String::as_ref) {
    Some(x) => {x}
    None => {"1"}
};

De esta forma extraemos el valor que nos interesa, establecemos uno por defecto o incluso podemos generar un error que paralice la ejecución del programa, pero no se me ocurre ahora una situación que pueda obligarnos a ello si una función no devuelve ningún dato.

5.2. Excepciones

También encontraremos funciones que devuelven un elemento de tipo Result, definido así:

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

Ahora la cosa cambia un poco. Si todo va bien, la estructura contendrá un elemento de tipo T, y si se ha producido algún fallo, lo que habrá será un error de tipo E. Igual que antes podríamos definir un valor por defecto en caso de error, aunque ahora sí que es mejor detener el programa si algo ha ido mal.

let count = match count.parse::<i32>() {
    Ok(c) => {c}
    Err(e) => {panic!(e.to_string())}
};

La gestión de errores es mucho más amplia que esta pincelada que he presentado, por eso más adelante deberíais estudiar esa parte de la documentación, pero al menos ahora ya tenemos lo más básico para seguir adelante.

6. Terminando el programa

Ya tenemos casi todas las piezas para el programa: hemos visto cómo crear un nuevo proyecto; hemos visto cómo trabajar con la línea de comandos, y hemos analizado las opciones que necesitaremos; hemos tratado el uso de librerías externas; se ha hablado del acceso al sistema de archivos, y en relación a él hemos visto cómo filtrar el contenido de un iterador; y finalmente se ha visto ligeramente la gestión de errores.

Solo nos faltaría comprobar cómo generar un número aleatorio (lo que podemos ver muy bien explicado en la documentación) y cómo ejecutar un comando externo desde nuestro código. Ésto último lo haremos con el constructor de procesos que nos proporciona la librería estándar.

Para nuestras necesidades no nos hacen falta la mayor parte de las posibilidades que nos ofrece el módulo process, y solo vamos a utilizar Command de la manera más básica: le indicamos el nombre de un reproductor y un fichero a reproducir.

Command::new("vlc")
    .arg(selected.to_str().unwrap())
    .spawn()
    .expect("Command Not Found");
Utilizo vlc por una mera cuestión de preferencias personales, podéis utilizar cualquier otro que tengáis instalado.

Casi se explica solo: creamos un proceso que va a llamar al programa vlc, le pasamos como argumento un fichero previamente seleccionado y lo ejecutamos. En caso de que no se encuentre el reproductor se mostraría el error correspondiente.

Y en líneas generales, esto sería más o menos todo. El código completo del programa lo podéis encontrar en GitHub, y si queréis experimentar con él se puede obtener fácilmente, bien sea descargándolo desde la página web o clonándolo:

$ git clone https://github.com/ylabs82/decider

7. Consideraciones finales

El programa no está realmente completo. Si se compila y se hacen unas pruebas se puede comprobar fácilmente que tiene algún error y varias cosas que se podrían mejorar:

  • Si ejecutamos el programa indicando una ruta donde solo existan ficheros sin extensión, terminará con un fallo no interceptado (el error está provocado por la clausura del filtro).
  • Muchas veces, si intento que elija al azar más de un fichero, me muestra opciones duplicadas.
  • Así como para ficheros de vídeo cumple su cometido, no es muy efectivo para la música, donde sería mejor que seleccionase carpetas completas en lugar de ficheros aislados.
  • No se utiliza ningún tipo de exploración recursiva de directorios, de modo que según tengamos organizado el contenido multimedia, la utilidad del programa podría verse seriamente afectada (si es que acaso alguien lo llega a considerar útil).

Son cosas que iré revisando en un futuro, pero no porque el programa merezca tanta atención, sino más bien por seguir aprendiendo a programar en Rust.

También debo insistir, por si plagar el artículo de enlaces no ha sido suficiente, que leáis la documentación. Está bien saltar adelante y atrás mientras buscamos algo concreto al escribir un programa, como he hecho yo con decider, pero al final hay que leerla, si no toda sí gran parte. De lo contrario se pueden quedar por el camino características que podrían facilitar mucho la escritura de código, como seguramente me habrá ocurrido en esta primera aproximación.

0 comentarios

Añadir comentario