Approccio modulare

La scrittura di programmi che risolvono problemi di media o grande complessità è favorita da un approccio modulare:

  • si individuano attività e sotto-problemi più semplici, se possibile anche logicamente indipendenti, che possono essere risolti più facilmente
  • si sviluppa direttamente il codice per questi sottoproblemi, senza preoccuparsi necessariamente del problema di partenza
  • si combinano le soluzioni dei sottoproblemi per ottenere la soluzione del problema di partenza

Esempio: Supponiamo di voler scrivere un programma che legge un numero maggiore di 1 e ne stampa il più piccolo divisore diverso da 1. Anche in questo semplice esempio possiamo individuare almeno tre attività debolmente correlate:

public class PiccoloDivisore {

    public static void main (String[] args) {

        // Legge un intero n strettamente maggiore di 1

        // Calcola il piu' piccolo divisore d di n

        // Stampa d con un opportuno messaggio

    }

}

In generale i linguaggi di programmazione mettono a disposizione dei meccanismi di astrazione che favoriscono l'approccio modulare. Nel caso di Java, i due principali meccanismi che ci interessano sono:

  • Astrazione funzionale: il programmatore può estendere le funzionalità del linguaggio definendo sottoprogrammi che risolvono (sotto)problemi specifici.
    • i sottoprogrammi sono di solito parametrici
    • possono essere scritti e testati separatamente
    • possono essere (ri)usati in altri programmi

    Esempio: Math.sqrt(..), Math.random(), Input.readInt()

  • Astrazione sui dati: il programmatore può definire nuovi tipi di dato astratti specifici per il particolare problema (sfruttando classi, oggetti e metodi d'istanza):
    • collezioni, anche strutturate e gerarchiche, di valori
    • le operazioni consentite per manipolare tali valori
    • possono essere (ri)usati in altri programmi

    Esempio: java.awt.Rectangle, String

Per il momento ci concentreremo sull'astrazione funzionale, basata sui metodi di classe. L'astrazione sui dati e i metodi d'istanza saranno approfonditi nel secondo semestre di LIP e in altri corsi, come Metodologie di Programmazione, e Laboratorio di Strutture Dati.




 Cosa sono i metodi?

Un metodo è un blocco di comandi associato ad un nome. I metodi possono essere definiti e invocati:

  • la definizione di un metodo stabilisce il codice che realizza l'operazione.

    Esempio: dichiarazione del metodo min(int,int) della classe Math.
    public class Math {
    
        public static int min (int a, int b) { 
            if ( a<b )
                return a;
            else
                return b;
        }
    
    ...
    

  • l'invocazione di un metodo (o chiamata) corrisponde all'utilizzo del metodo. L'invocazione del metodo causa l'esecuzione di un'istanza del corrispondente blocco di comandi associato.
  • Esempio: invocazioni del metodo min(int,int) della classe Math.
    public class UsaMin {
        public static void main (String[] args) {
    
            System.out.print("Dammi un intero: ");
            int n = Input.readInt();
            
            int x = Math.min(100,n); 
            System.out.println ("Il minimo tra 100 e " + n +" e': "+ x );
    
            int y = Math.min(n,200); 
            System.out.println ("Il minimo tra " + n + " e 200 e': "+ y );
        }
    }
    

Ovviamente la definizione è unica, mentre lo stesso metodo può essere invocato molte volte, con argomenti diversi.

Per questo motivo, nella definizione di un metodo il codice può far riferimento a dei parametri formali che saranno utilizzati per passare informazioni al metodo al momento dell'invocazione: si tratta di riferimenti simbolici che ad ogni invocazione corrisponderanno a valori diversi. Al momento della chiamata, gli argomenti usati per invocare il metodo divengono i parametri attuali, rispetto ai quali fare il calcolo.

Nell'esempio di Math.min, i parametri formali della definizione sono a e b, invece nelle invocazioni che compaiono nel main di UsaMin sono rispettivamente (nella prima invocazione) 100 e il valore di n letto da input e (nella seconda invocazione) ancora il valore di n e 200.

Nota: i nomi dei parametri formali non hanno alcuna attinenza con variabili omonime che potrebbero comparire nei programmi che invocano quel metodo.

Un metodo può restituire un valore come risultato della chiamata mediante una particolare istruzione chiamata return.




Dichiarazione di metodi

Ogni metodo deve essere dichiarato in una classe.

Un metodo può essere statico oppure d'istanza. Concettualente, un metodo statico è associato alla classe, mentre un metodo di istanza è associato agli oggetti della classe.

Sintassi della dichiarazione di un metodo:
 

<modificatori> <tipo> <nome> (<lista_parametri_formali>) {
    <corpo>
}

  • <modificatori>: i modificatori descrivono proprietà del metodo come
    • visibilità    (private, protected, public),
    • modificabilità    (final),
    • appartenenza ad una classe o alle istanze    (static).
    Per ora ci concentriamo solo su metodi public static (metodi "pubblici di classe").

  • <tipo>: Il tipo del metodo è il tipo del valore restituito dal metodo, oppure void.

  • <nome>: Il nome del metodo è un identificatore qualunque, che individua il metodo all'interno della classe (si raccomanda di scegliere sempre nomi significativi e che inizino con una lettera minuscola).

  • <lista_parametri_formali>: I parametri formali consistono di una lista (anche vuota) di coppie <tipo> <parametro> separate da virgole, dove <tipo> è un tipo di dati e <parametro> è una variabile (identificatore).

  • <corpo>: Il corpo del metodo è un blocco che contiene istruzioni fra cui una o più istruzioni return.

Dato un generico metodo:
 

<modificatori> <tipo> <nome> ( <tipo1> <p1> , ... , <tipon> <pn>) {
    ...
}

diciamo che la sua firma è data dalla sequenza:

<nome>(<tipo1>, ..., <tipon>)

Esempio: La firma del metodo visto prima è min(int, int)
Attenzione: la firma non comprende né il tipo del metodo, né i nomi dei parametri formali.




Il comando return

Ricordiamo che il tipo di un metodo è il tipo del dato restituito dal metodo, oppure void.

Se il tipo è diverso da void, allora si dice che il metodo è tipizzato e deve forzatamente restituire un valore del tipo indicato. Conseguentemente per ogni possibile ramo di esecuzione del corpo deve comparire un comando return  seguito da un'espressione che abbia lo stesso tipo del metodo.

public class EsempioBadReturn {
    public static int uno() {
        if (true)  
            return 1;
    }
}
> javac EsempioBadReturn.java

EsempioBadReturn.java:5: missing return statement
    }
    ^
1 error

public class EsempioOkReturn {
    public static int uno() {
        if (true)  
            return 1;
        else 
            return 0;
    }
}
> javac EsempioOkReturn.java
 
Compilation finished

Se invece il tipo è void, allora il metodo si chiama non tipizzato e non restituisce niente. I metodi non tipizzati normalmente non contengono alcun comando return. Possono comunque contenere dei comandi return, purché non siano seguiti da espressioni. In tal caso l'esecuzione del metodo termina quando si incontra un return oppure quando si sono eseguite tutte le istruzioni.

Attenzione: il comando return ha lo scopo di restituire un valore e quindi non dovrebbe mai alterare il flusso dell'esecuzione. In particolare, per motivarvi a scrivere codice leggibile e che favorisca la verifica di correttezza:

verrà penalizzato qualsiasi uso di return all'interno di istruzioni iterative!



Invocazione di un metodo

Per invocare un metodo d'istanza su di un oggetto si scrive

    <oggetto>.<metodo>(<lista_parametri_attuali>)

Esempio: invocazione di translate su di un rettangolo.
        Rectangle rect = new Rectangle(5, 10, 20, 30);
        rect.translate(15, 25);

Per invocare un metodo statico si scrive

    <classe>.<metodo>(<lista_parametri_attuali>)

Esempio: invocazione di min della classe Math.
        int m = Math.min(45,34);

In entrambi gli esempi riportati sopra, <lista_parametri_attuali> deve essere una lista di espressioni che concorda in numero e in tipo con la lista dei parametri formali.

Attenzione: i nomi dei tipi dei parametri devono essere scritti solo nella definizione (lista dei parametri formali), non ad ogni invocazione (lista dei parametri attuali).

Esempio: invocazione errata di min della classe Math.
        int m = Math.min(int 45,int 34); // errore di compilazione



Cosa succede quando si invoca un metodo?

Durante l'esecuzione di un programma Java, in ogni istante è in esecuzione un metodo (e uno solo), il metodo corrente.

Quando si incontra l'invocazione di un altro metodo, l'esecuzione del metodo corrente viene sospesa fino al completamento del metodo invocato.

Per eseguire il metodo invocato, viene allocato in memoria un record di attivazione (o frame). Questo contiene fra l'altro:

  • una variabile per ogni parametro formale del metodo invocato, inizializzata con il corrispondente parametro attuale;

  • le variabili locali del metodo invocato;

  • l'indirizzo di ritorno (IR), cioè il punto del metodo chiamante cui bisogna cedere il controllo (e restituire il risultato) alla fine dell'esecuzione del metodo invocato.

In una sequenza di chiamate di metodi l'ultimo metodo chiamato è il primo a terminare l'esecuzione.

Questo permette di gestire in modo efficace i record di attivazione utilizzando una pila  (o stack), una struttura dati che sarà studiata in altri corsi (ad esempio a LSD).




Allocazione di frames

Vediamo un semplice esempio di invocazione di metodi con la corrispondente allocazione di frames.

public class ChiamataMetodi {  
    public static void main(String[] args){
        int num = -5;
        num = doubleAbs(num);
        ... 
    }
    public static int doubleAbs(int n){
        int res = abs(n);
        return 2 * res;
    }
    public static int abs(int n){
        if (n < 0) n = -n;
        return n;
    }
}




Disallocazione di frames

Quando un metodo termina l'esecuzione, il controllo passa all'istruzione del metodo chiamante riferita dall'Indirizzo di ritorno, eventualmente con il passaggio del risultato. Il frame del metodo corrente viene disallocato, mentre il metodo chiamante riprende l'esecuzione.





Un Esempio: PiccoloDivisore.java

Per rendere il codice più leggibile è consigliabile individuare le funzionalità cruciali e implementarle con metodi opportuni. Riprendiamo l'esempio iniziale e vediamone una possibile soluzione.

Nota: quando scriviamo un metodo non possiamo fare ipotesi sugli argomenti coi quali verrà invocato e quindi dobbiamo gestire tutti i casi (vedi gestione di numeri minori di 2 nel metodo minDiv). Non sempre la scelta è ovvia (ad esempio, come gestireste il caso di divisore 0 nel metodo divide?). Vedremo nel secondo semestre come il meccanismo delle eccezioni possa essere usato anche per questo scopo.

public class PiccoloDivisore {

    public static void main (String[] args) {

        // Legge un intero n strettamente maggiore di 1
        int n = readIntGreaterThan(1);

        // Calcola il piu' piccolo divisore d di n
        int d = minDiv(n);

        // Stampa d con un opportuno messaggio
        System.out.print("Il piu' piccolo divisore di " + 
                         n + " e' " + d);
    }

    // Legge e restituisce un valore maggiore di k, ripetendo la
    // richiesta di input se necessario
    public static int readIntGreaterThan(int k) {
        int n=0;
        do {
            System.out.print("Dammi un intero (maggiore di " + k +"): ");
            n = Input.readInt();
        } while (n<=k);
        return n;
    }

    // Restituisce il piu' piccolo divisore (maggiore di 1) di m.
    // Se m e' minore di 2 restituisce 0.
    public static int minDiv(int m) {
        int k = 2;

        if (m>1) {
            int limite = (int) Math.sqrt(m);
            while (k<=limite && !divide(m,k)) k++;

            if (k>limite) k = m;

        } else k = 0;

        return k;
    }

    // Controlla se v e' divisibile per d
    public static boolean divide(int v, int d) {
        return v%d==0;
    }

}