¡Muy buenas! ¿Qué tal? ¿Cómo va el comienzo de año hasta ahora? Hoy vengo con una de las pequeñas promesas que hice al comenzar el año: aprender Rust.
Quería hacer este mini-post para compartir mis primeras impresiones y mis primeros pasos en Rust desde la perspectiva de alguien que, básicamente, tiene cero idea de gestión de memoria. Mi background es programación funcional en lenguajes como Scala y algo de Haskell, así que este viaje tiene pinta de ser interesante.
Primeras impresiones #
Siendo breve: Rust no me parece un lenguaje difícil, como mucha gente lo demoniza y grita a los cielos. Sin embargo, es cierto que trae consigo mecánicas a las que no estamos acostumbrados, y si tienes un background parecido al mío, además supone un cambio de paradigma.
En algunos momentos, me he sentido re-aprendiendo cosas que forzosamente me obligué a olvidar. Estoy hablando de los side-effects y la mutabilidad. (Ahora entro más en detalle en este punto).
Rust no es Scala. Rust es Rust #
Esta es una de las ideas que más me ha costado interiorizar. A base de repetición y frustración en mis primeros pasos, he tenido que lidiar con la idea de que NO ESTOY PROGRAMANDO EN SCALA.
¿Qué significa esto? Pues que no solo desaparecen comodidades como las for-comprehensions o el azúcar sintáctico, sino que además, al no haber un GC como en la JVM, no puedo hacer cosas como la siguiente y quedarme tan pancho:
fn do_something_string(text: String) -> String {
// whatever
todo!()
}
fn main() {
let s1 = String::from("Hello, Rust!");
let s2 = do_something_string(s1); // `s1` se mueve a la función y se devuelve
println!("{}", s2); // ✅ It Works
// println!("{}", s1); // ❌ ERROR: `s1` no existe más (moved)
}
¿Y por qué? Porque en Rust, cuando pasamos una variable que no implementa Copy, se transfiere la propiedad en lugar de copiarse. Si quiero seguir usando s1 después de la llamada a la función, tengo que clonarlo antes (s1.clone()) o asegurarme de que la función lo devuelva, como en este caso.
Métodos que no devuelven (por defecto) #
Otra de las cosas que más me ha chocado es que en Rust existen métodos que, en lugar de devolver resultados, modifican el estado original. Esto ocurre sobre todo con estructuras que se alojan en el heap, como los vectores.
Scala
val xs: Array[Int] = Array(1,2,3,4,5)
val ys = xs.reverse
ys // res: Array(5,4,3,2,1)
Rust
let mut xs: Vec<i32> = vec!(1, 2, 3, 4, 5);
// let ys: () = xs.reverse();
xs.reverse(); // Modifica `xs`
println!("{:?}", xs); // [5, 4, 3, 2, 1]
En Rust, ys no es un vector. El método reverse() no devuelve un nuevo vector, sino que modifica xs directamente. Además, xs debe ser mutable mut.
Cambio de paradigma #
Entonces, claro… esto es un paradigma al que no estoy acostumbrado. Hay una frase que escribe Wojciech Pituła en su post Rust from a Scala Perspective: Advent of Code 2024 con la que me siento muy identificado:
Weird mix of imperative and functional concepts — Developing in Rust felt like “let’s do FP until you can’t and switch to imperative then”.
Muy acertado. Recomiendo la lectura de ese post porque expresa con mayor claridad muchos de los frentes con los que me he encontrado.
Sin embargo, me encanta #
Nada de esto es malo, simplemente es diferente. Es cuestión de acostumbrarse a las nuevas mecánicas. Pero, aparte de ello, me gusta la granularidad y la semántica de los tipos primitivos.
Me gusta que Rust haga una distinción clara entre los tipos que van al heap y los que van al stack. Me gusta que me obligue a pensar entre cuáles tienen semántica Copy y cuáles Move. Y una de las cosas que más me ha gustado hasta ahora ha sido la implementación de traits.
Trait e implementación #
Esto es algo que vemos en Haskell y que, por suerte (o por desgracia, porque ha llegado tarde), estamos viendo en Scala 3. Me refiero a cómo se implementan los traits.
Pero no es solo el cómo, sino el cuándo y el por qué. Mientras que en Scala los traits como Functor se implementan directamente en los ADTs (List, Option, etc.), en Rust, como en Haskell, los traits se definen aparte. Esto refuerza la idea de que un trait no es una implementación por naturaleza del ADT, sino una abstracción que puede aplicarse a múltiples tipos.
enum Rational {
Rational(i32, i32),
}
impl fmt::Display for Rational {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
Rational::Rational(x, y) => write!(f, "R[{}/{}]", x, y),
}
}
}
// Para poder sumar dos elementos racionales usando el operador `+`
impl Add for Rational {
type Output = Rational;
fn add(self, rhs: Self) -> Self::Output {
match (self, rhs) {
(Rational::Rational(a1, b1), Rational::Rational(a2, b2)) => {
Rational::Rational(a1 * b2 + a2 * b1, b1 * b2)
}
}
}
}
// Para usar clonar usando .clone()
impl Clone for Racional {
fn clone(&self) -> Self {
match self {
Racional::Racional(x, y) => Racional::Racional(*x, *y),
}
}
}
fn main() {
let r1 = Racional::Racional(1, 2);
let r2 = Racional::Racional(1, 3);
println!("{} + {} = {}", r1.clone(), r2.clone(), r1 + r2) // R[1/2] + R[1/3] = R[5/6]
}
Aclaración #
En Scala, los traits pueden implementarse dentro de un ADT, pero también pueden definirse aparte usando type classes, al igual que en Rust o Haskell. La diferencia importante es que en Rust, los traits no pueden implementarse dentro del tipo como en Scala, sino que siempre se definen aparte.
Conclusión: Más allá de lo básico #
Obviamente, no he abordado ni una parte de la capa superficial de Rust. Apenas llevo un par de semanas con él. Ni siquiera he hablado de los lifetime parameters, del borrow checker, o del compilador (con el cual aún tengo una relación amor-odio).
Aunque empiezo a entender por qué Rust está ganando tanta popularidad, todavía estoy en mi lucha interna preguntándome si es un lenguaje para mí o no.