Cos'è ElectroYou | Login Iscriviti

ElectroYou - la comunità dei professionisti del mondo elettrico

Ricerca personalizzata
10
voti

Programmazione (4): metodi e funzioni

Indice

I sottoprogrammi

A volte ci si rende conto che alcune porzioni di codice sono ripetute più volte all'interno di un programma. Sarebbe utile avere uno strumento, una possibilità data dal linguaggio, per prendere questa porzione di codice e fare in modo che si possa utilizzare più volte nel programma senza doverla riscrivere.
Anche prima dell'avvento dei linguaggi di alto livello si usava scrivere i cosiddetti sottoprogrammi o meglio le subroutine. Lo si faceva anche con il linguaggio assembly, che era, più che un linguaggio, un semplice traduttore che trasformava una rappresentazione simbolica del linguaggio macchina in istruzioni eseguibili direttamente dal calcolatore. Il problema è che il linguaggio macchina è molto semplice, a volte estremamente limitato anche se estremamente veloce in fase di esecuzione. Basti pensare che i microprocessori non avevano né una istruzione per moltiplicare fra loro due valori, tanto meno la divisione. Ancora oggi alcuni micro, sopratutto quelli di fascia bassa, non hanno queste due istruzioni.

Il problema veniva risolto scrivendo una subroutine di moltiplicazione. Prima di fare un esempio di una classica subroutine di moltiplicazione è necessario sapere che il programma di un calcolatore risiede anch'esso nella memoria a partire da un indirizzo noto. Quindi è possibile, anzi è indispensabile, conoscere l'indirizzo di partenza delle varie parti del programma e, a maggior ragione, delle subroutine.
Prendiamo in considerazione un programma qualsiasi che viene eseguito a livello di codice macchina e che, ad un certo punto, o meglio all'indirizzo 112, ha bisogno di moltiplicare fra loro due numeri (come nel programma dell'articolo precedente). Sfortunatamente il processore non ha l'istruzione di moltiplicazione ma è disponibile una subroutine di moltiplicazione (scritta ovviamente da qualcuno) che risiede in memoria a partire dall'indirizzo 527.

Analizziamo cosa succede:

  • All'indirizzo 112 il processore incontra una istruzione per caricare un valore nel registro interno A, il valore è il byte successivo all'indirizzo 113 ed è 12.
  • All'indirizzo 114 il processore incontra una istruzione per caricare un valore nel registro interno B, il valore è il byte successivo all'indirizzo 115 ed è -5.
  • All'indirizzo 116 il processore incontra l'istruzione di call, che significa chiamata alla subroutine che parte dall'indirizzo 527 (memorizzato all'indirizzo 177)
  • Il processore effettua un salto all'indirizzo 527. Mentre effettua questo salto scrive il numero 117 (l'indirizzo da cui parte il salto) in un registro speciale chiamato registro di ritorno e quindi procede l'esecuzione dall'indirizzo 527.
  • All'indirizzo 527 risiede la subroutine con un algoritmo che, in qualche modo, moltiplica fra di loro i registri A e B e pone il risultato nel registro interno C.
  • L'ultima istruzione della subroutine è all'indirizzo 551 ed è un return. Quando il processore incontra questa istruzione, legge il contenuto del registro di ritorno e prosegue dalla locazione successiva all'indirizzo che ha letto, quindi dalla 118.

Alcune particolarità saltano subito all'occhio.

  • una volta scritta, la subroutine può essere utilizzata tutte le volte che si ha bisogno di una moltiplicazione. Non c'è limite all'uso della subroutine
  • il fatto che si carichino i registri A e B con moltiplicando e moltiplicatore e che si trovi il risultato nel registro C è una convenzione decisa dal programmatore e che deve essere rispettata in tutto il programma.
  • L'uso delle subroutine riduce la lunghezza del programma perché si sostituiscono un gran numero di istruzioni con poche istruzioni per la chiamata a subroutine.

I metodi in Java, le funzioni in C

Linguaggi di alto livello come C e Java permettono di scrivere sottoprogrammi che vengono chiamati funzioni (in C) o metodi (in Java). D'ora in poi li chiamerò metodi. Nel linguaggio macchina i sottoprogrammi vengono identificati mediante l'indirizzo in memoria, in Java e C sono identificati tramite un nome, un identificatore, come per le variabili. L'esempio della moltiplicazione mediante addizioni successive si presta bene per essere trasformato in un metodo che accetti come ingresso due valori e ne restituisca il prodotto. Vediamo come si presenta il programma dopo aver racchiuso l'algoritmo di moltiplicazione in un metodo:

package progjava;

public class ProgJava {
  
  public static int moltiplica(int m, int n) {
    int r = 0;
    boolean segnoMeno = false;
    if (n < 0) {
      segnoMeno = true;
      n = -n;
    }
    while(n > 0) {      
      r = r + m;
      n = n - 1;
    }
    if (segnoMeno == true) r = -r;
    return r;
  }

  public static void main(String[] args) { 
    int a = 0;
    a = moltiplica(12, -5);
    System.out.println(a);
  } 
}

Prendiamo in considerazione la prima linea della dichiarazione del metodo

public static int moltiplica(int m, int n) {

Questa linea è chiamata intestazione del metodo, e ne dichiara le convenzioni per poterlo utilizzare. Le parole chiave "public" e "static" restano ancora un mistero e bisogna scriverle, quello che interessa è quello che viene dopo.
La parola chiave "int" sta ad indicare che il metodo ritornerà un valore di tipo int. Vedremo più avanti cos'è questo valore. Dopo incontriamo il nome che arbitrariamente abbiamo dato al metodo, cioè "moltiplica". Fra le parentesi tonde troviamo la lista dei parametri formali che sono i valori che dobbiamo fornire al metodo. Il nome e la lista delle variabili sono chiamati firma o signature del metodo. Nel nostro caso la convenzione è che dobbiamo fornire due valori di tipo int al metodo.
Dopo la dichiarazione c'è una parentesi graffa che, insieme a quella posta dopo l'istruzione "return", racchiude il cosiddetto corpo del metodo e ne conclude la dichiarazione.
Osserviamo che all'interno del metodo non troviamo più le dichiarazioni delle variabili "m" ed "n", ma queste vengono comunque utilizzate. Tuttavia troviamo la dichiarazione di "m" ed "n" nella lista dei parametri formali. Appare chiaro che i parametri formali della dichiarazione del metodo si possono usare come variabili, anzi sono esattamente la stessa cosa!
L'algoritmo per la moltiplicazione l'abbiamo già analizzato in precedenza, è sempre lo stesso se non per la presenza della parola chiave return seguita da "r" (il risultato della moltiplicazione) che è chiamato valore di ritorno o, più discorsivamente valore restituito. In Java (purtroppo non in C e questo è fonte di errori logici) l'istruzione di "return" è obbligatoria, ed il valore (o la variabile) scritto dopo il "return" deve essere dello stesso tipo (o di un tipo compatibile) di quello indicato nella dichiarazione, nel nostro caso un "int".
Quindi invocando (sinonimo di chiamare o "fare eseguire") il metodo otteniamo un valore di ritorno che possiamo, ad esempio, assegnare ad una variabile. Analizziamo ora il main

  1. la prima linea dichiara la variabile "a" a cui assegna il valore zero.
  2. la seconda linea dice al calcolatore "invoca il metodo moltiplica con i parametri attuali (o argomenti) 12 (che viene assegnato al parametro formale m) e -5 (che viene assegnato al parametro formale n), ed assegna il valore da lui restituito alla variabile a.
  3. stampa il valore di "a".

Vediamo ora cosa succede nella memoria del calcolatore. Come detto in precedenza il compilatore sa esattamente quanto grande è lo stack di un metodo, e questa informazione rimane fruibile all'interno del programma compilato. Quando un metodo viene invocato, il calcolatore riserva lo spazio in memoria richiesto per il suo funzionamento (e sa bene dove andare a memorizzare quello che serve). Inoltre riserva altro spazio per poter memorizzare l'indirizzo a cui deve "saltare" al termine dell'esecuzione e quello per il valore di ritorno. Nel nostro caso lo stack sarà organizzato in questo modo

Le operazioni successive sono

  1. Scrivere l'indirizzo di ritorno per potere, una volta terminata l'esecuzione, riprendere dal punto dove è stato invocato. Esattamente come le subroutine.
  2. Trasferire i valori degli argomenti al loro posto

Immediatamente prima di eseguire la prima istruzione del metodo, lo stack è questo

Dopo le prime due dichiarazioni delle variabili "r" e "segnoMeno" lo stack si presenta così

Nota: i valori o e false assegnati alle variabili sono detti valori di default e vengono assegnati automaticamente dal compilatore. Dopo avere eseguito le istruzioni del corpo e prima di incontrare l'istruzione "return" lo stack è cambiato: il valore della variabile "n" vale ora 0 perché il ciclo ha fatto in modo che decrescesse fino a 0, "segnoMeno" vale true perché "n" era negativo, ed "r" contiene il risultato.

Infine, prima di riprendere l'esecuzione dove il metodo è stato invocato

L'istruzione "return" memorizza nella casella del valore di ritorno il valore di "r" e poi termina l'esecuzione del metodo. Attenzione perché l'esecuzione del metodo non termina naturalmente (lo fa solo nel caso di metodi che non restituiscono nessun valore) ma termina quando incontra l'istruzione "return".
Il calcolatore tiene per se il valore di ritorno, quindi salta all'istruzione successiva a <indirizzo>, libera la zona di memoria occupata dallo stack e riprende l'esecuzione dal punto in cui è stato invocato.

Nota: Anche se questo main è utile per capire come funzionano i metodi, c'è un errore logico: si usa una variabile che non serve. Infatti, sapendo che il metodo "System.out.println" accetta argomenti numerici e che il metodo "moltiplica" restituisce un valore numerico, il modo corretto per scrivere il programma è

public static void main(String[] args) { 
  System.out.println(moltiplica(12, -5));
} 

Metodi senza parametri formali

Può capitare di progettare metodi che non necessitano di argomenti, come ad esempio, un metodo che se invocato, rimanga in attesta che venga premuto un pulsante da tastiera e che ne ritorni il carattere ASCII associato a tale tasto. Tale metodo si presenterebbe così

public static char waitForKey() {
  char c;
  // Aspetta che venga premuto un tasto
  // e lo memorizza nella variabile c
  // ...
  return c;
}

Per indicare al compilatore che questo metodo non ha bisogno di parametri è sufficiente non scrivere la lista dei parametri. Quello che fa internamente per ora non ci interessa (ho solo dichiarato una variabile per poter scrivere correttamente l'istruzione di return), è solo un esempio di come dichiarare questa tipologia di metodi. Un esempio di come potrebbe essere usato questo metodo

  ch = waitForKey();
  Syetm.out.print"E' stato premuto il tasto ";
  System.out.println(ch);

Quando il calcolatore incontra questo frammento di codice, chiama il metodo "waitForKey" (che rimane in attesa della pressione di un tasto). Alla pressione di un tasto il metodo, avendo fatto il suo lavoro, termina l'esecuzione e restituisce il codice ASCII del tasto premuto. Seguono le due linee per la stampa del carattere.
In C la dichiarazione di una funzione come quella sopra è leggermente differente

char waitForKey(void) 
{
  char c;
  // Aspetta che venga premuto un tasto
  // e lo memorizza nella variabile c
  // ...
  return c;
}

Infatti in C si deve indicare al compilatori che la lista dei parametri è vuota e lo si fa mediante la parola chiave void. L'assenza di "void" fra le parentesi tonde genera un errore di sintassi.

Metodi che non ritornano nessun valore

Anche questi sono metodi assai comuni. Immaginiamo un metodo che disegni un rettangolo sullo schermo. Di sicuro ha bisogno di almeno due parametri (altezza e larghezza), eventualmente il colore, se deve essere riempito, la posizione sullo schermo e così via. Ha bisogno di molte cose ma quello di cui nessuno sente la necessità è che ritorni un valore. E' sufficiente che faccia il suo lavoro. La dichiarazione di un metodo del genere sarebbe questa

public static void drawRectangle(int dx, int dy, int col) {
  // Disegna un rettangolo
  // di dimensioni "dx" x "dy"
  // con il colore "col"
}
Per indicare al compilatore che il metodo non restituisce nessun valore, nella posizione del tipo di dato di ritorno si scrive la parola chiave "void". Questa è una porzione di codice per un suo eventuale utilizzo
  drawRectangle(25,82,3);

Che produrrebbe l'effetto di disegnare un rettangolo 25 x 82 con il colore che ha codice 3.
In C si scrive esattamente allo stesso modo, es:

void drawRectangle(int dx, int dy, int col) 
{
  // Disegna un rettangolo
  // di dimension "dx" x "dy"
  // con il colore "col"
}

Uso del valore di ritorno

Il valore di ritorno può essere usato oppure no. Quando si invoca un metodo che ritorna un valore, se questo valore non interessa semplicemente non lo si usa. Per esempio potremmo avere bisogno di un metodo che semplicemente aspetti la pressione di un tasto qualsiasi della tastiera. Possiamo benissimo usare il metodo dell'esempio di prima in questo modo

  System.out.println("Premere un tasto qualsiasi per continuare ...");
  waitForKey();


Metodi che chiamano altri metodi

All'interno di un metodo si possono trovare chiamate ad altri metodi. Questo è normalissimo, anzi è consigliato scrivere metodi in modo modulare per ottimizzare il codice sorgente e per dare una struttura logica al programma. Prendiamo in considerazioni l'esempio che stampa la tavola pitagorica dei numeri da 1 a 10 che abbiamo visto nel capitolo in cui sono stati trattati i costrutti di base.

package progjava;

public class ProgJava {

  public static void main(String[] args) { 
    int r = 1;
    while(r < 11) {
      int c = 1;
      while(c < 11) {
        System.out.print(r * c);
        System.out.print(" ");
        c = c + 1;
      }
      System.out.println();
      r = r + 1;
    }
  } 
}

Possiamo migliorarlo trasformandolo in un metodo che stampa la tavola pitagorica dei numeri da 1 ad un valore "n" che passiamo come parametro ad un metodo che chiamiamo "tavolaPitagorica". In fase di progettazione è necessario però tener conto di tutte le situazioni in cui questo metodo si potrebbe trovare perché "n" potrebbe valere zero o o essere un valore negativo, in questi casi il metodo non deve fare niente e non deve generare nessun errore.

package progjava;

public class ProgJava {
  
  public static void tavolaPitagorica(int n) {
    int r = 1;
    while(r < n + 1) {
      int c = 1;
      while(c < n +1) {
        System.out.print(r * c);
        System.out.print(" ");
        c = c + 1;
      }
      System.out.println();
      r = r + 1;
    }
  }

  public static void main(String[] args) { 
    tavolaPitagorica(10);
  }
}

Facendo partire il programma si otterrà lo stesso output dell'esempio precedente, se però il parametro attuale dell'invocazione del metodo da parte del main vale 5 otteniamo questo output.

run:
1 2 3 4 5 
2 4 6 8 10 
3 6 9 12 15 
4 8 12 16 20 
5 10 15 20 25 
BUILD SUCCESSFUL (total time: 1 second)

Ma se gli passassimo il valore 0 o un valore negativo? Succederebbe che il metodo eseguirebbe solo la dichiarazione della variabile r ma, appena incontrato il "while", non essendo r minore di 0+1 (in questo caso "r" è uguale a "n" + 1) non entra nemmeno nel ciclo, quindi non farebbe niente, che è quello che vogliamo.

A volte può capitare che un metodo richiami se stesso. Questo, di primo acchito, sembrerebbe un errore madornale ma non lo è anzi, in alcuni casi, diventa di estrema utilità. Prendiamo in esame l'esempio del conto alla rovescia. Trasformando il ciclo che conta all'indietro il programma si presenterebbe così

package progjava;

public class ProgJava {
  
  public static void contaIndietro(int n) {
    while(n > 0) { 
      System.out.println(n); 
      n = n - 1; 
    }
  }

  public static void main(String[] args) { 
    contaIndietro(3);
  }
}

Possiamo scriverlo anche in questo modo

package progjava;

public class ProgJava {
  
  public static void contaIndietroRec(int n) {
    if (n > 0) {
      System.out.println(n);
      contaIndietroRec(n - 1);
    }
  }

  public static void main(String[] args) { 
    contaIndietroRec(3);
  }
}

Una cosa salta subito all'occhio: non c'è nessun controllo di ciclo, anche se il metodo invocato si comporta come un ciclo! Analizziamo il programma. Nel main c'è solo la chiamata al metodo "contaIndietroRec" con un valore di 3. Il metodo è invece più interessante. La prima istruzione è un controllo di flusso che dice al calcolatore "se n è maggiore di zero allora esegui il blocco che segue" che vuole anche dire "se n vale 0 non fare niente ed esci dal metodo". All'interno del blocco la prima istruzione stampa il valore di "n", la seconda e come se dicesse al calcolatore "invoca te stesso per stampare in modo decrescente i numeri da n - 1 ad 1" che è proprio quello che il metodo fa. Quali sono le differenze fra i due approcci?

  • Nella prima versione in pratica diciamo al calcolatore esattamente cosa fare passo per passo. E' come se dovessimo erigere un muro e dicessimo al muratore tutte le operazioni che deve fare, dalla prima all'ultima. Il ciclo si svolge tutto all'interno del metodo. In pratica facciamo stampare i numeri da "n" a 1 in modo iterativo.
  • Nella seconda versione è come dare al calcolatore delle regole generali da seguire in modo ricorsivo.

Abbiamo visto prima cosa succede nello stack quando si chiama un metodo: questi si riserva uno spazio di memoria. Nell' esempio ricorsivo

  • La prima volta che s'invoca il metodo, questi si riserva dello spazio nello stack
  • Essendo "n" maggiore di zero il suo valore viene visualizzato sul terminale
  • Prima di uscire chiama un altro metodo (in questo caso lui stesso). Quindi lo spazio nello stack è ancora occupato ed il metodo chiamato, qualsiasi esso sia, si riserva altro spazio e lo mette in coda a quello già riservato.

Questo vuol dire che quando il metodo è chiamato con "n" che vale 0 lo stack si presenterà così

Ed è anche facile capire il perché: per ogni unità di "n" c'è una chiamata ricorsiva. E n valesse 1000? Avremmo uno stack con 1000 aree riservate più una, la chiamata dal main. La cosa rende particolarmente inefficiente e sconveniente l'uso di questa versione ricorsiva.
Resta comunque il fatto che la ricorsione è una tecnica importante perché permette di risolvere in modo semplice problemi molto complessi che sarebbe complicatissimo risolvere in modo iterativo. Se invece parliamo di studio della programmazione, la ricorsione ne è il sale, perché ragionare in modo ricorsivo è inusuale, a volte difficile, bisogna spremersi le meningi per trova la "regola". Ma è anche elegante, bello, raffinato.
Suggerisco caldamente di provare (e riuscire) a scrivere metodi iterativi in modo ricorsivo. La programmazione strutturata fornisce una sorta di schema mentale organizzato, razionale ed efficiente che, anche se non sembra, lo si applica inconsciamente nella vita quotidiana.
La ricorsione ne fornisce un altro. E vale la pena imparare a programmare ricorsivamente perché apre la mente.

Nota
L'immagine di copertina è tratta da questo sito
blog.prepscholar.com/single-variable-equations-algebra-act-math-strategies

0

Commenti e note

Inserisci un commento

Inserisci un commento

Per inserire commenti è necessario iscriversi ad ElectroYou. Se sei già iscritto, effettua il login.