1 puntos por GN⁺ 2023-11-16 | 1 comentarios | Compartir por WhatsApp
  • 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 o assert, 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 enum y enseguida vuelve a hacer match sobre él repite la misma condición como rama, estructura de datos y nueva rama, por lo que puede simplificarse a llamadas como foo(x) y bar(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 if dentro 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 Walrus es una forma mejor que una que recibe Option<Walrus> y devuelve si es None
    • 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
  • 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 if aumentan 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
  • 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() crea E::Foo(x) o E::Bar(y) según una condición, y g(e) vuelve a usar match para llamar a foo(x) o bar(y), la misma condición se repite en varias formas
    • Al llevar la condición a main(), se simplifica a una forma como if condition { foo(x) } else { bar(y) }

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 que for 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 if y for pueden aplicarse juntas
    • Una buena forma es ramificar fuera de if condition y ejecutar for walrus in walruses por cada rama
    • Una mala forma es evaluar if condition cada vez dentro del bucle for
    • Esto evita reevaluar condiciones, elimina ramas en los hot loops y puede habilitar la vectorización
  • 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 for es el rendimiento, operar a nivel de colecciones, como en jQuery, también puede ayudar a la expresividad

1 comentarios

 
GN⁺ 2023-11-16
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

    • Creo que este consejo sigue aplicando bastante bien también a la programación orientada a objetos
      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 inferiores
      A menudo ese bucle puede reemplazarse con una cláusula SQL where o un join, 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 datos
      Aun así, como cualquier principio de diseño, no debería aplicarse como un dogma sino de forma consciente
    • El diseño orientado a datos parece provocar siempre a la gente. Supongo que porque implica que buena parte del enfoque dominante de la programación orientada a objetos está equivocado
      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
    • Facilidad de cambio, corrección y rendimiento no son mutuamente excluyentes; de hecho, muchas veces avanzan juntos
      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/for y for/if pueden 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 lenta
    • Si escuchas a John Ousterhout, en la práctica la facilidad de cambio y la corrección no chocan con el rendimiento tanto como uno pensaría al principio
      Los 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 los if” 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-readability
    • Creo que ambos consejos aplican bastante bien incluso a las aplicaciones empresariales
      En 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) y insertToDb/postToServer(items) en casi todos los casos es de varios órdenes de magnitud
      He 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 if y los for, o si se usan map y filter, 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 secundarios

    • “Subir los if” no es bike-shedding; más bien está más cerca de una decisión arquitectónica como la que se mencionó
      Si 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
    • Trabajando en seguridad, he tratado mucho con tech leads y arquitectos de nivel L7~L9 según el estándar de FAANG, y quienes más se obsesionaban con el “diseño correcto” solían ser personas con unos 5 a 10 años de experiencia
      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
    • Si estos principios que parecen menores están definidos de antemano, se pueden reducir las discusiones desgastantes del mismo tipo en PR y debates
      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
    • También he visto mucho lo contrario. Hay personas a las que les encanta diseñar catedrales sobreingenierizadas, pero no piensan en la eficiencia de bajo nivel de los algoritmos
    • Una razón es que en los lenguajes de programación faltan formas de expresar de manera significativa componentes mucho más grandes que una función, o componentes conectados de una forma distinta a la de las funciones
      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 manager validated_xy para hacer que la función solo pueda llamarse dentro de un área validada
    El 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”

    • Creo que se pasó por alto la segunda parte del argumento del autor. La idea es “empujar la verificación de precondiciones hacia quien llama y hacerla cumplir con tipos
      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 if
      Este 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 compila
    • En Python también se puede expresar esto con tipos
      Por ejemplo, se puede recibir algo como do_something(position: ValidatedPosition) y, tras validar un Position normal, convertirlo y pasarlo como ValidatedPosition
      En la práctica, la validación probablemente se encapsularía dentro del constructor de ValidatedPosition, pero el punto clave es que si intentas pasar un Position tal cual, mypy te avisará que estás pasando un tipo incorrecto
      La 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
    • El consejo de “subir los if” no trata sobre verificar precondiciones, sino sobre elegir la ruta de código correcta
      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
    • En el ejemplo fn frobnicate(walrus: Walrus), si intentas pasar algo que no sea un Walrus en 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
    • Da la impresión de que la intención de public y private era, hasta cierto punto, servir para etiquetar código según un contexto específico
      O quizá haga falta una semántica más específica que cruce los ámbitos que cubren public, private y protected en el ecosistema de .NET
  • El primer ejemplo no es malo por if y for, sino por otra razón
    En 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”

    • El consejo dado parece correr el riesgo de subir demasiado la lógica de validación
      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 if añ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 inmediato
    • Si lo que se quiere decir es que el primer ejemplo sería mejor como walrus.frobnicate(), me parece que eso está más cerca de una preferencia de sintaxis que de la idea central que el autor intenta transmitir
  • No 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 if definitivamente deben ir más abajo, y conviene que sea de una forma en que los errores se propaguen bien hacia arriba
    Sobre for lo 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 no

    • En C, las interfaces deben documentar con cuidado las expectativas y verificar exactamente eso, no más
      La 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
    • En C, NULL suele ser un valor de puntero válido pero no desreferenciable
      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 p no puede ser NULL”, está bien comprobarlo. Pero si la precondición es “p debe ser un puntero válido”, buena suerte encontrando una aserción adecuada
    • Mi heurística es que si el sistema de tipos no puede bloquear valores incorrectos, entonces en tiempo de ejecución existe la responsabilidad de bloquearlos
      Últimamente trabajo mucho con T-SQL, y no se pueden declarar parámetros o variables como NOT NULL
      Por 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 temprano
      Aun 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 if son operaciones de control de flujo, así que parte del argumento del artículo no tiene mucho sentido
    El 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

    • El rendimiento puede ser un argumento, pero no uno fuerte
      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 walrus según una condición y llamar a frobnicate_batch(fwalrus) y transmogrify_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 for e if me sale por instinto, y el código que no lo hace simplemente se ve incorrecto

  • No 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ículo
    Como en el ejemplo del artículo, marcaban como “malo” código con if condition dentro de un for, pero en la mayoría del código escrito así, condition depende de walrus, así que no puedes subir el if
    Si 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

    • No soy particularmente dogmático con eso de “nunca anidar”, pero empíricamente nunca he visto un caso en que, asumiendo que la sintaxis lo permita, dividir los cuatro casos con match (condition_a, condition_b) no sea mejor que if anidados
    • Esto más bien me parece un buen ejemplo de cuándo hay que “subir” el if
      El propósito del código es realizar cierta operación sobre walrus según una condición, pero en realidad el if está usándose en lugar del polimorfismo y el sistema de tipos
      ¿Por qué walrus tendrí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
    • El refactor “GOOD” solo funciona cuando la condición no depende de walrus, y ayuda a dejar ese hecho explícito
      Si vuelves a aplicar aquí lo de “baja el for”, terminas llamando a frobnicate_batch(walruses) o transmogrify_batch(walruses) dentro de if condition
    • Me parece bueno que existan artículos así
      Alguien 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
    • Lo principal que hay que aprender de toda “heurística” y toda “buena práctica” es la razón que hay detrás
      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

    • De todos modos, la desdogmatización es necesaria
      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
    • Este artículo puede ser útil como un koan dentro de una colección más grande
      Algunos de esos koanes deberían contradecirse entre sí