Quale codice dovrebbe essere incluso in una classe astratta?


10

Ultimamente sono preoccupato per l'uso di classi astratte.

A volte una classe astratta viene creata in anticipo e funziona come modello di come funzionerebbero le classi derivate. Ciò significa, più o meno, che forniscono alcune funzionalità di alto livello ma tralasciano alcuni dettagli che devono essere implementati dalle classi derivate. La classe astratta definisce la necessità di questi dettagli mettendo in atto alcuni metodi astratti. In tali casi, una classe astratta funziona come un progetto, una descrizione di alto livello della funzionalità o come si desidera chiamarla. Non può essere utilizzato da solo, ma deve essere specializzato per definire i dettagli che sono stati esclusi dall'implementazione di alto livello.

Altre volte, accade che la classe astratta sia creata dopo la creazione di alcune classi "derivate" (dal momento che la classe genitore / astratta non è lì, non sono state ancora derivate, ma sai cosa intendo). In questi casi, la classe astratta viene generalmente utilizzata come luogo in cui è possibile inserire qualsiasi tipo di codice comune contenuto nelle attuali classi derivate.

Dopo aver fatto le osservazioni di cui sopra, mi chiedo quale di questi due casi dovrebbe essere la regola. Qualunque tipo di dettaglio dovrebbe essere gorgogliato nella classe astratta solo perché attualmente sono comuni in tutte le classi derivate? Dovrebbe esserci un codice comune che non fa parte di una funzionalità di alto livello?

Il codice che potrebbe non avere alcun significato per la classe astratta stessa dovrebbe essere lì solo perché sembra essere comune per le classi derivate?

Lasciatemi fare un esempio: la classe astratta A ha un metodo a () e un metodo astratto aq (). Il metodo aq (), in entrambe le classi derivate AB e AC, utilizza un metodo b (). B () dovrebbe essere spostato su A? Se sì, allora nel caso in cui qualcuno guardi solo A (facciamo finta che AB e AC non ci siano), l'esistenza di b () non avrebbe molto senso! è una cosa negativa? Qualcuno dovrebbe essere in grado di dare un'occhiata in una classe astratta e capire cosa sta succedendo senza visitare le classi derivate?

Ad essere onesti, al momento di chiederlo, tendo a credere che scrivere una classe astratta che abbia un senso senza dover guardare nelle classi derivate sia una questione di codice pulito e architettura pulita. Ι Non mi piace l'idea di una classe astratta che si comporta come una discarica per qualsiasi tipo di codice sembra essere comune in tutte le classi derivate.

Cosa ne pensi / pratica?


7
Perché deve esserci una "regola"?
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.-- Perché? Non è possibile che, nel corso della progettazione e dello sviluppo di un'applicazione, scopro che diverse classi hanno funzionalità comuni che possono essere naturalmente refactorizzate in una classe astratta? Devo essere abbastanza chiaroveggente da anticiparlo sempre prima di scrivere un codice per le classi derivate? Se non riesco ad essere così astuto, mi sarà vietato eseguire un tale refactoring? Devo lanciare il mio codice e ricominciare da capo?
Robert Harvey,

Scusa, se fossi stato frainteso! Stavo cercando di dire ciò che sento (per il momento) come una pratica migliore e non implicavo che ci fosse una regola assoluta. Inoltre, non sto insinuando che qualsiasi codice che appartiene alla classe astratta dovrebbe essere scritto solo in anticipo. Stavo descrivendo come, in pratica, una classe astratta finisce con un codice di alto livello (che funge da modello per quelli derivati) e un codice di basso livello (che non puoi capire che è usabilità in cui non guardi dentro le classi derivate).
Alexandros Gougousis,

@RobertHarvey per una classe di base pubblica, ti sarebbe vietato guardare le classi derivate. Per le classi interne, non fa differenza.
Frank Hileman,

Risposte:


4

Lasciatemi fare un esempio: la classe astratta A ha un metodo a () e un metodo astratto aq (). Il metodo aq (), in entrambe le classi derivate AB e AC, utilizza un metodo b (). B () dovrebbe essere spostato su A? Se sì, allora nel caso in cui qualcuno guardi solo A (facciamo finta che AB e AC non ci siano), l'esistenza di b () non avrebbe molto senso! è una cosa negativa? Qualcuno dovrebbe essere in grado di dare un'occhiata in una classe astratta e capire cosa sta succedendo senza visitare le classi derivate?

Quello che ti chiedi è dove collocare b()e, in qualche altro senso, una domanda è se Asia la scelta migliore come superclasse immediata per ABe AC.

Sembra che ci siano tre scelte:

  1. lasciare b()in entrambi ABeAC
  2. creare una classe intermedia ABAC-Parentche eredita da Ae che introduce b()e viene quindi utilizzata come superclasse immediata per ABeAC
  3. ha messo b()in A(senza sapere se un'altra classe futuro ADvorrà b()o no)

  1. soffre di non essere SECCO .
  2. soffre di YAGNI .
  3. quindi, questo lascia questo.

Fino a quando un'altra classe ADche non vuole b()presentarsi, (3) sembra la scelta giusta.

In un momento come ADregali, allora possiamo rifattorizzare l'approccio in (2) - dopo tutto è un software!


7
C'è una quarta opzione. Non iscriverti b()a nessuna lezione. Rendilo una funzione gratuita che prende tutti i suoi dati come argomenti e li ha entrambi ABe li ACchiama. Ciò significa che non è necessario spostarlo o creare altre classi quando si aggiunge AD.
user1118321

1
@utente1118321, eccellente, bello pensare fuori dagli schemi. Ha un senso particolare se b()non necessita di uno stato di lunga durata.
Erik Eidt,

Ciò che mi preoccupa in (3) è che la classe astratta non descrive più un comportamento autonomo. Sono frammenti di codice e non riesco a capire il codice di classe astratto senza andare avanti e indietro tra questa e tutte le classi derivate.
Alexandros Gougousis,

Puoi fare una base / default acche chiama binvece di acessere astratto?
Erik Eidt,

3
Per me, la domanda più importante è, PERCHÉ sia AB che AC usano entrambi lo stesso metodo b (). Se è solo una coincidenza, lascerei due implementazioni simili in AB e AC. Ma probabilmente è perché esiste qualche astrazione comune per AB e AC. Per me, DRY non è tanto un valore in sé, ma un suggerimento che probabilmente ti sei perso qualche utile astrazione.
Ralf Kleberhoff,

2

Una classe astratta non è pensata per essere la discarica per varie funzioni o dati che vengono gettati nella classe astratta perché è conveniente.

L'unica regola empirica che sembra fornire l'approccio orientato agli oggetti più affidabile ed estensibile è " Preferire la composizione rispetto all'eredità ". Una classe astratta è probabilmente meglio pensata come una specifica di interfaccia che non contiene alcun codice.

Se si dispone di un metodo che è un tipo di metodo di libreria che si accompagna alla classe astratta, un metodo che è il mezzo più probabile per esprimere alcune funzionalità o comportamenti di cui le classi derivate da una classe astratta in genere è necessario, allora ha senso creare un classe tra la classe astratta e altre classi in cui questo metodo è disponibile. Questa nuova classe fornisce una particolare istanza della classe astratta che definisce una particolare alternativa o percorso di implementazione fornendo questo metodo.

L'idea di una classe astratta è quella di avere un modello astratto di ciò che le classi derivate che implementano effettivamente la classe astratta dovrebbero fornire fino al servizio o al comportamento. L'ereditarietà è facile da usare e così tante volte le classi più utili sono composte da varie classi usando uno schema di mixin .

Tuttavia ci sono sempre le domande sul cambiamento, cosa cambierà e come cambierà e perché cambierà.

L'ereditarietà può portare a corpi fragili e fragili della fonte (vedi anche Ereditarietà: smetti di usarla già! ).

Il fragile problema della classe base è un problema architettonico fondamentale dei sistemi di programmazione orientati agli oggetti in cui le classi base (superclassi) sono considerate "fragili" perché modifiche apparentemente sicure a una classe base, se ereditate dalle classi derivate, possono causare il malfunzionamento delle classi derivate . Il programmatore non può determinare se una modifica della classe base è sicura semplicemente esaminando in modo isolato i metodi della classe base.


Sono sicuro che qualsiasi concetto OOP può portare a problemi se viene utilizzato in modo improprio o abusato o abusato! Inoltre, ipotizzare che b () sia un metodo di libreria (ad esempio una funzione pura senza altre dipendenze) restringe l'ambito della mia domanda. Evitiamo questo.
Alexandros Gougousis,

@AlexandrosGougousis Non presumo che b()sia una funzione pura. Potrebbe essere un functoid o un modello o qualcos'altro. Sto usando la frase "metodo di libreria" come significato di qualche componente che sia pura funzione, oggetto COM o qualunque cosa sia usata per la funzionalità che fornisce alla soluzione.
Richard Chambers,

Scusa, se non fossi chiaro! Ho citato la funzione pura come uno dei tanti esempi (ne hai dato di più).
Alexandros Gougousis,

1

La tua domanda suggerisce un approccio o un approccio alle classi astratte. Ma penso che dovresti considerarli come solo un altro strumento nella tua cassetta degli attrezzi. E poi la domanda diventa: per quali lavori / problemi le classi astratte sono lo strumento giusto?

Un eccellente caso d'uso è l'implementazione del modello di metodo del modello . Metti tutta la logica invariante nella classe astratta e la logica delle varianti nelle sue sottoclassi. Si noti che la logica condivisa in sé è incompleta e non funzionale. Il più delle volte si tratta di implementare un algoritmo, in cui diversi passaggi sono sempre gli stessi, ma almeno uno varia. Metti questo passo come metodo astratto che viene chiamato da una delle funzioni all'interno della classe astratta.

A volte una classe astratta viene creata in anticipo e funziona come modello di come funzionerebbero le classi derivate. Ciò significa, più o meno, che forniscono alcune funzionalità di alto livello ma tralasciano alcuni dettagli che devono essere implementati dalle classi derivate. La classe astratta definisce la necessità di questi dettagli mettendo in atto alcuni metodi astratti. In tali casi, una classe astratta funziona come un progetto, una descrizione di alto livello della funzionalità o come si desidera chiamarla. Non può essere utilizzato da solo, ma deve essere specializzato per definire i dettagli che sono stati esclusi dall'implementazione di alto livello.

Penso che il tuo primo esempio sia essenzialmente una descrizione del modello di metodo del modello (correggimi se sbaglio), quindi lo considero un caso d'uso perfettamente valido di classi astratte.

Altre volte, accade che la classe astratta sia creata dopo la creazione di alcune classi "derivate" (dal momento che la classe genitore / astratta non è lì, non sono state ancora derivate, ma sai cosa intendo). In questi casi, la classe astratta viene generalmente utilizzata come luogo in cui è possibile inserire qualsiasi tipo di codice comune contenuto nelle attuali classi derivate.

Per il tuo secondo esempio, penso che l'uso di classi astratte non sia la scelta ottimale, poiché esistono metodi superiori per gestire la logica condivisa e il codice duplicato. Diciamo che hai classe astratta A, classi derivate Be Ced entrambe le classi derivate di condividere una certa logica in forma di metodo s(). Per decidere l'approccio corretto per eliminare la duplicazione, è importante sapere se il metodo s()fa parte o meno dell'interfaccia pubblica.

Se non lo è (come nel tuo esempio concreto con il metodo b()), il caso è abbastanza semplice. Basta creare una classe separata da essa, estraendo anche il contesto necessario per eseguire l'operazione. Questo è un classico esempio di composizione rispetto all'eredità . Se c'è poco o nessun contesto necessario, una semplice funzione di supporto potrebbe già essere sufficiente, come suggerito in alcuni commenti.

Se s()fa parte dell'interfaccia pubblica, diventa un po 'più complicato. Partendo dal presupposto che s()non ha nulla a che fare con Be con cui si Cè in relazione A, non si dovrebbe mettere s()dentro A. Quindi dove metterlo allora? Direi per aver dichiarato un'interfaccia separata I, che definisce s(). Quindi, dovresti creare una classe separata che contenga la logica per l'implementazione s()e sia Be Cdipenda da essa.

Infine, ecco un link a un'eccellente risposta a un'interessante domanda su SO che potrebbe aiutarti a decidere quando scegliere un'interfaccia e quando per una classe astratta:

Una buona classe astratta ridurrà la quantità di codice che deve essere riscritto perché la sua funzionalità o stato può essere condivisa.


Concordo sul fatto che s () che fa parte dell'interfaccia pubblica rende le cose molto più complicate. Generalmente eviterei di definire metodi pubblici chiamando altri metodi pubblici nella stessa classe.
Alexandros Gougousis,

1

Mi sembra che manchi il punto di orientamento dell'oggetto, sia logicamente che tecnicamente. Descrivi due scenari: raggruppamento del comportamento comune dei tipi in una classe base e polimorfismo. Queste sono entrambe applicazioni legittime di una classe astratta. Ma se rendere la classe astratta o meno dipende dal modello analitico, non dalle possibilità tecniche.

Dovresti riconoscere un tipo che non ha incarnazioni nel mondo reale, ma getta le basi per tipi specifici che esistono. Esempio: un animale. Non esiste un animale. È sempre un cane o un gatto o qualsiasi altra cosa ma non esiste un vero animale. Eppure Animal li incornicia tutti.

Quindi parli di livelli. I livelli non fanno parte dell'accordo quando si tratta di eredità. Né è riconoscere dati comuni o comportamenti comuni, questo è un approccio tecnico che probabilmente non aiuterà. Dovresti riconoscere uno stereotipo e quindi inserire la classe base. Se non esiste uno stereotipo di questo tipo, potresti stare meglio con un paio di interfacce implementate da più classi.

Il nome della tua classe astratta insieme ai suoi membri dovrebbe avere un senso. Deve essere indipendente da qualsiasi classe derivata, sia tecnicamente che logicamente. Come Animal potrebbe avere un metodo astratto Eat (che sarebbe polimorfico) e proprietà astratte booleane IsPet e IsLifestock, che ha senso senza conoscere gatti, cani o maiali. Qualsiasi dipendenza (tecnica o logica) dovrebbe andare in un solo modo: dal discendente alla base. Le stesse classi base non dovrebbero avere alcuna conoscenza delle classi discendenti.


Lo stereotipo di cui parli è più o meno la stessa cosa che intendo quando parlo di funzionalità di livello superiore o quando qualcun altro parla di concetti generalizzati descritti da una classe superiore nella gerarchia (rispetto a concetti più specializzati descritti da classi inferiori nella gerarchia).
Alexandros Gougousis,

"Le stesse classi base non dovrebbero avere alcuna conoscenza delle classi discendenti." Sono totalmente d'accordo con questo. Una classe genitore (astratta o meno) dovrebbe contenere un pezzo di logica aziendale autonoma che non ha bisogno di ispezione dell'implementazione della classe figlio per dargli un senso.
Alexandros Gougousis,

C'è un'altra cosa in una classe base che conosce i suoi sottotipi. Ogni volta che viene sviluppato un nuovo sottotipo, è necessario modificare la classe di base che viene retrocessa e violata il principio aperto-chiuso. Recentemente ho riscontrato questo nel codice esistente. Una classe base aveva un metodo statico che creava istanze di sottotipi basate sulla configurazione dell'applicazione. L'intento era un modello di fabbrica. Farlo in questo modo viola anche SRP. Con alcuni punti SOLID pensavo che "duh, questo è ovvio" o "il linguaggio se ne occuperà automaticamente", ma ho scoperto che le persone sono più creative di quanto potessi immaginare.
Martin Maat,

0

Primo caso , dalla tua domanda:

In tali casi, una classe astratta funziona come un progetto, una descrizione di alto livello della funzionalità o come si desidera chiamarla.

Secondo caso:

Altre volte ... la classe astratta viene solitamente utilizzata come luogo in cui è possibile inserire qualsiasi tipo di codice comune contenuto nelle attuali classi derivate.

Quindi, la tua domanda :

Mi chiedo quale di questi due casi dovrebbe essere la regola. ... Il codice che potrebbe non avere alcun significato per la classe astratta stessa dovrebbe essere lì solo perché sembra essere comune per le classi derivate?

IMO hai due diversi scenari, quindi non puoi disegnare una sola regola per decidere quale disegno deve essere applicato.

Per il primo caso, abbiamo cose come il modello Metodo modello già menzionato in altre risposte.

Per il secondo caso, hai fornito l'esempio del metodo ab () astratto di ricezione A; IMO il metodo b () dovrebbe essere spostato solo se ha senso per TUTTE le altre classi derivate. In altre parole, se lo sposti perché viene utilizzato in due punti, probabilmente questa non è una buona scelta, perché domani potrebbe esserci una nuova classe Derivata concreta sulla quale b () non ha alcun senso. Inoltre, come hai detto, se guardi la classe A separatamente e anche b () non ha senso in quel contesto, probabilmente è un suggerimento che questa non sia una buona scelta di design.


-1

Ho avuto esattamente le stesse domande qualche tempo fa!

Quello che mi è venuto in mente ogni volta che mi imbatto in questo problema scopro che c'è un concetto nascosto che non ho trovato. E questo concetto molto probabilmente dovrebbe essere espresso con un valore-oggetto . Hai un esempio molto astratto, quindi temo di non poter dimostrare cosa intendo con il tuo codice. Ma ecco un caso dalla mia stessa pratica. Avevo due classi che rappresentavano un modo per inviare richieste a risorse esterne e analizzare la risposta. Vi erano somiglianze nel modo in cui le richieste venivano formate e nel modo in cui venivano analizzate le risposte. Ecco come appariva la mia gerarchia:

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'];
    }
}

Tale codice indica che uso semplicemente l'ereditarietà. Ecco come potrebbe essere refactored:

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',
            ];
    }
}

Da allora tratto l'eredità con molta attenzione perché mi sono fatto male molte volte.

Da allora sono giunto a un'altra conclusione sull'eredità. Sono fortemente contrario all'eredità basata sulla struttura interna . Rompe l'incapsulamento, è fragile, dopotutto è procedurale. E quando decompongo il mio dominio nel modo giusto , l'eredità semplicemente non accade molto spesso.


@Downvoter, per favore, fammi un favore e commenta cosa c'è di sbagliato in questa risposta.
Vadim Samokhin,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.