MokaByte Numero 34  - Ottobre  99
 
Le interfacce in Java
di
Giovannini
Andrea
Come semplificare le cose e  renderle più eleganti, nel caso che si programmi ad oggetti


Le interfacce sono uno strumento molto importante per il design e l'implementazione di sistemi OO. In questo articolo approfondiremo le interfacce in Java e il loro utilizzo per migliorare la riusabilità e l'estendibilità del software

Introduzione


Una delle linee guida fondamentali nella progettazione OO è la separazione fra l'interfaccia di una classe e la sua implementazione. A tal proposito i progettisti di Java hanno dotato il linguaggio di un costrutto, l'interfaccia appunto, distinto dalla classe. In questo modo si hanno a livello di implementazione due strumenti distinti per definire gli aspetti comportamentali (interfaccia) e quelli implementativi (classe). 
Vediamo un semplice esempio dalla libreria di Java, l'interfaccia Runnable: 
 

public interface Runnable { 
    public abstract void run(); 


Una classe che voglia implementare l'interfaccia Runnable dovrà definire un metodo run() che conterrà il codice da eseguire
in un thread di esecuzione separato. Una classe client che debba mandare in esecuzione un thread conterrà codice simile al
seguente 
 

public class Test { 
   public void runIt(Runnable obj) { 
       .... 
       obj.run(); 
       .... 
   } 


Il pricipale vantaggio derivante dall'utilizzo dell'interfaccia Runnable nel codice precedente consiste nel fatto che il metodo runIt() accetta come parametro oggetti di classe diversa, senza alcun legame fra di loro se non il fatto che tutti devono implementare l'interfaccia Runnable. Il metodo runIt() è quindi ampiamente riutilizzabile e inoltre viene garantita anche la massima estendibilità: non si ha infatti alcun vincolo sulle classi che implementano l'interfaccia e nulla vieta, ad esempio, di includere funzionalità avanzate come il pooling di thread. 
Come osservato in [2] il legame fra una classe che implementa un'interfaccia e i suoi client è  appresentato da i parametri dei suoi metodi. Per avere il massimo disaccoppiamento occorre quindi fare in modo che i parametri delle interfacce siano tipi predefiniti oppure interfacce, ma non classi concrete. L'introduzione delle interfacce nel design permette quindi di ridurre le dipendenze da classi concrete, come osservato in [3], ed è alla base di uno dei principi fondamentali della programmazione a oggetti: "Program to an interface, not an implementation" [5]. 
E' importante a questo punto osservare come un'interfaccia rappresenti un contratto fra la classe che la  implementa e i suoi client. I termini del contratto sono i metodi dichiarati nell'interfaccia, metodi che ogni classe che implementi l'interfaccia si impegna a definire. Sul comportamento dei metodi però non è possibile specificare nulla in Java e altri linguaggi come C++ se non attraverso la documentazione. Ad esempio per il metodo run() dell'interfaccia Runnable troviamo "... The general contract of the method run() is that it may take any action whatsoever.". In un prossimo articolo vedremo un approccio più rigoroso per la definizione del "contratto". 

 
 

Utilizzi delle interfacce

In questo paragrafo vedremo alcuni utilizzi tipici delle interfacce in Java e ne analizzeremo i vantaggi a livello di design e di implementazione. Inoltre considereremo gli aspetti di ereditarietà fra interfacce e di creazione di oggetti dei quali è nota solo l'interfaccia. 
 
 
 

Interfacce e polimorfismo

Il polimorfismo ottenuto attraverso l'ereditarietà è uno strumento molto potente. Le interfacce ci permettono di sfruttare  il polimorfismo anche senza ricorrere a gerarchie di ereditarietà. Vediamo un esempio 
 
public interface PricedItem { 
   public void setPrice(double price); 
   public double getPrice(); 


L'interfaccia PricedItem definisce il comportamento di un articolo con prezzo. Possiamo a questo punto implementare l'interfaccia in una classe applicativa Book nel modo seguente 

  public class Book implements PricedItem { 
        private String title; 
        private String author; 
        private double price; 
        ... 
        public void setPrice(double price) { 
            this.price = price; 
        } 

        public double getPrice() { 
            return price; 
        } 
    } 

Il codice client che necessita del prezzo di un libro può quindi essere scritto così: 

    double computeTotalPrice(Collection items) { 
        Iterator it = items.iterator(); 
        PricedItem item; 
        double total = 0; 

        while (it.hasNext()) { 
            item = (PricedItem)it.next(); 
            total += item.getPrice(); 
        } 
        return total; 
    } 

Il metodo precedente calcola il prezzo totale di una collezione di oggetti. Supponiamo ora di voler estendere l'applicazione per gestire non solo libri ma anche CD musicali. Introdurremo a questo proposito una nuova classe che  implementa l'interfaccia PricedItem 

    public class CD implements PricedItem { 
        private String title; 
        private String singer; 
        private Collection songs; 
        private double price; 
        .... 

        public void setPrice(double price) { 
            this.price = price; 
        } 

        public double getPrice() { 
            return price; 
        } 
    } 

Il codice precedente per il calcolo del prezzo totale funziona senza modifiche anche se la collezione contiene oggetti di classe CD perchè tale codice fà riferimento all'interfaccia e non alla classe concreta. 
 

 

"Ereditarietà" multipla

Come noto Java non supporta l'ereditarietà multipla fra classi. Una classe può però implementare più interfacce e in  questo modo possiamo per essa definire diversi comportamenti. Riprendiamo ora l'esempio precedente e aggiungiamo  alle nostre classi il supporto alla persistenza. L'interfaccia Persistent fà al caso nostro 

     public interface Persistent { 
        public void save(); 
    } 

Le nostre classi diventano quindi 

    public class Book implements PricedItem, Persistable { 
        ... 
    } 

    public class CD implements PricedItem, Persistable { 
        ... 
    } 

Quindi possiamo scrivere il codice di gestione del salvataggio nel seguente modo 

    public void saveAll(Collection items) { 
        Iterator it = items.iterator(); 
        Persistent item; 

        while (it.hasNext()) { 
            item = (Persistent)it.next(); 
            item.save(); 
        } 
    } 

Osserviamo che l'interfaccia Persistent nasconde completamente i dettagli di salvataggio che potrebbe avvenire su file oppure su DB attraverso JDBC. 

 
 

Composizione

La programmazione OO permette di riutilizzare funzionalità esistenti principalmente attraverso ereditarietà fra classi e  composizione di oggetti. La composizione permette di ottenere sistemi più flessibili mentre l'ereditarietà dovrebbe  essere utilizzata principalmente per modellare relazioni costanti nel tempo [4]. Non dobbiamo però pensare di poter ottenere il polimorfismo solo con l'ereditarietà: l'utilizzo combinato di interfacce e composizione ci permette di  progettare soluzioni molto interessanti dal punto di vista architetturale. Vediamo un esempio. Supponiamo di dover  sviluppare il supporto alla validazione per le classi Book viste prima. Le logiche di validazione saranno incorporate all'interno di un'opportuna classe che implementa una interfaccia Validator 

    public interface Validator { 
        public void validate(Object o); 
    } 

   public class BookValidator implements Validator { 
        public void validate(Object o) { 
            if (o instanceof Book) { 
                ... 
            } 
        } 
    } 

Vediamo ora la classe che si occupa di eseguire la validazione 

     public class Manager { 
        ... 
        Validator validator; 

        public Manager(Validator validator) { 
            this.validator = validator; 
            ... 
        } 

        public void validate() { 
            ... 
            validator.validate(object) 
            ... 
        } 
    } 

 La classe Manager non fà riferimento alla classe concreta BookValidator quindi possiamo cambiare la logica di validazione anche a run-time. La soluzione di design che abbiamo visto è nota come pattern Stategy [5]. 
 

 

Interfacce che estendono altre interfacce

Come le classi anche le interfacce possono essere organizzate in gerarchie di ereditarietà. Ad esempio 

    interface Base { 
        ... 
    } 
      interface Extended extends Base { 
        ... 
    } 

L'interfaccia Extended eredita quindi tutte le costanti e tutti i metodi dichiarati in Base. Ogni classe che implementa  Extended dovrà quindi fornire una definizione anche per i metodi dichiarati in Base. Le interfacce possono poi derivare da più interfacce, allo stesso modo in cui una classe può implementare più interfacce. 

     interface Sibling { ...} 
    interface Multiple extends Base, Sibling { ... } 

Vediamo ora come vengono gestiti i conflitti di nomi. Se due interfacce contengono due metodi con la stessa signature e con lo stesso valore di ritorno allora la classe concreta dovrà implementare il metodo solo una volta e il compilatore non segnalerà alcun errore. Se i metodi hanno invece lo stesso nome ma signature diverse allora la classe concreta dovrà dare un'implementazione per entrambi i metodi. I problemi si verificano quando le interfacce dichiarano due metodi con gli stessi parametri ma diverso valore di ritorno. Es. 

     interface Int1 { 
        int foo(); 
    } 

    interface Int2 { 
        String foo(); 
    } 

In questo caso il compilatore segnala un errore perchè il linguaggio non permette che una classe abbia due metodi la cui signature differisce solo per il tipo del valore di ritorno. Consideriamo infine il caso in cui due interfacce dichiarino due costanti con lo stesso nome, eventualmente anche con tipo diverso. La classe concreta potrà utilizzare entrambe le costanti qualificandole con il nome dell'interfaccia. 
 

Interfacce e creazione di oggetti

Come abbiamo visto le interfacce permettono di astrarre dai dettagli implementativi, eliminare le dipendenze da classi concrete e porre l'attenzione sul ruolo degli oggetti nell'architettura che si vuole sviluppare. Rimane però un problema relativo alla creazione degli oggetti: in tale situazione occorre comunque specificare il nome di una classe concreta. In questo caso si genera quindi una dipendenza di creazione [3]. Ad esempio 

    public class MyDocument { 
        .... 
        public void open(); 
        public void close(); 
        public void save(); 
        .... 
    } 

    MyDocument doc = new MyDocument(); 

Nell'istruzione precedente si crea un oggetto di classe concreta MyDocument ma il codice non potrà essere riutilizzato per creare un oggetto di classe estesa da MyDocument oppure un'altra classe che rappresenta un diverso tipo di documento. Come osservato sempre in [3] è possibile risolvere questo problema creando classi final oppure rendendo ridefinibile l'operazione di creazione. Quest'ultima soluzione è senz'altro la più interessante e i pattern creazionali [5] ci permettono di risolvere il problema. Vediamo ora come applicare il pattern Abstract Factory per incapsulare il processo di creazione ed eliminare la dipendenze di creazione di cui soffriva il precedente esempio. Innanzi tutto introduciamo un'interfaccia per rappresentare un documento 

    public interface Document { 
        public void open(); 
        public void close(); 
        public void save(); 
    } 

A questo punto definiamo un'interfaccia per un factory, cioè un oggetto il cui compito è quello di creare altri oggetti. Poichè a priori non sappiamo quale tipo di oggetti dovranno essere creati ricorriamo alle interfacce 

    public interface DocumentFacory { 
        public Document createDocument(); 
    } 

Possiamo ora definire diversi tipi di documento, ad esempio 

    public class TechicalDocument implements Document { 
        public void open() { ... } 
        public void close() { ... } 
        public void save() { ... } 
    } 

    public class CommercialDocument implements Document { 
        public void open() { ... } 
        public void close() { ... } 
        public void save() { ... } 
    } 

Ora vogliamo essere in grado di creare diversi tipi di documento. Per questo definiamo una classe factory per ogni diversa classe documento 

    public class TechicalDocumentFactory implements DocumentFactory { 
        public Document createDocument() { 
            Document doc = new TechicalDocument(); 
            ... 
            return doc; 
        } 
    } 

    public class CommercialDocumentFactory implements DocumentFactory { 
        public Document createDocument() { 
            Document doc = new CommercialDocument(); 
            ... 
            return doc; 
        } 
    } 

Possiamo quindi creare oggetti documento nel modo seguente 

    void manageDocument(DocumentFactory factory) { 
        Document doc = factory.createDocument(); 
        doc.open(); 
        ...// modify document 
        doc.save(); 
        doc.close(); 
    } 

Il codice precedente crea un oggetto che implementa l'interfaccia Document ma non ha alcun legame con classi concrete e si può quindi utilizzare con classi diverse, purché conformi all'interfaccia Document.
 

 

Vantaggi delle interfacce nello sviluppo del software

Dopo aver passato in rassegna diversi esempi sull'utilizzo delle interfacce possiamo a questo punto discutere sui loro reali vantaggi: 
  • le interfacce permettono innanzitutto di concentrarsi sugli aspetti comportamentali degli oggetti e costruire quindi astrazioni efficaci per il problema in esame, nascondendo i dettagli implementativi all'interno delle classi concrete; 
  • ragionare per interfacce permette di separare le politiche di interazione fra classi dalle caratteristiche interne di una classe; 
  • rappresentano inoltre uno strumento per il disaccoppiamento fra classi concrete, ovvero per l'eleminazione delle dipendenze che abbiamo visto essere deleterie per un buon design. 

Conclusioni


In questo articolo abbiamo approfondito l'utilizzo delle interfacce in Java e abbiamo visto diversi esempi di progettazione che traggono vantaggio dalle interfacce. Ulteriori considerazioni ed esempi si possono trovare in [6,7]. Rimane da approfondire un aspetto importante, ovvero come specificare condizioni più precise nell'invocazione dei metodi di un'interfaccia. L'idea è quella di vedere un'interfaccia come un contratto stipulato fra la classe che la implementa e i suoi client; il problema è quindi quello di stabilire i termini del contratto in maniera precisa e non ambigua. Esiste una metodologia di programmazione, il Design By Contract, che permette di specificare per ogni metodo le condizioni che il client deve rispettare (precondizioni), quelle garantite dal metodo (postcondizioni) e quelle sempre valide (invarianti). In un prossimo articolo vedremo in dettaglio il Design by Contract e le sue integrazioni con Java. 
 

 

Riferimenti

  1.Doug Lea, "Implementing Basic Design Pattern In Java", disponibile come supplemento online di "Concurrent
    Programming in Java" all'URL http://gee.cs.oswego.edu/dl/cpj/ifc.html
  2.Carlo Pescio, "Oggetti ed Interfacce", Computer Programming N. 63, Novembre 1997; 
  3.Carlo Pescio, "Systematic Object Oriented Design", Computer Programming N. 81, Giugno 1999; 
  4.Carlo Pescio, "Ereditarietà nei progetti reali", Computer Programming N. 71, Luglio/Agosto 1998; 
  5.GoF, "Design Patterns", Addison Wesley, 1994; 
  6.Bill Venners, "Designing with interfaces", JavaWorld, December 1998, 
    http://www.javaworld.com/javaworld/jw-12-1998/jw-12-techniques.html
  7.Michael Cymerman, "Smarter Java Development", JavaWorld, August 1999,
    http://www.javaworld.com/jw-08-1999/jw-08-interfaces.html

  
 

MokaByte rivista web su Java

MokaByte ricerca nuovi collaboratori
Chi volesse mettersi in contatto con noi può farlo scrivendo a mokainfo@mokabyte.it