Comprobaciones excesivas de punteros nil en Go
(konradreiche.com)- 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
RateLimitertiene un*redis.Clientcomo campo y dentro deAllowverificar.redis != nilparece 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)cuandoclient == niles 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.
- Si ocurre un error en
- Así, incluso deja de ser necesario que el constructor de
RateLimiterdevuelva 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 NULLo 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 == nildentro deRateLimiter.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
Allowvuelve 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 conhttp.StatusBadRequesty se retorna. - Después de la validación,
reqes un valor válido, y luegoh.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
Allowfinal se enfoca en la lógica real, sin comprobaciones de nil:userID := GetUserID(req)- si
userID == "", devuelvefalse, nil - de lo contrario, llama a
r.checkLimit(ctx, userID)
- La comprobación de
userIDvací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
Opiniones en Lobste.rs
Les vuelvo a pedir a otros programadores de Go que, por favor, envuelvan los errores
A medida que se desenrolla la pila de llamadas, debería acumularse contexto sobre el error
errmás interno indique qué pasóEn la práctica, “envolver” suele terminar siendo hacer
grepsobre la cadena del error, esperar que esa cadena sea única y forzar la creatividad para hacerla únicaHace 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
Creo que Go crea dos problemas aquí
Es la parte de “como no puedes controlar qué te pasan, tiene sentido comprobar si es
nilen 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 razonamientoEl 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 anulablesCreo que en 2026 ya no deberíamos seguir sufriendo este problema
En un lenguaje, la sintaxis suele ser una de las partes menos interesantes, pero en tu lenguaje de scripting favorito escribir
foo.bar.bazes mucho más fácil que elfoo.unwrap().bar.unwrap().bazde RustLo 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ácticaEntiendo que Go no es un lenguaje que modele bien los objetos no anulables
En esto se parece a C, y
Option<T>puede representarse comoT*, peroT*no necesariamente significaOption<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++
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 complejoPor eso hacer assert justo dentro de
NewRateLimitersí aporta una ventaja clara. En el código de ejemplo, sería cambiar por 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 runtimeUn 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 posteriorDecir 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
nilpor 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 invariantesEn 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
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 deif (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 checkerr != nil, da la impresión de que el código está lleno de ese patrónCreo 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 != nilen casi todas partes es funcionalmente obligatorio, y por eso los linters también terminan exigiéndolosCuando 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?
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