MokaByte Numero 25  -  Dicembre 98  

Java, OOP, Patterns

di 
Lorenzo Bettini

le caratteristiche OOP di Java utili per i pattern, ed un confronto col C++


I pattern, tanto di moda attualmente nel mondo dell'informatica, necessitano di un linguaggio ad oggetti, o almeno si possono studiare ed implementare meglio utilizzando un linguaggio che segue il paradigma ad oggetti, rispetto ad un linguaggio tradizionale. Nei testi classici sui pattern di solito vengono descritti esempi in Smalltalk ed in C++... ma anche Java non è da meno :-)


Introduzione

Si sente sempre più spesso parlare di pattern negli ambienti informatici (luoghi universitari, conferenze, e riviste di vario spessore e livello). Ciò che sta dietro al concetto di pattern è molto semplice, e più che altro serve a formalizzare tecniche di programmazione già viste ed utilizzate.

In effetti quando si affronta un progetto (di qualsiasi dimensione), che comporta l'implementazione di un qualche programma in un qualche linguaggio (ad alto livello) si sarà notato che ci si trova di fronte a problemi a cui si riesce a dare più facilmente una soluzione (nel senso che si sa come implementare un programma ed una funzione, con certe strutture dati, per risolvere semplicemente ed elegantemente certe situazioni). Questo è dettato sia dall'esperienza di programmatore che una persona si ritrova, sia a letture fatte, e quindi ad esempi di codice che tornano alla mente.

Una soluzione elegante è spesso preferibile in un progetto, in quanto di solito questa soluzione è estendibile e riutilizzabile, e quando, come nella maggior parte dei progetti di una certa dimensione, si dovrà modificare i programmi, ci renderemo conto di quanto sia semplice effettuare certe modifiche se si è sviluppato il programma seguendo certi criteri per ottenere riusabilità.

Del resto la programmazione ad oggetti è nata cercando di fornire ai programmatori strumenti per scrivere codice riutilizzabile. Spesso però se non si presta attenzione alla fase della progettazione si rischia di trovarsi di fronte ad una, o, peggio, più gerarchie di classi con dipendenze cicliche che non fanno altro che complicare il quadro generale del progetto e possono far maledire il momento in cui si è scelto un linguaggio ad oggetti al posto di uno procedurale classico. Questo però non è dovuto al linguaggio, ma ad una progettazione sbagliata e soprattutto a preferire certe soluzioni che al momento possono sembrare appetibili, ma che poi si rivelano scarsamente riutilizzabili. Citando [Sou94], sono le "cose semplici ed ovvie che creano e distruggono grossi progetti".

Quindi se si vuole trarre vantaggio dalla programmazione ad oggetti si deve ragionare secondo tale filosofia, altrimenti si utilizzerà soltanto un linguaggio ad oggetti come un normale linguaggio procedurale, complicando il tutto.

Spesso le soluzioni realmente riutilizzabili nella progettazione ad oggetti hanno una certa struttura comune se si riferiscono a problemi simili e ricorrenti. Ed è proprio questa la filosofia dei pattern: citando [LoR98], i pattern sono "soluzione assodate, a problemi ricorrenti, in contesti specifici". Si può quindi riutilizzare soluzioni (intendendo strutture e gerarchie di classi, ed idee di implementazioni) che sono già state utilizzate da altri programmatori e progettisti per risolvere situazioni molto simili alle nostre.

Di pattern si è comunque già parlato in questa rivista anche in [Tre98] e [Gra98] e quindi non ci dilungheremo molto su ulteriori definizioni a riguardo; invece vedremo le caratteristiche OOP di Java che ben si prestano per l'implementazione di progetti ed utilizzo di Pattern, basandosi sull'indiscussa Bibbia (nonché libro affascinante e bellissimo a mio parere) dei pattern [GOF95]. In effetti le parti in corsivo citate nell'articolo sono riprese più o meno liberamente da tale libro.

Pattern ed OOP

Si è visto quindi che i pattern si utilizzano meglio con linguaggi ad oggetti, in quanto si riesce ad implementare in modo più naturale e semplice classi e relazioni fra di esse.

E' bene però precisare alcune definizioni della filosofia ad oggetti, in quanto potrebbero non risultare chiari, soprattutto se si è abituati ad utilizzare il C++. Queste almeno sono alcuni concetti che in un primo momento, a me, già amante del C++, sono risultati poco chiari a prima lettura.

Tipi

Prima di tutto è bene introdurre il concetto di tipo.

Un concetto fondamentale nella programmazione ad oggetti è l'incapsulamento, tramite il quale si nascondono certi dettagli implementativi a chi utilizzerà effettivamente una certa classe; questo non è dovuto semplicemente alla voglia di tenere nascosto l'implementazione di una procedura o la struttura di un oggetto, ma è più orientato a favorire il riutilizzo di quella classe: accedendo ai servizi o ai campi di un oggetto tramite metodi, si evita di conoscere i suoi dettagli, e questo ci permette di ignorarli: se la classe di tale oggetto verrà modificata noi potremmo tranquillamente continuare ad utilizzare tale oggetto come eravamo soliti fare (ho usato il condizionale, perché questo è vero solo se la classe è realizzata effettivamente bene).

Quindi noi accediamo agli oggetti tramite le loro interfacce: conosciamo i servizi che abbiamo a disposizione, ma non ne conosciamo i dettagli implementativi. Quindi tramite l'invocazione dei metodi noi inviamo richieste agli oggetti, ma non sappiamo come questi effettivamente realizzeranno tali servizi. Un'interfaccia quindi non è altro che una lista di metodi, ognuno con la propria signature.

Questo del resto non è diverso dal mondo reale (in effetti tramite la programmazione ad oggetti si dovrebbe essere in grado di modellare meglio la realtà): quando noi guidiamo una macchina sappiamo che premendo certi pedali, o smuovendo certe leve, otterremo particolari risultati, ma non sappiamo in realtà cosa avvenga (almeno che uno non conosca come funziona il motore della macchina, cosa comunque non strettamente necessaria); oppure quando ci rechiamo in un certo negozio o ufficio, effettuiamo delle richieste ai commessi o agli addetti, ma possiamo tranquillamente ignorare come loro esaudiscano le nostre richieste: ci interessa il risultato.

Questo concetto è effettivamente utilizzato in CORBA [Bet98]: se si vogliono utilizzare oggetti remoti, su architetture diverse con sistemi operativi diversi, per di più scritti in linguaggi diversi, si deve necessariamente basarsi sulla sola conoscenza dell'interfaccia di tali oggetti; ed infatti in CORBA, si devono definire proprio le interfacce tramite un particolare linguaggio di programmazione: l'IDL (Interface Definition Language).

Diamo quindi la seguente definizione:

tipo: è un nome che viene utilizzato per denotare una particolare interfaccia

Quindi quando si dice che un oggetto ha il tipo X, si intende che è in grado di accettare tutte le richieste che sono definite dall'interfaccia X. Chiaramente un oggetto può avere più tipi, e si possono avere oggetti diversi che però hanno lo stesso tipo.

Un'interfaccia può contenere al proprio interno i metodi di un'altra interfaccia; si parla allora di:

un tipo è un sottotipo di un altro tipo, se la sua interfaccia contiene l'interfaccia del secondo (detto supertype).

Ovviamente, poiché l'interfaccia non dice niente dell'effettiva implementazione dei vari metodi, e poiché oggetti completamente differenti possono avere lo stesso tipo (cioè accettare le stesse richieste), è necessario un meccanismo che permetta di eseguire del codice in funzione all'oggetto a cui è stata effettuata una particolare richiesta, basandosi non tanto sul tipo (che si è visto essere uguale anche per oggetti morfologicamente diversi) ma sull'oggetto in questione.

L'associazione di una richiesta ad un oggetto ad una delle sue operazioni viene detto dynamic binding, in quanto avviene a run-time. In questo modo si possono tranquillamente sostituire oggetti completamente differenti, ma che hanno la stessa interfaccia: questo concetto è conosciuto come polimorfismo. L'utente dell'oggetto utilizza solo la sua interfaccia: se l'oggetto cambia, ma è in grado comunque di ricevere le medesime richieste, per l'utente tutto rimane come prima: se siamo abituati ad utilizzare una certa macchina per effettuare certe operazioni (ad esempio per la visualizzazione di informazioni sui treni), se questa viene modificata, e magari sostituita con del nuovo software, mantenendo però la stessa interfaccia di quella precedente, per noi non cambierà niente, anzi potremmo addirittura non accorgerci che è stata cambiata.

Classi

E l'effettiva implementazione? Qualcuno (il programmatore) dovrà effettivamente dare l'implementazione delle varie operazioni contenute in una certa interfaccia. Diamo quindi la seguente definizione

la classe di un oggetto definisce la sua implementazione

Quindi la classe di un oggetto non specifica solo l'implementazione dei vari metodi, ma definisce anche i vari campi, definisce cioè la struttura interna dell'oggetto. Quindi come si sente spesso dire:

un oggetto è un'istanza di una classe

Oppure (detto in altri termini):

gli oggetti si creano istanziando una classe

Istanziando una classe, si crea effettivamente l'oggetto, cioè si alloca la memoria che conterrà i vari dati dell'oggetto (la sua struttura interna), e alle operazioni dell'oggetto (i metodi della sua interfaccia) saranno collegate le implementazioni (si è visto comunque che tale collegamento avviene spesso, se non sempre, a run-time).

Si possono creare nuove classi basandosi su classi già definite, utilizzando il meccanismo dell'ereditarietà di classi. In questo modo, dalla classe da cui si eredita (detta classe base o parent class), si eredita tutte le definizioni di dati ed anche le sue operazioni. Ovviamente si possono aggiungere altri dati ed altre operazioni, oppure si può voler modificare il comportamento della classe base, dando implementazioni alternative dei vari metodi (effettuare cioè l'overriding di un metodo).

E che differenza c'è?

Un programmatore C++ potrebbe a questo punto chiedersi che differenza c'è (almeno questa è la domanda che mi posi io) fra tipi e classi in C++. In C++ esiste solo la parola chiave class, e non esiste una parola type. Questo vuole dire che in C++ non posso definire tipi, ma solo classi?

Per quanto detto, c'è una stretta relazione fra tipi e classi: una classe definisce le operazioni che un oggetto può effettuare, e quindi implicitamente ne definisce anche il tipo: cioè dicendo che un oggetto appartiene ad una certa classe, implicitamente si dice anche che supporta l'interfaccia definita dalla sua classe. Quindi

un oggetto, pur appartenendo ad una classe può avere più tipi, e oggetti di classi differenti possono avere lo stesso tipo.

Quindi che differenza c'è fra estendere (ereditare) una classe ed estendere un tipo?

Poiché un tipo dichiara solo le operazioni che possono essere eseguite (l'interfaccia), ereditando un tipo si ereditano tutte queste operazioni, nel senso che si ereditano tutte le richieste a cui si deve essere in grado di rispondere; eventualmente ci è possibile anche definirne altre. Quindi posso ereditare il tipo X, ed arricchire la sua interfaccia con nuovi metodi. Del resto se sono in grado di effettuare più operazioni di un oggetto di tipo X, posso in particolare effettuare tutte le operazioni di un oggetto di tipo X (utilizzando un colorito e popolare modo di dire: "nel più ci sta il meno"), e cioè posso essere utilizzato al posto di un oggetto di tipo X. Del resto, sempre riprendendo l'esempio della macchina che distribuisce le informazioni, se questa macchina viene ampliata con nuove funzionalità (che possono non interessarci), noi possiamo comunque continuarla ad utilizzare come prima. Quindi

l'ereditarietà di interfaccia (subtyping) descrive quando un oggetto può essere utilizzato al posto di un altro.

Mentre ereditando da una classe, si eredita la sua implementazione, quindi si crea una nuova implementazione, basandosi su quella precedente. Quindi

l'ereditarietà di classe (subclassing) è un meccanismo per condividere codice e dati.

Ma in C++ quando si eredita si eredita sia l'interfaccia che l'implementazione, quindi sembra che non ci sia una forma di subtyping! Non è effettivamente così... introduciamo un altro concetto.

Quando si creano delle gerarchie di classi, si intende definire cose e comportamenti che accomunano più classi, partendo da una classe che definisce le caratteristiche più generali e specializzando sempre di più derivando da questa, fino a formare una vera e propria gerarchia, dove all'interno, è molto facile trovare delle sottogerarchie ben evidenti (il solito esempietto presente in diversi testi di OOP, è quello della gerarchia degli animali: si parte da la classe più generale animale e poi si inizia a specializzare seguendo certi criteri: bipedi, quadrupedi, ecc...). Definendo le classi più comuni (quelle più in alto nella gerarchia) non si riuscirà sempre a dare le implementazioni delle interfacce in modo da avere comportamenti che ben si prestano ad essere riutilizzati dalle classi derivate: l'interfaccia può essere uguale nelle classi derivate, ma come le varie operazioni verranno implementate potrà differire molto a seconda del livello in cui ci troviamo nella gerarchia (del resto tutti gli animali si nutrono, ma è chiaro che lo faranno in modi completamente diversi, anche senza scendere troppo nella gerarchia delle classi).

Questo discorso è stato fatto per giustificare la necessità che si può avere, quando si definisce una classe, di lasciare in sospeso l'implementazione di qualche metodo (i cosiddetti metodi astratti, quelli che in C++ vengono detti metodi virtuali puri). Una classe che ha almeno un metodo astratto viene detta classe astratta, e del resto non può essere istanziata direttamente (in quanto non si sarebbe in grado poi di richiedere certe operazioni a quell'oggetto).

Se si definisce una classe astratta con soli metodi astratti, e senza definire nessun dato, ci si rende conto che non ci siamo discostati molto dal concetto di tipo: abbiamo definito solo l'interfaccia e non abbiamo definito nessun membro, quindi abbiamo in realtà definito un tipo. Quindi anche in C++ è possibile definire tipi (interfacce).

Quindi se in C++ si eredita (in modo pubblico) da una classe che contiene solo metodi virtuali puri (in tal caso la classe viene detta classe astratta pura), si effettua in realtà un'eredità di tipo.

Java e C++

In Java è invece presente il concetto di interfaccia, in quanto si può utilizzare la parola chiave interface; quindi un programmatore Java può non avere difficoltà a distinguere il concetto di tipo da quello di classe, in quanto anche nella programmazione può separare i due concetti.

Quindi Java è più OOP del C++?

Be' verrebbe voglia di dire di sì, anche perché il C++ è un linguaggio ad oggetti ibrido, in quanto permette di definire funzioni slegate da una particolare classe, mentre si sa che in Java, per ottenere lo stesso risultato, si deve ricorrere ai metodi statici.

Alzi la mano chi almeno una volta ha avuto problemi all'interno del metodo main di una certa classe, cercando di utilizzare direttamente un membro o un metodo di tale classe? Io la sto alzando ;-) Del resto una porzione di codice come la seguente può sembrare giusta:

class myClass {
	protected int myMember ;
	protected void myMethod( String s ) { ... }

	...

	public static void main( String args[] ) {
		myMember = 0 ;
		myMethod( "foo" ) ;
	}
}

in realtà non funziona, in quanto main è un metodo statico, e quindi una sorta di funzione del C++, e non effettivamente un metodo della classe, che quindi può accedere tranquillamente ai campi ed ai metodi della classe: non è nemmeno stato istanziato un oggetto di tale classe! La versione giusta è la seguente:

class myClass {
	protected int myMember ;
	protected void myMethod( String s ) { ... }

	...

	public static void main( String args[] ) {
		myClass mc = new myClass() ;
		mc.myMember = 0 ;
		mc.myMethod( "foo" ) ;
	}
}

Quindi un metodo statico può essere visto come una funzione del C++, con la restrizione che può essere utilizzata solo riferendoci alla classe in cui è definito (se questo è pubblico), o altrimenti utilizzato solo all'interno di tale classe (se questo è protetto o privato).

Quindi effettivamente Java è più puro rispetto al C++, ma la purezza forse serve a livello filosofico... quando si arriva a programmare avere a disposizione entrambi i paradigmi (quello ad oggetti e quello procedurale) può tornare comodo e può portare a soluzioni (almeno a mio parere) più eleganti.

Un'altra mancanza di Java è l'ereditarietà multipla, invece presente in C++.

Java sopperisce a questo permettendo solo l'ereditarietà singola di classe, ma permettendo l'ereditarietà multipla di interfacce. Questo può funzionare in molti casi, ma senz'altro risulta scomodo in molti altri: ereditare un'interfaccia, per come abbiamo detto, non è la stessa cosa che ereditare un'implementazione. Ci possono essere casi in cui questo non ci interessa (cioè in C++ avremmo ereditato da una classe astratta pura).

Del resto in [Tre98] è mostrato come si riesce ad ottenere lo stesso effetto dell'ereditarietà multipla in Java (tra l'altro proprio tramite un pattern, utilizzando la composizione insieme all'ereditarietà di interfaccia), ed in [Pes98] viene mostrato come i soliti esempi che si trovano nei libri di programmazione OOP (StudenteLavoratore che eredita sia da Studente, che da Lavoratore) mal si prestano ad essere utilizzate realmente nella vita reale, dovendo ricorrere invece ad una soluzione alternativa. Anche in [Sou94] viene mostrato come l'ereditarietà multipla può portare a dipendenze cicliche nei grafi delle gerarchie di classi, portando poi problemi quando si devono testare alcuni comportamenti o quando si devono riutilizzare certe classi. Questo forse vuol dire che se si ha una libreria di classi riutilizzabili, la situazione in cui si debba ereditare da più classi può non essere così frequente o comunque può portare a spiacevoli conseguenze!

Sempre in [Sou94] viene tuttavia mostrato come certe dipendenze cicliche possano tornare utili per implementare certe strutture complesse (suddividendo i compiti in varie classi legate fra di loro) e come la parola chiave friend (odiata dai puristi dell'OOP, e non a caso non presente in Java) possa aiutare (anzi risulti fondamentale) a far dialogare queste sotto classi fra loro. Quello però che l'utente vede è in realtà un'unica interfaccia (nel libro si parla di pattern class, ed in quel periodo il concetto di pattern non era ancora molto popolare, del resto [GOF95] fu pubblicato l'anno successivo). Questo non si discosta molto dal pattern facade ([GOF95]).

Quindi strumenti come l'ereditarietà multipla ed il costrutto friend (che permette di rompere l'incapsulazione di una classe), possono non essere utili (anzi possono essere dannosi) quando si deve utilizzare una libreria di classi, ma possono essere utili per implementarla.

I template, presenti in C++, ma assenti in Java, permettono di realizzare la programmazione generica, permettendo di astrarre dal tipo dei dati che si utilizzano in certe classi. Questo non è un problema in Java in quanto si può comunque utilizzare l'ereditarietà ed il polimorfismo per ottenere un risultato simile. In effetti la programmazione generica si scosta un po' dall'OOP standard (la stessa STL - Standard Template Library - del C++ non è considerata molto OO). Tuttavia l'utilizzo del polimorfismo porta all'utilizzo, in questi casi, di molti cast, mentre coi template il tutto è trasparente, ed indubbiamente più efficiente. Ovviamente non va tutto sempre così liscio come l'olio: i template sono la parte che forse dà più problemi di utilizzo e soprattutto mostra incongruenze fra i vari compilatori!

E allora ? (Conclusioni)

Ed insomma chi dei due vince questa sfida?

Ovviamente nessuno :-) Il C++ mette a disposizione più mezzi, e per questo permette di effettuare più operazioni pericolose (uso dei puntatori) o realizzare progetti più difficilmente gestibili (a causa delle dipendenze cicliche create con l'ereditarietà multipla). Java invece, "restringendo il campo d'azione", permette di programmare in modo più pulito, finché non ci si deve "sporcare" simulando certi comportamenti invece immediati in C++. Senz'altro però non ci si deve preoccupare della gestione della memoria :-)

La cosa bella dei pattern è però quella di essere allo stesso tempo

Quindi entrambi i linguaggi possono essere utilizzati e possono trarre benefici da queste soluzioni ormai assodate. Effettivamente l'implementazione deve comunque essere fatta, e quindi non si ha già a disposizione una soluzione utilizzabile, ma si ricordi comunque che anche la fase di progettazione è molto importante ed è da questa che nascono fuori le gerarchie e le strutture delle classi; i pattern forniscono proprio questo: la struttura base delle classi e le relazioni che devono intercorrere fra di esse... un bel passo avanti!

Personalmente preferisco avere più libertà, ma questo è solo un giudizio personale, e ci sono dei casi in cui invece, è più semplice sfruttare cose già predisposte, e sicure!

Nei prossimi numeri vorremmo proporvi una serie di articoli che parleranno di pattern e di come questi possono essere utilizzati ed implementati in Java, fornendo ovviamente del codice funzionante. Vedremo tra l'altro che Java utilizza già alcuni pattern. In effetti la reazione tipica di chi legge le descrizioni dei pattern è: "ah... ma io lo conoscevo già... guarda chi è..." ;-)

Quindi

A presto :-)
Lorenzo

Bibliografia