3 puntos por GN⁺ 2025-05-18 | 1 comentarios | Compartir por WhatsApp
  • Subir las sentencias if desde dentro de una función hacia el sitio donde se la llama ayuda a reducir la complejidad del código
  • Concentrar la verificación de condiciones y el manejo de ramas en un solo lugar permite detectar con facilidad duplicaciones y comprobaciones de ramas innecesarias
  • Usar el refactor de disolver enums ayuda a evitar que la misma condición se disperse por distintas partes del código
  • Los bucles for basados en operaciones por lotes son eficaces para mejorar el rendimiento y optimizar tareas repetitivas
  • Combinar el patrón de subir los if y bajar los for permite aumentar al mismo tiempo la legibilidad y la eficiencia del código

Una nota breve sobre dos reglas relacionadas

  • Cuando existe una condición if dentro de una función, se recomienda pensar si puede moverse al sitio donde la función es llamada
  • Como en el ejemplo, en lugar de comprobar una precondition (precondición) dentro de la función, es preferible delegar esa validación al punto de llamada o hacer que el tipo (o un assert) garantice la precondición
  • La estrategia de subir (Push up) las verificaciones de precondición afecta al código en general y produce el efecto de reducir la cantidad de comprobaciones innecesarias

Concentración del flujo de control y de las condiciones

  • El flujo de control y las sentencias if son una de las principales causas de complejidad y errores en el código
  • Es útil concentrar las condiciones en niveles superiores, como el punto de llamada, de modo que el manejo de ramas quede reunido en una sola función y el trabajo real se delegue a subrutinas lineales (straight-line)
  • Cuando las ramas y el flujo de control se reúnen en un solo lugar, resulta más fácil identificar ramas duplicadas y condiciones innecesarias

Ejemplo:

  • Cuando hay if anidados dentro de la función f, es más fácil reconocer código muerto (Dead Branch)
  • Si las ramas están dispersas a través de varias funciones (g, h), esto se vuelve más difícil de detectar

Refactor de disolver enums (Dissolving enum Refactor)

  • Si el código encapsula la misma rama condicional en un enum u otra estructura, se puede subir la condición a un nivel superior para separar con mayor claridad las ramas y el trabajo
  • Aplicar este enfoque evita que la misma condición aparezca repetida varias veces en el código

Ejemplo:

  • Una situación en la que la misma condición de rama está expresada por separado en las funciones f, g y en el enum E
  • Puede simplificarse todo el código con una sola rama condicional superior

Pensamiento orientado a datos (Data Oriented Thinking) y operaciones por lotes

  • La mayoría de los programas funcionan con múltiples objetos (entidades). El rendimiento en la ruta crítica (Hot Path) suele depender de procesar muchos objetos
  • Es recomendable introducir el concepto de lote (batch) para que las operaciones sobre conjuntos de objetos sean la base, y tratar las operaciones sobre un solo objeto como un caso especial

Ejemplo:

  • Tener como base una función de procesamiento por lotes, como frobnicate_batch(walruses)

  • Y convertir el tratamiento de objetos individuales en un caso especial procesado mediante un bucle for

  • Este enfoque cumple un papel importante en la optimización del rendimiento, ya que en trabajos masivos reduce el costo de arranque y aumenta la flexibilidad del orden de procesamiento

  • También permite aprovechar operaciones SIMD (como struct-of-array), de modo que se puedan procesar ciertos campos en bloque antes de continuar con el trabajo completo

Casos prácticos y patrón recomendado

  • Como en la multiplicación de polinomios basada en FFT, se puede maximizar el rendimiento al permitir operaciones simultáneas en múltiples puntos
  • La regla de subir las condiciones y bajar los bucles puede aplicarse en paralelo

Ejemplo:

  • En lugar de comprobar la misma condición una y otra vez dentro de un bucle, sacar la condición fuera del bucle reduce las ramas dentro de la iteración y facilita la optimización y vectorización
  • Este enfoque garantiza una alta eficiencia en planos de datos de sistemas a gran escala, como el diseño de TigerBeetle

Conclusión

  • Al combinar el patrón de mover los if (condiciones) hacia arriba —al punto de llamada o de control— y los for (iteraciones) hacia abajo —a la capa de operación o procesamiento de datos—, se puede mejorar la legibilidad, la eficiencia y el rendimiento del código
  • Pensar desde la perspectiva de un espacio vectorial abstracto (operaciones sobre conjuntos) es una mejor herramienta para resolver problemas que el manejo repetitivo de ramas
  • En resumen: ¡los if hacia arriba, los for hacia abajo!

1 comentarios

 
GN⁺ 2025-05-18
Opiniones en Hacker News
  • Mi modelo mental particular es que los distintos estados o flujos de un programa forman una estructura de árbol. Los condicionales sirven para podar las ramas de ese árbol. Quiero podar lo antes posible para reducir la cantidad de ramas que habrá que procesar después. Quiero evitar la situación de evaluar y descartar cada rama una por una para al final terminar cortando todo el árbol de una sola vez. Viéndolo desde un ángulo un poco distinto, un condicional es un proceso para “detectar trabajo innecesario”, y un bucle es el “trabajo real”. En última instancia, la función que quiero o se concentra en recorrer el árbol del programa o en hacer el trabajo real
    • Quiero proponer mi modelo alternativo. Las clases son sustantivos y las funciones son verbos
    • Mi modelo mental lo adapto al mundo en el que existe el código concreto que estoy escribiendo. Cambia según las características del dominio, los patrones del código existente, las etapas del pipeline de datos, el perfil de rendimiento, etc. Al principio intenté crear reglas o heurísticas como esta, pero después de escribir mucho código me di cuenta de que estas reglas abstractas en la práctica no significan gran cosa. Muchas veces terminas fijando un nombre de función raro o una sola palabra, y la regla solo se cumple dentro de esa “isla de código”, pero en un codebase real normalmente hay una razón para no haber unido esas funciones. Por ejemplo, sale el tema de la “duplicación y las condiciones muertas”, pero esa regla aplica bajo la cómoda suposición de que esa función solo se llama desde un único lugar. En la práctica, a menudo están separadas por otras razones
    • Me parece un modelo bastante bueno
  • Una regla más general es poner los condicionales lo más cerca posible de la fuente de entrada. La idea es identificar cuanto antes los puntos de entrada al programa desde el exterior (incluyendo datos traídos de otros servicios) y establecer todas las garantías posibles antes de llegar a la lógica central (sobre todo antes de llegar a las partes que consumen muchos recursos). También es muy bueno expresar eso explícitamente en los tipos
    • ¿Pero no hace eso más difícil entender qué supuestos tiene la lógica central? ¿No obliga a revisar toda la cadena de llamadas del código?
  • El consejo de “si una condición está dentro de una función, considera moverla al lado del llamador” tiene demasiados contraejemplos. Si una función se llama desde 37 lugares, ¿hay que repetir el mismo if en cada llamada? Por ejemplo, ¿de verdad podrías decir que hay que mover ese if en funciones como getaddrinfo o EnterCriticalSection? Creo que esta transformación solo vale la pena considerarla cuando la función se llama desde un par de lugares y cuando esa decisión está fuera de la responsabilidad de la función. Una manera de hacerlo es delegar en una función helper que solo ejecute la condición. Y cuando necesites mover la condición fuera del bucle, puedes hacer que el llamador use directamente el helper de condición de más bajo nivel. Pero en el fondo este tipo de discusión gira alrededor de la “optimización”. Muchas veces la optimización choca con un mejor diseño del programa. Puede ser mejor diseño que el llamador no necesite conocer la condición. Este dilema aparece mucho también en OOP. Una decisión representada por un if a veces en realidad se resuelve mediante despacho de métodos. Sacar ese despacho fuera del bucle también puede chocar con principios de diseño. Por ejemplo, al dibujar una imagen en un canvas, en vez de llamar repetidamente a putpixel, usar un método como blit
    • Si una función se llama desde 37 lugares, sí hace falta refactorizar. Y para responder esa pregunta, depende del caso. DRY se siente como la respuesta correcta, pero hay que decidir viendo el código de ejemplo real. Si es una librería, está en un límite de propiedad, así que cada quien tiene que gestionar sus propios datos y responsabilidades. Funciones como EnterCriticalSection sí deben hacer validaciones fuertes al entrar, incluyendo condicionales. Pero en código de aplicación, mover el if al llamador puede estar bien. En librerías o código central, es apropiado mover el flujo de control hacia los bordes. Dentro del dominio que controlas, conviene poner el flujo de control en los bordes. Pero como siempre, estas reglas son solo pautas; alguien capaz de juzgar con criterio según el contexto tiene que decidir caso por caso
  • El ejemplo del refactor de “dissolving enum” es básicamente un patrón de polimorfismo. Puedes reemplazar un match con una llamada a método polimórfica. La idea es separar el momento en que decides la rama condicional inicial del momento en que se ejecuta el comportamiento real. La distinción entre casos la lleva el objeto (aquí, el valor del enum) o un closure, así que no hace falta repetirla en cada invocación. Si cambia la distinción entre casos, solo hay que cambiar el punto de bifurcación, y no el lugar donde ocurre la acción real. La desventaja es el trade-off entre la comodidad de poder ver directamente la rama de comportamiento de cada caso y el hecho de introducir una dependencia a nivel de código respecto de la lista de casos
  • A veces me gusta tener el condicional dentro de la función. Así puedo impedir a propósito que el llamador se equivoque con el orden en que debe invocarla. Por ejemplo, cuando necesito garantizar idempotencia, primero reviso si ya se procesó el estado y, si no, entonces ejecuto la acción. Si saco esa condición al sitio de llamada, la idempotencia solo queda garantizada si todos los llamadores siguen correctamente ese procedimiento, así que la abstracción ya no ofrece esa garantía. Me pregunto cómo aplicar esta filosofía en un caso así. Otro ejemplo sería cuando quieres hacer una operación en una transacción de base de datos después de realizar una serie de checks; ahí también surge la duda de dónde poner esos checks
    • Creo que ya respondiste tu propia pregunta. Si sacas el condicional al sitio de llamada, la función deja de ser idempotente y, claro, ya no puede garantizarlo. Si estás metiendo lógica de manejo de estado en cada función para garantizar idempotencia, quizá estás escribiendo un código bastante raro y eso puede significar que estás concentrando demasiada lógica de negocio en una sola función. El código idempotente se divide en dos grandes casos. Primero, código donde el modelo de datos o la operación en sí ya es idempotente. En ese caso ni siquiera hace falta preocuparse demasiado por el orden de procesamiento. El segundo caso es crear una abstracción idempotente para operaciones de negocio más complejas. Ahí necesitas lógica más compleja, como rollback o una abstracción de aplicación atómica (abstraction on atomic apply), y eso no es algo que quepa de forma sencilla en una sola función
    • Otra opción es crear una función interna sin checks y administrarlo con una función wrapper externa que haga los checks y luego llame a la interna
  • Los scanners de complejidad de código al final tienden a empujar los if hacia abajo. Pero este artículo recomienda lo contrario: subir los if, o sea, moverlos a funciones de nivel más alto. Así puedes manejar de forma centralizada la lógica de bifurcación compleja en una sola función y delegar el trabajo concreto real a subrutinas
    • La solución es separar la “decisión” de la “ejecución”. Es una idea que aprendí de Bertrand Meyer. Por ejemplo: if (weShouldDoThis()) { doThis(); }. Si sacas cada check a una función aparte, es más fácil probarlo y gestionar la complejidad
    • Hay que desconfiar seriamente de los reportes de los scanners de código. sonarqube y similares reportan a lo loco hasta “code smell” que no son bugs reales. Intentar corregir incluso el “código que no es problemático” de esta manera aumenta mucho el riesgo de introducir bugs nuevos y solo hace perder tiempo que debería dedicarse a problemas realmente importantes
    • Este tipo de optimización suele terminar siendo un “óptimo local”. Es decir, cuando aparecen nuevos requisitos o casos excepcionales, la lógica de bifurcación termina necesitando estar fuera del bucle. Y si en ese estado tienes ramas tanto dentro como fuera del bucle, se vuelve difícil de entender. Si estás seguro de que la condición solo se necesita dentro del bucle, déjala así; si no, prefiero alargar un poco más el diseño desde el inicio, aunque el código quede más verboso, si eso lo hace más fácil de entender. Tuve esa experiencia usando Haskell. Cuando persigues la forma más concisa y optimizada posible de la lógica (el óptimo local), basta con que cambien un poquito los requisitos para que el diseño deje de expresar la intención y solo quede la lógica en sí, y entonces hasta un cambio pequeño provoca un desenrollado fuerte del código
    • Los scanners de complejidad de código siempre me han molestado. Incluso se quejan de funciones grandes que en realidad son fáciles de leer. Cuando pones la lógica en un solo lugar es más fácil entender el contexto general, pero al dividir funciones hay que tener cuidado de no perder el contexto real
    • Ayer en un hilo sobre LLM alguien hablaba de “herramientas poco confiables que todos los desarrolladores aceptan”. Ahora ya sé la respuesta…
  • En algunos casos conviene incluso irse al enfoque contrario y aprovechar SIMD. Por ejemplo, con AVX-512 y similares puedes procesar código con ramas como código sin ramas usando registros de máscara vectorial. Por ejemplo, un if dentro de un for es más fácil de mantener y más eficiente en acceso a memoria que un if fuera del for. Un ejemplo concreto: si quieres hacer una operación donde a los impares se les suma 1 y a los pares se les resta 2, normalmente tendrías que tomar una rama en cada iteración del bucle, pero con SIMD puedes procesar 16 int al mismo tiempo, sin ramas. Si el compilador vectoriza bien, puede convertir el código original en una versión optimizada sin ramas
    • Creo que el código before que presentaste no encaja tanto con el punto central del artículo, y que de hecho la versión SIMD optimizada sí coincide con la idea principal del texto. En el ejemplo, el if dentro del for depende de los datos, así que no se puede subir fácilmente. Si el algoritmo tuviera una estructura como if (length % 2 == 1) { ... } else { ... }, donde la condición está fuera del bucle, entonces obviamente sí conviene sacar esa condición por encima del for. En la versión SIMD el if desaparece por completo, y ese es el tipo de patrón de código ideal que probablemente también le gustaría al autor del artículo
    • Yo también pensé de inmediato en código que bifurca según el valor de cada elemento dentro de un bucle for. ¿Alguien sabe qué tan difícil es para un compilador autovectorizar ese tipo de código? Me da curiosidad dónde está el límite
  • Personalmente no creo que sea una regla “buena”. Hay casos donde aplica, pero depende demasiado del contexto como para sacar una conclusión tajante. Se siente como las reglas de ortografía del inglés: tienen tantas excepciones que cuesta tratarlas como una regla de verdad
  • Enlace a la discusión de entonces (2023) (662 puntos, 295 comentarios) https://news.ycombinator.com/item?id=38282950
  • Vi algo muy parecido en 99 Bottles of OOP de Sandi Metz. No es mi estilo, pero sí coincido en que puede ser útil subir la lógica de bifurcación hasta la parte más alta del stack de llamadas. Lo sentí especialmente en codebases donde se iban pasando flags por varias capas. https://sandimetz.com/99bottles
    • Eso me hizo pensar de inmediato en el artículo del mismo autor, “The Wrong Abstraction”. Una bifurcación dentro de un for crea una abstracción donde “el for es la regla y la bifurcación es la acción”. Pero cuando aparece un requisito nuevo, esa abstracción se rompe, y empiezas a meter parámetros a la fuerza o a multiplicar los casos especiales, haciendo que el código sea más difícil de entender. Si desde el principio hubieras escrito el código sin esa abstracción, el resultado probablemente habría sido más claro y más fácil de mantener. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction