Progettare con le interfacce

Una delle attività fondamentali di qualsiasi progetto di sistema software è la definizione delle interfacce tra i componenti del sistema. Poiché il costrutto dell'interfaccia di Java consente di definire un'interfaccia astratta senza specificare alcuna implementazione, un'attività principale di qualsiasi progetto di programma Java è "capire quali sono le interfacce". Questo articolo esamina le motivazioni alla base dell'interfaccia Java e fornisce linee guida su come sfruttare al meglio questa parte importante di Java.

Decifrare l'interfaccia

Quasi due anni fa, ho scritto un capitolo sull'interfaccia Java e ho chiesto ad alcuni amici che conoscono il C ++ di esaminarlo. In questo capitolo, che ora fa parte del mio lettore di corso Java Inner Java (vedi Risorse), ho presentato le interfacce principalmente come un tipo speciale di ereditarietà multipla: ereditarietà multipla dell'interfaccia (il concetto orientato agli oggetti) senza eredità multipla di implementazione. Un revisore mi ha detto che, sebbene abbia capito i meccanismi dell'interfaccia Java dopo aver letto il mio capitolo, non ha davvero "capito il punto" di loro. In che modo esattamente, mi ha chiesto, le interfacce di Java sono state un miglioramento rispetto al meccanismo di ereditarietà multipla di C ++? All'epoca non ero in grado di rispondere alla sua domanda in modo soddisfacente, soprattutto perché in quei giorni non avevoHo capito bene il punto delle interfacce.

Anche se ho dovuto lavorare con Java per un po 'prima di sentirmi in grado di spiegare il significato dell'interfaccia, ho notato subito una differenza tra l'interfaccia di Java e l'ereditarietà multipla di C ++. Prima dell'avvento di Java, ho trascorso cinque anni a programmare in C ++ e in tutto questo tempo non avevo mai utilizzato l'ereditarietà multipla. L'ereditarietà multipla non era esattamente contro la mia religione, semplicemente non ho mai incontrato una situazione di progettazione C ++ in cui sentivo che aveva senso. Quando ho iniziato a lavorare con Java, la prima cosa che mi è venuta in mente delle interfacce è stata la frequenza con cui mi erano utili. In contrasto con l'ereditarietà multipla in C ++, che in cinque anni non ho mai usato, stavo usando le interfacce di Java tutto il tempo.

Quindi, data la frequenza con cui ho trovato utili le interfacce quando ho iniziato a lavorare con Java, sapevo che stava succedendo qualcosa. Ma cosa, esattamente? L'interfaccia di Java potrebbe risolvere un problema inerente all'ereditarietà multipla tradizionale? L'ereditarietà multipla dell'interfaccia era in qualche modo intrinsecamente migliore della semplice, vecchia eredità multipla?

Interfacce e 'problema dei diamanti'

Una giustificazione delle interfacce che avevo sentito all'inizio era che risolvevano il "problema del diamante" dell'ereditarietà multipla tradizionale. Il problema del diamante è un'ambiguità che può verificarsi quando una classe si moltiplica eredita da due classi che discendono entrambe da una superclasse comune. Ad esempio, nel romanzo di Michael Crichton Jurassic Park,gli scienziati combinano il DNA dei dinosauri con il DNA delle rane moderne per ottenere un animale che somigliava a un dinosauro ma che in qualche modo si comportava come una rana. Alla fine del romanzo, gli eroi della storia si imbattono in uova di dinosauro. I dinosauri, che erano stati tutti creati femmine per impedire la fraternizzazione in natura, si stavano riproducendo. Chrichton ha attribuito questo miracolo dell'amore ai frammenti di DNA di rana che gli scienziati avevano usato per riempire i pezzi mancanti del DNA di dinosauro. Nelle popolazioni di rane dominate da un sesso, dice Chrichton, alcune rane del sesso dominante possono cambiare spontaneamente il loro sesso. (Sebbene questo sembri una buona cosa per la sopravvivenza delle specie di rane, deve essere terribilmente confuso per le singole rane coinvolte.) I dinosauri di Jurassic Park avevano inavvertitamente ereditato questo comportamento di cambio di sesso spontaneo dai loro antenati di rane,con tragiche conseguenze.

Questo scenario di Jurassic Park potrebbe essere potenzialmente rappresentato dalla seguente gerarchia di ereditarietà:

Il problema del diamante può sorgere nelle gerarchie di ereditarietà come quella mostrata nella Figura 1. In effetti, il problema del diamante prende il nome dalla forma a diamante di tale gerarchia di eredità. Un modo in cui il problema del diamante può sorgere nella gerarchia di Jurassic Park è se entrambi Dinosaure Frog, ma non Frogosaur, sovrascrivono un metodo dichiarato in Animal. Ecco come potrebbe apparire il codice se Java supportasse l'ereditarietà multipla tradizionale:

classe astratta Animale {

discorso astratto vuoto (); }

class Frog estende Animal {

void talk () {

System.out.println ("Ribit, ribit."); }

class Dinosaur estende Animal {

void talk () {System.out.println ("Oh, sono un dinosauro e sto bene ..."); }}

// (Questo non verrà compilato, ovviamente, perché Java // supporta solo l'ereditarietà singola.) Class Frogosaur estende Frog, Dinosaur {}

Il problema del diamante alza la sua brutta testa quando qualcuno cerca di invocare talk()un Frogosauroggetto da un Animalriferimento, come in:

Animale animale = nuovo Frogosauro (); animal.talk ();

A causa dell'ambiguità causata dal problema del diamante, non è chiaro se il sistema runtime debba invocare l Frog' Dinosaurimplementazione di talk(). Un Frogosaurgracchiare "Ribbit, Ribbit."o cantare "Oh, I'm a dinosaur and I'm okay..."?

Il problema del diamante si presenterebbe anche se Animalfosse stata dichiarata una variabile di istanza pubblica, che Frogosaurpoi avrebbe ereditato da entrambi Dinosaure Frog. Quando si fa riferimento a questa variabile in un Frogosauroggetto, quale copia della variabile - Frog's o Dinosaur' s - sarebbe selezionata? O forse ci sarebbe solo una copia della variabile in un Frogosauroggetto?

In Java, le interfacce risolvono tutte queste ambiguità causate dal problema del diamante. Attraverso le interfacce, Java consente l'ereditarietà multipla dell'interfaccia ma non dell'implementazione. L'implementazione, che include variabili di istanza e implementazioni di metodi, viene sempre ereditata singolarmente. Di conseguenza, in Java non sorgerà mai confusione su quale variabile di istanza ereditata o implementazione del metodo utilizzare.

Interfacce e polimorfismo

Nella mia ricerca per capire l'interfaccia, la spiegazione del problema del diamante aveva un senso per me, ma non mi soddisfaceva davvero. Certo, l'interfaccia rappresentava il modo in cui Java affrontava il problema del diamante, ma era quella l'intuizione chiave dell'interfaccia? E in che modo questa spiegazione mi ha aiutato a capire come utilizzare le interfacce nei miei programmi e progetti?

Con il passare del tempo ho iniziato a credere che l'intuizione chiave dell'interfaccia non riguardasse tanto l'ereditarietà multipla quanto il polimorfismo (vedere la spiegazione di questo termine di seguito). L'interfaccia ti consente di sfruttare maggiormente il polimorfismo nei tuoi progetti, che a sua volta ti aiuta a rendere il tuo software più flessibile.

Alla fine, ho deciso che il "punto" dell'interfaccia era:

L'interfaccia di Java offre più polimorfismo di quanto si possa ottenere con famiglie di classi ereditate singolarmente, senza il "peso" dell'ereditarietà multipla dell'implementazione.

Un aggiornamento sul polimorfismo

Questa sezione presenterà un rapido aggiornamento sul significato del polimorfismo. Se ti senti già a tuo agio con questa parola stravagante, sentiti libero di passare alla sezione successiva, "Ottenere più polimorfismo".

Polimorfismo significa usare una variabile di superclasse per fare riferimento a un oggetto di sottoclasse. Ad esempio, considera questa semplice gerarchia e codice di ereditarietà:

classe astratta Animale {

discorso astratto vuoto (); }

class Dog estende Animal {

void talk () {System.out.println ("Woof!"); }}

class Cat estende Animal {

void talk () {System.out.println ("Meow."); }}

Data questa gerarchia di ereditarietà, il polimorfismo consente di mantenere un riferimento a un Dogoggetto in una variabile di tipo Animal, come in:

Animale animale = nuovo cane (); 

La parola polimorfismo si basa su radici greche che significano "molte forme". In questo caso, una classe ha molte forme: quella della classe e delle sue sottoclassi. Un Animal, ad esempio, può assomigliare a Dogo a Cato qualsiasi altra sottoclasse di Animal.

Il polimorfismo in Java è reso possibile dal binding dinamico, il meccanismo mediante il quale la Java virtual machine (JVM) seleziona un'implementazione del metodo da invocare in base al descrittore del metodo (il nome del metodo e il numero e i tipi dei suoi argomenti) e la classe del oggetto su cui è stato invocato il metodo. Ad esempio, il makeItTalk()metodo mostrato di seguito accetta un Animalriferimento come parametro e richiama talk()quel riferimento:

class Interrogator {

static void makeItTalk (Animal subject) {subject.talk (); }}

In fase di compilazione, il compilatore non sa esattamente a quale classe di oggetto verrà passata makeItTalk()in fase di esecuzione. Sa solo che l'oggetto sarà una sottoclasse di Animal. Inoltre, il compilatore non sa esattamente quale implementazione di talk()dovrebbe essere invocata in fase di esecuzione.

Come accennato in precedenza, l'associazione dinamica significa che la JVM deciderà in fase di esecuzione quale metodo richiamare in base alla classe dell'oggetto. Se l'oggetto è una Dog, la JVM invocare Dog's attuazione del metodo, che dice "Woof!". Se l'oggetto è una Cat, la JVM invocare Cat's attuazione del metodo, che dice "Meow!". Il legame dinamico è il meccanismo che rende possibile il polimorfismo, la "sostituibilità" di una sottoclasse per una superclasse.

Il polimorfismo aiuta a rendere i programmi più flessibili, perché in un momento futuro è possibile aggiungere un'altra sottoclasse alla Animalfamiglia e il makeItTalk()metodo continuerà a funzionare. Se, ad esempio, in seguito aggiungi una Birdclasse:

class Bird estende Animal {

void talk () {

System.out.println ("Tweet, tweet!"); }}

è possibile passare un Birdoggetto al invariata makeItTalk()metodo, e dirà, "Tweet, tweet!".

Ottenere più polimorfismo

Le interfacce ti danno più polimorfismo rispetto alle famiglie di classi ereditate singolarmente, perché con le interfacce non devi far rientrare tutto in un'unica famiglia di classi. Per esempio:

interfaccia loquace {

void talk (); }

classe astratta Animal implementa loquace {

discorso vuoto astratto pubblico (); }

class Dog estende Animal {

public void talk () {System.out.println ("Woof!"); }}

class Cat estende Animal {

public void talk () {System.out.println ("Meow."); }}

class Interrogator {

static void makeItTalk (Soggetto loquace) {subject.talk (); }}

Dato questo insieme di classi e interfacce, in seguito è possibile aggiungere una nuova classe a una famiglia di classi completamente diversa e continuare a passare istanze della nuova classe a makeItTalk(). Ad esempio, immagina di aggiungere una nuova CuckooClockclasse a una Clockfamiglia già esistente :

class Clock {}

class CuckooClock implementa loquace {

public void talk () {System.out.println ("Cuculo, cuculo!"); }}

Poiché CuckooClockimplementa l' Talkativeinterfaccia, puoi passare un CuckooClockoggetto al makeItTalk()metodo:

class Example4 {

public static void main (String [] args) {CuckooClock cc = new CuckooClock (); Interrogator.makeItTalk (cc); }}

Con una sola eredità, dovresti in qualche modo inserirti CuckooClocknella Animalfamiglia o non usare il polimorfismo. Con le interfacce, qualsiasi classe di qualsiasi famiglia può Talkativeessere implementata ed essere passata a makeItTalk(). Questo è il motivo per cui dico che le interfacce ti danno più polimorfismo di quello che puoi ottenere con famiglie di classi ereditate singolarmente.

Il "peso" dell'attuazione dell'eredità

Ok, la mia affermazione di "più polimorfismo" sopra è abbastanza semplice ed era probabilmente ovvia a molti lettori, ma cosa intendo con "senza il peso dell'eredità multipla dell'implementazione?" In particolare, in che modo esattamente l'ereditarietà multipla dell'attuazione è un onere?

As I see it, the burden of multiple inheritance of implementation is basically inflexibility. And this inflexibility maps directly to the inflexibility of inheritance as compared to composition.

By composition, I simply mean using instance variables that are references to other objects. For example, in the following code, class Apple is related to class Fruit by composition, because Apple has an instance variable that holds a reference to a Fruit object:

class Fruit {

//... }

class Apple {

private Fruit fruit = new Fruit(); //... }

In this example, Apple is what I call the front-end class and Fruit is what I call the back-end class. In a composition relationship, the front-end class holds a reference in one of its instance variables to a back-end class.

In last month's edition of my Design Techniques column, I compared composition with inheritance. My conclusion was that composition -- at a potential cost in some performance efficiency -- usually yielded more flexible code. I identified the following flexibility advantages for composition:

  • It's easier to change classes involved in a composition relationship than it is to change classes involved in an inheritance relationship.

  • La composizione ti consente di ritardare la creazione di oggetti di back-end fino a quando (ea meno che) non siano necessari. Consente inoltre di modificare dinamicamente gli oggetti di back-end per tutta la durata dell'oggetto front-end. Con l'ereditarietà, ottieni l'immagine della superclasse nell'immagine dell'oggetto della sottoclasse non appena la sottoclasse viene creata e rimane parte dell'oggetto della sottoclasse per tutta la durata della sottoclasse.

L'unico vantaggio di flessibilità che ho identificato per l'ereditarietà era:

  • È più facile aggiungere nuove sottoclassi (ereditarietà) che aggiungere nuove classi front-end (composizione), perché l'ereditarietà viene fornita con il polimorfismo. Se hai un po 'di codice che si basa solo su un'interfaccia di superclasse, quel codice può funzionare con una nuova sottoclasse senza modifiche. Questo non è vero per la composizione, a meno che non usi la composizione con le interfacce.