Funzioni

Introduzione alle Funzioni in C++

In C++, una funzione è un blocco di codice che esegue un compito specifico. Le funzioni sono una componente essenziale di qualsiasi programma, perché permettono di organizzare il codice in modo modulare e riutilizzabile.

Vantaggi dell’uso delle funzioni:

  • Modularità: suddividendo il codice in funzioni, è possibile isolare le diverse parti logiche e semplificare la comprensione e la manutenzione del programma.
  • Riutilizzabilità: una funzione può essere chiamata più volte, evitando la duplicazione del codice.
  • Testabilità: è più facile testare e fare debug su una singola funzione piuttosto che su un programma monolitico.

Ogni funzione ha un nome che la identifica e che viene utilizzato per invocarla. Può accettare parametri (dati in ingresso) e restituire un valore.

Dichiarazione e Definizione di una Funzione

In C++, le funzioni sono costituite da due parti principali: dichiarazione e definizione.

  • Dichiarazione della funzione: informa il compilatore dell’esistenza della funzione, specificando il nome, i parametri (se presenti) e il tipo di valore restituito. È spesso inserita nella parte iniziale del file.

    Esempio di dichiarazione:

    int somma(int, int); // dichiara una funzione che accetta due interi e restituisce un intero
  • Definizione della funzione: specifica il corpo della funzione, cioè l’implementazione vera e propria di ciò che fa la funzione.

    Esempio di definizione:

    int somma(int a, int b) {
        return a + b; // restituisce la somma dei due numeri
    }

Nota: È importante che i tipi dei parametri e il tipo di ritorno della funzione nella dichiarazione corrispondano esattamente a quelli nella definizione.

Parametri delle Funzioni

Le funzioni possono accettare parametri che servono come input per eseguire operazioni. I parametri sono variabili che vengono definite nella parentesi della dichiarazione della funzione. Questi parametri vengono utilizzati all’interno della funzione per svolgere calcoli o elaborazioni.

Quando una funzione accetta parametri, il valore passato viene copiato all’interno della funzione, quindi eventuali modifiche a tale valore all’interno della funzione non influenzeranno il valore originale.

Esempio:

void stampaQuadruplo(int numero) {
    numero = numero * 4; // Modifica la copia locale
    cout << "Il quadruplo è: " << numero << endl;
}

Se la funzione viene chiamata con:

int valore = 5;
stampaQuadruplo(valore);
cout << valore; // Stampa ancora 5, perché la modifica avviene su una copia

Questo meccanismo di copia dei valori avviene per tutti i parametri… tranne quelli di tipo array

Quando un array viene passato a una funzione, non viene copiata una nuova versione dell’array. Al contrario, viene passato un riferimento all’array originale, quindi le modifiche effettuate alla variabile all’interno della funzione influenzeranno anche l’array esterno.

Esempio:

void modificaArray(int arr[]) {
    arr[0] = 100; // Modifica il primo elemento dell'array originale
}

int main() {
    int numeri[] = {1, 2, 3};
    modificaArray(numeri);
    cout << numeri[0]; // Output: 100, quindi l’array originale è stato modificato
    return 0;
}

Questa particolarità è dovuta al fatto che in C++ un array viene gestito come un indirizzo di memoria, quindi la funzione accede direttamente all’array originale e non a una sua copia.

Tipi di Ritorno delle Funzioni

Le funzioni possono restituire un valore utilizzando la parola chiave return. Il tipo di ritorno della funzione (definito prima del nome della funzione) specifica il tipo di dato che verrà restituito.

Esempi di tipi di ritorno comuni:

  • void: la funzione non restituisce alcun valore.
  • Tipi primitivi: int, double, char, bool, ecc.
  • Tipi complessi come puntatori, o strutture (struct).

Esempio:

double calcolaAreaCerchio(double raggio) {
    return 3.14159 * raggio * raggio; // Restituisce l'area del cerchio
}

Nota: una funzione può restituire un solo valore. Tuttavia, spesso è utile restituire più valori. Vedremo come fare questo nel capitolo seguente.

Ambito delle Variabili (Scope) e Durata delle Variabili (Lifetime)

Quando si lavora con le funzioni in C++, è importante comprendere come funzionano l’ambito e la durata delle variabili, perché influiscono direttamente sul comportamento e sulla gestione della memoria nel programma.

Ambito delle Variabili (Scope)

L’ambito di una variabile si riferisce alla porzione del codice in cui la variabile è visibile e utilizzabile. In C++, ci sono vari tipi di ambito:

  • Ambito Locale:

    • Le variabili dichiarate all’interno di una funzione (o di un blocco come un loop o un if) sono visibili solo all’interno di quella funzione o blocco.
    • Quando la funzione termina, queste variabili vengono automaticamente distrutte e la memoria viene liberata.

    Esempio:

    void esempio() {
        int x = 10; // Variabile locale: visibile solo all'interno di questa funzione
        cout << x << endl;
    }
    
    // cout << x << endl; // ERRORE: 'x' non è visibile qui
  • Ambito Globale:

    • Le variabili dichiarate al di fuori di tutte le funzioni sono visibili e accessibili da qualsiasi parte del programma.
    • Tuttavia, le variabili globali vanno usate con cautela, perché possono rendere il codice meno modulare e più difficile da gestire.

    Esempio:

    int globale = 100; // Variabile globale
    
    void stampaGlobale() {
        cout << globale << endl; // Accesso a variabile globale
    }

Durata delle Variabili (Lifetime)

La durata di una variabile si riferisce a quanto tempo una variabile esiste in memoria durante l’esecuzione del programma:

  • Variabili Automatiche (Locali):

    • La maggior parte delle variabili locali ha una durata automatica: vengono allocate quando la funzione viene chiamata e deallocate quando la funzione termina.
    • Queste variabili sono allocate nello stack.

    Esempio:

    void esempio() {
        int y = 5; // y è allocata e distrutta ogni volta che esempio() viene chiamata
    }
  • Variabili Globali: Le variabili globali hanno una durata che coincide con l’intero ciclo di vita del programma, e possono essere visibili ovunque.

Restituire Più Valori: Utilizzo delle struct

Poiché una funzione in C++ può restituire solo un valore alla volta, una soluzione comune per restituire più valori è l’uso delle struct, una struttura che consente di raggruppare virtualmente più valori sotto un unico valore effettivo.

Creare una struct per restituire più valori

Ad esempio, supponiamo di voler creare una funzione che restituisce sia la somma che il prodotto di due numeri. Creiamo una struct chiamata Risultati che contiene entrambi i valori:

struct Risultati {
    int somma;
    int prodotto;
};

Ora, definiamo una funzione che utilizza questa struct come tipo di ritorno:

Risultati calcolaSommaEProdotto(int a, int b) {
    Risultati res;
    res.somma = a + b;
    res.prodotto = a * b;
    return res;
}

La funzione calcolaSommaEProdotto accetta due interi e restituisce una struct contenente sia la somma che il prodotto di questi numeri.

Esempio di utilizzo

int main() {
    int x = 4, y = 5;
    Risultati risultato = calcolaSommaEProdotto(x, y);
    
    cout << "Somma: " << risultato.somma << endl;    // Output: Somma: 9
    cout << "Prodotto: " << risultato.prodotto << endl; // Output: Prodotto: 20
    
    return 0;
}

In questo modo, possiamo restituire più valori da una funzione raggruppandoli in una struttura, rendendo il codice più organizzato e chiaro.

Funzioni Ricorsive

Le funzioni ricorsive sono funzioni che chiamano se stesse direttamente o indirettamente. La ricorsione è uno strumento potente che permette di risolvere problemi complessi suddividendoli in sottoproblemi più semplici dello stesso tipo.

Struttura di una Funzione Ricorsiva

Una funzione ricorsiva deve avere:

  • Caso Base: Una condizione che, quando soddisfatta, termina la ricorsione. Senza un caso base, la funzione continuerebbe a chiamarsi all’infinito, causando uno stack overflow.
  • Passo Ricorsivo: La parte della funzione che riduce il problema e chiama nuovamente la funzione con parametri modificati, avvicinandosi gradualmente al caso base.

Esempio classico: il fattoriale di un numero (n!):

int fattoriale(int n) {
    if (n == 0) { // Caso base
        return 1;
    } else { // Passo ricorsivo
        return n * fattoriale(n - 1);
    }
}

Chiamando fattoriale(5), la funzione si chiamerà ripetutamente riducendo n finché n non raggiunge 0, momento in cui ritornerà 1 e risolverà le chiamate annidate.

Vantaggi e Svantaggi della Ricorsione

  • Vantaggi:

    • La ricorsione può semplificare la scrittura e la comprensione di algoritmi che seguono una struttura ripetitiva o gerarchica (come l’attraversamento di alberi o grafi).
    • Permette di esprimere alcuni algoritmi in modo naturale e compatto.
  • Svantaggi:

    • Può essere meno efficiente rispetto all’uso di cicli iterativi, poiché ogni chiamata ricorsiva comporta un sovraccarico di memoria.
    • Se il caso base non è ben definito o se la ricorsione non si avvicina correttamente al caso base, può causare un loop infinito e un stack overflow.

Esempi di Funzioni Ricorsive

  • Serie di Fibonacci: Calcolare l’n-esimo numero della serie di Fibonacci:

    int fibonacci(int n) {
        if (n == 0) return 0; // Caso base
        if (n == 1) return 1; // Caso base
        return fibonacci(n - 1) + fibonacci(n - 2); // Passo ricorsivo
    }
  • Ricerca in un Array: Ricerca binaria su un array ordinato (divide et impera):

    int ricercaBinaria(int arr[], int sinistra, int destra, int target) {
        if (sinistra > destra) return -1; // Caso base: elemento non trovato
    
        int medio = (sinistra + destra) / 2;
        if (arr[medio] == target) return medio;
        if (arr[medio] > target) 
            return ricercaBinaria(arr, sinistra, medio - 1, target); // Ricerca a sinistra
        else 
            return ricercaBinaria(arr, medio + 1, destra, target); // Ricerca a destra
    }

Considerazioni Finali sulla Ricorsione

Quando si utilizza la ricorsione, è importante bilanciare leggibilità e efficienza:

  • La ricorsione è adatta quando il problema può essere suddiviso facilmente in sottoproblemi più piccoli.
  • In casi in cui la ricorsione diventa troppo profonda (es. calcolo del Fibonacci senza ottimizzazioni), potrebbe essere più efficiente un approccio iterativo o una memoizzazione (salvataggio dei risultati intermedi).