Visualizzazione post con etichetta modularità. Mostra tutti i post
Visualizzazione post con etichetta modularità. Mostra tutti i post

lunedì 5 aprile 2021

Refactoring: improving abstraction

 Astrazione

L'astrazione consente di semplificare le entità attraverso la riduzione dei dettagli non rilevanti e la generalizzazione, operata individuandone le caratteristiche principali.
L'astrazione è cruciale nel problem solving per poter decomporre un sistema reale in componenti e stabilire le loro connessioni in modo da poterlo gestire più efficacemente.


I principi che dovrebbero guidare la progettazione e l'individuazione delle varie entità per comporre un modello di un sistema sono:
  • Identificare  e definire il contorno di un "oggetto"
  • Gli oggetti del dominio applicativo dovrebbero avere corrispondenza nel suo modello logico o concettuale. E' molto più facile parlare di oggetti che  hanno una corrispondenza nel mondo reale che di oggetti completamente astratti, pensiamo al caso in cui si voglia validare il modello con il cliente, o anche solo parlarne con un collega.
  • Ogni componente  del modello dovrebbe essere un oggetto "completo" di tutte le funzioni necessarie per operarvi. "Chiuso" potrebbe essere un altro aggettivo applicabile.
  • Ogni componente del modello dovrebbe avere un unico scopo e che abbia sufficiente complessità da garantirgli il diritto ad esistere
  • Non creare entità simili, ma gestire correttamente l'astrazione e la generalizzazione. Ad esempio non avrebbe senso sviluppare da zero una classe "lista di ordini" e "lista di fatture", ma può avere senso una classe "lista" generica su cui operare a prescindere dal contenuto, e poi in qualche modo ottenere le due classi specifiche a partire da quella

Vediamo allora dei possibili problemi che possiamo riscontrare in un sistema e come porvi rimedio.


Astrazione mancante

Un sintomo è la presenza di pezzi di codice simili in varie parti del programma al fine di ottenere gli stessi obiettivi. 
Le cause possono essere una progettazione incompleta, o manutenzioni di tipo quick-fix, o si è pensato di cercare di migliorare la velocità di esecuzione a scapito della modularità.

La soluzione consiste di solito nell'introdurre nuove classi, e sostituirle ai tipi semplici usati nel codice esistente, o in alternativa raggruppare strutture dati in classi e gestirle con i metodi della classe.
Questo refactoring favorirà la manutenibilità, l'estendibilità, la riusabilità e la testabilità, tuttavia bisogna stare attenti a non eccedere e creare classi per funzioni davvero troppo semplici (over-engineering)

Astrazione imperativa

Questo problema si evidenzia quando ci sono funzioni che sono state erroneamente trasformate in classi. Un sintomo è il nome della classe, di solito costituito da un verbo, come "leggiOrdini" o "stampaFatture". 
L'errore consiste nel fatto che la classe non rappresenta un entità del sistema, ma un'azione che si può compiere su essa. Può derivare da una cattiva traduzione da un programma di tipo procedurale ad uno ad oggetti, o una cattiva applicazione dell'OOP.
La soluzione può consistere nella creazione di un metodo ad una classe esistente, o la creazione di una nuova classe avente come oggetto i dati su cui operava la precedente classe, e magari altri metodi inerenti gli stessi dati.
Anche in questo caso il refactoring migliorerà la riusabilità (è molto meglio avere una classe che gestisce un'informazione "in toto" che non avere n classi per ogni operazione che si vuole svolgere su essa. Anche la leggibilità e la testabilità ne avranno beneficio, potendo collegare tali funzioni ai dati della classe e non a dati generici non strutturati o addirittura dislocati altrove.

Astrazione incompleta

Accade quando una classe non fornisce tutti i metodi necessari per operare sull'oggetto da essa gestito.
Il risultato è che per eseguire tali operazioni ci sono metodi esterni o codice duplicato sparso nel programma.
La causa potrebbe essere una progettazione incompleta che non ha previsto la realizzazione di una certa funzione, seguita da manutenzioni quick-fix operate su classi diverse.
Un indizio a cui prestare attenzione è la presenza di alcune funzioni, che di solito vanno in coppia, senza la presenza del proprio "simmetrico", come ad esempio la presenza di un min senza un max, o similmente per first/last, push/pop, left/right, open/close, insert/delete, start/stop e via dicendo.
La soluzione è certamente creare i metodi mancanti con attenzione ai parametri per poterli usare nel codice sostituendo lo spaghetti-code presente al momento.
Inutile dire che la manutenibilità ne avrà gran giovamento, così come la leggibilità e la testabilità.

Astrazione sovraccarica

Accade quando ad una classe sono addossate troppe responsabilità. E' un problema simile a quello che abbiamo già trovato quando abbiamo parlato di modularità (insufficiente in questo caso).
In questo caso abbiamo una ridotta manutenibilità e maggiore impatto delle modifiche della classe nei confronti del resto del sistema.
A volte si presenta quando si vuole gestire con la stessa classe dei  dati eterogenei, con metodi tuttavia simili. E' spesso complicato correggere tale situazione, perché per la sua natura è usata in molti punti. Il risultato è la mancanza di coesione e tutto quel che ne consegue.
Spesso la  soluzione è estrarre tante classi quante sono le diverse responsabilità che sono state in essa sovrapposte, ed usare questa nella classe stessa o direttamente nei client esistenti.

Astrazione inutilizzata

Si ha quando una classe è del tutto inutilizzata, ossia addirittura suo codice risulta irraggiungibile.
Può succedere se è stata progettata ma poi abbandonata in favore di altre classi, o era una classe astratta poi non seguita da alcuna implementazione concreta.
La progettazione non dovrebbe dare luogo a questo genere di classi, bensì dovrebbero essere sviluppate solo le classi che realmente sono necessarie. Cercare di prevedere futuri bisogni in maniera speculativa ed iperprudenziale tende a creare queste classi. In alternativa può capitare che in manutenzioni quick-fix siano state create nuove classi in sostituzione di quelle inutilizzate invece di manutenerle, per timore di creare malfunzionamenti sul codice esistente.
Tutto questo causa maggior lavoro di manutenzione (quando si analizza l'impatto di nuove modifiche) su codice che in realtà non è nemmeno utilizzato.
Di solito la soluzione è molto semplice: rimuovere del tutto queste classi, o iniziare ad usarle se per caso in qualche punto avrebbero dovuto essere usate ma ci si è dimenticati della loro esistenza e aggirato l'ostacolo con del codice collocato a macchia di leopardo in altre classi.


Astrazione non necessaria

Accade quando è introdotta una classe che non sarebbe stata necessaria, perché svolge un compito troppo semplice o quasi del tutto assente.
Può capitare se il codice deriva da traduzioni da altri linguaggi procedurali, e sono state introdotte classi che sostituiscono strutture dati, prive (o quasi) di metodi, oppure per sopperire a limitazioni del linguaggio, oppure come causa di over-engineering, una progettazione iperprotettiva.
In questi casi si può, a seconda, sopprimere la classe, spostarne i metodi in altre classi, o trovare i metodi per sopperire alle carenze di un linguaggio con altri costrutti del linguaggio ospitante.


Astrazione duplicata

Accade quando ci sono classi con lo stesso nome o con la stessa funzione. La duplicazione andrebbe evitata in ogni caso, perché crea problemi sia in fase di comprensione che di manutenzione e viola il principio DRY (Don't repeat yourself)
Possono essere generati da programmazione copia-incolla, da più rami di progetto sviluppati da team che non comunicavano tra loro, o necessità di derivare classi che però erano non derivabili.
La soluzione può essere rimuovere le copie e lasciare in vita solo una delle classi, ove possibile, oppure ove presenti piccole differenze, effettuare un subclassing da una classe base comune. In alternativa modificare i client per usare solo una delle classi esistenti e rimuovere le altre.
Se si trattava di non poter derivare una classe "chiusa" alle derivazioni, valutare se utilizzare una classe bridge




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

sabato 27 marzo 2021

Refactoring: improving modularization

 

Modularità

In generale, per modularità si intende la misura in cui un sistema può essere decomposto e ricombinato. La modularizzazione è applicata al fine di ridurre la complessità di un sistema decomponendolo in varie parti che presentano relazioni di indipendenza e di interdipendenza.

In questa sede parleremo di modularità riferendoci alla decomposizione in classi e non a "moduli" intesi come insiemi di classi.

La dipendenza tra classi diverse è identificata dall'accoppiamento tra di esse, mentre la qualità della decomposizione ha come buon indicatore la coesione della classe stessa.

Accoppiamento

L'accoppiamento misura la dipendenza tra due moduli, e idealmente deve essere minore possibile. Distinguiamo vari livelli di accoppiamento, che elenco dal migliore al peggiore:
  • nessun accoppiamento
  • per dati: lista di parametri passati costituiti da dati semplici, oppure un modulo usa un dato, semplice, prodotto da  un altro modulo
  • per struttura: nell'interfaccia di un metodo è presente una struttura dati, o un modulo usa una struttura prodotta da un altro modulo
  • per controllo: un modulo passa elementi di controllo (flag, ..) ad un altro modulo per condizionarne l'esecuzione
  • esterno: due moduli comunicano in modo strutturato attraverso dati comuni
  • comune: due moduli accedono a dati globali senza seguire un particolare protocollo
  • per contenuto: un modulo usa e modifica dati "privati" di un altro modulo

Coesione

Esprime il grado di correlazione tra gli elementi dello stesso modulo. Ogni modulo dovrebbe avere una alta coesione, e questo è raggiungibile se il modulo ha un unico compito, e lo esegue bene. 
Distinguiamo vari livelli di coesione, che elenco dalla peggiore alla migliore:
  • casuale: azioni o istruzioni completamente  scorrelate tra loro
  • logica: azioni correlate, ossia che trattano lo stesso argomento (I/O, trattamento errori, check..)
  • temporale: azioni che devono essere eseguite insieme (inizializzazioni, terminazioni..)
  • procedurale: azioni che devono essere eseguiti in un determinato ordine
  • comunicazionale: azioni che agiscono sugli stessi dati
  • sequenziale: azioni ognuna della quale dipende dal risultato della precedente
  • funzionale: modulo che svolge una sola attività e tutti gli elementi del modulo contribuiscono a realizzarla

Per approfondimenti su accoppiamento e coesione consiglio [1] e [2]

Avendo bene in mente questi obiettivi, ogni classe dovrebbe contenere un set di dati e le funzioni atte ad agire su quei dati, le classi dovrebbero essere di dimensioni non troppo grandi (sarebbe un segnale che stanno facendo troppe cose), non dovrebbero esserci dipendenze cicliche tra le classi e ogni classe non dovrebbe avere troppe dipendenze da altre classi. Denominati il fan-in ed il fan-out come le dipendenze in ingresso ed in uscita da una classe, questi dovrebbero in generale essere compresi in 5-9.
Il fan-out dovrebbe essere maggiore per i livelli logici più elevati, perché utilizzano servizi, mentre il fan in dovrebbe essere maggiore per quelli più bassi poiché sono quelli che forniscono servizi.


Vediamo i principali problemi che possono presentarsi con la modularizzazione.


Errata  decomposizione

Questo accade quando funzioni che logicamente avrebbero dovuto essere nella stessa classe, si trovano sparse in diversi punti, oppure ci sono classi con pochi metodi (perché gestiti da funzioni esterne alla classe), oppure ci sono metodi che agiscono su dati di classi esterne (altra faccia della stessa medaglia).
In questo caso abbiamo un cattivo accoppiamento ed una bassa coesione.

Soluzioni

  • se ci sono metodi che agiscono prevalentemente su dati di altre classi, valutare se spostare tali metodi (o parte di essi) nelle classi opportune in modo da ripristinare una buona coesione e correggere l'accoppiamento
  • Se ci sono classi "data" senza metodi o con pochi metodi, creare in esse la giusta interfaccia affinché le altre classi "client" possano agire sui dati di queste classi "data" senza accedere direttamente alle strutture
  • se un metodo di una classe B che non accede ai dati della classe B è più usato in una classe A che nella classe B stessa, valutare se spostarlo nella classe A, sempre se l'operazione ha senso e migliora la coesione e l'accoppiamento.
  • se un campo di B è più usato da una classe A che non nella classe B stessa (situazione direi abbastanza tragica), valutare se spostare tale campo nella classe A, , sempre se l'operazione ha senso e migliora la coesione e l'accoppiamento.
  • rendere privati i dati su cui è possibile operare tramite metodi
  • spostare campi non strettamente collegati alla funzione (astrazione) implementata dalla classe andrebbero spostati in altre classi

Insufficiente modularità

Questo accade quando una funzione non è stata completamente decomposta, e ci sarebbe bisogno di un'ulteriore decomposizione ai fini di ridurne la complessità e/o la dimensione. 
Sintomi possono essere:
  •  un numero elevato di membri e metodi nell'interfaccia, e questo può essere il sintomo che questa classe "fa troppe cose". Questo caso può nascere anche se più metodi sono stati raggruppati perché agivano sugli stessi dati, ma non sempre è una buona idea, infatti si migliora l'accoppiamento ma può peggiorare la coesione, senza tenere conto del fatto che una classe dovrebbe seguire il principio di "single responsibility"
  • un numero molto elevato di metodi nell'implementazione, e questo può essere il sintomo che a sua volta poteva essere decomposta in classi più semplici. 

Soluzioni

Se una classe ha troppi metodi o metodi molto complessi, una soluzione è decomporla ulteriormente incapsulando in un'altra classe parte delle sue funzionalità, sempre con  in mente l'obiettivo di non comprometterne la coesione. 
Se ci sono "isole" di metodi che si riferiscono a insiemi di dati diversi, separare tali isole in classi diverse.
Se ci sono gruppi di (molti) metodi che hanno scopi diversi, valutare se suddividere tali metodi in classi diverse con uno scopo più specifico, eventualmente esponenti un'interfaccia comune.

Se ci sono metodi molto complessi, valutare anche la creazione di classi private "helper" per semplificare la classe principale. 


Dipendenze cicliche

Succede quando tra due o più moduli si instaura una dipendenza circolare. Questo è un errore perché rende difficile comprendere la struttura complessiva, e anche il testing. Inoltre da un punto di vista logico risulta difficile capire chi dipende da chi.
Può accadere per vari motivi:
  • perché ci sono metodi collocati in classi sbagliate
  • ci sono metodi in cui viene passato "this" come parametro e il metodo chiamato agisce sui metodi del chiamante
  • ci sono funzioni di callback non necessarie

Soluzioni

In questi casi è possibile, a seconda dei casi:
  • introdurre nuove classi o interfacce 
  • spostare il metodo che crea la dipendenza circolare nella classe che esso richiama
  • unire due classi se in effetti implementano la stessa funzione


Moduli "hub"

Succede quando una classe ha un fan-in ed un fan-out elevato.
Abbiamo già visto che per i moduli di livello elevato ci aspettiamo un fan out maggiore mentre per quelli di livello più basso un maggiore fan in. 
Un simultaneo fan-in e fan-out elevato è senz'altro un sintomo che la classe sta facendo troppe cose, e, a meno che non si tratti  di una classe facente parte di un framework (come un mediator) questo non va bene, poiché diventa impossibile da cambiare senza che cambino i suoi utilizzatori, e viceversa diventa molto passibile di cambiamenti quando cambiano le classi da cui dipende.

Soluzioni

Certamente in questi casi si può cercare di suddividere la classe in classi diverse ed il più possibile indipendenti tra loro.
Se le dipendenze sono dovute a funzioni mal collocate, si può agire spostando i metodi dove sono chiamati, analogamente per l'accesso ai membri di altre classi (orrore)
Se il fan in è elevato, a volte si può ovviare utilizzando il pattern chain of responsibility in modo che ogni funzione deleghi ad una (sola) altra l'elaborazione qualora non la svolga lei stessa.




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

Refactoring: improving modularization

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