1. Introduzione
Se hai appena iniziato a scrivere codice con l’ereditarieta, “sovraccarico” e “ridefinizione” sembrano due parole che parlano della stessa cosa: “ho fatto un metodo con lo stesso nome, quindi in qualche modo si e sostituito”. E proprio qui Kotlin (insieme al compilatore) inizia a comportarsi come un insegnante severo: “no-no, amico, sono cose diverse”. Capire la differenza e importante non per i termini, ma perche altrimenti il codice funzionera, pero non come ti aspetti — il che lo rende perfetto per bug silenziosi che poi, all’improvviso, saltano fuori in report, log e nella tua autostima.
Partiamo da un quadro semplice e “umano”:
- Overloading — quando nello stesso posto (di solito nella stessa classe) definisci piu funzioni con lo stesso nome, ma con parametri diversi.
- Overriding — quando crei una nuova implementazione di un metodo della classe base in una sottoclasse, e questa implementazione deve scattare in una chiamata polimorfica.
2. Overloading: scelta in base ai parametri in fase di compilazione
Il sovraccarico e la situazione in cui hai piu funzioni con lo stesso nome, ma con una firma diversa. Per firma qui intenderemo prima di tutto l’insieme dei parametri (quantita, tipi, ordine). Il tipo di ritorno da solo non “salva” il sovraccarico: in Kotlin non puoi avere due funzioni che differiscono solo per il tipo del risultato, perche altrimenti il compilatore faticherebbe a scegliere in modo univoco cosa intendevi.
Il sovraccarico viene risolto in fase di compilazione: il compilatore guarda gli argomenti della chiamata e seleziona la versione adatta. E un po’ come scegliere la chiave giusta per una serratura: “aha, qui hai un Int, quindi prendiamo format(Int)”.
Mini-esempio: sovraccarichiamo format(...) dentro una sola classe
Immaginiamo che nella nostra applicazione console per la gestione delle finanze (chiamiamola, per convenzione, BudgetCLI) vogliamo stampare in modo carino numeri e stringhe. Possiamo dare due versioni della stessa funzione:
class ValueFormatter {
fun format(x: Int): String = "int=$x"
fun format(x: String): String = "str=\"$x\""
}
fun main() {
val f = ValueFormatter()
println(f.format(10)) // int=10
println(f.format("ok")) // str="ok"
}
Qui non c’e ereditarieta, non c’e open, non c’e override. Sono semplicemente due metodi diversi con lo stesso nome. Se passi 10, il compilatore scegliera format(Int). Se passi "ok", scegliera format(String).
Un pensiero comune dei principianti: “il sovraccarico serve per rendere piu comodo chiamare”
E vero, ma con una sfumatura. A volte fai sovraccarico per offrire una “chiamata corta e comoda” e una “chiamata dettagliata”:
class MoneyPrinter {
fun print(amountCents: Int) {
println("Amount: ${amountCents / 100.0}") // Amount: 12.34 (ad esempio)
}
fun print(amountCents: Int, currency: String) {
println("Amount: ${amountCents / 100.0} $currency") // Amount: 12.34 USD
}
}
Questo e sovraccarico: due metodi print, ma con parametri diversi.
Overloading e valori di default: sembrano uguali come sensazione, ma sono diversi nella meccanica
E molto facile confondere il sovraccarico con i parametri di default, perche dal punto di vista del codice chiamante l’effetto e simile: “posso chiamare in modo piu corto o piu dettagliato”. Ma il meccanismo e diverso: i parametri di default sono un solo metodo che puo essere chiamato in modi diversi; il sovraccarico sono piu metodi.
Ecco una variante senza sovraccarico, solo con un parametro di default (lo abbiamo visto prima, quando studiavamo le funzioni):
class MoneyPrinter {
fun print(amountCents: Int, currency: String = "USD") {
println("Amount: ${amountCents / 100.0} $currency") // ad esempio: Amount: 12.34 USD
}
}
Dal punto di vista pratico, piu spesso sceglierai parametri di default per non moltiplicare i sovraccarichi. Ma l’idea centrale della lezione di oggi e che overloading non riguarda l’ereditarieta e non riguarda la sostituzione del comportamento.
3. Overriding: scelta dell’implementazione in fase di esecuzione
La ridefinizione riguarda strettamente l’ereditarieta: abbiamo una classe base, in essa c’e un metodo (o una proprieta) consentito alla ridefinizione tramite open, e nella sottoclasse scriviamo override e forniamo la nostra implementazione. E importante che Kotlin renda tutto il piu “esplicito” possibile: se ti dimentichi override, il compilatore non sta zitto. E uno dei motivi per cui Kotlin viene spesso chiamato un linguaggio che “non ti lascia rompere il progetto per sbaglio”.
La ridefinizione viene risolta in fase di esecuzione (runtime), quando il programma sta gia girando. Cioe, la chiamata x.render() non si decide in base a quale tipo ha la variabile x nel codice, ma in base a quale classe reale ha l’oggetto che c’e dentro.
Mini-esempio: ridefiniamo un metodo nella sottoclasse
open class ReportLine {
open fun render(): String = "line"
}
class HeaderLine(private val title: String) : ReportLine() {
override fun render(): String = "== $title =="
}
fun main() {
val line: ReportLine = HeaderLine("January")
println(line.render()) // == January ==
}
Nota la cosa chiave: la variabile line ha tipo ReportLine, ma dentro c’e HeaderLine. E quando chiamiamo render() otteniamo l’implementazione della sottoclasse — questo e il polimorfismo in azione, basato sulla ridefinizione.
In Kotlin l’overriding va “abilitato” tramite open
In Kotlin non puoi ridefinire “per sbaglio” qualcosa, perche:
1) la classe base deve essere open, perche altrimenti non puoi nemmeno ereditarla;
2) il metodo nella classe base deve essere open, perche altrimenti non puoi ridefinirlo;
3) nella sottoclasse devi scrivere esplicitamente override.
Questa tripla protezione a volte sembra pedanteria, ma in pratica ti salva da un sacco di “coincidenze magiche”, quando pensavi di aver ridefinito e invece hai solo scritto un altro metodo.
4. La trappola principale: volevi overriding, ma hai fatto overloading
La confusione piu pericolosa e questa: scrivi un metodo nella sottoclasse con lo stesso nome, ma con parametri leggermente diversi; il compilatore non protesta, il progetto compila, tu sei contento… e poi, quando chiami tramite il tipo base, scatta l’implementazione base e inizi a sospettare un complotto.
Per vederlo, inseriamo un esempio nel nostro ipotetico BudgetCLI. Immaginiamo di avere l’invio di notifiche (anche solo in console): il sender base sa “inviare un messaggio”, mentre un sender “email” vuole conoscere anche l’indirizzo.
Classe base
open class Sender {
open fun send(text: String) {
println("send: $text") // send: ...
}
}
Sottoclasse con la trappola
class EmailSender : Sender() {
fun send(text: String, to: String) { // questo NON e override, ma overload
println("send to $to: $text") // send to ...
}
}
Dal punto di vista umano “ha senso”: EmailSender “sa inviare”, quindi sarebbe una “sostituzione”. Ma dal punto di vista del linguaggio sono due metodi diversi: send(String) e send(String, String).
Ora la cosa piu importante: guardiamo il codice che accetta il tipo base (esattamente lo stile che usiamo dopo la lezione sul polimorfismo).
fun notifyUser(sender: Sender) {
sender.send("Budget report is ready")
}
fun main() {
val s: Sender = EmailSender()
notifyUser(s) // send: Budget report is ready
}
L’output sara quello base:
send: Budget report is ready
Perche? Perche la funzione notifyUser conosce solo il contratto Sender.send(String). E il metodo send(String, String) non fa parte di quel contratto. Esiste, ma e “a lato”, e il polimorfismo non lo vede.
Come appare il vero overriding
Perche sia una ridefinizione, la firma deve combaciare e la sottoclasse deve avere override:
class EmailSender(private val defaultTo: String) : Sender() {
override fun send(text: String) {
println("send to $defaultTo: $text") // send to ...
}
}
Ora la stessa chiamata tramite il tipo base dara un comportamento diverso, ed e esattamente cio che di solito ci aspettiamo dall’ereditarieta.
5. Come il compilatore sceglie il metodo
E molto utile tenere a mente quando viene risolto il sovraccarico e quando viene risolta la ridefinizione. Sono due “momenti della verita” diversi.
Disegniamo un piccolo schema: si, i programmatori disegnano schemi — altrimenti come si spiega quello che succede nella testa del compilatore?
flowchart TD
A["Kod: f(x)"] --> B{"Est' neskol'ko f(...) s raznymi parametrami?"}
B -- "Da" --> C["Overloading: vybor po tipam argumentov (compile-time)"]
B -- "Net" --> D{"Metod open + override v ierarkhii klassov?"}
D -- "Da" --> E["Overriding: vybor po real'nomu tipu ob"ekta (runtime)"]
D -- "Net" --> F["Obychnyy vyzov odnogo metoda"]
Il sovraccarico riguarda soprattutto “l’insieme dei parametri e adatto/non e adatto”. La ridefinizione riguarda “quale oggetto reale c’e dentro una variabile di tipo base”.
Tabella: overloading vs overriding
Per fissare le differenze, e comodo confrontare questi fenomeni in un’unica tabella. Non sostituisce la comprensione, ma aiuta molto quando nel codice “non sei sicuro di cosa sia”.
| Caratteristica | Overloading | Overriding |
|---|---|---|
| Dove avviene | Di solito in una sola classe (o in un insieme di funzioni) | Nella gerarchia “base → sottoclasse” |
| Cosa cambia | Insieme dei parametri (firma) | Implementazione dello stesso metodo |
| Servono open/override | No | Si: open nella base e override nella sottoclasse |
| Quando viene scelto | In fase di compilazione | In fase di esecuzione |
| Quale tipo conta di piu | Tipi degli argomenti della chiamata | Tipo reale dell’oggetto (polimorfismo) |
| Obiettivo tipico | Comodita dell’API: modi diversi di chiamare | Modificare o estendere il comportamento della classe base |
6. Esempio pratico: stampa delle operazioni
Ora facciamo un esempio che assomiglia a codice reale di un’app didattica, non a un Sender astratto. Supponiamo che in BudgetCLI abbiamo una classe base di operazione e due sottoclassi. Non entriamo in sealed class (lo abbiamo visto prima, ma ora ci interessa proprio la meccanica dell’ereditarieta).
Modello base delle operazioni
open class Transaction(
val amountCents: Int,
val note: String
)
class Expense(amountCents: Int, note: String, val category: String) :
Transaction(amountCents, note)
class Income(amountCents: Int, note: String, val source: String) :
Transaction(amountCents, note)
La sintassi dell’ereditarieta in Kotlin usa i due punti : e la chiamata al costruttore della classe base.
Formatter con overriding
Ora facciamo un formatter base e un formatter “piu intelligente”. Quello base stampa “come sa fare”, quello intelligente stampa piu in dettaglio.
open class TransactionFormatter {
open fun format(tx: Transaction): String {
return "amount=${tx.amountCents} note=${tx.note}"
}
}
class VerboseFormatter : TransactionFormatter() {
override fun format(tx: Transaction): String {
return "[TX] " + super.format(tx)
}
}
Qui VerboseFormatter ridefinisce davvero format(Transaction), e la chiamata tramite tipo base deve selezionare l’implementazione corretta.
fun printAll(formatter: TransactionFormatter, items: List<Transaction>) {
for (tx in items) {
println(formatter.format(tx))
}
}
fun main() {
val list = listOf<Transaction>(
Expense(1500, "Lunch", "Food"),
Income(250000, "Salary", "Job")
)
val f: TransactionFormatter = VerboseFormatter()
printAll(f, list)
// [TX] amount=1500 note=Lunch
// [TX] amount=250000 note=Salary
}
Questo e overriding + polimorfismo.
Aggiungiamo overloading e vediamo dove e facile confondersi
Mettiamo che ti venga voglia di formattare “un po’ meglio” nello specifico Expense, per stampare la categoria. E scrivi:
class VerboseFormatter : TransactionFormatter() {
override fun format(tx: Transaction): String {
return "[TX] amount=${tx.amountCents} note=${tx.note}"
}
fun format(tx: Expense): String { // overload, non override
return "[EXPENSE] amount=${tx.amountCents} category=${tx.category}"
}
}
Sembra logico. Ma ora guarda le conseguenze.
Se chiami format direttamente con Expense, il compilatore scegliera il sovraccarico:
fun main() {
val f = VerboseFormatter()
val e = Expense(1500, "Lunch", "Food")
println(f.format(e)) // [EXPENSE] amount=1500 category=Food
}
Ma se memorizzi Expense in una variabile di tipo Transaction (e nelle collezioni di tipo base succedera continuamente), allora il sovraccarico puo “sparire” dalla selezione:
fun main() {
val f = VerboseFormatter()
val tx: Transaction = Expense(1500, "Lunch", "Food")
println(f.format(tx)) // [TX] amount=1500 note=Lunch
}
E questo non e un bug. E una conseguenza diretta del fatto che l’overload viene scelto in base ai tipi degli argomenti che il compilatore vede nel punto di chiamata, non in base al tipo reale dell’oggetto. Il tipo reale e importante per l’overriding, non per l’overloading.
Se ti aspettavi che ovunque venisse stampato "[EXPENSE] ...", hai appena ottenuto un piccolo ma molto realistico “perche non funziona”.
7. Come evitare la trappola
In Kotlin c’e un buon meccanismo di difesa: se stai davvero ridefinendo un metodo, sei obbligato a scrivere override. E questo implica una regola pratica che fa risparmiare ore:
Se scrivi un metodo in una sottoclasse e pensi “sto sostituendo il comportamento della base”, il primo pensiero deve essere “dov’e override?”. Se override non si lascia scrivere (il compilatore protesta), allora non stai ridefinendo: stai facendo qualcos’altro.
Ed e utile anche quando non sei sicuro che la firma combaci. Prova a scrivere override — e il compilatore o confermera, o ti spieghera perche non e override. Qui Kotlin fa da compagno di lavoro, che non ti lascia “sbagliare in silenzio”.
8. Errori tipici
Errore n. 1: “Ho aggiunto un metodo con lo stesso nome nella sottoclasse — quindi l’ho ridefinito”.
Sembra cosi finche non aggiungi o rimuovi un parametro, o non cambi il tipo di un argomento. In quel momento in realta stai creando un sovraccarico, non una ridefinizione. Il sintomo di solito si manifesta cosi: quando chiami tramite il tipo base, viene eseguita la versione base e “la tua nuova” sembra ignorata.
Errore n. 2: aspettarsi che l’overload venga scelto in base al tipo reale dell’oggetto.
E una confusione molto comune: “ma l’oggetto e davvero Expense, perche non ha chiamato format(Expense)?”. Perche l’overload viene scelto dal compilatore, che guarda il tipo della variabile nel punto di chiamata. Se la variabile ha tipo Transaction, il compilatore scegliera correttamente format(Transaction), anche se dentro c’e un Expense.
Errore n. 3: provare a “sistemare” la cosa con controlli di tipo nel codice chiamante.
Dopo l’errore precedente spesso nasce la tentazione di fare when (tx) { is Expense -> ... }. A volte e giustificato, ma molto spesso significa che hai progettato male il contratto della classe base. Se il comportamento deve davvero variare in base ai tipi, di solito e meglio dare alla classe base un metodo open e ridefinirlo nelle sottoclassi, invece di spargere la logica in tutti i punti in cui l’oggetto viene usato.
Errore n. 4: troppi sovraccarichi per “comodita” che iniziano a entrare in conflitto.
Il sovraccarico va bene finche e ovvio. Ma se fai cinque versioni di format(...) con parametri simili (Int, Long, Number, nullable/non-nullable), prima o poi otterrai overload resolution ambiguity e inizierai a odiare tutto cio che vive. In questi casi spesso e piu semplice avere un solo metodo e usare argomenti nominati o valori di default (se e davvero un unico contratto “di significato”).
Errore n. 5: pensare che la ridefinizione sia possibile senza open nella classe base.
In Kotlin una classe e i suoi membri sono final per default, e non possono essere ridefiniti finche non lo consenti esplicitamente tramite open. Se provi a costruire ereditarieta “in stile Java vecchio” (dove molte cose sono virtuali per default), il compilatore ti ricordera continuamente: “prima concordate l’API della classe base”.
Errore n. 6: pensare che la differenza sia solo terminologica, e nella pratica “sia uguale”.
Nella pratica la differenza e enorme: il sovraccarico influenza cio che il compilatore scegliera in base agli argomenti, mentre la ridefinizione influenza cio che verra scelto in una chiamata polimorfica. Se costruisci codice con collezioni di tipo base e con elaborazione “tramite un contratto comune”, confondere overload/override diventa una fonte di bug molto spiacevoli, ma perfettamente logici.
GO TO FULL VERSION