1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • Las comprobaciones de nil en Go pueden evitar pánicos, pero si se repiten en lugares incorrectos, el código deja de explicar por sí mismo “qué puede ser nil”.
  • Si se revisa una dependencia obligatoria como un cliente de Redis dentro de métodos internos, se termina tratando una falla de creación como si fuera una ruta normal de ejecución.
  • No basta con filtrar nil en el constructor; el fallo debe manejarse de inmediato en el punto de inicialización, como NewRedisClient(addr).
  • Los valores que vienen desde afuera, como objetos de solicitud, deben validarse en la capa de frontera, como handlers HTTP, dispatch de RPC o consumidores de colas, y la lógica interna debe confiar en esa garantía.
  • Si se permite silenciosamente un estado que debería ser imposible, la falla se vuelve silenciosa, tardía y ambigua, y luego aparece el costo de reconstruir con métricas, dashboards y alertas la señal que se perdió.

Una comprobación de nil no siempre es programación defensiva

  • Para evitar pánicos en producción, hace falta programación defensiva que revise entradas, rangos y punteros antes de un deferred recover.
  • Una comprobación de nil en el lugar correcto produce código seguro, pero una comprobación en el lugar equivocado es una señal de que no se está pudiendo rastrear qué valor puede ser nil.
  • Este patrón se ve con más frecuencia en código generado, pero no es un fenómeno nuevo ni está limitado a la IA.
  • Las comprobaciones de nil parecen baratas y seguras, pero le dejan al siguiente lector el mensaje de que “este valor puede ser nil”, y a menudo transmiten un significado incorrecto.

El problema de comprobar nil en dependencias

  • Un código donde RateLimiter tiene un *redis.Client como campo y dentro de Allow verifica r.redis != nil parece seguro a primera vista.
  • Si el cliente de Redis es nil, el problema ya ocurrió en el momento de creación, no cuando se ejecuta Allow.
  • Verificar nil dentro de un método interno trata como aceptable seguir funcionando con un estado de creación fallido.
  • Este tipo de comprobación es señal de que el código perdió el origen del objeto, la responsabilidad de inicialización y el invariante de que nil debería ser imposible.

Revisar nil solo en el constructor no alcanza

  • Devolver un error en NewRateLimiter(client *redis.Client) cuando client == nil es mejor, pero no es una solución completa.
  • El solo hecho de que un puntero nil haya llegado hasta la función ya significa que un estado incorrecto entró al sistema.
  • El error real debe manejarse en el punto de inicialización donde se crea el cliente de Redis.
    • Si ocurre un error en redisClient, err := NewRedisClient(addr), debe retornarse de inmediato.
    • Después, a NewRateLimiter(redisClient) solo debe pasársele un cliente válido.
  • Así, incluso deja de ser necesario que el constructor de RateLimiter devuelva un error.
  • Si hay que permitir que el almacenamiento esté temporalmente no disponible, no se debe propagar nil; hay que envolverlo en un tipo externo que siempre sea non-nil y encapsule internamente los reintentos o la degradación del rendimiento.
  • Esto es parecido a las restricciones NOT NULL o de clave foránea en una base de datos.
    • Si una fila incorrecta no puede existir desde el principio, cada consulta no necesita volver a verificar los datos.
    • Con valores en runtime, una vez establecido un invariante, el resto del código puede evitar comprobaciones repetidas.

El costo de las fallas silenciosas

  • Puede parecer estable dejar solo una comprobación de nil o un log para no detener el programa por un cambio pequeño.
  • En realidad, la elección se parece menos a “crashear vs. seguir ejecutando” y más a fallar ruidosamente vs. fallar en silencio.
  • Un error devuelto explícitamente tiene tres propiedades:
    • Claridad: se sabe que ocurrió una falla.
    • Inmediatez: se conoce la falla cerca de su causa.
    • Atribución: el llamador puede conectar la falla con esa operación.
  • Un error tragado funciona al revés:
    • La falla desaparece en silencio.
    • Después de que se ejecuta más código, aparece más tarde como un síntoma.
    • Cuando el síntoma se ve, resulta más difícil identificar la causa.
  • Cuantas más llamadas sobrevivan con el programa en un estado incorrecto, mayor será la distancia entre la causa y el síntoma.
  • La corrección adecuada no es ocultar localmente la falla, sino entender hacia dónde se propaga el error y dónde se convierte en rechazo de solicitud, falla de tarea, reintento, alerta o finalización.
  • Si devolver un error detiene más partes del sistema de las necesarias, el problema no está en esa función sino en el límite de manejo de errores.

El costo secundario de reconstruir señales perdidas

  • Cuando las fallas se vuelven silenciosas, no se puede saber qué ocurrió realmente y los bugs pueden quedar ocultos.
  • Entonces hay que construir infraestructura de observabilidad, como métricas, dashboards y alertas, para detectar la ausencia de comportamiento.
  • Cada vez que se permite un estado imposible o no manejado, se paga un costo de ingeniería para recuperar más tarde mediante observabilidad la señal que se descartó.

Roles de la capa externa y la capa interna

  • Donde comienza la ejecución y entra la información externa es la capa externa; el código más profundo al que llega esa llamada es la capa interna.
  • Al inicio de la ejecución no hay nada garantizado, pero tampoco se ha realizado trabajo todavía.
  • Durante la inicialización hay que configurar los elementos de los que depende el programa y decidir si cada uno es obligatorio o puede desaparecer temporalmente.
  • El diseño siempre debe inclinarse hacia dependencias disponibles, y minimizar las dependencias que pueden desaparecer en medio de la ejecución.

Los datos dentro del alcance de una solicitud deben validarse en la frontera

  • Los objetos de solicitud, sus campos y los valores derivados de una solicitud son distintos de las dependencias fijas.
  • Las solicitudes llegan en cada llamada desde afuera: handlers HTTP, RPC, colas, helpers de pruebas, otros paquetes, etc.
  • Verificar req == nil dentro de RateLimiter.Allow(ctx, req) es el mismo error que comprobar nil en una dependencia.
  • La solicitud no entró por primera vez en Allow; entró antes, en la frontera de transporte, y luego se movió dentro del código.
  • Si una función interna como Allow vuelve a validarla, una función profunda está revalidando algo que la capa externa debería garantizar, y la incertidumbre se propaga.

Tras validar en la frontera, la lógica interna confía en los invariantes

  • Las comprobaciones de nil deben estar en el punto de frontera donde bytes no confiables se convierten en un tipo interno como *Request.
  • En el ejemplo de un handler HTTP, si DecodeRequest(r) falla, se responde con http.StatusBadRequest y se retorna.
  • Después de la validación, req es un valor válido, y luego h.limiter.Allow(r.Context(), req) puede confiar en ese valor.
  • Como los datos recibidos desde afuera no se pueden controlar, tiene sentido revisar nil y las restricciones necesarias en la frontera.
  • Los datos que cruzan la frontera se mapean a tipos internos y lógica de negocio, y desde ahí pasan a ser invariantes del sistema.
  • El Allow final se enfoca en la lógica real, sin comprobaciones de nil:
    • userID := GetUserID(req)
    • si userID == "", devuelve false, nil
    • de lo contrario, llama a r.checkLimit(ctx, userID)
  • La comprobación de userID vacío también podría moverse a la capa HTTP, pero en el ejemplo se deja que el rate limiter sea dueño de esa política.

Las comprobaciones repetidas de nil crean nuevas ramas y nuevos comportamientos

  • Un sistema con esta estructura es fácil de razonar y de modificar.
  • En cambio, en un sistema sin invariantes se agregan comprobaciones por todas partes y luego hay que decidir qué hacer en cada una.
  • Cada comprobación de nil es una nueva rama, y cada rama obliga a definir un nuevo comportamiento para un estado que no debería existir.
  • Las comprobaciones de nil son útiles cuando hacen cumplir fronteras documentadas o modelan estados opcionales intencionales.
  • Hay que sospechar de las comprobaciones de nil que manejan silenciosamente estados que el programa considera imposibles.
  • Si aparecen comprobaciones de nil por todas partes, es una de dos cosas:
    • código normal que protege entradas de frontera no confiables
    • un problema de diseño donde el codebase no logró establecer invariantes
  • En un sistema donde no se puede confiar en ningún parámetro, quizá sea necesario agregar comprobaciones de inmediato, pero el trabajo real es establecer el invariante que esas comprobaciones están sustituyendo y convertirlo en una garantía confiable.

1 comentarios

 
GN⁺ 4 시간 전
Opiniones en Lobste.rs
  • Les vuelvo a pedir a otros programadores de Go que, por favor, envuelvan los errores

    redisClient, err := NewRedisClient(addr)  
    if err != nil {  
      return nil, fmt.Errorf("Couldn't obtain new RedisClient: %w", err)  
    }  
    

    A medida que se desenrolla la pila de llamadas, debería acumularse contexto sobre el error

    • Un ejemplo más idiomático se vería así
      redisClient, err := NewRedisClient(addr)  
      if err != nil {  
        return nil, fmt.Errorf("NewRedisClient: %w", err)  
      }  
      
      La idea es que luego cada capa solo agregue dónde ocurrió el error, y que el err más interno indique qué pasó
    • Lamentablemente no existe un stack trace unificado y de facto estándar para los errores
      En la práctica, “envolver” suele terminar siendo hacer grep sobre la cadena del error, esperar que esa cadena sea única y forzar la creatividad para hacerla única
    • Hay quienes se quejan de que las pilas de errores son demasiado largas, pero la mayoría ve estos mensajes como accionables y útiles
      Hace tiempo, en un producto de networking, un ingeniero pasó un mes corrigiendo cientos de mensajes de error, porque que en los logs apareciera “What the f-ck?” no ayudaba al usuario final
      Había que convertir esos mensajes en algo útil y, por los motivos anteriores, también agregar pilas de errores
    • Si no recuerdo mal, la forma actual tiende a usar errors.Join
  • Creo que Go crea dos problemas aquí

    1. Si Go tuviera nulabilidad (nullability) explícita, este problema casi desaparecería
    2. Como parece no haber forma de impedir la inicialización a cero de tipos a los que se les puede dar nombre, los errores pueden colarse en cualquier momento
    • Siento que esta frase del artículo muestra bien el problema de fondo
      Es la parte de “como no puedes controlar qué te pasan, tiene sentido comprobar si es nil en ese límite”
      Para entradas externas es correcto, pero si todos los punteros pueden ser nil, seguir dentro de una base de código cuáles son los límites seguros requiere razonamiento
      El problema de Go es que obliga a hacer ese razonamiento en la cabeza de cada programador, no en el compilador
  • Rust tiene Option<T> y C# tiene tipos anulables
    Creo que en 2026 ya no deberíamos seguir sufriendo este problema

    • Viéndolo desde el lado contrario, la capacidad de expresar de forma concisa “ausente” o “faltante” es muy útil, sobre todo al trabajar con estructuras de datos arbitrarias como JSON
      En un lenguaje, la sintaxis suele ser una de las partes menos interesantes, pero en tu lenguaje de scripting favorito escribir foo.bar.baz es mucho más fácil que el foo.unwrap().bar.unwrap().baz de Rust
      Lo digo incluso gustándome Rust; aunque Go y Rust a menudo se meten en el mismo saco, Go me parece mucho más cercano a un lenguaje de scripting reimplementado por un programador de C
      Aun así, si un lenguaje usa null, es mejor que el valor por defecto sea no anulable. Especialmente si hay sintaxis breve como ? o .?, en proyectos grandes vale la pena pagar esa carga sintáctica
    • Si no usas punteros, tampoco hay null, ¡viva!... 😭
  • Entiendo que Go no es un lenguaje que modele bien los objetos no anulables
    En esto se parece a C, y Option<T> puede representarse como T*, pero T* no necesariamente significa Option<T>
    En general estoy de acuerdo con el artículo. Cuando trabajaba en una empresa de firmware embebido, también intenté convencer al equipo de no poner checks de null por todas partes en el código C++ y usar assert
    Un assert es fácil de depurar, desde el punto de vista de cobertura no cuenta como una rama, y transmite claramente al lector las condiciones esperadas. Como se excluye en builds de release, también es más eficiente
    Sin embargo, entiendo que en Go una desreferencia de nil ya da buena información de depuración, así que la ventaja de assert no es tan grande como en C++

    • La desreferencia de nil en Go hace panic de forma determinista, mejor que la desreferencia de un puntero null en C, pero tampoco es tan buena porque el error recién aparece cuando realmente se desreferencia el puntero
      En el ejemplo del artículo explotaría bien adentro de checkLimit, y desde ahí habría que rastrear de dónde vino el nil. Según el sistema o la arquitectura, eso puede ser bastante complejo
      Por eso hacer assert justo dentro de NewRateLimiter sí aporta una ventaja clara. En el código de ejemplo, sería cambiar
      if client == nil {  
          return nil, errors.New("redis client is nil")  
      }  
      
      por
      if client == nil {  
          panic("redis client is nil")  
      }  
      
      Aunque el equipo de Go está fuertemente en contra de las assertions, y panic tampoco es ideal porque, si no se captura, crashea todo el runtime
    • Creo que un check de null y un assert son cosas completamente distintas
      Un assert significa “este estado no es válido”, y una macro de assert puede convertir ese check de null en una no-op en builds de release
      Según cómo esté definida la macro de assert, pueden producirse optimizaciones relacionadas con comportamiento indefinido, eliminarse checks posteriores y terminar en crashes confusos
      Por ejemplo, he visto definiciones de assert donde en assert(p); if (!p) { ... } se elimina el check posterior
      Decir sin más “no hagas checks de null, usa assert” puede servir para invariantes de estado, pero no para verificar errores
  • Hay un buen consejo en la conclusión
    Si aparecen checks de nil por todas partes, es una de dos: o es código normal que se defiende de entradas de frontera no confiables, o es un problema de diseño donde el codebase no logró establecer invariantes
    En un sistema donde no se puede confiar en ningún parámetro, la solución no es agregar más checks. Puede que por el momento haya que hacerlo, pero el trabajo real es establecer las invariantes que esos checks están sustituyendo y convertir gradualmente el ruido nacido del miedo en garantías de las que el sistema pueda depender
    Creo que esto va más allá de los checks de nil. Agregar checks o código defensivo en las “hojas” de un sistema suele aparecer como una forma de tratar el síntoma de que faltan invariantes o de que no se están imponiendo bien
    “Agregar un check más” es fácil de tomar como valor predeterminado, pero tiene un límite de escalabilidad. En algún momento, la lógica de checks supera a la lógica funcional y la complejidad total crece fuera de control
    Los checks adicionales para evitar uno o dos bugs normalmente no hacen daño, pero cuando uno siente que la cantidad y la complejidad de los checks están creciendo demasiado, a largo plazo suele ser mejor para el sistema y para la vida de quienes lo mantienen dar un paso atrás y buscar la causa raíz, en vez de seguir arreglando solo las hojas

    • Hacer assert de las invariantes es excelente cuando se empieza así desde el principio y se mantiene
      Pero entrenar a los desarrolladores para que dejen la programación defensiva es un problema más difícil
  • Estas invariantes, en este caso cosas como la no nulabilidad, se pueden modelar mucho mejor en sistemas de tipos más expresivos que el de Go
    Mi texto favorito sobre este tema es el artículo de Alexis King de 2019, Parse, don't validate
    El principio se puede aplicar en todas partes, pero en el sistema de tipos de Haskell parece realmente fácil. Intenté seguir los consejos de Alexis durante años en TypeScript, pero no fue sencillo

  • En resumen, el problema no es que haya demasiados checks, sino envolver nil como valor

  • Este problema ha aparecido una y otra vez, y lo veo como resultado de un lenguaje donde el manejo de errores no es una función de primera clase
    Como recuerdo que se mencionó en otros hilos, en la práctica los linters estándar terminan imponiendo esta estructura
    No sé si estos checks de nil sean lógicamente malos. Muchos lenguajes tienen manejo de errores incorporado, y la diferencia está más bien en la consistencia y la simplicidad de la propagación
    Ante una interfaz que produce errores, las opciones son básicamente cuatro: manejar y recuperarse, ignorar, propagar el error, o descartar el error y propagar uno propio; esta última también puede envolver el error existente
    Los lenguajes donde el manejo de errores es una función de primera clase suelen facilitar las opciones 2 y 3, y eso es más cierto en los lenguajes modernos. Por eso la 4 también puede quedar bastante limpia según el lenguaje
    La 1 no se puede ayudar demasiado con soporte de primera clase, salvo haciendo más explícito que ese manejo es necesario
    En el fondo, si una función puede producir un error, todos los lenguajes, independientemente de cómo lo implementen, están haciendo algo como {error,result} = functioncall() seguido de if (error) { ... }
    Como en Go el manejo de errores no es de primera clase, muchas funciones devuelven preventivamente una tupla (result, err), y como el linter prácticamente obliga a hacer el check err != nil, da la impresión de que el código está lleno de ese patrón
    Creo que es una falla de diseño del lenguaje que el propio lenguaje no trate directamente el manejo correcto de errores, pero una vez que estás en esa posición, este modelo probablemente parece cercano a lo mejor posible
    No sé si el código Go usa de forma idiomática tipos de retorno opcionales para distinguir entre errores que funcionalmente se pueden ignorar y errores “a los que hay que prestar atención”. Si incluso en esos casos lo idiomático es devolver siempre un tipo de error, entonces el linter seguramente impondrá siempre este patrón
    No es que odie Go; simplemente no estoy de acuerdo con una decisión de diseño. Uno puede quejarse de decisiones de diseño en casi cualquier lenguaje
    Creo que el mayor error de Go es que, en la práctica, hacer checks explícitos err != nil en casi todas partes es funcionalmente obligatorio, y por eso los linters también terminan exigiéndolos

  • Cuando Go apareció por primera vez, cientos de personas ya señalaron lo ridícula que era toda esta estructura
    Pero el lenguaje ganó mucha popularidad, y las críticas fueron desestimadas bajo la idea de que Rob Pike sabía más
    Me alegra ver que recién ahora la gente lo discute con argumentos lógicos y de forma normal
    No es que esto no se supiera desde hace décadas como una mala idea, pero si Google lo hace, debe ser bueno… ¿no?

    • No soy fan de Go, pero este encuadre me molesta
      Porque llamarlo “tontería ridícula” tiende a sofocar precisamente el pensamiento lógico que dices querer ver más
      No recuerdo en qué podcast de Oxide fue, pero Bryan Cantrill dijo algo como “quiero estudiar esto para poder odiarlo mejor”
      En ese sentido, quiero entender por qué la gente se entusiasmó tanto con Go en la década de 2010. Parte de eso sin duda fue hype, y en mi trabajo de entonces vi personalmente a desarrolladores entusiasmarse sin poder explicar por qué era bueno
      Pero no creo que haya sido puro hype. Me pregunto cuál habrá sido el steel-man argument más fuerte de esa época para usar Go