Subir los if y bajar los for
(matklad.github.io)- La idea central son dos reglas de estructuración de código: llevar las ramas condicionales hacia el lado del llamador y empujar el procesamiento repetitivo hacia las operaciones por lotes
- Al subir los
if, se vuelve más fácil imponer precondiciones mediante tipos oassert, y al concentrar el flujo de control complejo en un solo lugar, es más sencillo encontrar condiciones duplicadas y ramas muertas - Una estructura que crea un
enumy enseguida vuelve a hacermatchsobre él repite la misma condición como rama, estructura de datos y nueva rama, por lo que puede simplificarse a llamadas comofoo(x)ybar(y) - Al bajar los
for, las operaciones por lotes pasan a ser la unidad básica en vez de iterar objetos individuales, lo que permite amortizar costos de arranque, cambiar el orden de procesamiento, vectorizar y optimizar con struct-of-array - Sacar las condiciones fuera de los bucles reduce las ramas en los hot loops, y también lleva a arquitecturas como TigerBeetle, donde el costo de decisión del plano de control se amortiza mediante el procesamiento por lotes en el plano de datos
Subir los if
- Las condiciones
ifdentro de una función son candidatas a revisarse primero para ver si pueden moverse al llamador- El ejemplo considera que una función que recibe directamente un
Walruses una forma mejor que una que recibeOption<Walrus>y devuelve si esNone - En vez de comprobar una precondición dentro de la función y “no hacer nada”, es más claro que el llamador la verifique y fuerce su cumplimiento mediante el tipo o un
assert
- El ejemplo considera que una función que recibe directamente un
- La refactorización de subir las verificaciones de precondiciones puede propagarse a lo largo de la ruta de llamadas y, como resultado, reducir el número de verificaciones total
- El flujo de control y los
ifaumentan la complejidad y suelen convertirse en fuente de bugs- Al subir los
if, la lógica de ramificación compleja se concentra en una función, y el trabajo real baja a subrutinas lineales - Es más fácil detectar duplicaciones y condiciones muertas cuando el flujo de control complejo está en una sola función visible en una pantalla que cuando está disperso por todo el archivo
- Al subir los
- La refactorización “dissolving enum” consiste en eliminar el patrón de convertir una rama en una estructura de datos y luego volver a ramificar
- Si
f()creaE::Foo(x)oE::Bar(y)según una condición, yg(e)vuelve a usarmatchpara llamar afoo(x)obar(y), la misma condición se repite en varias formas - Al llevar la condición a
main(), se simplifica a una forma comoif condition { foo(x) } else { bar(y) }
- Si
Bajar los for
- Desde una perspectiva orientada a datos, los programas y los hot paths suelen manejar muchos objetos, así que se introducen lotes de objetos y las operaciones por lotes se toman como el caso base
- La versión escalar para objetos individuales pasa a ser un caso especial de la operación por lotes
frobnicate_batch(walruses)es una forma mejor quefor walrus in walruses { frobnicate(walrus) }
- El beneficio principal es el rendimiento
- Al manejar todo el lote de una vez, se pueden amortizar los costos de arranque
- También permite cambiar con flexibilidad el orden de procesamiento, vectorizar procesando primero un campo de todas las entidades o usar enfoques struct-of-array
- Como caso extremo, se enlaza Vectorized Interpreters Talk
- Un ejemplo interesante es 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 muchas veces
- Las reglas de
ifyforpueden aplicarse juntas- Una buena forma es ramificar fuera de
if conditiony ejecutarfor walrus in walrusespor cada rama - Una mala forma es evaluar
if conditioncada vez dentro del buclefor - Esto evita reevaluar condiciones, elimina ramas en los hot loops y puede habilitar la vectorización
- Una buena forma es ramificar fuera de
- Este patrón se aplica tanto a nivel micro como macro
- La arquitectura de TigerBeetle procesa lotes de objetos simultáneamente en el plano de datos para amortizar el costo de toma de decisiones del plano de control
- Aunque la motivación principal del consejo sobre
fores el rendimiento, operar a nivel de colecciones, como en jQuery, también puede ayudar a la expresividad
1 comentarios
Comentarios de Hacker News
Al principio me sorprendió que este artículo generara tanto rechazo, pero al recordar que esto es un consejo de diseño orientado a datos, se entiende
Como muchas personas en este foro, yo también paso la mayor parte del tiempo creando aplicaciones web de negocio, y en ese contexto este consejo puede parecer una tontería
Si en tu trabajo diario nunca tienes que preocuparte por la caché de instrucciones, parece una heurística razonable decir que en general puedes ignorar este consejo
Si quieres desarrollar intuición sobre cuándo esto sí importa, vale la pena buscar “Typical C++ Bullshit” de Mike Acton, y este artículo se siente como una versión más digerible de esas ideas
Coincido bastante con la preocupación de Casey Muratori, pero la mayor parte del software empresarial debería optimizarse más por facilidad de cambio y corrección, es decir, por la “programación a lo largo del tiempo”, que por rendimiento
Los desarrolladores intentan dividir lógica de negocio compleja en pequeños métodos privados dentro de clases para mantener DRY, pero “sube los
if” ayuda a evitar que la lógica de bifurcación quede dispersa entre varios métodos“Baja los
for” también es importante. Muchos flujos de llamadas terminan en consultas costosas a la base de datos, y es muy común que un bucle en un nivel superior provoque múltiples llamadas a la BD en niveles inferioresA menudo ese bucle puede reemplazarse con una cláusula SQL
whereo unjoin, y también conviene empujar agregaciones o filtros más cerca del DAO en vez de sacar montones de objetos y recorrerlos, porque así es más fácil optimizarlos cerca de la base de datosAun así, como cualquier principio de diseño, no debería aplicarse como un dogma sino de forma consciente
Me parece una lástima ver la facilidad de cambio y el rendimiento como si fueran opuestos. Todavía no he visto evidencia convincente de que realmente entren en conflicto
Uno de los aspectos más importantes del rendimiento es hacer las cosas pequeñas. Código pequeño, estructuras de datos pequeñas y menos instrucciones ejecutadas son la clave, y “pensar en la caché de instrucciones” en esencia también significa escribir código pequeño
Mientras más pequeño es el código, menos espacio hay para bugs, más fácil es entenderlo de una sola vez y más sencillo es lograr buena cobertura de pruebas, lo que favorece la corrección. La facilidad de cambio también mejora porque los cambios son más pequeños cuando el código es más pequeño
Claro, hay optimizaciones como paralelización, caché u optimizaciones de bajo nivel que vuelven el código más complejo, pero eso es solo una parte de la optimización de rendimiento, y un programador que realmente se toma el rendimiento en serio no haría eso sin perfilado ni análisis
if/foryfor/ifpueden no diferir mucho funcionalmente, pero uno puede ser más rápido que el otro, así que si puedes usar la forma correcta sin costo, no hay razón para elegir la más lentaLos programas simples suelen tener pilas de llamadas más cortas y evitan abstracciones complejas y costosas, así que tienden a usar menos memoria y correr más rápido
Buscar el máximo rendimiento posible puede volver complejo a un programa, pero la simplicidad real casi siempre trae un rendimiento razonable
“Bajar los
for” es principalmente un consejo orientado a datos, pero “subir losif” se acerca más a hacer el programa más simple. Más concretamente, se trata de aumentar la localidad del código fuente, y la clave es reunir la lógica de bifurcación en un solo lugar: https://loup-vaillant.fr/articles/source-of-readabilityEn particular, “bajar los bucles” es muy potente en apps CRUD. Procesar en lote las creaciones y actualizaciones siempre que sea posible suele ahorrar mucho más tiempo que cualquier cuello de botella de CPU
La diferencia entre
items.map(insertToDb/postToServer)yinsertToDb/postToServer(items)en casi todos los casos es de varios órdenes de magnitudHe visto optimizaciones de este tipo reducir tiempos de trabajo de segundos o minutos a milisegundos, y muchas veces además dejan APIs más limpias y logs más fáciles de leer
A medida que se gana experiencia, da la impresión de que demasiados programadores se preocupan por el “código bonito” en unidades pequeñas, pero no lo suficiente por el diseño de toda la base de código
Las funciones concisas y bien pulidas están bien, pero este tipo de textos puede terminar convirtiéndose en discusiones improductivas de bike-shedding en PR o debates
No me preocupa demasiado si una función está algo desordenada, dónde se ponen los
ify losfor, o si se usanmapyfilter, siempre que el nombre de la función sea apropiado, la interfaz y los tipos sean expresivos, el propósito esté claro, haya documentación y no se abuse de los efectos secundariosSi piensas en dónde validar cuando hay valores de entrada, según esta heurística debería validarse en la parte más alta, donde se recibe la entrada
Esto también ayuda a entender el código y, desde la perspectiva de la demostración, también es técnicamente necesario porque las precondiciones deben propagarse hacia arriba
El primer consejo claramente no es una discusión trivial, y el segundo es un poco más ambiguo
Intentan evitar que los juniors se disparen en el pie, pero muchas veces todavía no tienen una sensibilidad suficiente sobre el costo de la complejidad y sobre cómo evoluciona el código a largo plazo
En cambio, después de más de 20 años, uno suele valorar más la simplicidad que la mayoría de los elementos técnicos
Preguntas como “¿qué tan temprano conviene ramificar?” y “¿qué es lo que este código realmente tiene que hacer?” suelen dar las respuestas más valiosas a largo plazo
Las preguntas sobre abstracción y encapsulación, en cambio, tienden más bien a llevar a los debates del tipo mencionado antes, y si el código de quienes solo ven la “gran imagen” produce tantos problemas de seguridad, es en la práctica porque no entienden bien qué hace realmente esa base de código tan bien diseñada
En software sensible al rendimiento, donde el diseño orientado a datos encaja bien, sí hay que prestar más atención a estas cosas de pequeña escala. Así es como funciona la optimización del compilador
Entonces cambian las reglas del juego. Importa el significado de la sentencia, el código autoexplicativo cobra más sentido, los comentarios quedan solo para fines de razonamiento, y la “documentación” se acerca más a una especificación y a un manual de usuario
Como mucho puedes agrupar funciones, y a partir de ahí tienes que arreglártelas por tu cuenta
“Subir los if” tiene la desventaja de que dentro de la definición de la función no se ven directamente las precondiciones y poscondiciones, y hay que verificarlas en cada punto de llamada
En proyectos grandes con muchas personas, este tipo de función puede reutilizarse fuera del contexto previsto y terminar provocando bugs
Se puede resolver usando un framework de contratos, pero entonces las condiciones terminan escritas dos veces, en el contrato y en el código, y con los tipos dependientes pasa algo parecido
Es interesante la idea de etiquetar áreas de código que pertenecen a cierto contexto y definir funciones que solo puedan llamarse dentro de ese contexto
En Python, por ejemplo, se podría usar un decorador como
@requires_context("VALIDATED_XY")y un context managervalidated_xypara hacer que la función solo pueda llamarse dentro de un área validadaEl runtime no conoce el significado de ese contexto, pero con herramientas y pruebas se puede diseñar para que ese contexto solo se establezca cuando se cumplan ciertas condiciones
En lenguajes como Haskell, esto se puede forzar a nivel de tipos con algo como la mónada identidad, e incluso si no se fuerza a nivel de tipos, podría ser una forma interesante de proteger áreas de código “unsafe”
Por lo tanto, las precondiciones siguen siendo visibles directamente en la definición de la función, solo que aparecen como parte de la firma de tipos en vez de como una sentencia
ifEste es un patrón común en Rust, el lenguaje usado en el artículo, y a diferencia de una verificación con
if, aquí se trata de una precondición estricta comprobada en tiempo de compilación, no en tiempo de ejecución; si no se cumple, el programa ni siquiera compilaPor ejemplo, se puede recibir algo como
do_something(position: ValidatedPosition)y, tras validar unPositionnormal, convertirlo y pasarlo comoValidatedPositionEn la práctica, la validación probablemente se encapsularía dentro del constructor de
ValidatedPosition, pero el punto clave es que si intentas pasar unPositiontal cual, mypy te avisará que estás pasando un tipo incorrectoLa verificación de tipos en Python no es tan exhaustiva como en Rust, pero cada vez la uso más cuando quiero garantizar que los datos que se pasan hayan sido procesados de forma adecuada
Si una función tiene precondiciones, por supuesto se pueden afirmar al inicio de la función. Por ejemplo, como el sistema de tipos de Java permite null, una función que necesita un objeto debe lanzar una excepción si recibe null
Cada punto de llamada tiene la responsabilidad de invocar la función solo cuando las precondiciones sean verdaderas. Eso se desprende naturalmente de la propia definición de precondición
Llamar a una función violando sus precondiciones es un bug del llamador. Puede hacer falta código dentro de la función para comprobarlo y evitar comportamiento indefinido, pero hay que distinguir entre esas aserciones y el flujo de control real del programa, y el artículo trata de lo segundo
fn frobnicate(walrus: Walrus), si intentas pasar algo que no sea unWalrusen propiedad, el programa no compilaráIncluso si es genérico, el argumento debe satisfacer los límites de traits, y según cómo se use el argumento dentro de la función, el compilador exige en la definición de la función los límites necesarios
publicyprivateera, hasta cierto punto, servir para etiquetar código según un contexto específicoO quizá haga falta una semántica más específica que cruce los ámbitos que cubren
public,privateyprotecteden el ecosistema de .NETEl primer ejemplo no es malo por
ifyfor, sino por otra razónEn general, si existe un “contenedor” de algo, suele ser mejor escribir funciones para el “objeto” de nivel de dominio que está dentro, y no para el contenedor
En Clojure, cuando se usa un agent, no se escriben funciones para el agent, sino funciones para el objeto que el agent puede contener
En Elixir también, las funciones centrales del dominio operan sobre la estructura de datos interna del dominio, no sobre el PID, y cuando hace falta, la llamada a GenServer delega ahí
Así se gana flexibilidad y se separa con más limpieza el dominio central, “frobnicatear al Walrus”, del interés de la aplicación, “puede que haya o no un Walrus, y si lo hay, frobnicatearlo”
Validar temprano está bien, pero también es importante que la función emita claramente un error de validación en vez de reventar de pronto con un error raro
Haskell resuelve esto con
newtype, proporcionando un contenedor transparente que “certifica que ya se realizó la validación adecuada”El consejo que de verdad quisiera enfatizarle a la gente es preferir los “if tristes”. En vez de anidar varias capas para la ruta normal, casi siempre es más legible y mantenible un código que revisa una por una las condiciones incorrectas arriba y corrige o aborta de inmediato
Las personas tienden por naturaleza a enfocarse en el caso esperado, pero el código pide lo contrario: enfocarse primero en los casos excepcionales. Cada
ifañade carga mental, y si hay que obtener información de un sistema externo o terminar antes por un error, cuando la detección y el manejo están juntos esa carga puede soltarse de inmediatowalrus.frobnicate(), me parece que eso está más cerca de una preferencia de sintaxis que de la idea central que el autor intenta transmitirNo diría que esto sea mal consejo, pero tampoco es necesariamente buen consejo
El hecho de que el lenguaje elegido sea Rust ya dice bastante. Un sistema de tipos fuerte evita mucho de la programación defensiva que hace falta en otros lenguajes
Si un programador de C no verifica la validez de un puntero pasado a una función y provoca una desreferencia de NULL, no quisiera trabajar en equipo con esa persona
Así que al menos algunos
ifdefinitivamente deben ir más abajo, y conviene que sea de una forma en que los errores se propaguen bien hacia arribaSobre
forlo siento con menos fuerza, pero en C creo que la iteración también debe ir arriba y no abajo, porque los argumentos de arreglo decaen a punteros. En la función que crea el arreglo puedes confiar en la longitud, pero en una función a la que se le pasó como argumento noLa documentación debe reemplazar al sistema de tipos fuerte, no las verificaciones en tiempo de ejecución
Un código lleno de comprobaciones de NULL y otras medidas defensivas es mucho más difícil de leer
Se puede argumentar que en los límites de una librería hacen falta verificaciones más defensivas, y este artículo justo está diciendo que ese tipo de comprobaciones se muevan hacia arriba
El código crítico para seguridad puede ser distinto, pero en la mayoría de los casos basta con que una desreferencia accidental de NULL se detecte con pruebas, sanitizers y fuzzing
Para verificar valores de puntero inválidos habría que revisar el enorme conjunto de posibles valores incorrectos, y si solo se revisa NULL, en realidad no se está verificando un valor inválido
Si la precondición de una función es “el parámetro
pno puede ser NULL”, está bien comprobarlo. Pero si la precondición es “pdebe ser un puntero válido”, buena suerte encontrando una aserción adecuadaÚltimamente trabajo mucho con T-SQL, y no se pueden declarar parámetros o variables como
NOT NULLPor eso conviene revisar NULL tan pronto como sea razonablemente posible, normalmente al inicio del procedimiento almacenado
De lo contrario, NULL puede propagarse inesperadamente a lo profundo de la cadena de llamadas y crear problemas menos claros
Por suerte, los datos de las tablas sí pueden declararse como
NOT NULL, así que estos bugs normalmente no corrompen los datos, pero es mucho más fácil detectarlos tempranoAun así, si hay lógica que escribe en la base de datos según el valor de algún parámetro y ese valor inesperadamente es NULL, puede terminar escribiéndose un valor incorrecto o no escribirse en absoluto un valor necesario, lo que en la práctica puede corromper los datos
Así que la programación defensiva sí es la respuesta
Sin el contexto adecuado, este consejo se ve bastante extraño, e incluso podría ser mal consejo
Tanto los bucles como las sentencias
ifson operaciones de control de flujo, así que parte del argumento del artículo no tiene mucho sentidoEl argumento más fuerte parece ser el rendimiento, pero normalmente el rendimiento debería ser de las últimas cosas de las que preocuparse, especialmente si se trata de un consejo heurístico
Lamentablemente, el autor lo convirtió en una frase pegajosa, y ojalá no se difunda
Si se puede escribir el código mejorado como en el ejemplo del autor, entonces la condición es constante durante toda la ejecución del bucle. A menos que se evalúe una condición costosa en cada vuelta, la predicción de ramas se encargará en su mayor parte
Si la condición es una expresión booleana basada en un valor const, es posible que el compilador también pueda detectarlo
Pensé que todo el artículo llevaría a un ejemplo de este tipo: dividir
walrussegún una condición y llamar afrobnicate_batch(fwalrus)ytransmogrify_batch(twalrus)Pero en realidad fue por el camino de un ejemplo donde un solo condicional envuelve todo el bucle y lo parte en dos
Me sorprende que programadores con formación en ingeniería de software se equivoquen tan seguido con esto
Yo empecé a programar en el ámbito científico, y ahí es absolutamente necesario pensar en estas cosas
Solo elegir mal el orden de los bucles puede hacer que una simulación tarde 1 hora en lugar de 1 semana
Gracias a esa formación, optimizar de forma pequeña el orden de
foreifme sale por instinto, y el código que no lo hace simplemente se ve incorrectoNo estoy seguro de que este tipo de reglas generales realmente pueda aplicarse al código real
Estas reglas a menudo parecen una doctrina mal colocada, y aunque una entrada de blog empiece diciendo que es una heurística, los programadores jóvenes no siempre la reciben así
Hace unas semanas YouTube no dejaba de recomendarme un video llamado “I'm a never-nester”, y parecía defender que nunca debes anidar
if, lo cual resultaba bastante ridículoComo en el ejemplo del artículo, marcaban como “malo” código con
if conditiondentro de unfor, pero en la mayoría del código escrito así,conditiondepende dewalrus, así que no puedes subir elifSi sí pudiera subirse, sería tan obvio que estarías reevaluando la misma condición en cada vuelta del bucle que los programadores tenderían naturalmente a evitarlo
Pero si un junior o un estudiante lee un consejo que suena doctrinario, puede intentar seguirlo rígidamente y terminar produciendo peor código
match (condition_a, condition_b)no sea mejor queifanidadosifEl propósito del código es realizar cierta operación sobre
walrussegún una condición, pero en realidad elifestá usándose en lugar del polimorfismo y el sistema de tipos¿Por qué
walrustendría que tener dos funciones que deben llamarse en escenarios distintos? ¿Por qué no tener una sola función y dos tipos, y pasar el tipo correcto?Incluso con la estructura actual, podría elegirse arriba la función de procesamiento según la condición y hacer que el bucle de abajo invoque esa función seleccionada en cada iteración
Si tomas la decisión lo antes posible, no hace falta esparcirla por todo el código. El interior del código realiza siempre la misma tarea sin bifurcaciones, y la salida solo cambia a través del grafo de configuración
Claro que no es una idea nueva; ya era una idea vieja hace 15 años: https://www.youtube.com/watch?v=4F72VULWFvc
walrus, y ayuda a dejar ese hecho explícitoSi vuelves a aplicar aquí lo de “baja el
for”, terminas llamando afrobnicate_batch(walruses)otransmogrify_batch(walruses)dentro deif conditionAlguien logró poner en palabras un problema con el que me he topado varias veces pero que no había sabido expresar, y parece un buen modelo para tener presente
Al mismo tiempo, estas críticas también valen la pena. Ojalá algún desarrollador junior con tendencias dogmáticas lea ambas cosas y llegue a un juicio un poco más matizado
Los programadores en particular no deberían copiar este tipo de cosas y aplicarlas mecánicamente
Aplicar heurísticas y buenas prácticas a ciegas puede no ser una buena idea; al final, todo depende del contexto
Estas heurísticas suelen contener una pizca de sabiduría, pero en general luego hay que desdogmatizarlas otra vez para los programadores nuevos
Siempre hay muchos casos en los que empeora todo cuando intentas seguirlas con demasiada rigidez, y “saber cuándo no debes escuchar este consejo” es en realidad la parte más difícil
Me parece que estas reglas son útiles para jugar con ellas. Puedes adoptarlas, llevarlas hasta el extremo, incluso invertirlas un día o un año después, y ver hasta dónde llegan
Aprender sus límites es otro material más que se va acumulando sobre el pergamino reescrito una y otra vez
Algunos de esos koanes deberían contradecirse entre sí