3 puntos por GN⁺ 2025-05-18 | 1 comentarios | Compartir por WhatsApp
  • Si las bifurcaciones condicionales dentro de una función se suben al lado del llamador, y las tareas repetidas sobre varios elementos se bajan hacia operaciones por lotes en lugar de llamadas individuales, se puede simplificar al mismo tiempo el flujo del código y el rendimiento
  • Si se sube el if, resulta más fácil garantizar las precondiciones con tipos o assert, y se reducen las comprobaciones duplicadas y las bifurcaciones innecesarias dentro de la función
  • Si la lógica de bifurcación está dispersa entre varias funciones, se vuelve difícil encontrar condiciones muertas o decisiones repetidas, y una estructura donde se crea un enum para luego volver a hacer match también puede terminar repitiendo la misma bifurcación en la estructura de datos
  • Si se baja el for hacia operaciones por lotes, se pueden procesar varios objetos de una sola vez para amortizar el costo de arranque, y además aparecen oportunidades de optimización como reordenar el procesamiento o la vectorización
  • Si se usan juntas ambas reglas, disminuyen la reevaluación de condiciones dentro de los bucles y las bifurcaciones en los hot loops, acercando la estructura a una donde el costo de decisión del plano de control se traslada al procesamiento por lotes del plano de datos

Subir el if hacia el lado del llamador

  • Si hay una condición if dentro de una función, primero conviene revisar si esa condición puede moverse al llamador
    • Una función que recibe Walrus directamente es una forma más simple que una que recibe Option<Walrus> y retorna si es None
    • En lugar de que la función verifique la precondición y “no haga nada”, el llamador puede validarla antes y garantizarla mediante tipos o assert
  • Este modo de subir las comprobaciones de precondición puede propagarse a toda la ruta de llamadas, y como resultado motiva reducir la cantidad de comprobaciones
  • El flujo de control y los if tienden a aumentar la complejidad del código y la posibilidad de errores
    • Una estructura donde la lógica de bifurcación compleja se concentra en una sola función de nivel superior, y el trabajo real se delega a funciones inferiores lineales, resulta más fácil de leer
    • Cuando el flujo de control complejo está reunido en una sola función que cabe en una pantalla, en vez de disperso por todo el archivo, es más fácil notar condiciones duplicadas y condiciones muertas
  • El refactoring “dissolving enum” es útil cuando la creación de un enum y su match repiten la misma bifurcación
    • Si f() crea E::Foo o E::Bar según una condición, y g() luego vuelve a hacer match para llamar a foo() o bar(), la bifurcación aparece dos veces
    • Si se lleva la condición hacia arriba, main() pasa a llamar directamente a foo(x) o bar(y) usando esa misma condición

Bajar el for hacia operaciones por lotes

  • En el pensamiento orientado a datos, los programas suelen manejar conjuntos de varios objetos, y las rutas calientes también tienden a calentarse porque procesan muchas entidades
    • Es útil introducir el concepto de lote de objetos y tratar la versión escalar como un caso especial de la versión por lotes
    • for walrus in walruses { frobnicate(walrus) } es una forma peor que frobnicate_batch(walruses)
  • La principal ventaja del procesamiento por lotes es el rendimiento
    • Al manejar varios objetivos a la vez, se puede amortizar el costo de arranque
    • Se puede cambiar con flexibilidad el orden de procesamiento y también disminuye la necesidad de procesar entidades en un orden específico
    • Se pueden usar técnicas de vectorización o struct-of-array, procesando primero un campo de todas las entidades y luego pasando a otro campo
  • Como caso extremo de rendimiento, se enlaza Vectorized Interpreters Talk
  • Como ejemplo interesante, está la multiplicación de polinomios basada en FFT
    • Evaluar un polinomio simultáneamente en varios puntos puede ser más rápido que evaluar puntos individuales varias veces
  • Las reglas de if y for pueden aplicarse juntas
    • Si la condición está fuera del bucle, no se vuelve a evaluar condition repetidamente
    • Se eliminan las bifurcaciones del hot loop
    • Se abre la posibilidad de vectorización
  • El mismo patrón funciona tanto a nivel micro como a nivel macro
    • La estructura de TigerBeetle procesa lotes de objetos simultáneamente en el plano de datos, amortizando así el costo de decisión del plano de control
  • Aunque la motivación principal de la regla del for es el rendimiento, también puede ayudar a la expresividad
    • jQuery operaba sobre colecciones de elementos y tuvo éxito en el pasado
    • El lenguaje de los espacios vectoriales abstractos a veces puede ser una mejor herramienta mental que un conjunto de ecuaciones por coordenada

1 comentarios

 
GN⁺ 2025-05-18
Opiniones en Hacker News
  • Mi extraño modelo mental es que hay un árbol de estados posibles y de flujo del programa, y que las condicionales podan ese árbol.
    Conviene podarlo lo antes posible para terminar trabajando solo en menos ramas.
    En última instancia, me gustaría que una función se enfocara en una de dos cosas: recorrer el árbol del programa o hacer el trabajo real.

    • Esta perspectiva encaja bien con lo que se ve en la teoría de lenguajes de programación o en la semántica de pasos pequeños del cálculo lambda.
      Las expresiones se evalúan “reescribiéndose” repetidamente según reglas de reducción. Por ejemplo, (1 + 2) + 4 pasa a 3 + 4 y luego a 7.
      Aquí hay reglas de congruencia, que determinan qué subexpresión se evaluará a continuación, y reglas de cálculo, que realmente cambian la expresión y modifican el estado del programa.
      Los lenguajes estrictos, es decir, no perezosos, en su mayoría hacen que todas las subexpresiones se evalúen antes que la expresión padre, pero las construcciones especiales como las condicionales y los bucles infinitos son excepciones.
      En las condicionales, la regla de cálculo se aplica antes de que las reglas de congruencia indiquen evaluar todas las subexpresiones, y literalmente poda el árbol de expresiones.
      [1]: Recomiendo Types and Programming Languages, de Benjamin C. Pierce.
    • Mi modelo mental es ajustarme al mundo concreto en el que está el código que estoy escribiendo.
      Hay que mirar las características del dominio, los patrones de la base de código existente, en qué etapa del pipeline de datos se encuentra, las características de rendimiento, etc.
      Antes intentaba crear reglas y heurísticas de organización de código como estas, pero después de escribir suficiente código uno acepta que el nivel de abstracción está equivocado y no vale la pena aferrarse a ellas por mucho tiempo.
      También es revelador que estas discusiones se apoyen en nombres de funciones ficticios o variables de una sola letra. En una “isla de código” sin contexto externo, casi cualquier regla puede sonar razonable.
      Solo si se hace la cómoda suposición de que g es y seguirá siendo el único llamador de h, se puede decir que esta regla dejó al descubierto una rama muerta.
      En una base de código real, normalmente hay una razón por la que g y h no se fusionaron desde el principio.
    • Por tirar un modelo cercano: las clases son sustantivos y las funciones son verbos.
    • No es un modelo tan raro; si lo llevas hasta sus últimas consecuencias, en realidad se acerca bastante al modelo de ejecución de Prolog.
  • Una regla más general es poner el if cerca del origen de la entrada: https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...
    La clave es encontrar ese punto, incluyendo los puntos de entrada desde el exterior hacia el programa y los datos traídos desde otros servicios, y refinarlo para crear tantas garantías como sea posible antes de llegar a la lógica central, especialmente las partes que consumen muchos recursos.
    Si es posible, conviene codificar esas garantías en los tipos.

  • Una conjetura laxa como “si dentro de una función hay una condición if, considera si puede moverse al llamador” tiene demasiados contraejemplos.
    Si la función se llama desde 37 lugares, ¿hay que repetir el if en todos?
    Si esa función es getaddrinfo o EnterCriticalSection, ¿hay que empujar el if hacia afuera, hacia los usuarios de la API?
    Creo que esta transformación solo se puede considerar cuando se trata de una función interna llamada desde, como máximo, dos lugares, y cuando esa decisión queda fuera de la responsabilidad de la función.
    Otra forma es hacer que la función solo ejecute el if y llame a dos funciones auxiliares.
    Si el llamador necesita sacar la condición fuera del bucle, puede usar un “helper de interpretación de condición” de bajo nivel. Los llamadores que no necesitan dentro/fuera del bucle, sino un único if, pueden usar una función de conveniencia que oculte el if.
    Pero esto es para optimizar, y la optimización suele chocar con una buena estructura del programa.
    En orientación a objetos, la decisión del if dentro del llamado aparece como despacho de métodos, es decir, elegir qué método invocar.
    La técnica de sacar el despacho de métodos fuera del bucle también puede ir contra el flujo del diseño.
    Por ejemplo, si quieres llenar un objeto canvas con una imagen raster, no querrás recorrer los píxeles de la imagen llamando a canvas.putpixel(x, y, color). Debería haber un método para hacer blit de la imagen al canvas o a esa región rectangular.

    • Si la función se llama desde 37 lugares, en este caso el sentido sería más bien dividir la función en dos funciones que implementen las ramas verdadero/falso y hacer que se llamen desde 21 y 16 lugares, respectivamente.
    • La palabra clave aquí es consider.
      El artículo apunta a un problema de diseño bastante concreto que aparece especialmente al usar cosas como uniones etiquetadas.
    • Si una función se llama desde 37 lugares, habría que refactorizar el código, pero aun así la respuesta es “depende”.
      DRY se siente como la respuesta correcta, pero hay que ver ejemplos reales de código para decidir.
      Si es una función de biblioteca, su posición es especial. Está en una frontera de propiedad, los datos cruzan dominios y, dicho en términos de DDD, atraviesan un contexto delimitado. Así que debe proteger su propio territorio por sí misma.
      EnterCriticalSection sugiere una ruta de código donde tiene sentido una verificación fuerte al entrar, incluida la validación con condiciones if, y debe verse como una frontera de dominio.
      En cambio, si estás escribiendo una aplicación y una función común de la aplicación tiene un if, se puede empujar hacia afuera con seguridad.
      Incluso dentro de una biblioteca o de una sección crítica de código, es seguro subir el if hacia el borde, en lugar de dejarlo muy adentro.
      Suena razonable el consejo de administrar tu propio dominio, no exigirle cosas al dominio ajeno y, dentro de ese dominio, mover el flujo de control hacia los bordes.
      Claro que una regla práctica no deja de ser una regla práctica; en el mundo real, alguien que entienda el contexto y pueda juzgar razonablemente debe evaluarla.
  • El ejemplo de “refactorización por descomposición de enumeraciones” es, en esencia, polimorfismo.
    Se puede reemplazar match por una llamada a un método polimórfico sobre la enumeración.
    El objetivo es separar el punto donde se crea la distinción de casos, es decir, el primer if y el punto donde, en consecuencia, se ejecuta foo o bar.
    La distinción de casos la lleva consigo el objeto —aquí, el valor de la enumeración o el closure—, y no hace falta repetirla en el punto de llamada.
    Es decir, si cambia la distinción de casos, basta con cambiar el punto donde se crea esa distinción, y ya no hace falta cambiar los puntos que disparan el comportamiento para cada caso.
    Pero hay un trade-off. En el punto donde se ejecuta el comportamiento puede ser útil ver directamente los casos individuales que hay que considerar, pero a cambio se agrega una dependencia a nivel de código respecto de la lista de casos individuales.

  • Los escáneres de complejidad de código al final obligan a empujar el if hacia abajo. El artículo recomienda lo contrario.
    Si subes el if, el flujo de control suele centralizarse en una sola función, y esa función tiene lógica de ramificación compleja, pero delega el trabajo real a subrutinas lineales.
    https://docs.sonarsource.com/sonarqube-server/latest/user-gu...

    • La solución es separar decisión y ejecución, y este concepto lo tomé de Bertrand Meyer.
      if (weShouldDoThis()) { doThis(); }
      Esto complementa al núcleo funcional y la capa imperativa, o forma parte de ellos.
      Si dejas las verificaciones por separado, se vuelven más fáciles de probar; y si te preocupa la complejidad, puedes extraer cada cláusula dentro de la verificación a una función.
    • Los reportes de los escáneres de código no deben aceptarse como evangelio; hay que ponerlos en duda.
      En particular, Sonar reporta “code smells”, no bugs reales.
      Al corregir este tipo de elementos que “no son bugs”, el riesgo de introducir nuevos errores pasa de 0 a algo mayor que 0, y se puede desperdiciar tiempo de desarrolladores que deberían estar atendiendo problemas reales de producción.
    • En mi experiencia, esto suele ser un óptimo local.
      “Local” significa que solo lo es hasta que cambian los requisitos o se descubre una excepción y se necesita ramificar también fuera del loop.
      Si terminas con ramificaciones tanto dentro como fuera del loop, razonar sobre el código se vuelve más difícil.
      Si estás bastante seguro de que la condición solo afectará al interior del loop, está bien dejarla ahí.
      Pero si no es difícil imaginar un requisito que necesite ramificación también fuera del loop, puede convenir diseñar esa estructura desde antes.
      El código puede volverse más verboso, pero será más fácil de seguir y también habrá menos probabilidades de que se convierta en spaghetti más adelante.
      Esta es la razón por la que dejé de usar Haskell. Haskell te empuja a escribir la lógica más concisa y “localmente óptima”, pero eso se inclina más a expresar la lógica en sí que la intención de la lógica. Ante cambios menores de requisitos, podía terminar teniendo que desarmar todo de forma horrible.
    • Desde que vi quejarse de una función grande pero perfectamente legible, siempre me cayeron mal los escáneres de complejidad de código.
      Es mucho más fácil de leer cuando la lógica está en un solo lugar, y solo deberías intentar dividirla cuando los detalles empiecen a hacerte perder de vista el panorama general.
    • Ayer, en un hilo sobre LLM, alguien preguntó “cuál es otra herramienta poco confiable que la gente acepta para programar”, y ahora ya tengo una respuesta.
  • A veces prefiero dejar la lógica condicional dentro del llamado, porque evita que el llamador haga las cosas en el orden incorrecto por accidente.
    Por ejemplo, si quieres crear una operación idempotente, primero puedes verificar si esa tarea ya terminó y, si no, ejecutarla.
    Si empujas esa condición fuera del llamador, cada llamador de la función tiene que verificar por su cuenta que la invoca de la manera correcta para obtener la garantía de idempotencia, y no puedes abstraer esa garantía.
    También puede haber casos en los que debas ejecutar una serie de verificaciones antes de hacer algo dentro de una transacción de base de datos. ¿Cómo se supone que mantienes las verificaciones dentro del límite de la transacción al aplicar esta filosofía?

    • Tal vez podrías escribir una función sin verificaciones y luego tener una función wrapper que solo haga las verificaciones y llame a la función interna.
    • En realidad, la respuesta está en la pregunta.
      Si empujas la condición fuera del llamador, esa función ya no es idempotente, así que obviamente no puede ofrecer esa garantía.
      Pero si una función individual tiene que implementar gestión de estado para ofrecer idempotencia, probablemente sea una estructura bastante rara, y parece que está ocurriendo demasiada lógica dentro de una sola función.
      El código idempotente normalmente cae en dos categorías.
      Una es cuando el modelo de datos y la operación realizada son inherentemente idempotentes: una operación sin estado, o una operación estilo PUT donde los datos de entrada contienen todo el estado que debe registrarse.
      La otra es una operación de negocio más compleja que crea una abstracción idempotente proporcionando una abstracción de aplicación atómica que realiza rollback o garantiza que una falla parcial no corrompa el estado.
      En el primer caso, no hace falta verificar el orden de las operaciones. Como es inherentemente idempotente, simplemente la ejecutas otra vez.
      En el segundo caso, no puedes aplicar una abstracción simple. Debes registrar la operación deseada, garantizar que se complete o falle, y luego asegurar que esa finalización o falla persista de forma permanente.
      Ese tipo de lógica no es algo que convenga meter en una sola función y componer con otras operaciones.
  • Estos consejos son muy opinados, así que no deberían tratarse como reglas prácticas.
    Creo que aquí no hay una regla práctica como tal. Si tuviera que formular una, probablemente diría lo contrario.
    El if debería empujarse hacia abajo por DRY.
    Si el rendimiento lo permite, deberías considerar subir el for. Así puedes usar filter/map/reduce y composición de funciones para elegir qué comportamiento aplicar a qué objetos, y en la práctica puedes vectorizar el código.

    • O invirtieron los nombres, o las razones presentadas no parecen respaldar la conclusión.
      Empujar el if hacia abajo normalmente impide la vectorización.
      Los casos de los que habla el artículo son situaciones no DRY, especialmente cuando el tipo está etiquetado internamente y ramificaciones similares tienen que proliferar en muchas funciones más abajo en la pila.
  • No estoy convencido de que esta sea una regla “buena” que valga la pena seguir.
    A veces puede serlo, pero depende tanto del contexto que es difícil sacar una conclusión.
    Se siente parecida a una regla como “i antes de e, excepto después de c”. Tiene tantas excepciones que, en la práctica, es como si no hubiera regla.

  • Obtuve una versión parecida de 99 Bottles of OOP, de Sandi Metz.
    En general no es mi estilo, pero al trabajar en bases de código donde se pasan muchos flags varios niveles hacia abajo, el punto de mover las bifurcaciones de la lógica hacia arriba en la pila de llamadas me pareció muy convincente.
    https://sandimetz.com/99bottles

    • De inmediato me vino a la mente The Wrong Abstraction, de la misma autora.
      Poner una rama dentro de un bucle for es una abstracción que dice: “el bucle for es la regla, y la rama es el comportamiento”.
      Pero es muy común que nuevos requisitos rompan esa abstracción.
      Entonces hay que hacer rodeos, y el código resultante termina teniendo una abstracción que se aplica en algunos casos y en otros no, o se vuelve difícil de seguir porque se le meten un montón de parámetros a la abstracción para que aplique en todas partes.
      Si no se hubiera creado esa abstracción desde el principio, el código resultante podría haber sido más fácil de modificar y entender.
      https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
  • Para la legibilidad del código, conviene empujar todo hacia abajo.
    printInvoice(invoice, options) es mucho mejor que if(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}.
    Con los bucles pasa lo mismo: printInvoices(invoices) es mejor que for(invoice of invoices){ printInvoice(invoice) }.
    Al final, la legibilidad del código es muy importante, pero la encapsulación lo es aún más, así que hay que mezclar ambas adecuadamente.

    • La función printInvoice debería imprimir una factura.
      ¿Qué pasa si no puede imprimir la factura porque una de las condiciones indicadas por el nombre resulta ser falsa?
      Se podría lanzar una excepción o devolver un valor centinela o un tipo de error, pero en ese caso no queda claro de inmediato qué habría que hacer.
      En especial en lenguajes como Java o C++, donde se evita usar excepciones para el flujo de control normal y los errores de estilo monádico no son comunes, puede ser mejor estructurarlo más cerca del segundo estilo.
      Eso sí, si el formato vertical no representa un error, debería manejarlo la impresora de facturas.
      La encapsulación parece ser sobre todo una herramienta para la legibilidad del código a largo plazo, la refactorización y posibilidad de cambio locales, y la capacidad de razonar sobre el comportamiento global preocupándose solo por objetos locales.
      Por eso, comparar legibilidad y encapsulación para decidir cuál es más importante se siente como un error de categoría.
    • Decir “empujar todo hacia abajo para mejorar la legibilidad” mientras se muestra el antipatrón de flecha no está muy bien.
      En su lugar, debería hacerse así:
      if(!printerReady){ return; }
      if(!printerHasInk){ return; }
      if(!printerHasPaper){ return; }
      if(!invoiceFormatIsPortrait){ return; }
      Es mucho más fácil de leer que una flecha que va creciendo.
      Aun así, está bien poner el bucle en su propia función y dejar ya resueltas las demás suposiciones.
    • Esto podría ser el driver de impresora en una PC o el circuito interno de la impresora, así que las opiniones pueden variar según la persona.
      Cuando no hay papel, la impresora en sí jamás debería hacer girar las ruedas en vacío. Esa comprobación la dejaría dentro de la función.
    • Si fuera en Elixir, probablemente llamaría a esa función algo como maybe_print_invoice, y esa opción me gusta mucho más.