Dall’indicizzazione alle list comprehension, fino alla gestione di matrici e all’analisi di file di log: sei esercizi per utilizzare le liste Python in situazioni concrete.
Le liste sono una delle strutture dati che si incontrano più spesso quando si programma in Python. Permettono di raccogliere valori, conservarne l’ordine, accedere ai singoli elementi, modificare il contenuto e costruire nuove sequenze a partire da quelle esistenti. La loro sintassi è semplice, ma proprio questa semplicità può nascondere alcune insidie, soprattutto quando le liste diventano grandi o contengono altre strutture al loro interno.
Per lavorare bene con le liste non basta ricordare che append() aggiunge un elemento o che pop() lo rimuove: occorre capire come funzionano gli indici, quali differenze esistono tra indicizzazione e slicing, quando una modifica avviene direttamente sulla lista originale e quando viene creata una nuova struttura. Diventa inoltre importante conoscere il costo delle operazioni e saper scegliere una soluzione leggibile, senza ricorrere alle list comprehension quando un ciclo tradizionale sarebbe più adatto.
Gli esercizi proposti seguono una progressione precisa. Si parte dall’estrazione di valori da una lista di temperature, si passa allo slicing e ai metodi di modifica, per arrivare alle trasformazioni con le list comprehension e alla gestione di matrici. L’ultimo esercizio riunisce questi strumenti in un piccolo analizzatore di log, con controlli sui dati in ingresso e un report riepilogativo.
Ogni soluzione è accompagnata da commenti, output attesi e osservazioni sui comportamenti di Python che vale la pena comprendere, soprattutto quando si passa dagli esempi didattici a programmi destinati a essere utilizzati davvero.
🟢 Esercizio 1 — Il termometro della serra: indicizzazione di base
Il problema
Una serra automatizzata registra la temperatura in gradi Celsius ogni giorno per una settimana. Il sensore produce una lista di sette valori:
temperature = [18.5, 21.0, 19.7, 24.3, 26.8, 22.1, 17.9]
Il sistema di controllo deve estrarre alcune informazioni per il report settimanale:
- la temperatura del primo giorno;
- la temperatura dell’ultimo giorno;
- la temperatura del giorno centrale;
- i valori dei primi tre giorni e degli ultimi due;
- la lista senza il primo e l’ultimo valore;
- la sequenza invertita, dal giorno più recente al più vecchio.
Scrivi una funzione report_temperature(temperature) che restituisca un dizionario con tutte queste informazioni e gestisca esplicitamente il caso in cui la lista sia vuota.
Strategia e richiami teorici
Per lavorare con una lista occorre distinguere due operazioni: l’indicizzazione, che restituisce un singolo elemento, e lo slicing, che restituisce una nuova lista contenente una porzione della sequenza.
L’indicizzazione positiva parte da zero: lista[0] restituisce il primo elemento, mentre lista[n - 1] restituisce l’ultimo, dove n è la lunghezza della lista. Python mette però a disposizione anche gli indici negativi, che permettono di contare gli elementi a partire dalla fine: lista[-1] è l’ultimo elemento e lista[-2] il penultimo.
Lo slicing utilizza la forma lista[start:stop:step], nella quale l’indice iniziale è incluso e quello finale è escluso. Alcuni esempi utili sono:
| Espressione | Risultato |
|---|---|
lista[:3] |
Primi tre elementi |
lista[-2:] |
Ultimi due elementi |
lista[1:-1] |
Tutti gli elementi tranne il primo e l’ultimo |
lista[::-1] |
Lista invertita |
Per individuare l’elemento centrale si può utilizzare la divisione intera len(lista) // 2. Con sette elementi, l’indice ottenuto è 3, che corrisponde al quarto elemento. Se il numero di elementi è pari, invece, non esiste un unico elemento centrale: l’indice n // 2 seleziona il secondo dei due elementi centrali.
Soluzione commentata
def report_temperature(temperature: list[float]) -> dict:
"""
Costruisce un report settimanale delle temperature.
Args:
temperature: lista di letture giornaliere in °C.
Returns:
Dizionario con i valori principali del report.
Raises:
ValueError: se la lista è vuota.
TypeError: se un elemento non è numerico.
"""
# Verifica che siano disponibili delle letture
if not temperature:
raise ValueError(
"Nessuna temperatura registrata: report impossibile."
)
# bool è una sottoclasse di int in Python: lo escludiamo
# perché True e False non sono letture termiche valide.
if not all(
isinstance(t, (int, float)) and not isinstance(t, bool)
for t in temperature
):
raise TypeError("Tutte le temperature devono essere numeriche.")
n = len(temperature)
return {
"prima": temperature[0],
"ultima": temperature[-1],
"centrale": temperature[n // 2],
"primi_tre": temperature[:3],
"ultimi_due": temperature[-2:],
"senza_estremi": temperature[1:-1],
"inversa": temperature[::-1],
}
# Esempio di utilizzo
if __name__ == "__main__":
temperature = [18.5, 21.0, 19.7, 24.3, 26.8, 22.1, 17.9]
risultato = report_temperature(temperature)
for chiave, valore in risultato.items():
print(f"{chiave:>14}: {valore}")
Output atteso
prima: 18.5
ultima: 17.9
centrale: 24.3
primi_tre: [18.5, 21.0, 19.7]
ultimi_due: [22.1, 17.9]
senza_estremi: [21.0, 19.7, 24.3, 26.8, 22.1]
inversa: [17.9, 22.1, 26.8, 24.3, 19.7, 21.0, 18.5]
Osservazioni
Perché preferire temperature[-1] a temperature[6]?
L’indice negativo permette di individuare l’ultimo elemento senza conoscere la lunghezza della lista. Se il sensore dovesse registrare un numero diverso di giorni, temperature[-1] continuerebbe a restituire l’ultima lettura, mentre temperature[6] funzionerebbe soltanto quando esiste un elemento in quella posizione.
Che cosa succede quando il numero di elementi è pari?
Con una lista di sei valori, len(temperature) // 2 restituisce 3, quindi viene selezionato l’elemento in posizione 3, cioè il quarto. I due elementi centrali sarebbero quello in posizione 2 e quello in posizione 3. La funzione adotta quindi una convenzione precisa: quando il numero di letture è pari, considera il secondo dei due valori centrali.
Perché controllare la lista vuota?
Senza il controllo iniziale, l’accesso a temperature[0] o temperature[-1] genererebbe un IndexError. La funzione sceglie invece di sollevare un ValueError con un messaggio che descrive il problema in termini comprensibili per chi utilizza il codice. Non esiste una regola universale secondo cui ogni funzione debba validare ogni possibile input: la scelta dipende dal contratto della funzione e dal comportamento che si vuole garantire a chi la richiama.
Domanda di riflessione
Quale proprietà dell’indicizzazione negativa permette di ottenere l’ultimo elemento senza conoscere la lunghezza della lista? Che cosa accade quando la lista è vuota e perché Python solleva un’eccezione invece di restituire None?
🟢 Esercizio 2 — I turni della farmacia: slicing con passo e rotazioni
Il problema
Una farmacia organizza i turni di otto dipendenti, disposti in ordine alfabetico:
personale = [
"Anna", "Bruno", "Carla", "Dario",
"Elena", "Fabio", "Gina", "Hugo"
]
Il responsabile vuole suddividere il personale in due squadre alternate, utilizzando gli elementi che occupano le posizioni pari e dispari della lista. Deve inoltre poter ruotare l’organico di un certo numero di posizioni, in modo che, dopo una rotazione di tre posti, il dipendente che si trovava in posizione 3 diventi il primo della nuova sequenza.
Completa il programma in modo che:
- la squadra A comprenda gli elementi agli indici 0, 2, 4 e 6;
- la squadra B comprenda gli elementi agli indici 1, 3, 5 e 7;
- la funzione
ruota(lista, k)restituisca una nuova lista ruotata a sinistra; - siano gestiti anche valori negativi di
ke valori superiori alla lunghezza della lista; - venga verificato il comportamento dello slicing
personale[10:20].
Strategia e richiami teorici
Lo slicing consente di selezionare gli elementi utilizzando anche un passo diverso da uno. L’espressione lista[::2] parte dal primo elemento e procede saltandone uno, mentre lista[1::2] parte dal secondo e continua con lo stesso intervallo.
La rotazione a sinistra di k posizioni può essere realizzata concatenando due porzioni della lista:
lista[k:] + lista[:k]
La prima porzione contiene gli elementi che rimangono dopo lo spostamento, mentre la seconda raccoglie quelli che vengono riportati in fondo alla sequenza.
Per gestire valori di k negativi o superiori alla lunghezza della lista si può utilizzare l’operatore modulo:
k %= len(lista)
In questo modo la rotazione viene ricondotta a un numero di posizioni compreso tra zero e len(lista) - 1. Occorre però gestire separatamente la lista vuota, perché il modulo per zero genererebbe un errore.
Soluzione commentata
def ruota(lista: list[str], k: int) -> list[str]:
"""
Ruota a sinistra una lista di k posizioni.
Restituisce una nuova lista senza modificare quella originale.
"""
n = len(lista)
if n == 0:
return []
# Normalizza il numero di posizioni da ruotare
k %= n
# Sposta in fondo gli elementi iniziali
return lista[k:] + lista[:k]
def squadre(personale: list[str]) -> dict[str, list[str]]:
"""
Divide il personale in due squadre alternate.
"""
return {
"squadra_A": personale[::2],
"squadra_B": personale[1::2],
}
# Esempio di utilizzo
if __name__ == "__main__":
personale = [
"Anna", "Bruno", "Carla", "Dario",
"Elena", "Fabio", "Gina", "Hugo"
]
print("Squadre:")
print(squadre(personale))
print("\nRotazione di 3:")
print(ruota(personale, 3))
print("\nRotazione di -1:")
print(ruota(personale, -1))
print("\nRotazione di 11:")
print(ruota(personale, 11))
print("\nSlicing fuori intervallo:")
print(personale[10:20])
print("\nSlicing vuoto:")
print(personale[3:3])
Output atteso
Squadre:
{
'squadra_A': ['Anna', 'Carla', 'Elena', 'Gina'],
'squadra_B': ['Bruno', 'Dario', 'Fabio', 'Hugo']
}
Rotazione di 3:
['Dario', 'Elena', 'Fabio', 'Gina', 'Hugo', 'Anna', 'Bruno', 'Carla']
Rotazione di -1:
['Hugo', 'Anna', 'Bruno', 'Carla', 'Dario', 'Elena', 'Fabio', 'Gina']
Rotazione di 11:
['Dario', 'Elena', 'Fabio', 'Gina', 'Hugo', 'Anna', 'Bruno', 'Carla']
Slicing fuori intervallo:
[]
Slicing vuoto:
[]
Osservazioni
Indicizzazione e slicing non si comportano allo stesso modo.
L’espressione personale[100] genera un IndexError, perché richiede un elemento che non esiste. Lo slicing personale[100:200], invece, restituisce una lista vuota: non sta chiedendo un elemento specifico, ma una porzione della sequenza. Se nessun elemento rientra nell’intervallo richiesto, il risultato è semplicemente [].
Il modulo gestisce anche le rotazioni negative.
In Python, -1 % 8 restituisce 7. Di conseguenza, ruota(personale, -1) equivale a una rotazione a sinistra di sette posizioni, che produce lo stesso risultato di una rotazione a destra di una posizione.
La lista originale rimane invariata.
L’operazione lista[k:] + lista[:k] crea una nuova lista. Se invece si volesse modificare direttamente quella ricevuta come argomento, si potrebbe utilizzare un’assegnazione tramite slicing:
lista[:] = lista[k:] + lista[:k]
La differenza è importante: una funzione che restituisce una nuova lista lascia al chiamante la possibilità di conservare anche la sequenza iniziale, mentre una modifica in place agisce sull’oggetto condiviso.
Domanda di riflessione
Perché personale[10:20] restituisce una lista vuota, mentre personale[10] genera un’eccezione? Qual è la differenza tra richiedere un elemento in una posizione precisa e richiedere una sotto-sequenza?
🟡 Esercizio 3 — La coda della stampante: i metodi delle liste
Il problema
Una stampante aziendale gestisce una coda di documenti da elaborare. Ogni documento è rappresentato dal nome del file e la lista iniziale è la seguente:
documenti = [
"contratto.pdf",
"fattura.pdf",
"report.pdf"
]
Il sistema deve simulare alcune operazioni tipiche della gestione della coda:
- aggiungere due documenti in fondo alla lista;
- inserire un documento urgente in testa;
- servire il primo documento, rimuovendolo dalla coda;
- rimuovere un documento specifico tramite il suo nome;
- contare le occorrenze di
report.pdfe individuarne la posizione; - ordinare alfabeticamente i documenti e invertire l’ordine;
- restituire lo stato finale e il numero di operazioni effettuate.
La funzione deve anche gestire il caso in cui il documento da rimuovere non sia presente.
Strategia e richiami teorici
Le liste Python mettono a disposizione diversi metodi per aggiungere, rimuovere, cercare e ordinare gli elementi. La scelta del metodo non dipende soltanto dal risultato desiderato, ma anche dal modo in cui l’operazione viene eseguita.
| Metodo | Costo indicativo | Comportamento |
|---|---|---|
append(x) |
O(1) ammortizzato | Aggiunge un elemento in fondo |
insert(0, x) |
O(n) | Inserisce un elemento in testa |
pop() |
O(1) | Rimuove e restituisce l’ultimo elemento |
pop(0) |
O(n) | Rimuove e restituisce il primo elemento |
remove(x) |
O(n) | Rimuove la prima occorrenza del valore |
index(x) |
O(n) | Restituisce la posizione della prima occorrenza |
count(x) |
O(n) | Conta le occorrenze del valore |
sort() |
O(n log n) | Ordina la lista in place |
sorted(lista) |
O(n log n) | Restituisce una nuova lista ordinata |
Una distinzione merita particolare attenzione: remove() cerca un elemento in base al suo valore, mentre pop() lo rimuove in base alla posizione. Se il valore cercato non esiste, remove() solleva un ValueError; se l’indice richiesto da pop() non è valido, viene sollevato un IndexError.
Anche sort() ha un comportamento che spesso genera errori: modifica la lista direttamente e restituisce None. Pertanto, scrivere coda = coda.sort() significa assegnare a coda il valore None, perdendo il riferimento alla lista ordinata.
Soluzione commentata
def simula_stampa(documenti_iniziali: list[str]) -> dict:
"""
Simula il ciclo di vita di una coda di stampa.
La lista iniziale non viene modificata.
"""
# Creiamo una copia per proteggere la lista del chiamante
coda = list(documenti_iniziali)
operazioni = 0
serviti = []
rimossi = []
# Aggiunta di due documenti in fondo alla coda
coda.append("cv.pdf")
coda.append("preventivo.pdf")
operazioni += 2
# Inserimento del documento urgente in testa
coda.insert(0, "urgente.pdf")
operazioni += 1
# Servizio del primo documento
if coda:
serviti.append(coda.pop(0))
operazioni += 1
# Rimozione del documento richiesto
try:
coda.remove("fattura.pdf")
rimossi.append("fattura.pdf")
except ValueError:
# Il documento non è presente: la coda resta invariata
pass
# In questo esempio contiamo anche il tentativo di rimozione
operazioni += 1
# Conteggio e posizione del report
occorrenze_report = coda.count("report.pdf")
posizione_report = (
coda.index("report.pdf")
if occorrenze_report > 0
else None
)
operazioni += 2
# Ordinamento alfabetico decrescente
coda.sort()
coda.reverse()
operazioni += 2
return {
"stato_finale": coda,
"serviti": serviti,
"rimossi": rimossi,
"occorrenze_report": occorrenze_report,
"posizione_report": posizione_report,
"operazioni": operazioni,
}
# Esempio di utilizzo
if __name__ == "__main__":
documenti = [
"contratto.pdf",
"fattura.pdf",
"report.pdf"
]
risultato = simula_stampa(documenti)
for chiave, valore in risultato.items():
print(f"{chiave:>20}: {valore}")
print("\nLista originale intatta:", documenti)
Output atteso
stato_finale: ['report.pdf', 'preventivo.pdf', 'contratto.pdf', 'cv.pdf']
serviti: ['urgente.pdf']
rimossi: ['fattura.pdf']
occorrenze_report: 1
posizione_report: 1
operazioni: 9
Lista originale intatta: ['contratto.pdf', 'fattura.pdf', 'report.pdf']
Osservazioni
La posizione degli elementi ha un costo.
pop(0) e insert(0, x) sono operazioni O(n), perché una lista Python memorizza i riferimenti agli elementi in una sequenza contigua. Quando si rimuove il primo elemento, quelli successivi devono essere spostati per riempire lo spazio lasciato libero; quando si inserisce un elemento in testa, occorre invece spostarli verso destra.
Per una coda FIFO con molti elementi, una lista non è quindi sempre la struttura più adatta. Il modulo collections mette a disposizione deque, una struttura progettata per aggiungere e rimuovere elementi da entrambe le estremità in tempo O(1):
from collections import deque
coda = deque(["contratto.pdf", "fattura.pdf", "report.pdf"])
coda.append("cv.pdf")
documento_servito = coda.popleft()
print(documento_servito)
remove() e pop() rispondono a esigenze diverse.
Quando conosci il valore da eliminare, utilizzi remove(). Quando conosci la posizione, utilizzi pop(), che restituisce anche l’elemento rimosso. La gestione delle eccezioni deve rispettare questa distinzione.
La copia evita modifiche indesiderate.
La riga coda = list(documenti_iniziali) crea una copia superficiale della lista. In questo esempio gli elementi sono stringhe, quindi la copia è sufficiente a evitare che le aggiunte e le rimozioni modifichino la sequenza originale. Con liste contenenti altre strutture mutabili, invece, una copia superficiale non duplicherebbe anche gli oggetti interni.
Che cosa significa contare le operazioni?
Nel programma il contatore aumenta anche quando viene tentata la rimozione di fattura.pdf, indipendentemente dal fatto che il documento sia presente. Il valore 9 rappresenta quindi il numero di chiamate ai metodi e di operazioni previste dal flusso, non il numero di modifiche effettivamente riuscite.
Domanda di riflessione
Perché sort() restituisce None invece della lista ordinata? Per quale motivo pop(0) ha un costo O(n), mentre pop() ha un costo O(1)? Quale struttura dati sarebbe più adatta per una coda con migliaia di documenti?
🟡 Esercizio 4 — Sensori IoT: filtrare e trasformare con le list comprehension
Il problema
Un impianto industriale dispone di dieci sensori che rilevano la temperatura in gradi Celsius. Le letture ricevute sono raccolte nella seguente lista:
letture = [
21.4, 25.8, 19.2, 30.1, 27.6,
18.9, 33.4, 22.0, 29.5, 24.7
]
Il sistema di monitoraggio deve produrre un report che contenga:
- le letture superiori a 25 °C, da considerare come allarmi;
- tutte le letture convertite in Fahrenheit e arrotondate a una cifra decimale;
- le letture normali, cioè inferiori o uguali a 25 °C, convertite in Kelvin;
- un’etichetta
OKoppureALERTper ogni sensore; - un istogramma testuale che mostri la distribuzione delle temperature nelle fasce
<20,20-25,25-30e≥30.
La soluzione deve utilizzare le list comprehension per filtrare e trasformare i valori, senza ricorrere a cicli espliciti con append().
Strategia e richiami teorici
Una list comprehension permette di costruire una nuova lista partendo da un iterabile, applicando una trasformazione e, se necessario, una condizione di filtro.
La forma generale è:
[espressione for elemento in iterabile if condizione]
La posizione della condizione if è importante. Quando si trova dopo la clausola for, seleziona soltanto gli elementi che soddisfano il criterio. Quando invece si utilizza un’espressione condizionale nella parte iniziale, ogni elemento genera comunque un risultato, che cambia in base alla condizione.
# Filtro: il risultato può contenere meno elementi
[valore for valore in lista if condizione]
# Trasformazione condizionale: il risultato mantiene la lunghezza iniziale
["ALERT" if condizione else "OK" for valore in lista]
Le comprehension sono particolarmente utili quando la trasformazione è chiara e lineare. Se la logica diventa articolata, comprende molti passaggi o richiede effetti collaterali, un ciclo for tradizionale può risultare più leggibile.
Soluzione
SOGLIA_ALLARME = 25.0
def analizza_letture(letture: list[float]) -> dict:
"""
Analizza le temperature ricevute dai sensori IoT.
"""
if not letture:
raise ValueError("Nessuna lettura disponibile.")
if not all(
isinstance(t, (int, float)) and not isinstance(t, bool)
for t in letture
):
raise TypeError("Le letture devono essere numeriche.")
# Seleziona le temperature superiori alla soglia
allarmi = [
t
for t in letture
if t > SOGLIA_ALLARME
]
# Converte tutte le temperature in Fahrenheit
fahrenheit = [
round(t * 9 / 5 + 32, 1)
for t in letture
]
# Seleziona le temperature normali e le converte in Kelvin
kelvin_normali = [
round(t + 273.15, 1)
for t in letture
if t <= SOGLIA_ALLARME
]
# Assegna un'etichetta a ogni lettura
etichette = [
"ALERT" if t > SOGLIA_ALLARME else "OK"
for t in letture
]
# Conta le letture appartenenti a ciascuna fascia
fasce = [
("<20", sum(1 for t in letture if t < 20)),
("20-25", sum(1 for t in letture if 20 <= t < 25)),
("25-30", sum(1 for t in letture if 25 <= t < 30)),
("≥30", sum(1 for t in letture if t >= 30)),
]
# Costruisce le barre dell'istogramma
istogramma = {
nome: "█" * conteggio
for nome, conteggio in fasce
}
return {
"allarmi": allarmi,
"fahrenheit": fahrenheit,
"kelvin_normali": kelvin_normali,
"etichette": etichette,
"istogramma": istogramma,
}
# Esempio di utilizzo
if __name__ == "__main__":
letture = [
21.4, 25.8, 19.2, 30.1, 27.6,
18.9, 33.4, 22.0, 29.5, 24.7
]
risultato = analizza_letture(letture)
print("Allarmi: ", risultato["allarmi"])
print("Fahrenheit: ", risultato["fahrenheit"])
print("K normali: ", risultato["kelvin_normali"])
print("Etichette: ", risultato["etichette"])
print("\nDistribuzione:")
for fascia, barra in risultato["istogramma"].items():
print(f"{fascia:>6} | {barra}")
Output atteso
Allarmi: [25.8, 30.1, 27.6, 33.4, 29.5]
Fahrenheit: [70.5, 78.4, 66.6, 86.2, 81.7, 66.0, 92.1, 71.6, 85.1, 76.5]
K normali: [294.6, 292.4, 292.1, 295.2, 297.9]
Etichette: ['OK', 'ALERT', 'OK', 'ALERT', 'ALERT', 'OK', 'ALERT', 'OK', 'ALERT', 'OK']
Distribuzione:
<20 | ██
20-25 | ███
25-30 | ███
≥30 | ██
Osservazioni
Filtro e trasformazione non sono la stessa operazione.
La comprehension utilizzata per gli allarmi:
allarmi = [t for t in letture if t > 25]
restituisce soltanto i valori che superano la soglia, quindi la lista risultante può essere più corta di quella iniziale.
La comprehension delle etichette:
etichette = [
"ALERT" if t > 25 else "OK"
for t in letture
]
produce invece un’etichetta per ogni lettura e conserva la stessa lunghezza della lista di partenza. La condizione non elimina elementi, ma stabilisce quale valore inserire nel risultato.
La funzione sum() può essere utilizzata per contare gli elementi.
L’espressione:
sum(1 for t in letture if t < 20)
genera un valore 1 per ogni temperatura inferiore a 20 °C e somma i valori prodotti. In questo modo si ottiene il conteggio senza dover costruire una lista intermedia.
Le fasce dell’istogramma devono essere definite senza sovrapposizioni.
Nel programma gli intervalli sono semiaperti: 20 <= t < 25 comprende 20 ma esclude 25, mentre 25 <= t < 30 comprende 25 ma esclude 30. In questo modo ogni lettura appartiene a una sola fascia e il totale dei conteggi coincide con il numero dei sensori.
Non tutte le operazioni devono diventare una comprehension.
Una scrittura come [print(x) for x in lista] è tecnicamente possibile, ma crea una lista di valori None senza alcuna utilità. Quando si vuole stampare, salvare un risultato su file o eseguire altre operazioni con effetti collaterali, un ciclo for è la scelta più chiara.
Domanda di riflessione
Qual è la differenza tra una comprehension che contiene if dopo la clausola for e una che utilizza l’espressione A if condizione else B prima della clausola for? In quale caso il risultato può avere una lunghezza diversa dalla lista iniziale e quando conviene tornare a un ciclo for?
🟠 Esercizio 5 — Il magazzino a matrice: comprensioni annidate
Il problema
Un magazzino è organizzato in tre corsie, ciascuna delle quali contiene quattro scaffali. La matrice giacenze rappresenta la quantità disponibile per ogni prodotto:
giacenze = [
[12, 0, 5, 8], # corsia 0
[ 3, 15, 0, 2], # corsia 1
[ 7, 4, 10, 0], # corsia 2
]
Il responsabile deve ottenere alcune informazioni per aggiornare il sistema di gestione del magazzino:
- il totale delle quantità per ogni corsia;
- il totale delle quantità per ogni scaffale;
- la matrice trasposta, con righe e colonne scambiate;
- una lista piatta contenente tutte le quantità;
- le coordinate degli scaffali nei quali il prodotto è esaurito;
- una nuova matrice in cui le quantità pari a zero vengono sostituite dalla stringa
ESAURITO.
L’esercizio richiede inoltre di spiegare perché l’espressione [[0] * 4] * 3 non è un modo corretto per inizializzare una matrice con righe indipendenti.
Strategia e richiami teorici
Le comprensioni annidate permettono di costruire strutture bidimensionali utilizzando più clausole for. La prima clausola rappresenta il ciclo esterno, mentre quella successiva corrisponde al ciclo interno.
Per esempio:
[[funzione(i, j) for j in colonne] for i in righe]
è equivalente a due cicli annidati: per ogni riga vengono attraversate tutte le colonne.
La stessa logica viene utilizzata per appiattire una matrice:
[valore for riga in matrice for valore in riga]
La trasposizione può essere realizzata con una comprehension oppure con zip(*matrice). L’operatore * effettua l’unpacking delle righe, consentendo a zip() di raggruppare gli elementi che occupano la stessa posizione.
Un aspetto da non trascurare riguarda la forma della matrice: per calcolare correttamente la trasposta, tutte le righe devono avere lo stesso numero di elementi. Una matrice con righe di lunghezza diversa viene definita irregolare o non rettangolare.
Soluzione commentata
ESAURITO = "ESAURITO"
def analizza_magazzino(giacenze: list[list[int]]) -> dict:
"""
Analizza una matrice di giacenze organizzata per corsie e scaffali.
"""
# Verifica che la matrice e la prima riga non siano vuote
if not giacenze or not giacenze[0]:
raise ValueError("Matrice vuota.")
# Verifica che tutte le righe abbiano la stessa lunghezza
n_col = len(giacenze[0])
if any(len(riga) != n_col for riga in giacenze):
raise ValueError(
"La matrice deve essere rettangolare."
)
# Verifica che le quantità siano interi non negativi
if any(
not isinstance(q, int) or isinstance(q, bool) or q < 0
for riga in giacenze
for q in riga
):
raise ValueError(
"Le giacenze devono contenere interi non negativi."
)
# Totale per corsia: somma degli elementi di ogni riga
totali_corsia = [
sum(riga)
for riga in giacenze
]
# Totale per scaffale: somma degli elementi di ogni colonna
totali_scaffale = [
sum(colonna)
for colonna in zip(*giacenze)
]
# Trasposizione mediante comprensione annidata
trasposta = [
[
giacenze[i][j]
for i in range(len(giacenze))
]
for j in range(n_col)
]
# Appiattimento della matrice
piatto = [
quantita
for riga in giacenze
for quantita in riga
]
# Coordinate degli scaffali senza disponibilità
esauriti = [
(i, j)
for i, riga in enumerate(giacenze)
for j, quantita in enumerate(riga)
if quantita == 0
]
# Nuova matrice con l'etichetta ESAURITO al posto degli zeri
matrice_etichettata = [
[
ESAURITO if quantita == 0 else quantita
for quantita in riga
]
for riga in giacenze
]
return {
"totali_corsia": totali_corsia,
"totali_scaffale": totali_scaffale,
"trasposta": trasposta,
"piatto": piatto,
"esauriti": esauriti,
"matrice_etichettata": matrice_etichettata,
}
# Esempio di utilizzo
if __name__ == "__main__":
giacenze = [
[12, 0, 5, 8],
[ 3, 15, 0, 2],
[ 7, 4, 10, 0],
]
risultato = analizza_magazzino(giacenze)
print("Totali per corsia: ", risultato["totali_corsia"])
print("Totali per scaffale:", risultato["totali_scaffale"])
print("Trasposta: ", risultato["trasposta"])
print("Flatten: ", risultato["piatto"])
print("Esauriti:", risultato["esauriti"])
print("\nMatrice etichettata:")
for riga in risultato["matrice_etichettata"]:
print(" ", riga)
# Dimostrazione del problema di aliasing
print("\n--- Matrice inizializzata in modo errato ---")
sbagliata = [[0] * 3] * 2
sbagliata[0][0] = 99
print(sbagliata)
# Inizializzazione corretta con righe indipendenti
print("\n--- Matrice inizializzata correttamente ---")
giusta = [[0] * 3 for _ in range(2)]
giusta[0][0] = 99
print(giusta)
Output atteso
Totali per corsia: [25, 20, 21]
Totali per scaffale: [22, 19, 15, 10]
Trasposta: [[12, 3, 7], [0, 15, 4], [5, 0, 10], [8, 2, 0]]
Flatten: [12, 0, 5, 8, 3, 15, 0, 2, 7, 4, 10, 0]
Esauriti: [(0, 1), (1, 2), (2, 3)]
Matrice etichettata:
[12, 'ESAURITO', 5, 8]
[3, 15, 'ESAURITO', 2]
[7, 4, 10, 'ESAURITO']
--- Matrice inizializzata in modo errato ---
[[99, 0, 0], [99, 0, 0]]
--- Matrice inizializzata correttamente ---
[[99, 0, 0], [0, 0, 0]]
Osservazioni
L’ordine delle clausole for determina la struttura del risultato.
Nella comprehension:
piatto = [
valore
for riga in giacenze
for valore in riga
]
la prima clausola attraversa le righe, mentre la seconda attraversa i valori contenuti in ciascuna riga. L’ordine è lo stesso che si utilizzerebbe scrivendo due cicli tradizionali:
piatto = []
for riga in giacenze:
for valore in riga:
piatto.append(valore)
La trasposta può essere ottenuta anche con zip().
La soluzione seguente è più compatta:
trasposta = [
list(colonna)
for colonna in zip(*giacenze)
]
zip(*giacenze) raggruppa gli elementi per colonna e restituisce tuple; la conversione con list() produce righe rappresentate da liste. È una forma idiomatica e spesso più leggibile, ma occorre ricordare che zip() si ferma alla sequenza più corta. Se la matrice contiene righe di lunghezza diversa, la trasposta può risultare troncata senza generare un errore.
Il problema di [[0] * 4] * 3 è legato ai riferimenti condivisi.
L’espressione:
sbagliata = [[0] * 4] * 3
crea una lista interna e poi replica tre volte il riferimento a quella stessa lista. Non vengono create tre righe indipendenti: tutte le posizioni della lista esterna puntano allo stesso oggetto.
Di conseguenza, modificare un elemento di una riga significa modificare anche quello che viene visualizzato nelle altre righe.
La soluzione corretta è:
giusta = [[0] * 4 for _ in range(3)]
La comprehension esegue tre volte la creazione della lista interna, ottenendo tre oggetti distinti. Questa è la forma da utilizzare quando si inizializzano matrici con elementi mutabili.
Una copia profonda tramite copy.deepcopy() può essere utile quando si devono duplicare strutture annidate già esistenti, ma per inizializzare una matrice di questo tipo la comprehension è più semplice e diretta.
Domanda di riflessione
Che cosa può accadere quando si calcola la trasposta di una matrice con righe di lunghezza diversa? Per quale motivo [[0] * 4] * 3 produce righe che si modificano tutte insieme, mentre [[0] * 4 for _ in range(3)] mantiene le righe indipendenti?
🔴 Esercizio 6 — Analizzatore di log: dalle liste a un piccolo programma strutturato
Il problema
Un servizio web produce un file di log nel quale ogni riga contiene un timestamp, un livello di gravità e un messaggio. Alcune righe possono essere incomplete o contenere informazioni non riconosciute, come accade frequentemente nei file generati da sistemi diversi o da applicazioni che non gestiscono sempre correttamente gli errori.
Un estratto del file potrebbe essere simile al seguente:
2024-05-01T10:00:00 INFO Server avviato
2024-05-01T10:00:05 WARNING Disco al 85%
2024-05-01T10:00:12 ERROR Connessione DB persa
L’obiettivo è costruire una funzione analizza_log(righe, livelli=None) che:
- trasformi le righe valide in oggetti
dataclass; - scarti le righe malformate e tenga traccia del loro numero;
- filtri i risultati in base al livello di gravità, quando richiesto;
- produca un conteggio per livello utilizzando
Counter; - restituisca i messaggi degli errori in ordine cronologico;
- ordini le righe valide per timestamp;
- costruisca un report testuale con un istogramma proporzionale;
- non modifichi la lista ricevuta in ingresso e segnali i parametri non validi.
L’esercizio introduce anche alcuni strumenti della libreria standard di Python, mostrando come le liste possano essere utilizzate all’interno di un programma più articolato.
Strategia e richiami teorici
Per analizzare una riga di log si può utilizzare split(maxsplit=2). Il parametro maxsplit=2 limita la suddivisione a due separatori, in modo da ottenere tre parti: timestamp, livello e messaggio. Il messaggio può quindi contenere spazi senza essere frammentato in più elementi.
La classe dataclass consente di definire una struttura dati con attributi espliciti, mentre Counter permette di contare le occorrenze dei diversi livelli senza dover gestire manualmente un dizionario.
Per ordinare le righe si può utilizzare sorted() con il parametro key, che permette di indicare l’attributo da utilizzare come criterio di ordinamento. A differenza di list.sort(), sorted() restituisce una nuova lista e non modifica quella ricevuta.
La validazione deve distinguere due situazioni. Una riga malformata può essere considerata un dato sporco atteso e quindi scartata, mantenendo il conteggio delle righe non valide. Un parametro errato passato alla funzione, invece, rappresenta un problema nell’utilizzo del programma e deve essere segnalato con un’eccezione.
Per controllare il timestamp si può utilizzare datetime.fromisoformat(), che verifica che la stringa rappresenti una data e un’ora riconosciute nel formato ISO. Nel nostro esempio i timestamp sono privi di fuso orario; per questo il programma richiede che tutte le righe valide adottino la stessa convenzione temporale. Se si utilizzano timestamp con fusi orari differenti, è opportuno normalizzarli prima del confronto.
Soluzione commentata
from collections import Counter
from dataclasses import dataclass
from datetime import datetime
LIVELLI_VALIDI = frozenset({"INFO", "WARNING", "ERROR"})
@dataclass(frozen=True)
class VoceLog:
"""
Rappresenta una riga di log validata.
Il timestamp viene conservato come oggetto datetime,
così da poter ordinare correttamente le date.
"""
timestamp: datetime
livello: str
messaggio: str
def _parse_riga(riga: str) -> VoceLog | None:
"""
Converte una riga grezza in un oggetto VoceLog.
Restituisce None se la riga non rispetta il formato atteso.
Formato:
YYYY-MM-DDTHH:MM:SS LIVELLO messaggio
"""
if not isinstance(riga, str):
raise TypeError("Ogni riga deve essere una stringa.")
riga = riga.strip()
if not riga:
return None
# Separazione in timestamp, livello e messaggio
parti = riga.split(maxsplit=2)
if len(parti) != 3:
return None
timestamp_testo, livello, messaggio = parti
if livello not in LIVELLI_VALIDI:
return None
# Verifica che il timestamp sia interpretabile
try:
timestamp = datetime.fromisoformat(timestamp_testo)
except ValueError:
return None
if not messaggio.strip():
return None
return VoceLog(
timestamp=timestamp,
livello=livello,
messaggio=messaggio
)
def analizza_log(
righe: list[str],
livelli: list[str] | None = None
) -> dict:
"""
Analizza un elenco di righe di log.
Args:
righe: lista di stringhe contenenti i log.
livelli: livelli da includere nel report.
None o lista vuota significano nessun filtro.
Returns:
Dizionario contenente voci, conteggi, errori,
righe scartate e report testuale.
Raises:
TypeError: se gli argomenti hanno un tipo non valido.
ValueError: se vengono indicati livelli sconosciuti
o timestamp con convenzioni temporali miste.
"""
# Validazione della lista principale
if not isinstance(righe, list):
raise TypeError("'righe' deve essere una lista di stringhe.")
# Validazione del filtro
if livelli is not None:
if not isinstance(livelli, list):
raise TypeError("'livelli' deve essere una lista o None.")
if not all(isinstance(livello, str) for livello in livelli):
raise TypeError("Ogni livello deve essere una stringa.")
sconosciuti = set(livelli) - LIVELLI_VALIDI
if sconosciuti:
raise ValueError(
f"Livelli non validi: {sorted(sconosciuti)}"
)
# Parsing delle righe
parsate = [
_parse_riga(riga)
for riga in righe
]
# Elimina le righe che non hanno superato la validazione
voci = [
voce
for voce in parsate
if voce is not None
]
scartate = len(parsate) - len(voci)
# Verifica che i timestamp utilizzino la stessa convenzione:
# tutti con fuso orario oppure tutti senza fuso orario.
convenzioni = {
voce.timestamp.utcoffset() is not None
for voce in voci
}
if len(convenzioni) > 1:
raise ValueError(
"Non è possibile mescolare timestamp con e senza fuso orario."
)
# Filtro opzionale per livello
if livelli:
livelli_ammessi = set(livelli)
voci = [
voce
for voce in voci
if voce.livello in livelli_ammessi
]
# Conteggio delle occorrenze per livello
conteggi = Counter(
voce.livello
for voce in voci
)
# Ordinamento cronologico senza modificare la lista originale
voci_ordinate = sorted(
voci,
key=lambda voce: voce.timestamp
)
# Estrazione dei soli messaggi di errore
errori = [
voce.messaggio
for voce in voci_ordinate
if voce.livello == "ERROR"
]
# Costruzione dell'istogramma proporzionale
max_count = max(conteggi.values(), default=1) or 1
larghezza = 30
righe_report = [
"REPORT LOG",
"=" * 40
]
for livello, conteggio in conteggi.most_common():
lunghezza_barra = round(
conteggio * larghezza / max_count
)
barra = "█" * lunghezza_barra
righe_report.append(
f"{livello:>8} | {barra} {conteggio}"
)
if scartate:
righe_report.append(
f"{'SCARTATE':>8} | {scartate} righe malformate"
)
report = "\n".join(righe_report)
return {
"voci": voci_ordinate,
"conteggi": conteggi,
"errori": errori,
"scartate": scartate,
"report": report,
}
# Esempio di utilizzo
if __name__ == "__main__":
log_grezzo = [
"2024-05-01T10:00:00 INFO Server avviato",
"2024-05-01T10:00:05 WARNING Disco al 85%",
"riga malformata senza struttura",
"2024-05-01T10:00:12 ERROR Connessione DB persa",
"2024-05-01T10:00:20 DEBUG Livello non previsto",
"",
"2024-05-01T10:00:30 ERROR Timeout richiesta /api/ordini",
"2024-05-01T10:00:08 INFO Cache riscaldata",
]
# Analisi completa
risultato = analizza_log(log_grezzo)
print(risultato["report"])
print("\nErrori cronologici:")
for messaggio in risultato["errori"]:
print(" •", messaggio)
# Analisi filtrata: soltanto ERROR
risultato_errori = analizza_log(
log_grezzo,
livelli=["ERROR"]
)
print(
f"\nSolo ERROR: {risultato_errori['conteggi']} "
f"| messaggi: {len(risultato_errori['errori'])}"
)
# Verifica che la lista originale sia rimasta invariata
assert len(log_grezzo) == 8
Output atteso
REPORT LOG
========================================
ERROR | ██████████████████████████████ 2
INFO | ██████████████████████████████ 2
WARNING | ███████████████ 1
SCARTATE | 3 righe malformate
Errori cronologici:
• Connessione DB persa
• Timeout richiesta /api/ordini
Solo ERROR: Counter({'ERROR': 2}) | messaggi: 2
Osservazioni
Perché utilizzare split(maxsplit=2)?
Se il messaggio è Connessione DB persa, una normale chiamata a split() produrrebbe più di tre elementi. Con maxsplit=2, invece, vengono effettuate al massimo due separazioni: la prima individua il timestamp, la seconda il livello, mentre tutto il testo rimanente viene conservato come messaggio.
Perché il timestamp viene convertito in datetime?
Conservare il timestamp come stringa può essere sufficiente quando tutte le date utilizzano lo stesso formato ISO e la stessa convenzione temporale. La conversione in datetime rende però più esplicito il significato del dato e permette di effettuare confronti temporali. Quando si gestiscono fusi orari differenti, occorre normalizzare i valori, per esempio convertendoli in UTC, prima di ordinarli.
Perché None per le righe malformate e le eccezioni per i parametri errati?
La funzione tratta in modo diverso gli errori che appartengono ai dati e quelli che dipendono dall’utilizzo del programma. Una riga incompleta o con un livello non riconosciuto può essere scartata e conteggiata, perché il file potrebbe contenere dati imperfetti. Un filtro che richiede un livello inesistente, invece, rappresenta un errore nella chiamata alla funzione e viene segnalato con un ValueError.
Perché non modificare la lista originale?
Il programma costruisce nuove liste durante il parsing, il filtraggio e l’ordinamento, senza riordinare o eliminare elementi dalla lista ricevuta. Questo comportamento riduce il rischio di effetti indesiderati nel codice che richiama la funzione: chi passa una lista può continuare a utilizzarla nello stato iniziale.
È più corretto descrivere analizza_log come una funzione che non modifica gli input ricevuti, piuttosto che definirla completamente pura in senso stretto. Il risultato contiene infatti un oggetto Counter, che è mutabile, e la funzione non garantisce da sola tutte le proprietà associate alla purezza funzionale.
Che cosa succede quando non ci sono righe valide?
Il programma gestisce anche questo caso. Counter sarà vuoto, max() utilizzerà il valore predefinito e l’istogramma non conterrà barre. Non viene quindi generata un’eccezione soltanto perché il file non contiene righe valide.
Domanda di riflessione
Perché una funzione di analisi dovrebbe evitare di modificare la lista ricevuta in ingresso? Per quale motivo _parse_riga() restituisce None quando incontra una riga malformata, mentre analizza_log() solleva eccezioni quando riceve parametri non validi? Qual è la differenza tra un dato sporco atteso e un errore nell’utilizzo del programma?
Risposte alle domande di riflessione
Indicizzazione negativa e lista vuota
L’indicizzazione negativa permette di accedere agli elementi partendo dalla fine della lista. In particolare, lista[-1] restituisce sempre l’ultimo elemento, indipendentemente dalla lunghezza della sequenza.
Quando la lista è vuota non esiste alcun indice valido, quindi anche lista[-1] genera un IndexError. La funzione dell’esercizio intercetta questa situazione prima di accedere agli elementi e solleva un ValueError con un messaggio più vicino al problema che si sta cercando di risolvere.
La scelta tra restituire un valore particolare e sollevare un’eccezione dipende dal contratto della funzione. In questo caso una lista vuota non consente di produrre il report richiesto, perciò l’errore esplicito è una scelta coerente.
Slicing e indicizzazione fuori intervallo
L’indicizzazione richiede un elemento in una posizione precisa, mentre lo slicing richiede una sotto-sequenza compresa tra due estremi. Se l’indice non esiste, Python solleva un’eccezione; se invece l’intervallo dello slicing non contiene elementi, restituisce una lista vuota.
| Aspetto | lista[i] |
lista[a:b] |
|---|---|---|
| Operazione | Accesso a un elemento | Estrazione di una sequenza |
| Risultato | Un elemento | Una nuova lista |
| Indici fuori intervallo | IndexError |
Intervallo adattato ai limiti |
| Risultato possibile | Un elemento valido | Anche [] |
Questa differenza rende lo slicing particolarmente utile quando si devono estrarre porzioni di sequenze senza conoscere in anticipo la loro lunghezza.
sort(), pop(0) e strutture dati per le code
Il metodo sort() modifica direttamente la lista e restituisce None. Questa scelta permette di distinguere chiaramente un’operazione che agisce sull’oggetto esistente da una funzione che produce una nuova lista, come sorted().
coda.sort() # Modifica la lista esistente
nuova_coda = sorted(coda) # Crea una nuova lista ordinata
La differenza tra pop(0) e pop() dipende dalla posizione dell’elemento da rimuovere. Eliminare l’ultimo elemento non richiede di spostare gli altri valori, mentre eliminare il primo comporta lo spostamento degli elementi successivi. Per questo il primo caso ha costo O(1), mentre il secondo ha costo O(n).
Quando si gestiscono code FIFO con molte operazioni, collections.deque è generalmente più adatta di una lista, perché permette di aggiungere e rimuovere elementi dalle estremità in tempo O(1).
Filtro e trasformazione nelle list comprehension
Le due forme principali da distinguere sono:
# Filtro
[x for x in lista if condizione]
# Trasformazione condizionale
[A if condizione else B for x in lista]
Nel primo caso gli elementi che non soddisfano la condizione vengono esclusi dal risultato, che può quindi essere più corto della lista originale. Nel secondo caso ogni elemento genera un valore, scegliendo tra A e B: la lunghezza del risultato rimane uguale a quella iniziale.
Una comprehension è una buona scelta quando la trasformazione è semplice e leggibile. Se occorre gestire molti passaggi, effetti collaterali, condizioni articolate o operazioni di input/output, un ciclo for esplicito permette di rendere più chiaro il flusso del programma.
Matrici irregolari e riferimenti condivisi
Una matrice rettangolare ha lo stesso numero di elementi in ogni riga. Quando questa condizione non è rispettata, la trasposizione tramite indicizzazione può generare un IndexError se una riga è più corta, oppure ignorare alcuni elementi se una riga è più lunga.
Anche zip(*matrice) richiede attenzione: per impostazione predefinita si ferma alla sequenza più corta, producendo una trasposta troncata. La validazione della forma della matrice evita quindi risultati incompleti o errori difficili da individuare.
Il problema di [[0] * 4] * 3 è diverso: la moltiplicazione replica i riferimenti alla stessa lista interna, anziché creare tre righe indipendenti. La comprehension [[0] * 4 for _ in range(3)] crea invece una nuova lista a ogni iterazione e mantiene separate le righe.
Dati sporchi ed errori nei parametri
La funzione che analizza i log deve distinguere tra dati esterni non conformi e parametri di chiamata non validi.
Una riga malformata può essere considerata un’anomalia prevista nel file: viene scartata, ma il programma continua l’elaborazione e registra quante righe non sono state utilizzate. Un livello non riconosciuto passato come filtro, invece, è un errore nella richiesta effettuata alla funzione e deve essere segnalato con un’eccezione.
La distinzione può essere sintetizzata così:
| Situazione | Comportamento |
|---|---|
| Riga di log incompleta | Scarto e conteggio |
| Timestamp non valido | Scarto e conteggio |
| Livello non riconosciuto nella riga | Scarto e conteggio |
| Livello non valido nel filtro | ValueError |
| Lista principale di tipo errato | TypeError |
| Elemento della lista non testuale | TypeError |
La regola pratica è gestire le anomalie che fanno parte del dominio dei dati e segnalare gli errori che derivano da un utilizzo non corretto della funzione. In questo modo il programma può continuare a lavorare quando incontra dati imperfetti, senza nascondere problemi nella propria configurazione.
📚 Liste, strutture dati e manipolazione dei dati in Python
Le liste sono soltanto il punto di partenza per lavorare con le sequenze e organizzare i dati in Python. In questi articoli puoi approfondire le tecniche di manipolazione, l’ordinamento e l’utilizzo di strutture dati più complesse:
- 👉Python: esercizi svolti sul controllo del flusso con for, break, continue, all() e any()
Per approfondire cicli, condizioni e funzioni logiche applicate alle sequenze.
- 👉Guida completa all’ordinamento dati in Python: sorted() vs .sort(), chiavi custom ed esercizi pratici
Per imparare a ordinare liste di numeri, stringhe, tuple e dizionari utilizzando criteri personalizzati.
- 👉Imparare Python: 6 esercizi pratici sulle liste con soluzioni dettagliate
Altri esercizi per consolidare le operazioni sulle liste, le comprehension e la manipolazione dei dati.
- 👉Liste, tuple e dizionari in Python: guida completa con esercizi pratici
Per confrontare le principali strutture dati e scegliere quella più adatta alle diverse esigenze.
- 👉Python: esercizi svolti sulle matrici
Per lavorare con liste annidate, matrici, trasposizione e operazioni sugli elementi.
- 👉Python: esercizi sulle stringhe
Per estendere le competenze di indicizzazione e slicing anche alla manipolazione delle stringhe.
Dalle liste agli strumenti di analisi dei dati
I sei esercizi mostrano come le liste Python possano essere utilizzate per risolvere problemi molto diversi, dalla semplice estrazione di valori alla gestione di strutture bidimensionali e all’elaborazione di dati provenienti da sistemi informatici.
L’indicizzazione e lo slicing permettono di accedere alle informazioni senza dover ricostruire ogni volta la sequenza; i metodi delle liste consentono di modificarla e organizzarla, mentre le list comprehension rendono più dirette le operazioni di filtraggio e trasformazione. Quando le strutture diventano più complesse, le comprensioni annidate aiutano a lavorare con matrici, coordinate e dati organizzati per righe e colonne.
L’ultimo esercizio introduce un aspetto che spesso viene trascurato negli esempi più semplici: un programma non deve funzionare soltanto quando riceve dati perfetti. Deve anche sapere come comportarsi davanti a righe incomplete, valori non riconosciuti e parametri errati, evitando di modificare in modo imprevisto gli oggetti ricevuti.
La scelta dello strumento resta comunque legata al problema. Una list comprehension non è automaticamente migliore di un ciclo for, così come una lista non è sempre la struttura più adatta per gestire una coda. La qualità del codice dipende dalla capacità di valutare leggibilità, comportamento e costo delle operazioni, mantenendo chiaro il rapporto tra dati in ingresso e risultati prodotti.
Le liste costituiscono quindi una base importante per affrontare attività più articolate di programmazione e analisi dei dati, soprattutto quando vengono utilizzate insieme a dizionari, tuple, Counter, dataclass e alle altre strutture disponibili nella libreria standard di Python.




