La mejora de productividad inesperada de Rust
(lubeno.dev)- Rust permite hacer refactorización con confianza incluso en bases de código grandes gracias a sus sólidas garantías de seguridad, lo que mejora la productividad y la mantenibilidad
- El compilador puede detectar de antemano bugs relacionados con la planificación asíncrona, reforzando la estabilidad al evitar comportamiento indefinido
- Lenguajes como TypeScript suelen encontrar bugs asíncronos en producción con frecuencia debido a su sistema de tipos más flexible
- El sistema de tipos de Rust deja claro el impacto de los cambios en el código, lo que aumenta la confianza y la disposición a experimentar en proyectos complejos
- A diferencia de Rust, Zig tiene verificaciones más laxas en el manejo de errores, por lo que puede pasar por alto bugs causados por errores tipográficos y resultar menos confiable
Resumen y contexto
- El backend de Lubeno está escrito 100% en Rust, y la base de código ha crecido hasta un punto en el que ya es difícil abarcarla completamente de memoria
- En proyectos grandes, por lo general es difícil verificar los efectos secundarios de los cambios, lo que provoca una caída de productividad
- Las garantías de seguridad de Rust dejan claro el impacto de los cambios en el código, reduciendo el miedo a la refactorización
- Esto contribuye a una mejor mantenibilidad y a una mayor productividad a largo plazo
- Este texto comienza con un caso en el que el compilador de Rust detectó un bug asíncrono y explora las ventajas de productividad de Rust
Un caso de las garantías de seguridad de Rust
- Situación del problema: se encapsuló una estructura con un mutex para permitir acceso concurrente, y luego se ejecutó una operación asíncrona después de adquirir el bloqueo
let lock = mutex.lock(); db.insert_commit(commit).await; - Descubrimiento del problema: rust-analyzer no mostró ningún error, pero apareció un error de compilación en el archivo de definición de rutas
.route("/api/git/post-receive", post(git::post_receive)) ^^^^^^^^^^^^^^^^^ error: future cannot be sent between threads safely - Análisis de la causa:
- El framework web crea una tarea asíncrona por cada conexión HTTP, y el planificador de tareas mueve esas tareas entre hilos
- El mutex requiere que el bloqueo se libere en el mismo hilo; si el hilo cambia en el punto de
.await, podría producirse comportamiento indefinido - El compilador de Rust rastrea la vida útil del bloqueo y detecta la posibilidad de que se libere en otro hilo
- Solución: liberar el bloqueo antes de
.await - Importancia: Rust evita en tiempo de compilación bugs asíncronos que son difíciles de reproducir en el entorno de desarrollo
Caso comparativo con TypeScript
- Situación del problema: aparece un bug de redirección asíncrona en código TypeScript
if (redirect) { window.location.href = redirect; } let content = await response.json(); if (content.onboardingDone) { window.location.href = "/dashboard"; } else { window.location.href = "/onboarding"; } - Causa del problema:
window.location.hrefno redirige de inmediato, sino que programa la redirección, por lo que la ejecución del código continúa- Debido a una condición de carrera, ocurre una redirección no intencional
- Solución: agregar
returnal bloqueifif (redirect) { window.location.href = redirect; return; } - Limitación: TypeScript no tiene rastreo de lifetimes ni reglas de préstamo, así que no puede detectar este tipo de bug en tiempo de compilación
- Se descubre en producción y puede tomar mucho tiempo depurarlo
Ventajas de Rust para la refactorización
- En desarrollo web, Python, Ruby y JavaScript/Node.js ofrecen alta productividad inicial, pero cuando la base de código crece, su acoplamiento flexible dificulta hacer cambios
- Después de un cambio aparecen errores inesperados, lo que reduce la disposición a modificar el código
- En Rust, el sistema de tipos deja claro el impacto de los cambios, reduciendo el miedo a la refactorización
- Ejemplo: advertencias del tipo “este cambio podría afectar otras partes” permiten prevenir problemas de antemano
- Incluso a medida que crece la base de código, la productividad aumenta, porque se puede reutilizar código existente y mantener la estabilidad al hacer cambios
Comparación con las pruebas
- Las pruebas son útiles para evitar regresiones al refactorizar, pero como el compilador no las obliga, es posible omitirlas
- Escribir pruebas implica decidir el nivel de abstracción, comportamiento vs. detalles de implementación, y si realmente previenen errores, lo que genera una carga mental importante
- Rust hace que el compilador bloquee de antemano errores comunes, reduciendo la carga de decisión que implican las pruebas
- Las propiedades que no pueden verificarse con el sistema de tipos se complementan con pruebas
Comparación con Zig
- Zig es un lenguaje de programación de sistemas similar a Rust, pero más laxo en el manejo de errores
- Ejemplo: código de manejo de errores
const FileError = error{ AccessDenied }; fn doSomethingThatFails() FileError!void { return FileError.AccessDenied; } pub fn main() !void { doSomethingThatFails() catch |err| { if (err == error.AccessDenid) { std.debug.print("Access was denied!\n", .{}); } }; } - Debido al error tipográfico
AccessDenid, se produce un bug, pero el compilador de Zig lo trata como un número y la compilación se completa con éxito
- Ejemplo: código de manejo de errores
- Al usar una sentencia switch se detecta el error tipográfico, pero en una sentencia if se ignora, lo que genera problemas de confiabilidad
- Rust evita este tipo de debilidades de diseño y verifica estrictamente errores tipográficos y fallas lógicas
Implicaciones
- Rust mejora la productividad y la estabilidad en proyectos grandes gracias a sus garantías de seguridad y a su estricto sistema de tipos
- Puede detectar en tiempo de compilación problemas complejos como bugs asíncronos, reduciendo los costos de mantenimiento
- Los casos de TypeScript y Zig muestran los riesgos de las verificaciones laxas y resaltan el valor del compilador estricto de Rust
- Rust se consolida como una herramienta poderosa en desarrollo web no solo por la productividad inicial, sino también para la gestión de bases de código a largo plazo
3 comentarios
Cada vez que veo que dicen "¡esto es lo mejor, es un lenguaje poderoso!!", no puedo evitar pensar:
¿será que en realidad no hay tantos desarrolladores de Rust como uno imaginaría, y por eso andan tratando de convencer a la gente de usar Rust?
¿Soy el único al que los artículos recomendados sobre Rust le suenan a algo como el gourmet de Sikgaek diciendo "¡Prúebalo! ¡Prúebalo!"?
Comentarios en Hacker News
El año pasado porté un driver de red
virtio-hostescrito en Rust. Cambié el backend, el mecanismo de interrupciones y lo pasé de biblioteca a proceso independiente. Era un programa complejo que manejaba mapeo de memoria, interrupciones de VM, sockets de red y multihilo. Casi no tenía experiencia con Rust y poca convirtio, pero para cuando el proyecto compiló, funcionaba perfectamente. Salvo un bug relacionado conDrop, fue fácil de corregir. Creo que ayudó mucho que las bibliotecas de Rust estén diseñadas de forma que sea difícil usarlas malMe parece que Rust es excelente. Pero no estoy de acuerdo con la idea de que el bug de asignación de
hrefsea culpa de TypeScript. El núcleo del problema es que, aunque configureshref, la navegación de página no ocurre de inmediato, sino que se procesa después. En Rust podría pasar exactamente lo mismo. Si en Rust hubiera una funciónset_hrefy ese comportamiento se procesara más tarde, sería posible escribir algo así:set_href('/foo')if (some_condition) {set_href('/bar')}Creo que en Rust no se diseñaría así. Que un
setterdispare una acción no es buen diseño de biblioteca, y que al asignarhrefno ocurra la navegación al mismo tiempo es raro. En la biblioteca estándar de Rust no habría una implementación tan tonta. No es un tema de Rust vs TypeScript, sino de la biblioteca estándar de Rust frente a la Web Platform API. Sí estoy de acuerdo en que Rust no ofrecería una experiencia de usuario asíHablando formalmente, no es deseable diseñar algo para que una acción ocurra de inmediato desde un
setter. También habría que cambiar el nombre a algo comonavigate_to(href). En el entorno del navegador, todo el código JS funciona mediante callbacks y está controlado por el event loop, así que también es natural que no se ejecute inmediatamenteEl ejemplo de Rust es interesante, pero solo con el ejemplo de TypeScript no se puede saber si TS sirve para proyectos grandes. En Ruby me da inseguridad tener que atrapar bugs en runtime con frecuencia, pero al final funciona bien antes de hacer commit, y leer y modificar el código es fácil y satisfactorio. El tema de la navegación es un problema de JavaScript, y TS lo hereda. Surge porque JS permite modificar propiedades libremente. Pero como la página tampoco desaparece de inmediato, una vez que entiendes el comportamiento, resulta razonable
Técnicamente, en Rust se podría insinuar el significado con claridad según si
set_hrefdevuelve()o!. Pero incluso así, en una redirección condicional seguiría siendo difícil detectar el mal usoLo que quería señalar es que, con el modelo de ownership de Rust, se podría diseñar una API donde
window.set_href('/foo')tome posesión dewindow, de modo que no sea posible llamarlo dos veces. TypeScript ni siquiera tiene el concepto de rastreo de lifetimes, así que eso no puede existir ahí. Y como la API de JS ya existe, tampoco hay forma de introducir un sistema de ownership desde TypeScript. Quería mostrarlo como un caso donde varias funciones de Rust se combinan para ofrecer garantías más fuertesSuena a que tu base para decir que Rust es mejor termina siendo “porque los programadores de Rust son mejores”. No creo que los programadores de Rust hagan ese tipo de argumento circular
Después de la asignación, el código sigue ejecutándose a menos que se haga un retorno anticipado de forma explícita. En serio, no entiendo por qué alguien pensaría que asignar un valor va a detener la ejecución del script. Puede que al ejemplo de TS le falte contexto, pero es raro usarlo como ejemplo de “data race”
Asignar un valor a
window.location.hreftiene el efecto secundario de hacer que el navegador navegue a ese enlace. Ese comportamiento es inesperado, y como una simple asignación carga una página nueva, se siente un poco comoexecve, así que no es raro pensar que la ejecución de JS se detendría de inmediato. No deberías depender de esa suposición al programar, pero como el comportamiento en sí es extraño, entiendo que pueda confundirLo piense uno así o no, este tipo de bug queda claro de corregir una vez que alguien te lo señala. El punto principal que intentaba hacer el autor es que bugs así, que TS no puede detectar, pueden ser realmente difíciles de encontrar y tomar mucho tiempo
exit(),execve(), etc. sí detienen la ejecución de inmediato, así que uno podría pensar que una redirección se comporta igualEs raro cuestionarlo solo por haber compartido su experiencia
Esa asignación tiene un efecto secundario grande: hace que abandones la página. Tampoco me parece descabellado pensar en eso como una acción asíncrona que ocurre de inmediato. Yo también he hecho esa suposición antes
Es la historia de un desarrollador que se dio cuenta de que un sistema de tipos estático es útil. Siempre me da risa cuando veo textos así
¿No será que la mayoría de las ventajas viene simplemente de usar tipos estáticos, es decir, un lenguaje compilado? Lo mismo pasa con Java, Go y C++. TypeScript tiene sus trucos: compila a JS y hereda sus problemas, pero aun así sirve. Rust tiene un sistema de tipos más estricto y por eso puede hacer chequeos adicionales en tiempo de compilación, aunque a cambio me parece más difícil de aprender y también más difícil de leer
Hasta cierto punto estoy de acuerdo, pero Rust tiene más dimensiones en su sistema de tipos: ownership, acceso compartido/exclusivo, thread safety, sum types, etc. Gracias al sistema de ownership/borrowing, queda claro si al pasar un argumento se entrega una vista temporal o si se transfiere completamente. Eso ayuda mucho en programas grandes o al usar bibliotecas externas. Por ejemplo, en Go el tipo slice no deja claro en runtime qué operaciones están permitidas, y tampoco está muy claro cómo prestarlo de forma de solo lectura. Rust además puede garantizar thread safety a nivel del sistema de tipos, lo que permite bloquear en tiempo de compilación data races que en otros lenguajes serían difíciles de encontrar en runtime
Ver todos los lenguajes con tipos estáticos como si fueran uno solo probablemente significa que todavía no has sentido el verdadero poder de los tipos
union(sum) y del pattern matching. Una vez que te acostumbras a losunion types, los lenguajes estáticos tradicionales ya no te satisfacen igualUna gran ventaja son los
traits/impl traits. En Rust puedes agregar traits a cualquier tipo después, algo parecido a los Extension Methods de C#. En la mayoría de los lenguajes, el tipo queda completamente definido en la biblioteca donde se creó, pero en Rust puedes ir acumulando funcionalidad sobre tipos simples de forma progresiva. Ese carácter de late-bound introduce un elemento de dinamismo en el sistema de tipos. Dicho de forma un poco extrema, el verdadero superpoder de Rust no es el borrow checker, sino la apertura y flexibilidad de su sistema de tipos. No necesitas diseñarlo todo desde el principio; puedes extenderlo gradualmenteNo todos los lenguajes con tipos estáticos producen el mismo efecto. Java al final depende de
Objecty de casts en runtime. Go no tieneenum. C++ agregó algo comovariant, pero para usarlo con seguridad hace falta manejo manual tipotry/except, así que estructuralmente es incómodoDices que Rust es difícil de aprender, pero la verdad es que, si realmente lo aprendes bien, no es difícil. Al principio de la programación es importante poder escribir algo rápido y lograr que medio funcione, y Rust es un lenguaje poco amable con esa forma de trabajar. No lo recomendaría como lenguaje de entrada, pero no me parece difícil de leer
Gracias a la fuerte seguridad de Rust, aumenta mucho la confianza al tocar una base de código. Con esa confianza ya no da miedo refactorizar partes críticas, y como resultado mejoran mucho la productividad y la mantenibilidad. Pero para eso existen las pruebas. Sin tests, un compilador estricto ayuda bastante, pero si escribes buenas pruebas puedes refactorizar con confianza en cualquier lenguaje
Es mejor que el compilador demuestre estáticamente todo lo que sea posible. Las pruebas son óptimas solo para situaciones en las que es difícil obtener garantías estáticas. El ideal absoluto sería la verificación formal; en la práctica es muy difícil, así que no es algo generalizable, pero como principio es correcto
Tanto unas buenas pruebas como un sistema de tipos bien aprovechado son efectivos para encontrar bugs. Pero escribir pruebas a veces me recuerda al cómic de xkcd de “Standards”. Es como intentar corregir bugs escribiendo más código. Aun así, el mantenimiento del sistema de tipos lo hace el diseñador del lenguaje, así que no hay que administrarlo proyecto por proyecto
Cada vez que refactorizas código también tienes que refactorizar las pruebas, así que el trabajo se duplica
Creo que el sistema de tipos de Rust o F# brilla más al refactorizar código. La expresión “refactorización sin miedo” le queda perfecta
El ejemplo de Zig me impactó. Se ve demasiado inestable y no entiendo cómo alguien podría pensar que ese diseño es bueno
Creo que eso probablemente es un bug. Pero en un lenguaje centrado en su creador como Zig, para que se corrija un bug también importa que ese creador lo reconozca como bug. Si lo considera intencional, podría seguir así
Todos los lenguajes tienen un poco de diseño inestable. Por ejemplo, en Go o Zig siempre tienes que hacer
mutex.unlock()explícitamente y no se libera automáticamente al salir del scope. En cambio, con algo como el operadorasde Rust, es fácil hacer conversiones entre tipos numéricos, y por eso mismo me he pasado un día entero persiguiendo bugsAl principio no había visto ese error, pero me di cuenta al leer este comentario
Me pregunto si un linter podría advertir sobre referencias a errores que no existen dentro del sistema y recomendar usar
switchPensé que los sets de errores se generaban a partir de la firma de la función. Es algo bastante peculiar
Me gusta que un sistema de tipos estático fuerte y sólido ofrezca tantas capacidades. Yo también tuve la experiencia de poder hacer refactorizaciones grandes con facilidad en una base de código Haskell (1 millón de SLOC). Incluso sin funciones avanzadas, solo con el sistema de tipos ya fue suficiente
Rust detectó correctamente que se estaba manteniendo un lock a través de una frontera de
await, pero para saber si realmente es seguro liberar ese lock antes delawaithace falta más contexto. Pienso que el lock debería mantenerse hasta que se genere el commit de la transacción, y si se libera antes delawaitpodría haber problemas de concurrencia. No conozco bien Rust async, pero me da la impresión de que después del commit habría que bloquear conjoinoselectSi necesitas mantener un lock durante un
await, puedes usar un mutex compatible con async. Los cratesfuturesotokioimplementan este tipo de locks. Se usan sobre todo cuando hay que mantenerlos mucho tiempo o entreawait. Son más costosos que un lock normalSi necesitas mantener un lock incluso a través de una frontera de
await, puedes usar el mutex async-aware de Tokio. Consulta la documentación de tokio/sync/struct.Mutex