Vai al contenuto principale

Metodi dei dizionari Python: guida completa

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.
· 15 min leggi

Scopri con l'IA

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 value
    42: [1, 2, 3],             # integer key, list value
    (10, 20): {"nested": True} # tuple key, dict value
}
print(type(user_data), "valid dict")

# Invalid - will raise TypeError
try:
    invalid_dict = {[1, 2]: "value"}  # lists are not hashable
except TypeError as e:
    print(f"Error: {e}")
<class 'dict'> valid dict
Error: unhashable type: 'list'

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.

python dictionary methods

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_profile
{'name': 'Alice', 'role': 'Data Scientist', 'active': True}

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)
{'host': 'localhost', 'port': 8080, 'debug': True}
{'a': 1, 'b': 2, 'c': 3}

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:

  • Invertire un dizionario
  • Filtrare i valori nulli da un dataset

python dictionary comprehensions

Vediamo come creare una dictionary comprehension qui sotto:

# Classic use case: Creating a squares map
squares = {x: x**2 for x in range(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 is not None}
print(clean_data)
{0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
{'a': 10, 'c': 5}

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.

# 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)
{'electronics': 0, 'clothing': 0, 'food': 0, 'books': 0}
{'email': None, 'phone': None, 'address': None, 'company': None}

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)
{'A': [1], 'B': [1], 'C': [1]}
{'A': [1], 'B': [], 'C': []}

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 brackets
print(product["name"]) 
print(product["price"]) 

# Attempting to access a non-existent key raises KeyError
try:
    print(product["manufacturer"])
except KeyError as e:
    print(f"Key not found: {e}")
Laptop
1299.99
Key not found: 'manufacturer'

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)
None
No warranty information
Not provided
Not provided

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)
0
1
{'fruit': ['apple', 'banana'], 'vegetable': ['carrot', 'broccoli']}

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)
{'username': 'alice_2024', 'role': 'senior_analyst', 'email': '[email protected]', 'department': 'Analytics', 'level': 3}
{'username': 'alice_2024', 'role': 'senior_analyst', 'email': '[email protected]', 'department': 'Analytics', 'level': 3, 'projects': 12, 'rating': 4.8}
{'username': 'alice_2024', 'role': 'senior_analyst', 'email': '[email protected]', 'department': 'Analytics', 'level': 3, 'projects': 12, 'rating': 4.8, 'active': True, 'certified': True}

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) 
95
{'Bob': 87, 'Carol': 92, 'David': 78}

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)
('David', 78)
{'Alice': 95, 'Bob': 87, 'Carol': 92}

del:

# del statement - removes key without returning value
scores = {"Alice": 95, "Bob": 87, "Carol": 92, "David": 78}
del scores["Bob"]
print(scores)
{'Alice': 95, 'Carol': 92, 'David': 78}

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:

# .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))
{}
<class 'dict'>

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 keys
print(experiment.keys()) 

# .values() returns a view of all values
print(experiment.values())

# .items() returns (key, value) tuples - most commonly used
for metric, value in experiment.items():
    if isinstance(value, float):
        print(f"{metric}: {value:.2%}")
dict_keys(['model', 'accuracy', 'precision', 'recall'])
dict_values(['RandomForest', 0.94, 0.91, 0.89])
accuracy: 94.00%
precision: 91.00%
recall: 89.00%

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.87
print(keys_view)
dict_keys(['MAE', 'RMSE'])
dict_keys(['MAE', 'RMSE', 'R2'])

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)
{'b', 'c'}
{'b', 'c', 'd', 'a'}
{'a'}
{'d', 'a'}

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 configuration
Phone number not available
Warning: Database not specified, using default

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"}

def initialize_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 default
    
    return {"key": api_key, "endpoint": endpoint, "timeout": timeout}

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 handling
def extract_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}")
        return None
    
    # 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)
{'id': 12345, 'name': 'Jane Smith', 'email': 'N/A', 'verified': True}

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 existence
if "David" not in student_grades:
    student_grades["David"] = 0  # Initialize new student
    print("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 not in employee_record]
if missing_fields:
    print(f"Error: Missing required fields: {missing_fields}")
else:
    print("All required fields present")
No grade recorded for David
New student added to grading system
Error: Missing required fields: ['department']

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 structure
def validate_product_data(product):
    """Validate product dictionary has all required fields."""
    required = ["id", "name", "price", "category"]
    optional = ["description", "stock", "manufacturer"]
    
    # Check all required fields exist
    for field in required:
        if field not in product:
            raise ValueError(f"Missing required field: {field}")
    
    # Validate data types for existing fields
    if "price" in product and not isinstance(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")
    
    return True

# 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 processing
except (ValueError, TypeError) as e:
    print(f"Validation failed: {e}")
Product data validated successfully

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:

  • 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)
{'theme': 'dark', 'language': 'en', 'notifications': True, 'font_size': 14}
{'auto_save': True, 'theme': 'dark', 'font_size': 12}

Per versioni di Python precedenti alla 3.9, il metodo di unpacking con doppia stella (**) offre funzionalità simili:

# Double-star unpacking
base_config = {"host": "localhost", "port": 5432, "ssl": False}
override_config = {"port": 5433, "ssl": True, "timeout": 30}

# Merge using unpacking
merged = {**base_config, **override_config}
print(merged)

# Multiple dictionary merge
db_config = {"database": "analytics"}
auth_config = {"username": "admin", "password": "secret"}
pool_config = {"pool_size": 10, "max_overflow": 20}

complete_config = {**db_config, **auth_config, **pool_config}
print(complete_config)
{'host': 'localhost', 'port': 5433, 'ssl': True, 'timeout': 30}
{'database': 'analytics', 'username': 'admin', 'password': 'secret', 'pool_size': 10, 'max_overflow': 20}

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:

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)
{'a': 1, 'b': 20, 'c': 300, 'd': 4, 'e': 5}
{'a': 1, 'b': 20, 'c': 300, 'd': 4, 'e': 5}
{'c': 3, 'e': 5, 'b': 2, 'd': 4, 'a': 1}

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).

python dictionary methodsfor merging

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"] = 2000
print(original)
print(alias is original)
{'name': 'Dataset_v1', 'records': 2000}
True

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.001
print(original["parameters"]["learning_rate"]) 

shallow["results"].append(0.94)
print(original["results"])
Experiment_A
Experiment_B
0.001
[0.85, 0.89, 0.92, 0.94]

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 unchanged
print(original["hyperparameters"]["n_estimators"])
print(len(original["feature_importance"]))
print(len(deep["feature_importance"]))
100
5
6

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 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 not in word_count:
        word_count[word] = 0
    word_count[word] += 1

# defaultdict eliminates the check
word_count_auto = defaultdict(int)  # int() returns 0
for 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))
{'the': 2, 'quick': 1, 'brown': 1, 'fox': 1, 'jumps': 1, 'over': 1, 'lazy': 1, 'dog': 1}
2024-01-15: [('groceries', 45.5), ('gas', 60.0)]
2024-01-16: [('groceries', 32.75), ('entertainment', 25.0)]
2024-01-17: [('gas', 55.0)]
{'2024-01': defaultdict(<class 'int'>, {'login': 150, 'purchase': 45}), '2024-02': defaultdict(<class 'int'>, {'login': 200, 'purchase': 60})}

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:

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 elements
print(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) * 100
    print(f"{sentiment}: {count} ({percentage:.1f}%)")
Counter({'python': 3, 'data': 2, 'ml': 2, 'statistics': 1})
[('python', 3), ('data', 2)]
Counter({'Python': 2, 'SQL': 1, 'Tableau': 1, 'R': 1})
Counter({'Python': 1, 'SQL': 1})
Counter({'Python': 3, 'SQL': 2, 'Tableau': 1, 'R': 1})
satisfied: 4 (50.0%)
neutral: 2 (25.0%)
dissatisfied: 1 (12.5%)
very_satisfied: 1 (12.5%)

OrderedDict

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()))
True
False
['task3', 'task1', 'task2']
['task3', 'task1', 'task2']

Il grafico seguente illustra quando scegliere quale tipo di dizionario.

When to use which Python dictionary type

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 data
class UserProfile(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-check
def process_user(user: UserProfile) -> str:
    # IDE knows these keys exist and their types
    return f"User {user['username']} (ID: {user['user_id']}) - {user['role']}"

# Optional keys with total=False
class PartialConfig(TypedDict, total=False):
    host: str
    port: int
    database: str  # All keys are optional

config: PartialConfig = {"host": "localhost"}  # Valid - partial config

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 normally
print(CONFIG["API_VERSION"]) 
print(CONFIG["TIMEOUT"]) 

# Modifications raise TypeError
try:
    CONFIG["TIMEOUT"] = 60
except TypeError as e:
    print(f"Cannot modify: {e}")

# Caution: nested modifications succeed
try:
    CONFIG["ENDPOINTS"]["users"] = "/new/endpoint"
except TypeError as e:
    print(f"Cannot modify nested: {e}")

# Practical use case: Class constants
class DataPipeline:
    _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 {})}
v2
30
Cannot modify: 'mappingproxy' object does not support item assignment

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 dictionaries
def process_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 types
def display_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 earlier
host: localhost
port: 5432
API_VERSION: v2
MAX_RETRIES: 3
TIMEOUT: 30
ENDPOINTS: {'users': '/new/endpoint', 'data': '/api/v2/data'}

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


def benchmark_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 in range(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 noise
    for _ in range(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 microseconds
    return 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 ''}")
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 ~constant

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 collisions
class BadHash:
    """Object with intentionally poor hash function."""
    def __init__(self, value):
        self.value = value
    
    def __hash__(self):
        return 1  # All instances hash to same value - worst case!
    
    def __eq__(self, other):
        return isinstance(other, BadHash) and self.value == other.value

# This will have O(n) performance due to collisions
bad_dict = {BadHash(i): i for i in range(1000)}

# Compare with well-distributed hashes
good_dict = {f"key_{i}": i for i in range(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")
Bad hash lookup: 582.48 μs
Good hash lookup: 93.99 μs
Performance degradation: 6.2x slower

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")
List of tuples: 88 bytes
Tuple of tuples: 64 bytes
Dictionary: 184 bytes

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 attributes
class RegularUser:
    def __init__(self, user_id, name, email):
        self.user_id = user_id
        self.name = name
        self.email = email

# Optimized class - uses __slots__
class OptimizedUser:
    __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 usage
print(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 in range(1000)]
optimized_users = [OptimizedUser(i, f"User{i}", f"user{i}@example.com") for i in range(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}%")
Regular instance: 296 bytes (__dict__)
Optimized instance: 56 bytes (__slots__)

1000 regular instances: 96,000 bytes
1000 optimized instances: 56,000 bytes
Memory savings: 41.7%

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. 

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 in range(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.5 for i in range(n)],
    "category": pd.Series([f"cat_{i % 10}" for i in range(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_000

print(f"\nDF groupby mean: {df_time:.2f} μs")
print(f"Dict manual mean: {dict_time:.2f} μs")
Dictionary: 294,992 bytes
Optimized DataFrame: 130,972 bytes

DF groupby mean: 1630.24 μs
Dict manual mean: 3931.47 μs

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.

Autore

Rajesh Kumar

Sono una content writer specializzata in data science. Amo creare contenuti su temi di IA/ML/DS. Esploro anche nuovi strumenti di IA e ne scrivo.

Argomenti

Correlato