SOLIDO Parte 2 - Il principio aperto / chiuso

Single Responsibility (SRP), Open / Closed (OCP), Liskov's Substitution, Interface Segregation e Dependency Inversion. Cinque principi agili che dovrebbero guidarti ogni volta che devi scrivere codice.

Definizione

Le entità software (classi, moduli, funzioni, ecc.) Dovrebbero essere aperte per l'estensione, ma chiuse per la modifica.

Il Principio Aperto / Chiuso, in breve, OCP è accreditato a Bertrand Mayer, un programmatore francese, che lo pubblicò per la prima volta nel suo libro n Costruzione di software orientato agli oggetti nel 1988.

Il principio aumentò di popolarità nei primi anni 2000 quando divenne uno dei principi SOLID definiti da Robert C. Martin nel suo libro Agile Software Development, Principles, Patterns and Practices e successivamente ripubblicato nella versione C # del libro Agile Principles, Patterns e pratiche in C #.

Ciò di cui stiamo parlando fondamentalmente qui è progettare i nostri moduli, classi e funzioni in modo tale che quando una nuova funzionalità è necessaria, non dovremmo modificare il nostro codice esistente, ma piuttosto scrivere un nuovo codice che verrà utilizzato dal codice esistente. Questo suona un po 'strano, specialmente se stiamo lavorando in linguaggi come Java, C, C ++ o C # dove si applica non solo al codice sorgente stesso ma anche al binario. Vogliamo creare nuove funzionalità in modi che non ci impongano di ridistribuire binari, eseguibili o DLL esistenti.

OCP nel contesto SOLID

Man mano che progrediamo con questi tutorial, possiamo mettere ogni nuovo principio nel contesto di quelli già discussi. Abbiamo già discusso della Single Responsibility (SRP) che afferma che un modulo dovrebbe avere solo una ragione per cambiare. Se pensiamo a OCP e SRP, possiamo osservare che sono complementari. Il codice progettato in modo specifico con SRP è vicino ai principi OCP o è facile far rispettare questi principi. Quando abbiamo un codice che ha un solo motivo per cambiare, l'introduzione di una nuova funzionalità creerà un motivo secondario per tale cambiamento. Quindi sia SRP che OCP verrebbero violati. Allo stesso modo, se abbiamo un codice che dovrebbe cambiare solo quando la sua funzione principale cambia e dovrebbe rimanere invariato quando una nuova funzione viene aggiunta ad esso, rispettando così l'OCP, si rispetterà per lo più anche SRP.

Ciò non significa che SRP porti sempre a OCP o viceversa, ma nella maggior parte dei casi se uno di essi viene rispettato, il raggiungimento del secondo è abbastanza semplice.

L'ovvio esempio di violazione OCP

Da un punto di vista puramente tecnico, il principio Open / Closed è molto semplice. Una semplice relazione tra due classi, come quella qui sotto, viola l'OCP.


Il Utente la classe usa il Logica classe direttamente. Se dobbiamo implementare un secondo Logica classe in un modo che ci consentirà di utilizzare sia l'attuale che il nuovo, l'esistente Logica la classe dovrà essere cambiata. Utente è direttamente legato all'implementazione di Logica, non c'è modo per noi di fornire un nuovo Logica senza influenzare quello attuale. E quando parliamo di lingue tipizzate staticamente, è molto probabile che il Utente la classe richiederà anche delle modifiche. Se stiamo parlando di lingue compilate, sicuramente entrambe le Utente eseguibile e il Logica la libreria eseguibile o dinamica richiederà la ricompilazione e la ridistribuzione ai nostri clienti, un processo che vogliamo evitare quando possibile.

Mostrami il codice

Basandosi solo sullo schema precedente, si può dedurre che qualsiasi classe che utilizza direttamente un'altra classe violerebbe effettivamente il Principio Aperto / Chiuso. E questo è giusto, in senso stretto. Ho trovato molto interessante trovare i limiti, il momento in cui si disegna la linea e si decide che è più difficile rispettare l'OCP di modificare il codice esistente, o il costo architettonico non giustifica il costo della modifica del codice esistente.

Diciamo che vogliamo scrivere una classe in grado di fornire progressi come percentuale per un file scaricato tramite la nostra applicazione. Avremo due classi principali, a Progresso e a File, e immagino che vorremmo usarli come nel test qui sotto.

function testItCanGetTheProgressOfAFileAsAPercent () $ file = new File (); $ file-> lunghezza = 200; $ file-> inviati = 100; $ progress = new Progress ($ file); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

In questo test siamo utenti di Progresso. Vogliamo ottenere un valore in percentuale, indipendentemente dalle dimensioni effettive del file. Noi usiamo File come fonte di informazione per il nostro Progresso. Un file ha una lunghezza in byte e un campo chiamato inviato che rappresenta la quantità di dati inviati a chi esegue il download. Non ci interessa come questi valori vengono aggiornati nell'applicazione. Possiamo supporre che ci sia qualche logica magica che lo fa per noi, quindi in un test possiamo impostarli esplicitamente.

file di classe public $ length; pubblico $ inviato; 

Il File class è solo un semplice oggetto dati contenente i due campi. Naturalmente nella vita reale, probabilmente conterrebbe anche altre informazioni e comportamenti, come il nome del file, il percorso, il percorso relativo, la directory corrente, il tipo, i permessi e così via.

progresso della classe file $ privato; function __construct (File $ file) $ this-> file = $ file;  function getAsPercent () return $ this-> file-> inviato * 100 / $ this-> file-> lunghezza; 

Progresso è semplicemente una classe che prende un File nel suo costruttore. Per chiarezza, abbiamo specificato il tipo di variabile nei parametri del costruttore. C'è un unico metodo utile su Progresso, getAsPercent (), che prenderà i valori inviati e la lunghezza da File e trasformarli in una percentuale. Semplice e funziona.

I test sono iniziati alle 17:39 ... PHPUnit 3.7.28 di Sebastian Bergmann ... Tempo: 15 ms, Memoria: 2.50 Mb OK (1 test, 1 assertion)

Questo codice sembra essere corretto, tuttavia viola il Principio Aperto / Chiuso. Ma perché? E come?

Modifica dei requisiti

Ogni applicazione che dovrebbe evolvere nel tempo avrà bisogno di nuove funzionalità. Una nuova funzionalità per la nostra applicazione potrebbe essere quella di consentire lo streaming di musica, invece di scaricare solo i file. FileLa lunghezza è rappresentata in byte, la durata della musica in secondi. Vogliamo offrire una gradevole barra di avanzamento ai nostri ascoltatori, ma possiamo riutilizzare quello che già abbiamo?

No, non possiamo. Il nostro progresso è destinato a File. Comprende solo i file, anche se potrebbe essere applicato anche ai contenuti musicali. Ma per farlo dobbiamo modificarlo, dobbiamo farlo Progresso conoscere Musica e File. Se il nostro design rispetterebbe l'OCP, non avremmo bisogno di toccare File o Progresso. Potremmo semplicemente riutilizzare l'esistente Progresso e applicarlo a Musica.

Soluzione 1: sfruttare la natura dinamica di PHP

Le lingue digitate dinamicamente hanno il vantaggio di indovinare i tipi di oggetti in fase di esecuzione. Questo ci permette di rimuovere il tipo da Progresso'costruttore e il codice funzionerà ancora.

progresso della classe file $ privato; function __construct ($ file) $ this-> file = $ file;  function getAsPercent () return $ this-> file-> inviato * 100 / $ this-> file-> lunghezza; 

Ora possiamo lanciare qualsiasi cosa Progresso. E per niente, intendo letteralmente qualsiasi cosa:

class Music public $ length; pubblico $ inviato; artista $ pubblico; album $ pubblico; public $ releaseDate; function getAlbumCoverFile () return 'Images / Covers /'. $ questo-> artista. '/'. $ questo-> album. '.Png'; 

E a Musica la classe come quella sopra funzionerà perfettamente. Possiamo testarlo facilmente con un test molto simile a File.

function testItCanGetTheProgressOfAMusicStreamAsAPercent () $ music = new Music (); $ music-> length = 200; $ music-> sent = 100; $ progress = new Progress ($ music); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Quindi, in sostanza, qualsiasi contenuto misurabile può essere usato con il Progresso classe. Forse dovremmo esprimerlo nel codice cambiando anche il nome della variabile:

Progresso di classe $ privato misurabile; funzione __construct ($ measurableContent) $ this-> measurableContent = $ measurableContent;  function getAsPercent () return $ this-> measurableContent-> inviato * 100 / $ this-> measurableContent-> length; 

Bene, ma abbiamo un grosso problema con questo approccio. Quando abbiamo avuto File specificato come typehint, eravamo positivi su ciò che la nostra classe può gestire. Era esplicito e se qualcos'altro è arrivato, un bel errore ci ha detto così.

L'argomento 1 passato a Progress :: __ construct () deve essere un'istanza di File, istanza di Music fornita.

Ma senza il tipo, dobbiamo fare affidamento sul fatto che qualsiasi cosa entri avrà due variabili pubbliche di alcuni nomi esatti come "lunghezza" e "inviato"Altrimenti avremo un lascito rifiutato.

Permesso rifiutato: una classe che sovrascrive un metodo di una classe base in modo tale che il contratto della classe base non sia onorato dalla classe derivata. ~ Fonte Wikipedia.

Questo è uno dei odori di codice presentato in modo molto più dettagliato nel corso premium Detecting Code Smells. In breve, non vogliamo finire per cercare di chiamare metodi o campi di accesso su oggetti che non sono conformi al nostro contratto. Quando abbiamo avuto un typehint, il contratto è stato specificato da esso. I campi e i metodi del File classe. Ora che non abbiamo nulla, possiamo inviare qualsiasi cosa, anche una stringa, e ciò comporterebbe un brutto errore.

function testItFailsWithAParameterThatDoesNotRespectTheImplicitContract () $ progress = new Progress ('some string'); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Un test come questo, in cui inviamo una stringa semplice, produrrà un lascito rifiutato:

Cercando di ottenere la proprietà di non oggetto.

Mentre il risultato finale è lo stesso in entrambi i casi, il che significa che il codice si rompe, il primo ha prodotto un bel messaggio. Questo, tuttavia, è molto oscuro. Non c'è modo di sapere quale sia la variabile - una stringa nel nostro caso - e quali proprietà sono state cercate e non trovate. È difficile eseguire il debug e risolvere il problema. Un programmatore deve aprire il Progresso classe e leggerlo e capirlo. Il contratto, in questo caso, quando non specificiamo esplicitamente il tipo hint, è definito dal comportamento di Progresso. È un contratto implicito, noto solo a Progresso. Nel nostro esempio, è definito dall'accesso ai due campi, inviato e lunghezza, nel getAsPercent () metodo. Nella vita reale il contratto implicito può essere molto complesso e difficile da scoprire semplicemente cercando pochi secondi nella classe.

Questa soluzione è raccomandata solo se nessuno degli altri suggerimenti seguenti può essere facilmente implementato o se infliggere seri cambiamenti architettonici che non giustificano lo sforzo.

Soluzione 2: utilizzare il modello di progettazione della strategia

Questa è la soluzione più comune e probabilmente la più appropriata per rispettare l'OCP. È semplice ed efficace.


Il modello strategico introduce semplicemente l'uso di un'interfaccia. Un'interfaccia è un tipo speciale di entità in Object Oriented Programming (OOP) che definisce un contratto tra un client e una classe server. Entrambe le classi aderiranno al contratto per garantire il comportamento previsto. Potrebbero esserci diverse classi di server non correlate che rispettano lo stesso contratto e sono quindi in grado di servire la stessa classe di clienti.

interfaccia Measurable function getLength (); funzione getSent (); 

In un'interfaccia possiamo definire solo il comportamento. Ecco perché invece di utilizzare direttamente variabili pubbliche dovremo pensare all'utilizzo di getter e setter. Adattare le altre classi non sarà difficile a questo punto. Il nostro IDE può fare la maggior parte del lavoro.

function testItCanGetTheProgressOfAFileAsAPercent () $ file = new File (); $ File-> SetLength (200); $ File-> setSent (100); $ progress = new Progress ($ file); $ this-> assertEquals (50, $ progress-> getAsPercent ()); 

Come al solito, iniziamo con i nostri test. Avremo bisogno di usare setter per impostare i valori. Se considerati obbligatori, questi setter possono anche essere definiti in Misurabile interfaccia. Tuttavia, fai attenzione a ciò che hai messo lì. L'interfaccia è quella di definire il contratto tra la classe del cliente Progresso e le diverse classi di server come File e Musica. fa Progresso bisogno di impostare i valori? Probabilmente no. Quindi è molto improbabile che i setter siano necessari per essere definiti nell'interfaccia. Inoltre, se dovessi definire i setter, forzare tutte le classi del server a implementare i setter. Per alcuni di loro, potrebbe essere logico avere setter, ma altri potrebbero comportarsi in modo totalmente diverso. Cosa succede se vogliamo usare il nostro Progresso classe per mostrare la temperatura del nostro forno? Il OvenTemperature la classe può essere inizializzata con i valori nel costruttore o ottenere le informazioni da una terza classe. Chissà? Avere setter in quella classe sarebbe strano.

class File implementabile Measuring private $ length; $ privato inviato; nomefile $ pubblico; proprietario $ pubblico; function setLength ($ length) $ this-> length = $ length;  function getLength () return $ this-> length;  funzione setSent ($ inviata) $ this-> inviata = $ inviata;  function getSent () return $ this-> sent;  function getRelativePath () return dirname ($ this-> filename);  function getFullPath () return realpath ($ this-> getRelativePath ()); 

Il File la classe viene leggermente modificata per soddisfare i requisiti di cui sopra. Ora implementa il Misurabile interfaccia e ha setter e getter per i campi che ci interessano. Musica è molto simile, puoi controllare il suo contenuto nel codice sorgente allegato. Abbiamo quasi finito.

Progresso di classe $ privato misurabile; function __construct (Measurable $ measurableContent) $ this-> measurableContent = $ measurableContent;  function getAsPercent () return $ this-> measurableContent-> getSent () * 100 / $ this-> measurableContent-> getLength (); 

Progresso anche bisogno di un piccolo aggiornamento. Ora possiamo specificare un tipo, usando typehinting, nel costruttore. Il tipo previsto è Misurabile. Ora abbiamo un contratto esplicito. Progresso può essere sicuro che i metodi di accesso saranno sempre presenti perché sono definiti nel Misurabile interfaccia. File e Musica può anche essere sicuro di poter fornire tutto ciò che è necessario Progresso semplicemente implementando tutti i metodi sull'interfaccia, un requisito quando una classe implementa un'interfaccia.

Questo modello di progettazione è spiegato in maggior dettaglio nel corso Agile Design Patterns.

Una nota sul nome dell'interfaccia

Le persone tendono a nominare le interfacce con un capitale io di fronte a loro, o con la parola "Interfaccia"attaccato alla fine, come IFile o FileInterface. Questa è una notazione vecchio stile imposta da alcuni standard obsoleti. Siamo così tanto oltre le notazioni ungheresi o la necessità di specificare il tipo di una variabile o di un oggetto nel suo nome al fine di identificarlo più facilmente. Gli IDE identificano qualsiasi cosa in una frazione di secondo per noi. Questo ci permette di concentrarci su ciò che in realtà vogliamo astrarre.

Le interfacce appartengono ai loro clienti. Sì. Quando vuoi nominare un'interfaccia, devi pensare al cliente e dimenticarti dell'implementazione. Quando abbiamo chiamato la nostra interfaccia misurabile, lo abbiamo fatto pensando al progresso. Se fossi un progresso, cosa avrei bisogno di essere in grado di fornire la percentuale? La risposta è semplice, qualcosa che possiamo misurare. Quindi il nome misurabile.

Un'altra ragione è che l'implementazione può provenire da vari domini. Nel nostro caso, ci sono file e musica. Ma possiamo benissimo riutilizzare il nostro Progresso in un simulatore di corse. In quel caso, le classi misurate sarebbero Velocità, Carburante, ecc. Bello, non è vero??

Soluzione 3: utilizzare il modello di progettazione del metodo del modello

Il modello di progettazione del metodo Template è molto simile alla strategia, ma invece di un'interfaccia utilizza una classe astratta. Si consiglia di utilizzare un pattern Method Method quando abbiamo un client molto specifico per la nostra applicazione, con riusabilità ridotta e quando le classi server hanno un comportamento comune.


Questo modello di progettazione è spiegato in maggior dettaglio nel corso Agile Design Patterns.

Una vista a più alto livello

Quindi, come tutto ciò influisce sulla nostra architettura di alto livello?


Se l'immagine sopra rappresenta l'architettura attuale della nostra applicazione, l'aggiunta di un nuovo modulo con cinque nuove classi (quelle blu) dovrebbe influenzare il nostro design in modo moderato (classe rossa).


Nella maggior parte dei sistemi non puoi aspettarti assolutamente alcun effetto sul codice esistente quando vengono introdotte nuove classi. Tuttavia, il rispetto del principio aperto / chiuso ridurrà considerevolmente le classi e i moduli che richiedono un cambiamento costante.

Come per qualsiasi altro principio, cerca di non pensare a tutto da prima. Se lo fai, finirai con un'interfaccia per ciascuna delle tue classi. Un tale progetto sarà difficile da mantenere e capire. Di solito il modo più sicuro per andare è pensare alle possibilità e se è possibile determinare se ci saranno altri tipi di classi server. Molte volte puoi facilmente immaginare una nuova funzionalità o puoi trovarne una sul backlog del progetto che produrrà un'altra classe di server. In questi casi, aggiungi l'interfaccia dall'inizio. Se non puoi determinare, o se non sei sicuro - la maggior parte delle volte - semplicemente omettalo. Lascia che il prossimo programmatore, o forse anche tu stesso, aggiunga l'interfaccia quando hai bisogno di una seconda implementazione.

Pensieri finali

Se segui la tua disciplina e aggiungi le interfacce non appena è necessario un secondo server, le modifiche saranno poche e facili. Ricorda, se il codice richiesto cambia una volta, c'è un'alta possibilità che richiederà di nuovo il cambiamento. Quando questa possibilità si trasforma in realtà, l'OCP ti farà risparmiare molto tempo e fatica.

Grazie per aver letto.