Nell'Object Orient Programming la classe è uno dei componenti fondamentali nel paradigma, ed è spesso vista dai programmatori come poco più che un contenitore di codice dotato di uno stato (i dati interni, più o meno strutturati) ed un comportamento (i metodi).
La Classe come contenitore di dati e comportamenti
Ragionando in questi termini potremmo individuare inoltre due diverse tipologie di dati che vengono manipolati dal codice:
- Dati interni alla classe.
- Dati esterni alla classe.
Il codice interno utilizza i dati per modificarli, ovvero è il comportamento che altera o aggiorna lo stato di una classe.
Programmazione Data Centric: i dati al centro, separati dai comportamenti
Vedremo ora un concetto diverso: cioè i dati che verranno separati dal codice di comportamento. Questo porterà ad un basso livello di coesione per ogni classe, ma ci permetterà di spostare l'attenzione più sui dati che sul codice, introducendo alcuni concetti legati agli aspetti (AOP)
Un progetto di esempio
la struttura in Eclipse
Creiamo un progetto in Eclipse e chiamiamolo DataCentricProgramming
, questa la struttura che
andremo a realizzare:
Andiamo ora a definire le varie classi...
Un progetto di esempio
La classe DataStorage, per gestire i dati
Iniziamo creando la classe DataStorage
nel relativo package:
package com.data;
public class DataStorage {
private String message;
public DataStorage() {
super();
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
Questa classe verrà utilizzata come un semplice contenitore di dati.
Le classi con la logica di business
Creiamo ora quest'altra classe:
package com.business;
import com.actions.ActionExecutorInterface;
import com.data.DataStorage;
public class DataStorageWorkflow {
public static DataStorage dataStorage;
public DataStorageWorkflow() {
super();
}
public DataStorage getDataStorage() {
return dataStorage;
}
public Object execute(ActionExecutorInterface action) {
return action.execute();
}
}
Per quanto concerne quest'ultima classe è da notare prima di tutto il riferimento statico DataStorage
nel metodo getDataStorage()
, ed il metodo execute()
, che accetta un parametro di tipo interfaccia che andremo a definire.
Questa l'interfaccia in oggetto:
package com.actions;
public interface ActionExecutorInterface {
public Object execute();
}
É una interfaccia che permette semplicemente di eseguire del codice, e vedremo tra poco come utilizzarla.
Creiamo ora una classe Action
, cioè una Business Class che dovrà eseguire del codice legato al dominio applicativo:
package com.actions;
import com.business.DataStorageWorkflow;
import com.data.DataStorage;
public class PrintMessageAction implements ActionExecutorInterface {
public PrintMessageAction() {
super();
}
public void foo() {
System.out.println(DataStorageWorkflow.dataStorage.getMessage());
}
public Object execute() {
foo();
return null;
}
}
Qui il discorso si fa più interessante: la classe action esegue una attività che viene richiamata attraverso il
metodo execute
implementato dell'interfaccia ActionExecutorInterface
.
Ancora il metodo foo()
utilizza il riferimento statico al dataContainer
per recuperare un messaggio che dovrebbe essere interno alla classe stessa:
public void foo() {
System.out.println(DataStorageWorkflow.dataStorage.getMessage());
}
Questa soluzione costituisce una alternativa all'implementazione:
public class PrintMessageAction implements ActionExecutorInterface {
private String message;
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
public void foo() {
System.out.println(getMessage());
}
}
In sostanza abbiamo spostato nel DataContainer
una variabile di classe di printMessageAction()
.
Vediamo ora come implementare le attività.
Implementazione delle attività
Creiamo ora una classe Workflow executor che esegua cioè le attività che implementano l'interfaccia ActionexEcutorInterface
:
package com.business;
import com.actions.ActionExecutorInterface;
import com.data.DataStorage;
public class DataStorageWorkflow {
public static DataStorage dataStorage;
public DataStorageWorkflow() {
super();
}
public DataStorage getDataStorage() {
return dataStorage;
}
public Object execute(ActionExecutorInterface action) {
return action.execute();
}
}
e diamo l'idea di una possibile classe di prova:
package com.test;
import com.actions.PrintMessageAction;
import com.business.DataStorageWorkflow;
import com.data.DataStorage;
public class Test {
public Test() {}
public static void main(String[] args) {
PrintMessageAction printAction = new PrintMessageAction(); // 1.
DataStorageWorkflow.dataStorage = new DataStorage(); // 2.
DataStorageWorkflow.dataStorage.setMessage("messaggio!"); // 3.
DataStorageWorkflow dsw = new DataStorageWorkflow(); // 4.
dsw.execute(printAction); // 5.
}
}
Facciamo pertanto un riepilogo, partendo dal main:
- viene creata la classe action che esegue l’attività di stampa
- viene istanziato attraverso il riferimento statico di classe il dataStorage nella classe
DataStorageWorkflow
- viene inserito un messaggio del dataStorage
- viene creata l’istanza del dataStorage
- il workflow chiede alla action di modificare i dati
Se vogliamo farci una idea complessiva della struttura delle classi, prima di procedere oltre, questo è il diagramma UML generato facendo un po' di reverse engineering:
Completiamo quindi il nostro piccolo progetto con qualche rifattorizzazione finale.
Ora facciamo una cosa interessante, modifichiamo prima di tutto dataStorage
in questo modo:
package com.data;
public class DataStorage {
private String message;
public DataStorage() {
super();
}
public String getMessage(String owner) {
System.out.println("DataStorage: get [" + message +"] by " + owner);
return message;
}
public void setMessage(String owner,String message) {
System.out.println("DataStorage: set [" + message +"] by " + owner);
this.message = message;
}
}
E quindi PrintMessageAction
:
e la relativa classe di test:
Qualsiasi action che utilizzerà la variabile message
implicitamente utilizzerà il log. Oltretutto si può anche pensare di applicare il tracing in modo da valutare il funzionamento del codice attraverso il cambiamento di stato delle variabili in maniera molto più marcata rispetto all'approccio tradizionale.
Possiamo inoltre immaginare una serie di soluzioni potenzialmente applicabili alla nostra classe
dataContainer
: avendo cioè centralizzato l'utilizzo dei dati possiamo applicare su questi una serie di aspetti, che saranno condivisi da tutte le classi che impiegheranno quella variabile.
Si può anche pensare, per gestire diversi aspetti dal Log alle transazioni, di applicare il Chain Of Responsibility Pattern, prevedendo anche un filtro da parte del chiamante che specifica quali aspetti in particolare siano da applicare.
Ancora è da considerare l'utilizzo della sincronizzazione per il Thread-Safe, o evitare l'uso della parola
static
in ambiente MultiThreaded
, ma questo non costituisce un problema quanto una buona regola di scrittura del codice.
Si può infine anche generalizzare il prelievo e la memorizzazione di una variabile nel Datacontainer
, ma questo lo lascio ai lettori come esercizio. Allo stesso modo l'impiego del pattern Chain Of Responsibility Pattern per spostare la logica che riguarda il log in una classe "aspect" separata.