- Presenta hábitos de programación para prevenir bugs de antemano aprovechando activamente el sistema de tipos y el compilador de Rust
- Muestra casos de malos olores de código (Code Smells), como indexación de vectores, abuso de
Default, match incompletos y parámetros booleanos innecesarios, y explica sus alternativas
- El principio clave es diseñar la estructura para que el compilador haga cumplir los invariantes, usando pattern matching, campos privados y el atributo
#[must_use], entre otros
- Presenta de forma concreta técnicas defensivas a nivel de código real, como usar
TryFrom, desestructuración completa de structs, mutabilidad temporal y validación en constructores
- Estos patrones son esenciales para mantener la estabilidad al refactorizar y mejorar la mantenibilidad a largo plazo
Panorama general de la programación defensiva
- Los puntos marcados con el comentario
// this should never happen son lugares donde se rompen invariantes implícitos
- En la mayoría de los casos, el desarrollador no considera todas las condiciones límite ni los cambios futuros en el código
- El compilador de Rust garantiza la seguridad de memoria, pero los errores de lógica de negocio siguen siendo posibles
- Pequeños patrones habituales (idioms) obtenidos a lo largo de años de experiencia práctica pueden mejorar mucho la calidad del código
Code Smell: indexación de vectores
- La forma
if !vec.is_empty() { let x = &vec[0]; } tiene riesgo de panic en tiempo de ejecución porque la verificación de longitud y la indexación están separadas
- Si se usa pattern matching sobre slices (
match vec.as_slice()), el compilador obliga a revisar todos los estados posibles
- Se pueden manejar explícitamente todos los casos: vector vacío, un solo elemento, elementos duplicados, etc.
- Es un ejemplo representativo de diseñar para que el compilador garantice los invariantes
Code Smell: uso indiscriminado de Default
..Default::default() puede causar el riesgo de omitir campos al agregar nuevos y el problema de asignar valores implícitos
- Si se inicializan todos los campos explícitamente, el compilador obliga a configurar también los campos nuevos
- Con una forma como
let Foo { field1, field2, .. } = Foo::default(); es posible desestructurar primero la estructura por defecto y luego redefinir selectivamente
- Esto permite equilibrar la conservación de valores por defecto con overrides explícitos
Code Smell: implementaciones frágiles de Traits
- Al desestructurar completamente los campos de un struct al comparar, si se agrega un campo nuevo aparecerá un error de compilación como advertencia
- Ejemplo:
PartialEq al implementar let Self { size, toppings, .. } = self;
- Si se agrega un campo nuevo como
extra_cheese, se fuerza a revisar la lógica de comparación
- El mismo principio puede aplicarse a otros traits como
Hash, Debug y Clone
Code Smell: cuando hace falta TryFrom en lugar de From
- Si una conversión no siempre puede tener éxito, conviene usar
TryFrom para expresar explícitamente la posibilidad de falla en vez de From
- El uso de
unwrap_or_else es una señal de que se está ocultando una posible falla, y un enfoque de fallo temprano (fail fast) es más seguro
Code Smell: match incompletos
- Un patrón comodín como
_ => {} implica riesgo de omitir casos cuando se agregan nuevos variants
- Si se enumeran explícitamente todos los variants, el compilador puede advertir cuando falta manejar un caso nuevo
- La misma lógica puede agruparse con formas como
Variant3 | Variant4
Code Smell: abuso del placeholder _
- Si solo se usa
_, no queda claro qué variables fueron omitidas
- Con nombres explícitos como
has_fuel: _, has_crew: _ mejora la legibilidad
Pattern: mutabilidad temporal (Temporary Mutability)
- Cuando los datos solo deben ser mutables durante la inicialización, puede usarse la forma
let mut data = ...; data.sort(); let data = data;
- Si se aprovecha el alcance de bloques, se evita exponer variables temporales fuera de donde hacen falta
- Ejemplo:
let data = { let mut d = get_vec(); d.sort(); d };
- Permite separar con claridad los alcances en procesos de inicialización con varias variables temporales
Pattern: forzar validación en constructores
- Al crear un struct, se puede forzar que siempre pase por la lógica de validación
- Si se agrega un campo
_private: (), ya no se puede crear directamente desde afuera
- El atributo
#[non_exhaustive] bloquea la creación fuera del crate y además señala posibles extensiones futuras
- Si se quiere forzar esto también dentro de módulos internos, puede usarse una estructura de módulos anidados con un tipo privado (
Seal)
- Como
Seal solo existe internamente, no se puede crear directamente fuera de new()
- Si los campos se dejan privados y se ofrecen getters, se preserva el estado inmutable
- Criterios de aplicación
- Bloquear código externo:
_private o #[non_exhaustive]
- Bloquear código interno: módulo privado +
Seal
- Convertir la lógica de validación en una garantía a nivel de compilador
Pattern: uso del atributo #[must_use]
#[must_use] evita que se ignoren valores de retorno importantes
- Ejemplo:
#[must_use = "Configuration must be applied to take effect"]
- Si el usuario ignora el valor de retorno, el compilador emite una advertencia
- Es una medida defensiva simple pero potente, muy usada también en la biblioteca estándar con tipos como
Result
Code Smell: parámetros booleanos
- Una forma como
fn process_data(..., compress: bool, encrypt: bool, validate: bool) tiene riesgo de significado ambiguo y errores de orden
- Con
enum Compression, enum Encryption, etc., se puede expresar la intención de forma explícita
- Cuando hay varias opciones, conviene usar un struct de parámetros (Params struct)
- Métodos de configuración previa como
ProcessDataParams::production() mejoran la reutilización
- Si se agrega una opción nueva, se minimiza el impacto sobre las llamadas existentes
Automatización con lints de Clippy
- Los principales patrones defensivos pueden verificarse automáticamente con lints de Clippy
indexing_slicing: prohíbe la indexación directa
fallible_impl_from: recomienda TryFrom en lugar de From
wildcard_enum_match_arm: prohíbe el patrón _
fn_params_excessive_bools: advierte sobre exceso de parámetros booleanos
must_use_candidate: sugiere candidatos para #[must_use]
- Puede aplicarse a todo el proyecto con
#![deny(clippy::...)] o mediante configuración en Cargo.toml
Conclusión
- La clave de la programación defensiva es usar activamente el sistema de tipos y el compilador de Rust para volver los invariantes explícitos y verificables
- Estos patrones ayudan a mantener estabilidad al refactorizar, minimizar la posibilidad de bugs y fortalecer el mantenimiento a largo plazo
- Es una manera de llevar a la práctica el principio de que "el mejor bug es el que no compila"
1 comentarios
Opinión en Hacker News
Me gustó el artículo. Aunque el ejemplo de PizzaOrder da la impresión de meter demasiadas responsabilidades en un solo
structSi la intención es excluir
ordered_atde la comparación, me parece mejor separarlo en dosstruct:PizzaDetailsyPizzaOrderAsí, al implementar
PartialEq, se puede dejar claro que solo se comparadetailsSi la hora del pedido es distinta, entonces no es el mismo pedido, así que definirlos como iguales a nivel de tipos es riesgoso
Tener
PartialEqenPizzaDetailsestá bien, pero la lógica para comparar pedidos debería ir en una función de negocio separadaPizzaDetails, ese cambio podría afectar la lógica de deduplicación de pizzasLo ideal es que un
structse use solo para agrupar datosPara evitar que un cambio impacte en otros lugares, también se podría considerar un tipo separado como
PizzaComparatoroPizzaFlavorEstaría bien poder tener anotaciones de campos como
{important_to_flavour=true}al estilo ProtobufPor ejemplo, ¿cómo lo separarías si quisieras comparar cadenas sin distinguir mayúsculas y minúsculas?
Una de las cosas realmente geniales de Rust es que muchas veces no hace falta programación defensiva
Gracias a las reglas de ownership y referencias, se puede garantizar que el acceso a cierto objeto sea único en todo el programa
Las referencias no pueden ser null, y los smart pointers tampoco pueden ser null
El sistema de tipos también garantiza que, si transfieres la propiedad de
self, ya no puedes volver a llamar métodos despuésGracias a eso, la seguridad de hilos, los lifetimes, la posibilidad de clonar, etc., quedan validados globalmente en tiempo de compilación
En otros lenguajes, para obtener esos beneficios hay que mantener la inmutabilidad con un estilo funcional, pero Rust lo impone mediante el sistema de tipos
El tema del artículo eran bugs lógicos que ni siquiera el borrow checker detecta
Siento que es prudente evitar indexar directamente arreglos o vectores
El día del incidente de unwrap de Cloudflare, yo también encontré un bug donde un slice se salía del final de un vector
Después de eso cambié a un enfoque basado en iteradores y se siente mucho más seguro
unwrapen Rust es comoasserten C. Si falla, solo cumple la función de avisarte que hay un problemaEn Rust igual se pueden seguir escribiendo bugs
Uno de los hábitos contra los que los desarrolladores de Rust deberían defenderse es agregar dependencias innecesarias de crates
Rust tiende a fomentar ese hábito. Por ejemplo, el hecho de que el Rust Book use el crate
randen ejemplos básicos contribuye a esa culturaClaro, fue una decisión estratégica para poder reemplazar fácilmente paquetes relacionados con criptografía, pero aun así sigue siendo un problema que se vuelva costumbre
Pero después entendí la intención y cambié de opinión
La implementación de igualdad parcial me pareció interesante
Otra cosa que me da curiosidad es usar enum al evitar parámetros booleanos
Yo uso un
structque envuelve un bool, pero me molesta no poder tratarlo como un bool normalMe pregunto si habrá alguna forma de usar un enum como si fuera un bool
Suelo resolverlo agrupando la lógica necesaria en un Trait o agregando métodos comunes en un bloque
impl <Enum>Así queda más legible y se puede definir con claridad el comportamiento de cada variante
impl Deref, aunque no estoy seguro de que sea una buena ideaLa sentencia
matchdel primer ejemplo se siente demasiado exageradaVec.first()oVec.iter().nth(0)son más claros y van más con la intenciónmatchtermina siendo una solución más compleja que el problemaSi puedes eliminar el
if, también puedes eliminar elmatch, así que no hay diferencia en términos de seguridadfirst()es mucho más conciso y claromatchtiene sentido en que te obliga a manejar también el caso de “cuando hay uno o más elementos”Es decir, deja ver el principio de evitar separar la verificación del código que depende de ella
Cada vez que leo este tipo de artículos, me pregunto por qué no existe un equipo dedicado a monitorear patrones de código
Estaría bueno tener un equipo que observe a largo plazo los patrones del codebase, como pasa con SOC o QA
Las herramientas automatizadas para detectar code smells tienen sus límites
Se encarga de gestionar reglas de lint, documentación, capacitación para desarrolladores y mantenimiento de librerías comunes
Cuando varios equipos repiten el mismo problema, diseña una API central que permita unificarlo
Pero la realidad es que, cuando el código llega a millones de líneas, gestionarlo se vuelve muy difícil
Estoy pensando en cómo se podrían fomentar estos buenos patrones de código dentro del equipo
Durante el code review muchas veces termina en “discusiones de estilo” y se vuelve improductivo
Pero curiosamente, cuando el linter muestra una advertencia, ese tipo de discusiones casi desaparece
Fue muy útil que el trait
TryFromse agregara en la versión 1.34Es muy probable que el código que usa
unwrap_or_else()sea un resto de esa época anteriorLa documentación del trait From ahora explica con mucha claridad cuándo conviene implementarlo
unwrap_or_else()me da risa porque suena como si estuvieras “dándole una orden amenazante a la computadora”Creo que este tipo de patrones de programación defensiva también ayudarían a mejorar la calidad de la generación de código con IA a gran escala
La retroalimentación concreta que dan Clippy y el compilador de Rust puede jugar un papel importante para que los agentes de IA cometan menos errores y encuentren mejor el rumbo