A Programação Funcional é uma alternativa viável aos padrões de injeção de dependência?

21

Recentemente, tenho lido um livro intitulado Programação Funcional em C # e me ocorre que a natureza imutável e sem estado da programação funcional obtém resultados semelhantes aos padrões de injeção de dependência e é possivelmente uma abordagem ainda melhor, especialmente em relação ao teste de unidade.

Eu ficaria grato se alguém que tenha experiência com ambas as abordagens pudesse compartilhar seus pensamentos e experiências para responder à pergunta principal: a Programação Funcional é uma alternativa viável aos padrões de injeção de dependência?

Matt Cashatt
fonte
10
Isso não faz muito sentido para mim, a imutabilidade não remove dependências.
Telastyn
Concordo que não remove dependências. Provavelmente, meu entendimento está incorreto, mas fiz essa inferência porque, se não posso alterar o objeto original, é necessário que eu o transmita (injete) para qualquer função que faça uso dele.
Matt Cashatt
5
Há também Como enganar os programadores de OO para amar a programação funcional , que é realmente uma análise detalhada da DI, tanto da perspectiva do OO quanto do FP.
Robert Harvey
1
Essa pergunta, os artigos aos quais se vincula e a resposta aceita também podem ser úteis: stackoverflow.com/questions/11276319/… Ignore a palavra assustadora da Mônada. Como Runar aponta em sua resposta, não é um conceito complexo neste caso (apenas uma função).
itsbruce

Respostas:

27

O gerenciamento de dependências é um grande problema no OOP pelos dois motivos a seguir:

  • O forte acoplamento de dados e código.
  • Uso onipresente de efeitos colaterais.

A maioria dos programadores de OO considera o acoplamento rígido de dados e código totalmente benéfico, mas isso tem um custo. Gerenciar o fluxo de dados através das camadas é uma parte inevitável da programação em qualquer paradigma. O acoplamento de seus dados e código adiciona o problema adicional de que, se você quiser usar uma função em um determinado momento, precisará encontrar uma maneira de levar seu objeto a esse ponto.

O uso de efeitos colaterais cria dificuldades semelhantes. Se você usa um efeito colateral para algumas funcionalidades, mas deseja poder trocar sua implementação, praticamente não há outra opção a não ser injetar essa dependência.

Considere como exemplo um programa de spammer que rastreia páginas da Web em busca de endereços de e-mail e as envia por e-mail. Se você tem uma mentalidade de DI, agora está pensando nos serviços que irá encapsular atrás das interfaces e quais serviços serão injetados onde. Vou deixar esse design como um exercício para o leitor. Se você tem uma mentalidade de FP, agora está pensando nas entradas e saídas da camada mais baixa de funções, como:

  • Insira um endereço de página da web, produza o texto dessa página.
  • Insira o texto de uma página, produza uma lista de links dessa página.
  • Insira o texto de uma página, produza uma lista de endereços de email nessa página.
  • Insira uma lista de endereços de email, produza uma lista de endereços de email com duplicatas removidas.
  • Insira um endereço de email, envie um email de spam para esse endereço.
  • Insira um email de spam, produza os comandos SMTP para enviar esse email.

Quando você pensa em termos de entradas e saídas, não há dependências de funções, apenas dependências de dados. É isso que os torna tão fáceis de realizar testes unitários. Sua próxima camada organiza a saída de uma função para ser inserida na entrada da próxima e pode facilmente trocar as várias implementações, conforme necessário.

Em um sentido muito real, a programação funcional naturalmente o incentiva a sempre inverter suas dependências de funções e, portanto, você normalmente não precisa tomar nenhuma medida especial para fazê-lo após o fato. Quando você faz isso, ferramentas como funções de ordem superior, fechamentos e aplicativos parciais facilitam a realização com menos clichês.

Observe que não são as próprias dependências que são problemáticas. São as dependências que apontam para o lado errado. A próxima camada acima pode ter uma função como:

processText = spamToSMTP . emailAddressToSpam . removeEmailDups . textToEmailAddresses

É perfeitamente aceitável que essa camada tenha dependências codificadas dessa maneira, porque seu único objetivo é colar as funções da camada inferior. Trocar uma implementação é tão simples quanto criar uma composição diferente:

processTextFancy = spamToSMTP . emailAddressToFancySpam . removeEmailDups . textToEmailAddresses

Essa recomposição fácil é possível devido à falta de efeitos colaterais. As funções da camada inferior são completamente independentes uma da outra. A próxima camada acima pode escolher qual processTexté realmente usada com base em algumas configurações do usuário:

actuallyUsedProcessText = if (config == "Fancy") then processTextFancy else processText

Novamente, não é um problema, porque todas as dependências apontam para um caminho. Não precisamos inverter algumas dependências para que todas apontem da mesma maneira, porque funções puras já nos forçaram a fazê-lo.

Observe que você pode tornar isso muito mais acoplado passando configpara a camada mais baixa, em vez de verificá-la na parte superior. O FP não impede que você faça isso, mas tende a torná-lo muito mais irritante se você tentar.

Karl Bielefeldt
fonte
3
"O uso de efeitos colaterais cria dificuldades semelhantes. Se você usa um efeito colateral para algumas funcionalidades, mas deseja trocar sua implementação, praticamente não há outra opção a não ser injetar essa dependência." Eu não acho que os efeitos colaterais tenham algo a ver com isso. Se você deseja trocar implementações no Haskell, ainda precisa fazer a injeção de dependência . Descompacte as classes de tipo e você passará por uma interface como o primeiro argumento para todas as funções.
Doval
2
O cerne da questão é que quase todas as linguagens obrigam você a codificar referências para outros módulos de código; portanto, a única maneira de trocar implementações é usar o envio dinâmico em qualquer lugar e, em seguida, você fica parado resolvendo suas dependências no tempo de execução. Um sistema de módulos permite expressar o gráfico de dependência no momento da verificação do tipo.
Doval
@ Doval - Obrigado por seus comentários interessantes e instigantes. Talvez eu tenha entendido mal você, mas estou correto ao deduzir dos seus comentários que se eu usasse um estilo funcional de programação em um estilo DI (no sentido tradicional de C #), evitaria possíveis frustrações de depuração associadas ao tempo de execução resolução de dependências?
Matt Cashatt
@MatthewPatrickCashatt Não é uma questão de estilo ou paradigma, mas de recursos de linguagem. Se a linguagem não suportar módulos como itens de primeira classe, você precisará executar despacho dinâmico e injeção de dependência para trocar implementações, porque não há como expressar as dependências estaticamente. Em outras palavras, se o seu programa C # usa seqüências de caracteres, ele tem uma dependência codificada System.String. Um sistema de módulos permite substituir System.Stringpor uma variável, para que a escolha da implementação da cadeia não seja codificada, mas ainda seja resolvida no momento da compilação.
Doval
8

A Programação Funcional é uma alternativa viável aos padrões de injeção de dependência?

Isso me parece uma pergunta estranha. As abordagens de programação funcional são em grande parte tangenciais à injeção de dependência.

Certamente, ter um estado imutável pode fazer com que você não "trapaceie" tendo efeitos colaterais ou usando o estado de classe como um contrato implícito entre funções. Isso torna a passagem de dados mais explícita, o que suponho ser a forma mais básica de injeção de dependência. E o conceito de programação funcional de passar funções facilita muito isso.

Mas isso não remove dependências. Suas operações ainda precisam de todos os dados / operações de que precisavam quando seu estado era mutável. E você ainda precisa obter essas dependências de alguma forma. Portanto, eu não diria que as abordagens de programação funcional substituem a DI, portanto, não há nenhum tipo de alternativa.

Na verdade, eles acabaram de mostrar o quão ruim o código OO pode criar dependências implícitas do que os programadores raramente pensam.

Telastyn
fonte
Mais uma vez obrigado por contribuir com a conversa, Telastyn. Como você apontou, minha pergunta não é muito bem construída (minhas palavras), mas, graças ao feedback aqui, estou começando a entender um pouco melhor o que está provocando em meu cérebro tudo isso: todos concordamos (Eu acho) que o teste de unidade pode ser um pesadelo sem DI. Infelizmente, o uso de DI, especialmente com contêineres IoC, pode criar uma nova forma de pesadelo de depuração, graças ao fato de resolver dependências em tempo de execução. Semelhante ao DI, o FP facilita o teste de unidade, mas sem os problemas de dependência do tempo de execução.
Matt Cashatt
(continua de cima). . .Este é o meu entendimento atual de qualquer maneira. Informe-me se estiver faltando a marca. Não me importo em admitir que sou um mero mortal entre gigantes aqui!
Matt Cashatt
@MatthewPatrickCashatt - DI não implica necessariamente problemas de dependência de tempo de execução, que, como você observa, são horríveis.
Telastyn 11/03/2015
7

A resposta rápida para sua pergunta é: Não .

Mas, como outros afirmaram, a questão se casa com dois conceitos, um pouco não relacionados.

Vamos fazer isso passo a passo.

DI resulta em estilo não funcional

No núcleo da programação de funções, existem funções puras - funções que mapeiam a entrada para a saída, para que você sempre obtenha a mesma saída para uma determinada entrada.

O DI normalmente significa que sua unidade não é mais pura, pois a saída pode variar dependendo da injeção. Por exemplo, na seguinte função:

const bookSeats = ( seatCount, getBookedSeatCount ) => { ... }

getBookedSeatCount(uma função) pode variar, produzindo resultados diferentes para a mesma entrada. Isto fazbookSeats impuro.

Há exceções para isso - você pode injetar um dos dois algoritmos de classificação que implementam o mesmo mapeamento de entrada e saída, embora usando algoritmos diferentes. Mas essas são exceções.

Um sistema não pode ser puro

O fato de um sistema não poder ser puro é igualmente ignorado, pois é afirmado em fontes de programação funcional.

Um sistema deve ter efeitos colaterais, com os exemplos óbvios sendo:

  • UI
  • Base de dados
  • API (na arquitetura cliente-servidor)

Portanto, parte do seu sistema deve envolver efeitos colaterais e essa parte também pode envolver estilo imperativo ou estilo OO.

O paradigma do núcleo da casca

Tomando emprestado os termos da excelente conversa de Gary Bernhardt sobre limites , uma boa arquitetura de sistema (ou módulo) incluirá essas duas camadas:

  • Testemunho
    • Funções puras
    • Ramificação
    • Sem dependências
  • Concha
    • Impuro (efeitos colaterais)
    • Sem ramificação
    • Dependências
    • Pode ser imperativo, envolver o estilo OO, etc.

O principal argumento é "dividir" o sistema em sua parte pura (o núcleo) e a parte impura (a concha).

Embora ofereça uma solução (e conclusão) levemente falha, este artigo de Mark Seemann propõe o mesmo conceito. A implementação do Haskell é particularmente interessante, pois mostra que tudo pode ser feito usando o FP.

DI e FP

O emprego da DI é perfeitamente razoável, mesmo que a maior parte do seu aplicativo seja pura. A chave é confinar o DI dentro da concha impura.

Um exemplo serão os stubs da API - você deseja a API real em produção, mas usa stubs nos testes. A adesão ao modelo do núcleo da casca ajudará bastante aqui.

Conclusão

Então FP e DI não são exatamente alternativas. É provável que você tenha ambos no seu sistema, e o conselho é garantir a separação entre a parte pura e impura do sistema, onde FP e DI residem respectivamente.

Izhaki
fonte
Quando você se refere ao paradigma do núcleo do shell, como não seria possível obter ramificação no shell? Posso pensar em muitos exemplos em que um aplicativo precisaria fazer uma coisa impura ou outra com base em um valor. Esta regra de não ramificação é aplicável em linguagens como Java?
jrahhali 5/02
@jrahhali Consulte a palestra de Gary Bernhardt para obter detalhes (link na resposta).
Izhaki 5/02
outra série Seemann relevante blog.ploeh.dk/2017/01/27/…
jk.
1

Do ponto de vista da OOP, as funções podem ser consideradas interfaces de método único.

Interface é um contrato mais forte que uma função.

Se você estiver usando uma abordagem funcional e fizer muita DI, em comparação ao uso de uma abordagem OOP, obterá mais candidatos para cada dependência.

void DoStuff(Func<DateTime> getDateTime) {}; //Anything that satisfies the signature can be injected.

vs

void DoStuff(IDateTimeProvider dateTimeProvider) {}; //Only types implementing the interface can be injected.
Den
fonte
3
Qualquer classe pode ser agrupada para implementar a interface, para que o "contrato mais forte" não seja muito mais forte. Mais importante, atribuir a cada função um tipo diferente impossibilita a composição da função.
Doval
Programação funcional não significa "Programação com funções de ordem superior", refere-se a um conceito muito mais amplo; funções de ordem superior são apenas uma técnica útil no paradigma.
Jimmy Hoffa