Rust: una alternativa a C

Rust: una alternativa a C

En la amplia gama de lenguajes de programación que existen, hay, al menos, dos grupos: los de bajo nivel y los de alto nivel. Los primeros son eficientes y le dan un mayor control al programador, aunque sacrifican la facilidad de uso (por ejemplo, C y C++); los segundos, en cambio, tienden a tener una sintaxis amigable y una curva de aprendizaje rápida, pero merma su rendimiento y seguridad (Python). Como lenguaje predominante del primer grupo se encuentra C. No obstante, ha surgido hace poco un nuevo competidor: Rust.

Este texto comienza con una introducción a la programación de sistema para entender de qué trata y cuál es su importancia. Luego conoceremos a Rust, un moderno lenguaje de programación que brinda una real alternativa a C. Más tarde, presentaremos varios ejemplos de código que hacen evidentes las ventajas de Rust sobre C, aunque con un costo: dado que es un lenguaje que busca la seguridad, requiere que el programador explicite muchas cuestiones que tienen que ver con la gestión de memoria. Finalizaremos con algunas conclusiones sobre el futuro de Rust.

Programación de sistemas

Hablar de lenguajes de bajo nivel y de alto nivel no es lo más preciso. Podría considerarse anticuado. Sería mejor decir lenguajes de tipado estáticos y de tipado dinámico, en todo caso. Un lenguaje de programación de tipado estático, como C o C++ o el que introduciremos en este artículo: Rust, tienden a ser una mejor alternativa para la programación de sistemas (a saber, desarrollo de sistemas operativos, kernel, software para microcontroladores, compiladores, etcétera. En suma: un software que permite dar soportar a otro software) que uno interpretado, por las siguientes razones:

  1. Es más eficiente: el código puede ejecutarse en menos tiempo. Dado que una vez que una variable define su tipo, ésta no cambia en tiempo de ejecución, permitiendo así una mayor optimización –previa a la ejecución– de parte del compilador.
  2. Los ejecutables consumen menos recursos (espacio de disco y memoria). Fundamental en hardware que no tienen muchos recursos (por ejemplo, Raspberry Pi).

Igualmente, un lenguaje como Python no es una opción seria para la programación de sistema. Esto, en particular, porque su sistema de tipos es dinámico (ejemplo: una variable puede cambiar de valor numérico a una lista de elementos [o viceversa] en tiempo de ejecución) lo que lo hace propenso a errores que, si el programador no es cuidadoso, puede costarle mucho tiempo en detectar (sin contar lo perjudicial para el rendimiento). Y nadie que construya una pieza de software tan importante, ya sea un driver o un sistema operativo, desea eso en sus partes críticas.

Nota: un lenguaje de programación no es su implementación. Es decir, no es correcto decir “lenguaje compilado” o “lenguaje interpretado”, pues ambas cuestiones tienen que ver con su implementación más que con una característica propia. En cambio, decir que un lenguaje posee un tipado estático o dinámico sí es adecuado. Ejemplo: pypy es un compilador JIT (Just-In-Time) para Python; para C++ existe el intérprete Cling. Tenga presente que, si un lenguaje tiene tipado estático, es casi seguro que su implementación más popular será un compilador, para así aplicar mayores optimizaciones en tiempo de compilación (lo mismo ocurre a la inversa con los lenguajes de tipado dinámico).

Desde hace algunas décadas, el mundo de la programación de bajo nivel ha sido dominado, primero por C y luego por C++. Entre los sistemas destacados de esta clase –escritos en C– encontramos al kernel Linux, el gestor de versiones Git, el sistema operativo UNIX o el software a bordo de los coches autónomos de Tesla. Todos ellos funcionan debajo de otro software, a modo de soporte.

Un poco de historia: C surge en los Laboratorios Bell de la mano de Dennis Ritchie (científico de la computación) a principio de la década de 1970. Fue la evolución del lenguaje B (lenguaje diseñado unos años antes por el mismo Ritchie y Ken Thompson) y se creó para desarrollar UNIX (dado las limitaciones de escribir directamente en Assembly y B [éste, por ejemplo, solo tenía un tipo de dato]). 

Si revisamos los proyectos exitosos que se han escrito en C, ¿por qué aún hoy surgen competidores?

Introducción a Rust

Rust es un lenguaje de programación con un sistema de tipo estático. Su principal implementación es un compilador –hasta aquí similar a C–. Sin embargo, contiene varias mejoras sobre éste.

A continuación, describo las que considero relevantes (véase la Figura 1).

Un poco de historia: Mientras era ingeniero de Mozilla (en el área de investigación) en 2006, Graydon Hoare inició el proyecto interno que derivaría en Rust. La primera versión del compilador fue escrita en OCaml. Luego, por temas de rendimiento, pasaron a LLVM y, para 2011, nació la primera versión pública, aunque aún no era estable. No sería hasta 2014 que se presentaría la primera versión (1.0). Hoare dijo que los motivos que lo llevaron a crear Rust son dos: mayor seguridad y tratamiento de concurrencia más afable, detalles que en C y C++ no suelen ser simples. Él mismo agrega: “La audiencia objetivo [para usar Rust] son los programadores de C++ frustrados [...] que necesiten escribir código seguro sin tanto dolor”.

Nota: Puedes ver las instrucciones de instalación de Rust –según tu sistema operativo– en este enlace. La versión del compilador de Rust ocupada en este artículo es la 1.65.).

Figura 1. Cuatro ventajas que se suelen atribuir a Rust.

Usando Cargo

Cargo es el gestor de proyectos y paquetes de Rust, incluido con la instalación de Rust. A través de éste podemos crear proyectos y añadir o eliminar dependencias de los proyectos que hayamos creado. Así, cada proyecto que iniciemos podría tener distintas versiones de una misma biblioteca sin caer en conflictos.

En la terminal podemos crear un proyecto de esta manera:

> cargo new primer-proyecto

Created binary (application) `primer-proyecto` package

Esto generaría un directorio con el nombre del proyecto, un fichero Cargo.toml y una carpeta src dentro.

> cd primer-proyecto

> ls 

Cargo.toml src


El fichero Cargo.toml contiene todas las dependencias que tendrá nuestro proyecto. Así, si alguien necesita que reproduzcamos su proyecto en otro computador, tan solo requeriría que nos entregará su fichero Cargo.toml y luego hacer un cargo build para descargar todas las dependencias.

Dentro de la carpeta src encontramos un fichero de “partida” que, al igual que C, se llama main:

> ls src/

main.rs


Como pudiste notar, la extensión de un fichero de Rust es rs.

Finalmente, para ejecutar el fichero main.rs, se puede hacer con el comando: cargo run.

> cargo run

Hello, world!


Si hacemos de nuevo un ls vemos que, después de ejecutar el cargo run, se ha creado una carpeta target y dentro otra carpeta con el modo debug. Ésta contendrá el binario (ejecutable) con el mismo nombre del proyecto.

> cd target/debug

> ./primer-proyecto

Hello, world!

En el último ejemplo de este artículo se mostrará cómo añadir una dependencia con Cargo.

Ventajas de Rust con respecto a C

Lo mínimo que uno espera en un lenguaje moderno que quiera compararse con otro pasado es, sin duda, que pueda mejorar los aspectos que causan frustración y dolores de cabeza a los programadores del lenguaje previo.

Ejemplo 1. Verificación de los índices de un arreglo

El manejo de memoria en C suele ser uno de los puntos delicados y causante de múltiples errores en programas si no se usa con cuidado.

Un problema clásico en C es la creación de un arreglo e intentar acceder a un índice que no existe.

Caso correcto:

#include <stdio.h>

 

int main(int argc, char const *argv[])

{

   const int numeros_primos[] = {2, 3, 5, 7, 13};

   const int SIZE = sizeof(numeros_primos) / sizeof(int);

   for (int i = 0; i < SIZE; ++i){

       printf("%d \n", numeros_primos[i]);

   }   

   return 0;

}


Caso incorrecto: surge cuando intentamos acceder a un índice que no existe (por ejemplo, cambiando el operador “<” a “<=”). El resultado es el siguiente:

> gcc main.c -o ejemplo1

> ./ejemplo1

13 

32764


El último número es incorrecto porque se intenta acceder al índice 5, pero en C (y Rust), los índices comienzan desde 0. Por tanto, el arreglo numeros_primos tiene hasta el índice 4. Algo aún peor sería esto:

for (int i = 0; i <= SIZE + 10; ++i){


Compilamos y ejecutamos:

> gcc main.c -o ejemplo1 && ./ejemplo1

13 

32765 

-1245470208 

1540912055 

-1353468080 

22026 

-1242309497 

32538 

1446578568 

32765 


Los valores correctos son hasta el índice válido: 4 (valor 13). Luego, los demás son valores extraídos de la pila (stack) que no tiene ningún sentido, provocando un comportamiento indefinido.

Esto sucede porque, tanto en C como en C++, no hay validación de los rangos de los índices en los arreglos. En este punto, el compilador le da la responsabilidad al programador de ser precavido.

En Rust ocurre todo lo contrario: el compilador no tan solo verifica el rango de los índices en un arreglo, sino que da más información sobre ello. Aquí el código equivalente (exitoso) en Rust:

fn main() {

    let numeros_primos: [i32; 5] = [2, 3, 5, 7, 13];

    let size: usize = numeros_primeros.len();

    for i in 0..size {

        println!("{}", numeros_primeros[i]);

    }

}

La explicación:

  • La palabra clave let indica que esa variable será inmutable, es decir, una vez asignado el valor no puede ser modificado.
  • El tamaño de un arreglo (en este caso 5), se puede obtener con la función len.  Más interesante aún es que, si en vez de añadir un tipo usize (número positivo que representa el tamaño del arreglo) lo cambiamos a size: i32, el compilador devolverá un error, pues, la función len solo devuelve un usize. Esto indica mayores restricciones en Rust en pos de cometer menos fallas en tiempo de ejecución.

Ahora, el caso interesante es lo que ocurre si intentamos acceder a un índice inválido o inexistente. Primero cambiando la línea del for a: for i in 0..size+10.

> cargo run

3

5

7

13

thread 'main' panicked at 'index out of bounds: the len is 5 but the index is 5', src/main.rs:5:24

note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace


En compilador nos indica que el tamaño del arreglo numeros_primos es de 5 y se está intentando acceder al índice 5 y, dado que comienza desde 0, no nos permite compilar.

Mucho mejor, ¿no?

Ejemplo 2. Manejo de estructuras y paso por referencia

Algo de suma importancia en C es el manejo de estructuras (struct) pues es normal pasarlas como argumentos de una función. Es aquí donde pueden surgir problemas.

Supongamos que necesitamos almacenar la información de libros: título, autor y un identificador. En C, usando una estructura, quedaría de la siguiente forma:

#include <stdio.h>

#include <string.h>


struct Libros {

   char titulo[50];

   char autor[50];

   int identificador;

};


void mostrarLibro(struct Libros libro) {


   printf("Libro título: %s \n", libro.titulo);

   printf("Libro autor: %s \n", libro.autor);

   printf("Libro identificador: %d \n", libro.identificador);

}


int main(int argc, char const *argv[])

{


    struct Libros PrimerLibro;


    strcpy(PrimerLibro.titulo, "Don Quijote de la Mancha");

    strcpy(PrimerLibro.autor, "Miguel de Cervantes"); 

    PrimerLibro.identificador = 1;


    mostrarLibro(PrimerLibro);

    return 0;

}

Entonces, compilamos y ejecutamos:

> gcc main.c -o ejemplo1 && ./ejemplo1

Libro título: Don Quijote de la Mancha 

Libro autor: Miguel de Cervantes 

Libro identificador: 1


La función strcpy copia un valor (segunda argumento) al puntero del primero (atributo de la estructura Libro). Esto puede parecer algo confuso, pero es normal tener este tipo de nombres de funciones en C.

Lo interesante viene ahora: cambiar el valor del título ya asignado. En C, se puede hacer usando la referencia del puntero de una estructura. Primero añadiremos la función:

void modificarTitulo(struct Libros* libro, char titulo[50]){

    strcpy(libro->titulo, titulo);

}

Vemos que ahora el primer argumento es el puntero a la estructura (símbolo “*”). Luego, debemos pasar la referencia de la estructura:

modificarTitulo(&PrimerLibro, "Don Quijote de la Mancha - Edición conmemorativa");


La versión equivalente en Rust presenta varias cuestiones interesantes que hacen a este lenguaje más seguro que C, aunque con un precio: la sintaxis en algunos casos puede volverse compleja de entender.

struct Libros {

    titulo: String,

    autor: String,

    identificador: i32,

}


fn mostrarLibro(libro: Libros) {

    println!("Libro título: {}", libro.titulo);

    println!("Libro autor: {}", libro.autor);

    println!("Libro identificador: {}", libro.identificador);

}


fn modificarTitulo(libro: &mut Libros, titulo: String){

    libro.titulo = titulo;

}


fn main() {


    let mut primerLibro: Libros = Libros { 

                                titulo: String::from("Don Quijote de la Mancha"), 

                                autor: String::from("Miguel de Cervantes"), 

                                identificador: 1 };

    

    modificarTitulo(&mut primerLibro, String::from("Don Quijote de la Mancha - Edición conmemorativa"));

    mostrarLibro(primer_libro);


}


Lo primero distinto que se puede notar es el cambio del tipo de dato de titulo y de autor a String, y es que, en C, no existe el tipo String. Rust maneja las secuencia de caracteres con dos tipos: String y str. El primero es una secuencia de caracteres dinámicos que pueden modificarse. En cambio, str es inmutable. Para crear un String se debe usar la función String::from.

Otra cuestión interesante es la palabra reservada mut. Ésta es clave para saber cuándo un valor puede “mutar”, o sea luego de asignar un valor se puede reasignar a otro.

A la función modificarTitulo, justo antes del tipo de dato (Libros), se le debe añadir &mut. ¿Qué significa? Esta referencia (a la estructura) permite modificar sus atributos. Esto en C se puede hacer simplemente con el signo de referencia “&” y con un puntero. En Rust, en cambio, se debe ser explícito, aunque para nosotros es mejor porque con solo ver mut se puede saber que una variable cambiaría su valor.

Esto que puede parecer interesante y dar claridad sobre C. Ahora, si se ejecuta, el título de la estructura primerLibro cambia.

> cargo run

Libro título: Don Quijote de la Mancha - Edición conmemorativa

Libro autor: Miguel de Cervantes

Libro id: 1


Todo bien. Pero si añadimos esta línea después de la invocación de mostrarLibro…:

println!("{}", primerLibro.titulo);


…encontramos algo curioso:

> cargo run

error[E0382]: borrow of moved value: `primerLibro`

Dicho en otras palabras, el valor de la variable primerLibro se movió a un nuevo propietario (la función mostrarLibro). Esto se entiende si vemos la firma de la función: fn mostrarLibro(libro: Libros). Al primer argumento, la variable libro, no se le pasa una referencia, desplazando el valor. Se soluciona pasando por referencia la variable primerLibro (Levick), añadiendo el símbolo de “&” antes del tipo (no es necesario añadir el mut, pues no se va a modificar ningún atributo de la estructura).

fn mostrarLibro(libro: &Libros) {

    println!("Libro título: {}", libro.titulo);

    println!("Libro autor: {}", libro.autor);

    println!("Libro identificador: {}", libro.identificador);

}

mostrarLibro(&primerLibro);

El diseño de Rust fue pensado para ser muy seguro con la gestión de memoria. Evita cualquier error en la asignación de recursos de una variable en tiempo de ejecución. Por ello, es más estricto en la fase de compilación estática, aunque podría volverse bastante intrincado si no se tiene claro ciertos principios.

Ejemplo 3. Propiedad y movimiento de variables

Rust mejora el manejo de memoria si lo comparamos con C (como lo vimos en los ejemplos anteriores) e incluso el mismo compilador nos entrega más información. No obstante, este manejo de memoria podría volverse –en algunos casos– enrevesado. Véase este ejemplo:

#[derive(Debug)]

struct Persona {

    identificador: i32

}


fn main(){


    let persona = Persona { identificador: 659440 };

    let otra_persona = persona;


    println!("{:?}", otra_persona);

    println!("{:?}", persona);

}


Este código crea una estructura Persona con el identificador 659440. La asigna a la variable persona (en la Figura 2 se puede apreciar cómo sería el estado de la pila). Luego, crea una nueva variable, otra_persona, que le asigna (mueve) el valor de persona y, finalmente, se muestra en pantalla la variable otra_persona y persona.

Figura 2. La pila al momento de crear la variable persona.


Nota: Cada vez que se declara una variable local en cualquier lenguaje de programación, se encuentra en la pila (stack) cuando dicha variable es dinámica (necesita reservar memoria), es decir, puede cambiar en tiempo de ejecución, se encuentra en el heap (ejemplo: estructuras y arreglos). Considera la declaración: “let numero: i32 = 100;”, misma que no se encontrará en el heap, pues los valores están contenidos en la misma pila. 


La macro superior: “#[derive(Debug)]” permite mostrar por pantalla una struct usando un print. De otra forma, se tendría que implementar una forma manual de mostrar cada atributo de Persona.

Sin embargo, al intentar compilar devuelve el siguiente error:

error[E0382]: borrow of moved value: `persona`

  --> src/bin/ejemplo3.rs:12:20

   |

8  |     let persona = Persona { identificador: 659440 };

   |         ------- move occurs because `persona` has type `Persona`, which does not implement the `Copy` trait

9  |     let otra_persona = persona;

   |                       ------- value moved here

...

12 |     print!("{:?}", persona);

   |                    ^^^^^^^ value borrowed here after move

Lo que dice es que al intentar mostrar los valores de la variable persona (último println) ésta se ha movido a otro lugar (cambio de propietario). Y claro, se movió cuando se asignó a la variable otra_persona (véase la Figura 3 para entender qué ocurre con la pila.). Para evitar estos casos, es conveniente activar la copia de la estructura (Klabnik & Nichols; Rust Ownership, Move and Borrow - Part 1).

Figura 3. Movimiento de la variable persona a otra_persona. Ya no se puede acceder a los atributos desde la variable persona porque ha cambiado de propietario.


Lo primero es añadir Copy y Clone en la macro de Persona:

#[derive(Debug, Copy, Clone)]


Luego, el problema se resuelve:

fn main(){


    let persona = Persona { identificador: 659440 };

    let otra_persona = persona;


    print!("{:?} \n", otra_persona);

    print!("{:?} \n", persona);

}


Resultado:

Persona { identificador: 659440 } 

Persona { identificador: 659440 } 

Para copiar de manera segura una estructura se requiere añadir el Copy y Clone. La diferencia entre ambas radica en que hay tipos de datos que pueden ser copiados de manera implícita (como el ejemplo anterior), por lo que no es necesario invocar a la función clone:

let otra_persona = persona.clone();


Sin embargo, si la estructura Persona tuviera un atributo que no soportará la copia implícita (por ejemplo, String), entonces se requiere invocar a la función clone de manera explícita (implementarla manualmente).

Un ejemplo: implementar manualmente la función clone. Entonces, lo primero sería quitar Copy y Clone de la macro (pues lo implementaremos):

#[derive(Debug)]

struct Persona {

   identificador: i32

}


impl Clone for Persona {

    fn clone(&self) -> Persona {

        Self {

            identificador: self.identificador.clone() 

        }

    }

}

let otra_persona = persona.clone();


Comprender cómo funciona la memoria en Rust es, acaso, el mayor reto del lenguaje. Pero aunque pueda parecer duro a ratos, al final presenta una manera más efectiva y segura de escribir código de sistemas. Queda a tu criterio saber si el esfuerzo vale la pena.

Ejemplo 4. Añadiendo dependencias con Cargo

Incorporar nuevas bibliotecas a un proyecto de Rust es simple. Otro ejemplo: añadir la biblioteca rand para generar números aleatorios. Dentro del fichero Cargo.toml, y debajo de [dependencias], añade lo siguiente:

[dependencies]

rand = "0.8.4"

Ahora crea un fichero con este ejemplo:

use rand::prelude::*;


fn main(){

    let numero_aleatorio: u32 = random();

    println!("{}", numero_aleatorio);

}


Luego, ejecuta el comando:

> cargo build


Se comenzará a descargar la biblioteca rand según la versión asignada. Nada más queda ejecutar el cargo run.

> cargo run

2412007460

Conclusión

Rust es seguro y eficiente. En este artículo mostramos cuáles son las ventajas de Rust con respecto a C, transformándolo en una buena alternativa para sistemas donde se desea evitar fallos en la gestión de memoria al contar con un sistema más restrictivo a la hora de manejar el tiempo de vida de las variables. Esto hace que muchos problemas se detecten en la fase de compilación y no en ejecución (como suele ocurrir en C y C++).

No obstante, Rust no es fácil de aprender si lo comparamos con Python (u otro lenguaje de tipado dinámico). Su sintaxis requiere ser explícita para evitar errores. Aunque tenga ventajas sobre C, es un lenguaje que requiere su tiempo para comprenderlo, sobre todo si no has estado expuesto con anterioridad a la gestión manual de memoria.

El futuro de Rust parece prometedor. En diciembre de 2022 –cuando se escribió este artículo–, Rust aparece en la posición 20 de TOEBI (el ranking más popular para medir el impacto de un lenguaje de programación en la industria).

Con todo hay que ser cautelosos. La cantidad de código, o sea, sistemas escritos en C y C++ es enorme y, por más que un lenguaje presente características superiores, la adopción de una herramienta pasa más por otros factores: una robusta comunidad y que empresas importantes lo comiencen a usar (validación).

Recomendaciones de lecturas

Recomiendo hojear –y si lo lees, aún mejor– el libro The Rust Programming Language (Klabnik & Nichols, 2018), para obtener un mayor conocimiento del lenguaje.

Código

Todo el código presentado en este artículo se encuentra en el siguiente repositorio.

Referencias

  • Klabnik, S., & Nichols, C. (2018). The Rust Programming Language. https://lise-henry.github.io/books/trpl2.pdf
  • Levick, R. ·. (2018). Rust: Pass-By-Value or Pass-By-Reference? https://blog.ryanlevick.com/rust-pass-value-or-reference/
  • Rust Ownership, Move and Borrow - Part 1. (2021). Rust Community. https://www.openmymind.net/Rust-Ownership-Move-and-Borrow-part-1/