Usando Func em vez de interfaces para IoC

14

Contexto: estou usando c #

Projetei uma classe e, para isolá-la e facilitar o teste de unidade, estou passando em todas as suas dependências; não faz instanciação de objeto internamente. No entanto, em vez de fazer referência a interfaces para obter os dados de que precisa, eu faço referência a Funcs de uso geral retornando os dados / comportamento necessários. Quando injeto suas dependências, posso apenas fazê-lo com expressões lambda.

Para mim, essa parece ser uma abordagem melhor, porque não preciso fazer nenhuma zombaria tediosa durante o teste de unidade. Além disso, se a implementação circundante tiver alterações fundamentais, precisarei apenas alterar a classe da fábrica; nenhuma alteração na classe que contém a lógica será necessária.

No entanto, nunca vi a IoC dessa maneira antes, o que me faz pensar que existem algumas armadilhas em potencial que podem estar faltando. O único em que consigo pensar é uma pequena incompatibilidade com a versão anterior do C #, que não define o Func, e isso não é um problema no meu caso.

Existem problemas com o uso de delegados genéricos / funções de ordem superior, como Func para IoC, em oposição a interfaces mais específicas?

TheCatWhisperer
fonte
2
O que você está descrevendo são funções de ordem superior, uma faceta importante da programação funcional.
31719 Robert Harvey
2
Eu usaria um delegado em vez de um, Funcpois você pode nomear os parâmetros, onde você pode indicar a intenção deles.
Stefan Hanke
1
relacionados: stackoverflow: ioc-factory-pros-e-contras-para-interface-contra-delegados . @TheCatWhisperer questão é mais geral, enquanto a questão stackoverflow se reduz ao caso especial "fábrica"
k3b

Respostas:

11

Se uma interface contiver apenas uma função, não mais, e não houver motivo convincente para introduzir dois nomes (o nome da interface e o nome da função dentro da interface), o uso de um Funcpode evitar um código desnecessário e, na maioria dos casos, é preferível - assim como quando você começa a criar um DTO e reconhece que ele precisa de apenas um atributo de membro.

Acho que muitas pessoas estão mais acostumadas a usar interfaces, porque no momento em que a injeção de dependência e a IoC estavam ficando populares, não havia um equivalente real à Funcclasse em Java ou C ++ (nem tenho certeza se Funcestava disponível naquele momento em C # ) Portanto, muitos tutoriais, exemplos ou manuais ainda preferem o interfaceformulário, mesmo que o uso Funcseja mais elegante.

Você pode examinar minha resposta anterior sobre o princípio de segregação de interface e a abordagem de Flow Design de Ralf Westphal. Esse paradigma implementa DI com Funcparâmetros exclusivamente , exatamente pelo mesmo motivo que você já mencionou por si mesmo (e mais alguns). Então, como você vê, sua ideia não é realmente nova, muito pelo contrário.

E sim, usei essa abordagem sozinho, para código de produção, para programas que precisavam processar dados na forma de um pipeline com várias etapas intermediárias, incluindo testes de unidade para cada uma das etapas. Então, com isso, posso lhe dar uma experiência em primeira mão que pode funcionar muito bem.

Doc Brown
fonte
Este programa nem foi projetado com o princípio de segregação de interface em mente, eles são basicamente usados ​​apenas como classes abstratas. Essa é outra razão pela qual eu segui essa abordagem: as interfaces não são bem pensadas e geralmente são inúteis, pois apenas uma classe as implementa de qualquer maneira. O princípio de segregação de interface não precisa ser levado ao extremo, especialmente agora que temos o Func, mas, em minha opinião, ainda é muito importante.
TheCatWhisperer
1
Esse programa nem foi projetado com o princípio de segregação de interface em mente - bem, de acordo com sua descrição, você provavelmente não estava ciente de que o termo poderia ser aplicado ao seu tipo de design.
Doc Brown
Doc, eu estava me referindo ao código feito por meus antecessores, que estou refatorando, e do qual minha nova classe é um tanto dependente. Parte da razão que eu fui com esta abordagem é porque eu planeja fazer grandes mudanças para outras partes do programa no futuro, incluindo dependências desta classe
TheCatWhisperer
1
"... No momento em que a injeção de dependência e a IoC estavam ficando populares, não havia um equivalente real à classe Func" Doc, é exatamente o que eu quase respondi, mas não me senti suficientemente confiante! Obrigado por verificar minha suspeita sobre isso.
Graham
9

Uma das principais vantagens que encontro com a IoC é que ela permitirá que eu nomeie todas as minhas dependências nomeando sua interface, e o contêiner saberá qual fornecer ao construtor, combinando os nomes dos tipos. Isso é conveniente e permite nomes muito mais descritivos de dependências do que Func<string, string>.

Também acho frequentemente que, mesmo com uma dependência única e simples, às vezes ela precisa ter mais de uma função - uma interface permite agrupar essas funções de uma maneira que seja auto-documentada, em comparação com vários parâmetros que são todos parecidos Func<string, string> , Func<string, int>.

Definitivamente, há momentos em que é útil simplesmente ter um delegado que é passado como uma dependência. É uma decisão sobre quando você usa um delegado versus possui uma interface com muito poucos membros. A menos que seja realmente claro qual é o objetivo do argumento, geralmente errei ao produzir código de auto-documentação; ie escrevendo uma interface.

Erik
fonte
Eu tive que depurar o código de alguém, quando o implementador original não estava mais lá, e posso dizer, desde a primeira experiência, que descobrir qual lambda é executado, onde a pilha é muito trabalhosa e um processo realmente frustrante. Se o implementador original usasse interfaces, teria sido fácil encontrar o bug que eu estava procurando.
Cwap
cwap, sinto sua dor. No entanto, em minha implementação específica, os lambdas são todos liners que apontam para uma única função. Eles também são todos gerados em um único local.
TheCatWhisperer
3

Existem problemas com o uso de delegados genéricos / funções de ordem superior, como Func para IoC, em oposição a interfaces mais específicas?

Na verdade não. Um Func é seu próprio tipo de interface (no significado em inglês, não no significado de C #). "Este parâmetro é algo que fornece X quando solicitado." O Func ainda tem o benefício de fornecer preguiçosamente as informações apenas quando necessário. Faço isso um pouco e recomendo com moderação.

Quanto às desvantagens:

  • Os contêineres de IoC costumam fazer alguma mágica para conectar dependências de uma maneira em cascata e provavelmente não terão um bom desempenho quando algumas coisas estiverem acontecendo. T e outras estão Func<T>.
  • Os funcs têm alguma indireção, portanto pode ser um pouco mais difícil de raciocinar e depurar.
  • Os funis atrasam a instanciação, o que significa que erros de tempo de execução podem aparecer em momentos estranhos ou não durante o teste. Também pode aumentar as chances de problemas de ordem das operações e, dependendo do seu uso, bloqueios na ordem de inicialização.
  • O que você passa para o Func provavelmente será um fechamento, com as pequenas despesas gerais e complicações que isso implica.
  • Chamar um Func é um pouco mais lento do que acessar o objeto diretamente. (Não é o suficiente para você notar em qualquer programa não trivial, mas existe)
Telastyn
fonte
1
Eu uso o IoC Container para criar a fábrica de maneira tradicional e, em seguida, a fábrica empacota os métodos de interfaces em lambdas, contornando o problema que você mencionou em seu primeiro ponto. Bons pontos.
TheCatWhisperer
1

Vamos dar um exemplo simples - talvez você esteja injetando um meio de registro.

Injetando uma classe

class Worker: IWorker
{
    ILogger _logger;

    Worker(ILogger logger)
    {
        _logger = logger;
    }
    void SomeMethod()
    {
        _logger.Debug("This is a debug log statement.");
    }
}        

Eu acho que está bem claro o que está acontecendo. Além disso, se você estiver usando um contêiner de IoC, nem precisará injetar nada explicitamente, basta adicionar à raiz da composição:

container.RegisterType<ILogger, ConcreteLogger>();
container.RegisterType<IWorker, Worker>();
....
var worker = container.Resolve<IWorker>();

Ao depurar Worker , um desenvolvedor precisa apenas consultar a raiz da composição para determinar qual classe concreta está sendo usada.

Se um desenvolvedor precisar de uma lógica mais complicada, ele terá toda a interface para trabalhar:

    void SomeMethod()
    { 
       if (_logger.IsDebugEnabled) {
           _logger.Debug("This is a debug log statement.");
       }
    }

Injetando um método

class Worker
{
    Action<string> _methodThatLogs;

    Worker(Action<string> methodThatLogs)
    {
        _methodThatLogs = methodThatLogs;
    }
    void SomeMethod()
    {
        _methodThatLogs("This is a logging statement");
    }
}        

Primeiro, observe que o parâmetro construtor tem um nome mais longo agora methodThatLogs,. Isso é necessário porque você não pode dizer o que Action<string>é suposto fazer. Com a interface, ficou completamente claro, mas aqui precisamos recorrer à nomeação de parâmetros. Isso parece inerentemente menos confiável e mais difícil de aplicar durante uma compilação.

Agora, como injetamos esse método? Bem, o contêiner de IoC não fará isso por você. Então você fica injetando explicitamente quando você instancia Worker. Isso levanta alguns problemas:

  1. É mais trabalho instanciar um Worker
  2. Os desenvolvedores que tentarem depurar Workerdescobrirão que é mais difícil descobrir como uma instância concreta é chamada. Eles não podem simplesmente consultar a raiz da composição; eles terão que rastrear o código.

Que tal se precisarmos de lógica mais complicada? Sua técnica expõe apenas um método. Agora, suponho que você possa assar coisas complicadas no lambda:

var worker = new Worker((s) => { if (log.IsDebugEnabled) log.Debug(s) } );

mas quando você está escrevendo seus testes de unidade, como você testa essa expressão lambda? É anônimo, portanto sua estrutura de teste de unidade não pode instanciar diretamente. Talvez você possa descobrir uma maneira inteligente de fazer isso, mas provavelmente será uma PITA maior do que usar uma interface.

Resumo das diferenças:

  1. Injetar apenas um método dificulta inferir o objetivo, enquanto uma interface comunica claramente o objetivo.
  2. Injetar apenas um método expõe menos funcionalidade à classe que recebe a injeção. Mesmo se você não precisar dele hoje, poderá precisar dele amanhã.
  3. Você não pode injetar automaticamente apenas um método usando um contêiner de IoC.
  4. Você não pode distinguir da raiz da composição qual classe concreta está funcionando em uma instância específica.
  5. É um problema fazer o teste de unidade da própria expressão lambda.

Se você estiver bem com todas as opções acima, não há problema em injetar apenas o método. Caso contrário, sugiro que você siga a tradição e injete uma interface.

John Wu
fonte
Eu deveria ter especificado, a classe que estou fazendo é uma classe de lógica de processo. Ele conta com classes externas para obter os dados necessários para tomar uma decisão informada, um efeito colateral indutor de classe que esse criador de logs não é usado. Um problema com o seu exemplo é que ele é pobre em OO, especialmente no contexto do uso de um contêiner de IoC. Em vez de usar uma instrução if, que adiciona complexidade adicional à classe, ela deve simplesmente passar um log inerte. Além disso, a introdução de lógica nas lambdas realmente tornaria o teste mais difícil ... derrotando o objetivo de seu uso, e é por isso que não é feito.
TheCatWhisperer
As lambdas apontam para um método de interface no aplicativo e constroem estruturas de dados arbitrárias em testes de unidade.
TheCatWhisperer
Por que as pessoas sempre se concentram nas minúcias do que é obviamente um exemplo arbitrário? Bem, se você insistir em falar sobre o registro adequado, um registrador inerte seria uma péssima idéia em alguns casos, por exemplo, quando a cadeia a ser registrada é cara de calcular.
John Wu
Não sei ao certo o que você quer dizer com "lambdas aponta para um método de interface". Eu acho que você teria que injetar uma implementação de um método que corresponda à assinatura do delegado. Se o método pertencer a uma interface, isso é acidental; não há verificação de compilação ou tempo de execução para garantir isso. Estou entendendo mal? Talvez você possa incluir um exemplo de código em sua postagem?
John Wu
Não posso dizer que concordo com suas conclusões aqui. Por um lado, você deve associar sua classe à quantidade mínima de dependências; portanto, o uso de lambdas garante que cada item injetado seja exatamente uma dependência e não uma combinação de várias coisas em uma interface. Você também pode suportar um padrão de construção de Workeruma dependência viável por vez, permitindo que cada lambda seja injetado independentemente até que todas as dependências sejam atendidas. Isso é semelhante à aplicação parcial no FP. Além disso, o uso de lambdas ajuda a eliminar o estado implícito e o acoplamento oculto entre dependências.
Aaron M. Eshbach
0

Considere o seguinte código que escrevi há muito tempo:

public interface IPhysicalPathMapper
{
    /// <summary>
    /// Gets the physical path represented by the relative URL.
    /// </summary>
    /// <param name="relativeURL"></param>
    /// <returns></returns>
    String GetPhysicalPath(String relativeURL);
}

public class EmailBuilder : IEmailBuilder
{
    public IPhysicalPathMapper PhysicalPathMapper { get; set; }
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templateRelativeURL, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(this.PhysicalPathMapper.GetPhysicalPath(templateRelativeURL));

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

Leva um local relativo para um arquivo de modelo, carrega-o na memória, renderiza o corpo da mensagem e monta o objeto de email.

Você pode olhar IPhysicalPathMappere pensar: "Existe apenas uma função. Essa pode ser uma Func". Mas, na verdade, o problema aqui é que IPhysicalPathMappernem deveria existir. Uma solução muito melhor é apenas parametrizar o caminho :

public class EmailBuilder : IEmailBuilder
{
    public ITextFileLoader TextFileLoader { get; set; }
    public IEmailTemplateParser EmailTemplateParser { get; set; }
    public IEmaiBodyRenderer EmailBodyRenderer { get; set; }

    public String FromAddress { get; set; }

    public MailMessage BuildMailMessage(String templatePath, Object model, IEnumerable<String> toAddresses)
    {
        String templateText = this.TextFileLoader.LoadTextFromFile(templatePath);

        EmailTemplate template = this.EmailTemplateParser.Parse(templateText);

        MailMessage email = new MailMessage()
        {
            From = new MailAddress(this.FromAddress),
            Subject = template.Subject,
            IsBodyHtml = true,
            Body = this.EmailBodyRenderer.RenderBodyToHtml(template.BodyTemplate, model)
        };

        foreach (MailAddress recipient in toAddresses.Select<String, MailAddress>(toAddress => new MailAddress(toAddress)))
        {
            email.To.Add(recipient);
        }

        return email;
    }
}

Isso levanta uma série de outras questões para melhorar esse código. Por exemplo, talvez deva apenas aceitar umEmailTemplate e, em seguida, talvez ele deva aceitar um modelo pré-renderizado, e então talvez ele deva estar alinhado.

É por isso que não gosto da inversão de controle como um padrão generalizado. Geralmente é considerada uma solução divina para compor todo o seu código. Mas, na realidade, se você o usar amplamente (em vez de moderadamente), isso tornará seu código muito pior , incentivando a introdução de muitas interfaces desnecessárias usadas completamente para trás. (Retrocedendo no sentido de que o responsável pela chamada deve realmente ser responsável por avaliar essas dependências e transmitir o resultado, em vez de a própria classe chamar a chamada.)

As interfaces devem ser usadas com moderação e a inversão do controle e injeção de dependência também deve ser usada com moderação. Se você tiver grandes quantidades delas, seu código se tornará muito mais difícil de decifrar.

jpmc26
fonte