UML - Diagramma delle classi

La progettazione di una qualunque applicazione informatica, basata sul paradigma ad oggetti, inizia con l'individuazione delle classi, da cui saranno originati gli oggetti che costituiranno l'applicazione stessa e che interagiranno tra loro, attraverso le rispettive caratteristiche (attributi) e funzionalità (metodi).

Un diagramma delle classi UML permette di rappresentare graficamente le classi che costituiscono l'applicazione ed i tipi di legami/interazioni tra esse.

Esso rappresenta uno strumento fondamentale al progettista di un sistema software durante la fase di analisi e progettazione/modellazione del sistema.

Successivamente, esso costituisce un documento fondamentale dell'applicazione, nonché lo strumento di dialogo tra i progettisti e gli sviluppatori dell'applicazione stessa, ossia coloro che si occuperanno dell'implementazione dell'applicazione attraverso un linguaggio specifico di programmazione ad oggetti (es. Java).

In un diagramma UML, la classe si rappresenta con un rettangolo diviso in tre sezioni, contenenti, rispettivamente:

  • il nome della classe
  • gli attributi della classe (caratteristiche e/o informazioni di stato)
  • i metodi della classe (funzionalità)

Graficamente possiamo sintetizzare quanto detto con la figura che segue:

UML - Rappresentazione di una classe

Nota: Convenzioni di naming
Il termine convenzioni di naming indica una serie di regole, universalmente accettate, per l'attribuzione dei nomi alle classe, agli attributi ed ai metodi.
Le convenzioni di naming più usate, soprattutto in ambito diagrammi UML, per la modellazione di applicazioni software basate sul paradigma O.O. sono:
  • PascalCase: consiste nello scrivere i nomi con l'iniziale maiuscola; se esso è composto da più parole, queste sono concatenate senza nessun carattere di separazione, con l'iniziale di ciascuna parola in maiuscolo.
    Esempio: Automobile, Studente, MezzoDiTrasporto, EdificioScolastico, ...
  • camelCase: consiste nello scrivere i nomi con l'iniziale minuscola; se esso è composto da più parole, queste sono concatenate senza nessun carattere di separazione, con l'iniziale della prima parola minuscola e delle parole successive alla prima in maiuscolo.
    Esempio: produttore, modello, numeroDiTelaio, numeroPorte, cilindrata, ...

Dopo aver introdotto le convenzioni di naming, si precisa che, nei diagrammi delle classi UML, i nomi delle classi seguono la convenzione PascalCase, mentre i nomi degli attributi e dei metodi, la convenzione camelCase.

Mettendo insieme tutti i concetti visti fino a questo punto, possiamo sintetizzare la rappresentazione di una classe, attraverso un diagramma UML come raffigurato di seguito:

UML - Rappresentazione di una classe

Modificatori di visibilità di attributi/metodi

In aggiunta a quanto detto finora, la figura precedente mostra, a sinistra degli attributi e dei metodi, un segno (+/-) che prende il nome di indicatore di visibilità.
L'indicatore di visibilità può assumere i valori ed i significati seguenti:

  • +, che indica il livello di visibilità pubblico, ossia l'attributo o il metodo sono visibili (accessibili o utilizzabili) sia nella classe in cui sono definiti che nelle altre classi che la utilizzano; in altri termini essi sono visibili sia all'interno che all'esterno della classe
  • -, che indica il livello di visibilità privato, ossia l'attributo o il metodo sono visibili (accessibili o utilizzabili) solo nella classe in cui sono definiti; in altri termini essi sono visibili solo internamente alla classe, ma non all'esterno della stessa
  • #, che indica il livello di visibilità protetto, ossia l'attributo o il metodo sono visibili (accessibili o utilizzabili) sia nella classe in cui sono definiti che nelle altre classi che estendono o derivano da essa; in altri termini essi sono visibili internamente alla classe e nella gerarchia delle classi derivate

A questo punto della trattazione, possiamo provare a realizzare il primo esempio reale.
Supponiamo di dover modellare il concetto di televisore.
Attraverso un procedimento di astrazione possiamo individuare una serie di informazioni rilevanti (caratteristiche/stato) ed una serie di funzionalità.
Le informazioni individuate andranno a costituire gli atttributi, le funzionalità i metodi della classe che ne deriverà e che chiameremo, in accordo con le convenzioni precedentemente citate, Televisore (notare l'iniziale maiuscola, convenzione PascalCase).

Le informazioni di rilievo possono essere, ad esempio:

  • la marca
  • il modello
  • la dimensione dello schermo, in pollici
  • lo stato acceso/spento
  • il canale sul quale è sintonizzato
  • il livello del volume

Ci possiamo fermare alle informazioni elencate, ma se ne potrebbero individuare diverse altre, più o meno importanti, a seconda del contesto nel quale l'applicazione in esame dovrà trattare oggetti del tipo Televisore.
Le informazioni marca, modello e dimensione in pollici costituiscono delle caratteristiche, in quanto esse non possono cambiare nella vita di un televisore.
In altri termini, un televisore di una certa marca non potrà mai diventare di un'altra marca e così anche per le altre caratteristiche.
Viceversa, le informazioni acceso/spento, il canale ed il volume costituiscono informazioni di stato, perchè esse possono assumere valori differenti durante la vita di uno stesso televisore.

Concentriamo ora la nostra attenzione sulle funzionalità che un oggetto televisore può avere nel semplice contesto analizzato finora.
Possiamo elencare:

  • accendi, per accendere il televisore
  • spegni, per spegnere il televisore
  • imposta su un determinato canale
  • avanza di un canale
  • torna indietro di un canale
  • aumenta il livello del volume
  • diminuisci il livello del volume

Queste sono solo alcune delle funzionalità che si possono individuare a titolo di esempio.
Ovviamente è compito del progettista, mettere in azione le sue abilità, per determinare quali sono le funzionalità necessarie, nel contesto di utilizzo degli oggetti da modellare, nonché il tipo di definizione (tipo di funzionalità svolta, informazioni in ingresso ed in uscita) più indicate per rendere l'utilizzo degli oggetti di tale tipo, il più agevole possibile.

A questo punto possiamo provare a modellare quanto analizzato attraverso un diagramma delle classi UML.
Il risultato potrebbe essere quello evidenziato nella figura seguente:

UML - Esempio Televisore

Notare la distinzione tra attributi che rappresentano delle caratteristiche da quelli che rappresentano informazioni di stato.
Questa distinzione è importante, perchè, come vedremo più avanti, potrebbe determinare alcuni dettagli relativi ad altri metodi che aggiungeremo alla classe.
Infatti, in aggiunta ai metodi relativi a funzionalità che abbiamo ritenuto rilevanti in fase di analisi, ve ne sono degli altri che hanno delle funzioni particolari.
Tra questi:

  • il metodo o i metodi costruttore
  • i metodi getter
  • i metodi setter

Approfondiamoli nel dettaglio uno alla volta.

Il metodo costruttore

Il metodo costruttore è un metodo fondamentale per la creazione di oggetti dell tipo della classe.
Esso non deve specificare un tipo di output, in quanto il tipo restituito è la classe stessa.
Potrebbe non avere parametri di input (costruttore vuoto o costruttore di default), oppure avere quelli rilevanti, che l'analista ha ritenuto importanti per la costruzione degli oggetti concreti del tipo della classe.
Solitamente, se non si specifica un costruttore, si sottintende l'esistenza implicita del costruttore di default, ossia senza parametri di input.
L'analista potrebbe, comunque, prevedere la presenza di più costruttori, con differenti parametri di input per la creazione di oggetti del tipo classe inizializzati in modo differente.
Un tipo particolare di costruttore è il costruttore di copia che, come approfondiremo più avanti, ha un solo parametro di input dello stesso tipo della classe ed è utilizzato per creare una copia dell'oggetto ricevuto come parametro.

Ricollegandoci a quanto visto nella sezione relativa al polimorfismo, della parte introduttiva, possiamo concludere che i diversi costruttori costituiscono un overload del metodo costruttore, in quanto lo stesso metodo è sovraccaricato di parametri che differiscono per il tipo, l'ordine o il numero.

I metodi getter/setter

I metodi getter/setter, come si può intuire considerando il significato dei termini in inglese, hanno il compito di permettere la lettura (get) o la scrittura (set) del valore di un attributo.
Più specificatamente:

  • il metodo getter di un attributo è un metodo che non ha parametri di input ed ha, come tipo di output, lo stesso tipo dell'attributo del quale consente la lettura.
    Il suo nome è composto dalla parola get seguita dal nome dell'attributo (es. getModello è il nome del metodo getter dell'attributo di nome modello).
    Fanno eccezione i metodi getter degli attributi di tipo boolean, in quanto usano la parola is seguita dal nome dell'attributo (es. isAcceso è il nome del metodo getter dell'attributo di nome acceso).
    Esso è necessario, con visibilità pubblica, qualora si voglia permettere la lettura del valore di un attributo privato. Potrebbe, tuttavia, anche essere presente con visibilità privata, per questioni di comodità di codifica dello sviluppatore della classe.

  • il metodo setter di un attributo è un metodo che non ha valore di output (void) ed ha un unico parametro di input dello stesso tipo dell'attributo del quale consente la scrittura.
    Il suo nome è composto dalla parola set seguita dal nome dell'attributo (es. setModello).
    Esso è necessario, con visibilità pubblica, qualora si voglia permettere la scrittura del valore di un attributo privato. Potrebbe, tuttavia, anche essere presente con visibilità privata, per questioni di comodità di codifica dello sviluppatore della classe (es. per brevità, correttezza ed efficienza del codice, qualora esso debba compiere più azioni/controlli ogni volta che viene modificato il valore dell'attributo).

Torniamo ora all'esempio del televisore e completiamo il diagramma UML con l'aggiunta dei costruttori e dei metodi getter/setter.

UML - Esempio Televisore

L'esempio della classe televisore, visto in precedenza, è stato esteso aggiungendo un costruttore, che prende in input gli attributi che rappresentano le caratteristiche del televisore e che, quindi, sono note alla sua creazione e non variano durante il corso della vita dell'oggetto.
Quanto detto è anche riscontrabile dalla visibilità privata dei metodi setter di tali attributi.
Il secondo costruttore inserito rappresenta il costruttore di copia, infatti, come anticipato poc'anzi, possiede un unico attributo dello stesso tipo della classe in cui è definito, ossia della quale costituisce il costruttore di copia.

Seguono i metodi getter/setter dei rimanenti attributi, che rappresentano informazioni di stato del televisore, ossia se esso è acceso o spento, il canale su cui è sintonizzato ed il livello di volume impostato.
I getter hanno solitamente visibilità pubblica, in quanto la loro funzione è proprio permettere l'accesso ai valori degli attributi (solitamente con visibilità privata) all'esterno della classe.
Tra i setter, invece, ho preferito definire con visibilità privata il setter dello stato e del volume, in quanto ho lasciato i metodi accendi/spegni e aumentaVolume/diminuisciVolume.
Ho, invece, definito, con visibilità pubblica, il setter del canale, in quanto l'ho sostituito al metodo impostaCanale presente nalla versione precedente.
Avrei potuto, altresì, lasciare il metodo impostaCanale e definire anche il setter dell'attributo canale con visibilità privata.

Sottolineo che i metodi presenti e la loro visibilità sono una scelta del progettista, che deve condurre, ad ogni modo, ad un modello il più chiaro, intuitivo e comprensibile possibile, che renda l'utilizzo della classe, agli altri sviluppatori, semplice ed immediato, esonerandoli, nella maggior parte dei casi, dalla dispendiosa lettura della documentazione della classe.

Nota
La capacità di definire i metodi con cui la classe si interfaccia verso l'esterno (metodi public) rappresenta una delle competenze fondamentali di coloro che si occuperanno dell'analisi e progettazione delle classi delle applicazioni informatiche basate sul paradigma O.O.

Attributi statici e costanti

Estendiamo ora l'esempio del televisore, aggiungendo informazioni per introdurre ulteriori concetti della programmazione object oriented.
Supponiamo che il costo di un televisore sia determinato dalla sua dimensione in pollici moltiplicata per il costo unitario di un pollice, mentre il prezzo di vendita sia calcolato aggiungendo al costo del televisore una percentuale fissa di guadagno.
Si tratta, ovviamente di un esempio poco realistico, ma che si presta bene alla comprensione dei concetti e dell'utilità degli attributi statici e costanti.

Analizzando quanto detto, possiamo concludere che il costo per pollice è una informazione che riguarda tutte le istanze della classe Televisore, essendo il suo valore il medesimo per tutti i televisori.
Si tratta di una caratteristica differente rispetto, ad esempio, alla marca.
Quest'ultima, infatti, poteva essere diversa da televisore a televisore.

Questa esigenza di unicità di valore tra tutte le istanze della classe, è tecnicamente indirizzabile utilizzando un attributo statico (detto anche attributo di classe, a differenza degli esempi visti finora di attributi di istanza).
Un attributo statico si rappresenta nel diagramma UML sottolineando la definizione dell'attributo stesso, come evidenziato nella figura che segue.

UML - Esempio Televisore con attributi e metodi statici ed attributi costanti.

Essendo un attributo di classe, quindi che si non si riferisce ad una istanza particolare, ma bensì a tutte le istanze della classe, la sua definizione, in Java, deve avvenire utilizzando la parola chiave static ed il suo utilizzo, in lettura ed in scrittura, utilizzando il nome della classe e non la parola chiave this, come visto finora per gli attributi di istanza.
Di seguito il codice Java che illustra quanto detto.


// la dichiarazione dell'attributo statico  
// si effettua utilizzando la parola chiave static 
// prima del tipo dell'attributo
// come mostrato nella riga seguente
private static double costoPerPollice;

// L'utilizzo del valore dell'attributo statico, ad esempio
// nell'implementazione del metodo costo() 
// si effettua anteponendo il nome della 
// classe, come mostrato di seguito
public double costo() {
    return this.dimensione * Televisore.costoPerPollice;
    // nota: non this.costoPerPollice
} 


Un attributo costante, invece, si rappresenta scrivendone, per convenzione, il nome con caratteri maiuscoli (CAPITALCASE).
Agli attributi costanti può essere assegnato un valore nella definizione dell'attributo stesso, oppure, in alternativa, soltanto nei metodi costruttori.
Ogni altra assegnazione di valori genererà un errore di compilazione.

Per il resto, un attributo costante è un attributo di istanza (come le altre caratteristiche viste finora) a tutti gli effetti (quindi si utilizza tramite parola chiave this).

La dichiarazione in Java deve premettere la parola chiave final, come mostrato nel frammento di codice seguente:


// la dichiarazione dell'attributo costante  
// si effettua utilizzando la parola chiave final 
// prima del tipo dell'attributo
// come mostrato nella riga seguente
private final double PERCENTUALE_GUADAGNO = 0.2;
// nota: 0.2 equivale in percentuale al 20%

// L'utilizzo del valore dell'attributo statico, ad esempio
// nell'implementazione del metodo prezzoVendita() 
// si effettua, come visto finora, per 
// gli attributi di istanza, anteponendo 
// la parola chiave this
public double prezzoVendita() {
    // calcolo del costo incrementato del 20%
    return this.costo() * (1 + this.PERCENTUALE_GUADAGNO);
} 


I metodi statici

Spostiamo ora la nostra attenzione sui metodi getter e setter dell'attributo di classe (o statico) costoPerPollice.

Essendo l'attributo statico, quindi non di istanza, ma di classe, di conseguenza anche i suoi metodi getter e setter devono essere metodi di classe e non di istanza.

Questo vuol dire che essi dovranno usare la parola chiave static, prima della specifica del tipo restituito, nella loro definizione. Inoltre, la loro invocazione dovrà essere effettuata utilizzando il nome della classe e non la parola chiave this, come mostrato nel codice Java che segue.


// getter e setter dell'attributo costoPerPollice
// notare l'uso della parola chiave static
public static double getCostoPerPollice() {
    return Televisore.costoPerPollice;
}

public static void setCostoPerPollice(double costoPerPollice) {
    Televisore.costoPerPollice = costoPerPollice;
}

// l'invocazione di tali metodi all'esterno della classe Televisore
// deve avvenire utilizzando il nome della classe al posto
// del nome della variabile che contiene l'istanza di Televisore
public static void main(String[] args) {
    ...
    Televisore t1 = new Televisore(...);
    ...
    Televisore.setCostoPerPollice = 20.0;
    // nota: non t1.setCostoPerPollice = 20.0;

    System.out.println("Il prezzo del televisore è " + t1.prezzo());
    ...
} 

Nota:
I metodi statici non sono soltanto utilizzati per leggere (getter) o modificare (setter) il valore di un attributo statico.

Spesso si utilizzano metodi statici per implementare funzionalità appartenenti a tutti gli oggetti di una classe, a prescindere dalle loro caratteristiche concrete (ossia dai valori assunti dagli attributi).
Si può optare per un metodo statico ogni volta che la funzionalità da implementare non richiede di accedere ai valori degli attributi di una istanza specifica.

Un esempio classico di utilizzo dei metodi statici potrebbe essere una libreria di funzioni matematiche, che per eseguire il proprio lavoro non necessitano di una istanza specifica, ma semplicemente di valori che possono essere passati attraverso i parametri del metodo stesso.
I metodi statici, infatti, si possono utilizzare senza creare una istanza specifica della classe, ma semplicamente utilizzando il nome della classe.

Di seguito è mostrato un esempio di classe con metodi statici.


// Esempio di libreria di funzioni matematiche
public class Matematica {

    // calcolo della potenza
    public static int potenza(int base, int esponente) {
        // implementazione algoritmo di calcolo
        // della potenza per moltiplicazioni successive
        int potenza = 1;
        for (int i = 0; i < esponente; i++) {
            potenza *= base;
        }
        return potenza;
    }

    // calcolo del resto intero della divisione
    public static int modulo(int dividendo, int divisore) {
        // implementazione algoritmo di calcolo
        // del resto per sottrazioni successive

        int resto = dividendo;
        while (resto >= divisore) {
            resto -= divisore;
        }
        return resto;
    }

    // ... altre funzioni matematiche

}

Nota:
Si evidenziano l'assenza di attributi, l'assenza di un costruttore e l'uso della parola chiave static nella definizione dei metodi.

Un esempio di utilizzo, nel main, delle funzioni definite nella classe Matematica, potrebbe essere il seguente:


// Utilizzo delle funzioni definite nella classe Matematica
public static void main(String[] args) {
    // Notare l'utilizzo delle funzioni della 
    // classe Matematica senza la creazione di 
    // una istanza della classe stessa

    // Per stampare il risultato di 2 elevato alla 5 
    // si può semplicemente utilizzare la funzione 
    // statica potenza
    // Notare l'uso dell'espressione NomeDellaClasse.metodoStatico
    System.out.println(Matematica.potenza(2, 5));

    // Per stampare il resto della divisione tra 17 e 3 
    // si può semplicemente utilizzare la funzione 
    // statica modulo
    // Notare l'uso dell'espressione NomeDellaClasse.metodoStatico
    System.out.println(Matematica.modulo(17, 3));
}

Nota:
Alcuni linguaggi, tra cui Java, consentono di invocare metodi statici utilizzando istanze della classe (variabili del tipo della classe). Anche se permesso, questo non è concettualmente corretto in quanto i metodi statici, essendo metodi di classe e non di istanza, si utilizzano con la notazione NomeDellaClasse.metodoStatico. Molti linguaggi (es. C#) segnalano un errore di compilazione nel caso di utilizzo di un metodo statico attraverso una istanza della classe.