32 puntos por GN⁺ 2025-12-07 | 1 comentarios | Compartir por WhatsApp
  • 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

 
GN⁺ 2025-12-07
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 struct
    Si la intención es excluir ordered_at de la comparación, me parece mejor separarlo en dos struct: PizzaDetails y PizzaOrder
    Así, al implementar PartialEq, se puede dejar claro que solo se compara details

    • Buena observación. Pero aun así creo que, en términos lógicos, es un modelado incorrecto
      Si la hora del pedido es distinta, entonces no es el mismo pedido, así que definirlos como iguales a nivel de tipos es riesgoso
      Tener PartialEq en PizzaDetails está bien, pero la lógica para comparar pedidos debería ir en una función de negocio separada
    • El enfoque de separar la estructura está bien, pero el problema es que al modificar PizzaDetails, ese cambio podría afectar la lógica de deduplicación de pizzas
      Lo ideal es que un struct se use solo para agrupar datos
      Para evitar que un cambio impacte en otros lugares, también se podría considerar un tipo separado como PizzaComparator o PizzaFlavor
      Estaría bien poder tener anotaciones de campos como {important_to_flavour=true} al estilo Protobuf
    • Separar estructuras solo por tener una comparación distinta no es algo generalizable
      Por 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és
    Gracias a eso, la seguridad de hilos, los lifetimes, la posibilidad de clonar, etc., quedan validados globalmente en tiempo de compilación

    • Yo también creo que la verdadera ventaja de Rust está en las “cosas de las que no tienes que preocuparte”
      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
    • Pero este comentario no parece tener relación con el artículo original
      El tema del artículo eran bugs lógicos que ni siquiera el borrow checker detecta
    • El artículo se enfocaba sobre todo en patrones de código para evitar errores lógicos al ir mejorando un programa de forma iterativa
  • 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

    • No creo que haga falta ver el incidente de unwrap como un “incidente”
      unwrap en Rust es como assert en C. Si falla, solo cumple la función de avisarte que hay un problema
      En Rust igual se pueden seguir escribiendo bugs
    • Al final es el mismo problema. En la comunidad de Rust se habla de abandonar C, pero en C también es común usar handles en lugar de índices
  • 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 rand en ejemplos básicos contribuye a esa cultura
    Claro, 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

    • A mí también ese ejemplo me generó rechazo al principio cuando empecé con Rust
      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 struct que envuelve un bool, pero me molesta no poder tratarlo como un bool normal
    Me pregunto si habrá alguna forma de usar un enum como si fuera un bool

    • Yo también casi siempre prefiero enum + match!
      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
    • Tal vez podrías probar algo como impl Deref, aunque no estoy seguro de que sea una buena idea
  • La sentencia match del primer ejemplo se siente demasiado exagerada
    Vec.first() o Vec.iter().nth(0) son más claros y van más con la intención

    • Yo también estoy de acuerdo. Usar match termina siendo una solución más compleja que el problema
      Si puedes eliminar el if, también puedes eliminar el match, así que no hay diferencia en términos de seguridad
      first() es mucho más conciso y claro
    • Para expresar lo mismo de forma más simple, también se puede usar exactly_one de itertools
    • Aun así, match tiene 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

    • En nuestra empresa (unas 300 personas) sí existe un equipo dedicado a deuda técnica con ese rol
      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
    • En la mayoría de las grandes empresas tecnológicas existen equipos así
      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 TryFrom se agregara en la versión 1.34
    Es muy probable que el código que usa unwrap_or_else() sea un resto de esa época anterior
    La documentación del trait From ahora explica con mucha claridad cuándo conviene implementarlo

    • Todavía estoy aprendiendo Rust, pero el nombre 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