Una introducción a Rust

Desde hace algún tiempo Rust ha estado ganando popularidad entre desarrolladores, siendo el lenguaje más apreciado los últimos cinco años según las estadísticas de stackoverflow. Pero otros índices, como el de Tiobe, lo posicionan por debajo de los veinte lenguajes más usados. Es además dificil encontrar programadores actualmente trabajando con este lenguaje, o empresas utilizándolo en producción.

¿Qué significa todo esto? Mientras que Rust es reconocido como una herramienta de valor por parte de la comunidad de desarrolladores, su integración en la industria está requiriendo tiempo, quizás debido a su especialización y particularidades. ¿Por qué, entonces, invertir tiempo en un lenguaje así? ¿Cómo pinta el futuro de Rust? Para responder a todas estas cuestiones echaremos un vistazo al lenguaje, su sintaxis, sus funcionalidades fundamentales y cómo puede ser utilizado para aplicaciones web básicas.

Características

Rust se originó en el equipo de Mozilla como el proyecto personal de uno de sus desarrolladores, Graydon Hoare. Desde entonces ha evolucionado hasta convertirse en un proyecto soportado por una amplia comunidad de programadores, con la Rust Foundation como sus principal órgano de gobierno.

Tiene algunas características que, juntas, lo hacen diferente a otros lenguajes:

  • Es un lenguaje compilado.
  • Es de tipado fuerte, con tipado nominativo para structs y estructural para tuplas.
  • Carece de recolector de basura, usando un «verificador de préstamos» —«borrow checker»— en su lugar.
  • Provee de punteros/referencias para el stack, y punteros avanzados para el heap.
  • Está concebido tanto para OOP como para programación funcional.
  • As agnóstico respecto del entorno de ejecución asíncrono que se use.
  • Tiene una suite de tests integrada en el lenguaje.

Veamos cada punto para ver qué puede ofrecernos cada característica.

Siendo un lenguaje compilado, Rust nos ofrecerá gran cantidad de información sobre el uso de memoria de nuestro programa antes de la ejecución del mismo. Haciendo uso de estructuras fuertemente tipadas Rust garantiza que, una vez que el programa compila, es seguro en cuanto a uso de memoria. Es importante mencionar que usa tipado nominativo para structs y estructural para tuplas: esto quiere decir que dos instancias de structs diferentes tendrán diferente tipo para el compilador incluso cuando esos structs tengan una forma idéntica; lo contrario sucede con las tuplas: dos tuplas tendrán el mismo tipo siempre que tengan la misma forma.

Por supuesto, nosotros los programadores podremos cometer errores de muchas otras maneras, especialmente en cuanto a lógica de negocio se refiere; pero el compilador nos asegura que, estructuralmente, nuestro programa será tan eficiente y coherente como sea posible.

Esto sucede principalmente por el hecho de que Rust no depende de un sistema de recolección de basura. Lenguajes como Java, Python o Javascript dependen de un recolector de basura para manejar la memoria y eliminar variables que quedan en desuso. En otros lenguajes, como C o C++, se requiere manejar los recursos manualmente; pero en Rust tenemos un «comprobador de préstamos», esto es, un sistema que se asegura en tiempo de ejecución que cuando una variable queda fuera de uso, esta es eliminada.

Esta funcionalidad es automática para todos los valores cuyo tamaño sea conocido de antemano, almacenándose en el stack. Podemos hacer además uso de punteros al estilo C —(*&)— para referenciarlos y deferenciarlos; siempre con algunos límites impuestos por Rust para evitar acciones inseguras con la memoria.

Para aquellos otros valores cuyo tamaño no es conocido de antemano por el compilador, y que tienen que ser almacenaos en el heap, como por ejemplo los arrays —«vectores» en Rust—, el lenguaje nos ofrecerá estructuras específicas —Box, Rc, Arc, etc.—para envolverlos y asegurar que serán elminados lo más pronto posible.

Una de las principales funcionalidades de Rust es el retorno a una versión simplificada de las estructuras al estilo C, en oposición a las clases a las que lenguajes como C++ o Java nos tienen acostumbrados. Aquí encontraremos tres términos clave relacionados con estas estructuras: struct, impl y trait. Podemos definir un nuevo struct con struct; después de definirlo, podemos añadirle métodos usando impl. Finalmente, en caso que queramos describir un comportamiento común para structs diferentes podremos hacer uso de trait, que viene a ser algo similar a lo que en otros lenguajes se denominan «interfaces». Todo esto se vé más claro con un ejemplo:

// Podemos definir un struct para Persona
⁠// con un nombre que podrá ser un String
⁠struct Persona {
nombre: String,
⁠}

⁠// Igualmente podemos definir un struc para Gato
⁠// también con un nombre String
⁠struct Gato {
nombre: String,
⁠}

⁠// Cada ser vivo puede realizar ciertas acciones.
⁠// Por ejemplo, presentarse
⁠trait SerVivoAcciones {
fn presentarse(&self);
⁠}

⁠// La persona puede presentarse verbalmente
⁠impl SerVivoAcciones for Persona {
fn presentarse(&self) {
println!("- {}: Hola! Es un placer :)", &self.nombre);
}
⁠}

⁠// El gato también puede presentarse, pero… al estilo gatuno
⁠impl SerVivoAcciones for Gato {
fn presentarse(&self) {
println!("- {}: Mrrrrr… Miaaou", &self.nombre);
}
⁠}

⁠// Podemos instanciar una Persona y un Gato
⁠// y hacer que se presenten
⁠fn main() {
let fulanito = Persona {
nombre: String::from("Fulanito"),
};
let minino = Gato {
nombre: String::from("Minino"),
};
fulanito.presentarse(); // - Fulanito: Hola! Es un placer :)
minino.presentarse(); // - Minino: Mrrrrr… Miaaou
⁠}

Sintaxis de Rust: struct, trait, and impl: pruébame en el playground de Rust 

Este uso de structs en lugar de clases evita que escribamos estructuras muy anidadas, favoreciendo la composicion de elementos sobre las herencias. Rust nos permitirá escribir código tanto orientado a objetos como funcional, pero con ciertos límites en ambos casos. Es perfectamente posible usar patrones clásicos de programación orientada a objetos como la inyección de dependencias o funcionalidades como el polimorfismo, así como también lo es escribir código funcional con clausuras sin mutación de estado. Ambas aproximaciones encajan con naturalidad, aunque con algunas restricciones que nos inducirán a escribir estructuras más atómicas y legibles. El código resultante tendrá una sintaxis algo más compleja que en otros lenguajes, pero con estructuras planas y sencillas que, al final, harán el código más fácil de leer y entender.

Rust se distribuye con una librería estándar que provee de diferentes funcionalidades. Entre ellas se encuentran las herramientas de threading, que nos permiten escribir programas concurrents. Esto, con la ayuda del compilador, el tipado estático, el verificador de préstamos y los punteros avanzados nos asegurarán que nuestros programas concurrentes serán seuros en cuanto a memoria se refiere, incluso en caso de multi-threading.

Una funcionalidad interesante de Rust es que no trae un tiempo de ejecución asíncrono integrado. Para disponer de uno vamos a tener que elegir e instalar uno externo: una opción bastante popular es Tokio, que se puede encontrar en el repositorio oficial de Rust, aunque tenemos más opciones entre las que elegir.

Los tests se escriben con la suite que Rust trae integrada. Es posible escribir tanto tests unitarios para cada módulo como tests de integraciíon para toda la aplicación. Tiene unas funcionalidades muy concretas, pero muy claras y coherentes con la filosofía del lenguaje.

Trabajando con Rust viniendo de lenguajes tipo Java puede generar algo de confusión, pues hay un cambio de paradigma considerable; sin embargo, una vez que esta primera fase es superada, el lenguaje se escribe con asombrosa naturalidad. Tiene similitudes con C y C++, pero con una sintaxis muchísimo más limpia. Dadas las herramientas que nos ofrece para manejo de memoria y genéricos bien se podría describir Rust como «una versión declarativa de C».

Rust en práctica

Para demostrar cómo se podría estructurar un progama en Rust podemos vamos a crear una aplicación sencilla, simplemente para visualizar la sintaxis del lenguaje.

Podríamos, por ejemplo, escribir un programa al que pudieramos pedir una lista de usuarios que estuviera almacenada en algún tipo de base de datos, digamos que en PostgreSQL. Y además, queremos que nos dé esta lista en sentido inverso al que está en la base de datos.

De este modo nuestro programa tendría varios conceptos, a saber:

  • Un usuario User, que sería el modelo: un struct.
  • El proceso de invertir la lista, que correspondería a la lógica de negocio: esto sería un «caso de uso», que podríamos escribir en un struct. Este caso de uso utilizará el módulo que escribamos para recoger los datos de la base de datos, el cual tendrá que cumplir con un trait que vamos a describir en el siguiente punto.
  • Y finalmente, el sistema para recoger los datos de la base de datos en PostgreSQL, que será otro struct. Para evitar acoplar el caso de uso con el repositorio de PostgreSQL escribiremos un trait con la descripción general de nuestro struct para la base de datos, y que le pasaremos al caso de uso. Así, cualquier repositorio que queramos utilizar con nuestro caso de uso tendrá que cumplir con el trait, desacoplando el caso del uso del repositorio y haciendo más fácil cambiar de PostgreSQL a otro tipo de base de datos en el futuro.

Estructura de nuestra aplicación

Ahora que nos hemos hecho una idea de cómo deberíaser nuestra aplicación, vamos a implementarla. Omitiremos alguna línea de código necesaria para que nuestro programa complile, pero el ejemplo completo se puede ver en el playground de Rust.

Primero, vamos a crear nuestra entidad o modelo: un usuario User con un campo id:

pub struct User {
pub id: i8,
⁠}

 Struct para nuestro modelo User

Ahora que tenemos nuestro modelo principal —un struct—, la siguiente cuestión es qué queremos hacer con él. En otras palabras: queremos definir el caso de uso para recoger la lista de usuarios en sentido inverso. Sabemos que este caso de uso tendrá que tener una instancia del repositorio para recoger los datos de la base de datos, pero no sabemos todavía el tipo del repositorio: esto es una tarea para un tipo genérico:

struct GetUsersRevertedUseCase<T> {
repository: T,
⁠}

 Struct para nuestro caso de uso GetUsersRevertedUseCase

Nuestro caso de uso —también un struct— infiere el tipo del repositorio cuando se la pasamos al instanciarlo. Pero un struct no define acciones, y nuestro caso de uso precisamente tiene que realizar las acciones definidas por la lógica de negocio de nuestro programa. Como decíamos anteriormente, esto lo haremos implementando los métodos necesarios con el término impl:

impl<T: Repository> GetUsersRevertedUseCase<T> {
fn new(repository: T) -> GetUsersRevertedUseCase<T> {
GetUsersRevertedUseCase { repository }
}
⁠}

 Implementando new para nuestro caso de uso

Digno de mención aquí es la sintaxis <T: Repository>. Con esto le estamos diciendo al compliador que T puede ser cualquier struct implementando el trait Repository, devolviendo un error si pasamos cualquier otro tipo de instancia. Además, hemos añadido un método new que devuelve una instancia de sí mismo pero con la instancia del repositorio en su interior. Así que ya tenemos nuestro caso de uso con la capacidad de recibir un repositorio, siempre que este cumpla con los requerimientos de un determinado trait, el cual tenemos pendiente.

Veamos cómo podemos definir el tipo de repositorio que necesitamos. Sabemos que tiene que realizar una acción asíncrona —recoger datos de una base de datos—, y que tiene que devolver los datos que ha recibido —una lista de usuarios—. Podemos escribir un trait con estas especificaciones de la siguiente manera:

trait Repository {
async fn get_users(&self) -> Result<Vec<User>, String>;
⁠}

 Trait para nuestros repositorios

Hay algunas cosas nuevas aquí: primero, el término async, el cual hace que nuestra fución devuelva un tipo Result. Este tipo es una de las particularidades de Rust: representa el resultado de una acción que puede tener éxito —Ok(T)— o sufrir un error —Err(K)—. En nuestro caso, si la acción definida por nuestro método tiene éxito, tendrá como resultado Ok<Vec<User>>, esto es, una lista de ususarios User; por el contrario, si nuestra acción fracasa, devolverá Err(String). Para no liarnos mucho no vamos a implementar ningún error, pero el tipo Result nos obliga a tener esta posibilidad en consideración, puesto que estamos escribiendo código asíncrono. Esto es una de las ventajas de Rust: no nos deja ser perezosos con los tipos.

Ahora podemos pensar en cómo podemos lanzar nuestro caso de uso, para lo cual podemos añadirle un método execute.

impl<T: Repository> GetUsersRevertedUseCase<T> {
[…]
async fn execute(&self) -> Result<Vec<User>, String> {
let users = self.repository.get_users().await.unwrap();
let reverted_users: Vec<User> = users.into_iter().rev().collect();
Ok(reverted_users)
}
⁠}

 Implementando execute para nuestro caso de uso

Ah, el término async otra vez, esta vez para esperar a que la acción de nuestro repositorio termine. También está el tipo Result, esto empieza a ser familiar.

Como nuestro repositorio está devolviendo un tipo ResultOk<T> o Err<K>—, necesitamos extraer el valor —la lista de usuarios— de nuestro Ok<T> para trabajar con ella. Esto lo hacemos con la función unwrap(), que nos devuelve el valor T —nuestra lista de usuarios— si estamos en un Ok, o devolviendo un error si fracasa.

Además tenemos algunas funciones operando sobre nuestra lista de usuarios para invertirla: into_iter, que convierte un vector en una lista de elementos que podamos iterar; rev, que invierte una lista de elementos iterable, y collect, que convierte una lista iterable de nuevo en un vector. Estas funcionalidades pertenecen todas al tipo Vec, que está integrado en Rust para manipular vectores al estilo funcional.

Vale, ahora ya tenemos nuestro modelo, nuestro caso de uso, y un trait para cualquier repositorio que queramos pasarle. El siguiente paso será crear nuestro repositorio para el tipo concreto de base de datos que queramos utilizar: PostgreSQL:

impl Repository for PostgreSQLRepository {
async fn get_users(&self) -> Result<Vec<User>, String> {
⁠ let duration_secs = Duration::from_secs(2);
⁠ sleep(duration_secs);
⁠ let user_1 = User { id: 1 };
⁠ let user_2 = User { id: 2 };
⁠ Ok(vec![user_1, user_2])
⁠ }
⁠}

 Struct PostgreSQLRepository

Como esto es sólo una demostración simplemente vamos a simular que vamos a recoger datos de manera asíncrona de nuestra supuesta base de datos. Para ello usaremos dos métodos de la librería estándar: sleep y Duration. Duration devuelve un tipo específico de tiempo, que le podremos pasar a otros métodos, como por ejemplo sleep. Y sleep simplemente detendrá la ejecución del programa por el tiempo que le hayamos pasado. En nuestro caso estamos deteniendo nuestro programa por dos segundos, entonces instanciamos dos usuarios, y finalmente estamos devolviendo un Vector con ellos.

Ahora disponemos ya de todos los elementos necesarios para nuestro programa: el modelo User, el caso de uso GetUsersRevertedUseCase, el trait Repository y el struct PostgreSQLRepository que lo implementa. Sólo nos falta crear un punto de entrada para nuestro programa y hacer uso de ellos.

#[tokio::main]
⁠async fn main() {
⁠ let repository = PostgreSQLRepository;
⁠ let use_case = GetUsersRevertedUseCase::new(repository);
⁠ let users = use_case.execute().await.unwrap();
⁠ println!("{:#?}", users);
⁠}

Punto de entrada de nuestro programa

Como queremos ejecutar código asíncrono necesitaremos traer un tiempo de ejecución que nos lo facilite: vamos a usar Tokio, así que podemos llamar al macro #[tokio:main].

Después no tenemos más que crear una instancia de nuestro repositorio, construir nuestro caso de uso pasándole la instancia que acabamos de crear, y ejecutarla, esperándola con await. El resultado será una lista de usuarios en sentido inverso.

[
⁠ User {
⁠ id: 2,
⁠ },
⁠ User {
⁠ id: 1,
⁠ },
⁠]

Resultado: lista de usuarios en sentido inverso

Podemos ver este código completo en el playground de Rust, donde podemos compilarlo y ejecutarlo.

Conclusión

Rust no es un lenguaje sencillo. Exige estudio y bastante paciencia para entender su sintaxis y las funcionalidades que nos ofrece. Además es muy joven, y su ecositema está en constante evolución. Pero promete.

Es en tareas que requieren eficiencia donde el lenguaje encaja perfectamente. Para desarrollo web en términos generales, donde la velocidad de prototipado y se requieren dinámicas de trabajo agile son importantes, lenguajes interpretados como Python o TypeScript encajan mejor. Pero cuando pérdidas de memoria puedan provocar repuntes o problemas de eficiencia en uso de servidores, Rust es una opción mucho mejor.

La aceptación actual de Rust en la industria es muy limitada, pero la Rust Foundatio es soportada por Google, Microsoft, AWS, Huawei y Mozilla; además hay una clara demanda para lenguajes enfocados a eficiencia, y probablemente veremos cómo logra introducirse en los próximos años.

En cualquier caso hay numerosas dificultades: las empresas que dan soporte a Rust intentarán influenciar sobre su desarrollo  para que evolucione según sus necesidades particulares, y hay claros desacuerdos entre el equipo interno. Pero el lenguaje encaja tan bien en las tareas que hemos mencionado que sería interesante seguir su evolución e integración en la industria.

Para concluir: Rust es un lenguaje complejo, pero es posible y claramente deseable usar Rust en contextos específicos donde performance y seguridad es un requerimiento.