Benvenuti nella terza parte Kotlin – Gli oggetti della serie sul Kotlin. Potete trovare qui l’articolo precedente, qui la prima parte, mentre invece in questo articolo uno sguardo generale al linguaggio per i programmatori Java/OO.
Kotlin: Le classi
Dopo aver introdotto la concezione di oggetto, possiamo introdurre quella di classe.
Una classe è un costrutto onnipresente nei linguaggi Object-Oriented, e si usa per denotare una famiglia di oggetti; un oggetto invece, rappresenta solo un’entità di tutto l’insieme.
Un esempio era stato fatto nel caso dei numeri interi: Se rappresento con Int
la famiglia dei numeri interi, allora 1
è un oggetto di quella classe; 2
anche, e così via.
In Kotlin, non esistendo tipi “primitivi”, come accennato nel primo articolo della serie, formalmente qualsiasi variabile voi dichiarate, è un riferimento ad oggetto, dunque proveniente a sua volta da una classe.
Infatti ogni oggetto è un’istanza di una classe. Gli oggetti “anonimi” visti nell’articolo precedente fanno anch’essi parte di una classe anonima, come dimostrato nello snippet:
// Funzione di tipo implicito definito dall'oggetto anonimo private fun makePoint(px: Int, py: Int) = object { var x = px var y = py } fun main(args: Array<String>) { var punto1 = makePoint(0, 0) var punto2 = makePoint(0, 0) punto2 = punto1 // Consentito. La funzione makePoint ha un unico tipo di ritorno }
dove punto1
e punto2
sono dello stesso tipo, e dunque era permessa l’assegnazione.
Se fossero stati due oggetti di tipo diverso, anche se identici, per via della tipizzazione statica del Kotlin, si sarebbe presentato un errore a tempo di compilazione
var punto1 = object { // punto1 variabile ad oggetto anonimo di un certo tipo var x = 0 var y = 0 } var punto2 = object { // punto1 variabile ad oggetto anonimo di un altro tipo var x = 0 var y = 0 } punto2 = punto1 // Error: Type Mismatch // punto2 e punto1 sono di tipi differenti.
La differenza tra i due snippet, è che makePoint
essendo una funzione, ha un solo tipo di ritorno, e dunque non può ritornare a livello di compilazione tipi differenti caso per caso.
Il modo per definire esplicitamente una classe per i punti, è utilizzando il costrutto class
dove specifichiamo la struttura e la logica degli oggetti che istanzieremo.
class Point { // Gli attributi x e y var x: Int var y: Int // Costruttore che inizializza i punti e ci permette l'istanziazione Point(0,0) constructor(x: Int, y: Int) { this.x = x this.y = y } }
Dichiarazione riducibile in forma equivalente semplicemente a
class Point(var x: Int, var y: Int)
utilizzando il costruttore di default e specificandoci anche gli attributi di default.
// Esempio di inizializzazione dei punti var point1 = Point(0, 0) var point2 = Point(100, 100)
Vediamo adesso dunque le varie proprietà delle classi in Kotlin oltre al semplice poter definire generalizzazioni di oggetti.
Ereditarietà
Considerando una classe, come un repertorio di conoscenze, è possibile definire sottoclassi più specifiche sulla stessa base, anche dette, classi specializzanti.
Le sottoclassi erediteranno tutte le proprietà e la logica delle superclassi, e potranno aggiungere funzionalità o attributi, oppure modificare funzionalità esistenti.
Esempio: Un rettangolo è composto da una base e da una altezza. Se lo si immagina in un piano, anche di un punto di origine. Un quadrato, è una specializzazione di un rettangolo. Se abbiamo una funzione che si occupa di disegnare un rettangolo, allora è ragionevole aspettarsi che quella funzione, saprà disegnare anche un quadrato.
Soluzione all’esercizio della parte 2
Vediamo adesso la soluzione dell’esercizio precedente, e poi con un’estensione, una classe “Quadrato” (o Square).
// Utilizziamo come piano la console che consente solo punti interi class Point(var x: Int, var y: Int) class Rectangle(open var origin: Point, open var width: Int, open var height: Int) class RectangleDrawer { // Aggiunge size caratteri spazio. private fun leftOffset(size: Int) { for (i in 0..size-1) { print(" ") } } // Stampa size volte il carattere passato private fun printTopDownLedge(size: Int, char: Char) { for (j in 0..size-1) { print(char) } } fun draw(rectangle: Rectangle, char: Char) { // Spazia verticalmente l'origine val xStart = rectangle.origin.x for (i in 0..rectangle.origin.y-1) { println() } // Stampa la cornice superiore leftOffset(xStart) printTopDownLedge(size = rectangle.width, char = char) println() // Stampa il corpo for (i in 0..rectangle.height-2 -1) { leftOffset(xStart) print(char) for (j in 0..rectangle.width-2 -1) { print(' ') } println(char) } // Stampa la cornice inferiore leftOffset(xStart) printTopDownLedge(size = rectangle.width, char = char) println() } } fun main(args: Array<String>) { val rect = Rectangle(origin = Point(1,1), width = 5, height = 3) val drawer = RectangleDrawer() drawer.draw(rect, '#') }
Quanto codice scritto per il RectangleDrawer! E stampa in output solo una figura alla volta.
E se lo si volesse riciclare per stampare anche un quadrato? Certo, in questo caso basterebbe inizializzare un altro rettangolo dove width ed height coincidano, ma in un caso dove non conosciamo a priori (a tempo di compilazione) i loro valori? Dovrebbero essere controllati a runtime (es. con un if) per vedere se il rettangolo è un quadrato legale.
Invece potremmo semplicemente accettare la forma
val square = Square(origin = Point(2, 2), side = 4) drawer.draw(square, 'S')
Per realizzare questo, è sufficiente dichiarare una classe Square che erediti le proprietà di Rectangle, sia dotato di un costruttore che ammette l’istanziazione specificando il lato, non separatemente altezza e larghezza, e faccia sì che se si modificano altezza e larghezza (supponendo che gli oggetti siano mutabili), esse continuino a coincidere.
Inoltre, modifichiamo la dichiarazione di Rectangle rendendolo open, per comunicare al compilatore, che vogliamo poter ammettere sottoclassi di quella classe (altrimenti le classi si dicono final di default, ovvero che non ammettono classi derivate).
// Affinché Rectangle ammetta sottoclassi, è necessario definirlo open open class Rectangle(open var origin: Point, open var width: Int, open var height: Int) ... class Square(origin: Point, side: Int) : Rectangle(origin, side, side) { override var width: Int get() = super.width set(value) {super.width = value; super.height = value} override var height: Int get() = super.height set(value) {super.height = value; super.width = value} var side: Int get() = super.height set(value) {super.height = value; super.width = value} }
Sintassi
Ecco una spiegazione più approfondita a quanto scritto:
class Square(origin: Point, side: Int)
Classe rettangolo dotata di costruttore che accetta due oggetti. Uno di tipo Point e l’altro Intclass Square(...) : Rectangle(origin, side, side)
La classe Square è una sottoclasse di Rectangle, e chiama il suo costruttore originale passandogli la stessa origine, e side come altezza e larghezza allo stesso tempo.override var width: Int ...
Cambia il comportamento standard della proprietà width, nella fattispecie nei getter e i setter. Ogni volta che si richiede width, si restituisce il valore del rettangolo originale. Ogni volta che si modifica width, si modifica anche height, facendole coincidere anche se si prova ad alterare il quadrato dopo l’inizializzazione.override var height: Int ...
Come primavar side: Int ...
Non è un attributo dotato di intrinseco valore. È comunque una proprietà, ma non conserva un valore. Se si richiede side, si restituisce la proprietà altezza (o equivalentemente, larghezza). Se si modifica side, si modificano sia altezza che larghezza.
Per i programmatori Java: è come avere un metodo setter e un metodo getter, ma non un attributo corrispondente al nome, ben sì sfruttarne un altro ancora.
Aggiunto questo codice al programma di prima, sarà possibile aggiungere le due righe dello snippet precedente al main per disegnare anche un quadrato.
SSSS S S S S SSSS
Yuppy!
Se non siete soddisfatti e la figura non vi ricorda molto un quadrato (mal grado le S coincidano verticalmente e orizzontalmente), provate ad aggiungere una spaziatura orizzontale tra un carattere e l’altro.
Tipi di ereditarietà
Va da sé che questo è solo un tipo di scenario nell’ereditarietà, in particolare, l’ereditarietà per restrizione, ovvero dove la classe specializzante è una famiglia ristretta rispetto a quella definita dalla superclasse.
Nell’ereditarietà sono anche comuni invece, l’estensione, cioè dove si aggiungono funzionalità alla classe base; oppure, una modifica alle funzioni esistenti.
PolimorfismO
Il polimorfismo assieme all’ereditarietà e l’incapsulamento (che vedremo al paragrafo successivo) è l’altro caposaldo della programmazione ad oggetti.
Ci sono differenti tipi di polimorfismo e qui ci limiteremo a quello per inclusione.
Esempio
Mentre prima è stato fatto un esempio più concreto per l’ereditarietà, adesso vediamo un esempio più astratto e semplice, che include sia ereditarietà che uso di polimorfismo.
Definiamo la classe base astratta Animal, e le sottoclassi Dog e Cat, e Crocodrile.
Una classe astratta è una classe da cui non si può istanziare direttamente un oggetto, ma bisogna definire una sua sottoclasse.
// 1 abstract class Animal { // 2 abstract fun greet(): String? } // 3 class Dog : Animal() { // 4 override fun greet(): String { return "Woof woof" } } class Cat : Animal() { override fun greet(): String { return "Meow" } } class Crocodrile : Animal() { override fun greet(): String? { return null } } fun main(args: Array<String>) { // 5 var animal: Animal = Dog() println("ANIMAL (DOG)") // 6 println(animal.greet()) // 7 animal = Cat() println("ANIMAL (CAT)") println(animal.greet()) animal = Crocodrile() println("ANIMAL (CROCODRILE)") println(animal.greet()) //8 var cat: Cat = Cat() println("CAT GREET") println(cat.greet()) // 8 bis // non si può scrivere cat = Dog() var dog: Dog = Dog() println("DOG GREET") println(dog.greet()) }
Sguardo alla sintassi
Spieghiamo la sintassi analiticamente
- Dichiara una classe astratta Animal, cioè che dovrà per forza essere sottoclassata per generare oggetti compatibili col suo tipo.
- Dichiara un metodo astratto
greet()
, cioè che dovrà essere completato da una classe derivata. RestituisceString?
e nonString
perché non tutti gli animali potrebbero avere capacità di salutare. - Dichiara una classe concreta e finale Dog, che eredita dalla classe Animal le sue proprietà, e chiama con
()
il suo costruttore base predefinito. - Dichiara un metodo concreto
greet()
di tipoString
questa volta, perché è sicuro che non verrà ritornatonull
ma una stringa effettiva. Possibile la differenza del tipo perchéString
eString?
effettivamente sono dello stesso tipo in Kotlin. - Dichiara una variabile di tipo
Animal
istanziata a runtime comeDog
. A livello di compilazione, la variabile è di tipo di una certa classe (astratta). A runtime, la variabile è effettivamente tipo di un’altra classe derivata. - Ciò comporta che quando si chiama il metodo
greet()
, si dovrà guardare a runtime l’effettiva classe dell’oggetto per poter eseguire il codice. Infattigreet()
diAnimal
non ha corpo, ma quella istruzione, stampa comunque in output “Woof woof”. Questo approccio è anche detto binding dinamico. - Istanzia adesso la variabile di prima come oggetto
Cat
. Adesso il metodogreet()
avrà un comportamento differente, ben quanto per il compilatore, la variabileanimal
è sempre stata di tipoAnimal
. - Dichiara e istanzia una variabile di tipo
Cat
. Adesso il tipo della variabile definito a tempo di compilazione (compile-time) e tempo di esecuzione (o runtime) coincide. Quando si eseguiràcat.greet()
, non si dovrà andare a guardare il tipo dicat
a runtime e capire cosa eseguire, perchécat
non potrà essere di tipoDog
o altro. Questo è anche detto binding statico.
Una nota sul binding:
Un approccio polimorfico facente uso di binding dinamico, consente maggiore flessibilità nel codice. Può essere molto utile talvolta avere oggetti di tipo non esattamente definito, e preoccuparsi di che cosa fanno e non come lo fanno. Ad esempio in un gioco di scacchi, un metodo moves()
che elenca le possibili mosse che può fare un pezzo di scacchi, malgrado ogni pezzo ha le sue regole per potersi muovere.
Un approccio basato su binding statico invece, sacrifica della flessibilità per della piccola velocità d’esecuzione, oppure per un voluto maggior rigore. Sulla velocità infatti, la runtime di Kotlin utilizzata (es. JVM), saprà esattamente che codice eseguire alla chiamata del metodo perché sa a quale metodo di quale classe ci si sta riferendo. Nel caso del binding dinamico invece, dovrà controllare se quell’oggetto è anche tipo di una sottoclasse e in tal caso dunque eseguire il suo metodo che andrà a sostituire quello base.
Una nota sul binding per programmatori Java e open classes
Se un programmatore Java si limita a leggere lo snippet di codice di sopra, e legge di binding dinamico e statico, obietterà: “E chi dice che l’oggetto cat
di tipo Cat
, non può essere a runtime un oggetto di una classe derivata da Cat
, necessitando comunque del binding dinamico?
La risposta è semplice: In Kotlin le classi sono implicitamente final, cioè non possono avere sottoclassi. Se provate a sotto-classare la classe Cat o Dog, il compilatore vi darà un errore. Per rendere una classe sotto-classabile, bisogna specificarlo prima della dichiarazione con la keyword open.
Incapsulamento
Ecco l’ultimo caposaldo della programmazione ad oggetti, ovvero che un linguaggio deve possedere per potersi definire tale.
L’incapsulamento è il meccanismo per nascondere i dettagli di implementazione di una classe, per assicurarsi che mantenga una semantica valida e che non si manipolino arbitrariamente le sue proprietà.
Vediamo un esempio con una classe che definisce un vettore ordinato, ossia, una struttura dati che mantiene la sua integrità fintanto che tutti gli elementi inseriti siano in ordine. Se così non dovesse essere, chiaramente è un problema. L’utilità di una simile struttura ha varie applicazioni, tra cui performance molto veloci nella ricerca, a semplice comodità per contesti dove vogliamo preservare un ordine tra gli elementi.
Si farà uso della ricerca binaria. In questo articolo di Italiancoders invece è presente una spiegazione con codice e animazioni di tre differenti algoritmi di ricerca in un array.
struttura OrderedVector
import kotlin.collections.ArrayList class OrderedVector(var presize: Int = 10) { var list: MutableList<Int> init { if (presize < 0) { throw RuntimeException("Illegal size") } list = ArrayList(presize) } private fun binarySearchOrInsert(list: MutableList<Int>, item: Int): Int { var inf = 0 var sup = list.size - 1 while (inf <= sup) { val med = (inf + sup) / 2 if (list[med] == item) return med if (list[med] < item) inf = med + 1 else sup = med - 1 } return inf } fun add(item: Int) { // Cerca la posizione tra l'elemento minore di item e l'elemento maggiore di item val position = binarySearchOrInsert(list, item) list.add(position, item) } fun get(position: Int): Int { return list[position] } fun find(item: Int): Int? { val pos = binarySearchOrInsert(list, item) if (item == list.get(pos) ) { return pos } else { return null } } fun size(): Int = list.size override fun toString(): String { return list.toString() } }
La funzione binarySearchOrInsert, ben quanto possa certamente avere un nome migliore, restituisce o la posizione dell’elemento trovato, oppure la posizione dove si dovrebbe inserire il nuovo elemento che si sta cercando. Per differire se l’elemento trovato esiste dunque, bisognerà verificare che la lista o l’array, abbiano in quella posizione, l’elemento trovato. In caso contrario, l’elemento non sarà presente. Altre implementazioni dell’algoritmo di binary search invece in alternativa restituiscono -1. Per maggiore semplicità nel metodo add
, ci è più comoda la nostra implementazione.
La classe presenta due proprietà, presize e list. list
è la proprietà che effettivamente memorizza i valori della nostra collezione. Noi abbiamo un accesso indiscriminato a list
, infatti è compito della classe OrderedVector
assicurarsi con l’algoritmo di ricerca binaria di inserire sempre gli elementi in ordine, ma nulla vieterebbe teoricamente di inserirli in una posizione arbitraria.
Osserviamo dunque un esempio di main.
fun main(args: Array<String>) { val vector = OrderedVector(presize = 10) vector.add(10) vector.add(5) vector.add(1) vector.add(3) vector.add(15) // Stampa il vettore println(vector) // [1, 3, 5, 10, 15] // Cerca la posizione del numero 4 nel vettore println(vector.find(4)) // null // Cerca la posizione del numero 3 println(vector.find(3)) // 1 }
Apparentemente tutto in regola, ma sapendo della proprietà list
, è anche possibile scrivere (alla fine della funzione):
// Violazione alla struttura ordinata vector.list.add(0, 30) println(vector) // [30, 1, 3, 5, 10, 15]
Adesso il nostro vettore non è più ordinato! E non solo, ma anche i valori nella ricerca saranno sballati.
Uso di private
Come impedire una simile alterazione non voluta a una classe?
È sufficiente porre il modificatore di visibilità private alla proprerty list. Inoltre, visto che presize non è più utilizzato dopo la dichiarazione, possiamo rimuovere la keyword var per renderlo un argomento del costruttore anziché una property a sua volta.
class OrderedVector(presize: Int = 10) { private var list: MutableList<Int> ... } fun main(args: Array<String>) { val vector = OrderedVector(presize = 10) vector.add(10) // OK vector.add(5) vector.add(1) vector.add(3) vector.add(15) println(vector) // [1, 3, 5, 10, 15] println(vector.find(4)) // null println(vector.find(3)) // 1 vector.list.add(0, 30) // ERRORE DI COMPILAZIONE QUI println(vector) }
Adesso, non è più possibile rompere la semantica della nostra struttura.
Per quasi tutte le esigenze possibili, Kotlin presenta in totale 4 modificatori di visibilità:
private
significa visibile solamente all’interno della classe, inclusi i suoi membriprotected
simile aprivate
, ma la visibilità si estende anche alle sottoclassi della classe. NB: Criticato da molti in quanto permette di rendere public certi attributi semplicemente dichiarando una sottoclasse per poterli esporre pubblicamenteinternal
membri della classe visibili a qualsiasi cliente del package o del modulo correntepublic
chiunque da qualsiasi contesto può accedere ai membri della classe.
Nota per i programmatori Java: in caso di classi innestate, la classe “outer” non può vedere gli attributi privati della classe “inner”.
Getters
Talvolta può essere utile permettere a un cliente di una classe leggere una determinata informazione pur non potendola modificare. Se ci si limita a private
, si restringe anche la lettura.
In Java (da cui Kotlin ha preso molta ispirazione), si è soliti ad avere un attributo private
, e degli opportuni metodi getter e setter per modificare quell’attributo, rintracciare potenzialmente tali modifiche, in quanto è possibile scrivere qualsiasi codice alla chiamata di un metodo. La presenza di un attributo e metodi getter o setter, corrisponde alla definizione di property (proprietà).
In Kotlin si è sempre parlato di property in questi articoli, e infatti se provate da Java ad accedere alle classi che avete scritto, troverete esposti quei metodi getter e setter.
La scrittura dunque di un metodo adhoc non è necessaria.
È possibile lasciare pubblico il getter ma non il setter, ponendo private set alla riga successiva alla dichiarazione, o a capo, o separata da un punto e virgola dunque.
var id: Int = 0 private set
Nell’esempio in questo caso, non è stato dato nemmeno un getter pubblico, perché essendo la lista mutabile, anche ottenere un suo riferimento in sola lettura permette di modificare i contenuti della lista. La differenza tra riferimenti immutabili e oggetti immutabili, è spiegata nel precedente articolo al paragrafo “Come funzionano gli oggetti in Kotlin” e poi “Oggetti immutabili“.
Conclusione ed esercizio
Fine raggiunta per questo, abbastanza estensivo, articolo! Nel prossimo di questa serie si parlerà di interfacce e in seguito ancora qualcosa in più sui flussi di controllo in Kotlin.
Github
Si possono visualizzare gli esempi completi di codice dell’articolo su questo link.
Esercizio
Scrivere una classe Piece (astratta), e una classe Board. La classe Piece deve presentare una funzione (anche astratta) moves(). Deve anche avere una proprietà placing che restituisce la sua posizione sulla scacchiera, se è presente.
La classe Board invece, deve fornire due metodi place, uno che accetta due parametri, uno per il pezzo e l’altro per la posizione, e piazza il pezzo in quella posizione. L’altro invece, con un solo parametro, quello del pezzo, e restituisce una posizione nullabile.
Come classe per la posizione è possibile usare la classe Point presente negli ultimi articoli.