Używanie Func zamiast interfejsów dla IoC

15

Kontekst: Używam C #

Zaprojektowałem klasę. Aby ją odizolować i ułatwić testowanie jednostek, przekazuję wszystkie jej zależności; nie tworzy wewnętrznej instancji obiektu. Jednak zamiast odwoływać się do interfejsów w celu uzyskania potrzebnych danych, odwołuję się do Funcs ogólnego przeznaczenia zwracających wymagane dane / zachowanie. Kiedy wstrzykuję jej zależności, mogę to zrobić za pomocą wyrażeń lambda.

Wydaje mi się, że jest to lepsze podejście, ponieważ nie muszę robić żmudnych szyderstw podczas testów jednostkowych. Ponadto, jeśli implementacja otaczająca ma zasadnicze zmiany, będę potrzebowała jedynie zmiany klasy fabrycznej; żadne zmiany w klasie zawierającej logikę nie będą potrzebne.

Jednak nigdy wcześniej nie widziałem, aby IoC działało w ten sposób, co sprawia, że ​​myślę, że istnieją pewne potencjalne pułapki, których mogę przegapić. Jedyne, co mogę wymyślić, to niewielka niezgodność z wcześniejszą wersją C #, która nie definiuje Func, i to nie jest problem w moim przypadku.

Czy są jakieś problemy z używaniem ogólnych funkcji delegatów / funkcji wyższego rzędu, takich jak Func dla IoC, w przeciwieństwie do bardziej szczegółowych interfejsów?

TheCatWhisperer
źródło
2
Co ty opisujesz są funkcje wyższego rzędu, ważnym aspektem programowania funkcjonalnego.
Robert Harvey
2
Użyłbym delegata zamiast, Funcponieważ można nazwać parametry, w których można określić ich zamiary.
Stefan Hanke
1
powiązane: stackoverflow: ioc-factory-pros-and-contras-for-interface-vers--delegates . @TheCatWhisperer pytanie jest bardziej ogólne, podczas gdy pytanie o przepełnienie stosu zawęża się do specjalnego przypadku „fabryka”
k3b

Odpowiedzi:

11

Jeśli interfejs zawiera tylko jedną funkcję, nie więcej, i nie ma ważnego powodu, aby wprowadzić dwie nazwy (nazwa interfejsu i nazwa funkcji w interfejsie), użycie Funczamiast tego może uniknąć niepotrzebnego kodu wzorcowego i jest w większości przypadków zalecane - podobnie jak kiedy zaczynasz projektować DTO i rozpoznajesz, że potrzebuje on tylko jednego atrybutu członka.

Myślę, że wiele osób jest bardziej przyzwyczajonych do korzystania interfaces, ponieważ w czasie, gdy upowszechnianie wstrzykiwania zależności i IoC stawało się popularne, nie było prawdziwego odpowiednika Funcklasy w Javie lub C ++ (nie jestem nawet pewien, czy Funcw tym czasie był dostępny w C # ). Tak więc wiele samouczków, przykładów lub podręczników nadal preferuje interfaceformę, nawet jeśli użycie Funcbyłoby bardziej eleganckie.

Możesz zajrzeć do mojej poprzedniej odpowiedzi na temat zasady segregacji interfejsów i podejścia Flow Design Ralfa Westphala. Ten paradygmat implementuje DI z Funcparametrami wyłącznie z dokładnie tego samego powodu, o którym wspomniałeś już sam (i kilka innych). Jak więc widzisz, twój pomysł nie jest naprawdę nowy, wręcz przeciwnie.

I tak, sam zastosowałem to podejście, do kodu produkcyjnego, do programów, które musiały przetwarzać dane w postaci potoku z kilkoma pośrednimi krokami, w tym testami jednostkowymi dla każdego z nich. Dzięki temu mogę dać ci doświadczenie z pierwszej ręki, które może działać bardzo dobrze.

Doktor Brown
źródło
Ten program nie został nawet zaprojektowany z myślą o zasadzie segregacji interfejsów, są one po prostu używane jako klasy abstrakcyjne. Jest to kolejny powód, dla którego zdecydowałem się na takie podejście, interfejsy nie są dobrze przemyślane i są w większości bezużyteczne, ponieważ i tak implementuje je tylko jedna klasa. Zasada segregacji interfejsu nie musi być doprowadzona do skrajności, zwłaszcza teraz, gdy mamy Func, ale moim zdaniem jest to nadal bardzo ważne.
TheCatWhisperer
1
Ten program nie został nawet zaprojektowany z myślą o zasadzie segregacji interfejsu - cóż, zgodnie z twoim opisem, prawdopodobnie po prostu nie wiedziałeś, że termin ten można zastosować do twojego rodzaju projektu.
Doc Brown
Doc, miałem na myśli kod napisany przez moich poprzedników, który refaktoryzuję i od którego moja nowa klasa jest w pewnym stopniu zależna. Jednym z powodów, dla których zdecydowałem się na to podejście, jest to, że planuję wprowadzić w przyszłości poważne zmiany w innych częściach programu, w tym zależności od tej klasy
TheCatWhisperer
1
„... W czasach, gdy upowszechnianie wstrzykiwania zależności i Internetu rzeczy stały się popularne, nie było prawdziwego odpowiednika klasy Func„ Doc ”, na co prawie odpowiedziałem, ale nie czułem się wystarczająco pewnie! Dzięki za sprawdzenie mojego podejrzenia w tej sprawie.
Graham
9

Jedną z głównych zalet, które znalazłem w IoC, jest to, że pozwolę mi nazwać wszystkie moje zależności poprzez nazewnictwo ich interfejsu, a pojemnik będzie wiedział, który z nich dostarczyć konstruktorowi, dopasowując nazwy typów. Jest to wygodne i pozwala na znacznie bardziej opisowe nazwy zależności niż Func<string, string>.

Często też stwierdzam, że nawet z jedną, prostą zależnością, czasami musi mieć więcej niż jedną funkcję - interfejs pozwala zgrupować te funkcje razem w sposób, który sam się dokumentuje, w porównaniu z wieloma parametrami, które wszystkie czytają podobnie Func<string, string>, Func<string, int>.

Zdecydowanie są chwile, w których przydatne jest po prostu delegowanie, które jest przekazywane jako zależność. Jest to wezwanie do osądu, kiedy używasz delegata vs. mając interfejs z bardzo małą liczbą członków. O ile nie jest naprawdę jasne, jaki jest cel tego argumentu, zwykle popełniam błąd po stronie tworzenia samodokumentującego się kodu; to znaczy. pisanie interfejsu.

Erik
źródło
Musiałem debugować czyjś kod, kiedy nie było już oryginalnego implementatora, i mogę powiedzieć z pierwszego doświadczenia, że ​​ustalenie, która lambda jest uruchamiana tam, gdzie na stosie jest dużo pracy i naprawdę frustrujący proces. Gdyby oryginalny implementator używał interfejsów, znalezienie błędu, którego szukałem, byłoby dziecinnie proste.
cwap,
cwap, czuję twój ból. Jednak w mojej konkretnej implementacji lambdy są jednymi liniami wskazującymi na jedną funkcję. Wszystkie są również generowane w jednym miejscu.
TheCatWhisperer
Z mojego doświadczenia wynika, że ​​jest to tylko kwestia narzędzi. ReSharpers Inspect-> Incoming Calls wykonuje dobrą robotę, podążając ścieżką w górę funcs / lambdas.
Christian
3

Czy są jakieś problemy z używaniem ogólnych funkcji delegatów / funkcji wyższego rzędu, takich jak Func dla IoC, w przeciwieństwie do bardziej szczegółowych interfejsów?

Nie całkiem. Func jest własnym rodzajem interfejsu (w języku angielskim, nie w języku C #). „Ten parametr jest czymś, co dostarcza X, gdy zostanie o to poproszony”. Zaletą Func jest leniwe dostarczanie informacji tylko w razie potrzeby. Robię to trochę i polecam z umiarem.

Co do wad:

  • Kontenery IoC często magicznie łączą zależności w kaskadowy sposób i prawdopodobnie nie będą się dobrze bawić, gdy pewne rzeczy są, Ta niektóre są Func<T>.
  • Funcs mają pewną pośrednią naturę, więc może być nieco trudniej o uzasadnienie i debugowanie.
  • Funkcje opóźniają tworzenie instancji, co oznacza, że ​​błędy czasu wykonywania mogą pojawiać się w dziwnych momentach lub wcale nie podczas testowania. Może to również zwiększyć szanse na problemy z kolejnością operacji i, w zależności od zastosowania, zakleszczenia w kolejności inicjalizacji.
  • To, co przekazujesz Funcowi, może być zamknięciem, z niewielkimi kosztami i związanymi z tym komplikacjami.
  • Wywołanie Func jest nieco wolniejsze niż bezpośredni dostęp do obiektu. (Nie wystarczy, że zauważysz w każdym nietrywialnym programie, ale on tam jest)
Telastyn
źródło
1
Używam kontenera IoC do tworzenia fabryki w tradycyjny sposób, a następnie fabryka pakuje metody interfejsów do lambdas, omijając problem, o którym wspomniałeś w swoim pierwszym punkcie. Słuszne uwagi.
TheCatWhisperer
1

Weźmy prosty przykład - być może wstrzykujesz sposób logowania.

Wstrzykiwanie klasy

class Worker: IWorker
{
    ILogger _logger;

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

Myślę, że to całkiem jasne, co się dzieje. Co więcej, jeśli używasz kontenera IoC, nie musisz nawet wstrzykiwać niczego jawnie, po prostu dodajesz do katalogu głównego kompozycji:

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

Podczas debugowania Workerprogramista musi tylko skonsultować się z katalogiem głównym kompozycji, aby ustalić, która konkretna klasa jest używana.

Jeśli programista potrzebuje bardziej skomplikowanej logiki, ma cały interfejs do pracy z:

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

Wstrzyknięcie metody

class Worker
{
    Action<string> _methodThatLogs;

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

Po pierwsze należy zauważyć, że parametr konstruktora ma dłuższą nazwę teraz methodThatLogs. Jest to konieczne, ponieważ nie możesz powiedzieć, co Action<string>ma zrobić. Z interfejsem było to całkowicie jasne, ale tutaj musimy uciekać się do polegania na nazywaniu parametrów. Wydaje się to z natury mniej niezawodne i trudniejsze do wyegzekwowania podczas kompilacji.

Jak wstrzyknąć tę metodę? Kontener IoC nie zrobi tego za ciebie. Pozostaje ci to jawnie wstrzykiwać, gdy tworzysz instancję Worker. Rodzi to kilka problemów:

  1. Tworzenie instancji zajmuje więcej czasu Worker
  2. Programiści próbujący debugować Workerstwierdzą, że trudniej jest ustalić, jak wywoływana jest konkretna instancja. Nie mogą po prostu skonsultować się z katalogiem głównym kompozycji; będą musieli prześledzić kod.

A może potrzebujemy bardziej skomplikowanej logiki? Twoja technika ujawnia tylko jedną metodę. Teraz przypuszczam, że możesz upiec skomplikowane rzeczy w lambda:

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

ale kiedy piszesz testy jednostkowe, jak testujesz to wyrażenie lambda? Jest anonimowy, więc środowisko testów jednostkowych nie może go bezpośrednio utworzyć. Może uda ci się wymyślić jakiś sprytny sposób, ale prawdopodobnie będzie to większa PITA niż przy użyciu interfejsu.

Podsumowanie różnic:

  1. Wstrzyknięcie tylko metody utrudnia wnioskowanie o celu, podczas gdy interfejs wyraźnie komunikuje cel.
  2. Wstrzyknięcie tylko metody naraża mniej funkcjonalność klasy otrzymującej zastrzyk. Nawet jeśli nie potrzebujesz go dzisiaj, możesz potrzebować go jutro.
  3. Nie można automatycznie wstrzykiwać tylko metody przy użyciu kontenera IoC.
  4. Z katalogu głównego nie można stwierdzić, która konkretna klasa działa w konkretnym przypadku.
  5. Problemem jest testowanie jednostkowe samej ekspresji lambda.

Jeśli wszystko jest w porządku, możesz podać tylko tę metodę. W przeciwnym razie sugeruję trzymać się tradycji i wstrzykiwać interfejs.

John Wu
źródło
Powinienem był określić, że tworzona klasa jest klasą logiki procesu. Opiera się na klasach zewnętrznych, aby uzyskać dane potrzebne do podjęcia świadomej decyzji, efekt uboczny wywołujący klasę takiego rejestratora nie jest używany. Problem z twoim przykładem polega na tym, że jest to zły OO, szczególnie w kontekście używania kontenera IoC. Zamiast używać instrukcji if, która dodaje dodatkowej złożoności klasie, należy po prostu przekazać bezwładny rejestrator. Także wprowadzenie logiki do lambdów rzeczywiście utrudniłoby testowanie ... pokonanie celu jego użycia, dlatego nie jest to zrobione.
TheCatWhisperer
Lambdas wskazują na metodę interfejsu w aplikacji i konstruują dowolne struktury danych w testach jednostkowych.
TheCatWhisperer
Dlaczego ludzie zawsze koncentrują się na szczegółach tego, co oczywiście jest arbitralnym przykładem? Cóż, jeśli nalegasz na mówienie o prawidłowym logowaniu, obojętny rejestrator byłby okropnym pomysłem w niektórych przypadkach, np. Gdy łańcuch do zalogowania jest kosztowny do obliczenia.
John Wu
Nie jestem pewien, co masz na myśli przez „lambdas wskazują na metodę interfejsu”. Myślę, że będziesz musiał wprowadzić implementację metody pasującej do podpisu delegata. Jeśli metoda przypadkowo należy do interfejsu, jest to przypadkowe; nie ma sprawdzania kompilacji ani wykonywania w celu zapewnienia, że ​​tak się dzieje. Czy ja nie rozumiem? Być może mógłbyś zamieścić przykładowy kod w swoim poście?
John Wu
Nie mogę powiedzieć, że zgadzam się z pańskimi wnioskami tutaj. Po pierwsze, powinieneś połączyć swoją klasę z minimalną liczbą zależności, więc użycie lambdas gwarantuje, że każdy wstrzyknięty element jest dokładnie jedną zależnością, a nie połączeniem wielu rzeczy w interfejsie. Możesz także wspierać wzorzec budowania żywotnej Workerzależności na raz, pozwalając na wstrzykiwanie każdej lambda niezależnie, dopóki wszystkie zależności nie zostaną spełnione. Jest to podobne do częściowego zastosowania w FP. Ponadto użycie lambdas pomaga wyeliminować stan niejawny i ukryte sprzężenie między zależnościami.
Aaron M. Eshbach,
0

Rozważ następujący kod, który napisałem dawno temu:

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

Pobiera względną lokalizację do pliku szablonu, ładuje go do pamięci, renderuje treść wiadomości i składa obiekt e-mail.

Możesz spojrzeć IPhysicalPathMapperi pomyśleć: „Jest tylko jedna funkcja. To może być Func”. Ale tak naprawdę problem polega na tym, że IPhysicalPathMappernawet nie powinien istnieć. O wiele lepszym rozwiązaniem jest po prostu parametryzacja ścieżki :

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

Rodzi to wiele innych pytań w celu ulepszenia tego kodu. Np. Może powinien po prostu zaakceptować EmailTemplate, a następnie może zaakceptować wstępnie renderowany szablon, a może po prostu powinien być wbudowany.

Dlatego nie lubię odwracania kontroli jako wszechobecnego wzorca. Ogólnie uważa się to za boskie rozwiązanie do tworzenia całego kodu. Ale w rzeczywistości, jeśli używasz go w sposób wszechobecny (w przeciwieństwie do oszczędnego), znacznie pogorszy to twój kod , zachęcając cię do wprowadzenia wielu niepotrzebnych interfejsów, które są używane całkowicie wstecz. (Cofając się w tym sensie, że wywołujący powinien być naprawdę odpowiedzialny za ocenę tych zależności i przekazywanie wyniku zamiast wywoływania wywołania przez samą klasę).

Interfejsy powinny być używane oszczędnie, a odwracanie kontroli i wstrzykiwania zależności również powinno być stosowane oszczędnie. Jeśli masz ich duże ilości, twój kod staje się znacznie trudniejszy do odczytania.

jpmc26
źródło