[incr Tcl]

« Programmazione Object-OrientedAppunti di Tcl/TkEsempio: la classe pack »

Primo incontro con classi e oggetti

Nella terminologia OO i tipi di dati sono chiamati classi e le variabili che appartengono ad un certo tipo sono chiamate istanze della classe o brevemente oggetti. Il fatto che una variabile è di un tipo specifico si esprime adesso dicendo che ogni oggetto è associato o appartiene ad una sola classe specifica. Il termine classe deriva probabilmente dal fatto che un tipo rappresenta ora non più un semplice aggregato di dati, ma un'astrazione per un'intera classe di oggetti, che condividono proprietà e comportamenti.

Quando si definisce una classe (tipo), si definisce non solo l'insieme dei valori che questo può assumere, ma anche le operazioni che si possono fare su oggetti di quella classe. Col termine di incapsulazione si intende proprio l'unione dei record con le operazioni che li manipolano. Le operazioni sono anche dette metodi o funzioni membro e sono l'analogo delle funzioni e procedure della tradizionale programmazione procedurale.

La definizione della parte dati di una classe assomiglia a quella di un record di Pascal oppure di una struct di C (infatti dal punto di vista sintattico le classi sono state aggiunte a questi linguaggi tradizionali proprio come forme estese dei record): si tratta di un aggregato di variabili di tipi anche differenti e vengono dette variabili istanza o membri dati o anche campi dati, e corrispondono appunto campi dei record. Insieme identificano lo stato dell'oggetto.

Le variabili oggetto vengono utilizzate per schematizzare proprietà e comportamenti degli oggetti del mondo reale di interesse per la nostra applicazione. Nel codice all'esterno di una classe, è buona norma non accedere direttamente ai dati della classe stessa, ma farlo solo tramite i metodi previsti. Questa regola a prima vista potrebbe apparire arbitraria e insensata, ma in realtà ci sono ottime ragioni per questo.

Una è che, a meno che non ci siano errori di implementazioni (bachi) nei metodi, questo assicura che lo stato di un oggetto sia mantenuto consistente ed aiuta a prevenire gli errori nel codice client. Manipolando invece direttamente le variabili membro di un oggetto nel codice che è cliente dei servizi della classe, non c'è alcun meccanismo che assicura che queste non vengano lasciate in uno stato inconsistente. Quando lo stato è complesso, è facile commettere errori nella sua manipolazione diretta.

Ma la ragione più importante per cui conviene nascondere le strutture dati che rappresentano lo stato di un oggetto, anziché accedervi direttamente, è che in questo modo si può variare la rappresentazione dello stato senza che le altre parti del programma debbano essere cambiate (cosa che sarebbe necessaria se queste accedessero alle variabili membro direttamente).

Spesso si sottovaluta la necessità di cambiare le strutture di dati. Invece i motivi che potrebbero costringere al cambiamento sono tanti e difficilmente prevedibili a priori. Ad esempio:

È quindi buona norma definire ed usare sempre e solo metodi di una classe all'esterno della classe stessa, per limitare e stabilire il modo in cui devono avvenire gli accessi ai suoi dati. Naturalmente occorre che forniate tutti i metodi necessari all'interno della classe stessa, in modo da avere la massima coesione di funzionalità correlate.

Questa pratica viene detta information hiding e non è affatto originale nella OOP, anzi è stata largamente praticata anche prima dell'avvento della programmazione OO.

Grazie all'information hiding, eventuali cambiamenti alla rappresentazione interna dei dati della classe non si ripercuotono sul codice cliente e quindi non occorre modificarlo, a meno che non sia necessario mutare l'interfaccia dei metodi della classe.

Un esempio di information hiding che si trova nella classica libreria stdio(3) del C: per l'accesso ai file si utilizza la funzione fopen(3), che restituisce un puntatore ad una struttura in realtà una versione primitiva di oggetto, di tipo FILE. Questa struttura rappresenta il flusso di I/O ma non avete bisogno di conoscerne i dettagli implementativi (che variano da macchina a macchina). Per manipolare i suoi campi in modo consistente e portabile si usano funzioni predefinite quali fread(3) per leggere blocchi di byte, oppure fscanf(3) per l'input formattato.

Il seguente è un semplice esempio di classe in [incr Tcl]. La classe Person rappresenta alcune informazioni di contatto per una persona (ad esempio per un programma di posta elettronica):

class Person {
  public variable nick ""
  public variable fullname ""
  public variable email "" checkemail
  public variable notes ""

  constructor {args} {
    eval configure $args
    checkemail
  }

  private method checkemail {} {
    if {$email == ""} {
      error "email can't be empty"
    }
  }
}

In questo esempio class, public, private, variable, constructor e method sono tutte parole chiave introdotte da [incr Tcl].

Per provare il codice occorre che sia installata l'estensione itcl(n). Inoltre dovete prima caricarla nella shell tclsh o wish e importarne tutti i comandi o al limite solo il comando ::itcl::class.

package require Itcl
namespace import ::itcl::*

Un'alternativa all'importazione consiste nel qualificare col prefisso ::itcl:: tutti i comandi di itcl che si vogliono utilizzare, ad esempio scrivere ::itcl::class invece di un semplice class.

Per creare gli oggetti di una classe e leggere o scrivere i loro campi pubblici si usa una sintassi del tutto simile a quella usata per creare e configurare i widget Tk. Vediamo in dettaglio come questo avviene.

La definizione di una nuova classe tramite il comando class crea un nuovo comando avente nome coincidente col nome della classe specificato dopo la keyword class. È questo comando poi che gestisce la creazione degli oggetti della classe. La manpage class(n) contiene la sintassi più generale per la definizione di una classe, mentre noi ne esemplificheremo i vari aspetti poco per volta.

In altri termini, una volta definita la classe, il nome della classe va usato come un comando ogni volta che si vogliono creare nuovi oggetti appartenenti a quella classe, allo stesso modo di quando si definiscono e utilizzano nuovi tipi in un linguaggio come Pascal o C. La sintassi generale di un comando per la creazione degli oggetti è:

className objName ?args...?

Ad esempio:

% Person fred -nick "fred" -fullname "Fred Flinstone" \
  -email "fred@prehistory.net" -notes "Barny's friend"
fred

Definire una classe è quindi come definire un nuovo tipo di widget Tk, ma in Tcl stesso, senza dover ricorrere alla programmazione C. Definire un oggetto di una classe è invece l'analogo della creazione di un widget di di un certo tipo, ad esempio button(n).

Eventuali argomenti con nome nella definizione di un oggetto sono passati al costruttore della classe più specifica a cui appartiene l'oggetto che si sta creando. Fino a quando non introdurremo il concetto di ereditarietà, tutti gli oggetti apparteneranno ad una sola classe che sarà anche la loro classe più specifica, quindi per ora la dicitura "più specifica" è ridondante e si può ignorare.

Il costruttore è un metodo speciale: è senza nome e perciò non può essere invocato dopo che l'oggetto è stato creato. In mancanza di derivazione, viene definito tramite la sintassi:

constructor args body

Il costruttore viene invocato automaticamente ogni volta che viene creato un oggetto ed il suo compito è proprio di inizializzare il nuovo oggetto contestualmente alla sua creazione. Se tutto va bene il costruttore ritorna proprio il nome dell'oggetto che è stato creato (anche se all'interno del costruttore cercate di ritornare qualcosa di diverso), altrimenti ritorna un messaggio di errore e il nuovo oggetto non viene creato.

Si possono avere diversi oggetti della classe Person, ciascuno corrispondente ad un contatto registrato nella rubrica, e tutti hanno le stesse caratteristiche in quanto appartengono alla stessa classe Person: tutti hanno l'indirizzo di email, e possono avere un nickname, il nome vero ed altre informazioni aggiuntive (note):

% Person wilma -nick "wilma" -email "wilma@prehistory.net" -notes "Fred's wife"
wilma

Il comando:

variable varName ?init? ?config?
definisce una variabile membro chiamata varName. Ogni oggetto avrà la sua copia specifica di tale variabile. Le variabili membro saranno automaticamente disponibili all'interno dei metodi della classe, senza bisogno di nessuna dichiarazione per poterle usare, al contrario di quello che avviene con le variabili globali: infatti quando si vuole accedere ad una variabile globale all'interno di una proc occorre utilizzare il comando global(n).

La stringa init è opzionale ed indica il valore iniziale che deve assumere la variabile quando viene creato un nuovo oggetto: potete scegliere tra settare il valore iniziale qui oppure nel costruttore. Se il valore è una costante, vale a dire un valore di default per un campo dati, è più semplice farne l'inizializzazione all'atto della dichiarazione della variabile membro; tuttavia in questo modo si possono inizializzare solo variabili scalari: per creare delle variabili membro di tipo array occorre lasciarle non inizializzate all'atto della definizione ed utilizzarle come array nel costruttore o in qualche altro metodo. Questo perché se si inizializza una variabile come scalare non sarà possibile trattarla poi come array, esattamente come avviene per le normali variabili di Tcl:

% set var "scalar"
scalar
% set var(2) "two"
can't set "var(2)": variable isn't array

Notate che se una variable membro non viene inizializzata in alcun modo prima di utilizzarne il suo valore in un metodo si ottiene un errore di variabile non esistente.

Infine il parametro config è uno script che può essere associato solo alle variabili pubbliche.

Abbiamo supposto che l'indirizzo di email sia un campo obbligatorio: non avrebbe molto senso avere in rubrica una scheda relativa ad una persona in cui tutti i campi sono vuoti. Nel momento in cui si crea un oggetto persona, almeno il campo email deve essere specificato, pena un errore:

% Person betty
email can't be empty

Lo stesso se si tenta di assegnare al campo di un oggetto un valore nullo:

% wilma configure -email ""
email can't be empty

Quello che accade è che il frammento di codice config viene eseguito ogni volta che una variabile pubblica viene modificata tramite il metodo built-in "configure" chiamato all'esterno di un metodo della classe: configure prima modifica i valori delle variabili e poi esegue lo script config. Notate che anche il nostro costruttore chiama configure, ma siccome siamo all'interno di un metodo della classe lo script di controllo della configurazione non viene chiamato e abbiamo dovuto chiamarlo esplicitamente in modo da impedire la costruzione di un oggetto (betty nell'esempio precedente) con valore nullo per il campo email, che altrimenti verrebbe accettata.

Notate inoltre che il nostro costruttore riceve un numero variabile di argomenti e li passa tutti al metodo configure utilizzando eval(n). Lo scopo di eval(n) è di separare la lista degli argomenti in una serie di argomenti singoli, da passare al metodo configure. Se dimenticate di usare il comando eval, il metodo configure riceverà un solo argomento contenente la lista di tutti gli argomenti e genererà un errore.

Cosa accade se lo script di controllo della configurazione config fallisce, ovvero si verifica un errore oppure lo si genera esplicitamente tramite error(n)? Il metodo configure, che aveva chiamato config, fa il cosiddetto backup, ovvero rimette ai valori precedenti qualsiasi variabile che esso stesso aveva eventualmente modificato prima di eseguire lo script config e poi ritorna l'errore, in modo che l'intera configurazione fallisce e l'oggetto viene riportato allo stato precedente.

public e private sono anch'essi comandi e settano il livello di protezione dei membri della classe che vengono creati valutando il comando che è dato come loro argomento. Un membro dati pubblico di un oggetto, come ad esempio email, può essere sia letto che scritto dal codice cliente.

La lettura avviene secondo la seguente sintassi:

objName cget option

Questa è un caso particolare della sintassi del comando che invoca un metodo che opera su un oggetto preventivamente creato:

objName method ?args...?
e cget quindi non è altro che il nome di un metodo predefinito. Notate che, esattamente come in Tk, option deve essere una stringa del tipo -varName.

Ad esempio:

% fred cget -email
fred@prehistory.net

Come già esemplificato, la scrittura va fatta con la sintassi:

objName configure option value

Altro esempio:

% fred configure -email fred@flinstones.org

% fred cget -email
fred@flinstones.org

La sintassi di chiamata del metodo configure è del tutto simile a quella con cui potete chiamare un metodo definito da voi. Difatti, come cget, configure non è altro che un metodo. predefinito in tutti gli oggetti. Analogamente a cget, configure permette l'accesso alle variabili pubbliche con la stessa sintassi usata per accedere alle opzioni di configurazione di un widget Tk (eccetto il fatto che il nome del metodo non si può abbreviare, ad es. in config). La sintassi completa di configure permette di settare diverse opzioni con un solo comando, ad es.:

bambam configure -nick bam -notes "Flinstones' daughter"

Inoltre configure può essere usato anche per ritornare tutte le informazioni relative ad una variabile pubblica (non solo il suo valore come con cget), sotto forma di una lista di tre elementi:

% fred configure -email
-email {} fred@prehistory.net

Gli elementi della lista sono nell'ordine il nome della variabile, il suo valore iniziale e il suo valore corrente.

Se non è presente alcun argomento, questo metodo ritorna una lista di lista, che descrive tutte le variabili pubbliche:

% fred configure
{-email {} fred@flinstones.org} {-fullname {} {Fred Flinstone}} {-notes {} {Barny's friend}} {-nick {} fred}

% wilma configure
{-email {} wilma@prehistory.net} {-fullname {} {}} {-notes {} {Fred's wife}} {-nick {} wilma}

Le variabili membro o i metodi privati invece possono essere acceduti solo nella classe in cui sono definiti. Il codice contenuto nel metodo checkemail è stato inserito in un metodo per non doverlo ripetere due volte. Poichè è impossibile costruire un oggetto Person privo della email, non è utile che checkemail possa essere chiamato dal codice cliente. Si tratta di un metodo che fa parte dell'implementazione e non dell'interfaccia verso il mondo esterno e per questo motivo è stato reso privato. Tutto ciò che non fa parte dell'interfaccia dovrebbe essere reso privato (oppure protetto come vedremo quando parleremo dell'ereditarietà), in modo che i dettagli possano essere cambiati senza modificare i clienti della classe e l'interfaccia di una classe verso l'esterno contenga giusto il necessario.

In mancanza del comando public, private e protected, una variabile viene assunta protetta (per ora la differenza tra privata e protetta non è rilevante), mentre un metodo pubblico. Perciò public davanti a method viene spesso omesso, così pure come private di fronte a variable.

Ecco poi un altro classico esempio di una classe elementare: si tratta di un contatore (immaginate una specie di contachilometri), che parte dal valore iniziale e può essere solo resettato al valore iniziale, incrementato oppure se ne può leggere il valore:

class Counter {
  private variable count 0

  method reset {} {
    set count 0
  }

  method bump {{by 1}} {
    incr count $by
  }

  method get {} {
    return $count
  }
}

Mentre la classe persona aveva tutti i campi dati pubblici, questa classe è priva di campi pubblici ed ha un solo campo privato count. Questo impedisce al cliente di settare il contatore in modo arbitrario, anche se rimane comunque possibile il conteggio all'indietro utilizzando un valore negativo per l'argomento by del metodo bump. Alcuni esempi d'uso:

% Counter c
c

% c bump
1

% c bump 3
4

% c get
4

% c reset
0

Poichè Tcl è un linguaggio dinamico, è possibile non solo cancellare oggetti, ma anche classi. Ad esempio:

% delete object fred
libera dalla memoria i campi dati associati all'oggetto fred e rimuove il comando fred dall'interprete. Dopo di questo comando il nome fred può essere riutilizzato, ad esempio può essere creato un altro oggetto (anche di classe diversa da Person) con il nome fred nello stesso namespace. Invece:
% delete class Person
cancella tutti gli oggetti della classe Person e di eventuali classi derivate (parleremo in seguito della derivazione) e poi rimuove la definizione della classe Person e di eventuali classi derivate, in modo che non possano più essere creati oggetti di quella classe (a meno che non la si ridefinisca nuovamente).

Proprio come una normale variabile, ogni oggetto in Tcl ha un nome unico. Come abbiamo visto questo nome si può usare come un comando per invocare un metodo sull'oggetto (il metodo specificato come sottocomando). Questo è coerente con la sintassi di Tcl e Tk che contiene solo semplici comandi.

Come il nome di un comando in Tcl può essere il valore di una variabile, così il nome dell'oggetto di invocazione, che è un comando, può essere contenuto in una variabile. Questa variabile che contiene il nome di un oggetto è come una specie di puntatore letterale. Ad esempio:

% set guy fred
fred

% $guy cget -email
fred@flinstones.org

% $guy configure -email "NoSpam.fred@flinstones.org"

% $guy cget -email
NoSpam.fred@flinstones.org

% fred cget -email
NoSpam.fred@flinstones.org

Come vedete nell'esempio precedente le modifiche fatte da metodi invocati tramite il puntatore guy hanno cambiato lo stato dell'oggetto originario. Questo significa che quando passate il nome di un oggetto ad una proc o a un method il passaggio avviene per riferimento, a meno che la proc o il method non si crei una copia di lavoro dell'oggetto ed utilizzi questa invece dell'oggetto originale.

Per creare un oggetto locale ad una procedura o metodo (allocato sullo stack frame della procedura corrente), si usa il comando local(n). La sintassi è la stessa per creare un oggetto nello spazio globale, eccetto che si premette il nome del comando local:

local className objName ?arg arg ...?

Gli oggetti locali, a differenza di quelli globali, vengono automaticamente cancellati quando termina la procedura e il relativo call frame viene disallocato.

« Programmazione Object-OrientedAppunti di Tcl/TkEsempio: la classe pack »