"Se un metodo viene riutilizzato senza modifiche, inseriscilo in una classe base, altrimenti crea un'interfaccia" è una buona regola empirica?


10

Un mio collega ha escogitato una regola empirica per scegliere tra la creazione di una classe base o un'interfaccia.

Lui dice:

Immagina ogni nuovo metodo che stai per implementare. Per ognuno di questi, considera questo: questo metodo sarà implementato da più di una classe esattamente in questa forma, senza alcuna modifica? Se la risposta è "sì", crea una classe base. In ogni altra situazione, crea un'interfaccia.

Per esempio:

Considera le classi cate dog, che estendono la classe mammale hanno un solo metodo pet(). Aggiungiamo quindi la classe alligator, che non estende nulla e ha un solo metodo slither().

Ora, vogliamo aggiungere un eat()metodo a tutti loro.

Se l'implementazione del eat()metodo sarà esattamente la stessa per cat, doge alligator, dovremmo creare una classe base (diciamo, animal), che implementa questo metodo.

Tuttavia, se la sua implementazione alligatordifferisce nel minimo modo, dovremmo creare IEatun'interfaccia e crearla mammale alligatorimplementarla.

Insiste sul fatto che questo metodo copre tutti i casi, ma mi sembra un'eccessiva semplificazione.

Vale la pena seguire questa regola empirica?


27
alligatorL'implementazione di eatdifferisce, ovviamente, in quanto accetta cate dogcome parametri.
sbichenko,

1
Laddove ti piacerebbe davvero che una classe base astratta condividesse le implementazioni, ma dovessi usare le interfacce per una corretta estensibilità, puoi invece usare tratti . Cioè, se la tua lingua supporta questo.
amon,

2
Quando ti dipingi in un angolo, è meglio essere in quello più vicino alla porta.
JeffO,

10
L'errore più grande che la gente fa è credere che le interfacce siano semplicemente classi astratte vuote. Un'interfaccia è un modo per un programmatore di dire: "Non mi interessa cosa mi dai, purché segua questa convenzione". Il riutilizzo del codice dovrebbe quindi essere realizzato (idealmente) attraverso la composizione. Il tuo collega ha torto.
riwalk

2
Un mio prof di CS ha insegnato che le superclassi dovrebbero essere is ae che le interfacce sono acts likeo is. Quindi un cane is amammifero e acts likeun mangiatore. Questo ci direbbe che il mammifero dovrebbe essere una classe e il mangiatore dovrebbe essere un'interfaccia. È sempre stata una guida molto utile. Sidenote: un esempio di issarebbe The cake is eatableo The book is writable.
MirroredFate

Risposte:


13

Non penso che questa sia una buona regola empirica. Se sei preoccupato per il riutilizzo del codice, puoi implementare un PetEatingBehaviorruolo che è quello di definire le funzioni alimentari per cani e gatti. Quindi puoi avere IEate riutilizzare il codice insieme.

In questi giorni vedo sempre meno motivi per usare l'eredità. Un grande vantaggio è la facilità d'uso. Prendi un framework GUI. Un modo popolare per progettare un'API è quello di esporre un'enorme classe di base e documentare quali metodi l'utente può ignorare. Quindi possiamo ignorare tutte le altre cose complesse che la nostra applicazione deve gestire, dato che un'implementazione "predefinita" è data dalla classe base. Ma possiamo ancora personalizzare le cose reimplementando il metodo corretto quando è necessario.

Se ti attieni all'interfaccia per il tuo API, il tuo utente di solito ha più lavoro da fare per far funzionare semplici esempi. Ma le API con le interfacce sono spesso meno accoppiate e più facili da mantenere per la semplice ragione che IEatcontiene meno informazioni rispetto alla Mammalclasse base. Quindi i consumatori dipenderanno da un contratto più debole, avrai maggiori opportunità di refactoring, ecc.

Per citare Rich Hickey: Simple! = Easy


Potresti fornire un esempio base di PetEatingBehaviorimplementazione?
sbichenko,

1
@exizt Innanzitutto, sono pessimo nella scelta dei nomi. Quindi PetEatingBehaviorè probabilmente quello sbagliato. Non posso darti un'implementazione poiché questo è solo un esempio di giocattolo. Ma posso descrivere i passaggi del refactoring: ha un costruttore che prende tutte le informazioni utilizzate da cani / gatti per definire il eatmetodo (istanza di denti, istanza di stomaco, ecc.). Contiene solo il codice comune a cani e gatti utilizzati nel loro eatmetodo. Devi semplicemente creare un PetEatingBehaviormembro e inoltrarlo a dog.eat/cat.eat.
Simon Bergot,

Non riesco a credere che questa sia l'unica risposta di molti per allontanare l'OP dalle eredità :( L'ereditarietà non è per il riutilizzo del codice!
Danny Tuppeny,

1
@DannyTuppeny Perché no?
sbichenko,

4
@exizt Ereditare da una classe è un grosso problema; stai prendendo una dipendenza (probabilmente-permanente) da qualcosa, che in molte lingue puoi avere solo una delle. Il riutilizzo del codice è una cosa abbastanza banale per utilizzare questa classe base spesso solo. Che cosa succede se si finiscono con metodi in cui si desidera condividerli con un'altra classe, ma ha già una classe di base a causa del riutilizzo di altri metodi (o addirittura, che fa parte di una gerarchia "reale" utilizzata polimorficamente (?)). Usa l'ereditarietà quando ClassA è un tipo di ClassB (ad es. Un'auto eredita dal veicolo), non quando condivide alcuni dettagli di implementazione interni :-)
Danny Tuppeny

11

Vale la pena seguire questa regola empirica?

È una regola empirica decente, ma conosco parecchi posti in cui violo.

Per essere onesti, uso le classi di base (astratte) molto più dei miei colleghi. Lo faccio come programmazione difensiva.

Per me (in molte lingue), una classe base astratta aggiunge il vincolo chiave che potrebbe esserci solo una classe base. Scegliendo di utilizzare una classe base piuttosto che un'interfaccia, stai dicendo "questa è la funzionalità principale della classe (e di qualsiasi ereditario), ed è inappropriato mescolare volenti o nolenti con altre funzionalità". Le interfacce sono l'opposto: "Questo è un tratto di un oggetto, non necessariamente la sua responsabilità principale".

Questo tipo di scelte progettuali crea guide implicite per i tuoi implementatori su come dovrebbero usare il tuo codice e, per estensione, scrivere il loro codice. A causa del loro maggiore impatto sulla base di codice, tendo a prendere in considerazione queste cose più fortemente quando si prende la decisione di progettazione.


1
Rileggendo questa risposta 3 anni dopo, trovo che questo sia un ottimo punto: scegliendo di usare una classe base piuttosto che un'interfaccia, stai dicendo "questa è la funzionalità principale della classe (e di qualsiasi ereditario), ed è inappropriato mescolare volenti o nolenti con altre funzionalità "
sbichenko

9

Il problema con la semplificazione del tuo amico è che determinare se un metodo dovrà o meno cambiare è eccezionalmente difficile. È meglio usare una regola che parli della mentalità dietro superclassi e interfacce.

Invece di capire cosa dovresti fare guardando a ciò che pensi sia la funzionalità, osserva la gerarchia che stai cercando di creare.

Ecco come un mio professore ha insegnato la differenza:

Le superclass dovrebbero essere is ae le interfacce sono acts likeo is. Quindi un cane is amammifero e acts likeun mangiatore. Questo ci direbbe che il mammifero dovrebbe essere una classe e il mangiatore dovrebbe essere un'interfaccia. Un esempio di issarebbe The cake is eatableo The book is writable(rendendo entrambi eatablee writableinterfacce).

L'uso di una metodologia come questa è ragionevolmente semplice e facile, e ti fa codificare sulla struttura e sui concetti piuttosto che su ciò che pensi che farà il codice. Ciò semplifica la manutenzione, rende il codice più leggibile e il design più semplice da creare.

Indipendentemente da ciò che il codice potrebbe effettivamente dire, se usi un metodo come questo potresti tornare tra cinque anni e utilizzare lo stesso metodo per ripercorrere i tuoi passi e capire facilmente come è stato progettato il tuo programma e come interagisce.

Solo i miei due centesimi.


6

Su richiesta di exizt, sto espandendo il mio commento a una risposta più lunga.

L'errore più grande che la gente fa è credere che le interfacce siano semplicemente classi astratte vuote. Un'interfaccia è un modo per un programmatore di dire: "Non mi interessa cosa mi dai, purché segua questa convenzione".

La libreria .NET è un ottimo esempio. Ad esempio, quando scrivi una funzione che accetta un IEnumerable<T>, quello che stai dicendo è "Non mi interessa come stai memorizzando i tuoi dati. Voglio solo sapere che posso usare un foreachloop.

Questo porta ad un codice molto flessibile. Improvvisamente per essere integrati, tutto ciò che devi fare è giocare secondo le regole delle interfacce esistenti. Se l'implementazione dell'interfaccia è difficile o confusa, forse questo è un suggerimento che stai cercando di spingere un piolo quadrato in un foro rotondo.

Ma poi sorge la domanda: "Che dire del riutilizzo del codice? I miei professori CS mi hanno detto che l'ereditarietà era la soluzione a tutti i problemi di riutilizzo del codice e che l'ereditarietà ti consente di scrivere una volta e di utilizzarla ovunque e curerebbe la menangite nel processo di salvataggio degli orfani dai mari nascenti e non ci sarebbero più lacrime e così via ecc. ecc. ecc. "

Usare l'eredità solo perché ti piace il suono delle parole "riutilizzo del codice" è una pessima idea. La guida allo stile del codice di Google rende questo punto abbastanza conciso:

La composizione è spesso più appropriata dell'eredità. ... [B] poiché il codice che implementa una sottoclasse viene distribuito tra la base e la sottoclasse, può essere più difficile comprendere un'implementazione. La sottoclasse non può ignorare le funzioni che non sono virtuali, quindi la sottoclasse non può cambiare l'implementazione.

Per illustrare perché l'ereditarietà non è sempre la risposta, userò una classe chiamata MySpecialFileWriter †. Una persona che crede ciecamente che l'ereditarietà sia la soluzione a tutti i problemi potrebbe sostenere che dovresti provare a ereditare FileStream, per non duplicare FileStreamil codice. Le persone intelligenti riconoscono che questo è stupido. Dovresti semplicemente avere un FileStreamoggetto nella tua classe (come variabile locale o membro) e usare la sua funzionalità.

L' FileStreamesempio può sembrare inventato, ma non lo è. Se hai due classi che implementano entrambe la stessa interfaccia nello stesso identico modo, allora dovresti avere una terza classe che incapsula qualsiasi operazione duplicata. Il tuo obiettivo dovrebbe essere quello di scrivere classi che siano blocchi riutilizzabili autonomi che possono essere messi insieme come lego.

Ciò non significa che l'eredità debba essere evitata a tutti i costi. Ci sono molti punti da considerare, e la maggior parte verrà affrontata ricercando la domanda "Composizione contro ereditarietà". Il nostro Stack Overflow ha alcune buone risposte sull'argomento.

Alla fine della giornata, i sentimenti del tuo collega mancano della profondità o della comprensione necessarie per prendere una decisione informata. Cerca l'argomento e scoprilo da solo.

† Quando si illustra l'eredità, tutti usano gli animali. Questo è inutile. In 11 anni di sviluppo, non ho mai scritto una classe chiamata Cat, quindi non la userò come esempio.


Quindi, se ti capisco correttamente, l'iniezione di dipendenza fornisce le stesse capacità di riutilizzo del codice dell'ereditarietà. Ma per cosa useresti l'eredità? Forniresti un caso d'uso? (Ho davvero apprezzato quello con FileStream)
sbichenko

1
@exizt, no non fornisce le stesse funzionalità di riutilizzo del codice. Offre migliori capacità di riutilizzo del codice (in molti casi) in quanto mantiene le implementazioni ben contenute. Le classi vengono interagite in modo esplicito attraverso i loro oggetti e le loro API pubbliche, anziché acquisire implicitamente capacità e cambiare completamente metà delle funzionalità sovraccaricando le funzioni esistenti.
riwalk

4

Gli esempi raramente hanno senso, soprattutto non quando si tratta di automobili o animali. Il modo di mangiare (o di elaborare il cibo) di un animale non è realmente legato alla sua eredità, non esiste un solo modo di mangiare che si applichi a tutti gli animali. Ciò dipende maggiormente dalle sue capacità fisiche (le modalità di input, elaborazione e output, per così dire). I mammiferi possono essere carnivori, erbivori o onnivori, ad esempio, mentre uccelli, mammiferi e pesci possono mangiare insetti. Questo comportamento può essere catturato con determinati schemi.

In questo caso, è meglio astrarre il comportamento, in questo modo:

public interface IFoodEater
{
    void Eat(IFood food);
}

public class Animal : IFoodEater
{
    private IFoodProcessor _foodProcessor;
    public Animal(IFoodProcessor foodProcessor)
    {
        _foodProcessor = foodProcessor;
    }

    public void Eat(IFood food)
    {
        _foodProcessor.Process(food);
    }
}

O implementare un modello di visitatore in cui si FoodProcessortenta di nutrire animali compatibili ( ICarnivore : IFoodEater, MeatProcessingVisitor). Il pensiero di animali vivi può ostacolarti nel pensare di strappare la loro via digestiva e sostituirla con una generica, ma stai davvero facendo loro un favore.

Questo renderà il tuo animale una classe base, e ancora meglio, uno che non dovrai mai più cambiare quando aggiungi un nuovo tipo di cibo e un processore corrispondente, così puoi concentrarti sulle cose che rendono questa specifica classe animale che stai lavorando su così unico.

Quindi sì, le classi di base hanno il loro posto, si applica la regola empirica (spesso, non sempre, in quanto è una regola empirica) ma continua a cercare comportamenti che puoi isolare.


1
IFoodEater è piuttosto ridondante. Cos'altro ti aspetti di poter mangiare? Sì, gli animali come esempi sono una cosa terribile.
Euforico,

1
Che dire delle piante carnivore? :) Seriamente, uno dei problemi principali nel non usare le interfacce in questo caso è che hai intimamente legato il concetto di mangiare con gli animali ed è molto più lavoro introdurre nuove astrazioni per le quali la classe base di Animali non è appropriata.
Julia Hayward,

1
Credo che "mangiare" sia un'idea sbagliata qui: diventa più astratto. Che ne dici di IConsumerun'interfaccia. I carnivori possono consumare carne, gli erbivori consumano piante e le piante consumano luce solare e acqua.

2

Un'interfaccia è davvero un contratto. Non c'è funzionalità. Spetta all'implementatore implementare. Non c'è scelta in quanto si deve attuare il contratto.

Le classi astratte offrono scelte agli implementatori. Si può scegliere di avere un metodo che tutti devono implementare, fornire un metodo con funzionalità di base che si possa ignorare o fornire alcune funzionalità di base che tutti gli eredi possono utilizzare.

Per esempio:

public abstract class Person
    {
        /// <summary>
        /// Inheritors must implement a hello
        /// </summary>
        /// <returns>Hello</returns>
        public abstract string SayHello();
        /// <summary>
        /// Inheritors can use base functionality, or override
        /// </summary>
        /// <returns></returns>
        public virtual string SayGoodBye()
        {
            return "Good Bye";
        }
        /// <summary>
        /// Base Functionality that is inherited 
        /// </summary>
        public void DoSomething()
        {
            //Do Something Here
        }
    }

Direi che la tua regola empirica potrebbe essere gestita anche da una classe di base. In particolare dal momento che molti mammiferi probabilmente "mangiano" allo stesso modo, usare una classe base potrebbe essere migliore di un'interfaccia, poiché si potrebbe implementare un metodo di alimentazione virtuale che la maggior parte degli eredi potrebbe usare e quindi i casi eccezionali potrebbero prevalere.

Ora, se la definizione di "mangiare" varia ampiamente da animale a animale, allora forse l'interfaccia sarebbe migliore. Questa è talvolta un'area grigia e dipende dalla situazione individuale.


1

No.

Le interfacce riguardano l'implementazione dei metodi. Se stai basando la tua decisione su quando utilizzare l'interfaccia su come verranno implementati i metodi, allora stai facendo qualcosa di sbagliato.

Per me, la differenza tra interfaccia e classe base riguarda la posizione dei requisiti. Si crea l'interfaccia quando un pezzo di codice richiede una classe con API specifica e comportamento implicito che emerge da questa API. Non è possibile creare un'interfaccia senza codice che la chiamerà effettivamente.

D'altra parte, le classi base sono generalmente intese come un modo per riutilizzare il codice, quindi raramente ti preoccupi del codice che chiamerà questa classe base e prenderai in considerazione solo la ricerca degli oggetti di cui fa parte questa classe base.


Aspetta, perché reimplementare eat()in ogni animale? Possiamo farcela mammal, vero? Capisco il tuo punto sui requisiti, però.
sbichenko,

@exizt: mi dispiace, ho letto la tua domanda in modo errato.
Euforico,

1

Non è davvero una buona regola empirica perché se vuoi implementazioni diverse potresti sempre avere una classe base astratta con un metodo astratto. Ogni erede ha la garanzia di avere quel metodo, ma ognuno definisce la propria implementazione. Tecnicamente potresti avere una classe astratta con nient'altro che un insieme di metodi astratti, e sarebbe sostanzialmente la stessa di un'interfaccia.

Le classi di base sono utili quando si desidera un'implementazione effettiva di alcuni stati o comportamenti che possono essere utilizzati in più classi. Se ti trovi con diverse classi che hanno campi e metodi identici con implementazioni identiche, puoi probabilmente trasformarlo in una classe base. Devi solo stare attento a come erediti. Ogni campo o metodo esistente nella classe base deve avere senso nell'erede, specialmente quando viene lanciato come classe base.

Le interfacce sono utili quando è necessario interagire con un insieme di classi allo stesso modo, ma non è possibile prevedere o non preoccuparsi della loro effettiva implementazione. Prendi ad esempio qualcosa come un'interfaccia Collection. Lord conosce solo tutti i modi in cui qualcuno potrebbe decidere di implementare una raccolta di oggetti. Lista collegata? Lista di array? Pila? Coda? Questo metodo SearchAndReplace non ha importanza, deve solo passare qualcosa che può chiamare Aggiungi, Rimuovi e GetEnumerator.


1

Sto guardando le risposte a questa domanda da solo, per vedere cosa hanno da dire gli altri, ma dirò tre cose che sono propenso a pensare a questo:

  1. Le persone danno troppa fiducia alle interfacce e troppa poca fiducia nelle classi. Le interfacce sono essenzialmente classi di base molto annacquate e ci sono occasioni in cui l'uso di qualcosa di leggero può portare a cose come un codice eccessivo di plateplate.

  2. Non c'è assolutamente nulla di sbagliato nel sovrascrivere un metodo, chiamare al super.method()suo interno e lasciare che il resto del suo corpo faccia solo cose che non interferiscono con la classe base. Ciò non viola il principio di sostituzione di Liskov .

  3. Come regola generale, essere così rigido e dogmatico nell'ingegneria del software è una cattiva idea, anche se qualcosa è generalmente una buona pratica.

Quindi prenderei ciò che è stato detto con un granello di sale.


5
People put way too much faith into interfaces, and too little faith into classes.Divertente. Tendo a pensare che sia vero il contrario.
Simon Bergot,

1
"Questo non viola il principio di sostituzione di Liskov." Ma è estremamente vicino diventare una violazione di LSP. Meglio prevenire che curare.
Euforico,

1

Voglio adottare un approccio linguale e semantico al riguardo. Un'interfaccia non è lì per DRY. Mentre può essere usato come meccanismo di centralizzazione oltre ad avere una classe astratta che lo implementa, non è pensato per DRY.

Un'interfaccia è un contratto.

IAnimall'interfaccia è un contratto tutto o niente tra alligatore, gatto, cane e la nozione di animale. Ciò che dice essenzialmente è "se vuoi essere un animale, o devi avere tutti questi attributi e caratteristiche, oppure non sei più un animale".

L'ereditarietà dall'altra parte è un meccanismo per il riutilizzo. In questo caso, penso che avere un buon ragionamento semantico possa aiutarti a decidere quale metodo dovrebbe andare dove. Ad esempio, mentre tutti gli animali potrebbero dormire e dormire allo stesso modo, non userò una classe base per questo. Prima ho messo il Sleep()metodo IAnimalnell'interfaccia come enfasi (semantico) per ciò che serve per essere un animale, quindi uso una classe astratta di base per gatto, cane e alligatore come tecnica di programmazione per non ripetermi.


0

Non sono sicuro che questa sia la migliore regola empirica là fuori. È possibile inserire un abstractmetodo nella classe base e far sì che ogni classe lo implementi. Dal momento che ognuno mammaldeve mangiare, sarebbe logico farlo. Quindi ognuno mammalpuò usare il suo unico eatmetodo. In genere utilizzo solo interfacce per la comunicazione tra oggetti o come marker per contrassegnare una classe come qualcosa. Penso che un uso eccessivo delle interfacce sia un codice sciatto.

Concordo anche con @Panzercrisis che una regola empirica dovrebbe essere solo una linea guida generale. Non qualcosa che dovrebbe essere scritto nella pietra. Indubbiamente ci saranno momenti in cui semplicemente non funziona.


-1

Le interfacce sono tipi. Non forniscono alcuna implementazione. Definiscono semplicemente il contratto per la gestione di quel tipo. Questo contratto deve essere rispettato da qualsiasi classe che implementa l'interfaccia.

L'uso di interfacce e classi astratte / di base dovrebbe essere determinato dai requisiti di progettazione.

Una singola classe non richiede un'interfaccia.

Se due classi sono intercambiabili all'interno di una funzione, dovrebbero implementare un'interfaccia comune. Qualsiasi funzionalità implementata in modo identico potrebbe quindi essere refactored in una classe astratta che implementa l'interfaccia. L'interfaccia potrebbe essere considerata ridondante a quel punto se non ci sono funzionalità distintive. Ma allora qual è la differenza tra le due classi?

L'uso di classi astratte come tipi è generalmente disordinato poiché vengono aggiunte più funzionalità e con l'espandersi di una gerarchia.

Favorisci la composizione rispetto all'eredità: tutto ciò scompare.

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.