Capitolo 9

Classi e data class

Finora ogni dato era una variabile separata: un nome di qua, un voto di là. Una classe ti permette di descrivere un "tipo di cosa" e di creare tante copie di quella cosa, ognuna con i suoi valori. In Kotlin, per i dati esiste una scorciatoia speciale: la data class.

In questo capitolo impari

Ripasso lampo: stringhe ed elenchi

Le classi servono anche a mettere ordine nei testi: ripasso del capitolo 8. E prima di avere gli oggetti, gli elenchi di cose si tenevano con liste e calcoli: ripasso del capitolo 6.

Che cosa stampa questo programma?

fun main() {
    val s = "programmare"
    println(s.substring(0, 7))
    println(s[0])
}
program e pEsatto: substring(0, 7) prende dall'indice 0 (compreso) al 7 (escluso), e s[0] è il primo carattere.
programm e pIl secondo indice è escluso: si ferma al carattere 6, quindi il risultato è di 7 caratteri.
rogramma e pQuesto sarebbe substring(1, 8): il primo numero è l'inizio, non la lunghezza.

substring(a, b) prende i caratteri da a (compreso) a b (escluso): la lunghezza del pezzo è la differenza fra i due indici, non il secondo indice.

Che cosa stampa questo programma?

fun main() {
    val prezzi = mutableListOf(5, 12, 8, 20)
    val cari = prezzi.filter { it > 10 }
    println(cari.size)
    println(cari.sum())
}
2 e 32Esatto: filter tiene solo 12 e 20, quindi due elementi, e sum() li somma in 32.
4 e 45Questo è il risultato senza filtro: size e sum() su tutti e quattro i prezzi.
2 e 2020 è solo l'ultimo prezzo che supera 10: sum() somma tutti gli elementi della lista filtrata.

filter restituisce una nuova lista, e size e sum() lavorano su quella. Con un elenco di valori, però, il prezzo non dice a che cosa appartiene: per tenere insieme un nome e i suoi numeri serve una piccola classe, come data class in questo capitolo.

9.1 Perché le classi: mettere insieme i dati

Immagina di dover gestire dei libri. Con le variabili di prima servirebbero tre variabili per ogni libro: titolo, autore, prezzo. Con dieci libri diventa un caos. Una data class descrive una volta sola che forma ha un libro, poi crei quanti libri vuoi.

  1. println(p) usa il toString() che Kotlin scrive per te: Punto(x=3, y=4), con i nomi delle proprietà.
  2. copy(y = 10) crea un nuovo oggetto cambiando solo y: Punto(x=3, y=10). L'oggetto p non viene toccato.
  3. p == Punto(3, 4) confronta i valori, non l'indirizzo: è true.
  4. La destrutturazione val (a, b) = q mette 3 in a e 10 in b.
Come si legge la dichiarazione

data class Libro(val titolo: String, val autore: String, var prezzo: Double) si legge così: creo un tipo chiamato Libro i cui oggetti hanno un titolo, un autore e un prezzo. I parametri scritti con val diventano proprietà non modificabili, quelli scritti con var (come prezzo) si possono cambiare dopo la creazione.

9.2 toString() e == arrivano gratis

Nelle data class Kotlin scrive per te due cose importantissime: toString(), che trasforma l'oggetto in testo leggibile, e ==, che confronta il contenuto dei due oggetti (non "sono lo stesso oggetto in memoria?" ma "hanno gli stessi valori?").

  1. Con data class Kotlin scrive per te un == che confronta i valori delle proprietà: i due D(1) sono uguali, quindi true.
  2. Con una class normale quel confronto non c'è: == controlla se i due oggetti sono lo stesso oggetto in memoria, e qui sono due oggetti diversi...
  3. ...quindi C(1) == C(1) è false, anche se i valori sono identici.
data class o class?

Usa data class quando l'oggetto serve soprattutto a tenere dei dati (uno studente, un prodotto, una prenotazione): ottieni toString, ==, copy e la destrutturazione senza scrivere nulla. Usa class semplice quando l'oggetto ha un comportamento con stato che cambia nel tempo (un contatore, una connessione). Ne vedrai un esempio più avanti.

9.3 copy(): una copia cambiando solo qualcosa

copy(...) crea un nuovo oggetto con gli stessi valori, tranne quelli che indichi fra parentesi. È comodissimo quando vuoi "quasi lo stesso oggetto" con una differenza.

La proprietà prezzo è dichiarata val nel costruttore. Vuoi uno stesso libro con un prezzo diverso. Che cosa fai?

libro.prezzo = 15.0Kotlin ti ferma: una proprietà val non si può modificare dopo la creazione.
val nuovo = libro.copy(prezzo = 15.0)Esatto: copy crea un nuovo oggetto con tutti i valori uguali tranne quelli che indichi.
Cancelli e ricrei l'oggetto a manoFunziona, ma è più lungo e rischi di dimenticare qualche proprietà: copy fa proprio questo lavoro.

Regola: var nel costruttore = proprietà modificabile; val = per cambiarla serve copy.

copy crea sempre un oggetto nuovo

val nuovo = vecchio.copy(voto = 10) non tocca vecchio: sono due oggetti distinti. Serve anche perché le proprietà val non si possono modificare: invece di cambiarle, crei una copia aggiornata.

9.4 I metodi: far fare qualcosa all'oggetto

Dentro la classe puoi scrivere funzioni: diventano metodi che usi con nomeOggetto.nomeMetodo(). Dentro al metodo puoi leggere le proprietà scrivendone semplicemente il nome.

Funzione con corpo o con =

fun area(): Int = base * altezza è fun area(): Int { return base * altezza } scritti in modo più corto. Per calcoli di una riga si usa il =; per più istruzioni si usano le graffe con return.

9.5 Proprietà nel corpo, init e toString personalizzato

Oltre ai parametri del costruttore, puoi dichiarare altre proprietà nel corpo della classe e inizializzarle con un blocco init { }. In una class normale, poi, puoi decidere tu cosa restituisce toString() scrivendo override fun toString().

Ti serve un tipo che tiene dei dati (uno studente con nome e voto) e vuoi che il confronto con == e la stampa funzionino bene senza scrivere codice. Che cosa dichiari?

data classEsatto: ottieni gratis toString(), == per contenuto, copy() e la destrutturazione.
class normaleCon una class normale devi scrivere tu toString() e non hai né == per contenuto né copy().
Un array di due valoriFunzionerebbe, ma perdi i nomi dei campi: studente[0] non dice niente, studente.nome sì.

Usa data class quando l'oggetto è soprattutto un contenitore di dati; una class normale quando ha un comportamento con stato che cambia nel tempo (per esempio un contatore).

class e data class non sono intercambiabili in tutto

Una class normale non ha == per contenuto né copy() né la destrutturazione: le ottieni solo con data class. Per tenere dati usa quindi quasi sempre data class.

9.6 Destrutturare una data class

I valori del costruttore si possono "spacchettare" in variabili separate. È comodo per lavorare con i valori evitando di scrivere ogni volta punto.x.

9.7 Riepilogo

ScriviCosa fa
data class C(val a: Int, var b: String)dichiara un tipo con due proprietà (una fissa, una modificabile)
val o = C(1, "x")crea un oggetto (chiama il costruttore)
o.a, o.blegge una proprietà
o.b = "y"cambia una proprietà dichiarata con var
o.toString()testo leggibile, automatico nelle data class: C(a=1, b=x)
o == ptrue se tutte le proprietà sono uguali (data class)
o.copy(a = 5)nuovo oggetto con a cambiato e il resto uguale
val (x, y) = puntoestrapola le proprietà in variabili
fun nome(): Tipo { ... }metodo della classe, si chiama con o.nome()
fun metodo(param: Tipo = valore)argomento con valore di default
init { ... }codice eseguito alla creazione dell'oggetto
override fun toString()personalizza il testo di un oggetto (class normale)

9.8 Esercizi guidati

Leggi tre dati: nome di un prodotto, prezzo unitario e quantità. Crea una data class Prodotto(val nome: String, val prezzo: Double, val quantita: Int) con un metodo totale() che restituisce prezzo * quantita. Stampa una scheda con nome, prezzo, quantità e totale (con due decimali).

Il metodo si scrive fun totale(): Double = prezzo * quantita. Per i due decimali usa "%.2f".format(...).

  1. La data class dichiara per prima la "forma" del prodotto: le tre proprietà arrivano dal costruttore con val. Prima il tipo, poi l'uso.
  2. fun totale(): Double = prezzo * quantita è un metodo: dentro vede le proprietà come se fossero variabili normali.
  3. scheda() mette insieme testo e numeri usando il template $nome e la formattazione "%.2f".format(...).
  4. Dopo aver descritto il tipo, il programma legge i tre dati (readln() per il nome, readlnOrNull()?.toDoubleOrNull() ?: 0.0 per il prezzo) e costruisce l'oggetto con Prodotto(nome, prezzo, quantita).

Crea una data class Studente(val nome: String, val voto: Int) e tre studenti ("Anna" 8, "Luca" 5, "Sara" 9). Stampa la pagella, la media dei voti (due decimali), il nome del migliore e infine il migliore "con lode" creato con copy(voto = 10).

Per il migliore parti da var migliore = classe[0] e aggiornalo quando s.voto > migliore.voto. La copia con la lode è migliore.copy(voto = 10).

  1. La lista listOf(...) contiene tre oggetti Studente creati con il costruttore.
  2. Nel ciclo for accumuliamo la somma dei voti e cerchiamo il voto più alto, come si fa con i numeri normali: s.voto è un Int.
  3. La media si calcola con somma.toDouble() / classe.size: il toDouble() evita la divisione intera.
  4. migliore.copy(voto = 10) crea un nuovo oggetto con lo stesso nome e voto cambiato: l'oggetto migliore originale resta invariato.
  5. Stampando l'oggetto con "... " + migliore.copy(voto = 10) entra in gioco il toString() automatico: Studente(nome=Sara, voto=10).

Crea una data class Spettatore(val nome: String, val eta: Int) con un metodo prezzo(): 5.0 euro sotto i 14 anni, 6.0 euro dai 65 in su, 9.0 euro altrimenti. Leggi nome ed età, stampa il biglietto e poi, con copy, il prezzo che pagherebbe la stessa persona se avesse 70 anni.

Il prezzo può essere una if usata come espressione: fun prezzo(): Double = if (eta < 14) 5.0 else if (eta >= 65) 6.0 else 9.0.

  1. prezzo() usa la forma = if (...) ... else ...: in Kotlin una if può essere un'espressione che restituisce un valore.
  2. Le condizioni si controllano in ordine: prima i minori di 14 anni, poi i maggiori di 64; chi non rientra paga il prezzo pieno.
  3. biglietto() chiama un altro metodo della stessa classe scrivendo semplicemente prezzo(): i metodi si vedono tra loro.
  4. copy(eta = 70) e copy(eta = 10) mostrano in modo semplice come cambierebbe il prezzo: è utile per provare casi diversi senza riscrivere l'oggetto.

Scrivi tu il programma, senza codice di partenza. Crea una data class Riga(val nome: String, val quantita: Int, val prezzo: Double) con un metodo totale() che restituisce quantita * prezzo. Leggi tre articoli di magazzino, uno dopo l'altro: per ognuno il nome, la quantità e il prezzo unitario, sempre in quest'ordine (tre righe di input per articolo). Metti gli articoli in una lista e stampa: l'elenco con il totale di ogni riga (due decimali), il totale del magazzino (due decimali) e la riga più cara, cioè quella con il totale maggiore, con il suo nome.

Attenzione a un caso limite: una quantità può mancare (al posto del numero c'è una riga vuota). Il programma non deve fermarsi: readlnOrNull()?.toIntOrNull() ?: 0 la considera 0.

Con i dati Quaderno 3 2.5, Zaino 1 39.9 e Penne (vuoto) 0.8 deve stampare Totale: 47.40 euro e Riga più cara: Zaino (39.90 euro).

La strategia: dichiara la classe con il suo metodo, poi riempi una mutableListOf<Riga>() con un ciclo for (i in 1..3) che a ogni giro legge le tre righe nello stesso ordine. Per il totale prepara un accumulatore var totale = 0.0 e per la riga più cara tieni da parte l'intero oggetto, non solo il numero: così alla fine hai anche il nome da stampare. La quantità mancante non deve fermare il programma: toIntOrNull() ?: 0 la trasforma in zero.

  1. La data class descrive una riga di magazzino e totale() è un metodo che calcola un valore derivato dalle proprietà: nome, quantità e prezzo viaggiano insieme, invece di essere liste separate da tenere allineate.
  2. La lista si crea vuota con mutableListOf<Riga>() e cresce dentro il ciclo con add(...): i dati arrivano dall'input, quindi non si conoscono in anticipo.
  3. toIntOrNull() ?: 0 gestisce il dato mancante: la conversione non va a buon fine e il ?: fornisce il valore di riserva, così una riga vuota non ferma il programma.
  4. Somma e ricerca del massimo sono gli algoritmi dei capitoli precedenti applicati a r.totale(); tenere l'oggetto piuCara permette di stamparne anche il nome, e la prima riga fa da valore iniziale del confronto.

Il programma deve descrivere un libro e produrre una copia scontata, ma non funziona. Deve stampare Libro(titolo=Kotlin, prezzo=20.0) e poi Libro(titolo=Kotlin, prezzo=15.0).

Una proprietà dichiarata val nel costruttore non si modifica: per avere lo stesso libro a un prezzo diverso si crea un oggetto nuovo con copy.

  1. libro.prezzo = 15.0 è un errore: prezzo è una proprietà val, quindi non si può cambiare dopo la creazione.
  2. copy(prezzo = 15.0) crea un nuovo oggetto con tutti i valori uguali tranne quello indicato.
  3. L'oggetto di partenza resta Libro(titolo=Kotlin, prezzo=20.0): questo è il vantaggio di copy, che non tocca l'originale.
  4. Se il prezzo dovesse cambiare davvero nel tempo, la proprietà andrebbe dichiarata var: data class Libro(val titolo: String, var prezzo: Double).
  • so dichiarare una data class con proprietà val e var
  • so creare oggetti e leggerne o modificarne le proprietà
  • so spiegare che cosa Kotlin scrive gratis in una data class
  • so usare copy() per creare una variante di un oggetto
  • so scrivere metodi che leggono le proprietà dell'oggetto
  • so destrutturare un oggetto con val (a, b) = oggetto
  • so leggere un messaggio di errore e correggere il programma da solo