7 puntos por GN⁺ 5 시간 전 | 5 comentarios | Compartir por WhatsApp
  • La duplicación de código sale mucho más barata que una abstracción equivocada, y generalizar demasiado pronto aumenta el costo de mantenimiento a largo plazo
  • Incluso una extracción que al principio parecía razonable puede terminar acumulando parámetros y condicionales a medida que los requisitos cambian poco a poco, difuminando la intención original
  • Cuando una abstracción compartida empieza a cargar con varias ideas distintas, el código se transforma en un procedimiento centrado en condiciones y, mientras más funciones nuevas se le agregan, más fácil es que se rompa
  • Hay que cuidarse de la falacia del costo hundido, esa tendencia a querer proteger el esfuerzo ya invertido en el código; si hace falta, conviene volver a poner la abstracción en línea en los lugares donde se llama y dejar solo el código realmente necesario
  • Si ya quedó claro que la abstracción era incorrecta, suele ser más rápido reintroducir la duplicación, volver a observar qué tienen en común los requisitos actuales y recién después extraer otra vez

Cómo se forma una abstracción equivocada

  • La frase “duplication is far cheaper than the wrong abstraction” fue parte de una charla en RailsConf 2014, pero siguió citándose mucho después
  • Un camino de fracaso común es el siguiente
    • La persona desarrolladora A detecta duplicación
    • Extrae esa duplicación a un método o una clase, le pone nombre y crea una nueva abstracción
    • Sustituye el código repetido en los lugares donde se llama por llamadas a esa nueva abstracción
    • Con el tiempo aparece un requisito nuevo que casi encaja, pero no es exactamente igual
    • La persona desarrolladora B, tratando de conservar la abstracción existente, agrega parámetros e introduce condicionales que toman rutas distintas según el valor
    • Después, con cada requisito nuevo, aumentan los parámetros y los condicionales, y el código se vuelve cada vez más difícil de entender
  • Una vez creado, el código tiende a verse como una inversión que hay que preservar
    • Entra en juego la sensación de que sería un desperdicio perder el esfuerzo ya invertido
    • Cuanto más complejo y difícil de entender es el código, más fácil es sentir que debe de ser importante y que costó mucho tiempo, por lo que se vuelve más difícil descartarlo
    • Esto se relaciona con la falacia del costo hundido

Volver a la duplicación y extraer otra vez

  • Si se siguen implementando requisitos nuevos sobre una abstracción equivocada, el código compartido termina girando en torno a condicionales y se vuelve más inestable con cada nueva función agregada
  • En ese momento, el camino más rápido no es insistir, sino dar un paso atrás
    • Volver a poner en línea el código abstraído en cada lugar donde se llama para reintroducir la duplicación
    • Revisar, según los parámetros que se pasaban en cada llamada, qué código se ejecuta realmente
    • Eliminar el código que no necesita ese lugar de llamada
  • Este proceso de volver a poner en línea elimina tanto la abstracción como los condicionales, y deja cada lugar de llamada reducido solo al código que realmente necesita
  • Incluso código que parecía estar llamando a la misma abstracción puede haber estado ejecutando en realidad rutas de código bastante únicas en cada lugar de llamada
  • Solo después de eliminar por completo la abstracción anterior se puede volver a observar la duplicación y extraer una nueva abstracción adecuada a los requisitos actuales
  • Si se siguen agregando parámetros y rutas condicionales al código compartido, es muy probable que esa abstracción ya no sea la correcta
    • Puede que al principio sí haya sido la abstracción adecuada
    • Pero los requisitos pudieron cambiar hasta el punto de que ya no se puede mantener bien con la misma forma
  • En una abstracción equivocada, reintroducir la duplicación no es retroceder, sino avanzar mejor

5 comentarios

 
dieafterwork 1 시간 전

No estoy seguro de que este sea un tema que requiera una interpretación tan dicotómica.

 
hanje3765 2 시간 전

Oh, me identifico totalmente con esto.
Lo que no está ordenado se puede ordenar, pero
cuando algo ya está ordenado, parece que cuesta mucho más darle la vuelta.

 
jimmy2056 3 시간 전

ponytail lo subió y salió justo un texto como este jaja

 
shakespeares 4 시간 전

Siempre es una oposición.

 
GN⁺ 5 시간 전
Comentarios de Hacker News
  • Creo que el principio de una única fuente de verdad (single source of truth) siempre debe respetarse.
    Si es código duplicado que se convierte en bug cuando diverge, entonces hay que refactorizarlo. De lo contrario, aparece un acoplamiento a distancia que hace difícil que futuros desarrolladores lo noten antes de que explote un bug.
    Pero mientras ese principio no se viole, la abstracción es solo una conveniencia, y si empieza a volverse incómoda, entonces ya no está cumpliendo su función y no hay razón para usarla. Si una función necesita muchas banderas para comportamientos personalizados, probablemente sea una mala abstracción o una violación del principio de responsabilidad única.
    Si de verdad se necesita mucha personalización, a menudo conviene recibir una función/functor como argumento. Por ejemplo, en vez de solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...), puede hacerse algo como solve(f:double -> double, stopping_criteria: StoppingCriteriaClass)

    • El punto central del texto es que trata los casos en que todavía no está claro cuántas fuentes de verdad hay.
      No está claro si dos puntos del código usan el mismo algoritmo, o una versión apenas distinta, y más importante aún, si van a cambiar por la misma razón.
      El aforismo del título dice que forzar a que cosas distintas sean iguales duele más que duplicar cosas iguales y luego diferenciarlas más adelante, y yo también creo que es cierto. En el segundo caso, basta con hacer el mismo cambio dos veces o refactorizar introduciendo una abstracción; en el primero, hay que seguir parcheando la abstracción o revertirla.
      En especial, rompe la localidad (locality), y al hacer cambios esa es realmente la propiedad más importante. Quiero hacer solo este cambio y no preocuparme por efectos colaterales en partes no relacionadas del sistema.
    • Si por una presión extrema el software terminó con dos fuentes de verdad, una técnica bastante útil es agregar una prueba de CI que impida fusionar a main si ambas fuentes no coinciden.
      Un caso representativo es cuando sincronizar pyproject.toml / requirements.txt realmente es lo mejor, y parece algo aplicable de forma más general. La premisa es que las cosas ya salieron tan mal que una única fuente de verdad ya no es posible; está más cerca de la mitigación de daños que de una cura.
    • El criterio de “si divergen, es un bug” es una regla práctica muy buena.
      Muchas veces me pasó que dos fragmentos de código parecían similares en un momento, los abstraje de más, y después terminaron separándose.
    • En teoría es correcto, pero en la práctica hay mucha gente que intenta evitar cualquier duplicación a toda costa.
      En especial, algunos desarrolladores junior tratan la duplicación como si fuera la raíz de todos los males.
  • A veces pienso en este problema. Lo encontré hace poco en un proyecto personal mientras manejaba sprites 2D para unidades de un RTS: los sprites de unidad estaban en el spritesheet de forma consistente, con 5 sprites para 8 direcciones, donde 3 direcciones se espejaban, y el orden era stand, move, attack, die.
    Entonces hice un loader que recibía action + direction y devolvía el arreglo de sprites a reproducir.
    Pero luego aparecieron sprites de explosión sin dirección, sprites de cadáver con 4 direcciones y solo 2 espejadas, y además orcos y humanos compartían casi todo salvo los primeros cuatro.
    Pensé un momento cuál sería la abstracción común para todo eso, pero al final solo separé parte del código de carga, hice UnitLoader, CorpseLoader y EffectLoader, y seguí adelante. Puede que exista una mejor abstracción porque los tres loaders manejan cosas algo parecidas, pero si la descubro después, mejor. Es más fácil quitar la duplicación más adelante en ese momento que intentar construir ahora un EverythingLoader complicado para cubrir todos los casos.

    • Me gusta la frase “Las cosas deben ser tan simples como sea posible, pero no más simples que eso”.
      En programación existe el instinto de simplificar el código mediante generalización, pero la realidad es desordenada y muchas veces terminamos simplificando de más. Como dice el texto, con el tiempo aparecen nuevos requisitos y queda claro que fue una simplificación demasiado temprana.
      Bien podría existir el aforismo: “La abstracción prematura es la fuente de mucha porquería”.
    • Es muy probable que la abstracción común ya esté separada. Ese sería el código que carga y muestra los píxeles de un sprite individual.
      En el nivel superior, la interpretación de la disposición del spritesheet y el manejo de los modos de reproducción tienen varias variantes, y puede que no exista una abstracción común que sirva para todos los casos.
      Prefiero hacerlo como ahora, en vez de forzar una abstracción que no se ve o intentar encajar todo en una abstracción incompleta. Esperar hasta que la abstracción sea completamente clara y la necesidad sea evidente es algo bueno.
      Como antídoto del otro lado de DRY está WET. Significa escribir todo dos o tres veces. Más importante aún, creo que solo hay que abstraer casos de uso realmente demostrados, normalmente aquellos que primero se manifestaron como duplicación. El código escrito para futuros casos de uso que todavía no existen suele estorbar a la hora de abstraer lo que sí tenemos de verdad, y cada vez que eso pasa resulta hasta gracioso.
    • Este enfoque es el correcto. Hacer juegos debería ser divertido.
      Lo difícil y aburrido puede dejarse para cuando llegues al último 10% del proyecto.
      Además, a veces los “bugs” creados por la duplicación terminan siendo funciones divertidas que a los jugadores les encantan.
  • Cuando usaba OOP sufría mucho por las abstracciones, pero desde que me pasé a un enfoque casi puramente funcional, la duplicación de código se volvió rara.
    Simplemente haces una función y la llamas desde dos lugares. El principal problema de abstracción son las estructuras de datos, pero las interfaces de TypeScript son básicamente duck typing, así que tampoco hay demasiados problemas ahí.
    Por eso, la duplicación de código causada por problemas de abstracción es rara. La duplicación de código causada porque los desarrolladores trabajan en silos es mucho más común.

    • Uso lenguajes funcionales como hobby, y creo que lo importante para recordar es la técnica.
      La mayoría de los lenguajes modernos pueden apoyarse fácilmente en la teoría de la programación funcional, y no hace falta conocer Haskell sí o sí. A cada persona le funciona distinto la cabeza, pero para mí encaja muy bien la idea de que el todo se construye con piezas pequeñas, simples y a veces flexibles.
      Es lo opuesto a una gran máquina de transformación de formas, compleja y que lo hace todo.
    • Para sufrir duplicación de código no hace falta que los desarrolladores estén necesariamente aislados.
      Cuando el equipo supera cierto tamaño y ya no es posible que cada quien sepa todo lo que hacen los demás, la duplicación de código se vuelve bastante inevitable. Eso sigue siendo cierto aunque todos escriban en estilo funcional.
      De hecho, el mes pasado me pasó esto en la empresa. Escribí una nueva función helper pura y la puse al principio del archivo, y una semana después un compañero me avisó que al final del mismo archivo ya había otra función helper parecida, esencialmente con la misma funcionalidad pero con una firma distinta.
    • Me intriga qué significa exactamente “llamar a una función desde dos partes”.
  • En el mismo sentido que el artículo, cualquiera que haya vivido ambos casos va a estar de acuerdo. Una base de código con poco diseño es muchísimo más fácil de manejar que una base de código sobreingenierizada.

  • El peor código que me tocó mantener era código que intentaba seguir DRY. Pero ni siquiera trataba de entender la intención original de ese principio.
    La única forma de salir de ese desastre fue volver a introducir duplicación de código a gran escala.

    • No pasa nada, no te preocupes: solo agrega algunos parámetros booleanos ambiguos más a la función reutilizable para soportar el nuevo caso de uso y haz deploy.
    • Lo importante es que “se intentó”. Después de hacer eso por un tiempo, se llega a un punto en el que ya no se puede seguir fielmente porque la abstracción estaba mal desde el principio.
  • Esto me hace pensar en dos charlas: Data-Oriented Design and C++ de Mike Acton [1] y The Complexity of Simplicity de Brian Cantrill [2].
    La charla de Mike dice que una solución de código no tiene por qué modelar el mundo real, que datos distintos generan problemas distintos y, por lo tanto, requieren soluciones distintas. Me cuesta transmitir bien la charla, pero me impactó mucho.
    La charla de Brian trata la abstracción en general y lo difícil que es encontrar la abstracción “correcta”.

    1. https://www.youtube.com/watch?v=rX0ItVEVjHc
    2. https://www.youtube.com/watch?v=Cum5uN2634o
    • Siempre me ha parecido raro que incluso ingenieros bastante inteligentes a veces prioricen la metáfora del mundo real por encima de las necesidades reales de la base de código.
      Hace años, poco después de salir de la escuela, estaba implementando un pool de conexiones en Rust, y la implementación más razonable era que el objeto de conexión tuviera una referencia débil al pool y se devolviera automáticamente al hacer drop.
      Mi jefe, que era un gerente con muchísima experiencia, no quería esa idea porque “la biblioteca sostiene los libros; los libros no sostienen la biblioteca”. No me pareció una razón lo bastante convincente como para cambiar el diseño, pero él no quería abordar el problema fuera del lente de esa metáfora.
      Al final, otro gerente destrabó la situación al sugerir que “los libros de biblioteca no contienen la biblioteca, pero sí llevan atrás un sello con el nombre de la biblioteca que indica a dónde devolverlos”. Al parecer, a ese gerente esa extensión de la analogía sí le pareció razonable.
      Si yo hubiera tenido más experiencia, quizá habría encontrado la forma de conversar dentro de esa metáfora sin ceder el punto central; pero incluso hoy me sigue pareciendo completamente extraño insistir en esa metáfora como marco estándar, en lugar de evaluar la abstracción del código y el resultado de usar la librería.
  • Nadie quiere escuchar. De verdad, nadie escucha. En el 90% de las empresas hay de esos supuestos desarrolladores senior que se entusiasman al crear una nueva abstracción.
    La sobreingeniería, la abstracción y la optimización prematura son los tres grandes desastres de la ingeniería.
    Al mismo tiempo, me alegra que existan, porque así siempre habrá trabajo.

    • Kubernetes, los microservicios que superan en número a los ingenieros, los protocolos complejos para ahorrar unos cuantos bytes de overhead, todo en la nube, y la enorme cantidad de clases que podrían haber sido simples funciones son ejemplos perfectos.
  • En una línea similar, parece que algunos desarrolladores creen que toda cadena inline o constante numérica es malvada. Vi esto en un PR:
    HTTPS_SCHEME = 'https'
    DOMAIN = 'www.example.com'
    url = HTTPS_SCHEME + '://' + DOMAIN
    No veo qué se gana con esto, más allá de seguir como cargo cult eso de “no hardcodees constantes”. Encima, las definiciones de constantes estaban al principio del archivo y el código que armaba la URL estaba a cientos de líneas de distancia.

    • En el código valoro muchísimo la proximidad. Prefiero definir las cosas lo más cerca posible de donde se usan. Es una costumbre que de verdad me molesta.
      Las expresiones regulares tampoco tienen por qué ir al principio del archivo; pueden ir donde se usan. El lenguaje es lo bastante inteligente como para probablemente darse cuenta de que son constantes.
      Si es una función muy pequeña, simplemente usa una lambda. Ojalá no se hicieran funciones de una sola línea, usadas una o dos veces, en lugares lejísimos.
    • Poner las constantes arriba hace que sea más fácil personalizarlas. Eso es todavía más cierto si ese archivo se va a copiar.
      Si en testing o staging necesitas cambiar https por http, tiene sentido separar el esquema y el dominio y dejar las constantes arriba o en otro archivo. También importa si url se arma en varios lugares o en uno solo.
      Poner constantes con nombre al principio del archivo es un estilo muy común y, a veces, incluso parte del estándar de código del equipo.
      También puede haber otras razones, así que conviene recordar Chesterton’s Fence. En cualquier caso, no es buena idea asumir de entrada que se trata de cargo cult. Alguien podría decir exactamente lo mismo de usar literales inline. Si algo te parece raro, puedes preguntar; puede haber una buena razón, o tal vez a nadie le importó y hasta les gustaría que lo refactorizaras para volver a poner las constantes inline.
    • A mí también me pasó algo así. Si un Event tiene nombre, puedes hacerle grep directamente en todo un gran monolito o en un conjunto de repositorios de microservicios y encontrar todos los archivos relacionados con ese evento.
      Si lo extraes a una constante, entonces te toca volver a abrir proyecto por proyecto y buscar usos.
  • Con microservicios puedes hacer ambas cosas.

    • Ya sé que es una broma, pero en los microservicios de un mundo ideal no existe el concepto de duplicación de código entre servicios. Si mantienes un servicio, no tienes por qué preocuparte por el código de otro servicio. ¿Por qué te importaría el código de otro equipo? Ni siquiera necesitas saber que ese equipo existe. En sistemas grandes, a veces ni siquiera es realista conocer la existencia de todas las aplicaciones.
    • ¡Pero espera, aún hay más!
      ¡Por solo $19.95 te convertimos un único punto de falla en varios puntos únicos de falla!
    • 9 de cada 10 veces, los microservicios terminan dependiendo mucho unos de otros y se convierten en un monolito distribuido.
      Es mejor usar arquitectura orientada a servicios pero desplegar simplemente un monolito. Es más fácil de testear y además evitas la capa extra de serialización/deserialización.
  • La mayoría de los perfiles senior sabe que no se debe seguir DRY ciegamente. Aun así, a muchas personas nos incomoda la idea de tener que mantener varias fuentes de código duplicadas
    Para abordar esto, hay que examinar de cerca el modelo simple en el que dos llamadores dependen de código común. Si el código común tiene que cambiar por necesidades de solo uno de los llamadores, entonces ese código no pertenece a lo común
    El objetivo equivocado de DRY es intentar resolver esto con encapsulación. La encapsulación traslada el trabajo de refactorización desde el llamador hacia el código común. Pero como el impacto de actualizar el código común es mucho mayor que el de actualizar los llamadores, no es la dirección deseada
    Se puede mantener DRY sin recurrir a la encapsulación. Es mejor tener varias abstracciones delgadas de las que el llamador deba ser consciente. En OOP, esto se refleja en aprender SRP e IoC, y en programación procedural aparece de forma natural como una serie de llamadas a funciones helper