Utilisation de Func au lieu d'interfaces pour IoC

15

Contexte: j'utilise C #

J'ai conçu une classe, et afin de l'isoler, et de faciliter les tests unitaires, je passe dans toutes ses dépendances; il ne fait aucune instanciation d'objet en interne. Cependant, au lieu de référencer des interfaces pour obtenir les données dont il a besoin, je l'ai en référence à des fonctions générales renvoyant les données / comportements dont il a besoin. Lorsque j'injecte ses dépendances, je peux simplement le faire avec des expressions lambda.

Pour moi, cela semble être une meilleure approche parce que je n'ai pas à me moquer de moi pendant les tests unitaires. De plus, si l'implémentation environnante a des changements fondamentaux, je n'aurai besoin que de changer la classe d'usine; aucun changement dans la classe contenant la logique ne sera nécessaire.

Cependant, je n'ai jamais vu l'IoC faire de cette façon auparavant, ce qui me fait penser qu'il y a des pièges potentiels que je pourrais manquer. Le seul auquel je peux penser est une incompatibilité mineure avec une version antérieure de C # qui ne définit pas Func, et ce n'est pas un problème dans mon cas.

Existe-t-il des problèmes avec l'utilisation de délégués génériques / de fonctions d'ordre supérieur telles que Func pour IoC, par opposition à des interfaces plus spécifiques?

TheCatWhisperer
la source
2
Ce que vous décrivez sont des fonctions d'ordre supérieur, une facette importante de la programmation fonctionnelle.
Robert Harvey
2
J'utiliserais un délégué au lieu d'un Funcpuisque vous pouvez nommer les paramètres, où vous pouvez indiquer leur intention.
Stefan Hanke
1
en relation: stackoverflow: ioc-factory-pros-and-contras-for-interface-versus-delegates . La question @TheCatWhisperer est plus générale tandis que la question stackoverflow se réduit au cas spécial "usine"
k3b

Réponses:

11

Si une interface ne contient qu'une seule fonction, pas plus, et qu'il n'y a aucune raison impérieuse d'introduire deux noms (le nom de l'interface et le nom de la fonction à l'intérieur de l'interface), l'utilisation d'un à la Funcplace peut éviter le code passe-partout inutile et est dans la plupart des cas préférable - tout comme lorsque vous commencez à concevoir un DTO et que vous reconnaissez qu'il n'a besoin que d'un seul attribut membre.

Je suppose que beaucoup de gens sont plus habitués à utiliser interfaces, car au moment où l'injection de dépendances et l'IoC devenaient populaires, il n'y avait pas vraiment d'équivalent à la Funcclasse en Java ou C ++ (je ne suis même pas sûr si elle Funcétait disponible à ce moment-là en C # ). Donc, beaucoup de tutoriels, d'exemples ou de manuels préfèrent toujours le interfaceformulaire, même si l'utilisation Funcserait plus élégante.

Vous pouvez consulter mon ancienne réponse sur le principe de ségrégation d'interface et l'approche de conception de flux de Ralf Westphal. Ce paradigme implémente DI avec des Funcparamètres exclusivement , pour exactement la même raison que vous avez déjà mentionnée par vous-même (et quelques autres). Donc, comme vous le voyez, votre idée n'est pas vraiment nouvelle, bien au contraire.

Et oui, j'ai utilisé cette approche par moi-même, pour le code de production, pour les programmes qui devaient traiter des données sous la forme d'un pipeline avec plusieurs étapes intermédiaires, y compris des tests unitaires pour chacune des étapes. Je peux donc vous donner une expérience de première main qui peut très bien fonctionner.

Doc Brown
la source
Ce programme n'a même pas été conçu avec le principe de ségrégation d'interface à l'esprit, ils sont simplement utilisés comme classes abstraites. C'est une autre raison pour laquelle j'ai choisi cette approche, les interfaces ne sont pas bien pensées et surtout inutiles car une seule classe les implémente de toute façon. Le principe de la ségrégation des interfaces ne doit pas être poussé à l'extrême, surtout maintenant que nous avons Func, mais dans mon esprit, il est toujours très important.
TheCatWhisperer
1
Ce programme n'a même pas été conçu avec le principe de ségrégation d'interface à l'esprit - eh bien, selon votre description, vous ne saviez probablement pas que le terme pouvait être appliqué à votre type de conception.
Doc Brown
Doc, je faisais référence au code créé par mes prédécesseurs, que je refactorise et dont ma nouvelle classe dépend quelque peu. Une partie de la raison pour laquelle j'ai choisi cette approche est parce que je prévois d'apporter des changements majeurs à d'autres parties du programme à l'avenir, y compris les dépendances de cette classe
TheCatWhisperer
1
"... Au moment où l'injection de dépendances et l'IoC devenaient populaires, il n'y avait pas vraiment d'équivalent à la classe Func" Doc, c'est exactement ce à quoi j'ai presque répondu mais je ne me sentais pas assez en confiance! Merci d'avoir vérifié mes soupçons à ce sujet.
Graham
9

L'un des principaux avantages que je trouve avec l'IoC est qu'il me permettra de nommer toutes mes dépendances en nommant leur interface, et le conteneur saura lequel fournir au constructeur en faisant correspondre les noms de type. C'est pratique et cela permet des noms de dépendances beaucoup plus descriptifs que Func<string, string>.

Je trouve également souvent que même avec une seule dépendance simple, il doit parfois avoir plus d'une fonction - une interface vous permet de regrouper ces fonctions de manière auto-documentée, par opposition à avoir plusieurs paramètres qui se lisent tous comme Func<string, string>, Func<string, int>.

Il y a certainement des moments où il est utile d'avoir simplement un délégué qui est passé en tant que dépendance. C'est une question de jugement quant à l'utilisation d'un délégué par rapport à une interface avec très peu de membres. À moins qu'il ne soit vraiment clair quel est le but de l'argument, je me trompe généralement du côté de la production de code auto-documenté; c'est à dire. écrire une interface.

Erik
la source
J'ai dû déboguer le code de quelqu'un, lorsque l'implémentateur d'origine n'était plus là, et je peux vous dire par première expérience, que trouver quel lambda est exécuté là où la pile est un gros travail et un processus vraiment frustrant. Si l'implémenteur d'origine avait utilisé des interfaces, cela aurait été un jeu d'enfant de trouver le bogue que je cherchais.
cwap
cwap, je sens ta douleur. Cependant, dans mon implémentation particulière, les lambdas sont tous des doublons pointant vers une seule fonction. Ils sont également tous générés en un seul endroit.
TheCatWhisperer
D'après mon expérience, ce n'est qu'une question d'outillage. ReSharpers Inspect-> Appels entrants fait un bon travail en suivant le chemin vers le haut des funcs / lambdas.
Christian
3

Existe-t-il des problèmes avec l'utilisation de délégués génériques / de fonctions d'ordre supérieur telles que Func pour IoC, par opposition à des interfaces plus spécifiques?

Pas vraiment. Un Func est son propre type d'interface (au sens anglais, pas au sens C #). "Ce paramètre est quelque chose qui fournit X à la demande." Le Func a même l'avantage de fournir paresseusement les informations uniquement selon les besoins. Je le fais un peu et le recommande avec modération.

Quant aux inconvénients:

  • Les conteneurs IoC font souvent de la magie pour câbler les dépendances d'une manière en cascade, et ne joueront probablement pas bien quand certaines choses sont Tet d'autres le sont Func<T>.
  • Les funcs ont une certaine indirection, il peut donc être un peu plus difficile de raisonner et de déboguer.
  • Les fonctions retardent l'instanciation, ce qui signifie que des erreurs d'exécution peuvent apparaître à des moments étranges ou pas du tout pendant les tests. Il peut également augmenter les chances de problèmes d'ordre des opérations et selon votre utilisation, des blocages dans l'ordre d'initialisation.
  • Ce que vous passez dans le Func est probablement une fermeture, avec les frais généraux légers et les complications que cela implique.
  • Appeler un Func est un peu plus lent que d'accéder directement à l'objet. (Pas assez que vous remarquerez dans un programme non trivial, mais il est là)
Telastyn
la source
1
J'utilise le conteneur IoC pour créer l'usine de manière traditionnelle, puis l'usine empaquette les méthodes d'interfaces en lambdas, contournant le problème que vous avez mentionné dans votre premier point. Bons points.
TheCatWhisperer
1

Prenons un exemple simple - vous injectez peut-être un moyen de journalisation.

Injecter une classe

class Worker: IWorker
{
    ILogger _logger;

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

Je pense que c'est assez clair ce qui se passe. De plus, si vous utilisez un conteneur IoC, vous n'avez même pas besoin d'injecter quoi que ce soit explicitement, vous ajoutez simplement à votre racine de composition:

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

Lors du débogage Worker, un développeur n'a qu'à consulter la racine de la composition pour déterminer quelle classe concrète est utilisée.

Si un développeur a besoin d'une logique plus compliquée, il a toute l'interface avec laquelle travailler:

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

Injection d'une méthode

class Worker
{
    Action<string> _methodThatLogs;

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

Tout d' abord, notez que le paramètre constructeur a un nom plus maintenant, methodThatLogs. Ceci est nécessaire car vous ne pouvez pas dire ce que vous êtes Action<string>censé faire. Avec l'interface, c'était complètement clair, mais ici, nous devons recourir à la dénomination des paramètres. Cela semble intrinsèquement moins fiable et plus difficile à appliquer lors d'une génération.

Maintenant, comment injectons-nous cette méthode? Eh bien, le conteneur IoC ne le fera pas pour vous. Il vous reste donc à l'injecter explicitement lorsque vous instanciez Worker. Cela soulève quelques problèmes:

  1. C'est plus de travail pour instancier un Worker
  2. Les développeurs qui tentent de déboguer Workertrouveront qu'il est plus difficile de déterminer quelle instance concrète est appelée. Ils ne peuvent pas simplement consulter la racine de la composition; ils devront tracer à travers le code.

Et si nous avions besoin d'une logique plus compliquée? Votre technique n'expose qu'une seule méthode. Maintenant, je suppose que vous pouvez faire cuire les choses compliquées dans le lambda:

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

mais quand vous écrivez vos tests unitaires, comment testez-vous cette expression lambda? Il est anonyme, donc votre framework de test unitaire ne peut pas l'instancier directement. Peut-être que vous pouvez trouver un moyen intelligent de le faire, mais ce sera probablement un PITA plus grand que d'utiliser une interface.

Résumé des différences:

  1. Injecter uniquement une méthode rend plus difficile à déduire le but, tandis qu'une interface communique clairement le but.
  2. L'injection d'une méthode uniquement expose moins de fonctionnalités à la classe qui reçoit l'injection. Même si vous n'en avez pas besoin aujourd'hui, vous en aurez peut-être besoin demain.
  3. Vous ne pouvez pas injecter automatiquement uniquement une méthode à l'aide d'un conteneur IoC.
  4. Vous ne pouvez pas dire à partir de la racine de composition quelle classe concrète est à l'œuvre dans une instance particulière.
  5. Il est difficile de tester uniquement l'expression lambda elle-même.

Si vous êtes d'accord avec tout ce qui précède, vous pouvez injecter uniquement la méthode. Sinon, je vous suggère de vous en tenir à la tradition et d'injecter une interface.

John Wu
la source
J'aurais dû préciser, la classe que je fais est une classe de logique de processus. Il s'appuie sur des classes externes pour obtenir les données dont il a besoin pour prendre une décision éclairée, aucun effet induisant une classe telle qu'un enregistreur n'est utilisé. Un problème avec votre exemple est qu'il s'agit d'une mauvaise OO, en particulier dans le contexte de l'utilisation d'un conteneur IoC. Au lieu d'utiliser une instruction if, qui ajoute une complexité supplémentaire, à la classe, il convient simplement de lui transmettre un enregistreur inerte. De plus, l'introduction de la logique dans les lambdas rendrait en effet le test plus difficile ... ce qui irait à l'encontre de l'objectif de son utilisation, c'est pourquoi il n'est pas fait.
TheCatWhisperer
Les lambdas pointent vers une méthode d'interface dans l'application et construisent des structures de données arbitraires dans les tests unitaires.
TheCatWhisperer
Pourquoi les gens se concentrent-ils toujours sur les détails de ce qui est évidemment un exemple arbitraire? Eh bien, si vous insistez pour parler d'une journalisation appropriée, un enregistreur inerte serait une idée terrible dans certains cas, par exemple lorsque la chaîne à consigner est coûteuse à calculer.
John Wu
Je ne suis pas sûr de ce que vous entendez par «point lambdas vers une méthode d'interface». Je pense qu'il faudrait injecter une implémentation d'une méthode qui correspond à la signature du délégué. Si la méthode appartient à une interface, c'est accessoire; il n'y a pas de vérification de compilation ou d'exécution pour s'assurer que c'est le cas. Suis-je incompréhensible? Peut-être pourriez-vous inclure un exemple de code dans votre message?
John Wu
Je ne peux pas dire que je suis d'accord avec vos conclusions ici. D'une part, vous devez coupler votre classe à la quantité minimale de dépendances, donc l'utilisation de lambdas garantit que chaque élément injecté est exactement une dépendance et non une fusion de plusieurs choses dans une interface. Vous pouvez également prendre en charge un modèle de création d'une Workerdépendance viable à la fois, permettant à chaque lambda d'être injecté indépendamment jusqu'à ce que toutes les dépendances soient satisfaites. Ceci est similaire à une application partielle dans FP. De plus, l'utilisation de lambdas permet d'éliminer l'état implicite et le couplage caché entre les dépendances.
Aaron M. Eshbach
0

Considérez le code suivant que j'ai écrit il y a longtemps:

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;
    }
}

Il prend un emplacement relatif dans un fichier modèle, le charge en mémoire, rend le corps du message et assemble l'objet de messagerie.

Vous pourriez regarder IPhysicalPathMapperet penser: "Il n'y a qu'une seule fonction. Cela pourrait être un Func." Mais en réalité, le problème ici est que cela IPhysicalPathMapperne devrait même pas exister. Une bien meilleure solution consiste à simplement paramétrer le chemin :

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;
    }
}

Cela soulève une foule d'autres questions pour améliorer ce code. Par exemple, peut-être qu'il devrait simplement accepter un EmailTemplate, puis peut-être qu'il devrait accepter un modèle pré-rendu, et alors peut-être qu'il devrait simplement être aligné.

C'est pourquoi je n'aime pas l'inversion du contrôle comme modèle omniprésent. Il est généralement considéré comme cette solution divine pour composer tout votre code. Mais en réalité, si vous l'utilisez de manière omniprésente (plutôt que parcimonieuse), cela aggrave votre code en vous encourageant à introduire de nombreuses interfaces inutiles qui sont utilisées complètement à l'envers. (En arrière dans le sens où l'appelant devrait vraiment être responsable de l'évaluation de ces dépendances et de la transmission du résultat plutôt que de la classe elle-même appelant l'appel.)

Les interfaces doivent être utilisées avec parcimonie, et l'inversion du contrôle et l'injection de dépendance doivent également être utilisées avec parcimonie. Si vous en avez de grandes quantités, votre code devient beaucoup plus difficile à déchiffrer.

jpmc26
la source