Jak przefakturować program OO na funkcjonalny?

26

Mam trudności ze znalezieniem zasobów na temat pisania programów w funkcjonalnym stylu. Najbardziej zaawansowanym tematem, jaki mogłem znaleźć w Internecie, było użycie pisania strukturalnego w celu ograniczenia hierarchii klas; większość po prostu zajmuje się tym, jak używać map / fold / zmniejsz / etc, aby zastąpić imperatywne pętle.

Naprawdę chciałbym znaleźć dogłębną dyskusję na temat implementacji OOP nietrywialnego programu, jego ograniczeń i sposobu refaktoryzacji w funkcjonalnym stylu. Nie tylko algorytm lub struktura danych, ale coś z kilkoma różnymi rolami i aspektami - być może gra wideo. Nawiasem mówiąc, przeczytałam Tomas Petricek, „Real-World Functional Programming”, ale chciałem więcej.

Asik
źródło
6
nie sądzę, żeby to było możliwe. musisz przeprojektować (i przepisać) wszystko ponownie.
Bryan Chen
18
-1, ten post jest stronniczy z powodu błędnego założenia, że ​​OOP i styl funkcjonalny są sprzeczne. Są to głównie ortogonalne koncepcje, a IMHO to mit, że nimi nie są. „Funkcjonalny” jest bardziej przeciwny do „Proceduralnego”, a oba style można stosować w połączeniu z OOP.
Doc Brown,
11
@DocBrown, OOP zbyt mocno opiera się na stanie zmiennym. Bezstanowe obiekty nie pasują dobrze do obecnej praktyki projektowania OOP.
SK-logic
9
@ SK-logic: kluczem nie są obiekty bezstanowe, ale obiekty niezmienne. A nawet gdy obiekty są zmienne, często można ich używać w funkcjonalnej części systemu, o ile nie są zmieniane w danym kontekście. Co więcej, myślę, że wiesz, że przedmioty i zamknięcia są wymienne. To wszystko pokazuje, że OOP i „funkcjonalne” nie są sprzeczne.
Doc Brown,
12
@DocBrown: Myślę, że konstrukcje językowe są ortogonalne, podczas gdy nastawienia mają tendencję do kolidowania. Ludzie OOP zwykle pytają „jakie są obiekty i jak współpracują?”; funkcjonalni ludzie pytają „jakie są moje dane i jak chcę je przekształcić?”. To nie są te same pytania i prowadzą do różnych odpowiedzi. Myślę też, że źle odczytałeś pytanie. To nie jest „śliniki OOP i zasady FP, jak się pozbyć OOP?”, To „dostaję OOP i nie dostaję FP, czy istnieje sposób na przekształcenie programu OOP w funkcjonalny, więc mogę uzyskać jakiś wgląd? ”.
Michael Shaw

Odpowiedzi:

31

Definicja programowania funkcjonalnego

Wstęp do Radości Clojure mówi:

Programowanie funkcjonalne jest jednym z tych terminów komputerowych, które mają amorficzną definicję. Jeśli poprosisz 100 programistów o ich definicję, prawdopodobnie otrzymasz 100 różnych odpowiedzi ...

Programowanie funkcjonalne dotyczy i ułatwia stosowanie i układ funkcji ... Aby język można było uznać za funkcjonalny, jego pojęcie funkcji musi być najwyższej klasy. Funkcje pierwszej klasy mogą być przechowywane, przekazywane i zwracane tak jak każdy inny element danych. Oprócz tej podstawowej koncepcji [definicje FP mogą obejmować] czystość, niezmienność, rekurencję, lenistwo i przejrzystość referencyjną.

Programowanie w Scali Wydanie 2 s. 10 ma następującą definicję:

Programowanie funkcjonalne opiera się na dwóch głównych pomysłach. Pierwszym pomysłem jest to, że funkcje są pierwszorzędnymi wartościami ... Możesz przekazywać funkcje jako argumenty do innych funkcji, zwracać je jako wyniki funkcji lub przechowywać w zmiennych ...

Drugą główną ideą programowania funkcjonalnego jest to, że operacje programu powinny mapować wartości wejściowe na wartości wyjściowe, a nie zmieniać dane w miejscu.

Jeśli zaakceptujemy pierwszą definicję, jedyną rzeczą, którą musisz zrobić, aby Twój kod był „funkcjonalny”, jest wywrócenie pętli na lewą stronę. Druga definicja obejmuje niezmienność.

Funkcje pierwszej klasy

Wyobraź sobie, że obecnie otrzymujesz Listę Pasażerów z obiektu Autobus i iterujesz nad nią, zmniejszając rachunek bankowy każdego pasażera o kwotę opłaty za autobus. Funkcjonalnym sposobem wykonania tej samej akcji byłoby posiadanie metody na magistrali, być może nazywanej forEachPassenger, która przyjmuje funkcję jednego argumentu. Wówczas Bus będzie iterował swoich pasażerów, ale najlepiej to osiągnąć, a kod klienta, który nalicza opłatę za przejazd, zostałby włączony w funkcję i przekazany do forEachPassenger. Voila! Używasz programowania funkcjonalnego.

Tryb rozkazujący:

for (Passenger p : Bus.getPassengers()) {
    p.debit(fare);
}

Funkcjonalny (przy użyciu anonimowej funkcji lub „lambda” w Scali):

myBus = myBus.forEachPassenger(p:Passenger -> { p.debit(fare) })

Bardziej słodka wersja Scali:

myBus = myBus.forEachPassenger(_.debit(fare))

Funkcje nie pierwszej klasy

Jeśli twój język nie obsługuje pierwszorzędnych funkcji, może to być bardzo brzydkie. W Javie 7 lub wcześniejszej musisz udostępnić interfejs „Obiekt funkcjonalny” w następujący sposób:

// Java 8 has java.util.function.Consumer, but in earlier
// versions you have to roll your own:
public interface Consumer<T> {
    public void accept(T t);
}

Następnie klasa Bus zapewnia wewnętrzny iterator:

public void forEachPassenger(Consumer<Passenger> c) {
    for (Passenger p : passengers) {
        c.accept(p);
    }
}

Na koniec przekazujesz anonimowy obiekt funkcji do magistrali:

// Java 8 has syntactic sugar to make this look more like
// the Scala solution, but earlier versions require manually
// instantiating a "Function Object," in this case, a
// Consumer:
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
    }
}

Java 8 umożliwia przechwytywanie zmiennych lokalnych w zakresie funkcji anonimowej, ale we wcześniejszych wersjach wszelkie takie zmienne muszą być deklarowane jako ostateczne. Aby obejść ten problem, może być konieczne utworzenie klasy opakowania MutableReference. Oto klasa specyficzna dla liczb całkowitych, która pozwala dodać licznik pętli do powyższego kodu:

public static class MutableIntWrapper {
    private int i;
    private MutableIntWrapper(int in) { i = in; }
    public static MutableIntWrapper ofZero() {
        return new MutableIntWrapper(0);
    }
    public int value() { return i; }
    public void increment() { i++; }
}

final MutableIntWrapper count = MutableIntWrapper.ofZero();
Bus.forEachPassenger(new Consumer<Passenger>() {
    @Override
    public void accept(final Passenger p) {
        p.debit(fare);
        count.increment();
    }
}

System.out.println(count.value());

Nawet przy tej brzydocie czasem korzystne jest wyeliminowanie skomplikowanej i powtarzalnej logiki z pętli rozproszonych w całym programie poprzez zapewnienie wewnętrznego iteratora.

Ta brzydota została naprawiona w Javie 8, ale obsługa sprawdzonych wyjątków wewnątrz funkcji pierwszej klasy jest nadal bardzo brzydka, a Java nadal zakłada założenie zmienności we wszystkich swoich kolekcjach. Co prowadzi nas do innych celów często związanych z FP:

Niezmienność

Przedmiotem 13 Josha Blocha jest „Preferuj niezmienność”. Mimo zwykłych śmieci mówi się inaczej, OOP można zrobić z niezmiennymi obiektami, a to czyni go znacznie lepszym. Na przykład String w Javie jest niezmienny. StringBuffer, OTOH musi być modyfikowalny, aby zbudować niezmienny ciąg. Niektóre zadania, takie jak praca z buforami, z natury wymagają modyfikacji.

Czystość

Każda funkcja powinna przynajmniej być zapamiętywalna - jeśli podasz jej te same parametry wejściowe (i nie powinna mieć żadnych danych wejściowych oprócz faktycznych argumentów), powinna generować to samo wyjście za każdym razem bez powodowania „efektów ubocznych”, takich jak zmiana stanu globalnego, wykonanie I / O lub zgłaszanie wyjątków.

Powiedziano, że w Programowaniu Funkcjonalnym „zwykle zło jest potrzebne do wykonania pracy”. 100% czystości na ogół nie jest celem. Minimalizacja skutków ubocznych to.

Wniosek

Naprawdę, ze wszystkich powyższych pomysłów, niezmienność była największą pojedynczą wygraną pod względem praktycznych zastosowań dla uproszczenia mojego kodu - czy to OOP, czy FP. Przekazywanie funkcji iteratorom to druga największa wygrana. Dokumentacja Java 8 Lambdas zawiera najlepsze wyjaśnienie, dlaczego. Rekurencja jest świetna do przetwarzania drzew. Lenistwo pozwala pracować z nieskończonymi kolekcjami.

Jeśli podoba Ci się JVM, polecam spojrzeć na Scalę i Clojure. Oba są wnikliwymi interpretacjami programowania funkcjonalnego. Scala jest bezpieczna dla typu z składnią nieco podobną do C, chociaż tak naprawdę ma tak wiele składni wspólnych z Haskellem jak z C. Clojure nie jest bezpieczny dla typu i jest Lispem. Niedawno opublikowałem porównanie Java, Scala i Clojure w odniesieniu do jednego konkretnego problemu z refaktoryzacją. Porównanie Logana Campbella z użyciem Game of Life obejmuje również Haskella i napisane Clojure.

PS

Jimmy Hoffa zwrócił uwagę, że moja klasa autobusów jest zmienna. Zamiast naprawić oryginał, myślę, że pokaże to dokładnie rodzaj refaktoryzacji tego pytania. Można to naprawić, ustawiając każdą metodę w autobusie jako fabrykę produkującą nowy autobus, każdą metodę w pasażerze - fabrykę produkującą nowego pasażera. Dlatego dodałem typ zwracany do wszystkiego, co oznacza, że ​​skopiuję java.util.function.Function Java 8 zamiast interfejsu konsumenta:

public interface Function<T,R> {
    public R apply(T t);
    // Note: I'm leaving out Java 8's compose() method here for simplicity
}

Następnie w autobusie:

public Bus mapPassengers(Function<Passenger,Passenger> c) {
    // I have to use a mutable collection internally because Java
    // does not have immutable collections that return modified copies
    // of themselves the way the Clojure and Scala collections do.
    List<Passenger> newPassengers = new ArrayList(passengers.size());
    for (Passenger p : passengers) {
        newPassengers.add(c.apply(p));
    }
    return Bus.of(driver, Collections.unmodifiableList(passengers));
}

Wreszcie anonimowy obiekt funkcji zwraca zmodyfikowany stan rzeczy (nowy autobus z nowymi pasażerami). Zakłada się, że p.debit () zwraca teraz nowego niezmiennego Pasażera z mniejszą ilością pieniędzy niż oryginał:

Bus b = b.mapPassengers(new Function<Passenger,Passenger>() {
    @Override
    public Passenger apply(final Passenger p) {
        return p.debit(fare);
    }
}

Mamy nadzieję, że możesz teraz podjąć własną decyzję o tym, jak funkcjonalny chcesz uczynić swój imperatywny język, i zdecydować, czy lepiej byłoby przeprojektować swój projekt za pomocą funkcjonalnego języka. W Scali lub Clojure kolekcje i inne interfejsy API zostały zaprojektowane w celu ułatwienia programowania funkcjonalnego. Oba mają bardzo dobre współdziałanie Java, dzięki czemu można mieszać i dopasowywać języki. W rzeczywistości, dla interoperacyjności Java, Scala kompiluje swoje funkcje pierwszej klasy do anonimowych klas, które są prawie kompatybilne z interfejsami funkcjonalnymi Java 8. Możesz przeczytać o szczegółach w Scala w sekcie Głębokość. 1.3.2 .

GlenPeterson
źródło
Doceniam wysiłek, organizację i jasną komunikację w tej odpowiedzi; ale muszę trochę poradzić sobie z niektórymi technikami. Jednym z kluczy wspomnianych u góry jest kompozycja funkcji, to sięga wstecz, dlaczego w dużej mierze enkapsulacja funkcji wewnątrz obiektów nie daje celu: jeśli funkcja znajduje się wewnątrz obiektu, musi tam być, aby działać na tym obiekcie; a jeśli działa na ten obiekt, musi zmieniać swoje elementy wewnętrzne. Teraz wybaczę, że nie każdy wymaga przejrzystości odniesienia lub niezmienności, ale jeśli zmieni obiekt na miejscu, nie będzie go już musiał zwracać
Jimmy Hoffa
I gdy tylko funkcja nie zwróci wartości, nagle funkcja nie może być skomponowana z innymi i tracisz całą abstrakcję składu funkcjonalnego. Możesz sprawić, by funkcja zmieniła obiekt na miejscu, a następnie zwróciła obiekt, ale jeśli to robi, dlaczego po prostu nie sprawić, aby funkcja wzięła obiekt jako parametr i uwolniła go z ograniczeń obiektu nadrzędnego? Uwolniony od obiektu nadrzędnego będzie mógł pracować również na innych typach, co jest kolejną ważną częścią FP, której ci brakuje: abstrakcja typu. Twój forEachPasenger działa tylko przeciwko pasażerom ...
Jimmy Hoffa
1
Abstrahujesz od mapowania i zmniejszania obiektów, a te funkcje nie są powiązane z zawieraniem obiektów, dzięki czemu można ich używać na niezliczonej liczbie typów poprzez polimorfizm parametryczny. To połączenie tych różnorodnych abstrakcji, których nie ma w językach OOP, naprawdę definiuje FP i nadaje mu wartość. To nie tak, że lenistwo, więzy przejrzystości, niezmienność, a nawet system typu HM są niezbędne do stworzenia FP, te rzeczy są efekty raczej uboczne tworzenia języków przeznaczona dla kompozycji funkcjonalnej gdzie funkcje mogą streszczenie nad typy ogólnie
Jimmy Hoffa
@ JimmyHoffa Bardzo rzetelnie skrytykowałeś mój przykład. Uwiodła mnie zmienność interfejsu Java8 Consumer. Ponadto definicja FP chouser / fogus nie zawierała niezmienności, a później dodałem definicję Odersky / Spoon / Venners. Zostawiłem oryginalny przykład, ale dodałem nową, niezmienną wersję w sekcji „PS” na dole. Jest brzydki. Ale myślę, że pokazuje funkcje działające na obiekty w celu wytworzenia nowych obiektów zamiast zmiany wewnętrznych oryginałów. Świetny komentarz!
GlenPeterson
1
Ta rozmowa jest kontynuowana na tablicy: chat.stackexchange.com/transcript/message/11702383#11702383
GlenPeterson
12

Mam osobiste doświadczenie w „osiągnięciu” tego. W końcu nie wymyśliłem czegoś, co jest czysto funkcjonalne, ale wymyśliłem coś, z czego jestem zadowolony. Oto jak to zrobiłem:

  • Konwertuj cały stan zewnętrzny na parametr funkcji. EG: jeśli xmetoda obiektu ulegnie modyfikacji , zmień ją tak, aby metoda była przekazywana xzamiast wywoływania this.x.
  • Usuń zachowanie z obiektów.
    1. Udostępnij dane obiektu publicznie
    2. Konwertuj wszystkie metody na funkcje wywoływane przez obiekt.
    3. Kod klienta, który wywołuje obiekt, wywołuje nową funkcję, przekazując dane obiektu. EG: Konwertuj x.methodThatModifiesTheFooVar()nafooFn(x.foo)
    4. Usuń oryginalną metodę z obiektu
  • Wymień jak wielu pętli iteracyjnych jak można z funkcja wyższego rzędu podoba map, reduce, filter, itd.

Nie mogłem pozbyć się stanu zmiennego. W moim języku (JavaScript) było to zbyt mało idiomatyczne. Ale poprzez przekazanie i zwrócenie wszystkich stanów, każdą funkcję można przetestować. Różni się to od OOP, gdzie konfiguracja stanu zajęłaby zbyt długo lub oddzielenie zależności często wymaga najpierw modyfikacji kodu produkcyjnego.

Mogę się również mylić co do definicji, ale myślę, że moje funkcje są referencyjnie przejrzyste: moje funkcje będą miały taki sam efekt przy tych samych danych wejściowych.

Edytować

Jak widać tutaj , w JavaScript nie można stworzyć naprawdę niezmiennego obiektu. Jeśli jesteś sumienny i kontrolujesz, kto wywołuje Twój kod, możesz to zrobić, zawsze tworząc nowy obiekt zamiast mutować bieżący. Nie było to dla mnie warte wysiłku.

Ale jeśli używasz Javy , możesz użyć tych technik, aby uczynić swoje klasy niezmiennymi.

Daniel Kaplan
źródło
+1 W zależności od tego, co dokładnie próbujesz zrobić, jest to prawdopodobnie tak daleko, jak to możliwe, bez wprowadzania zmian projektowych, które wykraczałyby poza samo „refaktoryzowanie”.
Evicatos
@Evicatos: Nie wiem, jeśli JavaScript ma lepszą obsługę niezmiennego stanu, myślę, że moje rozwiązanie byłoby tak funkcjonalne, jak w dynamicznym języku funkcjonalnym, takim jak Clojure. Jaki jest przykład czegoś, co wymagałoby czegoś więcej niż tylko refaktoryzacji?
Daniel Kaplan
Myślę, że pozbycie się stanu zmiennego kwalifikuje się. Nie sądzę, że jest to tylko kwestia lepszego wsparcia w języku, myślę, że przejście od zmiennego do niezmiennego będzie w zasadzie zawsze wymagało fundamentalnych zmian architektonicznych, które zasadniczo stanowią przepisanie. Ymmv w zależności od twojej definicji refaktoryzacji.
Evicatos
@Evicatos zobacz moją edycję
Daniel Kaplan
1
@tieTYT tak, to smutne, że JS jest tak zmienny, ale przynajmniej Clojure może skompilować do JavaScript: github.com/clojure/clojurescript
GlenPeterson
3

Nie sądzę, że naprawdę można całkowicie zrefaktoryzować program - musiałbyś przeprojektować i ponownie wdrożyć w prawidłowym paradygmacie.

Widziałem refaktoryzację kodu zdefiniowaną jako „zdyscyplinowana technika restrukturyzacji istniejącego fragmentu kodu, zmieniająca jego wewnętrzną strukturę bez zmiany jego zachowania zewnętrznego”.

Możesz sprawić, by pewne rzeczy były bardziej funkcjonalne, ale nadal masz program zorientowany obiektowo. Nie możesz po prostu zmieniać małych kawałków, aby dostosować go do innego paradygmatu.

Uri
źródło
Dodałbym, że dobrym pierwszym znakiem jest dążenie do przejrzystości referencyjnej. Po uzyskaniu tego zyskujesz ~ 50% korzyści z programowania funkcjonalnego.
Daniel Gratzer
3

Myślę, że ta seria artykułów jest dokładnie tym, czego chcesz:

Czysto funkcjonalne Retrogames

http://prog21.dadgum.com/23.html Część 1

http://prog21.dadgum.com/24.html Część 2

http://prog21.dadgum.com/25.html Część 3

http://prog21.dadgum.com/26.html Część 4

http://prog21.dadgum.com/37.html Dalsze działania

Podsumowanie to:

Autor sugeruje główną pętlę z efektami ubocznymi (efekty uboczne muszą się gdzieś zdarzyć, prawda?), A większość funkcji zwraca małe niezmienne zapisy opisujące, w jaki sposób zmieniły stan gry.

Oczywiście, pisząc program z prawdziwego świata, będziesz mieszał i dopasowywał kilka stylów programowania, wykorzystując każdy, w którym najbardziej pomaga. Jednak dobrym doświadczeniem w nauce jest pisanie programu w najbardziej funkcjonalny / niezmienny sposób, a także pisanie go w najbardziej spaghetti, używając tylko zmiennych globalnych :-) (proszę zrobić to w ramach eksperymentu, a nie w produkcji)

Marek
źródło
2

Prawdopodobnie musiałbyś wywrócić cały kod na lewą stronę, ponieważ OOP i FP mają dwa przeciwne podejścia do organizowania kodu.

OOP organizuje kod wokół typów (klas): różne klasy mogą implementować tę samą operację (metoda o tej samej sygnaturze). W rezultacie OOP jest bardziej odpowiednie, gdy zestaw operacji niewiele się zmienia, a nowe typy można bardzo często dodawać. Rozważmy na przykład bibliotekę GUI, w którym każdy widget ma stały zestaw metod ( hide(), show(), paint(), move(), i tak dalej), ale nowe widżety mogą być dodawane jako biblioteki zostanie przedłużony. W OOP łatwo jest dodać nowy typ (dla danego interfejsu): wystarczy dodać nową klasę i zaimplementować wszystkie jej metody (lokalna zmiana kodu). Z drugiej strony dodanie nowej operacji (metody) do interfejsu może wymagać zmiany wszystkich klas implementujących ten interfejs (nawet jeśli dziedziczenie może zmniejszyć ilość pracy).

FP organizuje kod wokół operacji (funkcji): każda funkcja implementuje pewne operacje, które mogą traktować różne typy na różne sposoby. Zwykle osiąga się to poprzez wysyłkę do typu poprzez dopasowanie wzoru lub inny mechanizm. W rezultacie FP jest bardziej odpowiedni, gdy zestaw typów jest stabilny, a nowe operacje są dodawane częściej. Weźmy na przykład stały zestaw formatów obrazu (GIF, JPEG itp.) I niektóre algorytmy, które chcesz wdrożyć. Każdy algorytm może być zaimplementowany przez funkcję, która zachowuje się inaczej w zależności od typu obrazu. Dodanie nowego algorytmu jest łatwe, ponieważ wystarczy zaimplementować nową funkcję (zmiana kodu lokalnego). Dodanie nowego formatu (typu) wymaga modyfikacji wszystkich funkcji, które do tej pory zaimplementowałeś w celu jego obsługi (zmiana nielokalna).

Konkluzja: OOP i FP różnią się zasadniczo pod względem sposobu organizacji kodu, a zmiana projektu OOP na projekt FP wymagałaby zmiany całego kodu w celu odzwierciedlenia tego. Może to być ciekawe ćwiczenie. Zobacz także te notatki do wykładu do książki SICP cytowanej przez mikemay, w szczególności slajdy od 13.1.5 do 13.1.10.

Giorgio
źródło