Padroneggia i metodi dei dizionari Python: dalla creazione e modifica all’ottimizzazione avanzata. Impara a gestire gli errori e a scalare le prestazioni per workflow di produzione.
Per chiunque faccia data science o ingegneria del software, saper mappare in modo efficiente le relazioni tra i dati è una competenza imprescindibile. Che tu stia analizzando complesse risposte JSON da un’API, aggregando statistiche da un dataset enorme o semplicemente configurando le impostazioni di un’applicazione, il dizionario è probabilmente lo strumento più potente di Python. Consente manipolazioni dei dati pulite, leggibili e altamente ottimizzate.
Chiunque può cercare un valore in un dizionario; la vera competenza emerge quando sai applicarne i metodi ai tuoi workflow sui dati e sbloccare pattern avanzati.
In questo articolo vedremo le tabelle hash che rendono i dizionari così veloci, i metodi essenziali dei dizionari, le strategie di gestione degli errori e le tecniche di ottimizzazione delle prestazioni.
Se sei alle prime armi con i dizionari, ti consiglio di partire dalla nostra guida introduttiva Python Dictionary.
Cosa sono i dizionari in Python?
I dizionari in Python sono una struttura dati integrata progettata per ricerche rapide e flessibili. Ti permettono di archiviare e recuperare valori usando chiavi significative invece di posizioni numeriche, il che li rende ideali per rappresentare dati strutturati del mondo reale. Vediamo la loro struttura e le loro proprietà principali.
Architettura di base dei dizionari e hashing
Prima di entrare nei dettagli dei metodi dei dizionari di Python, è utile capire come i dizionari si basino su tabelle hash. Molti degli errori che incontrerai, come TypeError: unhashable type, derivano direttamente dal funzionamento di questa struttura.
A livello strutturale, un dizionario Python implementa una tabella hash. Questa scelta architetturale è ciò che conferisce al dizionario velocità e versatilità. Quando definisci un dizionario, stai essenzialmente creando un array sparso, spesso chiamato bucket array.
Quando inserisci una coppia chiave-valore, Python passa la chiave attraverso una funzione di hash. Questa funzione calcola un intero univoco (l’hash) che determina l’indice specifico del bucket array in cui il valore verrà memorizzato.
A causa di questo design:
Le chiavi devono essere hashable, il che di solito significa che devono essere di tipo immutabile (ad es., str, int, tuple)
I valori possono essere mutabili, incluse liste, altri dizionari o oggetti personalizzati
Ricerche, inserimenti ed eliminazioni hanno tempo medio ammortizzato O(1)
Il seguente esempio mostra alcune chiavi valide, ma dimostra anche che una lista non è accettata come chiave di un dizionario:
# Valid dictionary - immutable keys, any values
user_data ={"name":"Alice",# string key, string value42:[1,2,3],# integer key, list value(10,20):{"nested":True}# tuple key, dict value}print(type(user_data),"valid dict")# Invalid - will raise TypeErrortry:
invalid_dict ={[1,2]:"value"}# lists are not hashableexcept TypeError as e:print(f"Error: {e}")Offerto da
<class 'dict'> valid dict
Error: unhashable type: 'list'Offerto da
Questa struttura è importante per le prestazioni. Cercare un elemento in una lista richiede l’iterazione elemento per elemento, un’operazione O(n); recuperare un valore da un dizionario è invece in media un’operazione O(1).
Questo significa che cercare un ID utente in un dataset da un milione di utenti richiede all’incirca lo stesso tempo che cercarlo in un dataset da dieci utenti. Capire le differenze tra i tipi di dati in Python è fondamentale per scegliere la struttura giusta per il tuo caso d’uso.
Evoluzione dell’ordine e delle proprietà
Uno dei cambiamenti più significativi nella storia di Python è avvenuto con la versione 3.7. Prima, i dizionari erano considerati collezioni non ordinate e iterarli poteva produrre chiavi in sequenze apparentemente casuali. Stampando un dizionario, gli elementi potevano apparire in un ordine diverso rispetto all’inserimento, a seconda dei valori di hash e della storia dell’array interno.
Tuttavia, a partire da Python 3.6, i dizionari hanno iniziato a preservare l’ordine di inserimento come dettaglio d’implementazione in CPython. Poi, da Python 3.7 (e garantito ufficialmente nella specifica del linguaggio), i dizionari preservano l’ordine di inserimento.
Questo passaggio da mappature non ordinate a ordinate ha alcune implicazioni importanti per lo sviluppo moderno in Python. Ad esempio, la serializzazione JSON ora produce output prevedibili, rendendo il debug più semplice e garantendo la riproducibilità dei dati tra esecuzioni diverse.
Se lavori con pipeline di dati in cui l’ordine conta, come l’elaborazione di eventi di serie temporali o il mantenimento di gerarchie di configurazione, questa garanzia elimina un’intera classe di bug sottili. Vediamo ora come creare un dizionario.
Creare un dizionario in Python
Sebbene creare un dizionario sembri semplice, il metodo che scegli può influire sia sulla leggibilità sia sulle prestazioni del tuo codice. Python offre più modi di inizializzazione, che vanno dai letterali semplici a tecniche di generazione programmatica avanzate per i workflow di data science. Vediamo i più importanti.
Letterali con parentesi graffe
Il modo più comune e preferito per creare un dizionario è usare la sintassi con parentesi graffe {}. Questa notazione letterale non è solo più leggibile, ma anche più veloce di metodi alternativi. Python può ottimizzare direttamente il bytecode senza l’overhead di una chiamata di funzione. Ecco un esempio di dizionario:
# Preferred: Literal syntax
user_profile ={"name":"Alice","role":"Data Scientist","active":True}
user_profileOfferto da
{'name': 'Alice', 'role': 'Data Scientist', 'active': True}Offerto da
Costruttore dict()
Tuttavia, il costruttore dict() è indispensabile in alcuni scenari. Funziona come convertitore di tipo, permettendoti di costruire dizionari da sequenze di tuple o argomenti keyword. È particolarmente utile in questi casi specifici:
Le chiavi sono identificatori Python validi, ma vuoi evitare di mettere le stringhe tra virgolette
Devi trasformare strutture dati come liste di valori accoppiati
# Using keyword arguments (cleaner for string keys)
config =dict(host="localhost", port=8080, debug=True)print(config)# Converting a list of tuples (common in data processing)
pairs =[("a",1),("b",2),("c",3)]
lookup_table =dict(pairs)print(lookup_table)Offerto da
Per scenari di creazione più complessi, le dictionary comprehensions offrono un modo conciso ed efficiente per filtrare, trasformare o generare coppie chiave-valore in modo programmatico. È una tecnica essenziale per chi lavora con i dati e ha bisogno di elaborarli e rimodellarli dinamicamente.
Le comprehensions sono fondamentali per attività come:
Invertire un dizionario
Filtrare i valori nulli da un dataset
Vediamo come creare una dictionary comprehension qui sotto:
# Classic use case: Creating a squares map
squares ={x: x**2for x inrange(5)}print(squares)# Filtering data during creation
raw_data ={"a":10,"b":None,"c":5}
clean_data ={k: v for k, v in raw_data.items()if v isnotNone}print(clean_data)Offerto da
Un altro metodo utile per l’inizializzazione è dict.fromkeys(). Questo metodo crea un nuovo dizionario con le chiavi specificate e un unico valore. È spesso usato per inizializzare contatori o flag di stato.
# Initialize multiple keys with the same default value
categories =["electronics","clothing","food","books"]
inventory =dict.fromkeys(categories,0)print(inventory)# Initialize with None for optional fields
user_fields =["email","phone","address","company"]
user_profile =dict.fromkeys(user_fields)print(user_profile)Offerto da
Quando usi .fromkeys() con oggetti mutabili come liste o dizionari, tutte le chiavi faranno riferimento allo stesso oggetto in memoria. Questo crea una “condivisione del riferimento” che può portare a comportamenti inattesi. Vediamolo con un esempio:
# DANGEROUS - all keys share the same list!
categories =["A","B","C"]
wrong_way =dict.fromkeys(categories,[])
wrong_way["A"].append(1)print(wrong_way)# CORRECT - use dictionary comprehension for independent lists
right_way ={cat:[]for cat in categories}
right_way["A"].append(1)print(right_way)Offerto da
Vediamo che nello stesso valore hanno fatto riferimento tutte le chiavi nel primo caso. Per evitarlo, usa una dictionary comprehension per ottenere liste indipendenti.
Metodi dei dizionari Python per accesso e modifica
Una volta creato un dizionario, interagire con i dati al suo interno è uno dei compiti quotidiani più comuni. Vediamo alcuni modi per farlo.
Accesso e recupero dei valori
Ci sono diversi modi per accedere ai valori.
Notazione con parentesi
Il modo più diretto per recuperare un valore da un dizionario è tramite la notazione con parentesi d[key], che restituisce il valore associato se la chiave esiste. Questo approccio è ideale quando sei sicuro che la chiave sia presente nel dizionario. Ecco il codice per farlo:
product ={"name":"Laptop","price":1299.99,"stock":45,"category":"Electronics"}# Direct access with bracketsprint(product["name"])print(product["price"])# Attempting to access a non-existent key raises KeyErrortry:print(product["manufacturer"])except KeyError as e:print(f"Key not found: {e}")Offerto da
Laptop
1299.99
Key not found: 'manufacturer'Offerto da
Metodo .get()
Per un recupero più sicuro quando l’esistenza della chiave è incerta, il metodo .get() offre una soluzione elegante. Restituisce None (o un valore predefinito specificato) invece di generare un’eccezione se la chiave non esiste.
# Safe retrieval with .get()
manufacturer = product.get("manufacturer")print(manufacturer)# None# Provide a custom default value
warranty = product.get("warranty","No warranty information")print(warranty)# .get() is especially useful in data pipelines
customer_data ={"name":"John Doe","email":"[email protected]"}
phone = customer_data.get("phone","Not provided")print(phone)
address = customer_data.get("address","Not provided")print(address)Offerto da
None
No warranty information
Not provided
Not providedOfferto da
Metodo .setdefault()
Il metodo .setdefault() combina recupero e inserimento in un’unica operazione. Recupera un valore se la chiave esiste, oppure inserisce un valore predefinito e lo restituisce se la chiave manca: perfetto per pattern di accumulo.
# Using .setdefault() for initialization and retrieval
page_visits ={}# First visit to 'home' - inserts 0 and returns it
count = page_visits.setdefault("home",0)print(count)
page_visits["home"]+=1# Subsequent call returns existing value
count = page_visits.setdefault("home",0)print(count)# Practical example: grouping items
inventory =[("apple","fruit"),("carrot","vegetable"),("banana","fruit"),("broccoli","vegetable")]
grouped ={}for item, category in inventory:
grouped.setdefault(category,[]).append(item)print(grouped)Offerto da
0
1
{'fruit': ['apple', 'banana'], 'vegetable': ['carrot', 'broccoli']}Offerto da
Modifica dei dizionari
I dizionari sono dinamici; spesso dovrai aggiungere o rimuovere dati durante l’esecuzione del programma.
Aggiungere coppie chiave-valore con il metodo .update()
Aggiungere una singola coppia è semplice come un’assegnazione (d['new'] = 1), ma per operazioni in blocco il metodo .update() è superiore. Accetta un altro dizionario o un iterabile di coppie chiave-valore e li unisce all’oggetto esistente.
Vediamo come usare il metodo .update():
# Simple assignment for single key-value pairs
user ={"username":"alice_2024","role":"analyst"}
user["email"]="[email protected]"# Add new key
user["role"]="senior_analyst"# Update existing key# Bulk update with .update()
user.update({"department":"Analytics","level":3})print(user)# Update from sequence of tuples
additional_info =[("projects",12),("rating",4.8)]
user.update(additional_info)print(user)# Update with keyword arguments
user.update(active=True, certified=True)print(user)Offerto da
Per una spiegazione dettagliata sull’aggiunta di elementi, ti consiglio questa guida su Python Dictionary Append.
Rimuovere elementi da un dizionario
Python offre tre metodi distinti per rimuovere voci da un dizionario, ognuno con comportamenti e casi d’uso diversi:
.pop(key): rimuove la chiave e ne restituisce il valore. Utile quando ti serve usare il dato un’ultima volta prima di eliminarlo.
.popitem(): rimuove e restituisce l’ultima coppia chiave-valore inserita (LIFO). È un beneficio diretto della natura ordinata dei dizionari moderni.
del d[key]: elimina semplicemente la chiave. Non restituisce il valore ed è leggermente più veloce se non ti serve il valore rimosso.
Vediamo esempi di questi metodi:
Metodo .pop():
scores ={"Alice":95,"Bob":87,"Carol":92,"David":78}# .pop() - removes key and returns its value
alice_score = scores.pop("Alice")print(alice_score)print(scores)Offerto da
95
{'Bob': 87, 'Carol': 92, 'David': 78}Offerto da
Metodo .popitem():
# .popitem() - removes and returns last inserted pair (LIFO in Python 3.7+)
scores ={"Alice":95,"Bob":87,"Carol":92,"David":78}
last_item = scores.popitem()print(last_item)print(scores)Offerto da
('David', 78)
{'Alice': 95, 'Bob': 87, 'Carol': 92}Offerto da
del:
# del statement - removes key without returning value
scores ={"Alice":95,"Bob":87,"Carol":92,"David":78}del scores["Bob"]print(scores)Offerto da
Il metodo .clear() svuota l’intero dizionario, lasciandoti con un oggetto {} vuoto. Questo è diverso dall’eliminare del tutto la variabile. L’oggetto rimane in memoria, solo che è vuoto. Vediamo come funziona:
# .clear() - removes all items but keeps the dictionary object
scores ={"Alice":95,"Bob":87,"Carol":92,"David":78}
scores.clear()print(scores)print(type(scores))Offerto da
La distinzione tra questi metodi è importante. Usa .pop() quando ti serve il valore rimosso, .popitem() per un comportamento da stack, del per una rimozione semplice e .clear() per azzerare un dizionario preservandone l’identità.
Viste e iterazione
Nelle vecchie versioni di Python 2, metodi come .keys() restituivano una lista statica. Nelle versioni attuali di Python (3.x), questi restituiscono oggetti vista. Le viste sono finestre dinamiche sul dizionario. Se il dizionario cambia, la vista riflette le modifiche all’istante senza dover essere richiamata.
Iterare con .keys(), .values() e .items()
Puoi iterare sulle chiavi (.keys()), sui valori (.values()) o su entrambi contemporaneamente usando .items(). Vediamo questi metodi con un esempio:
experiment ={"model":"RandomForest","accuracy":0.94,"precision":0.91,"recall":0.89}# .keys() returns a view of all keysprint(experiment.keys())# .values() returns a view of all valuesprint(experiment.values())# .items() returns (key, value) tuples - most commonly usedfor metric, value in experiment.items():ifisinstance(value,float):print(f"{metric}: {value:.2%}")Offerto da
La natura dinamica degli oggetti vista significa che riflettono automaticamente le modifiche apportate al dizionario dopo la loro creazione. Ecco un esempio:
metrics ={"MAE":0.23,"RMSE":0.45}
keys_view = metrics.keys()print(keys_view)# Add new metric
metrics["R2"]=0.87print(keys_view)Offerto da
dict_keys(['MAE', 'RMSE'])
dict_keys(['MAE', 'RMSE', 'R2'])Offerto da
Intersezioni e unioni
Una caratteristica potente degli oggetti vista è che supportano le operazioni insiemistiche. Puoi eseguirle direttamente sulle viste delle chiavi per confrontare due dizionari in modo efficiente usando questi operatori:
Intersezione: &
Unione: |
Differenza: -
Differenza simmetrica/XOR: ^
Vediamo un esempio:
dict1 ={"a":1,"b":2,"c":3}
dict2 ={"b":20,"c":30,"d":4}# Find common keys (intersection)
common_keys = dict1.keys()& dict2.keys()print(common_keys)# Find all unique keys (union)
all_keys = dict1.keys()| dict2.keys()print(all_keys)# Find keys in dict1 but not in dict2 (difference)
unique_to_dict1 = dict1.keys()- dict2.keys()print(unique_to_dict1)# Symmetric difference - keys in either but not both
exclusive_keys = dict1.keys()^ dict2.keys()print(exclusive_keys)Offerto da
{'b', 'c'}
{'b', 'c', 'd', 'a'}
{'a'}
{'d', 'a'}Offerto da
Queste operazioni insiemistiche basate sulle viste sono molto più efficienti in memoria rispetto a convertire esplicitamente i dizionari in set, specialmente con dataset di grandi dimensioni.
Evitare errori nei metodi dei dizionari in Python
Poiché i dizionari sono spesso l’interfaccia principale per dati esterni, come payload JSON da API o file di configurazione, sono una fonte comune di errori a runtime. Scrivere codice robusto richiede più che sapere come accedere ai dati. Richiede sapere come gestirne l’assenza.
L’errore più comune con i dizionari è KeyError, che si verifica quando si tenta di accedere a una chiave inesistente. Python offre due approcci filosofici per gestirlo:
EAFP (Easier to Ask Forgiveness than Permission)
LBYL (Look Before You Leap)
EAFP: gestire KeyError ed eccezioni
Il pattern EAFP è considerato più “pythonic” e spesso offre prestazioni migliori quando le chiavi esistono di solito, poiché evita controlli ridondanti. Usa blocchi try-except per gestire gli errori dopo che si sono verificati, assumendo che le operazioni in genere riescano. Vediamo come funziona:
# EAFP approach - try first, handle exceptions
user_data ={"username":"data_analyst","email":"[email protected]"}try:
phone = user_data["phone"]print(f"Phone: {phone}")except KeyError:print("Phone number not available")
phone =None# More sophisticated error handling with specific actions
config ={"host":"localhost","port":5432}try:
database = config["database"]except KeyError:print("Warning: Database not specified, using default")
database ="default_db"
config["database"]= database # Add missing configurationOfferto da
Phone number not available
Warning: Database not specified, using defaultOfferto da
Tuttavia, ci sono scenari in cui fallire in modo evidente, lasciando propagare il KeyError, è preferibile a fallimenti silenziosi. Nell’approccio LBYL, controlli esplicitamente l’esistenza della chiave prima di accedervi. Vediamo un esempio:
# Critical configuration - fail loudly if missing
required_config ={"api_key":"secret123","endpoint":"api.example.com"}definitialize_api(config):# Don't catch KeyError - we WANT the program to crash if required keys are missing
api_key = config["api_key"]
endpoint = config["endpoint"]
timeout = config.get("timeout",30)# Optional with defaultreturn{"key": api_key,"endpoint": endpoint,"timeout": timeout}Offerto da
Invocare questa funzione genererà KeyError se manca una qualunque chiave nel dizionario, il che è il comportamento corretto perché è meglio fallire in fase di inizializzazione che in modo silenzioso in produzione.
Gestione esplicita degli errori
Quando elabori dati da fonti esterne come API o input utente, la gestione esplicita degli errori diventa importante. Vediamo come farlo con un esempio:
# Processing API response with defensive error handlingdefextract_user_info(api_response):"""Extract user information with comprehensive error handling."""
user_info ={}try:
user_info["id"]= api_response["user"]["id"]
user_info["name"]= api_response["user"]["profile"]["name"]except KeyError as e:print(f"Missing required field in API response: {e}")returnNone# Optional fields - use .get() with defaults
user_info["email"]= api_response.get("user",{}).get("contact",{}).get("email","N/A")
user_info["verified"]= api_response.get("user",{}).get("verified",False)return user_info
# Example usage
response ={"user":{"id":12345,"profile":{"name":"Jane Smith"},"verified":True}}
user = extract_user_info(response)print(user)Offerto da
{'id': 12345, 'name': 'Jane Smith', 'email': 'N/A', 'verified': True}Offerto da
Capire quando usare .get() rispetto alla notazione con parentesi o a try-except è importante.
LBYL: test proattivo di appartenenza
L’approccio LBYL usa istruzioni condizionali con gli operatori in e not in per controllare l’esistenza della chiave prima di tentare l’accesso. Questo pattern è più chiaro quando si ha a che fare con logiche condizionali o quando devi intraprendere azioni diverse in base alla presenza della chiave. Ecco un esempio:
# Proactive checking with 'in' operator
student_grades ={"Alice":95,"Bob":87,"Carol":92}# Check before access
student_name ="David"if student_name in student_grades:print(f"{student_name}'s grade: {student_grades[student_name]}")else:print(f"No grade recorded for {student_name}")# Conditional update based on existenceif"David"notin student_grades:
student_grades["David"]=0# Initialize new studentprint("New student added to grading system")# Multiple key checks for validation
required_fields =["name","email","department"]
employee_record ={"name":"John Doe","email":"[email protected]"}
missing_fields =[field for field in required_fields if field notin employee_record]if missing_fields:print(f"Error: Missing required fields: {missing_fields}")else:print("All required fields present")Offerto da
No grade recorded for David
New student added to grading system
Error: Missing required fields: ['department']Offerto da
Quando convalidi dizionari derivati da fonti esterne come JSON da API, file CSV o input utente, il test proattivo di appartenenza offre una logica di validazione chiara e leggibile. Vediamolo con un esempio:
# Validating API response structuredefvalidate_product_data(product):"""Validate product dictionary has all required fields."""
required =["id","name","price","category"]
optional =["description","stock","manufacturer"]# Check all required fields existfor field in required:if field notin product:raise ValueError(f"Missing required field: {field}")# Validate data types for existing fieldsif"price"in product andnotisinstance(product["price"],(int,float)):raise TypeError("Price must be a number")if"stock"in product and product["stock"]<0:raise ValueError("Stock cannot be negative")returnTrue# Example usage with proper error handling
product_from_api ={"id":101,"name":"Wireless Mouse","price":29.99,"category":"Electronics","stock":150}try:if validate_product_data(product_from_api):print("Product data validated successfully")# Proceed with processingexcept(ValueError, TypeError)as e:print(f"Validation failed: {e}")Offerto da
La scelta tra EAFP e LBYL dipende spesso dal caso d’uso. Usa EAFP quando le operazioni di solito vanno a buon fine e le eccezioni sono rare. Usa LBYL quando hai bisogno di ramificazioni logiche esplicite o quando devi convalidare l’input prima di operazioni costose.
Prima di iterare su un dizionario derivato da una fonte esterna, è buona pratica:
Convalidare presto
Controllare tutte le chiavi richieste in un’unica passata
Sollevare eccezioni specifiche e informative
Separare chiaramente i campi obbligatori da quelli opzionali
Convalidare più della sola presenza quando necessario
Preferire fallimenti espliciti a fallback silenziosi
Seguendo queste pratiche, renderai il tuo codice molto più robusto quando lavori con dati imprevedibili o incompleti da API, input utente o file di configurazione.
Per uno sguardo più approfondito alla scrittura di codice Python resiliente, ti consiglio il corso Writing Efficient Python Code.
Metodi avanzati dei dizionari in Python
Man mano che i progetti di data science diventano più complessi, spesso dovrai combinare, copiare e trasformare dizionari. Imparare queste operazioni avanzate ti permette di manipolare le strutture dati in modo efficiente evitando bug sottili che possono mandare fuori strada le pipeline. Vediamo alcuni di questi metodi.
Operatori di merge
Python 3.9 ha introdotto eleganti operatori di unione per unire i dizionari:
| (operatore di merge): crea un nuovo dizionario unito.
|= (operatore di update): aggiorna in place un dizionario esistente.
Questi operatori forniscono una sintassi pulita e leggibile per combinare dati da più fonti. Ecco un esempio:
# Union operator | creates a new merged dictionary
defaults ={"theme":"light","language":"en","notifications":True}
user_prefs ={"theme":"dark","font_size":14}
final_config = defaults | user_prefs
print(final_config)# Update operator |= modifies in place
settings ={"auto_save":True,"theme":"light"}
settings |={"theme":"dark","font_size":12}print(settings)Offerto da
In tutte le tecniche di merge, la regola è semplice: vince l’ultimo visto. Il valore del dizionario a destra sovrascrive quello a sinistra se le chiavi collidono:
dict1 ={"a":1,"b":2,"c":3}
dict2 ={"b":20,"c":30,"d":4}
dict3 ={"c":300,"e":5}
result = dict1 | dict2 | dict3
print(result)
result2 ={**dict1,**dict2,**dict3}print(result2)# Order matters - reversing changes the result
result3 = dict3 | dict2 | dict1
print(result3)Offerto da
Questo comportamento è coerente che tu usi |, |=, l’unpacking o il metodo .update(). L’ordine conta: è particolarmente utile per la gestione stratificata delle configurazioni (ad es., default di sistema < config d’ambiente < override dell’utente).
Meccanismi di copia
Una delle insidie più pericolose è fraintendere come Python gestisca le assegnazioni di variabili.
Le assegnazioni per riferimento creano alias, non copie
dict_a = dict_b non crea una copia. Crea un riferimento (un alias). Modificare l’uno modifica l’altro. L’esempio seguente illustra il concetto:
# Reference assignment - creates an alias, not a copy
original ={"name":"Dataset_v1","records":1000}
alias = original
# Modifying through the alias changes the original
alias["records"]=2000print(original)print(alias is original)Offerto da
{'name': 'Dataset_v1', 'records': 2000}
TrueOfferto da
Come si vede, esiste un unico dizionario e sia original sia alias lo referenziano. Di conseguenza, quando il valore della chiave records viene impostato a 2000, la modifica si applica a quell’unico dizionario.
Copie superficiali con .copy() o .dict()
Una copia superficiale (.copy() o dict()) crea un nuovo oggetto dizionario, ma gli oggetti mutabili annidati restano riferimenti condivisi.
# Shallow copy - creates a new dict but shares nested objects
original ={"name":"Experiment_A","parameters":{"learning_rate":0.01,"epochs":100},"results":[0.85,0.89,0.92]}
shallow = original.copy()# Modifying top-level keys works as expected
shallow["name"]="Experiment_B"print(original["name"])print(shallow["name"])# But modifying nested objects affects both
shallow["parameters"]["learning_rate"]=0.001print(original["parameters"]["learning_rate"])
shallow["results"].append(0.94)print(original["results"])Offerto da
Experiment_A
Experiment_B
0.001
[0.85, 0.89, 0.92, 0.94]Offerto da
Come vedi, le chiavi al primo livello si modificano come previsto, ma se modifichi oggetti mutabili annidati, la modifica riguarda sia l’originale sia la copia superficiale.
Copie profonde con copy.deepcopy()
Per questo motivo, le copie superficiali spesso causano bug sottili nelle pipeline di machine learning e nella gestione delle configurazioni.
Pertanto, per dizionari che contengono oggetti mutabili annidati, serve una copia profonda usando copy.deepcopy() dalla libreria standard. Questa copia ricorsivamente tutti gli oggetti annidati, creando strutture completamente indipendenti. Un esempio è mostrato sotto:
import copy
# Deep copy - creates completely independent nested structures
original ={"model":"RandomForest","hyperparameters":{"n_estimators":100,"max_depth":10,"min_samples_split":2},"feature_importance":[0.3,0.25,0.2,0.15,0.1]}
deep = copy.deepcopy(original)# Modify nested structures
deep["hyperparameters"]["n_estimators"]=200
deep["feature_importance"].append(0.05)# Original remains completely unchangedprint(original["hyperparameters"]["n_estimators"])print(len(original["feature_importance"]))print(len(deep["feature_importance"]))Offerto da
Implementando le seguenti tecniche, garantirai integrità dei dati e manutenibilità nelle pipeline complesse.
Usa | e |= per merge puliti dei dizionari.
Ricorda che in caso di collisione vince l’ultimo visto.
Distingui tra assegnazione per riferimento, copie superficiali e copie profonde per evitare bug sottili.
Preferisci sempre copy.deepcopy() quando lavori con strutture mutabili annidate in codice di produzione.
Tipi specializzati di dizionari in Python
Sebbene il dict standard sia versatile, la libreria standard di Python offre mappature specializzate ottimizzate per compiti specifici come conteggio, raggruppamento o imposizione di strutture dei dati. Scegliere il tipo specializzato giusto può semplificare drasticamente il tuo codice e prevenire intere classi di bug.
Estensioni del modulo collections
Il modulo collections offre alcuni utili tipi di dizionari specializzati.
defaultdict
Il defaultdict del modulo collections elimina i controlli ripetitivi sull’esistenza delle chiavi inizializzando automaticamente le chiavi mancanti con un valore predefinito. È molto utile per compiti di accumulo come conteggi, raggruppamenti o la costruzione di strutture annidate:
from collections import defaultdict
# Standard dict requires manual key checking
word_count ={}
text ="the quick brown fox jumps over the lazy dog".split()for word in text:if word notin word_count:
word_count[word]=0
word_count[word]+=1# defaultdict eliminates the check
word_count_auto = defaultdict(int)# int() returns 0for word in text:
word_count_auto[word]+=1# No checking needed!print(dict(word_count_auto))# Grouping with defaultdict(list)
transactions =[("2024-01-15","groceries",45.50),("2024-01-15","gas",60.00),("2024-01-16","groceries",32.75),("2024-01-16","entertainment",25.00),("2024-01-17","gas",55.00)]
by_date = defaultdict(list)for date, category, amount in transactions:
by_date[date].append((category, amount))for date, items in by_date.items():print(f"{date}: {items}")# Nested defaultdict for complex structures
nested = defaultdict(lambda: defaultdict(int))
events =[("2024-01","login",150),("2024-01","purchase",45),("2024-02","login",200),("2024-02","purchase",60)]for month, event_type, count in events:
nested[month][event_type]+= count
print(dict(nested))Offerto da
La classe Counter è una sottoclasse specializzata di dizionario progettata per contare oggetti hashable ed eseguire operazioni sui multiset. È particolarmente potente per analisi statistiche e distribuzioni di frequenza. Vediamo un esempio di come funziona:
from collections import Counter
# Count occurrences in a sequence
tags =["python","data","python","ml","data","python","statistics","ml"]
tag_counts = Counter(tags)print(tag_counts)# Most common elementsprint(tag_counts.most_common(2))# Counter arithmetic - multiset operations
skills_alice = Counter(["Python","SQL","Tableau","Python"])
skills_bob = Counter(["Python","R","SQL"])# Union (maximum of counts)
combined_skills = skills_alice | skills_bob
print(combined_skills)# Intersection (minimum of counts)
shared_skills = skills_alice & skills_bob
print(shared_skills)# Addition (sum of counts)
total_mentions = skills_alice + skills_bob
print(total_mentions)# Practical example: Analyzing survey responses
responses =["satisfied","neutral","satisfied","satisfied","dissatisfied","neutral","satisfied","very_satisfied"]
sentiment_analysis = Counter(responses)# Calculate percentage distribution
total =sum(sentiment_analysis.values())for sentiment, count in sentiment_analysis.most_common():
percentage =(count / total)*100print(f"{sentiment}: {count} ({percentage:.1f}%)")Offerto da
Prima di Python 3.7, OrderedDict era essenziale per preservare l’ordine di inserimento. Sebbene i dizionari standard ora mantengano l’ordine, OrderedDict ha ancora casi d’uso specifici, in particolare per confronti di uguaglianza che considerano l’ordine e per spostare elementi a un’estremità o all’altra:
from collections import OrderedDict
# OrderedDict equality considers order
dict1 ={"a":1,"b":2}
dict2 ={"b":2,"a":1}print(dict1 == dict2)
ordered1 = OrderedDict([("a",1),("b",2)])
ordered2 = OrderedDict([("b",2),("a",1)])print(ordered1 == ordered2)# Move items to beginning or end
task_queue = OrderedDict([("task1","pending"),("task2","in_progress"),("task3","pending")])# Move task3 to the beginning (highest priority)
task_queue.move_to_end("task3", last=False)print(list(task_queue.keys()))# Move task2 to the end (lowest priority)
task_queue.move_to_end("task2")print(list(task_queue.keys()))Offerto da
True
False
['task3', 'task1', 'task2']
['task3', 'task1', 'task2']Offerto da
Il grafico seguente illustra quando scegliere quale tipo di dizionario.
Sicurezza dei tipi moderna e viste in sola lettura
Con la crescente adozione di Python in ambienti di produzione su larga scala, la sicurezza dei tipi diventa un aspetto importante. I moduli typing e types introducono modi per imporre struttura ai tuoi dizionari. Comprenderee i dunder methods di Python può anche aiutarti a creare oggetti simili a dizionari con comportamenti speciali.
TypedDict
Il modulo typing di Python fornisce TypedDict per definire strutture di dizionari con chiavi richieste specifiche e annotazioni di tipo. Migliora la documentazione del codice, abilita l’autocompletamento negli IDE e intercetta errori di tipo durante l’analisi statica. Vediamo come funziona:
from typing import TypedDict
# Define strict structure for user dataclassUserProfile(TypedDict):
user_id:int
username:str
email:str
is_active:bool
role:str# Create properly typed dictionary
user: UserProfile ={"user_id":12345,"username":"data_scientist","email":"[email protected]","is_active":True,"role":"analyst"}# IDE will autocomplete and type-checkdefprocess_user(user: UserProfile)->str:# IDE knows these keys exist and their typesreturnf"User {user['username']} (ID: {user['user_id']}) - {user['role']}"# Optional keys with total=FalseclassPartialConfig(TypedDict, total=False):
host:str
port:int
database:str# All keys are optional
config: PartialConfig ={"host":"localhost"}# Valid - partial configOfferto da
MappingProxyType
Per scenari in cui devi esporre un dizionario ma impedirne la modifica, MappingProxyType crea una vista immutabile, in sola lettura, di un dizionario standard. È eccellente per proteggere costanti di configurazione globali da modifiche accidentali. Vediamo questo in azione:
from types import MappingProxyType
# Create read-only view of configuration
_INTERNAL_CONFIG ={"API_VERSION":"v2","MAX_RETRIES":3,"TIMEOUT":30,"ENDPOINTS":{"users":"/api/v2/users","data":"/api/v2/data"}}# Expose as immutable proxy
CONFIG = MappingProxyType(_INTERNAL_CONFIG)# Reading works normallyprint(CONFIG["API_VERSION"])print(CONFIG["TIMEOUT"])# Modifications raise TypeErrortry:
CONFIG["TIMEOUT"]=60except TypeError as e:print(f"Cannot modify: {e}")# Caution: nested modifications succeedtry:
CONFIG["ENDPOINTS"]["users"]="/new/endpoint"except TypeError as e:print(f"Cannot modify nested: {e}")# Practical use case: Class constantsclassDataPipeline:
_default_config ={"batch_size":1000,"parallel_workers":4,"retry_failed":True}# Expose as read-only to prevent accidental changes
DEFAULT_CONFIG = MappingProxyType(_default_config)def__init__(self, custom_config=None):# Merge with custom config while keeping defaults safe
self.config ={**self.DEFAULT_CONFIG,**(custom_config or{})}Offerto da
v2
30
Cannot modify: 'mappingproxy' object does not support item assignmentOfferto da
Come si vede, MappingProxyType rende il dizionario stesso in sola lettura (niente aggiunte, eliminazioni o riassegnazioni di chiavi), ma non congela i valori mutabili al suo interno. Per impedire modifiche alle strutture annidate, devi rendere immutabili anche quegli oggetti o avvolgerli in viste in sola lettura proprie.
Firme di funzione e type hint
Per le firme di funzione e i type hint, usa Dict e Mapping dal modulo typing per documentare le strutture di dizionario attese. Possiamo farlo come mostrato sotto:
from typing import Dict, List, Mapping, Any
# Dict for mutable dictionariesdefprocess_scores(scores: Dict[str,float])-> Dict[str,str]:"""Convert numeric scores to letter grades."""
grades ={}for student, score in scores.items():if score >=90:
grades[student]="A"elif score >=80:
grades[student]="B"elif score >=70:
grades[student]="C"else:
grades[student]="F"return grades
# Mapping for read-only or general mapping typesdefdisplay_config(config: Mapping[str, Any])->None:"""Display configuration - accepts any mapping type."""for key, value in config.items():print(f"{key}: {value}")# Works with dict, MappingProxyType, OrderedDict, etc.
display_config({"host":"localhost","port":5432})
display_config(CONFIG)# MappingProxyType from earlierOfferto da
Ottimizzare le prestazioni dei metodi dei dizionari in Python
Quando i dataset crescono da migliaia a milioni di record, l’efficienza del codice diventa importante. Per questo è necessario comprendere le caratteristiche prestazionali dei dizionari. Vediamo quindi complessità computazionale, implicazioni di memoria e strategie di ottimizzazione per le operazioni sui dizionari.
Analisi della complessità temporale
Le operazioni sui dizionari raggiungono la loro alta velocità grazie all’implementazione con tabelle hash, offrendo complessità media O(1) per le tre operazioni più comuni:
Recupero dei valori (get)
Inserimento o aggiornamento delle voci (set)
Rimozione delle voci (delete)
Queste prestazioni a tempo costante significano che tali operazioni richiedono all’incirca lo stesso ordine di grandezza di tempo in media, che il tuo dizionario contenga 10 elementi o 10 milioni. Vediamolo con un esempio di codice:
import time
import statistics
defbenchmark_lookup(size, repeats=50_000):"""
Measure the median time for a single dictionary lookup in a dictionary of given size.
Parameters:
size (int): Number of elements in the dictionary to create
repeats (int): How many times to repeat the lookup (for more stable median)
Returns:
float: Median lookup time in microseconds (μs)
"""# Create a large dictionary with string keys and integer values
large_dict ={f"key_{i}": i for i inrange(size)}# The key we will look up repeatedly (last element)
target_key =f"key_{size -1}"# Store individual measurement times (in nanoseconds)
times =[]# Perform many lookups to reduce measurement noisefor _ inrange(repeats):# Use high-resolution timer (nanoseconds)
start = time.perf_counter_ns()
_ = large_dict[target_key]# The actual dictionary lookup
end = time.perf_counter_ns()
times.append(end - start)# Calculate median time to minimize impact of outliers
median_ns = statistics.median(times)# Convert nanoseconds to microsecondsreturn median_ns /1000# Sizes to test (from 100k to 10 million elements)
sizes =[100_000,1_000_000,10_000_000]print("Dictionary lookup benchmark (median time over many repeats)\n")print(f"{'Size':>12} | {'Median Lookup Time':>18} | Notes")print("-"*50)for size in sizes:
lookup_time_us = benchmark_lookup(size)print(f"{size:>12,} | {lookup_time_us:>15.2f} μs | "f"{'→ still ~constant'if size == sizes[-1]else''}")Offerto da
Dictionary lookup benchmark (median time over many repeats)
Size | Median Lookup Time | Notes
--------------------------------------------------
100,000 | 0.14 μs |
1,000,000 | 0.14 μs |
10,000,000 | 0.14 μs | → still ~constantOfferto da
I tempi mediani di lookup sopra riportati possono variare in base alle tue risorse computazionali. Il punto chiave è che, quali che siano i valori, restano quasi identici con una certa variabilità: la differenza è del tutto trascurabile, a conferma della proprietà O(1).
Caso medio vs caso peggiore
Tuttavia, nello scenario peggiore, la complessità può degradare a O(n) quando si verificano collisioni di hash eccessive. Le collisioni avvengono quando chiavi diverse producono lo stesso valore di hash, costringendo Python a cercare tra più voci memorizzate nello stesso bucket. Vediamolo con un esempio:
# Pathological case: forcing hash collisionsclassBadHash:"""Object with intentionally poor hash function."""def__init__(self, value):
self.value = value
def__hash__(self):return1# All instances hash to same value - worst case!def__eq__(self, other):returnisinstance(other, BadHash)and self.value == other.value
# This will have O(n) performance due to collisions
bad_dict ={BadHash(i): i for i inrange(1000)}# Compare with well-distributed hashes
good_dict ={f"key_{i}": i for i inrange(1000)}# Benchmark the difference
start = time.perf_counter()
_ = bad_dict[BadHash(999)]
bad_time = time.perf_counter()- start
start = time.perf_counter()
_ = good_dict["key_999"]
good_time = time.perf_counter()- start
print(f"Bad hash lookup: {bad_time *1_000_000:.2f} μs")print(f"Good hash lookup: {good_time *1_000_000:.2f} μs")print(f"Performance degradation: {bad_time / good_time:.1f}x slower")Offerto da
Bad hash lookup: 582.48 μs
Good hash lookup: 93.99 μs
Performance degradation: 6.2x slowerOfferto da
Anche in questo caso, i risultati esatti e l’entità del degrado prestazionale varieranno in base alla tua potenza di calcolo. Ma la tendenza resta la stessa: il lookup con hash “cattivo” impiegherà molto più tempo rispetto a quello con complessità O(1).
Ridimensionamento dei dizionari
Un altro costo nascosto è il ridimensionamento. Man mano che i dizionari crescono, Python ridimensiona automaticamente la tabella hash interna per mantenere le prestazioni. Questa operazione ha un costo computazionale, perché richiede di ricalcolare gli hash di tutte le chiavi esistenti e ridistribuirle in un array più grande.
Python usa una strategia di crescita con fattore moltiplicativo, in genere raddoppiando almeno la dimensione al raggiungimento di una certa soglia. Capire questi pattern di ridimensionamento aiuta quando inizializzi grandi dizionari. Se conosci la dimensione finale approssimativa, puoi pre-allocare spazio per evitare molteplici operazioni di resize.
Gestione della memoria e dimensionamento
La velocità spesso ha un costo in termini di memoria. I dizionari hanno un overhead di memoria significativo rispetto a tuple o liste perché devono memorizzare la struttura della tabella hash (indici, hash, chiavi e valori). Vediamolo con un esempio di codice:
import sys
# Compare memory footprint of different data structures
data_list =[("name","Alice"),("age",30),("city","NYC")]
data_tuple =(("name","Alice"),("age",30),("city","NYC"))
data_dict ={"name":"Alice","age":30,"city":"NYC"}print(f"List of tuples: {sys.getsizeof(data_list)} bytes")print(f"Tuple of tuples: {sys.getsizeof(data_tuple)} bytes")print(f"Dictionary: {sys.getsizeof(data_dict)} bytes")Offerto da
List of tuples: 88 bytes
Tuple of tuples: 64 bytes
Dictionary: 184 bytesOfferto da
Usare __slots__ per ridurre la dimensione degli oggetti
Nell’esempio sopra, puoi vedere che il dizionario usa ~2x-3x memoria in più per gli stessi dati. Per classi che creano molte istanze, usare __slots__ invece del __dict__ dell’istanza può ridurre drasticamente il consumo di memoria.
Per impostazione predefinita, Python memorizza gli attributi d’istanza in un dizionario accessibile tramite __dict__, ma __slots__ usa una struttura più compatta basata su array. Vediamo un esempio:
import sys
# Regular class - uses __dict__ for attributesclassRegularUser:def__init__(self, user_id, name, email):
self.user_id = user_id
self.name = name
self.email = email
# Optimized class - uses __slots__classOptimizedUser:
__slots__ =['user_id','name','email']def__init__(self, user_id, name, email):
self.user_id = user_id
self.name = name
self.email = email
# Create instances
regular = RegularUser(12345,"Alice","[email protected]")
optimized = OptimizedUser(12345,"Alice","[email protected]")# Compare memory usageprint(f"Regular instance: {sys.getsizeof(regular.__dict__)} bytes (__dict__)")print(f"Optimized instance: {sys.getsizeof(optimized)} bytes (__slots__)")# For massive instance counts, the savings multiply
regular_users =[RegularUser(i,f"User{i}",f"user{i}@example.com")for i inrange(1000)]
optimized_users =[OptimizedUser(i,f"User{i}",f"user{i}@example.com")for i inrange(1000)]
regular_total =sum(sys.getsizeof(u.__dict__)for u in regular_users)
optimized_total =sum(sys.getsizeof(u)for u in optimized_users)print(f"\n1000 regular instances: {regular_total:,} bytes")print(f"1000 optimized instances: {optimized_total:,} bytes")print(f"Memory savings: {((regular_total - optimized_total)/ regular_total *100):.1f}%")Offerto da
Infine, sebbene i dizionari siano eccellenti per l’accesso casuale, non sono ottimizzati per l’elaborazione di dati colonnari. Se gestisci dati tabellari su larga scala (ad esempio, milioni di righe), migrare apandas DataFrameè una scelta saggia, perché sono ottimizzati sia per l’efficienza di memoria sia per la velocità vettorializzata.
import pandas as pd
import time
import sys
n =10_000# Dict
dict_data ={i:{"user_id": i,"score": i *1.5,"category":f"cat_{i %10}"}for i inrange(n)}# Optimized DF: use category dtype for strings, int32 for ids
df_data = pd.DataFrame({"user_id": pd.Series(range(n), dtype="int32"),"score":[i *1.5for i inrange(n)],"category": pd.Series([f"cat_{i %10}"for i inrange(n)], dtype="category")})
dict_memory = sys.getsizeof(dict_data)
df_memory = df_data.memory_usage(deep=True).sum()print(f"Dictionary: {dict_memory:,} bytes")print(f"Optimized DataFrame: {df_memory:,} bytes")# Bulk operation: mean score per category
start = time.perf_counter()
df_mean = df_data.groupby("category")["score"].mean()
df_time =(time.perf_counter()- start)*1_000_000# Equivalent in dict (manual loop)
start = time.perf_counter()from collections import defaultdict
means = defaultdict(lambda:[0,0])for row in dict_data.values():
cat = row["category"]
means[cat][0]+= row["score"]
means[cat][1]+=1
dict_mean ={k: s/c for k,(s, c)in means.items()}
dict_time =(time.perf_counter()- start)*1_000_000print(f"\nDF groupby mean: {df_time:.2f} μs")print(f"Dict manual mean: {dict_time:.2f} μs")Offerto da
Come puoi vedere chiaramente, nel nostro esempio il DataFrame supera il dizionario. Per workflow di data science che richiedono sia lookup rapidi sia operazioni analitiche, valuta approcci ibridi: usa i dizionari per indicizzazione e accesso veloce, poi converti in DataFrame per analisi massiva.
La chiave dell’ottimizzazione è abbinare la struttura dati ai tuoi pattern di accesso. Se esegui lookup frequenti basati su chiave, i dizionari sono ottimali. Per operazioni colonnari, filtri e aggregazioni su grandi dataset, i DataFrame offrono buone prestazioni.
Conclusione
I dizionari sono molto più di semplici contenitori. Sono la colla che tiene insieme applicazioni di data science complesse. Che tu stia costruendo una tabella di lookup veloce per uno script o progettando una pipeline dati ad alto throughput, un dizionario è probabilmente il tuo strumento più prezioso.
Tuttavia, affidarsi al comportamento predefinito senza un solido handling degli errori è una ricetta per i fallimenti a runtime. Pattern robusti di gestione degli errori, come approcci EAFP rispetto a LBYL e la validazione proattiva, con ogni probabilità eviteranno errori a runtime quando elabori dati esterni.
Raccolte specializzate come defaultdict, Counter e TypedDict trasformano il tuo codice da funzionale a pronto per la produzione. Tieni sempre a mente complessità temporale e gestione della memoria per garantire che il codice giri in modo efficiente. Ricorda anche che l’ottimizzazione è un processo iterativo.
Per continuare a sviluppare le tue abilità in Python, ti consiglio di seguire il nostro Intermediate Python per approfondire le strutture dati, oppure il più ampio percorso di carriera Python Developer per un percorso formativo completo.
Metodi dei dizionari Python: FAQ
Qual è il vantaggio di usare il metodo .get() invece della notazione con parentesi?⌄
Il metodo .get() restituisce in sicurezza None (o un valore predefinito personalizzato) se una chiave manca, mentre la notazione con le parentesi d[key] blocca il programma sollevando un KeyError.
Come funziona il metodo .setdefault()?⌄
Combina recupero e inserimento in un unico passaggio. Se la chiave esiste, restituisce il valore corrente. Se la chiave manca, la inserisce con il valore predefinito specificato e restituisce quel nuovo valore.
Qual è la differenza tra .pop() e .popitem()?⌄
Il metodo .pop(key) rimuove una chiave specifica e ne restituisce il valore. Il metodo .popitem() rimuove e restituisce l’ultima coppia chiave-valore inserita, seguendo un pattern LIFO (Last-In, First-Out).
Qual è il modo più efficiente per unire più nuovi elementi in un dizionario?
⌄
Usa il metodo .update(). Ti permette di aggiungere o sovrascrivere in blocco più coppie chiave-valore passando un altro dizionario, un iterabile di tuple o argomenti keyword.
I risultati restituiti da .keys(), .values() e .items() sono liste statiche?⌄
No, nelle versioni moderne di Python (3.x) questi metodi restituiscono oggetti vista dinamici. Se aggiungi o rimuovi elementi dal dizionario, queste viste riflettono istantaneamente le modifiche senza doverle richiamare di nuovo.
Scopri i 15 migliori server MCP remoti che stanno trasformando lo sviluppo AI nel 2026. Scopri come migliorano automazione, ragionamento, sicurezza e velocità dei workflow.
Dal test del codice all'implementazione del controllo di versione, dal web scraping allo sviluppo di pacchetti, fai un passo avanti nel tuo percorso di sviluppatore Python!
Per chiunque faccia data science o ingegneria del software, saper mappare in modo efficiente le relazioni tra i dati è una competenza imprescindibile. Che tu stia analizzando complesse risposte JSON da un’API, aggregando statistiche da un dataset enorme o semplicemente configurando le impostazioni di un’applicazione, il dizionario è probabilmente lo strumento più potente di Python. Consente manipolazioni dei dati pulite, leggibili e altamente ottimizzate.
Chiunque può cercare un valore in un dizionario; la vera competenza emerge quando sai applicarne i metodi ai tuoi workflow sui dati e sbloccare pattern avanzati.
In questo articolo vedremo le tabelle hash che rendono i dizionari così veloci, i metodi essenziali dei dizionari, le strategie di gestione degli errori e le tecniche di ottimizzazione delle prestazioni.
Se sei alle prime armi con i dizionari, ti consiglio di partire dalla nostra guida introduttiva Python Dictionary.
Cosa sono i dizionari in Python?
I dizionari in Python sono una struttura dati integrata progettata per ricerche rapide e flessibili. Ti permettono di archiviare e recuperare valori usando chiavi significative invece di posizioni numeriche, il che li rende ideali per rappresentare dati strutturati del mondo reale. Vediamo la loro struttura e le loro proprietà principali.
Architettura di base dei dizionari e hashing
Prima di entrare nei dettagli dei metodi dei dizionari di Python, è utile capire come i dizionari si basino su tabelle hash. Molti degli errori che incontrerai, come
TypeError: unhashable type, derivano direttamente dal funzionamento di questa struttura.A livello strutturale, un dizionario Python implementa una tabella hash. Questa scelta architetturale è ciò che conferisce al dizionario velocità e versatilità. Quando definisci un dizionario, stai essenzialmente creando un array sparso, spesso chiamato bucket array.
Quando inserisci una coppia chiave-valore, Python passa la chiave attraverso una funzione di hash. Questa funzione calcola un intero univoco (l’hash) che determina l’indice specifico del bucket array in cui il valore verrà memorizzato.
A causa di questo design:
Le chiavi devono essere hashable, il che di solito significa che devono essere di tipo immutabile (ad es.,
str,int,tuple)I valori possono essere mutabili, incluse liste, altri dizionari o oggetti personalizzati
Ricerche, inserimenti ed eliminazioni hanno tempo medio ammortizzato O(1)
Il seguente esempio mostra alcune chiavi valide, ma dimostra anche che una lista non è accettata come chiave di un dizionario:
Questa struttura è importante per le prestazioni. Cercare un elemento in una lista richiede l’iterazione elemento per elemento, un’operazione O(n); recuperare un valore da un dizionario è invece in media un’operazione O(1).
Questo significa che cercare un ID utente in un dataset da un milione di utenti richiede all’incirca lo stesso tempo che cercarlo in un dataset da dieci utenti. Capire le differenze tra i tipi di dati in Python è fondamentale per scegliere la struttura giusta per il tuo caso d’uso.
Evoluzione dell’ordine e delle proprietà
Uno dei cambiamenti più significativi nella storia di Python è avvenuto con la versione 3.7. Prima, i dizionari erano considerati collezioni non ordinate e iterarli poteva produrre chiavi in sequenze apparentemente casuali. Stampando un dizionario, gli elementi potevano apparire in un ordine diverso rispetto all’inserimento, a seconda dei valori di hash e della storia dell’array interno.
Tuttavia, a partire da Python 3.6, i dizionari hanno iniziato a preservare l’ordine di inserimento come dettaglio d’implementazione in CPython. Poi, da Python 3.7 (e garantito ufficialmente nella specifica del linguaggio), i dizionari preservano l’ordine di inserimento.
Questo passaggio da mappature non ordinate a ordinate ha alcune implicazioni importanti per lo sviluppo moderno in Python. Ad esempio, la serializzazione JSON ora produce output prevedibili, rendendo il debug più semplice e garantendo la riproducibilità dei dati tra esecuzioni diverse.
Se lavori con pipeline di dati in cui l’ordine conta, come l’elaborazione di eventi di serie temporali o il mantenimento di gerarchie di configurazione, questa garanzia elimina un’intera classe di bug sottili. Vediamo ora come creare un dizionario.
Creare un dizionario in Python
Sebbene creare un dizionario sembri semplice, il metodo che scegli può influire sia sulla leggibilità sia sulle prestazioni del tuo codice. Python offre più modi di inizializzazione, che vanno dai letterali semplici a tecniche di generazione programmatica avanzate per i workflow di data science. Vediamo i più importanti.
Letterali con parentesi graffe
Il modo più comune e preferito per creare un dizionario è usare la sintassi con parentesi graffe
{}. Questa notazione letterale non è solo più leggibile, ma anche più veloce di metodi alternativi. Python può ottimizzare direttamente il bytecode senza l’overhead di una chiamata di funzione. Ecco un esempio di dizionario:Costruttore dict()
Tuttavia, il costruttore
dict()è indispensabile in alcuni scenari. Funziona come convertitore di tipo, permettendoti di costruire dizionari da sequenze di tuple o argomenti keyword. È particolarmente utile in questi casi specifici:Dizionari comprensione
Per scenari di creazione più complessi, le dictionary comprehensions offrono un modo conciso ed efficiente per filtrare, trasformare o generare coppie chiave-valore in modo programmatico. È una tecnica essenziale per chi lavora con i dati e ha bisogno di elaborarli e rimodellarli dinamicamente.
Le comprehensions sono fondamentali per attività come:
Vediamo come creare una dictionary comprehension qui sotto:
Se vuoi approfondire, ti consiglio la nostra guida su Python Dictionary Comprehension.
dict.fromkeys()
Un altro metodo utile per l’inizializzazione è
dict.fromkeys(). Questo metodo crea un nuovo dizionario con le chiavi specificate e un unico valore. È spesso usato per inizializzare contatori o flag di stato.Quando usi
.fromkeys()con oggetti mutabili come liste o dizionari, tutte le chiavi faranno riferimento allo stesso oggetto in memoria. Questo crea una “condivisione del riferimento” che può portare a comportamenti inattesi. Vediamolo con un esempio:Vediamo che nello stesso valore hanno fatto riferimento tutte le chiavi nel primo caso. Per evitarlo, usa una dictionary comprehension per ottenere liste indipendenti.
Metodi dei dizionari Python per accesso e modifica
Una volta creato un dizionario, interagire con i dati al suo interno è uno dei compiti quotidiani più comuni. Vediamo alcuni modi per farlo.
Accesso e recupero dei valori
Ci sono diversi modi per accedere ai valori.
Notazione con parentesi
Il modo più diretto per recuperare un valore da un dizionario è tramite la notazione con parentesi
d[key], che restituisce il valore associato se la chiave esiste. Questo approccio è ideale quando sei sicuro che la chiave sia presente nel dizionario. Ecco il codice per farlo:Metodo .get()
Per un recupero più sicuro quando l’esistenza della chiave è incerta, il metodo
.get()offre una soluzione elegante. RestituisceNone(o un valore predefinito specificato) invece di generare un’eccezione se la chiave non esiste.Metodo .setdefault()
Il metodo
.setdefault()combina recupero e inserimento in un’unica operazione. Recupera un valore se la chiave esiste, oppure inserisce un valore predefinito e lo restituisce se la chiave manca: perfetto per pattern di accumulo.Modifica dei dizionari
I dizionari sono dinamici; spesso dovrai aggiungere o rimuovere dati durante l’esecuzione del programma.
Aggiungere coppie chiave-valore con il metodo .update()
Aggiungere una singola coppia è semplice come un’assegnazione (
d['new'] = 1), ma per operazioni in blocco il metodo.update()è superiore. Accetta un altro dizionario o un iterabile di coppie chiave-valore e li unisce all’oggetto esistente.Vediamo come usare il metodo
.update():Per una spiegazione dettagliata sull’aggiunta di elementi, ti consiglio questa guida su Python Dictionary Append.
Rimuovere elementi da un dizionario
Python offre tre metodi distinti per rimuovere voci da un dizionario, ognuno con comportamenti e casi d’uso diversi:
.pop(key): rimuove la chiave e ne restituisce il valore. Utile quando ti serve usare il dato un’ultima volta prima di eliminarlo..popitem(): rimuove e restituisce l’ultima coppia chiave-valore inserita (LIFO). È un beneficio diretto della natura ordinata dei dizionari moderni.del d[key]: elimina semplicemente la chiave. Non restituisce il valore ed è leggermente più veloce se non ti serve il valore rimosso.Vediamo esempi di questi metodi:
Metodo
.pop():Metodo
.popitem():del:Svuotare un dizionario con il metodo .clear()
Il metodo
.clear()svuota l’intero dizionario, lasciandoti con un oggetto{}vuoto. Questo è diverso dall’eliminare del tutto la variabile. L’oggetto rimane in memoria, solo che è vuoto. Vediamo come funziona:La distinzione tra questi metodi è importante. Usa
.pop()quando ti serve il valore rimosso,.popitem()per un comportamento da stack,delper una rimozione semplice e.clear()per azzerare un dizionario preservandone l’identità.Viste e iterazione
Nelle vecchie versioni di Python 2, metodi come
.keys()restituivano una lista statica. Nelle versioni attuali di Python (3.x), questi restituiscono oggetti vista. Le viste sono finestre dinamiche sul dizionario. Se il dizionario cambia, la vista riflette le modifiche all’istante senza dover essere richiamata.Iterare con .keys(), .values() e .items()
Puoi iterare sulle chiavi (
.keys()), sui valori (.values()) o su entrambi contemporaneamente usando.items(). Vediamo questi metodi con un esempio:La natura dinamica degli oggetti vista significa che riflettono automaticamente le modifiche apportate al dizionario dopo la loro creazione. Ecco un esempio:
Intersezioni e unioni
Una caratteristica potente degli oggetti vista è che supportano le operazioni insiemistiche. Puoi eseguirle direttamente sulle viste delle chiavi per confrontare due dizionari in modo efficiente usando questi operatori:
Intersezione:
&Unione:
|Differenza:
-Differenza simmetrica/XOR:
^Vediamo un esempio:
Queste operazioni insiemistiche basate sulle viste sono molto più efficienti in memoria rispetto a convertire esplicitamente i dizionari in set, specialmente con dataset di grandi dimensioni.
Evitare errori nei metodi dei dizionari in Python
Poiché i dizionari sono spesso l’interfaccia principale per dati esterni, come payload JSON da API o file di configurazione, sono una fonte comune di errori a runtime. Scrivere codice robusto richiede più che sapere come accedere ai dati. Richiede sapere come gestirne l’assenza.
L’errore più comune con i dizionari è
KeyError, che si verifica quando si tenta di accedere a una chiave inesistente. Python offre due approcci filosofici per gestirlo:EAFP: gestire KeyError ed eccezioni
Il pattern EAFP è considerato più “pythonic” e spesso offre prestazioni migliori quando le chiavi esistono di solito, poiché evita controlli ridondanti. Usa blocchi
try-exceptper gestire gli errori dopo che si sono verificati, assumendo che le operazioni in genere riescano. Vediamo come funziona:Tuttavia, ci sono scenari in cui fallire in modo evidente, lasciando propagare il
KeyError, è preferibile a fallimenti silenziosi. Nell’approccio LBYL, controlli esplicitamente l’esistenza della chiave prima di accedervi. Vediamo un esempio:Invocare questa funzione genererà
KeyErrorse manca una qualunque chiave nel dizionario, il che è il comportamento corretto perché è meglio fallire in fase di inizializzazione che in modo silenzioso in produzione.Gestione esplicita degli errori
Quando elabori dati da fonti esterne come API o input utente, la gestione esplicita degli errori diventa importante. Vediamo come farlo con un esempio:
Capire quando usare
.get()rispetto alla notazione con parentesi o atry-exceptè importante.LBYL: test proattivo di appartenenza
L’approccio LBYL usa istruzioni condizionali con gli operatori
inenot inper controllare l’esistenza della chiave prima di tentare l’accesso. Questo pattern è più chiaro quando si ha a che fare con logiche condizionali o quando devi intraprendere azioni diverse in base alla presenza della chiave. Ecco un esempio:Quando convalidi dizionari derivati da fonti esterne come JSON da API, file CSV o input utente, il test proattivo di appartenenza offre una logica di validazione chiara e leggibile. Vediamolo con un esempio:
Scegliere l’approccio giusto
La scelta tra EAFP e LBYL dipende spesso dal caso d’uso. Usa EAFP quando le operazioni di solito vanno a buon fine e le eccezioni sono rare. Usa LBYL quando hai bisogno di ramificazioni logiche esplicite o quando devi convalidare l’input prima di operazioni costose.
Prima di iterare su un dizionario derivato da una fonte esterna, è buona pratica:
Seguendo queste pratiche, renderai il tuo codice molto più robusto quando lavori con dati imprevedibili o incompleti da API, input utente o file di configurazione.
Per uno sguardo più approfondito alla scrittura di codice Python resiliente, ti consiglio il corso Writing Efficient Python Code.
Metodi avanzati dei dizionari in Python
Man mano che i progetti di data science diventano più complessi, spesso dovrai combinare, copiare e trasformare dizionari. Imparare queste operazioni avanzate ti permette di manipolare le strutture dati in modo efficiente evitando bug sottili che possono mandare fuori strada le pipeline. Vediamo alcuni di questi metodi.
Operatori di merge
Python 3.9 ha introdotto eleganti operatori di unione per unire i dizionari:
|(operatore di merge): crea un nuovo dizionario unito.|=(operatore di update): aggiorna in place un dizionario esistente.Questi operatori forniscono una sintassi pulita e leggibile per combinare dati da più fonti. Ecco un esempio:
Per versioni di Python precedenti alla 3.9, il metodo di unpacking con doppia stella (
**) offre funzionalità simili:Precedenza in caso di collisione
In tutte le tecniche di merge, la regola è semplice: vince l’ultimo visto. Il valore del dizionario a destra sovrascrive quello a sinistra se le chiavi collidono:
Questo comportamento è coerente che tu usi
|,|=, l’unpacking o il metodo.update(). L’ordine conta: è particolarmente utile per la gestione stratificata delle configurazioni (ad es., default di sistema < config d’ambiente < override dell’utente).Meccanismi di copia
Una delle insidie più pericolose è fraintendere come Python gestisca le assegnazioni di variabili.
Le assegnazioni per riferimento creano alias, non copie
dict_a = dict_bnon crea una copia. Crea un riferimento (un alias). Modificare l’uno modifica l’altro. L’esempio seguente illustra il concetto:Come si vede, esiste un unico dizionario e sia
originalsiaaliaslo referenziano. Di conseguenza, quando il valore della chiaverecordsviene impostato a2000, la modifica si applica a quell’unico dizionario.Copie superficiali con .copy() o .dict()
Una copia superficiale (
.copy()odict()) crea un nuovo oggetto dizionario, ma gli oggetti mutabili annidati restano riferimenti condivisi.Come vedi, le chiavi al primo livello si modificano come previsto, ma se modifichi oggetti mutabili annidati, la modifica riguarda sia l’originale sia la copia superficiale.
Copie profonde con copy.deepcopy()
Per questo motivo, le copie superficiali spesso causano bug sottili nelle pipeline di machine learning e nella gestione delle configurazioni.
Pertanto, per dizionari che contengono oggetti mutabili annidati, serve una copia profonda usando
copy.deepcopy()dalla libreria standard. Questa copia ricorsivamente tutti gli oggetti annidati, creando strutture completamente indipendenti. Un esempio è mostrato sotto:Best practice per merge e copie
Implementando le seguenti tecniche, garantirai integrità dei dati e manutenibilità nelle pipeline complesse.
Usa
|e|=per merge puliti dei dizionari.Ricorda che in caso di collisione vince l’ultimo visto.
Distingui tra assegnazione per riferimento, copie superficiali e copie profonde per evitare bug sottili.
Preferisci sempre
copy.deepcopy()quando lavori con strutture mutabili annidate in codice di produzione.Tipi specializzati di dizionari in Python
Sebbene il
dictstandard sia versatile, la libreria standard di Python offre mappature specializzate ottimizzate per compiti specifici come conteggio, raggruppamento o imposizione di strutture dei dati. Scegliere il tipo specializzato giusto può semplificare drasticamente il tuo codice e prevenire intere classi di bug.Estensioni del modulo collections
Il modulo
collectionsoffre alcuni utili tipi di dizionari specializzati.defaultdict
Il
defaultdictdel modulocollectionselimina i controlli ripetitivi sull’esistenza delle chiavi inizializzando automaticamente le chiavi mancanti con un valore predefinito. È molto utile per compiti di accumulo come conteggi, raggruppamenti o la costruzione di strutture annidate:Counter
La classe
Counterè una sottoclasse specializzata di dizionario progettata per contare oggetti hashable ed eseguire operazioni sui multiset. È particolarmente potente per analisi statistiche e distribuzioni di frequenza. Vediamo un esempio di come funziona:OrderedDict
Prima di Python 3.7,
OrderedDictera essenziale per preservare l’ordine di inserimento. Sebbene i dizionari standard ora mantengano l’ordine,OrderedDictha ancora casi d’uso specifici, in particolare per confronti di uguaglianza che considerano l’ordine e per spostare elementi a un’estremità o all’altra:Il grafico seguente illustra quando scegliere quale tipo di dizionario.
Sicurezza dei tipi moderna e viste in sola lettura
Con la crescente adozione di Python in ambienti di produzione su larga scala, la sicurezza dei tipi diventa un aspetto importante. I moduli
typingetypesintroducono modi per imporre struttura ai tuoi dizionari. Comprenderee i dunder methods di Python può anche aiutarti a creare oggetti simili a dizionari con comportamenti speciali.TypedDict
Il modulo
typingdi Python fornisceTypedDictper definire strutture di dizionari con chiavi richieste specifiche e annotazioni di tipo. Migliora la documentazione del codice, abilita l’autocompletamento negli IDE e intercetta errori di tipo durante l’analisi statica. Vediamo come funziona:MappingProxyType
Per scenari in cui devi esporre un dizionario ma impedirne la modifica,
MappingProxyTypecrea una vista immutabile, in sola lettura, di un dizionario standard. È eccellente per proteggere costanti di configurazione globali da modifiche accidentali. Vediamo questo in azione:Come si vede,
MappingProxyTyperende il dizionario stesso in sola lettura (niente aggiunte, eliminazioni o riassegnazioni di chiavi), ma non congela i valori mutabili al suo interno. Per impedire modifiche alle strutture annidate, devi rendere immutabili anche quegli oggetti o avvolgerli in viste in sola lettura proprie.Firme di funzione e type hint
Per le firme di funzione e i type hint, usa
DicteMappingdal modulotypingper documentare le strutture di dizionario attese. Possiamo farlo come mostrato sotto:Ottimizzare le prestazioni dei metodi dei dizionari in Python
Quando i dataset crescono da migliaia a milioni di record, l’efficienza del codice diventa importante. Per questo è necessario comprendere le caratteristiche prestazionali dei dizionari. Vediamo quindi complessità computazionale, implicazioni di memoria e strategie di ottimizzazione per le operazioni sui dizionari.
Analisi della complessità temporale
Le operazioni sui dizionari raggiungono la loro alta velocità grazie all’implementazione con tabelle hash, offrendo complessità media O(1) per le tre operazioni più comuni:
get)set)delete)Queste prestazioni a tempo costante significano che tali operazioni richiedono all’incirca lo stesso ordine di grandezza di tempo in media, che il tuo dizionario contenga 10 elementi o 10 milioni. Vediamolo con un esempio di codice:
I tempi mediani di lookup sopra riportati possono variare in base alle tue risorse computazionali. Il punto chiave è che, quali che siano i valori, restano quasi identici con una certa variabilità: la differenza è del tutto trascurabile, a conferma della proprietà O(1).
Caso medio vs caso peggiore
Tuttavia, nello scenario peggiore, la complessità può degradare a O(n) quando si verificano collisioni di hash eccessive. Le collisioni avvengono quando chiavi diverse producono lo stesso valore di hash, costringendo Python a cercare tra più voci memorizzate nello stesso bucket. Vediamolo con un esempio:
Anche in questo caso, i risultati esatti e l’entità del degrado prestazionale varieranno in base alla tua potenza di calcolo. Ma la tendenza resta la stessa: il lookup con hash “cattivo” impiegherà molto più tempo rispetto a quello con complessità O(1).
Ridimensionamento dei dizionari
Un altro costo nascosto è il ridimensionamento. Man mano che i dizionari crescono, Python ridimensiona automaticamente la tabella hash interna per mantenere le prestazioni. Questa operazione ha un costo computazionale, perché richiede di ricalcolare gli hash di tutte le chiavi esistenti e ridistribuirle in un array più grande.
Python usa una strategia di crescita con fattore moltiplicativo, in genere raddoppiando almeno la dimensione al raggiungimento di una certa soglia. Capire questi pattern di ridimensionamento aiuta quando inizializzi grandi dizionari. Se conosci la dimensione finale approssimativa, puoi pre-allocare spazio per evitare molteplici operazioni di resize.
Gestione della memoria e dimensionamento
La velocità spesso ha un costo in termini di memoria. I dizionari hanno un overhead di memoria significativo rispetto a tuple o liste perché devono memorizzare la struttura della tabella hash (indici, hash, chiavi e valori). Vediamolo con un esempio di codice:
Usare __slots__ per ridurre la dimensione degli oggetti
Nell’esempio sopra, puoi vedere che il dizionario usa ~2x-3x memoria in più per gli stessi dati. Per classi che creano molte istanze, usare
__slots__invece del__dict__dell’istanza può ridurre drasticamente il consumo di memoria.Per impostazione predefinita, Python memorizza gli attributi d’istanza in un dizionario accessibile tramite
__dict__, ma__slots__usa una struttura più compatta basata su array. Vediamo un esempio:Dizionario Python vs pandas DataFrame
Infine, sebbene i dizionari siano eccellenti per l’accesso casuale, non sono ottimizzati per l’elaborazione di dati colonnari. Se gestisci dati tabellari su larga scala (ad esempio, milioni di righe), migrare a pandas DataFrame è una scelta saggia, perché sono ottimizzati sia per l’efficienza di memoria sia per la velocità vettorializzata.
Come puoi vedere chiaramente, nel nostro esempio il DataFrame supera il dizionario. Per workflow di data science che richiedono sia lookup rapidi sia operazioni analitiche, valuta approcci ibridi: usa i dizionari per indicizzazione e accesso veloce, poi converti in DataFrame per analisi massiva.
La chiave dell’ottimizzazione è abbinare la struttura dati ai tuoi pattern di accesso. Se esegui lookup frequenti basati su chiave, i dizionari sono ottimali. Per operazioni colonnari, filtri e aggregazioni su grandi dataset, i DataFrame offrono buone prestazioni.
Conclusione
I dizionari sono molto più di semplici contenitori. Sono la colla che tiene insieme applicazioni di data science complesse. Che tu stia costruendo una tabella di lookup veloce per uno script o progettando una pipeline dati ad alto throughput, un dizionario è probabilmente il tuo strumento più prezioso.
Tuttavia, affidarsi al comportamento predefinito senza un solido handling degli errori è una ricetta per i fallimenti a runtime. Pattern robusti di gestione degli errori, come approcci EAFP rispetto a LBYL e la validazione proattiva, con ogni probabilità eviteranno errori a runtime quando elabori dati esterni.
Raccolte specializzate come
defaultdict,CountereTypedDicttrasformano il tuo codice da funzionale a pronto per la produzione. Tieni sempre a mente complessità temporale e gestione della memoria per garantire che il codice giri in modo efficiente. Ricorda anche che l’ottimizzazione è un processo iterativo.Per continuare a sviluppare le tue abilità in Python, ti consiglio di seguire il nostro Intermediate Python per approfondire le strutture dati, oppure il più ampio percorso di carriera Python Developer per un percorso formativo completo.