La programmazione ad oggetti

Di cosa si tratta?

Affrontiamo adesso un argomento che fino a cinque anni fa sarebbe stato definito "avanzato". Oggi la "Programmazione ad oggetti" è divenuta la norma. Linguaggi come Java o Python sono costruiti intorno a tale concetto a tal punto che si può fare ben poco senza imbattersi negli oggetti. Quindi di cosa si tratta?

Le migliori introduzioni sono a mio avviso:

I testi sono in ordine crescente di approfondimento, dimensione ed esattezza accademica. Il primo è già adeguato per la maggior parte degli scopi non professionali. Per una introduzione maggiormente focalizzata sulla programmazione consiglio Object Oriented Programming di Timothy Budd (seconda edizione). Non l'ho letta presonalmente, ma ha ottenuto ottime recensioni da parte di persone di cui ho stima.

[Mi sono limitato, per il momento, a riportare le citazioni dell'originale. Mi riservo di cercare citazioni di testi italiani in una prossima edizione di questa traduzione. N.d.T.]

Per finire potete trovare una gran mole di informazioni su tutti gli argomenti correlati alla programmazione ad oggetti presso il sito: http://www.cetus.org

Assumendo che in questo momento non abbiate il tempo o la voglia di scorrere tutti i testi ed i link citati, forniremo una breve introduzione al concetto di programmazione ad oggetti. (Nota: alcuni trovano difficoltà nei primi approcci alla programmazione ad oggetti, altri la trovano estremamente naturale. Non preoccupatevi se appartenente alla prima categoria, potrete usare gli oggetti anche se non siete realmente "illuminati").

Una nota finale: in questo capitolo useremo esclusivamente Python poiché né il BASIC né il Tcl supportano gli oggetti. È sempre possibile implementare un progetto con i criteri della programmazione ad oggetti in un linguaggio che non la supporta adottando opportune convenzioni nella codifica, ma di solito si tratta di una opportunità non raccomandabile. Se il problema si presta ad una implementazione con tecniche ad oggetti è meglio utilizzare un linguaggio ad oggetti.

Dati e funzioni insieme

Gli oggetti sono collezioni di dati e di funzioni che operano su di essi. Le due cose sono impacchettate insieme in modo che qualunque parte di un programma che utilizza un oggetto ha accesso non solo agli attributi dei dati, ma anche a tutte le operazioni disponibili.

Ad esempio un oggetto stringa contiene la stringa di caratteri, ma fornisce anche metodi che operano sulla stringa: ricercano caratteri, cambiano maiuscole in minuscole, calcolano la lunghezza, ecc.

Gli oggetti utilizzano una metafora detta scambio di messaggi per cui un oggetto invia un messaggio ad un altro oggetto e l'oggetto che lo riceve risponde eseguendo una delle sue operazioni, ovvero un metodo. Cioè un metodo viene invocato quando l'oggetto che ne è proprietario riceve il messaggio corrispondente. Esistono varie notazioni per rappresentare questa richiesta ma la più comune è simile al modo con cui si accede ai campi di un record: usando il punto. Quindi per una ipotetica classe "widget":

w = Widget() # crea w, una nuova istanza di widget
w.paint()  # invia il messaggio "paint" all'oggetto

Il risultato è che il metodo paint dell'oggetto widget viene attivato.

Come si definiscono le classi

Esattamente come esistono vari tipi di dati anche gli oggetti possono essere di diversi tipi. Collezioni di oggetti con caratteristiche identiche vengono detti collettivamente classi. Dopo aver definito una classe possiamo creare istanze della classe che sono gli oggetti. Possiamo poi usare variabili del programma per memorizzare riferimenti agli oggetti.

Esaminiamo un esempio concreto per spiegare meglio. Vogliamo creare una classe messaggio che contiene una stringa, il testo del messaggio, ed un metodo che visualizza il messaggio.

class Messaggio:
    def __init__(self, laStringa):
        self.testo = laStringa
    def visualizza(self):
        print self.testo

Nota 1:Uno dei metodi della classe viene chiamato __init__ ed è un metodo speciale detto costruttore. L'origine del nome dipende dal fatto che il metodo viene eseguito ogni volta che viene creata una nuova istanza o oggetto. Ogni variabile cui si assegna un valore (e che quindi, in Python, viene creata) all'interno del metodo è specifica della nuova istanza. In Python esistono un certo numero di metodi speciali come questo, quasi tutti caratterizzati dal formato del nome: __xxx__.

Nota 2:Entrambi i metodi che abbiamo definito hanno come primo argomento self. Il nome è convenzionale ed indica l'istanza dell'oggetto. Come vedremo è l'interprete e non il programmatore che assegna un valore a questo argomento al momento della chiamata. Perciò visualizza viene chiamato senza argomenti: m.visualizza().

Nota 3:Abbiamo chiamato la classe Messaggio con l'iniziale maiuscola. Si tratta di una pura convenzione che è però di uso abbastanza frequente non solo in Python ma anche in altri linguaggi ad oggetti. Un' altra convenzione simile indica di usare l'iniziale minuscola e lettere maiuscole per le iniziali delle successive parole nei nomi dei metodi. Cioè un metodo il cui nome debba significare "calcola il bilancio corrente" potrebbe chiamarsi: calcolaBilancioCorrente.

A questo punto potreste tornare al capitolo "I materiali" e riguardare il paragrafo relativo ai "Tipi compositi definiti dall'utente". L'esempio relativo agli indirizzi dovrebbe adesso risultare più chiaro. Essenzialmente l'unico tipo definibile dall'utente in Python è la classe. Una classe dotata di attributi, ma non di metodi (escluso __init__) è a tutti gli effetti equivalente ad un record del BASIC.

Come usare le classi

Adesso che abbiamo definito una classe possiamo creare istanze della classe Messaggio e manipolarle:

m1 = Messaggio("Ciao gente")
m2 = Messaggio("Addio, è stato bello fino a che è durato")

nota = [m1, m2] # metti gli oggetti in una lista
for msg in nota:
    msg.visualizza() # visualizza gli oggetti, uno per volta

In pratica la classe viene trattata come un qualunque tipo di dato standard, il che era peraltro lo scopo dell'esercizio.

La stessa cosa, fatta in modo differente

Per adesso abbiamo imparato a definire i nostri tipi (classi) ed a creare istanze di questi tipi assegnandole a variabili. Possiamo quindi passare messaggi agli oggetti in modo da attivare i metodi che abbiamo definito. Ma c'è un ultimo dettaglio della programmazione ad oggetti che per molti aspetti è il più importante di tutti.

Se abbiamo due oggetti di classi differenti che supportano lo stesso insieme di messaggi implementati ciascuno con propri metodi, allora possiamo raccogliere insieme questi oggetti e trattarli in modo identico nei nostri programmi, anche se gli oggetti si comportano differentemente. Questa capacità di rispondere differentemente allo stesso messaggio viene detta polimorfismo.

Un tipico esempio potrebbe essere rappresentato da vari oggetti grafici diversi che generano il proprio disegno quando ricevono il messaggio "disegna". Un cerchio disegna una forma assai diversa da quella di un triangolo, ma se entrambi possiedono un metodo disegna noi programmatori possiamo ignorare le differenze e pensarli semplicemente come "forme".

Vediamo un esempio in cui, invece di disegnare le forme, calcoliamo la loro area:

Prima di tutto definiamo le classi Quadrato e Cerchio:

class Quadrato:
    def __init__(self, lato):
        self.lato = lato
    def calcolaArea(self):
        return self.lato**2

class Cerchio:
    def __init__(self, raggio):
        self.raggio = raggio
    def calcolaArea(self):
        import math
        return math.pi*(self.raggio**2)

Adesso possiamo creare una lista di figure geometriche (cerchi o quadrati) e quindi visualizzare le loro aree:

lista = [Cerchio(5),Cerchio(7),Quadrato(9),Cerchio(3),Quadrato(12)]

for figura in lista:
    print "L'area vale: ", figura.calcolaArea()

Infine combinando questi concetti con i moduli otteniamo un potente meccanismo per la riutilizzazione del codice. Basta mettere la definizione di classe in un modulo, diciamo "figure.py" e quindi semplicemente importare il modulo quando vogliamo trattare figure geometriche. Questo è esattamente ciò che avviene con molti dei moduli standard di Python, ed ecco perché l'uso dei metodi di un oggetto somiglia cosí tanto all'uso delle funzioni di un modulo.

Ereditarietà

L'ereditarietà viene spesso usata come meccanismo per implementare il polimorfismo. In realtà in molti linguaggi OO costitusce il solo modo di implementare il polimorfismo. Essa segue la seguente logica:

Una classe può ereditare sia gli attributi che le operazioni da una classe padre o superclasse. Ciò significa che una nuova classe che è uguale per molti aspetti ad una classe esistente non deve necessariamente implementare di nuovo tutti i metodi della classe esistente, ma può ereditare le operazioni e quindi effettuare l'"override" [Sovrapposizione, N.d.t.] di quelli che devono operare in modo differente (come il metodo visualizza dell'esempio precedente).

Come al solito un esempio è utile ad illustrare i concetti. Useremo una gerarchia di classi che descrive conti correnti bancari su cui possiamo fare versamenti, verificare il saldo e fare prelievi. Alcuni dei conti generano interessi (che in questo esempio saranno calcolati ad ogni versamento: una interessante innovazione da suggerire al mondo delle banche!) ed altri addebitano un costo per ogni prelievo.

La classe ContoCorrente

Vediamo come procedere. Prima di tutto consideriamo gli attributi e le operazioni di un conto corrente bancario al livello più generale (o astratto).

Di solito è meglio prima considerare le operazioni e poi aggiungere gli attributi necessari a supportare le operazioni. Quindi relativamente ad un conto corrente possiamo:

  • Depositare contante,
  • Prelevare contante,
  • Verificare il saldo e
  • Trasferire importi su un altro conto.

    Per supportare queste operazioni occorre un numero di conto CC (per l'operazione di trasferimento) ed il saldo corrente.

    Definiamo adesso la classe che supporta quanto detto:

    ErroreBilancio = "Siamo spiacenti il vostro saldo ammonta solo a  %6.2f Euro"
    
    class ContoCorrente:
        def __init__(self, SaldoIniziale):
           self.saldo = SaldoIniziale
           print "Conto corrente creato con saldo iniziale: %5.2f Euro" % self.saldo
    
        def versamento(self, importo):
           self.saldo = self.saldo + importo
    
        def prelievo(self, importo):
           if self.saldo >= importo:
              self.saldo = self.saldo - importo
           else:
              raise ErroreBilancio % self.saldo
    
        def verificaSaldo(self):
           return self.saldo
           
        def trasferimento(self, importo, account):
           try: 
              self.prelievo(importo)
              account.versamento(importo)
           except ErroreBilancio:
              print ErroreBilancio
    

    Nota 1: Prima di un prelievo viene verificato il saldo e si utilizza una eccezione per trattare gli errori. Naturalmente non esiste un errore di tipo ErroreBilancio quindi dobbiamo crearne uno: si tratta solo di una variablie stringa.

    Nota 2: Il metodo trasferimento usa le funzioni membro o metodi prelievo/versamento di ContoCorrente per effettuare l'operazione. Questo è un caso molto comune nella programmazione OO ed è detto auto invio di messaggi. Ciò permette alle classi derivate di implementare le proprie versioni di versamento/prelievo mantenendo lo stesso metodo trasferimento per tutti i tipi di conto corrente.

    La classe ContoAInteresse

    Usiamo adesso l'ereditarietà per creare un conto corrente che aggiunge un interesse (diciamo del 3%) ad ogni versamento. La classe sarà identica alla classe standard ContoCorrente tranne che per il metodo versamento. Quindi ne facciamo semplicemente l'override:

    class ContoAInteresse(ContoCorrente):
       def versamento(self, importo):
           ContoCorrente.versamento(self,importo)
           self.saldo = self.saldo * 1.03
           
    

    Questo è tutto. Comiciamo ad intravedere la potenza della programmazione OO: tutti gli altri metodi vengono ereditati da ContoCorrente (avendo introdotto ContoCorrente fra parentesi dopo il nome della nuova classe). Notate anche che il metodo versamento chiama il corrispondente metodo della superclasse invece che ripetere il codice. Se in seguito modificheremo il metodo versamento di ContoCorrente, ad esempio per aggiungere qualche tipo di controllo di errore, anche la classe derivata acquisterà automaticamente le nuove caratteristiche.

    La classe ContoConSpese

    Anche questo conto corrente è identico a quello standard, tranne che per il fatto che ad ogni prelievo vengono addebitati 3 Euro. Come già abbiamo fatto per ContoAInteresse possiamo definire una classe che eredita da ContoCorrente e modificare solo il metodo prelievo.

    class ContoConSpese(ContoCorrente):
        def __init__(self, SaldoIniziale):
            ContoCorrente.__init__(self, SaldoIniziale)
            self.spese = 3
            
        def prelievo(self, importo):
            ContoCorrente.prelievo(self, importo+self.spese)
    

    Nota 1: Memorizziamo il costo dell'operazione in una variabile di istanza in modo che in seguito sia possibile cambiarla facilmente. Notate anche che possiamo chiamare il metodo __init__ ereditato esattamente come ogni altro metodo.

    Nota 2: Il costo dell'operazione viene semplicemente sommato all'importo del prelievo e poi viene chiamato il metodo prelievo di ContoCorrente per effettuare l'operazione.

    Nota 3:Qui si introduce un effetto nascosto in quanto il costo dell'operazione viene addebitato anche per i trasferimenti, ma probabilmente è corretto che questo avvenga.

    Mettiamo alla prova il nostro programma

    Per verificare che tutto funzioni, provate ad eseguire il frammento di codice seguente (potete farlo al prompt di Python, oppure creando un file separato per la prova).

    from contocorrente import *
    
    # Inizialmente si usa  ContoCorrente
    a = ContoCorrente(500)
    b = ContoCorrente(200)
    a.prelievo(100)
    # a.prelievo(1000)
    a.trasferimento(100,b)
    print "A = ", a.verificaSaldo()
    print "B = ", b.verificaSaldo()
    
    
    # Poi si usa ContoAInteresse
    c = ContoAInteresse(1000)
    c.versamento(100)
    print "C = ", c.verificaSaldo()
    
    
    # E poi ContoConSpese
    d = ContoConSpese(300)
    d.versamento(200)
    print "D = ", d.verificaSaldo()
    d.prelievo(50)
    print "D = ", d.verificaSaldo()
    d.trasferimento(100,a)
    print "A = ", a.verificaSaldo()
    print "D = ", d.verificaSaldo()
    
    
    # Infine si trasferisce dal conto con spese a quello con interesse
    # quello con spese dovrebbe addebitare le spese e quello con
    # interesse accreditare l'interesse.  
    print "C = ", c.verificaSaldo()
    print "D = ", d.verificaSaldo()
    d.trasferimento(20,c)
    print "C = ", c.verificaSaldo()
    print "D = ", d.verificaSaldo()
    

    Se adesso togliete il commento dalla linea a.prelievo(1000) potete vedere l'intervento dell'eccezione.

    Questo è tutto. Si tratta di un esempio abbastanza semplice, ma mostra come è possibile usare l'ereditarietà per estendere rapidamente uno schema di base aggiungendovi interessanti nuove funzionalità.

    Abbiamo visto che si può costruire un esempio per passi successivi e come si può mettere insieme un programma di prova per verificarne il funzionamento. Le nostre prove non sono complete in quanto non abbiamo trattato ogni possibile caso e avremmo potuto aggiungere altre prove; ad esempio verificare cosa accade quando si cerca di aprire un conto con un valore iniziale negativo ...

    Ma confidiamo che l'esempio vi abbia fornito un assaggio della Programmazione ad Oggetti e che possiate utilizzare altri manuali in rete o leggere qualcuno dei testi citati all'inizio di questa introduzione per trovare altre informazioni ed altri esempi.


    Precedente  Successivo  Indice


    Se avete domande o suggerimenti relativi a questa pagina mandate un e-mail all'autore: alan.gauld@yahoo.co.uk o al traduttore italiano: lfini@arcetri.astro.it