Ekstrakcja metody a podstawowe założenia

27

Kiedy dzielę duże metody (lub procedury lub funkcje - to pytanie nie jest specyficzne dla OOP, ale ponieważ pracuję w językach OOP w 99% przypadków, to terminologia, z którą czuję się najlepiej) na wiele małych , Często jestem niezadowolony z wyników. Trudniej jest rozumować te małe metody niż wtedy, gdy były to tylko bloki kodu w dużej, ponieważ gdy je wydobywam, tracę wiele podstawowych założeń, które wynikają z kontekstu wywołującego.

Później, kiedy patrzę na ten kod i widzę poszczególne metody, nie od razu wiem, skąd są one wywoływane, i myślę o nich jako o zwykłych metodach prywatnych, które można wywoływać z dowolnego miejsca w pliku. Wyobraźmy sobie na przykład metodę inicjowania (konstruktora lub inną) podzieloną na szereg małych: w kontekście samej metody wyraźnie wiesz, że stan obiektu jest nadal nieprawidłowy, ale w zwykłej prywatnej metodzie prawdopodobnie wychodzisz z założenia, że ​​obiekt jest już zainicjowany i jest w poprawnym stanie.

Jedynym rozwiązaniem, jakie widziałem w tym przypadku, jest whereklauzula w Haskell, która pozwala zdefiniować małe funkcje, które są używane tylko w funkcji „nadrzędnej”. Zasadniczo wygląda to tak:

len x y = sqrt $ (sq x) + (sq y)
    where sq a = a * a

Ale inne języki, których używam, nie mają czegoś takiego - najbliższą rzeczą jest zdefiniowanie lambdy w zasięgu lokalnym, co prawdopodobnie jest jeszcze bardziej mylące.

Moje pytanie brzmi: czy napotykasz to i czy w ogóle widzisz, że to problem? Jeśli tak, to jak zazwyczaj to rozwiązujesz, szczególnie w „głównych” językach OOP, takich jak Java / C # / C ++?

Edytuj o duplikatach: jak zauważyli inni, są już pytania dotyczące metod podziału i drobne pytania, które są jednowierszowe. Przeczytałem je i nie dyskutują na temat podstawowych założeń, które można wyprowadzić z kontekstu dzwoniącego (na przykład powyżej, obiekt jest inicjowany). To jest sens mojego pytania i dlatego moje pytanie jest inne.

Aktualizacja: Jeśli podążyłeś za tym pytaniem i dyskusją poniżej, możesz polubić ten artykuł Johna Carmacka na ten temat , w szczególności:

Oprócz wiedzy o wykonywanym kodzie, funkcje wstawiania mają tę zaletę, że nie umożliwiają wywołania funkcji z innych miejsc. Brzmi to absurdalnie, ale ma to sens. Ponieważ baza kodów rośnie z biegiem lat, pojawi się wiele okazji, aby skorzystać ze skrótu i ​​po prostu wywołać funkcję, która wykonuje tylko pracę, którą Twoim zdaniem należy wykonać. Może istnieć funkcja FullUpdate (), która wywołuje PartialUpdateA () i PartialUpdateB (), ale w niektórych szczególnych przypadkach możesz zdać sobie sprawę (lub pomyśleć), że potrzebujesz tylko PartialUpdateB () i jesteś wydajny, unikając innych praca. Wynika z tego mnóstwo błędów. Większość błędów wynika z faktu, że stan wykonania nie jest dokładnie taki, jak myślisz.

Max Yankov
źródło
@gnat pytanie, które podłączyłeś, omawia, czy w ogóle wyodrębniać funkcje, a ja nie kwestionuję tego. Zamiast tego kwestionuję najbardziej optymalną metodę do tego.
Max Yankov
2
@gnat istnieją inne powiązane pytania, ale stamtąd nie dyskutuje się o tym, że ten kod może opierać się na konkretnych założeniach, które są ważne tylko w kontekście osoby dzwoniącej.
Max Yankov
1
@Doval z mojego doświadczenia, naprawdę. Kiedy pojawiają się kłopotliwe metody pomocnicze, jak to opisujesz, zajmuje się tym wyodrębnienie nowej spójnej klasy
komara

Odpowiedzi:

29

Wyobraźmy sobie na przykład metodę inicjowania podzieloną na szereg małych: w kontekście samej metody wyraźnie wiesz, że stan obiektu jest nadal nieprawidłowy, ale w zwykłej prywatnej metodzie prawdopodobnie wychodzisz z założenia, że ​​obiekt jest już zainicjowany i jest w prawidłowym stanie. Jedyne rozwiązanie, jakie widziałem, to ...

Twoje obawy są uzasadnione. Jest inne rozwiązanie.

Zrób krok wstecz. Jaki jest zasadniczo cel metody? Metody wykonują tylko jedną z dwóch rzeczy:

  • Podaj wartość
  • Powodować efekt

Lub niestety oba. Staram się unikać metod, które robią obie rzeczy, ale wiele robi. Powiedzmy, że wytworzony efekt lub wytworzona wartość jest „wynikiem” metody.

Zauważ, że metody są wywoływane w „kontekście”. Co to za kontekst?

  • Wartości argumentów
  • Stan programu poza metodą

Zasadniczo zwracasz uwagę: poprawność wyniku metody zależy od kontekstu, w którym jest ona wywoływana .

Nazywamy te warunki wymagane przed organem metoda zaczyna do sposobu wytwarzania prawidłowy wynik jego warunki wstępne , a my nazywamy warunków, które zostaną wytworzone po ciało metoda zwraca jego postconditions .

Zasadniczo, na co zwracasz uwagę: kiedy wyodrębniam blok kodu do jego własnej metody, tracę informacje kontekstowe na temat warunków wstępnych i końcowych .

Rozwiązaniem tego problemu jest wyraźne określenie warunków wstępnych i dodatkowych w programie . Na przykład w języku C # można użyć Debug.Assertlub Kod umów, aby wyrazić warunki wstępne i dodatkowe.

Na przykład: Kiedyś pracowałem na kompilatorze, który przechodził przez kilka „etapów” kompilacji. Najpierw kod byłby leksykowany, następnie analizowany, następnie typy byłyby rozwiązywane, następnie sprawdzane były hierarchie dziedziczenia pod kątem cykli i tak dalej. Każdy fragment kodu był bardzo wrażliwy na jego kontekst; na przykład katastrofalne byłoby pytanie „czy można przekształcić ten typ w ten typ?” jeśli wykres typów bazowych nie był jeszcze znany jako acykliczny! Dlatego każdy fragment kodu wyraźnie udokumentował swoje warunki wstępne. Pragniemy assertw sposób, który sprawdził dla typu wymienialności że już przeszły „typy bazowe Acylic” czek, a potem stało się jasne dla czytelnika, gdy sposób można nazwać i gdzie nie można było nazywane.

Oczywiście istnieje wiele sposobów, w jaki dobry projekt metody łagodzi zidentyfikowany problem:

  • tworzyć metody, które są użyteczne ze względu na ich efekty lub wartość, ale nie oba
  • tworzyć metody, które są możliwie „czyste”; „czysta” metoda generuje wartość, która zależy tylko od jej argumentów i nie daje żadnego efektu. Są to najłatwiejsze do uzasadnienia metody, ponieważ „kontekst”, którego potrzebują, jest bardzo zlokalizowany.
  • zminimalizować liczbę mutacji, które występują w stanie programu; mutacje to punkty, w których kod staje się trudniejszy do uzasadnienia
Eric Lippert
źródło
+1 za odpowiedź wyjaśniającą problem w kategoriach warunków wstępnych / dodatkowych.
Pytanie C
5
Dodałbym, że często jest możliwe (i dobry pomysł!) Delegowanie sprawdzania warunków wstępnych i końcowych do systemu typów. Jeśli masz funkcję, która pobiera stringi zapisuje ją w bazie danych, istnieje ryzyko wstrzyknięcia SQL, jeśli zapomnisz ją wyczyścić. Z drugiej strony, jeśli twoja funkcja wymaga a SanitisedString, a jedynym sposobem na uzyskanie a SantisiedStringjest wywołanie Sanitise, to wykluczyłeś błędy iniekcji SQL ze względu na budowę. Coraz częściej szukam sposobów, aby kompilator odrzucił niepoprawny kod.
Benjamin Hodgson
+1 Jedną z rzeczy, na które należy zwrócić uwagę, jest to, że podzielenie dużej metody na mniejsze części jest kosztem: zazwyczaj nie jest to przydatne, chyba że warunki wstępne i dodatkowe są bardziej zrelaksowane niż byłyby początkowo, i możesz w końcu ponieść koszty, ponownie wykonując kontrole, które w innym przypadku zostałyby już wykonane. To nie jest całkowicie „darmowy” proces refaktoryzacji.
Mehrdad
„Co to za kontekst?” tylko dla wyjaśnienia, miałem na myśli głównie prywatny stan obiektu, w którym ta metoda jest wywoływana. Wydaje mi się, że należy do drugiej kategorii.
Max Yankov
To doskonała i prowokująca do myślenia odpowiedź, dziękuję. (Nie mówiąc oczywiście, że inne odpowiedzi są w jakikolwiek sposób złe). Nie oznaczę jeszcze pytania jako odpowiedzi, ponieważ naprawdę podoba mi się tutaj dyskusja (i zwykle kończy się, gdy odpowiedź jest oznaczona jako odpowiedź) i potrzebuję czasu, aby ją przetworzyć i przemyśleć.
Max Yankov
13

Często to widzę i zgadzam się, że to problem. Zwykle rozwiązuję to, tworząc obiekt metody : nową wyspecjalizowaną klasę, której członkami są zmienne lokalne z oryginalnej, zbyt dużej metody.

Nowa klasa ma zwykle nazwę „Eksporter” lub „Tabulacja” i otrzymuje wszelkie informacje niezbędne do wykonania tego konkretnego zadania z szerszego kontekstu. Następnie można zdefiniować jeszcze mniejsze fragmenty kodu pomocnika, które nie są narażone na użycie do niczego poza tabelowaniem lub eksportowaniem.

Kilian Foth
źródło
Naprawdę podoba mi się ten pomysł, im więcej o nim myślę. Może to być klasa prywatna w klasie publicznej lub wewnętrznej. Nie zaśmiecaj przestrzeni nazw klasami, na których zależy ci tylko lokalnie, i jest to sposób na zaznaczenie, że są to „pomocnicy konstruktorów”, „pomocnicy parsowania” lub cokolwiek innego.
Mike wspiera Monikę
Ostatnio znalazłem się w sytuacji, która byłaby do tego idealna z perspektywy architektury. Napisałem renderer oprogramowania z klasą renderer i publiczną metodą renderowania, która miała wiele kontekstów, których używała do wywoływania innych metod. Zastanawiałem się nad stworzeniem do tego osobnej klasy RenderContext, jednak przydzielanie i zwalnianie tego projektu dla każdej klatki wydawało się ogromnie marnotrawstwem. github.com/golergka/tinyrenderer/blob/master/src/renderer.h
Max Yankov
6

Wiele języków pozwala zagnieżdżać funkcje takie jak Haskell. Java / C # / C ++ są w rzeczywistości względnymi wartościami odstającymi. Niestety są tak popularne, że ludzie zaczynają myśleć: „To musi być zły pomysł, w przeciwnym razie mój ulubiony język„ głównego nurtu ”na to pozwala”.

Java / C # / C ++ zasadniczo uważa, że ​​klasa powinna być jedyną grupą metod, jakiej kiedykolwiek potrzebujesz. Jeśli masz tak wiele metod, że nie możesz określić ich kontekstów, możesz zastosować dwa ogólne podejścia: posortuj je według kontekstu lub podziel je według kontekstu.

Sortowanie według kontekstu to jedno zalecenie sformułowane w Czystym kodzie , w którym autor opisuje wzorzec „akapitów DO”. Zasadniczo polega to na umieszczeniu funkcji pomocniczych bezpośrednio po funkcji, która je wywołuje, dzięki czemu można je czytać jak akapity w artykule w gazecie, a więcej szczegółów można uzyskać w miarę czytania. Myślę, że w swoich filmach nawet je wcina.

Drugim podejściem jest podzielenie klas. Nie można tego posunąć zbyt daleko, ponieważ denerwująca jest potrzeba tworzenia instancji obiektów, zanim będzie można wywołać na nich dowolną metodę, oraz nieodłączne problemy z decyzją, która z kilku małych klas powinna być właścicielem każdej części danych. Jeśli jednak zidentyfikowałeś już kilka metod, które tak naprawdę pasują tylko do jednego kontekstu, prawdopodobnie są dobrym kandydatem do rozważenia umieszczenia ich we własnej klasie. Na przykład, złożoną inicjalizację można wykonać w kreacyjnym wzorcu, takim jak builder.

Karl Bielefeldt
źródło
Funkcje zagnieżdżania ... czy nie takie funkcje lambda osiągają w C # (i Java 8)?
Arturo Torres Sánchez
Myślałem bardziej jak zamknięcie zdefiniowane nazwą, jak te przykłady python . Jagnięta nie są najlepszym sposobem na zrobienie czegoś takiego. Są bardziej do krótkich wyrażeń, takich jak predykat filtru.
Karl Bielefeldt
Te przykłady w języku Python są z pewnością możliwe w języku C #. Na przykład silnia . Mogą być bardziej gadatliwi, ale są w 100% możliwe.
Arturo Torres Sánchez
2
Nikt nie powiedział, że to niemożliwe. OP wspomniał nawet o użyciu lambdas w swoim pytaniu. Po prostu wyodrębnij metodę ze względu na czytelność, byłoby miło, gdyby była bardziej czytelna.
Karl Bielefeldt
Twój pierwszy akapit wydaje się sugerować, że nie jest to możliwe, zwłaszcza z twoim cytatem: „To musi być zły pomysł, w przeciwnym razie mój ulubiony język„ głównego nurtu ”na to pozwoli”.
Arturo Torres Sánchez
4

Myślę, że w większości przypadków odpowiedzią jest kontekst. Jako programista piszący kod powinieneś założyć, że Twój kod zostanie w przyszłości zmieniony. Klasa może być zintegrowana z inną klasą, może zastąpić wewnętrzny algorytm lub zostać podzielona na kilka klas w celu utworzenia abstrakcji. Są to rzeczy, które początkujący programiści zwykle nie biorą pod uwagę, co powoduje konieczność niepotrzebnych obejść lub późniejszych przeglądów.

Wyodrębnianie metod jest dobre, ale do pewnego stopnia. Zawsze próbuję zadać sobie następujące pytania podczas inspekcji lub przed napisaniem kodu:

  • Czy ten kod jest używany tylko przez tę klasę / funkcję? czy pozostanie taki sam w przyszłości?
  • Jeśli będę musiał zrezygnować z niektórych konkretnych implementacji, czy mogę to zrobić z łatwością?
  • Czy inni programiści w moim zespole mogą zrozumieć, co zrobiono w tej funkcji?
  • Czy ten sam kod jest używany gdzieś indziej w tej klasie? powinieneś unikać powielania w prawie wszystkich przypadkach.

W każdym razie zawsze myśl o pojedynczej odpowiedzialności. Klasa powinna ponosić jedną odpowiedzialność, jej funkcje powinny służyć jednej stałej usłudze, a jeśli wykonują wiele akcji, akcje te powinny mieć swoje własne funkcje, więc łatwo je rozróżnić lub zmienić później.

Tomer Blu
źródło
1

Trudniej jest rozumować te małe metody niż wtedy, gdy były to tylko bloki kodu w dużej, ponieważ gdy je wydobywam, tracę wiele podstawowych założeń, które wynikają z kontekstu wywołującego.

Nie zdawałem sobie sprawy z tego, jak duży to był problem, dopóki nie przyjąłem ECS, który zachęcał do większych, pętlowych funkcji systemowych (przy czym jedyne systemy mają funkcje) i zależności płynących w kierunku surowych danych , a nie abstrakcji.

To, ku mojemu zaskoczeniu, dało bazę kodów o wiele łatwiejszą do uzasadnienia i utrzymania w porównaniu do baz kodów, w których pracowałem w przeszłości, gdzie podczas debugowania trzeba było prześledzić wszystkie rodzaje drobnych małych funkcji, często poprzez abstrakcyjne wywołania funkcji przez czyste interfejsy prowadzące do tego, kto wie, dokąd się do niego nie prześledzisz, tylko po to, by spawnować pewną kaskadę zdarzeń, które prowadzą do miejsc, o których nie myślałeś, że kod powinien kiedykolwiek prowadzić.

W przeciwieństwie do Johna Carmacka, moim największym problemem z tymi bazami kodów nie była wydajność, ponieważ nigdy nie miałem tak bardzo ciasnego zapotrzebowania na silniki gier AAA na opóźnienia i większość naszych problemów z wydajnością dotyczyła bardziej przepustowości. Oczywiście możesz również zacząć utrudniać optymalizację hotspotów, gdy pracujesz w węższych i węższych ramach funkcji i klas Teeniera i Teeniera bez przeszkadzania tej strukturze (wymagając, abyś stopił wszystkie te małe elementy z powrotem na coś większego, zanim będzie można zacząć skutecznie sobie z tym radzić).

Jednak największym problemem dla mnie było to, że pomimo wszystkich testów nie mogłem w sposób pewny uzasadnić ogólnej poprawności systemu. Miałem zbyt wiele do zrozumienia i zrozumienia, ponieważ ten rodzaj systemu nie pozwolił ci o tym myśleć bez uwzględnienia wszystkich tych drobnych szczegółów i niekończących się interakcji między drobnymi funkcjami i obiektami, które działały wszędzie. Było zbyt wiele „co jeśli?”, Zbyt wiele rzeczy, które należało wywołać we właściwym czasie, zbyt wiele pytań o to, co by się stało, gdyby nazwano ich niewłaściwym czasem (które zaczynają się wznosić do punktu paranoi, kiedy mieć jedno zdarzenie wyzwalające inne zdarzenie wywołujące inne, prowadzące do różnego rodzaju nieprzewidywalnych miejsc) itp.

Teraz podoba mi się mój duży tyłek, 80-liniowy, tu i tam, o ile nadal wykonują pojedynczą i wyraźną odpowiedzialność i nie mają 8 poziomów zagnieżdżonych bloków. Powodują wrażenie, że w systemie jest mniej rzeczy do przetestowania i zrozumienia, nawet jeśli mniejsze, pokrojone w kostkę wersje tych większych funkcji były tylko prywatnymi szczegółami implementacji, których nikt inny nie mógł wywołać ... wciąż, jakoś, wydaje się, że w całym systemie dzieje się mniej interakcji. Lubię nawet bardzo skromne powielanie kodu, o ile nie jest to skomplikowana logika (powiedzmy tylko 2-3 wiersze kodu), jeśli oznacza to mniej funkcji. Podoba mi się rozumowanie Carmacka dotyczące wbudowania, które uniemożliwia wywołanie tej funkcji w innym miejscu pliku źródłowego. Tam'

Prostota nie zawsze zmniejsza złożoność na poziomie dużego obrazu, jeśli opcja jest pomiędzy jedną mięsistą funkcją a 12 bardzo prostymi, które wywołują się nawzajem za pomocą złożonego wykresu zależności. Pod koniec dnia często musisz zastanowić się nad tym, co dzieje się poza funkcją, musisz dowiedzieć się, do czego ostatecznie przyczyniają się te funkcje, i trudniej jest zobaczyć ten duży obraz, jeśli musisz wywnioskować z tego najmniejsze puzzle.

Oczywiście bardzo dobrze przetestowany kod typu biblioteki ogólnego przeznaczenia może zostać zwolniony z tej reguły, ponieważ taki kod ogólnego przeznaczenia często działa i sam sobie radzi. Jest również mniejszy w porównaniu z kodem nieco bliższym do domeny twojej aplikacji (tysiące wierszy kodu, a nie milionów) i jest tak szeroko stosowany, że zaczyna być częścią codziennego słownictwa. Ale z czymś bardziej specyficznym dla twojej aplikacji, w której niezmienniki systemowe, które musisz utrzymywać, wykraczają daleko poza jedną funkcję lub klasę, wydaje mi się, że to pomaga mieć pełniejsze funkcje z jakiegokolwiek powodu. O wiele łatwiej jest mi pracować z większymi puzzlami, próbując dowiedzieć się, co się dzieje z dużym obrazem.


źródło
0

Nie sądzę, że to duży problem, ale zgadzam się, że to kłopotliwe. Zwykle umieszczam pomocnika bezpośrednio za jego beneficjentem i dodam przyrostek „Pomocnik”. To plus specyfikator privatedostępu powinien jasno określić swoją rolę. Jeśli istnieje jakiś niezmiennik, który nie działa, gdy wywoływany jest pomocnik, dodaję komentarz w pomocniku.

To rozwiązanie ma tę niefortunną wadę, że nie uchwyca zakresu funkcji, którą pomaga. Idealnie twoje funkcje są małe, więc mam nadzieję, że nie spowoduje to zbyt wielu parametrów. Zwykle rozwiązujesz ten problem, definiując nowe struktury lub klasy w celu powiązania parametrów, ale wymagana do tego ilość płyty kotłowej może być z łatwością dłuższa niż sam pomocnik, a następnie wracasz tam, gdzie zacząłeś bez oczywistego sposobu skojarzenia struct z funkcją.

Wspomniałeś już o innym rozwiązaniu - zdefiniuj pomocnika wewnątrz głównej funkcji. W niektórych językach może to być dość nietypowy idiom, ale nie sądzę, że byłoby to mylące (chyba że twoi rówieśnicy są zdezorientowani przez lambdas w ogóle). Działa to tylko wtedy, gdy można łatwo zdefiniować funkcje lub obiekty podobne do funkcji. Nie próbowałbym tego na przykład w Javie 7, ponieważ anonimowa klasa wymaga wprowadzenia 2 poziomów zagnieżdżenia nawet dla najmniejszej „funkcji”. To jest tak blisko letlub whereklauzuli, jak można dostać; możesz odwoływać się do zmiennych lokalnych przed definicją, a pomocnika nie można używać poza tym zakresem.

Doval
źródło