Definire funzioni in linguaggio C

In questa lezione daremo uno sguardo alla sintassi per definire le funzioni in linguaggio C. Esamineremo inoltre come invocare le funzioni e ottenere dei risultati da esse. Infine, vedremo alcuni degli errori più comuni che gli sviluppatori dovrebbero evitare.

In dettaglio vedremo:

  1. Come dichiarare una funzione in C
  2. Come scrivere il corpo di una funzione in C
  3. Come invocare una funzione in C
  4. Come restituire un valore da una funzione in C

Definizione di una funzione personalizzata

Il primo passaggio nella creazione di una funzione personalizzata in linguaggio C consiste nella sua definizione.

La definizione di una funzione in C è composta di due parti:

  • L'intestazione della funzione: definisce il nome della funzione, i suoi parametri ed il tipo del valore che la funzione restituisce.
  • Il corpo della funzione contiene le istruzioni che devono essere eseguite quando una funzione è invocata.

Inoltre, il corpo della funzione deve seguire immediatamente la sua intestazione.

Proviamo a chiarire meglio i concetti con un esempio. Vogliamo creare una funzione che stampi a schermo una sequenza di 10 asterischi. Chiameremo questa funzione stampa_asterischi. Osserviamo il programma che segue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>

void stampa_asterischi(void)
{
    for (int i = 0; i < 10; ++i) {
        printf("*");
    }
    printf("\n");
}

int main() {
    stampa_asterischi();
    printf("Ciao, come si va?\n");
    stampa_asterischi();
    return 0;
}

Compilando ed eseguendo il programma di sopra otterremo il seguente risultato:

**********
Ciao, come si va?
**********

Esaminiamo adesso il codice. Per prima cosa notiamo che la nostra funzione stampa_asterischi viene invocata due volte all'interno del main, alle righe 12 e 14. Se non avessimo definito la funzione, avremmo dovuto replicare il codice della funzione stessa due volte. Quindi il meccanismo delle funzioni ci permette di evitare di replicare il codice con tutti i benefici che ne conseguono:

  1. Il codice risulta più modulare
  2. La quantità di codice da scrivere si riduce
  3. I programmi sono più semplici da testare.

La definizione della funzione stampa_asterischi è suddivisa in due parti:

  • Riga 3: è l'intestazione della funzione. Ne definisce il nome, i parametri e il tipo di ritorno della funzione.
  • Righe 4-9: definisce il corpo della funzione tra parentesi graffe.

In generale, per definire una funzione bisogna specificare il tipo di ritorno prima del suo nome e i parametri tra parentesi tonde separati da virgola in questo modo:

Tipo_Ritorno Nome_Funzione(Tipo_A Nome_Parametro_A, Tipo_B Nome_Parametro B)

Tuttavia la funzione stampa_asterischi definita sopra non restituisce alcun valore per cui il suo nome è anteceduto da void. Inoltre non ha bisogno di parametri per cui tra parentesi tonde abbiamo inserito void.

Per quanto riguarda il corpo della funzione esso contiene tutte le istruzioni che la funzione esegue tra parentesi graffe. Nel nostro caso il corpo di stampa_asterischi è definito alle righe 4-9.

Definizione

Definizione di una funzione in C

La sintassi per definire una funzione in C è la seguente:

Tipo_Ritorno Nome_Funzione(Tipo_A Nome_Parametro_A, Tipo_B Nome_Parametro B)
{
    /* Corpo della funzione */
}

La sintassi può essere scomposta in due parti:

  • L'intestazione della funzione:

    c Tipo_Ritorno Nome_Funzione(Tipo_A Nome_Parametro_A, Tipo_B Nome_Parametro B)

  • Il corpo della funzione:

    deve seguire immediatamente l'intestazione ed è racchiuso tra due parentesi graffe {}.

L'intestazione di una funzione, a sua volta, è composta da:

  • Tipo_Ritorno: è il tipo di ritorno della funzione. Se la funzione non restituisce alcun valore si usa void.
  • Nome_Funzione: è il nome della funzione.
  • Tipo_A Nome_Parametro_A, Tipo_B Nome_Parametro B: la lista dei parametri di ingresso della funzione.

Un punto importante da sottolineare è che i compilatori C sono compilatori a Passo Singolo. Cosa vuol dire questo? Significa che il compilatore quando analizza il file sorgente parte dalla prima riga e, in sequenza, analizza le righe successive sino alla fine ma non torna mai indietro. La conseguenza è che qualunque identificatore che il compilatore trova deve essere stato necessariamente definito prima.

Per le funzioni in C questo si traduce nel fatto che, quando il compilatore C trova le invocazioni alla funzione stampa_asterischi alle righe 12 e poi 14 si aspetta che la funzione sia stata già definita. Se avessimo invertito funzione main e funzione stampa_asterischi avremmo ottenuto un warning di compilazione oppure un errore di compilazione. Proviamo ad esaminare il listato che segue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
/* ATTENZIONE: Questo codice usa una funzione prima di averla definita */
#include <stdio.h>

int main() {
    /*
     * Il compilatore C non ha ancora trovato
     * la definizione di stampa_asterischi
     */
    stampa_asterischi();
    printf("Ciao, come si va?\n");
    stampa_asterischi();
    return 0;
}

void stampa_asterischi(void)
{
    for (int i = 0; i < 10; ++i) {
        printf("*");
    }
    printf("\n");
}

In questo esempio, le invocazioni di stampa_asterischi alle righe 9 e 11 potrebbero produrre un errore di compilazione o un warning (avvertimento). Ad esempio, il compilatore gcc produce il seguente output:

test.c: In function ‘main’:
test.c:9:5: warning: implicit declaration of function ‘stampa_asterischi’ [-Wimplicit-function-declaration]
    9 |     stampa_asterischi();
      |     ^~~~~~~~~~~~~~~~~

In pratica, gcc ci sta avvertendo che la funzione stampa_asterischi di cui ha trovato l'invocazione alla riga 9 è stata dichiarata implicitamente. Il compilatore, in questo caso, non fornisce un errore ma un avvertimento o warning. Altri compilatori potrebbero, invece, terminare la compilazione con un errore. Per questo motivo, bisogna sempre definire una funzione prima del suo utilizzo.

Per poter definire una funzione dopo il punto in cui viene utilizzata in C è possibile utilizzare i prototipi di funzioni che vedremo più avanti.

In generale:

Definizione

Un compilatore C è un Compilatore a Passo Singolo

Ogni compilatore C è un compilatore a Passo Singolo. Ciò significa che in fase di compilazione i file sorgenti vengono analizzati una volta sola dalla prima riga fino all'ultima.

Se in una riga vi è un riferimento ad un identificatore che non è stato definito ancora, in quanto la sua definizione si trova ad una riga successiva, si ottiene un warning oppure un errore di compilazione.

Ne consegue che ogni invocazione a funzione deve essere successiva alla definizione della funzione stessa o del suo prototipo.

Parametri di una funzione

Nel programma di esempio visto sopra abbiamo definito una funzione, stampa_asterischi, che stampa a video 10 asterischi. Proviamo a modificare il programma in maniera tale da poter stampare, a seconda dei casi, un numero di asterischi differente.

Un modo semplice di affrontare questo problema potrebbe essere quello di definire varie funzioni. Ad esempio potremmo definire:

  • stampa_10_asterischi per stampare 10 asterischi a schermo
  • stampa_20_asterischi per stampare 20 asterischi

e così via...

Questa soluzione, però, non è affatto ottimale. Il numero di funzioni da dover definire potrebbe moltiplicarsi a dismisura. Ne conseguirebbe che dovremmo replicare il codice di stampa_asterischi tante volte cambiando soltanto il numero di volte per cui il ciclo for interno debba ripetersi.

Come possiamo, quindi, indicare ad una funzione un valore che ne modifichi il comportamento a seconda dei casi? Attraverso l'utilizzo dei parametri.

I parametri di una funzione forniscono il mezzo attraverso il quale è possibile passare delle informazioni alla funzione stessa. In generale un parametro è un segnaposto per gli argomenti che il chiamante passa alla funzione stessa:

Definizione

Parametro di una funzione

Un paramtero di una funzione è una variabile segnaposto che riceve al proprio interno un valore passato dal chiamante della funzione. Tale variabile può essere adoperata all'interno del corpo della funzione stessa.

Definizione

Argomento di una funzione

L'argomento di una funzione è il valore effettivo che il chiamante della funzione passa a quest'ultima all'interno di uno dei suoi parametri.

Per specificare i parametri di una funzione è necessario indicare la lista di parametri tra parentesi e separati da virgole. Nel nostro caso è necessario un unico parametro che indichi quante volte il carattere asterisco deve essere stampato:

void stampa_n_asterischi(int n)

Abbiamo chiamato il parametro n e con esso il chiamante può indicare il numero di asterischi da stampare. Di seguito l'esempio completo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>

void stampa_n_asterischi(int n)
{
    for (int i = 0; i < n; ++i) {
        printf("*");
    }
    printf("\n");
}

int main() {
    stampa_n_asterischi(10);
    printf("Ciao, come si va?\n");
    stampa_n_asterischi(20);
    return 0;
}

Se proviamo a compilare ed eseguire questo programma, otteniamo il seguente output:

**********
Ciao, come si va?
********************

In questo programma, la nuova funzione stampa_n_asterischi accetta un parametro n che è un int e che indica il numero di volte che la funzione deve stampara a schermo il carattere asterisco. Tale parametro è locale alla funzione, ossia il parametro n può essere utilizzato esclusivamente all'interno del corpo della funzione.

Il valore del parametro n cambia ad ogni invocazione a seconda dell'argomento con cui la funzione è invocata. Infatti, la funzione stampa_n_asterischi viene invocata due volte:

  • Alla riga 12 con argomento 10
  • Alla riga 14 con argomento 20

Nel primo caso n varrà 10 all'interno del corpo della funzione mentre nel secondo caso varrà 20.

Possiamo estendere ulteriormente il nostro esempio. Mettiamo il caso che vogliamo modificare la funzione in maniera tale che gli possiamo indicare, oltre al numero di volte, anche il carattere da stampare.

Possiamo definire una nuova funzione: stampa_n_caratteri

void stampa_n_caratteri(char c, int n)

In questo caso abbiamo specificato due parametri della funzione separati da una virgola:

  • char c indica il carattere da stampare
  • int n indica il numero di volte

Per cui possiamo riscrivere l'esempio in questo modo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>

void stampa_n_caratteri(char c, int n)
{
    for (int i = 0; i < n; ++i) {
        printf(c);
    }
    printf("\n");
}

int main() {
    stampa_n_caratteri('*', 10);
    printf("Ciao, come si va?\n");
    stampa_n_caratteri('-', 20);
    return 0;
}

Se proviamo a compilare ed eseguire questo programma, otteniamo il seguente output:

**********
Ciao, come si va?
--------------------

Funzioni che restituiscono un valore

Una funzione può anche restituire un valore, ossia fornire in uscita un risultato di una elaborazione.

Per specificare che una funzione restituisce un valore bisogna specificare il tipo di tale valore nella definizione della funzione stessa. Spesso nel gergo tecnico, quando una funzione restituisce un valore di un certo tipo "A" si suole dire che la funzione è di tipo "A".

Proviamo con un esempio: definiamo una funzione cubo che restituisce il cubo di un numero intero, ossia il valore del numero elevato al cubo. La funzione cubo sarà una funzione di tipo int:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>

int cubo(int n) {
    return n * n * n;
}

int main() {
    int v = 0;
    printf("Inserisci un numero intero: ");
    scanf("%d", &v);
    printf("Il cubo di %d è %d\n", v, cubo(v));
    return 0;
}

La prima cosa da notare in questo esempio è il modo in cui abbiamo definito la funzione, in particolare la sua intestazione:

int cubo(int n)

In particolare, abbiamo inserito prima del nome cubo il tipo int del valore restituito. Stiamo dicendo al compilatore che questa funzione restituisce un intero. Qualunque tipo valido del C può essere utilizzato come tipo di ritorno. Nel caso in cui, invece, la funzione non restituisce alcun valore (si tratta quindi di una procedura) possiamo usare void come abbiamo fatto negli esempi precedenti.

Quando una funzione restituisce un valore, essa può essere utilizzata all'interno di qualunque espressione che ammette quel tipo. Ad esempio, avremmo potuto utilizzare la funzione cubo definita sopra all'interno di un'espressione del tipo:

int x = 5 + cubo(6);

In fase di esecuzione, il programma cederà dapprima il controllo alla funzione cubo e, successivamente, prenderà il risultato di cubo(6) per poi sostituirlo nell'espressione. Alla fine la variabile x conterrà il valore 221.

Parola chiave return

Quando una funzione restituisce un valore bisogna utilizzare un meccanismo per indicare al compilatore quale sia questo valore. In C si utilizza la parola chiave return. Ritornando all'esempio di sopra osserviamo la riga 4:

return n * n * n;

Qui abbiamo usato la parola chiave del C return per indicare al compilatore quale sia il valore che la nostra funzione deve restituire. In particolare return deve essere seguito da un'espressione valida del C. Nel nostro caso è seguita dall'espressione n * n * n che calcola il valore di n al cubo.

Definizione

Parola chiave return

La parola chiave return è usata all'interno del corpo di una funzione per specificare il valore di ritorno della funzione stessa.

Essa deve essere seguita da un'espressione che ha lo stesso tipo di quello restituito dalla funzione:

return espressione;

Exit Code

Come abbiamo avuto modo di osservare, in tutti gli esempi mostrati finora anche la funzione main ha un valore di ritorno.

Infatti, la maggior parte dei programmi C ha una funzione main realizzata in questo modo:

int main() {
    /* Codice */
    return 0;
}

Alla luce delle nuove informazioni viste in questa lezione sappiamo quindi che main è una funzione che restituisce un intero, ma nella maggior parte degli esempi visti finora questo intero è sempre pari a zero: 0. Cosa rappresenta questo valore?

Il valore restituito dalla funzione main prende il nome di Exit Status, in italiano: Codice di uscita.

Questo codice di uscita viene adoperato in tutti i sistemi operativi, Linux, Windows e MacOs X, per indicare genericamente se il programma si è eseguito correttamente oppure ha avuto problemi durante la sua esecuzione. In particolare, un codice di uscita pari a 0 indica che il programma si è chiuso correttamente. Viceversa, un qualunque valore diverso da 0 indica che durante l'esecuzione si è verificata un'anomalia.

Un codice diverso da 0 rappresenta un'anomalia, ma il significato di ogni codice varia da programma a programma ed è a carico dello sviluppatore.

Lo standard del C definisce, in particolare, due valori nel file header stdlib.h:

  • EXIT_SUCCESS: che vale tipicamente 0 e indica successo.
  • EXIT_FAILURE: che vale tipicamente 1 e indica un generico fallimento.

Per cui, possiamo riscrivere i nostri programmi in questo modo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>
#include <stdlib.h>

int main() {
    /* Codice */

    if ( /* errore */ ) {
        return EXIT_FAILURE;
    }
    else {
        return EXIT_SUCCESS;
    }
}
Definizione

Exit Code o Codice di Uscita

L'Exit Code è un valore restituito dalla funzione main che si utilizza per indicare al sistema operativo se l'esecuzione del programma è andata a buon fine oppure si è verificata un'anomalia.

Un Exit Code pari a 0 indica Successo.

Un Exit Code diverso da 0 indica un Fallimento e il suo significato specifico è a discrezione dello sviluppatore.

Assenza di return

Quando definiamo una funzione che restituisce un valore ma non c'è nessuna espressione return tipicamente il compilatore ci avverte con un warning (un avvertimento). Questo perché il comportamento non è definito.

Prendiamo, ad esempio, il programma che segue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>

int quadrato(int n) {
    /* ATTENZIONE: Nessuna espressione return */
}

int main() {
    printf("Il quadrato di 6 è: %d\n", quadrato(6));
    return 0;
}

In questo programma abbiamo definito una funzione quadrato che dovrebbe restituire un int ma che non ha nel suo corpo nessuna espressione return. Quando andiamo, successivamente, a richiamare la funzione alla riga 8 non possiamo sapere in anticipo che cosa può accadere. In quel punto, infatti, il programma si aspetta un valore di ritorno che non esiste, per cui il comportamento è impredicibile.

Provando a compilare questo programma con GCC non otteniamo nessun errore, a meno che non usiamo il flag di compilazione -Wall che indica al compilatore di segnalare qualunque warning. In tal caso, infatti, otteniamo un output simile al seguente:

$ gcc -Wall test.c -o test
test.c: In function ‘quadrato’:
test.c:5:1: warning: control reaches end of non-void function [-Wreturn-type]
    5 | }
      | ^

Il compilatore GCC, infatti, ci segnala l'avvertimento: "control reaches end of non-void function", ossia "il controllo raggiunge la fine di una funzione non void".

Possiamo anche forzare il compilatore a segnalare errore in tal caso usando il flag di compilazione -Werror che trasforma qualsiasi warning in un errore:

$ gcc -Wall -Werror test.c -o test
test.c: In function ‘quadrato’:
test.c:5:1: error: control reaches end of non-void function [-Werror=return-type]
    5 | }
      | ^
cc1: all warnings being treated as errors

Dato che il comportamento non è predicibile in queste situazione è sempre buona prassi quella di assicurarsi che le funzioni non void restituiscano un valore con un'espressione return:

Definizione

Ogni funzione non void deve sempre avere un'espressione return

É buona norma di programmazione inserire un'espressione return all'interno di funzioni non void.

Funzioni void e return

Una funzione void, ossia una funzione che non restituisce alcun valore (e quindi una procedura), non deve avere al proprio interno un'espressione return.

Tuttavia, vi possono essere alcuni casi in cui può risultare utile uscire prima dalla funzione stessa. In tal caso si può usare un'espressione return vuota ossia non seguita da nessuna espressione, in questo modo:

return;

Proviamo a chiarire con un esempio. Supponiamo di voler implementare una funzione che prenda in ingresso un numero intero. Se questo numero è positivo o uguale a 0 la funzione stampa a schermo una riga di 10 asterischi *. Se il numero è negativo, oltre agli asterischi la funzione deve stampare una riga di 10 chiocciole @.

Una prima implementazione potrebbe essere:

1
2
3
4
5
6
7
8
9
void mia_funzione(int n) {
    if (n >= 0) {
        printf("**********\n");
    }
    else {
        printf("**********\n");
        printf("@@@@@@@@@@\n");
    }
}

Potremmo riscrivere la funzione di sopra in questo modo:

1
2
3
4
5
6
7
void mia_funzione(int n) {
    printf("**********\n");
    if (n >= 0) {
        return;
    }
    printf("@@@@@@@@@@\n");
}

Anche se non è propriamente elegante, in questo esempio abbiamo usato un'espressione return vuota per uscire prima dalla nostra funzione in caso di numero positivo o uguale a 0. In questo modo l'istruzione alla riga 6 viene eseguita solo in caso di numero negativo.

Definizione

Espressioni return vuote

Un'espressione return vuota può essere usata esclusivamente in una funzione void per uscire prima dal corpo della funzione:

return;

In Sintesi

Questa lezione rappresenta una panoramica di come definire le funzioni in linguaggio C. Abbiamo visto come definire una funzione, come passare dei parametri e come restituire un valore. Abbiamo anche discusso di come usare la parola chiave return e cosa rappresenta l'Exit Code di una funzione.

Inoltre abbiamo introdotto la differenza principale tra parametro e argomento di una funzione.

Nelle prossime lezioni esamineremo i concetti visti in questa lezione in dettaglio, esaminandone i casi particolari.

Partiremo proprio dal Meccanismo di Invocazione di una funzione.