Programação orientada a ferrovias
(fsharpforfunandprofit.com)- Railway Oriented Programming é uma abordagem para tratar, dentro de pipelines funcionais, caminhos de falha recorrentes em aplicações reais, como validação, logging e erros de rede
- Seu ponto central é explicar as limitações dos exemplos que encadeiam apenas o “happy path” por meio da metáfora da ferrovia, facilitando também para usuários de F# entender visualmente o fluxo de erros
- Do ponto de vista de Haskell, ela se aproxima do tipo
Eithere da composição de Kleisli, mas foca mais em receitas de tratamento de erros e padrões de implementação do que em teoria abstrata - Para aplicação prática, são fornecidos juntos a biblioteca F# Chessie para NuGet, exemplos no GitHub comparando C# e F#, e um exemplo aplicado a FizzBuzz
- É uma abordagem útil, mas não deve ser aplicada em excesso a todos os problemas; como F# não tem type classes, a biblioteca de exemplo define diretamente as funções necessárias
Material da apresentação e código de exemplo
- A página de Railway Oriented Programming reúne em um só lugar os slides e o código da apresentação
- Ela parte do problema de que é difícil criar aplicações reais apenas com o “happy path” que exemplos de programação funcional costumam pressupor
- Aplicações robustas também precisam lidar com validação, logging, erros de rede, erros de serviços e outras situações excepcionais
- O objetivo é apresentar de forma breve e fácil de entender, por meio da metáfora da ferrovia, como tratar esses erros de uma maneira funcional e limpa
- Um tema semelhante continua na série recipe for a functional app
- O código real pode ser visto no projeto no GitHub que compara abordagens de ROP em C# e F#
- Embora seja útil para tratamento de erros, também é indicado consultar “Against Railway-Oriented Programming”, que alerta para não aplicar a abordagem de forma extrema
Vídeos e slides
- Este tema foi apresentado na NDC London 2014
- Outros vídeos da mesma apresentação também estão disponíveis
- Há slides da apresentação de 14 de março de 2014 no Functional Programming eXchange
- Os slides em PowerPoint também podem ser baixados no GitHub, com a indicação de que podem ser reutilizados livremente
A mônada Either e composição de Kleisli
- Para usuários de Haskell, essa abordagem pode parecer o tipo
Either - Aqui, ela é especializada na forma de usar uma lista de tipos de erro customizados no caso
Left- Um tipo de exemplo é
type TwoTrack a b = Either [a] (b,[a])
- Um tipo de exemplo é
- O que foi criado não é exatamente o conceito de tratamento de erros em si, mas sim a metáfora da ferrovia para explicá-lo
- O motivo para não priorizar a terminologia padrão de Haskell é claro
- Este material não é um tutorial de mônadas, mas é focado em resolver o problema de tratamento de erros
- Muitas pessoas que têm contato com F# não estão familiarizadas com mônadas, então uma explicação visual e menos intimidadora pode ser mais intuitiva
- Chamar imediatamente um tipo two-track com
bindde mônada é impreciso, e as leis das mônadas não são abordadas Eitheré uma ferramenta genérica demais; aqui a intenção é oferecer uma receita, mais do que a ferramenta em si
Componentes da receita de tratamento de erros
- Essa abordagem é um conjunto de técnicas mais abrangente do que “basta usar
Eitherebind” - As técnicas incluídas são:
- Em vez de uma forma simples como
Either String a, usar listas de tipos de erro customizados tanto à esquerda quanto à direita - Integrar funções monádicas ao pipeline com
bind(>>=) - Compor funções monádicas com composição de Kleisli (
>=>) - Integrar funções não monádicas ao pipeline com
map(fmap) - Como F# não usa a mônada IO, integrar funções unit ao pipeline com
tee - Mapear exceções para casos de erro
- Combinar funções monádicas em paralelo com
&&&em situações como validação - Aproveitar os benefícios de tipos de erro customizados em domain-driven design
- Extensões como logging, eventos de domínio e transações compensatórias
- Em vez de uma forma simples como
- O objetivo é criar um template versátil o bastante para ser usado em quase todas as situações, mas restritivo o suficiente para impor um estilo consistente
- Quando a forma de escrever o código converge praticamente para uma só, quem fizer manutenção depois consegue entender rapidamente a estrutura do código
- Essa abordagem não é a única solução, mas pode ser um bom ponto de partida
- Mesmo dentro da comunidade Haskell, as formas de tratamento de erros não são consistentes, o que pode ser confuso para iniciantes
Como aplicar no meu código
- Se você precisa de uma biblioteca F# pronta, pode consultar o projeto Chessie, que pode ser usado com NuGet
- Um serviço web de exemplo que aplica essa técnica é fornecido como projeto no GitHub
- Um exemplo que aplica a abordagem ROP ao FizzBuzz pode ser visto em Railway Oriented Programming: carbonated
- F# não tem type class, portanto não há uma forma geral de reutilizar mônadas
- A biblioteca FSharpX oferece uma abordagem útil, mas a biblioteca
Rop.fsdefine todas as funções diretamente do zero - Esse isolamento tem a vantagem de não introduzir nenhuma dependência externa
Leituras adicionais
- Para definir corretamente uma mônada, não basta simplesmente implementar
bindereturn - Uma mônada é uma estrutura algébrica que deve satisfazer as leis das mônadas, e essas leis se relacionam às leis dos monoides em determinadas situações
- Se você tem interesse em
Eithere composição de Kleisli, pode consultar os materiais a seguir- Monads in general
- The
Eithermonad - Kleisli categories and composition
- Comprehensive error handling approaches
2 comentários
Railway-Oriented Programming para criar programas sem falhas
Opiniões no Hacker News
Elixir lida com esse problema de forma bastante limpa com a palavra-chave/macro
withEle executa as funções de cima para baixo, como em
with {:ok, file_handle} <- File.open(filename), ... do {:ok, parsed} end, e, se cada valor retornado não corresponder ao padrão à esquerda, faz um retorno antecipado com esse valorAssim, em vez de precisar fazer cada função aceitar tanto
{:ok, value}quanto{:error, reason}, a função pode receber apenas o valor que lhe interessa e deixar a correspondência de padrões do blocowithcuidar da propagação de errosPor exemplo, se
File.openretornar{:error, reason},IO.readnão será executado, e o resultado de todo owithserá{:error, reason}. No fim, você escreve apenas o caminho de sucesso, e o caminho de falha pode ser tratado por correspondência pelo chamador, se ele quiserDá a impressão de reinventar parcialmente o tratamento de exceções; se chamassem de algo como
elseem vez decatch, só pareceria menos com tratamento de exceçõeswheredo Haskell e peloletdo LispÉ como definir as pré-condições de uma função, e no Elixir ainda é possível usar um bloco
elseIO.readfalhar, também há a questão de quem fecha o handle do arquivoO autor também escreveu, alguns anos depois, um texto de continuação chamado Against Railway Oriented Programming: https://fsharpforfunandprofit.com/posts/against-railway-orie...
A programação orientada a ferrovias é interessante e tem seus usos, mas acho que precisa de um grande aviso de cautela. Na prática, vi com frequência ela ser usada para reinventar mal o tratamento de exceções; quando exceções são bem compreendidas e usadas corretamente, a maioria das condições de erro pode ser tratada de forma mais limpa e eficaz
A vantagem das exceções é que, na maioria dos casos, a escolha segura é o padrão. Uma condição de erro é um sinal de que o código não consegue fazer o que foi especificado; se você continuar nesse estado, os dados podem ser corrompidos por suposições incorretas. Exceções, por padrão, fazem a limpeza e se propagam para o chamador, enquanto abordagens como a programação orientada a ferrovias exigem muito boilerplate fácil de esquecer e de errar
Ainda assim, ela é útil em dois casos. Um é quando erros esperados, específicos e bem definidos, como validação, precisam ser tratados no ponto em que ocorrem; o outro é em ambientes onde não se pode usar tratamento de exceções. O código assíncrono antigo baseado em Promises do jQuery era quase uma implementação de programação orientada a ferrovias, mas no JavaScript moderno, com
async/await, é possível voltar a usar tratamento de exceçõesA grande recompensa da programação orientada a ferrovias é que, só de olhar a assinatura da função, você sabe imediatamente quais erros podem ocorrer
O boilerplate pode ser reduzido dependendo da linguagem. Haskell tem a notação
do, e em F# dá para escrever de forma muito limpa com expressões de computação para result. Por exemplo, pode-se definir algo comoLoginError = InvalidUser | InvalidPwd | Unauthorized of AuthErrore compor o fluxo comResult.requireSome,Result.requireTrueeResult.mapErrorPor outro lado, fico curioso se também seria possível mitigar as desvantagens das exceções. Gostaria de saber se existe algum linter ou ferramenta de análise estática integrada a uma IDE Java que mostre automaticamente as exceções não capturadas que podem ser lançadas em uma determinada linha de código
https://demystifyfp.gitbook.io/fstoolkit-errorhandling/fstoo...
Para começar, muitas linguagens impõem um escopo desajeitado e desnecessário para capturar exceções. Se você quer declarar e inicializar uma variável com o resultado de uma função que pode lançar exceção e, em caso de falha, colocar outro valor, acaba tendo de separar a declaração da inicialização
O problema maior é que condições de erro deveriam ser tratadas no nível mais baixo que ainda tenha contexto suficiente para lidar corretamente com elas, mas o padrão das exceções é “lançar até o topo”. Basta adicionar um cache de arquivos para que a função
HandleRequestde nível superior de repente possa lançar uma exceção de I/O, e a abstração começa a vazarAcho que tudo que uma função pode retornar deveria fazer parte explicitamente da sua assinatura, e o chamador deveria tratar isso explicitamente ou ao menos indicar que está repassando para cima. A linguagem não precisa exigir muito boilerplate para isso
Resultde Rust acertou no tratamento de errosMesmo quando um arquivo ou registro não existe, depende se isso era esperado. Se a consulta é feita por um ID fornecido pelo usuário, a própria consulta faz parte da validação da entrada do usuário. Mas se você consulta o armazenamento usando o nome de um blob salvo em um registro do banco de dados e o blob não existe, essa é uma situação que se encaixa bem como exceção
A fronteira entre erros e exceções é nebulosa e varia conforme a equipe e o domínio do problema, mas acho que essa é uma boa regra prática
Em código C++, dá para inspecionar os dados no início; se a inspeção falhar, deixar a aplicação crashar; e então depurar e corrigir o código de inspeção inicial com base no estado de erro encontrado
Como os dados tratados seguem formatos de dados CAD conhecidos ou topologia geométrica, são dados relativamente bem definidos, então é “bastante fácil” lidar com as condições de erro no sentido de que dá para primeiro entender o que são dados corretos
Sinceramente, este site inteiro é um tesouro. Mesmo que você não pretenda usar uma linguagem funcional, ele oferece outras perspectivas, e isso foi muito útil para mim. Também recomendo os outros textos
Mesmo assim, acho que nunca vou entender o que é uma mônada
https://fsharpforfunandprofit.com/series/understanding-parse...
Fico me perguntando se a afirmação de que “empurrar o tratamento de erros para longe do ponto de chamada é ruim” se sustenta. Como quem chama é quem melhor pode lidar com o erro, talvez devesse tratá-lo diretamente em vez de passá-lo para baixo
Por exemplo, em um fluxo
validate and-then update-db and-then send-email, talvez não haja problema em devolver uma falha devalidateao chamador. Mas, seupdate-dbfalhar, é preciso decidir se vai tentar de novo, usar outro serviço, colocar de volta na fila ou avisar o usuário. O mesmo vale para uma falha desend-emailNo fim, isso não vira uma estrutura de ramificações mais complexa, em que
ValidationErroravisa o usuário,DBErrortenta novamente 5 minutos depois ou avisa após uma nova tentativa remota, eEmailFailedrecoloca o e-mail na fila e considera como sucesso? Talvez isso até seja aceitávelQuem não gosta de Go reclama que é preciso verificar condições de erro com frequência demais, e as exceções verificadas de Java são parecidas. Com
Either, você não é obrigado a verificar; pode verificar diretamente ou repassar ao chamadorQuem não gosta das exceções não verificadas de Java reclama que não dá para saber o que será lançado nem quando. Com
Either, isso fica explícitoO ponto central é que você não sabe o que fazer quando ocorre um erro, e
Eitherse encaixa bem nessa falta de conhecimento. Em uma base de código Java atual, quandoLimit getUserLimit(User)não existe, ele pode lançar uma exceçãoNotFound, retornarnullou retornar um valor padrão, e não dá para saber antes de mergulhar no códigoSe fosse
Either, o chamador poderia confiar sem ler o código interno e poderia convertê-lo facilmente para outra condição de falha com ferramentas embutidas comoorElse(null)ouorElseThrow(...). Além disso, comoEitheré uma expressão, é bom para abstração; em vez de código de retentativa especializado dentro deDbUpdater, dá para escrever uma lógica de retentativa genéricatry-catchde Java, e ficou bem pior, comtryaninhado,catch(DBError),sleep(5), nova tentativa remota, reenfileiramento deEmailFailedErrore notificação deValidationErrortodos misturadoshttps://gist.github.com/Andrewp2/9d97bd213b061166d6df565ce26...
Alguém pode até escrever uma versão
try-catchmelhor, mas acho que ela ainda ficaria muito pior que a versão em estilo ferroviário apresentadaEu gosto de DDD, mas um programador comum como eu precisa de um ciclo de manutenção razoável que consiga administrar. O motivo de TDD ter sido bom em código mutável foi reduzir a carga cognitiva da manutenção
A programação ferroviária pode carregar muito contexto ao passar por muitas transformações, e eu quero evitar receber, no fim da linha, um trem carregado de contexto de negócio e de sistema para o qual não estou preparado
Acho que DDD e arquitetura em cebola oferecem o melhor dos dois mundos. No fim, a ferrovia aparece, mas fica do lado de fora, não embutida dentro de um microapp. Questões em estilo ferroviário são melhores quando ficam explícitas e são codificadas de forma um pouco trabalhosa; se for bem feito, a maioria das decisões ferroviárias é uma decisão de negócio que não deve ser evitada por uma estrutura de código esperta
Programação monádica é excelente, mas existe a tentação de usá-la para evitar decisões de negócio necessárias. Colocar ambiguidade no sistema de tipos pode levar a situações difíceis ou impossíveis mais adiante
try/catchespecializado dentro de um grandetry/catchPor exemplo, dentro da função
send-email, você pode optar por tratar erros transitórios muito rápidos. Se a conexão acontecer dentro do tempo permitido, volta ao caminho de sucesso; caso contrário, retorna ao chamadorSe houver uma regra clara, é que qualquer tratamento que exija intervenção humana deve obrigatoriamente ser encapsulado e repassado para cima. Mesmo que volte ao caminho de sucesso, é melhor deixar um log de aviso
notify-userficaria responsável pelas notificações ao usuário paraValidationErroreDBError,try-update-dbagrupariaupdate-db, a retentativa 5 minutos depois e a retentativa remota, etry-send-emailpoderia recolocar o e-mail na fila em caso de falha e converter isso em sucessoAssim, o nível superior continuaria como
validate and-then try-update-db and-then try-send-email or-then notify-user. Parece queand/or/thenforam bem mapeados paramap/bindEste site foi o melhor site de ensino de programação que já encontrei no sentido de ensinar conceitos concretos e práticos. Gosto especialmente do conceito de tornar estados inválidos impossíveis de representar, e tento aplicá-lo independentemente da linguagem. Claro que algumas linguagens tornam isso mais fácil
https://fsharpforfunandprofit.com/posts/designing-with-types...
Usar fluxo de dados em vez de chamada/retorno resolve melhor esse problema, e boa parte dele desaparece por conta própria
No modelo de chamada/retorno, é preciso retornar alguma coisa; então, se não há um valor a retornar por causa de um erro, é preciso retornar o erro ou, como em Go, retornar o erro junto com o valor normal. Isso contamina o caminho de sucesso. Encadear o restante do processamento por meio de um contêiner polimórfico melhora um pouco, mas o problema continua
No fluxo de dados, basta não enviar nada para a próxima etapa do filtro, então o caminho de sucesso não é afetado de forma alguma. Erros podem ser enviados para algum lugar como a saída de erro padrão e centralizados no nível da aplicação
Pode parecer bom demais, mas usamos isso de fato no Wunderlist e funcionou bem. A mesma técnica usada quando não há um valor também se aplica diretamente quando ainda não há um valor, ou seja, ao processamento assíncrono
Se ocorre um erro em um fluxo de dados simples, não há dados para a próxima etapa e tudo parar é aceitável. Mas, se em
sqrt(-1)você imediatamente “quebra” e pula para a saída de erro ou para o logging, fica difícil, em determinados casos de uso, ampliar o domínio para retornar outro valor para números negativosNesse caso, um tipo de erro explícito como
Maybeé útil. Você escreve o caminho de sucesso como se não houvesse erro, mas usa outro operador para composição de funções. Se quiser se recuperar de um erro, trata os dois caminhos naquele ponto e, depois disso, pode até deixar de permitir erros. Gosto especialmente do fato de que, para extrair um valor real de um “valor que pode existir” abstrato, é preciso lidar com os dois caminhos de forma eleganteAo usar tipos de erro explícitos, também é possível sair da dicotomia sucesso/erro. Dá para representar estados como “não há valor, mas...” ou “há valor, mas...”, e também acumular erros de várias tarefas que podem ser processadas em paralelo para reuni-los no resultado. Indo além, é possível codificar não determinismo e usar isso em coisas como percorrer grafos
Também é possível transportar estado de forma pura, como fazem programadores Haskell, e a infame mônada
IOde Haskell é mais um mecanismo para limitar amainos pontos de contato com o mundo externo do que algo que represente um caminho real. Isso é em nome da pureza, embora eu ache que a pureza nem sempre seja um valor a ser perseguido de forma absolutaEssa técnica é muito mais variada do que uma simples ferrovia e oferece uma forma limpa de usar apenas o caminho de sucesso mantendo a pureza. Também é vantajosa para análise estática baseada em equações e testes unitários sem mocks. Eu gostaria que mais linguagens mainstream oferecessem melhor essa caixa de ferramentas de mônadas
Links relacionados: Railway Oriented Programming - https://news.ycombinator.com/item?id=31404643 - maio de 2022, Railway-Oriented Programming (2015) - https://news.ycombinator.com/item?id=17337155 - junho de 2018, Railway oriented programming - https://news.ycombinator.com/item?id=11955917 - junho de 2016, Railway Oriented Programming - https://news.ycombinator.com/item?id=9166943 - março de 2015, Railway-oriented programming - https://news.ycombinator.com/item?id=7887134 - junho de 2014
Além disso, What is railway oriented programming? (2020) - https://news.ycombinator.com/item?id=34245639 - janeiro de 2023, Railway Oriented Programming in Elixir (2015) - https://news.ycombinator.com/item?id=11958578 - junho de 2016
Esse estilo, como outras mônadas, é contagioso, então toda a lógica de negócio acaba ficando com esse formato. Se isso for aceitável para você, pode continuar lendo
Exceções são, como o nome sugere, erros inesperados; elas desenrolam a pilha até o ponto em que são capturadas, e a limpeza necessária é feita automaticamente. São adequadas para erros que ocorrem fora do escopo de uma função de negócio, como erros de I/O ou falta de memória
Se você quer validar dados, não precisa depender de tratadores de exceção nem de mônadas. Basta transformar o resultado de um pipeline funcional em dados. Se houver erros de validação, reúna-os em um conjunto e verifique depois que o pipeline terminar. Em geral, é bem provável que você não queira falhar no primeiro erro
Se precisar de efeitos colaterais no pipeline, não os faça; descreva esses efeitos como dados e execute-os depois. Se precisar sair mais cedo no meio do caminho, divida o pipeline nesse ponto, trate o resultado e então passe-o para outro pipeline
Ou você pode usar uma cadeia de interceptadores para decidir se o caminho de sucesso deve continuar. Se a linguagem oferecer suporte, use pattern matching. O ponto importante é que a decisão de pular ou não parte do pipeline acontece fora da função de negócio; assim, a função de negócio se preocupa apenas com uma transformação de dados pura, aumentando sua possibilidade de reutilização
É bom só pelo fato de um tema como este voltar à tona e ser discutido. Li este site durante um ano e acompanhei os exemplos; foi um dos melhores materiais sobre F#
Só os conceitos abordados neste site já ajudaram a pensar de forma funcional em qualquer linguagem
É um site antigo, então fico curioso se o fato de estar recebendo atenção agora significa que F# está ganhando tração. E também fico curioso se programadores de outras linguagens usam tratamento de erros em estilo ferroviário, como em Rust
Ainda assim, o motivo de eu não conseguir migrar para F# é que passo a maior parte do tempo ajustando front-ends desktop. Nessa área, F# não parece trazer grandes vantagens. Front-ends desktop prontos para produção são, em sua maioria, código orientado a objetos baseado em C#, não F# nativo, e ficam ligados ao F# por uma fina camada de código de glue
Quando surge a necessidade de qualquer customização de UI um pouco mais complexa, é preciso voltar ao mundo orientado a objetos; nesse caso, acho melhor fazer diretamente em C#. Pessoalmente, para F# ganhar tração, ele precisa de um framework de UI nativo
Te um enum específico que define as condições de resultadoSe estiver com pressa, dá para fazer com
std::pairtambém, ou retornar a mensagem de erro em uma string e encerrar cedo se a string não estiver vazia. Se todas as funções retornarem esse tipo, a mensagem de erro pode borbulhar até o nível mais altoGosto que este texto volte a aparecer a cada poucos anos. É um artigo e uma apresentação realmente excelentes
Também é interessante que, a cada releitura, eu esteja em um ponto diferente da minha jornada técnica, em termos de habilidade ou filosofia, então ele sempre parece diferente