Progettazione

Introduzione

Durante le lezioni, per discutere di progettazione siamo partiti da un esempio di programma in C che stampa una canzone. Il codice considerato è talmente illeggibile che Jekyll rifiuta di compilare se lo si prova ad includere in un file Markdown.

Successivamente abbiamo scomposto il codice per renderlo logicamente più sensato e facilmente modificabile, sono state estratte le parti comuni e spostate in una funzione apposita, mentre le parti mutabili sono state salvate in alcune strutture dati; la canzone viene così stampata tramite un ciclo. In questo modo scrivendo un codice più semplice siamo stati in grado di creare una soluzione più generale e più aperta ai cambiamenti.

public class TwelveDaysOfChristmas {
    static String[] days = {"first", "second", ..., "twelfth"};
    static String[] gifts = { "a partdrige in a pear tree", "two turtle doves", ... };

    static String firstLine(int day) {
        return "On the " + days[day] +
               " day of Christmas my true love gave to me:\n";
    }

    static String allGifts(int day) {
        if (day == 0) {
            return "and " + gifts[0];
        } else {
            return gifts[day] + "\n" + allGifts(day-1);
        }
    }

    public static void main(String[] args) {
        System.out.println(firstLine(0));
        System.out.println(gifts[0]);
        for (int day == 1; day < 12; day++) {
            System.out.println(firstLine(day));
            System.out.println(allGifts(day));
        }
    }
}

È importante quindi adottare la soluzione più semplice (che non è quella più stupida!) e una misura convenzionale per dire quanto una cosa è semplice - almeno in Università - si esprime in termini del tempo dedicato dal programmatore all’implementazione. Tale misura si sposa bene con il TDD, che richiede brevi iterazioni di circa 10 minuti: se la feature attuale richiede più tempo è opportuno ridurre la portata scomponendo il problema.

Refactoring

Durante il refactoring è opportuno rispettare le seguenti regole:

  • le modifiche al codice non devono modificare le funzionalità: il refactoring DEVE essere invisibile al cliente;
  • non possono essere aggiunti test aggiuntivi rispetto alla fase verde appena raggiunta.

Se la fase di refactoring sta richiedendo troppo tempo allora è possibile fare rollback all’ultima versione verde e pianificare meglio l’attività di refactoring, per esempio scomponendolo in più step. Vale la regola del “do it twice”: il secondo approccio a un problema è solitamente più veloce e migliore.

Motivazioni

Spesso le motivazioni dietro un refactoring sono:

  • precedente design molto complesso e poco leggibile, a causa della velocità del passare ad uno scenario verde;
  • preparare il design di una funzionalità che non si integra bene in quello esistente; dopo aver raggiunto uno scenario verde in una feature, è possibile che la feature successiva sia difficile da integrare. In questo caso, se il refactoring non è banale è bene fermarsi, tornare indietro e evolvere il codice per facilitare l’iterazione successiva (design for change).
  • presenza di debito tecnico su lavoro fatto in precendenza, ovvero debolezze e “scorciatoie” che ostacolano notevolmente evoluzioni future: “ogni debito tecnico lo si ripaga con gli interessi”.

Design knowledge

La design knowledge è la conoscenza del design architetturale di un progetto. È possibile utilizzare:

  • la memoria: non è efficace perché nel tempo si erode, specialmente in coppia;
  • i documenti di design (linguaggio naturale o diagrammi): se non viene aggiornato di pari passo con il codice rimane disallineato, risultando più dannoso che d’aiuto.
  • le piattaforme di discussione (version control, issue management): possono aiutare ma le informazioni sono sparse in luoghi diversi e di conseguenza difficili da reperire e rimane il problema di mantenere aggiornate queste informazioni.
  • gli UML: tramite diagrammi UML si è cercato di sfruttare l’approccio generative programming, ovvero la generazione automatica del codice a partire da specificazioni di diagrammi. Con l’esperienza si è visto che non funziona.
  • il codice stesso: tramite la lettura del codice è possibile capire il design ma è difficile rappresentare le ragioni della scelta.

È bene sfruttare tutte le tecniche sopra proposte combinandole, partendo dal codice.
È inoltre importante scrivere documentazione per spiegare le ragioni dietro le scelte effettuate e non le scelte in sé, che si possono dedurre dal codice.

Condivisione

Per condividere tali scelte di design (il know how) è possibile sfruttare:

  • metodi: con pratiche (come Agile) o addirittura l’object orientation stessa, che può essere un metodo astratto per condividere scelte di design.
  • design pattern: fondamentali per condividere scelte di design, sono utili anche per generare un vocabolario comune (sfruttiamo dei nomi riconosciuti da tutti per descrivere i ruoli dei componenti) e aiutano l’implementazione (i pattern hanno delle metodologie note per essere implementati). I pattern non si concentrano sulle prestazioni di un particolare sistema ma sulla generalità e la riusabilità di soluzioni a problemi comuni;
  • principi: per esempio i principi SOLID.

Conoscenze preliminari di concetti e termini

Prima di proseguire è bene richiamare concetti e termini fondamentali presumibilmente visti durante il corso di Programmazione II.

Object orientation

Per essere definito object oriented, un linguaggio di programmazione deve soddisfare tre proprietà:

  • ereditarietà: ovvero la possibilità di poter definire una classe ereditando proprietà e comportamenti di un’altra classe.
  • polimorfismo: quando una classe può assumere diverse forme in base alle interfacce che implementa. Il prof fa l’esempio del tennista scacchista: in un torneo di tennis è poco utile sostituire una persona che gioca a tennis ed è brava con gli scacchi (quindi una classe che implementa entrambe le interfacce) con una che gioca a scacchi. Il collegamento tra capacità e oggetto è fatto a tempo di compilazione: non è importante quindi se la capacità non è ancora definita;
  • collegamento dinamico: in Java il tipo concreto degli oggetti e quindi il problema di stabilire quale metodo chiamare viene risolto durante l’esecuzione. In C++ occorre esplicitare questo comportamento utilizzando la keyword virtual.

SOLID principles

Ci sono 5 parti che compongono questo principio:

  1. SINGLE RESPONSIBILITY: una classe, un solo scopo. Così facendo, le classi rimangono semplici e si agevola la riusabilità.
  2. OPEN-CLOSE PRINCIPLE: le classi devono essere aperte ai cambiamenti (opened) ma senza modificare le parti già consegnate e in produzione (closed). Il refactoring è comunque possibile, ma deve essere preferibile estendere la classe attuale.
  3. LISKOV SUBSTITUTION PRINCIPLE: c’è la garanzia che le caratteristiche eredidate dalla classe padre continuinino ad esistere nelle classi figlie. Questo concetto si collega all’aspetto contract-based del metodo Agile: le precondizioni di un metodo di una classe figlia devono essere ugualmente o meno restrittive del metodo della classe padre. Al contrario, le postcondizioni di un metodo della classe figlia non possono garantire più di quello che garantiva il metodo nella classe padre. Fare casting bypassa queste regole.
  4. INTERFACE SEGREGATION: più le capacità e competenze di una classe sono frammentate in tante interfacce più è facile utilizzarla in contesti differenti. In questo modo un client non dipende da metodi che non usa. Meglio quindi avere tante interfacce specifiche e piccole (composte da pochi metodi), piuttosto che poche, grandi e generali.
  5. DEPENDENCY INVERSION: il codice dal quale una classe dipende non deve essere più concreto di tale classe. Per esempio, se il telaio della FIAT 500 dipende da uno specifico motore, è possibile utilizzarlo solo per quel specifico motore. Se invece il telaio dipende da un concetto di motore, non c’è questa limitazione. In conlusione, le classi concrete devono tendenzialmente dipendere da classi astratte e non da altre classi concrete.

Reference escaping

Il reference escaping è una violazione dell’incapsulamento.

Può capitare, per esempio:

  • quando un getter ritorna un riferimento a un segreto;
    public Deck {
      private List<Card> cards;
            
      public List<Card> getCards() {
          return this.cards;
      }
    }
    
  • quando un setter assegna a un segreto un riferimento che gli viene passato;
    public Deck {
      private List<Card> cards;
    
      public setCards(List<Card> cards) {
          this.cards = cards;
      }
    }
    
  • quando il costruttore assegna al segreto un riferimento che gli viene passato;
    public Deck {
      private List<Card> cards;
    
      public Deck(List<Card> cards) {
          this.cards = cards;
      }
    }
    

Encapsulation e information hiding

Legge di Parnas (L8).

Solo ciò che è nascosto può essere cambiato liberamente e senza pericoli.

Lo stato mostrato all’esterno non può essere modificato, mentre quello nascosto sì.

Questo principio serve per facilitare la comprensione del codice e renderne più facile la modifica parziale senza fare danni.

Immutabilità

Una classe è immutabile quando non c’è modo di modificare lo stato di ogni suo oggetto dopo la creazione.

Per assicurare tale proprietà è necessario:

  • non fornire metodi di modifica allo stato;
  • avere tutti gli attributi privati per i tipi potenzialmente mutabili (come List<T>);
  • avere tutti gli attributi final se non già privati;
  • assicurare l’accesso esclusivo a tutte le parti non mutabili, ovvero non avere reference escaping.

Code smell

I code smell sono dei segnali che suggeriscono problemi nella progettazione del codice. Di seguito ne sono elencati alcuni:

  • codice duplicato: si può fare per arrivare velocemente al verde ma è da togliere con il refactoring. Le parti di codice in comune possono quindi essere fattorizzate.
  • metodi troppo lunghi: sono poco leggibili e poco riusabili;
  • troppi livelli di indentazione: scarsa leggibilità e riusabilità, è bene fattorizzare il codice;
  • troppi attributi: suggerisce che la classe non rispetta la single responsability, ovvero fa troppe cose;
  • lunghe sequenze di if-else o switch;
  • classe troppo grande;
  • lista parametri troppo lunga;
  • numeri magici: è importante assegnare alle costanti numeriche all’interno del codice un nome per comprendere meglio il loro scopo;
  • commenti che spiegano cosa fa il codice: indicazione che il codice non è abbastanza chiaro;
  • nomi oscuri o inconsistenti;
  • codice morto: nel programma non deve essere presente del codice irraggiungibile o commentato. Utilizzando strumenti di versioning è possibile riaccedere a codice precedentemente scritto con facilità.
  • getter e setter: vedi principio di tell don’t ask.

Principio Tell-Don’t-Ask

Non chiedere i dati, ma dì cosa vuoi che si faccia sui dati

Il responsabile di un’informazione è anche responsabile di tutte le operazioni su quell’informazione.

Il principio Tell-Don’t-Ask sancisce che piuttosto di chiedere ad un oggetto dei dati e fare delle operazioni con quei dati è meglio dire a questo oggetto cosa fare con i dati che contiene.

Esempio

Se desideriamo stampare il contenuto di tutte le carte in un mazzo possiamo partire da questo codice.

class Main {
    public static void main(String[] args) {
        Deck deck = new Deck();
        Card card = new Card();

        card.setSuit(Suit.DIAMONDS);
        card.setRank(Rank.THREE);
        deck.getCards().add(card);
        deck.getCards().add(new Card());    // <-- !!!

        System.out.println("There are " + deck.getCards().size() + " cards:");
        for (Card currentCard : deck.getCards()) {
            System.out.println(
                currentCard.getRank() + 
                " of " + 
                currentCard.getSuit()
            );
        }
    }
}

All’interno del ciclo reperiamo gli attributi della carta e li utilizziamo per stampare le sue informazioni. Inoltre, nella riga evidenziata viene aggiunta una carta senza settare i suoi attributi. La responsabilità della gestione dell’informazione della carta è quindi erroneamente delegata alla classe chiamante.

Per risolvere, è possibile trasformare la classe Card nel seguente modo:

class Card {
    private Suit suit;
    private Rank rank;

    public Card(@NotNull Suit s, @NotNull Rank r) {
        suit = s;
        rank = r;
    }

    @Override
    public String toString() {
        return rank + " of " + suit;
    }
}

l’informazione viene ora interamente gestita dalla classe Card, che la gestisce nel metodo toString() per ritornare la sua rappresentazione testuale.

Interface segregation

Le interfacce possono “nascere” tramite due approcci:

  • up front: scrivere direttamente l’interfaccia;
  • down front: scrivere il codice e quindi tentare di estrarne un’interfaccia.

L’approccio down-front si adatta meglio al TDD ed è illustrato nel seguente esempio.

Esempio con gerarchia Card / Deck

In questo esempio sono trattati numerosi principi, come l’interface segreagation, linking dinamico/statico, implementazione di interfacce multiple e il contract based design vs la programmazione difensiva.

Interface segregation all’opera

public static List<Card> drawCards(Deck deck, int number) {
    List<Card> result = new ArrayList<>();
    for (int i = 0; i < number && !deck.isEmpty(); i++) {
        result.add(deck.draw());
    }
    return result;
}

Consideriamo il metodo drawCards che prende come parametri un Deck e un intero.
Le uniche competenze riconosciute a Deck sono l’indicazione se è vuoto (isEmpty()) e il pescare una carta dal mazzo (draw()). Deck può quindi implementare un’interfaccia che mette a disposizione queste capacità.

È possibile modificare il metodo in modo da accettare un qualunque oggetto in grado di eseguire le operazioni sopra elencate, ovvero che implementi l’interfaccia CardSource.

public interface CardSource {
    /**
     * @return The next available card.
     * @pre !isEmpty()
     */
    Card draw();

    /**
     * @return True if there is no card in the source
     */
    boolean isEmpty();
}
public class Deck implements CardSource { ... }
public static List<Card> drawCards(CardSource deck, int number) {
    List<Card> result = new ArrayList<>();
    for (int i = 0; i < number && !deck.isEmpty(); i++) {
        result.add(deck.draw());
    }
    return result;
}

Collegamento statico e dinamico

Notare come è necessario specificare staticamente che Deck implementi CardSource, ovvero occorre forzare la dichiarazione del fatto che Deck sia un sottotipo di CardSource (Java è strong typed) e quindi sia possibile mettere un oggetto Deck ovunque sia richiesto un oggetto CardSource.
In altri linguaggi come Go c’è una maggiore dinamicità perché non c’è bisogno di specificare nel codice che un oggetto è sottotipo di qualcos’altro, è sufficiente solo che implementi un metodo con la stessa signature. Il controllo che l’oggetto passato ad una funzione abbia le capacità necessarie avviene a runtime e non prima.

Un problema della troppa dinamicità (duck typing) è che se i metodi di un oggetto non hanno dei nomi abbastanza specifici si possono avere dei problemi. Per esempio, in un programma per il gioco del tennis se una funzione richiede un oggetto che implementa il metodo play(), e riceve in input un oggetto che non c’entra nulla con il tennis (per esempio un oggetto di tipo GiocatoreDiScacchi) che ha il metodo play(), si possono avere degli effetti indesiderati.

Loose coupling

Il loose coupling è la capacità di una variabile o un parametro di accettare l’assegnamento di oggetti aventi tipo diverso da quello della variabile o parametro, a patto che sia un sottotipo.

Deck deck = new Deck();
CardSource source = deck;
List<Card> cards = drawCards(deck, 5);

Interfacce multiple

Tornando all’esempio, la classe Deck (che implementa CardSource) può implementare anche altre interfacce, come Shuffable o Iterable<Card>. Al metodo precedente interessa solo che Deck abbia le capacità specificate in CardSource, se poi implementa anche altre interfaccie è ininfluente.

Contract-based design vs programmazione difensiva

Tornando alla specificazione dell’interfaccia di CardSource, è possibile notare dei commenti in formato Javadoc che specificano le precondizioni e le postcondizioni (il valore di ritorno) del metodo. Secondo il contract-based design, esiste un “contratto” tra chi implementa un metodo e chi lo chiama.

Per esempio, considerando il metodo draw(), è responsabilità del chiamante verificare il soddisfacimento delle precondizioni (“il mazzo non è vuoto”) prima di invocare il metodo. Se draw() viene chiamato quando il mazzo è vuoto ci troviamo in una situazione di violazione di contratto e può anche esplodere la centrale nucleare.

Per specificare il contratto si possono utilizzare delle asserzioni o il @pre nei commenti. Le prime sono particolarmenti utili in fase di sviluppo perché interrompono l’esecuzione del programma in caso di violazione, ma vengono solitamente rimosse in favore delle seconde nella fase di deployment.

Un’altro approccio è la programmazione difensiva che al contrario delega la responsabilità del soddisfacimento delle precondizioni al chiamato, e non al chiamante.

Classi astratte

Una classe astratta che implementa un’interfaccia non deve necessariamente implementarne tutti i metodi, ma può delegarne l’implementazione alle sottoclassi impedendo l’istanziamento di oggetti del suo tipo.

Le interfacce diminuiscono leggermente le performance, però migliorano estremamente la generalità (che aiutano l’espandibilità ed evolvibilità del programma), quindi vale la pena di utilizzarle.

È possibile utilizzare le classi astratte anche per classi complete, ma che non ha senso che siano istanziate. Un buon esempio sono le classi utility della libreria standard di Java.

Classe utility della libreria standard di Java

Un esempio è Collections.shuffle(List<?> list) che accetta una lista omogenea di elementi e la mischia. Il tipo degli elementi è volutamente ignorato in quanto non è necessario conoscerlo per mischiarli.

Per l’ordinamento, invece, è necessario conoscere il tipo degli oggetti in quanto bisogna confrontarli tra loro per poterli ordinare. La responsabilità della comparazione è però delegata all’oggetto, che deve aderire all’interfaccia Comparable<T>.

Collections.sort(...) ha, infatti, la seguente signature:

public static <T extends Comparable<? super T>> void sort(List<T> list)

La notazione di generico aggiunge dei vincoli su T, ovvero il tipo degli elementi contenuti nella lista:

  • T extends Comparable<...> significa che T deve estendere - e quindi implementare - l’interfaccia Comparable<...>;
  • Comparable<? super T> significa che tale interfaccia può essere implementata su un antenato di T (o anche T stesso).

Comparable è un altro esempio di interface segregation: serve per specificare che un oggetto ha bisogno della caratteristica di essere comparabile.

Digressione: la classe Collections era l’unico modo per definire dei metodi sulle interfacce (es: dare la possibilità di avere dei metodi sulle collezioni, ovvero liste, mappe, ecc), ma ora si possono utilizzare i metodi di default.

Analisi del testo naturale

Come organizzare la partenza del design suddividendo in classi e responsabilità?

I due approcci principali sono:

  • pattern: riconoscere una situazione comune da una data;
  • TDD: partendo dalla soluzione più semplice si definiscono classi solo all’occorrenza.

Un’altra tecnica che vedremo è l’estrazione dei nomi (noun extraction), per un certo senso naive ma adatta in caso di storie complesse.

Noun extraction

Basandosi sulle specifiche - come i commenti esplicativi delle User Stories - si parte dai sostantivi (o frasi sostantivizzate), si sfolsticono con dei criteri, si cercano le relazioni tra loro e quindi si produce la gerarchia delle classi.

Per spiegare il procedimento considereremo il seguente esempio:

  • The library contains books and journals. It may have several copies of a given book. Some of the books are for short term loans only. All other books may be borrowed by any library member for three weeks.
  • Memebers of the library can normally borrow up to six items at a time, but members of staff may borrow up to 12 items at one time. Only member of staff may borrow journals.
  • The system must keep track of when books and journals are borrowed and returned, enforcing the rules described above.

Nell’esempio sopra sono stati evidenziati i sostantivi e le frasi sostantivizzate.

Criteri di sfoltimento

I criteri di sfoltimento servono per diminuire il numero di sostantivi considerando solo quelli rilevanti per risolvere il problema. In questa fase, in caso di dubbi è possibile rimandare la decisione a un momento successivo.

Di seguito ne sono riportati alcuni:

  • Ridondanza: sinonimi, termini diversi per indicare lo stesso concetto. Anche se è stata utilizzata una locuzione diversa potrebbe essere comunque ridondante, sopratutto in lingue diverse dall’inglese in cui ci sono molti sinonimi.
    Nell’esempio: library member e member of the library, loan e short term loan.
  • Vaghezza: nomi generici, comuni a qualunque specifica; potrebbero essere sintomo di una classe comune astratta.
    Nell’esempio: items.
  • Nomi di eventi e operazioni: nomi che indicano azioni e non hanno un concetto di stato.
    Nell’esempio: loan.
  • Metalinguaggio: parti statiche che fanno parte della logica del programma e che quindi non necessitano di essere rappresentati come classi.
    Nell’esempio: system, rules.
  • Esterne al sistema: concetti esterni o verità “assolute” al di fuori del controllo del programma.
    Esempio: library, week (una settimana ha 7 giorni).
  • Attributi: informazioni atomiche e primitive (stringhe, interi, …) relative a una classe, che quindi non necessitano la creazione di una classe di per sé.
    Esempio: name of the member (se ci fosse stato).

Al termine di questa fase, si avrà una lista di classi “certe” e “incerte”. In questo esempio, sono soppravvisuti i termini journal, book, copy (of book), library member e member of staff.

Relazioni tra classi

Il prossimo passo è definire le relazioni tra le classi.

Inizialmente, si collegano con delle linee (non frecce) senza specificare la direzione dell’associazione. Parliamo di associazioni e non attributi perché non è necessariamente vero che tutte le associazioni si trasformino in attributi.

Il prossimo passo è specificare le cardinalità delle relazioni, come specificato dal linguaggio UML (opposto in questo aspetto al diagramma ER). La precisione richiesta in questo punto è soggettiva: da una parte, specificare puntualmente il numero massimo di elementi di una associazione può aiutare ad ottimizzare successivamente, dall’altra porta confusione.

Dopo aver ragionato sulle cardinalità, si iniziano a cercare generalizzazioni e fattorizzazioni. In questo caso, notiamo che:

  • StaffMember è un LibraryMember con in più la possibilità di prendere Journal. Inoltre, un altro indicatore è che hanno la stesso tipo di relazioni con gli altri oggetti.
  • Items è un termine generico per indicare CopyOfBook e Journal.

Distinguere CopyOfBook e Journal è inutile, perché di fatto un Journal è una copia di un giornale. Si può quindi fattorizzare rimuovendo la generalizzazione, come mostrato di seguito.

È imporante però preoccuparsi delle cardinalità delle relazioni: è sì vero che un BorrowableItem può non essere una copia di un Book e di un Journal, ma deve essere copia di esattamente una delle due opzioni. UML prevede un linguaggio OCL (Object Constraint Language) per esprimere vincoli divesamente impossibili da esprimere in un diagramma. È anche possibile scrivere il constraint in linguaggio naturale come nota.