Introduzione a Kotlin: Parte 3 – Le classi

I

Benvenuti nella terza parte Kotlin – Gli oggetti della serie sul Kotlin. Potete trovare qui l’articolo precedentequi 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 Int
  • class 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 prima
  • var 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

  1. Dichiara una classe astratta Animal, cioè che dovrà per forza essere sottoclassata per generare oggetti compatibili col suo tipo.
  2. Dichiara un metodo astratto greet(), cioè che dovrà essere completato da una classe derivata. Restituisce String? e non String perché non tutti gli animali potrebbero avere capacità di salutare.
  3. Dichiara una classe concreta e finale Dog, che eredita dalla classe Animal le sue proprietà, e chiama con () il suo costruttore base predefinito.
  4. Dichiara un metodo concreto greet() di tipo String questa volta, perché è sicuro che non verrà ritornato null ma una stringa effettiva. Possibile la differenza del tipo perché StringString? effettivamente sono dello stesso tipo in Kotlin.
  5. Dichiara una variabile di tipo Animal istanziata a runtime come Dog. A livello di compilazione, la variabile è di tipo di una certa classe (astratta). A runtime, la variabile è effettivamente tipo di un’altra classe derivata.
  6. Ciò comporta che quando si chiama il metodo greet(), si dovrà guardare a runtime l’effettiva classe dell’oggetto per poter eseguire il codice. Infatti greet() di Animal non ha corpo, ma quella istruzione, stampa comunque in output “Woof woof”. Questo approccio è anche detto binding dinamico.
  7. Istanzia adesso la variabile di prima come oggetto Cat. Adesso il metodo greet() avrà un comportamento differente, ben quanto per il compilatore, la variabile animal è sempre stata di tipo Animal.
  8. 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 di cat a runtime e capire cosa eseguire, perché cat non potrà essere di tipo Dogo 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 CatDog, 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à, presizelist.  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 membri
  • protected simile a private, 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 pubblicamente
  • internal membri della classe visibili a qualsiasi cliente del package o del modulo corrente
  • public 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.

A proposito di me

Ruggiero Altini

Laureato in Informatica, appassionato di algoritmi, ha esperienza in Computer Vision, Machine Learning e nello sviluppo di app per iOS e Android. Ha usato e usa estensivamente Java, Python, C++, Swift e Kotlin. Si interessa attualmente ai rami dell'Intelligenza Artificiale.

I nostri Partner

Gli articoli più letti

Articoli recenti

Commenti recenti