Verwenden von Func anstelle von Schnittstellen für IoC

14

Kontext: Ich verwende C #

Ich habe eine Klasse entworfen, und um sie zu isolieren und das Testen von Einheiten zu vereinfachen, übergebe ich alle Abhängigkeiten. Intern wird keine Objektinstanziierung durchgeführt. Anstatt auf Schnittstellen zu verweisen, um die benötigten Daten zu erhalten, verweise ich auf allgemeine Funktionen, die die erforderlichen Daten / das erforderliche Verhalten zurückgeben. Wenn ich seine Abhängigkeiten einspeise, kann ich dies einfach mit Lambda-Ausdrücken tun.

Für mich scheint dies ein besserer Ansatz zu sein, da ich während des Unit-Tests keine langweiligen Verspottungen anstellen muss. Wenn die umgebende Implementierung grundlegende Änderungen aufweist, muss ich nur die Factory-Klasse ändern. Es sind keine Änderungen in der Klasse mit der Logik erforderlich.

Ich habe jedoch noch nie zuvor gesehen, dass IoC auf diese Weise durchgeführt wird, was mich zu der Annahme veranlasst, dass es einige potenzielle Fallstricke gibt, die mir möglicherweise fehlen. Das Einzige, an das ich denken kann, ist die geringfügige Inkompatibilität mit früheren Versionen von C #, die Func nicht definieren, und dies ist in meinem Fall kein Problem.

Gibt es irgendwelche Probleme bei der Verwendung von allgemeinen Delegaten / Funktionen höherer Ordnung wie Func für IoC im Gegensatz zu spezifischeren Schnittstellen?

TheCatWhisperer
quelle
2
Was Sie beschreiben, sind Funktionen höherer Ordnung, eine wichtige Facette der funktionalen Programmierung.
Robert Harvey
2
Ich würde einen Delegaten anstelle eines verwenden, Funcda Sie die Parameter benennen können, in denen Sie ihre Absicht angeben können.
Stefan Hanke
1
verwandt: stackoverflow: ioc-factory-pro-und-kontra-für-schnittstelle-gegen-delegierte . @TheCatWhisperer Frage ist allgemeiner, während die Stackoverflow-Frage auf den Sonderfall "Fabrik"
k3b

Antworten:

11

Wenn eine Schnittstelle nur eine Funktion enthält, nicht mehr, und es keinen zwingenden Grund gibt, zwei Namen (den Schnittstellennamen und den Funktionsnamen innerhalb der Schnittstelle) Funceinzufügen , kann die Verwendung von a unnötigen Kesselcode vermeiden und ist in den meisten Fällen vorzuziehen. Genau wie wenn Sie damit beginnen, ein DTO zu entwerfen und zu erkennen, dass es nur ein Mitgliedsattribut benötigt.

Ich denke, viele Leute sind es gewohnter zu verwenden interfaces, weil es zu der Zeit, als Abhängigkeitsinjektion und IoC populär wurden, kein wirkliches Äquivalent zu der FuncKlasse in Java oder C ++ gab (ich bin nicht einmal sicher, ob Funces zu dieser Zeit in C # verfügbar war). ). Daher bevorzugen viele Tutorials, Beispiele oder Lehrbücher immer noch die interfaceForm, auch wenn die Verwendung Funceleganter wäre.

Sie könnten in meine frühere Antwort zum Prinzip der Schnittstellentrennung und zum Flow-Design-Ansatz von Ralf Westphal schauen . Dieses Paradigma implementiert DI ausschließlich mit FuncParametern , aus genau demselben Grund, den Sie bereits selbst (und einige weitere) erwähnt haben. Wie Sie sehen, ist Ihre Idee nicht wirklich neu, ganz im Gegenteil.

Ja, ich habe diesen Ansatz für den Produktionscode für Programme verwendet, die Daten in Form einer Pipeline mit mehreren Zwischenschritten verarbeiten mussten, einschließlich Unit-Tests für jeden der Schritte. So kann ich Ihnen die Erfahrung aus erster Hand geben, dass es sehr gut funktionieren kann.

Doc Brown
quelle
Dieses Programm wurde nicht einmal im Hinblick auf das Prinzip der Schnittstellentrennung entwickelt, sie werden im Grunde genommen nur als abstrakte Klassen verwendet. Dies ist ein weiterer Grund, warum ich diesen Ansatz gewählt habe. Die Schnittstellen sind nicht gut durchdacht und meistens unbrauchbar, da sie sowieso nur von einer Klasse implementiert werden. Das Prinzip der Schnittstellentrennung muss nicht ins Extreme getrieben werden, besonders jetzt, wo wir Func haben, aber meiner Meinung nach ist es immer noch sehr wichtig.
TheCatWhisperer
1
Dieses Programm wurde nicht einmal nach dem Prinzip der Schnittstellentrennung entwickelt. Nun , Ihrer Beschreibung nach waren Sie sich wahrscheinlich einfach nicht bewusst, dass der Begriff auf Ihre Art von Design angewendet werden kann.
Doc Brown
Doc, ich bezog mich auf den Code meiner Vorgänger, den ich überarbeite und von dem meine neue Klasse etwas abhängig ist. Ein Grund, warum ich mich für diesen Ansatz entschieden habe, ist, dass ich
beabsichtige,
1
"... Zu der Zeit, als Abhängigkeitsinjektion und IoC populär wurden, gab es kein wirkliches Äquivalent zur Func-Klasse." Doc, genau das hätte ich beinahe geantwortet, aber ich fühlte mich nicht sicher genug! Vielen Dank für die Bestätigung meines Verdachts.
Graham
9

Einer der Hauptvorteile von IoC besteht darin, dass ich alle meine Abhängigkeiten über die Benennung ihrer Schnittstelle benennen kann und der Container weiß, welche dem Konstruktor zur Verfügung gestellt werden muss, indem er die Typnamen angleicht. Dies ist praktisch und erlaubt viel aussagekräftigere Namen von Abhängigkeiten als Func<string, string>.

Ich stelle auch oft fest, dass selbst bei einer einfachen Abhängigkeit manchmal mehr als eine Funktion erforderlich ist - eine Schnittstelle ermöglicht es Ihnen, diese Funktionen auf eine Art und Weise zu gruppieren, die sich selbst dokumentiert, statt mehrere Parameter zu haben, die alle wie folgt lauten Func<string, string>: Func<string, int>.

Es gibt definitiv Zeiten, in denen es nützlich ist, einfach einen Delegaten zu haben, der als Abhängigkeit übergeben wird. Es ist ein Urteilsspruch darüber, wann Sie einen Delegierten einsetzen, anstatt eine Schnittstelle mit sehr wenigen Mitgliedern zu haben. Sofern nicht wirklich klar ist, wozu das Argument dient, werde ich mich normalerweise auf die Seite der Erzeugung von selbstdokumentierendem Code begeben. dh eine Schnittstelle schreiben.

Erik
quelle
Ich musste den Code von jemandem debuggen, als der ursprüngliche Implementierer nicht mehr da war, und ich kann Ihnen aus erster Erfahrung sagen, dass es eine Menge Arbeit und ein wirklich frustrierender Prozess ist, herauszufinden, welches Lambda auf dem Stack ausgeführt wird. Wenn der ursprüngliche Implementierer Schnittstellen verwendet hätte, wäre es ein Kinderspiel gewesen, den Fehler zu finden, nach dem ich gesucht habe.
Cwap
Cwap, ich fühle deinen Schmerz. In meiner speziellen Implementierung sind die Lambdas jedoch allesamt Einzeiler, die auf eine einzelne Funktion verweisen. Sie werden auch alle an einem einzigen Ort generiert.
TheCatWhisperer
3

Gibt es irgendwelche Probleme bei der Verwendung von allgemeinen Delegaten / Funktionen höherer Ordnung wie Func für IoC im Gegensatz zu spezifischeren Schnittstellen?

Nicht wirklich. Ein Func ist eine eigene Art von Schnittstelle (in der englischen Bedeutung, nicht in der C # -Bedeutung). Msgstr "Dieser Parameter liefert X, wenn gefragt wird." Der Func hat sogar den Vorteil, die Informationen nur bei Bedarf faul bereitzustellen. Ich mache das ein bisschen und empfehle es in Maßen.

Was die Nachteile angeht:

  • IoC-Container machen oft etwas Magie, um Abhängigkeiten in einer Art Kaskade zu verbinden, und werden wahrscheinlich nicht gut aussehen, wenn einige Dinge vorliegen T und andere Dinge sind Func<T>.
  • Funcs haben eine gewisse Indirektion, daher kann es etwas schwieriger sein, darüber nachzudenken und Fehler zu beheben.
  • Funks verzögern die Instantiierung, was bedeutet, dass Laufzeitfehler zu merkwürdigen Zeiten oder gar nicht während des Tests auftreten können. Dies kann auch die Wahrscheinlichkeit von Problemen mit der Reihenfolge von Vorgängen erhöhen und je nach Verwendung zu Deadlocks bei der Initialisierungsreihenfolge führen.
  • Was Sie in die Func übergeben, ist wahrscheinlich ein Abschluss, mit dem geringen Aufwand und den damit verbundenen Komplikationen.
  • Das Aufrufen einer Funktion ist etwas langsamer als der direkte Zugriff auf das Objekt. (Nicht genug, dass Sie es in einem nicht trivialen Programm bemerken werden, aber es ist da)
Telastyn
quelle
1
Ich verwende den IoC-Container, um die Factory auf herkömmliche Weise zu erstellen. Anschließend verpackt die Factory die Methoden der Schnittstellen in Lambdas, um das Problem zu umgehen, das Sie in Ihrem ersten Punkt erwähnt haben. Gute Argumente.
TheCatWhisperer
1

Nehmen wir ein einfaches Beispiel - vielleicht injizieren Sie ein Protokollierungsmittel.

Injizieren einer Klasse

class Worker: IWorker
{
    ILogger _logger;

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

Ich denke, das ist ziemlich klar, was los ist. Wenn Sie einen IoC-Container verwenden, müssen Sie nicht einmal explizit etwas einfügen. Fügen Sie dies einfach zu Ihrem Kompositionsstamm hinzu:

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

Beim Debuggen Workermuss ein Entwickler lediglich den Kompositionsstamm konsultieren, um festzustellen, welche konkrete Klasse verwendet wird.

Wenn ein Entwickler eine kompliziertere Logik benötigt, kann er mit der gesamten Oberfläche arbeiten:

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

Injizieren einer Methode

class Worker
{
    Action<string> _methodThatLogs;

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

Beachten Sie zunächst, dass der Konstruktorparameter jetzt einen längeren Namen hat methodThatLogs. Dies ist notwendig, weil Sie nicht sagen können, was eine Action<string>tun soll. Bei der Schnittstelle war es völlig klar, aber hier müssen wir uns auf die Benennung der Parameter verlassen. Dies scheint inhärent weniger zuverlässig und schwieriger während eines Builds durchzusetzen zu sein.

Wie injizieren wir diese Methode? Nun, der IoC-Container erledigt das nicht für Sie. Sie müssen es also beim Instanziieren explizit injizieren Worker. Dies wirft einige Probleme auf:

  1. Es ist mehr Arbeit, a zu instanziieren Worker
  2. Entwickler, die versuchen zu debuggen, Workerwerden feststellen, dass es schwieriger ist, herauszufinden, welche konkrete Instanz aufgerufen wird. Sie können nicht nur die Kompositionswurzel konsultieren; Sie müssen den Code nachverfolgen.

Wie wäre es, wenn wir eine kompliziertere Logik brauchen? Ihre Technik macht nur eine Methode verfügbar. Nun, ich nehme an, Sie könnten das komplizierte Zeug in das Lambda backen:

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

aber wenn Sie Ihre Komponententests schreiben, wie testen Sie diesen Lambda-Ausdruck? Es ist anonym, sodass Ihr Unit-Test-Framework es nicht direkt instanziieren kann. Vielleicht können Sie eine clevere Möglichkeit finden, dies zu tun, aber es wird wahrscheinlich eine größere PITA sein als die Verwendung einer Schnittstelle.

Zusammenfassung der Unterschiede:

  1. Das Injizieren nur einer Methode erschwert das Ableiten des Zwecks, wohingegen eine Schnittstelle den Zweck klar kommuniziert.
  2. Wenn nur eine Methode injiziert wird, ist für die Klasse, die die Injektion erhält, weniger Funktionalität verfügbar. Auch wenn Sie es heute nicht brauchen, können Sie es morgen brauchen.
  3. Sie können nicht automatisch nur eine Methode mithilfe eines IoC-Containers injizieren.
  4. Sie können der Kompositionswurzel nicht entnehmen, welche konkrete Klasse in einer bestimmten Instanz aktiv ist.
  5. Es ist ein Problem, den Lambda-Ausdruck selbst zu testen.

Wenn Sie mit all dem Obenstehenden einverstanden sind, ist es in Ordnung, nur die Methode zu injizieren. Ansonsten würde ich vorschlagen, dass Sie bei der Tradition bleiben und eine Schnittstelle einfügen.

John Wu
quelle
Ich hätte angeben sollen, die Klasse, die ich mache, ist eine Prozesslogikklasse. Es ist auf externe Klassen angewiesen, um die Daten zu erhalten, die für eine fundierte Entscheidung erforderlich sind. Eine Klasse, die einen solchen Logger hervorruft, wird nicht verwendet. Ein Problem mit Ihrem Beispiel ist, dass es ein schlechtes OO ist, insbesondere im Zusammenhang mit der Verwendung eines IoC-Containers. Anstelle der Verwendung einer if-Anweisung, die der Klasse zusätzliche Komplexität verleiht, sollte einfach eine inerte Protokollfunktion übergeben werden. Außerdem würde die Einführung von Logik in die Lambdas das Testen in der Tat erschweren ... und den Zweck seiner Verwendung zunichte machen, weshalb dies nicht erfolgt.
TheCatWhisperer
Die Lambdas verweisen auf eine Schnittstellenmethode in der Anwendung und konstruieren in Unit-Tests beliebige Datenstrukturen.
TheCatWhisperer
Warum konzentrieren sich die Leute immer auf die Details eines offensichtlich willkürlichen Beispiels? Nun, wenn Sie darauf bestehen, über eine ordnungsgemäße Protokollierung zu sprechen, wäre ein inaktiver Protokollierer in einigen Fällen eine schreckliche Idee, z. B. wenn die zu protokollierende Zeichenfolge in der Berechnung teuer ist.
John Wu
Ich bin nicht sicher, was Sie mit "Lambda-Punkt auf eine Interface-Methode" meinen. Ich denke, Sie müssten eine Implementierung einer Methode einschleusen, die mit der Signatur des Delegaten übereinstimmt. Wenn die Methode zufällig zu einer Schnittstelle gehört, ist dies ein Zufall. Es gibt keine Kompilierungs- oder Laufzeitprüfung, um dies sicherzustellen. Missverstehe ich Vielleicht könnten Sie Ihrem Beitrag ein Codebeispiel hinzufügen?
John Wu
Ich kann nicht sagen, dass ich Ihren Schlussfolgerungen hier zustimme. Zum einen sollten Sie Ihre Klasse an die minimale Anzahl von Abhängigkeiten koppeln. Wenn Sie also Lambdas verwenden, stellen Sie sicher, dass jedes injizierte Element genau eine Abhängigkeit ist und keine Verschmelzung mehrerer Dinge in einer Schnittstelle. Sie können auch ein Muster zum Aufbau einer funktionsfähigen WorkerAbhängigkeit unterstützen, sodass jedes Lambda unabhängig injiziert werden kann, bis alle Abhängigkeiten erfüllt sind. Dies ähnelt der Teilanwendung in FP. Darüber hinaus hilft die Verwendung von Lambdas dabei, den impliziten Zustand und die versteckte Kopplung zwischen Abhängigkeiten zu beseitigen.
Aaron M. Eshbach
0

Betrachten Sie den folgenden Code, den ich vor langer Zeit geschrieben habe:

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

Es nimmt eine relative Position zu einer Vorlagendatei ein, lädt sie in den Speicher, rendert den Nachrichtentext und stellt das E-Mail-Objekt zusammen.

Sie könnten sehen IPhysicalPathMapperund denken: "Es gibt nur eine Funktion. Das könnte eine sein Func." Das Problem hierbei ist jedoch, dass IPhysicalPathMapperes nicht einmal existieren sollte. Eine viel bessere Lösung besteht darin, nur den Pfad zu parametrisieren :

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

Dies wirft eine ganze Reihe weiterer Fragen zur Verbesserung dieses Codes auf. ZB sollte es nur eine akzeptieren EmailTemplate, und dann sollte es vielleicht eine vorgerenderte Vorlage akzeptieren, und dann sollte es vielleicht nur ausgekleidet sein.

Aus diesem Grund mag ich es nicht, die Kontrolle als ein allgegenwärtiges Muster umzukehren. Es wird im Allgemeinen als diese gottähnliche Lösung zum Verfassen Ihres gesamten Codes angesehen. Aber in Wirklichkeit wird Ihr Code erstellt, wenn Sie ihn überall verwenden (im Gegensatz zu sparsam) viel schlimmer indem Sie dazu ermutigen, viele unnötige Schnittstellen einzuführen, die vollständig rückwärts verwendet werden. (Rückwärts in dem Sinne, dass der Aufrufer wirklich dafür verantwortlich sein sollte, diese Abhängigkeiten zu bewerten und das Ergebnis weiterzuleiten, anstatt dass die Klasse selbst den Aufruf aufruft.)

Schnittstellen sollten sparsam verwendet werden, und Inversion von Kontrolle und Abhängigkeitsinjektion sollten auch sparsam verwendet werden. Wenn Sie große Mengen davon haben, wird es viel schwieriger, Ihren Code zu entschlüsseln.

jpmc26
quelle