arduino funzioni

#4/2 – Codice su Arduino: Funzioni

Con una forzatura, possiamo vedere dall’esterno le funzioni come “variabili temporanee parametrizzate“:

  • Variabili, in quanto possono restituire un valore, che può essere utilizzato con gli operatori di assegnamento o di confronto.
  • Temporanee, perché il valore restituito è disponibile solo per eseguire l’operazione di assegnamento o confronto che ha “chiamato” la funzione, se esiste.
  • Parametrizzate, perché il loro valore (può) dipende(re) da parametri in ingresso che vengono comunicati mentre chiamiamo la funzione.

Dall’interno, invece, le funzioni non sono altro che sottoprocedure del codice. Possono leggere i dati a loro “visibili”, che possono elaborare utilizzando le istruzioni (semplici e/o chiamando altre funzioni), e poi possono restituire uno o più valori in output.

Le funzioni rappresentano il vero cuore del codice: dei blocchi con cui scrivere qualsiasi algoritmo, dal più semplice al più complesso.

Ogni funzione ha un identificatore, che segue le stesse regole di denominazione delle variabili.

Perché esistono le funzioni

Immaginiamo per un attimo di avere a disposizione solo istruzioni semplici. Tutti i nostri programmi sarebbero un’unica sequenza di istruzioni per fare assegnamenti, operazioni elementari (cambiare lo stato di un’uscita, fare un calcolo matematico), o operazioni per controllare il flusso di esecuzione (che vedremo più avanti in questa guida).

Ed è quello che effettivamente avviene sotto qualsiasi codice, sia per Arduino che non: le funzioni “non esistono”, quando andiamo a compilare il codice per poterlo poi eseguire.

Le funzioni esistono solo per semplificare la scrittura del codice (che è quello di cui tratta questa guida base), per noi che lo andiamo a scrivere: in una funzione si mettono istruzioni che hanno uno scopo comune, che magari è il nome che diamo alla funzione stessa (come vedremo tra poco).

Quando andiamo a programmare, a qualsiasi livello e complessità, vige sempre l’antico motto “divide et impera“. Si affrontano problemi complessi riducendoli a problemi più semplici da risolvere. Noi divideremo un problema relativamente semplice (la nostra applicazione), in un insieme di funzioni (i mattoncini della nostra applicazione).

Una funzione è utile anche per poter eseguire in modo facile le stesse istruzioni su dati iniziali diversi, senza dover fare copia-incolla che complicherebbero solo il codice.

Come definire una funzione

Le definiremo nel seguente modo:

tiporestituito nome_funzione(tipoargomento1 arg1, ....) {
  //Codice funzione
}

Le scritte tra parentesi vengono chiamati parametri in ingresso. Possiamo vederli come dichiarazioni di variabili esterne alla funzione. Dove poi risiedono queste variabili sarà un argomento trattato in seguito nell’articolo.

Una nota a parte meritano gli argomenti in ingresso che sono array e matrici.

Per quanto riguarda gli array, ci comportiamo come se fossero un tipoargomento qualsiasi, tuttavia omettiamo la lunghezza tra le parentesi:

tiporestituito funzione(tipovariabili array[]){
  //Codice funzione
}

Specificare la lunghezza se si ha una sola dimensione è infatti facoltativo. Diventa obbligatorio dalla seconda dimensione in poi (magari approfondiremo in seguito il perché per i più curiosi). Per esempio nel caso delle matrici bidimensionali:

tiporestituito funzione(tipovariabili matrice[][numerocolonne]){
  //Codice funzione
}

Non è obbligatorio che una funzione abbia argomenti in ingresso. In quel caso, scriveremo:

tiporestituito nome_funzione(){
  //Codice funzione
}

Le variabili in ingresso vengono riempite/associate durante la chiamata alla “funzione”, che quando finisce di eseguire le sue istruzioni, ci restituisce una variabile “tiporestituito” (come già detto, dall’esterno potremmo vedere ogni funzione come una variabile tiporestituito con dei parametri in ingresso).

Le parentesi graffe identificano l’inizio e la fine della sequenza di istruzioni semplici/composte della nostra funzione.

N.B.: per alcune funzioni, non ci interesserà avere un risultato quando le andiamo ad eseguire. Per queste funzioni tiporestituito sarà void e non è necessario mettere l’istruzione finale return (se proprio, scrivere “return;”).

Evidenziamo, per i lettori più curiosi, che “void” non significa che la variabile associata alla funzione non contenga niente, ma semplicemente non fornisce indicazione su come leggere i dati binari associati. Le regole della buona programmazione ci impongono di utilizzare void soltanto quando non ci interessa l’output, ma in realtà potremmo usarlo sempre e fare quelli che chiameremo cast.

Esecuzione delle istruzioni

Le istruzioni vengono eseguite una dopo l’altra, partendo da quella più in alto, a quella più in basso. Immaginiamo infatti il flusso di esecuzione come una freccetta di fianco al nostro codice.

Finché incontra istruzioni semplici, le esegue una dopo l’altra. Trovata un’istruzione composta, salta all’interno della funzione corrispondente, e continua con lo stesso metodo. Quando ha finito le istruzioni di una funzione, dobbiamo farla ritornare alla funzione chiamante, e proseguire da dove aveva interrotto. Per fare questo usiamo una speciale istruzione:

return valore; //Dove valore può essere il nome di una variabile 
// o proprio un valore (ad esempio numerico)

Quando eseguiamo l’istruzione return, il contesto della funzione viene cancellato. Ogni variabile che era stata dichiarata all’interno della funzione viene rimossa dalla memoria.

Naturalmente valore deve essere una variabile/valore dello stesso tipo di “tiporestituito”. Nel caso la funzione sia void, non è necessario fornire un valore.

simulazione variabili e funzioni
Simulazione esecuzione chiamata a funzione (vedi prossimi paragrafi)

Chiamare le funzioni

Per chiamare una sottoprocedura (o istruzione composta), basta fare come segue:

nome_funzione(); //Se non ha argomenti in ingresso
nome_funzione(arg1, arg2); //Se ha due argomenti in ingresso. Ovviamente dopo aver creato le variabili arg1, arg2.
risultato = nome_funzione(arg1, arg2); //Se ci serve sapere anche la sua risposta.

Così facendo, possiamo passare un numero arbitrario di argomenti a una funzione, eseguirla, e salvarci il suo risultato in una variabile a nostra scelta.

Per passare un array di valori come argomento di una funzione, semplicemente scrivere il suo identificatore:

int numeri[10];
mia_funzione(numeri); //Passa l'array "numeri" alla funzione "mia_funzione"

Ovviamente il lettore più attento può chiedersi se per caso non esista un modo per avere più risultati al posto di uno. La risposta è certamente positiva, e la vedremo più avanti (dovremo usare i puntatori).

Contesto di esecuzione

Quando eseguiamo una funzione, questa ha accesso solo alle variabili che contiene, alle variabili globali, e a quelle collegate agli argomenti in ingresso.

Il motivo è molto semplice: anche se ha accesso a tutta la memoria (assegnata al programma), non ha modo di sapere dove si trovano le altre, se non glielo diciamo noi!

Prendiamo questo esempio:

int array_globale[3] = {1,2,3};
//Supponiamo l'esecuzione inizi sulla prima istruzione di questa funzione
void funzione_principale(){ 
   int n = 4;
   char carattere = 'a';
   int numeri_privato[2] = {4,5};
   sottofunzione(n, numeri_privato);
}
void sottofunzione(int numero, int numeri[]){
   int variabile_locale;
   //Altro codice
   //return; si può omettere
}

Durante l’esecuzione, le istruzioni presenti in “sottofunzione” potranno accedere (leggere e modificare):

  • Alle variabili numero e numeri, poiché sono gli argomenti in ingresso, quindi fanno parte del contesto di “sottofunzione”.
  • Alla variabile variabile_locale, poiché è stata creata all’interno del contesto di “sottofunzione”
  • Alla variabile array_globale, perché si trova fuori da ogni funzione, quindi non appartiene a nessun contesto specifico (motivo per cui è accessibile da tutti).

Tuttavia non potranno accedere alla variabile carattere, poiché questa appartiene al contesto di “funzione_principale”, che ha scelto di non condividerla passandola come argomento.

Propagazione delle modifiche sulle variabili in ingresso

Quando chiamiamo una funzione, passandole dei parametri in ingresso, ci sono due possibili comportamenti:

  • Se si tratta di una variabile che non sia un array o una matrice, questa viene “passata per valore“, ovvero nella funzione che chiamiamo lavoriamo su una copia, e non ci viene dato accesso alla memoria della variabile originaria. Ogni modifica su di una variabile passata per valore, viene persa quando la funzione chiamata termina di eseguire (esegue return), poiché il suo contesto viene distrutto.
  • Se si tratta di un array o una matrice, questa viene “passato per riferimento“, ovvero ogni modifica si propaga immediatamente fuori dal contesto della funzione chiamata. La funzione chiamante, dopo l’esecuzione della funzione chiamata, potrà trovare quindi il suo array passato come parametro diverso da prima.

Quest’ultimo comportamento è dovuto al fatto che sarebbe troppo dispendioso creare copie di interi array e matrici, mentre una variabile normale non supera i 4 bytes. Per array o matrici, viene quindi sempre dato accesso alla memoria “originale”.

Esempio chiarificatore:

int array_globale[3] = {1,2,3};
void funzione_principale(){ //Supponiamo l'esecuzione inizi sulla prima istruzione di questa funzione
   //Creo delle variabili di test
   int n = 4;
   char carattere = 'a';
   int numeri_privato[2] = {4,5};
   
   //Chiamo la mia sottofunzione
   sottofunzione(n, numeri_privato);
   /*  Arrivato qui, ora le mie variabili contengono:
    *  - n contiene ancora 4 (era stato passato per valore)
    *  - carattere è rimasto 'a' (non poteva essere modificato dall'esterno)
    *  - numeri è {14,15}, poiché è stato modificato da sottofunzione
    */ 
}
void sottofunzione(int numero, int numeri[]){
   numero = 10;
   numeri[0] = 14;
   numeri[1] = 15;
   
   array_globale[0] = 5; //Questa modifica è istantanea e visibile da tutto il codice (poiché array_globale è fuori da tutti i contesti)
}

Il lettore più curioso può chiedersi se esiste un modo per modificare la variabile “numero” del contesto di “funzione_principale” direttamente da “sottofunzione”. La risposta è ovviamente affermativa, ma per farlo avremo bisogno dei puntatori (li vedremo in una guida avanzata).

Utilizzo variabili con stesso identificatore

Probabilmente il lettore attento si sta chiedendo se è possibile riutilizzare i nomi delle variabili nel codice, oppure ogni variabile deve avere un diverso identificatore.

La risposta è la seguente: dobbiamo utilizzare identificatori diversi per ogni contesto di esecuzione (con identificatori non facciamo distinzione tra nomi di variabili e di funzioni).

Attenzione alle variabili globali: poiché sono in tutti i contesti, non possiamo utilizzare variabili locali con un identificatore uguale a quello di una qualsiasi variabile globale.

Esempio:

bool mela_verde;
int numero;
void main(){
  //Posso utilizzare tutti gli identificatori che siano diversi da:
  //mela_verde, numero, main, sottofunzione
  bool mela_rossa;
}
void sottofunzione(int n, int f){
  //Posso utilizzare tutti gli identificatori che siano diversi da:
  //mela_verde, numero, n, f, main, sottofunzione
  bool mela_blu;
  bool mela_rossa; //Lecito, perché non è in questo contesto
}

Quante e quali funzioni creare?

La scelta delle funzioni è una prima forma elementare di design del codice, che affronteremo in modo seppur limitato in futuro.

Un codice corto, chiaro, pulito è all’inizio più difficile da scrivere, ma più semplice da capire, da correggere e da mantenere nel tempo. E soprattutto è anche molto più probabile che faccia al primo colpo ciò per cui è stato scritto.

La divisione in funzioni non è banale. Una regola empirica può essere: una funzione deve avere uno scopo ben definito (possiamo chiamarla blocco base) oppure servire come collegamento di più funzioni (che chiameremo blocco logico).

Premettendo che questa distinzione è puramente inventata (così come la terminologia), andiamo a vedere più nel dettaglio i due elementi:

  • Quello che abbiamo chiamato blocco base, contiene una sequenza di istruzioni semplici, che hanno uno scopo ben definito. Rappresenta un mattoncino della nostra applicazione.
  • Il blocco logico contiene per la maggior parte chiamate di funzione e istruzioni per modificare il flusso di esecuzione (che vedremo più avanti). Ha la funzione fondamentale di orchestrare i blocchi base per assicurare che l’applicazione svolga il suo scopo.

Andremo a vedere degli esempi dettagliati appena avremo introdotto anche le istruzioni per il controllo di flusso.

Prima di continuare, ed esplorare gran parte delle istruzioni e funzioni “di sistema” di Arduino (come “return”), ovvero quelle che non dobbiamo implementare noi, ma ci vengono già date, dobbiamo spostarci nell’Ambiente di Sviluppo.