Esiste un linguaggio o un modello di progettazione che consenta la * rimozione * del comportamento o delle proprietà di un oggetto in una gerarchia di classi?

28

Una ben nota lacuna delle gerarchie classiche di classe è che sono cattive quando si tratta di modellare il mondo reale. Ad esempio, cercando di rappresentare le specie di animali con le classi. Ci sono in realtà diversi problemi quando lo faccio, ma uno che non ho mai visto una soluzione è quando una sottoclasse "perde" un comportamento o una proprietà che è stata definita in una super-classe, come un pinguino che non è in grado di volare (lì sono probabilmente esempi migliori, ma questo è il primo che mi viene in mente).

Da un lato, non si desidera definire, per ogni proprietà e comportamento, un flag che specifica se è presente, e controllarlo ogni volta prima di accedere a quel comportamento o proprietà. Vorresti solo dire che gli uccelli possono volare, semplicemente e chiaramente, nella classe Bird. Ma poi sarebbe bello se si potessero definire "eccezioni" in seguito, senza dover usare alcuni hack orribili ovunque. Ciò accade spesso quando un sistema è produttivo da un po 'di tempo. All'improvviso trovi un'eccezione che non si adatta affatto al design originale e non desideri modificare gran parte del codice per adattarlo.

Quindi, ci sono linguaggi o schemi di progettazione in grado di gestire in modo pulito questo problema, senza richiedere modifiche importanti alla "super-classe" e a tutto il codice che la usa? Anche se una soluzione gestisce solo un caso specifico, diverse soluzioni potrebbero costituire una strategia completa.

Dopo aver riflettuto di più, mi rendo conto che mi sono dimenticato del Principio di sostituzione di Liskov. Ecco perché non puoi farlo. Supponendo di definire "tratti / interfacce" per tutti i principali "gruppi di caratteristiche", puoi implementare liberamente tratti in diversi rami della gerarchia, come la caratteristica Volante potrebbe essere implementata da Uccelli, e qualche tipo speciale di scoiattoli e pesci.

Quindi la mia domanda potrebbe ammontare a "Come potrei non implementare un tratto?" Se la tua super classe è una Serializable Java, devi esserne uno anche se non è possibile per te serializzare il tuo stato, ad esempio se conteneva un "Socket".

Un modo per farlo è quello di definire sempre tutti i tratti a coppie dall'inizio: Volare e NotFlying (che genererebbe UnsupportedOperationException, se non confrontato). Il non-tratto non definirebbe alcuna nuova interfaccia e potrebbe essere semplicemente controllato. Sembra una soluzione "economica", in particolare se utilizzata sin dall'inizio.

    
posta Sebastien Diot 15.11.2011 - 13:04
fonte

14 risposte

17

Come altri hanno menzionato, dovresti andare contro LSP.

Tuttavia, si può sostenere che una sottoclasse è semplicemente un'estensione arbitaria di una super classe. È un nuovo oggetto a sé stante e l'unica relazione con la super classe è che viene utilizzata una base.

Questo può avere senso logico, piuttosto che dire che Penguin è un uccello. Il tuo pinguino dicendo eredita alcuni sottoinsiemi di comportamento da Bird.

In generale le lingue dinamiche ti consentono di esprimerlo facilmente, un esempio che utilizza JavaScript è il seguente:

var Penguin = Object.create(Bird);
Penguin.fly = undefined;
Penguin.swim = function () { ... };

In questo caso particolare, Penguin ombreggia attivamente il metodo Bird.fly che eredita scrivendo una proprietà fly con valore undefined all'oggetto.

Ora puoi affermare che Penguin non può più essere considerato come% normaleBird. Ma come accennato, nel mondo reale non può semplicemente. Perché modelliamo Bird come entità volante.

L'alternativa è di non dare l'idea generale che Bird's possa volare. Sarebbe sensato avere un'astrazione Bird che permetta a tutti gli uccelli di ereditarsene, senza errori. Questo significa solo fare supposizioni che tutte le sottoclassi possono contenere.

Generalmente l'idea di Mixin si applica bene qui. Avere una classe base molto sottile e mescolare tutti gli altri comportamenti.

Esempio:

// for some value of Object.make
var Penguin = Object.make(
  /* base class: */ Bird,
  /* mixins: */ Swimmer, ...
);
var Hawk = Object.make(
  /* base class: */ Bird,
  /* mixins: */ Flyer, Carnivore, ...
);

Se sei curioso, ho un implementazione di Object.make

Aggiunta:

So my question could amount to "How could I un-implement a trait?" If your super-class is a Java Serializable, you have to be one too, even if there is no way for you to serialize your state, for example if you contained a "Socket".

Non "disattivi" un tratto. Risolvi semplicemente la gerarchia dell'eredità. O puoi soddisfare il tuo contratto di super classi o non dovresti fingere di essere di quel tipo.

Questo è dove la composizione dell'oggetto brilla.

Per inciso, Serializable non significa che tutto dovrebbe essere serializzato, significa solo "lo stato a cui tieni" deve essere serializzato.

Non dovresti usare un tratto "NotX". Questo è solo un orrendo codice gonfiato. Se una funzione si aspetta un oggetto volante, dovrebbe schiantarsi e bruciarsi quando gli dai un mammut.

    
risposta data 15.11.2011 - 13:52
fonte
28

AFAIK tutte le lingue basate sull'ereditarietà sono basate sul Principio di sostituzione di Liskov . Rimozione / disabilitazione di una proprietà di classe base in una sottoclasse viola chiaramente LSP, quindi non penso che tale possibilità sia implementata ovunque. Il mondo reale è davvero disordinato e non può essere modellato con precisione da astrazioni matematiche.

Alcune lingue forniscono tratti o mixin, proprio per affrontare tali problemi in modo più flessibile.

    
risposta data 15.11.2011 - 13:10
fonte
15

Fly() è nel primo esempio in: Head First Design Patterns per The Strategy Pattern , e questa è una buona situazione sul perché dovresti " Favorire la composizione sull'ereditarietà. ".

Potresti mescolare la composizione e l'ereditarietà con supertipi di FlyingBird , FlightlessBird che hanno il comportamento corretto iniettato da una Fabbrica, che i sottotipi pertinenti, ad es. Penguin : FlightlessBird si ottiene automaticamente e qualsiasi altra cosa veramente specifica viene gestita dalla Factory come una cosa naturale.

    
risposta data 15.11.2011 - 15:14
fonte
11

Non è il vero problema che stai assumendo che Bird abbia un metodo Fly ? Perché no:

class Bird
{
    // features that all birds have
}

class BirdThatCanSwim : Bird
{
    public void Swim() {...};
}

class BirdThatCanFly : Bird
{
    public void Fly() {...};
}


class Penguin : BirdThatCanSwim { }
class Sparrow : BirdThatCanFly { }

Ora il problema ovvio è l'ereditarietà multipla ( Duck ), quindi quello di cui hai veramente bisogno sono le interfacce:

interface IBird { }
interface IBirdThatCanSwim : IBird { public void Swim(); }
interface IBirdThatCanFly : IBird { public void Fly(); }
interface IBirdThatCanQuack : IBird { public void Quack(); }

class Duck : BirdThatCanFly, IBirdThatCanSwim, IBirdThatCanQuack
{
    public void Swim() {...};
    public void Quack() {...};
}
    
risposta data 15.11.2011 - 16:41
fonte
7

In primo luogo, SÌ, qualsiasi linguaggio che consenta una modifica dinamica dell'oggetto semplice ti consentirebbe di farlo. In Ruby, ad esempio, puoi facilmente rimuovere un metodo.

Ma come Péter Török ha detto che violerebbe LSP .

In questa parte, dimenticherò LSP e assumerò che:

  • Bird è una classe con un metodo fly ()
  • Il pinguino deve ereditare da Bird
  • Penguin non può volare ()
  • Non mi interessa se è un buon design o se corrisponde al mondo reale, come è l'esempio fornito in questa domanda.

Hai detto:

On the one hand, you don't want to define for every property and behavior some flag that specifies if it is at all present, and check it every time before accessing that behavior or property

Sembra che quello che vuoi sia " di Python che richiede perdono piuttosto che permesso "

Fai in modo che il tuo Pinguino lanci un'eccezione o erediti da una classe NonFlyingBird che genera un'eccezione (pseudo codice):

class Penguin extends Bird {
     function fly():void {
          throw new Exception("Hey, I'm a penguin, I can't fly !");
     }
}

A proposito, qualunque cosa tu scelga: sollevare un'eccezione o rimuovere un metodo, alla fine, il codice seguente (supponendo che la tua lingua supporti la rimozione del metodo):

var bird:Bird = new Penguin();
bird.fly();

genererà un'eccezione di runtime.

    
risposta data 15.11.2011 - 13:33
fonte
7

Come qualcuno ha sottolineato sopra nei commenti, i pinguini sono uccelli, i pinguini non volano, quindi non tutti gli uccelli possono volare.

Quindi Bird.fly () non dovrebbe esistere o essere autorizzato a non funzionare. Preferisco il precedente.

Avere FlyingBird estende Bird se il metodo .fly () è corretto, naturalmente.

    
risposta data 15.11.2011 - 16:33
fonte
6

Il vero problema con l'esempio fly () è che l'input e l'output dell'operazione non sono definiti correttamente. Cosa è necessario per un uccello per volare? E cosa succede dopo aver volato? I tipi di parametri e i tipi di ritorno per la funzione fly () devono contenere tali informazioni. Altrimenti il tuo design dipende da effetti collaterali casuali e qualsiasi cosa può accadere. La parte qualsiasi è ciò che causa l'intero problema, l'interfaccia non è definita correttamente e tutti i tipi di implementazione sono consentiti.

Quindi, invece di questo:

class Bird {
public:
   virtual void fly()=0;
};

Dovresti avere qualcosa di simile a questo:

   class Bird {
   public:
      virtual float fly(float x) const=0;
   };

Ora definisce esplicitamente i limiti della funzionalità - il tuo comportamento di volo ha solo un singolo galleggiamento per decidere - la distanza da terra, quando viene data la posizione. Ora l'intero problema si risolve automaticamente da solo. Un uccello che non può volare restituisce appena 0.0 da quella funzione, non lascia mai la terra. È un comportamento corretto per questo, e una volta deciso il float, sai di aver implementato completamente l'interfaccia.

Il comportamento reale può essere difficile da codificare per i tipi, ma questo è l'unico modo per specificare correttamente le tue interfacce.

Modifica: voglio chiarire un aspetto. Questa versione float- > float della funzione fly () è importante anche perché definisce un percorso. Questa versione significa che l'unico uccello non può duplicarsi magicamente mentre sta volando. Questo è il motivo per cui il parametro è float singolo - è la posizione nel percorso che l'uccello prende. Se vuoi percorsi più complessi, allora Point2d posinpath (float x); che usa la stessa x della funzione fly ().

    
risposta data 15.11.2011 - 18:31
fonte
4

Tecnicamente puoi farlo praticamente in qualsiasi linguaggio di digitazione dinamica / anatra (JavaScript, Ruby, Lua, ecc.) ma è quasi sempre una pessima idea. Rimuovere i metodi da una classe è un incubo di manutenzione, simile all'utilizzo di variabili globali (ad esempio, non si può dire in un modulo che lo stato globale non è stato modificato altrove).

I buoni schemi per il problema che hai descritto sono Decorator o Strategia, progettando un'architettura di componenti. Fondamentalmente, piuttosto che rimuovere i comportamenti non necessari dalle sottoclassi, si costruiscono oggetti aggiungendo i comportamenti necessari. Quindi per costruire la maggior parte degli uccelli devi aggiungere il componente volante, ma non aggiungere quel componente ai tuoi pinguini.

    
risposta data 15.11.2011 - 14:45
fonte
3

Peter ha menzionato il Principio di sostituzione di Liskov, ma ritengo che sia necessario spiegarlo.

Let q(x) be a property provable about objects x of type T. Then q(y) should be provable for objects y of type S where S is a subtype of T.

Quindi, se un uccello (oggetto x di tipo T) può volare (q (x)) allora un pinguino (oggetto y di tipo S) può volare (q (y)), per definizione. Ma chiaramente non è il caso. Ci sono anche altre creature che possono volare ma non sono di tipo Uccello.

Il modo in cui gestisci questo dipende dalla lingua. Se una lingua supporta l'ereditarietà multipla, dovresti usare una classe astratta per le creature che possono volare; se una lingua preferisce interfacce, allora questa è la soluzione (e l'implementazione di fly dovrebbe essere incapsulata piuttosto che ereditata); oppure, se una lingua supporta Duck Typing (non è previsto il gioco di parole), puoi semplicemente implementare un metodo di volo su quelle classi che possono e chiamarlo se è lì.

Ma ogni proprietà di una superclasse dovrebbe applicarsi a tutte le sue sottoclassi.

[In risposta alla modifica]

L'applicazione di un "tratto" di CanFly a Bird non è migliore. Sta ancora suggerendo di chiamare il codice che tutti gli uccelli possono volare.

Un tratto nei termini che hai definito è esattamente ciò che intendeva Liskov quando ha detto "proprietà".

    
risposta data 15.11.2011 - 13:42
fonte
2

Vorrei iniziare citando (come tutti gli altri) il Principio di sostituzione di Liskov, che spiega perché non dovresti farlo. Tuttavia, il problema di cosa dovresti fare è di progettazione. In alcuni casi potrebbe non essere importante che Penguin non possa effettivamente volare. Forse puoi fare in modo che Penguin lanci InsufficientWingsException quando ti viene chiesto di volare, purché nella documentazione di Bird :: fly () sia chiaro che può lanciarlo per gli uccelli che non possono volare. Di avere un test per vedere se è davvero in grado di volare, anche se questo complica l'interfaccia.

L'alternativa è ristrutturare le tue classi. Creiamo la classe "FlyingCreature" (o meglio un'interfaccia, se hai a che fare con il linguaggio che lo consente). "Bird" non eredita da FlyingCreature, ma puoi creare "FlyingBird" che lo fa. Lark, Vulture e Eagle ereditano tutti da FlyingBird. Il pinguino no. Si eredita solo da Bird.

È un po 'più complicato della struttura ingenua, ma ha il vantaggio di essere accurato. Noterai che tutte le classi previste sono lì (Bird) e che l'utente di solito ignora quelle "inventate" (FlyingCreature) se non è importante se la tua creatura può volare o meno.

    
risposta data 15.11.2011 - 16:20
fonte
0

Il modo tipico per gestire una situazione del genere è di lanciare qualcosa come UnsupportedOperationException (Java) resp. NotImplementedException (C #).

    
risposta data 15.11.2011 - 14:15
fonte
0

Molte buone risposte con molti commenti, ma non tutti sono d'accordo, e posso scegliere solo una singola, quindi riassumerò qui tutte le opinioni con le quali sono d'accordo.

0) Non assumere "tipizzazione statica" (l'ho fatto quando ho chiesto, perché Java lo faccio quasi esclusivamente). Fondamentalmente, il problema dipende molto dal tipo di linguaggio che si usa.

1) Si dovrebbe separare la gerarchia di tipi dalla gerarchia di riutilizzo del codice nella progettazione e nella propria testa, anche se per lo più si sovrappongono. In genere, utilizza le classi per il riutilizzo e le interfacce per i tipi.

2) Il motivo per cui normalmente Bird IS-A Fly è perché la maggior parte degli uccelli può volare, quindi è pratico dal punto di vista del riutilizzo del codice, ma dicendo che Bird IS-A Fly è in realtà sbagliato visto che c'è almeno un'eccezione (Penguin).

3) In entrambi i linguaggi statici e dinamici, potresti semplicemente generare un'eccezione. Ma questo dovrebbe essere usato solo se è esplicitamente dichiarato nel "contratto" della classe / interfaccia che dichiara la funzionalità, altrimenti si tratta di una "violazione del contratto". Significa anche che ora devi essere pronto a cogliere l'eccezione ovunque, quindi scrivi più codice sul sito di chiamata ed è un brutto codice.

4) In alcuni linguaggi dinamici, è effettivamente possibile "rimuovere / nascondere" la funzionalità di una super-classe. Se verificare la presenza della funzionalità è come si controlla "IS-A" in quella lingua, allora questa è una soluzione adeguata e ragionevole. Se invece l'operazione "IS-A" è un'altra cosa che dice ancora che il tuo oggetto "dovrebbe" implementare la funzionalità ora mancante, allora il tuo codice chiamante supporterà che la funzionalità sia presente e la chiami e si blocchi, quindi è un tipo di importo per lanciare un'eccezione.

5) L'alternativa migliore è quella di separare effettivamente la caratteristica Fly dal tratto Bird. Quindi un volatile deve estendere / implementare esplicitamente sia Bird che Fly / Flying. Questo è probabilmente il design più pulito, in quanto non devi "rimuovere" nulla. L'unico svantaggio è che quasi tutti gli uccelli devono implementare sia Bird and Fly, quindi si scrive più codice. Il modo per aggirare questo è avere una classe intermedia FlyingBird, che implementa sia Bird che Fly, e rappresenta il caso comune, ma questa soluzione potrebbe essere di uso limitato senza ereditarietà multipla.

6) Un'altra alternativa che non richiede l'ereditarietà multipla è usare la composizione invece delle eredità. Ogni aspetto di un animale è modellato da una classe indipendente, e un Bird concreto è una composizione di Bird, e possibilmente Fly o Swim, ... Ottieni pieno riutilizzo del codice, ma devi fare uno o più passaggi aggiuntivi per arrivare a la funzionalità di volo, quando si ha un riferimento di un uccello concreto. Inoltre, il linguaggio naturale "oggetto IS-A Fly" e "oggetto AS-A (cast) Fly" non funzionerà più, quindi è necessario inventare la propria sintassi (alcuni linguaggi dinamici potrebbero avere un modo per aggirare questo). Ciò potrebbe rendere il tuo codice più ingombrante.

7) Definisci la tua caratteristica Fly in modo che offra una via d'uscita chiara per qualcosa che non può volare. Fly.getNumberOfWings () potrebbe restituire 0. Se Fly.fly (direction, currentPotinion) dovrebbe restituire la nuova posizione dopo il volo, allora Penguin.fly () potrebbe semplicemente restituire la posizione corrente senza cambiarla. Si potrebbe finire con il codice che tecnicamente funziona, ma ci sono alcuni avvertimenti. In primo luogo, alcuni codici potrebbero non avere un ovvio comportamento "non fare nulla". Inoltre, se qualcuno chiama x.fly (), si aspetterebbe che faccia qualcosa , anche se il commento dice che fly () è autorizzato a non fare nulla . Infine, il pinguino IS-A Flying tornerebbe ancora true, il che potrebbe creare confusione per il programmatore.

8) Fai come 5), ma usa la composizione per aggirare i casi che richiederebbero più ereditarietà. Questa è l'opzione che preferirei per un linguaggio statico, come 6) sembra più ingombrante (e probabilmente richiede più memoria perché abbiamo più oggetti). Un linguaggio dinamico potrebbe rendere 6) meno ingombrante, ma dubito che diventi meno ingombrante di 5).

    
risposta data 15.11.2011 - 22:49
fonte
0

Definire un comportamento predefinito (contrassegnarlo come virtuale) nella classe base e sovrascriverlo come necessario. In questo modo ogni uccello può "volare".

Anche i pinguini volano, il suo volo attraverso il ghiaccio a quota zero!

Il comportamento del volo può essere sovrascritto, se necessario.

Un'altra possibilità è avere una Fly Interface. Non tutti gli uccelli implementeranno tale interfaccia.

class eagle : bird, IFly
class penguin : bird

Le proprietà non possono essere rimosse, ecco perché è importante sapere quali proprietà sono comuni a tutti gli uccelli. Penso che sia più un problema di progettazione per assicurarsi che le proprietà comuni siano implementate al livello base.

    
risposta data 15.11.2011 - 22:59
fonte
-1

Penso che lo schema che stai cercando sia un buon vecchio polimorfismo. Mentre potresti essere in grado di rimuovere un'interfaccia da una classe in alcune lingue, probabilmente non è una buona idea per le ragioni fornite da Péter Török. In qualsiasi linguaggio OO, tuttavia, è possibile sovrascrivere un metodo per cambiarne il comportamento, e questo include non fare nulla. Per prendere in prestito il tuo esempio, potresti fornire un metodo Penguin :: fly () che svolge una delle seguenti azioni:

  • non
  • genera un'eccezione
  • chiama invece il metodo Penguin :: swim ()
  • asserisce che il pinguino è sott'acqua (fanno una specie di "volo" attraverso l'acqua)

Le proprietà possono essere poco più facili da aggiungere e rimuovere se si pianifica in anticipo. È possibile memorizzare le proprietà in un array mappa / dizionario / associativo anziché utilizzare variabili di istanza. È possibile utilizzare il modello Factory per produrre istanze standard di tali strutture, quindi un Bird proveniente da BirdFactory inizierà sempre con lo stesso insieme di proprietà. La Key Value Coding di Objective-C è un buon esempio di questo genere di cose.

Nota: la lezione seria dei commenti sottostanti è che, sebbene l'override per la rimozione di un comportamento possa funzionare, non è sempre la soluzione migliore. Se ti ritrovi a doverlo fare in modo significativo dovresti considerare che un strong segnale che il tuo grafico di ereditarietà è difettoso. Non è sempre possibile rifattorizzare le classi da cui si eredita, ma quando è spesso è la soluzione migliore.

Usando il tuo esempio Penguin, un modo per il refattore sarebbe quello di separare l'abilità di volo dalla classe Bird. Poiché non tutti gli uccelli possono volare, anche un metodo di volo () in Bird non è appropriato e conduce direttamente al tipo di problema che stai chiedendo. Quindi, sposta il metodo fly () (e forse takeoff () e land ()) su una classe o interfaccia Aviator (a seconda della lingua). Questo ti consente di creare una classe FlyingBird che eredita da Bird e Aviator (o eredita da Bird e implementa Aviator). Penguin può continuare ad ereditare direttamente da Bird ma non da Aviator, evitando così il problema. Un simile accordo potrebbe anche facilitare la creazione di classi per altri oggetti volanti: FlyingFish, FlyingMammal, FlyingMachine, AnnoyingInsect e così via.

    
risposta data 15.11.2011 - 15:01
fonte

Leggi altre domande sui tag