Programación orientada a ferrocarriles
(fsharpforfunandprofit.com)- 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
Eithery 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
- El objetivo es presentar, mediante la analogía del ferrocarril, una forma breve y fácil de entender para tratar este manejo de errores con un enfoque funcional limpio
- Un tema similar continúa en la serie recipe for a functional app
- El código real puede verse en el proyecto de GitHub que compara los enfoques ROP en C# y F#
- También se señala como referencia “Against Railway-Oriented Programming”, junto con la advertencia de que es útil para el manejo de errores, pero no debe aplicarse de manera extrema
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])
- Un tipo de ejemplo es
- 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
bindes impreciso, y no se tratan las leyes de las mónadas Eitheres 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
Eitherybind” - 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
- Usar listas de tipos de error personalizados tanto en el lado izquierdo como en el derecho, en lugar de formas simples como
- 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.fsdefine todas las funciones desde cero - Aislarlo de esta manera tiene la ventaja de no tener ninguna dependencia externa
Lecturas adicionales
- Para definir correctamente una mónada, no basta con implementar
bindyreturn - Una mónada es una estructura algebraica que debe satisfacer las leyes de las mónadas, y estas leyes están relacionadas con las leyes de monoides en ciertos contextos
- Si te interesan
Eithery la composición de Kleisli, puedes consultar los siguientes recursos- Monads in general
- The
Eithermonad - Kleisli categories and composition
- Comprehensive error handling approaches
2 comentarios
Programación orientada al ferrocarril para crear programas sin defectos
Opiniones de Hacker News
Elixir aborda este problema de forma bastante limpia con la palabra clave/macro
withEjecuta 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 valorAsí, 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 bloquewithse encargue de propagar los erroresPor ejemplo, si
File.opendevuelve{:error, reason},IO.readno se ejecuta y el resultado completo dewithpasa 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 quiereSe siente como reinventar a medias el manejo de excepciones, y si se le pusiera un nombre como
elseen vez decatch, simplemente se vería menos como manejo de excepcioneswherede Haskell y elletde LispEs como establecer las precondiciones de una función, y en Elixir además se puede usar un bloque
elseIO.readfalla, también está el problema de quién cierra el file handleEl 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/awaitse puede volver a usar manejo de excepciones.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 comoLoginError = InvalidUser | InvalidPwd | Unauthorized of AuthErrory componer el flujo conResult.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...
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
HandleRequestpuede 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.
Resultde Rust logró hacer bien el manejo de errores.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.
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.
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 devalidate. Pero si fallaupdate-db, hay que decidir si reintentar, usar otro servicio, volver a ponerlo en una cola o avisarle al usuario. Lo mismo con una falla desend-email.Al final, ¿no termina siendo una estructura de ramificación más compleja, donde
ValidationErrorse le informa al usuario,DBErrorse reintenta 5 minutos después o se informa luego de un reintento remoto, yEmailFailedvuelve a poner el email en la cola y se considera exitoso? Quizá eso también esté bien.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
Eitherencaja bien con ese no saber. En una base de código Java actual, cuando no existeLimit getUserLimit(User), podría lanzar una excepciónNotFound, devolvernullo 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 comoorElse(null)uorElseThrow(...)podría convertirlo fácilmente en otras condiciones de falla. Además, comoEitheres 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 deDbUpdater.try-catchde Java, y se ve mucho peor, contryanidados,catch(DBError),sleep(5), reintentos remotos, reencolado deEmailFailedErrory notificaciones deValidationErrortodos 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.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.
try/catchespecializado dentro de un grantry/catch.Por ejemplo, dentro de la función
send-emailse 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.
notify-userse encarga de las notificaciones al usuario segúnValidationErroryDBError;try-update-dbagrupaupdate-db, el reintento 5 minutos después y el reintento remoto; ytry-send-emailpuede 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 bienand/or/thenamap/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.
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
IOde Haskell se parece más a un mecanismo para limitar los puntos de contacto con el mundo externo amainque 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
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
Ty un enum específico que define la condición del resultadoSi 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 superiorMe 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