# Lezione 15 - Le funzioni

# ---==== LE FUNZIONI ====---

# Le funzioni sono blocchi di codice che eseguono un compito specifico. Le funzioni
# sono utili per suddividere il codice in blocchi più piccoli e più facili da gestire.
# Inoltre, le funzioni permettono di riutilizzare il codice, in quanto possono essere
# richiamate più volte.

# Sintassi:
# def nome_funzione(parametro1, parametro2, ..., parametroN):
#     # Blocco di istruzioni

# Per definire una funzione si utilizza la parola chiave def seguita dal nome della
# funzione e tra parentesi tonde i parametri della funzione. I parametri sono variabili
# che vengono passate alla funzione e che possono essere utilizzate all'interno del
# blocco di istruzioni. I parametri sono opzionali, cioè una funzione può non avere
# parametri.

# Esempio:
def saluta():
    print("Ciao!")

# Questa funzione si chiama "saluta" e non ha parametri. Quando viene chiamata, esegue
# il blocco di istruzioni, che in questo caso stampa la stringa "Ciao!".

# Per chiamare una funzione si scrive il nome della funzione seguito da parentesi tonde.
# Esempio:
saluta()

# Output:
# Ciao!

# Per comprendere il funzionamento dei parametri, possiamo considerare le funzioni come
# dei sotto-programmi le cui variabili sono definite nell'elenco dei parametri.
# È importante, però, notare che le variabili definite all'interno di una funzione
# non sono visibili all'esterno della funzione stessa. Questo concetto è chiamato
# "scope" delle variabili.

# Esempio:
def saluta(nome):
    print(f"Ciao, {nome}!")

# Questa funzione si chiama "saluta" e ha un parametro chiamato "nome". Quando viene
# chiamata, si deve passare un argomento che verrà assegnato al parametro "nome". La
# funzione stamperà la stringa "Ciao, " seguita dal valore del parametro "nome".

# Tornando al parallelismo con i sotto-programmi, la funzione saluta(nome) è come un
# un programma scritto come segue:
nome = "Drugo"
print(f"Ciao, {nome}!")

# Già da questo esempio si può notare la comodità delle funzioni per riutilizzare
# codice. Infatti, se volessimo salutare un'altra persona, potremmo chiamare la
# funzione saluta(nome) passando un altro nome, invece di scrivere di nuovo il codice
# e modificare il valore della variabile "nome".

# Esempio:
saluta("Drugo")
saluta("Biero")
saluta("Ambrogio")

# Output:
# Ciao, Drugo!
# Ciao, Biero!
# Ciao, Ambrogio!

# ---==== FUNZIONI CON VALORE DI RITORNO (PAROLA CHIAVE return) ====---

# Le funzioni possono restituire un valore. Per farlo, si utilizza la parola chiave
# return seguita dall'espressione che si vuole restituire.

# Esempio:
def somma(a, b):
    risultato_somma = a + b
    return risultato_somma

# Questa funzione si chiama "somma" e ha due parametri, "a" e "b". Quando viene chiamata,
# la funzione restituisce la somma dei due parametri.

# Per utilizzare il valore restituito da una funzione, si può assegnare il risultato a una
# variabile.

# Esempio:
risultato = somma(10, 20)
print(risultato)

# Output:
# 30

# È possibile restituire più valori da una funzione separandoli con una virgola.

# Esempio:
def divisione_e_resto(dividendo, divisore):
    quoziente = dividendo // divisore  # Questo operatore restituisce la parte intera della divisione
    resto = dividendo % divisore  # Questo operatore restituisce il resto della divisione
    return quoziente, resto

# Questa funzione si chiama "divisione_e_resto" e ha due parametri, "dividendo" e "divisore".
# Quando viene chiamata, la funzione restituisce il quoziente e il resto della divisione tra
# i due parametri.

# Per utilizzare i valori restituiti da una funzione, si possono assegnare a due variabili.

# Esempio:
q, r = divisione_e_resto(10, 3)
print(q)
print(r)

# Output:
# 3
# 1


# ---==== PAROLA CHIAVE yield ====---

# La parola chiave yield è simile a return, ma restituisce un generatore. Un generatore
# è un oggetto iterabile che può essere utilizzato in un ciclo for. I generatori sono
# utili quando si devono generare grandi quantità di dati, perché non richiedono di
# memorizzare tutti i dati in memoria.

# Esempio:
def numeri_pari(n):
    for i in range(n):
        if i % 2 == 0:
            yield i

# Questa funzione si chiama "numeri_pari" e ha un parametro "n". Quando viene chiamata,
# la funzione restituisce un generatore che genera i numeri pari da 0 a n-1.

# Per utilizzare i valori generati da un generatore, si può utilizzare un ciclo for.

# Esempio:
for numero in numeri_pari(10):
    print(numero)

# Output:
# 0
# 2
# 4
# ...

# ---==== TIPAGGIO DELLE FUNZIONI ====---

# Le funzioni in Python sono dinamiche, cioè non richiedono di specificare il tipo dei
# parametri e del valore di ritorno. Questo significa che una funzione può essere chiamata
# con argomenti di qualsiasi tipo e restituire valori di qualsiasi tipo.

# Tuttavia, è possibile specificare il tipo dei parametri e del valore di ritorno utilizzando
# le "type hints". Le type hints sono delle annotazioni che vengono scritte dopo i due punti
# e che specificano il tipo di una variabile o di un valore di ritorno.

# Esempio:
def somma(a: int, b: int) -> int:
    return a + b

# Questa funzione si chiama "somma" e ha due parametri, "a" e "b", di tipo int. Il valore di
# ritorno della funzione è di tipo int.

# Le type hints non influenzano il funzionamento delle funzioni, ma sono utili per rendere
# più chiara la documentazione del codice e per aiutare l'IDE a fornire suggerimenti durante
# la scrittura del codice.

# Abbiamo già visto in passato che le funzioni restituiscono SEMPRE un valore, anche se non
# è presente nessun return statement. In questo caso, la funzione restituisce None (NoneType).

# --= UTILIZZO DELLO STATEMENT return =--

# Lo statement return termina l'esecuzione della funzione e restituisce il valore specificato.
# Se una funzione ha più di uno statement return, solo il primo
# statement return viene eseguito. Gli statement return successivi vengono ignorati.

# Esempio:
def funzione():
    print("Prima del return")
    return 10
    print("Dopo il return")

risultato = funzione()
print(risultato)

# Output:
# Prima del return
# 10

# In questo esempio, la funzione restituisce il valore 10 e termina l'esecuzione. Lo statement
# print("Dopo il return") non viene eseguito.

# Il return, in questo modo, può essere utilizzato per far ritornare dalla funzione valori
# diversi a seconda di condizioni.

# Esempio:
def massimo(a, b):
    if a > b:
        return a
    else:
        return b

risultato = massimo(10, 20)
print(risultato)

# Output:
# 20

# In questo esempio, la funzione restituisce il valore maggiore tra i due parametri.

# ---==== FUNZIONI RICORSIVE ====---

### N.B.: Questo sotto-argomento è particolarmente complesso, quindi probabilmente
###       ci torneremo in futuro per approfondirlo meglio.

# Una funzione può richiamare se stessa. Questo tipo di funzioni sono chiamate "funzioni
# ricorsive". Le funzioni ricorsive sono utili per risolvere problemi che possono essere
# suddivisi in sottoproblemi più piccoli.

# Esempio:
def fattoriale(n):
    if n == 0:
        return 1
    else:
        return n * fattoriale(n - 1)
    
risultato = fattoriale(5)
print(risultato)

# Output:
# 120

# In questo esempio, la funzione "fattoriale" calcola il fattoriale di un numero n. Se n è
# uguale a 0, la funzione restituisce 1. Altrimenti, la funzione restituisce n moltiplicato
# per il fattoriale di n-1.

# Le funzioni ricorsive devono avere una condizione di uscita, cioè un caso base che termina
# la ricorsione. Senza una condizione di uscita, la funzione andrebbe in loop infinito.

# ---==== RIASSUNTO ====---

# - Le funzioni sono blocchi di codice che eseguono un compito specifico.
# - Le funzioni sono utili per suddividere il codice in blocchi più piccoli e più facili
#   da gestire.
# - Le funzioni permettono di riutilizzare il codice, in quanto possono essere richiamate
#   più volte.
# - Per definire una funzione si utilizza la parola chiave def seguita dal nome della
#   funzione e tra parentesi tonde i parametri della funzione.
# - I parametri sono variabili che vengono passate alla funzione e che possono essere
#   utilizzate all'interno del blocco di istruzioni.
# - I parametri sono opzionali, cioè una funzione può non avere parametri.
# - Per chiamare una funzione si scrive il nome della funzione seguito da parentesi tonde.
# - Le funzioni possono restituire un valore. Per farlo, si utilizza la parola chiave
#   return seguita dall'espressione che si vuole restituire.
# - È possibile restituire più valori da una funzione separandoli con una virgola.
# - La parola chiave yield restituisce un generatore, che è un oggetto iterabile che può
#   essere utilizzato in un ciclo for.
# - Le type hints sono delle annotazioni che specificano il tipo dei parametri e del valore
#   di ritorno di una funzione. Le type hints non influenzano il funzionamento delle funzioni,
#   ma sono utili per rendere più chiara la documentazione del codice e per aiutare l'IDE a
#   fornire suggerimenti durante la scrittura del codice.
# - Lo statement return termina l'esecuzione della funzione e restituisce il valore specificato.
# - Se una funzione ha più di uno statement return, solo il primo statement return viene eseguito.
# - Le funzioni ricorsive sono funzioni che richiamano se stesse. Le funzioni ricorsive sono
#   utili per risolvere problemi che possono essere suddivisi in sottoproblemi più piccoli.
# - Le funzioni ricorsive devono avere una condizione di uscita, cioè un caso base che termina
#   la ricorsione.