4 pontos por GN⁺ 2023-08-20 | 2 comentários | Compartilhar no WhatsApp
  • 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 Either e 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

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])
  • 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 bind de 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 Either e bind
  • 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
  • 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.fs define todas as funções diretamente do zero
  • Esse isolamento tem a vantagem de não introduzir nenhuma dependência externa

Leituras adicionais

2 comentários

 
GN⁺ 2023-08-20
Opiniões no Hacker News
  • Elixir lida com esse problema de forma bastante limpa com a palavra-chave/macro with
    Ele 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 valor
    Assim, 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 bloco with cuidar da propagação de erros
    Por exemplo, se File.open retornar {:error, reason}, IO.read não será executado, e o resultado de todo o with será {: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 quiser

    • Se ele “retorna antecipadamente o que não correspondeu”, fico curioso sobre o que acontece quando esses valores são todos de tipos diferentes. Se a tipagem é dinâmica, parece que seria preciso olhar qual é o tipo e inferir onde a falha ocorreu
      Dá a impressão de reinventar parcialmente o tratamento de exceções; se chamassem de algo como else em vez de catch, só pareceria menos com tratamento de exceções
    • Pelo que sei, essa forma foi influenciada pelo where do Haskell e pelo let do Lisp
      É como definir as pré-condições de uma função, e no Elixir ainda é possível usar um bloco else
    • Fico curioso para saber em que isso é melhor do que exceções. E, se IO.read falhar, também há a questão de quem fecha o handle do arquivo
  • O 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ções

    • O ponto frustrante nas exceções das linguagens mainstream é que esse tratamento acontece de forma invisível. Não é fácil saber, em tempo de compilação, quais exceções podem surgir dentro de uma função
      A 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 como LoginError = InvalidUser | InvalidPwd | Unauthorized of AuthError e compor o fluxo com Result.requireSome, Result.requireTrue e Result.mapError
      Por 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...
    • Acho que a forma tradicional de implementar exceções é bem ruim
      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 HandleRequest de nível superior de repente possa lançar uma exceção de I/O, e a abstração começa a vazar
      Acho 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
    • Acho que Rust mostrou muito bem que a programação orientada a ferrovias com suporte sintático de primeira classe pode quase eliminar o boilerplate. Pessoalmente, acho que o tipo/trait Result de Rust acertou no tratamento de erros
    • Acho que a diferença entre erros como valores e exceções é mais útil ao descrever erros específicos do domínio. Erros de domínio devem ser representados como valores; problemas sem relação com o domínio, como o banco de dados não estar conectado, parecem mais naturais como exceções
      Mesmo 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
    • Tirando erros em nível de sistema, gostaria de ver exemplos de estados de erro que não sejam “erros esperados, concretos e bem definidos, que precisam ser tratados no ponto em que ocorrem”. Quero entender se, nesses casos, exceções são melhores pela natureza do problema ou por restrições do runtime
      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

    • A série sobre criar parser combinators do zero foi um dos materiais mais valiosos que já li e acompanhei na prática. Muitos dos conceitos e mecanismos apresentados ali foram extremamente úteis ao trabalhar com linguagens tipadas em estilo funcional
      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 de validate ao chamador. Mas, se update-db falhar, é 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 de send-email
    No fim, isso não vira uma estrutura de ramificações mais complexa, em que ValidationError avisa o usuário, DBError tenta novamente 5 minutos depois ou avisa após uma nova tentativa remota, e EmailFailed recoloca o e-mail na fila e considera como sucesso? Talvez isso até seja aceitável

    • O tratamento de erros monádico é uma forma de obter as vantagens dos dois lados
      Quem 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 chamador
      Quem 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ícito
      O ponto central é que você não sabe o que fazer quando ocorre um erro, e Either se encaixa bem nessa falta de conhecimento. Em uma base de código Java atual, quando Limit getUserLimit(User) não existe, ele pode lançar uma exceção NotFound, retornar null ou retornar um valor padrão, e não dá para saber antes de mergulhar no código
      Se 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 como orElse(null) ou orElseThrow(...). Além disso, como Either é uma expressão, é bom para abstração; em vez de código de retentativa especializado dentro de DbUpdater, dá para escrever uma lógica de retentativa genérica
    • Tentei implementar essa lógica no estilo try-catch de Java, e ficou bem pior, com try aninhado, catch(DBError), sleep(5), nova tentativa remota, reenfileiramento de EmailFailedError e notificação de ValidationError todos misturados
      https://gist.github.com/Andrewp2/9d97bd213b061166d6df565ce26...
      Alguém pode até escrever uma versão try-catch melhor, mas acho que ela ainda ficaria muito pior que a versão em estilo ferroviário apresentada
    • O tratamento de erros de baixo nível e de alto nível pode coexistir, mas acho que esse estilo funciona melhor em exemplos de quadro branco ou em situações de fazer uma vez e encerrar
      Eu 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
    • O tratamento de erros de baixo nível e de alto nível pode coexistir. Não é diferente de ter um try/catch especializado dentro de um grande try/catch
      Por 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 chamador
      Se 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
    • Pode acabar como no exemplo que você apresentou, mas também dá para organizá-lo de outra forma mantendo o mesmo pipeline básico de alto nível
      notify-user ficaria responsável pelas notificações ao usuário para ValidationError e DBError, try-update-db agruparia update-db, a retentativa 5 minutos depois e a retentativa remota, e try-send-email poderia recolocar o e-mail na fila em caso de falha e converter isso em sucesso
      Assim, o nível superior continuaria como validate and-then try-update-db and-then try-send-email or-then notify-user. Parece que and/or/then foram bem mapeados para map/bind
  • Este 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

    • Fico me perguntando se isso é fundamentalmente diferente de retornar dados ou lançar uma exceção para ser capturada em outro lugar
    • Seria bom ter um exemplo de código para ajudar a entender
    • Essa abordagem também tem desvantagens e, em especial, parece limitada. O tratamento de exceções, sobretudo exceções assíncronas, pode dificultar a recuperação de erros
      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 negativos
      Nesse 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 elegante
      Ao 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 IO de Haskell é mais um mecanismo para limitar a main os 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 absoluta
      Essa 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

    • Tenho uma forte base em C# para aplicações desktop e, com o tempo, meu estilo de programação em C# foi ficando mais funcional. Uso bastante Linq, classes imutáveis e tipos de retorno que podem carregar informações detalhadas de erro
      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
    • Também usamos essa abordagem em C++. Definimos um tipo de retorno template que pode ser parametrizado com o tipo de retorno T e um enum específico que define as condições de resultado
      Se estiver com pressa, dá para fazer com std::pair també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 alto
  • Gosto 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

    • Gosto muito desse tipo de material. Nos meus primeiros anos, eu assistia a cada seis meses a uma palestra de Python chamada “so you want to be a python expert”, e a cada vez entendia mais. Mesmo se eu assistir de novo agora, provavelmente ainda vou aprender algo novo