La creazione di sottoclassi per istanze specifiche è una cattiva pratica?


37

Considera il seguente disegno

public class Person
{
    public virtual string Name { get; }

    public Person (string name)
    {
        this.Name = name;
    }
}

public class Karl : Person
{
    public override string Name
    {
        get
        {
            return "Karl";
        }
    }
}

public class John : Person
{
    public override string Name
    {
        get
        {
            return "John";
        }
    }
}

Pensi che ci sia qualcosa di sbagliato qui? Per me le lezioni di Karl e John dovrebbero essere solo istanze anziché classi poiché sono esattamente le stesse di:

Person karl = new Person("Karl");
Person john = new Person("John");

Perché dovrei creare nuove classi quando le istanze sono sufficienti? Le classi non aggiungono nulla all'istanza.


17
Sfortunatamente, lo troverai in molti codici di produzione. Puliscilo quando puoi.
Adam Zuckerman,

14
Questo è un ottimo design - se uno sviluppatore vuole rendersi indispensabile per ogni cambiamento nei dati aziendali e non ha problemi a essere disponibile 24/7 ;-)
Doc Brown,

1
Ecco una sorta di domanda correlata in cui il PO sembra credere il contrario della saggezza convenzionale. programmers.stackexchange.com/questions/253612/…
Dunk

11
Progetta le tue lezioni in base al comportamento e non ai dati. Per me le sottoclassi non aggiungono alcun nuovo comportamento alla gerarchia.
Songo,

2
Questo è ampiamente usato con sottoclassi anonime (come i gestori di eventi) in Java prima che lambda arrivasse a Java 8 (che è la stessa cosa con sintassi diversa).
marczellm,

Risposte:


77

Non è necessario disporre di sottoclassi specifiche per ogni persona.

Hai ragione, dovrebbero essere invece esempi.

Obiettivo delle sottoclassi: estendere le classi genitore

Le sottoclassi vengono utilizzate per estendere la funzionalità fornita dalla classe genitore. Ad esempio, potresti avere:

  • Una classe genitore Batteryche può Power()qualcosa e può avere una Voltageproprietà,

  • E una sottoclasse RechargeableBattery, che può ereditare Power()e Voltage, ma può anche essere Recharge()d.

Si noti che è possibile passare un'istanza di RechargeableBatteryclasse come parametro a qualsiasi metodo che accetta un Batterycome argomento. Questo si chiama principio di sostituzione di Liskov , uno dei cinque principi SOLID. Allo stesso modo, nella vita reale, se il mio lettore MP3 accetta due batterie AA, posso sostituirle con due batterie ricaricabili AA.

Si noti che a volte è difficile determinare se è necessario utilizzare un campo o una sottoclasse per rappresentare una differenza tra qualcosa. Ad esempio, se dovessi gestire batterie AA, AAA e 9 Volt, creeresti tre sottoclassi o utilizzeresti un enum? “Sostituisci la sottoclasse con i campi” in Refactoring di Martin Fowler, pagina 232, può darti alcune idee e come spostarti da uno all'altro.

Nel tuo esempio, Karle Johnnon si estendono nulla, né forniscono alcun valore aggiunto: si può avere la stessa funzionalità esattamente utilizzando Persondirettamente di classe. Avere più righe di codice senza valore aggiuntivo non è mai positivo.

Un esempio di un caso aziendale

Quale potrebbe essere un caso aziendale in cui sarebbe sensato creare una sottoclasse per una persona specifica?

Diciamo che costruiamo un'applicazione che gestisce le persone che lavorano in un'azienda. L'applicazione gestisce anche le autorizzazioni, quindi Helen, la contabile, non può accedere al repository SVN, ma Thomas e Mary, due programmatori, non possono accedere ai documenti relativi alla contabilità.

Jimmy, il grande capo (fondatore e CEO della società) ha privilegi molto specifici che nessuno ha. Può, ad esempio, arrestare l'intero sistema o licenziare una persona. Ti viene l'idea.

Il modello più scarso per tale applicazione è avere classi come:

          Il diagramma delle classi mostra le classi Helen, Thomas, Mary e Jimmy e il loro genitore comune: la classe Person astratta.

perché la duplicazione del codice sorgerà molto rapidamente. Anche nell'esempio di base di quattro dipendenti, duplicherai il codice tra le classi Thomas e Mary. Questo ti spingerà a creare una classe genitore comune Programmer. Dato che potresti avere anche più contabili, probabilmente creerai anche Accountantclasse.

          Il diagramma della classe mostra una persona astratta con tre figli: ragioniere astratto, un programmatore astratto e Jimmy.  Il ragioniere ha una sottoclasse Helen e il programmatore ha due sottoclassi: Thomas e Mary.

Ora noti che avere la classe Helennon è molto utile, oltre a mantenere Thomase Mary: la maggior parte del tuo codice funziona comunque al livello superiore, a livello di ragionieri, programmatori e Jimmy. Al server SVN non importa se è Thomas o Mary che deve accedere al registro, deve solo sapere se è un programmatore o un contabile.

if (person is Programmer)
{
    this.AccessGranted = true;
}

Quindi finisci per rimuovere le classi che non usi:

          Il diagramma delle classi contiene le sottoclassi Accountant, Programmer e Jimmy con la sua classe genitore comune: persona astratta.

"Ma posso mantenere Jimmy così com'è, dato che ci sarebbe sempre un solo CEO, un grande capo - Jimmy", pensi. Inoltre, Jimmy è molto usato nel tuo codice, che in realtà sembra più simile a questo, e non come nell'esempio precedente:

if (person is Jimmy)
{
    this.GiveUnrestrictedAccess(); // Because Jimmy should do whatever he wants.
}
else if (person is Programmer)
{
    this.AccessGranted = true;
}

Il problema con questo approccio è che Jimmy può ancora essere investito da un autobus e ci sarebbe un nuovo CEO. Oppure il consiglio di amministrazione potrebbe decidere che Mary è così brava da essere un nuovo CEO e Jimmy sarebbe retrocesso in una posizione di venditore, quindi ora è necessario esaminare tutto il codice e cambiare tutto.


6
@DocBrown: sono spesso sorpreso di vedere che le risposte brevi che non sono sbagliate ma non abbastanza profonde hanno un punteggio molto più alto rispetto alle risposte che spiegano in profondità l'argomento. Probabilmente troppe persone non amano leggere molto, quindi le risposte molto brevi sembrano più attraenti per loro. Tuttavia, ho modificato la mia risposta per fornire almeno alcune spiegazioni.
Arseni Mourzenko,

3
Le risposte brevi tendono ad essere pubblicate per prime. I post precedenti ottengono voti più alti.
Tim B,

1
@TimB: di solito inizio con risposte di medie dimensioni, quindi le modifico per essere molto complete (ecco un esempio , dato che probabilmente c'erano circa quindici revisioni, di cui solo quattro sono mostrate). Ho notato che più a lungo diventa la risposta nel tempo, meno voti riceve; una domanda simile che rimane breve attira più voti.
Arseni Mourzenko,

Accetto con @DocBrown. Ad esempio, una buona risposta direbbe qualcosa come "sottoclasse per comportamento , non per contenuto" e fare riferimento ad alcuni riferimenti che non farò in questo commento.
user949300

2
Nel caso in cui non fosse chiaro dall'ultimo paragrafo: anche se hai solo 1 persona con accesso senza restrizioni, dovresti comunque renderla un'istanza di una classe separata che implementa l'accesso senza restrizioni. Il controllo della persona stessa provoca l'interazione codice-dati e può aprire un'intera lattina di worm. Ad esempio, cosa succede se il Consiglio di amministrazione decide di nominare un triumvirato come CEO? Se non avessi una classe "Unrestricted", non dovresti semplicemente aggiornare il CEO esistente, ma aggiungere anche altri 2 controlli per gli altri membri. Se lo avevi, li hai semplicemente impostati come "Senza restrizioni".
Nzall,

15

È sciocco usare questo tipo di struttura di classe solo per variare i dettagli che dovrebbero ovviamente essere campi impostabili su un'istanza. Ma questo è particolare per il tuo esempio.

Fare diverse Personclassi diverse è quasi sicuramente una cattiva idea: dovresti cambiare programma e ricompilare ogni volta che una nuova persona entra nel tuo dominio. Tuttavia, ciò non significa che ereditare da una classe esistente in modo che una stringa diversa venga restituita da qualche parte non sia talvolta utile.

La differenza sta nel modo in cui prevedi che le entità rappresentate da quella classe possano variare. Si presume quasi sempre che le persone vadano e vengano, e si prevede che quasi tutte le applicazioni serie che si occupano di utenti possano aggiungere e rimuovere utenti in fase di esecuzione. Ma se si modellano cose come algoritmi di crittografia diversi, il supporto di una nuova è probabilmente una modifica importante, e ha senso inventare una nuova classe il cui myName()metodo restituisce una stringa diversa (e il cui perform()metodo presumibilmente fa qualcosa di diverso).


12

Perché dovrei creare nuove classi quando le istanze sono sufficienti?

Nella maggior parte dei casi, non lo faresti. Il tuo esempio è davvero un buon caso in cui questo comportamento non aggiunge valore reale.

Inoltre viola il principio Open Closed , in quanto le sottoclassi non sono sostanzialmente un'estensione ma modificano il funzionamento interno del genitore. Inoltre, il costruttore pubblico del genitore ora è irritante nelle sottoclassi e l'API diventa così meno comprensibile.

Tuttavia, a volte, se avrai solo una o due configurazioni speciali che vengono spesso utilizzate in tutto il codice, a volte è più conveniente e richiede meno tempo sottoclassare un genitore che ha un costruttore complesso. In casi così speciali, non vedo nulla di sbagliato in un simile approccio. Chiamiamolo costruttore curry j / k


Non sono sicuro di essere d'accordo con il paragrafo OCP. Se una classe espone un setter di proprietà ai suoi discendenti allora - supponendo che stia seguendo buoni principi di progettazione - la proprietà non dovrebbe far parte del suo funzionamento interno
Ben Aaronson,

@BenAaronson Fintanto che il comportamento della proprietà stessa non viene modificato, non si viola OCP, ma qui lo fanno. Ovviamente puoi usare quella Proprietà nei suoi meccanismi interni. Ma non dovresti alterare il comportamento esistente! Dovresti solo estendere il comportamento, non cambiarlo con l'ereditarietà. Certo, ci sono eccezioni ad ogni regola.
Falcon,

1
Quindi qualsiasi uso di membri virtuali è una violazione OCP?
Ben Aaronson,

3
@Falcon Non è necessario derivare una nuova classe solo per evitare ripetute chiamate costruttive complesse. Basta creare fabbriche per i casi comuni e parametrizzare le piccole variazioni.
Appassionato il

@BenAaronson Direi che avere membri virtuali che hanno un'implementazione effettiva è una tale violazione. I membri astratti, o dicono metodi che non fanno nulla, sarebbero punti di estensione validi. Fondamentalmente, quando guardi il codice di una classe base, sai che funzionerà così com'è, invece che ogni metodo non definitivo è un candidato per essere sostituito con qualcosa di completamente diverso. O per dirla diversamente: i modi in cui una classe ereditaria può influenzare il comportamento della classe base saranno espliciti e costretti a essere "additivi".
millimoose,

8

Se questa è l'estensione della pratica, sono d'accordo che questa è una cattiva pratica.

Se John e Karl hanno comportamenti diversi, le cose cambiano un po '. Potrebbe essere che personha un metodo per cleanRoom(Room room)cui risulta che John è un grande compagno di stanza e pulisce in modo molto efficace, ma Karl non lo è e non pulisce molto la stanza.

In questo caso, sarebbe logico che fossero la loro sottoclasse con il comportamento definito. Esistono modi migliori per ottenere la stessa cosa (ad esempio una CleaningBehaviorclasse), ma almeno in questo modo non è una terribile violazione dei principi OO.


Nessun comportamento diverso, solo dati diversi. Grazie.
Ignacio Soler Garcia,

6

In alcuni casi sai che ci sarà solo un numero prestabilito di istanze e quindi sarebbe un po 'OK (anche se secondo me brutto) usare questo approccio. È meglio usare un enum se la lingua lo consente.

Esempio Java:

public enum Person
{
    KARL("Karl"),
    JOHN("John")

    private final String name;

    private Person(String name)
    {
        this.name = name;
    }

    public String getName()
    {
        return this.name;
    }
}

6

Molte persone hanno già risposto. Ho pensato di dare la mia prospettiva personale.


Una volta ho lavorato su un'app (e lo faccio ancora) che crea musica.

L'applicazione ha avuto un abstract Scaleclasse con diverse sottoclassi: CMajor, DMinor, ecc Scalesembrava una cosa in questo modo:

public abstract class Scale {
    protected Note[] notes;
    public Scale() {
        loadNotes();
    }
    // .. some other stuff ommited
    protected abstract void loadNotes(); /* subclasses put notes in the array
                                          in this method. */
}

I generatori di musica hanno lavorato con Scaleun'istanza specifica per generare musica. L'utente selezionerebbe una scala da un elenco, da cui generare musica.

Un giorno mi è venuta in mente un'idea interessante: perché non consentire all'utente di creare le proprie scale? L'utente seleziona le note da un elenco, preme un pulsante e una nuova scala verrà aggiunta all'elenco delle scale disponibili.

Ma non sono stato in grado di farlo. Questo perché tutte le scale sono già impostate al momento della compilazione, poiché sono espresse come classi. Poi mi ha colpito:

È spesso intuitivo pensare in termini di "superclassi e sottoclassi". Quasi tutto può essere espresso attraverso questo sistema: superclasse Persone sottoclassi Johne Mary; superclasse Care sottoclassi Volvoe Mazda; superclasse Missilee sottoclassi SpeedRocked, LandMinee TrippleExplodingThingy.

È molto naturale pensare in questo modo, soprattutto per la persona relativamente nuova a OO.

Ma dovremmo sempre ricordare che le classi sono modelli e gli oggetti sono contenuti inseriti in questi modelli . Puoi inserire qualsiasi contenuto nel modello, creando infinite possibilità.

Compilare il modello non è compito della sottoclasse. È il lavoro dell'oggetto. Il lavoro della sottoclasse è aggiungere funzionalità effettive o espandere il modello .

Ed è per questo che avrei dovuto creare una Scaleclasse concreta , con un Note[]campo, e lasciare che gli oggetti riempissero questo modello ; forse attraverso il costruttore o qualcosa del genere. E alla fine, così ho fatto.

Ogni volta che si progetta un modello in una classe (ad esempio, un Note[]membro vuoto che deve essere riempito o un String namecampo a cui è necessario assegnare un valore), ricordare che è compito degli oggetti di questa classe riempire il modello ( o possibilmente quelli che creano questi oggetti). Le sottoclassi hanno lo scopo di aggiungere funzionalità, non di compilare modelli.


Potresti essere tentato di creare una "superclasse Person, sottoclasse Johne Mary" tipo di sistema, come hai fatto tu, perché ti piace la formalità che ti procura.

In questo modo, puoi semplicemente dire Person p = new Mary(), invece di Person p = new Person("Mary", 57, Sex.FEMALE). Rende le cose più organizzate e più strutturate. Ma come abbiamo detto, creare una nuova classe per ogni combinazione di dati non è un buon approccio, poiché gonfia il codice per nulla e ti limita in termini di capacità di runtime.

Quindi ecco una soluzione: utilizzare una fabbrica di base, potrebbe anche essere statica. Così:

public final class PersonFactory {
    private PersonFactory() { }

    public static Person createJohn(){
        return new Person("John", 40, Sex.MALE);
    }

    public static Person createMary(){
        return new Person("Mary", 57, Sex.FEMALE);
    }

    // ...
}

In questo modo, puoi facilmente usare i "preset" come "vieni con il programma", in questo modo:, Person mary = PersonFactory.createMary()ma ti riservi anche il diritto di progettare nuove persone in modo dinamico, ad esempio nel caso in cui tu voglia consentire all'utente di farlo . Per esempio:

// .. requesting the user for input ..
String name = // user input
int age = // user input
Sex sex = // user input, interpreted
Person newPerson = new Person(name, age, sex);

O ancora meglio: fai qualcosa del genere:

public final class PersonFactory {
    private PersonFactory() { }

    private static Map<String, Person> persons = new HashMap<>();
    private static Map<String, PersonData> personBlueprints = new HashMap<>();

    public static void addPerson(Person person){
        persons.put(person.getName(), person);
    }

    public static Person getPerson(String name){
        return persons.get(name);
    }

    public static Person createPerson(String blueprintName){
        PersonData data = personBlueprints.get(blueprintName);
        return new Person(data.name, data.age, data.sex);
    }

    // .. or, alternative to the last method
    public static Person createPerson(String personName){
        Person blueprint = persons.get(personName);
        return new Person(blueprint.getName(), blueprint.getAge(), blueprint.getSex());
    }

}

public class PersonData {
    public String name;
    public int age;
    public Sex sex;

    public PersonData(String name, int age, Sex sex){
        this.name = name;
        this.age = age;
        this.sex = sex;
    }
}

Sono stato portato via. Penso che tu abbia avuto l'idea.


Le sottoclassi non intendono compilare i modelli impostati dalle rispettive superclassi. Le sottoclassi hanno lo scopo di aggiungere funzionalità . Gli oggetti hanno lo scopo di compilare i modelli, ecco a cosa servono.

Non dovresti creare una nuova classe per ogni possibile combinazione di dati. (Proprio come non avrei dovuto creare una nuova Scalesottoclasse per ogni possibile combinazione di Notes).

È una linea guida: ogni volta che crei una nuova sottoclasse, considera se aggiunge nuove funzionalità che non esistono nella superclasse. Se la risposta a questa domanda è "no", allora potresti provare a "compilare il modello" della superclasse, nel qual caso basta creare un oggetto. (E possibilmente una Fabbrica con "preset", per semplificare la vita).

Spero che sia d'aiuto.


Questo è un ottimo esempio, grazie mille.
Ignacio Soler Garcia,

@SoMoS Sono contento di poterti aiutare.
Aviv Cohn,

2

Questa è un'eccezione alle "dovrebbero essere istanze" qui - sebbene leggermente estreme.

Se si desidera che il compilatore imponga le autorizzazioni per accedere ai metodi in base al fatto che si tratti di Karl o John (in questo esempio) anziché richiedere l'implementazione del metodo per verificare quale istanza è stata passata, è possibile utilizzare classi diverse. Applicando classi diverse anziché l'istanza, si rendono possibili controlli di differenziazione tra "Karl" e "John" in fase di compilazione anziché in fase di esecuzione e ciò potrebbe essere utile, in particolare in un contesto di sicurezza in cui il compilatore potrebbe essere più affidabile di run-time controlli del codice di (ad esempio) librerie esterne.


2

È considerato una cattiva pratica avere un codice duplicato .

Ciò è almeno in parte dovuto al fatto che le linee di codici e quindi le dimensioni della base di codice sono correlate ai costi di manutenzione e ai difetti .

Ecco la logica del senso comune pertinente per me su questo particolare argomento:

1) Se dovessi cambiare questo, quanti posti dovrei visitare? Meno è meglio qui.

2) C'è un motivo per cui è fatto in questo modo? Nel tuo esempio - non potrebbe esserci - ma in alcuni esempi potrebbe esserci qualche motivo per farlo. Uno che mi viene in mente dalla parte superiore della mia testa è in alcuni framework come i primi Grails in cui l'eredità non ha necessariamente giocato bene con alcuni dei plugin per GORM. Pochi e rari.

3) È più pulito e più facile da capire in questo modo? Ancora una volta, non è nel tuo esempio, ma ci sono alcuni modelli di ereditarietà che possono essere più estesi in cui le classi separate sono effettivamente più facili da mantenere (alcuni usi del comando o modelli di strategia vengono in mente).


1
se è cattivo o no non è incastonato nella pietra. Esistono, ad esempio, scenari in cui l'overhead aggiuntivo della creazione di oggetti e delle chiamate ai metodi può essere così dannoso per le prestazioni che l'applicazione non soddisfa più le specifiche.
jwenting,

Vedi punto 2. Certo, ci sono ragioni per cui, di tanto in tanto. Ecco perché è necessario chiedere prima di immettere il codice di qualcuno.
dcgregorya,

0

Esiste un caso d'uso: formare un riferimento a una funzione o metodo come oggetto normale.

Puoi vederlo molto nel codice della GUI Java:

ActionListener a = new ActionListener() {
    public void actionPerformed(ActionEvent arg0) {
        /* ... */
    }
};

Il compilatore ti aiuta qui creando una nuova sottoclasse anonima.

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.