Zbyt dużo abstrakcji utrudnia rozszerzenie kodu


9

Mam problemy z tym, co uważam za zbyt abstrakcyjne w bazie kodu (a przynajmniej radzenie sobie z tym). Większość metod w bazie kodu została wyodrębniona w celu przyjęcia najwyższego rodzica A w bazie kodu, ale dziecko B tego rodzica ma nowy atrybut, który wpływa na logikę niektórych z tych metod. Problem polega na tym, że tych atrybutów nie można sprawdzić w tych metodach, ponieważ dane wejściowe są abstrakcyjne do A, a A oczywiście nie ma tego atrybutu. Jeśli spróbuję stworzyć nową metodę obsługi B w inny sposób, zostanie ona wywołana w celu powielenia kodu. Sugestia mojego kierownika technicznego polega na stworzeniu wspólnej metody, która przyjmuje parametry logiczne, ale problem polega na tym, że niektórzy ludzie postrzegają to jako „ukryty przepływ kontroli”, gdzie wspólna metoda ma logikę, która może nie być widoczna dla przyszłych programistów , a także ta wspólna metoda stanie się nadmiernie złożona / skomplikowana jeden raz, jeśli konieczne będzie dodanie przyszłych atrybutów, nawet jeśli zostanie ona podzielona na mniejsze wspólne metody. Zwiększa to również sprzężenie, zmniejsza spójność i narusza zasadę pojedynczej odpowiedzialności, na co zwrócił uwagę ktoś z mojego zespołu.

Zasadniczo duża część abstrakcji w tej bazie kodu pomaga ograniczyć powielanie kodu, ale utrudnia rozszerzanie / zmienianie metod, gdy są zmuszone do pobierania najwyższej abstrakcji. Co powinienem zrobić w takiej sytuacji? Jestem w centrum winy, chociaż wszyscy inni nie mogą się zgodzić, co uważają za dobre, więc w końcu mnie to boli.


10
dodanie próbki kodu w celu analfabetyzowania „problemu” pomogłoby lepiej zrozumieć sytuację
Seabizkit

Myślę, że złamano tutaj dwie SOLIDNE zasady. Pojedyncza odpowiedzialność - jeśli przekażesz wartość logiczną do funkcji, która ma kontrolować zachowanie, funkcja nie będzie już miała pojedynczej odpowiedzialności. Drugi to zasada podstawienia Liskowa. Wyobraź sobie, że istnieje funkcja, która przyjmuje klasę A jako parametr. Jeśli zdasz w klasie B zamiast A, czy funkcjonalność tej funkcji zostanie zerwana?
bobek

Podejrzewam, że metoda A jest dość długa i robi więcej niż jedną rzecz. Czy tak jest w przypadku?
Rad80

Odpowiedzi:


27

Jeśli spróbuję stworzyć nową metodę obsługi B w inny sposób, zostanie ona wywołana w celu powielenia kodu.

Nie wszystkie duplikacje kodu są sobie równe.

Załóżmy, że masz metodę, która pobiera dwa parametry i dodaje je razem total(). Załóżmy, że masz inną add(). Ich implementacje wyglądają zupełnie identycznie. Czy powinny zostać połączone w jedną metodę? NIE!!!

Zasada Don't-Repeat-Yourself lub DRY nie polega na powtarzaniu kodu. Chodzi o rozpowszechnianie decyzji, pomysłów, więc jeśli kiedykolwiek zmienisz swój pomysł, musisz przepisać wszędzie, gdzie rozpowszechniasz ten pomysł. Blegh. To straszne. Nie rób tego Zamiast tego użyj DRY, aby pomóc Ci podejmować decyzje w jednym miejscu .

Zasada DRY (Don't Repeat Yourself) mówi:

Każda wiedza musi mieć jedną, jednoznaczną, autorytatywną reprezentację w systemie.

wiki.c2.com - Nie powtarzaj się

Ale DRY może zostać zepsute w nawyk skanowania kodu w poszukiwaniu podobnej implementacji, która wydaje się być kopią i wklejaniem gdzie indziej. To martwa mózg forma SUSZENIA. Do diabła, możesz to zrobić za pomocą narzędzia do analizy statycznej. Nie pomaga, ponieważ ignoruje sens DRY, który polega na zachowaniu elastyczności kodu.

Jeśli zmienią się moje wymagania sumowania, być może będę musiał zmienić totalimplementację. To nie znaczy, że muszę zmienić addimplementację. Jeśli jakiś Goober zgniótł je razem w jedną metodę, teraz czeka mnie niepotrzebny ból.

Ile bólu Z pewnością mógłbym po prostu skopiować kod i stworzyć nową metodę, gdy jej potrzebuję. Więc nic wielkiego, prawda? Malarky! Jeśli nic więcej nie kosztuje mnie dobre imię! Dobre imiona są trudne do zdobycia i nie reagują dobrze, gdy majstrujesz przy ich znaczeniu. Dobre nazwy, które wyraźnie wyjaśniają cel, są ważniejsze niż ryzyko, że skopiujesz błąd, który, szczerze mówiąc, łatwiej jest naprawić, gdy twoja metoda ma prawidłową nazwę.

Tak więc radzę przestać pozwalać reakcjom szarpiącym kolana na podobny kod wiązać bazę kodu w węzłach. Nie twierdzę, że możesz zignorować fakt, że istnieją metody, a zamiast tego skopiuj i wklej willy nilly. Nie, każda metoda powinna mieć cholernie dobrą nazwę, która popiera jeden pomysł, o którym chodzi. Jeśli jego implementacja będzie pasować do implementacji innego dobrego pomysłu, to teraz, komu, do diabła, to obchodzi?

Z drugiej strony, jeśli masz sum()metodę, która ma identyczną lub nawet inną implementację niż total(), ale za każdym razem, gdy zmieniają się twoje łączne wymagania, musisz zmienić, sum()to jest duża szansa, że ​​są to ten sam pomysł pod dwiema różnymi nazwami. Kod byłby nie tylko bardziej elastyczny, gdyby zostały scalone, ale korzystanie z niego byłoby mniej skomplikowane.

Jeśli chodzi o parametry boolowskie, to nieprzyjemny zapach kodu. Kontrola przepływu nie tylko stanowi problem, ale gorzej pokazuje, że w złym momencie włączyłeś abstrakcję. Abstrakcje mają uprościć, a nie skomplikować. Przekazywanie booli do metody kontrolującej jej zachowanie jest jak tworzenie tajnego języka, który decyduje, którą metodę naprawdę wywołujesz. Ow! Nie rób mi tego. Nadaj każdej metodzie własną nazwę, chyba że masz jakiś uczciwy sposób na pogarszanie się polimorfizmu .

Teraz wydajesz się wypalony abstrakcją. Szkoda, ponieważ abstrakcja jest cudowną rzeczą, gdy jest dobrze wykonana. Używasz go dużo, nie myśląc o tym. Za każdym razem, gdy prowadzisz samochód bez konieczności rozumienia mechanizmu zębatkowego, za każdym razem, gdy używasz polecenia drukowania, nie myśląc o przerwach w systemie operacyjnym, i za każdym razem, gdy myjesz zęby, nie myśląc o każdym włosiu.

Nie, problemem, z którym się wydajesz, jest zła abstrakcja. Abstrakcja stworzona, by służyć innym celom niż twoje potrzeby. Potrzebujesz prostych interfejsów do złożonych obiektów, które pozwolą ci zaspokoić twoje potrzeby bez konieczności rozumienia tych obiektów.

Pisząc kod klienta korzystający z innego obiektu, wiesz, jakie są Twoje potrzeby i czego potrzebujesz od tego obiektu. Tak nie jest. Dlatego kod klienta jest właścicielem interfejsu. Kiedy jesteś klientem, nic nie jest w stanie powiedzieć, jakie są twoje potrzeby oprócz ciebie. Udostępniasz interfejs, który pokazuje, jakie są twoje potrzeby i żądasz, aby wszystko, co zostanie ci przekazane, spełniło te potrzeby.

To jest abstrakcja. Jako klient nie wiem nawet, z kim rozmawiam. Po prostu wiem, czego potrzebuję. Jeśli to oznacza, że ​​musisz coś zawinąć, aby zmienić jego interfejs, zanim w porządku. Nie obchodzi mnie to. Po prostu rób to, co muszę zrobić. Przestań to komplikować.

Jeśli muszę zajrzeć do abstrakcji, aby zrozumieć, jak z niej korzystać, abstrakcja nie powiodła się. Nie powinienem wiedzieć, jak to działa. Po prostu to działa. Nadaj mu dobre imię, a jeśli zajrzę do środka, nie powinienem być zaskoczony tym, co znajdę. Nie każ mi zaglądać do środka, żeby pamiętać, jak go używać.

Kiedy nalegasz, aby abstrakcja działała w ten sposób, liczba poziomów za nią nie ma znaczenia. Tak długo, jak nie patrzysz za abstrakcją. Nalegasz, aby abstrakcja była zgodna z twoimi potrzebami, nie dostosowując się do niej. Aby to zadziałało, musi być łatwe w użyciu, mieć dobre imię i nie przeciekać .

Takie podejście zrodziło Dependency Injection (lub po prostu odniesienie do podania, jeśli jesteś starą szkołą taką jak ja). Działa to dobrze, preferując kompozycję i przekazywanie zamiast dziedziczenia . Takie podejście ma wiele nazw. Moim ulubionym jest powiedz, nie pytaj .

Mógłbym cię utopić w zasadach przez cały dzień. I wygląda na to, że twoi współpracownicy już są. Ale o to chodzi: w przeciwieństwie do innych dziedzin inżynierii, to oprogramowanie ma mniej niż 100 lat. Wszyscy wciąż to rozgryzamy. Więc nie pozwól, aby ktoś z dużą ilością zastraszających, brzmiących książek nauczył cię pisania trudnego do odczytania kodu. Słuchaj ich, ale nalegaj, aby miały sens. Nie bierz niczego na wiarę. Ludzie, którzy w jakiś sposób kodują tylko dlatego, że powiedziano im to w ten sposób, nie wiedząc, dlaczego robią największe bałagany ze wszystkich.


Z całego serca się zgadzam. DRY to trzyliterowy akronim trzyliterowego haseł Don't Repeat Yourself, który z kolei jest 14-stronicowym artykułem na wiki . Jeśli wszystko co robisz jest ślepo mamrocząc te trzy litery bez zapoznaniem artykuł 14 stronie będzie prowadzony w kłopoty. Jest również ściśle związany z Raz na Raz (OAOO), a luźniej związany z Single Point Of Truth (SPOT) / Single Source Of Truth (SSOT) .
Jörg W Mittag

„Ich implementacje wyglądają zupełnie identycznie. Czy powinny zostać połączone w jedną metodę? NIE !!!” - Odwrotna jest również prawda: fakt, że dwa fragmenty kodu są różne, nie oznacza, że ​​nie są duplikatami. Na stronie wiki OAOO znajduje się świetny cytat autorstwa Rona Jeffriesa : „Kiedyś widziałem, jak Beck deklaruje dwie łatki prawie zupełnie innego kodu jako„ duplikację ”, zmień je tak, aby BYŁO duplikacją, a następnie usuń nowo wstawioną duplikację, aby się pojawiła z czymś wyraźnie lepszym. ”
Jörg W Mittag

@ JörgWMittag oczywiście. Istotna jest idea. Jeśli powielasz ten pomysł innym kodem, nadal naruszasz zasady suche.
candied_orange

Muszę sobie wyobrazić, że 14-stronicowy artykuł na temat nie powtarzania się często by się powtarzał.
Chuck Adams

7

Zwykłe powiedzenie, że wszyscy tu czytamy i brzmi:

Wszystkie problemy można rozwiązać, dodając kolejną warstwę abstrakcji.

To nie jest prawda! Twój przykład to pokazuje. Proponuję zatem nieco zmodyfikowane oświadczenie (zachęcamy do ponownego użycia ;-)):

Każdy problem można rozwiązać za pomocą WŁAŚCIWEGO poziomu abstrakcji.

W twoim przypadku występują dwa różne problemy:

  • nadmierne uogólnienie spowodowane dodając każdą metodę na abstrakcyjnym poziomie;
  • fragmentacja z konkretnymi zachowaniami, które prowadzą do wrażenia nie dostaję duże zdjęcie i uczucie utracone. Trochę jak w pętli zdarzeń systemu Windows.

Oba są powiązane:

  • jeśli wyodrębnisz metodę, w której każda specjalizacja robi to inaczej, wszystko będzie dobrze. Nikt nie ma problemu z uchwyceniem tego, że Shapemożna go obliczyć surface()w wyspecjalizowany sposób.
  • Jeśli wyodrębnisz operację, w której występuje ogólny ogólny wzór zachowania, masz dwie możliwości:

    • albo powtórzysz wspólne zachowanie w każdej specjalizacji: jest to bardzo zbędne; i trudne w utrzymaniu, szczególnie w celu zapewnienia, że ​​wspólna część pozostaje zgodna we wszystkich specjalizacjach:
    • używasz jakiegoś wariantu wzorca metody szablonów : pozwala to uwzględnić wspólne zachowanie, stosując dodatkowe abstrakcyjne metody, które można łatwo specjalizować. Jest mniej zbędny, ale dodatkowe zachowania często stają się bardzo podzielone. Zbyt wiele oznaczałoby, że jest to może zbyt abstrakcyjne.

Ponadto takie podejście może skutkować abstrakcyjnym efektem sprzężenia na poziomie projektu. Za każdym razem, gdy chcesz dodać jakieś nowe wyspecjalizowane zachowanie, musisz je wyodrębnić, zmienić abstrakcyjnego rodzica i zaktualizować wszystkie pozostałe klasy. To nie jest rodzaj propagacji zmian, jakiego można by się spodziewać. I to nie jest tak naprawdę w duchu abstrakcji, która nie zależy od specjalizacji (przynajmniej od projektu).

Nie znam twojego projektu i nie mogę pomóc więcej. Być może jest to naprawdę bardzo złożony i abstrakcyjny problem i nie ma lepszego sposobu. Ale jakie są szanse? Objawy nadmiernej generalizacji są tutaj. Może nadszedł czas, aby spojrzeć na to jeszcze raz i rozważyć kompozycję nad generalizacją ?


5

Ilekroć widzę metodę, w której zachowanie włącza typ jej parametru, od razu zastanawiam się, czy metoda rzeczywiście należy do parametru metody. Na przykład zamiast metody takiej jak:

public void sort(List values) {
    if (values instanceof LinkedList) {
        // do efficient linked list sort
    } else { // ArrayList
        // do efficient array list sort
    }
}

Zrobiłbym to:

values.sort();

// ...

class ArrayList {
    public void sort() {
        // do efficient array list sort
    }
}

class LinkedList {
    public void sort() {
        // do efficient linked list sort
    }
}

Przenosimy zachowanie do miejsca, które wie, kiedy go użyć. Tworzymy prawdziwą abstrakcję, w której nie musisz znać rodzajów ani szczegółów wdrożenia. W twojej sytuacji bardziej sensowne może być przeniesienie tej metody z oryginalnej klasy (którą wywołam O), aby wpisać Ai przesłonić ją w tekście B. Jeśli metoda jest wywoływana doItdla jakiegoś obiektu, przejdź doItdo Ai zastąp go innym zachowaniem w B. Jeśli istnieją bity danych, z których doItpierwotnie wywoływano, lub jeśli metoda jest używana w wystarczającej liczbie miejsc, możesz opuścić oryginalną metodę i przekazać:

class O {
    int x;
    int y;

    public void doIt(A a) {
        a.doIt(this.x, this.y);
    }
}

Możemy jednak zanurkować nieco głębiej. Spójrzmy na sugestię, aby zamiast tego użyć parametru boolowskiego i zobaczmy, czego możemy się dowiedzieć o sposobie myślenia twojego współpracownika. Jego propozycja polega na:

public void doIt(A a, boolean isTypeB) {
    if (isTypeB) {
        // do B stuff
    } else { 
        // do A stuff
    }
}

Wygląda to okropnie podobnie jak instanceofw moim pierwszym przykładzie, z tą różnicą, że przekazujemy tę kontrolę na zewnątrz. Oznacza to, że musielibyśmy to nazwać na jeden z dwóch sposobów:

o.doIt(a, a instanceof B);

lub:

o.doIt(a, true); //or false

Po pierwsze, punkt wywoławczy nie ma pojęcia, jaki ma typ A. Czy zatem powinniśmy przepuszczać booleany do samego końca? Czy to naprawdę wzór, który chcemy w całej bazie kodu? Co się stanie, jeśli istnieje trzeci typ, który musimy uwzględnić? Jeśli tak się nazywa ta metoda, powinniśmy przenieść ją na typ i pozwolić systemowi wybrać dla nas implementację polimorficzną.

Po drugie, musimy już znać rodzaj apunktu wywoławczego. Zwykle oznacza to, że albo tworzymy tam instancję, albo bierzemy instancję tego typu jako parametr. Stworzenie metody, Októra zajmuje Btutaj, działałoby. Kompilator będzie wiedział, którą metodę wybrać. Kiedy przeprowadzamy takie zmiany, powielanie jest lepsze niż tworzenie niewłaściwej abstrakcji , przynajmniej dopóki nie dowiemy się, dokąd naprawdę zmierzamy. Oczywiście sugeruję, że tak naprawdę nie jesteśmy skończeni bez względu na to, co zmieniliśmy do tego momentu.

Musimy przyjrzeć się bliżej relacjom między Ai B. Mówi się nam ogólnie, że powinniśmy preferować kompozycję zamiast dziedziczenia . Nie jest to prawdą w każdym przypadku, ale jest to prawdą w zaskakującej liczbie przypadków, gdy zagłębimy się w. BDziedziczy A, co oznacza, że ​​wierzymy, że Bjest A. Bpowinien być używany tak samo A, z tym wyjątkiem, że działa trochę inaczej. Ale jakie są te różnice? Czy możemy nadać różnicom bardziej konkretną nazwę? Czy to nie Bjest A, ale tak naprawdę Ama Xto może być A'albo B'? Jak wyglądałby nasz kod, gdybyśmy to zrobili?

Jeśli przenieśliśmy metodę Ajak sugerowano wcześniej, moglibyśmy wstrzyknąć instancję Xdo Ai delegować tę metodę do X:

class A {
    X x;
    A(X x) {
        this.x = x;
    }

    public void doIt(int x, int y) {
        x.doIt(x, y);
    }
}

Możemy realizować A'i B', i pozbyć B. Ulepszyliśmy kod, nadając nazwę koncepcji, która mogła być bardziej niejawna, i pozwoliliśmy sobie ustawić to zachowanie w czasie wykonywania zamiast czasu kompilacji. Astało się również mniej abstrakcyjne. Zamiast relacji rozszerzonego dziedziczenia wywołuje metody na obiekcie delegowanym. Ten obiekt jest abstrakcyjny, ale bardziej skupia się tylko na różnicach w implementacji.

Jest jeszcze jedna rzecz do obejrzenia. Cofnijmy się do propozycji twojego współpracownika. Jeśli we wszystkich witrynach z rozmowami wyraźnie znamy rodzaj Anaszego połączenia, powinniśmy wykonywać połączenia takie jak:

B b = new B();
o.doIt(b, true);

Założyliśmy wcześniej podczas komponowania że Ama Xto albo A'albo B'. Ale może nawet to założenie jest nieprawidłowe. Jest to jedyne miejsce, gdzie ta różnica między Ai Bsprawy? Jeśli tak, to może możemy przyjąć nieco inne podejście. Nadal mamy X, że jest albo A'albo B', ale nie należy do A. O.doItTroszczy się tylko o to, więc przekażmy to tylko O.doIt:

class O {
    int x;
    int y;

    public void doIt(A a, X x) {
        x.doIt(a, x, y);
    }
}

Teraz nasza strona połączeń wygląda następująco:

A a = new A();
o.doIt(a, new B'());

Po raz kolejny Bznika, a abstrakcja przechodzi w bardziej skupioną X. Tym razem Ajest jeszcze prostszy, wiedząc mniej. To jest jeszcze mniej abstrakcyjne.

Ważne jest ograniczenie duplikacji w bazie kodu, ale musimy najpierw rozważyć, dlaczego duplikacja ma miejsce. Duplikacja może być oznaką głębszych abstrakcji, które próbują się wydostać.


1
Uderza mnie to, że przykładowy „zły” kod, który podajesz tutaj, jest podobny do tego, co chciałbym zrobić w języku innym niż OO. Zastanawiam się, czy nauczyli się złych lekcji i wprowadzili je do świata OO w sposób, w jaki kodują?
Baldrickk

1
@Baldrickk Każdy paradygmat ma swój własny sposób myślenia, z jego wyjątkowymi zaletami i wadami. W funkcjonalnym Haskell lepszym rozwiązaniem byłoby dopasowanie wzoru. Chociaż w takim języku niektóre aspekty pierwotnego problemu również nie byłyby możliwe.
cbojar

1
To jest poprawna odpowiedź. Metodą zmieniającą implementację w zależności od typu, na którym działa, powinna być metoda tego typu.
Roman Reiner

0

Abstrakcja przez dziedziczenie może stać się dość brzydka. Hierarchie klas równoległych z typowymi fabrykami. Refaktoryzacja może stać się bólem głowy. A także późniejszy rozwój, miejsce, w którym się znajdujesz.

Istnieje alternatywa: punkty rozszerzenia , ścisłe abstrakty i wielopoziomowe dostosowywanie. Powiedz jedno dostosowanie klientów rządowych na podstawie tego dostosowania dla konkretnego miasta.

Ostrzeżenie: Niestety działa to najlepiej, gdy wszystkie (lub większość) klas są rozszerzone. Brak opcji dla ciebie, może w małych.

Ta rozszerzalność polega na tym, że klasa podstawowa rozszerzalnego obiektu zawiera rozszerzenia:

void f(CreditorBO creditor) {
    creditor.as(AllowedCreditorBO.class).ifPresent(allowedCreditor -> ...);
}

Wewnętrznie istnieje leniwe mapowanie obiektu na obiekty rozszerzone według klasy rozszerzenia.

W przypadku klas GUI i komponentów ta sama rozszerzalność, częściowo z dziedziczeniem. Dodawanie przycisków i tym podobne.

W twoim przypadku sprawdzanie poprawności powinno sprawdzać, czy jest rozszerzone i sprawdzać się względem rozszerzeń. Wprowadzenie punktów rozszerzenia tylko dla jednego przypadku dodaje niezrozumiały kod, co nie jest dobre.

Nie ma więc rozwiązania, próba pracy w obecnym kontekście.


0

„ukryta kontrola przepływu” wydaje mi się zbyt skomplikowana.
Każda konstrukcja lub element wyjęte z kontekstu mogą mieć tę cechę.

Abstrakcje są dobre. Ulepszam je za pomocą dwóch wskazówek:

  • Lepiej nie streszczać zbyt wcześnie. Poczekaj na więcej przykładów wzorów przed zatarciem. „Więcej” jest oczywiście subiektywne i specyficzne dla trudnej sytuacji.

  • Unikaj zbyt wielu poziomów abstrakcji tylko dlatego, że abstrakcja jest dobra. Programiści będą musieli zachować te poziomy w głowie, aby uzyskać nowy lub zmieniony kod, gdy zgłębią bazę kodu i przejdą 12 poziomów w głąb. Pragnienie dobrze wyodrębnionego kodu może prowadzić do tak wielu poziomów, że jest to trudne dla wielu ludzi. Prowadzi to również do baz kodowych utrzymywanych tylko przez ninja.

W obu przypadkach „więcej” i „zbyt wiele” nie są stałymi liczbami. To zależy. To sprawia, że ​​jest to trudne.

Podoba mi się również ten artykuł Sandi Metz

https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstraction

powielanie jest znacznie tańsze niż zła abstrakcja
i
wolę powielanie niż zła abstrakcja

Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.