Introduzione
SOLID è un acronimo mnemonico per cinque principi di progettazione orientata agli oggetti che sono stati introdotti da Robert C. Martin, popolarmente noto come Uncle Bob. Questi principi hanno lo scopo di aiutare progettisti, architetti, ingegneri e sviluppatori di software a creare software più flessibile, manutenibile e scalabile. Seguendo questi principi, è possibile progettare classi più facili da testare, sottoporre a refactoring, riutilizzare ed estendere.
L'acronimo SOLID sta per:
S – Principio di singola responsabilità
O – Principio di apertura-chiusura
L – Principio di sostituzione di Liskov
I – Principio di segregazione delle interfacce
D – Principio di inversione delle dipendenze
In questo articolo spiegheremo ogni principio individualmente per capire come può aiutarti a scrivere codice migliore. Inoltre, aggiungeremo frammenti di codice per ciascun principio per mostrarti come applicarli nel tuo percorso di programmazione, oltre a cosa dovresti evitare nell'architettura del codice pulito. Per dimostrare i concetti, utilizzeremo il linguaggio di programmazione Kotlin sviluppato da JetBrains e dai contributori open-source.
Principio di singola responsabilità
Il Principio di singola responsabilità (SRP) è un principio di progettazione software che stabilisce che ogni classe o modulo in un programma dovrebbe avere una singola responsabilità ben definita. Ciò significa che una classe dovrebbe avere un solo motivo per cambiare e dovrebbe essere responsabile di una sola parte delle funzionalità del programma.
Considera il seguente frammento di codice come esempio di come questo principio possa essere applicato in Kotlin:
|
1 2 3 4 5 6 7 8 9 |
data class User( val firstName: String, val lastName: String, val email: String ) |
La data keyword in Kotlin indica che questa classe è una data class, il che significa che è destinata a contenere dati e non ha alcun comportamento complesso. Utilizzando questa data class, possiamo creare una classe UserService per la gestione degli utenti come mostrato di seguito:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // codice per creare un utente nel database } fun deleteUser(user: User) { // codice per eliminare un utente dal database } fun updateUser(user: User) { // codice per aggiornare un utente nel database } fun getUser(id: Int): User { // codice per recuperare un utente dal database } } |
In questo esempio, la classe UserService ha una singola responsabilità: gestire gli utenti in un database. Ogni utente è rappresentato dal codice data class User condiviso in prevenzione. Tutti i metodi nella classe UserService sono correlati a questa responsabilità e sono quindi coesi. Ciò rende la classe più facile da comprendere e mantenere, poiché è chiaro che tutti i metodi al suo interno sono correlati a un unico compito ben definito.
Un metodo che viola il Principio di singola responsabilità (SRP) nella classe UserService sarebbe uno non correlato alla responsabilità primaria della classe, ovvero la gestione degli utenti in un database. Ad esempio, considera la seguente variante della classe UserService:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // codice per creare un utente nel database } fun deleteUser(user: User) { // codice per eliminare un utente dal database } fun updateUser(user: User) { // codice per aggiornare un utente nel database } fun getUser(id: Int): User { // codice per recuperare un utente dal database } fun sendEmail(user: User, subject: String, message: String) { // codice per inviare un'email all'utente } } |
In questo esempio, il sendEmail metodo non si riferisce alla responsabilità primaria della classe UserService, che consiste nel gestire gli utenti in un database. Questo metodo è responsabile dell'invio di email, che è una preoccupazione separata rispetto alla gestione degli utenti. Di conseguenza, questo metodo viola l'SRP, poiché introduce un secondo motivo di modifica per la classe UserService.
Per aderire al Single Responsibility Principle, sarebbe meglio separare la funzionalità di invio email in una classe distinta, come una classe EmailService . Ciò consentirebbe alla classe UserService di concentrarsi sulla sua responsabilità primaria di gestire gli utenti, e alla classe EmailService di concentrarsi sulla sua responsabilità di inviare email.
Si noti che il Single Responsibility Principle non riguarda il numero di metodi di una classe, ma piuttosto la coesione dei metodi e la chiara separazione delle responsabilità all'interno di una classe.
Open-Closed Principle
L'Open-Closed Principle (OCP) è un principio di progettazione software che stabilisce che le entità software (come classi, moduli o funzioni) dovrebbero essere aperte all'estensione, ma chiuse alle modifiche. Ciò significa che dovrebbe essere possibile aggiungere nuove funzionalità a una classe o a un modulo senza modificarne il codice esistente.
Considera la classe UserService dell'esempio precedente. Supponiamo di voler aggiungere una nuova funzionalità alla classe UserService che ci consenta di cercare gli utenti per indirizzo email. Un modo per farlo sarebbe aggiungere un nuovo metodo alla classe UserService come evidenziato nel codice qui sotto:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class UserService { fun createUser(user: User) { // codice per creare un utente nel database } fun deleteUser(user: User) { // codice per eliminare un utente dal database } fun updateUser(user: User) { // codice per aggiornare un utente nel database } fun getUser(id: Int): User { // codice per recuperare un utente dal database } fun searchUsersByEmail(email: String): List<User> { // codice per cercare gli utenti per indirizzo email } } |
Questo approccio funziona, ma viola l'Open-Closed Principle, poiché abbiamo dovuto modificare la classe esistente UserService per aggiungere la nuova funzionalità. Un approccio migliore consisterebbe nell'utilizzare l'ereditarietà o la composizione per estendere le funzionalità della classe UserService senza modificarne il codice.
Per ottenere questo risultato, potremmo creare una nuova classe chiamata UserSearchService che estende la classe UserService e aggiunge la funzionalità di ricerca per email:
|
1 2 3 4 5 6 7 8 9 |
class UserSearchService(private val userService: UserService) : UserService() { fun searchUsersByEmail(email: String): List<User> { // codice per cercare gli utenti per indirizzo email } } |
In questo esempio, la classe UserSearchService è aperta all'estensione, poiché fornisce funzionalità aggiuntive rispetto a quelle offerte dalla classe UserService classe. Allo stesso tempo, la classe UserService rimane chiusa alle modifiche, in quanto non abbiamo avuto bisogno di modificare il suo codice per aggiungere la funzionalità di ricerca via email.
Principio di Sostituzione di Liskov
Il Principio di Sostituzione di Liskov (LSP) è un principio di progettazione software che stabilisce che gli oggetti di una superclasse dovrebbero poter essere sostituiti con oggetti di una sottoclasse senza influire sulla correttezza del programma. Ciò significa che una sottoclasse dovrebbe essere un valido sostituto per la sua superclasse e dovrebbe comportarsi allo stesso modo della superclasse quando utilizzata nello stesso contesto.
Continueremo la dimostrazione utilizzando le classi User e UserService dei precedenti esempi. Per consentire alla classe User di essere estesa, utilizziamo la open parola chiave in Kotlin:
|
1 2 3 4 5 6 7 8 9 |
open class User( val firstName: String, val lastName: String, val email: String ) |
Ecco la classe originale UserService che utilizza la data class User di cui sopra:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class UserService { fun createUser(user: User) { // codice per creare un utente nel database } fun deleteUser(user: User) { // codice per eliminare un utente dal database } fun updateUser(user: User) { // codice per aggiornare un utente nel database } fun getUser(id: Int): User { // codice per recuperare un utente dal database } } |
Supponiamo di voler creare una sottoclasse di User chiamata AdminUser che rappresenta gli utenti con privilegi amministrativi. Potremmo farlo in questo modo:
|
1 2 3 4 5 6 7 8 9 |
class AdminUser( firstName: String, lastName: String, email: String, ) : User(firstName, lastName, email) |
In questo esempio, la classe AdminUser è un valido sostituto per la classe User , poiché si comporta allo stesso modo della classe User e può essere utilizzata ovunque sia previsto un oggetto User. Ad esempio, possiamo utilizzare la classe AdminUser con la classe UserService in questo modo:
|
1 2 3 4 5 6 7 8 9 |
fun main() { val userService = UserService() val adminUser = AdminUser("John", "Doe", "john.doe@example.com") userService.createUser(adminUser) } |
Questo codice è corretto, poiché la classe AdminUser è un valido sostituto per la classe User e può essere utilizzata allo stesso modo di un oggetto User.
È importante notare che il Principio di Sostituzione di Liskov va oltre la semplice ereditarietà. Si tratta di garantire che gli oggetti di una sottoclasse si comportino allo stesso modo degli oggetti della superclasse, indipendentemente da come la sottoclasse è implementata. Ad esempio, se la classe AdminUser dovesse comportarsi diversamente dalla classe User in qualche modo, violerebbe il Principio di Sostituzione di Liskov, poiché non sarebbe un valido sostituto per la classe User.
Principio di Segregazione delle Interfacce
Il Principio di Segregazione delle Interfacce (ISP) è un principio di progettazione software che stabilisce che i client non dovrebbero essere forzati a dipendere da interfacce che non utilizzano. Ciò significa che in genere è una buona idea creare piccole interfacce mirate che fanno bene una sola cosa, piuttosto che creare grandi interfacce di uso generale che cercano di fare molte cose.
Ecco un esempio di come questo principio possa essere applicato riscrivendo le classi User e UserService dei precedenti esempi:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User } class DatabaseUserService : UserService { override fun createUser(user: User) { // codice per creare un utente nel database } override fun deleteUser(user: User) { // codice per eliminare un utente dal database } override fun updateUser(user: User) { // codice per aggiornare un utente nel database } override fun getUser(id: Int): User { // codice per recuperare un utente dal database } } |
In questo esempio, l'interfaccia UserService definisce quattro metodi relativi alla gestione degli utenti in un database. La classe DatabaseUserService implementa questa interfaccia e fornisce implementazioni concrete per questi metodi.
Supponiamo di voler aggiungere una nuova funzionalità all'interfaccia UserService che ci consenta di cercare gli utenti per indirizzo email. Un modo per farlo sarebbe aggiungere un nuovo metodo all'interfaccia UserService:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
interface UserService { fun createUser(user: User) fun deleteUser(user: User) fun updateUser(user: User) fun getUser(id: Int): User fun searchUsersByEmail(email: String): List<User> } |
Il codice non funzionerà, a meno che non si implementi questo metodo anche in tutte le classi che implementano l'interfaccia UserService:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService { override fun createUser(user: User) { // codice per creare un utente nel database } override fun deleteUser(user: User) { // codice per eliminare un utente dal database } override fun updateUser(user: User) { // codice per aggiornare un utente nel database } override fun getUser(id: Int): User { // codice per recuperare un utente dal database } override fun searchUsersByEmail(email: String): List<User> { // codice per cercare gli utenti tramite indirizzo email } } |
Sebbene questo approccio funzioni, viola l'Interface Segregation Principle, poiché costringe la classe DatabaseUserService ad implementare un metodo ( searchUsersByEmail ) di cui potrebbe non aver bisogno o che potrebbe non utilizzare.
Un approccio migliore sarebbe quello di creare una interfaccia separata per la funzionalità di ricerca email:
|
1 2 3 4 5 |
interface UserSearchService { fun searchUsersByEmail(email: String): List<User> } |
Ora abbiamo interfacce separate, piccole e mirate, ovvero UserService e UserSearchServiceche hanno una singola responsabilità. Una classe che richiede tutte le funzionalità di queste interfacce può implementarle come mostrato nello snippet di codice qui sotto:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
class DatabaseUserService : UserService, UserSearchService { override fun createUser(user: User) { // codice per creare un utente nel database } override fun deleteUser(user: User) { // codice per eliminare un utente dal database } override fun updateUser(user: User) { // codice per aggiornare un utente nel database } override fun getUser(id: Int): User { // codice per recuperare un utente dal database } override fun searchUsersByEmail(email: String): List<User> { // codice per cercare utenti tramite indirizzo email } } |
Questo rispetta il principio di segregazione delle interfacce, in quanto garantisce che i client (come la DatabaseUserService classe) non siano costretti a dipendere da interfacce che non utilizzano.
Per comprendere meglio questo concetto, supponiamo di avere un'altra classe chiamata MemoryUserService che implementa l'interfaccia UserService ma non ha bisogno della funzionalità di ricerca email, possiamo scrivere il codice in questo modo:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserService : UserService { private val users = mutableListOf<User>() override fun createUser(user: User) { users.add(user) } override fun deleteUser(user: User) { users.remove(user) } override fun updateUser(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun getUser(id: Int): User { return users[id] } } |
In questo esempio, la classe MemoryUserService deve solo implementare i metodi definiti nell'interfaccia UserService, e non deve preoccuparsi della funzionalità di ricerca email. Questo consente alla classe MemoryUserService di concentrarsi sulla sua responsabilità primaria di gestire gli utenti in memoria, invece di essere costretta a implementare funzionalità non correlate.
Principio di inversione delle dipendenze
Il principio di inversione delle dipendenze (DIP) è un principio di progettazione software che stabilisce che i moduli di alto livello non dovrebbero dipendere da moduli di basso livello, ma entrambi dovrebbero dipendere da astrazioni. Ciò significa che in genere è una buona idea progettare il software in modo tale che i componenti di alto livello non siano legati a implementazioni specifiche di componenti di basso livello, ma dipendano piuttosto da astrazioni (come interfacce o classi astratte) che possono essere implementate in modi diversi.
Vediamo un esempio di come questo principio possa essere applicato alle classi User e UserService utilizzate negli snippet di codice precedenti:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
interface UserRepository { fun create(user: User) fun delete(user: User) fun update(user: User) fun get(id: Int): User } class UserService(private val repository: UserRepository) { fun createUser(user: User) { repository.create(user) } fun deleteUser(user: User) { repository.delete(user) } fun updateUser(user: User) { repository.update(user) } fun getUser(id: Int): User { return repository.get(id) } } |
In questo esempio, la classe UserService dipende da un'astrazione chiamata interfaccia UserRepository, invece di dipendere da un'implementazione specifica di un repository utente. Questo ci consente di implementare l'interfaccia UserRepository in modi diversi, ad esempio con un database o un'archiviazione in memoria, senza influire sulla classe UserService.
Ad esempio, ecco un'implementazione dell'interfaccia UserRepository che utilizza un database:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
class DatabaseUserRepository: UserRepository { override fun create(user: User) { // codice per creare un utente nel database } override fun delete(user: User) { // codice per eliminare un utente dal database } override fun update(user: User) { // codice per aggiornare un utente nel database } override fun get(id: Int): User { // codice per recuperare un utente dal database } } |
Ecco un'altra implementazione dell'interfaccia UserRepository che utilizza l'archiviazione in memoria:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
class MemoryUserRepository : UserRepository { private val users = mutableListOf<User>() override fun create(user: User) { users.add(user) } override fun delete(user: User) { users.remove(user) } override fun update(user: User) { val index = users.indexOf(user) if (index >= 0) { users[index] = user } } override fun get(id: Int): User { return users[id] } } |
Questo rende il sistema più flessibile e manutenibile, in quanto ci consente di modificare l'implementazione del repository senza influire sul resto del sistema. Inoltre, rende più semplice testare la classe UserService, poiché possiamo simulare la dipendenza UserRepository nei nostri test.
Conclusione
In questo articolo abbiamo parlato dei cinque principi del SOLID codice e frammenti di codice condivisi che soddisfano ciascun principio. L’adesione a questi principi può aiutarti a progettare sistemi software più flessibili, manutenibili e scalabili. Tuttavia, è importante tenere presente che questi principi sono linee guida, piuttosto che regole rigide e assolute, e spetta allo sviluppatore decidere quando e come applicarli nel contesto del proprio progetto specifico. Continua ad apprendere dando un’occhiata al nostro blog per articoli più approfonditi e aggiornati e tutorial su cloud computing e DevOps, progettazione e sviluppo software, tendenze tecnologiche da tenere d’occhio e altro ancora.
Buona programmazione!
Commenti
Ancora nessun commento. Scrivi il primo.