← Volver

Pensar en Rust: los conceptos que no vienen de otros lenguajes

Una reflexión sobre los conceptos clave de Rust que cambian el modelo mental del desarrollador: ownership, borrowing, lifetimes, errores y diseño.

Rust
Pensar en Rust

Aprender un nuevo lenguaje de programación te va a costar más o menos dependiendo de la experiencia actual que tengas con otros. Algunos, se aprenden por acumulación: sumas sintaxis, sumas librerías, añades trucos y practicas hasta la extenuación, claro está.

Y luego está Rust, que se aprende por sustitución. No porque sea caprichoso, sino porque te obliga a cambiar el modelo mental con el que llevas años resolviendo problemas, con algunos mecanismos del lenguaje que, reconozco, me han costado bastante digerir, hasta que en algún momento algo te hace clic y comprendes la razón de esos puntos que al principio parecen un tanto esotéricos, sobre todo si cometes el error de compararlos con otros lenguajes.

Si vienes de lenguajes interpretados y de muy alto nivel como Python o JavaScript, Rust te va a sonar a chino, literalmente.

Si tu mundo es Java o C#, la curva de aprendizaje será, digamos, media.

Y por último, si conoces C++, tu aprendizaje de Rust será algo natural, aunque también te requerirá cierto esfuerzo.

Al inicio de mi carrera profesional (hace más años de lo que recuerdo), comencé programando en C++, de modo que cuando me acerqué a Rust hace unos años, de alguna manera pensé que me resultaría ameno y hasta atractivo, pero no fue así: comencé leyendo el libro clásico Programming Rust y toda la documentación del lenguaje (muy buena, por cierto), pero me quedé bloqueado en varias ocasiones, después de optar a un proyecto en WebAssembly con Rust (que al final no salió).

No obstante, siempre volvía a Rust y a su ecosistema, más que nada por curiosidad y por la escalabilidad en mis escarceos en infraestructuras HPC (high performance computing), en donde ahorrar hasta el último byte y ciclo de CPU son importantes.

Y de ahí este artículo, cuya intención es principalmente insistir en los conceptos que debes asumir y comprender muy bien desde el principio de Rust para no estrellarte, hasta que comienzas a ver el bosque completo.

Hoy día siento cierta fluidez con Rust; es más, diría que hasta entusiasmo. Rust no te pregunta “¿cómo quieres programar?”. Rust te pregunta “¿qué garantías estás dispuesto a declarar?”. Y en esta pregunta, que tiene cierto tono filosófico, está la razón por la que cuesta tanto al principio… y por la que, cuando por fin te iluminas, ya no miras igual ni el lenguaje ni el resto de su ecosistema.

Siempre he odiado las comparaciones infantiles entre lenguajes y entornos: cada uno tiene su sitio y su nicho, y nuestra habilidad es saber elegir qué entornos, tecnologías, etc. son las más adecuadas según cada proyecto (y su ciclo de vida futuro).

Este artículo, a modo de brújula, es una forma de nombrar lo que suele doler en Rust cuando vienes de otros lenguajes y que a mí me hubiera gustado encontrar al comienzo.

En cierto modo, Rust no es una nueva sintaxis, sino una especie de nuevo pacto al programar, muy diferente a otros lenguajes aunque, obviamente, tenga sus connotaciones similares con C++.

1) Rust no es C++, ni Java, ni Python (aunque tenga ciertos parecidos a elementos concretos de todos ellos)

Es tentador clasificar Rust por proximidad o similitud a otros lenguajes:

  • “Se parece a C++ porque compila y es rápido”.
  • “Se parece a Java/C# porque tiene traits que recuerdan a interfaces”.
  • “Se parece a Python en la ergonomía moderna y el tooling”.

Y, sin embargo, si lo abordas desde esas comparaciones, Rust se te queda como una maqueta: se parece por fuera, pero por dentro no encaja.

En C++ el poder se negocia con convenciones, disciplina (no te olvides de los delete) y una cultura de “haz lo correcto”, pero nada, salvo tu buen hacer, te obliga a hacer lo correcto. En Rust, como veremos, esto es diferente.

En Java/C# la memoria y la vida de los objetos se externaliza a un runtime y al potente recolector de basura que tanto te aligera programar y despreocuparte del ciclo de vida de variables y recursos. En Python, por su parte, el coste está en la ejecución, no en la compilación; te centras en lo funcional y el lenguaje mismo te da ciertas garantías, pero todo tiene un coste, claro.

Rust, en cambio, te da la primera en la frente al mover una parte importante de la complejidad de una aplicación al momento de compilarla. Sí, has leído bien: me costó mucho captar esta idea y su razón.

En otros lenguajes, muchos asuntos internos de una aplicación, de bajo nivel, te los dan todo hecho, pero Rust es mucho más explícito, y esto duele al principio (y luego te enamoras precisamente de esta cualidad que abordas y fluye de forma natural y productiva).

2) El uso de la memoria no es un detalle interno: es una parte del diseño

La intención principal de Rust es evitar bugs antes de que aparezcan en ejecución: lagunas de memoria, data races, punteros tontos, accesos no permitidos, etc., y para eso, para muchos tipos de errores, tienes que preocuparte explícitamente de cómo tu aplicación usa internamente la memoria. Pero tranquilo: en realidad, esto es más fácil de lo que suena.

En muchos lenguajes, la gestión de la memoria ni aparece por ningún lado: declaras una variable o instancias un objeto, lo usas, y luego te olvidas; un recolector de basura hace su trabajo. Pero para ganar esta fluidez a la hora de escribir código, hay que pagar un precio: bien en la forma de uso ineficiente de la memoria y falta de optimización, o bien con la introducción de errores que acabarán saliendo a la luz en algún momento.

En Rust, la memoria se vuelve parte de la arquitectura. Esto me costó mucho comprenderlo (después de estar muy viciado con C# y JavaScript con Node).

En realidad, esto es una ventaja… si aceptas algo chocante al comienzo:

  • No existe un recolector de basura que amortigüe el coste de decisiones vagas.
  • La vida de los datos no es una consecuencia: es un contrato.

Esto es: explícitamente hay que indicar para qué vas a usar una variable o recurso, cómo y cuál va a ser su ciclo de vida en la aplicación.

Esta es la primera gran fricción: en Rust, el lenguaje te pregunta “¿quién es el dueño de esto?”, y lo hace porque quiere garantizar algo muy concreto: que su liberación sea segura, determinista y eficiente, y que se haga lo antes posible para liberar recursos. Esto se consigue directamente por la propia naturaleza del lenguaje.

Para ello, se introduce el concepto de propiedad (ownership), mover (move) y préstamo (borrow) de variables y recursos:

  • Ownership: cada valor tiene un único dueño.
  • Cuando el dueño sale de scope, el valor se libera y, punto.
  • El valor puede cambiar de dueño (lo que se denomina “mover”).
  • Al prestar un valor, no se transfiere su propiedad.

Y ya está: ideas simples, pero con un impacto poderoso.

En el siguiente snippet se puede ver todo lo anterior (y creedme, al principio es muy frustrante ver cómo, para unas simples líneas de código, el compilador no para de protestar hasta que corriges lo que está mal):

fn main() {
    let s = String::from("El Libro Negro del Programador");
    let t = s;
    println!("{}", t);
    println!("{}", s); // error: s ya no tiene el valor
}

Pero lo importante es que ese error se detecta en compilación, no en ejecución, y esto tiene un impacto extraordinario en la seguridad y eficiencia de tu aplicación.

Cuando el compilador de Rust indica que está todo bien, te garantiza que formalmente el código es correcto, al menos desde el punto de vista de data races, asignaciones erróneas de valores y mil cosas más.

3) Mutabilidad y aliasing: cuando &mut es una promesa de exclusividad

Si vienes de lenguajes donde puedes tener múltiples referencias y mutar casi en cualquier sitio, Rust te parecerá extraño e incluso hostil. Pero la regla clave es elegante cuando la comprendes:

  • Puedes tener muchas referencias inmutables (&T) a un mismo valor.
  • Solo puede existir una referencia mutable a un valor (&mut T).

Para los que conocen C o C++, les sonará &, pero en Rust no existe el concepto de “puntero” (pointer); es tan solo indicar una referencia a un valor y el derecho de leerlo o cambiarlo.

Esto parece un obstáculo hasta que lo miras como lo que realmente es: un principio de diseño para eliminar data races y estados intermedios ilegales que pueden provocar errores catastróficos al ejecutar la aplicación.

Lo importante es que Rust te obliga a declarar cuándo algo puede cambiar, y la consecuencia natural de esto es que no solo se ayuda al compilador, sino también al lector del código a comprenderlo y, además, al diseño de la aplicación.

Pero todo tiene un coste: hay que pensarlo todo antes. Al principio, como digo, es muy frustrante comprobar cómo para cualquier pequeño código saltan tantos errores en el compilador, hasta que integras las reglas anteriores y comienzas a enfocar el código de esta forma de manera natural.

4) Tipos como contratos: Option y Result no son “verbosidad”, son dominio

Acostumbrado a manejar excepciones en C#, y casi de forma opcional, llegas a Rust y descubres que en su propia sintaxis está la exigencia de tratar los errores cuando una función puede fallar por alguna razón.

Al principio, esto es muy molesto, pero finalmente te das cuenta de que gracias a esto tu aplicación, de forma innata, presenta menos posibilidad de error o de introducir bugs, porque te obliga a gestionar y pensar en todas las situaciones.

En Rust, los errores y la ausencia de valor no son excepciones ni nulos que aparezcan en runtime, sino que se gestionan en tiempo de compilación con los tipos Result<V, E> y Option<T>. En Rust, por diseño, es imposible utilizar un valor que se ha quedado nulo.

Con esto, lo que Rust te está obligando es a expresar la posibilidad de que algo pueda fallar y a manejarlo en el código.

La disciplina aquí no es estética. Es operativa e inherente al lenguaje.

5) Traits y genéricos: no es polimorfismo “bonito”, es diseño estructural

Si el ownership te hace sudar, los traits suelen venir después con su factura particular, especialmente cuando aparecen bounds, lifetimes y tipos asociados.

El salto mental importante es el siguiente:

  • En muchos lenguajes: “heredo para reutilizar”.
  • En Rust: “expreso capacidades para componer”.

Otro punto sutil de comprender, sobre todo su impacto en el diseño de la aplicación.

Traits son capacidades, contratos de comportamiento, y Rust te deja elegir la forma de polimorfismo según lo que necesites:

  • Estático (genéricos): más rendimiento, monomorfización.
  • Dinámico (dyn Trait): flexibilidad.

Hasta aquí es simple. Lo que cuesta es entender que el diseño en Rust se basa menos en jerarquías y más en composición de capacidades.

Cuando lo aceptas, dejas de pelearte con el lenguaje y empiezas a usarlo como herramienta de arquitectura.

6) El compilador no solo compila, sino que es un verificador formal ligero

Rust tiene una relación particular con el compilador. En otros lenguajes, compilar es algo así como pasar un trámite, comprobar que la sintaxis está bien. En Rust, compilar es una conversación, a veces frustrante hasta que comienzas a dominarlo.

Los mensajes del compilador suelen ser largos, específicos y yo diría que hasta pedagógicos. Y eso es totalmente deliberado: Rust intenta ayudarte a mantener invariantes que, de otro modo, se te escaparían en runtime provocando errores.

Pero aquí hay una verdad que te conviene asumir pronto: si el compilador protesta, normalmente no es porque falte una coma (que también), sino porque no has expresado una idea correctamente.

Cuando el borrow checker no te deja avanzar, no está señalando un error de sintaxis: está señalando una ambigüedad en la vida o en el acceso a los datos, lo cual es susceptible y muy probable de provocar errores en ejecución. Algo que en otros lenguajes funcionaría hasta que dejara de hacerlo.

Rust te obliga a resolver esa ambigüedad lo antes posible.

7) Concurrencia sin data races: el “superpoder” que tiene un precio

Rust tiene una afirmación implícita: si compila, ciertas clases de data race no pueden existir, por diseño del lenguaje. Esto no te vuelve invencible, pero sí te da una base increíble.

Por poner algunos ejemplos, te describo dos conceptos protagonistas:

  • Send y Sync (traits automáticos) para transferir y compartir entre hilos de forma segura.
  • Propiedad y préstamos para que la concurrencia no sea un festival de aliasing mutable.

El resultado es extraordinario: Rust no arregla la concurrencia, aunque reduce enormemente el espacio de errores que se pueden producir.

De nuevo: más trabajo al principio, pero menos sorpresas después.

Como conclusión, Rust exige dedicar más esfuerzo en tiempo de compilación y te obliga a responder en el código a estas preguntas:

  • ¿Quién posee este dato?
  • ¿Cuánto va a vivir?
  • ¿Quién puede mutarlo?
  • ¿Qué ocurre si falla?
  • ¿Qué garantiza este tipo?
  • ¿Qué capacidades tiene este tipo y cuáles necesito utilizar?

Al principio esto se siente como una especie de burocracia molesta, pero después descubres que era puro diseño para producir un código más eficiente y seguro. Y, con el tiempo, se convierte en una forma de programar que no depende tanto de la memoria humana, sino de invariantes verificables a los que te obliga el lenguaje: a pensar en un conjunto de reglas, simples, casi axiomáticas, pero con un impacto extraordinario.

Rust no te hace escribir más por capricho. Te hace declarar lo que antes dejabas implícito (y mucho más dado a introducir bugs y a utilizar ineficientemente la memoria), y eso te obliga a ser más preciso, más disciplinado y más consciente. Y, precisamente por eso, cuando te acostumbras, la sensación es distinta: no es que Rust te haga más lento, es que te hace más fiable.

Y sí, al principio duele. Pero luego te das cuenta de que esa sensación de “me está impidiendo programar” es exactamente la misma que sentías cuando un buen arquitecto te dice: “no, esa solución no escala” o un buen coach te obliga a mirar más allá de la primera idea. Rust no te está frenando: te está enseñando a pensar mejor antes de escribir.