1. Introduzione
Quando si inizia a programmare, si vorrebbe che il codice semplicemente “funzionasse”: inserisci un numero → ottieni un risultato. Ma non appena compaiono modelli propri (ad esempio Expense nel nostro mini-tracker delle spese), inizi a confrontare oggetti, a salvarli in collezioni e a stamparli per il debugging. Ed e proprio li che improvvisamente scopri che senza un buon equals/hashCode/toString il programma puo comportarsi come un gatto che fa finta di non conoscerti.
Immaginiamo il nostro programma console per la gestione delle spese. Aggiungiamo spese a una lista, costruiamo report per categorie e a volte vogliamo prevenire i duplicati. Che cos’e un “duplicato”? Due oggetti con gli stessi campi? O “e letteralmente lo stesso identico oggetto”? E quando facciamo println(expense), vogliamo vedere qualcosa di leggibile, non un misterioso indirizzo in memoria del tipo “l’oggetto c’e da qualche parte, ma dove — non te lo dico”.
La data class esiste proprio per questi scenari: i modelli dati quasi sempre devono essere facili da stampare e corretti da confrontare. Kotlin si occupa di generare i metodi standard, inclusi equals(), hashCode() e toString().
2. Ripasso veloce: == e ===
Prima di “dare la colpa alla data class di tutti i peccati”, e importante distinguere con sicurezza due operatori di confronto. == in Kotlin e l’uguaglianza strutturale (per significato/valore), cioe di fatto chiama equals(). === e l’uguaglianza referenziale, verifica che sia lo stesso oggetto (in un certo senso “la stessa scatola”, e non “lo stesso contenuto delle scatole”). Con i dati di solito serve ==, altrimenti confronteremo non il significato, ma “se e la stessa istanza”.
Vediamo un esempio minimo (e allo stesso tempo restiamo aderenti al futuro modello della nostra applicazione):
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val a = Expense(1, "Coffee", 250)
val b = Expense(1, "Coffee", 250)
println(a == b) // true
println(a === b) // false
}
La logica e questa: le spese sono uguali nei campi, quindi == e vero. Ma sono oggetti diversi, creati da due chiamate al costruttore — quindi === e falso.
3. Cosa genera una data class e quali campi partecipano
Una trappola molto frequente per chi inizia: “Ho scritto una data class — quindi adesso tutto dentro l’oggetto partecipa al confronto e alla stampa”. No. Kotlin lo fa in modo prevedibile: usa solo le proprieta (val/var) dichiarate nel primary constructor. Da queste vengono derivati equals/hashCode/toString (e non solo, ma oggi restiamo focalizzati proprio su questi tre).
Questo e comodo perche il primary constructor diventa il “passaporto dei dati”: cio che e elencato li e cio che viene considerato lo stato del modello.
Esempio “come lo vogliamo” per la gestione delle spese:
data class Expense(
val id: Int,
val title: String,
val amountCents: Long,
val category: String
)
Un oggetto del genere e comodo da confrontare su tutti e quattro i campi, comodo da stampare e comodo da usare nelle collezioni.
E viceversa: se sposti una proprieta nel corpo della classe, essa non partecipa a equals/hashCode/toString generati automaticamente. Questa e una regola ufficiale delle data class: le proprieta nel corpo della classe sono escluse dalla generazione.
4. toString() per debugging e log
Quando un programma non funziona, le prime due reazioni di uno sviluppatore sono: “perche?” e “dov’e il mio println?”. Ed e qui che toString() diventa improvvisamente molto utile: vuoi vedere l’oggetto per intero e in modo leggibile. In una data class toString() viene generato in un formato tipo Expense(id=1, title=Coffee, amountCents=250, category=Food) — cioe mostra direttamente il nome della classe e i valori dei campi del costruttore.
Aggiungiamo la stampa di una spesa:
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val e = Expense(1, "Coffee", 250)
println(e) // Expense(id=1, title=Coffee, amountCents=250)
}
Nota una sfumatura importante: toString() e una rappresentazione diagnostica, non un “bell’output per l’utente”. L’utente, molto probabilmente, vuole “Coffee — $2.50”, non “Expense(amountCents=250)”. Per questo, nella nostra applicazione useremo toString() come strumento di debugging e logging, e per l’output verso l’utente faremo una funzione di formattazione separata (ma senza fanatismo — siamo ancora nel mondo console).
Una piccola abitudine pratica: quando fai debugging della logica di business, stampare println(expense) e come una torcia in una cantina buia. Non rende la cantina bella, ma aiuta a non cadere in un tombino.
5. equals() e il significato di “essere uguali” per gli oggetti
I modelli dati spesso si confrontano proprio per contenuto. Per una data class Kotlin genera equals() in modo che il confronto avvenga sui valori delle proprieta del primary constructor.
Cioe un controllo del genere diventa naturale:
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val a = Expense(1, "Coffee", 250)
val b = Expense(1, "Coffee", 250)
val c = Expense(2, "Coffee", 250)
println(a == b) // true
println(a == c) // false
}
Nel contesto della nostra applicazione questo da un significato chiaro: se due spese hanno gli stessi campi, allora sono uguali come dati.
Ma qui c’e un’idea importante di progettazione: l’uguaglianza e un contratto di significato. Devi decidere quali campi definiscono davvero “l’essere uguali”. Per esempio, se id e un identificatore univoco, si puo considerare che due spese siano uguali solo quando gli id coincidono (e gli altri campi sono solo “dati del record”). Ma se vuoi trovare duplicati per contenuto, allora id puo essere un ostacolo. Oggi non ci “spacchiamo la testa” con dispute architetturali — fissiamo solo questo: la data class confronta tutti i campi del costruttore, quindi scegli quei campi in modo consapevole.
6. hashCode() e perche senza di esso Set e Map si rompono
Il confronto via equals() e solo meta della storia. Nelle collezioni Set e Map un ruolo chiave lo gioca hashCode(). In una data class Kotlin genera la coppia equals()/hashCode() in modo coerente, cioe cosi che gli oggetti uguali secondo equals abbiano lo stesso hashCode.
Perche e cosi importante? Perche Set e Map funzionano piu o meno secondo lo schema: “prima trovare il bucket tramite l’hash, poi affinare con equals()”. Se hashCode ed equals “vivono ciascuno di vita propria”, la collezione iniziera a perdere elementi o a non trovare le chiavi.
Uno schema molto semplificato della ricerca in una HashMap appare cosi:
flowchart TD
A["Klyuch (key)"] --> B["hashCode()"]
B --> C["korzina / bucket"]
C --> D["equals() s kandidatami"]
D --> E["nashli znachenie / ne nashli"]
Ora mostriamo l’utilita nella pratica. Mettiamo che vogliamo salvare le spese per id:
data class ExpenseId(val value: Int)
fun main() {
val m = mapOf(ExpenseId(1) to "Coffee", ExpenseId(2) to "Taxi")
println(m[ExpenseId(1)]) // Coffee
}
Perche funziona? Perche ExpenseId(1) e “uguale” a un altro ExpenseId(1) secondo equals, e hanno lo stesso hashCode. Ed e proprio per questo che possiamo creare un oggetto-chiave nuovo e trovare comunque il valore precedente.
7. Proprieta nel corpo della data class e campi “invisibili”
Questo e un errore classico: aggiungi un campo, ti aspetti che partecipi al confronto e alla stampa, e invece e “come se fosse invisibile”. La ragione e quasi sempre una: il campo e dichiarato non nel primary constructor, ma nel corpo della classe. Allora viene escluso da equals/hashCode/toString (e dalle altre funzioni generate), perche Kotlin considera solo le proprieta del costruttore.
Vediamo un esempio che sembra logico, ma si comporta in modo inatteso:
data class User(val name: String) {
var age: Int = 0
}
fun main() {
val a = User("Ann").also { it.age = 10 }
val b = User("Ann").also { it.age = 99 }
println(a == b) // true
println(a) // User(name=Ann)
}
Se lo leggi “da umano”, viene da dire: “ma sono diversi, l’eta e diversa”. Ma dal punto di vista della data class l’eta non e parte dei dati, perche non e nel primary constructor. Kotlin lo fa in modo intenzionale e prevedibile: sei tu a decidere cosa entra nel “passaporto dei dati” e cosa e uno stato ausiliario.
Regola pratica per la nostra app di gestione spese: se un campo influisce sull’unicita, sul confronto, sull’uso in Map/Set ed e in generale parte dei dati del modello — tienilo nel primary constructor. Se invece e cache, un flag temporaneo per la UI (anche in console c’e una “UI”, non ridere), o uno stato tecnico — lo puoi mettere nel corpo della classe, ma allora non sorprenderti delle conseguenze.
8. Pratica nell’applicazione: Set, Map e stabilita delle chiavi
Anti-duplicati e Set
Nelle prime versioni del progetto spesso salvavamo i record in una MutableList e facevamo ricerca/rimozione a mano. Va bene per partire. Ma quando usiamo gia le classi e soprattutto le data class, viene voglia di un po’ piu ordine: per esempio, prevenire l’aggiunta di un duplicato completo se l’utente ripete accidentalmente un comando.
Mettiamo di avere una semplice funzione di aggiunta spesa alla lista, ma vogliamo controllare i duplicati. Per la dimostrazione basta un Set, perche conserva elementi unici (secondo equals/hashCode).
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val unique = mutableSetOf<Expense>()
unique.add(Expense(1, "Coffee", 250))
unique.add(Expense(1, "Coffee", 250))
println(unique.size) // 1
}
Dal punto di vista del Set, il secondo elemento e un duplicato, perche e uguale al primo secondo equals() e ha lo stesso hashCode(). Per i modelli dati di solito e esattamente cio che si aspetta il programmatore (e anche l’utente: “perche mi servono due caffe identici nello stesso momento?”).
Ma qui riaffiora la questione di progettazione: cosa considerare duplicato — “lo stesso contenuto” o “lo stesso id”? In un’app reale dipende dalla tua logica. Oggi ci interessa di piu un’altra cosa: la data class rende prevedibile il comportamento delle collezioni, se hai scelto bene i campi del costruttore.
Chiavi Map e “perche non lo trova?!”
Scenario pratico successivo: costruisci un indice o un accesso veloce. Per esempio, vuoi trovare una spesa per id. Finche usavi una List, cercavi con un ciclo o con find. Ma una Map consente l’accesso per chiave quasi istantaneo — se le chiavi si confrontano correttamente.
Mostriamolo con un piccolo esempio (senza complicare con l’architettura):
data class Expense(val id: Int, val title: String, val amountCents: Long)
fun main() {
val byId = mapOf(
1 to Expense(1, "Coffee", 250),
2 to Expense(2, "Taxi", 1500)
)
println(byId[2]) // Expense(id=2, title=Taxi, amountCents=1500)
}
E se vogliamo che la chiave non sia un Int, ma un tipo separato (spesso si fa per non confondere identificatori diversi), allora una data class e perfetta:
data class ExpenseId(val value: Int)
fun main() {
val byId = mapOf(ExpenseId(1) to "Coffee")
println(byId[ExpenseId(1)]) // Coffee
}
Ed e proprio qui che si vede di nuovo perche equals/hashCode sono critici: abbiamo creato un nuovo ExpenseId(1) e abbiamo comunque trovato il valore. Questa e la “magia” di base di una Map, e funziona grazie al contratto di uguaglianza, che la data class genera automaticamente.
Trappola: chiavi mutabili in Map ed elementi in Set
Ora arriva il momento in cui molti capiscono per la prima volta perche gli sviluppatori “adulti” amano tanto val, e non var. E no, non e una questione morale. Il punto e che hashCode deve essere stabile finche l’oggetto sta in un HashSet o viene usato come chiave in una HashMap.
Se i campi che partecipano a equals/hashCode sono modificabili (var), puoi inserire l’oggetto in un Set, poi modificarlo e… il Set inizia a comportarsi in modo strano: l’oggetto sembra esserci, ma contains puo restituire false. Non e un “bug di Kotlin”, e una conseguenza naturale: l’oggetto e finito in un bucket in base al vecchio hash, ma dopo la modifica dovrebbe stare in un altro.
Esempio cattivo (come esempio, non come modello da seguire):
data class Key(var id: Int)
fun main() {
val s = hashSetOf(Key(1))
val k = s.first()
k.id = 2
println(s.contains(Key(2))) // false (molto probabile)
}
Qui non c’e garanzia che vedrai lo stesso risultato su tutte le implementazioni, ma l’idea resta: cambiare i campi chiave dopo aver inserito in HashSet/HashMap e come cambiare il numero dell’appartamento sulla porta quando il corriere e gia sulle scale. Il corriere (la collezione) e arrivato correttamente all’indirizzo, ma l’indirizzo “si e aggiornato all’improvviso”.
Per la nostra app la regola pratica e semplice: se un oggetto sara elemento di Set o chiave di Map, allora i campi che influenzano equals/hashCode e meglio farli val e non modificarli. Rende il programma prevedibile e fa risparmiare ore di debugging che altrimenti finirebbero in un dialogo con il vuoto: “perche la chiave non si trova, l’ho appena aggiunta”.
Si possono ridefinire equals/hashCode/toString manualmente?
A volte viene da pensare: “E se mi servisse un formato di stampa diverso o un’uguaglianza diversa?”. In Kotlin e possibile: puoi scrivere implementazioni personalizzate di equals(), hashCode() o toString() direttamente nel corpo della data class. Ma allora il compilatore non generera questi metodi da solo — usera i tuoi. Questo e descritto come parte delle regole di generazione delle data class: se esistono implementazioni esplicite (o finali in una superclasse), non vengono generate automaticamente.
A livello del corso (e soprattutto al livello “non programmo ancora con grande sicurezza”) e meglio attenersi al principio: se la data class va bene — usa l’autogenerazione. Se non va bene — e un segnale che il modello dati forse e pensato male, o che stai cercando di far fare a un solo modello compiti troppo diversi.
A volte si vorrebbe cambiare toString(), ma ricorda: abbiamo concordato che toString() e uno strumento di debugging. Se lo rendi “bello per l’utente”, potresti perdere valore diagnostico. Piu avanti, quando miglioreremo il design dei modelli di dominio, imparerai a separare ancora piu accuratamente “logging/debug” e “output per l’utente”.
9. Errori tipici
Errore n. 1: aspettarsi che i campi nel corpo della data class partecipino all’uguaglianza e alla stampa.
E una delle cause piu frequenti di “comportamento magico”: hai aggiunto var age oppure var cachedTotal, e poi ti stupisci che println(obj) non lo mostri, e che obj1 == obj2 lo ignori. Nelle data class l’autogenerazione considera solo le proprieta del primary constructor, ed e fatto apposta per rendere visibile in modo esplicito la “struttura dei dati” nell’intestazione della classe.
Errore n. 2: usare === per confrontare modelli dati.
L’uguaglianza referenziale serve di rado e di solito in casi particolari. Per modelli come Expense, User, Product quasi sempre serve ==, perche il significato di “essere uguali” e avere gli stessi campi, non “lo stesso riferimento”. Se confronti per sbaglio con ===, otterrai falso anche per spese “identiche” e inizierai a costruire logica su un controllo sbagliato.
Errore n. 3: rendere var i campi chiave e modificare l’oggetto dopo averlo aggiunto a HashSet/HashMap.
E il caso in cui il programma non si limita a crashare — inizia a farti dubitare della realta: “l’elemento non c’e”, anche se lo avevi aggiunto. Il motivo e che HashSet e HashMap si basano sulla stabilita di hashCode. Se il campo che partecipa al calcolo dell’hash cambia, l’oggetto “si sposterebbe” in un altro bucket, ma la collezione non lo sa. Per questo per chiavi ed elementi unici quasi sempre si sceglie val.
Errore n. 4: considerare toString() come output per l’utente.
Il toString() di una data class e davvero molto comodo e leggibile, ma e pensato per la diagnostica: debugging, log, verifica dello stato. Se inizi a mostrarlo all’utente come “interfaccia”, ti scontri presto con il fatto che il formato non e amichevole (e puo cambiare se rinomini i campi). Meglio avere una funzione di formattazione separata per la visualizzazione, e lasciare toString() allo sviluppatore — cioe a te, quando di notte sistemi un bug e prometti piano a te stesso “domani riscrivo tutto in modo bello”.
GO TO FULL VERSION