4 puntos por GN⁺ 2023-08-20 | 2 comentarios | Compartir por WhatsApp
  • Railway Oriented Programming es un enfoque para manejar, dentro de pipelines funcionales, rutas de falla que se repiten en aplicaciones reales, como validación, logging y errores de red
  • Su punto central es explicar las limitaciones de los ejemplos que solo encadenan el camino de éxito mediante la analogía del ferrocarril, para que incluso quienes usan F# puedan entender visualmente el flujo de errores
  • Desde la perspectiva de Haskell, se parece al tipo Either y a la composición de Kleisli, pero se enfoca más en recetas de manejo de errores y patrones de implementación que en teoría abstracta
  • Para su aplicación práctica, se ofrecen también la biblioteca de F# para NuGet Chessie, ejemplos en GitHub que comparan C# y F#, y un ejemplo aplicado a FizzBuzz
  • Es una técnica útil, pero no conviene aplicarla en exceso a todos los problemas; como F# no tiene type classes, la biblioteca de ejemplo define directamente las funciones necesarias

Material de la charla y código de ejemplo

  • La página de Railway Oriented Programming reúne en un solo lugar diapositivas y código de la presentación
  • Parte del problema de que es difícil crear aplicaciones reales solo con el “happy path” que suelen asumir los ejemplos de programación funcional
  • Una aplicación robusta también debe manejar validación, logging, errores de red, errores de servicios y otras situaciones excepcionales

Videos y diapositivas

  • Este tema se presentó en NDC London 2014
  • También hay otros videos publicados de la misma charla
  • Existen diapositivas de la presentación del 14 de marzo de 2014 en Functional Programming eXchange
  • Las diapositivas en PowerPoint también pueden descargarse desde GitHub, con la indicación de que pueden reutilizarse libremente

La mónada Either y la composición de Kleisli

  • Para usuarios de Haskell, este enfoque puede parecer el tipo Either
  • Aquí se especializa usando una lista de tipos de error personalizados en el caso Left
    • Un tipo de ejemplo es type TwoTrack a b = Either [a] (b,[a])
  • Lo nuevo no es tanto el concepto de manejo de errores en sí, sino la analogía del ferrocarril para explicarlo
  • La razón para no poner por delante la terminología estándar de Haskell es clara
    • Este material no es un tutorial de mónadas, sino que se concentra en resolver el problema del manejo de errores
    • Muchas personas que se acercan a F# no están familiarizadas con las mónadas, por lo que una explicación visual y menos intimidante puede resultar más intuitiva
    • Llamar directamente mónada a un tipo de dos vías con bind es impreciso, y no se tratan las leyes de las mónadas
    • Either es una herramienta demasiado general, por lo que aquí se busca ofrecer una receta más que la herramienta en sí

Componentes de la receta de manejo de errores

  • Este enfoque es un conjunto de técnicas más amplio que simplemente “usar Either y bind
  • Las técnicas incluidas son las siguientes
    • Usar listas de tipos de error personalizados tanto en el lado izquierdo como en el derecho, en lugar de formas simples como Either String a
    • Integrar funciones monádicas al pipeline con bind (>>=)
    • Componer funciones monádicas con composición de Kleisli (>=>)
    • Integrar funciones no monádicas al pipeline con map (fmap)
    • Como F# no usa la mónada IO, integrar funciones unit al pipeline con tee
    • Mapear excepciones a casos de error
    • Combinar funciones monádicas en paralelo con &&& en situaciones como validación
    • Aprovechar los beneficios de los tipos de error personalizados en diseño guiado por el dominio
    • Extensiones como logging, eventos de dominio y transacciones compensatorias
  • El objetivo es crear una plantilla suficientemente versátil para usarse en casi cualquier situación, pero lo bastante restrictiva como para imponer un estilo consistente
  • Si la forma de escribir código converge prácticamente en una sola, a quienes lo mantengan después les resulta más fácil entender rápidamente la estructura del código
  • Este enfoque no es la única solución, pero puede ser un buen punto de partida
  • Incluso dentro de la comunidad de Haskell, las formas de manejar errores no son consistentes, lo que puede resultar confuso para principiantes

Cómo aplicarlo a mi código

  • Si necesitas una biblioteca preparada de F# que pueda usarse con NuGet, puedes revisar el proyecto Chessie
  • Un servicio web de ejemplo que aplica esta técnica está disponible como proyecto de GitHub
  • Un ejemplo que aplica el enfoque ROP a FizzBuzz puede verse en Railway Oriented Programming: carbonated
  • F# no tiene type classes, por lo que no existe una forma general de reutilizar mónadas
  • La biblioteca FSharpX ofrece un enfoque útil, pero la biblioteca Rop.fs define todas las funciones desde cero
  • Aislarlo de esta manera tiene la ventaja de no tener ninguna dependencia externa

Lecturas adicionales

2 comentarios

 
GN⁺ 2023-08-20
Opiniones de Hacker News
  • Elixir aborda este problema de forma bastante limpia con la palabra clave/macro with
    Ejecuta funciones de arriba hacia abajo, como en with {:ok, file_handle} <- File.open(filename), ... do {:ok, parsed} end, y si cada valor devuelto no coincide con el patrón de la izquierda, hace un retorno anticipado con ese valor
    Así, sin tener que hacer que cada función reciba tanto {:ok, value} como {:error, reason}, la función puede recibir solo el valor que le interesa y dejar que el pattern matching del bloque with se encargue de propagar los errores
    Por ejemplo, si File.open devuelve {:error, reason}, IO.read no se ejecuta y el resultado completo de with pasa a ser {:error, reason}. Al final, uno escribe solo la ruta de éxito y deja que el llamador haga el matching de la ruta de falla si quiere

    • Si “devuelve anticipadamente lo que no hizo match”, me da curiosidad qué pasa cuando todos esos valores son de tipos distintos. Si el tipado es dinámico, supongo que habría que mirar de qué tipo es para deducir dónde falló
      Se siente como reinventar a medias el manejo de excepciones, y si se le pusiera un nombre como else en vez de catch, simplemente se vería menos como manejo de excepciones
    • Hasta donde sé, esta forma está influenciada por el where de Haskell y el let de Lisp
      Es como establecer las precondiciones de una función, y en Elixir además se puede usar un bloque else
    • Me pregunto qué ventaja tiene esto sobre las excepciones. Y si IO.read falla, también está el problema de quién cierra el file handle
  • El autor, unos años después, también escribió una continuación titulada Against Railway Oriented Programming: https://fsharpforfunandprofit.com/posts/against-railway-orie...
    La programación orientada a ferrocarriles es interesante y tiene casos de uso, pero creo que necesita una advertencia importante. En la práctica, la he visto usarse a menudo para reinventar mal el manejo de excepciones, y si se entienden y usan bien las excepciones, la mayoría de las condiciones de error se pueden manejar de forma más limpia y eficaz.
    La ventaja de las excepciones es que, en la mayoría de los casos, la opción segura es la predeterminada. Una condición de error es una señal de que el código no puede hacer lo especificado y, si se continúa en ese estado, los datos pueden corromperse por supuestos incorrectos. Las excepciones, por defecto, limpian y se propagan al llamador, pero enfoques como la programación orientada a ferrocarriles exigen mucho código repetitivo fácil de olvidar y propenso a errores.
    Aun así, son útiles en dos casos. Uno es cuando hay que manejar en el punto donde ocurren errores esperados, específicos y bien definidos, como en la validación; el otro es cuando no se puede usar manejo de excepciones. El código asíncrono basado en Promises del viejo jQuery era casi una implementación de programación orientada a ferrocarriles, pero en el JavaScript moderno con async/await se puede volver a usar manejo de excepciones.

    • Lo que decepciona de las excepciones en los lenguajes convencionales es que este manejo ocurre de forma invisible. No es fácil saber en tiempo de compilación qué excepciones pueden salir de una función.
      La gran recompensa de la programación orientada a ferrocarriles es que, con solo mirar la firma de una función, se puede saber de inmediato qué errores pueden aparecer.
      El boilerplate se puede reducir según el lenguaje. Haskell tiene la notación do, y en F# se puede escribir de forma muy limpia con expresiones de cálculo para result. Por ejemplo, se puede definir algo como LoginError = InvalidUser | InvalidPwd | Unauthorized of AuthError y componer el flujo con Result.requireSome, Result.requireTrue, Result.mapError.
      En cambio, me pregunto si también se pueden mitigar las desventajas de las excepciones. Me gustaría saber si existe algún linter o herramienta de análisis estático, integrada con IDEs de Java, que muestre automáticamente las excepciones no capturadas que podrían lanzarse desde una línea de código concreta.
      https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo...
    • Creo que la forma tradicional de implementar excepciones es bastante mala.
      Para empezar, muchos lenguajes obligan a usar scopes incómodos e innecesarios para capturar excepciones. Si uno declara e inicializa una variable con el resultado de una función que puede lanzar una excepción y quiere asignar otro valor en caso de fallo, termina teniendo que separar la declaración de la inicialización.
      El problema más grande es que las condiciones de error deberían manejarse en el nivel más bajo que tenga contexto suficiente para tratarlas correctamente, pero el valor predeterminado de las excepciones es “lanzar hasta arriba”. Por ejemplo, se agrega una caché de archivos y, de pronto, la función superior HandleRequest puede lanzar excepciones de I/O; así se filtran abstracciones con facilidad.
      Creo que todo lo que una función puede devolver debería ser parte explícita de su firma, y quien la llama debería manejarlo explícitamente o al menos indicar que lo pasa hacia arriba. El lenguaje no tiene por qué exigir mucho boilerplate para eso.
    • Creo que Rust demostró bien que la programación orientada a ferrocarriles con soporte sintáctico de primera clase puede eliminar casi todo el boilerplate. Personalmente, el tipo/trait Result de Rust logró hacer bien el manejo de errores.
    • Creo que la diferencia entre errores como valores y excepciones es más útil al describir errores específicos del dominio. Los errores de dominio conviene representarlos como valores, mientras que problemas no relacionados con el dominio, como que la base de datos no esté conectada, se sienten más naturales como excepciones.
      Incluso cuando no existe un archivo o un registro, depende de si era esperado. Si la solicitud busca por un ID entregado por el usuario, esa búsqueda en sí es validación de entrada del usuario. Pero si se consulta el almacenamiento con el nombre de un blob guardado en un registro de la base de datos y el blob no existe, esa es una situación que encaja bien como excepción.
      La frontera entre errores y excepciones es difusa y depende del equipo y del área del problema, pero creo que esto sirve como una regla práctica razonable.
    • Dejando de lado los errores a nivel de sistema, me gustaría conocer ejemplos de estados de error que no sean “errores esperados, concretos y bien definidos que deben manejarse en el punto donde ocurren”. Quiero entender si, en esos casos, las excepciones son mejores por la naturaleza del problema o por restricciones del runtime.
      En código C++, se puede manejar inspeccionando los datos al inicio, haciendo que la aplicación crashee si la inspección falla, y luego depurando y corrigiendo el código de inspección inicial con el estado de error encontrado.
      Como los datos que se manejan son relativamente claros y siguen formatos de datos CAD conocidos o topología geométrica, es “bastante fácil” tratar las condiciones de error en el sentido de que primero se puede entender qué datos son correctos.
  • Sinceramente, todo este sitio es un tesoro. Aunque no tengas intención de usar lenguajes funcionales, te da otra perspectiva, y a mí personalmente me ayudó mucho. También recomiendo otros artículos.

    • La serie sobre construir parser combinators desde cero fue uno de los materiales más valiosos que leí y seguí en la práctica. Muchos de los conceptos y mecanismos que aparecen ahí resultaron enormemente útiles al trabajar con lenguajes tipados en estilo funcional.
      Aun así, creo que nunca voy a terminar de entender qué es una mónada.
      https://fsharpforfunandprofit.com/series/understanding-parse...
  • Me pregunto si se sostiene la afirmación de que “es malo alejar el manejo de errores del punto de llamada”. Dado que quien llama es quien mejor puede manejar el error, parecería que debería manejarlo directamente en vez de pasarlo hacia abajo.
    Por ejemplo, en un flujo validate and-then update-db and-then send-email, puede estar bien devolver al llamador una falla de validate. Pero si falla update-db, hay que decidir si reintentar, usar otro servicio, volver a ponerlo en una cola o avisarle al usuario. Lo mismo con una falla de send-email.
    Al final, ¿no termina siendo una estructura de ramificación más compleja, donde ValidationError se le informa al usuario, DBError se reintenta 5 minutos después o se informa luego de un reintento remoto, y EmailFailed vuelve a poner el email en la cola y se considera exitoso? Quizá eso también esté bien.

    • El manejo de errores monádico es una forma de obtener ventajas de ambos lados.
      Quienes detestan Go se quejan de que hay que revisar condiciones de error demasiado seguido, y las excepciones comprobadas de Java son similares. Con Either, no estás obligado a revisar: puedes revisarlo directamente o pasarlo al llamador.
      Quienes detestan las excepciones no comprobadas de Java se quejan de que no se sabe qué se va a lanzar ni cuándo. Con Either, eso queda explícito.
      El punto central es que uno no sabe qué hacer cuando ocurre un error, y Either encaja bien con ese no saber. En una base de código Java actual, cuando no existe Limit getUserLimit(User), podría lanzar una excepción NotFound, devolver null o devolver un valor por defecto, pero no lo sabes hasta meterte en el código.
      Si fuera Either, el llamador podría confiar sin leer el código interno, y con herramientas incorporadas como orElse(null) u orElseThrow(...) podría convertirlo fácilmente en otras condiciones de falla. Además, como Either es una expresión, se presta bien a la abstracción, así que se puede escribir lógica de reintentos general en vez de código de reintento especializado dentro de DbUpdater.
    • Probé implementar esta lógica con el estilo try-catch de Java, y se ve mucho peor, con try anidados, catch(DBError), sleep(5), reintentos remotos, reencolado de EmailFailedError y notificaciones de ValidationError todos mezclados.
      https://gist.github.com/Andrewp2/9d97bd213b061166d6df565ce26...
      Alguien podría escribir una mejor versión con try-catch, pero creo que sería bastante peor que la versión ferroviaria presentada.
    • El manejo de errores de bajo y alto nivel puede coexistir, pero creo que este estilo encaja mejor con ejemplos de pizarrón o situaciones de “se hace una vez y listo”.
      Me gusta DDD, pero alguien común como yo necesita un ciclo de mantenimiento razonable que pueda manejar. Lo bueno de TDD en código modificable era que reducía la carga cognitiva del mantenimiento.
      La programación ferroviaria puede transportar mucho contexto a través de muchas transformaciones, y quiero evitar recibir al final de la vía un tren cargado de contexto de negocio y de sistema para el que no estoy preparado.
      Creo que DDD y la arquitectura cebolla dan lo mejor de ambos mundos. Al final aparecen vías, pero están afuera, no incrustadas dentro de la microapp. Es mejor que los temas ferroviarios sean explícitos y se codifiquen de forma algo tediosa; si se hizo bien, la mayoría de las decisiones ferroviarias son decisiones de negocio que no deberían evitarse con una estructura de código ingeniosa.
      La programación monádica es excelente, pero existe la tentación de usarla para evitar decisiones de negocio necesarias. Meter ambigüedad en el sistema de tipos puede llevar más adelante a situaciones difíciles o imposibles.
    • El manejo de errores de bajo y alto nivel puede coexistir. No es distinto de tener un try/catch especializado dentro de un gran try/catch.
      Por ejemplo, dentro de la función send-email se puede optar por manejar errores transitorios muy rápidos. Si se conecta dentro del tiempo permitido, vuelve al camino exitoso; si no, se devuelve al llamador.
      Si hay una regla clara, es que el manejo que requiere intervención humana necesariamente debe envolverse y pasarse hacia arriba. Aunque se vuelva al camino exitoso, conviene dejar un log de advertencia.
    • Puede terminar como en el ejemplo que planteas, pero también se puede estructurar de otra manera manteniendo el mismo pipeline principal de alto nivel.
      notify-user se encarga de las notificaciones al usuario según ValidationError y DBError; try-update-db agrupa update-db, el reintento 5 minutos después y el reintento remoto; y try-send-email puede volver a poner el email en la cola cuando falla y convertirlo en éxito.
      Entonces el nivel superior se mantiene como validate and-then try-update-db and-then try-send-email or-then notify-user. Parece que trasladaron bien and/or/then a map/bind.
  • Este sitio fue el mejor sitio de educación en programación que encontré para enseñar conceptos reales y prácticos. Me gusta especialmente el concepto de hacer que los estados inválidos sean irrepresentables, y trato de aplicarlo sin importar el lenguaje. Claro que algunos lenguajes lo facilitan más.
    https://fsharpforfunandprofit.com/posts/designing-with-types...

  • Si se usa flujo de datos en lugar de llamadas/retornos, este problema se resuelve mejor y en gran medida desaparece solo.
    En el modelo de llamada/retorno, hay que devolver algo; si por un error no hay un valor para devolver, hay que devolver el error o, como en Go, devolver juntos el error y el valor normal. Eso contamina el camino de éxito. Si se encadena el resto del procesamiento mediante un contenedor polimórfico, mejora un poco, pero el problema sigue ahí.
    En un flujo de datos, basta con no enviar nada a la siguiente etapa del filtro, así que el camino de éxito no se ve afectado en absoluto. Los errores pueden enviarse a algún lugar como la salida estándar de errores y centralizarse a nivel de la aplicación.
    Puede sonar demasiado bueno, pero en Wunderlist lo usamos de verdad y funcionó bien. La misma técnica que se usa cuando no hay valor también se aplica tal cual cuando todavía no hay valor, es decir, al procesamiento asíncrono.

    • Me pregunto si esto es fundamentalmente distinto de devolver datos o lanzar una excepción para que se capture en otro lugar.
    • Estaría bueno tener un ejemplo de código para entenderlo mejor.
    • Ese enfoque también tiene desventajas y, en particular, se siente limitado. El manejo de excepciones, sobre todo el manejo de excepciones asíncronas, puede dificultar la recuperación ante errores.
      Si ocurre un error en un flujo de datos simple, está bien que se detenga porque no hay datos para la siguiente etapa. Pero si en sqrt(-1) se “crashea” de inmediato y se salta a la salida de errores o al logging, se vuelve difícil cuando, para ciertos casos de uso, uno quiere ampliar el dominio para dar otro valor a los números negativos.
      Ahí es donde resulta útil un tipo de error explícito como Maybe. El camino de éxito se escribe como si no hubiera errores, pero para la composición de funciones se usa otro operador. Si quieres recuperarte de un error, en ese punto manejas ambos caminos, y después de eso incluso puedes no permitir más errores. Algo especialmente bueno es que, para extraer el valor real de un “valor que quizá exista” abstracto, hay que manejar elegantemente ambos caminos.
      Con tipos de error explícitos, también se puede ir más allá de la dicotomía éxito/error. Se pueden expresar estados como “no hay valor, pero...” o “hay valor, pero...”, y en varias tareas que pueden procesarse en paralelo se pueden acumular errores y reunirlos en el resultado. Incluso se puede codificar no determinismo y usarlo para cosas como recorridos de grafos.
      También se puede transportar estado de forma pura, como hacen los programadores de Haskell, y la célebre mónada IO de Haskell se parece más a un mecanismo para limitar los puntos de contacto con el mundo externo a main que a una representación de un camino real. Es por la pureza, pero creo que la pureza no siempre es un valor que deba perseguirse de manera absoluta.
      Esta técnica es mucho más variada que un simple ferrocarril y ofrece una forma limpia de usar solo el camino de éxito manteniendo la pureza. También favorece el análisis estático basado en ecuaciones y las pruebas unitarias sin mocks. Ojalá más lenguajes mainstream ofrecieran mejor esta caja de herramientas de mónadas.
  • Enlaces relacionados: Railway Oriented Programming - https://news.ycombinator.com/item?id=31404643 - mayo de 2022, Railway-Oriented Programming (2015) - https://news.ycombinator.com/item?id=17337155 - junio de 2018, Railway oriented programming - https://news.ycombinator.com/item?id=11955917 - junio de 2016, Railway Oriented Programming - https://news.ycombinator.com/item?id=9166943 - marzo de 2015, Railway-oriented programming - https://news.ycombinator.com/item?id=7887134 - junio de 2014
    Además, What is railway oriented programming? (2020) - https://news.ycombinator.com/item?id=34245639 - enero de 2023, Railway Oriented Programming in Elixir (2015) - https://news.ycombinator.com/item?id=11958578 - junio de 2016

  • Este estilo es contagioso, como otras mónadas, así que toda la lógica de negocio termina con esta forma. Si eso te parece bien, sigue leyendo.
    Las excepciones, como indica su nombre, son errores inesperados; deshacen la pila hasta el punto donde se capturan y se encargan automáticamente de la limpieza necesaria. Son adecuadas para errores que ocurren fuera del alcance de una función de negocio, como errores de I/O o falta de memoria.
    Si quieres validar datos, no hace falta depender de manejadores de excepciones ni de mónadas. Basta con convertir el resultado de un pipeline funcional en datos. Si hay errores de validación, se reúnen en un conjunto y se revisan después de que termina el pipeline. Normalmente es muy probable que no quieras fallar ante el primer error.
    Si necesitas efectos secundarios en el pipeline, no los hagas ahí; describe esos efectos como datos y ejecútalos más tarde. Si necesitas salir temprano a mitad de camino, divide el pipeline en ese punto, procesa el resultado y luego pásalo a otro pipeline.
    O puedes usar una cadena de interceptores para decidir si continuar por el camino de éxito. Si el lenguaje lo soporta, usa pattern matching. Lo importante es que la decisión de saltarse o no una parte del pipeline se tome fuera de la función de negocio; así, la función de negocio se ocupa solo de una transformación de datos pura y tiene más posibilidades de reutilizarse.

  • Me alegra que temas como este vuelvan a aparecer y se discutan. Leí este sitio durante un año y seguí los ejemplos; fue uno de los mejores recursos sobre F#
    Los conceptos que trata este sitio, por sí solos, me ayudaron a pensar de forma funcional en cualquier lenguaje
    Es un sitio viejo, así que me pregunto si el hecho de que ahora esté recibiendo atención significa que F# está ganando impulso. Y también me pregunto si programadores de otros lenguajes usan manejo de errores estilo ferroviario, como en Rust

    • Tengo una fuerte base en C# para aplicaciones de escritorio, y con el tiempo mi estilo de programación en C# se ha vuelto más funcional. Uso mucho Linq, clases inmutables y tipos de retorno que pueden contener información detallada de errores
      Aun así, la razón por la que no logro pasarme a F# es que paso la mayor parte del tiempo ajustando frontends de escritorio. En esta área, F# no parece aportar muchas ventajas. Los frontends de escritorio listos para producción no suelen ser F# nativo, sino en su mayoría código orientado a objetos basado en C#, unido con F# mediante una capa delgada de código glue
      En cuanto hace falta personalizar una UI aunque sea un poco compleja, hay que volver al mundo orientado a objetos, y en ese caso me parece mejor hacerlo directamente en C#. Personalmente, creo que para que F# gane impulso necesita un framework de UI nativo
    • En C++ también usamos ese enfoque. Definimos un tipo de retorno template que se puede parametrizar con el tipo de retorno T y un enum específico que define la condición del resultado
      Si estás apurado, también se puede hacer con std::pair, o devolver una cadena con el mensaje de error y terminar antes si la cadena no está vacía. Si todas las funciones devuelven ese tipo, se puede hacer que el mensaje de error burbujee hasta el nivel superior
  • Me gusta que este artículo vuelva a aparecer cada pocos años. Es un artículo y una charla realmente excelentes
    Cada vez que lo releo, estoy en un punto distinto de mi recorrido técnico, ya sea en habilidad o en filosofía, y también es interesante que cada vez se vea diferente

    • Me encantan este tipo de recursos. En mis primeros años veía cada seis meses una charla de Python llamada “so you want to be a python expert”, y cada vez entendía más. Si la volviera a ver ahora, probablemente también sacaría algo nuevo