Ereditarietà e composizione (relazione Is-a vs Has-a) in Java

1. Panoramica

Ereditarietà e composizione, insieme all'astrazione, all'incapsulamento e al polimorfismo, sono le pietre angolari della programmazione orientata agli oggetti (OOP).

In questo tutorial, tratteremo le basi dell'ereditarietà e della composizione e ci concentreremo fortemente sull'individuazione delle differenze tra i due tipi di relazioni.

2. Nozioni di base sull'ereditarietà

L'ereditarietà è un meccanismo potente ma abusato e abusato.

In poche parole, con l'ereditarietà, una classe base (nota anche come tipo di base) definisce lo stato e il comportamento comune per un dato tipo e consente alle sottoclassi (note anche come sottotipi) di fornire versioni specializzate di quello stato e comportamento.

Per avere un'idea chiara su come lavorare con l'ereditarietà, creiamo un esempio ingenuo: una classe base Person che definisce i campi ei metodi comuni per una persona, mentre le sottoclassi Waitress e Actress forniscono implementazioni di metodi aggiuntive e dettagliate.

Ecco la classe Persona :

public class Person { private final String name; // other fields, standard constructors, getters }

E queste sono le sottoclassi:

public class Waitress extends Person { public String serveStarter(String starter) { return "Serving a " + starter; } // additional methods/constructors } 
public class Actress extends Person { public String readScript(String movie) { return "Reading the script of " + movie; } // additional methods/constructors }

Inoltre, creiamo uno unit test per verificare che le istanze delle classi Waitress e Actress siano anche istanze di Person , dimostrando così che la condizione "è-a" è soddisfatta a livello di tipo:

@Test public void givenWaitressInstance_whenCheckedType_thenIsInstanceOfPerson() { assertThat(new Waitress("Mary", "[email protected]", 22)) .isInstanceOf(Person.class); } @Test public void givenActressInstance_whenCheckedType_thenIsInstanceOfPerson() { assertThat(new Actress("Susan", "[email protected]", 30)) .isInstanceOf(Person.class); }

È importante sottolineare qui l'aspetto semantico dell'ereditarietà . Oltre a riutilizzare l'implementazione della classe Person , abbiamo creato una relazione "è-a" ben definita tra il tipo base Persona ei sottotipi Cameriera e Attrice . Le cameriere e le attrici sono, effettivamente, persone.

Questo può farci chiedere: in quali casi d'uso l'ereditarietà è l'approccio giusto da adottare?

Se i sottotipi soddisfano la condizione "è-a" e forniscono principalmente funzionalità additive più in basso nella gerarchia delle classi, l' ereditarietà è la strada da percorrere.

Ovviamente, l'override del metodo è consentito fintanto che i metodi sovrascritti preservano la sostituibilità del tipo / sottotipo di base promossa dal principio di sostituzione di Liskov.

Inoltre, dobbiamo tenere presente che i sottotipi ereditano l'API del tipo di base , il che in alcuni casi può essere eccessivo o semplicemente indesiderabile.

Altrimenti, dovremmo usare invece la composizione.

3. Ereditarietà in Design Patterns

Sebbene il consenso sia che dovremmo privilegiare la composizione rispetto all'ereditarietà, ove possibile, ci sono alcuni casi d'uso tipici in cui l'eredità ha il suo posto.

3.1. Il pattern del supertipo di livello

In questo caso, usiamo l'ereditarietà per spostare il codice comune in una classe base (il supertipo), in base al livello .

Ecco un'implementazione di base di questo modello nel livello di dominio:

public class Entity { protected long id; // setters } 
public class User extends Entity { // additional fields and methods } 

Possiamo applicare lo stesso approccio agli altri livelli del sistema, come i livelli di servizio e di persistenza.

3.2. Il modello del metodo Pattern

Nel modello del metodo modello, possiamo utilizzare una classe base per definire le parti invarianti di un algoritmo e quindi implementare le parti varianti nelle sottoclassi :

public abstract class ComputerBuilder { public final Computer buildComputer() { addProcessor(); addMemory(); } public abstract void addProcessor(); public abstract void addMemory(); } 
public class StandardComputerBuilder extends ComputerBuilder { @Override public void addProcessor() { // method implementation } @Override public void addMemory() { // method implementation } }

4. Nozioni di base sulla composizione

La composizione è un altro meccanismo fornito da OOP per il riutilizzo dell'implementazione.

In poche parole, la composizione ci permette di modellare oggetti che sono costituiti da altri oggetti , definendo così una relazione “ha-a” tra loro.

Inoltre, la composizione è la forma più forte di associazione , il che significa che gli oggetti che compongono o sono contenuti in un oggetto vengono distrutti anche quando quell'oggetto viene distrutto .

Per capire meglio come funziona la composizione, supponiamo di dover lavorare con oggetti che rappresentano i computer .

Un computer è composto da diverse parti, compreso il microprocessore, la memoria, una scheda audio e così via, quindi possiamo modellare sia il computer che ciascuna delle sue parti come classi individuali.

Ecco come potrebbe apparire una semplice implementazione della classe Computer :

public class Computer { private Processor processor; private Memory memory; private SoundCard soundCard; // standard getters/setters/constructors public Optional getSoundCard() { return Optional.ofNullable(soundCard); } }

Le seguenti classi modellano un microprocessore, la memoria e una scheda audio (le interfacce sono omesse per brevità):

public class StandardProcessor implements Processor { private String model; // standard getters/setters }
public class StandardMemory implements Memory { private String brand; private String size; // standard constructors, getters, toString } 
public class StandardSoundCard implements SoundCard { private String brand; // standard constructors, getters, toString } 

È facile capire le motivazioni che stanno dietro a spingere la composizione sull'ereditarietà. In ogni scenario in cui è possibile stabilire una relazione semanticamente corretta "ha-a" tra una data classe e le altre, la composizione è la scelta giusta da fare.

In the above example, Computer meets the “has-a” condition with the classes that model its parts.

It's also worth noting that in this case, the containing Computer object has ownership of the contained objects if and only if the objects can't be reused within another Computer object. If they can, we'd be using aggregation, rather than composition, where ownership isn't implied.

5. Composition Without Abstraction

Alternatively, we could've defined the composition relationship by hard-coding the dependencies of the Computer class, instead of declaring them in the constructor:

public class Computer { private StandardProcessor processor = new StandardProcessor("Intel I3"); private StandardMemory memory = new StandardMemory("Kingston", "1TB"); // additional fields / methods }

Naturalmente, questo sarebbe un design rigido e strettamente accoppiato, poiché renderemmo il computer fortemente dipendente da implementazioni specifiche di processore e memoria .

Non sfrutteremmo il livello di astrazione fornito dalle interfacce e dall'inserimento delle dipendenze.

Con il design iniziale basato sulle interfacce, otteniamo un design ad accoppiamento libero, che è anche più facile da testare.

6. Conclusione

In questo articolo, abbiamo appreso i fondamenti dell'ereditarietà e della composizione in Java e abbiamo esplorato in profondità le differenze tra i due tipi di relazioni ("è-a" vs "ha-a").

Come sempre, tutti gli esempi di codice mostrati in questo tutorial sono disponibili su GitHub.