Utilizzo di Func invece di interfacce per IoC


15

Contesto: sto usando C #

Ho progettato una classe e, al fine di isolarla e di semplificare i test unitari, sto passando tutte le sue dipendenze; non crea istanze di oggetti internamente. Tuttavia, invece di fare riferimento alle interfacce per ottenere i dati di cui ha bisogno, devo fare riferimento a Func di uso generale che restituisce i dati / comportamento richiesti. Quando inietto le sue dipendenze, posso farlo con le espressioni lambda.

Per me, questo sembra un approccio migliore perché non devo fare alcun beffardo noioso durante i test delle unità. Inoltre, se l'implementazione circostante presenta cambiamenti fondamentali, avrò solo bisogno di cambiare la classe di fabbrica; non saranno necessarie modifiche nella classe contenente la logica.

Tuttavia, non ho mai visto l'IoC in questo modo prima, il che mi fa pensare che ci siano alcune potenziali insidie ​​che potrei perdere. L'unica che mi viene in mente è l'incompatibilità minore con la versione precedente di C # che non definisce Func, e questo non è un problema nel mio caso.

Ci sono problemi con l'utilizzo di delegati generici / funzioni di ordine superiore come Func per IoC, al contrario di interfacce più specifiche?


2
Quello che stai descrivendo sono funzioni di ordine superiore, un aspetto importante della programmazione funzionale.
Robert Harvey,

2
Userei un delegato invece di un Funcdato che puoi nominare i parametri, dove puoi dichiarare il loro intento.
Stefan Hanke,

1
correlati: stackoverflow: ioc-factory-pros-and-contras-for-interface-versus-delegates . @TheCatWhisperer question è più generale mentre la domanda stackoverflow si restringe al caso speciale "factory"
k3b

Risposte:


11

Se un'interfaccia contiene solo una funzione, non di più, e non vi è alcun motivo convincente per introdurre due nomi (il nome dell'interfaccia e il nome della funzione all'interno dell'interfaccia), l'utilizzo di un Funcinvece può evitare il codice inutile di plateplate ed è nella maggior parte dei casi preferibile - proprio come quando si inizia a progettare un DTO e si riconosce che ha bisogno di un solo attributo membro.

Immagino che molte persone siano più abituate a usare interfaces, perché al momento in cui l'iniezione di dipendenza e l'IoC stavano diventando popolari, non c'era un vero equivalente alla Funcclasse in Java o C ++ (non sono nemmeno sicuro che Funcfosse disponibile in quel momento in C # ). Quindi molti tutorial, esempi o libri di testo preferiscono ancora il interfacemodulo, anche se l'utilizzo Funcsarebbe più elegante.

Potresti esaminare la mia precedente risposta sul principio di segregazione dell'interfaccia e l'approccio Flow Design di Ralf Westphal. Questo paradigma implementa DI con Funcparametri esclusivamente , esattamente per lo stesso motivo che hai menzionato già da te (e alcuni altri). Quindi, come vedi, la tua idea non è davvero nuova, al contrario.

E sì, ho usato questo approccio da solo, per il codice di produzione, per i programmi che dovevano elaborare i dati sotto forma di una pipeline con diversi passaggi intermedi, compresi i test unitari per ciascuno dei passaggi. Quindi da ciò posso darti un'esperienza di prima mano che può funzionare molto bene.


Questo programma non è stato nemmeno progettato tenendo presente il principio di segregazione dell'interfaccia, ma sono fondamentalmente utilizzati solo come classi astratte. Questo è un altro motivo per cui sono andato con questo approccio, le interfacce non sono ben pensate e per lo più inutili poiché solo una classe le implementa comunque. Il principio di segregazione dell'interfaccia non deve essere portato all'estremo, specialmente ora che abbiamo Func, ma nella mia mente è ancora molto importante.
TheCatWhisperer,

1
Questo programma non è stato nemmeno progettato tenendo presente il principio di segregazione dell'interfaccia : beh, secondo la tua descrizione, probabilmente non eri a conoscenza del fatto che il termine potesse essere applicato al tuo tipo di progetto.
Doc Brown,

Doc, mi riferivo al codice creato dai miei predecessori, che sto refactoring, e da cui la mia nuova classe dipende in qualche modo. Parte del motivo per cui ho seguito questo approccio è perché ho intenzione di apportare modifiche importanti ad altre parti del programma in futuro, comprese le dipendenze di questa classe
TheCatWhisperer

1
"... Nel momento in cui l'iniezione di dipendenza e l'IoC stavano diventando popolari, non c'era un vero equivalente alla classe Func" Doc, è esattamente quello che ho quasi risposto, ma non mi sentivo abbastanza sicuro! Grazie per aver verificato il mio sospetto su quello.
Graham,

9

Uno dei principali vantaggi che trovo con IoC è che mi permetterà di nominare tutte le mie dipendenze tramite la denominazione della loro interfaccia, e il contenitore saprà quale fornire al costruttore abbinando i nomi dei tipi. Questo è conveniente e consente nomi di dipendenze molto più descrittivi di Func<string, string>.

Trovo spesso anche che, anche con una singola, semplice dipendenza, a volte debba avere più di una funzione: un'interfaccia ti consente di raggruppare queste funzioni in modo auto-documentante, rispetto a più parametri che tutti leggono come Func<string, string>, Func<string, int>.

Ci sono sicuramente momenti in cui è utile semplicemente avere un delegato che viene passato come dipendenza. È una decisione di giudizio su quando si utilizza un delegato rispetto a un'interfaccia con pochissimi membri. A meno che non sia davvero chiaro quale sia lo scopo dell'argomento, di solito sbaglierò sul lato della produzione di codice autocompattante; vale a dire. scrivere un'interfaccia.


Ho dovuto eseguire il debug del codice di qualcuno, quando l'implementatore originale non era più lì, e posso dirti dalla prima esperienza, che scoprire quale lambda viene eseguito in cui lo stack è un sacco di lavoro e un processo davvero frustrante. Se l'attuatore originale avesse utilizzato le interfacce, sarebbe stato semplicissimo trovare il bug che cercavo.
Cwap

cwap, sento il tuo dolore. Tuttavia, nella mia particolare implementazione, i lambda sono tutti una fodera che punta a una singola funzione. Sono inoltre tutti generati in un unico posto.
TheCatWhisperer,

Nella mia esperienza, questa è solo una questione di utensili. ReSharpers Inspect-> Incoming Calls fa un buon lavoro nel seguire il percorso su funcs / lambdas.
Christian,

3

Ci sono problemi con l'utilizzo di delegati generici / funzioni di ordine superiore come Func per IoC, al contrario di interfacce più specifiche?

Non proprio. A Func è il suo tipo di interfaccia (nel significato inglese, non nel significato C #). "Questo parametro è qualcosa che fornisce X quando richiesto." Func ha anche il vantaggio di fornire pigramente le informazioni solo se necessario. Lo faccio un po 'e lo consiglio con moderazione.

Per quanto riguarda gli aspetti negativi:

  • I contenitori IoC spesso fanno un po 'di magia per collegare le dipendenze in una sorta di cascata, e probabilmente non giocheranno bene quando alcune cose sono Te alcune cose lo sono Func<T>.
  • Func ha alcune indicazioni indirette, quindi può essere un po 'più difficile ragionare e eseguire il debug.
  • L'istanza di ritardo di Funcs, il che significa che gli errori di runtime possono apparire in momenti strani o per niente durante il test. Può anche aumentare le possibilità di problemi relativi all'ordine delle operazioni e, a seconda dell'utilizzo, degli deadlock nell'ordine di inizializzazione.
  • È probabile che ciò che passi nella Func sia una chiusura, con il leggero sovraccarico e le complicazioni che ciò comporta.
  • La chiamata a Func è un po 'più lenta dell'accesso diretto all'oggetto. (Non abbastanza che noterai in qualsiasi programma non banale, ma è lì)

1
Uso il contenitore IoC per creare la fabbrica in modo tradizionale, quindi la fabbrica impacchetta i metodi delle interfacce in lambdas, aggirando il problema che hai citato nel tuo primo punto. Punti buoni.
TheCatWhisperer,

1

Facciamo un semplice esempio: forse stai iniettando un mezzo di registrazione.

Iniezione di una classe

class Worker: IWorker
{
    ILogger _logger;

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

Penso che sia abbastanza chiaro cosa sta succedendo. Inoltre, se si utilizza un contenitore IoC, non è nemmeno necessario iniettare nulla in modo esplicito, è sufficiente aggiungere alla radice della composizione:

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

Durante il debug Worker, uno sviluppatore deve solo consultare la radice della composizione per determinare quale classe concreta viene utilizzata.

Se uno sviluppatore ha bisogno di una logica più complicata, ha l'intera interfaccia con cui lavorare:

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

Iniezione di un metodo

class Worker
{
    Action<string> _methodThatLogs;

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

In primo luogo, si noti che il parametro del costruttore ha un nome più lungo ora, methodThatLogs. Questo è necessario perché non puoi dire cosa Action<string>dovrebbe fare. Con l'interfaccia, era completamente chiaro, ma qui dobbiamo ricorrere a fare affidamento sulla denominazione dei parametri. Questo sembra intrinsecamente meno affidabile e più difficile da applicare durante una build.

Ora, come iniettiamo questo metodo? Bene, il contenitore IoC non lo farà per te. Quindi ti viene iniettato esplicitamente quando crei un'istanza Worker. Ciò solleva un paio di problemi:

  1. È più lavoro istanziare a Worker
  2. Gli sviluppatori che tentano di eseguire il debug Workertroveranno più difficile capire quale istanza concreta viene chiamata. Non possono semplicemente consultare la radice della composizione; dovranno tracciare il codice.

Che ne dici se abbiamo bisogno di una logica più complicata? La tua tecnica espone solo un metodo. Ora suppongo che potresti cuocere le cose complicate nella lambda:

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

ma quando scrivi i tuoi test unitari, come testate quell'espressione lambda? È anonimo, quindi il framework di unit test non può istanziarlo direttamente. Forse puoi trovare un modo intelligente per farlo, ma probabilmente sarà un PITA più grande rispetto all'uso di un'interfaccia.

Riepilogo delle differenze:

  1. Iniettare solo un metodo rende più difficile inferire lo scopo, mentre un'interfaccia comunica chiaramente lo scopo.
  2. L'iniezione di un solo metodo espone meno funzionalità alla classe che riceve l'iniezione. Anche se non ne hai bisogno oggi, potresti averne bisogno domani.
  3. Non è possibile iniettare automaticamente solo un metodo utilizzando un contenitore IoC.
  4. Dalla radice della composizione non è possibile sapere quale classe concreta è al lavoro in una particolare istanza.
  5. È un problema test unitario dell'espressione lambda stessa.

Se stai bene con tutto quanto sopra, allora va bene iniettare solo il metodo. Altrimenti ti suggerirei di restare fedele alla tradizione e di iniettare un'interfaccia.


Avrei dovuto specificare, la classe che sto creando è una classe di logica di processo. Si affida a classi esterne per ottenere i dati necessari per prendere una decisione informata, non viene utilizzato un effetto collaterale che induca una classe come un logger. Un problema con il tuo esempio è che è scarsa OO, specialmente nel contesto dell'uso di un contenitore IoC. Invece di utilizzare un'istruzione if, che aggiunge ulteriore complessità, alla classe, dovrebbe semplicemente passare un logger inerte. Inoltre, introdurre la logica nei lambda renderebbe davvero più difficile il test ... sconfiggendo lo scopo del suo uso, motivo per cui non viene fatto.
TheCatWhisperer

I lambda puntano a un metodo di interfaccia nell'applicazione e costruiscono strutture di dati arbitrarie in unit test.
TheCatWhisperer,

Perché le persone si concentrano sempre sulle minuzie di quello che è ovviamente un esempio arbitrario? Bene, se insisti nel parlare di una registrazione adeguata, un logger inerte sarebbe un'idea terribile in alcuni casi, ad esempio quando la stringa da registrare è costosa da calcolare.
John Wu,

Non sono sicuro di cosa intendi con "punto lambda per un metodo di interfaccia". Penso che dovresti iniettare un'implementazione di un metodo che corrisponda alla firma del delegato. Se il metodo sembra appartenere a un'interfaccia, ciò è secondario; non esiste alcun controllo di compilazione o di runtime per assicurarsi che ciò avvenga. Sto fraintendendo? Forse potresti includere un esempio di codice nel tuo post?
John Wu,

Non posso dire di essere d'accordo con le tue conclusioni qui. Per prima cosa, dovresti accoppiare la tua classe alla minima quantità di dipendenze, quindi l'uso di lambdas garantisce che ogni elemento iniettato sia esattamente una dipendenza e non una fusione di più cose in un'interfaccia. È inoltre possibile supportare un modello di creazione di Workeruna dipendenza valida alla volta, consentendo di iniettare ogni lambda in modo indipendente fino a quando tutte le dipendenze sono soddisfatte. Questo è simile all'applicazione parziale in FP. Inoltre, l'uso di lambda aiuta a eliminare lo stato implicito e l'accoppiamento nascosto tra le dipendenze.
Aaron M. Eshbach il

0

Considera il seguente codice che ho scritto molto tempo fa:

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

Prende una posizione relativa in un file modello, lo carica in memoria, esegue il rendering del corpo del messaggio e assembla l'oggetto di posta elettronica.

Potresti guardare IPhysicalPathMappere pensare: "C'è solo una funzione. Potrebbe essere una Func". Ma in realtà, il problema qui è che IPhysicalPathMappernon dovrebbe nemmeno esistere. Una soluzione molto migliore è semplicemente parametrizzare il percorso :

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

Ciò solleva molte altre domande per migliorare questo codice. Ad esempio, forse dovrebbe semplicemente accettare un EmailTemplate, e quindi forse dovrebbe accettare un modello pre-renderizzato, e quindi forse dovrebbe essere solo allineato.

Questo è il motivo per cui non mi piace l'inversione del controllo come modello pervasivo. È generalmente considerato come una soluzione divina per comporre tutto il codice. Ma in realtà, se lo usi pervasivamente (al contrario di con parsimonia), peggiora molto il tuo codice incoraggiandoti a introdurre molte interfacce non necessarie che vengono utilizzate completamente all'indietro. (All'indietro nel senso che il chiamante dovrebbe davvero essere responsabile della valutazione di tali dipendenze e del passaggio del risultato piuttosto che della classe stessa che invoca la chiamata.)

Le interfacce dovrebbero essere usate con parsimonia, e anche l'inversione del controllo e l'iniezione di dipendenza dovrebbero essere usate con parsimonia. Se ne hai grandi quantità, il tuo codice diventa molto più difficile da decifrare.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.