Incapsulamento

Progettazione e Sviluppo del Software

C.D.L. Tecnologie dei Sistemi Informatici

Gianluca Aguzzi — gianluca.aguzzi@unibo.it

Angelo Filaseta — angelo.filaseta@unibo.it

versione stampabile

Riconoscimenti

  • Questo materiale è ampiamente basato su quello realizzato dai Prof. Mirko Viroli e Roberto Casadei, che ringrazio.

  • Ogni errore riscontratovi è esclusiva responsabilità degli autori di questo documento.

Outline

Goal della lezione

  • Illustrare concetti generali di incapsulamento e information hiding
  • Mostrare tecniche di programmazione “standard”
  • Fornire primi esempi di classi ben progettate

Argomenti

  • Incapsulamento in Java
  • Una metodologia di progettazione

Alcuni principi di buona progettazione

Dai meccanismi alla buona progettazione/programmazione

La nostra analisi dell’OO in Java finora, ci ha insegnato:

  • Parte imperativa/procedurale di Java (tipi primitivi, operatori, cicli)
  • Classi, oggetti, costruttori, campi, metodi
  • Codice statico, controllo d’accesso

Detto ciò, come realizziamo un buon sistema?

Come programmiamo il sistema

  • per giungere al risultato voluto (soddisfacimento requisiti, funzionali e non), e
  • così che sia facilmente manutenibile (estendibile, flessibile, leggibile)?

$\Rightarrow$ un lunghissimo percorso: muoviamo i primi passi..

Il nostro approccio: per passi

  • Anticiperemo alcune tecniche di programmazione efficace basate su
    • tecniche di incapsulamento
    • principio di decomposizione ($\Leftarrow$ aspetto cruciale della OO)
  • Daremo qualche linea guida utile in future per costruire sistemi di più grosse dimensioni

Nota: sono tecniche/linee guida importanti per gestire il livello di articolazione di Java

Decomposizione, incapsulamento, information hiding

Il principo di decomposizione: divide et impera

Dividi e conquista: approccio top-down

  • La soluzione di un problema complesso avviene dividendolo in problemi più semplici
  • La suddivisione è spesso multi-livello

Esempi

  • SW calcolatrice con GUI: GUI, gestione eventi, calcoli matematici
  • Disegno Mandelbrot: Complex, Mandelbrot, MandelbrotApp

Decomposizione, modularità, e dipendenze

Un punto cruciale della decomposizione è la modularità

  • Modularità: il grado con cui un sistema è formato da “moduli” separati e combinabili
    • La suddivisione (in moduli) va fatta in modo tale che sia effettivamente conveniente
    • Bisogna isolare i “sottoproblemi” più semplici
  • Dipendenza: modulo $A$ è una dipendenza del modulo $B$ se $B$ ha bisogno di $A$ per alcune sue funzioni
    • Una dipendenza crea un accoppiamento (coupling)
  • Bisogna cercare di ridurre al massimo le “dipendenze” fra i sottoproblemi (ovvero, di favorire un accoppiamento lasco [loose coupling]), il che consente:
    • più autonomia decisionale
    • meno interazione con altri
    • meno influenze negative nel caso di modifiche (se il modulo $A$ cambia, può essere necessario modificare come conseguenza il modulo “dipendente” $B$)
  • Coesione: il grado con cui una serie di componenti lavora bene insieme

modularità = alta coesione intra-modulo + basso accoppiamento inter-moduli

Decomposizione e programmazione OO

Nella programmazione OO, almeno 3 livelli di decomposizione

  • Suddivisione in package (dell’intero programma)
  • Suddivisione in classi (di un package o programma)
  • Suddivisione in metodi (di una classe)

Il punto cruciale da affrontare ora è la suddivisone in classi

  • È necessario suddividire il codice in classi nel modo opportuno
  • Creando il miglior link col “problem space”
  • Diminuendo il più possibile le dipendenze fra classi

Tecnica

  • Esistono consolidate pratiche di programmazione efficace che risolvono il problema, e che cominceremo ad analizzare in questa lezione – farlo bene richiede molto tempo ed esperienza!

Dipendenze e OO

Dipendenza

Una classe A dipende da una classe B se in A si menziona B (ad es. come input di un metodo) o qualche sua proprietà.

  • La dipendenza è tanto più profonda (high coupling) quanto più in A si usano anche costruttori / campi / metodi di B.

Implicazione

  • Una dipendenza vincola fortemente la capacità di fare modifiche, perché ne comporta altre in cascata.
    • Se A dipende da B e modifico B, dovrò probabilmente modificare anche A.

La sindrome dell’“intoccabilità”—SW “rigido”

Costruendo software complesso con troppe dipendenze, si giunge al punto che ogni singola modifica ne richiederebbe molte altre, e rischierebbe quindi di essere troppo costosa – risultato: non si cambia più il software!

Incapsulamento

Definizione

Incapsulamento: il grado con cui un insieme di dati e funzioni sono “parte interna” di un oggetto

L’incapsulamento si fonda su due ingredienti cruciali della programmazione OO:

  1. Impacchettamento dati + funzioni per manipolarli
  2. Information hiding (via controllo d’accesso)

Incapsulamento: linee guida

  • Ogni classe dichiari public solo quei (pochi) metodi/costruttori necessari a interagire con (o creare) le sue istanze
  • Il resto (che quindi include meri aspetti realizzativi) sia private
    • metodi/costruttori a solo uso interno
    • tutti i campi (ossia lo stato interno)

L’incapsulamento aiuta a limitare dipendenze/accoppiamento

  • Limitando la parti public, si riduce la superficie accessibile e quindi la dipendenza potenziale
  • Il “cliente” è debolmente influenzato da possibili modifiche future riguardanti meri aspetti realizzativi.

Esempio base: conteggio (NON incapsulato)

  • dati e funzioni sono tenuti separati
package it.unibo.encapsulation.bad;

public class CounterValue {
    public final int value;

    public CounterValue(int value) {
        this.value = value;
    }
}
package it.unibo.encapsulation.bad;

public class CounterFunctions {
    static CounterValue increment(CounterValue cv) {
        return new CounterValue(cv.value + 1);
    }

    public static void main(String[] args){
        CounterValue result = CounterFunctions.increment(new CounterValue(10));
        System.out.println("Result: " + result.value);
    } 
}

Esempio base: classe Counter (POCO incapsulata)

  • dati e funzioni sono tenuti insieme (cosa piuttosto naturale nella OOP)
  • ma i dati sono ben visibili dall’esterno (no information hiding)
package it.unibo.encapsulation.bad;

public class Counter {
    int value;

    public void increment() {
        value++;
    } 
}
package it.unibo.encapsulation.bad;

public class UseCounter {
    public static void main(String[] args){
        Counter c = new Counter();
        c.increment();
        c.increment();
        System.out.println("Current value: " + c.value);
        c.value -= 10;
        System.out.println("Current value: " + c.value);
    } 
}

Esempio base: classe Counter (ben incapsulata)

class Counter {

	/* Il campo è reso inaccessibile */
	private int countValue;

	/* E' il costruttore che inizializza i campi! */
	public Counter() {
		this.countValue = 0;
	}

	/* Unico modo per osservare lo stato */
	public int getValue() {
		return this.countValue;
	}

	/* Unico modo per modificare lo stato */
	public void increment() {
		this.countValue++;
	}
}

Semplice uso

public class UseCounter {
	
	public static void main(String[] args) {
		Counter c = new Counter();
		System.out.println(c.getValue()); // 0
		c.increment();
		c.increment();
		c.increment();
		c.increment();
		System.out.println(c.getValue()); // 4
	}
}

Uso contatore

La classe contatore

  • Incapsula una semplice funzionalità di conteggio
  • Dà un approccio più astratto rispetto all’uso di un int
  • Consende di agire sul conteggio solo con getValue() e increment()
  • $\Rightarrow$ è impossibile modificare il conteggio a piacimento (o per errore), ad esempio decrementando invece che incrementando, o azzerando
public class UseCounter2 {

   /* Conto su un array, con Contatore creato internamente */
   static int countElements(int[] array, int elem) {
      Counter c = new Counter();
      for (int i : array) { // schema "for-each"
         if (i == elem) {
            c.increment();
         }
      }
      return c.getValue();
   }

Uso contatore, pt 2

Altra possibilità

  • Passo il contatore alle funzioni che hanno bisogno di conteggi
  • Ciò consente un più ampio grado di riuso
  • In generale, ho aperto nuove possibilità d’uso
   /* Conto su un array, con Contatore passato in input */
   static void countInArray(int[] array, int elem, Counter c) {
      for (int i : array) {
         if (i == elem) {
            c.increment();
         }
      }
   }

   /* Conto su una matrice, riusando appieno la countInArray */
   static void countInMatrix(int[][] matrix, int elem, Counter c) {
      for (int[] array : matrix) {
         countInArray(array, elem, c);
      }
   }
}

Riflessione: incapsulamento e contratto

Contratto

  • Nel diritto, un contratto è un accordo tra due o più parti, per regolare un rapporto
  • Nell’OOP, un contratto è una specifica del rapporto tra un oggetto e i suoi utilizzatori
    • indica cosa l’oggetto si attende dai suoi utilizzatori (“obblighi”)
    • indica cosa gli utilizzatori si possono attendere dall’uso dell’oggetto (“benefici”)
  • Grazie all’incapsulamento, è possibile vincolare fortemente questi contratti, controllando meglio il comportamento degli oggetti!

Il caso del Contatore

  • L’utilizzatore può solo (1) creare un contatore; (2) ottenere il valore del contatore; e (3) incrementare il contatore
  • Il valore del conteggio all’atto della costruzione è 0
  • Il valore del conteggio in ogni altro istante è pari al numero di chiamate di increment()

Osservazione

È grazie a questa idea che è più facile comporre oggetti in sistemi più complicati (vedi funzione countInMatrix)

Oggetti immutabili

Cosa sono

  • Oggetto immutabile: oggetto per il quale è garantito che il valore iniziale dei campi non cambierà mai
  • Portano un ulteriore livello di indipendenza (e eleganza) nel codice
  • In alcuni casi potrebbero portare a soluzioni poco performanti

Come si costruiscono

  • I campi della classe sono dichiarati final (oltre che private), e..
    • ..contengono a loro volta valori primitivi o oggetti immutabili
  • Quindi nessun metodo può modificarli (solo i costruttori possono, la prima volta)
  • Pattern di utilizzo: invece che cambiare i campi si possono solo creare nuovi oggetti con il nuovo stato desiderato

Osservazione

Per ora è sufficiente saperli riconoscere e costruire

Favorire sempre immutabilità ove possibile – un principio avanzato

Indicare anche final variabili e argomenti che non verranno modificati

ImmutablePoint3D

public class ImmutablePoint3D {

   private final double x; // x coordinate
   private final double y; // y coordinate
   private final double z; // z coordinate

   public ImmutablePoint3D(final double x, final double y, final double z) {
      this.x = x;
      this.y = y;
      this.z = z;
   }

   public double getSquareModule() {
      return this.x * this.x + this.y * this.y + this.z * this.z;
   }

   public double getX() {
      return this.x;
   }

   public double getY() {
      return this.y;
   }

   public double getZ() {
      return this.z;
   }

   /* A method that changes state is mimicked by creating a new object */
   public ImmutablePoint3D translate(final double x, final double y, final double z) {
      return new ImmutablePoint3D(this.x + x, this.y + y, this.z + z);
   }
}

UseImmutablePoint3D

public class UseImmutablePoint3D {

   public static void main(String[] args) {
      final ImmutablePoint3D p = new ImmutablePoint3D(10, 20, 30);
      // l'oggetto puntato da p non potrà essere modificato

      ImmutablePoint3D q = p.translate(1, 1, 1);
      // q punta ad un nuovo oggetto

      System.out.println(p.getX() + " " + p.getY() + " " + p.getZ());
      // 10,20,30
      System.out.println(q.getX() + " " + q.getY() + " " + q.getZ());
      // 11,21,31

      q = q.translate(1, 1, 1);
      // la variabile q punta ad un nuovo oggetto
      // il vecchio verrà eleminato dal GC

      System.out.println(q.getX() + " " + q.getY() + " " + q.getZ());
      // 12,22,32
   }
}

Una metodologia basata sull’incapsulamento

Altro esempio: classe Lamp

Analisi del problema

In un sistema domotico, dovremo gestire un certo numero di lampadine (da accendere/spegnere e pilotare tramite un apposito controllore centralizzato, oltre che tramite i comandi a muro). Tali comandi sono a pulsante con controllo di intensità (10 livelli). Il controllore deve poter accedere alla situazione di ogni lampadina (accesa/spenta, livello di intensità) e modificarla a piacimento. Al primo avvio, le lampadine sono spente e il controllo di intensità è a zero (in un intervallo $[0,1]$).

Come procediamo alla costruzione della classe Lamp?

Progettazione e implementazione: fasi

Fasi nella costruzione di una classe

  • Progettazione della parte pubblica della classe
  • Costruzione dello stato
  • Completamento implementazione
  • Miglioramento codice finale (refactoring)
  • Test del risultato

Fase 1: Progettazione della parte pubblica della classe

Ovvero, del nome della classe e delle signature di operazioni pubbliche (metodi e costruttori)

Linee guida

  • Considerare tutti i vari casi d’uso di un oggetto della classe
  • Inserire costruttori e metodi pubblici solo per le operazioni necessarie
    • Evitare ove possibile di inserire un numero elevato di tali operazioni

Il caso Lamp

  • Un costruttore unico senza argomenti
  • Metodi per accendere/spegnere
  • Metodi per aumentare/ridurre/impostare intensità
  • Metodi per accedere allo stato della lampadina

Parte pubblica della classe Lamp

/* Classe d'esempio che modella il concetto di Lampadina
   in un sistema Domotico */
public class Lamp {
    
    /* Inizializzazione */ 
    public Lamp() { .. }
    
    /* Accendo/Spengo */
    public void switchOn() { .. }
    public void switchOff() { .. }
    
    /* Meno intenso/Più intenso/Quanto intenso */  
    public void dim() { .. }
    public void brighten() { .. }
    public void setIntensity(double value) { .. }
    
    /* Selezione */
    public double getIntensity() { .. }
    public boolean isSwitchedOn() { .. }
} 

Fase 2: Costruzione dello stato

Ovvero, dei campi privati della classe

Linee guida

  • Considerare che esistono varie scelte possibili (è un aspetto implementativo, ritrattabile successivamente)
  • L’insieme dei campi deve essere più piccolo possibile, per esigenze di performance (spazio in memoria) e di non duplicazione
  • L’insieme dei campi deve essere sufficiente a tenere traccia di tutti i modi in cui il comportamento dell’oggetto può cambiare a fronte dei messaggi ricevuti

Il caso Lamp

  • Dovremo sapere se è accesa o spenta (boolean switchedOn)
  • Dovremo sapere il livello attuale di intensità (double intensity)
  • Non sembrano servire altre informazioni

Stato e metodi della classe Lamp

/* Classe d'esempio che modella il concetto di Lampadina
   in un sistema Domotico */
public class Lamp{

    /* Campi */
    private boolean switchedOn;
    private double intensity;
    
    /* Costruttore */
    public Lamp(){ .. }
 
    /* Metodi */
    public void switchOn(){ .. }
    public void switchOff(){ .. }
    public void dim(){ .. }
    public void brighten(){ .. }
    public void setIntensity(double value){ .. }
    public double getIntensity(){ .. }
    public boolean isSwitchedOn(){ .. }
} 

Fase 3: Completamento implementazione

Ovvero, del corpo di costruttori e metodi

Linee guida

  • Realizzare il corpo di ogni costruttore e metodo in modo compatibile col contratto previsto per la classe
  • Accettare il fatto che la prima versione prodotta non necessariamente sarà quella finale

Il caso Lamp

  • switchOn(), switchOff() sono semplici setter del campo switchedOn
  • isSwitchedOn(), getIntensity() semplici getter dei due campi
  • dim() e brigthen() modificano il campo intensity (se nel range!)

Prima versione Classe Lamp

public class Lamp {
    private double intensity;
    private boolean switchedOn;
    
    public Lamp(){
    	this.switchedOn = false;
    	this.intensity = 0;
    }
    public void switchOn(){
    	this.switchedOn = true;
    }
    public void switchOff(){
    	this.switchedOn = false;
    }
    public void dim(){
    	this.intensity = (this.intensity < 0.1 ? 0 : this.intensity-0.1);
    }
    public void brighten(){
    	this.intensity = (this.intensity > 0.9  ? 1 : this.intensity+0.1);
    }
    public void setIntensity(double value){
    	this.intensity = value;
    	if (value < 0) { this.intensity = 0; } // Mal formattato!
    	if (value > 1) { this.intensity = 1; } // Mal formattato!
    }
    public double getIntensity(){
    	return this.intensity;
    }
    public boolean isSwitchedOn(){
    	return this.switchedOn;
    }
} 

Fase 4: Miglioramento codice finale

Linee guida

  • Inserire commenti nel codice
  • Verificare la necessità di costanti per evitare numeri “magici”
  • Eventualmente fattorizzare sotto-funzioni in metodi/costruttori pubblici/privati, per evitare duplicazioni

In concreto in questo caso

  • Vi sono numeri magici, usare costanti!
  • Gestire meglio il limite $0..1$
  • Evitare livelli intermedi ($0.145$) di luminosità
  • Ritrattare la scelta del tipo del campo intensity – meglio un int fra $0$ e $10$!!

Versione finale Classe Lamp

public class Lamp {

   /* Costanti luminosità */
   private static final int LEVELS = 10;
   private static final double DELTA = 0.1;

   /* Campi della classe */
   private int intensity;
   private boolean switchedOn;

   /* Costruttore */
   public Lamp() {
      this.switchedOn = false;
      this.intensity = 0;
   }

   /* Gestione switching */
   public void switchOn() {
      this.switchedOn = true;
   }

   public void switchOff() {
      this.switchedOn = false;
   }

   public boolean isSwitchedOn() {
      return this.switchedOn;
   }
   /* Gestione intensita' */
   private void correctIntensity() { // A solo uso interno
      if (this.intensity < 0) {
         this.intensity = 0;
      } else if (this.intensity > LEVELS) {
         this.intensity = LEVELS;
      }
   }

   public void setIntensity(final double value) {
      this.intensity = Math.round((float) (value / DELTA));
      this.correctIntensity();
   }

   public void dim() {
      this.intensity--;
      this.correctIntensity();
   }

   public void brighten() {
      this.intensity++;
      this.correctIntensity();
   }

   public double getIntensity() {
      return this.intensity * DELTA;
   }

   public String toString() {
	   return "Acceso: " + this.isSwitchedOn() + " Intensità: " + this.getIntensity();
   }
}

Fase 5: Test del risultato

Linee guida

  • Definire un insieme di scenari d’uso di un oggetto
  • Per ognuno costruire una procedura che crea l’oggetto, lo usa, e stampa i risultati necessari

Il caso Lamp

Un possibile caso (non costituisce da solo un test esaustivo):

  • Costruisco l’oggetto lampadina
  • La accendo
  • Imposto la luminosità, poi la vario un poco
  • Leggo e stampo lo stato del sistema

Classe UseLamp

public class UseLamp {
   private static void test1() {
      final Lamp l = new Lamp();
      System.out.println(l);
      l.switchOn();
      l.setIntensity(0.5);
      l.dim();
      l.dim();
      System.out.println(l);
      l.brighten();
      System.out.println(l);
      // Acceso: true Intensità: 0.4
   }

   public static void main(final String[] s) {
      UseLamp.test1();
      // altri test...
   }
}

Il metodo toString()

Una convenzione Java

  • Ogni classe dovrebbe definire un metodo toString()
  • Questo deve restituire una rappresentazione in stringa dell’oggetto
  • Così si incapsula anche la funzionalità di presentazione (su console)
  • Tale metodo è quello che viene automaticamente chiamato quando si usa l’operatore + per concatenare stringhe a oggetti

UseLamp: uso di toString

public class UseLampString {
  	public static void main(String[] s) {
        LampString l = new LampString();
        l.switchOn();
        l.setIntensity(0.5);
        l.dim();
        l.dim();
        l.brighten();
        System.out.println(l.toString());
        System.out.println("Oppure : " + l);
    }
}

Il collaudo di Lamp è completato?

Quanti scenari di test vanno preparati?

  • Non c’è un numero giusto

  • Non è possibile in generale controllare in modo completo

  • Bisogna trovare il giusto rapporto tempo/risultato

  • Certamente, un unico scenario è insufficiente

  • La metodologia test-driven development consiglia di costruire test esaustivi prima dell’effettivo sviluppo di ogni funzionalità

    • Inizialmente percepito come un po’ noioso
    • Sembra far perdere tempo
    • Spesso ripaga in termini di temi complessivi e qualità del software profotto
    • Supportata da framework dedicati ai test (JUnit)