Introduzione alle Funzioni in C++

Una funzione è un blocco di codice con un nome. Eseguiamo il codice chiamando la funzione. Una funzione può accettare in ingresso zero o più valori, chiamati argomenti, e può restituire un valore come risultato. Le funzioni ci aiutano a organizzare i programmi in blocchi logici, a evitare la ripetizione di codice e a facilitare il debug e la manutenzione del codice.

Concetti Chiave
  • Le funzioni sono fondamentali per la programmazione in C++. Ci permettono di organizzare il codice in moduli riutilizzabili e di migliorare la leggibilità.
  • Una funzione è definita da un tipo di ritorno, un nome, una lista di parametri e un corpo.
  • Le funzioni possono essere chiamate con argomenti che inizializzano i parametri della funzione.
  • Il tipo di ritorno di una funzione specifica il tipo di valore che la funzione restituisce. Una funzione può anche avere un tipo di ritorno void, indicando che non restituisce alcun valore.

Fondamenti sulle Funzioni

Una definizione di funzione consiste tipicamente in un tipo di ritorno, un nome, una lista di zero o più parametri, e un corpo. I parametri sono specificati in una lista separata da virgole racchiusa tra parentesi. Le azioni che la funzione esegue sono specificate in un blocco di istruzioni, chiamato corpo della funzione:

tipo_di_ritorno nome_funzione(parametro1_tipo parametro1_nome, parametro2_tipo parametro2_nome, ...)
{
    // corpo della funzione
}

Eseguiamo una funzione attraverso l'operatore di chiamata, che è una coppia di parentesi. L'operatore di chiamata prende un'espressione che è una funzione o punta a una funzione. All'interno delle parentesi c'è una lista separata da virgole di argomenti. Gli argomenti vengono usati per inizializzare i parametri della funzione. Il tipo di un'espressione di chiamata è il tipo di ritorno della funzione.

Scrivere una Funzione

Come esempio, scriveremo una funzione per determinare il fattoriale di un numero dato. Il fattoriale di un numero n è il prodotto dei numeri da 1 a n. Il fattoriale di 5, ad esempio, è 120:

5! = 5 \cdot 4 \cdot 3 \cdot 2 \cdot 1 = 120

Potremmo definire questa funzione come segue:

int fattoriale(int val)
{
    // variabile locale per contenere il risultato mentre lo calcoliamo
    int ris = 1;

    while (val > 1)
        ris *= val--; // assegna (ris * val) a ris e decrementa val

    return ris; // restituisce il risultato
}

La nostra funzione si chiama fattoriale. Prende un parametro int e restituisce un valore int. All'interno del ciclo while, calcoliamo il fattoriale usando l'operatore di decremento postfisso per ridurre il valore di val di 1 ad ogni iterazione. L'istruzione return termina l'esecuzione di fattoriale e restituisce il valore di ris.

Chiamare una Funzione

Per chiamare fattoriale, dobbiamo fornire un valore int. Il risultato della chiamata è anche un int:

int main()
{
    // j è uguale a 120, cioè il risultato di fattoriale(5)
    int j = fattoriale(5);
    cout << "5! è " << j << endl;
    return 0;
}

Una chiamata di funzione fa due cose: Inizializza i parametri della funzione dagli argomenti corrispondenti, e trasferisce il controllo a quella funzione. L'esecuzione della funzione chiamante viene sospesa e l'esecuzione della funzione chiamata inizia.

L'esecuzione di una funzione inizia con la definizione (implicita) e l'inizializzazione dei suoi parametri. Quindi, quando chiamiamo fattoriale, la prima cosa che accade è che una variabile int chiamata val viene creata. Questa variabile viene inizializzata dall'argomento nella chiamata a fattoriale, che in questo caso è 5.

L'esecuzione di una funzione termina quando viene incontrata un'istruzione return. Come una chiamata di funzione, l'istruzione return fa due cose: Restituisce il valore (se presente) nel return, e trasferisce il controllo dalla funzione chiamata alla funzione chiamante. Il valore restituito dalla funzione viene usato per inizializzare il risultato dell'espressione di chiamata. L'esecuzione continua con qualunque cosa rimanga dell'espressione in cui è apparsa la chiamata. Quindi, la nostra chiamata a fattoriale è equivalente al seguente:

int val = 5; // inizializza val dal letterale 5
int ris = 1; // codice dal corpo di fattoriale
while (val > 1)
    ris *= val--;
int j = ris; // inizializza j come una copia di ris

Parametri e Argomenti

Gli argomenti sono gli inizializzatori per i parametri di una funzione. Il primo argomento inizializza il primo parametro, il secondo argomento inizializza il secondo parametro, e così via. Anche se sappiamo quale argomento inizializza quale parametro, non abbiamo garanzie sull'ordine in cui gli argomenti vengono valutati. Il compilatore è libero di valutare gli argomenti nell'ordine che preferisce.

Il tipo di ogni argomento deve corrispondere al parametro corrispondente nello stesso modo in cui il tipo di qualsiasi inizializzatore deve corrispondere al tipo dell'oggetto che inizializza. Dobbiamo passare esattamente lo stesso numero di argomenti che la funzione ha di parametri. Poiché ogni chiamata è garantita passare tanti argomenti quanti sono i parametri della funzione, i parametri vengono sempre inizializzati.

Poiché fattoriale ha un singolo parametro di tipo int, ogni volta che lo chiamiamo dobbiamo fornire un singolo argomento che può essere convertito a int:

fattoriale("ciao");         // ERRORE: tipo di argomento sbagliato
fattoriale();               // ERRORE: troppo pochi argomenti
fattoriale(42, 10, 0);      // ERRORE: troppi argomenti
fattoriale(3.14);           // OK: l'argomento viene convertito in int

La prima chiamata fallisce perché non c'è conversione da const char * a int. La seconda e la terza chiamata passano il numero sbagliato di argomenti. La funzione fattoriale deve essere chiamata con un argomento; è un errore chiamarla con qualsiasi altro numero. L'ultima chiamata è legale perché c'è una conversione da double a int. In questa chiamata, l'argomento viene implicitamente convertito a int (attraverso il troncamento). Dopo la conversione, questa chiamata è equivalente a:

fattoriale(3);

Lista dei Parametri di una Funzione

La lista dei parametri di una funzione può essere vuota ma non può essere omessa. Tipicamente definiamo una funzione senza parametri scrivendo una lista di parametri vuota. Per compatibilità con il linguaggio C, possiamo anche usare la parola chiave void per indicare che non ci sono parametri:

void f1(){ /* ... */ } // lista di parametri void implicita
void f2(void){ /* ... */ } // lista di parametri void esplicita

Una lista di parametri consiste tipicamente in una lista separata da virgole di parametri, ognuno dei quali assomiglia a una dichiarazione con un singolo dichiaratore. Anche quando i tipi di due parametri sono gli stessi, il tipo deve essere ripetuto:

int f3(int v1, v2) { /* ... */ } // errore
int f4(int v1, int v2) { /* ... */ } // ok

Nessun due parametri possono avere lo stesso nome. Inoltre, le variabili locali nello scope più esterno della funzione non possono usare lo stesso nome di alcun parametro.

I nomi dei parametri sono opzionali. Tuttavia, non c'è modo di usare un parametro senza nome. Pertanto, i parametri hanno normalmente dei nomi. Occasionalmente una funzione ha un parametro che non viene usato. Tali parametri sono spesso lasciati senza nome, per indicare che non vengono usati. Lasciare un parametro senza nome non cambia il numero di argomenti che una chiamata deve fornire. Una chiamata deve fornire un argomento per ogni parametro, anche se quel parametro non viene usato.

Tipo di Ritorno di una Funzione

La maggior parte dei tipi può essere usata come tipo di ritorno di una funzione. In particolare, il tipo di ritorno può essere void, il che significa che la funzione non restituisce un valore. Tuttavia, il tipo di ritorno non può essere un tipo array o un tipo funzione. Inoltre, una funzione può restituire un puntatore a un array o una funzione. Vedremo come definire funzioni che restituiscono puntatori (o riferimenti) ad array in seguito e come restituire puntatori a funzioni in seguito.

Esercizi

  • Qual è la differenza tra un parametro e un argomento?

    Risposta: Un parametro è una variabile definita nella dichiarazione di una funzione che riceve il valore passato alla funzione. Un argomento è il valore effettivo passato alla funzione quando viene chiamata.

  • Indica in quali delle seguenti porzioni di codice c'è un errore e perché. Suggerisci come potresti correggere i problemi.

    • Codice 1:

      int f() {
          string s;
          // ...
          return s;
      }
      

      Risposta: C'è un errore perché la funzione f è dichiarata per restituire un int, ma sta tentando di restituire una string. Per correggere questo, dovresti cambiare il tipo di ritorno della funzione a string:

      string f() {
          string s;
          // ...
          return s;
      }
      
    • Codice 2:

      f2(int i) { /* ... */ }
      

      Risposta: C'è un errore perché manca il tipo di ritorno della funzione. Dovresti specificare un tipo di ritorno, ad esempio void se la funzione non restituisce nulla:

      void f2(int i) { /* ... */ }
      
    • Codice 3:

      int calc(int v1, int v1) { /* ... */ }
      

      Risposta: C'è un errore perché non puoi avere due parametri con lo stesso nome. Dovresti rinominare uno dei parametri:

      int calc(int v1, int v2) { /* ... */ }
      
    • Codice 4:

      double quadrato(double x) return x * x;
      

      Risposta: C'è un errore di sintassi perché manca il corpo della funzione racchiuso tra parentesi graffe. Dovresti aggiungere le parentesi graffe:

      double quadrato(double x) { return x * x; }
      
  • Scrivi una funzione che interagisce con l'utente, chiedendo un numero e generando il fattoriale di quel numero. Chiama questa funzione da main.

    Risposta:

    #include <iostream>
    using namespace std;
    
    int fattoriale() {
        int val;
        cout << "Inserisci un numero: ";
        cin >> val;
    
        int ris = 1;
        while (val > 1)
            ris *= val--;
        return ris;
    }
    
    int main() {
        int risultato = fattoriale();
        cout << "Il fattoriale è: " << risultato << endl;
        return 0;
    }
    
  • Scrivi una funzione per restituire il valore assoluto del suo argomento.

    Risposta:

    int valoreAssoluto(int x) {
        return (x < 0) ? -x : x;
    }
    

Oggetti Locali

In C++, i nomi hanno un ambito o scope, e gli oggetti hanno durate di vita. È importante comprendere entrambi questi concetti.

  • Lo scope di un nome è la parte del testo del programma in cui quel nome è visibile.
  • La durata di vita di un oggetto è il tempo durante l'esecuzione del programma in cui l'oggetto esiste.

Come abbiamo visto, il corpo di una funzione è un blocco di istruzioni. Come al solito, il blocco forma un nuovo scope in cui possiamo definire variabili. I parametri e le variabili definite all'interno del corpo di una funzione sono chiamate variabili locali. Sono "locali" a quella funzione e nascondono dichiarazioni dello stesso nome fatte in uno scope esterno.

Gli oggetti definiti al di fuori di qualsiasi funzione esistono durante l'esecuzione del programma. Tali oggetti vengono creati quando il programma inizia e non vengono distrutti fino a quando il programma termina. La durata di vita di una variabile locale dipende da come è definita.

Oggetti Automatici

Gli oggetti che corrispondono alle ordinarie variabili locali vengono creati quando il percorso di controllo della funzione passa attraverso la definizione della variabile. Vengono distrutti quando il controllo passa attraverso la fine del blocco in cui la variabile è definita. Gli oggetti che esistono solo mentre un blocco è in esecuzione sono noti come oggetti automatici. Dopo che l'esecuzione esce da un blocco, i valori degli oggetti automatici creati in quel blocco sono indefiniti.

I parametri sono oggetti automatici. Lo storage per i parametri viene allocato quando la funzione inizia. I parametri sono definiti nello scope del corpo della funzione. Quindi vengono distrutti quando la funzione termina.

Gli oggetti automatici corrispondenti ai parametri della funzione vengono inizializzati dagli argomenti passati alla funzione. Gli oggetti automatici corrispondenti alle variabili locali vengono inizializzati se la loro definizione contiene un inizializzatore. Altrimenti, vengono inizializzati per default, il che significa che le variabili locali non inizializzate di tipo built-in hanno valori indefiniti.

Oggetti static Locali

Può essere utile avere una variabile locale la cui durata di vita continua attraverso le chiamate alla funzione. Otteniamo tali oggetti definendo una variabile locale come static. Ogni oggetto static locale viene inizializzato prima della prima volta che l'esecuzione passa attraverso la definizione dell'oggetto. Gli static locali non vengono distrutti quando una funzione termina; vengono distrutti quando il programma termina.

Come esempio banale, ecco una funzione che conta quante volte viene chiamata:

size_t conta_chiamate()
{
    static size_t cont = 0; // il valore persisterà tra le chiamate
    return ++cont;
}

int main()
{
    for (size_t i = 0; i != 10; ++i)
        cout << conta_chiamate() << endl;
    return 0;
}

Questo programma stamperà i numeri da 1 a 10 inclusi.

Prima che il controllo fluisca attraverso la definizione di cont per la prima volta, cont viene creato e gli viene dato un valore iniziale di 0. Ogni chiamata incrementa cont e restituisce il suo nuovo valore. Ogni volta che conta_chiamate viene eseguita, la variabile cont esiste già e ha qualunque valore fosse in quella variabile l'ultima volta che la funzione è uscita. Quindi, alla seconda invocazione, il valore di cont è 1, alla terza è 2, e così via.

Se uno static locale non ha un inizializzatore esplicito, viene inizializzato per valore, il che significa che gli static locali di tipo built-in vengono inizializzati a zero.

Esercizio

  • Scrivi una funzione che restituisce 0 quando viene chiamata per la prima volta e poi genera numeri in sequenza ogni volta che viene chiamata di nuovo.

    Risposta:

    int generaSequenza() {
        static int numero = 0; // Inizializzato a 0 solo la prima volta
        return numero++;
    }
    

Dichiarazioni di Funzioni

Come qualsiasi altro nome, il nome di una funzione deve essere dichiarato prima di poterlo usare. Come con le variabili, una funzione può essere definita solo una volta ma può essere dichiarata più volte. Con un'eccezione che tratteremo in seguito, possiamo dichiarare una funzione che non è definita purché non usiamo mai quella funzione.

Una dichiarazione di funzione è proprio come una definizione di funzione eccetto che una dichiarazione non ha corpo della funzione. In una dichiarazione, un punto e virgola sostituisce il corpo della funzione.

Poiché una dichiarazione di funzione non ha corpo, non c'è bisogno di nomi di parametri. Quindi, i nomi dei parametri sono spesso omessi in una dichiarazione. Anche se i nomi dei parametri non sono richiesti, possono essere usati per aiutare gli utenti della funzione a capire cosa fa la funzione:

// nomi dei parametri scelti per indicare
// che gli iteratori denotano un intervallo di valori da stampare
void stampa(vector<int>::const_iterator inizio,
            vector<int>::const_iterator fine);

Questi tre elementi, ossia il tipo di ritorno, il nome della funzione e i tipi dei parametri, descrivono l'interfaccia della funzione. Specificano tutte le informazioni di cui abbiamo bisogno per chiamare la funzione. Le dichiarazioni di funzioni sono anche conosciute come prototipo di funzione.

I Prototipi di Funzioni Vanno nei File Header

Ricorda che le variabili sono dichiarate nei file header e definite nei file sorgente. Per le stesse ragioni, le funzioni dovrebbero essere dichiarate nei file header e definite nei file sorgente.

Potrebbe essere allettante, e sarebbe anche legale, mettere una dichiarazione di funzione direttamente in ogni file sorgente che usa la funzione. Tuttavia, farlo è tedioso e soggetto a errori. Quando usiamo file header per le nostre dichiarazioni di funzioni, possiamo assicurare che tutte le dichiarazioni per una data funzione concordino. Inoltre, se l'interfaccia alla funzione cambia, solo una dichiarazione deve essere cambiata.

Il file sorgente che definisce una funzione dovrebbe includere l'header che contiene la dichiarazione di quella funzione. In questo modo il compilatore verificherà che la definizione e la dichiarazione siano consistenti.

L'header che dichiara una funzione dovrebbe essere incluso nel file sorgente che definisce quella funzione.

Compilazione Separata

Man mano che i nostri programmi diventano più complicati, vorremo memorizzare le varie parti del programma in file separati. Ad esempio, potremmo memorizzare le funzioni che abbiamo scritto per gli esercizi in un file e memorizzare il codice che usa queste funzioni in altri file sorgente. Per permettere ai programmi di essere scritti in parti logiche, C++ supporta quello che è comunemente conosciuto come compilazione separata. La compilazione separata ci permette di dividere i nostri programmi in diversi file, ognuno dei quali può essere compilato indipendentemente.

Compilare e Collegare File Sorgente Multipli

Come esempio, supponiamo che la definizione della nostra funzione fattoriale sia in un file chiamato fattoriale.cpp e la sua dichiarazione sia in un file header chiamato fattoriale.h. Il nostro file fattoriale.cpp, come qualsiasi file che usa queste funzioni, includerà l'header fattoriale.h. Memorizzeremo una funzione main che chiama fattoriale in un secondo file chiamato fattorialeMain.cpp.

Per produrre un file eseguibile, dobbiamo dire al compilatore dove trovare tutto il codice che usiamo. Potremmo compilare questi file come segue:

$ gcc fattorialeMain.cpp fattoriale.cpp # genera fattorialeMain.exe o a.out
$ gcc fattorialeMain.cpp fattoriale.cpp -o main # genera main o main.exe

Qui gcc è il compilatore GNU GCC, $ è il prompt del nostro sistema, e # inizia un commento da riga di comando. Ora possiamo eseguire il file eseguibile, che eseguirà la nostra funzione main.

Se abbiamo modificato solo uno dei nostri file sorgente, vorremmo ricompilare solo il file che è effettivamente cambiato. La maggior parte dei compilatori fornisce un modo per compilare separatamente ogni file. Questo processo solitamente produce un file con l'estensione .obj (sotto Windows) o .o (UNIX), indicando che il file contiene codice oggetto.

Il compilatore ci permette di collegare file oggetto insieme per formare un eseguibile. Sul sistema che usiamo, compileremmo separatamente il nostro programma come segue:

$ gcc -c fattorialeMain.cpp # genera fattorialeMain.o
$ gcc -c fattoriale.cpp # genera fattoriale.o
$ gcc fattorialeMain.o fattoriale.o # genera fattorialeMain.exe o a.out
$ gcc fattorialeMain.o fattoriale.o -o main # genera main o main.exe