È mai appropriato usare la sottotipizzazione per una relazione has-a?

6

Uno dei precetti di base della programmazione orientata agli oggetti è che la sottotipizzazione rappresenta la relazione "è-a". Cioè, il bambino è sempre una forma specifica del genitore. Un esempio comune è che un quadrato è una forma specifica di rettangolo, quindi sembra logico che una classe Square venga ereditata da una classe Rectangle . (leggi qui perché questo in realtà non ha senso - grazie a @KevinKrumwiede per averlo indicato)

Occasionalmente, si incontra una situazione in cui la sottotipizzazione è un modo conveniente per scrivere una relazione diversa.

Quello che attualmente affronto è relativamente semplice. Sto controllando un semplice sistema meccanico B, che contiene il sottosistema A. Quindi, nello spazio della carne, vediamo una relazione "ha-a" molto letterale: il sistema B ha un sistema A.

Scrivendo il codice come una stretta analogia con la realtà, mi sono ritrovato con qualcosa di simile al seguente: (Mostrato in Python per brevità. Il codice reale è in C ++.)

class A:
    def m1(self): print "A.m1"
    def m2(self): print "A.m2"
    def m3(self, arg): print "A.m3(" + arg + ")"

class B:
    def __init__(self): self.a = A()
    def m1(self): a.m1()
    def m2(self): a.m2()
    def m3(self): print "B.m3"
    def m4(self): a.m3("B.m4")

Sebbene la classe B contribuisca a funzionalità utili (nella versione di vita reale), molti dei suoi metodi sono semplici pass-through. La classe B non ha nulla di utile da aggiungere a m1() o m2() - deve solo esporli ai proprietari di oggetti B .

Se eliminiamo il precetto che l'ereditarietà dovrebbe rappresentare una relazione "è-a", potremmo semplificare le cose:

class A:
    def m1(self): print "A.m1"
    def m2(self): print "A.m2"
    def m3(self, arg): print "A.m3(" + arg + ")"

class B(A):
    def __init__(self): pass
    def m3(self): print "B.m3"
    def m4(self): a.m3("B.m4")

Ora tutti i metodi di A sono esposti come se appartenessero a B . B può (e fa) sovrascrivere i metodi di A come necessario. Il maintainer di B non ha bisogno di aggiornare B ogni volta che A aggiunge un nuovo metodo - i passthrough sono gratuiti.

In qualche modo questo sembra sbagliato, ma non riesco a capire del tutto perché. Si tratta di un abuso dei tecnicismi del meccanismo ereditario? È mai appropriato rappresentare una relazione "ha-a" in questo modo? B è non una forma più specifica di A , ma l'abbiamo sottoclassata come se fosse.

L'unico inconveniente pratico che riesco a pensare è che i metodi tutti di A sono ora disponibili per la chiamata e B non ha alcun modo per impedirlo. In un linguaggio come Python, non esiste un concetto di metodi privati, quindi non importa. Potrebbe in una lingua come C ++. Esistono altri svantaggi?

Modifica: per tutti coloro che troveranno questa domanda in futuro, assicurati di leggere questa risposta così come la risposta accettata . Entrambi elencano gli svantaggi degni di nota nell'usare l'ereditarietà per realizzare la composizione.

    
posta John Walthour 27.04.2016 - 15:05
fonte

3 risposte

6

Se la tua situazione di composizione è abbastanza semplice, puoi invece utilizzare l'ereditarietà.

Non consigliato, perché sarebbe fonte di confusione per gli altri, e richiederà anche il refactoring non appena la situazione della composizione diventa meno semplice.

La composizione consente un numero di cose. Poiché le classi sono separate, ciascuna può avere una propria gerarchia di ereditarietà. Quindi, usando la composizione, una B può avere una relazione has-a con qualsiasi istanza A, compresi quelli la cui classe è una sottoclasse di A.

Composition consente a un A esistente di essere riferito da un nuovo B. Permette a più B di riferirsi a un singolo A. Inoltre, la composizione potenzialmente permette anche di cambiare quale A che B ha-a in runtime, mentre l'ereditarietà significa che si ottiene una nuova A con una nuova B (periodo).

Inoltre, per le lingue che distinguono tra classi e interfacce, la composizione consente una relazione has-a con un'interfaccia, mentre l'ereditarietà richiede che una classe fissa e nota sia specificata come base. Per questi motivi (forse e anche di più), la composizione è più flessibile rispetto all'ereditarietà, e la possibilità di utilizzare le interfacce rafforza ulteriormente l'accoppiamento più flessibile, che riduce gli oneri di manutenzione.

Se non usi nessuna di queste funzionalità, potresti usare l'ereditarietà per raggruppare una A con una B. Ma non sarai mai in grado di scegliere una sottoclasse di A per riguardare B, quindi se A è coinvolto nella sottoclasse non sarai in grado di scegliere tra di loro per B. Dovrai duplicare B nella nuova classe B 'che eredita da A'. Se facessi questo tipo di duplicazione, sarebbe un odore di codice.

Inoltre, non sarebbe possibile scegliere un A-B esistente per A-B. Dovresti sempre creare una nuova A insieme a una nuova B.

L'ereditarietà consente metodi virtuali e sovrascritture per sottoclassi e passaggi di sottoclasse quando è prevista una classe base o successiva. Se erediti e non esegui metodi virtuali o sostituzioni, o polimorfismo, sembra che sarebbe anche un odore di codice.

Si noti che C ++ consente l'incorporamento di istanze come un altro meccanismo per raggruppare una A all'interno di una B, senza utilizzare l'ereditarietà; avrai le stesse restrizioni per non essere in grado di scegliere una sottoclasse di A senza usare una B ', ma almeno non stai confondendo con altri maintainer con ereditarietà altrimenti non utilizzati. Altri linguaggi (Java, C #) usano riferimenti, quindi l'incorporamento non è un'opzione: è un'eredità o una composizione.

    
risposta data 27.04.2016 - 17:49
fonte
3

La praticità dell'ereditarietà in questo esempio sta riducendo la quantità di codice boilerplate che devi scrivere, ma questo rientra nel riutilizzo del codice piuttosto che nel riutilizzo comportamentale. Di per sé, non è un argomento contro l'ereditarietà, ma piuttosto che il riutilizzo del codice non è un argomento sufficiente per l'ereditarietà.

L'ereditarietà ha significato e conseguenze oltre la riduzione della ripetizione del codice. Quando le conseguenze non sono intese, possono sorgere problemi. I problemi relativi all'utilizzo dell'ereditarietà per una relazione "ha-a" includono:

  • viola l'incapsulamento (come citi nella domanda)
  • accoppia le classi più strettamente del necessario, il che ha conseguenze di vasta portata
  • L'accoppiamento
  • può creare involontariamente metodi sovrascritti, che possono produrre diversi bug:
    • nel bambino, il metodo derivato non chiama la base quando dovrebbe (può essere catturato nei test unitari e risolto)
    • nei metodi padre, le chiamate al metodo overriden possono invece chiamare il metodo derivato quando non dovrebbero
  • L'accoppiamento
  • può anche creare involontariamente campi sovrascritti che, combinati con la violazione dell'incapsulazione, possono causare errori
  • consente di utilizzare istanze figlio ovunque sia possibile utilizzare il genitore. Questo potrebbe essere un comportamento desiderabile o potrebbe essere una fonte di bug. Ha senso per una funzione che prende un sottosistema per ricevere invece il sistema meccanico?
  • influisce su come la gerarchia di tipi può essere modificata e le classi estese

Se non si desidera scrivere esplicitamente metodi di inoltro, a volte è possibile utilizzare le funzionalità della lingua per inoltrare le chiamate di metodo non definite a un altro oggetto (la parte funzionale del modello di proxy (oggetto)). Ciò potrebbe comunque violare le restrizioni di accesso, ma in gran parte evitare altri problemi.

In Python, puoi usare __getattr__ , __setattr__ e __delattr__ (ci sono altri metodi speciali che possono essere utili o necessari); questi ti danno anche l'opportunità di implementare la restrizione dell'accesso, in quanto unpitonica. Implementa il Proxy Pattern nella sua classe e puoi aggiungere la funzionalità alla maggior parte delle altre classi che desideri con meno di 2 righe di codice.

In C ++, puoi sovraccaricare operator-> , ma al costo dell'accesso illimitato e devi usare l'operatore freccia anziché l'operatore punto per chiamare i metodi (che è meno di accoppiamento rispetto all'ereditarietà ma leggermente più che scrivere esplicitamente ogni inoltro metodo).

    
risposta data 28.04.2016 - 00:05
fonte
1

Il tuo obiettivo in entrambi i casi è lo stesso: extending B's behviour. Questo può essere fatto se B usa A o se B eredita da A .

Usage potrebbe essere fatto in tre modi:

a) Aggregazione

b) Composizione

c) B fa un uso temporaneo di A per fare stuff (pensa a A come parte di un messaggio inviato a B ).

Entrambe le implementazioni differiscono semanticamente. La seconda implementazione ha senso solo nel contesto polimorfico. Pensa ai decoratori , dove vuoi sostituire B per A con un comportamento alternativo. B estende il comportamento di A s. Ma oltre a ciò, l'implementazione va bene.

    
risposta data 27.04.2016 - 19:44
fonte

Leggi altre domande sui tag