Jaki kod powinien być zawarty w klasie abstrakcyjnej?

10

Ostatnio niepokoją mnie zastosowania klas abstrakcyjnych.

Czasami klasa abstrakcyjna jest wcześniej tworzona i działa jako szablon tego, jak działają klasy pochodne. Oznacza to mniej więcej, że zapewniają one pewną funkcjonalność na wysokim poziomie, ale pomijają pewne szczegóły do ​​zaimplementowania przez klasy pochodne. Klasa abstrakcyjna określa potrzebę tych szczegółów, wprowadzając pewne metody abstrakcyjne. W takich przypadkach klasa abstrakcyjna działa jak plan, ogólny opis funkcjonalności lub jakkolwiek chcesz to nazwać. Nie można go używać samodzielnie, ale musi być wyspecjalizowany, aby zdefiniować szczegóły, które zostały pominięte we wdrożeniu wysokiego poziomu.

Innym razem zdarza się, że klasa abstrakcyjna jest tworzona po utworzeniu niektórych klas „pochodnych” (ponieważ klasy macierzystej / abstrakcyjnej nie ma, nie zostały jeszcze wyprowadzone, ale wiesz, co mam na myśli). W takich przypadkach klasa abstrakcyjna jest zwykle używana jako miejsce, w którym można umieścić dowolny rodzaj wspólnego kodu, który zawiera bieżące klasy pochodne.

Po dokonaniu powyższych obserwacji zastanawiam się, który z tych dwóch przypadków powinien być regułą. Czy jakikolwiek szczegół powinien zostać przekazany do klasy abstrakcyjnej tylko dlatego, że obecnie są one wspólne dla wszystkich klas pochodnych? Czy powinien istnieć wspólny kod, który nie jest częścią funkcjonalności wysokiego poziomu?

Czy kod, który może nie mieć żadnego znaczenia dla samej klasy abstrakcyjnej, powinien istnieć tylko dlatego, że zdarza się, że jest wspólny dla klas pochodnych?

Podam przykład: Klasa abstrakcyjna A ma metodę a () i metodę abstrakcyjną aq (). Metoda aq (), w obu pochodnych klasach AB i AC, wykorzystuje metodę b (). Czy b () przeniósł się do A? Jeśli tak, to w przypadku, gdyby ktoś patrzył tylko na A (udawajmy, że nie ma AB i AC), istnienie b () nie miałoby większego sensu! Czy to źle? Czy ktoś powinien spojrzeć na klasę abstrakcyjną i zrozumieć, co się dzieje bez odwiedzania klas pochodnych?

Szczerze mówiąc, w momencie zadawania tego pytania, mam skłonność wierzyć, że pisanie abstrakcyjnej klasy, która ma sens bez konieczności szukania klas pochodnych, jest kwestią czystego kodu i czystej architektury. Ι Nie podoba mi się pomysł, że klasa abstrakcyjna, która działa jak zrzut dowolnego kodu, jest powszechna we wszystkich klasach pochodnych.

Co myślisz / ćwiczysz?

Alexandros Gougousis
źródło
7
Dlaczego musi istnieć „zasada”?
Robert Harvey
1
Writing an abstract class that makes sense without having to look in the derived classes is a matter of clean code and clean architecture.-- Dlaczego? Czy nie jest możliwe, że podczas projektowania i rozwijania aplikacji odkryłem, że kilka klas ma wspólną funkcjonalność, którą można naturalnie przekształcić w klasę abstrakcyjną? Czy muszę być wystarczająco jasnowidzem, aby zawsze to przewidzieć przed napisaniem kodu dla klas pochodnych? Jeśli nie jestem tak bystry, czy zabrania mi się przeprowadzania takiego refaktoryzacji? Czy muszę wyrzucić swój kod i zacząć od nowa?
Robert Harvey
Przepraszam, jeśli zostałem źle zrozumiany! Próbowałem powiedzieć, co czuję (na razie), jako lepszą praktykę i nie sugerowałem, że powinna istnieć absolutna zasada. Co więcej, nie sugeruję, że każdy kod należący do klasy abstrakcyjnej powinien być napisany tylko z góry. Opisałem, jak w praktyce klasa abstrakcyjna kończy się kodem wysokiego poziomu (który działa jak szablon dla pochodnych), a także kodem niskiego poziomu (że nie można zrozumieć, że jest użyteczna, ponieważ nie zagląda się klasy pochodne).
Alexandros Gougousis,
@RobertHarvey dla publicznej klasy bazowej, zabronione byłoby patrzenie na klasy pochodne. W przypadku klas wewnętrznych nie ma to znaczenia.
Frank Hileman

Odpowiedzi:

4

Podam przykład: Klasa abstrakcyjna A ma metodę a () i metodę abstrakcyjną aq (). Metoda aq (), w obu pochodnych klasach AB i AC, wykorzystuje metodę b (). Czy b () przeniósł się do A? Jeśli tak, to w przypadku, gdyby ktoś patrzył tylko na A (udawajmy, że nie ma AB i AC), istnienie b () nie miałoby większego sensu! Czy to źle? Czy ktoś powinien spojrzeć na klasę abstrakcyjną i zrozumieć, co się dzieje bez odwiedzania klas pochodnych?

To, o co pytasz, to gdzie umieścić b(), i, w innym sensie, pytanie brzmi, czy Ajest najlepszym wyborem jako bezpośrednia superklasa dla ABi AC.

Wydaje się, że są trzy możliwości:

  1. zostaw b()w obu ABiAC
  2. utworzyć klasę pośrednią, ABAC-Parentktóra dziedziczy Ai wprowadza b(), a następnie jest używana jako bezpośrednia superklasa dla ABiAC
  3. umieścić b()w A(nie wiedząc, czy inna klasa przyszłości ADbędzie chciał b(), czy nie)

  1. cierpi na to, że nie jest SUCHY .
  2. cierpi na YAGNI .
  3. więc to pozostawia ten.

Dopóki inna klasa AD, która nie chce się b()prezentować, wydaje się właściwym wyborem.

W takim momencie, jako ADprezent, możemy zmienić podejście do podejścia w (2) - w końcu to oprogramowanie!

Erik Eidt
źródło
7
Jest czwarta opcja. Nie stawiaj b()żadnej klasy. Zrób z niego bezpłatną funkcję, która bierze wszystkie swoje dane jako argumenty i ma obie te funkcje ABi ACwywołuje ją Oznacza to, że nie musisz go przenosić ani tworzyć żadnych klas podczas dodawania AD.
user1118321,
1
@ user1118321, doskonałe, miłe myślenie nieszablonowe. Ma to szczególny sens, jeśli b()nie potrzebuje stanu długowiecznego.
Erik Eidt,
W (3) niepokoi mnie to, że klasa abstrakcyjna nie opisuje już samodzielnego zachowania. Jest to fragment kodu i nie mogę zrozumieć abstrakcyjnego kodu klasy bez przechodzenia między tą i wszystkimi klasami pochodnymi.
Alexandros Gougousis,
Czy możesz ustawić bazę / default, acktóra będzie wywoływać bzamiast acbyć abstrakcyjna?
Erik Eidt,
3
Dla mnie najważniejsze pytanie brzmi: DLACZEGO oba AB i AC używają tej samej metody b (). Jeśli to tylko zbieg okoliczności, zostawiłbym dwie podobne implementacje w AB i AC. Ale prawdopodobnie dzieje się tak, ponieważ istnieje pewna powszechna abstrakcja dla AB i AC. Dla mnie DRY nie jest wartością samą w sobie, ale jest wskazówką, że prawdopodobnie przegapiłeś jakąś przydatną abstrakcję.
Ralf Kleberhoff,
2

Klasa abstrakcyjna nie jest przeznaczona do wysypywania różnych funkcji lub danych, które są wrzucane do klasy abstrakcyjnej, ponieważ jest to wygodne.

Jedną z praktycznych zasad, która wydaje się być najbardziej niezawodnym i możliwym do rozszerzenia podejściem zorientowanym obiektowo, jest: „ Wolę kompozycję niż dziedziczenie ”. Klasa abstrakcyjna jest prawdopodobnie najlepiej traktowana jako specyfikacja interfejsu, która nie zawiera żadnego kodu.

Jeśli masz jakąś metodę, która jest rodzajem metody bibliotecznej, która idzie w parze z klasą abstrakcyjną, metoda, która jest najbardziej prawdopodobnym sposobem wyrażenia pewnej funkcjonalności lub zachowania, których zwykle potrzebują klasy pochodzące z klasy abstrakcyjnej, wówczas sensowne jest utworzenie klasa pomiędzy klasą abstrakcyjną a innymi klasami, w których ta metoda jest dostępna. Ta nowa klasa zapewnia szczególną instancję klasy abstrakcyjnej definiującej konkretną alternatywę implementacyjną lub ścieżkę za pomocą tej metody.

Ideą klasy abstrakcyjnej jest posiadanie abstrakcyjnego modelu tego, co klasy pochodne, które faktycznie implementują klasę abstrakcyjną, powinny zapewniać jako usługę lub zachowanie. Dziedziczenie jest łatwe w użyciu i dlatego wiele razy najbardziej przydatne klasy składają się z różnych klas wykorzystujących wzór mixin .

Zawsze jednak pojawiają się pytania dotyczące zmiany, co się zmieni i jak to się zmieni i dlaczego to się zmieni.

Dziedziczenie może prowadzić do kruchych i kruchych ciał źródła (patrz także Dziedziczenie: przestań go już używać! ).

Problem delikatnej klasy bazowej jest podstawowym problemem architektonicznym zorientowanych obiektowo systemów programowania, w których klasy podstawowe (nadklasy) są uważane za „delikatne”, ponieważ pozornie bezpieczne modyfikacje klasy bazowej po odziedziczeniu przez klasy pochodne mogą spowodować nieprawidłowe działanie klas pochodnych . Programista nie może ustalić, czy zmiana klasy podstawowej jest bezpieczna, po prostu analizując oddzielnie metody klasy podstawowej.

Richard Chambers
źródło
Jestem pewien, że każda koncepcja OOP może prowadzić do problemów, jeśli będzie niewłaściwie używana, nadużywana lub nadużywana! Co więcej, przyjęcie założenia, że ​​b () jest metodą biblioteczną (np. Czystą funkcją bez innych zależności) zawęża zakres mojego pytania. Unikajmy tego.
Alexandros Gougousis,
@AlexandrosGougousis Nie zakładam, że b()jest to czysta funkcja. Może to być funktoid, szablon lub coś innego. Używam wyrażenia „metoda biblioteczna” jako jakiegoś komponentu, czy to czystej funkcji, obiektu COM, czy czegokolwiek, co jest używane do funkcjonalności zapewnianej przez rozwiązanie.
Richard Chambers
Przepraszam, jeśli nie było jasne! Wspomniałem o czystej funkcji jako jednym z wielu przykładów (podałeś więcej).
Alexandros Gougousis,
1

Twoje pytanie sugeruje albo podejście do klas abstrakcyjnych. Uważam jednak, że powinieneś traktować je jako kolejne narzędzie w swoim zestawie narzędzi. I wtedy pojawia się pytanie: dla jakich zadań / problemów klasy abstrakcyjne są właściwym narzędziem?

Jednym z doskonałych przypadków użycia jest wdrożenie wzorca metody szablonu . Umieszczasz całą logikę niezmienną w klasie abstrakcyjnej, a logikę wariantową w jej podklasach. Zauważ, że wspólna logika sama w sobie jest niekompletna i niefunkcjonalna. W większości przypadków chodzi o implementację algorytmu, w którym kilka kroków jest zawsze takich samych, ale co najmniej jeden krok jest różny. Umieść ten jeden krok jako metodę abstrakcyjną, która jest wywoływana z jednej z funkcji wewnątrz klasy abstrakcyjnej.

Czasami klasa abstrakcyjna jest wcześniej tworzona i działa jako szablon tego, jak działają klasy pochodne. Oznacza to mniej więcej, że zapewniają one pewną funkcjonalność na wysokim poziomie, ale pomijają pewne szczegóły do ​​zaimplementowania przez klasy pochodne. Klasa abstrakcyjna określa potrzebę tych szczegółów, wprowadzając pewne metody abstrakcyjne. W takich przypadkach klasa abstrakcyjna działa jak plan, ogólny opis funkcjonalności lub jakkolwiek chcesz to nazwać. Nie można go używać samodzielnie, ale musi być wyspecjalizowany, aby zdefiniować szczegóły, które zostały pominięte we wdrożeniu wysokiego poziomu.

Myślę, że twój pierwszy przykład jest w zasadzie opisem wzorca metody szablonu (popraw mnie, jeśli się mylę), dlatego uważam to za całkowicie poprawny przypadek użycia klas abstrakcyjnych.

Innym razem zdarza się, że klasa abstrakcyjna jest tworzona po utworzeniu niektórych klas „pochodnych” (ponieważ klasy macierzystej / abstrakcyjnej nie ma, nie zostały jeszcze wyprowadzone, ale wiesz, co mam na myśli). W takich przypadkach klasa abstrakcyjna jest zwykle używana jako miejsce, w którym można umieścić dowolny rodzaj wspólnego kodu, który zawiera bieżące klasy pochodne.

W drugim przykładzie uważam, że użycie klas abstrakcyjnych nie jest optymalnym wyborem, ponieważ istnieją lepsze metody radzenia sobie ze wspólną logiką i zduplikowanym kodem. Powiedzmy, że masz klasę abstrakcyjną A, klas pochodnych Bi Ci obie klasy pochodzące udostępnić jakąś logikę w postaci metody s(). Aby zdecydować o właściwym podejściu do pozbycia się duplikacji, ważne jest, aby wiedzieć, czy metoda s()jest częścią interfejsu publicznego, czy nie.

Jeśli tak nie jest (jak we własnym konkretnym przykładzie z metodą b()), sprawa jest dość prosta. Po prostu stwórz z niej osobną klasę, wyodrębniając kontekst potrzebny do wykonania operacji. Jest to klasyczny przykład kompozycji zamiast dziedziczenia . Jeśli kontekst jest niewielki lub nie jest potrzebny, prosta funkcja pomocnicza może już wystarczyć, jak sugerowano w niektórych komentarzach.

Jeśli s()jest częścią interfejsu publicznego, staje się nieco trudniejszy. Zakładając, że s()nie ma to nic wspólnego Bi nie Cjest z Atobą spokrewniony , nie powinieneś wkładać do s()środka A. Więc gdzie to położyć? Argumentowałbym za deklaracją oddzielnego interfejsu I, który określa s(). Potem znowu, należy utworzyć oddzielną klasę, która zawiera logikę za wdrażanie s()i zarówno Bi Cod niej zależne.

Ostatni, tu jest link do doskonałej odpowiedzi na pytanie o ciekawej, dzięki czemu może pomóc Ci zdecydować, kiedy należy przejść do interfejsu i kiedy do klasy abstrakcyjnej:

Dobra klasa abstrakcyjna zmniejszy ilość kodu, który trzeba przepisać, ponieważ można udostępnić jego funkcjonalność lub stan.

larsbe
źródło
Zgadzam się, że s () będący częścią publicznego interfejsu czyni sprawy o wiele trudniejsze. Generalnie unikałbym definiowania metod publicznych wywołujących inne metody publiczne w tej samej klasie.
Alexandros Gougousis,
1

Wydaje mi się, że brakuje ci punktu orientacji obiektu, zarówno logicznie, jak i technicznie. Opisujesz dwa scenariusze: grupowanie typowego zachowania typów w klasie bazowej i polimorfizm. Są to oba uzasadnione zastosowania klasy abstrakcyjnej. Ale to, czy powinieneś uczynić klasę abstrakcyjną, czy nie, zależy od modelu analitycznego, a nie możliwości technicznych.

Powinieneś rozpoznać typ, który nie ma inkarnacji w prawdziwym świecie, a jednak stanowi podstawę dla określonych typów, które istnieją. Przykład: zwierzę. Nie ma czegoś takiego jak zwierzę. Zawsze jest to pies, kot lub cokolwiek innego, ale nie ma prawdziwego zwierzęcia. Jednak Animal je wszystkie.

Potem mówisz o poziomach. Poziomy nie są częścią umowy, jeśli chodzi o dziedziczenie. Nie uznaje się również wspólnych danych ani wspólnych zachowań, to podejście techniczne, które prawdopodobnie nie pomoże. Powinieneś rozpoznać stereotyp, a następnie wstawić klasę podstawową. Jeśli nie ma takiego stereotypu, możesz być lepszy z kilkoma interfejsami implementowanymi przez wiele klas.

Nazwa twojej abstrakcyjnej klasy wraz z jej członkami powinna mieć sens. Musi być niezależny od dowolnej klasy pochodnej, zarówno pod względem technicznym, jak i logicznym. Podobnie jak Animal może mieć metodę abstrakcyjną Jeść (która byłaby polimorficzna) i logiczne właściwości abstrakcyjne IsPet i IsLifestock, co ma sens bez wiedzy o kotach, psach lub świniach. Każda zależność (techniczna lub logiczna) powinna iść tylko w jedną stronę: od potomka do bazy. Same klasy podstawowe również nie powinny mieć wiedzy o klasach malejących.

Martin Maat
źródło
Wspomniany stereotyp jest mniej więcej tym samym, co mam na myśli, gdy mówię o funkcjonalności wyższego poziomu lub gdy ktoś inny mówi o uogólnionych pojęciach opisanych przez klasę wyżej w hierarchii (w porównaniu do bardziej specjalistycznych pojęć opisanych przez klasy niżej w hierarchia).
Alexandros Gougousis,
„Same klasy podstawowe również nie powinny mieć wiedzy o klasach malejących”. Całkowicie się z tym zgadzam. Klasa nadrzędna (abstrakcyjna lub nie) powinna zawierać samodzielną logikę biznesową, która nie musi sprawdzać implementacji klasy podrzędnej, aby ją zrozumieć.
Alexandros Gougousis,
Jest jeszcze jedna rzecz w klasie podstawowej znającej jej podtypy. Ilekroć opracowywany jest nowy podtyp, klasa podstawowa musi zostać zmodyfikowana, co jest odwrócone i narusza zasadę otwartego-zamkniętego. Ostatnio spotkałem się z tym w istniejącym kodzie. Klasa podstawowa miała metodę statyczną, która tworzyła instancje podtypów na podstawie konfiguracji aplikacji. Zamiarem był wzór fabryczny. Postępowanie w ten sposób narusza również SRP. Z niektórymi SOLIDNYMI punktami myślałem „duh, to oczywiste” lub „język automatycznie się tym zajmie”, ale odkryłem, że ludzie są bardziej kreatywni, niż mogłem sobie wyobrazić.
Martin Maat
0

Pierwszy przypadek z twojego pytania:

W takich przypadkach klasa abstrakcyjna działa jak plan, ogólny opis funkcjonalności lub jakkolwiek chcesz to nazwać.

Drugi przypadek:

W innych przypadkach ... klasa abstrakcyjna jest zwykle używana jako miejsce, w którym można umieścić dowolny rodzaj wspólnego kodu, który zawiera bieżące klasy pochodne.

Następnie twoje pytanie :

Zastanawiam się, który z tych dwóch przypadków powinien być regułą. ... Czy kod, który może nie mieć żadnego znaczenia dla samej klasy abstrakcyjnej, powinien istnieć tylko dlatego, że zdarza się, że jest wspólny dla klas pochodnych?

IMO ma dwa różne scenariusze, dlatego nie można narysować jednej reguły, aby zdecydować, który projekt należy zastosować.

W pierwszym przypadku mamy takie rzeczy, jak wzorzec metody szablonów, wspomniany już w innych odpowiedziach.

W drugim przypadku podałeś przykład abstrakcyjnej klasy A odbierającej metodę ab (); IMO metodę b () należy przenieść tylko wtedy, gdy ma to sens dla WSZYSTKICH innych klas pochodnych. Innymi słowy, jeśli przenosisz go, ponieważ jest używany w dwóch miejscach, prawdopodobnie nie jest to dobry wybór, ponieważ jutro może być nowa konkretna klasa pochodna, w której b () nie ma żadnego sensu. Ponadto, jak powiedziałeś, jeśli spojrzysz na klasę A osobno, a b () również nie ma sensu w tym kontekście, to prawdopodobnie jest to wskazówka, że ​​nie jest to również dobry wybór projektu.

Emerson Cardoso
źródło
-1

Jakiś czas temu miałem dokładnie takie same pytania!

Kiedy natknąłem się na tę kwestię, odkryłem, że istnieje jakaś ukryta koncepcja, której nie znalazłem. I ta koncepcja najprawdopodobniej powinna być wyrażona za pomocą jakiegoś obiektu wartości . Masz bardzo abstrakcyjny przykład, więc obawiam się, że nie mogę pokazać, co mam na myśli, używając twojego kodu. Ale tutaj jest przypadek z mojej własnej praktyki. Miałem dwie klasy, które reprezentowały sposób wysłania żądania do zewnętrznego zasobu i parsowania odpowiedzi. Istniały podobieństwa w sposobie formułowania żądań i analizie odpowiedzi. Tak więc wyglądała moja hierarchia:

abstract class AbstractProtocol
{
    /**
     * @return array Registration params to send
     */
    abstract protected function assembleRegistrationPart();

    /**
     * @return array Payment params to send
     */
    abstract protected function assemblePaymentPart();

    protected function doSend(array $data)
    {
        return
            (new HttpClient(
                [
                    'timeout' => 60,
                    'encoding' => 'utf-8',
                    'language' => 'en',
                ]
            ))
                ->send($data);
    }

    protected function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}

class ClassicProtocol extends AbstractProtocol
{
    public function send()
    {
        $registration = $this->assembleRegistrationPart();
        $payment = $this->assemblePaymentPart();
        $specificParams = $this->assembleClassicSpecificPart();

        $dataToSend =
            array_merge(
                $registration, $payment, $specificParams
            );

        $this->log($dataToSend);

        $this->doSend($dataToSend);
    }

    protected function assembleRegistrationPart()
    {
        return ['hello' => 'there'];
    }

    protected function assemblePaymentPart()
    {
        return ['pay' => 'yes'];
    }
}

Taki kod wskazuje, że po prostu niewłaściwie używam dziedziczenia. W ten sposób można go refaktoryzować:

class ClassicProtocol
{
    private $request;
    private $logger;

    public function __construct(Request $request, Logger $logger, Client $client)
    {
        $this->request = $request;
        $this->client = $client;
        $this->logger = $logger;
    }

    public function send()
    {
        $this->logger->log($this->request->getData());
        $this->client->send($this->request->getData());
    }
}

$protocol =
    new ClassicProtocol(
        new PaymentRequest(
            new RegistrationData(),
            new PaymentData(),
            new ClassicSpecificData()
        ),
        new ClassicLogger(),
        new ClassicClient()
    );

class RegistrationData
{
    public function getData()
    {
        return ['hello' => 'there'];
    }
}

class PaymentData
{
    public function getData()
    {
        return ['pay' => 'yes'];
    }
}

class ClassicLogger
{
    public function log(array $data)
    {
        $header = 'Here is a request to external system!';
        $body = implode(', ', $this->maskData($data));
        Logger::log($header . '. \n ' . $body);
    }
}
class ClassicClient
{
    private $properties;

    public function __construct()
    {
        $this->properties =
            [
                'timeout' => 60,
                'encoding' => 'utf-8',
                'language' => 'en',
            ];
    }
}

Od tego czasu dziedziczę dziedzictwo bardzo ostrożnie, ponieważ wiele razy mnie raniono.

Od tego czasu doszedłem do kolejnego wniosku dotyczącego dziedziczenia. Jestem zdecydowanie przeciwny dziedziczeniu opartemu na wewnętrznej strukturze . Łamie enkapsulację, jest krucha, w końcu jest proceduralna. A kiedy rozkładam domenę we właściwy sposób , dziedziczenie po prostu nie zdarza się często.

Vadim Samokhin
źródło
@Downvoter, wyświadcz mi przysługę i skomentuj, co jest nie tak z tą odpowiedzią.
Vadim Samokhin