MokaByte 69 - Dicembre 2002 
Corso introduttivo su Java
IX parte: la programmazione strutturata
di
Andrea Gini
La programmazione strutturata è una filosofia che ha preso piede a partire dagli anni '70. Sebbene concettualmente superata dalla programmazione orientata agli oggetti, i suoi principi sono ancora utilizzati, seppur se in scala ridotta, in linguaggi Object Oriented come Java o C++. Vale dunque la pena di operare una panoramica sul tema, mettendone in luce i principi fondanti.

I principi della Programmazione Strutturata
Il principio fondante della programmazione strutturata è che sono sufficienti appena tre strutture di controllo per realizzare qualunque tipo di programma: la sequenza, l'iterazione e la selezione. Le istruzioni possibili appartengono a loro volta a tre sole categorie: input, output ed assegnamento.
Attraverso la scomposizione modulare del codice, resa possibile dal costrutto della procedura (quello che in Java chiamiamo metodo), è possibile suddividere un grosso problema in sottoproblemi, permettendo un livello più alto di organizzazione. Ogni procedura può essere considerata a tutti gli effetti una sorta di estensione al linguaggio: in altre parole, è possibile applicare i costrutti fondamentali di sequenza, iterazione e selezione su ciascuna procedura accessibile da un programma.
Resta tuttavia aperta una questione: come facciamo a suddividere un problema grosso in sotto-problemi di dimensioni inferiori? Esistono delle pratiche che facilitino l'analisi e la formalizzazione di problemi? Queste pratiche esistono, e sono oggetto di studio di una scienza, detta "Ingegneria del Software". Alla luce di quanto spiegato fino ad ora, ci limiteremo ad analizzare i due approcci più in uso nella programmazione strutturata: Top Down e Bottom Up.

 

Sviluppo Top-Down
Il principio su cui si fonda l'approccio Top Down è lo stesso che permise a Cesare di conquistare la Gallia, riassunto dalla celebre massima "Divide et Impera". Quando ci si trova di fronte ad un problema troppo complesso da poter essere affrontato in un colpo solo, l'approccio migliore è quello di suddividerlo in passaggi più semplici. Ogni singolo passaggio può a sua volta essere suddiviso ulteriormente, fino a giungere a un insieme di sottoproblemi facilmente risolvibile. Questo modus operandi permette di portare a termine un problema in modo incrementale, con un procedimento che minimizza gli sforzi e massimizza i risultati. Proviamo a vedere come affrontare un problema di una certa complessità attraverso un approccio Top Down.
Un esempio pratico: Tavole Pitagoriche di dimensione arbitraria
Si vuole realizzare un programma che, dopo aver chiesto all'utente un valore compreso tra 10 e 100, calcola una tavola pitagorica della dimensione specificata, e quindi la stampa su schermo.
Al momento di pianificare lo sviluppo di un simile programma, dobbiamo anzitutto individuare quante e quali sono le fasi in cui possiamo suddividere il problema. La più intuitiva risulta essere la seguente:

1. Richiesta della dimensione della tavola
2. Creazione in memoria della tavola
3. Stampa su schermo della tavola pitagorica

Ognuna di queste fasi può essere risolta con un metodo opportuno. Il primo di questi metodi si occuperà dell'interrogazione utente, e restituirà un intero:

static int chiediDimensioneTavola() { … }

Il secondo costruirà una tavola pitagorica della dimensione specificata in input, e restituirà in output un vettore bidimensionale, ossia la rappresentazione in memoria della tavola pitagorica stessa:

static int[][] creaTavolaPitagorica(int dim) { … }

Infine il terzo metodo stamperà su schermo il vettore passato come parametro:

static void stampaTavolaPitagorica(int[][] t) { … }

Sulla base delle precedenti dichiarazioni di metodo, possiamo già formulare il metodo main come segue:

public static void main(String argv[]) {
  int dim = chiediDimensioneTavola();
  int[][] tabellina = creaTavolaPitagorica(dim);
  stampaTavolaPitagorica(tabellina);
}

Da un problema grosso, siamo passati a tre problemi piccoli, ciascuno dei quali viene esemplificato dal metodo corrispondente. Si noti che il fatto di aver definito le interfacce di programmazione dei metodi (nome, parametri e il tipo di ritorno) ci ha già permesso di isolare i sottoproblemi, riducendo di gran lunga la difficoltà del problema di ordine superiore. Vediamo ora come risolvere il primo di questi sottoproblemi, ossia la richiesta di input all'utente:

static int chiediDimensioneTavola() {
  // Richiede l'inserimento di un valore
  String num = JOptionPane.showInputDialog(null,
                           "Inserisci un numero tra 10 e 100");
  // trasforma l'input in un valore numerico
  int n = Integer.parseInt(num);

  if(n < 10) // verifica il rispetto del limite inferiore
    n = 10;
  if(n > 100) // verifica il rispetto del limite superiore
    n = 100;
  return n+1; // restituisce il valore maggiorato di 1
}

La prima istruzione visualizza su schermo una finestra di input, che richiede l'inserimento di un numero da 10 a 100. La seconda trasforma l'input in un valore int: questi due passaggi non possono essere spiegati in dettaglio al momento attuale, dal momento che richiedono una certa conoscenza di programmazione Object Oriented. I due costrutti if seguenti servono a limitare il valore di input: se l'utente segnala un valore inferiore a 10 o maggiore di 100, esso verrà comunque riportato entro i limiti stabiliti. Infine, l'istruzione return restituisce un valore maggiorato di 1, una scelta necessaria per fare in modo che anche lo 0 sia incluso nella tavola pitagorica.
Passiamo ora al problema di creare in memoria la tavola pitagorica vera e propria:

static int[][] creaTavolaPitagorica(int dim) {
  // crea un vettore bidimensionale
  int[][] t = new int[dim][dim];

  for ( int i = 0; i < dim; i++)
    for ( int j = 0; j < dim; j++)
      // assegna ad ogni elemento della tavola il suo valore
      t[i][j] = i * j;
  return t;
}

La prima istruzione crea un vettore bidimensionale della dimensione specificata dal parametro dim. Le istruzioni successive, ricorrendo ad un ciclo for annidato, riempiono la tavola con i valori corretti. Infine, l'istruzione return restituisce il vettore appena creato.

Per terminare il programma, resta solo da formulare l'ultima funzione, quella che si occupa della stampa:

static void stampaTavolaPitagorica(int[][] t) {
  System.out.print("Tavola Pitagorica del ");
  System.out.println(t.length);

  for ( int i = 0; i < t.length; i++) {
    for ( int j = 0; j < t.length; j++) {
      System.out.print(t[i][j]);
      System.out.print("\t");
    }
    System.out.println();
  }
}

Le prime due istruzioni stampano a video una semplice intestazione, mentre i due cicli for innestati stampano a schermo i valori del vettore, separando ogni valore dal successivo con un carattere di tabulazione.
L'esempio presentato ci ha permesso di creare un programma di una certa complessità attraverso un processo incrementale, che ci ha permesso di suddividere un problema grosso in partenza in tre sotto-problemi molto più semplici. Vediamo ora l'esempio completo:

public class TavolaPitagorica {

  static int chiediDimensioneTavola() {
    // Richiede l'inserimento di un valore
    String num = JOptionPane.showInputDialog(null,
                             "Inserisci un numero tra 10 e 100");
    // trasforma l'input in un valore numerico
    int n = Integer.parseInt(num);

    if(n < 10) // verifica il rispetto del limite inferiore
      n = 10;
    if(n > 100) // verifica il rispetto del limite superiore
      n = 100;

    return n+1; // restituisce il valore maggiorato di 1
  }

  static int[][] creaTavolaPitagorica(int dim) {
    // crea un vettore bidimensionale
    int[][] t = new int[dim][dim];

  for ( int i = 0; i < dim; i++)
    for ( int j = 0; j < dim; j++)
      t[i][j] = i * j; // assegna ad ogni elemento il suo valore
  return t;
  }

  static void stampaTavolaPitagorica(int[][] t) {
    System.out.print("Tavola Pitagorica del ");
    System.out.println(t.length);

    for ( int i = 0; i < t.length; i++) {
      for ( int j = 0; j < t.length; j++) {
        System.out.print(t[i][j]);
        System.out.print("\t");
      }
      System.out.println();
    }
  }

  public static void main(String argv[]) {
    int dim = chiediDimensioneTavola();
    int[][] tabellina = creaTavolaPitagorica(dim);
    stampaTavolaPitagorica(tabellina);
  }
}

Questo esempio, per quanto sia stato costruito ad hoc, è una buona dimostrazione di come mettere in pratica un approccio Top Down per la risoluzione di un problema reale. Esistono tuttavia dei limiti a questa metodologia, che è importante chiarire.

 

Limiti della programmazione Top Down
Un tempo si pensava che l'approccio Top Down fosse la chiave per affrontare qualunque problema di programmazione. Ma a partire dagli anni '80 emersero almeno due grossi problemi: in primo luogo, l'approccio Top Down è ideale per problemi di dimensioni contenute, risolvibili con programmi da poche centinaia o migliaia di righe di codice. Di contro, diventa quasi impraticabile su programmi di milioni di righe, sempre più comuni ai giorni nostri.
In secondo luogo, la programmazione strutturata non offre un valido supporto alla progettazione di strutture di dati, uno degli aspetti più importanti della programmazione. La progettazione di strutture dati con linguaggi procedurali è un tema talmente complesso da scoraggiarne la trattazione anche solo in forma superficiale. Come vedremo, i linguaggi ad oggetti offrono un approccio molto più intuitivo e naturale all'argomento.
Infine, la progettazione Top Down presuppone che il programmatore affronti ogni nuovo problema partendo da zero, cosa che nella realtà si verifica assai di rado. Pensiamo ad un architetto che progetta una casa: sebbene ogni casa sia diversa dalle altre, è anche vero che in fase di progettazione ciascuna di esse presenta lo stesso tipo di problematiche. Al momento di progettare una casa, l'architetto non è costretto ogni volta a considerare che la casa deve assolvere, tra gli altri, anche il compito di permettere l'igiene personale, e da questo dedurre che la soluzione più adatta sia di costruire una stanza apposta. Al contrario, egli sa con certezza che ogni casa deve possedere un bagno, e si limita a prendere delle decisioni sulla disposizione del bagno in rapporto agli altri locali, o a stabilire come disporre i vari elementi all'interno del bagno stesso. In conclusione, l'architetto ricorre ad un approccio basato sulla conoscenza di determinati schemi progettuali predefiniti, che vengono raffinati attraverso la composizione di oggetti preesistenti (come lavandini o la vasche da bagno). Questo approccio è attuabile ad ogni livello del progetto, dalla singola camera, all'appartamento fino ad arrivare a comprendere un intero edificio. A ciascuno di questi livelli molti requisiti sono ben noti, e pertanto non è necessario re inventarli.
La programmazione ad oggetti, come vedremo, è basata largamente sul principio della composizione e dell'astrazione delle strutture dati. L'approccio Top Down è comunque sempre presente, anche se su scala minore, in determinati passaggi della stesura del codice.

 

Approccio Bottom Up e Funzioni di Libreria
L'approccio Bottom Up è l'altra metodologia di sviluppo caratteristica della Programmazione Strutturata. Il suo obbiettivo è l'opposto di quello Top Down: invece lavorare sulla scomposizione di un problema sottoproblemi, ha l'obiettivo di individuare algoritmi utili e di generalizzarli in modo da creare un insieme di funzioni di libreria.
Ogni linguaggio che si rispetti comprende per default un buon numero funzioni di libreria, che coprono le esigenze più svariate. Java non fa eccezione, ma nonostante la dimensione di tali librerie, esistono ancora oggi delle circostanze in cui ha un senso realizzare librerie per uso personale. La particolarità di una funzione di libreria è la sua versatilità: essa non si deve limitare a risolvere un particolare problema, ma una intera classe di problemi. Un tipico esempio di funzioni riutilizzabili sono le librerie di funzioni matematiche: il loro uso spazia dal calcolo scientifico a quello commerciale, dalla grafica 3D ai videogames.Vediamo ora qualche esempio pratico, per dimostrare come procedere alla generalizzazione di un problema al fine di ricavarne una funzione di libreria.

Valore assoluto
Il valore assoluto di un numero è pari al numero stesso, se esso è positivo, o al suo opposto se è negativo. In altre parole, la funzione Valore Assoluto prende in input un numero intero positivo o negativo, e restituisce lo stesso valore privo di segno. Possiamo facilmente creare una funzione di questo tipo ricorrendo all'operatore condizionale '?':

static int abs(int i) {
  return i >= 0 ? i : - i;
}

Max e Min
Max e Min sono altre funzioni facilmente realizzabili con l'operatore condizionale. Esse ricevono in input una coppia di valori interi, e restituiscono rispettivamente il maggiore o il minore dei due:

static int max(int n1, int n2) {
  return n1 >= n2 ? n1 : n2;
}
static int min(int n1, int n2) {
  return n1 <= n2 ? n1 : n2;
}

Fattoriale
Abbiamo già visto come si può realizzare in Java la funzione fattoriale. Per poterne realizzare una versione di libreria, dobbiamo essere sicuri che essa funzioni su qualsiasi input. Dal momento che non è possibile calcolare il fattoriale di numeri negativi, adottiamo la convenzione di restituire il valore '-1' per segnalare la condizione di errore (si noti tuttavia che questo metopdo non è in grado di calcolare la funzione fattoriale per valori superiori a al di sopra del 20: oltre tale soglia il valore calcolato eccede la capacità di memorizzazione di una variabile long)

static long factorial(int i) {
  if ( i < 0 )
    return - 1; // seganla una condizione di errore

  long fact = 1;
  for ( ; i > 0; i--)
    fact = fact * i;
  return fact;
}

Ordinamento di vettori
Per finire un esempio di difficoltà maggiore rispetto ai precedenti: una procedura per l'ordinamento di vettori di interi. Essa riceve in input un vettore di interi, e lo restituisce ordinato, con i numeri più bassi all'inizio e i valori più alti alla fine. Esso scandisce la lista n volte, spostandosi ogni volta in avanti di un elemento. Ad ogni iterazione, cerca il numero più basso e lo sposta nella posizione corrispondente. Al termine dell'operazione, restituisce il vettore riordinato. Si noti che esistono algoritmi più efficienti per l'ordinamento: questo esempio ha voluto privilegiare la chiarezza di esposizione. Chi fosse interessato ad approfondire la tematica degli algoritmi di ordinamento, e degli algoritmi in generale, può consultare la bibliografia.

static int[] sort(int[] list) {
  for(int i = 0 ; i < list.length ; i++ ) {
    int minIndex = i;
    for(int j = i ; j < list.length ; j++) {
      if ( list[j] < list[minIndex] )
        minIndex = j;
    }
    int tmp = list[i];
    list[i] = list[minIndex];
    list[minIndex] = tmp;
  }
  return list;
}

 

Conclusioni
Abbiamo studiato a fondo i metodi visti come funzioni matematiche. Abbiamo completato la panoramica sull'uso base del linguaggio: è finalmente giunto il momento di introdurre gli oggetti.

 

Bibliografia
Algoritmi in Java,
R. Sedgewick,
Addison-Wesley Italia, 1993.

Risorse
Scarica qui i sorgenti presentati nell'articolo

MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it