El código lineal es más fácil de leer
(blog.separateconcerns.com)- La postura es que, más que separar niveles de abstracción dividiendo el código en funciones pequeñas, el código lineal que fluye de arriba hacia abajo facilita seguir el flujo completo.
- Si se crea una estructura top-down mediante extracción de funciones, puede ser necesario ir y venir para verificar entre funciones con nombres parecidos como
bakeybakePizza. - Como ocurre con la ubicación del precalentamiento del horno o con el resultado de pasar dos veces la pizza, las funciones pequeñas pueden revelar la intención, pero también ocultar el comportamiento real.
- Si al código lineal se le agregan comentarios por etapas, se puede explicar la intención de la tarea sin aumentar las referencias indirectas, por lo que puede ser más legible que agregar más abstracción.
- Extraer funciones pequeñas que se usan una sola vez provoca una pérdida de linealidad y, como en la forma de crear el horno del ejemplo, en código real incluso puede exponer problemas de rendimiento.
Cuando la linealidad es más importante que extraer funciones
- El ejemplo de Google Testing Blog compara dos implementaciones de
createPizzay considera que la implementación de la derecha es más fácil de leer y top-down porque no mezcla niveles de abstracción. - Desde la postura contraria, lo más importante es que la implementación de la izquierda es código que se lee linealmente de arriba hacia abajo en la pantalla.
- En la implementación de la derecha, para entender todo el comportamiento hay que moverse entre varias definiciones de funciones pequeñas.
- Incluso en la forma de mostrarlo, se omite parte del código de la derecha, por lo que ambas implementaciones parecen tener un tamaño similar, pero en realidad la de la derecha es más larga.
- Extraer funciones puede hacer que sea difícil conocer suficientemente el comportamiento solo por el nombre.
- Cuando existen tanto
bakecomobakePizza, no es fácil saber de inmediato qué función calienta el horno. - Para comprobar si al pasar dos veces la misma pizza la operación es idempotente o si arruina el resultado, hay que ver la implementación interna.
- Cuando existen tanto
Código lineal con comentarios y el ejemplo del horno
- Se evalúa que la forma más fácil de leer es una versión del código lineal de la izquierda con los nombres de las funciones de la derecha como comentarios.
- Comentarios como
Prepare pizza,Add toppings,Heat oven,Bake pizza,Box and slicemuestran la intención de cada paso. - La legibilidad no proviene de capas adicionales de abstracción ni de referencias indirectas, sino de explicar correctamente lo que se está haciendo en ese momento.
- Comentarios como
- La conclusión se acerca a no extraer del código lineal funciones pequeñas que se usan una sola vez.
- Se considera que los beneficios de extraer funciones pequeñas no compensan la pérdida de linealidad.
- El manejo del horno en el ejemplo también es estructuralmente incómodo.
- Precalentar el horno es una acción autocontenida, por lo que sería más adecuado que fuera un método del horno.
- El flujo de crear y precalentar un horno nuevo cada vez que se hace una pizza no coincide con un uso realista.
- En código real también aparecen estructuras así y, a veces, pueden generar problemas de rendimiento.
- Es muy posible que el horno deba recibirse como parámetro en lugar de crearse dentro de
createPizza.- Proveer el horno se parece más a una responsabilidad del llamador.
- Si el flujo consiste en meter la pizza en una caja, una interfaz que devuelva la caja, y no la pizza, podría ser más natural.
1 comentarios
Opiniones de Hacker News
Es una cuestión de estilo y, como en la cocina, tanto demasiada sal como muy poca pueden arruinar el platillo.
Espero que nadie aquí esté proponiendo una función dios de 1000 líneas, pero un máximo de 5 líneas por función tampoco resulta legible. Decidir dónde dividir requiere criterio, buen instinto e iteración. Que la primera abstracción que intentaste haya sido mala no significa que haya que abandonar las abstracciones; después de refactorizar algunas veces, pueden aparecer clases y API que encajen bien con el dominio del negocio.
Al mismo tiempo, no hay que apresurarse demasiado con las abstracciones ni actuar como si unas pocas líneas duplicadas fueran una herida mortal. Las abstracciones prematuras suelen agrupar código que no necesita evolucionar en conjunto. Extraer una función que solo se llama desde un lugar, para ocultar una unidad de trabajo, puede dejar más limpio un algoritmo, y es especialmente útil cuando oculta boilerplate o la mezcla de lógica de negocio con intereses de infraestructura como el manejo de conexiones a la DB. Pero hay que usarlo con cuidado, y conviene evitar dividir pasos que deberían estar en el mismo nivel de abstracción.
Una persona con la que trabajé extraía todas las condiciones booleanas a funciones porque “era más legible”, y no escribía ningún comentario porque “los comentarios son malos”. No me gusta ese libro porque crea fanáticos que siguen ciegamente ese tipo de malos consejos.
A veces el dominio puede requerir una función dios de 1000 líneas, y que la lógica y las tareas estén reunidas en un solo lugar puede ser mucho más legible que 20 funciones de 50 líneas. Al final, para entenderlo todo tendrás que leer esas 20 funciones de todos modos, y alguien podría intentar reutilizar algunas de ellas, ajustarlas para 2 o 3 requisitos que no existían en la tarea original, y terminar acoplando cierta lógica con casos de uso no relacionados.
Si esa función es una función pura, me da igual si tiene 1000 o 10000 líneas; aun así me parece aceptable.
Las recetas de cocina ya están muy abstraídas. Cuando dicen “sofreír la cebolla”, asumen que ya sabes cómo cortar una cebolla y cuál es el algoritmo para sofreír. Si escribieras todo inline, se volvería ilegible.
Con el código pasa algo parecido. Si excluyes estrictamente las abstracciones, bajas hasta el nivel más bajo que permite el lenguaje, y eso definitivamente no es código legible. Por ejemplo, si en vez de usar el método
decodede Python intentaras hacer tú mismo la decodificación Unicode, sería muy difícil entender qué hace realmente el programa. Nadie lo hace solo porque el lenguaje ofrece abstracciones simples y bien validadas; entonces, ¿qué diferencia hay con crear tus propias abstracciones simples y bien validadas para usarlas en toda la lógica de negocio?La parte difícil es crear abstracciones tan bien elegidas que nadie tenga que volver a tocarlas.
Que ahora las líneas de código se vean parecidas no significa que deban ser iguales en el futuro ni que haya que mantenerlas iguales. Si fuerzas la unión de dos casos de uso distintos solo porque “el código está casi repetido”, con el tiempo es fácil que termines con una abstracción que no abstrae nada.
Si los casos de uso divergen demasiado, la implementación termina empujando mucha lógica hacia el lado del llamador, o exponiendo las diferencias como flags y manteniendo internamente dos implementaciones distintas una al lado de la otra. Lo primero es una abstracción superficial y aporta poco valor; lo segundo es menos claro que dos implementaciones independientes.
El código de ejemplo es tan simple que, obviamente, el código lineal se lee mejor, pero esa idea no escala bien.
También hay que considerar la reutilización y la facilidad para hacer pruebas unitarias, y si se mete todo el código en una sola función, todas las variables locales —que pueden o no estar relacionadas con el bloque de código que se está leyendo— quedan dentro del alcance, lo que puede volver más difícil razonar sobre él.
Dicho eso, al mirar atrás a mis épocas de menos experiencia, muchas veces tomé código lineal perfectamente válido y lo modularicé demasiado, convirtiéndolo en código menos mantenible que obligaba a saltar de un lado a otro. La forma en que se escribió originalmente está más cerca del flujo de pensamiento que tenía en la cabeza en ese momento, y tiene la ventaja de que el lector probablemente también lo interprete así. Con una refactorización excesiva, eso puede desaparecer.
Al final, programar se parece más a una artesanía, y la experiencia ayuda a elegir según la situación.
Tenía un único propósito. Era una tarea para convertir páginas HTML individuales que se usaban en un rincón de la app, en una plataforma, en un carrusel que imitaba la sensación nativa de otra plataforma, y estaba extremadamente especializada para esa plataforma y esa área de la app.
Podría haber convertido cada uno de los 9 ámbitos en funciones, pero entonces los desarrolladores habrían querido reutilizarlas. Cada etapa tenía supuestos sutiles sobre lo que había ocurrido en la etapa anterior, y para convertirlas en funciones separadas habría que volver a revisar esos supuestos, generalizarlos y verificar que cada método funcionara de manera independiente. No había motivo para gastar ese costo en código que casi no se necesitaba en otros lugares.
Tampoco era más difícil de depurar, había pruebas de punta a punta, y el estado de las etapas intermedias no se filtraba fuera de la función. De hecho, con el tiempo otros 2 desarrolladores contribuyeron con modificaciones, funcionó bien y además fue rápido de escribir.
El código lineal escala bien y resuelve problemas. No siempre es la forma que uno quiere, pero en muchas más situaciones de las que se piensa hace la vida mucho más fácil.
Mi reacción al ver por primera vez el monstruo de 2000 líneas no fue buena, pero con solo mirarlo 5 minutos era difícil encontrar defectos reales; con unas pocas pruebas, los miedos eran solo temores que no se materializaban.
En algún momento uno se da cuenta de que esas decenas de funciones deben llamarse en un orden específico y que cada una se usa una sola vez. Al final, es como obligar a quien quiera usar esas funciones de forma útil a conocer un orden de combinación casi mágico.
La razón central por la que una función lineal enorme a menudo es más legible y deseable es que permite mantener simultáneamente varios conceptos y relaciones en un solo bloque, sin cambio de contexto, lo que ayuda a entender. Un defensor extremo de esto es Arthur Whitney, inventor del lenguaje K, que escribe código extremadamente conciso, casi incomprensible para los demás, para meter la mayor cantidad posible en una sola pantalla.
Como ejemplo personal, me resultó mucho más fácil leer, entender y depurar una enorme función de procesamiento de mensajes de Windows, es decir
WndProc, con la lógica de negocio dentro de un granswitch, que una versión de Visual C++ donde los manejadores de mensajes estaban separados en funciones distintas.También vi código de ejemplo para microcontroladores donde un ejemplo de uso de ADC estaba completo en un solo archivo, y otra versión dividida en varios archivos como
main.c,config.c,interrupts.c,timer.c, etc.; aunque no llegaba a 200 líneas, la segunda era difícil de entender por el cambio de contexto.Estos fragmentos de código suelen terminar como funciones
privatede una clase, y tienen estado. Al ser funcionesprivate, en la práctica también son difíciles de probar.Ahora aparecen montones de funciones
privateque se llaman una sola vez y que normalmente modifican estado con efectos secundarios. Si están justo al lado del llamador, en casos simples todavía pueden leerse, pero con el tiempo alguien agrega otra función entre la función que llama y la función extraída.Entonces, a menos que uno mire el grafo de llamadas o busque dentro del archivo de la clase, esos fragmentos de código cuyo punto de llamada se desconoce terminan modificando distintos estados con efectos secundarios.
Si van a hacer que el código sea no lineal, al menos me gustaría que consideraran convertir las funciones
privateextraídas en funciones internas de la función llamadora, cuando el lenguaje lo permita. Así queda claro que no se llaman desde otros lugares.En bases de código reales, esto tampoco es una elección binaria, sino más bien un arte de combinar ambas formas para lograr algo legible y mantenible.
¿La gente probará todas esas ramas? ¿O escribirá solo una prueba que mete una pizza y se limitará a ver si más o menos funciona? Probar varias ramas desde afuera suele ser engorroso, y como es más molesto que probar funciones pequeñas y especializadas, parece más probable lo segundo.
Decir que “el código lineal no escala” es más bien lo contrario. En una base de código grande, la verdadera pesadilla son las funciones pequeñas y concisas con pilas de llamadas profundamente anidadas.
No queda claro dónde agregar código nuevo, hay que rastrear todos los caminos por los que el código podría ser invocado, lo que aumenta exponencialmente la dificultad de entender el impacto de los cambios, y además aparecen subrutinas duplicadas.
En el 99% de los casos no creaste una buena abstracción, así que es mejor usar código lineal. Prefiero copiar/pegar antes que semánticas de función dudosas.
print_table(), alguien lo encuentra, lo usa en su propio código y le agrega un pequeño flag para ajustar la salida a su caso de uso.12 meses después termina viéndose así:
print_table(rows,headers = None,is_unicode = False,left_align = False,align = [],remove_emoji = None,max_width = 80,potato_mode = 7,_debug_frontend = not FLAGS.dont_debug,ellipsis_for = 0,no_print = False,)Si se ven ambos conceptos como ortogonales, salvo por el hecho de que la legibilidad puede influir en la escalabilidad, el código lineal no escala tan bien como el código modular. Vale la pena conocer esta dicotomía y considerarla según el contexto.
Aun así, sigo sin estar de acuerdo. Si una función pequeña es una función pura, no genera problemas de legibilidad. Eso significa que no toca estado, no inyecta lógica en el código y se debe minimizar explícitamente la inyección de dependencias y el paso de funciones a otras funciones.
Si armas un pipeline de funciones puras que solo pasan datos, el resultado es legible y escalable. Hay muchos menos casos en los que tengas que reescribir lógica por fallas de diseño, y al combinar funciones puras el código se vuelve como Lego. Refactorizar también se parece más a reorganizar y recombinar elementos primitivos existentes.
El código de ejemplo habría distraído menos si al menos hubiera intentado mantener significativa la metáfora de la pizza, o si no hubiera sido código Go de bajo nivel.
preparees un nombre terrible para una función. Un Gopher experimentado probablemente le habría puesto algo comoNewPizzaFromOrder.No veo razón para tener
addToppingscomo función aparte. Si hiciera falta, personalmente lo habría convertido en un método dePizza, comofunc (p *Pizza) WithToppings(topping ...Topping) *Pizza { /* ... */ }. Una pizza real es mutable, así que el método modifica el receptor.Tampoco entiendo por qué se instancia un horno nuevo cada vez que se hornea una pizza. Debería empezar con un horno existente, llamar a
oven.Preheat()y luego aoven.Bake(pizza). Más aún,oven.Preheat()podría devolver un nuevo tipo deOvenque exponga.Bake(), para impedir en tiempo de compilación el error de hornear sin precalentar. En otra parte podría haber una interfazBaker, y quizá una implementaciónToasterOvendonde el precalentamiento no sea tan importante y no haga falta.Aunque no cambiara el código, habría reordenado las declaraciones para que siguieran el flujo esperado. Así, al revisar funciones que se llaman entre sí, no habría que saltar arriba y abajo por la página.
No tengo tiempo, así que lo dejo aquí, pero este código ya es un ejemplo demasiado malo incluso para iniciar el debate de “cuál es más legible”.
John Carmack dijo casi lo mismo, y desde entonces lo sigo aplicando. El código lineal es naturalmente más fácil de leer porque sigue el orden de ejecución y minimiza los saltos visuales.
Cierto código tiene que ser no lineal para permitir reutilización, y entonces la ejecución se convierte en un grafo. Si el código no aprovecha la reutilización propia de una estructura de grafo, no hace falta introducir un vértice donde basta con una sola arista.
http://number-none.com/blow/blog/programming/2014/09/26/carm...
En este caso, creo que el código de la izquierda habría quedado mejor con algo como
pizza.Toppings = get_pizza_toppings(order.kind), dejando la modificación de la pizza como foco en la función principal.Coincido en cierta medida en que el código lineal es más fácil de leer, pero eso por sí solo no lo convierte en una buena práctica de código
Creo que el buen código lineal es más fácil de leer, pero su mantenibilidad y facilidad de prueba son mucho peores. Tengo décadas de experiencia y también hago evaluaciones externas de estudiantes de CS, y entre las buenas prácticas que he visto en la realidad durante muchos años, lo único seguro ha sido mantener las funciones pequeñas
No es que me gusten particularmente las abstracciones, ni creo que haya que evitar a toda costa la duplicación de código, pero si haces funciones lo más cercanas posible a un único propósito, tu yo del futuro te lo va a agradecer
Si un código como el del ejemplo corre en producción durante 10 años, cada sección va a cambiar. Con suerte, los comentarios también se actualizarán, pero en la mayoría de los casos no será así. Las pruebas unitarias también se volverán grandes y difíciles de manejar, cada vez más descuidadas, y alguien puede olvidarse de modificar una parte de una prueba que no parece estar claramente relacionada con el cambio. Es muy probable que el código también se vuelva menos legible con el tiempo. No por intención ni por incompetencia, sino por razones humanas como la presión de tiempo
En un mundo perfecto no haría falta separar responsabilidades, pero vivimos en un mundo imperfecto, y cuanto más pequeñas sean las funciones y menos responsabilidades tengan, más fácil será lidiar con esa imperfección con el paso del tiempo
Si estás pasando un objeto por un flujo de estados concretos, creo que es mejor dividirlo y marcar las transiciones con tipos, o bien escribirlo como una sola función grande. Por ejemplo, si
bakePizzarecibeRawPizzay devuelveBakedPizza, puedes hacer cumplir el orden de las llamadas en tiempo de compilaciónPrefiero lo primero por legibilidad, corrección y facilidad de prueba, pero en la mayoría de los lenguajes de programación, cambiar el tipo de un objeto requiere crear un objeto nuevo y tiene un costo en tiempo de ejecución. Si es una ruta de código caliente, tiene sentido modificar en el lugar, y en ese caso es mejor dejarlo en una sola función lineal
https://mitpress.mit.edu/9780262045490/
Un correo relacionado de John Carmack: http://number-none.com/blow/blog/programming/2014/09/26/carm...
Discusión: https://news.ycombinator.com/item?id=12120752
Totalmente de acuerdo. Antes estaba en el bando contrario
La tensión básica aquí está entre la localidad del comportamiento por un lado y el deseo de mostrar claramente una vista de alto nivel tipo “tabla de contenidos” por el otro. Para que el código sea legible, la localidad es más importante. Como dice el artículo, la perspectiva de tabla de contenidos puede hacerse suficientemente clara con comentarios de sección
Hay una razón aún más importante para preferir el código lineal. Al navegar por toda una base de código, es mucho más fácil si los “bloques”, es decir, funciones, clases o unidades impuestas por el lenguaje, se corresponden aproximadamente con casos de uso de negocio. Si no, el espacio de búsqueda se vuelve demasiado grande y uno tiene que reconstruir el todo a partir de las piezas. La estructura del código debería hacer ese trabajo por ti
Si varias “cosas” están todas relacionadas con una sola tarea, por ejemplo registrarse o comprar, conviene mantenerlas juntas también en el código. Es mucho más fácil encontrarlas y cambiarlas. Solo hay que dividir en subfunciones cuando se necesite reutilización, no dividir solo por organización
[0] https://htmx.org/essays/locality-of-behaviour/
La razón principal es el estado. Cuanto más larga es una función, más amplio es el alcance de las variables locales. Cualquier variable puede cambiarse en cualquier parte de la función, y el flujo de datos no queda claro de inmediato. Con más funciones, el alcance se mantiene pequeño y el flujo de datos es más explícito
Como efecto secundario, también se reduce la indentación
Al mismo tiempo, no me gustan las funciones demasiado pequeñas. Porque se vuelve difícil encontrar dónde ocurre el trabajo real
Imagina un proceso de cierre diario con 10 pasos no reutilizables que deben ejecutarse en orden, y cada paso tiene 100 líneas. Cada paso usa datos parecidos al anterior, pero no iguales. ¿De verdad elegirías una única función de 1000 líneas?
Ambos se leen de forma lineal. La versión que extrae funciones pequeñas tiene una tabla de contenidos en la parte superior de la página y resume el flujo de datos entre pasos. Si vas a leerlo todo, parece un orden de lectura atractivo
Sin embargo, para mantener esta legibilidad, cuando cambia el orden de los pasos también hay que mover la ubicación de las funciones. Si son funciones
privatey solo se llaman desde la tabla de contenidos, está bien. Pero nada obliga a mantener el orden, ni tampoco obliga a pensar en el flujo de lectura completoCuando una función empieza a reutilizarse, a menudo deja de ser posible linealizarla. A veces la gente se rinde y las ordena alfabéticamente, o simplemente quedan en orden aleatorio
Según mi experiencia, cuanto más familiarizada está una persona con el código, más tiende a pensar que meter el código en funciones pequeñas es el camino correcto.
Como ya construyó un modelo mental de ese código, para esa persona la implementación más limpia es una con muy pocas líneas.
Pero cuando llega la siguiente persona, tiene que ir y venir por todos lados para construir el mismo modelo mental sin el contexto original, haciendo push/pop en su pila mental, y eso es mucho más difícil.
Por ejemplo, ¿qué tan seguido leemos el código fuente de la biblioteca estándar del lenguaje que usamos? Casi nunca; normalmente vemos la firma del método y, si es algo un poco complejo o nuevo, leemos la documentación.
El punto de una interfaz es hacer que te importe qué hace un método, no cómo está implementado. Eso se explica mediante una combinación de contexto, nombres y documentación. Pero muchos desarrolladores no lo entienden o no les importa, y escriben código que no tiene sentido, sea lineal o modular.
Por ejemplo, si en una clase de servicio hay que llamar a un método para obtener ciertos datos, a otro método para obtener otros datos, y a un tercer método para obtener datos que deben combinarse con los dos anteriores, ¿cuál es el sentido de ese servicio? Es como exponer toda la complejidad interna hacia afuera.
No se trata de imponer métodos pequeños. Veinte funciones de 5 líneas que se llaman una sola vez, hacen algo muy específico y deben llamarse en el orden correcto no tienen sentido. Eso no es código limpio; se parece más a programación cargo cult.
Lo importante es abstraer adecuadamente para que tenga sentido tanto para los nuevos integrantes del equipo como para los más experimentados, sea fácil de razonar y la complejidad quede oculta en el lugar correcto. No es fácil, pero es posible.
Mi hijo, aunque era bastante inteligente, tuvo dificultades en la escuela, y uno de varios especialistas explicó que la escuela suele enseñar de abajo hacia arriba, mientras que mi hijo aprendía de forma muy de arriba hacia abajo. Él necesitaba primero una visión general antes de entrar en los detalles; otras personas primero captan los detalles y luego ensamblan la visión general. La escuela normalmente enseña pensando en el segundo grupo.
Puede que entre programadores exista una diferencia similar.
Si el desarrollador anterior escribió una función
BakePizza, basta con asumir que la pizza se horneará correctamente y pasar a la siguiente línea. Si, al intentar entender cómo funciona el restaurante, te pierdes en detalles como la temperatura del horno, terminarás sin entender cómo funciona el restaurante y además olvidarás la temperatura exacta del horno.El editor debería tener un toggle para hacer inline temporalmente una función. Así ya no haría falta ir y venir.