sabato 8 maggio 2021

Refactoring: improving Hierarchy

Gerarchia

La gerarchia è un principio che suggerisce la creazione di una organizzazione gerarchica di classi sulla base della loro generalità, classificazione, sostituibilità.
La gerarchia è il modo naturale con cui suddividiamo le entità anche nel mondo naturale. Nel software abbiamo due principali relazioni che si prestano alla istituzione di gerarchie: la relazione "is a" e la relazione "is part of". La relazione "is a" è di tipo strutturale ed è quella che nella programmazione ad oggetti viene implementata con l'ereditarietà. 
C'è da dire che il meccanismo di ereditarietà inizialmente concepito nei linguaggi OOP nel tempo ha perso un po' del suo appeal iniziale, ed è stato via via sostituito con la mera esposizione di interfacce, per via della migliore manutenibilità e possibilità di refactoring che queste offrono, tuttavia rimangono in molti ambiti in cui l'ereditarietà è un meccanismo perfettamente valido ed efficace.

Linee guida

La suddivisione in classi deve essere guidata da delle considerazioni di natura generale:
  • Identificare i comportamenti comuni (è il processo di generalizzazione) delle entità del sistema, ed identificare le eccezioni a questi. I comportamenti comuni possono essere collocati in classi base, le eccezioni nelle classi derivate, in una struttura gerarchica.
  • Assicurare il principio di Liskov, ossia accertarsi che una classe base possa essere sostituita dalle classi derivate senza modificare le funzioni che la utilizzano.
  • Non creare relazioni molto complicate tra le classi per non compromettere la comprensibilità complessiva della gerarchia

Problemi possibili

Presentiamo un elenco di situazioni che denotano una cattiva suddivisione gerarchica e come ovviare ove possibile.



Gerarchia mancante

Ci accorgiamo di questo problema ad esempio quando troviamo, nell'elaborazione degli elementi di una certa classe, di frequente dei costrutti case o delle condizioni (in modo sempre uguale sparso nel codice) per decidere quale comportamento adottare su essi. E' possibile invece derivare più classi da una classe base comune ed invocare un metodo specifico di questa classe, che sarà differenziato nelle classi derivate ed eviterà quei costrutti di selezione multipla ricorrenti.
In tal modo si avrà anche la possibilità di estendere la gerarchia di comportamenti aggiungendo nuove classi derivate e senza necessità di dover cercare e rivedere tutti quei costrutti di selezione.

Gerarchia inutile

E' un caso tipico di over-engineering, in cui si sono introdotte delle classi derivate, ma andando a indagare su come sono stati implementati i metodi, sono identici a quelli della classe base, magari perché questa è già sufficientemente elastica da gestire le varie situazioni. Oppure si aveva l'intenzione iniziale di differenziare prima o poi alcuni comportamenti ma poi non è stato fatto.
In generale non è bene fare proliferare le classi in questo modo, perché aumenta la quantità di codice da manutenere e da capire (e confrontare, per rendersi ogni volta conto che sono effettivamente identici), ed è bene potare completamente queste classi.


Metodi ripetuti nelle classi derivate

Se un comportamento è presente in una classe base, è bene richiamare, nelle classi derivate, i metodi della classe base e non ripeterne codice, non fosse altro che per avere meno codice da manutenere, oltre al canonico DRY (don't repeat yourself). 
Se ci sono comportamenti ripetuti nelle classi derivate e sono comuni a tutte le sottoclassi, questi comportamenti possono essere semplicemente spostati nella classe base
Se invece ci sono comportamenti comuni (e quindi codice ripetuto) a molte sottoclassi ma non tutte, potrebbe essere il caso di introdurre un livello intermedio ed avere una classe intermedia in cui collocare questo subset di comportamenti.
 

Gerarchia troppo vasta

Se una gerarchia presenta dei livelli con molti elementi, potrebbe essere un sintomo dell'assenza di un livello intermedio di astrazione, e/o di classi simili che sono state create perché non potevano essere derivate l'una dall'altra.
Il numero massimo consigliato del numero di classi in un certo livello (con la stessa classe base) è di 9 elementi, che deriva dalla regola aurea del 7+/- 2 applicata in molti frangenti in ingegneria del software.

A volte può capitare perché non si è effettuato un refactoring man mano che nuove classi sono state derivate, altre volte semplicemente non si è prestato attenzione ad identificare delle classi intermedie, magari perché si stava facendo un semplice porting da un altro software.


Gerarchia speculativa

Come spesso accade, scrivere codice sulla base di requisiti immaginari anziché reali porta di solito a vari problemi, oltre all'utilizzare del tempo per scrivere codice per ottenere funzioni che nessuno ha richiesto e che non saranno utilizzate anziché altre che sarebbero invece utili. 
La generalizzazione andrebbe operata ricercando comportamenti comuni e le differenze, e non per cercare speculativamente dei comportamenti che qualora saranno mai richiesti potrebbero differire.
Quando nel futuro saranno richiesti, saranno piuttosto analizzati ed implementati in base alla reali esigenze.

Gerarchia eccessivamente profonda

Una gerarchia di profondità di più di 6 livelli è in genere da considerarsi troppo profonda. Se ogni classe dovrebbe ereditare una buona parte dei propri comportamenti dalla propria classe base, e si hanno tanti livelli, si ha un problema, poiché la manutenzione e persino l'utilizzo diventa complicato.
Diventa difficile anche capire da quale livello è necessario una nuova classe in certi casi.
Potrebbe presentarsi essere perché si sono creati livelli intermedi speculativamente per poter derivare nuove classi, e in questo caso si rientra in un certo senso nel caso precedente della gerarchia speculativa, oppure ci si è preoccupati molto della riusabilità ma a scapito della comprensibilità e dell'usabilità.
In questo caso si può spesso collassare dei livelli per ridurre la profondità, o rimuovere del tutto alcune classi base ed implementarne le (presumibilmente poche) funzioni specifiche nelle classi derivate.

Gerarchia ribelle

In base a quanto abbiamo indicato nelle linee guida per creare le classi derivate, queste dovrebbero essere, secondo il principio di Liskov, sostituibili alla classe base in ogni occasione.
Ci si aspetta quindi che per ogni metodo della classe base, la classe derivata ne erediti il comportamento pari pari, o in alternativa ne fornisca uno alternativo, eventualmente richiamando nell'implementazione il metodo della classe base.
Se invece la classe derivata "rifiuta" di eseguire tali metodi dando un'eccezione, non facendo nulla, o esponendo una qualsiasi forma di "non aderenza" al comportamento atteso, abbiamo una violazione di tale principio.
Questo potrebbe succedere perché si è adottato una classe base con funzionalità troppo specifiche e indesiderate nelle classi derivate, ed in questo caso si potrebbe valutare di creare una classe intermedia per risolvere il problema.
Oppure potrebbe succedere perché la classe base funge da multi-utility  con molte funzioni che potrebbero essere utili nelle derivate, ma inesorabilmente poi diventano inapplicabili in alcune derivate.
La soluzione è spostare alcune di queste funzioni o spostare le funzioni "utility" in una classe apposita, sempre nel rispetto dei principi di "single responsibility".


Gerarchia simulata

Abbiamo visto la gerarchia di tipo strutturale dovrebbe rappresentare una relazione "is a" e in questo caso ci si aspetta che la classe derivata erediti una buona parte di comportamenti dalla classe base. Ma se si deriva da una classe base una classe che effettivamente non è relazionata come "is a" ma solo perché fortuitamente questa ha alcuni o molti dei comportamenti che si intende implementare, abbiamo una violazione dei principi gerarchici, e questo porta a codice incomprensibile e poco manutenibile.
Questa violazione può avvenire a tre livelli distinti di gravità:
  • tutti i metodi della classe base sono (fortuitamente) applicabili alla classe derivata, tuttavia non c'è alcuna garanzia che lo saranno anche altri che saranno eventualmente aggiunti in futuro non essendoci effettivamente una relazione logica "is a". 
  • alcuni metodi della classe base non sono logicamente applicabili alla classe derivata, ma questa li "accetta" passivamente per non violare il principio di Liskov. 
  • alcuni metodi della classe base non sono logicamente applicabili alla classe derivata, e questa li rigetta, ad esempio lanciando un'eccezione (come nel caso della gerarchia ribelle).
In questo caso sarebbe bene non usare quella classe base ma al limite una nuova con i comportamenti desiderati, oppure ancora utilizzare un design pattern diverso (come l'adapter, il composite o il bridge, di cui abbiamo parlato nella nota Structural patterns), ad esempio non derivando da alcuna classe e utilizzando la classe base creandone un'istanza privata.


Eredità multipath

Questo tipo di problema si presenta se una classe eredita da una classe base A più volte, poiché eredita da classi distinte che a loro volta, direttamente o indirettamente, ereditano da A. Questo può avvenire sia a livello di pura interfaccia o anche a livello di ereditarietà effettiva ove il linguaggio utilizzato consenta l'ereditarietà multipla di classe.

Questa situazione rende la classe ed il suo comportamento difficile da decifrare, cosi come la sua struttura in generale, e di solito è dovuto al fatto che si è ereditato da una classe di troppo, o si sono implementate troppe interfacce.
La soluzione è in genere rimuovere una delle classi base.


Ereditarietà ciclica

Questo problema si presenta se una classe dipende in qualsiasi modo da una sua classe derivata:
  • contiene un oggetto che ha come tipo una sua classe derivata
  • contiene un riferimento al nome di una sua classe derivata
  • accede ai membri  o metodi di una sua derivata
Se si considera che la suddivisione in una gerarchia di classi dovrebbe servire a suddividere la complessità e distribuire i comportamenti comuni, questo rappresenta senz'altro una distorsione dell'uso dell'ereditarietà.
La soluzione potrebbe stare nello smettere di derivare la classe derivata dalla classe base considerata, oppure fondere le due classi, o spostare qualche metodo dalla classe base alla derivata (quelli con i riferimenti in questione).
Invece  di usare l'ereditarietà può essere il caso di usare uno state pattern od uno strategy pattern (vedasi Behavioral patterns)




bibliografia
Refactoring for Software design smells,  Girish Suryanarayana, Ganesh Samarthyam, Tushar Sharma



Nessun commento:

Posta un commento

Refactoring: improving modularization

  Modularità In generale, per modularità si intende la misura in cui un sistema può essere decomposto e ricombinato. La modularizzazione è a...