Czy programowanie funkcjonalne jest realną alternatywą dla wzorców wstrzykiwania zależności?

21

Niedawno czytałem książkę zatytułowaną Programowanie funkcjonalne w języku C # i przychodzi mi do głowy, że niezmienna i bezstanowa natura programowania funkcjonalnego osiąga wyniki podobne do wzorców wstrzykiwania zależności i jest prawdopodobnie jeszcze lepszym podejściem, szczególnie w odniesieniu do testów jednostkowych.

Byłbym wdzięczny, gdyby ktokolwiek, kto ma doświadczenie w obu podejściach, mógł podzielić się swoimi przemyśleniami i doświadczeniami, aby odpowiedzieć na podstawowe pytanie: czy programowanie funkcjonalne jest realną alternatywą dla wzorców wstrzykiwania zależności?

Matt Cashatt
źródło
10
To nie ma dla mnie większego sensu, niezmienność nie usuwa zależności.
Telastyn
Zgadzam się, że nie usuwa zależności. Prawdopodobnie moje rozumowanie jest niepoprawne, ale doszedłem do tego wniosku, ponieważ jeśli nie mogę zmienić oryginalnego obiektu, muszę wymagać przekazania go (wstrzyknięcia) do dowolnej funkcji, która z niego korzysta.
Matt Cashatt,
5
Istnieje również Jak oszukać programistów OO w kochanie programowania funkcjonalnego , co jest naprawdę szczegółową analizą DI zarówno z perspektywy OO, jak i FP.
Robert Harvey
1
To pytanie, artykuły, do których prowadzi, oraz zaakceptowana odpowiedź mogą być również przydatne: stackoverflow.com/questions/11276319/... Zignoruj ​​przerażające słowo Monady. Jak wskazuje Runar w swojej odpowiedzi, w tym przypadku nie jest to złożona koncepcja (tylko funkcja).
itsbruce

Odpowiedzi:

27

Zarządzanie zależnościami jest dużym problemem w OOP z dwóch następujących powodów:

  • Ścisłe połączenie danych i kodu.
  • Wszechobecne stosowanie efektów ubocznych.

Większość programistów OO uważa ścisłe połączenie danych i kodu za całkowicie korzystne, ale wiąże się to z pewnymi kosztami. Zarządzanie przepływem danych przez warstwy jest nieuniknioną częścią programowania w dowolnym paradygmacie. Połączenie danych i kodu dodaje dodatkowy problem, że jeśli chcesz użyć funkcji w pewnym momencie, musisz znaleźć sposób, aby dostać się do tego obiektu.

Stosowanie efektów ubocznych stwarza podobne trudności. Jeśli używasz efektu ubocznego do niektórych funkcji, ale chcesz mieć możliwość zamiany jego implementacji, właściwie nie masz innego wyjścia, jak wstrzyknąć tę zależność.

Rozważmy jako przykład program spamujący, który usuwa strony internetowe w poszukiwaniu adresów e-mail, a następnie wysyła je pocztą elektroniczną. Jeśli masz sposób myślenia DI, teraz myślisz o usługach, które będziesz enkapsulować za interfejsami i które usługi zostaną tam wprowadzone. Zostawię ten projekt jako ćwiczenie dla czytelnika. Jeśli masz nastawienie FP, teraz myślisz o wejściach i wyjściach dla najniższej warstwy funkcji, takich jak:

  • Wpisz adres strony internetowej, wyślij tekst tej strony.
  • Wpisz tekst strony, wypisz listę linków z tej strony.
  • Wpisz tekst strony, wyślij listę adresów e-mail na tej stronie.
  • Wprowadź listę adresów e-mail, wyślij listę adresów e-mail z usuniętymi duplikatami.
  • Wpisz adres e-mail, wyślij spam dla tego adresu.
  • Wpisz spam, wyślij polecenia SMTP, aby wysłać ten email.

Gdy myślisz o wejściach i wyjściach, nie ma zależności funkcji, tylko zależności danych. Dzięki temu są tak łatwe do przetestowania w jednostce. Następna warstwa organizuje przekazywanie danych wyjściowych jednej funkcji do danych wejściowych kolejnej i może w razie potrzeby łatwo wymieniać różne implementacje.

W bardzo realnym sensie programowanie funkcjonalne w naturalny sposób prowadzi do odwrócenia zależności funkcji i dlatego zwykle nie trzeba podejmować żadnych specjalnych działań po tym fakcie. Gdy to zrobisz, narzędzia takie jak funkcje wyższego rzędu, zamknięcia i częściowe zastosowanie ułatwiają to przy mniejszej liczbie płyt kotłowych.

Zauważ, że same zależności nie są problematyczne. Zależności wskazują niewłaściwie. Następna warstwa może mieć funkcję:

processText = spamToSMTP . emailAddressToSpam . removeEmailDups . textToEmailAddresses

Zupełnie dobrze jest, aby ta warstwa miała tak zakodowane zależności, ponieważ jej jedynym celem jest sklejenie funkcji dolnej warstwy razem. Zamiana implementacji jest tak prosta, jak utworzenie innej kompozycji:

processTextFancy = spamToSMTP . emailAddressToFancySpam . removeEmailDups . textToEmailAddresses

Ta łatwa rekompozycja jest możliwa dzięki brakowi efektów ubocznych. Funkcje niższej warstwy są całkowicie od siebie niezależne. Następna warstwa może wybrać, która processTextjest faktycznie używana na podstawie konfiguracji użytkownika:

actuallyUsedProcessText = if (config == "Fancy") then processTextFancy else processText

Znowu nie jest to problem, ponieważ wszystkie zależności wskazują w jedną stronę. Nie musimy odwracać niektórych zależności, aby wszystkie wskazywały w ten sam sposób, ponieważ czyste funkcje już nas do tego zmusiły.

Zauważ, że możesz to uczynić o wiele bardziej sprzężonym, przechodząc configdo najniższej warstwy zamiast sprawdzać ją u góry. FP nie przeszkadza ci to robić, ale sprawia, że ​​staje się o wiele bardziej irytujący, jeśli spróbujesz.

Karl Bielefeldt
źródło
3
„Stosowanie efektów ubocznych stwarza podobne trudności. Jeśli użyjesz efektu ubocznego do niektórych funkcji, ale chcesz mieć możliwość zamiany jego implementacji, właściwie nie masz innego wyjścia, jak wstrzyknąć tę zależność”. Nie sądzę, żeby efekty uboczne miały z tym coś wspólnego. Jeśli chcesz zamienić implementacje w Haskell, nadal musisz wykonać wstrzyknięcie zależności . Opróżnij klasy typów, a przekażesz interfejs jako pierwszy argument każdej funkcji.
Doval
2
Sedno sprawy polega na tym, że prawie każdy język zmusza Cię do umieszczenia na stałe odniesień do innych modułów kodu, więc jedynym sposobem na zamianę implementacji jest użycie dynamicznej wysyłki wszędzie, a następnie nie możesz rozwiązać swoich zależności w czasie wykonywania. System modułowy pozwala wyrazić wykres zależności w czasie sprawdzania typu.
Doval
@ Doval - Dziękujemy za interesujące i dające do myślenia komentarze. Być może źle cię zrozumiałem, ale mam rację, wywnioskując z twoich komentarzy, że gdybym użył funkcjonalnego stylu programowania nad stylem DI (w tradycyjnym sensie C #), uniknęłbym możliwych frustracji związanych z debugowaniem związanych z czasem wykonywania rozwiązywanie zależności?
Matt Cashatt,
@MatthewPatrickCashatt To nie jest kwestia stylu ani paradygmatu, ale funkcji językowych. Jeśli język nie obsługuje modułów jako rzeczy pierwszorzędnych, będziesz musiał wykonać dynamiczne wysyłanie i wstrzykiwanie zależności do implementacji wymiany, ponieważ nie ma sposobu, aby wyrazić zależności statycznie. Mówiąc inaczej, jeśli Twój program C # używa ciągów, ma na stałe zależną od niego zależność System.String. System modułowy pozwoliłby zastąpić System.Stringzmienną, tak aby wybór implementacji łańcucha nie był zakodowany na stałe, ale nadal był rozstrzygany w czasie kompilacji.
Doval
8

czy programowanie funkcjonalne jest realną alternatywą dla wzorców wstrzykiwania zależności?

To uderza mnie jako dziwne pytanie. Metody programowania funkcjonalnego są w dużej mierze styczne do wstrzykiwania zależności.

Oczywiście, posiadanie niezmiennego stanu może zmusić cię do tego, aby nie „oszukiwać”, wywołując skutki uboczne lub wykorzystując stan klasy jako domyślną umowę między funkcjami. To sprawia, że ​​przekazywanie danych jest bardziej wyraźne, co, jak sądzę, jest najbardziej podstawową formą wstrzykiwania zależności. A koncepcja programowania funkcjonalnego polegająca na przekazywaniu funkcji znacznie ułatwia.

Ale to nie usuwa zależności. Twoje operacje nadal potrzebują wszystkich danych / operacji, których potrzebowali, gdy stan był zmienny. I nadal musisz jakoś zdobyć te zależności. Nie powiedziałbym więc, że funkcjonalne metody programowania w ogóle zastępują DI, więc nie są żadną alternatywą.

Jeśli już, to właśnie pokazali ci, jak zły kod OO może tworzyć ukryte zależności, niż programiści rzadko myślą.

Telastyn
źródło
Jeszcze raz dziękuję za wkład w rozmowę, Telastyn. Jak już wspomniałeś, moje pytanie nie jest zbyt dobrze skonstruowane (moje słowa), ale dzięki tym informacjom zaczynam rozumieć nieco lepiej to, co iskrzy mi w mózgu na ten temat: wszyscy się zgadzamy (Myślę), że testy jednostkowe mogą być koszmarem bez DI. Niestety użycie DI, szczególnie w kontenerach IoC, może stworzyć nową formę debugowania koszmaru, ponieważ rozwiązuje on zależności w czasie wykonywania. Podobnie jak DI, FP ułatwia testowanie jednostkowe, ale bez problemów związanych z zależnością czasu wykonywania.
Matt Cashatt,
(ciąg dalszy z góry). . To jest moje obecne rozumienie. Daj mi znać, jeśli zgubię znak. Nie mam nic przeciwko przyznaniu, że jestem tutaj zwykłym śmiertelnikiem wśród gigantów!
Matt Cashatt,
@MatthewPatrickCashatt - DI niekoniecznie oznacza problemy z zależnościami w czasie wykonywania, które, jak zauważasz, są okropne.
Telastyn
7

Szybka odpowiedź na twoje pytanie brzmi: nie .

Ale jak twierdzą inni, pytanie łączy dwie, nieco niepowiązane koncepcje.

Zróbmy to krok po kroku.

DI skutkuje niefunkcjonalnym stylem

Podstawą programowania funkcji są funkcje czyste - funkcje, które odwzorowują dane wejściowe na dane wyjściowe, dzięki czemu zawsze uzyskuje się takie same dane wyjściowe dla danego wejścia.

DI zwykle oznacza, że ​​urządzenie nie jest już czyste, ponieważ moc wyjściowa może się różnić w zależności od iniekcji. Na przykład w następującej funkcji:

const bookSeats = ( seatCount, getBookedSeatCount ) => { ... }

getBookedSeatCount(funkcja) może się różnić, dając różne wyniki dla tego samego danych wejściowych. To również czyni bookSeatsnieczystym.

Istnieją wyjątki od tego - możesz wstrzyknąć jeden z dwóch algorytmów sortujących, które implementują to samo odwzorowanie wejścia-wyjścia, aczkolwiek używając różnych algorytmów. Ale to są wyjątki.

System nie może być czysty

Fakt, że system nie może być czysty, jest równie ignorowany, jak to jest stwierdzone w źródłach programowania funkcjonalnego.

System musi mieć skutki uboczne, a oczywistymi przykładami są:

  • Interfejs użytkownika
  • Baza danych
  • API (w architekturze klient-serwer)

Więc część twojego systemu musi wiązać się z efektami ubocznymi, a ta część może również obejmować imperatywny styl lub styl OO.

Paradygmat shell-core

Pożyczając terminy z doskonałej rozmowy Gary'ego Bernhardta na temat granic , dobra architektura systemowa (lub modułowa) będzie obejmować te dwie warstwy:

  • Rdzeń
    • Czyste funkcje
    • Rozgałęzienie
    • Bez zależności
  • Muszla
    • Nieczyste (skutki uboczne)
    • Bez rozgałęzień
    • Zależności
    • Może być konieczny, obejmować styl OO itp.

Kluczową kwestią jest podzielenie systemu na jego czystą część (rdzeń) i nieczystą (powłokę).

Mimo że oferuje nieco wadliwe rozwiązanie (i wniosek), artykuł Marka Seemanna proponuje tę samą koncepcję. Wdrożenie Haskell jest szczególnie wnikliwe, ponieważ pokazuje, że można to wszystko zrobić przy użyciu FP.

DI i FP

Stosowanie DI jest całkowicie uzasadnione, nawet jeśli większość aplikacji jest czysta. Kluczem jest ograniczenie DI w nieczystej powłoce.

Przykładem mogą być kody pośredniczące API - chcesz mieć prawdziwy interfejs API w produkcji, ale używaj kodów pośredniczących w testowaniu. Przestrzeganie modelu z rdzeniem skorupowym bardzo pomoże tutaj.

Wniosek

Zatem FP i DI nie są dokładnie alternatywami. Prawdopodobnie będziesz mieć oba w swoim systemie, a rada polega na zapewnieniu separacji między czystą i nieczystą częścią systemu, w której rezydują odpowiednio FP i DI.

Izhaki
źródło
Kiedy odwołujesz się do paradygmatu rdzeń-powłoka, jak można osiągnąć brak rozgałęzień w powłoce? Mogę wymyślić wiele przykładów, w których aplikacja musiałaby zrobić jedną nieczystą rzecz na podstawie wartości. Czy ta zasada braku rozgałęzień ma zastosowanie w językach takich jak Java?
jrahhali
@jrahhali Szczegółowe informacje można znaleźć w wykładzie Gary'ego Bernhardta (link w odpowiedzi).
Izhaki
kolejny relavent serii Seemann blog.ploeh.dk/2017/01/27/...
jk.
1

Z punktu widzenia OOP funkcje można uznać za interfejsy jednej metody.

Interfejs to silniejszy kontrakt niż funkcja.

Jeśli korzystasz z podejścia funkcjonalnego i wykonujesz dużo DI, to w porównaniu z podejściem OOP otrzymasz więcej kandydatów na każdą zależność.

void DoStuff(Func<DateTime> getDateTime) {}; //Anything that satisfies the signature can be injected.

vs

void DoStuff(IDateTimeProvider dateTimeProvider) {}; //Only types implementing the interface can be injected.
Legowisko
źródło
3
Każda klasa może być opakowana w celu implementacji interfejsu, więc „silniejszy kontrakt” nie jest dużo silniejszy. Co ważniejsze, nadanie każdej funkcji innego typu sprawia, że ​​tworzenie granic funkcji jest niemożliwe.
Doval,
Programowanie funkcjonalne nie oznacza „programowania z funkcjami wyższego rzędu”, odnosi się do znacznie szerszej koncepcji, funkcje wyższego rzędu to tylko jedna technika przydatna w paradygmacie.
Jimmy Hoffa,