Un gradino più in alto

Ho saputo che qualcuno di voi ha provato a modificare i semplici programmi che ho proposto, il che è cosa buona e giusta, e mi riprometto d’ora in avanti di preparare anche qualche esercizio più complesso, perché altrimenti ci si annoia e basta. All’inizio di queste lezioni avevo detto che imparare a programmare significa soprattutto imparare a pensare, cioé ad analizzare e risolvere problemi con la testa prima che con il codice. A questo punto uno potrebbe immaginare di doversi mettere a fare esercizi su esercizi per imparare a risolvere problemi, ma la verità è, come peraltro sostiene un mio buon insegnante, che fare decine di volte gli stessi esercizi è perfettamente inutile, oltre che cosa tipicamente ingegneristica, perché uno le cose le capisce dopo averle fatte una volta, massimo due, se usa la testa, e se non le ha capite si vede che non ha usato la testa.

Il motivo classico per fare esercizi è imparare i procedimenti, e allora è ovvio che uno prende un esempio con tutti i suoi dati, ne toglie uno e cerca di derivarlo usando i procedimenti che vuole imparare. Questo sfortunatamente non significa imparare a pensare. In informatica ci sono un mucchio di problemi e un mucchio di procedimenti diversi per risolverli, ma per fortuna ognuno di questi è utile se eseguito una sola volta, per tutte le altre c’è il computer. Il fatto che ci siano tanti modi per risolvere lo stesso problema è uno dei cardini dell’informatica, dato che per forza di cose un certo modo è migliore di un altro per certi versi e peggiore per altri, ma è importante anche perché ciascuno di questi modi rappresenta un diverso approccio allo stesso problema, che significa pensare diversamente allo stesso problema, che alla fine della fiera significa pensare, che è quello che ci interessa.

Lunga introduzione per una lezione ponte. Con la scorsa lezione abbiamo visto più o meno tutti i fondamentali di quella che chiamiamo programmazione procedurale [1]: blocchi di istruzioni, modi per ripetere blocchi di istruzioni, e modi per decidere quale blocco di istruzioni è più opportuno eseguire a seconda delle condizioni in cui ci si trova. Con la prossima serie di lezioni affronteremo un notevole cambio di paradigma che comporterà senza dubbio fatica e stridore di denti, e lo scopo di questa lezione è vedere perché varrà la pena di soffrire ancora un po’. In tutto ciò ho lasciato fuori un paio di concetti estremamente utili ed interessanti perché affrontarli proceduralmente significherebbe soffrire veramente troppo per guadagnare apparentemente troppo poco. Con queste premesse dolorose, più che una lezione ponte questa sembra una lezione Caronte…

Reinventare la ruota è SBAGLIATO

Riprendiamo l’esempio delle palline colorate della lezione sui cicli e guardiamolo da una certa distanza. Quello che succede è che Processing continua a ripetere il codice che abbiamo inserito nella funzione draw(), e abbiamo visto che questo ha un suo senso nell’economia di Processing. Il codice che abbiamo scritto in questa funzione ha un problema fondamentale: agisce mentre accade, che significa che ogni volta che viene invocato fa delle cose molto concrete e piuttosto incasinate tipo disegnare delle palline in certi punti dello schermo. Se questo fosse tutto quello che deve fare, il problema quasi non si porrebbe: abbiamo due cicli for annidati e la funzione ellipse() che disegna i cerchietti.

Siccome non ci divertivamo abbastanza però abbiamo voluto fare delle modifiche a questi cerchietti: in primo luogo il loro colore e in secondo luogo il raggio, due parametri che cambiano in base allo scorrere del tempo, alla loro posizione nello schermo e alla loro distanza dal puntatore del mouse. Per fare questo abbiamo aggiunto delle istruzioni che facevano dei conti piuttosto complicati e prendevano delle decisioni. In pratica non abbiamo fatto niente di male, in teoria invece abbiamo fatto un gran casino. Idealmente la funzione draw() dovrebbe occuparsi solo di disegnare cose sullo schermo, non certo di fare calcoli e prendere decisioni, ma Processing è progettato per chiamare quella funzione a ciclo continuo [2] e quindi dobbiamo inventarci qualcosa. In informatica spesso la teoria è più importante della pratica [3] e per questo il concetto di separazione delle responsabilità è particolarmente importante: permette non solo di assegnare a componenti diverse compiti diversi, rendendo più facile la gestione del codice, ma realizza anche il fondamentale concetto di modularità che ci permette di riutilizzare in altri progetti parti di codice che abbiamo già scritto e che già sappiamo funzionare.

Dieci piccoli indiani

Quando abbiamo parlato per la prima volta di tipi di dato ho menzionato i tipi astratti. Non è ancora il momento di parlarne ma, prima di riprendere in mano il vecchio codice, vediamo di fare un altro passo verso il cambio di paradigma della prossima lezione. Fin’ora abbiamo gestito variabili che contenevano numeri, ma ognuna di queste variabili poteva contenere un solo numero per volta, il che è del tutto legittimo ma ci costringe a fare una cosa per volta ogni volta che la dobbiamo fare. Prendiamo i raggi dei cerchietti che continuano a cambiare: ogni volta che dovevamo disegnare un cerchietto nuovo dovevamo calcolare il suo raggio e tutte le altre cose prima di poter passare al cerchietto successivo, ragionando su un cerchietto alla volta e solo su quello per ogni iterazione. Pensate se avessimo voluto che il raggio del nostro cerchietto fosse in qualche modo legato a quello dei cerchietti che lo precedevano: ad ogni iterazione il nostro universo è fatto da una posizione nello schermo e dal tempo che passa, e solo su quello, non ci è dato sapere che altri cerchietti ci circondano! Mettiamo che uno dei cerchietti decidesse di andarsene in giro, non potremmo sapere se va a sbattere con un altro cerchietto! Che disastro!

Un modo per alleviare le nostre sofferenze c’è, ed è tenere sempre in memoria una lunga sequenza per ogni parametro che vogliamo controllare. Ma dato che le variabili tengono un solo valore per volta e che non sappiamo in partenza di quanti cerchietti stiamo parlando [4] l’array ci viene in soccorso.

L’array è un tipo di dato speciale, chiamato anche contenitore, che crea una lista indicizzata di valori dello stesso tipo. Per esempio se vogliamo lavorare su una lista di dieci interi dovremo creare un array che contiene interi e dirgli che vogliamo che ne contenga dieci. Siccome sento già urlare, ecco un disegnino che dovrebbe chiarire il tutto:

La sintassi non è molto diversa da quella delle altre variabili. Un array di interi si dichiara

int[] myArray;

dove int è il tipo di dato da contenere, myArray è il nome con cui ci riferiremo all’array, e [] è il modo per dichiarare che myArray è proprio un array. Fin’ora non abbiamo mai detto che vogliamo dieci interi, però. Possiamo farlo in due modi: il primo è valido e ragionevole se sappiamo dall’inizio quali valori vogliamo che contenga, e se la quantità è piccola

int[] myArray = { 4, 5, 2, 6, 45, anInt, anotherInt, 8, 10, 0 };

posto che anInt e anotherInt siano due variabili di tipo int; il secondo, e più comune, è valido se vogliamo un array con tantissimi elementi, o non ne conosciamo i valori al momento della dichiarazione, o entrambe le cose:

int[] myArray = new int[10];

dove new è una nuova parola chiave di Java che vedremo nella prossima lezione ma che per il momento ci basta sapere che è necessaria per creare un array in questo modo. A questo punto non ci resta che vedere come accedere agli elementi dell’array, come modificarli e come leggerli, e finalmente vediamo a cosa servono quelle parentesi quadre che fin’ora se ne stavano lì in un silenzio imbarazzato: le parentesi quadre sono il modo con cui indichiamo quale elemento dell’array vogliamo usare. Per esempio, se vogliamo inserire il numero 10 nella prima posizione, scriveremo

myArray[0] = 10;

ricordandoci che i computer iniziano a contare da 0 e quindi la prima posizione è la numero 0 — il che crea sempre qualche confusione all’inizio perché per crearlo abbiamo scritto new int[10] ma se scriviamo myArray[10] veniamo insultati gentilmente informati che la posizione 10 non esiste: la decima posizione è infatti la numero 9. Niente paura: con la pratica ci si fa l’abitudine. Infine, tanto per complicare ulteriormente le cose, gli array possono contenere anche altri array, sempre che a loro volta contengano lo stesso tipo di dato, e questo è il motivo per cui a volte, in italiano, vengono chiamati anche vettori o matrici.

int[][] myMatrix = new int[10][20];

In questo modo abbiamo creato una matrice 10 × 20, o 20 × 10, a seconda di come vogliamo interpretare il primo e il secondo indice. Ovviamente il giochino si può ripetere numerose volte fino a livelli da insanità mentale, ma qui ci accontentiamo di due dimensioni, anche perché finalmente è arrivato il momento di vedere il codice modificato!

int width, height, dotCount, step;
float radius, fps;

int[][] positions;
float[] radii;
float[] distances;
color[] colours;

void setup() {
  width = 600;
  height = 400;
  radius = 5.0f;
  fps = 30.0f;
  step = 20;

  size(width, height);
  frameRate(fps);
  noStroke();
  colorMode(HSB, 360, 100, 100);

  dotCount = (width*height)/(step*step);

  positions = new int[dotCount][2];
  radii = new float[dotCount];
  distances = new float[dotCount];
  colours = new color[dotCount];

  int i = 0;
  for(int y = step/2; y < height; y = y + step) {
    for(int x = step/2; x < width; x = x + step) {
      positions[i][0] = x;
      positions[i][1] = y;
      radii[i] = radius;
      distances[i] = sqrt(pow(mouseX - x, 2) + pow(mouseY - y, 2));
      colours[i] = color(map(x, 0.0f, width, 0.0f, 360.0f),
                         distances[i], 100);
      i++;
    }
  }
}

void update() {
  float t = frameCount/fps;

  for(int i = 0; i < dotCount; i++) {
    int x = positions[i][0];
    int y = positions[i][1];

    radii[i] = radius + 2*sin(x - 4*t) + 2*cos(y - 2*t);
    distances[i] = sqrt(pow(mouseX - x, 2) + pow(mouseY - y, 2));
    if(distances[i] < 100.0f) {
      radii[i] = radii[i] + (100.0f - distances[i])/10.0f;
    }
    colours[i] = color(map(x, 0.0f, width, 0.0f, 360.0f),
                       distances[i], 100);
  }
}

void draw() {
  update();

  background(0, 0, 0);

  for(int i = 0; i < dotCount; i++) {
    fill(colours[i]);
    ellipse(positions[i][0], positions[i][1], radii[i], radii[i]);
  }
}

Partiamo dall’inizio, dalle dichiarazioni delle variabili globali, dove troviamo ben quattro array, di cui uno bidimensionale. Saltiamo in fondo al programma, alla funzione draw(), e diamole uno sguardo: la prima cosa che fa è chiamare una funzione, update(), che troviamo definita qualche riga più in alto, ma per ora procediamo. La seconda cosa che fa è cominciare a disegnare dipingendo di nero lo sfondo della finestra, poi entra in un ciclo che conta da 0 al valore di dotCount meno uno che, se torniamo qualche riga più in alto, alla funzione setup(), troviamo inizializzato in modo da contare quanti cerchietti ci stanno nella finestra che abbiamo dimensionato, in questo caso

$$ \frac{\mathrm{width} \cdot \mathrm{height}}{\mathrm{step}^2} = \frac{600 \cdot 400}{20^2} = 600 $$

quindi il ciclo andrà da 0 a 599. Dentro al ciclo impostiamo il colore col valore che sta alla posizione i dell’array colours e disegnamo il cerchietto con le coordinate che troviamo alla posizione i dell’array positions e col raggio che troviamo alla posizione i dell’array radii [5]. Alla posizione i dell’array positions però c’è un array di due elementi: se guardiamo nella funzione setup() vediamo che l’inizializzazione ce lo conferma, e così possiamo scriverci le coordinate $x$ e $y$ nelle posizioni rispettivamente 0 e 1 di ciascun array posizione.

Tutto qui: finalmente la funzione draw() si occupa solo di disegnare roba sullo schermo. Torniamo un po’ su alla funzione update() che ora viene chiamata da draw() ad ogni fotogramma, e qui troviamo tutto quello che manca dalla draw(): tempo che scorre, calcolo dei raggi, distanze, colori… ora sappiamo che se abbiamo un problema grafico dovremo cercare in draw() e se abbiamo un problema di calcolo dovremo cercare in update(). Inoltre in ogni momento possiamo accedere alle informazioni di tutti i cerchietti che ci stanno intorno perché sono sempre disponibili nei vari array al solo prezzo di sapere dove andarli a cercare, ma siccome di volta in volta sappiamo su quale cerchietto stiamo lavorando, guardarsi intorno non può essere così difficile.

I più svegli tra voi avranno forse notato che abbiamo raddoppiato il numero di iterazioni. In effetti tenendo tutto dentro la draw() facevamo tutti i conti che ci servivano in 600 iterazioni, ora facciamo 600 iterazioni in draw() per disegnare e 600 in update() per ricalcolare i parametri. È vero, questo può essere un problema in generale, e vedremo a tempo debito che in informatica c’è una grande attenzione a non sprecare operazioni per nulla, ma in questo caso possiamo far finta di niente perché presumibilmente le operazioni che facciamo per disegnare sono molte meno e molto meno onerose di quelle che facciamo per calcolare e quindi con ogni probabilità perdiamo pochissime prestazioni e guadagnamo tantissimo in chiarezza. Per chi non fosse ancora convinto, facciamo due conti. Mettiamo che nella vecchia versione, contando le righe, facevamo 6 istruzioni nei cicli annidati, quindi 6 × 600 = 3600. Ora nella nuova versione facciamo 2 istruzioni di disegno e 5 di calcolo (gli assegnamenti non li contiamo) e quindi 7 × 600 = 4200 che è più di prima ma, se è vero che le operazioni per disegnare sono molto meno onerose di quelle per calcolare, ci stiamo dentro ugualmente [6]. Se vi è venuto mal di testa, niente paura: non faremo spesso questi discorsi, per ora non sono fondamentali, quindi limitiamoci a chiudere il discorso tornando alla funzione setup() in cui troviamo le inizializzazioni degli array (compreso l’array multidimensionale positions che ha 600 array di due posizioni ciascuno, ottimo per le coordinate) e un bel for per impostare i valori iniziali del tutto. Eh, sì: dal momento che non stiamo più disegnando le cose nel momento in cui succedono, e che quindi in particolare non calcoliamo ogni volta le posizioni dei cerchietti, è bene inizializzare gli array con dei valori prima di cominciare a disegnare [7]. Come esercizio potreste provare a trasformare i due cicli annidati in setup() in uno solo che faccia le stesse cose, scorrendo il numero del cerchietto da considerare anziché procedendo per righe e colonne sullo schermo.

In conclusione, avevamo detto che separando le responsabilità le cose si sarebbero semplificate notevolmente, e in effetti è così. Quello che non si è semplificato è il codice che, anzi, è lievitato notevolmente, e pure la notazione degli array non è che aiuti la chiarezza. Niente paura: questo era un passaggio obbligato verso il prossimo ciclo di lezioni, e in più abbiamo imparato gli array che sono una delle strutture di dati più comuni e utilizzate, l’anello di congiunzione tra la rigidità dei tipi primitivi e la flessibilità di quelli astratti.

  1. Qualcuno potrebbe essere tentato di dire imperativa ma la linea è talmente sfumata che l’uno vale l’altro, e a me procedurale sembra più corretto.
  2. Che non è una scelta di progetto molto felice, ma Processing è figlio di Java… just saying.
  3. Questa è una cosa troppo spesso soverchiata dalla logica della produttività per cui appena una cosa sembra funzionare si passa oltre e non ci si pensa più, ma vorrei vedere cosa succederebbe se un ingegnere, appena gli paresse che i conti di un ponte tornassero, passasse oltre senza ricontrollare…
  4. Ok, in questo caso lo sappiamo perché la finestra grafica la impostiamo noi ma se la volessimo rendere ridimensionabile?
  5. Sì, perché radiuses è orribile, dai…
  6. Inoltre il calcolo non è preciso perché nelle operazioni per ricalcolare i parametri non possiamo davvero dire una riga uguale una istruzione, basta guardare quante funzioni chiamiamo per riga…
  7. In generale è sempre bene inizializzare le variabili con dei valori noti ma gli array possono essere particolarmente insidiosi nel caso in cui la nostra richiesta per un array venisse soddisfatta solo con l’indicazione dell’area di memoria da usare, senza che sia stata pulita prima. In Java non è il caso, per fortuna o per sfortuna, ma è sempre buona pratica lavare i vestiti nuovi prima di indossarli.

Commenti

Rispondi