1 / 1
Indice della lezione

Capitolo 4 - Le funzioni

Nei capitoli precedenti abbiamo imparato a:

  • rappresentare dati;
  • utilizzare variabili;
  • prendere decisioni;
  • ripetere operazioni;
  • gestire liste, tuple, set e dizionari.

Ora dobbiamo affrontare un problema fondamentale.

Immagina di avere un programma di diverse centinaia di righe e di accorgerti che lo stesso procedimento compare in cinque punti diversi.

Potremmo copiare il codice cinque volte.

Ma cosa succede se dobbiamo modificarlo?

Dovremmo ricordarci di modificare tutte le copie.

Le funzioni risolvono proprio questo tipo di problema.

Una funzione permette di:

  • raggruppare istruzioni correlate;
  • dare un nome a un comportamento;
  • riutilizzare il codice;
  • ricevere dati in ingresso;
  • restituire risultati;
  • rendere il programma più leggibile;
  • suddividere un problema complesso in problemi più piccoli.

Alla fine del capitolo saprai utilizzare:

  • def;
  • parametri e argomenti;
  • return;
  • valori predefiniti;
  • argomenti posizionali e keyword;
  • unpacking * e **;
  • scope locale e globale;
  • global e nonlocal;
  • docstring;
  • type hints;
  • funzioni variadiche;
  • keyword-only e positional-only parameters;
  • funzioni come oggetti;
  • lambda;
  • funzioni di ordine superiore;
  • map(), filter() e sorted();
  • ricorsione;
  • closure;
  • decoratori come prima introduzione;
  • functools;
  • tecniche per progettare funzioni semplici e riutilizzabili.

4.1 Perché servono le funzioni?

Consideriamo questo programma:

price = 10
quantity = 3
total = price * quantity
print(f"Totale: {total:.2f} €")

Supponiamo di dover eseguire lo stesso calcolo per dieci prodotti.

Potremmo ripetere:

price1 = 10
quantity1 = 3
total1 = price1 * quantity1

price2 = 5
quantity2 = 4
total2 = price2 * quantity2

Ma stiamo duplicando la logica.

Una funzione ci permette di descrivere il comportamento una sola volta:

def calculate_total(price, quantity):
    return price * quantity

Poi possiamo utilizzarla:

total1 = calculate_total(10, 3)
total2 = calculate_total(5, 4)

La funzione diventa un piccolo componente riutilizzabile.

Possiamo rappresentare il concetto così:

             dati
              ↓
       ┌───────────────┐
       │   funzione    │
       └───────────────┘
              ↓
           risultato

4.2 Definire una funzione con def

La sintassi fondamentale è:

def nome_funzione():
    istruzioni

Esempio:

def say_hello():
    print("Hello, Python!")

Attenzione: definire una funzione non significa eseguirla.

La funzione viene eseguita quando la chiamiamo:

say_hello()

Possiamo chiamarla più volte:

say_hello()
say_hello()
say_hello()

Output:

Hello, Python!
Hello, Python!
Hello, Python!

4.3 Il blocco della funzione

Come per if, for e while, l'indentazione definisce il corpo della funzione:

def say_hello():
    print("Hello")
    print("Welcome")

Entrambe le istruzioni appartengono alla funzione.

Questa invece:

def say_hello():
    print("Hello")

print("Outside")

contiene una sola istruzione nella funzione.

L'indentazione non è quindi decorativa: definisce la struttura del programma.


📝 Esercizi --- Prime funzioni

Esercizio 1

Definisci una funzione:

show_message()

che visualizzi:

Benvenuto nel corso di Python!

Esercizio 2

Chiama la funzione tre volte.

Esercizio 3

Definisci:

show_separator()

che visualizzi una riga di =.


4.4 Parametri e argomenti

Una funzione può ricevere dati.

def greet(name):
    print(f"Ciao {name}!")

name è un parametro.

Quando chiamiamo:

greet("Anna")

"Anna" è un argomento.

Possiamo utilizzare valori diversi:

greet("Anna")
greet("Luca")
greet("Marco")

Una distinzione terminologica utile:

parametro → variabile definita nella funzione
argomento → valore passato durante la chiamata

4.5 Più parametri

Una funzione può avere più parametri:

def introduce(name, age):
    print(f"{name} ha {age} anni")

Chiamata:

introduce("Anna", 25)

Possiamo anche avere tre o più parametri:

def create_product(name, price, quantity):
    print(f"{name}: {price:.2f} € × {quantity}")

4.6 Argomenti posizionali

Nella chiamata:

introduce("Anna", 25)

i valori vengono associati ai parametri in base alla posizione:

name ← "Anna"
age  ← 25

Per questo:

introduce(25, "Anna")

non produce lo stesso significato.

La posizione degli argomenti conta.


4.7 Keyword arguments

Possiamo specificare esplicitamente il nome del parametro:

introduce(name="Anna", age=25)

In questo caso l'ordine può essere cambiato:

introduce(age=25, name="Anna")

Questo rende le chiamate più leggibili, soprattutto quando una funzione possiede molti parametri.

Possiamo anche mescolare posizionali e keyword, ma gli argomenti posizionali devono precedere quelli keyword:

introduce("Anna", age=25)

è valido.

Questo invece non è valido:

introduce(name="Anna", 25)

📝 Esercizi --- Parametri

Esercizio 4

Definisci:

def add(a, b):
    ...

che calcoli la somma.

Esercizio 5

Crea:

def show_product(name, price, quantity):
    ...

e chiamala utilizzando keyword arguments.

Esercizio 6

Scrivi una funzione che riceva:

nome
città

e produca una frase descrittiva.


4.8 return: restituire un risultato

Una funzione può restituire un valore utilizzando return.

def add(a, b):
    return a + b

Ora:

result = add(3, 5)

print(result)

produce:

8

La funzione non deve necessariamente stampare il risultato.

È spesso preferibile che una funzione calcoli e restituisca, lasciando al chiamante la decisione su cosa fare del risultato.

Confrontiamo:

def add(a, b):
    print(a + b)

con:

def add(a, b):
    return a + b

La seconda è più riutilizzabile:

result = add(3, 5)
double = add(3, 5) * 2
text = f"Risultato: {add(3, 5)}"

4.9 return interrompe la funzione

Quando Python incontra return, la funzione termina immediatamente.

def test():
    print("A")
    return
    print("B")

Chiamando:

test()

otteniamo:

A

B non viene eseguito.


4.10 Restituire più valori

Una funzione può restituire più valori:

def statistics(numbers):
    minimum = min(numbers)
    maximum = max(numbers)
    average = sum(numbers) / len(numbers)

    return minimum, maximum, average

Possiamo utilizzare tuple unpacking:

minimum, maximum, average = statistics([10, 20, 30])

In realtà la funzione restituisce una tupla:

(minimum, maximum, average)

4.11 Funzioni senza return

Se una funzione non contiene return, oppure raggiunge la fine senza eseguire un return, il valore restituito è:

None

Esempio:

def hello():
    print("Hello")

result = hello()

print(result)

Output:

Hello
None

None rappresenta l'assenza di un valore.


📝 Esercizi --- return

Esercizio 7

Scrivi:

def square(number):
    ...

che restituisca il quadrato.

Esercizio 8

Scrivi una funzione che riceva prezzo e quantità e restituisca il totale.

Esercizio 9

Scrivi una funzione che restituisca minimo, massimo e media di una lista di numeri.


4.12 Parametri con valore predefinito

Possiamo assegnare un valore predefinito a un parametro:

def greet(name, greeting="Ciao"):
    return f"{greeting}, {name}!"

Possiamo chiamarla:

print(greet("Anna"))

ottenendo:

Ciao, Anna!

Oppure:

print(greet("Anna", "Buongiorno"))

ottenendo:

Buongiorno, Anna!

I parametri con valore predefinito devono seguire i parametri obbligatori:

def greet(name, greeting="Ciao"):
    ...

è valido.

Questo:

def greet(greeting="Ciao", name):
    ...

non è valido.


4.13 Attenzione ai valori mutabili come default

Questo è un errore concettuale molto importante.

Evitiamo:

def add_item(item, items=[]):
    items.append(item)
    return items

Il valore predefinito [] viene creato una sola volta, quando la funzione viene definita.

Quindi le chiamate successive condividono quella stessa lista.

È preferibile:

def add_item(item, items=None):
    if items is None:
        items = []

    items.append(item)
    return items

Ora ogni chiamata che non fornisce items crea una nuova lista.

Regola pratica: per parametri mutabili come liste, dizionari e set, usare spesso None come valore predefinito e creare l'oggetto dentro la funzione.


📝 Esercizi --- Default arguments

Esercizio 10

Crea:

def power(number, exponent=2):
    ...

Esercizio 11

Crea una funzione:

def connect(host="localhost", port=8080):
    ...

che restituisca una stringa di connessione.

Esercizio 12

Spiega perché è rischioso usare:

items=[]

come default.


4.14 *args: un numero variabile di argomenti

Possiamo creare funzioni che ricevono un numero variabile di argomenti posizionali:

def total(*numbers):
    return sum(numbers)

Possiamo chiamare:

print(total(1, 2))
print(total(1, 2, 3, 4))
print(total())

Dentro la funzione:

numbers

è una tupla.

Quindi:

total(1, 2, 3)

equivale concettualmente a:

numbers = (1, 2, 3)

4.15 **kwargs: argomenti keyword variabili

**kwargs raccoglie un numero variabile di argomenti nominati in un dizionario.

def show_info(**info):
    for key, value in info.items():
        print(f"{key}: {value}")

Chiamata:

show_info(
    name="Anna",
    age=25,
    city="Torino"
)

Dentro la funzione:

info

è un dizionario.


4.16 Combinare parametri, *args e **kwargs

Possiamo scrivere:

def function(required, *args, option=True, **kwargs):
    ...

L'ordine generale dei parametri è regolato dalla sintassi delle funzioni Python.

Per esempio:

def example(a, b=10, *args, option=True, **kwargs):
    ...

Qui abbiamo:

  • parametro obbligatorio a;
  • parametro con default b;
  • argomenti posizionali aggiuntivi in args;
  • parametro keyword-only option;
  • argomenti keyword aggiuntivi in kwargs.

Non è necessario usare sempre tutti questi strumenti. Una funzione con troppi meccanismi può diventare difficile da capire.


4.17 Unpacking * nella chiamata

Se abbiamo:

numbers = [1, 2, 3]

e:

def add(a, b, c):
    return a + b + c

possiamo scrivere:

result = add(*numbers)

Python espande la lista:

add(1, 2, 3)

Lo stesso vale per tuple e altri iterabili compatibili.


4.18 Unpacking ** nella chiamata

Dato:

def introduce(name, age):
    return f"{name} ha {age} anni"

e:

person = {
    "name": "Anna",
    "age": 25
}

possiamo scrivere:

print(introduce(**person))

Il dizionario viene espanso in:

introduce(name="Anna", age=25)

Le chiavi devono corrispondere ai parametri della funzione.


📝 Esercizi --- Argomenti variabili

Esercizio 13

Scrivi una funzione:

def multiply_all(*numbers):
    ...

che moltiplichi tutti i valori.

Esercizio 14

Scrivi:

def print_profile(**profile):
    ...

che visualizzi tutte le informazioni.

Esercizio 15

Usa * per passare una lista a una funzione che richiede tre parametri.

Esercizio 16

Usa ** per passare un dizionario a una funzione.


4.19 Positional-only parameters

Python permette di dichiarare parametri che possono essere passati solo per posizione.

def divide(a, b, /):
    return a / b

Questa chiamata è valida:

divide(10, 2)

Questa non è valida:

divide(a=10, b=2)

I parametri prima di / sono positional-only.

Questo può essere utile quando vogliamo controllare esplicitamente l'API di una funzione.


4.20 Keyword-only parameters

Possiamo invece richiedere che alcuni parametri siano passati solo per nome.

def create_user(name, *, admin=False):
    ...

Questa chiamata è valida:

create_user("Anna", admin=True)

Questa no:

create_user("Anna", True)

I parametri dopo * sono keyword-only.

Questa tecnica rende le chiamate più esplicite.


4.21 Esempio completo di firma

Possiamo combinare le due tecniche:

def example(a, b, /, c, *, option=False):
    ...

Qui:

  • a e b sono positional-only;
  • c può essere posizionale o keyword;
  • option è keyword-only.

La firma di una funzione può quindi comunicare molto precisamente come deve essere utilizzata.


4.22 Scope: dove esiste una variabile?

Consideriamo:

def calculate():
    total = 100
    print(total)

calculate()

La variabile:

total

è locale alla funzione.

Non possiamo normalmente utilizzarla fuori:

calculate()

print(total)

Questo produce NameError.

La variabile esiste nello scope locale della funzione.


4.23 Variabili locali e globali

Possiamo avere:

total = 100

def show_total():
    print(total)

La funzione può leggere la variabile globale.

Ma se assegniamo:

total = 100

def change_total():
    total = 200

la funzione crea una nuova variabile locale total.

La variabile globale rimane:

100

Questo è un punto fondamentale:

Un'assegnazione dentro una funzione crea normalmente una variabile locale.


4.24 global

Se abbiamo davvero bisogno di modificare una variabile globale, possiamo usare global:

counter = 0

def increment():
    global counter
    counter += 1

Dopo:

increment()
print(counter)

otteniamo:

1

Tuttavia, l'uso frequente di global è generalmente un segnale che il programma potrebbe essere progettato meglio.

È spesso preferibile passare dati come parametri e restituire risultati.

Meglio:

def increment(counter):
    return counter + 1

counter = increment(counter)

che affidarsi allo stato globale.


4.25 nonlocal

nonlocal viene utilizzato nelle funzioni annidate quando vogliamo modificare una variabile appartenente allo scope della funzione esterna.

Esempio:

def counter():
    value = 0

    def increment():
        nonlocal value
        value += 1
        return value

    return increment

Possiamo creare:

next_number = counter()

print(next_number())
print(next_number())
print(next_number())

Output:

1
2
3

Abbiamo creato una closure: la funzione interna conserva l'accesso alla variabile value della funzione esterna.


4.26 LEGB

Python cerca i nomi seguendo una regola chiamata LEGB:

L → Local
E → Enclosing
G → Global
B → Built-in

In pratica:

  1. cerca nello scope locale;
  2. poi negli scope delle funzioni esterne;
  3. poi nello scope globale;
  4. infine nei nomi built-in.

Esempio:

name = "global"

def outer():
    name = "enclosing"

    def inner():
        name = "local"
        print(name)

    inner()

outer()

Viene stampato:

local

perché il nome viene trovato immediatamente nello scope locale di inner().


📝 Esercizi --- Scope

Esercizio 17

Spiega perché questo programma stampa 10:

x = 10

def test():
    x = 20
    print(x)

test()
print(x)

Esercizio 18

Riscrivi un programma che utilizzi una funzione con parametro invece di una variabile globale.

Esercizio 19

Crea una closure che mantenga un contatore.


4.27 Docstring

Una funzione dovrebbe spiegare chiaramente cosa fa.

Python permette di inserire una docstring come prima istruzione della funzione:

def square(number):
    """Restituisce il quadrato di number."""
    return number ** 2

Possiamo leggere la docstring:

print(square.__doc__)

oppure utilizzare:

help(square)

Una docstring è particolarmente importante nelle funzioni che fanno parte di un progetto o di una libreria.


4.28 Docstring più complete

Per funzioni più importanti possiamo documentare parametri e risultato:

def calculate_total(price, quantity):
    """
    Calcola il costo totale di un prodotto.

    Args:
        price: prezzo unitario.
        quantity: quantità acquistata.

    Returns:
        Il costo totale.
    """
    return price * quantity

La sintassi esatta della documentazione può seguire convenzioni diverse. L'importante è mantenere coerenza nel progetto.


4.29 Type hints

Possiamo documentare i tipi attesi:

def add(a: int, b: int) -> int:
    return a + b

Possiamo usare type hints anche per strutture più complesse:

def average(numbers: list[float]) -> float:
    return sum(numbers) / len(numbers)

Con Python moderno, list[int] e sintassi simili sono preferibili alle vecchie forme del modulo typing quando compatibili con la versione utilizzata.

Ricordiamo:

I type hints descrivono le intenzioni del programmatore, ma Python non li applica automaticamente come vincoli runtime.


4.30 Type hints e strumenti di analisi

I type hints possono essere utilizzati da:

  • editor;
  • IDE;
  • type checker;
  • strumenti di documentazione;
  • analizzatori statici.

Per esempio, una funzione:

def greet(name: str) -> str:
    return f"Ciao {name}"

comunica chiaramente:

input  → str
output → str

Questo diventa molto utile nei progetti grandi.


📝 Esercizi --- Documentazione

Esercizio 20

Aggiungi una docstring a:

def multiply(a, b):
    return a * b

Esercizio 21

Aggiungi type hints.

Esercizio 22

Scrivi una funzione calculate_discount() completa di docstring e type hints.


4.31 Funzioni come oggetti

In Python le funzioni sono first-class objects.

Significa che possiamo:

  • assegnarle a variabili;
  • inserirle in liste;
  • passarle come argomento;
  • restituirle da altre funzioni.

Esempio:

def square(x):
    return x ** 2

operation = square

print(operation(5))

operation ora fa riferimento alla stessa funzione.


4.32 Passare una funzione come argomento

Possiamo scrivere:

def apply_operation(operation, value):
    return operation(value)

Poi:

def square(x):
    return x ** 2

print(apply_operation(square, 5))

Risultato:

25

La funzione apply_operation() riceve un comportamento come dato.

Queste sono chiamate funzioni di ordine superiore.


4.33 Funzioni di ordine superiore

Una funzione è di ordine superiore quando:

  • riceve una funzione;
  • oppure restituisce una funzione;
  • oppure entrambe le cose.

Esempio:

def repeat(operation, value, times):
    result = value

    for _ in range(times):
        result = operation(result)

    return result

Possiamo usare:

def double(x):
    return x * 2

print(repeat(double, 1, 4))

Risultato:

16

La funzione è diventata un piccolo meccanismo riutilizzabile.


4.34 Lambda

Una lambda permette di creare una piccola funzione senza darle necessariamente un nome.

square = lambda x: x ** 2

Equivale concettualmente a:

def square(x):
    return x ** 2

Le lambda sono particolarmente utili quando passiamo una funzione a un'altra funzione.

Esempio:

numbers = [1, 2, 3, 4]

squares = list(map(lambda x: x ** 2, numbers))

4.35 sorted() e key

Abbiamo già incontrato key nel capitolo sulle strutture dati.

Esempio:

names = ["Anna", "Luca", "Alessandro", "Mia"]

ordered = sorted(names, key=lambda name: len(name))

La funzione passata a key stabilisce il valore utilizzato per l'ordinamento.

Possiamo utilizzare una funzione normale:

def by_length(name):
    return len(name)

ordered = sorted(names, key=by_length)

4.36 map()

map() applica una funzione a ogni elemento:

numbers = [1, 2, 3, 4]

doubled = map(lambda x: x * 2, numbers)

print(list(doubled))

Output:

[2, 4, 6, 8]

map() restituisce un iteratore.


4.37 filter()

filter() conserva gli elementi per i quali la funzione restituisce un valore truthy:

numbers = [1, 2, 3, 4, 5, 6]

even = filter(lambda x: x % 2 == 0, numbers)

print(list(even))

Risultato:

[2, 4, 6]

4.38 Comprehension o map()/filter()?

Spesso possiamo scegliere tra più stili.

Con map():

squares = list(map(lambda x: x ** 2, numbers))

Con comprehension:

squares = [x ** 2 for x in numbers]

La seconda forma è spesso più leggibile in Python.

Analogamente:

even = list(filter(lambda x: x % 2 == 0, numbers))

può diventare:

even = [x for x in numbers if x % 2 == 0]

La scelta dipende dal contesto e dalla chiarezza.


📝 Esercizi --- Funzioni come valori

Esercizio 23

Crea una funzione double() e passala a una funzione apply().

Esercizio 24

Ordina una lista di parole per lunghezza utilizzando una lambda.

Esercizio 25

Trasforma una lista di numeri con map().

Esercizio 26

Filtra i numeri pari con filter().

Esercizio 27

Riscrivi gli esercizi 25 e 26 usando comprehension.


4.39 Closure

Una closure si verifica quando una funzione interna conserva un riferimento a variabili dello scope esterno anche dopo che la funzione esterna ha terminato la propria esecuzione.

Esempio:

def make_multiplier(factor):
    def multiply(value):
        return value * factor

    return multiply

Creiamo:

double = make_multiplier(2)
triple = make_multiplier(3)

print(double(10))
print(triple(10))

Output:

20
30

La funzione multiply() conserva il valore di factor.


4.40 Perché le closure sono utili?

Una closure permette di creare una funzione configurata.

double = make_multiplier(2)

significa:

crea una funzione che moltiplica per 2.

Mentre:

triple = make_multiplier(3)

crea:

una funzione che moltiplica per 3.

Le closure sono alla base di alcuni pattern funzionali e sono importanti per comprendere i decoratori.


4.41 Decoratori: prima introduzione

Un decoratore è una funzione che riceve una funzione e ne restituisce una nuova con comportamento aggiuntivo.

Esempio:

def log_call(function):
    def wrapper():
        print("Funzione in esecuzione")
        result = function()
        print("Funzione terminata")
        return result

    return wrapper

Possiamo applicarlo manualmente:

def hello():
    print("Hello")

hello = log_call(hello)

hello()

Output:

Funzione in esecuzione
Hello
Funzione terminata

La sintassi più elegante utilizza @:

@log_call
def hello():
    print("Hello")

Ora:

hello()

ha lo stesso comportamento.


4.42 Decoratori e functools.wraps

Un decoratore reale dovrebbe generalmente utilizzare functools.wraps:

from functools import wraps

def log_call(function):
    @wraps(function)
    def wrapper():
        print("Funzione in esecuzione")
        result = function()
        print("Funzione terminata")
        return result

    return wrapper

wraps() aiuta a preservare metadati della funzione originale, come nome e docstring.


4.43 Decoratori con argomenti

Un decoratore generico deve poter funzionare con funzioni aventi argomenti diversi.

Possiamo usare *args e **kwargs:

from functools import wraps

def log_call(function):
    @wraps(function)
    def wrapper(*args, **kwargs):
        print(f"Chiamata a {function.__name__}")
        result = function(*args, **kwargs)
        print("Chiamata terminata")
        return result

    return wrapper

Ora:

@log_call
def add(a, b):
    return a + b

print(add(3, 5))

Il decoratore funziona anche con una funzione che riceve parametri.

I decoratori saranno approfonditi quando avremo studiato moduli, classi ed eccezioni.


4.44 Ricorsione

Una funzione ricorsiva è una funzione che richiama se stessa.

Esempio classico: fattoriale.

def factorial(n):
    if n <= 1:
        return 1

    return n * factorial(n - 1)

Calcoliamo:

print(factorial(5))

La sequenza concettuale è:

factorial(5)
5 × factorial(4)
5 × 4 × factorial(3)
5 × 4 × 3 × factorial(2)
5 × 4 × 3 × 2 × factorial(1)

e factorial(1) restituisce 1.


4.45 Caso base e passo ricorsivo

Ogni ricorsione ben progettata deve avere almeno:

  1. un caso base;
  2. un passo ricorsivo che avvicini il problema al caso base.

Nel nostro esempio:

if n <= 1:
    return 1

è il caso base.

Mentre:

return n * factorial(n - 1)

è il passo ricorsivo.

Senza caso base avremmo una ricorsione infinita.


4.46 Ricorsione e stack

Ogni chiamata di funzione utilizza una porzione dello stack di esecuzione.

Una ricorsione troppo profonda può quindi produrre:

RecursionError

Python non è progettato per gestire ricorsioni enormemente profonde.

Per molti problemi che possono essere risolti facilmente con un ciclo, un approccio iterativo è preferibile.


4.47 Esempio: somma ricorsiva

Possiamo scrivere:

def recursive_sum(numbers):
    if not numbers:
        return 0

    return numbers[0] + recursive_sum(numbers[1:])

Funziona, ma non è necessariamente una buona soluzione pratica.

Per una lista, il semplice:

sum(numbers)

è migliore.

Questo esempio serve a comprendere la ricorsione, non a suggerire di sostituire le funzioni built-in con codice ricorsivo.


📝 Esercizi --- Ricorsione

Esercizio 28

Implementa ricorsivamente il fattoriale.

Esercizio 29

Implementa una funzione ricorsiva che calcoli la somma dei numeri da 1 a n.

Esercizio 30

Individua il caso base e il passo ricorsivo della funzione:

def countdown(n):
    if n == 0:
        return

    print(n)
    countdown(n - 1)

4.48 Funzioni pure

Una funzione è detta pura quando, in termini pratici:

  • per gli stessi input produce lo stesso output;
  • non modifica stato esterno osservabile.

Esempio:

def add(a, b):
    return a + b

La funzione è pura.

Un esempio meno puro:

total = 0

def add_to_total(value):
    global total
    total += value

La funzione modifica uno stato esterno.

Le funzioni pure sono generalmente più facili da:

  • testare;
  • comprendere;
  • riutilizzare;
  • combinare.

Non significa che un programma debba contenere esclusivamente funzioni pure. I programmi reali devono anche interagire con file, rete, database, utenti e sistemi esterni.


4.49 Evitare funzioni che fanno troppe cose

Consideriamo:

def process_order():
    # legge input
    # valida dati
    # calcola prezzo
    # salva database
    # invia email
    # stampa ricevuta
    ...

La funzione è probabilmente troppo grande.

Potremmo separare:

read_order()
validate_order()
calculate_total()
save_order()
send_confirmation()
print_receipt()

Ogni funzione ha una responsabilità più precisa.

Questa idea viene spesso espressa come:

Una funzione dovrebbe avere una responsabilità chiara.

Non significa che ogni funzione debba contenere una sola riga. Significa che il suo scopo dovrebbe essere facilmente identificabile.


4.50 Funzioni piccole e composizione

Possiamo costruire comportamenti complessi componendo funzioni semplici.

def calculate_subtotal(price, quantity):
    return price * quantity


def calculate_discount(total, percentage):
    return total * percentage / 100


def calculate_final_price(price, quantity, discount):
    subtotal = calculate_subtotal(price, quantity)
    discount_value = calculate_discount(subtotal, discount)
    return subtotal - discount_value

Ora:

final_price = calculate_final_price(100, 2, 10)
print(final_price)

Le singole funzioni sono facili da verificare separatamente.


4.51 Funzioni e validazione

Le funzioni sono anche un buon posto per centralizzare regole.

def is_valid_price(price):
    return price >= 0

Possiamo utilizzare:

if is_valid_price(price):
    ...

Invece di ripetere:

if price >= 0:

in molti punti.

Questo diventa ancora più importante quando una regola cambia.


4.52 Funzioni e None

Quando una funzione può non trovare un risultato, None è spesso una scelta naturale:

def find_user(users, name):
    for user in users:
        if user["name"] == name:
            return user

    return None

Il chiamante può controllare:

user = find_user(users, "Anna")

if user is not None:
    print(user)

Per confrontare con None è preferibile usare:

is None
is not None

piuttosto che == None.


4.53 Funzioni come API del programma

Possiamo vedere una funzione come una piccola interfaccia:

INPUT
  ↓
┌─────────────────┐
│     funzione    │
└─────────────────┘
  ↓
OUTPUT

Il chiamante non deve necessariamente conoscere come funziona internamente.

Per esempio:

total = calculate_final_price(100, 2, 10)

Chi utilizza la funzione deve sapere:

  • quali dati fornire;
  • cosa restituisce.

Non deve necessariamente conoscere tutti i calcoli interni.

Questa separazione tra interfaccia e implementazione sarà fondamentale quando arriveremo alla programmazione a oggetti.


📝 Esercizi --- Progettazione

Esercizio 31

Dividi un programma che:

  1. legge un prezzo;
  2. legge una quantità;
  3. calcola il totale;
  4. applica uno sconto;
  5. stampa il risultato.

in almeno tre funzioni.

Esercizio 32

Scrivi una funzione find_product() che restituisca il prodotto oppure None.

Esercizio 33

Spiega perché è utile separare il calcolo dalla visualizzazione.


4.54 functools

Il modulo functools contiene strumenti utili per lavorare con funzioni.

Uno dei più importanti è:

from functools import reduce

reduce() combina progressivamente gli elementi di un iterabile.

Esempio:

from functools import reduce

numbers = [1, 2, 3, 4]

product = reduce(lambda a, b: a * b, numbers)

print(product)

Risultato:

24

In Python, però, reduce() non dovrebbe essere utilizzato automaticamente quando esiste una soluzione più leggibile.

Per esempio:

sum(numbers)

è molto più chiaro per una somma.

Per un prodotto, math.prod() è spesso una scelta migliore:

from math import prod

product = prod(numbers)

reduce() è uno strumento importante da conoscere, ma la leggibilità deve guidare la scelta.


4.55 functools.partial

partial() permette di creare una nuova funzione con alcuni argomenti già impostati.

from functools import partial

def multiply(a, b):
    return a * b

double = partial(multiply, 2)

print(double(10))

Risultato:

20

Abbiamo trasformato:

multiply(2, 10)

in:

double(10)

È utile quando vogliamo creare una versione specializzata di una funzione generale.


4.56 Funzioni nidificate

Una funzione può essere definita all'interno di un'altra:

def outer():
    def inner():
        print("Inner")

    inner()

La funzione interna è disponibile all'interno dello scope della funzione esterna.

Le funzioni nidificate sono utilizzate, tra gli altri casi, per:

  • closure;
  • decoratori;
  • nascondere dettagli di implementazione locali.

Non è necessario usarle sempre.


4.57 pass

Quando dobbiamo definire una funzione ma non abbiamo ancora deciso cosa farà, possiamo usare:

def future_function():
    pass

pass non fa nulla.

È utile come segnaposto temporaneo.

È però preferibile non lasciare a lungo funzioni vuote nel codice di produzione.


4.58 Funzioni e gestione degli errori

Una funzione può lasciare propagare un'eccezione:

def divide(a, b):
    return a / b

Se:

divide(10, 0)

si verifica ZeroDivisionError.

Non dobbiamo necessariamente gestire l'errore dentro la funzione.

A seconda del progetto, può essere più corretto lasciare che il chiamante decida come gestirlo.

Approfondiremo questo tema nel capitolo dedicato alle eccezioni.


4.59 Esempio completo: funzioni per un negozio

Costruiamo un piccolo modulo logico per un negozio.

def calculate_subtotal(price, quantity):
    return price * quantity


def calculate_discount(subtotal, percentage):
    return subtotal * percentage / 100


def calculate_total(price, quantity, discount_percentage=0):
    subtotal = calculate_subtotal(price, quantity)
    discount = calculate_discount(subtotal, discount_percentage)

    return subtotal - discount

Utilizzo:

price = 12.50
quantity = 4

total = calculate_total(
    price,
    quantity,
    discount_percentage=10
)

print(f"Totale: {total:.2f} €")

Risultato:

Totale: 45.00 €

Abbiamo separato:

calcolo del subtotale
        ↓
calcolo dello sconto
        ↓
calcolo del totale

Ogni funzione ha una responsabilità precisa.


4.60 Esempio completo: analisi di studenti

Consideriamo:

students = [
    {"name": "Anna", "grades": [7, 8, 6, 9]},
    {"name": "Luca", "grades": [5, 6, 6, 7]},
    {"name": "Marco", "grades": [9, 8, 10, 9]}
]

Possiamo creare:

def calculate_average(grades):
    return sum(grades) / len(grades)

Poi:

def student_average(student):
    return calculate_average(student["grades"])

E:

for student in students:
    average = student_average(student)
    print(f"{student['name']}: {average:.2f}")

Separando le funzioni, possiamo riutilizzare il calcolo della media in molti punti.


4.61 Una funzione per trovare il migliore

Possiamo utilizzare max() con key:

best_student = max(
    students,
    key=student_average
)

Ora:

print(best_student["name"])

produce:

Marco

Questa soluzione mostra quanto sia potente la combinazione:

funzioni
+
key
+
funzioni built-in

4.62 Funzioni e strutture dati

Le funzioni permettono di incapsulare operazioni sulle strutture dati.

Esempio:

def get_low_stock(products, threshold=10):
    return [
        product
        for product in products
        if product["stock"] <= threshold
    ]

Utilizzo:

low_stock = get_low_stock(products)

La funzione nasconde i dettagli dell'algoritmo e offre un'interfaccia semplice.


📝 Esercizi --- Funzioni e dati

Esercizio 34

Scrivi:

def get_total_value(products):
    ...

che calcoli il valore totale del magazzino.

Esercizio 35

Scrivi:

def get_categories(products):
    ...

che restituisca un set delle categorie.

Esercizio 36

Scrivi:

def get_expensive_products(products, minimum_price):
    ...

che restituisca i prodotti sopra una determinata soglia.


4.63 Errori comuni nelle funzioni

Errore 1 --- Dimenticare le parentesi nella chiamata

Definire:

def hello():
    print("Hello")

e scrivere:

hello

non esegue la funzione.

Serve:

hello()

Errore 2 --- Confondere print() e return

Questo:

def add(a, b):
    print(a + b)

non restituisce il risultato.

Questo:

def add(a, b):
    return a + b

lo restituisce.

Errore 3 --- Parametro mancante

def add(a, b):
    return a + b

add(5)

produce TypeError.

Errore 4 --- Default mutabile

Evitare:

def add_item(item, items=[]):
    ...

Errore 5 --- Funzioni troppo grandi

Una funzione che contiene centinaia di righe è spesso difficile da testare e mantenere.

Errore 6 --- Troppe variabili globali

Le dipendenze globali rendono più difficile capire da dove arrivano i dati e prevedere gli effetti di una funzione.


4.64 Mini-verifica

Domanda 1

A cosa serve def?

Domanda 2

Qual è la differenza tra parametro e argomento?

Domanda 3

A cosa serve return?

Domanda 4

Che cosa restituisce una funzione senza return?

Domanda 5

Che cos'è un keyword argument?

Domanda 6

A cosa servono *args e **kwargs?

Domanda 7

Che cosa fa * durante una chiamata?

Domanda 8

Che cosa fa ** durante una chiamata?

Domanda 9

Che cosa significa scope locale?

Domanda 10

A cosa servono global e nonlocal?

Domanda 11

Che cos'è una docstring?

Domanda 12

Che cosa sono i type hints?

Domanda 13

Che cosa significa che una funzione è un first-class object?

Domanda 14

Che cos'è una funzione di ordine superiore?

Domanda 15

Quando è utile una lambda?

Domanda 16

Che cos'è una closure?

Domanda 17

Che cos'è un decoratore?

Domanda 18

Quali sono i due elementi fondamentali della ricorsione?

Domanda 19

Perché i default mutabili possono essere problematici?

Domanda 20

Perché è utile dividere un programma in funzioni piccole?


4.65 Esercizi di riepilogo

Esercizio 37 --- Calcolatrice

Crea le funzioni:

add()
subtract()
multiply()
divide()

e una funzione:

calculate()

che scelga l'operazione.


Esercizio 38 --- Analisi numerica

Crea funzioni per:

  • somma;
  • media;
  • minimo;
  • massimo;
  • conteggio dei numeri pari.

Esercizio 39 --- Filtri

Scrivi:

def get_even(numbers):
    ...

e:

def get_greater_than(numbers, threshold):
    ...

Esercizio 40 --- Prodotti

Dato:

products = [
    {"name": "Milk", "price": 1.50, "stock": 20},
    {"name": "Bread", "price": 2.00, "stock": 8},
    {"name": "Eggs", "price": 3.20, "stock": 15}
]

crea funzioni per:

  • valore di un prodotto;
  • valore totale;
  • prodotti con scorta bassa;
  • prodotto più costoso.

4.66 Sfida finale --- Sistema di gestione ordini

È arrivato il momento di mettere insieme tutto ciò che abbiamo studiato.

Costruisci un piccolo sistema di gestione ordini utilizzando funzioni.

Dati

products = [
    {
        "name": "Milk",
        "price": 1.50,
        "stock": 20
    },
    {
        "name": "Bread",
        "price": 2.00,
        "stock": 8
    },
    {
        "name": "Eggs",
        "price": 3.20,
        "stock": 15
    },
    {
        "name": "Apples",
        "price": 2.50,
        "stock": 30
    }
]

Un ordine è rappresentato da:

order = [
    {"product": "Milk", "quantity": 3},
    {"product": "Eggs", "quantity": 2},
    {"product": "Apples", "quantity": 5}
]

Requisiti

Crea almeno queste funzioni:

find_product(products, name)

Restituisce il prodotto oppure None.

calculate_line_total(product, quantity)

Calcola il totale di una riga dell'ordine.

calculate_order_total(products, order)

Calcola il totale dell'ordine.

get_low_stock(products, threshold=10)

Restituisce i prodotti con scorta bassa.

get_order_summary(products, order)

Restituisce una struttura contenente le informazioni dell'ordine.

Il programma deve inoltre:

  1. utilizzare type hints;
  2. utilizzare docstring;
  3. utilizzare almeno un default argument;
  4. utilizzare almeno una comprehension;
  5. utilizzare almeno una funzione built-in con key;
  6. gestire il caso di prodotto inesistente;
  7. evitare variabili globali per i dati dell'ordine;
  8. stampare un riepilogo leggibile.

Output indicativo

========================================
          RIEPILOGO ORDINE
========================================

Milk       × 3    4.50 €
Eggs       × 2    6.40 €
Apples     × 5   12.50 €

Totale ordine: 23.40 €

Prodotti con scorta bassa:
- Bread

========================================

4.67 Soluzione guidata della sfida

Una possibile soluzione è:

def find_product(products: list[dict], name: str) -> dict | None:
    """Restituisce il prodotto con il nome indicato, oppure None."""

    for product in products:
        if product["name"].lower() == name.lower():
            return product

    return None


def calculate_line_total(product: dict, quantity: int) -> float:
    """Calcola il totale di una riga dell'ordine."""

    return product["price"] * quantity


def calculate_order_total(
    products: list[dict],
    order: list[dict]
) -> float:
    """Calcola il totale complessivo dell'ordine."""

    total = 0.0

    for line in order:
        product = find_product(products, line["product"])

        if product is None:
            continue

        total += calculate_line_total(
            product,
            line["quantity"]
        )

    return total


def get_low_stock(
    products: list[dict],
    threshold: int = 10
) -> list[dict]:
    """Restituisce i prodotti con scorta minore o uguale alla soglia."""

    return [
        product
        for product in products
        if product["stock"] <= threshold
    ]


def get_order_summary(
    products: list[dict],
    order: list[dict]
) -> list[dict]:
    """Costruisce il riepilogo delle righe valide dell'ordine."""

    summary = []

    for line in order:
        product = find_product(products, line["product"])

        if product is None:
            continue

        quantity = line["quantity"]

        summary.append({
            "name": product["name"],
            "quantity": quantity,
            "total": calculate_line_total(product, quantity)
        })

    return summary

Possiamo utilizzare le funzioni:

products = [
    {"name": "Milk", "price": 1.50, "stock": 20},
    {"name": "Bread", "price": 2.00, "stock": 8},
    {"name": "Eggs", "price": 3.20, "stock": 15},
    {"name": "Apples", "price": 2.50, "stock": 30}
]

order = [
    {"product": "Milk", "quantity": 3},
    {"product": "Eggs", "quantity": 2},
    {"product": "Apples", "quantity": 5}
]

summary = get_order_summary(products, order)
total = calculate_order_total(products, order)
low_stock = get_low_stock(products)

print("========================================")
print("          RIEPILOGO ORDINE")
print("========================================")
print()

for line in summary:
    print(
        f"{line['name']:10} × "
        f"{line['quantity']:<3} "
        f"{line['total']:7.2f} €"
    )

print()
print(f"Totale ordine: {total:.2f} €")

print()
print("Prodotti con scorta bassa:")

for product in low_stock:
    print(f"- {product['name']}")

print()
print("========================================")

4.68 Migliorare la soluzione

La soluzione precedente funziona, ma possiamo riflettere su alcuni aspetti progettuali.

La funzione:

find_product()

conosce la struttura dei prodotti.

La funzione:

calculate_line_total()

si occupa soltanto del calcolo della riga.

La funzione:

calculate_order_total()

coordina il calcolo.

La funzione:

get_order_summary()

prepara i dati da presentare.

Abbiamo quindi una struttura:

                    programma
                       │
              ┌────────┴────────┐
              ↓                 ↓
       gestione ordine     gestione inventario
              │                 │
      ┌───────┴───────┐         │
      ↓               ↓         ↓
 find_product   line_total   get_low_stock
      │
      ↓
calculate_order_total

Questa suddivisione sarà sempre più importante nei capitoli successivi.


4.69 Una funzione non deve sapere tutto

Un buon principio progettuale è limitare le responsabilità.

Per esempio, calculate_order_total() non dovrebbe necessariamente stampare:

print("Totale:", total)

È meglio:

return total

In questo modo il chiamante può decidere cosa fare:

total = calculate_order_total(products, order)

print(f"Totale: {total:.2f} €")

oppure:

save_total_to_file(total)

oppure:

send_total_by_email(total)

La funzione di calcolo non deve conoscere il sistema di visualizzazione o di salvataggio.

Questa separazione è una delle basi della progettazione del software.


4.70 Riepilogo del capitolo

In questo capitolo abbiamo imparato a utilizzare le funzioni per organizzare e riutilizzare il codice.

Abbiamo visto che:

  • def definisce una funzione;
  • una funzione viene eseguita quando viene chiamata;
  • i parametri rappresentano i dati attesi dalla funzione;
  • gli argomenti sono i valori forniti durante la chiamata;
  • gli argomenti possono essere posizionali o keyword;
  • return restituisce un risultato e termina la funzione;
  • una funzione senza return restituisce None;
  • una funzione può restituire più valori attraverso una tupla;
  • i parametri possono avere valori predefiniti;
  • è rischioso utilizzare direttamente valori mutabili come default;
  • *args raccoglie argomenti posizionali;
  • **kwargs raccoglie argomenti keyword;
  • * può espandere un iterabile durante una chiamata;
  • ** può espandere un dizionario durante una chiamata;
  • / permette di definire parametri positional-only;
  • * nella firma permette di definire parametri keyword-only;
  • le variabili hanno uno scope;
  • Python utilizza la regola LEGB per la ricerca dei nomi;
  • global permette di riferirsi a una variabile globale;
  • nonlocal permette di modificare una variabile di uno scope esterno non globale;
  • le docstring documentano le funzioni;
  • i type hints descrivono i tipi attesi;
  • le funzioni sono first-class objects;
  • le funzioni possono essere passate come argomenti;
  • lambda permette di creare piccole funzioni anonime;
  • map(), filter() e sorted() possono ricevere funzioni;
  • le closure conservano variabili dello scope esterno;
  • i decoratori aggiungono comportamento alle funzioni;
  • functools offre strumenti avanzati per lavorare con funzioni;
  • la ricorsione permette a una funzione di richiamare se stessa;
  • una ricorsione deve avere un caso base;
  • le funzioni pure sono più facili da comprendere e testare;
  • dividere un programma in funzioni con responsabilità chiare migliora leggibilità e manutenzione.

Il modello mentale costruito nei primi quattro capitoli è ora:

CAPITOLO 1
DATI
│
├── variabili
├── tipi
├── stringhe
└── operatori

CAPITOLO 2
CONTROLLO
│
├── if
├── for
├── while
└── condizioni

CAPITOLO 3
STRUTTURE DATI
│
├── list
├── tuple
├── set
└── dict

CAPITOLO 4
FUNZIONI
│
├── parametri
├── return
├── scope
├── funzioni come valori
└── composizione

Ora abbiamo gli strumenti fondamentali per iniziare a costruire programmi strutturati.

Nel prossimo capitolo entreremo nel mondo della programmazione a oggetti, imparando a rappresentare nel codice entità, stato e comportamento attraverso classi e oggetti.

← → per navigare F fullscreen